ZHIWEI666 commited on
Commit
a35024b
·
verified ·
1 Parent(s): 389fb25

Upload 98 files

Browse files
Files changed (32) hide show
  1. 前端页面/components/UI交互提示组件.js +81 -16
  2. 前端页面/components/全局设置组件.js +512 -643
  3. 前端页面/components/卡片导航生命周期.js +43 -4
  4. 前端页面/components/图片展示测试.js +317 -130
  5. 前端页面/components/图片沙盒组件.js +217 -232
  6. 前端页面/components/图片裁剪组件.js +68 -78
  7. 前端页面/components/性能优化工具.js +135 -108
  8. 前端页面/components/用户体验_国际化.js +44 -2
  9. 前端页面/components/用户体验增强.js +3 -1
  10. 前端页面/core/侧边栏主程序.js +12 -16
  11. 前端页面/core/全局配置.js +19 -4
  12. 前端页面/core/网络请求_缓存管理.js +22 -12
  13. 前端页面/market/列表卡片组件.js +6 -3
  14. 前端页面/market/创作者卡片组件.js +2 -5
  15. 前端页面/market/资金与钱包_充值组件.js +34 -28
  16. 前端页面/market/资金与钱包_提现组件.js +38 -27
  17. 前端页面/post/发布帖子组件.js +85 -92
  18. 前端页面/post/帖子详情组件.js +120 -96
  19. 前端页面/post/讨论区组件.js +35 -14
  20. 前端页面/profile/个人中心_UI模板.js +45 -70
  21. 前端页面/profile/个人列表组件.js +84 -125
  22. 前端页面/profile/个人设置表单组件.js +75 -91
  23. 前端页面/prompt/价格校验.js +7 -0
  24. 前端页面/prompt/发布提示词组件.js +121 -131
  25. 前端页面/prompt/提示词组件.js +76 -57
  26. 前端页面/prompt/提示词详情组件.js +167 -132
  27. 前端页面/social/私信聊天组件.js +4 -1
  28. 前端页面/social/评论数据工具.js +9 -0
  29. 前端页面/styles/主题基础.css +0 -0
  30. 前端页面/task/任务榜组件.js +61 -24
  31. 前端页面/task/任务详情组件.js +160 -148
  32. 前端页面/task/发布任务组件.js +43 -54
前端页面/components/UI交互提示组件.js CHANGED
@@ -26,6 +26,39 @@ import { escapeHtml, UI } from "../core/全局配置.js";
26
  let toastQueue = [];
27
  let toastObserver = null;
28
  let toastSizeObserver = null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  let dialogInstanceId = 0;
30
  const dialogStack = [];
31
 
@@ -36,6 +69,7 @@ function repositionToasts() {
36
  toast.style.top = `${top}px`;
37
  top += heights[index] + UI.TOAST.GAP;
38
  });
 
39
  }
40
 
41
  function observeToastSize(toast) {
@@ -57,6 +91,7 @@ function releaseToast(toast) {
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();
@@ -65,6 +100,11 @@ function releaseToast(toast) {
65
  toastObserver = null;
66
  toastSizeObserver?.disconnect();
67
  toastSizeObserver = null;
 
 
 
 
 
68
  }
69
  }
70
 
@@ -76,6 +116,17 @@ function observeToastRemoval() {
76
  toastObserver.observe(document.body, { childList: true });
77
  }
78
 
 
 
 
 
 
 
 
 
 
 
 
79
  /**
80
  * 显示 Toast 消息提示
81
  * @param {string} message - 提示内容
@@ -83,6 +134,7 @@ function observeToastRemoval() {
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";
@@ -93,7 +145,7 @@ export function showToast(message, type = "info", duration = 3000) {
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 标签,故无条件转义不会改变现有显示效果。
@@ -138,22 +190,24 @@ export function showToast(message, type = "info", duration = 3000) {
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
 
@@ -502,10 +556,11 @@ export function showRetryDialog(message, retryFn, options = {}) {
502
 
503
  let settled = false;
504
  let retryInProgress = false;
505
- let dialogAccessibility;
506
  const settle = result => {
507
  if (settled) return;
508
  settled = true;
 
509
  dialogAccessibility?.cleanup();
510
  _closeDialog(overlay, box, false, () => {
511
  dialogAccessibility?.restoreFocus();
@@ -517,32 +572,42 @@ export function showRetryDialog(message, retryFn, options = {}) {
517
  if (!retryInProgress) settle(false);
518
  }
519
  );
 
 
 
 
 
 
 
 
520
 
521
  box.querySelector("#ui-btn-cancel").onclick = () => {
522
  settle(false);
523
  };
524
 
525
  box.querySelector("#ui-btn-retry").onclick = async () => {
 
526
  const retryBtn = box.querySelector("#ui-btn-retry");
527
  retryBtn.innerHTML = `<span class="btn-spinner"></span> ${t('common.retrying')}`;
528
  retryBtn.disabled = true;
529
  retryInProgress = true;
 
530
 
531
  try {
532
  await retryFn();
 
533
  settle(true);
534
- } catch (error) {
535
- // 重试错误可能来自远端或扩展,即使初始提示允许富文本也必须作为纯文本处理
536
- const errText = error.message || t('common.retry_failed');
537
  const errBox = box.querySelector(".comfy-ranking-dialog__message");
538
- if (html) {
539
- errBox.innerHTML = escapeHtml(errText);
540
- } else {
541
- errBox.textContent = errText;
542
- }
543
  retryBtn.innerHTML = escapeHtml(retryText);
544
  retryBtn.disabled = false;
545
  retryInProgress = false;
 
546
  }
547
  };
548
  });
 
26
  let toastQueue = [];
27
  let toastObserver = null;
28
  let toastSizeObserver = null;
29
+ let toastPointer = null;
30
+ let toastPointerListening = false;
31
+
32
+ function isToastHovered(toast) {
33
+ if (!toastPointer) return false;
34
+ const bounds = toast.getBoundingClientRect();
35
+ return toastPointer.x >= bounds.left && toastPointer.x < bounds.right &&
36
+ toastPointer.y >= bounds.top && toastPointer.y < bounds.bottom;
37
+ }
38
+
39
+ function updateToastHover() {
40
+ const states = toastQueue.map(isToastHovered);
41
+ toastQueue.forEach((toast, index) => toast._setToastHovered?.(states[index]));
42
+ }
43
+
44
+ function handleToastPointer(event) {
45
+ if (event.pointerType === "touch") return;
46
+ toastPointer = { x: event.clientX, y: event.clientY };
47
+ updateToastHover();
48
+ }
49
+
50
+ function clearToastPointer() {
51
+ toastPointer = null;
52
+ updateToastHover();
53
+ }
54
+
55
+ function observeToastPointer() {
56
+ if (toastPointerListening) return;
57
+ toastPointerListening = true;
58
+ document.addEventListener("pointermove", handleToastPointer, { passive: true });
59
+ document.addEventListener("pointerleave", clearToastPointer);
60
+ window.addEventListener("blur", clearToastPointer);
61
+ }
62
  let dialogInstanceId = 0;
63
  const dialogStack = [];
64
 
 
69
  toast.style.top = `${top}px`;
70
  top += heights[index] + UI.TOAST.GAP;
71
  });
72
+ updateToastHover();
73
  }
74
 
75
  function observeToastSize(toast) {
 
91
  toast._previousFocus = null;
92
  toast._cleanupToastEvents?.();
93
  toast._cleanupToastEvents = null;
94
+ toast._setToastHovered = null;
95
  const index = toastQueue.indexOf(toast);
96
  if (index > -1) toastQueue.splice(index, 1);
97
  repositionToasts();
 
100
  toastObserver = null;
101
  toastSizeObserver?.disconnect();
102
  toastSizeObserver = null;
103
+ document.removeEventListener("pointermove", handleToastPointer);
104
+ document.removeEventListener("pointerleave", clearToastPointer);
105
+ window.removeEventListener("blur", clearToastPointer);
106
+ toastPointerListening = false;
107
+ toastPointer = null;
108
  }
109
  }
110
 
 
116
  toastObserver.observe(document.body, { childList: true });
117
  }
118
 
119
+ function reserveToastSlot() {
120
+ while (toastQueue.length >= UI.TOAST.MAX_VISIBLE) {
121
+ const candidate = toastQueue.find(toast => !toast.contains(document.activeElement) && !isToastHovered(toast))
122
+ || toastQueue.find(toast => !toast.contains(document.activeElement));
123
+ if (!candidate) return false;
124
+ candidate.remove();
125
+ releaseToast(candidate);
126
+ }
127
+ return true;
128
+ }
129
+
130
  /**
131
  * 显示 Toast 消息提示
132
  * @param {string} message - 提示内容
 
134
  * @param {number} duration - 显示时长(毫秒),默认3000
135
  */
136
  export function showToast(message, type = "info", duration = 3000) {
137
+ if (!reserveToastSlot()) return;
138
  const previousFocus = document.activeElement;
139
  const toast = document.createElement("div");
140
  const safeType = ["success", "error", "warning", "info"].includes(type) ? type : "info";
 
145
  const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
146
  if (reduceMotion) toast.classList.add("comfy-ranking-toast--reduced-motion");
147
  // 新提示从目标位置上方滑入;关闭后再按当前队列位置重排。
148
+ toast.style.top = `${UI.TOAST.OFFSET + toastQueue.reduce((height, item) => height + item.offsetHeight + UI.TOAST.GAP, 0) - 30}px`;
149
 
150
  // 提示文本统一转义:message 常携带昵称/标题/后端报错等用户可控内容,
151
  // 全库无任何调用方故意向 showToast 传 HTML 标签,故无条件转义不会改变现有显示效果。
 
190
  deadline = performance.now() + remaining;
191
  toast._autoCloseTimer = setTimeout(() => closeToast(toast), remaining);
192
  };
193
+ toast._setToastHovered = value => {
194
+ if (hovered === value) return;
195
+ hovered = value;
196
+ if (hovered) pauseTimer();
197
+ else resumeTimer();
198
+ };
199
  const handleFocusOut = event => {
200
  if (!toast.contains(event.relatedTarget)) queueMicrotask(resumeTimer);
201
  };
 
 
202
  toast.addEventListener("focusin", pauseTimer);
203
  toast.addEventListener("focusout", handleFocusOut);
204
  toast._cleanupToastEvents = () => {
205
  closeButton.removeEventListener("click", handleClose);
 
 
206
  toast.removeEventListener("focusin", pauseTimer);
207
  toast.removeEventListener("focusout", handleFocusOut);
208
  };
209
+ observeToastPointer();
210
+ updateToastHover();
211
  resumeTimer();
212
  }
213
 
 
556
 
557
  let settled = false;
558
  let retryInProgress = false;
559
+ let dialogAccessibility, observer;
560
  const settle = result => {
561
  if (settled) return;
562
  settled = true;
563
+ observer?.disconnect();
564
  dialogAccessibility?.cleanup();
565
  _closeDialog(overlay, box, false, () => {
566
  dialogAccessibility?.restoreFocus();
 
572
  if (!retryInProgress) settle(false);
573
  }
574
  );
575
+ observer = new MutationObserver(() => {
576
+ if (settled || overlay.isConnected) return;
577
+ settled = true;
578
+ observer.disconnect();
579
+ dialogAccessibility.cleanup();
580
+ resolve(false);
581
+ });
582
+ observer.observe(document.body, { childList: true, subtree: true });
583
 
584
  box.querySelector("#ui-btn-cancel").onclick = () => {
585
  settle(false);
586
  };
587
 
588
  box.querySelector("#ui-btn-retry").onclick = async () => {
589
+ if (settled || retryInProgress || !overlay.isConnected) return;
590
  const retryBtn = box.querySelector("#ui-btn-retry");
591
  retryBtn.innerHTML = `<span class="btn-spinner"></span> ${t('common.retrying')}`;
592
  retryBtn.disabled = true;
593
  retryInProgress = true;
594
+ box.setAttribute("aria-busy", "true");
595
 
596
  try {
597
  await retryFn();
598
+ if (settled || !overlay.isConnected) return;
599
  settle(true);
600
+ } catch {
601
+ if (settled || !overlay.isConnected) return;
 
602
  const errBox = box.querySelector(".comfy-ranking-dialog__message");
603
+ errBox.textContent = t('common.retry_failed');
604
+ errBox.setAttribute("role", "alert");
605
+ } finally {
606
+ if (settled || !overlay.isConnected) return;
 
607
  retryBtn.innerHTML = escapeHtml(retryText);
608
  retryBtn.disabled = false;
609
  retryInProgress = false;
610
+ box.removeAttribute("aria-busy");
611
  }
612
  };
613
  });
前端页面/components/全局设置组件.js CHANGED
@@ -1,643 +1,512 @@
1
- // 前端页面/components/全局设置组件.js
2
- // ==========================================
3
- // ⚙️ 全局设置组件(界面偏好设置)
4
- // ==========================================
5
- // 作用:提供全局界面设置入口,控制显示偏好
6
- // 关联文件:
7
- // - 顶部导航组件.js (设置按钮入口)
8
- // - 创作者卡片组件.js (背景图显示控制)
9
- // ==========================================
10
-
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
- */
27
- function formatSize(bytes) {
28
- if (bytes === 0) return '0 B';
29
- const units = ['B', 'KB', 'MB', 'GB'];
30
- const i = Math.floor(Math.log2(bytes) / 10);
31
- const unitIndex = Math.min(i, units.length - 1);
32
- const size = bytes / Math.pow(1024, unitIndex);
33
- return `${size.toFixed(1)} ${units[unitIndex]}`;
34
- }
35
-
36
- /**
37
- * 📊 获取浏览器缓存统计
38
- */
39
- function getBrowserCacheStats() {
40
- let totalSize = 0;
41
- let count = 0;
42
- let available = true;
43
- try {
44
- for (let i = 0; i < localStorage.length; i++) {
45
- const key = localStorage.key(i);
46
- if (key && key.startsWith('ComfyRanking_')) {
47
- const value = localStorage.getItem(key);
48
- if (value) {
49
- totalSize += new Blob([value]).size;
50
- count++;
51
- }
52
- }
53
- }
54
- } catch (e) { available = false; }
55
- return { count, totalSize, available };
56
- }
57
-
58
- /**
59
- * ⚙️ 创建设置页面视图
60
- */
61
- export function createSettingsView() {
62
- const settings = getSettings();
63
-
64
- const container = document.createElement("div");
65
- Object.assign(container.style, {
66
- display: "flex",
67
- flexDirection: "column",
68
- gap: "0",
69
- color: "#ccc",
70
- fontSize: "14px",
71
- padding: "0",
72
- overflowY: "auto",
73
- flex: "1",
74
- boxSizing: "border-box"
75
- });
76
-
77
- // 添加开关样式
78
- const styleSheet = document.createElement("style");
79
- styleSheet.textContent = `
80
- .setting-switch {
81
- color: inherit;
82
- appearance: none;
83
- border: 0;
84
- padding: 0;
85
- position: relative;
86
- width: 44px;
87
- height: 24px;
88
- background: #444;
89
- border-radius: 12px;
90
- cursor: pointer;
91
- transition: 0.3s;
92
- flex-shrink: 0;
93
- }
94
- .setting-switch:focus-visible {
95
- outline: 2px solid currentColor;
96
- outline-offset: 3px;
97
- }
98
- .setting-switch.active {
99
- background: #4CAF50;
100
- }
101
- .setting-switch::after {
102
- content: '';
103
- position: absolute;
104
- top: 2px;
105
- left: 2px;
106
- width: 20px;
107
- height: 20px;
108
- background: #fff;
109
- border-radius: 50%;
110
- transition: 0.3s;
111
- }
112
- .setting-switch.active::after {
113
- left: 22px;
114
- }
115
- .setting-item {
116
- display: flex;
117
- justify-content: space-between;
118
- align-items: center;
119
- padding: 16px;
120
- border-bottom: 1px solid var(--border-color, #333);
121
- transition: background 0.2s;
122
- }
123
- .setting-item:hover {
124
- background: rgba(255,255,255,0.03);
125
- }
126
- .setting-select {
127
- background: var(--comfy-input-bg);
128
- border: 1px solid #444;
129
- color: #fff;
130
- padding: 8px 12px;
131
- border-radius: 6px;
132
- font-size: 13px;
133
- cursor: pointer;
134
- min-width: 200px;
135
- outline: none;
136
- transition: 0.2s;
137
- }
138
- .setting-select:hover {
139
- border-color: #4CAF50;
140
- }
141
- .setting-select:focus {
142
- border-color: #4CAF50;
143
- box-shadow: 0 0 0 2px rgba(76,175,80,0.2);
144
- }
145
- .setting-select option {
146
- background: var(--comfy-input-bg);
147
- color: #fff;
148
- padding: 8px;
149
- }
150
- .setting-number-input {
151
- background: var(--comfy-input-bg);
152
- border: 1px solid #444;
153
- color: #fff;
154
- padding: 8px 12px;
155
- border-radius: 6px;
156
- font-size: 13px;
157
- width: 80px;
158
- outline: none;
159
- transition: 0.2s;
160
- text-align: center;
161
- }
162
- .setting-number-input:hover {
163
- border-color: #4CAF50;
164
- }
165
- .setting-number-input:focus {
166
- border-color: #4CAF50;
167
- box-shadow: 0 0 0 2px rgba(76,175,80,0.2);
168
- }
169
- .setting-number-input::-webkit-inner-spin-button,
170
- .setting-number-input::-webkit-outer-spin-button {
171
- opacity: 0;
172
- width: 0;
173
- }
174
- .setting-range-slider {
175
- -webkit-appearance: none;
176
- appearance: none;
177
- width: 100%;
178
- height: 6px;
179
- border-radius: 3px;
180
- background: linear-gradient(to right, #4CAF50 0%, #333 0%);
181
- outline: none;
182
- cursor: pointer;
183
- }
184
- .setting-range-slider::-webkit-slider-thumb {
185
- -webkit-appearance: none;
186
- appearance: none;
187
- width: 16px;
188
- height: 16px;
189
- border-radius: 50%;
190
- background: #4CAF50;
191
- cursor: pointer;
192
- box-shadow: 0 1px 4px rgba(0,0,0,0.4);
193
- transition: transform 0.15s;
194
- }
195
- .setting-range-slider::-webkit-slider-thumb:hover {
196
- transform: scale(1.2);
197
- }
198
- .setting-range-slider::-moz-range-thumb {
199
- width: 16px;
200
- height: 16px;
201
- border-radius: 50%;
202
- background: #4CAF50;
203
- cursor: pointer;
204
- border: none;
205
- }
206
- .cache-clear-btn {
207
- flex: 1;
208
- min-width: 120px;
209
- background: rgba(156, 39, 176, 0.15);
210
- border: 1px solid #9C27B0;
211
- color: #ccc;
212
- padding: 8px 14px;
213
- border-radius: 6px;
214
- cursor: pointer;
215
- font-size: 13px;
216
- transition: 0.2s;
217
- }
218
- .cache-clear-btn:hover:not(:disabled) {
219
- background: rgba(156, 39, 176, 0.3);
220
- color: #fff;
221
- }
222
- .cache-clear-btn:disabled {
223
- opacity: 0.5;
224
- cursor: not-allowed;
225
- }
226
- `;
227
- document.head.appendChild(styleSheet);
228
-
229
- container.innerHTML = `
230
- <!-- 顶部标题栏 -->
231
- <div style="display: flex; align-items: center; gap: 10px; padding: 15px; border-bottom: 1px solid #444; background: #1a1a1a;">
232
- <!-- 🚀 返回按钮位置可调整参数:margin-left 控制右移,margin-top 控制下移 -->
233
- <button id="btn-back-settings" style="margin-left: 15px; margin-top: 15px; background: rgba(51,51,51,0.8); border: 1px solid rgba(85,85,85,0.8); color: #fff; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: bold; display: flex; align-items: center; gap: 6px; box-shadow: 0 2px 4px rgba(0,0,0,0.3); transition: 0.2s;" onmouseover="this.style.background='#4CAF50'; this.style.borderColor='#4CAF50'" onmouseout="this.style.background='rgba(51,51,51,0.8)'; this.style.borderColor='rgba(85,85,85,0.8)'">
234
- ⬅ ${t('common.back')}
235
- </button>
236
- <span style="font-size: 16px; font-weight: bold; color: #fff;">⚙️ ${t('settings.title')}</span>
237
- </div>
238
-
239
- <!-- 设置内容区域 -->
240
- <div style="padding: 0; flex: 1;">
241
-
242
- <!-- 分类:通用设置 -->
243
- <div style="padding: 16px 16px 10px; color: #2196F3; font-size: 13px; font-weight: bold; border-bottom: 1px solid var(--border-color, #333); background: rgba(33,150,243,0.05);">
244
- 🌐 ${t('settings.general')}
245
- </div>
246
-
247
- <!-- 设置项:界面语言 -->
248
- <div class="setting-item">
249
- <div style="flex: 1;">
250
- <div id="label-language" style="color: #fff; font-size: 14px; margin-bottom: 4px; font-weight: 500;">${t('settings.language')} / Language</div>
251
- <div style="color: #888; font-size: 12px; line-height: 1.4;">${t('settings.language_desc')} / Select display language</div>
252
- </div>
253
- <select aria-labelledby="label-language" id="select-language" class="setting-select">
254
- ${LANGUAGE_OPTIONS.map(opt =>
255
- `<option value="${opt.value}" ${(settings.language || 'zh-CN') === opt.value ? 'selected' : ''}>${opt.label}</option>`
256
- ).join('')}
257
- </select>
258
- </div>
259
-
260
- <!-- 分类:显示偏好 -->
261
- <div style="padding: 16px 16px 10px; color: #4CAF50; font-size: 13px; font-weight: bold; border-bottom: 1px solid var(--border-color, #333); background: rgba(76,175,80,0.05); margin-top: 20px;">
262
- 📐 ${t('settings.display')}
263
- </div>
264
-
265
- <!-- 设置项:创作者卡片背景图 -->
266
- <div class="setting-item">
267
- <div style="flex: 1;">
268
- <div id="label-creator-banner" style="color: #fff; font-size: 14px; margin-bottom: 4px; font-weight: 500;">${t('settings.creator_banner')}</div>
269
- <div id="desc-creator-banner" style="color: #888; font-size: 12px; line-height: 1.4;">${t('settings.creator_banner_desc')}</div>
270
- </div>
271
- <button type="button" role="switch" id="switch-creator-banner" aria-labelledby="label-creator-banner" aria-describedby="desc-creator-banner" class="setting-switch ${settings.showCreatorBanner ? 'active' : ''}"></button>
272
- </div>
273
-
274
- <!-- 分类:更多功能 -->
275
- <div style="padding: 16px 16px 10px; color: #FF9800; font-size: 13px; font-weight: bold; border-bottom: 1px solid var(--border-color, #333); background: rgba(255,152,0,0.05); margin-top: 20px;">
276
- ✨ ${t('settings.animation')}
277
- </div>
278
-
279
- <!-- 设置项:榜单切换动画 -->
280
- <div class="setting-item">
281
- <div style="flex: 1;">
282
- <div id="label-animations" style="color: #fff; font-size: 14px; margin-bottom: 4px; font-weight: 500;">${t('settings.animations')}</div>
283
- <div id="desc-animations" style="color: #888; font-size: 12px; line-height: 1.4;">${t('settings.animations_desc')}</div>
284
- </div>
285
- <button type="button" role="switch" id="switch-animations" aria-labelledby="label-animations" aria-describedby="desc-animations" class="setting-switch ${settings.enableAnimations ? 'active' : ''}"></button>
286
- </div>
287
-
288
- <!-- 设置项:动画音效 -->
289
- <div class="setting-item">
290
- <div style="flex: 1;">
291
- <div id="label-sound-effects" style="color: #fff; font-size: 14px; margin-bottom: 4px; font-weight: 500;">${t('settings.sound')}</div>
292
- <div id="desc-sound-effects" style="color: #888; font-size: 12px; line-height: 1.4;">${t('settings.sound_desc')}</div>
293
- </div>
294
- <button type="button" role="switch" id="switch-sound-effects" aria-labelledby="label-sound-effects" aria-describedby="desc-sound-effects" class="setting-switch ${settings.enableSoundEffects ? 'active' : ''}"></button>
295
- </div>
296
-
297
- <!-- 分类:数据与缓存 -->
298
- <div style="padding: 16px 16px 10px; color: #9C27B0; font-size: 13px; font-weight: bold; border-bottom: 1px solid var(--border-color, #333); background: rgba(156,39,176,0.05); margin-top: 20px;">
299
- ${t('settings.data_cache')}
300
- </div>
301
-
302
- <!-- 设置项:缓存刷新间隔(滑条) -->
303
- <div class="setting-item" style="flex-direction: column; align-items: flex-start; gap: 12px;">
304
- <div style="flex: 1; width: 100%;">
305
- <div id="label-cache-expire" style="color: #fff; font-size: 14px; margin-bottom: 4px; font-weight: 500;">${t('settings.cache_expire')}</div>
306
- <div style="color: #888; font-size: 12px; line-height: 1.4;">${t('settings.cache_expire_desc')}</div>
307
- </div>
308
- <div style="display: flex; flex-direction: column; gap: 10px; width: 100%;">
309
- <div style="display: flex; align-items: center; gap: 12px; width: 100%;">
310
- <input aria-labelledby="label-cache-expire" type="range" id="slider-cache-expire" class="setting-range-slider"
311
- value="${settings.cacheExpireSeconds || 7200}"
312
- min="60" max="86400" step="60"
313
- style="flex: 1;">
314
- <input aria-labelledby="label-cache-expire" type="number" id="input-cache-expire" class="setting-number-input"
315
- value="${settings.cacheExpireSeconds || 7200}"
316
- min="60" max="86400" step="60">
317
- <span id="cache-expire-label" style="color: #4CAF50; font-size: 12px; min-width: 55px; white-space: nowrap;"></span>
318
- </div>
319
- <div style="color: #666; font-size: 11px; line-height: 1.5; padding: 8px 12px; background: rgba(0,0,0,0.2); border-radius: 4px; border-left: 2px solid #9C27B0;">
320
- 💡 ${t('settings.cache_trigger_desc')}
321
- </div>
322
- </div>
323
- </div>
324
-
325
- <!-- 设置项:缓存统计与清理 -->
326
- <div class="setting-item" style="flex-direction: column; align-items: flex-start; gap: 12px;">
327
- <div style="flex: 1; width: 100%;">
328
- <div style="color: #fff; font-size: 14px; margin-bottom: 2px; font-weight: 500;">${t('settings.cache_stats')}</div>
329
- <div style="color: #999; font-size: 12px; line-height: 1.4;">${t('settings.cache_stats_hint')}</div>
330
- </div>
331
- <div id="cache-stats-panel" style="width: 100%; display: flex; flex-direction: column; gap: 6px;">
332
- <div style="padding: 10px 12px; background: rgba(0,0,0,0.2); border-radius: 4px; border-left: 3px solid #9C27B0; color: #888; font-size: 13px;">
333
- ${t('settings.no_cache')}
334
- </div>
335
- </div>
336
- <div style="display: flex; gap: 10px; width: 100%; flex-wrap: wrap;">
337
- <button id="btn-clear-browser-cache" class="cache-clear-btn">${t('settings.clear_browser_cache')}</button>
338
- ${IS_WEB_MODE ? '' : `<button id="btn-clear-disk-cache" class="cache-clear-btn">${t('settings.clear_disk_cache')}</button>
339
- <button id="btn-clear-all-cache" class="cache-clear-btn">${t('settings.clear_all_cache')}</button>`}
340
- </div>
341
- </div>
342
-
343
- <!-- 动画预览提示 -->
344
- <div style="padding: 16px; background: rgba(33, 150, 243, 0.05); border-radius: 8px; margin: 16px; border: 1px dashed var(--border-color, #333);">
345
- <div style="color: #2196F3; font-size: 12px; font-weight: bold; margin-bottom: 8px;">🎨 ${t('settings.animation_preview')}</div>
346
- <div style="color: #888; font-size: 11px; line-height: 1.5;">
347
- • ${t('settings.anim_cascade')}<br>
348
- • ${t('settings.anim_fan')}<br>
349
- • ${t('settings.anim_abyss')}<br>
350
- • ${t('settings.anim_dataflow')}
351
- </div>
352
- </div>
353
- </div>
354
-
355
- <!-- 底部信息 -->
356
- <div style="padding: 15px; border-top: 1px solid var(--border-color, #333); background: #1a1a1a; text-align: center;">
357
- <div style="color: #666; font-size: 11px;">${t('settings.auto_save')}</div>
358
- </div>
359
- `;
360
-
361
- // 🔌 绑定事件
362
-
363
- // 返回按钮
364
- container.querySelector("#btn-back-settings").onclick = () => {
365
- window.dispatchEvent(new CustomEvent("comfy-route-back"));
366
- };
367
-
368
- /**
369
- * 通用开关绑定辅助函数
370
- * @param {string} selector - querySelector 选择器
371
- * @param {string} settingKey - settings 对象的字段名
372
- * @param {string} labelKey - i18n label key
373
- * @param {string} [iconOn] - 开启时的图标前缀,默认 '✅'
374
- */
375
- function _bindSwitchHandler(selector, settingKey, labelKey, iconOn = '✅') {
376
- const switchEl = container.querySelector(selector);
377
- switchEl.setAttribute("aria-checked", String(switchEl.classList.contains("active")));
378
- switchEl.onclick = () => {
379
- const newValue = !switchEl.classList.contains("active");
380
- switchEl.classList.toggle("active", newValue);
381
- switchEl.setAttribute("aria-checked", String(newValue));
382
- const currentSettings = getSettings();
383
- currentSettings[settingKey] = newValue;
384
- saveSettings(currentSettings);
385
- showToast(
386
- newValue
387
- ? `${iconOn} ${t(labelKey)} ${t('settings.enabled')}`
388
- : `${t(labelKey)} ${t('settings.disabled')}`,
389
- "success"
390
- );
391
- };
392
- }
393
-
394
- // 🌐 下拉控制:界面语言
395
- const selectLanguage = container.querySelector("#select-language");
396
- let languageTimer = null;
397
- let languageGeneration = 0;
398
- selectLanguage.onchange = () => {
399
- if (cacheViewDisposed || !container.isConnected) return;
400
- const newLang = selectLanguage.value;
401
- if (!LANGUAGE_OPTIONS.some(option => option.value === newLang)) return;
402
- const generation = ++languageGeneration;
403
- clearTimeout(languageTimer);
404
- languageTimer = null;
405
-
406
- const currentSettings = getSettings();
407
- currentSettings.language = newLang;
408
- saveSettings(currentSettings);
409
-
410
- // 同步到 i18n 模块
411
- setLanguage(newLang);
412
- if (cacheViewDisposed || generation !== languageGeneration || !container.isConnected) return;
413
-
414
- const langName = LANGUAGE_OPTIONS.find(opt => opt.value === newLang)?.label || newLang;
415
- showToast(`🌐 ${t('settings.language_changed')} ${langName.split(' / ')[0]}`, "success");
416
-
417
- languageTimer = setTimeout(() => {
418
- languageTimer = null;
419
- if (cacheViewDisposed || generation !== languageGeneration || !container.isConnected) return;
420
- const restoreFocus = document.activeElement === selectLanguage;
421
- const newSettingsView = createSettingsView();
422
- if (cacheViewDisposed || generation !== languageGeneration || !container.isConnected) {
423
- newSettingsView._cleanup?.();
424
- return;
425
- }
426
- window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view: newSettingsView } }));
427
- if (!newSettingsView.isConnected) {
428
- newSettingsView._cleanup?.();
429
- return;
430
- }
431
- if (restoreFocus && (document.activeElement === document.body || document.activeElement === selectLanguage)) {
432
- newSettingsView.querySelector("#select-language")?.focus({ preventScroll: true });
433
- }
434
- }, 100);
435
- };
436
-
437
- // 🎛️ 开关控制:创作者背景图 / 榜单切换动画 / 动画音效
438
- _bindSwitchHandler("#switch-creator-banner", "showCreatorBanner", "settings.creator_banner", "✅");
439
- _bindSwitchHandler("#switch-animations", "enableAnimations", "settings.animations", "✨");
440
- _bindSwitchHandler("#switch-sound-effects", "enableSoundEffects", "settings.sound", "🔊");
441
-
442
- // ⏱️ 滑条+输入框联动:缓存刷新间隔
443
- const inputCacheExpire = container.querySelector("#input-cache-expire");
444
- const sliderCacheExpire = container.querySelector("#slider-cache-expire");
445
- const cacheExpireLabel = container.querySelector("#cache-expire-label");
446
-
447
- // 格式化时间显示
448
- function formatCacheTime(seconds) {
449
- if (seconds >= 3600) {
450
- const hours = Math.floor(seconds / 3600);
451
- const mins = Math.floor((seconds % 3600) / 60);
452
- return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
453
- }
454
- return `${Math.floor(seconds / 60)}min`;
455
- }
456
-
457
- // 更新滑条渐变背景
458
- function updateSliderTrack() {
459
- const val = sliderCacheExpire.value;
460
- const pct = ((val - 60) / (86400 - 60)) * 100;
461
- sliderCacheExpire.style.background = `linear-gradient(to right, #4CAF50 ${pct}%, #333 ${pct}%)`;
462
- }
463
-
464
- // 保存设置
465
- function saveCacheExpire(newValue) {
466
- const currentSettings = getSettings();
467
- currentSettings.cacheExpireSeconds = newValue;
468
- saveSettings(currentSettings);
469
- cacheExpireLabel.textContent = formatCacheTime(newValue);
470
- updateSliderTrack();
471
- }
472
-
473
- // 初始化显示
474
- cacheExpireLabel.textContent = formatCacheTime(parseInt(inputCacheExpire.value));
475
- updateSliderTrack();
476
-
477
- // 滑条拖动 → 同步输入框 + 保存
478
- sliderCacheExpire.oninput = () => {
479
- const val = parseInt(sliderCacheExpire.value);
480
- inputCacheExpire.value = val;
481
- cacheExpireLabel.textContent = formatCacheTime(val);
482
- updateSliderTrack();
483
- };
484
- sliderCacheExpire.onchange = () => {
485
- saveCacheExpire(parseInt(sliderCacheExpire.value));
486
- showToast(`⏱️ ${t('settings.cache_expire')}: ${formatCacheTime(parseInt(sliderCacheExpire.value))}`, "success");
487
- };
488
-
489
- // 输入框修改 → 同步滑条 + 保存
490
- inputCacheExpire.onchange = () => {
491
- let newValue = parseInt(inputCacheExpire.value);
492
- if (isNaN(newValue) || newValue < 60) { newValue = 60; }
493
- else if (newValue > 86400) { newValue = 86400; }
494
- inputCacheExpire.value = newValue;
495
- sliderCacheExpire.value = newValue;
496
- saveCacheExpire(newValue);
497
- showToast(`⏱️ ${t('settings.cache_expire')}: ${formatCacheTime(newValue)}`, "success");
498
- };
499
-
500
- // 📊 缓存统计面板
501
- const cacheStatsPanel = container.querySelector("#cache-stats-panel");
502
- cacheStatsPanel.setAttribute('role', 'status');
503
- cacheStatsPanel.setAttribute('aria-live', 'polite');
504
- let statsGeneration = 0;
505
- let statsController = null;
506
- let lastStatsHTML = null;
507
- let cacheViewDisposed = false;
508
- container._cleanup = () => {
509
- if (cacheViewDisposed) return;
510
- cacheViewDisposed = true;
511
- languageGeneration++;
512
- clearTimeout(languageTimer);
513
- languageTimer = null;
514
- statsGeneration++;
515
- statsController?.abort();
516
- statsController = null;
517
- };
518
-
519
- async function loadCacheStats() {
520
- const generation = ++statsGeneration;
521
- statsController?.abort();
522
- const controller = new AbortController();
523
- statsController = controller;
524
- // 📱 Web 模式:无本地磁盘缓存,仅统计浏览器缓存
525
- if (IS_WEB_MODE) {
526
- statsController = null;
527
- const browserStats = getBrowserCacheStats();
528
- if (!browserStats.available) {
529
- const status = lastStatsHTML === null ? t('settings.cache_stats_unavailable') : t('settings.cache_stats_stale');
530
- cacheStatsPanel.innerHTML = `<div>${status}</div>` + (lastStatsHTML || '');
531
- return;
532
- }
533
- cacheStatsPanel.innerHTML = `
534
- <div style="padding: 10px 12px; background: rgba(0,0,0,0.2); border-radius: 4px; border-left: 3px solid #9C27B0; color: #aaa; font-size: 13px;">
535
- <span style="color: #ccc;">${t('settings.cache_browser')}:</span>${browserStats.count} ${t('settings.files')} · ${formatSize(browserStats.totalSize)}
536
- </div>
537
- `;
538
- lastStatsHTML = cacheStatsPanel.innerHTML;
539
- return;
540
- }
541
- try {
542
- const [diskRes, browserStats] = await Promise.all([
543
- getDiskCacheStats({ signal: controller.signal }),
544
- Promise.resolve(getBrowserCacheStats())
545
- ]);
546
- if (generation !== statsGeneration || cacheViewDisposed || !container.isConnected) return;
547
- if (!browserStats.available) throw new Error('cache_stats_unavailable');
548
-
549
- const imageCount = diskRes.image_count;
550
- const imageSize = diskRes.image_size;
551
- const videoCount = diskRes.video_count;
552
- const videoSize = diskRes.video_size;
553
- const browserCount = browserStats.count;
554
- const browserSize = browserStats.totalSize;
555
-
556
- const hasAnyCache = imageCount > 0 || videoCount > 0 || browserCount > 0;
557
-
558
- if (!hasAnyCache) {
559
- cacheStatsPanel.innerHTML = `
560
- <div style="padding: 10px 12px; background: rgba(0,0,0,0.2); border-radius: 4px; border-left: 3px solid #9C27B0; color: #888; font-size: 13px;">
561
- ${t('settings.no_cache')}
562
- </div>
563
- `;
564
- lastStatsHTML = cacheStatsPanel.innerHTML;
565
- return;
566
- }
567
-
568
- cacheStatsPanel.innerHTML = `
569
- <div style="padding: 10px 12px; background: rgba(0,0,0,0.2); border-radius: 4px; border-left: 3px solid #9C27B0; color: #aaa; font-size: 13px;">
570
- <span style="color: #ccc;">${t('settings.cache_disk_images')}:</span>${imageCount} ${t('settings.files')} · ${formatSize(imageSize)}
571
- </div>
572
- <div style="padding: 10px 12px; background: rgba(0,0,0,0.2); border-radius: 4px; border-left: 3px solid #9C27B0; color: #aaa; font-size: 13px;">
573
- <span style="color: #ccc;">${t('settings.cache_disk_videos')}:</span>${videoCount} ${t('settings.files')} · ${formatSize(videoSize)}
574
- </div>
575
- <div style="padding: 10px 12px; background: rgba(0,0,0,0.2); border-radius: 4px; border-left: 3px solid #9C27B0; color: #aaa; font-size: 13px;">
576
- <span style="color: #ccc;">${t('settings.cache_browser')}:</span>${browserCount} ${t('settings.files')} · ${formatSize(browserSize)}
577
- </div>
578
- `;
579
- lastStatsHTML = cacheStatsPanel.innerHTML;
580
- } catch (e) {
581
- if (generation !== statsGeneration || cacheViewDisposed || !container.isConnected) return;
582
- cacheStatsPanel.innerHTML = `
583
- <div style="padding: 10px 12px; background: rgba(0,0,0,0.2); border-radius: 4px; border-left: 3px solid #9C27B0; color: #888; font-size: 13px;">
584
- ${lastStatsHTML === null ? t('settings.cache_stats_unavailable') : t('settings.cache_stats_stale')}
585
- </div>
586
- ` + (lastStatsHTML || '');
587
- } finally {
588
- if (statsController === controller) statsController = null;
589
- }
590
- }
591
-
592
- // 🧹 缓存清理按钮
593
- const btnClearBrowser = container.querySelector("#btn-clear-browser-cache");
594
- const btnClearDisk = container.querySelector("#btn-clear-disk-cache");
595
- const btnClearAll = container.querySelector("#btn-clear-all-cache");
596
- const clearButtons = [btnClearBrowser, btnClearDisk, btnClearAll].filter(Boolean);
597
- let clearingCache = false;
598
-
599
- async function handleClearCache(btn, action) {
600
- if (clearingCache || cacheViewDisposed || !container.isConnected) return;
601
- clearingCache = true;
602
- const originalText = btn.textContent;
603
- btn.textContent = t('settings.cache_clearing');
604
- clearButtons.forEach(button => { button.disabled = true; button.setAttribute('aria-busy', 'true'); });
605
-
606
- try {
607
- if (action === 'browser' || action === 'all') {
608
- if (!clearAllCache()) throw new Error('cache_operation_unconfirmed');
609
- }
610
- if (action === 'disk' || action === 'all') {
611
- await clearDiskCache();
612
- }
613
- if (cacheViewDisposed || !container.isConnected) return;
614
- await loadCacheStats();
615
- if (!cacheViewDisposed && container.isConnected) showToast(t('settings.cache_cleared'), "success");
616
- } catch (e) {
617
- if (cacheViewDisposed || !container.isConnected) return;
618
- showToast(t('settings.cache_clear_unconfirmed'), "error");
619
- await loadCacheStats();
620
- } finally {
621
- btn.textContent = originalText;
622
- clearingCache = false;
623
- clearButtons.forEach(button => { button.disabled = false; button.removeAttribute('aria-busy'); });
624
- }
625
- }
626
-
627
- btnClearBrowser.onclick = () => handleClearCache(btnClearBrowser, 'browser');
628
- if (btnClearDisk) btnClearDisk.onclick = () => handleClearCache(btnClearDisk, 'disk');
629
- if (btnClearAll) btnClearAll.onclick = () => handleClearCache(btnClearAll, 'all');
630
-
631
- // 进入设置页时自动加载缓存统计
632
- loadCacheStats();
633
-
634
- return container;
635
- }
636
-
637
- /**
638
- * ⚙️ 打开设置页面(通过路由)
639
- */
640
- export function openSettingsPage() {
641
- const view = createSettingsView();
642
- window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } }));
643
- }
 
1
+ // 前端页面/components/全局设置组件.js
2
+ // ==========================================
3
+ // ⚙️ 全局设置组件(界面偏好设置)
4
+ // ==========================================
5
+ // 作用:提供全局界面设置入口,控制显示偏好
6
+ // 关联文件:
7
+ // - 顶部导航组件.js (设置按钮入口)
8
+ // - 创作者卡片组件.js (背景图显示控制)
9
+ // ==========================================
10
+
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
+ */
27
+ function formatSize(bytes) {
28
+ if (bytes === 0) return '0 B';
29
+ const units = ['B', 'KB', 'MB', 'GB'];
30
+ const i = Math.floor(Math.log2(bytes) / 10);
31
+ const unitIndex = Math.min(i, units.length - 1);
32
+ const size = bytes / Math.pow(1024, unitIndex);
33
+ return `${size.toFixed(1)} ${units[unitIndex]}`;
34
+ }
35
+
36
+ /**
37
+ * 📊 获取浏览器缓存统计
38
+ */
39
+ function getBrowserCacheStats() {
40
+ let totalSize = 0;
41
+ let count = 0;
42
+ let available = true;
43
+ try {
44
+ for (let i = 0; i < localStorage.length; i++) {
45
+ const key = localStorage.key(i);
46
+ if (key && key.startsWith('ComfyRanking_')) {
47
+ const value = localStorage.getItem(key);
48
+ if (value) {
49
+ totalSize += new Blob([value]).size;
50
+ count++;
51
+ }
52
+ }
53
+ }
54
+ } catch (e) { available = false; }
55
+ return { count, totalSize, available };
56
+ }
57
+
58
+ /**
59
+ * ⚙️ 创建设置页面视图
60
+ */
61
+ export function createSettingsView() {
62
+ const settings = getSettings();
63
+
64
+ const container = document.createElement("div");
65
+ container.className = "ranking-settings";
66
+
67
+ container.innerHTML = `
68
+ <!-- 顶部标题栏 -->
69
+ <header class="ranking-settings__header">
70
+ <button type="button" class="ranking-settings__back" id="btn-back-settings" >
71
+ ⬅ ${t('common.back')}
72
+ </button>
73
+ <h1 class="ranking-settings__title">⚙️ ${t('settings.title')}</h1>
74
+ </header>
75
+
76
+ <!-- 设置内容区域 -->
77
+ <div class="ranking-settings__body" >
78
+
79
+ <!-- 分类:通用设置 -->
80
+ <h2 class="ranking-settings__heading-general">
81
+ 🌐 ${t('settings.general')}
82
+ </h2>
83
+
84
+ <!-- 设置项:界面语言 -->
85
+ <div class="setting-item">
86
+ <div class="ranking-settings__copy" >
87
+ <div class="ranking-settings__label" id="label-language" >${t('settings.language')} / Language</div>
88
+ <div class="ranking-settings__description" >${t('settings.language_desc')} / Select display language</div>
89
+ </div>
90
+ <select aria-labelledby="label-language" id="select-language" class="setting-select">
91
+ ${LANGUAGE_OPTIONS.map(opt =>
92
+ `<option value="${opt.value}" ${(settings.language || 'zh-CN') === opt.value ? 'selected' : ''}>${opt.label}</option>`
93
+ ).join('')}
94
+ </select>
95
+ </div>
96
+
97
+ <section class="setting-shortcuts" aria-labelledby="heading-shortcuts">
98
+ <h2 id="heading-shortcuts" class="setting-shortcuts__heading">⌨️ ${t('settings.shortcuts')}</h2>
99
+ <div class="setting-item">
100
+ <div class="setting-shortcuts__copy">
101
+ <div id="label-keyboard-shortcuts" class="setting-shortcuts__label">${t('settings.keyboard_combinations')}</div>
102
+ <p id="desc-keyboard-shortcuts" class="setting-shortcuts__desc">${t('settings.keyboard_combinations_desc')}</p>
103
+ </div>
104
+ <button type="button" role="switch" id="switch-keyboard-shortcuts" aria-labelledby="label-keyboard-shortcuts" aria-describedby="desc-keyboard-shortcuts" class="setting-switch ${settings.enableKeyboardShortcuts === true ? 'active' : ''}"></button>
105
+ </div>
106
+ <dl class="setting-shortcuts__list">
107
+ ${[[t('settings.shortcuts_tab_keys'), t('settings.shortcuts_controls')], [t('settings.shortcuts_activate_keys'), t('settings.shortcuts_activate')], [t('settings.shortcuts_arrows'), t('settings.shortcuts_tabs')], [t('settings.shortcuts_image_keys'), t('settings.shortcuts_images')], [t('settings.shortcuts_escape_key'), t('settings.shortcuts_escape')]].map(([keys, description]) => `<div class="setting-shortcuts__entry"><dt><kbd>${keys}</kbd></dt><dd>${description}</dd></div>`).join('')}
108
+ </dl>
109
+ <p class="setting-shortcuts__note">${t('settings.shortcuts_scope')}</p>
110
+ </section>
111
+
112
+ <!-- 分类:显示偏好 -->
113
+ <h2 class="ranking-settings__heading-display">
114
+ 📐 ${t('settings.display')}
115
+ </h2>
116
+
117
+ <!-- 设置项:创作者卡片背景图 -->
118
+ <div class="setting-item">
119
+ <div class="ranking-settings__copy" >
120
+ <div class="ranking-settings__label" id="label-creator-banner" >${t('settings.creator_banner')}</div>
121
+ <div class="ranking-settings__description" id="desc-creator-banner" >${t('settings.creator_banner_desc')}</div>
122
+ </div>
123
+ <button type="button" role="switch" id="switch-creator-banner" aria-labelledby="label-creator-banner" aria-describedby="desc-creator-banner" class="setting-switch ${settings.showCreatorBanner ? 'active' : ''}"></button>
124
+ </div>
125
+
126
+ <!-- 分类:更多功能 -->
127
+ <h2 class="ranking-settings__heading-animation">
128
+ ✨ ${t('settings.animation')}
129
+ </h2>
130
+
131
+ <!-- 设置项:榜单切换动画 -->
132
+ <div class="setting-item">
133
+ <div class="ranking-settings__copy" >
134
+ <div class="ranking-settings__label" id="label-animations" >${t('settings.animations')}</div>
135
+ <div class="ranking-settings__description" id="desc-animations" >${t('settings.animations_desc')}</div>
136
+ </div>
137
+ <button type="button" role="switch" id="switch-animations" aria-labelledby="label-animations" aria-describedby="desc-animations" class="setting-switch ${settings.enableAnimations ? 'active' : ''}"></button>
138
+ </div>
139
+
140
+ <!-- 设置项:动画音效 -->
141
+ <div class="setting-item">
142
+ <div class="ranking-settings__copy" >
143
+ <div class="ranking-settings__label" id="label-sound-effects" >${t('settings.sound')}</div>
144
+ <div class="ranking-settings__description" id="desc-sound-effects" >${t('settings.sound_desc')}</div>
145
+ </div>
146
+ <button type="button" role="switch" id="switch-sound-effects" aria-labelledby="label-sound-effects" aria-describedby="desc-sound-effects" class="setting-switch ${settings.enableSoundEffects ? 'active' : ''}"></button>
147
+ </div>
148
+
149
+ <!-- 分类:数据与缓存 -->
150
+ <h2 class="ranking-settings__heading-cache">
151
+ ${t('settings.data_cache')}
152
+ </h2>
153
+
154
+ <!-- 设置项:缓存刷新间隔(滑条) -->
155
+ <div class="ranking-settings__stack setting-item" >
156
+ <div class="ranking-settings__copy-wide" >
157
+ <div class="ranking-settings__label" id="label-cache-expire" >${t('settings.cache_expire')}</div>
158
+ <div class="ranking-settings__description" >${t('settings.cache_expire_desc')}</div>
159
+ </div>
160
+ <div class="ranking-settings__controls" >
161
+ <div class="ranking-settings__range-row" >
162
+ <input aria-labelledby="label-cache-expire" type="range" id="slider-cache-expire" class="ranking-settings__copy setting-range-slider"
163
+ value="${settings.cacheExpireSeconds || 7200}"
164
+ min="60" max="86400" step="60"
165
+ >
166
+ <input aria-labelledby="label-cache-expire" type="number" id="input-cache-expire" class="setting-number-input"
167
+ value="${settings.cacheExpireSeconds || 7200}"
168
+ min="60" max="86400" step="60">
169
+ <span class="ranking-settings__time" id="cache-expire-label" ></span>
170
+ </div>
171
+ <div class="ranking-settings__hint" >
172
+ 💡 ${t('settings.cache_trigger_desc')}
173
+ </div>
174
+ </div>
175
+ </div>
176
+
177
+ <!-- 设置项:缓存统计与清理 -->
178
+ <div class="ranking-settings__stack setting-item" >
179
+ <div class="ranking-settings__copy-wide" >
180
+ <div class="ranking-settings__stats-label" >${t('settings.cache_stats')}</div>
181
+ <div class="ranking-settings__stats-hint" >${t('settings.cache_stats_hint')}</div>
182
+ </div>
183
+ <div class="ranking-settings__stats" id="cache-stats-panel" >
184
+ <div class="ranking-settings__stat-muted" >
185
+ ${t('settings.no_cache')}
186
+ </div>
187
+ </div>
188
+ <div class="ranking-settings__actions" >
189
+ <button type="button" id="btn-clear-browser-cache" class="cache-clear-btn">${t('settings.clear_browser_cache')}</button>
190
+ ${IS_WEB_MODE ? '' : `<button type="button" id="btn-clear-disk-cache" class="cache-clear-btn">${t('settings.clear_disk_cache')}</button>
191
+ <button type="button" id="btn-clear-all-cache" class="cache-clear-btn">${t('settings.clear_all_cache')}</button>`}
192
+ </div>
193
+ </div>
194
+
195
+ <!-- 动画预览提示 -->
196
+ <div class="ranking-settings__preview" >
197
+ <div class="ranking-settings__preview-title" >🎨 ${t('settings.animation_preview')}</div>
198
+ <div class="ranking-settings__preview-copy" >
199
+ • ${t('settings.anim_cascade')}<br>
200
+ • ${t('settings.anim_fan')}<br>
201
+ • ${t('settings.anim_abyss')}<br>
202
+ • ${t('settings.anim_dataflow')}
203
+ </div>
204
+ </div>
205
+ </div>
206
+
207
+ <!-- 底部信息 -->
208
+ <div class="ranking-settings__footer" >
209
+ <div class="ranking-settings__footer-copy" >${t('settings.auto_save')}</div>
210
+ </div>
211
+ `;
212
+
213
+ // 🔌 绑定事件
214
+
215
+ // 返回按钮
216
+ container.querySelector("#btn-back-settings").onclick = () => {
217
+ window.dispatchEvent(new CustomEvent("comfy-route-back"));
218
+ };
219
+
220
+ /**
221
+ * 通用开关绑定辅助函数
222
+ * @param {string} selector - querySelector 选择器
223
+ * @param {string} settingKey - settings 对象的字段名
224
+ * @param {string} labelKey - i18n label key
225
+ * @param {string} [iconOn] - 开启时的图标前缀,默认 '✅'
226
+ */
227
+ function _bindSwitchHandler(selector, settingKey, labelKey, iconOn = '✅') {
228
+ const switchEl = container.querySelector(selector);
229
+ switchEl.setAttribute("aria-checked", String(switchEl.classList.contains("active")));
230
+ switchEl.onclick = () => {
231
+ if (settingKey === 'enableKeyboardShortcuts' && (cacheViewDisposed || !container.isConnected)) return;
232
+ let currentSettings;
233
+ try { currentSettings = getSettings(settingKey === 'enableKeyboardShortcuts' ? { strict: true } : undefined); }
234
+ catch { showToast(t('common.error'), 'error'); return; }
235
+ const previousValue = switchEl.classList.contains("active");
236
+ const newValue = !previousValue;
237
+ switchEl.classList.toggle("active", newValue);
238
+ switchEl.setAttribute("aria-checked", String(newValue));
239
+ currentSettings[settingKey] = newValue;
240
+ const saved = saveSettings(currentSettings);
241
+ let confirmed = saved === true;
242
+ if (settingKey === 'enableKeyboardShortcuts') {
243
+ try { confirmed = confirmed && getSettings({ strict: true }).enableKeyboardShortcuts === newValue; }
244
+ catch { confirmed = false; }
245
+ }
246
+ if (settingKey === 'enableKeyboardShortcuts' && !confirmed) {
247
+ switchEl.classList.toggle('active', previousValue);
248
+ switchEl.setAttribute('aria-checked', String(previousValue));
249
+ showToast(t('common.error'), 'error');
250
+ return;
251
+ }
252
+ showToast(
253
+ newValue
254
+ ? `${iconOn} ${t(labelKey)} ${t('settings.enabled')}`
255
+ : `${t(labelKey)} ${t('settings.disabled')}`,
256
+ "success"
257
+ );
258
+ };
259
+ }
260
+
261
+ // 🌐 下拉控制:界面语言
262
+ const selectLanguage = container.querySelector("#select-language");
263
+ let languageTimer = null;
264
+ let languageGeneration = 0;
265
+ selectLanguage.onchange = () => {
266
+ if (cacheViewDisposed || !container.isConnected) return;
267
+ const newLang = selectLanguage.value;
268
+ if (!LANGUAGE_OPTIONS.some(option => option.value === newLang)) return;
269
+ const generation = ++languageGeneration;
270
+ clearTimeout(languageTimer);
271
+ languageTimer = null;
272
+
273
+ const currentSettings = getSettings();
274
+ currentSettings.language = newLang;
275
+ saveSettings(currentSettings);
276
+
277
+ // 同步到 i18n 模块
278
+ setLanguage(newLang);
279
+ if (cacheViewDisposed || generation !== languageGeneration || !container.isConnected) return;
280
+
281
+ const langName = LANGUAGE_OPTIONS.find(opt => opt.value === newLang)?.label || newLang;
282
+ showToast(`🌐 ${t('settings.language_changed')} ${langName.split(' / ')[0]}`, "success");
283
+
284
+ languageTimer = setTimeout(() => {
285
+ languageTimer = null;
286
+ if (cacheViewDisposed || generation !== languageGeneration || !container.isConnected) return;
287
+ const restoreFocus = document.activeElement === selectLanguage;
288
+ const newSettingsView = createSettingsView();
289
+ if (cacheViewDisposed || generation !== languageGeneration || !container.isConnected) {
290
+ newSettingsView._cleanup?.();
291
+ return;
292
+ }
293
+ window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view: newSettingsView } }));
294
+ if (!newSettingsView.isConnected) {
295
+ newSettingsView._cleanup?.();
296
+ return;
297
+ }
298
+ if (restoreFocus && (document.activeElement === document.body || document.activeElement === selectLanguage)) {
299
+ newSettingsView.querySelector("#select-language")?.focus({ preventScroll: true });
300
+ }
301
+ }, 100);
302
+ };
303
+
304
+ // 🎛️ 开关控制:创作者背景图 / 榜单切换动画 / 动画音效
305
+ _bindSwitchHandler("#switch-keyboard-shortcuts", "enableKeyboardShortcuts", "settings.keyboard_combinations");
306
+ _bindSwitchHandler("#switch-creator-banner", "showCreatorBanner", "settings.creator_banner", "✅");
307
+ _bindSwitchHandler("#switch-animations", "enableAnimations", "settings.animations", "✨");
308
+ _bindSwitchHandler("#switch-sound-effects", "enableSoundEffects", "settings.sound", "🔊");
309
+
310
+ // ⏱️ 滑条+输入框联动:缓存刷新间隔
311
+ const inputCacheExpire = container.querySelector("#input-cache-expire");
312
+ const sliderCacheExpire = container.querySelector("#slider-cache-expire");
313
+ const cacheExpireLabel = container.querySelector("#cache-expire-label");
314
+
315
+ // 格式化时间显示
316
+ function formatCacheTime(seconds) {
317
+ if (seconds >= 3600) {
318
+ const hours = Math.floor(seconds / 3600);
319
+ const mins = Math.floor((seconds % 3600) / 60);
320
+ return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
321
+ }
322
+ return `${Math.floor(seconds / 60)}min`;
323
+ }
324
+
325
+ // 更新滑条渐变背景
326
+ function updateSliderTrack() {
327
+ const val = sliderCacheExpire.value;
328
+ const pct = ((val - 60) / (86400 - 60)) * 100;
329
+ sliderCacheExpire.style.setProperty("--settings-range-progress", `${pct}%`);
330
+ }
331
+
332
+ // 保存设置
333
+ function saveCacheExpire(newValue) {
334
+ const currentSettings = getSettings();
335
+ currentSettings.cacheExpireSeconds = newValue;
336
+ saveSettings(currentSettings);
337
+ cacheExpireLabel.textContent = formatCacheTime(newValue);
338
+ updateSliderTrack();
339
+ }
340
+
341
+ // 初始化显示
342
+ cacheExpireLabel.textContent = formatCacheTime(parseInt(inputCacheExpire.value));
343
+ updateSliderTrack();
344
+
345
+ // 滑条拖动 → 同步输入框 + 保存
346
+ sliderCacheExpire.oninput = () => {
347
+ const val = parseInt(sliderCacheExpire.value);
348
+ inputCacheExpire.value = val;
349
+ cacheExpireLabel.textContent = formatCacheTime(val);
350
+ updateSliderTrack();
351
+ };
352
+ sliderCacheExpire.onchange = () => {
353
+ saveCacheExpire(parseInt(sliderCacheExpire.value));
354
+ showToast(`⏱️ ${t('settings.cache_expire')}: ${formatCacheTime(parseInt(sliderCacheExpire.value))}`, "success");
355
+ };
356
+
357
+ // 输入框修改 → 同步滑条 + 保存
358
+ inputCacheExpire.onchange = () => {
359
+ let newValue = parseInt(inputCacheExpire.value);
360
+ if (isNaN(newValue) || newValue < 60) { newValue = 60; }
361
+ else if (newValue > 86400) { newValue = 86400; }
362
+ inputCacheExpire.value = newValue;
363
+ sliderCacheExpire.value = newValue;
364
+ saveCacheExpire(newValue);
365
+ showToast(`⏱️ ${t('settings.cache_expire')}: ${formatCacheTime(newValue)}`, "success");
366
+ };
367
+
368
+ // 📊 缓存统计面板
369
+ const cacheStatsPanel = container.querySelector("#cache-stats-panel");
370
+ cacheStatsPanel.setAttribute('role', 'status');
371
+ cacheStatsPanel.setAttribute('aria-live', 'polite');
372
+ let statsGeneration = 0;
373
+ let statsController = null;
374
+ let lastStatsHTML = null;
375
+ let cacheViewDisposed = false;
376
+ container._cleanup = () => {
377
+ if (cacheViewDisposed) return;
378
+ cacheViewDisposed = true;
379
+ container.querySelector("#switch-keyboard-shortcuts").onclick = null;
380
+ languageGeneration++;
381
+ clearTimeout(languageTimer);
382
+ languageTimer = null;
383
+ statsGeneration++;
384
+ statsController?.abort();
385
+ statsController = null;
386
+ };
387
+
388
+ async function loadCacheStats() {
389
+ const generation = ++statsGeneration;
390
+ statsController?.abort();
391
+ const controller = new AbortController();
392
+ statsController = controller;
393
+ // 📱 Web 模式:无本地磁盘缓存,仅统计浏览器缓存
394
+ if (IS_WEB_MODE) {
395
+ statsController = null;
396
+ const browserStats = getBrowserCacheStats();
397
+ if (!browserStats.available) {
398
+ const status = lastStatsHTML === null ? t('settings.cache_stats_unavailable') : t('settings.cache_stats_stale');
399
+ cacheStatsPanel.innerHTML = `<div>${status}</div>` + (lastStatsHTML || '');
400
+ return;
401
+ }
402
+ cacheStatsPanel.innerHTML = `
403
+ <div class="ranking-settings__stat" >
404
+ <span class="ranking-settings__stat-title" >${t('settings.cache_browser')}:</span>${browserStats.count} ${t('settings.files')} · ${formatSize(browserStats.totalSize)}
405
+ </div>
406
+ `;
407
+ lastStatsHTML = cacheStatsPanel.innerHTML;
408
+ return;
409
+ }
410
+ try {
411
+ const [diskRes, browserStats] = await Promise.all([
412
+ getDiskCacheStats({ signal: controller.signal }),
413
+ Promise.resolve(getBrowserCacheStats())
414
+ ]);
415
+ if (generation !== statsGeneration || cacheViewDisposed || !container.isConnected) return;
416
+ if (!browserStats.available) throw new Error('cache_stats_unavailable');
417
+
418
+ const imageCount = diskRes.image_count;
419
+ const imageSize = diskRes.image_size;
420
+ const videoCount = diskRes.video_count;
421
+ const videoSize = diskRes.video_size;
422
+ const browserCount = browserStats.count;
423
+ const browserSize = browserStats.totalSize;
424
+
425
+ const hasAnyCache = imageCount > 0 || videoCount > 0 || browserCount > 0;
426
+
427
+ if (!hasAnyCache) {
428
+ cacheStatsPanel.innerHTML = `
429
+ <div class="ranking-settings__stat-muted" >
430
+ ${t('settings.no_cache')}
431
+ </div>
432
+ `;
433
+ lastStatsHTML = cacheStatsPanel.innerHTML;
434
+ return;
435
+ }
436
+
437
+ cacheStatsPanel.innerHTML = `
438
+ <div class="ranking-settings__stat" >
439
+ <span class="ranking-settings__stat-title" >${t('settings.cache_disk_images')}:</span>${imageCount} ${t('settings.files')} · ${formatSize(imageSize)}
440
+ </div>
441
+ <div class="ranking-settings__stat" >
442
+ <span class="ranking-settings__stat-title" >${t('settings.cache_disk_videos')}:</span>${videoCount} ${t('settings.files')} · ${formatSize(videoSize)}
443
+ </div>
444
+ <div class="ranking-settings__stat" >
445
+ <span class="ranking-settings__stat-title" >${t('settings.cache_browser')}:</span>${browserCount} ${t('settings.files')} · ${formatSize(browserSize)}
446
+ </div>
447
+ `;
448
+ lastStatsHTML = cacheStatsPanel.innerHTML;
449
+ } catch (e) {
450
+ if (generation !== statsGeneration || cacheViewDisposed || !container.isConnected) return;
451
+ cacheStatsPanel.innerHTML = `
452
+ <div class="ranking-settings__stat-muted" >
453
+ ${lastStatsHTML === null ? t('settings.cache_stats_unavailable') : t('settings.cache_stats_stale')}
454
+ </div>
455
+ ` + (lastStatsHTML || '');
456
+ } finally {
457
+ if (statsController === controller) statsController = null;
458
+ }
459
+ }
460
+
461
+ // 🧹 缓存清理按钮
462
+ const btnClearBrowser = container.querySelector("#btn-clear-browser-cache");
463
+ const btnClearDisk = container.querySelector("#btn-clear-disk-cache");
464
+ const btnClearAll = container.querySelector("#btn-clear-all-cache");
465
+ const clearButtons = [btnClearBrowser, btnClearDisk, btnClearAll].filter(Boolean);
466
+ let clearingCache = false;
467
+
468
+ async function handleClearCache(btn, action) {
469
+ if (clearingCache || cacheViewDisposed || !container.isConnected) return;
470
+ clearingCache = true;
471
+ const originalText = btn.textContent;
472
+ btn.textContent = t('settings.cache_clearing');
473
+ clearButtons.forEach(button => { button.disabled = true; button.setAttribute('aria-busy', 'true'); });
474
+
475
+ try {
476
+ if (action === 'browser' || action === 'all') {
477
+ if (!clearAllCache()) throw new Error('cache_operation_unconfirmed');
478
+ }
479
+ if (action === 'disk' || action === 'all') {
480
+ await clearDiskCache();
481
+ }
482
+ if (cacheViewDisposed || !container.isConnected) return;
483
+ await loadCacheStats();
484
+ if (!cacheViewDisposed && container.isConnected) showToast(t('settings.cache_cleared'), "success");
485
+ } catch (e) {
486
+ if (cacheViewDisposed || !container.isConnected) return;
487
+ showToast(t('settings.cache_clear_unconfirmed'), "error");
488
+ await loadCacheStats();
489
+ } finally {
490
+ btn.textContent = originalText;
491
+ clearingCache = false;
492
+ clearButtons.forEach(button => { button.disabled = false; button.removeAttribute('aria-busy'); });
493
+ }
494
+ }
495
+
496
+ btnClearBrowser.onclick = () => handleClearCache(btnClearBrowser, 'browser');
497
+ if (btnClearDisk) btnClearDisk.onclick = () => handleClearCache(btnClearDisk, 'disk');
498
+ if (btnClearAll) btnClearAll.onclick = () => handleClearCache(btnClearAll, 'all');
499
+
500
+ // 进入设置���时自动加载缓存统计
501
+ loadCacheStats();
502
+
503
+ return container;
504
+ }
505
+
506
+ /**
507
+ * ⚙️ 打开设置页面(通过路由)
508
+ */
509
+ export function openSettingsPage() {
510
+ const view = createSettingsView();
511
+ window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } }));
512
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
前端页面/components/卡片导航生命周期.js CHANGED
@@ -14,11 +14,50 @@ export function createCardNavigationScope(target, isCurrent, { captureSession =
14
  for (const event of events) target.addEventListener(event, invalidate);
15
  for (const event of identityEvents) target.addEventListener(event, revokeIdentity);
16
  target.addEventListener('storage', onStorage);
 
 
 
 
 
17
  return {
18
- capture() {
19
- const captured = generation;
20
- const isSessionCurrent = captureSession();
21
- return () => !disposed && !identityRevoked && captured === generation && isCurrent() && isViewSessionCurrent() && isSessionCurrent();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  },
23
  dispose() {
24
  if (disposed) return;
 
14
  for (const event of events) target.addEventListener(event, invalidate);
15
  for (const event of identityEvents) target.addEventListener(event, revokeIdentity);
16
  target.addEventListener('storage', onStorage);
17
+ const capture = () => {
18
+ const captured = generation;
19
+ const isSessionCurrent = captureSession();
20
+ return () => !disposed && !identityRevoked && captured === generation && isCurrent() && isViewSessionCurrent() && isSessionCurrent();
21
+ };
22
  return {
23
+ capture,
24
+ captureListFocus(container) {
25
+ let doc = container?.ownerDocument;
26
+ let active = doc?.activeElement;
27
+ if (!active || !container.contains(active)) return () => {};
28
+ let oldCards = Array.from(container.children).filter(card => card._rankingListId != null);
29
+ let oldCard = oldCards.find(card => card === active || card.contains(active));
30
+ if (!oldCard) return () => {};
31
+ const id = oldCard._rankingListId;
32
+ const index = oldCards.indexOf(oldCard);
33
+ const rootFocused = oldCard === active;
34
+ const tag = active.tagName;
35
+ const className = typeof active.className === 'string' ? active.className : '';
36
+ const current = capture();
37
+ oldCard = oldCards = null;
38
+ let consumed = false;
39
+ return () => {
40
+ if (consumed) return;
41
+ consumed = true;
42
+ try {
43
+ if (!current() || !container.isConnected || (active.isConnected && container.contains(active))) return;
44
+ const focused = doc.activeElement;
45
+ if (focused !== doc.body && focused !== doc.documentElement && focused !== active) return;
46
+ const cards = Array.from(container.children).filter(card => card._rankingListId != null);
47
+ const card = cards.find(item => item._rankingListId === id) || cards[Math.min(index, cards.length - 1)];
48
+ if (!card) {
49
+ container.setAttribute('tabindex', '-1');
50
+ container.focus({ preventScroll: true });
51
+ return;
52
+ }
53
+ const sameAction = rootFocused ? card : [...card.querySelectorAll('button, a[href], [tabindex]')]
54
+ .find(item => item.tagName === tag && item.className === className);
55
+ const replacement = sameAction || card.querySelector('.business-list-card__title') || card;
56
+ replacement.focus({ preventScroll: true });
57
+ } finally {
58
+ container = doc = active = null;
59
+ }
60
+ };
61
  },
62
  dispose() {
63
  if (disposed) return;
前端页面/components/图片展示测试.js CHANGED
@@ -6,7 +6,7 @@
6
  // 接口:openImageViewer(imageUrls, startIndex, options)
7
  // ==========================================
8
 
9
- import { escapeHtml } from "../core/全局配置.js";
10
  import { t } from "./用户体验增强.js";
11
 
12
  // ==========================================
@@ -400,10 +400,14 @@ function _calcGridLayout(displayWidth, displayHeight, maxGridSize) {
400
  */
401
  class CyberImageViewer {
402
  constructor(imageUrls, startIndex = 0) {
403
- // 处理URL,确保使用本地缓存代理
404
  this.imageUrls = imageUrls.map(url => this._proxyImageUrl(url));
405
  this.currentIndex = Math.max(0, Math.min(startIndex, this.imageUrls.length - 1));
406
  this.preloadCache = new Map();
 
 
 
 
407
  this.isAnimating = false;
408
  this.container = null;
409
  this.elements = {};
@@ -412,30 +416,163 @@ class CyberImageViewer {
412
  // 自动播放状态管理
413
  this._autoPlayEnabled = false;
414
  this._autoPlayTimerId = null;
 
415
  this._autoPlayInterval = 1000; // 默认1秒展示时间
416
  this._autoPlayStateChangeCallbacks = []; // 状态变化回调
417
  this._returnFocus = document.activeElement;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
  }
419
 
420
  /**
421
- * 将URL转换为本地缓存代理格式
422
  * 自动识别代理URL、data: URL、blob: URL和普通HTTP(S) URL
423
  */
424
  _proxyImageUrl(url) {
425
- if (!url) return '';
426
- // data: URL 和 blob: URL 直接使用不转换
427
- if (url.startsWith('data:') || url.startsWith('blob:')) {
428
- return url;
429
- }
430
- // 如果已经是代理格式,直接返回
431
- if (url.startsWith('/community_hub/image?url=')) {
432
- return url;
433
- }
434
- // 如果是HTTP/HTTPS链接,添加代理前缀
435
- if (url.startsWith('http')) {
436
- return `/community_hub/image?url=${encodeURIComponent(url)}`;
437
  }
438
- return url;
 
439
  }
440
 
441
  /**
@@ -534,6 +671,7 @@ class CyberImageViewer {
534
  // 渲染缩略图
535
  this._renderThumbnails();
536
 
 
537
  // 加载初始图片
538
  this._loadImage(this.currentIndex);
539
 
@@ -571,7 +709,7 @@ class CyberImageViewer {
571
  * 开始自动播放
572
  */
573
  _startAutoPlay() {
574
- if (this._autoPlayEnabled) return;
575
 
576
  console.log('▶️ 自动播放开始');
577
  this._autoPlayEnabled = true;
@@ -580,6 +718,8 @@ class CyberImageViewer {
580
  // 如果当前没有动画,立即开始第一张图片的计时
581
  if (!this.isAnimating) {
582
  this._scheduleNextAutoPlay();
 
 
583
  }
584
  }
585
 
@@ -591,10 +731,12 @@ class CyberImageViewer {
591
 
592
  console.log('⏸️ 自动播放停止');
593
  this._autoPlayEnabled = false;
 
 
594
 
595
  // 清除定时器
596
  if (this._autoPlayTimerId) {
597
- clearTimeout(this._autoPlayTimerId);
598
  this._autoPlayTimerId = null;
599
  }
600
 
@@ -632,6 +774,7 @@ class CyberImageViewer {
632
  * @param {Function} callback - 回调函数,参数为新的自动播放状态
633
  */
634
  onAutoPlayStateChange(callback) {
 
635
  this._autoPlayStateChangeCallbacks.push(callback);
636
  }
637
 
@@ -642,7 +785,7 @@ class CyberImageViewer {
642
  _scheduleNextAutoPlay() {
643
  // 清除之前的定时器
644
  if (this._autoPlayTimerId) {
645
- clearTimeout(this._autoPlayTimerId);
646
  this._autoPlayTimerId = null;
647
  }
648
 
@@ -652,7 +795,7 @@ class CyberImageViewer {
652
 
653
  console.log(`⏱️ 自动播放:等待 ${this._autoPlayInterval}ms 后切换`);
654
 
655
- this._autoPlayTimerId = setTimeout(() => {
656
  // 检查动画ID守卫
657
  if (currentAnimId !== this._animationId) {
658
  console.log('⛔ 自动播放定时器:动画已被中断,跳过本次切换');
@@ -692,7 +835,10 @@ class CyberImageViewer {
692
  if (!this._autoPlayEnabled) return;
693
 
694
  // 递增动画ID,用于中断检测
695
- this._animationId++;
 
 
 
696
  const currentAnimId = this._animationId;
697
 
698
  // 计算下一张索引(循环播放)
@@ -728,7 +874,10 @@ class CyberImageViewer {
728
  * @param {number} expectedAnimId - 期望的动画ID
729
  */
730
  _waitForBlockAnimationAndSchedule(expectedAnimId) {
 
 
731
  const checkAndSchedule = () => {
 
732
  // 检查动画ID守卫
733
  if (expectedAnimId !== this._animationId) {
734
  console.log('⛔ 自动播放等待:动画已被中断');
@@ -743,7 +892,7 @@ class CyberImageViewer {
743
 
744
  // 如果动画还在进行,继续等待
745
  if (this.isAnimating) {
746
- setTimeout(checkAndSchedule, 100);
747
  return;
748
  }
749
 
@@ -762,6 +911,7 @@ class CyberImageViewer {
762
  _bindEvents() {
763
  // 键盘导航
764
  this._keydownHandler = (e) => {
 
765
  if (e.defaultPrevented || e.isComposing || e.keyCode === 229
766
  || e.ctrlKey || e.metaKey || e.altKey || (e.shiftKey && e.key !== 'Tab')) return;
767
  if (e.key === 'Tab') {
@@ -783,29 +933,29 @@ class CyberImageViewer {
783
 
784
  }
785
  };
786
- this.container.addEventListener('keydown', this._keydownHandler);
787
 
788
  // 点击导航区域 - 传递点击位置用于波浪效果
789
- this.elements.navLeft.addEventListener('click', (e) => {
790
  this._stopAutoPlay(); // 手动切换时停止自动播放
791
  this.prev(e.clientX, e.clientY);
792
  });
793
- this.elements.navRight.addEventListener('click', (e) => {
794
  this._stopAutoPlay(); // 手动切换时停止自动播放
795
  this.next(e.clientX, e.clientY);
796
  });
797
 
798
  // 关闭按钮
799
- this.elements.closeBtn.addEventListener('click', () => this.close());
800
 
801
  // 自动播放按钮
802
- this.elements.autoPlayBtn.addEventListener('click', () => this.toggleAutoPlay());
803
 
804
  // 阻止右键菜单
805
- this.container.addEventListener('contextmenu', (e) => e.preventDefault());
806
 
807
  // 阻止拖拽
808
- this.elements.mainImage.addEventListener('dragstart', (e) => e.preventDefault());
809
  }
810
 
811
  /**
@@ -823,7 +973,7 @@ class CyberImageViewer {
823
  thumb.setAttribute('aria-current', i === this.currentIndex ? 'true' : 'false');
824
  // 🔒 XSS防护:图片 URL 来自其他用户发布的内容,写入 innerHTML 前必须转义
825
  thumb.innerHTML = `<img src="${escapeHtml(this.imageUrls[i])}" alt="">`;
826
- thumb.addEventListener('click', (e) => {
827
  this._stopAutoPlay(); // 手动切换时停止自动播放
828
  this.goTo(i, e.clientX, e.clientY);
829
  });
@@ -854,69 +1004,89 @@ class CyberImageViewer {
854
  * 加载指定索引的图片
855
  */
856
  async _loadImage(index) {
857
- if (index < 0 || index >= this.imageUrls.length) return;
858
-
859
- const url = this.imageUrls[index];
 
 
 
860
  const img = this.elements.mainImage;
861
- const loader = this.elements.loader;
862
-
863
- // 隐藏当前图片,准备加载新图片(同步立即隐藏,无过渡)
864
  img.style.transition = 'none';
865
  img.style.opacity = '0';
866
  img.style.visibility = 'hidden';
867
  img.classList.remove('visible');
868
- loader.style.display = 'flex';
869
-
870
- // 检查缓存
871
- if (this.preloadCache.has(url)) {
872
- const cachedImg = this.preloadCache.get(url);
873
- img.src = cachedImg.src;
874
- this._onImageLoaded();
875
- } else {
876
- // 加载图片
877
- try {
878
- await this._loadImageAsync(url);
879
  img.src = url;
880
- this._onImageLoaded();
881
- } catch (err) {
882
- console.error('图片加载失败');
883
- this._onImageError();
884
  }
 
 
 
 
 
 
 
 
885
  }
886
-
887
- // 更新计数器
888
  this.elements.counterCurrent.textContent = index + 1;
889
- this.elements.mainImage.alt = t('image.viewer.image_position', { current: index + 1, total: this.imageUrls.length });
890
  this._updateThumbnails();
891
-
892
- // 预加载相邻图片
893
- this._preloadAdjacent(index);
894
  }
895
 
896
- /**
897
- * 异步加载图片
898
- */
899
- _loadImageAsync(url) {
900
  return new Promise((resolve, reject) => {
 
 
 
 
901
  const img = new Image();
902
- console.log('📥 开始加载图片');
903
- img.onload = () => {
904
- console.log('✅ 图片加载成功');
905
- resolve(img);
906
- };
907
- img.onerror = (err) => {
908
- console.error('❌ 图片加载失败');
909
- reject(new Error('图片加载失败'));
 
 
 
 
 
910
  };
911
- img.src = url;
 
 
 
 
 
 
912
  });
913
  }
914
 
915
  /**
916
  * 图片加载完成回调 - 触发方块合并动画
917
  */
918
- _onImageLoaded() {
919
- requestAnimationFrame(() => {
 
 
 
920
  this.elements.loader.style.display = 'none';
921
  // 确保图片初始隐藏,不要在这里显示图片(同步立即隐藏,无过渡)
922
  const img = this.elements.mainImage;
@@ -941,10 +1111,13 @@ class CyberImageViewer {
941
  * 使用与粒子消散相同的网格参数和背景图裁切方式
942
  */
943
  _playBlockAssemblyAnimation() {
 
 
944
  const currentAnimId = this._animationId; // 记录当前动画ID
945
  const img = this.elements.mainImage;
946
  const wrapper = this.elements.wrapper;
947
  const imgSrc = img.src;
 
948
 
949
  // 调试日志
950
  console.log('🎬 方块聚合动画开始');
@@ -1009,8 +1182,7 @@ class CyberImageViewer {
1009
  block.style.top = `${r * blockSide}px`;
1010
 
1011
  // 使用与粒子消散相同的背景图设置方式
1012
- // 注意:URL已经由_proxyImageUrl处理过,直接使用,不要再次编码
1013
- block.style.backgroundImage = `url("${imgSrc}")`;
1014
  block.style.backgroundSize = `${displayWidth}px ${displayHeight}px`;
1015
  // 背景定位:根据正方形方块的实际位置计算
1016
  const bgPosX = c * blockSide;
@@ -1056,8 +1228,8 @@ class CyberImageViewer {
1056
  blocksContainer.getBoundingClientRect();
1057
 
1058
  // 触发方块聚合动画 - 使用双重requestAnimationFrame确保浏览器已渲染初始状态
1059
- requestAnimationFrame(() => {
1060
- requestAnimationFrame(() => {
1061
  // 检查动画是否已被中断
1062
  if (currentAnimId !== this._animationId) {
1063
  console.log('⛔ 方块聚合动画被中断,跳过动画触发');
@@ -1078,7 +1250,7 @@ class CyberImageViewer {
1078
  console.log('⏱️ 动画持续时间:', animationDuration, 'ms');
1079
 
1080
  // 动画完成后显示完整图片并清理方块
1081
- setTimeout(() => {
1082
  // 检查动画是否已被中断
1083
  if (currentAnimId !== this._animationId) {
1084
  // 动画已被中断,只做清理
@@ -1109,7 +1281,7 @@ class CyberImageViewer {
1109
  blocksContainer.style.transition = 'opacity 0.15s ease';
1110
 
1111
  // 等待方块淡出完成后移除容器,然后恢复transition
1112
- setTimeout(() => {
1113
  // 内层timeout也要检查
1114
  if (currentAnimId !== this._animationId) {
1115
  if (blocksContainer.parentNode) {
@@ -1137,10 +1309,12 @@ class CyberImageViewer {
1137
  * 支持多种路径类型:弧线(90%)、S形(5%)、直线(5%)
1138
  */
1139
  async _playParticleDisperseAnimation(clickX, clickY) {
 
1140
  const currentAnimId = this._animationId; // 记录当前动画ID
1141
  const img = this.elements.mainImage;
1142
  const wrapper = this.elements.wrapper;
1143
  const imgSrc = img.src;
 
1144
 
1145
  console.log('💨 粒子消散动画开始');
1146
 
@@ -1257,7 +1431,7 @@ class CyberImageViewer {
1257
  particleInner = document.createElement('div');
1258
  particleInner.style.width = '100%';
1259
  particleInner.style.height = '100%';
1260
- particleInner.style.backgroundImage = `url("${imgSrc}")`;
1261
  particleInner.style.backgroundSize = `${displayWidth}px ${displayHeight}px`;
1262
  particleInner.style.backgroundPosition = `-${c * particleSide}px -${r * particleSide}px`;
1263
  particleInner.style.backgroundRepeat = 'no-repeat';
@@ -1342,7 +1516,7 @@ class CyberImageViewer {
1342
  particle.style.left = `${c * particleSide}px`;
1343
  particle.style.top = `${r * particleSide}px`;
1344
 
1345
- particle.style.backgroundImage = `url("${imgSrc}")`;
1346
  particle.style.backgroundSize = `${displayWidth}px ${displayHeight}px`;
1347
  particle.style.backgroundPosition = `-${c * particleSide}px -${r * particleSide}px`;
1348
  particle.style.backgroundRepeat = 'no-repeat';
@@ -1388,8 +1562,8 @@ class CyberImageViewer {
1388
  particlesContainer.getBoundingClientRect();
1389
 
1390
  // 触发消散动画 - 使用双重requestAnimationFrame
1391
- requestAnimationFrame(() => {
1392
- requestAnimationFrame(() => {
1393
  console.log('🎭 开始粒子消散动画');
1394
  particles.forEach(({ element, innerElement, disperseX, disperseY, rotate, pathType }) => {
1395
  if (pathType === 'arc' || pathType === 's-curve') {
@@ -1416,7 +1590,7 @@ class CyberImageViewer {
1416
 
1417
  // 等待动画完成
1418
  return new Promise(resolve => {
1419
- setTimeout(() => {
1420
  console.log('✨ 粒子消散动画完成');
1421
  // 无论是否被中断,都要清理粒子容器
1422
  if (particlesContainer.parentNode) {
@@ -1430,7 +1604,7 @@ class CyberImageViewer {
1430
  img.classList.remove('visible');
1431
  }
1432
  resolve();
1433
- }, animationDuration);
1434
  });
1435
  }
1436
 
@@ -1441,35 +1615,51 @@ class CyberImageViewer {
1441
  this.elements.loader.style.display = 'none';
1442
  this.elements.mainImage.src = this._createErrorPlaceholder();
1443
  // 错误图片直接显示,不使用方块动画
 
 
1444
  this.elements.mainImage.classList.add('visible');
 
1445
  }
1446
 
1447
  /**
1448
  * 创建错误占位图
1449
  */
1450
  _createErrorPlaceholder() {
1451
- return `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300' viewBox='0 0 400 300'%3E%3Crect fill='%231a1a1a' width='400' height='300'/%3E%3Ctext x='200' y='140' text-anchor='middle' fill='%23ff6b6b' font-size='48'%3E⚠️%3C/text%3E%3Ctext x='200' y='180' text-anchor='middle' fill='%23888' font-size='14'%3E图片加载失败%3C/text%3E%3C/svg%3E`;
1452
  }
1453
 
1454
  /**
1455
  * 预加载相邻图片
1456
  */
1457
  _preloadAdjacent(currentIndex) {
1458
- const indices = [
1459
- currentIndex - 1,
1460
- currentIndex + 1
1461
- ].filter(i => i >= 0 && i < this.imageUrls.length);
1462
-
1463
- indices.forEach(index => {
1464
- const url = this.imageUrls[index];
1465
- if (!this.preloadCache.has(url)) {
1466
- const img = new Image();
1467
- img.onload = () => {
1468
- this.preloadCache.set(url, img);
1469
- };
1470
- img.src = url;
1471
  }
1472
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1473
  }
1474
 
1475
  /**
@@ -1480,23 +1670,23 @@ class CyberImageViewer {
1480
  * @param {number} clickY - 点击位置的Y坐标(用于波浪效果)
1481
  */
1482
  async goTo(index, clickX, clickY) {
 
1483
  if (index === this.currentIndex) return;
1484
  if (index < 0 || index >= this.imageUrls.length) return;
1485
 
1486
  // 递增动画ID,用于中断检测
1487
- this._animationId++;
 
 
 
 
1488
  const currentAnimId = this._animationId;
1489
 
1490
  // 如果正在动画中,立��清理并跳过载出动画,直接切换到新图片
1491
- if (this.isAnimating) {
 
1492
  console.log('⚡ 快速切换:跳过载出动画,直接切换到图片索引:', index);
1493
 
1494
- // 清理所有动画容器
1495
- const blocks = this.container.querySelector('.cyber-blocks-container');
1496
- if (blocks) blocks.remove();
1497
- const particles = this.container.querySelector('.cyber-particles-container');
1498
- if (particles) particles.remove();
1499
-
1500
  // 立即隐藏当前图片(同步操作,防止旧图闪现)
1501
  const img = this.elements.mainImage;
1502
  img.style.transition = 'none';
@@ -1525,7 +1715,7 @@ class CyberImageViewer {
1525
  if (currentAnimId !== this._animationId) return;
1526
 
1527
  // 2. 载出动画完全结束后,等待0.5秒间隔
1528
- await new Promise(resolve => setTimeout(resolve, 250));
1529
 
1530
  // 检查是否被新的切换中断
1531
  if (currentAnimId !== this._animationId) return;
@@ -1566,27 +1756,24 @@ class CyberImageViewer {
1566
  * 关闭查看器
1567
  */
1568
  close() {
1569
- if (this._isClosing) return;
1570
  this._isClosing = true;
1571
- // 停止自动播放并清除定时器
1572
- this._stopAutoPlay();
1573
-
1574
- // 清理事件监听
1575
- this.container.removeEventListener('keydown', this._keydownHandler);
1576
-
1577
- // 添加关闭动画
1578
- this.container.style.opacity = '1';
1579
- this.container.style.transition = 'opacity 0.3s ease';
1580
-
1581
- requestAnimationFrame(() => {
1582
- this.container.style.opacity = '0';
1583
- setTimeout(() => {
1584
- if (this.container.parentNode) {
1585
- this.container.parentNode.removeChild(this.container);
1586
- }
1587
- if (this._returnFocus?.isConnected && typeof this._returnFocus.focus === 'function') this._returnFocus.focus();
1588
- }, 300);
1589
- });
1590
  }
1591
  }
1592
 
@@ -1619,10 +1806,10 @@ export function openImageViewer(imageUrls, startIndex = 0, options = {}) {
1619
  viewer.elements.closeBtn.focus();
1620
 
1621
  // 添加入场动画
1622
- requestAnimationFrame(() => {
1623
  container.style.opacity = '0';
1624
  container.style.transition = 'opacity 0.3s ease';
1625
- requestAnimationFrame(() => {
1626
  container.style.opacity = '1';
1627
  });
1628
  });
 
6
  // 接口:openImageViewer(imageUrls, startIndex, options)
7
  // ==========================================
8
 
9
+ import { escapeHtml, API, IS_WEB_MODE } from "../core/全局配置.js";
10
  import { t } from "./用户体验增强.js";
11
 
12
  // ==========================================
 
400
  */
401
  class CyberImageViewer {
402
  constructor(imageUrls, startIndex = 0) {
403
+ // 按运行模式处理图片地址
404
  this.imageUrls = imageUrls.map(url => this._proxyImageUrl(url));
405
  this.currentIndex = Math.max(0, Math.min(startIndex, this.imageUrls.length - 1));
406
  this.preloadCache = new Map();
407
+ this._preloadRequests = new Map();
408
+ this._imageReads = new Set();
409
+ this._loadGeneration = 0;
410
+ this._mainLoadController = null;
411
  this.isAnimating = false;
412
  this.container = null;
413
  this.elements = {};
 
416
  // 自动播放状态管理
417
  this._autoPlayEnabled = false;
418
  this._autoPlayTimerId = null;
419
+ this._autoWaitTimerId = null;
420
  this._autoPlayInterval = 1000; // 默认1秒展示时间
421
  this._autoPlayStateChangeCallbacks = []; // 状态变化回调
422
  this._returnFocus = document.activeElement;
423
+ this._frames = new Map();
424
+ this._timers = new Map();
425
+ this._eventCleanups = [];
426
+ this._removalObserver = null;
427
+ this._wasMounted = false;
428
+ this._disposed = false;
429
+ }
430
+
431
+ _isActive(allowClosing = false) {
432
+ return !this._disposed && (!this._isClosing || allowClosing) && !!this.container?.isConnected;
433
+ }
434
+
435
+ _scheduleFrame(callback, allowClosing = false) {
436
+ if (!this._isActive(allowClosing)) return null;
437
+ const record = {};
438
+ const id = requestAnimationFrame(time => {
439
+ if (this._frames.get(id) !== record) return;
440
+ this._frames.delete(id);
441
+ if (!this._isActive(allowClosing)) {
442
+ if (!this.container?.isConnected) this._dispose();
443
+ return;
444
+ }
445
+ callback(time);
446
+ });
447
+ this._frames.set(id, record);
448
+ return id;
449
+ }
450
+
451
+ _scheduleTimer(callback, delay, onCancel = null, allowClosing = false) {
452
+ if (!this._isActive(allowClosing)) { onCancel?.(); return null; }
453
+ const record = { onCancel };
454
+ const id = setTimeout(() => {
455
+ if (this._timers.get(id) !== record) return;
456
+ this._timers.delete(id);
457
+ if (!this._isActive(allowClosing)) {
458
+ onCancel?.();
459
+ if (!this.container?.isConnected) this._dispose();
460
+ return;
461
+ }
462
+ callback();
463
+ }, delay);
464
+ this._timers.set(id, record);
465
+ return id;
466
+ }
467
+
468
+ _cancelTimer(id) {
469
+ if (id == null) return;
470
+ const record = this._timers.get(id);
471
+ if (!record) return;
472
+ this._timers.delete(id);
473
+ clearTimeout(id);
474
+ record.onCancel?.();
475
+ }
476
+
477
+ _listen(target, type, callback, options) {
478
+ const owned = event => {
479
+ if (!this._isActive()) return;
480
+ this._watchRemoval();
481
+ callback(event);
482
+ };
483
+ target.addEventListener(type, owned, options);
484
+ this._eventCleanups.push(() => target.removeEventListener(type, owned, options));
485
+ }
486
+
487
+ _watchRemoval() {
488
+ if (this._disposed || this._removalObserver) return;
489
+ const container = this.container;
490
+ const inspect = records => {
491
+ if (this._disposed || this._removalObserver !== observer) return;
492
+ if (container.isConnected) { this._wasMounted = true; return; }
493
+ const appeared = records.some(record => [...record.addedNodes, ...record.removedNodes]
494
+ .some(node => node === container || node.contains?.(container)));
495
+ if (this._wasMounted || appeared) this._dispose();
496
+ };
497
+ const observer = new MutationObserver(inspect);
498
+ this._removalObserver = observer;
499
+ observer.observe(document.body, { childList: true, subtree: true });
500
+ queueMicrotask(() => {
501
+ if (this._disposed || this._removalObserver !== observer) return;
502
+ inspect(observer.takeRecords());
503
+ if (!container.isConnected && !this._wasMounted && !this._disposed) {
504
+ observer.disconnect();
505
+ this._removalObserver = null;
506
+ }
507
+ });
508
+ }
509
+
510
+ _cancelAnimationWork() {
511
+ this._animationId++;
512
+ for (const id of this._frames.keys()) cancelAnimationFrame(id);
513
+ this._frames.clear();
514
+ for (const id of [...this._timers.keys()]) this._cancelTimer(id);
515
+ this.container?.querySelectorAll('.cyber-blocks-container, .cyber-particles-container').forEach(node => node.remove());
516
+ this.isAnimating = false;
517
+ this._autoPlayTimerId = null;
518
+ this._autoWaitTimerId = null;
519
+ }
520
+
521
+ _stopOwnedWork() {
522
+ this._loadGeneration++;
523
+ this._mainLoadController?.abort();
524
+ this._mainLoadController = null;
525
+ for (const cancel of [...this._imageReads]) cancel();
526
+ for (const controller of this._preloadRequests.values()) controller.abort();
527
+ this._preloadRequests.clear();
528
+ for (const image of this.preloadCache.values()) image.src = '';
529
+ this.preloadCache.clear();
530
+ const wasPlaying = this._autoPlayEnabled;
531
+ this._autoPlayEnabled = false;
532
+ this._cancelTimer(this._autoPlayTimerId);
533
+ this._autoPlayTimerId = null;
534
+ if (wasPlaying && this.container?.isConnected) {
535
+ try { this._updateAutoPlayUI(); } catch (_) { console.warn('Image viewer playback cleanup failed'); }
536
+ }
537
+ this._cancelAnimationWork();
538
+ for (const cleanup of this._eventCleanups) cleanup();
539
+ this._eventCleanups = [];
540
+ this._keydownHandler = null;
541
+ }
542
+
543
+ _dispose() {
544
+ if (this._disposed) return;
545
+ this._disposed = true;
546
+ this._isClosing = true;
547
+ this._stopOwnedWork();
548
+ this._removalObserver?.disconnect();
549
+ this._removalObserver = null;
550
+ this._returnFocus = null;
551
+ this._autoPlayStateChangeCallbacks = [];
552
+ this.container?.querySelectorAll('img').forEach(image => image.removeAttribute('src'));
553
+ this.imageUrls = [];
554
  }
555
 
556
  /**
557
+ * 按本地或网页模式处理图片地址
558
  * 自动识别代理URL、data: URL、blob: URL和普通HTTP(S) URL
559
  */
560
  _proxyImageUrl(url) {
561
+ if (typeof url !== 'string' || !url) return '';
562
+ const prefix = API.LOCAL_IMAGE_PROXY_PREFIX;
563
+ if (IS_WEB_MODE) {
564
+ let original = url;
565
+ for (let depth = 0; depth < 10 && original.startsWith(prefix); depth++) {
566
+ let encoded = original.slice(prefix.length);
567
+ if (encoded.endsWith(API.COMPLETE_IMAGE_QUERY_SUFFIX)) {
568
+ encoded = encoded.slice(0, -API.COMPLETE_IMAGE_QUERY_SUFFIX.length);
569
+ }
570
+ try { original = decodeURIComponent(encoded); } catch (_) { break; }
571
+ }
572
+ return original;
573
  }
574
+ if (url.startsWith('data:') || url.startsWith('blob:') || url.startsWith(prefix)) return url;
575
+ return url.startsWith('http') ? prefix + encodeURIComponent(url) : url;
576
  }
577
 
578
  /**
 
671
  // 渲染缩略图
672
  this._renderThumbnails();
673
 
674
+ this._watchRemoval();
675
  // 加载初始图片
676
  this._loadImage(this.currentIndex);
677
 
 
709
  * 开始自动播放
710
  */
711
  _startAutoPlay() {
712
+ if (!this._isActive() || this._autoPlayEnabled) return;
713
 
714
  console.log('▶️ 自动播放开始');
715
  this._autoPlayEnabled = true;
 
718
  // 如果当前没有动画,立即开始第一张图片的计时
719
  if (!this.isAnimating) {
720
  this._scheduleNextAutoPlay();
721
+ } else {
722
+ this._waitForBlockAnimationAndSchedule(this._animationId);
723
  }
724
  }
725
 
 
731
 
732
  console.log('⏸️ 自动播放停止');
733
  this._autoPlayEnabled = false;
734
+ this._cancelTimer(this._autoWaitTimerId);
735
+ this._autoWaitTimerId = null;
736
 
737
  // 清除定时器
738
  if (this._autoPlayTimerId) {
739
+ this._cancelTimer(this._autoPlayTimerId);
740
  this._autoPlayTimerId = null;
741
  }
742
 
 
774
  * @param {Function} callback - 回调函数,参数为新的自动播放状态
775
  */
776
  onAutoPlayStateChange(callback) {
777
+ if (this._isClosing || this._disposed) return;
778
  this._autoPlayStateChangeCallbacks.push(callback);
779
  }
780
 
 
785
  _scheduleNextAutoPlay() {
786
  // 清除之前的定时器
787
  if (this._autoPlayTimerId) {
788
+ this._cancelTimer(this._autoPlayTimerId);
789
  this._autoPlayTimerId = null;
790
  }
791
 
 
795
 
796
  console.log(`⏱️ 自动播放:等待 ${this._autoPlayInterval}ms 后切换`);
797
 
798
+ this._autoPlayTimerId = this._scheduleTimer(() => {
799
  // 检查动画ID守卫
800
  if (currentAnimId !== this._animationId) {
801
  console.log('⛔ 自动播放定时器:动画已被中断,跳过本次切换');
 
835
  if (!this._autoPlayEnabled) return;
836
 
837
  // 递增动画ID,用于中断检测
838
+ this._cancelAnimationWork();
839
+ this._loadGeneration++;
840
+ this._mainLoadController?.abort();
841
+ this._mainLoadController = null;
842
  const currentAnimId = this._animationId;
843
 
844
  // 计算下一张索引(循环播放)
 
874
  * @param {number} expectedAnimId - 期望的动画ID
875
  */
876
  _waitForBlockAnimationAndSchedule(expectedAnimId) {
877
+ this._cancelTimer(this._autoWaitTimerId);
878
+ this._autoWaitTimerId = null;
879
  const checkAndSchedule = () => {
880
+ this._autoWaitTimerId = null;
881
  // 检查动画ID守卫
882
  if (expectedAnimId !== this._animationId) {
883
  console.log('⛔ 自动播放等待:动画已被中断');
 
892
 
893
  // 如果动画还在进行,继续等待
894
  if (this.isAnimating) {
895
+ this._autoWaitTimerId = this._scheduleTimer(checkAndSchedule, 100);
896
  return;
897
  }
898
 
 
911
  _bindEvents() {
912
  // 键盘导航
913
  this._keydownHandler = (e) => {
914
+ if (!this._isActive()) return;
915
  if (e.defaultPrevented || e.isComposing || e.keyCode === 229
916
  || e.ctrlKey || e.metaKey || e.altKey || (e.shiftKey && e.key !== 'Tab')) return;
917
  if (e.key === 'Tab') {
 
933
 
934
  }
935
  };
936
+ this._listen(this.container, 'keydown', this._keydownHandler);
937
 
938
  // 点击导航区域 - 传递点击位置用于波浪效果
939
+ this._listen(this.elements.navLeft, 'click', (e) => {
940
  this._stopAutoPlay(); // 手动切换时停止自动播放
941
  this.prev(e.clientX, e.clientY);
942
  });
943
+ this._listen(this.elements.navRight, 'click', (e) => {
944
  this._stopAutoPlay(); // 手动切换时停止自动播放
945
  this.next(e.clientX, e.clientY);
946
  });
947
 
948
  // 关闭按钮
949
+ this._listen(this.elements.closeBtn, 'click', () => this.close());
950
 
951
  // 自动播放按钮
952
+ this._listen(this.elements.autoPlayBtn, 'click', () => this.toggleAutoPlay());
953
 
954
  // 阻止右键菜单
955
+ this._listen(this.container, 'contextmenu', (e) => e.preventDefault());
956
 
957
  // 阻止拖拽
958
+ this._listen(this.elements.mainImage, 'dragstart', (e) => e.preventDefault());
959
  }
960
 
961
  /**
 
973
  thumb.setAttribute('aria-current', i === this.currentIndex ? 'true' : 'false');
974
  // 🔒 XSS防护:图片 URL 来自其他用户发布的内容,写入 innerHTML 前必须转义
975
  thumb.innerHTML = `<img src="${escapeHtml(this.imageUrls[i])}" alt="">`;
976
+ this._listen(thumb, 'click', (e) => {
977
  this._stopAutoPlay(); // 手动切换时停止自动播放
978
  this.goTo(i, e.clientX, e.clientY);
979
  });
 
1004
  * 加载指定索引的图片
1005
  */
1006
  async _loadImage(index) {
1007
+ if (this._isClosing || index < 0 || index >= this.imageUrls.length) return;
1008
+ this._mainLoadController?.abort();
1009
+ const controller = new AbortController();
1010
+ this._mainLoadController = controller;
1011
+ const generation = ++this._loadGeneration;
1012
+ const container = this.container;
1013
  const img = this.elements.mainImage;
1014
+ const url = this.imageUrls[index];
1015
+ const current = () => !this._isClosing && generation === this._loadGeneration &&
1016
+ this.container === container && this.elements.mainImage === img;
1017
  img.style.transition = 'none';
1018
  img.style.opacity = '0';
1019
  img.style.visibility = 'hidden';
1020
  img.classList.remove('visible');
1021
+ this.elements.loader.style.display = 'flex';
1022
+ let loaded = false;
1023
+ try {
1024
+ if (this.preloadCache.has(url)) {
1025
+ if (!container.isConnected) return;
1026
+ img.src = this.preloadCache.get(url).src;
1027
+ this._onImageLoaded(generation);
1028
+ } else {
1029
+ await this._loadImageAsync(url, { signal: controller.signal });
1030
+ if (!current() || !container.isConnected) return;
1031
+ this._watchRemoval();
1032
  img.src = url;
1033
+ this._onImageLoaded(generation);
 
 
 
1034
  }
1035
+ loaded = true;
1036
+ } catch (error) {
1037
+ if (!current() || !container.isConnected || controller.signal.aborted) return;
1038
+ this._watchRemoval();
1039
+ console.error('图片加载失败');
1040
+ this._onImageError();
1041
+ } finally {
1042
+ if (this._mainLoadController === controller) this._mainLoadController = null;
1043
  }
1044
+ if (!current()) return;
 
1045
  this.elements.counterCurrent.textContent = index + 1;
1046
+ img.alt = t('image.viewer.image_position', { current: index + 1, total: this.imageUrls.length });
1047
  this._updateThumbnails();
1048
+ if (loaded) this._preloadAdjacent(index);
 
 
1049
  }
1050
 
1051
+ _loadImageAsync(url, { signal } = {}) {
 
 
 
1052
  return new Promise((resolve, reject) => {
1053
+ if (this._isClosing || signal?.aborted) {
1054
+ reject(new DOMException('Image read cancelled', 'AbortError'));
1055
+ return;
1056
+ }
1057
  const img = new Image();
1058
+ let settled = false;
1059
+ let timer = null;
1060
+ const finish = error => {
1061
+ if (settled) return;
1062
+ settled = true;
1063
+ if (timer !== null) clearTimeout(timer);
1064
+ img.onload = img.onerror = null;
1065
+ signal?.removeEventListener('abort', cancel);
1066
+ this._imageReads.delete(cancel);
1067
+ if (error) {
1068
+ img.src = '';
1069
+ reject(error);
1070
+ } else resolve(img);
1071
  };
1072
+ const cancel = () => finish(new DOMException('Image read cancelled', 'AbortError'));
1073
+ this._imageReads.add(cancel);
1074
+ signal?.addEventListener('abort', cancel, { once: true });
1075
+ img.onload = () => finish(null);
1076
+ img.onerror = () => finish(new Error('Image read failed'));
1077
+ timer = setTimeout(() => finish(new Error('Image read timed out')), API.TIMEOUT);
1078
+ try { img.src = url; } catch (error) { finish(error); }
1079
  });
1080
  }
1081
 
1082
  /**
1083
  * 图片加载完成回调 - 触发方块合并动画
1084
  */
1085
+ _onImageLoaded(generation = this._loadGeneration) {
1086
+ const container = this.container;
1087
+ this._scheduleFrame(() => {
1088
+ if (this._isClosing || generation !== this._loadGeneration ||
1089
+ this.container !== container || !container.isConnected) return;
1090
  this.elements.loader.style.display = 'none';
1091
  // 确保图片初始隐藏,不要在这里显示图片(同步立即隐藏,无过渡)
1092
  const img = this.elements.mainImage;
 
1111
  * 使用与粒子消散相同的网格参数和背景图裁切方式
1112
  */
1113
  _playBlockAssemblyAnimation() {
1114
+ if (!this._isActive()) return;
1115
+ this.isAnimating = true;
1116
  const currentAnimId = this._animationId; // 记录当前动画ID
1117
  const img = this.elements.mainImage;
1118
  const wrapper = this.elements.wrapper;
1119
  const imgSrc = img.src;
1120
+ const imageBackground = `url(${JSON.stringify(imgSrc)})`;
1121
 
1122
  // 调试日志
1123
  console.log('🎬 方块聚合动画开始');
 
1182
  block.style.top = `${r * blockSide}px`;
1183
 
1184
  // 使用与粒子消散相同的背景图设置方式
1185
+ block.style.backgroundImage = imageBackground;
 
1186
  block.style.backgroundSize = `${displayWidth}px ${displayHeight}px`;
1187
  // 背景定位:根据正方形方块的实际位置计算
1188
  const bgPosX = c * blockSide;
 
1228
  blocksContainer.getBoundingClientRect();
1229
 
1230
  // 触发方块聚合动画 - 使用双重requestAnimationFrame确保浏览器已渲染初始状态
1231
+ this._scheduleFrame(() => {
1232
+ this._scheduleFrame(() => {
1233
  // 检查动画是否已被中断
1234
  if (currentAnimId !== this._animationId) {
1235
  console.log('⛔ 方块聚合动画被中断,跳过动画触发');
 
1250
  console.log('⏱️ 动画持续时间:', animationDuration, 'ms');
1251
 
1252
  // 动画完成后显示完整图片并清理方块
1253
+ this._scheduleTimer(() => {
1254
  // 检查动画是否已被中断
1255
  if (currentAnimId !== this._animationId) {
1256
  // 动画已被中断,只做清理
 
1281
  blocksContainer.style.transition = 'opacity 0.15s ease';
1282
 
1283
  // 等待方块淡出完成后移除容器,然后恢复transition
1284
+ this._scheduleTimer(() => {
1285
  // 内层timeout也要检查
1286
  if (currentAnimId !== this._animationId) {
1287
  if (blocksContainer.parentNode) {
 
1309
  * 支持多种路径类型:弧线(90%)、S形(5%)、直线(5%)
1310
  */
1311
  async _playParticleDisperseAnimation(clickX, clickY) {
1312
+ if (!this._isActive()) return;
1313
  const currentAnimId = this._animationId; // 记录当前动画ID
1314
  const img = this.elements.mainImage;
1315
  const wrapper = this.elements.wrapper;
1316
  const imgSrc = img.src;
1317
+ const imageBackground = `url(${JSON.stringify(imgSrc)})`;
1318
 
1319
  console.log('💨 粒子消散动画开始');
1320
 
 
1431
  particleInner = document.createElement('div');
1432
  particleInner.style.width = '100%';
1433
  particleInner.style.height = '100%';
1434
+ particleInner.style.backgroundImage = imageBackground;
1435
  particleInner.style.backgroundSize = `${displayWidth}px ${displayHeight}px`;
1436
  particleInner.style.backgroundPosition = `-${c * particleSide}px -${r * particleSide}px`;
1437
  particleInner.style.backgroundRepeat = 'no-repeat';
 
1516
  particle.style.left = `${c * particleSide}px`;
1517
  particle.style.top = `${r * particleSide}px`;
1518
 
1519
+ particle.style.backgroundImage = imageBackground;
1520
  particle.style.backgroundSize = `${displayWidth}px ${displayHeight}px`;
1521
  particle.style.backgroundPosition = `-${c * particleSide}px -${r * particleSide}px`;
1522
  particle.style.backgroundRepeat = 'no-repeat';
 
1562
  particlesContainer.getBoundingClientRect();
1563
 
1564
  // 触发消散动画 - 使用双重requestAnimationFrame
1565
+ this._scheduleFrame(() => {
1566
+ this._scheduleFrame(() => {
1567
  console.log('🎭 开始粒子消散动画');
1568
  particles.forEach(({ element, innerElement, disperseX, disperseY, rotate, pathType }) => {
1569
  if (pathType === 'arc' || pathType === 's-curve') {
 
1590
 
1591
  // 等待动画完成
1592
  return new Promise(resolve => {
1593
+ this._scheduleTimer(() => {
1594
  console.log('✨ 粒子消散动画完成');
1595
  // 无论是否被中断,都要清理粒子容器
1596
  if (particlesContainer.parentNode) {
 
1604
  img.classList.remove('visible');
1605
  }
1606
  resolve();
1607
+ }, animationDuration, resolve);
1608
  });
1609
  }
1610
 
 
1615
  this.elements.loader.style.display = 'none';
1616
  this.elements.mainImage.src = this._createErrorPlaceholder();
1617
  // 错误图片直接显示,不使用方块动画
1618
+ this.elements.mainImage.style.visibility = 'visible';
1619
+ this.elements.mainImage.style.opacity = '1';
1620
  this.elements.mainImage.classList.add('visible');
1621
+ this.isAnimating = false;
1622
  }
1623
 
1624
  /**
1625
  * 创建错误占位图
1626
  */
1627
  _createErrorPlaceholder() {
1628
+ return 'data:image/svg+xml,' + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300"><rect fill="#1a1a1a" width="400" height="300"/><text x="200" y="140" text-anchor="middle" fill="#ff6b6b" font-size="48">⚠️</text><text x="200" y="180" text-anchor="middle" fill="#888" font-size="14">${escapeHtml(t('image.load_failed'))}</text></svg>`);
1629
  }
1630
 
1631
  /**
1632
  * 预加载相邻图片
1633
  */
1634
  _preloadAdjacent(currentIndex) {
1635
+ if (this._isClosing || !this.container?.isConnected) return;
1636
+ const wanted = new Set([currentIndex - 1, currentIndex + 1]
1637
+ .filter(index => index >= 0 && index < this.imageUrls.length)
1638
+ .map(index => this.imageUrls[index]));
1639
+ const retained = new Set([...wanted, this.imageUrls[currentIndex]]);
1640
+ for (const [url, image] of this.preloadCache) {
1641
+ if (!retained.has(url)) {
1642
+ image.src = '';
1643
+ this.preloadCache.delete(url);
 
 
 
 
1644
  }
1645
+ }
1646
+ for (const [url, controller] of this._preloadRequests) {
1647
+ if (!wanted.has(url)) {
1648
+ controller.abort();
1649
+ this._preloadRequests.delete(url);
1650
+ }
1651
+ }
1652
+ for (const url of wanted) {
1653
+ if (this.preloadCache.has(url) || this._preloadRequests.has(url)) continue;
1654
+ const controller = new AbortController();
1655
+ this._preloadRequests.set(url, controller);
1656
+ this._loadImageAsync(url, { signal: controller.signal }).then(img => {
1657
+ if (!this._isClosing && this.container?.isConnected && !controller.signal.aborted &&
1658
+ this._preloadRequests.get(url) === controller) this.preloadCache.set(url, img);
1659
+ }).catch(() => {}).finally(() => {
1660
+ if (this._preloadRequests.get(url) === controller) this._preloadRequests.delete(url);
1661
+ });
1662
+ }
1663
  }
1664
 
1665
  /**
 
1670
  * @param {number} clickY - 点击位置的Y坐标(用于波浪效果)
1671
  */
1672
  async goTo(index, clickX, clickY) {
1673
+ if (this._isClosing || !this.container?.isConnected) return;
1674
  if (index === this.currentIndex) return;
1675
  if (index < 0 || index >= this.imageUrls.length) return;
1676
 
1677
  // 递增动画ID,用于中断检测
1678
+ const interrupted = this.isAnimating;
1679
+ this._cancelAnimationWork();
1680
+ this._loadGeneration++;
1681
+ this._mainLoadController?.abort();
1682
+ this._mainLoadController = null;
1683
  const currentAnimId = this._animationId;
1684
 
1685
  // 如果正在动画中,立��清理并跳过载出动画,直接切换到新图片
1686
+ if (interrupted) {
1687
+ this.isAnimating = true;
1688
  console.log('⚡ 快速切换:跳过载出动画,直接切换到图片索引:', index);
1689
 
 
 
 
 
 
 
1690
  // 立即隐藏当前图片(同步操作,防止旧图闪现)
1691
  const img = this.elements.mainImage;
1692
  img.style.transition = 'none';
 
1715
  if (currentAnimId !== this._animationId) return;
1716
 
1717
  // 2. 载出动画完全结束后,等待0.5秒间隔
1718
+ await new Promise(resolve => this._scheduleTimer(resolve, 250, resolve));
1719
 
1720
  // 检查是否被新的切换中断
1721
  if (currentAnimId !== this._animationId) return;
 
1756
  * 关闭查看器
1757
  */
1758
  close() {
1759
+ if (this._isClosing || this._disposed) return;
1760
  this._isClosing = true;
1761
+ this._stopOwnedWork();
1762
+ if (!this.container?.isConnected) { this._dispose(); return; }
1763
+ const container = this.container;
1764
+ container.style.opacity = '1';
1765
+ container.style.transition = 'opacity 0.3s ease';
1766
+ this._scheduleFrame(() => {
1767
+ container.style.opacity = '0';
1768
+ this._scheduleTimer(() => {
1769
+ const restore = container.contains(document.activeElement);
1770
+ const previousFocus = this._returnFocus;
1771
+ container.remove();
1772
+ this._dispose();
1773
+ if (restore && document.activeElement === document.body && previousFocus?.isConnected &&
1774
+ typeof previousFocus.focus === 'function') previousFocus.focus();
1775
+ }, 300, null, true);
1776
+ }, true);
 
 
 
1777
  }
1778
  }
1779
 
 
1806
  viewer.elements.closeBtn.focus();
1807
 
1808
  // 添加入场动画
1809
+ viewer._scheduleFrame(() => {
1810
  container.style.opacity = '0';
1811
  container.style.transition = 'opacity 0.3s ease';
1812
+ viewer._scheduleFrame(() => {
1813
  container.style.opacity = '1';
1814
  });
1815
  });
前端页面/components/图片沙盒组件.js CHANGED
@@ -12,7 +12,7 @@
12
  // ==========================================
13
 
14
  import { t } from "./用户体验增强.js";
15
- import { escapeHtml } from "../core/全局配置.js";
16
  import { openImageViewer } from "./图片展示测试.js";
17
 
18
  // 🔧 通用SVG占位图生成函数
@@ -34,25 +34,31 @@ function getErrorSVG() { return createErrorSVG(t('image.load_failed')); }
34
  function getTimeoutSVG() { return createTimeoutSVG(t('image.load_timeout')); }
35
 
36
  // 🔧 P3优化:带超时机制的图片加载器
37
- const IMAGE_LOAD_TIMEOUT = 30000; // 30秒超时
38
-
39
- function loadImageWithTimeout(url, timeout = IMAGE_LOAD_TIMEOUT) {
40
  return new Promise((resolve, reject) => {
41
- const img = new Image();
42
- const timer = setTimeout(() => {
43
- img.onload = img.onerror = null;
44
- reject(new Error('图片加载超时'));
45
- }, timeout);
46
-
47
- img.onload = () => {
48
- clearTimeout(timer);
49
- resolve(img);
50
- };
51
- img.onerror = () => {
52
- clearTimeout(timer);
53
- reject(new Error('图片加载失败'));
54
  };
55
- img.src = url;
 
 
 
 
 
 
 
 
 
56
  });
57
  }
58
 
@@ -65,8 +71,8 @@ export function getCoverSandboxHTML(imageSource, lazyLoad = true) {
65
  // 兼容:接受单个URL或数组
66
  let imageUrls = [];
67
  if (Array.isArray(imageSource)) {
68
- imageUrls = imageSource.filter(url => url && url.trim());
69
- } else if (imageSource && imageSource.trim()) {
70
  imageUrls = [imageSource];
71
  }
72
 
@@ -75,7 +81,7 @@ export function getCoverSandboxHTML(imageSource, lazyLoad = true) {
75
  const firstUrl = imageUrls[0];
76
 
77
  // 🚀 本地代理URL已缓存,无需懒加载(从磁盘读取几乎瞬时)
78
- const isLocalProxy = firstUrl && firstUrl.startsWith('/community_hub/image?url=');
79
  const shouldLazy = lazyLoad && !isLocalProxy;
80
 
81
  // 🔒 XSS防护:图片 URL 来自其他用户发布的内容,写入 HTML 属性前必须转义
@@ -86,36 +92,26 @@ export function getCoverSandboxHTML(imageSource, lazyLoad = true) {
86
 
87
  // 🖼️ 多图模式:显示左右切换按钮和计数器
88
  const hasMultiple = imageUrls.length > 1;
 
89
  const navButtons = hasMultiple ? `
90
- <button class="btn-prev" style="position: absolute; left: 30px; top: 50%; transform: translateY(-50%); width: 36px; height: 36px; border-radius: 50%; background: rgba(0,0,0,0.6); color: #fff; border: none; cursor: pointer; font-size: 18px; z-index: 20; display: flex; align-items: center; justify-content: center; transition: 0.2s;" onmouseover="this.style.background='rgba(76,175,80,0.8)'" onmouseout="this.style.background='rgba(0,0,0,0.6)'">❮</button>
91
- <button class="btn-next" style="position: absolute; right: 30px; top: 50%; transform: translateY(-50%); width: 36px; height: 36px; border-radius: 50%; background: rgba(0,0,0,0.6); color: #fff; border: none; cursor: pointer; font-size: 18px; z-index: 20; display: flex; align-items: center; justify-content: center; transition: 0.2s;" onmouseover="this.style.background='rgba(76,175,80,0.8)'" onmouseout="this.style.background='rgba(0,0,0,0.6)'">❯</button>
92
- <div class="img-counter" style="position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.7); color: #fff; padding: 4px 12px; border-radius: 12px; font-size: 12px; z-index: 20;">1 / ${imageUrls.length}</div>
93
  ` : '';
94
-
95
- // 🔒 图片保护:禁用右键菜单、拖拽
96
- const protectionStyles = `
97
- -webkit-user-select: none;
98
- -moz-user-select: none;
99
- -ms-user-select: none;
100
- user-select: none;
101
- pointer-events: none;
102
- -webkit-user-drag: none;
103
- `;
104
-
105
  return `
106
- <div style="font-size: 12px; font-weight: bold; margin-bottom: 6px; color: #aaa;">🖼️ ${t('image.gallery')}${hasMultiple ? ` (${imageUrls.length})` : ''}</div>
107
- <div class="img-viewport" data-images='${escapeHtml(JSON.stringify(imageUrls))}' data-current="0" style="position: relative; width: 100%; height: 260px; background: #111; border: 2px dashed #666; border-radius: 6px; overflow: hidden; display: flex; align-items: center; justify-content: center; margin-bottom: 12px; cursor: grab; user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none;" oncontextmenu="return false;">
108
- <img class="target-img ${lazyClass}" src="${escapeHtml(imgSrc)}" ${dataAttr} draggable="false" style="max-width: 100%; max-height: 100%; object-fit: contain; transform-origin: center; transition: transform 0.05s linear, opacity 0.3s ease; ${protectionStyles}">
109
  ${navButtons}
110
- <div style="position: absolute; top: 10px; right: 10px; display: flex; gap: 5px; opacity: 0.3; transition: 0.3s; z-index: 10;" onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3">
111
- <button class="btn-zoom-out" style="background: #333; color: #fff; border: 1px solid #555; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-weight: bold;">-</button>
112
- <button class="btn-zoom-reset" style="background: #333; color: #fff; border: 1px solid #555; height: 28px; padding: 0 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">${t('common.reset')}</button>
113
- <button class="btn-zoom-in" style="background: #333; color: #fff; border: 1px solid #555; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-weight: bold;">+</button>
114
- <button class="btn-fullscreen" style="background: #333; color: #fff; border: 1px solid #555; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-weight: bold; display: flex; align-items: center; justify-content: center; font-size: 14px; margin-left: 5px;" title="${escapeHtml(t('image.viewer.fullscreen'))}">⛶</button>
115
  </div>
116
- <div class="sliders-wrapper" style="opacity: 0.2; transition: 0.3s; position: absolute; width: 100%; height: 100%; top:0; left:0; pointer-events: none;" onmouseover="this.style.opacity=0.8" onmouseout="this.style.opacity=0.2">
117
- <input type="range" class="slider-x" min="-400" max="400" value="0" style="position: absolute; bottom: 0px; left: 50%; transform: translateX(-50%); width: 80%; pointer-events: auto; z-index: 10;">
118
- <input type="range" class="slider-y" min="-400" max="400" value="0" style="position: absolute; left: -100px; top: 50%; transform: translateY(-50%) rotate(270deg); width: 230px; pointer-events: auto; z-index: 10;">
119
  </div>
120
  </div>
121
  `;
@@ -125,201 +121,192 @@ export function getCoverSandboxHTML(imageSource, lazyLoad = true) {
125
  * 🖼️ 设置图片沙盒事件(轮播、缩放、拖动)
126
  */
127
  export function setupImageSandboxEvents(detailView) {
128
- const viewport = detailView.querySelector('.img-viewport');
129
  if (!viewport) return;
130
-
131
- const targetImg = detailView.querySelector('.target-img');
132
- const btnIn = detailView.querySelector('.btn-zoom-in');
133
- const btnOut = detailView.querySelector('.btn-zoom-out');
134
- const btnReset = detailView.querySelector('.btn-zoom-reset');
135
- const sliderX = detailView.querySelector('.slider-x');
136
- const sliderY = detailView.querySelector('.slider-y');
137
- const btnPrev = detailView.querySelector('.btn-prev');
138
- const btnNext = detailView.querySelector('.btn-next');
139
- const imgCounter = detailView.querySelector('.img-counter');
140
-
141
- // 🔒 图片保护:禁用右键菜单
142
- viewport.addEventListener('contextmenu', (e) => e.preventDefault());
143
- targetImg.addEventListener('dragstart', (e) => e.preventDefault());
144
-
145
- // 📦 解析图片数组
146
- let imageUrls = [];
147
- let currentIndex = 0;
148
- try {
149
- imageUrls = JSON.parse(viewport.dataset.images || '[]');
150
- } catch (e) {
151
- imageUrls = [];
152
- }
153
-
154
- // 🖼️ 加载指定索引的图片(🔧 P3优化:带超时机制)
155
- const loadImage = async (index) => {
156
- if (index < 0 || index >= imageUrls.length) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  currentIndex = index;
158
- viewport.dataset.current = index;
159
-
160
- const url = imageUrls[index];
161
  targetImg.classList.add('lazy-loading');
162
  targetImg.src = getPlaceholderSVG();
163
-
164
  try {
165
- await loadImageWithTimeout(url);
 
 
166
  targetImg.src = url;
167
- targetImg.classList.remove('lazy-loading');
168
  targetImg.classList.add('lazy-loaded');
169
  targetImg.style.opacity = '1';
170
- } catch (err) {
171
- // 区分超时和加载失败
172
- targetImg.src = err.message.includes('超时') ? getTimeoutSVG() : getErrorSVG();
173
- targetImg.classList.remove('lazy-loading');
 
174
  targetImg.classList.add('lazy-error');
 
 
175
  }
176
-
177
- // 更新计数器
178
- if (imgCounter) {
179
- imgCounter.textContent = `${index + 1} / ${imageUrls.length}`;
180
- }
181
-
182
- // 重置缩放和位置
183
- scale = 1;
184
- sliderX.value = 0;
185
- sliderY.value = 0;
186
- syncTransform();
187
  };
188
-
189
- // ⚡ 初始加载第一张图片(🔧 P3优化:带超时机制)
190
- if (targetImg.classList.contains('lazy-loading') && targetImg.dataset.src) {
191
- const realSrc = targetImg.dataset.src;
192
-
193
- loadImageWithTimeout(realSrc).then(() => {
194
- targetImg.src = realSrc;
195
- targetImg.classList.remove('lazy-loading');
196
- targetImg.classList.add('lazy-loaded');
197
- targetImg.style.opacity = '1';
198
- delete targetImg.dataset.src;
199
- }).catch(err => {
200
- targetImg.src = err.message.includes('超时') ? getTimeoutSVG() : getErrorSVG();
201
- targetImg.classList.remove('lazy-loading');
202
- targetImg.classList.add('lazy-error');
203
- });
204
- }
205
-
206
- // 🖼️ 轮播切换按钮事件
207
- if (btnPrev && btnNext) {
208
- btnPrev.onclick = (e) => {
209
- e.stopPropagation();
210
- loadImage(currentIndex > 0 ? currentIndex - 1 : imageUrls.length - 1);
211
- };
212
- btnNext.onclick = (e) => {
213
- e.stopPropagation();
214
- loadImage(currentIndex < imageUrls.length - 1 ? currentIndex + 1 : 0);
215
- };
216
- }
217
-
218
- // 🔍 缩放功能
219
- let scale = 1.0;
220
- const syncTransform = () => {
221
- targetImg.style.transform = `scale(${scale}) translate(${sliderX.value}px, ${sliderY.value}px)`;
222
- };
223
-
224
- const zoom = (delta) => {
225
- scale = Math.max(0.5, Math.min(5.0, scale + delta));
226
- syncTransform();
227
- };
228
-
229
- btnIn.onclick = () => zoom(0.2);
230
- btnOut.onclick = () => zoom(-0.2);
231
- btnReset.onclick = () => { scale = 1; sliderX.value = 0; sliderY.value = 0; syncTransform(); };
232
-
233
- // ⛶ 全屏播放按钮事件
234
- const btnFullscreen = viewport.querySelector('.btn-fullscreen');
235
- if (btnFullscreen) {
236
- btnFullscreen.onclick = (e) => {
237
- e.stopPropagation();
238
-
239
- // 收集当前viewport的所有图片URL
240
- const dataImages = viewport.getAttribute('data-images');
241
- let imageUrls = [];
242
-
243
- if (dataImages) {
244
- try {
245
- imageUrls = JSON.parse(dataImages);
246
- } catch (e) {
247
- // 单图模式
248
- const img = viewport.querySelector('.target-img');
249
- if (img && img.src) {
250
- imageUrls = [img.src];
251
- }
252
- }
253
- } else {
254
- // 无data-images属性,取当前img的src
255
- const img = viewport.querySelector('.target-img');
256
- if (img && img.src) {
257
- imageUrls = [img.src];
258
- }
259
- }
260
-
261
- // 获取当前图片索引
262
- const currentIndex = parseInt(viewport.getAttribute('data-current') || '0', 10);
263
-
264
- // 🔍 调试日志:输出收集到的图片URL
265
- console.log('🖼️ 全屏播放图片:', '图片数量:', imageUrls.length, '当前索引:', currentIndex);
266
-
267
- if (imageUrls.length > 0) {
268
- // 打开全屏图像播放器(使用静态import,避免动态import路径问题)
269
- try {
270
- openImageViewer(imageUrls, currentIndex, { opaque: true });
271
- } catch (err) {
272
- console.error('无法打开图像播放器');
273
- // 显示用户可见的错误提示
274
- import('./UI交互提示组件.js').then(module => {
275
- module.showToast(t('image.viewer.load_failed'), 'error');
276
- }).catch(() => {
277
- // 动态import也失败时,创建简单的内联提示
278
- const fallback = document.createElement('div');
279
- fallback.textContent = t('image.viewer.load_failed');
280
- Object.assign(fallback.style, { position: 'fixed', top: '50px', left: '50%', transform: 'translateX(-50%)', background: '#F44336', color: '#fff', padding: '10px 20px', borderRadius: '8px', zIndex: '10000', fontSize: '14px' });
281
- document.body.appendChild(fallback);
282
- setTimeout(() => fallback.remove(), 3000);
283
- });
284
- }
285
- } else {
286
- console.warn('⚠️ 没有可播放的图片URL');
287
- }
288
- };
289
- }
290
-
291
- viewport.addEventListener('wheel', (e) => { e.preventDefault(); zoom(e.deltaY > 0 ? -0.1 : 0.1); });
292
- sliderX.addEventListener('input', syncTransform);
293
- sliderY.addEventListener('input', syncTransform);
294
-
295
- // ✨ 拖动功能(统一管理事件监听器)
296
- let isDragging = false; let startX, startY, initValX, initValY;
297
-
298
- // 🔧 命名事件处理函数,便于清理
299
- const handleMouseMove = (e) => {
300
- if (!isDragging) return;
301
- const dx = (e.clientX - startX) / scale; const dy = (e.clientY - startY) / scale;
302
- sliderX.value = initValX + dx; sliderY.value = initValY + dy; syncTransform();
303
  };
304
-
305
- const handleMouseUp = () => { isDragging = false; viewport.style.cursor = 'grab'; };
306
-
307
- viewport.addEventListener('mousedown', (e) => {
308
- if(e.target.tagName.toLowerCase() === 'input' || e.target.tagName.toLowerCase() === 'button') return;
309
- e.preventDefault(); // 阻止浏览器默认的文本/图片选中行为
310
- isDragging = true; viewport.style.cursor = 'grabbing';
311
- startX = e.clientX; startY = e.clientY; initValX = parseInt(sliderX.value); initValY = parseInt(sliderY.value);
312
  });
313
- window.addEventListener('mousemove', handleMouseMove);
314
- window.addEventListener('mouseup', handleMouseUp);
315
- viewport.addEventListener('mouseleave', () => { isDragging = false; viewport.style.cursor = 'grab'; });
316
-
317
- // 🔧 P3优化:注册清理函数,供组件销毁时调用
318
- const cleanup = () => {
319
- window.removeEventListener('mousemove', handleMouseMove);
320
- window.removeEventListener('mouseup', handleMouseUp);
321
  };
322
- viewport._sandboxCleanup = cleanup;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  }
324
 
325
  /**
@@ -327,9 +314,7 @@ export function setupImageSandboxEvents(detailView) {
327
  * @param {HTMLElement} detailView - 详情视图容器
328
  */
329
  export function cleanupImageSandbox(detailView) {
330
- const viewport = detailView?.querySelector('.img-viewport');
331
- if (viewport && viewport._sandboxCleanup) {
332
- viewport._sandboxCleanup();
333
- viewport._sandboxCleanup = null;
334
- }
335
  }
 
12
  // ==========================================
13
 
14
  import { t } from "./用户体验增强.js";
15
+ import { escapeHtml, API, UI } from "../core/全局配置.js";
16
  import { openImageViewer } from "./图片展示测试.js";
17
 
18
  // 🔧 通用SVG占位图生成函数
 
34
  function getTimeoutSVG() { return createTimeoutSVG(t('image.load_timeout')); }
35
 
36
  // 🔧 P3优化:带超时机制的图片加载器
37
+ function loadImageWithTimeout(url, signal) {
 
 
38
  return new Promise((resolve, reject) => {
39
+ if (signal.aborted) { reject(new DOMException('Image read cancelled', 'AbortError')); return; }
40
+ const image = new Image();
41
+ let settled = false;
42
+ let timer = null;
43
+ const finish = error => {
44
+ if (settled) return;
45
+ settled = true;
46
+ if (timer !== null) clearTimeout(timer);
47
+ image.onload = image.onerror = null;
48
+ signal.removeEventListener('abort', cancel);
49
+ if (error) { image.src = ''; reject(error); }
50
+ else resolve(image);
 
51
  };
52
+ const cancel = () => finish(new DOMException('Image read cancelled', 'AbortError'));
53
+ signal.addEventListener('abort', cancel, { once: true });
54
+ image.onload = () => finish(null);
55
+ image.onerror = () => finish(new Error('Image read failed'));
56
+ timer = setTimeout(() => {
57
+ const error = new Error('Image read timed out');
58
+ error.name = 'TimeoutError';
59
+ finish(error);
60
+ }, API.TIMEOUT);
61
+ try { image.src = url; } catch (error) { finish(error); }
62
  });
63
  }
64
 
 
71
  // 兼容:接受单个URL或数组
72
  let imageUrls = [];
73
  if (Array.isArray(imageSource)) {
74
+ imageUrls = imageSource.filter(url => typeof url === 'string' && url.trim());
75
+ } else if (typeof imageSource === 'string' && imageSource.trim()) {
76
  imageUrls = [imageSource];
77
  }
78
 
 
81
  const firstUrl = imageUrls[0];
82
 
83
  // 🚀 本地代理URL已缓存,无需懒加载(从磁盘读取几乎瞬时)
84
+ const isLocalProxy = firstUrl && firstUrl.startsWith(API.LOCAL_IMAGE_PROXY_PREFIX);
85
  const shouldLazy = lazyLoad && !isLocalProxy;
86
 
87
  // 🔒 XSS防护:图片 URL 来自其他用户发布的内容,写入 HTML 属性前必须转义
 
92
 
93
  // 🖼️ 多图模式:显示左右切换按钮和计数器
94
  const hasMultiple = imageUrls.length > 1;
95
+ const positionLabel = escapeHtml(t('image.viewer.image_position', { current: 1, total: imageUrls.length }));
96
  const navButtons = hasMultiple ? `
97
+ <button type="button" class="sandbox-control btn-prev" aria-label="${escapeHtml(t('image.viewer.previous_short'))}" title="${escapeHtml(t('image.viewer.previous_short'))}">❮</button>
98
+ <button type="button" class="sandbox-control btn-next" aria-label="${escapeHtml(t('image.viewer.next_short'))}" title="${escapeHtml(t('image.viewer.next_short'))}">❯</button>
99
+ <div class="img-counter" role="status" aria-label="${positionLabel}">1 / ${imageUrls.length}</div>
100
  ` : '';
 
 
 
 
 
 
 
 
 
 
 
101
  return `
102
+ <div class="sandbox-heading">🖼️ ${t('image.gallery')}${hasMultiple ? ` (${imageUrls.length})` : ''}</div>
103
+ <div class="img-viewport image-sandbox" role="group" aria-label="${escapeHtml(t('image.gallery'))}" data-images='${escapeHtml(JSON.stringify(imageUrls))}' data-current="0">
104
+ <img class="target-img ${lazyClass}" src="${escapeHtml(imgSrc)}" ${dataAttr} draggable="false" alt="${positionLabel}">
105
  ${navButtons}
106
+ <div class="sandbox-tools">
107
+ <button type="button" class="sandbox-control btn-zoom-out" aria-label="${escapeHtml(t('image.zoom_out'))}" title="${escapeHtml(t('image.zoom_out'))}">−</button>
108
+ <button type="button" class="sandbox-control btn-zoom-reset" title="${escapeHtml(t('common.reset'))}">${t('common.reset')}</button>
109
+ <button type="button" class="sandbox-control btn-zoom-in" aria-label="${escapeHtml(t('image.zoom_in'))}" title="${escapeHtml(t('image.zoom_in'))}">+</button>
110
+ <button type="button" class="sandbox-control btn-fullscreen" aria-label="${escapeHtml(t('image.viewer.fullscreen'))}" title="${escapeHtml(t('image.viewer.fullscreen'))}">⛶</button>
111
  </div>
112
+ <div class="sliders-wrapper">
113
+ <input type="range" class="slider-x" min="-400" max="400" value="0" aria-label="${escapeHtml(t('image.pan_horizontal'))}">
114
+ <input type="range" class="slider-y" min="-400" max="400" value="0" aria-label="${escapeHtml(t('image.pan_vertical'))}">
115
  </div>
116
  </div>
117
  `;
 
121
  * 🖼️ 设置图片沙盒事件(轮播、缩放、拖动)
122
  */
123
  export function setupImageSandboxEvents(detailView) {
124
+ const viewport = detailView?.matches?.('.img-viewport') ? detailView : detailView?.querySelector('.img-viewport');
125
  if (!viewport) return;
126
+ viewport._sandboxCleanup?.();
127
+ const targetImg = viewport.querySelector('.target-img');
128
+ const btnIn = viewport.querySelector('.btn-zoom-in');
129
+ const btnOut = viewport.querySelector('.btn-zoom-out');
130
+ const btnReset = viewport.querySelector('.btn-zoom-reset');
131
+ const sliderX = viewport.querySelector('.slider-x');
132
+ const sliderY = viewport.querySelector('.slider-y');
133
+ if (![targetImg, btnIn, btnOut, btnReset, sliderX, sliderY].every(Boolean)) return;
134
+ let imageUrls;
135
+ try { imageUrls = JSON.parse(viewport.dataset.images || '[]'); } catch (_) { imageUrls = []; }
136
+ if (!Array.isArray(imageUrls)) imageUrls = [];
137
+ imageUrls = imageUrls.filter(url => typeof url === 'string' && url.trim());
138
+ if (!imageUrls.length) return;
139
+ let currentIndex = Number.parseInt(viewport.dataset.current || '0', 10);
140
+ if (!Number.isInteger(currentIndex) || currentIndex < 0 || currentIndex >= imageUrls.length) currentIndex = 0;
141
+ let disposed = false, generation = 0, readController = null, closeViewer = null;
142
+ let observer = null, wasMounted = false, fallbackNode = null, fallbackTimer = null;
143
+ const stops = [];
144
+ const owns = () => !disposed && viewport._sandboxCleanup === cleanup;
145
+ const connected = () => viewport.isConnected && (detailView === viewport || detailView.contains(viewport));
146
+ const current = () => owns() && connected();
147
+ const cleanup = () => {
148
+ if (disposed) return;
149
+ disposed = true;
150
+ generation++;
151
+ readController?.abort();
152
+ readController = null;
153
+ for (const stop of stops) stop();
154
+ stops.length = 0;
155
+ observer?.disconnect();
156
+ observer = null;
157
+ if (fallbackTimer !== null) clearTimeout(fallbackTimer);
158
+ fallbackTimer = null;
159
+ fallbackNode?.remove();
160
+ fallbackNode = null;
161
+ const close = closeViewer;
162
+ closeViewer = null;
163
+ try { close?.(); } catch (_) { console.warn('Image sandbox viewer cleanup failed'); }
164
+ if (viewport._sandboxCleanup === cleanup) viewport._sandboxCleanup = null;
165
+ };
166
+ viewport._sandboxCleanup = cleanup;
167
+ const watchRemoval = () => {
168
+ if (!owns() || observer) return;
169
+ const inspect = records => {
170
+ if (!owns() || observer !== ownedObserver) return;
171
+ if (connected()) { wasMounted = true; return; }
172
+ const appeared = records.some(record => [...record.addedNodes, ...record.removedNodes]
173
+ .some(node => node === viewport || node.contains?.(viewport)));
174
+ if (wasMounted || appeared) cleanup();
175
+ };
176
+ const ownedObserver = new MutationObserver(inspect);
177
+ observer = ownedObserver;
178
+ observer.observe(document.body, { childList: true, subtree: true });
179
+ queueMicrotask(() => {
180
+ if (!owns() || observer !== ownedObserver) return;
181
+ inspect(ownedObserver.takeRecords());
182
+ if (!connected() && !wasMounted && owns()) { ownedObserver.disconnect(); observer = null; }
183
+ });
184
+ };
185
+ const guard = callback => event => {
186
+ if (!current()) return;
187
+ watchRemoval();
188
+ callback(event);
189
+ };
190
+ const listen = (target, type, callback, options) => {
191
+ const owned = guard(callback);
192
+ target.addEventListener(type, owned, options);
193
+ stops.push(() => target.removeEventListener(type, owned, options));
194
+ };
195
+ const click = (target, callback) => {
196
+ if (!target) return;
197
+ const owned = guard(callback);
198
+ target.onclick = owned;
199
+ stops.push(() => { if (target.onclick === owned) target.onclick = null; });
200
+ };
201
+ let scale = Number(viewport.dataset.sandboxScale);
202
+ if (!Number.isFinite(scale) || scale < 0.5 || scale > 5) scale = 1;
203
+ const syncTransform = () => {
204
+ viewport.dataset.sandboxScale = String(scale);
205
+ targetImg.style.transform = `scale(${scale}) translate(${sliderX.value}px, ${sliderY.value}px)`;
206
+ };
207
+ const counter = viewport.querySelector('.img-counter');
208
+ const updateCounter = () => {
209
+ viewport.dataset.current = String(currentIndex);
210
+ const label = t('image.viewer.image_position', { current: currentIndex + 1, total: imageUrls.length });
211
+ targetImg.alt = label;
212
+ if (counter) {
213
+ counter.textContent = `${currentIndex + 1} / ${imageUrls.length}`;
214
+ counter.setAttribute('aria-label', label);
215
+ }
216
+ };
217
+ const loadImage = async index => {
218
+ if (!owns() || index < 0 || index >= imageUrls.length) return;
219
+ readController?.abort();
220
+ const controller = new AbortController();
221
+ readController = controller;
222
+ const expected = ++generation;
223
  currentIndex = index;
224
+ updateCounter();
225
+ scale = 1; sliderX.value = 0; sliderY.value = 0; syncTransform();
226
+ targetImg.classList.remove('lazy-loaded', 'lazy-error');
227
  targetImg.classList.add('lazy-loading');
228
  targetImg.src = getPlaceholderSVG();
229
+ const url = imageUrls[index];
230
  try {
231
+ await loadImageWithTimeout(url, controller.signal);
232
+ if (!current() || expected !== generation) return;
233
+ watchRemoval();
234
  targetImg.src = url;
235
+ targetImg.classList.remove('lazy-loading', 'lazy-error');
236
  targetImg.classList.add('lazy-loaded');
237
  targetImg.style.opacity = '1';
238
+ } catch (error) {
239
+ if (!current() || expected !== generation || controller.signal.aborted) return;
240
+ watchRemoval();
241
+ targetImg.src = error.name === 'TimeoutError' ? getTimeoutSVG() : getErrorSVG();
242
+ targetImg.classList.remove('lazy-loading', 'lazy-loaded');
243
  targetImg.classList.add('lazy-error');
244
+ } finally {
245
+ if (readController === controller) readController = null;
246
  }
 
 
 
 
 
 
 
 
 
 
 
247
  };
248
+ delete targetImg.dataset.src;
249
+ watchRemoval();
250
+ updateCounter();
251
+ syncTransform();
252
+ const ready = targetImg.getAttribute('src') === imageUrls[currentIndex] &&
253
+ (targetImg.classList.contains('lazy-loaded') || (targetImg.complete && targetImg.naturalWidth > 0));
254
+ if (!ready) loadImage(currentIndex);
255
+ else { targetImg.classList.remove('lazy-loading', 'lazy-error'); targetImg.classList.add('lazy-loaded'); }
256
+ listen(viewport, 'contextmenu', event => event.preventDefault());
257
+ listen(targetImg, 'dragstart', event => event.preventDefault());
258
+ click(viewport.querySelector('.btn-prev'), event => { event.stopPropagation(); loadImage(currentIndex > 0 ? currentIndex - 1 : imageUrls.length - 1); });
259
+ click(viewport.querySelector('.btn-next'), event => { event.stopPropagation(); loadImage(currentIndex < imageUrls.length - 1 ? currentIndex + 1 : 0); });
260
+ const zoom = delta => { scale = Math.max(0.5, Math.min(5, scale + delta)); syncTransform(); };
261
+ click(btnIn, () => zoom(0.2));
262
+ click(btnOut, () => zoom(-0.2));
263
+ click(btnReset, () => { scale = 1; sliderX.value = 0; sliderY.value = 0; syncTransform(); });
264
+ const notifyFailure = async () => {
265
+ try {
266
+ const module = await import('./UI交互提示组件.js');
267
+ if (current()) module.showToast(t('image.viewer.load_failed'), 'error');
268
+ } catch (_) {
269
+ if (!current()) return;
270
+ fallbackNode?.remove();
271
+ if (fallbackTimer !== null) clearTimeout(fallbackTimer);
272
+ const node = document.createElement('div');
273
+ node.textContent = t('image.viewer.load_failed');
274
+ Object.assign(node.style, { position: 'fixed', top: '50px', left: '50%', transform: 'translateX(-50%)', background: '#F44336', color: '#fff', padding: '10px 20px', borderRadius: '8px', zIndex: '10000', fontSize: '14px' });
275
+ fallbackNode = node;
276
+ document.body.appendChild(node);
277
+ fallbackTimer = setTimeout(() => { node.remove(); if (fallbackNode === node) { fallbackNode = null; fallbackTimer = null; } }, UI.TOAST.DURATION);
278
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  };
280
+ click(viewport.querySelector('.btn-fullscreen'), event => {
281
+ event.stopPropagation();
282
+ try { closeViewer?.(); closeViewer = openImageViewer(imageUrls, currentIndex, { opaque: true }); }
283
+ catch (_) { console.error('无法打开图像播放器'); notifyFailure(); }
 
 
 
 
284
  });
285
+ listen(viewport, 'wheel', event => { event.preventDefault(); zoom(event.deltaY > 0 ? -0.1 : 0.1); }, { passive: false });
286
+ const rangeKeys = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown']);
287
+ const keepNativeRangeKey = event => {
288
+ if (event.defaultPrevented || event.isComposing || event.keyCode === 229 ||
289
+ event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) return;
290
+ if (rangeKeys.has(event.key)) event.stopPropagation();
 
 
291
  };
292
+ listen(sliderX, 'keydown', keepNativeRangeKey);
293
+ listen(sliderY, 'keydown', keepNativeRangeKey);
294
+ listen(sliderX, 'input', syncTransform);
295
+ listen(sliderY, 'input', syncTransform);
296
+ let dragging = false, startX, startY, initialX, initialY;
297
+ listen(viewport, 'mousedown', event => {
298
+ if (event.target.closest('input, button')) return;
299
+ event.preventDefault(); dragging = true; viewport.style.cursor = 'grabbing';
300
+ startX = event.clientX; startY = event.clientY; initialX = Number(sliderX.value); initialY = Number(sliderY.value);
301
+ });
302
+ listen(window, 'mousemove', event => {
303
+ if (!dragging) return;
304
+ sliderX.value = initialX + (event.clientX - startX) / scale;
305
+ sliderY.value = initialY + (event.clientY - startY) / scale;
306
+ syncTransform();
307
+ });
308
+ listen(window, 'mouseup', () => { dragging = false; viewport.style.cursor = 'grab'; });
309
+ listen(viewport, 'mouseleave', () => { dragging = false; viewport.style.cursor = 'grab'; });
310
  }
311
 
312
  /**
 
314
  * @param {HTMLElement} detailView - 详情视图容器
315
  */
316
  export function cleanupImageSandbox(detailView) {
317
+ const viewports = [...(detailView?.querySelectorAll?.('.img-viewport') || [])];
318
+ if (detailView?.matches?.('.img-viewport')) viewports.unshift(detailView);
319
+ for (const viewport of viewports) viewport._sandboxCleanup?.();
 
 
320
  }
前端页面/components/图片裁剪组件.js CHANGED
@@ -53,11 +53,7 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
53
  const dialogId = `image-crop-dialog-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
54
  // 创建遮罩层
55
  const overlay = document.createElement("div");
56
- Object.assign(overlay.style, {
57
- position: "fixed", top: "0", left: "0", width: "100%", height: "100%",
58
- background: "rgba(0,0,0,0.85)", zIndex: "99999",
59
- display: "flex", justifyContent: "center", alignItems: "center"
60
- });
61
 
62
  // 创建弹窗容器
63
  const modal = document.createElement("div");
@@ -66,18 +62,11 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
66
  modal.setAttribute("aria-labelledby", `${dialogId}-title`);
67
  modal.setAttribute("aria-describedby", `${dialogId}-hint`);
68
  modal.setAttribute("tabindex", "-1");
69
- Object.assign(modal.style, {
70
- background: "var(--comfy-input-bg)", borderRadius: "12px", padding: "20px",
71
- maxWidth: "90vw", maxHeight: "90vh", display: "flex", flexDirection: "column",
72
- boxShadow: "0 8px 32px rgba(0,0,0,0.5)"
73
- });
74
 
75
  // 标题栏
76
  const header = document.createElement("div");
77
- Object.assign(header.style, {
78
- display: "flex", justifyContent: "space-between", alignItems: "center",
79
- marginBottom: "15px", paddingBottom: "10px", borderBottom: "1px solid #444"
80
- });
81
  // 比例显示文字
82
  let ratioText = '16:9';
83
  if (aspectRatio === 1) ratioText = '1:1';
@@ -85,40 +74,23 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
85
  // 📱 触控设备与鼠标设备的操作提示区分(移动端无滚轮/拖动概念);文案走词典,随界面语言切换
86
  const gestureHint = _isTouchDevice() ? t('crop.gesture_hint_touch') : t('crop.gesture_hint_mouse');
87
  header.innerHTML = `
88
- <span id="${dialogId}-title" role="heading" aria-level="2" style="font-size: 16px; font-weight: bold; color: #fff;">✂️ ${escapeHtml(title)}</span>
89
- <span id="${dialogId}-hint" style="font-size: 12px; color: #888;">${escapeHtml(t('crop.ratio_label'))} ${ratioText} | ${escapeHtml(gestureHint)}</span>
90
  `;
91
 
92
  // 裁剪区域容器
93
  const cropContainer = document.createElement("div");
94
  let containerWidth, containerHeight;
95
- if (aspectRatio === 1) {
96
- // 1:1 正方形
97
- containerWidth = containerHeight = Math.min(400, window.innerWidth * 0.7);
98
- } else if (aspectRatio > 1) {
99
- // 16:9 横向
100
- containerWidth = Math.min(600, window.innerWidth * 0.8);
101
- containerHeight = containerWidth / aspectRatio;
102
- } else {
103
- // 9:16 纵向
104
- containerWidth = Math.min(300, window.innerWidth * 0.5);
105
- containerHeight = Math.min(450, window.innerHeight * 0.5);
106
- }
107
-
108
- Object.assign(cropContainer.style, {
109
- width: `${containerWidth}px`, height: `${containerHeight}px`,
110
- position: "relative", overflow: "hidden", borderRadius: "8px",
111
- background: "#1a1a1a", border: "2px solid #4CAF50", cursor: "move",
112
- // 📱 禁止浏览器接管触摸手势(页面滚动/双指缩放页面),交由裁剪组件自行处理
113
- touchAction: "none"
114
- });
115
 
116
  // 图片元素
117
  const img = document.createElement("img");
118
- Object.assign(img.style, {
119
- position: "absolute", maxWidth: "none", maxHeight: "none",
120
- userSelect: "none", pointerEvents: "none"
121
- });
122
 
123
  // 裁剪状态
124
  let scale = 1;
@@ -177,13 +149,30 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
177
  img.style.height = `${scaledHeight}px`;
178
  img.style.left = `${left}px`;
179
  img.style.top = `${top}px`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  }
181
 
182
  // 鼠标拖动
183
  cropContainer.onmousedown = (e) => {
 
184
  isDragging = true;
185
- dragStartX = e.clientX - offsetX;
186
- dragStartY = e.clientY - offsetY;
 
187
  cropContainer.style.cursor = "grabbing";
188
  };
189
 
@@ -192,6 +181,7 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
192
 
193
  // 🔧 P3优化:ESC 键关闭
194
  function handleKeyDown(e) {
 
195
  if (e.key === 'Escape') {
196
  e.preventDefault();
197
  cleanup();
@@ -221,8 +211,9 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
221
 
222
  function handleMouseMove(e) {
223
  if (!isDragging) return;
224
- offsetX = e.clientX - dragStartX;
225
- offsetY = e.clientY - dragStartY;
 
226
  updateImagePosition();
227
  }
228
 
@@ -234,6 +225,7 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
234
  // 滚轮缩放
235
  cropContainer.onwheel = (e) => {
236
  e.preventDefault();
 
237
  const delta = e.deltaY > 0 ? 0.95 : 1.05;
238
  const newScale = scale * delta;
239
 
@@ -254,9 +246,6 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
254
  let pinchCenterY = 0;
255
  let pinchStartOffsetX = 0;
256
  let pinchStartOffsetY = 0;
257
- // 🔧 手势起始时容器的视口原点(含 border),用于把 clientX/Y 换算成与 img.style.left 同源的容器内坐标
258
- let pinchOriginX = 0;
259
- let pinchOriginY = 0;
260
 
261
  // 获取缩放边界(与滚轮缩放共用同一规则)
262
  function _getScaleLimits() {
@@ -267,10 +256,8 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
267
  }
268
 
269
  function _getTouchMidPoint(touches) {
270
- return {
271
- x: (touches[0].clientX + touches[1].clientX) / 2,
272
- y: (touches[0].clientY + touches[1].clientY) / 2
273
- };
274
  }
275
 
276
  function _getTouchDistance(touches) {
@@ -280,22 +267,19 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
280
  }
281
 
282
  function handleTouchStart(e) {
 
283
  if (e.touches.length === 1) {
284
  isDragging = true;
285
- dragStartX = e.touches[0].clientX - offsetX;
286
- dragStartY = e.touches[0].clientY - offsetY;
 
287
  } else if (e.touches.length === 2) {
288
  isDragging = false;
289
  pinchStartDist = _getTouchDistance(e.touches);
290
  pinchStartScale = scale;
291
- // 🔧 clientX 是视口坐标,offsetX/containerWidth 是容器内坐标,两者必须同原点才能参与锚点计算;
292
- // clientLeft/clientTop 为 2px 边框宽度(img 的 absolute 定位原点在 padding box 左上角)
293
- const rect = cropContainer.getBoundingClientRect();
294
- pinchOriginX = rect.left + cropContainer.clientLeft;
295
- pinchOriginY = rect.top + cropContainer.clientTop;
296
  const mid = _getTouchMidPoint(e.touches);
297
- pinchCenterX = mid.x - pinchOriginX;
298
- pinchCenterY = mid.y - pinchOriginY;
299
  pinchStartOffsetX = offsetX;
300
  pinchStartOffsetY = offsetY;
301
  }
@@ -305,8 +289,9 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
305
  function handleTouchMove(e) {
306
  if (e.touches.length === 1 && isDragging) {
307
  // 单指拖动
308
- offsetX = e.touches[0].clientX - dragStartX;
309
- offsetY = e.touches[0].clientY - dragStartY;
 
310
  updateImagePosition();
311
  } else if (e.touches.length === 2 && pinchStartDist > 0) {
312
  // 双指捏合缩放(以两指中点为锚点,图片跟随手指中心移动)
@@ -314,9 +299,8 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
314
  const newScale = _clamp(pinchStartScale * (_getTouchDistance(e.touches) / pinchStartDist), minScale, maxScale);
315
  const ratio = newScale / pinchStartScale;
316
  const mid = _getTouchMidPoint(e.touches);
317
- // 换算到容器内坐标(与 handleTouchStart 同原点,一次捏合手势期间弹窗不移动)
318
- const relX = mid.x - pinchOriginX;
319
- const relY = mid.y - pinchOriginY;
320
  // 图片 left = (W - s·imgW)/2 + o,故锚点不动的条件解出:
321
  // o1 = (p1 - p0) + ratio·(o0 - d) + d,其中 d = p0 - W/2 为起始手指相对容器中心的偏移
322
  const centerX = containerWidth / 2;
@@ -339,8 +323,9 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
339
  // 双指抬起剩单指:切换为拖动模式,避免图片跳变
340
  pinchStartDist = 0;
341
  isDragging = true;
342
- dragStartX = e.touches[0].clientX - offsetX;
343
- dragStartY = e.touches[0].clientY - offsetY;
 
344
  }
345
  }
346
 
@@ -351,24 +336,20 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
351
 
352
  // 缩放滑块
353
  const sliderContainer = document.createElement("div");
354
- Object.assign(sliderContainer.style, {
355
- display: "flex", alignItems: "center", gap: "10px", marginTop: "15px", padding: "0 10px"
356
- });
357
  sliderContainer.innerHTML = `
358
- <span style="color: #888; font-size: 12px;">🔍 ${escapeHtml(t('crop.zoom_label'))}</span>
359
  <input type="range" id="crop-scale-slider" min="100" max="500" value="100" aria-label="${escapeHtml(t('crop.zoom_label'))}" aria-valuetext="100%"
360
- style="flex: 1; accent-color: #4CAF50;">
361
- <span id="crop-scale-value" style="color: #4CAF50; font-size: 12px; min-width: 40px;">100%</span>
362
  `;
363
 
364
  // 按钮区域
365
  const buttonContainer = document.createElement("div");
366
- Object.assign(buttonContainer.style, {
367
- display: "flex", gap: "10px", marginTop: "15px", justifyContent: "flex-end"
368
- });
369
  buttonContainer.innerHTML = `
370
- <button id="btn-crop-cancel" type="button" style="padding: 10px 20px; background: transparent; border: 1px solid #555; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px;">${escapeHtml(t('common.cancel'))}</button>
371
- <button id="btn-crop-confirm" type="button" disabled style="padding: 10px 30px; background: #4CAF50; border: none; color: white; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: bold;">✂️ ${escapeHtml(t('crop.confirm'))}</button>
372
  `;
373
 
374
  // 组装DOM
@@ -379,6 +360,13 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
379
  modal.appendChild(buttonContainer);
380
  overlay.appendChild(modal);
381
  document.body.appendChild(overlay);
 
 
 
 
 
 
 
382
  modal.querySelector("#btn-crop-cancel").focus();
383
 
384
  // 绑定缩放滑块事件
@@ -389,6 +377,7 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
389
  const scaleValue = modal.querySelector("#crop-scale-value");
390
 
391
  slider.oninput = () => {
 
392
  const minScaleX = containerWidth / imgNaturalWidth;
393
  const minScaleY = containerHeight / imgNaturalHeight;
394
  const minScale = Math.max(minScaleX, minScaleY);
@@ -411,6 +400,7 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
411
  const btn = modal.querySelector("#btn-crop-confirm");
412
  if (btn.disabled) return;
413
  btn.textContent = `⏳ ${t('crop.processing')}`;
 
414
  btn.disabled = true;
415
 
416
  encodeTimer = setTimeout(fail, IMAGE_PROCESS.TIMEOUT);
 
53
  const dialogId = `image-crop-dialog-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
54
  // 创建遮罩层
55
  const overlay = document.createElement("div");
56
+ overlay.className = "image-crop-overlay";
 
 
 
 
57
 
58
  // 创建弹窗容器
59
  const modal = document.createElement("div");
 
62
  modal.setAttribute("aria-labelledby", `${dialogId}-title`);
63
  modal.setAttribute("aria-describedby", `${dialogId}-hint`);
64
  modal.setAttribute("tabindex", "-1");
65
+ modal.className = "image-crop-dialog";
 
 
 
 
66
 
67
  // 标题栏
68
  const header = document.createElement("div");
69
+ header.className = "image-crop-header";
 
 
 
70
  // 比例显示文字
71
  let ratioText = '16:9';
72
  if (aspectRatio === 1) ratioText = '1:1';
 
74
  // 📱 触控设备与鼠标设备的操作提示区分(移动端无滚轮/拖动概念);文案走词典,随界面语言切换
75
  const gestureHint = _isTouchDevice() ? t('crop.gesture_hint_touch') : t('crop.gesture_hint_mouse');
76
  header.innerHTML = `
77
+ <span id="${dialogId}-title" role="heading" aria-level="2" class="image-crop-title">✂️ ${escapeHtml(title)}</span>
78
+ <span id="${dialogId}-hint" class="image-crop-hint">${escapeHtml(t('crop.ratio_label'))} ${ratioText} | ${escapeHtml(gestureHint)}</span>
79
  `;
80
 
81
  // 裁剪区域容器
82
  const cropContainer = document.createElement("div");
83
  let containerWidth, containerHeight;
84
+ const preferredWidth = aspectRatio === 1
85
+ ? Math.min(400, window.innerWidth * 0.7)
86
+ : aspectRatio > 1 ? Math.min(600, window.innerWidth * 0.8)
87
+ : Math.min(300, window.innerWidth * 0.5, Math.min(450, window.innerHeight * 0.5) * aspectRatio);
88
+ cropContainer.className = "image-crop-viewport";
89
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
 
91
  // 图片元素
92
  const img = document.createElement("img");
93
+ img.className = "image-crop-image";
 
 
 
94
 
95
  // 裁剪状态
96
  let scale = 1;
 
149
  img.style.height = `${scaledHeight}px`;
150
  img.style.left = `${left}px`;
151
  img.style.top = `${top}px`;
152
+ const slider = modal.querySelector('#crop-scale-slider');
153
+ if (slider && imgNaturalWidth) {
154
+ const percent = Math.round(scale / _getScaleLimits().minScale * 100);
155
+ slider.value = String(percent);
156
+ slider.setAttribute('aria-valuetext', `${percent}%`);
157
+ modal.querySelector('#crop-scale-value').textContent = `${percent}%`;
158
+ }
159
+ }
160
+
161
+ function localPoint(clientX, clientY) {
162
+ const rect = cropContainer.getBoundingClientRect();
163
+ return {
164
+ x: (clientX - rect.left) * (containerWidth + cropContainer.clientLeft * 2) / rect.width - cropContainer.clientLeft,
165
+ y: (clientY - rect.top) * (containerHeight + cropContainer.clientTop * 2) / rect.height - cropContainer.clientTop
166
+ };
167
  }
168
 
169
  // 鼠标拖动
170
  cropContainer.onmousedown = (e) => {
171
+ if (!imgNaturalWidth || e.button !== 0) return;
172
  isDragging = true;
173
+ const point = localPoint(e.clientX, e.clientY);
174
+ dragStartX = point.x - offsetX;
175
+ dragStartY = point.y - offsetY;
176
  cropContainer.style.cursor = "grabbing";
177
  };
178
 
 
181
 
182
  // 🔧 P3优化:ESC 键关闭
183
  function handleKeyDown(e) {
184
+ if (e.isComposing || e.ctrlKey || e.altKey || e.metaKey) return;
185
  if (e.key === 'Escape') {
186
  e.preventDefault();
187
  cleanup();
 
211
 
212
  function handleMouseMove(e) {
213
  if (!isDragging) return;
214
+ const point = localPoint(e.clientX, e.clientY);
215
+ offsetX = point.x - dragStartX;
216
+ offsetY = point.y - dragStartY;
217
  updateImagePosition();
218
  }
219
 
 
225
  // 滚轮缩放
226
  cropContainer.onwheel = (e) => {
227
  e.preventDefault();
228
+ if (!imgNaturalWidth) return;
229
  const delta = e.deltaY > 0 ? 0.95 : 1.05;
230
  const newScale = scale * delta;
231
 
 
246
  let pinchCenterY = 0;
247
  let pinchStartOffsetX = 0;
248
  let pinchStartOffsetY = 0;
 
 
 
249
 
250
  // 获取缩放边界(与滚轮缩放共用同一规则)
251
  function _getScaleLimits() {
 
256
  }
257
 
258
  function _getTouchMidPoint(touches) {
259
+ return localPoint((touches[0].clientX + touches[1].clientX) / 2,
260
+ (touches[0].clientY + touches[1].clientY) / 2);
 
 
261
  }
262
 
263
  function _getTouchDistance(touches) {
 
267
  }
268
 
269
  function handleTouchStart(e) {
270
+ if (!imgNaturalWidth) return;
271
  if (e.touches.length === 1) {
272
  isDragging = true;
273
+ const point = localPoint(e.touches[0].clientX, e.touches[0].clientY);
274
+ dragStartX = point.x - offsetX;
275
+ dragStartY = point.y - offsetY;
276
  } else if (e.touches.length === 2) {
277
  isDragging = false;
278
  pinchStartDist = _getTouchDistance(e.touches);
279
  pinchStartScale = scale;
 
 
 
 
 
280
  const mid = _getTouchMidPoint(e.touches);
281
+ pinchCenterX = mid.x;
282
+ pinchCenterY = mid.y;
283
  pinchStartOffsetX = offsetX;
284
  pinchStartOffsetY = offsetY;
285
  }
 
289
  function handleTouchMove(e) {
290
  if (e.touches.length === 1 && isDragging) {
291
  // 单指拖动
292
+ const point = localPoint(e.touches[0].clientX, e.touches[0].clientY);
293
+ offsetX = point.x - dragStartX;
294
+ offsetY = point.y - dragStartY;
295
  updateImagePosition();
296
  } else if (e.touches.length === 2 && pinchStartDist > 0) {
297
  // 双指捏合缩放(以两指中点为锚点,图片跟随手指中心移动)
 
299
  const newScale = _clamp(pinchStartScale * (_getTouchDistance(e.touches) / pinchStartDist), minScale, maxScale);
300
  const ratio = newScale / pinchStartScale;
301
  const mid = _getTouchMidPoint(e.touches);
302
+ const relX = mid.x;
303
+ const relY = mid.y;
 
304
  // 图片 left = (W - s·imgW)/2 + o,故锚点不动的条件解出:
305
  // o1 = (p1 - p0) + ratio·(o0 - d) + d,其中 d = p0 - W/2 为起始手指相对容器中心的偏移
306
  const centerX = containerWidth / 2;
 
323
  // 双指抬起剩单指:切换为拖动模式,避免图片跳变
324
  pinchStartDist = 0;
325
  isDragging = true;
326
+ const point = localPoint(e.touches[0].clientX, e.touches[0].clientY);
327
+ dragStartX = point.x - offsetX;
328
+ dragStartY = point.y - offsetY;
329
  }
330
  }
331
 
 
336
 
337
  // 缩放滑块
338
  const sliderContainer = document.createElement("div");
339
+ sliderContainer.className = "image-crop-zoom";
 
 
340
  sliderContainer.innerHTML = `
341
+ <label for="crop-scale-slider" class="image-crop-zoom-label">🔍 ${escapeHtml(t('crop.zoom_label'))}</label>
342
  <input type="range" id="crop-scale-slider" min="100" max="500" value="100" aria-label="${escapeHtml(t('crop.zoom_label'))}" aria-valuetext="100%"
343
+ class="image-crop-range">
344
+ <span id="crop-scale-value" class="image-crop-zoom-value">100%</span>
345
  `;
346
 
347
  // 按钮区域
348
  const buttonContainer = document.createElement("div");
349
+ buttonContainer.className = "image-crop-actions";
 
 
350
  buttonContainer.innerHTML = `
351
+ <button id="btn-crop-cancel" type="button" class="image-crop-button image-crop-cancel">${escapeHtml(t('common.cancel'))}</button>
352
+ <button id="btn-crop-confirm" type="button" disabled class="image-crop-button image-crop-confirm">✂️ ${escapeHtml(t('crop.confirm'))}</button>
353
  `;
354
 
355
  // 组装DOM
 
360
  modal.appendChild(buttonContainer);
361
  overlay.appendChild(modal);
362
  document.body.appendChild(overlay);
363
+ const modalStyle = getComputedStyle(modal);
364
+ const availableWidth = modal.clientWidth - parseFloat(modalStyle.paddingLeft) - parseFloat(modalStyle.paddingRight);
365
+ const viewportStyle = getComputedStyle(cropContainer);
366
+ containerWidth = Math.min(preferredWidth, Math.max(1, availableWidth - parseFloat(viewportStyle.borderLeftWidth) - parseFloat(viewportStyle.borderRightWidth)));
367
+ containerHeight = containerWidth / aspectRatio;
368
+ cropContainer.style.width = `${containerWidth}px`;
369
+ cropContainer.style.height = `${containerHeight}px`;
370
  modal.querySelector("#btn-crop-cancel").focus();
371
 
372
  // 绑定缩放滑块事件
 
377
  const scaleValue = modal.querySelector("#crop-scale-value");
378
 
379
  slider.oninput = () => {
380
+ if (!imgNaturalWidth) return;
381
  const minScaleX = containerWidth / imgNaturalWidth;
382
  const minScaleY = containerHeight / imgNaturalHeight;
383
  const minScale = Math.max(minScaleX, minScaleY);
 
400
  const btn = modal.querySelector("#btn-crop-confirm");
401
  if (btn.disabled) return;
402
  btn.textContent = `⏳ ${t('crop.processing')}`;
403
+ if (modal.contains(document.activeElement)) modal.focus({ preventScroll: true });
404
  btn.disabled = true;
405
 
406
  encodeTimer = setTimeout(fail, IMAGE_PROCESS.TIMEOUT);
前端页面/components/性能优化工具.js CHANGED
@@ -15,7 +15,8 @@
15
  // 🔒 P0安全优化:敏感数据脱敏
16
  // ==========================================
17
 
18
- import { API, PAGINATION } from "../core/全局配置.js";
 
19
  // 📦 列表缓存工具需要本地可用的缓存实现;下方「向后兼容重导出」那行是纯转发、
20
  // 不会生成本地绑定,故此处必须单独 import(勿误删,否则 readListCache 运行时报错)
21
  import { setCache, getCacheWithMeta, removeCache, getCacheKeysByPrefix } from "../core/网络请求_缓存管理.js";
@@ -429,136 +430,162 @@ export function renderListSearchMessage(container, message) {
429
  // 特点:
430
  // - 使用 IntersectionObserver API
431
  // - 支持占位图和加载动画
432
- // - 自动重试失败的图片
433
 
434
- // 默认占位图(Base64 灰色方块)
435
- const PLACEHOLDER_IMAGE = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'%3E%3Crect fill='%23f0f0f0' width='100' height='100'/%3E%3Ctext x='50' y='55' text-anchor='middle' fill='%23999' font-size='12'%3E加载中%3C/text%3E%3C/svg%3E";
 
436
 
437
- // 错误占位图
438
- const ERROR_IMAGE = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'%3E%3Crect fill='%23ffeaea' width='100' height='100'/%3E%3Ctext x='50' y='55' text-anchor='middle' fill='%23c44' font-size='12'%3E加载失败%3C/text%3E%3C/svg%3E";
 
 
 
 
439
 
440
- // 全局 Observer 实例
441
- let lazyLoadObserver = null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
442
 
443
- /**
444
- * 初始化图片懒加载(全局单例)
445
- */
446
  function _initLazyLoadObserver() {
447
- if (lazyLoadObserver) return;
448
-
449
- // 检查浏览器支持
450
- if (!("IntersectionObserver" in window)) {
451
- console.warn("⚠️ 浏览器不支持 IntersectionObserver,懒加载降级为直接加载");
452
- return;
453
- }
454
-
455
- lazyLoadObserver = new IntersectionObserver((entries) => {
456
- entries.forEach(entry => {
457
- if (entry.isIntersecting) {
458
- const img = entry.target;
459
- _loadImage(img);
460
- lazyLoadObserver.unobserve(img);
 
 
 
 
 
 
461
  }
462
- });
463
- }, {
464
- rootMargin: "200px 0px", // 提前 200px 开始加载
465
- threshold: 0.01
466
  });
 
 
467
  }
468
 
469
- /**
470
- * 实际加载图片
471
- */
472
- function _loadImage(img) {
473
- const src = img.dataset.src;
474
- if (!src) return;
475
-
476
- // 创建临时图片测试加载
477
- const tempImg = new Image();
478
-
479
- tempImg.onload = () => {
480
  img.src = src;
481
- img.classList.remove("lazy-loading");
482
- img.classList.add("lazy-loaded");
483
  delete img.dataset.src;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
484
  };
485
-
486
- tempImg.onerror = () => {
487
- // 加载失败,显示错误占位图
488
- img.src = ERROR_IMAGE;
489
- img.classList.remove("lazy-loading");
490
- img.classList.add("lazy-error");
491
-
492
- // 记录失败,后续可重试
493
- img.dataset.retries = (parseInt(img.dataset.retries) || 0) + 1;
494
- };
495
-
496
- tempImg.src = src;
497
  }
498
 
499
- /**
500
- * 创建懒加载图片元素
501
- * @param {string} src - 图片真实地址
502
- * @param {string} alt - 图片描述
503
- * @param {Object} style - 样式对象
504
- * @returns {HTMLImageElement}
505
- */
506
- export function createLazyImage(src, alt = "", style = {}) {
507
- _initLazyLoadObserver();
508
-
509
- const img = document.createElement("img");
510
  img.alt = alt;
511
- img.src = PLACEHOLDER_IMAGE;
512
- img.dataset.src = src;
513
- img.className = "lazy-image lazy-loading";
514
-
515
- // 应用样式
516
- Object.assign(img.style, {
517
- transition: "opacity 0.3s ease",
518
- ...style
519
- });
520
-
521
- // 添加到观察队列
522
- if (lazyLoadObserver) {
523
- lazyLoadObserver.observe(img);
524
- } else {
525
- // 降级:直接加载
526
- img.src = src;
527
- }
528
-
529
  return img;
530
  }
531
 
532
- /**
533
- * 将现有图片转���为懒加载
534
- * @param {HTMLImageElement} img - 已存在的图片元素
535
- */
536
  export function makeLazy(img) {
537
  if (!img.src || img.dataset.src) return;
538
-
539
- _initLazyLoadObserver();
540
-
541
- img.dataset.src = img.src;
542
- img.src = PLACEHOLDER_IMAGE;
543
- img.classList.add("lazy-image", "lazy-loading");
544
-
545
- if (lazyLoadObserver) {
546
- lazyLoadObserver.observe(img);
547
- }
548
  }
549
 
550
- /**
551
- * 批量设置容器内所有图片为懒加载
552
- * @param {HTMLElement} container - 容器元素
553
- * @param {string} selector - 图片选择器,默认 "img"
554
- */
555
- export function lazyLoadImages(container, selector = "img") {
556
- const images = container.querySelectorAll(selector);
557
- images.forEach(img => {
558
- // 跳过已处理的和 data URL
559
- if (img.dataset.src || img.src.startsWith("data:")) return;
560
  makeLazy(img);
561
- });
562
  }
563
 
564
 
 
15
  // 🔒 P0安全优化:敏感数据脱敏
16
  // ==========================================
17
 
18
+ import { API, PAGINATION, LAZY_IMAGES } from "../core/全局配置.js";
19
+ import { t } from "./用户体验_国际化.js";
20
  // 📦 列表缓存工具需要本地可用的缓存实现;下方「向后兼容重导出」那行是纯转发、
21
  // 不会生成本地绑定,故此处必须单独 import(勿误删,否则 readListCache 运行时报错)
22
  import { setCache, getCacheWithMeta, removeCache, getCacheKeysByPrefix } from "../core/网络请求_缓存管理.js";
 
430
  // 特点:
431
  // - 使用 IntersectionObserver API
432
  // - 支持占位图和加载动画
433
+ // - 节点移除、读取超时与迟到结果清理
434
 
435
+ const lazyImages = new Map();
436
+ let lazyLoadObserver = null;
437
+ let lazyRemovalObserver = null;
438
 
439
+ function lazyPlaceholder(failed = false) {
440
+ const text = t(failed ? 'image.load_failed' : 'image.loading');
441
+ const fill = failed ? '%23ffeaea' : '%23f0f0f0';
442
+ const color = failed ? '%23c44' : '%23999';
443
+ return `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'%3E%3Crect fill='${fill}' width='100' height='100'/%3E%3Ctext x='50' y='55' text-anchor='middle' fill='${color}' font-size='12'%3E${encodeURIComponent(text)}%3C/text%3E%3C/svg%3E`;
444
+ }
445
 
446
+ function releaseLazyImage(record, restore = false) {
447
+ const img = record.img;
448
+ if (!img || lazyImages.get(img) !== record) return;
449
+ if (restore && img.dataset.src === record.src && img.getAttribute('src') === record.placeholder) {
450
+ img.loading = 'lazy';
451
+ img.src = record.src;
452
+ delete img.dataset.src;
453
+ img.classList.remove('lazy-loading');
454
+ }
455
+ lazyImages.delete(img);
456
+ lazyLoadObserver?.unobserve(img);
457
+ if (record.timer !== null) clearTimeout(record.timer);
458
+ record.timer = null;
459
+ if (record.reader) {
460
+ record.reader.onload = record.reader.onerror = null;
461
+ record.reader.src = '';
462
+ record.reader = null;
463
+ }
464
+ if (img._lazyCleanup === record.cleanup) delete img._lazyCleanup;
465
+ record.img = null;
466
+ record.cleanup = null;
467
+ if (!lazyImages.size) {
468
+ lazyLoadObserver?.disconnect(); lazyLoadObserver = null;
469
+ lazyRemovalObserver?.disconnect(); lazyRemovalObserver = null;
470
+ }
471
+ }
472
+
473
+ function ownsLazyImage(record) {
474
+ const img = record.img;
475
+ return !!img && lazyImages.get(img) === record && img.isConnected &&
476
+ img.dataset.src === record.src && img.getAttribute('src') === record.placeholder;
477
+ }
478
 
 
 
 
479
  function _initLazyLoadObserver() {
480
+ if (lazyLoadObserver || !('IntersectionObserver' in window)) return;
481
+ const observer = new IntersectionObserver(entries => {
482
+ if (lazyLoadObserver !== observer) return;
483
+ for (const entry of entries) {
484
+ if (entry.isIntersecting) _loadImage(entry.target);
485
+ }
486
+ }, { rootMargin: LAZY_IMAGES.ROOT_MARGIN, threshold: LAZY_IMAGES.THRESHOLD });
487
+ lazyLoadObserver = observer;
488
+ }
489
+
490
+ function watchLazyImages() {
491
+ if (lazyRemovalObserver) return;
492
+ const observer = new MutationObserver(mutations => {
493
+ if (lazyRemovalObserver !== observer) return;
494
+ for (const record of lazyImages.values()) {
495
+ const img = record.img;
496
+ if (mutations.some(mutation => [...mutation.addedNodes].some(node => node === img || node.contains?.(img)))) record.seen = true;
497
+ if (img.isConnected) {
498
+ record.seen = true;
499
+ if (!record.reader && record.timer !== null) { clearTimeout(record.timer); record.timer = null; }
500
  }
501
+ if ((!img.isConnected && record.seen) || img.dataset.src !== record.src || img.getAttribute('src') !== record.placeholder) {
502
+ releaseLazyImage(record, !img.isConnected);
503
+ }
504
+ }
505
  });
506
+ lazyRemovalObserver = observer;
507
+ observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['src', 'data-src'] });
508
  }
509
 
510
+ function registerLazyImage(img, src) {
511
+ const previous = lazyImages.get(img);
512
+ if (previous) releaseLazyImage(previous);
513
+ _initLazyLoadObserver();
514
+ if (!lazyLoadObserver) {
515
+ img.classList.add('lazy-image');
 
 
 
 
 
516
  img.src = src;
 
 
517
  delete img.dataset.src;
518
+ img.classList.remove('lazy-loading', 'lazy-error');
519
+ return;
520
+ }
521
+ const placeholder = lazyPlaceholder();
522
+ img.dataset.src = src;
523
+ img.src = placeholder;
524
+ img.classList.remove('lazy-loaded', 'lazy-error');
525
+ img.classList.add('lazy-image', 'lazy-loading');
526
+ const record = { img, src, placeholder, seen: img.isConnected, reader: null, timer: null, cleanup: null };
527
+ record.cleanup = () => releaseLazyImage(record, true);
528
+ img._lazyCleanup = record.cleanup;
529
+ lazyImages.set(img, record);
530
+ watchLazyImages();
531
+ if (!record.seen) record.timer = setTimeout(() => {
532
+ if (ownsLazyImage(record)) { record.seen = true; record.timer = null; }
533
+ else {
534
+ const unmounted = !record.seen && img.dataset.src === src && img.getAttribute('src') === placeholder;
535
+ releaseLazyImage(record);
536
+ if (unmounted) { img.src = src; delete img.dataset.src; img.classList.remove('lazy-loading'); }
537
+ }
538
+ }, LAZY_IMAGES.MOUNT_TIMEOUT);
539
+ lazyLoadObserver.observe(img);
540
+ }
541
+
542
+ function _loadImage(img) {
543
+ const record = lazyImages.get(img);
544
+ if (!record || record.reader) return;
545
+ if (!ownsLazyImage(record)) {
546
+ if (record.seen || img.dataset.src !== record.src || img.getAttribute('src') !== record.placeholder) releaseLazyImage(record, !img.isConnected);
547
+ return;
548
+ }
549
+ record.seen = true;
550
+ lazyLoadObserver?.unobserve(img);
551
+ if (record.timer !== null) clearTimeout(record.timer);
552
+ const reader = new Image();
553
+ record.reader = reader;
554
+ const finish = failed => {
555
+ if (!ownsLazyImage(record)) { releaseLazyImage(record, !img.isConnected); return; }
556
+ const src = record.src;
557
+ releaseLazyImage(record);
558
+ img.src = failed ? lazyPlaceholder(true) : src;
559
+ img.classList.remove('lazy-loading');
560
+ img.classList.add(failed ? 'lazy-error' : 'lazy-loaded');
561
+ if (failed) img.dataset.retries = (parseInt(img.dataset.retries) || 0) + 1;
562
+ else delete img.dataset.src;
563
  };
564
+ reader.onload = () => finish(false);
565
+ reader.onerror = () => finish(true);
566
+ record.timer = setTimeout(() => finish(true), LAZY_IMAGES.READ_TIMEOUT);
567
+ reader.src = record.src;
 
 
 
 
 
 
 
 
568
  }
569
 
570
+ export function createLazyImage(src, alt = '', style = {}) {
571
+ const img = document.createElement('img');
 
 
 
 
 
 
 
 
 
572
  img.alt = alt;
573
+ Object.assign(img.style, { transition: 'opacity 0.3s ease', ...style });
574
+ registerLazyImage(img, src);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
575
  return img;
576
  }
577
 
 
 
 
 
578
  export function makeLazy(img) {
579
  if (!img.src || img.dataset.src) return;
580
+ registerLazyImage(img, img.src);
 
 
 
 
 
 
 
 
 
581
  }
582
 
583
+ export function lazyLoadImages(container, selector = 'img') {
584
+ for (const img of container.querySelectorAll(selector)) {
585
+ if (img.classList.contains('target-img') && img.closest('.img-viewport')?._sandboxCleanup) continue;
586
+ if (img.dataset.src || img.src.startsWith('data:')) continue;
 
 
 
 
 
 
587
  makeLazy(img);
588
+ }
589
  }
590
 
591
 
前端页面/components/用户体验_国际化.js CHANGED
@@ -206,6 +206,21 @@ const translations = {
206
  // 设置页面
207
  'settings.title': '界面设置',
208
  'settings.general': '通用设置',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  'settings.display': '显示偏好',
210
  'settings.animation': '动画与音效',
211
  'settings.language': '界面语言',
@@ -478,7 +493,7 @@ const translations = {
478
  'wallet.withdraw.fill_account': '请完整填写收款人支付宝账号与真实姓名!',
479
  'wallet.withdraw.invalid_code': '请输入 6 位有效验证码!',
480
  'wallet.withdraw.code_sent': '验证码已发送,请查收邮箱 (注意检查垃圾箱)',
481
- 'wallet.withdraw.code_failed': '发送失败: ',
482
  'wallet.withdraw.success': '🎉 提现申请提交成功!预计 1-3 个工作日内打款至您的支付宝。',
483
  'wallet.withdraw.submit_failed': '提交失败: ',
484
  'wallet.withdraw.fee_free_quota': '🎉 您目前享有平台 100元 内免手续费优惠福利!',
@@ -562,6 +577,7 @@ const translations = {
562
  'social.tip_author': '赞赏作者',
563
  'social.comment_refresh_required': '评论操作已完成,请重新打开当前页面更新显示后再操作。',
564
  'social.comment_unconfirmed': '评论操作结果尚未确认,请重新打开当前页面核对后再操作。',
 
565
  'social.say_something': '说点什么...',
566
  'social.reply': '回复',
567
  'social.reply_to': '回复',
@@ -1156,6 +1172,7 @@ const translations = {
1156
  'prompt.text_placeholder': '输入完整提示词,发布后仅购买者可查看...',
1157
  'prompt.price_label': '定价',
1158
  'prompt.price_invalid': '请输入 0–{max} 的整数价格。',
 
1159
  'prompt.video_label': '示例视频',
1160
  'prompt.title_required': '请输入标题',
1161
  'prompt.content_required': '请输入介绍文案',
@@ -1277,6 +1294,10 @@ const translations = {
1277
  'image.load_failed': '图片加载失败',
1278
  'image.load_timeout': '加载超时',
1279
  'image.gallery': '效果展示图',
 
 
 
 
1280
  'image.viewer.fullscreen': '全屏播放',
1281
  'image.viewer.load_failed': '图像播放器加载失败,请刷新页面重试',
1282
  'image.viewer.close': '关闭 (ESC)',
@@ -1940,6 +1961,21 @@ const translations = {
1940
  // Settings
1941
  'settings.title': 'Settings',
1942
  'settings.general': 'General',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1943
  'settings.display': 'Display',
1944
  'settings.animation': 'Animation & Sound',
1945
  'settings.language': 'Language',
@@ -2212,7 +2248,7 @@ const translations = {
2212
  'wallet.withdraw.fill_account': 'Please fill in both Alipay account and real name!',
2213
  'wallet.withdraw.invalid_code': 'Please enter a valid 6-digit code!',
2214
  'wallet.withdraw.code_sent': 'Code sent! Please check your email (including spam folder)',
2215
- 'wallet.withdraw.code_failed': 'Send failed: ',
2216
  'wallet.withdraw.success': '🎉 Withdrawal submitted! Funds will be transferred within 1-3 business days.',
2217
  'wallet.withdraw.submit_failed': 'Submit failed: ',
2218
  'wallet.withdraw.fee_free_quota': '🎉 You enjoy fee-free withdrawal up to 100 CNY!',
@@ -2297,6 +2333,7 @@ const translations = {
2297
  'social.tip_author': 'Tip Author',
2298
  'social.comment_refresh_required': 'The comment action completed. Reopen this page to update the display before another action.',
2299
  'social.comment_unconfirmed': 'The comment result is unconfirmed. Reopen this page to check before another action.',
 
2300
  'social.say_something': 'Say something...',
2301
  'social.reply_to': 'Reply to',
2302
  'social.comment_deleted': 'This comment has been deleted',
@@ -2967,6 +3004,7 @@ const translations = {
2967
  'prompt.text_placeholder': 'Enter the full prompt, only visible to buyers after publishing...',
2968
  'prompt.price_label': 'Price',
2969
  'prompt.price_invalid': 'Enter a whole-number price from 0 to {max}.',
 
2970
  'prompt.video_label': 'Sample Video',
2971
  'prompt.title_required': 'Please enter a title',
2972
  'prompt.content_required': 'Please enter the intro',
@@ -3103,6 +3141,10 @@ const translations = {
3103
  'image.load_failed': 'Image load failed',
3104
  'image.load_timeout': 'Load timeout',
3105
  'image.gallery': 'Gallery',
 
 
 
 
3106
  'image.viewer.fullscreen': 'Open fullscreen viewer',
3107
  'image.viewer.load_failed': 'Could not load the image viewer. Refresh and try again.',
3108
  'image.viewer.close': 'Close (Esc)',
 
206
  // 设置页面
207
  'settings.title': '界面设置',
208
  'settings.general': '通用设置',
209
+ 'settings.shortcuts': '快捷键',
210
+ 'settings.keyboard_combinations': '启用插件组合键',
211
+ 'settings.keyboard_combinations_desc': '默认关闭,避免与 ComfyUI 冲突。开启后,仅在对应控件聚焦时生效。',
212
+ 'settings.shortcuts_controls': '控件之间移动',
213
+ 'settings.shortcuts_activate': '激活已聚焦的按钮',
214
+ 'settings.shortcuts_tabs': '聚焦榜单标签后切换标签',
215
+ 'settings.shortcuts_images': '聚焦发布图片后,在已有图片或新图片组内排序;需开启组合键',
216
+ 'settings.shortcuts_escape': '关闭当前媒体或弹窗,仅在其内部生效',
217
+ 'settings.shortcuts_scope': '基础键盘操作始终保留。插件不提供全局保存快捷键,不接管 ComfyUI 画布按键。',
218
+ 'settings.shortcuts_tab_keys': 'Tab / Shift+Tab',
219
+ 'settings.shortcuts_activate_keys': 'Enter / Space',
220
+ 'settings.shortcuts_image_keys': 'Alt+← / Alt+→',
221
+ 'settings.shortcuts_escape_key': 'Escape',
222
+ 'settings.shortcuts_arrows': '← / → / Home / End',
223
+ 'post.image_drag_hint': '可拖拽排序,首张图片为封面。',
224
  'settings.display': '显示偏好',
225
  'settings.animation': '动画与音效',
226
  'settings.language': '界面语言',
 
493
  'wallet.withdraw.fill_account': '请完整填写收款人支付宝账号与真实姓名!',
494
  'wallet.withdraw.invalid_code': '请输入 6 位有效验证码!',
495
  'wallet.withdraw.code_sent': '验证码已发送,请查收邮箱 (注意检查垃圾箱)',
496
+ 'wallet.withdraw.code_failed': '暂时无法确认验证码发送,请稍后重试。',
497
  'wallet.withdraw.success': '🎉 提现申请提交成功!预计 1-3 个工作日内打款至您的支付宝。',
498
  'wallet.withdraw.submit_failed': '提交失败: ',
499
  'wallet.withdraw.fee_free_quota': '🎉 您目前享有平台 100元 内免手续费优惠福利!',
 
577
  'social.tip_author': '赞赏作者',
578
  'social.comment_refresh_required': '评论操作已完成,请重新打开当前页面更新显示后再操作。',
579
  'social.comment_unconfirmed': '评论操作结果尚未确认,请重新打开当前页面核对后再操作。',
580
+ 'social.comment_pending': '评论结果待确认',
581
  'social.say_something': '说点什么...',
582
  'social.reply': '回复',
583
  'social.reply_to': '回复',
 
1172
  'prompt.text_placeholder': '输入完整提示词,发布后仅购买者可查看...',
1173
  'prompt.price_label': '定价',
1174
  'prompt.price_invalid': '请输入 0–{max} 的整数价格。',
1175
+ 'prompt.price_unknown': '价格暂时无法确认,请重新打开详情后重试。',
1176
  'prompt.video_label': '示例视频',
1177
  'prompt.title_required': '请输入标题',
1178
  'prompt.content_required': '请输入介绍文案',
 
1294
  'image.load_failed': '图片加载失败',
1295
  'image.load_timeout': '加载超时',
1296
  'image.gallery': '效果展示图',
1297
+ 'image.zoom_in': '放大图片',
1298
+ 'image.zoom_out': '缩小图片',
1299
+ 'image.pan_horizontal': '水平移动图片',
1300
+ 'image.pan_vertical': '垂直移动图片',
1301
  'image.viewer.fullscreen': '全屏播放',
1302
  'image.viewer.load_failed': '图像播放器加载失败,请刷新页面重试',
1303
  'image.viewer.close': '关闭 (ESC)',
 
1961
  // Settings
1962
  'settings.title': 'Settings',
1963
  'settings.general': 'General',
1964
+ 'settings.shortcuts': 'Keyboard shortcuts',
1965
+ 'settings.keyboard_combinations': 'Enable plugin key combinations',
1966
+ 'settings.keyboard_combinations_desc': 'Off by default to avoid ComfyUI conflicts. When enabled, combinations work only in the focused plugin control.',
1967
+ 'settings.shortcuts_controls': 'Move between controls',
1968
+ 'settings.shortcuts_activate': 'Activate the focused button',
1969
+ 'settings.shortcuts_tabs': 'Navigate focused ranking tabs',
1970
+ 'settings.shortcuts_images': 'Reorder focused publishing images within the existing or new image group; requires key combinations',
1971
+ 'settings.shortcuts_escape': 'Close the current media viewer or dialog, only while focus is inside it',
1972
+ 'settings.shortcuts_scope': 'Standard keyboard controls stay available. The plugin has no global save shortcut and does not capture ComfyUI canvas keys.',
1973
+ 'settings.shortcuts_tab_keys': 'Tab / Shift+Tab',
1974
+ 'settings.shortcuts_activate_keys': 'Enter / Space',
1975
+ 'settings.shortcuts_image_keys': 'Alt+← / Alt+→',
1976
+ 'settings.shortcuts_escape_key': 'Escape',
1977
+ 'settings.shortcuts_arrows': '← / → / Home / End',
1978
+ 'post.image_drag_hint': 'Drag to reorder. The first image is the cover.',
1979
  'settings.display': 'Display',
1980
  'settings.animation': 'Animation & Sound',
1981
  'settings.language': 'Language',
 
2248
  'wallet.withdraw.fill_account': 'Please fill in both Alipay account and real name!',
2249
  'wallet.withdraw.invalid_code': 'Please enter a valid 6-digit code!',
2250
  'wallet.withdraw.code_sent': 'Code sent! Please check your email (including spam folder)',
2251
+ 'wallet.withdraw.code_failed': 'Code delivery could not be confirmed. Try again later.',
2252
  'wallet.withdraw.success': '🎉 Withdrawal submitted! Funds will be transferred within 1-3 business days.',
2253
  'wallet.withdraw.submit_failed': 'Submit failed: ',
2254
  'wallet.withdraw.fee_free_quota': '🎉 You enjoy fee-free withdrawal up to 100 CNY!',
 
2333
  'social.tip_author': 'Tip Author',
2334
  'social.comment_refresh_required': 'The comment action completed. Reopen this page to update the display before another action.',
2335
  'social.comment_unconfirmed': 'The comment result is unconfirmed. Reopen this page to check before another action.',
2336
+ 'social.comment_pending': 'Comment result unconfirmed',
2337
  'social.say_something': 'Say something...',
2338
  'social.reply_to': 'Reply to',
2339
  'social.comment_deleted': 'This comment has been deleted',
 
3004
  'prompt.text_placeholder': 'Enter the full prompt, only visible to buyers after publishing...',
3005
  'prompt.price_label': 'Price',
3006
  'prompt.price_invalid': 'Enter a whole-number price from 0 to {max}.',
3007
+ 'prompt.price_unknown': 'The price is unavailable. Reopen the details and try again.',
3008
  'prompt.video_label': 'Sample Video',
3009
  'prompt.title_required': 'Please enter a title',
3010
  'prompt.content_required': 'Please enter the intro',
 
3141
  'image.load_failed': 'Image load failed',
3142
  'image.load_timeout': 'Load timeout',
3143
  'image.gallery': 'Gallery',
3144
+ 'image.zoom_in': 'Zoom in',
3145
+ 'image.zoom_out': 'Zoom out',
3146
+ 'image.pan_horizontal': 'Move image horizontally',
3147
+ 'image.pan_vertical': 'Move image vertically',
3148
  'image.viewer.fullscreen': 'Open fullscreen viewer',
3149
  'image.viewer.load_failed': 'Could not load the image viewer. Refresh and try again.',
3150
  'image.viewer.close': 'Close (Esc)',
前端页面/components/用户体验增强.js CHANGED
@@ -10,6 +10,8 @@
10
  // - 用户体验_国际化.js (i18n 翻译字典)
11
  // ==========================================
12
 
 
 
13
  import { t, initI18n } from './用户体验_国际化.js';
14
 
15
 
@@ -231,7 +233,7 @@ export function registerShortcut(key, handler, options = {}) {
231
  if (event.target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])')
232
  && event.key !== 'Escape') return;
233
  const combo = `${event.ctrlKey || event.metaKey ? 'Ctrl+' : ''}${event.altKey ? 'Alt+' : ''}${event.shiftKey ? 'Shift+' : ''}${event.key}`;
234
- if (combo !== key) return;
235
  if (preventDefault) event.preventDefault();
236
  event.stopPropagation();
237
  handler(event);
 
10
  // - 用户体验_国际化.js (i18n 翻译字典)
11
  // ==========================================
12
 
13
+ import { areKeyboardShortcutsEnabled } from '../core/全局配置.js';
14
+
15
  import { t, initI18n } from './用户体验_国际化.js';
16
 
17
 
 
233
  if (event.target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])')
234
  && event.key !== 'Escape') return;
235
  const combo = `${event.ctrlKey || event.metaKey ? 'Ctrl+' : ''}${event.altKey ? 'Alt+' : ''}${event.shiftKey ? 'Shift+' : ''}${event.key}`;
236
+ if (combo !== key || ((event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) && !areKeyboardShortcutsEnabled())) return;
237
  if (preventDefault) event.preventDefault();
238
  event.stopPropagation();
239
  handler(event);
前端页面/core/侧边栏主程序.js CHANGED
@@ -124,6 +124,16 @@ async function loadPublishFactory(kind) {
124
  throw new Error('Invalid publish view');
125
  }
126
 
 
 
 
 
 
 
 
 
 
 
127
  export function buildSidebarDOM() {
128
  // 🔧 P1修复:重建前先清理上一轮的 window 监听,避免重复注册
129
  _cleanupSidebarWindowListeners();
@@ -132,13 +142,8 @@ export function buildSidebarDOM() {
132
  container.className = "comfy-ranking-root";
133
 
134
  // 加载本地背景图
135
- const savedBg = BackgroundStore.load();
136
- const bgStyle = savedBg
137
- ? `background-image: url(${savedBg}); background-size: cover; background-position: center;`
138
- : `background-color: var(--comfy-menu-bg);`;
139
 
140
- container.style.cssText += bgStyle;
141
-
142
  const topNav = createTopNav();
143
  const sidebarInstanceId = `comfy-ranking-${++_sidebarInstanceSequence}`;
144
 
@@ -293,16 +298,7 @@ export function buildSidebarDOM() {
293
 
294
  // 监听从设置页面触发的背景更新事件
295
  _onSidebarWindow("comfy-sidebar-bg-update", () => {
296
- const newBg = BackgroundStore.load();
297
- if (newBg) {
298
- container.style.backgroundImage = `url(${newBg})`;
299
- container.style.backgroundSize = "cover";
300
- container.style.backgroundPosition = "center";
301
- container.style.backgroundColor = "transparent";
302
- } else {
303
- container.style.backgroundImage = "none";
304
- container.style.backgroundColor = "var(--comfy-menu-bg)";
305
- }
306
  });
307
  // =========================================================================
308
 
 
124
  throw new Error('Invalid publish view');
125
  }
126
 
127
+ function _applySidebarBackground(container, value) {
128
+ const valid = typeof value === "string" && value.trim() && !/[\u0000-\u001f\u007f]/.test(value);
129
+ container.style.backgroundImage = valid ? `url(${JSON.stringify(value)})` : "none";
130
+ container.style.backgroundColor = valid ? "transparent" : "var(--comfy-menu-bg)";
131
+ if (valid) {
132
+ container.style.backgroundSize = "cover";
133
+ container.style.backgroundPosition = "center";
134
+ }
135
+ }
136
+
137
  export function buildSidebarDOM() {
138
  // 🔧 P1修复:重建前先清理上一轮的 window 监听,避免重复注册
139
  _cleanupSidebarWindowListeners();
 
142
  container.className = "comfy-ranking-root";
143
 
144
  // 加载本地背景图
145
+ _applySidebarBackground(container, BackgroundStore.load());
 
 
 
146
 
 
 
147
  const topNav = createTopNav();
148
  const sidebarInstanceId = `comfy-ranking-${++_sidebarInstanceSequence}`;
149
 
 
298
 
299
  // 监听从设置页面触发的背景更新事件
300
  _onSidebarWindow("comfy-sidebar-bg-update", () => {
301
+ _applySidebarBackground(container, BackgroundStore.load());
 
 
 
 
 
 
 
 
 
302
  });
303
  // =========================================================================
304
 
前端页面/core/全局配置.js CHANGED
@@ -104,6 +104,13 @@ export const API = {
104
 
105
 
106
  // 发布视频的本地读取预算,不改变云端上传额度。
 
 
 
 
 
 
 
107
  export const PUBLISH_MEDIA_LIMITS = Object.freeze({
108
  MAX_IMAGES: 9,
109
  IMAGE_MAX_EDGE: 1920,
@@ -809,6 +816,7 @@ const SETTINGS_KEY = "ComfyCommunity_Settings";
809
 
810
  // 🔧 默认设置值
811
  const DEFAULT_SETTINGS = {
 
812
  showCreatorBanner: true, // 创作者卡片显示背景图
813
  enableAnimations: true, // ✨ 榜单切换动画
814
  enableSoundEffects: true, // 🔊 动画音效
@@ -822,23 +830,30 @@ const DEFAULT_SETTINGS = {
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
  }
 
104
 
105
 
106
  // 发布视频的本地读取预算,不改变云端上传额度。
107
+ export const LAZY_IMAGES = Object.freeze({
108
+ ROOT_MARGIN: "200px 0px",
109
+ THRESHOLD: 0.01,
110
+ MOUNT_TIMEOUT: 30000,
111
+ READ_TIMEOUT: 30000
112
+ });
113
+
114
  export const PUBLISH_MEDIA_LIMITS = Object.freeze({
115
  MAX_IMAGES: 9,
116
  IMAGE_MAX_EDGE: 1920,
 
816
 
817
  // 🔧 默认设置值
818
  const DEFAULT_SETTINGS = {
819
+ enableKeyboardShortcuts: false,
820
  showCreatorBanner: true, // 创作者卡片显示背景图
821
  enableAnimations: true, // ✨ 榜单切换动画
822
  enableSoundEffects: true, // 🔊 动画音效
 
830
  /**
831
  * 📦 获取当前设置
832
  */
833
+ export function getSettings(options = {}) {
834
  try {
835
  const saved = localStorage.getItem(SETTINGS_KEY);
836
  if (saved) {
837
+ const value = JSON.parse(saved);
838
+ if (options.strict && (!value || typeof value !== 'object' || Array.isArray(value))) throw new Error('Invalid settings');
839
+ return { ...DEFAULT_SETTINGS, ...value };
840
  }
841
+ } catch (e) { if (options.strict) throw e; }
842
  return { ...DEFAULT_SETTINGS };
843
  }
844
 
845
  /**
846
  * 💾 保存设置
847
  */
848
+ export function areKeyboardShortcutsEnabled() {
849
+ return getSettings().enableKeyboardShortcuts === true;
850
+ }
851
+
852
  export function saveSettings(settings) {
853
  try {
854
  localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
855
  // 🔔 广播设置变更事件,让其他组件响应
856
  window.dispatchEvent(new CustomEvent("comfy-settings-changed", { detail: settings }));
857
+ return true;
858
+ } catch (e) { return false; }
859
  }
前端页面/core/网络请求_缓存管理.js CHANGED
@@ -300,9 +300,9 @@ export function getCacheKeysByPrefix(prefix) {
300
  function _touchStorageEntry(key, cached) {
301
  if (memoryOnlyEntries.has(cached)) return;
302
  try {
303
- if (localStorage.getItem(key) === null) return;
304
  const now = Date.now();
305
  if (Number.isFinite(cached.lastAccessAt) && now - cached.lastAccessAt < 60_000) return;
 
306
  cached.lastAccessAt = now;
307
  localStorage.setItem(key, JSON.stringify(cached));
308
  } catch {}
@@ -394,7 +394,12 @@ export function removeCache(key) {
394
  */
395
  export function removeCacheByPrefix(prefix) {
396
  if (!prefix) return 0;
397
- const candidates = prefix.startsWith(CACHE_PREFIX) ? [prefix] : [_fullKey(prefix), prefix];
 
 
 
 
 
398
  const isMatch = (key) => candidates.some(p => key.startsWith(p));
399
  const removed = new Set();
400
 
@@ -474,8 +479,11 @@ function _cleanExpiredStorage(aggressive = false) {
474
  // 激进模式:清除所有缓存项
475
  keysToRemove.push(key);
476
  } else {
 
 
 
477
  try {
478
- const cached = JSON.parse(localStorage.getItem(key));
479
  if (cached.expireAt < now) {
480
  keysToRemove.push(key);
481
  }
@@ -496,24 +504,29 @@ function _cleanExpiredStorage(aggressive = false) {
496
  function _evictOldestStorage() {
497
  try {
498
  const entries = [];
 
499
  for (let i = 0; i < localStorage.length; i++) {
500
  const key = localStorage.key(i);
501
  if (_isManagedStorageKey(key)) {
 
 
 
502
  try {
503
- const cached = JSON.parse(localStorage.getItem(key));
504
  if (!_isValidCacheEntry(cached)) {
505
- localStorage.removeItem(key);
506
  continue;
507
  }
508
  const writtenAt = Number.isFinite(cached.expireAt) ? cached.expireAt - DEFAULT_TTL : 0;
509
  entries.push({ key, lastAccessAt: Number.isFinite(cached.lastAccessAt) ? cached.lastAccessAt : writtenAt });
510
  } catch {
511
- localStorage.removeItem(key);
512
  }
513
  }
514
  }
515
 
516
- if (entries.length === 0) return false;
 
517
 
518
  entries.sort((a, b) => a.lastAccessAt - b.lastAccessAt);
519
 
@@ -702,11 +715,8 @@ export function invalidateRelatedCache(endpoint, method = null) {
702
  // 🔧 同时修复:原实现无条件给 pattern 补 CACHE.PREFIX,使上方映射表中的裸键前缀
703
  // (ComfyCommunity_ProfileCache_,SWR 头像缓存直接写 localStorage、不带前缀)被拼成
704
  // ComfyRanking_ComfyCommunity_ProfileCache_,永远匹配不到,改头像/昵称后该缓存清不掉。
705
- // 现统一走 removeCacheByPrefix:两级缓存一起清,且兼容带前缀与裸键两种键空间。
706
- let cleared = 0;
707
- for (const pattern of patterns) {
708
- cleared += removeCacheByPrefix(pattern);
709
- }
710
 
711
  if (cleared > 0) {
712
  console.log(`🗑️ 精确清除缓存: ${cleared} 个项`);
 
300
  function _touchStorageEntry(key, cached) {
301
  if (memoryOnlyEntries.has(cached)) return;
302
  try {
 
303
  const now = Date.now();
304
  if (Number.isFinite(cached.lastAccessAt) && now - cached.lastAccessAt < 60_000) return;
305
+ if (localStorage.getItem(key) === null) return;
306
  cached.lastAccessAt = now;
307
  localStorage.setItem(key, JSON.stringify(cached));
308
  } catch {}
 
394
  */
395
  export function removeCacheByPrefix(prefix) {
396
  if (!prefix) return 0;
397
+ return _removeCacheByPrefixes([prefix]);
398
+ }
399
+
400
+ function _removeCacheByPrefixes(prefixes) {
401
+ const candidates = [...new Set(prefixes.flatMap(prefix =>
402
+ prefix.startsWith(CACHE_PREFIX) ? [prefix] : [_fullKey(prefix), prefix]))];
403
  const isMatch = (key) => candidates.some(p => key.startsWith(p));
404
  const removed = new Set();
405
 
 
479
  // 激进模式:清除所有缓存项
480
  keysToRemove.push(key);
481
  } else {
482
+ let stored;
483
+ try { stored = localStorage.getItem(key); } catch { continue; }
484
+ if (stored === null) continue;
485
  try {
486
+ const cached = JSON.parse(stored);
487
  if (cached.expireAt < now) {
488
  keysToRemove.push(key);
489
  }
 
504
  function _evictOldestStorage() {
505
  try {
506
  const entries = [];
507
+ const invalidKeys = [];
508
  for (let i = 0; i < localStorage.length; i++) {
509
  const key = localStorage.key(i);
510
  if (_isManagedStorageKey(key)) {
511
+ let stored;
512
+ try { stored = localStorage.getItem(key); } catch { continue; }
513
+ if (stored === null) continue;
514
  try {
515
+ const cached = JSON.parse(stored);
516
  if (!_isValidCacheEntry(cached)) {
517
+ invalidKeys.push(key);
518
  continue;
519
  }
520
  const writtenAt = Number.isFinite(cached.expireAt) ? cached.expireAt - DEFAULT_TTL : 0;
521
  entries.push({ key, lastAccessAt: Number.isFinite(cached.lastAccessAt) ? cached.lastAccessAt : writtenAt });
522
  } catch {
523
+ invalidKeys.push(key);
524
  }
525
  }
526
  }
527
 
528
+ invalidKeys.forEach(key => localStorage.removeItem(key));
529
+ if (entries.length === 0) return invalidKeys.length > 0;
530
 
531
  entries.sort((a, b) => a.lastAccessAt - b.lastAccessAt);
532
 
 
715
  // 🔧 同时修复:原实现无条件给 pattern 补 CACHE.PREFIX,使上方映射表中的裸键前缀
716
  // (ComfyCommunity_ProfileCache_,SWR 头像缓存直接写 localStorage、不带前缀)被拼成
717
  // ComfyRanking_ComfyCommunity_ProfileCache_,永远匹配不到,改头像/昵称后该缓存清不掉。
718
+ // 复用两级前缀删除逻辑;同次失效合并为一次存储遍历,兼容带前缀与裸键。
719
+ const cleared = _removeCacheByPrefixes(patterns);
 
 
 
720
 
721
  if (cleared > 0) {
722
  console.log(`🗑️ 精确清除缓存: ${cleared} 个项`);
前端页面/market/列表卡片组件.js CHANGED
@@ -4,7 +4,7 @@ import { api, captureRequestSession } from "../core/网络请求API.js";
4
  import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
5
  import { recordView, escapeHtml } from "../components/互动工具函数.js"; // 🧹 P2归一:escapeHtml 局部副本已移除
6
  import { renderItemTrendChart } from "../components/图表渲染组件.js";
7
- import { getCoverSandboxHTML, setupImageSandboxEvents } from "../components/图片沙盒组件.js";
8
  import { setupResourceInstall, checkItemStatus } from "./资源安装引擎.js";
9
  import { showToast } from "../components/UI交互提示组件.js";
10
  import { openTipModal } from "../profile/个人中心_赞赏组件.js";
@@ -368,7 +368,10 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
368
  let commentUI = null;
369
  const toggleButton = summaryView.querySelector('.card-summary__toggle');
370
  const setExpanded = (expanded) => {
371
- if (expanded) mountComments();
 
 
 
372
  commentUI?._setActive?.(expanded);
373
  if (!expanded && detailView.contains(document.activeElement)) toggleButton.focus();
374
  detailView.hidden = !expanded;
@@ -455,7 +458,7 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
455
  // 🖼️ 启用图片懒加载(跳过 data URL 和已处理的)
456
  lazyLoadImages(card);
457
 
458
- card._cleanup = () => commentUI?._cleanup?.();
459
  card.appendChild(summaryView);
460
  card.appendChild(detailView);
461
  return card;
 
4
  import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
5
  import { recordView, escapeHtml } from "../components/互动工具函数.js"; // 🧹 P2归一:escapeHtml 局部副本已移除
6
  import { renderItemTrendChart } from "../components/图表渲染组件.js";
7
+ import { getCoverSandboxHTML, setupImageSandboxEvents, cleanupImageSandbox } from "../components/图片沙盒组件.js";
8
  import { setupResourceInstall, checkItemStatus } from "./资源安装引擎.js";
9
  import { showToast } from "../components/UI交互提示组件.js";
10
  import { openTipModal } from "../profile/个人中心_赞赏组件.js";
 
368
  let commentUI = null;
369
  const toggleButton = summaryView.querySelector('.card-summary__toggle');
370
  const setExpanded = (expanded) => {
371
+ if (expanded) {
372
+ mountComments();
373
+ if (isRendered) setupImageSandboxEvents(detailView);
374
+ } else cleanupImageSandbox(detailView);
375
  commentUI?._setActive?.(expanded);
376
  if (!expanded && detailView.contains(document.activeElement)) toggleButton.focus();
377
  detailView.hidden = !expanded;
 
458
  // 🖼️ 启用图片懒加载(跳过 data URL 和已处理的)
459
  lazyLoadImages(card);
460
 
461
+ card._cleanup = () => { cleanupImageSandbox(detailView); commentUI?._cleanup?.(); };
462
  card.appendChild(summaryView);
463
  card.appendChild(detailView);
464
  return card;
前端页面/market/创作者卡片组件.js CHANGED
@@ -300,15 +300,12 @@ export function createCreatorCard(creatorData, currentUser = null) {
300
  const cacheKey = getBannerCacheKey(creatorData.account);
301
  let cachedBanner = null;
302
  try { cachedBanner = localStorage.getItem(cacheKey); } catch (_) { /* 可选装饰缓存不可用时继续构建卡片。 */ }
303
- const validBanner = value => typeof value === 'string' && value.trim() ? value : '';
304
  const bannerUrl = validBanner(cachedBanner) || validBanner(creatorData.bannerUrl);
305
  const hasBanner = !!(showBanner && bannerUrl); // 有效本地缓存优先,受显示设置控制
306
 
307
- // 🖼️ 全覆盖背景图样式
308
- // 在使用 bannerUrl 之前添加安全过滤
309
- const safeBannerUrl = (bannerUrl || '').replace(/['"()\\]/g, '');
310
  summaryView.classList.toggle('creator-card__summary--banner', !!hasBanner);
311
- if (hasBanner) summaryView.style.backgroundImage = `url("${safeBannerUrl}")`;
312
  const profileName = typeof creatorData.name === 'string' && creatorData.name.trim() ? creatorData.name : creatorData.account;
313
  const profileLabel = `${t('creator.visit_profile')}: ${profileName}`;
314
  const stat = (field, label, value, unit = '') => `<div class="creator-card__stat" data-stat="${field}"><span>${escapeHtml(label)}</span><strong class="creator-card__stat-value"><span class="${/^\d+$/.test(String(value)) ? 'creator-card__number' : ''}">${escapeHtml(value)}</span>${unit ? ` <small>${escapeHtml(unit)}</small>` : ''}</strong></div>`;
 
300
  const cacheKey = getBannerCacheKey(creatorData.account);
301
  let cachedBanner = null;
302
  try { cachedBanner = localStorage.getItem(cacheKey); } catch (_) { /* 可选装饰缓存不可用时继续构建卡片。 */ }
303
+ const validBanner = value => typeof value === 'string' && value.trim() && !/[\u0000-\u001f\u007f]/.test(value) ? value : '';
304
  const bannerUrl = validBanner(cachedBanner) || validBanner(creatorData.bannerUrl);
305
  const hasBanner = !!(showBanner && bannerUrl); // 有效本地缓存优先,受显示设置控制
306
 
 
 
 
307
  summaryView.classList.toggle('creator-card__summary--banner', !!hasBanner);
308
+ if (hasBanner) summaryView.style.backgroundImage = `url(${JSON.stringify(bannerUrl)})`;
309
  const profileName = typeof creatorData.name === 'string' && creatorData.name.trim() ? creatorData.name : creatorData.account;
310
  const profileLabel = `${t('creator.visit_profile')}: ${profileName}`;
311
  const stat = (field, label, value, unit = '') => `<div class="creator-card__stat" data-stat="${field}"><span>${escapeHtml(label)}</span><strong class="creator-card__stat-value"><span class="${/^\d+$/.test(String(value)) ? 'creator-card__number' : ''}">${escapeHtml(value)}</span>${unit ? ` <small>${escapeHtml(unit)}</small>` : ''}</strong></div>`;
前端页面/market/资金与钱包_充值组件.js CHANGED
@@ -26,8 +26,6 @@ function _setButtonLoading(btn, isLoading, loadingText, normalText) {
26
  btn.disabled = isLoading;
27
  btn.setAttribute('aria-busy', String(isLoading));
28
  btn.innerText = isLoading ? loadingText : normalText;
29
- btn.style.background = isLoading ? "#888" : "#4CAF50";
30
- btn.style.cursor = isLoading ? "not-allowed" : "pointer";
31
  }
32
 
33
  export async function openRechargeModal(currentUser, onBalanceChange) {
@@ -57,7 +55,7 @@ export async function openRechargeModal(currentUser, onBalanceChange) {
57
  if (Number.isSafeInteger(wallet.balance)) balanceText = String(wallet.balance);
58
  } catch (_) { if (!scope.isCurrent()) return; }
59
 
60
- container.style.color = "#ccc";
61
 
62
  const rechargeOptions = [
63
  { points: 10, price: 10, isCustom: false },
@@ -70,58 +68,58 @@ export async function openRechargeModal(currentUser, onBalanceChange) {
70
  let paymentMethod = "alipay";
71
 
72
  function _rechargeOptionWrapper(isSelected, index, innerHtml) {
73
- return `<button type="button" aria-pressed="${isSelected}" class="recharge-opt ${isSelected ? 'selected' : ''}" data-index="${index}" style="flex: 1; min-width: 45%; padding: 15px; background: ${isSelected ? 'rgba(76, 175, 80, 0.2)' : 'var(--comfy-input-bg)'}; border: 2px solid ${isSelected ? '#4CAF50' : '#444'}; border-radius: 8px; cursor: pointer; text-align: center; transition: 0.2s;">${innerHtml}</button>`;
74
  }
75
 
76
  const renderOptions = () => rechargeOptions.map((opt, index) => {
77
  const isSelected = selectedOption === opt;
78
  if (opt.isCustom) {
79
- const inner = `<div style="font-size: 18px; font-weight: bold; color: ${isSelected ? '#4CAF50' : '#fff'}; margin-bottom: 5px; line-height: 22px;">${opt.label}</div><div style="font-size: 12px; color: #888;">${t('wallet.recharge.any_amount')}</div>`;
80
  return _rechargeOptionWrapper(isSelected, index, inner);
81
  } else {
82
- const inner = `<div style="font-size: 18px; font-weight: bold; color: ${isSelected ? '#4CAF50' : '#fff'}; margin-bottom: 5px;">${opt.points} ${t('wallet.recharge.points_suffix')}</div><div style="font-size: 12px; color: #888;">${t('wallet.recharge.price_prefix')}${opt.price.toFixed(2)}</div>`;
83
  return _rechargeOptionWrapper(isSelected, index, inner);
84
  }
85
  }).join("");
86
 
87
  container.innerHTML = `
88
- <div style="margin-bottom: 20px; text-align: center;">
89
- <div style="font-size: 14px; color: #aaa;">${t('wallet.recharge.current_balance')}</div>
90
- <div style="font-size: 28px; font-weight: bold; color: #FF9800; margin-top: 5px;">${balanceText} <span style="font-size: 14px;">${t('wallet.recharge.points_suffix')}</span></div>
91
  </div>
92
 
93
- <div style="margin-bottom: 15px;">
94
- <label style="display: block; margin-bottom: 8px; font-weight: bold; color: #eee;">${t('wallet.recharge.select_amount')} <span style="color: #888; font-weight: normal;">${t('wallet.recharge.exchange_rate')}</span></label>
95
- <div id="recharge-grid" style="display: flex; flex-wrap: wrap; gap: 10px;">
96
  ${renderOptions()}
97
  </div>
98
- <div id="custom-input-container" style="display: none; margin-top: 10px;">
99
- <input type="number" id="custom-amount" placeholder="${t('wallet.recharge.custom_placeholder')}" min="1" max="10000" step="1" style="width: 100%; padding: 10px; background: #222; border: 1px solid #4CAF50; color: #fff; border-radius: 4px; box-sizing: border-box; text-align: center; font-size: 16px; font-weight: bold; outline: none;">
100
  </div>
101
  </div>
102
 
103
- <div style="margin-bottom: 20px;">
104
- <label style="display: block; margin-bottom: 8px; font-weight: bold; color: #eee;">${t('wallet.recharge.payment_method')}</label>
105
- <div style="display: flex; gap: 15px;">
106
- <label style="display: flex; align-items: center; gap: 5px; cursor: pointer;">
107
  <input type="radio" name="pay_method" value="alipay" checked>
108
- <span style="color: #00A1E9; font-weight: bold;">${t('wallet.recharge.alipay')}</span>
109
  </label>
110
- <label style="display: flex; align-items: center; gap: 5px; cursor: pointer;">
111
  <input type="radio" name="pay_method" value="wechat" disabled>
112
- <span style="color: #888; font-weight: bold;">${t('wallet.recharge.wechat')}</span>
113
  </label>
114
  </div>
115
  </div>
116
 
117
- <div id="qr-container" style="display: none; text-align: center; margin-bottom: 20px; padding: 20px; background: #fff; border-radius: 8px;">
118
- <div id="qr-loading" style="color: #666; font-size: 14px; margin-bottom: 10px;">${t('wallet.recharge.connecting')}</div>
119
- <img id="qr-image" style="width: 180px; height: 180px; display: none; margin: 0 auto;">
120
- <div style="color: #333; font-size: 13px; margin-top: 10px; font-weight: bold;">${t('wallet.recharge.scan_to_pay')} <span id="pay-price-text" style="color: #F44336;"></span> ${t('wallet.recharge.yuan')}</div>
121
- <div style="color: #888; font-size: 11px; margin-top: 5px;">${t('wallet.recharge.auto_redirect')}</div>
122
  </div>
123
 
124
- <button id="btn-create-order" style="width: 100%; padding: 12px; background: #4CAF50; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 15px; box-shadow: 0 4px 10px rgba(76, 175, 80, 0.3);">${t('wallet.recharge.get_qr')}</button>
125
  `;
126
 
127
  const customInputContainer = container.querySelector("#custom-input-container");
@@ -131,6 +129,8 @@ export async function openRechargeModal(currentUser, onBalanceChange) {
131
  container.querySelectorAll('.recharge-opt').forEach(el => {
132
  el.onclick = () => {
133
  if (!scope.isCurrent() || submitting) return;
 
 
134
  selectedOption = rechargeOptions[parseInt(el.getAttribute('data-index'))];
135
  container.querySelector('#recharge-grid').innerHTML = renderOptions();
136
 
@@ -141,7 +141,13 @@ export async function openRechargeModal(currentUser, onBalanceChange) {
141
  customInputContainer.style.display = "none";
142
  customAmountInput.value = "";
143
  }
144
- bindGridEvents();
 
 
 
 
 
 
145
  };
146
  });
147
  };
 
26
  btn.disabled = isLoading;
27
  btn.setAttribute('aria-busy', String(isLoading));
28
  btn.innerText = isLoading ? loadingText : normalText;
 
 
29
  }
30
 
31
  export async function openRechargeModal(currentUser, onBalanceChange) {
 
55
  if (Number.isSafeInteger(wallet.balance)) balanceText = String(wallet.balance);
56
  } catch (_) { if (!scope.isCurrent()) return; }
57
 
58
+ container.className = "wallet-modal wallet-modal--recharge";
59
 
60
  const rechargeOptions = [
61
  { points: 10, price: 10, isCustom: false },
 
68
  let paymentMethod = "alipay";
69
 
70
  function _rechargeOptionWrapper(isSelected, index, innerHtml) {
71
+ return `<button type="button" aria-pressed="${isSelected}" class="recharge-opt ${isSelected ? 'selected' : ''}" data-index="${index}">${innerHtml}</button>`;
72
  }
73
 
74
  const renderOptions = () => rechargeOptions.map((opt, index) => {
75
  const isSelected = selectedOption === opt;
76
  if (opt.isCustom) {
77
+ const inner = `<div class="wallet-recharge__option-title">${opt.label}</div><div class="wallet-modal__hint">${t('wallet.recharge.any_amount')}</div>`;
78
  return _rechargeOptionWrapper(isSelected, index, inner);
79
  } else {
80
+ const inner = `<div class="wallet-recharge__option-title">${opt.points} ${t('wallet.recharge.points_suffix')}</div><div class="wallet-modal__hint">${t('wallet.recharge.price_prefix')}${opt.price.toFixed(2)}</div>`;
81
  return _rechargeOptionWrapper(isSelected, index, inner);
82
  }
83
  }).join("");
84
 
85
  container.innerHTML = `
86
+ <div class="wallet-recharge__balance">
87
+ <div class="wallet-modal__muted">${t('wallet.recharge.current_balance')}</div>
88
+ <div class="wallet-recharge__balance-value">${balanceText} <span class="wallet-recharge__balance-unit">${t('wallet.recharge.points_suffix')}</span></div>
89
  </div>
90
 
91
+ <div class="wallet-modal__field">
92
+ <label for="custom-amount" class="wallet-modal__label">${t('wallet.recharge.select_amount')} <span class="wallet-modal__muted">${t('wallet.recharge.exchange_rate')}</span></label>
93
+ <div id="recharge-grid" class="wallet-recharge__grid">
94
  ${renderOptions()}
95
  </div>
96
+ <div id="custom-input-container" class="wallet-recharge__custom" style="display: none;">
97
+ <input type="number" id="custom-amount" placeholder="${t('wallet.recharge.custom_placeholder')}" min="1" max="10000" step="1" class="wallet-modal__input wallet-recharge__custom-input">
98
  </div>
99
  </div>
100
 
101
+ <div class="wallet-modal__field wallet-modal__field--last">
102
+ <div class="wallet-modal__label">${t('wallet.recharge.payment_method')}</div>
103
+ <div class="wallet-recharge__methods">
104
+ <label class="wallet-recharge__method">
105
  <input type="radio" name="pay_method" value="alipay" checked>
106
+ <span class="wallet-recharge__method-name">${t('wallet.recharge.alipay')}</span>
107
  </label>
108
+ <label class="wallet-recharge__method">
109
  <input type="radio" name="pay_method" value="wechat" disabled>
110
+ <span class="wallet-modal__muted">${t('wallet.recharge.wechat')}</span>
111
  </label>
112
  </div>
113
  </div>
114
 
115
+ <div id="qr-container" class="wallet-recharge__qr" style="display: none;">
116
+ <div id="qr-loading" class="wallet-recharge__qr-status">${t('wallet.recharge.connecting')}</div>
117
+ <img id="qr-image" class="wallet-recharge__qr-image" style="display: none;">
118
+ <div class="wallet-recharge__pay-label">${t('wallet.recharge.scan_to_pay')} <span id="pay-price-text" class="wallet-modal__danger"></span> ${t('wallet.recharge.yuan')}</div>
119
+ <div class="wallet-recharge__qr-hint">${t('wallet.recharge.auto_redirect')}</div>
120
  </div>
121
 
122
+ <button type="button" id="btn-create-order" class="wallet-modal__button wallet-modal__button--primary">${t('wallet.recharge.get_qr')}</button>
123
  `;
124
 
125
  const customInputContainer = container.querySelector("#custom-input-container");
 
129
  container.querySelectorAll('.recharge-opt').forEach(el => {
130
  el.onclick = () => {
131
  if (!scope.isCurrent() || submitting) return;
132
+ const restoreOptionFocus = document.activeElement === el;
133
+ const optionIndex = el.getAttribute('data-index');
134
  selectedOption = rechargeOptions[parseInt(el.getAttribute('data-index'))];
135
  container.querySelector('#recharge-grid').innerHTML = renderOptions();
136
 
 
141
  customInputContainer.style.display = "none";
142
  customAmountInput.value = "";
143
  }
144
+ bindGridEvents();
145
+ if (restoreOptionFocus && !selectedOption.isCustom && scope.isCurrent() && container.isConnected &&
146
+ document.activeElement === document.body) {
147
+ const replacement = Array.from(container.querySelectorAll('.recharge-opt'))
148
+ .find(button => button.getAttribute('data-index') === optionIndex);
149
+ replacement?.focus({ preventScroll: true });
150
+ }
151
  };
152
  });
153
  };
前端页面/market/资金与钱包_提现组件.js CHANGED
@@ -20,7 +20,6 @@ let withdrawalSequence = 0;
20
  function _setButtonState(btn, disabled, bgColor, text) {
21
  btn.disabled = disabled;
22
  btn.setAttribute('aria-busy', String(disabled));
23
- btn.style.background = bgColor;
24
  btn.innerText = text;
25
  }
26
 
@@ -85,48 +84,48 @@ export async function openWithdrawModal(currentUser, onSuccess) {
85
  showToast(t('wallet.withdraw.no_balance'), "warning"); scope.dispose(); return;
86
  }
87
 
88
- container.style.color = "#ccc";
89
 
90
  container.innerHTML = `
91
- <div style="margin-bottom: 20px; background: rgba(33, 150, 243, 0.1); border: 1px dashed #2196F3; padding: 15px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center;">
92
  <div>
93
- <div style="font-size: 12px; color: #2196F3; margin-bottom: 5px;">${t('wallet.withdraw.total_withdrawable')}</div>
94
- <div style="font-size: 24px; font-weight: bold; color: #fff;">${maxWithdraw}</div>
95
  </div>
96
- <div style="text-align: right;">
97
- <div style="font-size: 12px; color: #888; margin-bottom: 5px;">${t('wallet.withdraw.rmb_equivalent')}</div>
98
- <div style="font-size: 16px; font-weight: bold; color: #4CAF50;">≈ ${t('wallet.recharge.price_prefix')} ${maxWithdraw.toFixed(2)}</div>
99
  </div>
100
  </div>
101
 
102
- <div style="margin-bottom: 15px;">
103
- <label style="display:block; margin-bottom:5px; color:#ccc;">${t('wallet.withdraw.amount_label')} <span style="color:#F44336">*</span></label>
104
- <input type="number" id="withdraw-amount" placeholder="${t('wallet.withdraw.amount_placeholder')}" max="${maxWithdraw}" min="1" step="1" style="width:100%; padding:10px; background:var(--comfy-input-bg); border:1px solid #555; color:#fff; border-radius:4px; box-sizing:border-box;">
105
 
106
- <div id="fee-calc-box" style="margin-top: 10px; padding: 10px; background: rgba(255,152,0,0.1); border: 1px dashed #FF9800; border-radius: 4px; font-size: 12px; color: #FF9800; display:none; line-height: 1.5;">
107
  <div id="fee-text"></div>
108
  </div>
109
  </div>
110
 
111
- <div style="margin-bottom: 15px;">
112
- <label style="display:block; margin-bottom:5px; color:#ccc;">${t('wallet.withdraw.alipay_account')} <span style="color:#F44336">*</span></label>
113
- <input type="text" id="withdraw-account" placeholder="${t('wallet.withdraw.alipay_placeholder')}" style="width:100%; padding:10px; background:var(--comfy-input-bg); border:1px solid #555; color:#fff; border-radius:4px; box-sizing:border-box;">
114
  </div>
115
 
116
- <div style="margin-bottom: 15px;">
117
- <label style="display:block; margin-bottom:5px; color:#ccc;">${t('wallet.withdraw.real_name')} <span style="color:#F44336">*</span></label>
118
- <input type="text" id="withdraw-name" placeholder="${t('wallet.withdraw.real_name_placeholder')}" style="width:100%; padding:10px; background:var(--comfy-input-bg); border:1px solid #555; color:#fff; border-radius:4px; box-sizing:border-box;">
119
  </div>
120
 
121
- <div style="margin-bottom: 20px;">
122
- <label style="display:block; margin-bottom:5px; color:#ccc;">${t('wallet.withdraw.security_code')} <span style="color:#F44336">*</span></label>
123
- <div style="display:flex; gap:10px;">
124
- <input type="text" id="withdraw-code" placeholder="${t('wallet.withdraw.code_placeholder')}" maxlength="6" style="flex:1; padding:10px; background:var(--comfy-input-bg); border:1px solid #555; color:#fff; border-radius:4px; box-sizing:border-box;">
125
- <button id="btn-send-code" style="padding:0 15px; background:#2196F3; color:white; border:none; border-radius:4px; cursor:pointer; font-weight:bold; white-space:nowrap; transition:0.2s;">${t('wallet.withdraw.get_code')}</button>
126
  </div>
127
  </div>
128
 
129
- <button id="btn-submit-withdraw" style="width:100%; padding:12px; background:#4CAF50; color:white; border:none; border-radius:4px; cursor:pointer; font-weight:bold; font-size:15px; transition:0.2s;" >${t('wallet.withdraw.confirm')}</button>
130
  `;
131
 
132
  // 🚀 核心逻辑:动态计算提现手续费
@@ -151,9 +150,9 @@ export async function openWithdrawModal(currentUser, onSuccess) {
151
 
152
  feeBox.style.display = "block";
153
  if (fee > 0) {
154
- feeText.innerHTML = `${t('wallet.withdraw.fee_quota_exhausted')}<br>${t('wallet.withdraw.fee_deducted')}<strong style="color:#F44336;">${fee} ${t('wallet.recharge.points_suffix')}</strong><br>${t('wallet.withdraw.fee_actual')}<strong style="color:#4CAF50;">${(val - fee).toFixed(2)} ${t('wallet.withdraw.yuan')}</strong>`;
155
  } else {
156
- feeText.innerHTML = `${t('wallet.withdraw.fee_free_quota')}<br>${t('wallet.withdraw.fee_full_amount')}<strong style="color:#4CAF50;">${val.toFixed(2)} ${t('wallet.withdraw.yuan')}</strong>`;
157
  }
158
  };
159
 
@@ -162,8 +161,20 @@ export async function openWithdrawModal(currentUser, onSuccess) {
162
  if (!scope.isCurrent() || btnSendCode.disabled) return;
163
  _setButtonState(btnSendCode, true, "#555", t('wallet.withdraw.sending'));
164
  try {
165
- await api.sendVerifyCode(currentUser.email, "email", "withdraw", currentUser.account);
 
166
  if (!scope.isCurrent()) return;
 
 
 
 
 
 
 
 
 
 
 
167
  showToast(t('wallet.withdraw.code_sent'), "success");
168
  let count = 60;
169
  countdown = setInterval(() => {
 
20
  function _setButtonState(btn, disabled, bgColor, text) {
21
  btn.disabled = disabled;
22
  btn.setAttribute('aria-busy', String(disabled));
 
23
  btn.innerText = text;
24
  }
25
 
 
84
  showToast(t('wallet.withdraw.no_balance'), "warning"); scope.dispose(); return;
85
  }
86
 
87
+ container.className = "wallet-modal wallet-modal--withdraw";
88
 
89
  container.innerHTML = `
90
+ <div class="wallet-withdraw__summary">
91
  <div>
92
+ <div class="wallet-withdraw__summary-label">${t('wallet.withdraw.total_withdrawable')}</div>
93
+ <div class="wallet-withdraw__balance">${maxWithdraw}</div>
94
  </div>
95
+ <div class="wallet-withdraw__equivalent">
96
+ <div class="wallet-withdraw__summary-label">${t('wallet.withdraw.rmb_equivalent')}</div>
97
+ <div class="wallet-withdraw__equivalent-value">≈ ${t('wallet.recharge.price_prefix')} ${maxWithdraw.toFixed(2)}</div>
98
  </div>
99
  </div>
100
 
101
+ <div class="wallet-modal__field">
102
+ <label for="withdraw-amount" class="wallet-modal__label">${t('wallet.withdraw.amount_label')} <span class="wallet-modal__danger">*</span></label>
103
+ <input type="number" id="withdraw-amount" placeholder="${t('wallet.withdraw.amount_placeholder')}" max="${maxWithdraw}" min="1" step="1" class="wallet-modal__input">
104
 
105
+ <div id="fee-calc-box" class="wallet-withdraw__fee" style="display:none;">
106
  <div id="fee-text"></div>
107
  </div>
108
  </div>
109
 
110
+ <div class="wallet-modal__field">
111
+ <label for="withdraw-account" class="wallet-modal__label">${t('wallet.withdraw.alipay_account')} <span class="wallet-modal__danger">*</span></label>
112
+ <input type="text" id="withdraw-account" placeholder="${t('wallet.withdraw.alipay_placeholder')}" class="wallet-modal__input">
113
  </div>
114
 
115
+ <div class="wallet-modal__field">
116
+ <label for="withdraw-name" class="wallet-modal__label">${t('wallet.withdraw.real_name')} <span class="wallet-modal__danger">*</span></label>
117
+ <input type="text" id="withdraw-name" placeholder="${t('wallet.withdraw.real_name_placeholder')}" class="wallet-modal__input">
118
  </div>
119
 
120
+ <div class="wallet-modal__field wallet-modal__field--last">
121
+ <label for="withdraw-code" class="wallet-modal__label">${t('wallet.withdraw.security_code')} <span class="wallet-modal__danger">*</span></label>
122
+ <div class="wallet-withdraw__code-row">
123
+ <input type="text" id="withdraw-code" placeholder="${t('wallet.withdraw.code_placeholder')}" maxlength="6" class="wallet-modal__input wallet-withdraw__code-input">
124
+ <button type="button" id="btn-send-code" class="wallet-modal__button wallet-modal__button--code">${t('wallet.withdraw.get_code')}</button>
125
  </div>
126
  </div>
127
 
128
+ <button type="button" id="btn-submit-withdraw" class="wallet-modal__button wallet-modal__button--primary" >${t('wallet.withdraw.confirm')}</button>
129
  `;
130
 
131
  // 🚀 核心逻辑:动态计算提现手续费
 
150
 
151
  feeBox.style.display = "block";
152
  if (fee > 0) {
153
+ feeText.innerHTML = `${t('wallet.withdraw.fee_quota_exhausted')}<br>${t('wallet.withdraw.fee_deducted')}<strong class="wallet-modal__danger">${fee} ${t('wallet.recharge.points_suffix')}</strong><br>${t('wallet.withdraw.fee_actual')}<strong class="wallet-modal__success">${(val - fee).toFixed(2)} ${t('wallet.withdraw.yuan')}</strong>`;
154
  } else {
155
+ feeText.innerHTML = `${t('wallet.withdraw.fee_free_quota')}<br>${t('wallet.withdraw.fee_full_amount')}<strong class="wallet-modal__success">${val.toFixed(2)} ${t('wallet.withdraw.yuan')}</strong>`;
156
  }
157
  };
158
 
 
161
  if (!scope.isCurrent() || btnSendCode.disabled) return;
162
  _setButtonState(btnSendCode, true, "#555", t('wallet.withdraw.sending'));
163
  try {
164
+ const profile = await api.getUserProfile(account, { noCache: true, noStore: true, componentId,
165
+ timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES });
166
  if (!scope.isCurrent()) return;
167
+ if (!profile || typeof profile !== "object" || Array.isArray(profile) || profile.status !== "success" ||
168
+ !profile.data || typeof profile.data !== "object" ||
169
+ Array.isArray(profile.data) || profile.data.account !== account ||
170
+ typeof profile.data.email !== "string" || !profile.data.email.trim()) {
171
+ throw new Error("unconfirmed withdrawal contact");
172
+ }
173
+ const result = await api.sendVerifyCode(profile.data.email, "email", "withdraw", account);
174
+ if (!scope.isCurrent()) return;
175
+ if (!result || typeof result !== "object" || Array.isArray(result) || result.status !== "success") {
176
+ throw new Error("unconfirmed verification code");
177
+ }
178
  showToast(t('wallet.withdraw.code_sent'), "success");
179
  let count = 60;
180
  countdown = setInterval(() => {
前端页面/post/发布帖子组件.js CHANGED
@@ -17,7 +17,7 @@ import { t } from "../components/用户体验增强.js";
17
  import { removeCache, removeCacheByPrefix } from "../components/性能优化工具.js";
18
  import { createPublishEditingLock } from "../market/发布编辑锁.js";
19
  import { createPublishSubmissionScope } from "../market/发布提交生命周期.js";
20
- import { API, CACHE, PUBLISH_MEDIA_LIMITS, IMAGE_PROCESS, escapeHtml } from "../core/全局配置.js";
21
 
22
  function requirePostUpload(result) {
23
  if (result?.status !== 'success' || typeof result.url !== 'string' || !result.url.trim()) {
@@ -58,8 +58,9 @@ function updateCoverMark(previewContainer) {
58
  wrapper.tabIndex = 0;
59
  wrapper.setAttribute('role', 'group');
60
  wrapper.setAttribute('aria-label', t('post.image_position', { index: idx + 1, total: wrappers.length }));
61
- wrapper.setAttribute('aria-keyshortcuts', 'Alt+ArrowLeft Alt+ArrowRight');
62
- if (!wrapper.title) wrapper.title = t('post.image_move_hint');
 
63
  const remove = wrapper.querySelector('button[data-action="remove"]');
64
  if (remove) {
65
  remove.type = 'button';
@@ -68,13 +69,13 @@ function updateCoverMark(previewContainer) {
68
  if (img && !img.hasAttribute('alt')) img.alt = '';
69
 
70
  if (img) {
71
- img.style.borderColor = idx === 0 ? '#4CAF50' : 'var(--rk-border, var(--border-color, #444))';
72
  }
73
  if (idx === 0) {
74
  if (!coverLabel) {
75
  const label = document.createElement('span');
76
  label.className = 'cover-label';
77
- label.style.cssText = 'position: absolute; top: 4px; left: 4px; background: #4CAF50; color: var(--rk-text, var(--fg-color, #ddd)); font-size: 10px; padding: 2px 6px; border-radius: 3px; pointer-events: none;';
78
  label.textContent = t('post.cover');
79
  wrapper.appendChild(label);
80
  }
@@ -109,7 +110,7 @@ function setupImageDragSort(previewContainer, fileArray, options = {}) {
109
 
110
  wrappers.forEach(wrapper => {
111
  wrapper.draggable = true;
112
- wrapper.style.cursor = 'grab';
113
 
114
  // 绑定删除按钮(如果尚未绑定)
115
  const removeBtn = wrapper.querySelector('button[data-action="remove"]');
@@ -131,7 +132,7 @@ function setupImageDragSort(previewContainer, fileArray, options = {}) {
131
  }
132
 
133
  wrapper.onkeydown = (e) => {
134
- if (e.target !== wrapper || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey ||
135
  e.repeat || e.isComposing || !canInteract() ||
136
  !['ArrowLeft', 'ArrowRight'].includes(e.key)) return;
137
  const current = getWrappers();
@@ -177,13 +178,15 @@ function setupImageDragSort(previewContainer, fileArray, options = {}) {
177
  _clearDragIndicators(currentWrappers);
178
 
179
  // 在当前目标左侧显示绿色竖线指示插入位置
180
- wrapper.style.borderLeft = '3px solid #4CAF50';
181
  wrapper.style.paddingLeft = '5px';
 
182
  };
183
 
184
  wrapper.ondragleave = () => {
185
  wrapper.style.borderLeft = '';
186
  wrapper.style.paddingLeft = '';
 
187
  };
188
 
189
  wrapper.ondrop = (e) => {
@@ -234,7 +237,7 @@ function formatFileSize(bytes) {
234
 
235
  // ===== 共用样式常量 =====
236
  // P3-1: 图片预览包装器通用尺寸样式
237
- const PREVIEW_WRAPPER_STYLE = { position: "relative", width: "80px", height: "80px" };
238
 
239
  // ===== 共用辅助函数 =====
240
 
@@ -243,6 +246,7 @@ function _clearDragIndicators(wrappers) {
243
  wrappers.forEach(w => {
244
  w.style.borderLeft = '';
245
  w.style.paddingLeft = '';
 
246
  });
247
  }
248
 
@@ -252,19 +256,7 @@ function _createAddButton(imagesInput, scope) {
252
  addBtn.type = "button";
253
  addBtn.setAttribute("aria-label", t("post.upload_images"));
254
  addBtn.setAttribute("data-file-picker-button", "");
255
- Object.assign(addBtn.style, {
256
- width: "80px",
257
- height: "80px",
258
- background: "transparent",
259
- border: "2px dashed var(--rk-border, var(--border-color, #444))",
260
- borderRadius: "6px",
261
- display: "flex",
262
- alignItems: "center",
263
- justifyContent: "center",
264
- cursor: "pointer",
265
- color: "var(--rk-muted, var(--descrip-text, #a4a4a4))",
266
- fontSize: "24px"
267
- });
268
  addBtn.textContent = "+";
269
  addBtn.onclick = (e) => {
270
  if (!scope.isCurrent() || imagesInput.disabled) return;
@@ -379,7 +371,7 @@ export function createPublishPostView(currentUser, editPostData = null) {
379
 
380
  const container = document.createElement("div");
381
 
382
- container.classList.add("ranking-publish-form");
383
  container.innerHTML = `
384
  <!-- 顶部标题栏 -->
385
  <div class="ranking-publish-header">
@@ -394,65 +386,65 @@ export function createPublishPostView(currentUser, editPostData = null) {
394
  <!-- 表单内容 -->
395
  <div class="ranking-publish-body">
396
  <!-- 模式切换 -->
397
- <div style="margin-bottom: 15px;">
398
- <label style="display: block; font-size: 13px; font-weight: bold; color: var(--rk-text, var(--fg-color, #ddd)); margin-bottom: 8px;">
399
  ${t('post.mode_label')}
400
  </label>
401
- <div role="group" aria-label="${t('post.mode_label')}" style="display: flex; gap: 0; background: var(--rk-panel, var(--comfy-input-bg, #292929)); border: 1px solid var(--rk-border, var(--border-color, #444)); border-radius: 6px; overflow: hidden;">
402
- <button id="mode-image" style="flex: 1; padding: 10px; background: #4CAF50; color: var(--rk-on-accent, #fff); border: none; cursor: pointer; font-size: 13px; font-weight: bold; transition: 0.2s;">
403
  📷 ${t('post.mode_image')}
404
  </button>
405
- <button id="mode-video" style="flex: 1; padding: 10px; background: transparent; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); border: none; cursor: pointer; font-size: 13px; font-weight: bold; transition: 0.2s; border-left: 1px solid var(--rk-border, var(--border-color, #444));">
406
  🎬 ${t('post.mode_video')}
407
  </button>
408
  </div>
409
  </div>
410
 
411
  <!-- 图片上传区 -->
412
- <div id="image-upload-section" style="margin-bottom: 20px;">
413
- <label style="display: block; font-size: 13px; font-weight: bold; color: var(--rk-text, var(--fg-color, #ddd)); margin-bottom: 8px;">
414
- 🖼️ ${t('post.upload_images')} <span style="color: #F44336;">*</span>
415
- <span style="font-weight: normal; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); font-size: 12px;">(${t('post.max_9_images')})</span>
416
  </label>
417
  <input type="file" id="images-input" accept="image/*" multiple style="display: none;">
418
- <div id="images-preview" style="display: flex; flex-wrap: wrap; gap: 8px; min-height: 80px; padding: 15px; background: var(--rk-panel, var(--comfy-input-bg, #292929)); border: 2px dashed var(--rk-border, var(--border-color, #444)); border-radius: 8px; cursor: pointer; transition: 0.2s;">
419
- <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); font-size: 13px;">
420
- <div style="font-size: 32px; margin-bottom: 8px;">📷</div>
421
  ${t('post.click_upload')}
422
  </div>
423
  </div>
424
  </div>
425
 
426
  <!-- 视频上传区 -->
427
- <div id="video-upload-section" style="margin-bottom: 20px; display: none;">
428
- <label style="display: block; font-size: 13px; font-weight: bold; color: var(--rk-text, var(--fg-color, #ddd)); margin-bottom: 8px;">
429
- 🎬 ${t('post.upload_video')} <span style="color: #F44336;">*</span>
430
- <span style="font-weight: normal; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); font-size: 12px;">${t('post.video_limits_hint', { megabytes: PUBLISH_MEDIA_LIMITS.VIDEO_MAX_BYTES / (1024 * 1024), minutes: PUBLISH_MEDIA_LIMITS.VIDEO_MAX_SECONDS / 60 })}</span>
431
  </label>
432
  <input type="file" id="video-input" accept="video/mp4,video/webm,video/quicktime" style="display: none;">
433
- <div id="video-preview-area" style="padding: 15px; background: var(--rk-panel, var(--comfy-input-bg, #292929)); border: 2px dashed var(--rk-border, var(--border-color, #444)); border-radius: 8px; cursor: pointer; transition: 0.2s;">
434
- <div id="video-empty-state" style="display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); font-size: 13px;">
435
- <div style="font-size: 32px; margin-bottom: 8px;">🎬</div>
436
  ${t('post.click_upload_video')}
437
  </div>
438
  <div id="video-player-wrap" style="display: none;">
439
- <video id="video-player" controls style="width: 100%; max-height: 300px; border-radius: 8px; background: #000; display: block;"></video>
440
- <div style="display: flex; align-items: center; justify-content: space-between; margin-top: 10px;">
441
- <div style="font-size: 12px; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); line-height: 1.5;">
442
  <div id="video-name"></div>
443
  <div id="video-meta"></div>
444
  </div>
445
- <button id="btn-remove-video" style="width: 28px; height: 28px; border-radius: 50%; background: #F44336; color: var(--rk-on-accent, #fff); border: none; cursor: pointer; font-size: 16px; line-height: 1; flex-shrink: 0;">×</button>
446
  </div>
447
  <!-- 封面区域 -->
448
- <div id="video-cover-wrap" style="display: none; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border-color, #333);">
449
- <div style="font-size: 12px; font-weight: bold; color: var(--rk-text, var(--fg-color, #ddd)); margin-bottom: 8px;">${t('post.video_cover')}</div>
450
  <div class="ranking-publish-cover-controls">
451
- <div style="position: relative; width: 80px; height: 80px; flex-shrink: 0;">
452
- <img id="cover-thumb" src="" style="width: 80px; height: 80px; object-fit: cover; border-radius: 6px; border: 2px solid #4CAF50; display: none;">
453
- <span id="cover-label" style="position: absolute; top: 4px; left: 4px; background: #4CAF50; color: var(--rk-on-accent, #fff); font-size: 10px; padding: 2px 6px; border-radius: 3px; display: none;">${t('post.cover')}</span>
454
  </div>
455
- <div style="display: flex; flex-direction: column; gap: 6px;">
456
  <button type="button" id="btn-capture-frame" class="ranking-publish-secondary">
457
  📸 ${t('post.capture_frame')}
458
  </button>
@@ -468,40 +460,40 @@ export function createPublishPostView(currentUser, editPostData = null) {
468
  </div>
469
 
470
  <!-- 标题输入 -->
471
- <div style="margin-bottom: 15px;">
472
- <label for="title-input" style="display: block; font-size: 13px; font-weight: bold; color: var(--rk-text, var(--fg-color, #ddd)); margin-bottom: 8px;">
473
- 📝 ${t('post.title_label')} <span style="color: #F44336;">*</span>
474
  </label>
475
  <input type="text" id="title-input" class="ranking-publish-input" value="${escapeHtml(editTitle)}" placeholder="${t('post.title_placeholder')}" maxlength="50">
476
- <div style="text-align: right; font-size: 11px; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); margin-top: 4px;">
477
  <span id="title-count">${editTitle.length}</span>/50
478
  </div>
479
  </div>
480
 
481
  <!-- 正文输入 -->
482
- <div style="margin-bottom: 15px;">
483
- <label for="content-input" style="display: block; font-size: 13px; font-weight: bold; color: var(--rk-text, var(--fg-color, #ddd)); margin-bottom: 8px;">
484
  ✍️ ${t('post.content_label')}
485
  </label>
486
- <textarea id="content-input" class="ranking-publish-input" placeholder="${t('post.content_placeholder')}" maxlength="2000" style="height: 200px; resize: none; line-height: 1.6;">${escapeHtml(editContent)}</textarea>
487
- <div style="text-align: right; font-size: 11px; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); margin-top: 4px;">
488
  <span id="content-count">${editContent.length}</span>/2000
489
  </div>
490
  </div>
491
 
492
  <!-- 原创作品勾选框 -->
493
- <div style="margin-bottom: 15px; padding: 12px; background: var(--rk-panel, var(--comfy-input-bg, #292929)); border: 1px solid var(--rk-border, var(--border-color, #444)); border-radius: 6px;">
494
- <label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
495
- <input type="checkbox" id="is-original-checkbox" ${editIsOriginal ? 'checked' : ''} style="width: 16px; height: 16px; cursor: pointer; accent-color: #4CAF50;" />
496
- <span style="font-size: 13px; color: var(--rk-text, var(--fg-color, #ddd));">🎨 ${t('publish.mark_as_original')}</span>
497
  </label>
498
- <div style="font-size: 11px; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); margin-top: 6px; padding-left: 24px;">
499
  ${t('publish.original_default_hint')}
500
  </div>
501
  </div>
502
 
503
  <!-- 提示信息 -->
504
- <div style="background: rgba(76,175,80,0.1); border: 1px solid rgba(76,175,80,0.3); border-radius: 6px; padding: 12px; font-size: 12px; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); line-height: 1.6;">
505
  💡 <strong>${t('post.publish_notice')}:</strong><br>
506
  • ${t('post.notice_original')}<br>
507
  • ${t('post.notice_compress')}<br>
@@ -509,8 +501,8 @@ export function createPublishPostView(currentUser, editPostData = null) {
509
  </div>
510
 
511
  <!-- 确认发布按钮 -->
512
- <button id="btn-submit-post" style="width: 100%; padding: 12px; background: #4CAF50; border: none; color: var(--rk-on-accent, #fff); border-radius: 6px; cursor: pointer; font-size: 15px; font-weight: bold; transition: 0.2s; margin-top: 15px; margin-bottom: 5px;" onmouseover="this.style.background='#45a049'" onmouseout="this.style.background='#4CAF50'">
513
- 🚀 ${isEditMode ? '保存修改' : t('common.publish')}
514
  </button>
515
  </div>
516
  `;
@@ -560,6 +552,15 @@ export function createPublishPostView(currentUser, editPostData = null) {
560
  const isOriginalCheckbox = container.querySelector("#is-original-checkbox");
561
  const account = currentUser?.account;
562
  const scope = createPublishSubmissionScope(container, submitBtn, account);
 
 
 
 
 
 
 
 
 
563
  const editingLock = createPublishEditingLock(container, [submitBtn, container.querySelector("#btn-back-publish")]);
564
  scope.addCleanup(() => editingLock.dispose());
565
  const previewReaders = new Set(), temporaryVideos = new Map();
@@ -620,17 +621,9 @@ export function createPublishPostView(currentUser, editPostData = null) {
620
  modeImageBtn.setAttribute('aria-pressed', String(postMode === 'image'));
621
  modeVideoBtn.setAttribute('aria-pressed', String(postMode === 'video'));
622
  if (postMode === 'image') {
623
- modeImageBtn.style.background = '#4CAF50';
624
- modeImageBtn.style.color = 'var(--rk-on-accent, #fff)';
625
- modeVideoBtn.style.background = 'transparent';
626
- modeVideoBtn.style.color = 'var(--rk-muted, var(--descrip-text, #a4a4a4))';
627
  imageSection.style.display = 'block';
628
  videoSection.style.display = 'none';
629
  } else {
630
- modeImageBtn.style.background = 'transparent';
631
- modeImageBtn.style.color = 'var(--rk-muted, var(--descrip-text, #a4a4a4))';
632
- modeVideoBtn.style.background = '#4CAF50';
633
- modeVideoBtn.style.color = 'var(--rk-on-accent, #fff)';
634
  imageSection.style.display = 'none';
635
  videoSection.style.display = 'block';
636
  }
@@ -774,8 +767,8 @@ export function createPublishPostView(currentUser, editPostData = null) {
774
 
775
  if (totalImages === 0) {
776
  imagesPreview.innerHTML = `
777
- <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); font-size: 13px;">
778
- <div style="font-size: 32px; margin-bottom: 8px;">📷</div>
779
  ${t('post.click_upload')}
780
  </div>
781
  `;
@@ -801,12 +794,12 @@ export function createPublishPostView(currentUser, editPostData = null) {
801
 
802
  existingImageUrls.forEach((url, idx) => {
803
  const wrapper = document.createElement("div");
804
- Object.assign(wrapper.style, PREVIEW_WRAPPER_STYLE);
805
 
806
  wrapper.innerHTML = `
807
- <img src="${escapeHtml(url)}" style="width: 80px; height: 80px; object-fit: cover; border-radius: 6px; border: 2px solid ${idx === 0 ? '#4CAF50' : 'var(--rk-border, var(--border-color, #444))'};">
808
- ${idx === 0 ? `<span class="cover-label" style="position: absolute; top: 4px; left: 4px; background: #4CAF50; color: var(--rk-on-accent, #fff); font-size: 10px; padding: 2px 6px; border-radius: 3px;">${t('post.cover')}</span>` : ''}
809
- <button data-action="remove" data-existing-idx="${idx}" style="position: absolute; top: -6px; right: -6px; width: 24px; height: 24px; border-radius: 50%; background: #F44336; color: var(--rk-on-accent, #fff); border: none; cursor: pointer; font-size: 12px; line-height: 1;">×</button>
810
  `;
811
 
812
  wrapper._imageType = 'existing';
@@ -830,11 +823,11 @@ export function createPublishPostView(currentUser, editPostData = null) {
830
  previewReaders.add(reader);
831
  reader.onabort = () => finishPreviewReader(reader);
832
  const wrapper = document.createElement("div");
833
- Object.assign(wrapper.style, PREVIEW_WRAPPER_STYLE);
834
  wrapper.innerHTML = `
835
- <img src="" style="width: 80px; height: 80px; object-fit: cover; border-radius: 6px; border: 2px solid ${existingImageUrls.length === 0 && idx === 0 ? '#4CAF50' : 'var(--rk-border, var(--border-color, #444))'};">
836
- ${existingImageUrls.length === 0 && idx === 0 ? `<span class="cover-label" style="position: absolute; top: 4px; left: 4px; background: #4CAF50; color: var(--rk-on-accent, #fff); font-size: 10px; padding: 2px 6px; border-radius: 3px;">${t('post.cover')}</span>` : ''}
837
- <button data-action="remove" data-idx="${idx}" style="position: absolute; top: -6px; right: -6px; width: 24px; height: 24px; border-radius: 50%; background: #F44336; color: var(--rk-on-accent, #fff); border: none; cursor: pointer; font-size: 12px; line-height: 1;">×</button>
838
  `;
839
 
840
  wrapper.querySelector('img').removeAttribute('src');
@@ -881,8 +874,8 @@ export function createPublishPostView(currentUser, editPostData = null) {
881
  const generation = beginImagePreviews();
882
  if (imageFiles.length === 0) {
883
  imagesPreview.innerHTML = `
884
- <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); font-size: 13px;">
885
- <div style="font-size: 32px; margin-bottom: 8px;">📷</div>
886
  ${t('post.click_upload')}
887
  </div>
888
  `;
@@ -898,12 +891,12 @@ export function createPublishPostView(currentUser, editPostData = null) {
898
  previewReaders.add(reader);
899
  reader.onabort = () => finishPreviewReader(reader);
900
  const wrapper = document.createElement("div");
901
- Object.assign(wrapper.style, PREVIEW_WRAPPER_STYLE);
902
 
903
  wrapper.innerHTML = `
904
- <img src="" style="width: 80px; height: 80px; object-fit: cover; border-radius: 6px; border: 2px solid ${idx === 0 ? '#4CAF50' : 'var(--rk-border, var(--border-color, #444))'};">
905
- ${idx === 0 ? `<span class="cover-label" style="position: absolute; top: 4px; left: 4px; background: #4CAF50; color: var(--rk-on-accent, #fff); font-size: 10px; padding: 2px 6px; border-radius: 3px;">${t('post.cover')}</span>` : ''}
906
- <button data-action="remove" data-idx="${idx}" style="position: absolute; top: -6px; right: -6px; width: 24px; height: 24px; border-radius: 50%; background: #F44336; color: var(--rk-on-accent, #fff); border: none; cursor: pointer; font-size: 12px; line-height: 1;">×</button>
907
  `;
908
 
909
  wrapper.querySelector('img').removeAttribute('src');
 
17
  import { removeCache, removeCacheByPrefix } from "../components/性能优化工具.js";
18
  import { createPublishEditingLock } from "../market/发布编辑锁.js";
19
  import { createPublishSubmissionScope } from "../market/发布提交生命周期.js";
20
+ import { areKeyboardShortcutsEnabled, API, CACHE, PUBLISH_MEDIA_LIMITS, IMAGE_PROCESS, escapeHtml } from "../core/全局配置.js";
21
 
22
  function requirePostUpload(result) {
23
  if (result?.status !== 'success' || typeof result.url !== 'string' || !result.url.trim()) {
 
58
  wrapper.tabIndex = 0;
59
  wrapper.setAttribute('role', 'group');
60
  wrapper.setAttribute('aria-label', t('post.image_position', { index: idx + 1, total: wrappers.length }));
61
+ if (areKeyboardShortcutsEnabled()) wrapper.setAttribute('aria-keyshortcuts', 'Alt+ArrowLeft Alt+ArrowRight');
62
+ else wrapper.removeAttribute('aria-keyshortcuts');
63
+ wrapper.title = t(areKeyboardShortcutsEnabled() ? 'post.image_move_hint' : 'post.image_drag_hint');
64
  const remove = wrapper.querySelector('button[data-action="remove"]');
65
  if (remove) {
66
  remove.type = 'button';
 
69
  if (img && !img.hasAttribute('alt')) img.alt = '';
70
 
71
  if (img) {
72
+ img.style.borderColor = idx === 0 ? 'var(--rk-success, #4caf50)' : 'var(--rk-border, var(--border-color, #444))';
73
  }
74
  if (idx === 0) {
75
  if (!coverLabel) {
76
  const label = document.createElement('span');
77
  label.className = 'cover-label';
78
+
79
  label.textContent = t('post.cover');
80
  wrapper.appendChild(label);
81
  }
 
110
 
111
  wrappers.forEach(wrapper => {
112
  wrapper.draggable = true;
113
+
114
 
115
  // 绑定删除按钮(如果尚未绑定)
116
  const removeBtn = wrapper.querySelector('button[data-action="remove"]');
 
132
  }
133
 
134
  wrapper.onkeydown = (e) => {
135
+ if (!areKeyboardShortcutsEnabled() || e.defaultPrevented || e.keyCode === 229 || e.target !== wrapper || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey ||
136
  e.repeat || e.isComposing || !canInteract() ||
137
  !['ArrowLeft', 'ArrowRight'].includes(e.key)) return;
138
  const current = getWrappers();
 
178
  _clearDragIndicators(currentWrappers);
179
 
180
  // 在当前目标左侧显示绿色竖线指示插入位置
181
+ wrapper.style.borderLeft = '3px solid var(--rk-success, #4caf50)';
182
  wrapper.style.paddingLeft = '5px';
183
+
184
  };
185
 
186
  wrapper.ondragleave = () => {
187
  wrapper.style.borderLeft = '';
188
  wrapper.style.paddingLeft = '';
189
+
190
  };
191
 
192
  wrapper.ondrop = (e) => {
 
237
 
238
  // ===== 共用样式常量 =====
239
  // P3-1: 图片预览包装器通用尺寸样式
240
+
241
 
242
  // ===== 共用辅助函数 =====
243
 
 
246
  wrappers.forEach(w => {
247
  w.style.borderLeft = '';
248
  w.style.paddingLeft = '';
249
+
250
  });
251
  }
252
 
 
256
  addBtn.type = "button";
257
  addBtn.setAttribute("aria-label", t("post.upload_images"));
258
  addBtn.setAttribute("data-file-picker-button", "");
259
+ addBtn.className = "post-publish-add";
 
 
 
 
 
 
 
 
 
 
 
 
260
  addBtn.textContent = "+";
261
  addBtn.onclick = (e) => {
262
  if (!scope.isCurrent() || imagesInput.disabled) return;
 
371
 
372
  const container = document.createElement("div");
373
 
374
+ container.classList.add("ranking-publish-form", "discussion-publish");
375
  container.innerHTML = `
376
  <!-- 顶部标题栏 -->
377
  <div class="ranking-publish-header">
 
386
  <!-- 表单内容 -->
387
  <div class="ranking-publish-body">
388
  <!-- 模式切换 -->
389
+ <div class="post-publish__field-group">
390
+ <label class="post-publish__field-label">
391
  ${t('post.mode_label')}
392
  </label>
393
+ <div role="group" aria-label="${t('post.mode_label')}" class="post-publish__modes">
394
+ <button type="button" id="mode-image" class="post-publish__mode-image">
395
  📷 ${t('post.mode_image')}
396
  </button>
397
+ <button type="button" id="mode-video" class="post-publish__mode-video">
398
  🎬 ${t('post.mode_video')}
399
  </button>
400
  </div>
401
  </div>
402
 
403
  <!-- 图片上传区 -->
404
+ <div id="image-upload-section" class="post-publish__upload-section">
405
+ <label for="images-input" class="post-publish__field-label">
406
+ 🖼️ ${t('post.upload_images')} <span class="post-publish__required">*</span>
407
+ <span class="post-publish__hint">(${t('post.max_9_images')})</span>
408
  </label>
409
  <input type="file" id="images-input" accept="image/*" multiple style="display: none;">
410
+ <div id="images-preview" class="post-publish__images-preview">
411
+ <div class="post-publish__upload-empty">
412
+ <div class="post-publish__upload-icon">📷</div>
413
  ${t('post.click_upload')}
414
  </div>
415
  </div>
416
  </div>
417
 
418
  <!-- 视频上传区 -->
419
+ <div id="video-upload-section" style="display: none;" class="post-publish__upload-section">
420
+ <label for="video-input" class="post-publish__field-label">
421
+ 🎬 ${t('post.upload_video')} <span class="post-publish__required">*</span>
422
+ <span class="post-publish__hint">${t('post.video_limits_hint', { megabytes: PUBLISH_MEDIA_LIMITS.VIDEO_MAX_BYTES / (1024 * 1024), minutes: PUBLISH_MEDIA_LIMITS.VIDEO_MAX_SECONDS / 60 })}</span>
423
  </label>
424
  <input type="file" id="video-input" accept="video/mp4,video/webm,video/quicktime" style="display: none;">
425
+ <div id="video-preview-area" class="post-publish__video-preview">
426
+ <div id="video-empty-state" class="post-publish__upload-empty">
427
+ <div class="post-publish__upload-icon">🎬</div>
428
  ${t('post.click_upload_video')}
429
  </div>
430
  <div id="video-player-wrap" style="display: none;">
431
+ <video id="video-player" controls class="post-publish__video"></video>
432
+ <div class="post-publish__video-info">
433
+ <div class="post-publish__video-meta">
434
  <div id="video-name"></div>
435
  <div id="video-meta"></div>
436
  </div>
437
+ <button type="button" id="btn-remove-video" aria-label="${t('common.delete')} ${t('post.mode_video')}" class="post-publish__remove-video">×</button>
438
  </div>
439
  <!-- 封面区域 -->
440
+ <div id="video-cover-wrap" style="display: none;" class="post-publish__cover-section">
441
+ <div class="post-publish__cover-heading">${t('post.video_cover')}</div>
442
  <div class="ranking-publish-cover-controls">
443
+ <div class="post-publish__cover-slot">
444
+ <img id="cover-thumb" src="" style="display: none;" class="post-publish__cover-image">
445
+ <span id="cover-label" style="display: none;" class="post-publish__cover-label">${t('post.cover')}</span>
446
  </div>
447
+ <div class="post-publish__cover-actions">
448
  <button type="button" id="btn-capture-frame" class="ranking-publish-secondary">
449
  📸 ${t('post.capture_frame')}
450
  </button>
 
460
  </div>
461
 
462
  <!-- 标题输入 -->
463
+ <div class="post-publish__field-group">
464
+ <label for="title-input" class="post-publish__field-label">
465
+ 📝 ${t('post.title_label')} <span class="post-publish__required">*</span>
466
  </label>
467
  <input type="text" id="title-input" class="ranking-publish-input" value="${escapeHtml(editTitle)}" placeholder="${t('post.title_placeholder')}" maxlength="50">
468
+ <div class="post-publish__counter">
469
  <span id="title-count">${editTitle.length}</span>/50
470
  </div>
471
  </div>
472
 
473
  <!-- 正文输入 -->
474
+ <div class="post-publish__field-group">
475
+ <label for="content-input" class="post-publish__field-label">
476
  ✍️ ${t('post.content_label')}
477
  </label>
478
+ <textarea id="content-input" class="post-publish__content ranking-publish-input" placeholder="${t('post.content_placeholder')}" maxlength="2000">${escapeHtml(editContent)}</textarea>
479
+ <div class="post-publish__counter">
480
  <span id="content-count">${editContent.length}</span>/2000
481
  </div>
482
  </div>
483
 
484
  <!-- 原创作品勾选框 -->
485
+ <div class="post-publish__original">
486
+ <label class="post-publish__original-label">
487
+ <input type="checkbox" id="is-original-checkbox" ${editIsOriginal ? 'checked' : ''} class="post-publish__original-checkbox" />
488
+ <span class="post-publish__original-text">🎨 ${t('publish.mark_as_original')}</span>
489
  </label>
490
+ <div class="post-publish__original-hint">
491
  ${t('publish.original_default_hint')}
492
  </div>
493
  </div>
494
 
495
  <!-- 提示信息 -->
496
+ <div class="post-publish__notice">
497
  💡 <strong>${t('post.publish_notice')}:</strong><br>
498
  • ${t('post.notice_original')}<br>
499
  • ${t('post.notice_compress')}<br>
 
501
  </div>
502
 
503
  <!-- 确认发布按钮 -->
504
+ <button type="button" id="btn-submit-post" class="post-publish__submit">
505
+ 🚀 ${isEditMode ? t('task.save_changes') : t('common.publish')}
506
  </button>
507
  </div>
508
  `;
 
552
  const isOriginalCheckbox = container.querySelector("#is-original-checkbox");
553
  const account = currentUser?.account;
554
  const scope = createPublishSubmissionScope(container, submitBtn, account);
555
+ const refreshImageShortcuts = () => {
556
+ if (scope.isCurrent()) updateCoverMark(imagesPreview);
557
+ };
558
+ window.addEventListener('comfy-settings-changed', refreshImageShortcuts);
559
+ window.addEventListener('storage', refreshImageShortcuts);
560
+ scope.addCleanup(() => {
561
+ window.removeEventListener('comfy-settings-changed', refreshImageShortcuts);
562
+ window.removeEventListener('storage', refreshImageShortcuts);
563
+ });
564
  const editingLock = createPublishEditingLock(container, [submitBtn, container.querySelector("#btn-back-publish")]);
565
  scope.addCleanup(() => editingLock.dispose());
566
  const previewReaders = new Set(), temporaryVideos = new Map();
 
621
  modeImageBtn.setAttribute('aria-pressed', String(postMode === 'image'));
622
  modeVideoBtn.setAttribute('aria-pressed', String(postMode === 'video'));
623
  if (postMode === 'image') {
 
 
 
 
624
  imageSection.style.display = 'block';
625
  videoSection.style.display = 'none';
626
  } else {
 
 
 
 
627
  imageSection.style.display = 'none';
628
  videoSection.style.display = 'block';
629
  }
 
767
 
768
  if (totalImages === 0) {
769
  imagesPreview.innerHTML = `
770
+ <div class="post-publish__upload-empty">
771
+ <div class="post-publish__upload-icon">📷</div>
772
  ${t('post.click_upload')}
773
  </div>
774
  `;
 
794
 
795
  existingImageUrls.forEach((url, idx) => {
796
  const wrapper = document.createElement("div");
797
+ wrapper.className = "post-publish-preview";
798
 
799
  wrapper.innerHTML = `
800
+ <img src="${escapeHtml(url)}" class="post-publish__preview-image">
801
+ ${idx === 0 ? `<span class="post-publish__cover-label cover-label">${t('post.cover')}</span>` : ''}
802
+ <button type="button" data-action="remove" data-existing-idx="${idx}" class="post-publish__remove-image">×</button>
803
  `;
804
 
805
  wrapper._imageType = 'existing';
 
823
  previewReaders.add(reader);
824
  reader.onabort = () => finishPreviewReader(reader);
825
  const wrapper = document.createElement("div");
826
+ wrapper.className = "post-publish-preview";
827
  wrapper.innerHTML = `
828
+ <img src="" class="post-publish__preview-image">
829
+ ${existingImageUrls.length === 0 && idx === 0 ? `<span class="post-publish__cover-label cover-label">${t('post.cover')}</span>` : ''}
830
+ <button type="button" data-action="remove" data-idx="${idx}" class="post-publish__remove-image">×</button>
831
  `;
832
 
833
  wrapper.querySelector('img').removeAttribute('src');
 
874
  const generation = beginImagePreviews();
875
  if (imageFiles.length === 0) {
876
  imagesPreview.innerHTML = `
877
+ <div class="post-publish__upload-empty">
878
+ <div class="post-publish__upload-icon">📷</div>
879
  ${t('post.click_upload')}
880
  </div>
881
  `;
 
891
  previewReaders.add(reader);
892
  reader.onabort = () => finishPreviewReader(reader);
893
  const wrapper = document.createElement("div");
894
+ wrapper.className = "post-publish-preview";
895
 
896
  wrapper.innerHTML = `
897
+ <img src="" class="post-publish__preview-image">
898
+ ${idx === 0 ? `<span class="post-publish__cover-label cover-label">${t('post.cover')}</span>` : ''}
899
+ <button type="button" data-action="remove" data-idx="${idx}" class="post-publish__remove-image">×</button>
900
  `;
901
 
902
  wrapper.querySelector('img').removeAttribute('src');
前端页面/post/帖子详情组件.js CHANGED
@@ -11,7 +11,7 @@
11
 
12
  import { api, proxyImages, captureRequestSession, requestCancelManager } from "../core/网络请求API.js";
13
  import { showToast } from "../components/UI交互提示组件.js";
14
- import { getCoverSandboxHTML, setupImageSandboxEvents } from "../components/图片沙盒组件.js";
15
  import { getVideoPlayerHTML, setupVideoPlayerEvents, cleanupVideoPlayer } from "../components/视频播放器组件.js";
16
  import { renderTipLevelHTML } from "../components/打赏等级工具.js";
17
  import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
@@ -25,6 +25,8 @@ import { createRatingStars, isValidRatingResponse } from "../social/评论与互
25
  import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
26
  import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
27
 
 
 
28
  const viewContexts = new WeakMap();
29
  let postViewSequence = 0;
30
  const isCurrent = node => !!viewContexts.get(node)?.scope.isCurrent();
@@ -48,10 +50,10 @@ function _generateAvatarHtml(avatar, name, size, extraStyle = '') {
48
  const initial = (name || 'U')[0].toUpperCase();
49
  // 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
50
  if (avatar) {
51
- return `<img class="swr-avatar" src="${escapeHtml(avatar)}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; ${extraStyle}">`;
52
  }
53
  const fontSize = Math.max(9, Math.round(size * 0.4));
54
- 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>`;
55
  }
56
 
57
  /**
@@ -66,7 +68,7 @@ function _updateAvatarElement(avatarEl, profile, size, extraStyle = '') {
66
  if (avatarEl.tagName === 'IMG') {
67
  avatarEl.src = profile.avatar;
68
  } else {
69
- avatarEl.outerHTML = `<img class="swr-avatar" src="${escapeHtml(profile.avatar)}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; ${extraStyle}">`;
70
  }
71
  }
72
 
@@ -75,31 +77,21 @@ function _updateAvatarElement(avatarEl, profile, size, extraStyle = '') {
75
  */
76
  export function createPostDetailView(postId, currentUser) {
77
  const container = document.createElement("div");
78
- Object.assign(container.style, {
79
- display: "flex",
80
- flexDirection: "column",
81
- gap: "0",
82
- color: "#ccc",
83
- fontSize: "14px",
84
- padding: "0",
85
- overflowY: "auto",
86
- flex: "1",
87
- boxSizing: "border-box"
88
- });
89
 
90
  container.innerHTML = `
91
  <!-- 顶部标题栏 -->
92
- <div style="display: flex; align-items: center; gap: 10px; padding: 15px; border-bottom: 1px solid #444; background: #1a1a1a;">
93
- <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); 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)'">
94
  ⬅ ${t('common.back')}
95
  </button>
96
- <span style="font-size: 16px; font-weight: bold; color: #fff;">${t('post.detail_title')}</span>
97
  </div>
98
 
99
  <!-- 内容区域 -->
100
- <div id="post-content" style="flex: 1; overflow-y: auto; padding: 15px;">
101
- <div style="text-align: center; padding: 60px; color: #888;">
102
- <div style="font-size: 24px; margin-bottom: 10px;">⏳</div>
103
  ${t('post.loading')}
104
  </div>
105
  </div>
@@ -123,6 +115,7 @@ export function createPostDetailView(postId, currentUser) {
123
  viewContexts.set(container, context); viewContexts.set(contentArea, context);
124
  const handleRouteBack = () => scope.dispose();
125
  scope.addCleanup(() => {
 
126
  cleanupVideoPlayer(contentArea);
127
  contentArea.querySelectorAll('audio').forEach(audio => { audio.pause(); audio.removeAttribute('src'); audio.load(); });
128
  contentArea.replaceChildren(); observer?.disconnect();
@@ -158,6 +151,7 @@ async function loadPostDetail(container, postId, currentUser) {
158
 
159
  // 先清理旧的视频播放器全局事件(如果存在)
160
  if (contentArea) {
 
161
  cleanupVideoPlayer(contentArea);
162
  }
163
 
@@ -182,8 +176,8 @@ async function loadPostDetail(container, postId, currentUser) {
182
 
183
  if (!post) {
184
  contentArea.innerHTML = `
185
- <div style="text-align: center; padding: 60px; color: #F44336;">
186
- <div style="font-size: 24px; margin-bottom: 10px;">⚠️</div>
187
  ${t('post.not_exist')}
188
  </div>
189
  `;
@@ -209,92 +203,89 @@ async function loadPostDetail(container, postId, currentUser) {
209
 
210
  contentArea.innerHTML = `
211
  <!-- 图片展示区 -->
212
- <div id="images-area" style="margin-bottom: 15px;">
213
  ${mediaHTML}
214
  </div>
215
 
216
  <!-- 标题 -->
217
- <div style="font-size: 18px; font-weight: bold; color: #fff; margin-bottom: 8px; line-height: 1.4;">
218
  ${escapeHtml(post.title)}
219
  </div>
220
 
221
  <!-- 原创标识 -->
222
  ${post.is_original ? `
223
- <div style="display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px;
224
- background: linear-gradient(135deg, #FF6B35, #FF8F00); border-radius: 12px;
225
- font-size: 11px; color: #fff; font-weight: 500; margin-bottom: 12px;">
226
  🎨 ${t('publish.original_no_commercial')}
227
  </div>
228
  ` : ''}
229
 
230
  <!-- 作者信息(SWR 缓存头像) -->
231
- <div id="author-row" style="display: flex; align-items: center; gap: 10px; margin-bottom: 15px; padding: 10px; background: #1a1a1a; border-radius: 8px; cursor: pointer;">
232
- <div class="swr-avatar-container" style="width: 40px; height: 40px;"></div>
233
- <div style="flex: 1;">
234
- <div class="swr-name" style="font-size: 14px; font-weight: 500; color: #fff;">${escapeHtml(post.author_name || post.author)}</div>
235
- <div style="font-size: 11px; color: #888;">${formatTime(post.created_at)}</div>
236
- </div>
237
  ${currentUser && currentUser.account === post.author ? `
238
- <div style="display: flex; gap: 8px;" onclick="event.stopPropagation()">
239
- ${IS_WEB_MODE ? '' : `<button id="btn-edit-post" style="background: #2196F3; border: none; color: #fff; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;" title="${t('common.edit')}">✏️</button>`}
240
- <button id="btn-delete-post" style="background: #F44336; border: none; color: #fff; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;" title="${t('common.delete')}">🗑️</button>
241
  </div>
242
  ` : ''}
243
  </div>
244
 
245
  <!-- 正文内容 -->
246
- <div style="font-size: 14px; color: #ddd; line-height: 1.8; margin-bottom: 20px; white-space: pre-wrap; word-wrap: break-word;">
247
- ${escapeHtml(post.content)}
248
- </div>
249
 
250
  <!-- 互动按钮栏 -->
251
- <div id="interaction-bar" style="display: flex; align-items: center; gap: 15px; padding: 15px 0; border-top: 1px solid var(--border-color, #333); border-bottom: 1px solid var(--border-color, #333);">
252
- <button id="btn-like" style="background: ${isLiked ? '#FF5722' : 'var(--comfy-input-bg)'}; border: 1px solid ${isLiked ? '#FF5722' : '#555'}; color: #fff; padding: 8px 16px; border-radius: 20px; cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 6px; transition: 0.2s;">
253
  ❤️ <span id="like-count">${post.likes || 0}</span>
254
  </button>
255
- <button id="btn-favorite" style="background: ${isFavorited ? '#FFC107' : 'var(--comfy-input-bg)'}; border: 1px solid ${isFavorited ? '#FFC107' : '#555'}; color: ${isFavorited ? '#000' : '#fff'}; padding: 8px 16px; border-radius: 20px; cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 6px; transition: 0.2s;">
256
  🔖 <span id="favorite-count">${post.favorites || 0}</span>
257
  </button>
258
- <button id="btn-tip" style="background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 8px 16px; border-radius: 20px; cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 6px; transition: 0.2s;">
259
  ${t('post.tip_author')}
260
  </button>
261
  <!-- 👀 浏览量统计(纯展示) -->
262
- <div style="display: flex; align-items: center; gap: 12px; margin-left: auto; color: #888; font-size: 13px;">
263
- <span style="display: flex; align-items: center; gap: 4px;">🔥 <span id="post-view-total">${post.views || 0}</span></span>
264
- <span style="display: flex; align-items: center; gap: 4px;">📅 <span id="post-view-daily">${post.daily_views || 0}</span></span>
265
  </div>
266
  </div>
267
 
268
  <!-- 评分区域 -->
269
- <div id="rating-section" style="padding: 12px 0; border-bottom: 1px solid var(--border-color, #333); margin-bottom: 15px;"></div>
270
 
271
  <!-- 打赏榜单 -->
272
- <div id="tip-board-area" style="margin-bottom: 15px;">
273
  ${renderTipBoard(post.tip_board || [])}
274
  </div>
275
 
276
  <!-- 评论区 -->
277
- <div style="font-size: 14px; font-weight: bold; color: #fff; margin-bottom: 10px; display: flex; align-items: center; gap: 8px;">
278
- ${t('post.comments_title')} <span style="color: #888; font-weight: normal;">(${post.comments || 0})</span>
279
  </div>
280
 
281
  <!-- 评论输入框 -->
282
- <div style="display: flex; gap: 10px; margin-bottom: 15px;">
283
- <input type="text" id="comment-input" placeholder="${t('post.comment_placeholder')}" style="flex: 1; padding: 10px 12px; background: var(--comfy-input-bg); border: 1px solid #444; border-radius: 20px; color: #fff; font-size: 13px; outline: none;" onfocus="this.style.borderColor='#4CAF50'" onblur="this.style.borderColor='#444'">
284
- <button id="btn-send-comment" style="background: #4CAF50; border: none; color: #fff; padding: 10px 20px; border-radius: 20px; cursor: pointer; font-size: 13px; font-weight: bold; transition: 0.2s;" onmouseover="this.style.background='#45a049'" onmouseout="this.style.background='#4CAF50'">
285
  ${t('common.send')}
286
  </button>
287
  </div>
288
 
289
  <!-- 评论列表 -->
290
- <div id="comments-list" style="display: flex; flex-direction: column; gap: 10px;">
291
- <div style="text-align: center; padding: 20px; color: #888; font-size: 12px;">
292
  ${t('post.comment_loading')}
293
  </div>
294
  </div>
295
  `;
296
 
297
  // 设置图片沙盒事件(视频帖无沙盒元素,函数会安全返回)
 
298
  setupImageSandboxEvents(contentArea);
299
 
300
  // 视频播放器事件绑定
@@ -489,19 +480,19 @@ async function showTipDialog(post, currentUser, container) {
489
  }
490
 
491
  const content = document.createElement("div");
492
- content.style.color = "#ccc";
493
  content.innerHTML = `
494
- <div style="margin-bottom: 12px; background: rgba(255,152,0,0.1); padding: 10px; border-radius: 4px; border: 1px solid #FF9800; text-align: center;">
495
- ${t('wallet.current_balance')}: <strong style="color:#FF9800;">${balance}</strong> ${t('wallet.credits')}
496
  </div>
497
- <div style="display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; margin-bottom: 15px;">
498
- <button class="tip-amount" data-amount="10" style="background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px 20px; border-radius: 6px; cursor: pointer;">10 ${t('task.points')}</button>
499
- <button class="tip-amount" data-amount="50" style="background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px 20px; border-radius: 6px; cursor: pointer;">50 ${t('task.points')}</button>
500
- <button class="tip-amount" data-amount="100" style="background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px 20px; border-radius: 6px; cursor: pointer;">100 ${t('task.points')}</button>
501
- <button class="tip-amount" data-amount="500" style="background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px 20px; border-radius: 6px; cursor: pointer;">500 ${t('task.points')}</button>
502
  </div>
503
- <div style="display: flex; gap: 10px;">
504
- <button id="tip-cancel" style="flex: 1; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer;">${t('common.cancel')}</button>
505
  </div>
506
  `;
507
 
@@ -550,6 +541,13 @@ async function loadComments(container, postId, currentUser) {
550
  const sendBtn = container.querySelector("#btn-send-comment");
551
  if (!isCurrent(container)) return;
552
  const context = viewContexts.get(container);
 
 
 
 
 
 
 
553
  const generation = context.commentGeneration = (context.commentGeneration || 0) + 1;
554
  const current = () => isCurrent(container) && context.commentGeneration === generation && commentsList.isConnected;
555
 
@@ -563,7 +561,7 @@ async function loadComments(container, postId, currentUser) {
563
 
564
  if (comments.length === 0) {
565
  commentsList.innerHTML = `
566
- <div style="text-align: center; padding: 30px; color: #666; font-size: 13px;">
567
  ${t('post.no_comments')}
568
  </div>
569
  `;
@@ -577,13 +575,13 @@ async function loadComments(container, postId, currentUser) {
577
  const avatarHtml = _generateAvatarHtml(avatar, name, 24, 'background: var(--comfy-input-bg);');
578
 
579
  return `
580
- <div id="comment-${idx}" data-account="${escapeHtml(c.author)}" style="background: #1a1a1a; padding: 12px; border-radius: 8px;">
581
- <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
582
  <div class="swr-avatar-container">${avatarHtml}</div>
583
- <span class="swr-name" style="font-size: 13px; color: #fff; font-weight: 500;">${escapeHtml(name)}</span>
584
- <span style="font-size: 11px; color: #888; margin-left: auto;">${formatTime(c.created_at)}</span>
585
  </div>
586
- <div style="font-size: 13px; color: #ddd; line-height: 1.5; padding-left: 32px;">
587
  ${escapeHtml(c.content)}
588
  </div>
589
  </div>
@@ -607,45 +605,71 @@ async function loadComments(container, postId, currentUser) {
607
  } catch (err) {
608
  if (!current()) return;
609
  commentsList.innerHTML = `
610
- <div style="text-align: center; padding: 20px; color: #F44336; font-size: 12px;">
611
  ${t('post.comment_load_failed')}
612
  </div>
613
  `;
614
  }
615
 
616
- // 发送评论
 
 
 
 
 
 
 
617
  sendBtn.onclick = async () => {
618
- if (!isCurrent(container) || !sendBtn.isConnected || sendBtn.disabled) return;
619
- if (!currentUser) {
620
  showToast(t('auth.login_required'), "warning");
621
  return;
622
  }
623
- const content = commentInput.value.trim();
624
  if (!content) {
 
 
625
  showToast(t('post.comment_required'), "warning");
626
  return;
627
  }
 
 
 
 
 
628
  try {
629
- sendBtn.disabled = true;
630
- sendBtn.textContent = t('common.sending');
631
  const result = await api.addPostComment(postId, content);
632
- if (!isCurrent(container) || !sendBtn.isConnected) return;
633
- if (result?.status !== "success") throw new Error();
634
- if (commentInput.value.trim() === content) commentInput.value = "";
 
635
  showToast(t('post.comment_success'), "success");
636
- loadComments(container, postId, currentUser);
 
637
  } catch (err) {
638
- if (isCurrent(container) && sendBtn.isConnected) showToast(t('post.comment_failed'), "error");
 
 
 
 
 
 
 
639
  } finally {
640
- if (!isCurrent(container) || !sendBtn.isConnected) return;
641
- sendBtn.disabled = false;
642
- sendBtn.textContent = t('common.send');
 
 
643
  }
644
  };
645
 
646
  // 回车发送
647
  commentInput.onkeydown = (e) => {
648
- if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
 
 
 
649
  e.preventDefault();
650
  sendBtn.click();
651
  }
@@ -665,15 +689,15 @@ function renderTipBoard(tipBoard) {
665
  function showDeleteConfirmDialog(post, container) {
666
  if (!isCurrent(container)) return;
667
  const content = document.createElement("div");
668
- content.style.color = "#ccc";
669
  content.innerHTML = `
670
- <div style="text-align: center;">
671
- <div style="font-size: 40px; margin-bottom: 15px;">⚠️</div>
672
- <div style="font-size: 16px; font-weight: bold; color: #fff; margin-bottom: 10px;">${t('post.delete_confirm_title')}</div>
673
- <div style="font-size: 13px; color: #888; margin-bottom: 20px;">${t('post.delete_confirm_desc')}</div>
674
- <div style="display: flex; gap: 10px;">
675
- <button id="delete-cancel" style="flex: 1; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer;">${t('common.cancel')}</button>
676
- <button id="delete-confirm" style="flex: 1; background: #F44336; border: none; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer; font-weight: bold;">${t('common.delete')}</button>
677
  </div>
678
  </div>
679
  `;
 
11
 
12
  import { api, proxyImages, captureRequestSession, requestCancelManager } from "../core/网络请求API.js";
13
  import { showToast } from "../components/UI交互提示组件.js";
14
+ import { getCoverSandboxHTML, setupImageSandboxEvents, cleanupImageSandbox } from "../components/图片沙盒组件.js";
15
  import { getVideoPlayerHTML, setupVideoPlayerEvents, cleanupVideoPlayer } from "../components/视频播放器组件.js";
16
  import { renderTipLevelHTML } from "../components/打赏等级工具.js";
17
  import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
 
25
  import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
26
  import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
27
 
28
+ import { isConfirmedDedicatedCommentResponse, isDefiniteCommentRejection } from "../social/评论数据工具.js";
29
+
30
  const viewContexts = new WeakMap();
31
  let postViewSequence = 0;
32
  const isCurrent = node => !!viewContexts.get(node)?.scope.isCurrent();
 
50
  const initial = (name || 'U')[0].toUpperCase();
51
  // 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
52
  if (avatar) {
53
+ return `<img alt="" class="swr-avatar post-detail-avatar ${extraStyle === 'border: 2px solid #444;' ? 'post-detail-avatar--border' : extraStyle === 'background: var(--comfy-input-bg);' ? 'post-detail-avatar--panel' : ''}" src="${escapeHtml(avatar)}" style="width: ${size}px; height: ${size}px; ${['border: 2px solid #444;', 'background: var(--comfy-input-bg);'].includes(extraStyle) ? '' : extraStyle}">`;
54
  }
55
  const fontSize = Math.max(9, Math.round(size * 0.4));
56
+ return `<div class="swr-avatar post-detail-avatar ${extraStyle === 'border: 2px solid #444;' ? 'post-detail-avatar--border' : extraStyle === 'background: var(--comfy-input-bg);' ? 'post-detail-avatar--panel' : ''}" style="width: ${size}px; height: ${size}px; font-size: ${fontSize}px; font-weight: bold;">${escapeHtml(initial)}</div>`;
57
  }
58
 
59
  /**
 
68
  if (avatarEl.tagName === 'IMG') {
69
  avatarEl.src = profile.avatar;
70
  } else {
71
+ avatarEl.outerHTML = `<img alt="" class="swr-avatar post-detail-avatar ${extraStyle === 'border: 2px solid #444;' ? 'post-detail-avatar--border' : extraStyle === 'background: var(--comfy-input-bg);' ? 'post-detail-avatar--panel' : ''}" src="${escapeHtml(profile.avatar)}" style="width: ${size}px; height: ${size}px; ${['border: 2px solid #444;', 'background: var(--comfy-input-bg);'].includes(extraStyle) ? '' : extraStyle}">`;
72
  }
73
  }
74
 
 
77
  */
78
  export function createPostDetailView(postId, currentUser) {
79
  const container = document.createElement("div");
80
+ container.className = "discussion-detail";
 
 
 
 
 
 
 
 
 
 
81
 
82
  container.innerHTML = `
83
  <!-- 顶部标题栏 -->
84
+ <div class="post-detail__header">
85
+ <button type="button" id="btn-back-detail" class="post-detail__back">
86
  ⬅ ${t('common.back')}
87
  </button>
88
+ <span class="post-detail__heading">${t('post.detail_title')}</span>
89
  </div>
90
 
91
  <!-- 内容区域 -->
92
+ <div id="post-content" class="post-detail__body">
93
+ <div class="post-detail__loading" role="status">
94
+ <div class="post-detail__icon">⏳</div>
95
  ${t('post.loading')}
96
  </div>
97
  </div>
 
115
  viewContexts.set(container, context); viewContexts.set(contentArea, context);
116
  const handleRouteBack = () => scope.dispose();
117
  scope.addCleanup(() => {
118
+ cleanupImageSandbox(contentArea);
119
  cleanupVideoPlayer(contentArea);
120
  contentArea.querySelectorAll('audio').forEach(audio => { audio.pause(); audio.removeAttribute('src'); audio.load(); });
121
  contentArea.replaceChildren(); observer?.disconnect();
 
151
 
152
  // 先清理旧的视频播放器全局事件(如果存在)
153
  if (contentArea) {
154
+ cleanupImageSandbox(contentArea);
155
  cleanupVideoPlayer(contentArea);
156
  }
157
 
 
176
 
177
  if (!post) {
178
  contentArea.innerHTML = `
179
+ <div class="post-detail__error" role="alert">
180
+ <div class="post-detail__icon">⚠️</div>
181
  ${t('post.not_exist')}
182
  </div>
183
  `;
 
203
 
204
  contentArea.innerHTML = `
205
  <!-- 图片展示区 -->
206
+ <div id="images-area" class="post-detail__media">
207
  ${mediaHTML}
208
  </div>
209
 
210
  <!-- 标题 -->
211
+ <div class="post-detail__title">
212
  ${escapeHtml(post.title)}
213
  </div>
214
 
215
  <!-- 原创标识 -->
216
  ${post.is_original ? `
217
+ <div class="post-detail__original">
 
 
218
  🎨 ${t('publish.original_no_commercial')}
219
  </div>
220
  ` : ''}
221
 
222
  <!-- 作者信息(SWR 缓存头像) -->
223
+ <div class="post-detail__author post-detail-author-row">
224
+ <button type="button" id="author-row" class="post-detail-author-button"><div class="post-detail__avatar-slot swr-avatar-container"></div>
225
+ <div class="post-detail__identity">
226
+ <div class="post-detail__name swr-name">${escapeHtml(post.author_name || post.author)}</div>
227
+ <div class="post-detail__timestamp">${formatTime(post.created_at)}</div>
228
+ </div></button>
229
  ${currentUser && currentUser.account === post.author ? `
230
+ <div onclick="event.stopPropagation()" class="post-detail__owner-actions">
231
+ ${IS_WEB_MODE ? '' : `<button type="button" id="btn-edit-post" aria-label="${t('common.edit')}" title="${t('common.edit')}" class="post-detail__edit">✏️</button>`}
232
+ <button type="button" id="btn-delete-post" aria-label="${t('common.delete')}" title="${t('common.delete')}" class="post-detail__delete">🗑️</button>
233
  </div>
234
  ` : ''}
235
  </div>
236
 
237
  <!-- 正文内容 -->
238
+ <div class="post-detail__content">${escapeHtml(post.content)}</div>
 
 
239
 
240
  <!-- 互动按钮栏 -->
241
+ <div id="interaction-bar" class="post-detail__interactions">
242
+ <button type="button" id="btn-like" aria-label="${t('market.like')}" aria-pressed="${isLiked}" class="post-detail-toggle">
243
  ❤️ <span id="like-count">${post.likes || 0}</span>
244
  </button>
245
+ <button type="button" id="btn-favorite" aria-label="${t('market.favorite')}" aria-pressed="${isFavorited}" class="post-detail-toggle">
246
  🔖 <span id="favorite-count">${post.favorites || 0}</span>
247
  </button>
248
+ <button type="button" id="btn-tip" class="post-detail__tip">
249
  ${t('post.tip_author')}
250
  </button>
251
  <!-- 👀 浏览量统计(纯展示) -->
252
+ <div class="post-detail__stats">
253
+ <span class="post-detail__stat">🔥 <span id="post-view-total">${post.views || 0}</span></span>
254
+ <span class="post-detail__stat">📅 <span id="post-view-daily">${post.daily_views || 0}</span></span>
255
  </div>
256
  </div>
257
 
258
  <!-- 评分区域 -->
259
+ <div id="rating-section" class="post-detail__rating"></div>
260
 
261
  <!-- 打赏榜单 -->
262
+ <div id="tip-board-area" class="post-detail__media">
263
  ${renderTipBoard(post.tip_board || [])}
264
  </div>
265
 
266
  <!-- 评论区 -->
267
+ <div class="post-detail__comments-heading">
268
+ ${t('post.comments_title')} <span class="post-detail__comments-count">(${post.comments || 0})</span>
269
  </div>
270
 
271
  <!-- 评论输入框 -->
272
+ <div class="post-detail__comment-composer">
273
+ <input type="text" id="comment-input" required aria-label="${escapeHtml(t('post.comment_placeholder'))}" placeholder="${t('post.comment_placeholder')}" class="post-detail__comment-input">
274
+ <button type="button" id="btn-send-comment" class="post-detail__comment-send">
275
  ${t('common.send')}
276
  </button>
277
  </div>
278
 
279
  <!-- 评论列表 -->
280
+ <div id="comments-list" class="post-detail__comments-list">
281
+ <div class="post-detail__comments-loading" role="status">
282
  ${t('post.comment_loading')}
283
  </div>
284
  </div>
285
  `;
286
 
287
  // 设置图片沙盒事件(视频帖无沙盒元素,函数会安全返回)
288
+ contentArea.querySelectorAll("#images-area button:not([type])").forEach(button => { button.type = "button"; });
289
  setupImageSandboxEvents(contentArea);
290
 
291
  // 视频播放器事件绑定
 
480
  }
481
 
482
  const content = document.createElement("div");
483
+ content.className = "discussion-modal";
484
  content.innerHTML = `
485
+ <div class="post-detail__balance">
486
+ ${t('wallet.current_balance')}: <strong class="post-detail__balance-value">${balance}</strong> ${t('wallet.credits')}
487
  </div>
488
+ <div class="post-detail__tip-options">
489
+ <button type="button" class="post-detail__tip-amount tip-amount" data-amount="10">10 ${t('task.points')}</button>
490
+ <button type="button" class="post-detail__tip-amount tip-amount" data-amount="50">50 ${t('task.points')}</button>
491
+ <button type="button" class="post-detail__tip-amount tip-amount" data-amount="100">100 ${t('task.points')}</button>
492
+ <button type="button" class="post-detail__tip-amount tip-amount" data-amount="500">500 ${t('task.points')}</button>
493
  </div>
494
+ <div class="post-detail__modal-actions">
495
+ <button type="button" id="tip-cancel" class="post-detail__modal-cancel">${t('common.cancel')}</button>
496
  </div>
497
  `;
498
 
 
541
  const sendBtn = container.querySelector("#btn-send-comment");
542
  if (!isCurrent(container)) return;
543
  const context = viewContexts.get(container);
544
+ const account = typeof currentUser === "string" ? currentUser : currentUser?.account;
545
+ let submission = context.commentSubmission;
546
+ if (!submission || submission.input !== commentInput || submission.button !== sendBtn || submission.account !== account) {
547
+ submission = context.commentSubmission = { input: commentInput, button: sendBtn, account, phase: "idle", bound: false };
548
+ }
549
+ const submissionCurrent = () => isCurrent(container) && context.commentSubmission === submission
550
+ && commentInput.isConnected && sendBtn.isConnected;
551
  const generation = context.commentGeneration = (context.commentGeneration || 0) + 1;
552
  const current = () => isCurrent(container) && context.commentGeneration === generation && commentsList.isConnected;
553
 
 
561
 
562
  if (comments.length === 0) {
563
  commentsList.innerHTML = `
564
+ <div class="post-detail__comments-empty">
565
  ${t('post.no_comments')}
566
  </div>
567
  `;
 
575
  const avatarHtml = _generateAvatarHtml(avatar, name, 24, 'background: var(--comfy-input-bg);');
576
 
577
  return `
578
+ <div id="comment-${idx}" data-account="${escapeHtml(c.author)}" class="post-detail__comment">
579
+ <div class="post-detail__comment-author">
580
  <div class="swr-avatar-container">${avatarHtml}</div>
581
+ <span class="post-detail__comment-name swr-name">${escapeHtml(name)}</span>
582
+ <span class="post-detail__comment-time">${formatTime(c.created_at)}</span>
583
  </div>
584
+ <div class="post-detail__comment-content">
585
  ${escapeHtml(c.content)}
586
  </div>
587
  </div>
 
605
  } catch (err) {
606
  if (!current()) return;
607
  commentsList.innerHTML = `
608
+ <div class="post-detail__comments-error" role="alert">
609
  ${t('post.comment_load_failed')}
610
  </div>
611
  `;
612
  }
613
 
614
+ if (submission.bound || !submissionCurrent()) return;
615
+ submission.bound = true;
616
+ sendBtn.disabled = false;
617
+ commentInput.readOnly = false;
618
+ sendBtn.removeAttribute("aria-busy");
619
+ commentInput.oninput = () => {
620
+ if (submissionCurrent()) commentInput.removeAttribute("aria-invalid");
621
+ };
622
  sendBtn.onclick = async () => {
623
+ if (!submissionCurrent() || submission.phase !== "idle") return;
624
+ if (typeof account !== "string" || !account.trim()) {
625
  showToast(t('auth.login_required'), "warning");
626
  return;
627
  }
628
+ const originalInput = commentInput.value, content = originalInput.trim();
629
  if (!content) {
630
+ commentInput.setAttribute("aria-invalid", "true");
631
+ commentInput.focus();
632
  showToast(t('post.comment_required'), "warning");
633
  return;
634
  }
635
+ commentInput.removeAttribute("aria-invalid");
636
+ submission.phase = "submitting";
637
+ sendBtn.disabled = true;
638
+ sendBtn.setAttribute("aria-busy", "true");
639
+ sendBtn.textContent = t('common.sending');
640
  try {
 
 
641
  const result = await api.addPostComment(postId, content);
642
+ if (!submissionCurrent()) return;
643
+ if (!isConfirmedDedicatedCommentResponse(result, account, content)) throw new Error("Unconfirmed comment");
644
+ submission.phase = "committed";
645
+ if (commentInput.value === originalInput) commentInput.value = "";
646
  showToast(t('post.comment_success'), "success");
647
+ await loadComments(container, postId, currentUser);
648
+ if (submissionCurrent()) submission.phase = "idle";
649
  } catch (err) {
650
+ if (!submissionCurrent()) return;
651
+ if (submission.phase === "committed") {
652
+ try { showToast(t('social.comment_refresh_required'), "warning"); } catch (_) {}
653
+ return;
654
+ }
655
+ const rejected = isDefiniteCommentRejection(err);
656
+ submission.phase = rejected ? "idle" : "unconfirmed";
657
+ showToast(t(rejected ? 'post.comment_failed' : 'social.comment_unconfirmed'), rejected ? "error" : "warning");
658
  } finally {
659
+ if (!submissionCurrent()) return;
660
+ sendBtn.disabled = submission.phase !== "idle";
661
+ commentInput.readOnly = submission.phase !== "idle";
662
+ sendBtn.removeAttribute("aria-busy");
663
+ sendBtn.textContent = t(submission.phase === "unconfirmed" ? 'social.comment_pending' : 'common.send');
664
  }
665
  };
666
 
667
  // 回车发送
668
  commentInput.onkeydown = (e) => {
669
+ if (isCurrent(container) && !e.defaultPrevented && e.key === "Enter"
670
+ && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey
671
+ && !e.isComposing && e.keyCode !== 229 && !e.repeat
672
+ && !commentInput.disabled && !commentInput.readOnly) {
673
  e.preventDefault();
674
  sendBtn.click();
675
  }
 
689
  function showDeleteConfirmDialog(post, container) {
690
  if (!isCurrent(container)) return;
691
  const content = document.createElement("div");
692
+ content.className = "discussion-modal";
693
  content.innerHTML = `
694
+ <div class="post-detail__modal-center">
695
+ <div class="post-detail__warning-icon">⚠️</div>
696
+ <div class="post-detail__modal-title">${t('post.delete_confirm_title')}</div>
697
+ <div class="post-detail__modal-description">${t('post.delete_confirm_desc')}</div>
698
+ <div class="post-detail__modal-actions">
699
+ <button type="button" id="delete-cancel" class="post-detail__modal-cancel">${t('common.cancel')}</button>
700
+ <button type="button" id="delete-confirm" class="post-detail__modal-danger">${t('common.delete')}</button>
701
  </div>
702
  </div>
703
  `;
前端页面/post/讨论区组件.js CHANGED
@@ -259,7 +259,7 @@ export function createPostsView(currentUser, keyword = "") {
259
  // 🚀 本地排序优先:已有数据时直接本地排序渲染
260
  if (allPostsData.length > 0) {
261
  const sorted = sortPostsLocally(allPostsData, currentSort);
262
- renderPostsFromCache(sorted);
263
  // 后台静默刷新最新数据
264
  silentRefresh();
265
  } else {
@@ -282,7 +282,7 @@ export function createPostsView(currentUser, keyword = "") {
282
  listViewGeneration++;
283
  if (allPostsData.length > 0) {
284
  const sorted = sortPostsLocally(allPostsData, currentSort);
285
- renderPostsFromCache(sorted);
286
  }
287
  silentRefresh();
288
  };
@@ -445,6 +445,7 @@ export function createPostsView(currentUser, keyword = "") {
445
  // 从缓存渲染帖子列表
446
  const renderPostsFromCache = (posts, deferSearchScan = false) => {
447
  firstPageReady = true;
 
448
  postsGrid.innerHTML = "";
449
 
450
  // 🔍 搜索过滤
@@ -460,6 +461,7 @@ export function createPostsView(currentUser, keyword = "") {
460
  postsGrid.innerHTML = _renderEmptyState(t);
461
  loadMoreWrapper.style.display = "none";
462
  }
 
463
  return;
464
  }
465
 
@@ -487,6 +489,7 @@ export function createPostsView(currentUser, keyword = "") {
487
  } else {
488
  loadMoreWrapper.style.display = "none";
489
  }
 
490
  };
491
 
492
  // 后台静默更新
@@ -548,14 +551,25 @@ export function createPostsView(currentUser, keyword = "") {
548
  const _postsDataChanged = (oldData, newData) => {
549
  if (!oldData || !newData) return true;
550
  if (oldData.length !== newData.length) return true;
551
- const checkCount = Math.min(10, oldData.length);
552
  const COMPARE_FIELDS = ['likes', 'favorites', 'views', 'daily_views', 'comments_count'];
 
 
 
 
553
  for (let i = 0; i < checkCount; i++) {
554
  if ((oldData[i].id) !== (newData[i].id)) return true;
 
 
 
 
 
 
555
  for (const f of COMPARE_FIELDS) {
556
  if ((oldData[i][f] || 0) !== (newData[i][f] || 0)) return true;
557
  }
558
- if (Math.abs((oldData[i].rating_avg || 0) - (newData[i].rating_avg || 0)) > 0.01) return true;
 
559
  }
560
  return false;
561
  };
@@ -601,12 +615,17 @@ export function createPostsView(currentUser, keyword = "") {
601
  function createPostCard(post, currentUser = currentUserCache, isCurrent = () => true, captureNavigation = () => isCurrent) {
602
  const card = document.createElement("div");
603
  card.className = "business-list-card posts-card";
 
 
 
604
 
605
  // 格式化时间
606
  const timeStr = formatTime(post.created_at);
607
 
608
  // 判断是否为视频帖子
609
  const isVideo = post.post_type === "video";
 
 
610
 
611
  card.innerHTML = `
612
  <!-- 封面图 -->
@@ -630,7 +649,7 @@ function createPostCard(post, currentUser = currentUserCache, isCurrent = () =>
630
  </button>
631
 
632
  <!-- 作者信息(SWR 缓存头像) -->
633
- <div class="business-list-card__author posts-card__author"></div>
634
 
635
  <!-- 互动数据 -->
636
  <div class="posts-card__stats">
@@ -650,9 +669,9 @@ function createPostCard(post, currentUser = currentUserCache, isCurrent = () =>
650
  if (!authorContainer) return;
651
 
652
  const account = post.author;
653
- const cached = getCachedProfile(account);
654
- const avatar = cached?.avatar || post.author_avatar || '';
655
- const name = cached?.name || post.author_name || account || '';
656
  const initial = (name || 'U')[0].toUpperCase();
657
 
658
  // 渲染初始头像
@@ -661,21 +680,23 @@ function createPostCard(post, currentUser = currentUserCache, isCurrent = () =>
661
  authorContainer.innerHTML = `${avatarHtml}<span class="swr-name posts-card__name">${escapeHtml(name)}</span>`;
662
 
663
  // 后台静默校对
664
- getProfileWithSWR(account, api.getUserProfile, (profile) => {
665
  if (!isCurrent() || !card.isConnected) return;
666
  const avatarEl = authorContainer.querySelector('.swr-avatar');
667
  const nameEl = authorContainer.querySelector('.swr-name');
668
- if (avatarEl && profile.avatar) {
669
  if (avatarEl.tagName === 'IMG') {
670
- avatarEl.src = profile.avatar;
671
  } else {
672
- avatarEl.outerHTML = _generateAvatarHtml(profile.avatar, profile.name || name);
673
  }
674
  }
675
- if (nameEl && profile.name) {
676
  nameEl.textContent = profile.name;
677
  }
678
- });
 
 
679
  }, 0);
680
 
681
  const cover = card.querySelector('img');
 
259
  // 🚀 本地排序优先:已有数据时直接本地排序渲染
260
  if (allPostsData.length > 0) {
261
  const sorted = sortPostsLocally(allPostsData, currentSort);
262
+ renderPostsFromCache(sorted, true);
263
  // 后台静默刷新最新数据
264
  silentRefresh();
265
  } else {
 
282
  listViewGeneration++;
283
  if (allPostsData.length > 0) {
284
  const sorted = sortPostsLocally(allPostsData, currentSort);
285
+ renderPostsFromCache(sorted, true);
286
  }
287
  silentRefresh();
288
  };
 
445
  // 从缓存渲染帖子列表
446
  const renderPostsFromCache = (posts, deferSearchScan = false) => {
447
  firstPageReady = true;
448
+ const restoreFocus = cardNavigation.captureListFocus(postsGrid);
449
  postsGrid.innerHTML = "";
450
 
451
  // 🔍 搜索过滤
 
461
  postsGrid.innerHTML = _renderEmptyState(t);
462
  loadMoreWrapper.style.display = "none";
463
  }
464
+ restoreFocus();
465
  return;
466
  }
467
 
 
489
  } else {
490
  loadMoreWrapper.style.display = "none";
491
  }
492
+ restoreFocus();
493
  };
494
 
495
  // 后台静默更新
 
551
  const _postsDataChanged = (oldData, newData) => {
552
  if (!oldData || !newData) return true;
553
  if (oldData.length !== newData.length) return true;
554
+ const checkCount = oldData.length;
555
  const COMPARE_FIELDS = ['likes', 'favorites', 'views', 'daily_views', 'comments_count'];
556
+ const contentFields = ["title", "content", "post_type", "created_at", "author", "author_name"];
557
+ const mediaFields = ["cover_image", "author_avatar"];
558
+ const normalizedImage = value => typeof value === 'string'
559
+ ? proxyImages({ cover_image: value }).cover_image : value;
560
  for (let i = 0; i < checkCount; i++) {
561
  if ((oldData[i].id) !== (newData[i].id)) return true;
562
+ for (const field of contentFields) {
563
+ if (!Object.is(oldData[i][field], newData[i][field])) return true;
564
+ }
565
+ for (const field of mediaFields) {
566
+ if (!Object.is(normalizedImage(oldData[i][field]), normalizedImage(newData[i][field]))) return true;
567
+ }
568
  for (const f of COMPARE_FIELDS) {
569
  if ((oldData[i][f] || 0) !== (newData[i][f] || 0)) return true;
570
  }
571
+ const ratingText = value => (typeof value === 'number' && Number.isFinite(value) ? value : 0).toFixed(1);
572
+ if (ratingText(oldData[i].rating_avg) !== ratingText(newData[i].rating_avg)) return true;
573
  }
574
  return false;
575
  };
 
615
  function createPostCard(post, currentUser = currentUserCache, isCurrent = () => true, captureNavigation = () => isCurrent) {
616
  const card = document.createElement("div");
617
  card.className = "business-list-card posts-card";
618
+ if (typeof post.id === 'string' || (typeof post.id === 'number' && Number.isFinite(post.id))) {
619
+ card._rankingListId = String(post.id);
620
+ }
621
 
622
  // 格式化时间
623
  const timeStr = formatTime(post.created_at);
624
 
625
  // 判断是否为视频帖子
626
  const isVideo = post.post_type === "video";
627
+ const initialAuthor = isCurrent() && typeof post.author === 'string' ? getCachedProfile(post.author) : null;
628
+ const initialAuthorName = [initialAuthor?.name, post.author_name, post.author].find(value => typeof value === 'string' && value) || '';
629
 
630
  card.innerHTML = `
631
  <!-- 封面图 -->
 
649
  </button>
650
 
651
  <!-- 作者信息(SWR 缓存头像) -->
652
+ <div class="business-list-card__author posts-card__author">${_generateAvatarHtml('', initialAuthorName)}<span class="swr-name posts-card__name">${escapeHtml(initialAuthorName)}</span></div>
653
 
654
  <!-- 互动数据 -->
655
  <div class="posts-card__stats">
 
669
  if (!authorContainer) return;
670
 
671
  const account = post.author;
672
+ const cached = initialAuthor;
673
+ const avatar = [cached?.avatar, post.author_avatar].find(value => typeof value === 'string' && value) || '';
674
+ const name = initialAuthorName;
675
  const initial = (name || 'U')[0].toUpperCase();
676
 
677
  // 渲染初始头像
 
680
  authorContainer.innerHTML = `${avatarHtml}<span class="swr-name posts-card__name">${escapeHtml(name)}</span>`;
681
 
682
  // 后台静默校对
683
+ const updateAuthor = (profile) => {
684
  if (!isCurrent() || !card.isConnected) return;
685
  const avatarEl = authorContainer.querySelector('.swr-avatar');
686
  const nameEl = authorContainer.querySelector('.swr-name');
687
+ if (avatarEl && typeof profile.avatar === 'string' && profile.avatar) {
688
  if (avatarEl.tagName === 'IMG') {
689
+ if (avatarEl.getAttribute('src') !== profile.avatar) avatarEl.src = profile.avatar;
690
  } else {
691
+ avatarEl.outerHTML = _generateAvatarHtml(profile.avatar, typeof profile.name === 'string' && profile.name ? profile.name : name);
692
  }
693
  }
694
+ if (nameEl && typeof profile.name === 'string' && profile.name && nameEl.textContent !== profile.name) {
695
  nameEl.textContent = profile.name;
696
  }
697
+ };
698
+ const latestCached = getProfileWithSWR(account, api.getUserProfile, updateAuthor);
699
+ if (latestCached) updateAuthor(latestCached);
700
  }, 0);
701
 
702
  const cover = card.querySelector('img');
前端页面/profile/个人中心_UI模板.js CHANGED
@@ -12,34 +12,9 @@ import { getBannerCacheKey, PLACEHOLDERS } from "../core/全局配置.js";
12
  import { t } from "../components/用户体验增强.js";
13
  import { escapeHtml } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
14
 
15
- // 一次性注入组件样式(CSS类替代内联hover事件+减少重复样式)
16
- if (!document.getElementById('profile-template-styles')) {
17
- const style = document.createElement('style');
18
- style.id = 'profile-template-styles';
19
- style.textContent = `
20
- .profile-nav-btn{background:rgba(51,51,51,0.8);border:1px solid rgba(85,85,85,0.8);color:#fff;padding:6px 14px;border-radius:6px;cursor:pointer;font-size:13px;font-weight:bold;display:flex;align-items:center;gap:6px;box-shadow:0 2px 4px rgba(0,0,0,0.3);transition:0.2s}
21
- .profile-nav-btn:hover{background:#4CAF50;border-color:#4CAF50}
22
- .profile-header-btn{padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px;transition:0.2s;backdrop-filter:blur(4px)}
23
- .profile-header-btn--settings{background:rgba(51,51,51,0.8);border:1px solid rgba(85,85,85,0.8);color:#ccc}
24
- .profile-header-btn--settings:hover{color:#fff;border-color:#888}
25
- .profile-header-btn--logout{background:rgba(244,67,54,0.2);border:1px solid #F44336;color:#F44336}
26
- .profile-header-btn--logout:hover{background:#F44336;color:#fff}
27
- .profile-action-btn{border-radius:4px;cursor:pointer;font-weight:bold}
28
- .profile-action-btn--primary{background:#FF9800;border:none;color:white;box-shadow:0 2px 4px rgba(0,0,0,0.2);transition:0.2s}
29
- .profile-action-btn--primary:hover{opacity:0.8}
30
- .profile-action-btn--withdraw{background:rgba(76,175,80,0.2);border:1px solid #4CAF50;color:#4CAF50;transition:0.2s}
31
- .profile-action-btn--withdraw:hover{background:rgba(76,175,80,0.3)}
32
- .profile-action-btn--send{background:#2196F3;border:none;color:white;box-shadow:0 2px 4px rgba(0,0,0,0.2)}
33
- .profile-action-btn--follow{background:#4CAF50;border:none;color:white;box-shadow:0 2px 4px rgba(0,0,0,0.2)}
34
- .profile-action-btn--me{padding:8px 12px;font-size:13px}
35
- .profile-action-btn--other{padding:6px 12px;font-size:12px;width:100%}
36
- `;
37
- document.head.appendChild(style);
38
- }
39
-
40
  /** 生成返回按钮HTML */
41
- function _backButtonHtml(extraStyle = '') {
42
- return `<button id="btn-back-profile" class="profile-nav-btn" style="${extraStyle}">⬅ ${t('common.back')}</button>`;
43
  }
44
 
45
  export function buildProfileHTML(userData, isMe, isSettingsView, isFollowing, followingCount, activeTab, tabs) {
@@ -55,78 +30,78 @@ export function buildProfileHTML(userData, isMe, isSettingsView, isFollowing, fo
55
  }
56
  }
57
 
58
- // 🔒 XSS防护:他人查看时 bannerUrl 直接来自云端(用户可自行填写),
59
- // 写入 CSS url() 前剔除引号与括号,避免突破属性(与创作者卡片、个人设置表单同一写法)
60
- const bannerStyle = bannerImageUrl
61
- ? `background-image: url(${String(bannerImageUrl).replace(/['"()\\]/g, '')}); background-size: cover; background-position: center;`
62
- : `background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);`;
63
 
64
  // 设置视图时返回简化的返回按钮(与个人资料页按钮样式一致,但不显示背景图)
65
  if (isSettingsView && isMe) {
66
- return _backButtonHtml('align-self:flex-start;margin-left:15px;margin-top:15px');
67
  }
68
 
69
  const actionButtons = isMe
70
- ? `<div style="display: flex; flex-direction: column; gap: 10px; width: 100%;">
71
- <button id="btn-wallet" class="profile-action-btn profile-action-btn--primary profile-action-btn--me">💰 ${t('profile.recharge')}</button>
72
- <button id="btn-withdraw" class="profile-action-btn profile-action-btn--withdraw profile-action-btn--me">💸 ${t('profile.withdraw')}</button>
73
  </div>`
74
- : `<button id="btn-tip-user" class="profile-action-btn profile-action-btn--primary profile-action-btn--other" style="margin-bottom: 8px">🎁 ${t('profile.tip_support')}</button>
75
- <button id="btn-send-msg" class="profile-action-btn profile-action-btn--send profile-action-btn--other">✉️ ${t('profile.send_message')}</button>
76
- <button id="btn-follow-user" class="profile-action-btn profile-action-btn--follow profile-action-btn--other" style="margin-top: 8px">${isFollowing ? `✔️ ${t('profile.followed')}` : `➕ ${t('profile.follow_author')}`}</button>`;
77
 
78
  // 统计数据区域(现在在背景图内)
79
  const statsHtml = `
80
- <div style="display: flex; gap: 15px; font-size: 12px; color: rgba(255,255,255,0.9); flex-wrap: wrap; margin-top: 15px; padding-top: 12px; border-top: 1px solid rgba(255,255,255,0.1);">
81
  ${isMe ? `
82
- <div style="display:flex; flex-direction: column; gap: 6px; width: 100%;">
83
- <div style="font-size: 14px;">
84
- <span>💰 ${t('profile.balance')}: <strong style="color:#FF9800; font-size: 16px;">${escapeHtml(userData.balance || 0)}</strong></span>
85
  </div>
86
- <div style="font-size: 11px; color: rgba(255,255,255,0.6);">
87
  <span>🛍️ ${t('profile.sales')}: ${escapeHtml(userData.earn_balance || 0)}</span>
88
- <span style="margin-left: 12px;">🎁 ${t('profile.tips')}: ${escapeHtml(userData.tip_balance || 0)}</span>
89
- <span style="margin-left: 12px;">📋 ${t('profile.task_earnings')}: ${escapeHtml(userData.task_balance || 0)}</span>
90
  </div>
91
  </div>
92
  ` : ''}
93
- <div style="display:flex; gap: 12px;">
94
- <span>👍 ${t('profile.received_likes')}: <strong style="color:#fff;">${escapeHtml(userData.receivedLikes || 0)}</strong></span>
95
- <span>🔖 ${t('profile.received_favorites')}: <strong style="color:#fff;">${escapeHtml(userData.receivedFavorites || 0)}</strong></span>
96
- <span id="btn-followers" style="cursor: pointer;">👥 ${t('profile.followers_count')}: <strong style="color:#fff;">${userData.followers ? userData.followers.length : 0}</strong></span>
97
- <span id="btn-following" style="cursor: pointer;">🏃 ${t('profile.following_count')}: <strong style="color:#fff;">${escapeHtml(followingCount)}</strong></span>
98
  </div>
99
  </div>
100
  `;
101
 
102
  // 整个头部区域(包括返回按钮、用户信息、统计数据)都在背景图内
103
  const headerHtml = `
104
- <div style="position: relative; border-radius: 12px; overflow: hidden; margin-bottom: 15px;">
105
- <div style="${bannerStyle} padding: 15px; min-height: 200px;">
106
  <!-- 顶部导航栏 -->
107
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
108
- <div style="display: flex; gap: 8px; align-items: center;">
109
- ${_backButtonHtml('backdrop-filter:blur(4px)')}
110
  </div>
111
  ${isMe ? `
112
- <div style="display: flex; gap: 10px;">
113
- <button id="btn-open-settings" class="profile-header-btn profile-header-btn--settings">⚙️ ${t('profile.settings')}</button>
114
- <button id="btn-logout" class="profile-header-btn profile-header-btn--logout">🚪 ${t('profile.logout')}</button>
115
  </div>` : ''}
116
  </div>
117
 
118
  <!-- 用户信息区域 -->
119
- <div style="display: flex; align-items: flex-start; gap: 15px; position: relative;">
120
- <img src="${escapeHtml(userData.avatarDataUrl || userData.avatar || PLACEHOLDERS.AVATAR)}" style="width: 80px; height: 80px; border-radius: 50%; border: 3px solid #4CAF50; object-fit: cover; box-shadow: 0 4px 12px rgba(0,0,0,0.4);">
121
- <div style="flex: 1; padding-right: 150px;">
122
- <div style="font-size: 20px; font-weight: bold; margin-bottom: 4px; display: flex; align-items: center; gap: 8px; text-shadow: 0 2px 4px rgba(0,0,0,0.6);">
123
  ${escapeHtml(userData.name)}
124
- <span style="font-size: 12px; background: rgba(68,68,68,0.8); padding: 2px 6px; border-radius: 4px; font-weight: normal; backdrop-filter: blur(4px);">${userData.gender === 'male' ? '♂️' : (userData.gender === 'female' ? '♀️' : '🔒')} ${userData.age !== undefined && userData.age !== null ? escapeHtml(userData.age) : t('profile.unknown_age')}${t('profile.age_years')}</span>
125
  </div>
126
- <div style="font-size: 12px; color: rgba(255,255,255,0.85); margin-bottom: 8px; text-shadow: 0 1px 3px rgba(0,0,0,0.6);">📍 ${escapeHtml(userData.country) || t('profile.location_secret')} - ${escapeHtml(userData.region || '')}</div>
127
- <div style="font-size: 13px; color: rgba(255,255,255,0.95); line-height: 1.4; word-break: break-all; text-shadow: 0 1px 3px rgba(0,0,0,0.6);">${escapeHtml(userData.intro) || t('profile.no_intro')}</div>
128
  </div>
129
- <div style="position: absolute; top: 0; right: 0; width: 135px; display: flex; flex-direction: column; gap: 8px;">${actionButtons}</div>
130
  </div>
131
 
132
  <!-- 统计数据区域 -->
@@ -135,12 +110,12 @@ export function buildProfileHTML(userData, isMe, isSettingsView, isFollowing, fo
135
  </div>
136
  `;
137
 
138
- let tabsHtml = `<div style="display: flex; gap: 10px; margin-bottom: 15px; border-bottom: 1px solid #444; padding-bottom: 5px;">`;
139
  tabs.forEach(tab => {
140
  const isActive = activeTab === tab.id;
141
- tabsHtml += `<button class="profile-tab-btn" data-id="${tab.id}" style="background: transparent; border: none; cursor: pointer; padding: 5px 10px; color: ${isActive ? '#4CAF50' : '#888'}; font-weight: ${isActive ? 'bold' : 'normal'}; border-bottom: ${isActive ? '2px solid #4CAF50' : 'none'};">${tab.label}</button>`;
142
  });
143
- tabsHtml += `</div><div id="profile-list-container" style="flex: 1; overflow-y: auto; padding-right: 5px;"></div>`;
144
 
145
  return headerHtml + tabsHtml;
146
  }
 
12
  import { t } from "../components/用户体验增强.js";
13
  import { escapeHtml } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  /** 生成返回按钮HTML */
16
+ function _backButtonHtml(extraClass = '') {
17
+ return `<button type="button" id="btn-back-profile" class="profile-nav-btn ${extraClass}">⬅ ${t('common.back')}</button>`;
18
  }
19
 
20
  export function buildProfileHTML(userData, isMe, isSettingsView, isFollowing, followingCount, activeTab, tabs) {
 
30
  }
31
  }
32
 
33
+ const hasBannerImage = typeof bannerImageUrl === "string" && bannerImageUrl.trim()
34
+ && !/[\u0000-\u001f\u007f]/.test(bannerImageUrl);
35
+ const bannerStyle = hasBannerImage
36
+ ? `background-image: ${escapeHtml(`url(${JSON.stringify(bannerImageUrl)})`)}; background-size: cover; background-position: center;`
37
+ : ``;
38
 
39
  // 设置视图时返回简化的返回按钮(与个人资料页按钮样式一致,但不显示背景图)
40
  if (isSettingsView && isMe) {
41
+ return _backButtonHtml('profile-nav-btn--settings-return');
42
  }
43
 
44
  const actionButtons = isMe
45
+ ? `<div class="profile-wallet-actions">
46
+ <button type="button" id="btn-wallet" class="profile-action-btn profile-action-btn--primary profile-action-btn--me">💰 ${t('profile.recharge')}</button>
47
+ <button type="button" id="btn-withdraw" class="profile-action-btn profile-action-btn--withdraw profile-action-btn--me">💸 ${t('profile.withdraw')}</button>
48
  </div>`
49
+ : `<button type="button" id="btn-tip-user" class="profile-tip-action profile-action-btn profile-action-btn--primary profile-action-btn--other">🎁 ${t('profile.tip_support')}</button>
50
+ <button type="button" id="btn-send-msg" class="profile-action-btn profile-action-btn--send profile-action-btn--other">✉️ ${t('profile.send_message')}</button>
51
+ <button type="button" id="btn-follow-user" class="profile-follow-action profile-action-btn profile-action-btn--follow profile-action-btn--other">${isFollowing ? `✔️ ${t('profile.followed')}` : `➕ ${t('profile.follow_author')}`}</button>`;
52
 
53
  // 统计数据区域(现在在背景图内)
54
  const statsHtml = `
55
+ <div class="profile-stats">
56
  ${isMe ? `
57
+ <div class="profile-wallet-stats">
58
+ <div class="profile-balance">
59
+ <span>💰 ${t('profile.balance')}: <strong class="profile-balance-amount">${escapeHtml(userData.balance || 0)}</strong></span>
60
  </div>
61
+ <div class="profile-earnings">
62
  <span>🛍️ ${t('profile.sales')}: ${escapeHtml(userData.earn_balance || 0)}</span>
63
+ <span class="profile-earning-item">🎁 ${t('profile.tips')}: ${escapeHtml(userData.tip_balance || 0)}</span>
64
+ <span class="profile-earning-item">📋 ${t('profile.task_earnings')}: ${escapeHtml(userData.task_balance || 0)}</span>
65
  </div>
66
  </div>
67
  ` : ''}
68
+ <div class="profile-counts">
69
+ <span>👍 ${t('profile.received_likes')}: <strong class="profile-count">${escapeHtml(userData.receivedLikes || 0)}</strong></span>
70
+ <span>🔖 ${t('profile.received_favorites')}: <strong class="profile-count">${escapeHtml(userData.receivedFavorites || 0)}</strong></span>
71
+ <button type="button" class="profile-count-link" id="btn-followers">👥 ${t('profile.followers_count')}: <strong class="profile-count">${userData.followers ? userData.followers.length : 0}</strong></button>
72
+ <button type="button" class="profile-count-link" id="btn-following">🏃 ${t('profile.following_count')}: <strong class="profile-count">${escapeHtml(followingCount)}</strong></button>
73
  </div>
74
  </div>
75
  `;
76
 
77
  // 整个头部区域(包括返回按钮、用户信息、统计数据)都在背景图内
78
  const headerHtml = `
79
+ <div class="profile-header">
80
+ <div class="profile-banner" style="${bannerStyle}">
81
  <!-- 顶部导航栏 -->
82
+ <div class="profile-navigation">
83
+ <div class="profile-back-group">
84
+ ${_backButtonHtml()}
85
  </div>
86
  ${isMe ? `
87
+ <div class="profile-account-actions">
88
+ <button type="button" id="btn-open-settings" class="profile-header-btn profile-header-btn--settings">⚙️ ${t('profile.settings')}</button>
89
+ <button type="button" id="btn-logout" class="profile-header-btn profile-header-btn--logout">🚪 ${t('profile.logout')}</button>
90
  </div>` : ''}
91
  </div>
92
 
93
  <!-- 用户信息区域 -->
94
+ <div class="profile-identity">
95
+ <img alt="" class="profile-avatar" src="${escapeHtml(userData.avatarDataUrl || userData.avatar || PLACEHOLDERS.AVATAR)}">
96
+ <div class="profile-bio">
97
+ <div class="profile-name">
98
  ${escapeHtml(userData.name)}
99
+ <span class="profile-age">${userData.gender === 'male' ? '♂️' : (userData.gender === 'female' ? '♀️' : '🔒')} ${userData.age !== undefined && userData.age !== null ? escapeHtml(userData.age) : t('profile.unknown_age')}${t('profile.age_years')}</span>
100
  </div>
101
+ <div class="profile-location">📍 ${escapeHtml(userData.country) || t('profile.location_secret')} - ${escapeHtml(userData.region || '')}</div>
102
+ <div class="profile-intro">${escapeHtml(userData.intro) || t('profile.no_intro')}</div>
103
  </div>
104
+ <div class="profile-actions">${actionButtons}</div>
105
  </div>
106
 
107
  <!-- 统计数据区域 -->
 
110
  </div>
111
  `;
112
 
113
+ let tabsHtml = `<div class="profile-tabs">`;
114
  tabs.forEach(tab => {
115
  const isActive = activeTab === tab.id;
116
+ tabsHtml += `<button type="button" class="profile-tab-btn" data-id="${tab.id}" aria-pressed="${isActive}">${tab.label}</button>`;
117
  });
118
+ tabsHtml += `</div><div class="profile-list" id="profile-list-container"></div>`;
119
 
120
  return headerHtml + tabsHtml;
121
  }
前端页面/profile/个人列表组件.js CHANGED
@@ -1,5 +1,6 @@
1
  // 前端页面/profile/个人列表组件.js
2
  import { api } from "../core/网络请求API.js";
 
3
  import { createItemCard } from "../market/列表卡片组件.js";
4
  import { getAcquiredItems } from "../market/资源安装引擎.js";
5
  import { showToast } from "../components/UI交互提示组件.js";
@@ -243,14 +244,12 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
243
  };
244
 
245
  const listDiv = document.createElement("div");
246
- listDiv.style.display = "flex";
247
- listDiv.style.flexDirection = "column";
248
- listDiv.style.gap = "10px";
249
 
250
  // 渲染我发布的任务
251
  if (publishedTasks.length > 0) {
252
  const sectionTitle = document.createElement("div");
253
- sectionTitle.style.cssText = "font-size: 13px; font-weight: bold; color: #FF9800; margin-bottom: 5px; padding: 5px 0;";
254
  sectionTitle.textContent = `📤 ${t('profile.published_tasks')} (${publishedTasks.length})`;
255
  listDiv.appendChild(sectionTitle);
256
 
@@ -263,7 +262,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
263
  // 渲染我接的任务
264
  if (assignedTasks.length > 0) {
265
  const sectionTitle = document.createElement("div");
266
- sectionTitle.style.cssText = "font-size: 13px; font-weight: bold; color: #2196F3; margin-bottom: 5px; padding: 5px 0; margin-top: 15px;";
267
  sectionTitle.textContent = `📥 ${t('profile.assigned_tasks')} (${assignedTasks.length})`;
268
  listDiv.appendChild(sectionTitle);
269
 
@@ -304,7 +303,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
304
  const salesStats = salesStatsRes.data || {};
305
 
306
  const containerDiv = document.createElement("div");
307
- containerDiv.style.cssText = "display: flex; flex-direction: column; gap: 15px;";
308
 
309
  // 💰 销售统计卡片(放在最上方,与打赏统计样式一致)
310
  // 🔧 下方销售/打赏两处的 profile.transactions_unit 不再加 || '笔' 兜底:
@@ -342,7 +341,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
342
 
343
  // 📝 交易明细列表
344
  const txListDiv = document.createElement("div");
345
- txListDiv.style.cssText = "display: flex; flex-direction: column; gap: 8px;";
346
 
347
  if (transactions.length === 0) {
348
  txListDiv.innerHTML = _renderEmptyState('💰', t('profile.no_transactions'));
@@ -384,9 +383,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
384
  }
385
 
386
  const listDiv = document.createElement("div");
387
- listDiv.style.display = "flex";
388
- listDiv.style.flexDirection = "column";
389
- listDiv.style.gap = "10px";
390
 
391
  posts.forEach(post => {
392
  const postDiv = createPostItem(post, currentUser);
@@ -419,9 +416,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
419
  }
420
 
421
  const listDiv = document.createElement("div");
422
- listDiv.style.display = "flex";
423
- listDiv.style.flexDirection = "column";
424
- listDiv.style.gap = "10px";
425
 
426
  prompts.forEach(prompt => {
427
  const promptDiv = createPromptItem(prompt, currentUser);
@@ -453,11 +448,11 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
453
  return;
454
  }
455
  const listDiv = document.createElement("div");
456
- listDiv.style.cssText = "display:flex;flex-direction:column;gap:10px;";
457
 
458
  if (items && items.length > 0) {
459
  const sectionTitle = document.createElement("div");
460
- sectionTitle.style.cssText = "font-size: 13px; font-weight: bold; color: #FF9800; margin-bottom: 5px; padding: 5px 0;";
461
  sectionTitle.textContent = `📦 ${t('profile.collected_items')} (${items.length})`;
462
  listDiv.appendChild(sectionTitle);
463
  items.forEach(item => { listDiv.appendChild(createItemCard(item, currentUser)); });
@@ -465,7 +460,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
465
 
466
  if (posts && posts.length > 0) {
467
  const sectionTitle = document.createElement("div");
468
- sectionTitle.style.cssText = "font-size: 13px; font-weight: bold; color: #9C27B0; margin-bottom: 5px; padding: 5px 0;" + (items && items.length > 0 ? " margin-top: 15px;" : "");
469
  sectionTitle.textContent = `💬 ${t('profile.collected_posts')} (${posts.length})`;
470
  listDiv.appendChild(sectionTitle);
471
  posts.forEach(post => { listDiv.appendChild(createPostItem(post, currentUser)); });
@@ -473,7 +468,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
473
 
474
  if (prompts && prompts.length > 0) {
475
  const sectionTitle = document.createElement("div");
476
- sectionTitle.style.cssText = "font-size: 13px; font-weight: bold; color: #00BCD4; margin-bottom: 5px; padding: 5px 0;" + ((items && items.length > 0) || (posts && posts.length > 0) ? " margin-top: 15px;" : "");
477
  sectionTitle.textContent = `🧩 ${t('profile.collected_prompts')} (${prompts.length})`;
478
  listDiv.appendChild(sectionTitle);
479
  prompts.forEach(prompt => { listDiv.appendChild(createPromptItem(prompt, currentUser)); });
@@ -582,15 +577,16 @@ function _renderEmptyState(icon, title, subtitle = '') {
582
  */
583
  function _createStatsCard(title, stats, extraStyle = '') {
584
  const card = document.createElement('div');
585
- card.style.cssText = `background: linear-gradient(135deg, var(--comfy-input-bg), #1a1a1a); border-radius: 12px; padding: 15px; border: 1px solid #444;${extraStyle}`;
 
586
  const colsHtml = stats.map(s => `
587
- <div style="text-align: center; padding: 10px; background: rgba(${s.bgRgb || '76,175,80'},0.1); border-radius: 8px;">
588
- <div style="font-size: 20px; font-weight: bold; color: ${s.color};">${s.value}</div>
589
- <div style="font-size: 11px; color: #888;">${s.label}</div>
590
  </div>`).join('');
591
  card.innerHTML = `
592
- <div style="font-size: 14px; font-weight: bold; color: #fff; margin-bottom: 12px;">${title}</div>
593
- <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;">${colsHtml}</div>
594
  `;
595
  return card;
596
  }
@@ -599,35 +595,26 @@ function _createStatsCard(title, stats, extraStyle = '') {
599
  * 📝 创建任务卡片项
600
  */
601
  function createTaskItem(task, statusColors, statusLabels, currentUser) {
602
- const taskDiv = document.createElement("div");
603
- Object.assign(taskDiv.style, {
604
- padding: "12px",
605
- background: "var(--comfy-input-bg)",
606
- borderRadius: "8px",
607
- border: "1px solid #444",
608
- cursor: "pointer",
609
- transition: "0.2s"
610
- });
611
 
612
- const statusColor = statusColors[task.status] || "#666";
 
613
  const statusLabel = statusLabels[task.status] || t('task.status_unknown');
614
 
615
  taskDiv.innerHTML = `
616
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
617
- <span style="background: ${statusColor}; color: #fff; padding: 2px 8px; border-radius: 4px; font-size: 11px;">${statusLabel}</span>
618
- <span style="color: #FF9800; font-weight: bold;">💰 ${task.total_price} ${t('task.points')}</span>
619
  </div>
620
- <div style="font-weight: bold; color: #eee; margin-bottom: 6px; font-size: 14px;">${escapeHtml(task.title)}</div>
621
- <div style="font-size: 11px; color: #888; display: flex; gap: 15px;">
622
- <span>✈️ ${formatDeadline(task.deadline)}</span>
623
- ${task.status === "open" ? `<span>👥 ${(task.applicants || []).length} ${t('task.applicants')}</span>` : ""}
624
  </div>
625
  `;
626
 
627
- // 悬停效果
628
- taskDiv.onmouseover = () => { taskDiv.style.borderColor = "#666"; taskDiv.style.transform = "translateX(3px)"; };
629
- taskDiv.onmouseout = () => { taskDiv.style.borderColor = "#444"; taskDiv.style.transform = "translateX(0)"; };
630
-
631
  // 点击进入详情
632
  taskDiv.onclick = () => {
633
  import("../task/任务详情组件.js").then(module => {
@@ -661,15 +648,9 @@ function formatDeadline(deadline) {
661
  * 💬 创建帖子卡片项
662
  */
663
  function createPostItem(post, currentUser) {
664
- const postDiv = document.createElement("div");
665
- Object.assign(postDiv.style, {
666
- padding: "12px",
667
- background: "var(--comfy-input-bg)",
668
- borderRadius: "8px",
669
- border: "1px solid #444",
670
- cursor: "pointer",
671
- transition: "all 0.2s"
672
- });
673
 
674
  // 截取内容摘要
675
  const contentPreview = (post.content || "").slice(0, 80) + ((post.content || "").length > 80 ? "..." : "");
@@ -688,39 +669,31 @@ function createPostItem(post, currentUser) {
688
  }
689
 
690
  postDiv.innerHTML = `
691
- <div style="display: flex; align-items: flex-start; gap: 10px;">
692
  ${coverImageUrl ? `
693
- <div style="position: relative; flex-shrink: 0;">
694
- <img src="${escapeHtml(coverImageUrl)}" style="width: 60px; height: 60px; object-fit: cover; border-radius: 6px; display: block;">
695
  ${isVideo ? `
696
- <div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
697
- width: 28px; height: 28px; background: rgba(0,0,0,0.6); border-radius: 50%;
698
- display: flex; align-items: center; justify-content: center; pointer-events: none;">
699
- <div style="width: 0; height: 0; border-left: 10px solid rgba(255,255,255,0.9);
700
- border-top: 6px solid transparent; border-bottom: 6px solid transparent;
701
- margin-left: 2px;"></div>
702
  </div>
703
  ` : ''}
704
  </div>
705
  ` : ""}
706
- <div style="flex: 1; min-width: 0;">
707
- <div style="font-size: 14px; color: #fff; margin-bottom: 6px; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;">
708
  ${escapeHtml(contentPreview)}
709
  </div>
710
- <div style="display: flex; align-items: center; gap: 12px; font-size: 11px; color: #888;">
711
- <span>❤️ ${post.like_count || 0}</span>
712
- <span>💬 ${post.comment_count || 0}</span>
713
  ${isVideo ? `<span>🎬 ${t('post.video_tag')}</span>` : ((post.images || []).length > 1 ? `<span>🖼️ ${(post.images || []).length}${t('post.images_unit')}</span>` : "")}
714
- <span style="margin-left: auto;">${timeStr}</span>
715
  </div>
716
  </div>
717
  </div>
718
  `;
719
 
720
- // 悬停效果(使用 mouseenter/mouseleave 避免子元素边界闪烁)
721
- postDiv.onmouseenter = () => { postDiv.style.borderColor = "#9C27B0"; postDiv.style.transform = "translateX(2px)"; };
722
- postDiv.onmouseleave = () => { postDiv.style.borderColor = "#444"; postDiv.style.transform = "translateX(0)"; };
723
-
724
  // 点击进入详情
725
  postDiv.onclick = () => {
726
  import("../post/帖子详情组件.js").then(module => {
@@ -739,15 +712,9 @@ function createPostItem(post, currentUser) {
739
  * 🧩 创建提示词卡片项(复刻 createPostItem 模式)
740
  */
741
  function createPromptItem(prompt, currentUser) {
742
- const promptDiv = document.createElement("div");
743
- Object.assign(promptDiv.style, {
744
- padding: "12px",
745
- background: "var(--comfy-input-bg)",
746
- borderRadius: "8px",
747
- border: "1px solid #444",
748
- cursor: "pointer",
749
- transition: "all 0.2s"
750
- });
751
 
752
  // 时间格式化(复用帖子时间格式函数)
753
  const timeStr = formatPostTime(prompt.created_at);
@@ -757,46 +724,39 @@ function createPromptItem(prompt, currentUser) {
757
  const coverImageUrl = prompt.cover_image || null;
758
 
759
  // 价格徽标
760
- const priceBadge = (prompt.price > 0)
761
- ? `<span style="background: rgba(255,193,7,0.15); color: #FFC107; padding: 1px 6px; border-radius: 4px; font-size: 10px;">💎 ${prompt.price}</span>`
762
- : `<span style="background: rgba(76,175,80,0.15); color: #4CAF50; padding: 1px 6px; border-radius: 4px; font-size: 10px;">${t('prompt.free')}</span>`;
 
763
 
764
  promptDiv.innerHTML = `
765
- <div style="display: flex; align-items: flex-start; gap: 10px;">
766
  ${coverImageUrl ? `
767
- <div style="position: relative; flex-shrink: 0;">
768
- <img src="${escapeHtml(coverImageUrl)}" style="width: 60px; height: 60px; object-fit: cover; border-radius: 6px; display: block;">
769
  ${isVideo ? `
770
- <div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
771
- width: 28px; height: 28px; background: rgba(0,0,0,0.6); border-radius: 50%;
772
- display: flex; align-items: center; justify-content: center; pointer-events: none;">
773
- <div style="width: 0; height: 0; border-left: 10px solid rgba(255,255,255,0.9);
774
- border-top: 6px solid transparent; border-bottom: 6px solid transparent;
775
- margin-left: 2px;"></div>
776
  </div>
777
  ` : ''}
778
  </div>
779
  ` : ""}
780
- <div style="flex: 1; min-width: 0;">
781
- <div style="font-size: 14px; color: #fff; margin-bottom: 6px; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;">
782
  ${escapeHtml(prompt.title || '')}
783
  </div>
784
- <div style="display: flex; align-items: center; gap: 8px; font-size: 11px; color: #888; flex-wrap: wrap;">
785
  ${priceBadge}
786
- ${prompt.category ? `<span style="background: rgba(0,188,212,0.12); color: #00BCD4; padding: 1px 6px; border-radius: 4px; font-size: 10px;">${escapeHtml(tIfExists('promptcat.' + prompt.category, prompt.category))}</span>` : ''}
787
- <span>❤️ ${prompt.likes || 0}</span>
788
- <span>⭐ ${prompt.favorites || 0}</span>
789
- <span>👁️ ${prompt.views || 0}</span>
790
- <span style="margin-left: auto;">${timeStr}</span>
791
  </div>
792
  </div>
793
  </div>
794
  `;
795
 
796
- // 悬停效果(青色主题,与提示词 Tab 配色一致)
797
- promptDiv.onmouseenter = () => { promptDiv.style.borderColor = "#00BCD4"; promptDiv.style.transform = "translateX(2px)"; };
798
- promptDiv.onmouseleave = () => { promptDiv.style.borderColor = "#444"; promptDiv.style.transform = "translateX(0)"; };
799
-
800
  // 点击进入提示词详情
801
  promptDiv.onclick = () => {
802
  import("../prompt/提示词详情组件.js").then(module => {
@@ -833,10 +793,9 @@ function formatPostTime(timestamp) {
833
  */
834
  function createTransactionCard(tx) {
835
  const txDiv = document.createElement("div");
836
- txDiv.style.cssText = "padding: 12px; background: var(--comfy-input-bg); border-radius: 8px; border: 1px solid #444; display: flex; justify-content: space-between; align-items: flex-start;";
837
 
838
  const isPositive = tx.amount > 0;
839
- const amountColor = isPositive ? '#4CAF50' : '#F44336';
840
  const timeStr = tx.created_at ? new Date(tx.created_at).toLocaleString(getLanguage(), { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '';
841
 
842
  // 获取交易类型配置
@@ -945,31 +904,31 @@ function createTransactionCard(tx) {
945
 
946
  // 资源标题(如果有)
947
  const itemTitle = tx.item_title && tx.tx_type !== 'TIP_IN' && tx.tx_type !== 'TIP_OUT'
948
- ? `<span style="color: #888; margin-left: 6px;">- ${escapeHtml(tx.item_title)}</span>`
949
  : '';
950
 
951
  // 实到金额(仅提现类型)
952
  const netAmountHtml = (tx.tx_type === 'WITHDRAW' && tx.net_amount !== null && tx.net_amount !== undefined)
953
- ? `<div style="font-size: 11px; color: #888; text-align: right;">${t('tx.net_amount')}: ${tx.net_amount}</div>`
954
  : '';
955
 
956
  if (hasDetailedInfo) {
957
  // 详细展示模式
958
  txDiv.innerHTML = `
959
- <div style="display: flex; align-items: flex-start; gap: 10px; flex: 1; min-width: 0;">
960
- <span style="font-size: 20px; flex-shrink: 0;">${typeConfig.icon}</span>
961
- <div style="flex: 1; min-width: 0;">
962
- <div style="display: flex; align-items: center; flex-wrap: wrap;">
963
- <span style="font-weight: bold; color: #eee; font-size: 13px;">${typeConfig.title}</span>
964
  ${itemTitle}
965
  </div>
966
- ${description ? `<div style="font-size: 11px; color: #aaa; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(description)}</div>` : ''}
967
- ${detailText ? `<div style="font-size: 11px; color: #888; margin-top: 3px;">${escapeHtml(detailText)}</div>` : ''}
968
  </div>
969
  </div>
970
- <div style="text-align: right; flex-shrink: 0; margin-left: 10px;">
971
- <div style="font-weight: bold; font-size: 15px; color: ${amountColor};">
972
- ${isPositive ? '+' : ''}${tx.amount}
973
  </div>
974
  ${netAmountHtml}
975
  </div>
@@ -977,15 +936,15 @@ function createTransactionCard(tx) {
977
  } else {
978
  // 简洁展示模式(向后兼容旧数据)
979
  txDiv.innerHTML = `
980
- <div style="display: flex; align-items: center; gap: 10px;">
981
- <span style="font-size: 20px;">${typeConfig.icon}</span>
982
  <div>
983
- <div style="font-weight: bold; color: #eee; font-size: 13px;">${typeConfig.title}</div>
984
- <div style="font-size: 11px; color: #888;">${timeStr}${tx.related_account ? ' · ' + escapeHtml(tx.related_account.substring(0, 8)) + '...' : ''}</div>
985
  </div>
986
  </div>
987
- <div style="font-weight: bold; font-size: 15px; color: ${amountColor};">
988
- ${isPositive ? '+' : ''}${tx.amount}
989
  </div>
990
  `;
991
  }
 
1
  // 前端页面/profile/个人列表组件.js
2
  import { api } from "../core/网络请求API.js";
3
+ import { getPromptPriceState } from "../prompt/价格校验.js";
4
  import { createItemCard } from "../market/列表卡片组件.js";
5
  import { getAcquiredItems } from "../market/资源安装引擎.js";
6
  import { showToast } from "../components/UI交互提示组件.js";
 
244
  };
245
 
246
  const listDiv = document.createElement("div");
247
+ listDiv.className = 'profile-content-list';
 
 
248
 
249
  // 渲染我发布的任务
250
  if (publishedTasks.length > 0) {
251
  const sectionTitle = document.createElement("div");
252
+ sectionTitle.className = 'profile-list-section-title';
253
  sectionTitle.textContent = `📤 ${t('profile.published_tasks')} (${publishedTasks.length})`;
254
  listDiv.appendChild(sectionTitle);
255
 
 
262
  // 渲染我接的任务
263
  if (assignedTasks.length > 0) {
264
  const sectionTitle = document.createElement("div");
265
+ sectionTitle.className = 'profile-list-section-title';
266
  sectionTitle.textContent = `📥 ${t('profile.assigned_tasks')} (${assignedTasks.length})`;
267
  listDiv.appendChild(sectionTitle);
268
 
 
303
  const salesStats = salesStatsRes.data || {};
304
 
305
  const containerDiv = document.createElement("div");
306
+ containerDiv.className = 'profile-transactions';
307
 
308
  // 💰 销售统计卡片(放在最上方,与打赏统计样式一致)
309
  // 🔧 下方销售/打赏两处的 profile.transactions_unit 不再加 || '笔' 兜底:
 
341
 
342
  // 📝 交易明细列表
343
  const txListDiv = document.createElement("div");
344
+ txListDiv.className = 'profile-transaction-list';
345
 
346
  if (transactions.length === 0) {
347
  txListDiv.innerHTML = _renderEmptyState('💰', t('profile.no_transactions'));
 
383
  }
384
 
385
  const listDiv = document.createElement("div");
386
+ listDiv.className = 'profile-content-list';
 
 
387
 
388
  posts.forEach(post => {
389
  const postDiv = createPostItem(post, currentUser);
 
416
  }
417
 
418
  const listDiv = document.createElement("div");
419
+ listDiv.className = 'profile-content-list';
 
 
420
 
421
  prompts.forEach(prompt => {
422
  const promptDiv = createPromptItem(prompt, currentUser);
 
448
  return;
449
  }
450
  const listDiv = document.createElement("div");
451
+ listDiv.className = 'profile-content-list';
452
 
453
  if (items && items.length > 0) {
454
  const sectionTitle = document.createElement("div");
455
+ sectionTitle.className = 'profile-list-section-title';
456
  sectionTitle.textContent = `📦 ${t('profile.collected_items')} (${items.length})`;
457
  listDiv.appendChild(sectionTitle);
458
  items.forEach(item => { listDiv.appendChild(createItemCard(item, currentUser)); });
 
460
 
461
  if (posts && posts.length > 0) {
462
  const sectionTitle = document.createElement("div");
463
+ sectionTitle.className = 'profile-list-section-title';
464
  sectionTitle.textContent = `💬 ${t('profile.collected_posts')} (${posts.length})`;
465
  listDiv.appendChild(sectionTitle);
466
  posts.forEach(post => { listDiv.appendChild(createPostItem(post, currentUser)); });
 
468
 
469
  if (prompts && prompts.length > 0) {
470
  const sectionTitle = document.createElement("div");
471
+ sectionTitle.className = 'profile-list-section-title';
472
  sectionTitle.textContent = `🧩 ${t('profile.collected_prompts')} (${prompts.length})`;
473
  listDiv.appendChild(sectionTitle);
474
  prompts.forEach(prompt => { listDiv.appendChild(createPromptItem(prompt, currentUser)); });
 
577
  */
578
  function _createStatsCard(title, stats, extraStyle = '') {
579
  const card = document.createElement('div');
580
+ card.className = extraStyle === ' margin-bottom: 15px;' ? 'profile-stats-card profile-stats-card--spaced' : 'profile-stats-card';
581
+ const tones = { '#4CAF50': 'income', '#F44336': 'expense', '#2196F3': 'net' };
582
  const colsHtml = stats.map(s => `
583
+ <div class="profile-stats-card__cell profile-stats-card__cell--${Object.hasOwn(tones, s.color) ? tones[s.color] : 'net'}">
584
+ <div class="profile-stats-card__value">${escapeHtml(String(s.value))}</div>
585
+ <div class="profile-stats-card__label">${escapeHtml(String(s.label))}</div>
586
  </div>`).join('');
587
  card.innerHTML = `
588
+ <div class="profile-stats-card__title">${escapeHtml(String(title))}</div>
589
+ <div class="profile-stats-card__grid">${colsHtml}</div>
590
  `;
591
  return card;
592
  }
 
595
  * 📝 创建任务卡片项
596
  */
597
  function createTaskItem(task, statusColors, statusLabels, currentUser) {
598
+ const taskDiv = document.createElement("button");
599
+ taskDiv.type = 'button';
600
+ taskDiv.className = 'profile-task-card';
 
 
 
 
 
 
601
 
602
+ const statusTones = { '#4CAF50': 'open', '#2196F3': 'active', '#FF9800': 'pending', '#F44336': 'disputed' };
603
+ const statusTone = Object.hasOwn(statusTones, statusColors[task.status]) ? statusTones[statusColors[task.status]] : 'neutral';
604
  const statusLabel = statusLabels[task.status] || t('task.status_unknown');
605
 
606
  taskDiv.innerHTML = `
607
+ <div class="profile-task-card__summary">
608
+ <span class="profile-task-card__status profile-task-card__status--${statusTone}">${escapeHtml(String(statusLabel))}</span>
609
+ <span class="profile-task-card__price">💰 ${escapeHtml(String(task.total_price))} ${t('task.points')}</span>
610
  </div>
611
+ <div class="profile-list-card__title">${escapeHtml(task.title)}</div>
612
+ <div class="profile-list-card__meta">
613
+ <span>✈️ ${escapeHtml(String(formatDeadline(task.deadline)))}</span>
614
+ ${task.status === "open" ? `<span>👥 ${escapeHtml(String((task.applicants || []).length))} ${t('task.applicants')}</span>` : ""}
615
  </div>
616
  `;
617
 
 
 
 
 
618
  // 点击进入详情
619
  taskDiv.onclick = () => {
620
  import("../task/任务详情组件.js").then(module => {
 
648
  * 💬 创建帖子卡片项
649
  */
650
  function createPostItem(post, currentUser) {
651
+ const postDiv = document.createElement("button");
652
+ postDiv.type = 'button';
653
+ postDiv.className = 'profile-entry-card';
 
 
 
 
 
 
654
 
655
  // 截取内容摘要
656
  const contentPreview = (post.content || "").slice(0, 80) + ((post.content || "").length > 80 ? "..." : "");
 
669
  }
670
 
671
  postDiv.innerHTML = `
672
+ <div class="profile-entry-card__row">
673
  ${coverImageUrl ? `
674
+ <div class="profile-entry-card__media">
675
+ <img src="${escapeHtml(coverImageUrl)}" class="profile-entry-card__image">
676
  ${isVideo ? `
677
+ <div class="profile-entry-card__play">
678
+ <div class="profile-entry-card__play-triangle"></div>
 
 
 
 
679
  </div>
680
  ` : ''}
681
  </div>
682
  ` : ""}
683
+ <div class="profile-list-card__body">
684
+ <div class="profile-list-card__title">
685
  ${escapeHtml(contentPreview)}
686
  </div>
687
+ <div class="profile-list-card__meta">
688
+ <span>❤️ ${escapeHtml(String(post.like_count || 0))}</span>
689
+ <span>💬 ${escapeHtml(String(post.comment_count || 0))}</span>
690
  ${isVideo ? `<span>🎬 ${t('post.video_tag')}</span>` : ((post.images || []).length > 1 ? `<span>🖼️ ${(post.images || []).length}${t('post.images_unit')}</span>` : "")}
691
+ <span class="profile-list-card__time">${escapeHtml(String(timeStr))}</span>
692
  </div>
693
  </div>
694
  </div>
695
  `;
696
 
 
 
 
 
697
  // 点击进入详情
698
  postDiv.onclick = () => {
699
  import("../post/帖子详情组件.js").then(module => {
 
712
  * 🧩 创建提示词卡片项(复刻 createPostItem 模式)
713
  */
714
  function createPromptItem(prompt, currentUser) {
715
+ const promptDiv = document.createElement("button");
716
+ promptDiv.type = 'button';
717
+ promptDiv.className = 'profile-entry-card';
 
 
 
 
 
 
718
 
719
  // 时间格式化(复用帖子时间格式函数)
720
  const timeStr = formatPostTime(prompt.created_at);
 
724
  const coverImageUrl = prompt.cover_image || null;
725
 
726
  // 价格徽标
727
+ const priceState = getPromptPriceState(prompt.price);
728
+ const priceBadge = priceState !== 'free'
729
+ ? `<span class="profile-list-badge profile-list-badge--price">💎 ${escapeHtml(priceState === 'paid' ? String(prompt.price) : t('common.unknown'))}</span>`
730
+ : `<span class="profile-list-badge profile-list-badge--free">${t('prompt.free')}</span>`;
731
 
732
  promptDiv.innerHTML = `
733
+ <div class="profile-entry-card__row">
734
  ${coverImageUrl ? `
735
+ <div class="profile-entry-card__media">
736
+ <img src="${escapeHtml(coverImageUrl)}" class="profile-entry-card__image">
737
  ${isVideo ? `
738
+ <div class="profile-entry-card__play">
739
+ <div class="profile-entry-card__play-triangle"></div>
 
 
 
 
740
  </div>
741
  ` : ''}
742
  </div>
743
  ` : ""}
744
+ <div class="profile-list-card__body">
745
+ <div class="profile-list-card__title">
746
  ${escapeHtml(prompt.title || '')}
747
  </div>
748
+ <div class="profile-list-card__meta">
749
  ${priceBadge}
750
+ ${prompt.category ? `<span class="profile-list-badge">${escapeHtml(tIfExists('promptcat.' + prompt.category, prompt.category))}</span>` : ''}
751
+ <span>❤️ ${escapeHtml(String(prompt.likes || 0))}</span>
752
+ <span>⭐ ${escapeHtml(String(prompt.favorites || 0))}</span>
753
+ <span>👁️ ${escapeHtml(String(prompt.views || 0))}</span>
754
+ <span class="profile-list-card__time">${escapeHtml(String(timeStr))}</span>
755
  </div>
756
  </div>
757
  </div>
758
  `;
759
 
 
 
 
 
760
  // 点击进入提示词详情
761
  promptDiv.onclick = () => {
762
  import("../prompt/提示词详情组件.js").then(module => {
 
793
  */
794
  function createTransactionCard(tx) {
795
  const txDiv = document.createElement("div");
796
+ txDiv.className = 'profile-transaction-card';
797
 
798
  const isPositive = tx.amount > 0;
 
799
  const timeStr = tx.created_at ? new Date(tx.created_at).toLocaleString(getLanguage(), { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '';
800
 
801
  // 获取交易类型配置
 
904
 
905
  // 资源标题(如果有)
906
  const itemTitle = tx.item_title && tx.tx_type !== 'TIP_IN' && tx.tx_type !== 'TIP_OUT'
907
+ ? `<span class="profile-transaction-card__item">- ${escapeHtml(tx.item_title)}</span>`
908
  : '';
909
 
910
  // 实到金额(仅提现类型)
911
  const netAmountHtml = (tx.tx_type === 'WITHDRAW' && tx.net_amount !== null && tx.net_amount !== undefined)
912
+ ? `<div class="profile-transaction-card__net">${t('tx.net_amount')}: ${escapeHtml(String(tx.net_amount))}</div>`
913
  : '';
914
 
915
  if (hasDetailedInfo) {
916
  // 详细展示模式
917
  txDiv.innerHTML = `
918
+ <div class="profile-transaction-card__summary">
919
+ <span class="profile-transaction-card__icon">${typeConfig.icon}</span>
920
+ <div class="profile-list-card__body">
921
+ <div class="profile-transaction-card__heading">
922
+ <span class="profile-transaction-card__title">${escapeHtml(String(typeConfig.title))}</span>
923
  ${itemTitle}
924
  </div>
925
+ ${description ? `<div class="profile-transaction-card__description">${escapeHtml(description)}</div>` : ''}
926
+ ${detailText ? `<div class="profile-transaction-card__detail">${escapeHtml(detailText)}</div>` : ''}
927
  </div>
928
  </div>
929
+ <div class="profile-transaction-card__money">
930
+ <div class="profile-transaction-card__amount ${isPositive ? 'profile-transaction-card__amount--income' : 'profile-transaction-card__amount--expense'}">
931
+ ${isPositive ? '+' : ''}${escapeHtml(String(tx.amount))}
932
  </div>
933
  ${netAmountHtml}
934
  </div>
 
936
  } else {
937
  // 简洁展示模式(向后兼容旧数据)
938
  txDiv.innerHTML = `
939
+ <div class="profile-transaction-card__summary">
940
+ <span class="profile-transaction-card__icon">${typeConfig.icon}</span>
941
  <div>
942
+ <div class="profile-transaction-card__title">${escapeHtml(String(typeConfig.title))}</div>
943
+ <div class="profile-transaction-card__detail">${escapeHtml(String(timeStr))}${tx.related_account ? ' · ' + escapeHtml(tx.related_account.substring(0, 8)) + '...' : ''}</div>
944
  </div>
945
  </div>
946
+ <div class="profile-transaction-card__amount ${isPositive ? 'profile-transaction-card__amount--income' : 'profile-transaction-card__amount--expense'}">
947
+ ${isPositive ? '+' : ''}${escapeHtml(String(tx.amount))}
948
  </div>
949
  `;
950
  }
前端页面/profile/个人设置表单组件.js CHANGED
@@ -16,6 +16,11 @@ function _safeLocalStorageRemove(key) {
16
  try { window.localStorage.removeItem(key); return true; } catch { return false; }
17
  }
18
 
 
 
 
 
 
19
  // 计算年龄工具函数
20
  function calculateAge(birthDate) {
21
  if (!birthDate) return null;
@@ -101,7 +106,7 @@ function _readAndPreviewImage(file, previewElement, cacheKey, current, readers,
101
  }
102
  }
103
  }
104
- previewElement.style.backgroundImage = `url(${base64})`;
105
  previewElement.style.backgroundSize = "cover";
106
  previewElement.style.backgroundPosition = "center";
107
  finish(base64);
@@ -147,85 +152,85 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
147
  const countryOptions = sortedCountries.map(c => `<option value="${c}" ${c === userData.country ? 'selected' : ''}>${c}</option>`).join("");
148
 
149
  container.innerHTML = `
150
- <div style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #444; font-weight: bold; font-size: 16px;">⚙️ ${t('settings_form.title')}</div>
151
- <div style="display: flex; flex-direction: column; gap: 10px; margin-bottom: 20px;">
152
- <div style="display: flex; align-items: center; gap: 15px; margin-bottom: 5px; background: var(--comfy-input-bg); padding: 10px; border-radius: 6px; border: 1px dashed #555;">
153
- <img id="setting-avatar-preview" src="${escapeHtml(userData.avatarDataUrl || PLACEHOLDERS.AVATAR)}" style="width: 60px; height: 60px; border-radius: 50%; object-fit: cover; border: 2px solid #666;">
154
  <div>
155
- <button id="btn-trigger-avatar" style="padding: 6px 12px; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; border-radius: 4px; cursor: pointer; font-size: 12px; margin-bottom: 5px;">${t('settings_form.change_avatar')}</button>
156
- <div style="font-size: 11px; color: #888;">${t('settings_form.avatar_hint')}</div>
157
  </div>
158
- <input type="file" id="setting-avatar" accept="image/*" style="display: none;">
159
  </div>
160
 
161
- <div style="background: var(--comfy-input-bg); padding: 10px; border-radius: 6px; border: 1px dashed #555;">
162
- <div style="font-size: 12px; color: #aaa; margin-bottom: 8px;">🇺️ ${t('settings_form.banner_title')}</div>
163
- <div id="setting-banner-preview" style="width: 100%; height: 80px; border-radius: 4px; background: ${userData.bannerUrl ? `url(${String(userData.bannerUrl).replace(/['"()\\]/g, '')})` : '#1a1a1a'}; background-size: cover; background-position: center; margin-bottom: 8px; border: 1px solid #444;"></div>
164
- <div style="display: flex; gap: 8px;">
165
- <button id="btn-trigger-banner" style="flex: 1; padding: 6px 12px; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; border-radius: 4px; cursor: pointer; font-size: 12px;">📷 ${t('settings_form.banner_upload')}</button>
166
- <button id="btn-clear-banner" style="padding: 6px 12px; background: transparent; border: 1px solid #555; color: #888; border-radius: 4px; cursor: pointer; font-size: 12px;">🗑️ ${t('settings_form.banner_clear')}</button>
167
  </div>
168
- <div style="font-size: 11px; color: #666; margin-top: 5px;">${t('settings_form.banner_hint')}</div>
169
- <input type="file" id="setting-banner" accept="image/*" style="display: none;">
170
  </div>
171
 
172
- <div style="background: var(--comfy-input-bg); padding: 10px; border-radius: 6px; border: 1px dashed #555;">
173
- <div style="font-size: 12px; color: #aaa; margin-bottom: 8px;">🎨 ${t('settings_form.ui_bg_title')}</div>
174
- <div id="setting-ui-bg-preview" style="width: 100%; height: 100px; border-radius: 4px; background: #1a1a1a; background-size: cover; background-position: center; margin-bottom: 8px; border: 1px solid #444;"></div>
175
- <div style="display: flex; gap: 8px;">
176
- <button id="btn-trigger-ui-bg" style="flex: 1; padding: 6px 12px; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; border-radius: 4px; cursor: pointer; font-size: 12px;">📷 ${t('settings_form.banner_upload')}</button>
177
- <button id="btn-clear-ui-bg" style="padding: 6px 12px; background: transparent; border: 1px solid #555; color: #888; border-radius: 4px; cursor: pointer; font-size: 12px;">🗑️ ${t('settings_form.banner_clear')}</button>
178
  </div>
179
- <div style="font-size: 11px; color: #666; margin-top: 5px;">${t('settings_form.ui_bg_hint')}</div>
180
- <input type="file" id="setting-ui-bg" accept="image/*" style="display: none;">
181
  </div>
182
 
183
- <div><label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">${t('settings_form.display_name')}</label><input type="text" id="setting-name" value="${escapeHtml(userData.name || '')}" style="width: 100%; padding: 8px; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; border-radius: 4px; box-sizing: border-box;"></div>
184
 
185
- <div style="display: flex; gap: 10px;">
186
- <div style="flex: 1;"><label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">${t('settings_form.gender')}</label>
187
- <select id="setting-gender" style="width: 100%; padding: 8px; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; border-radius: 4px;">
188
  <option value="male" ${userData.gender === 'male' ? 'selected' : ''}>${t('settings_form.gender_male')}</option>
189
  <option value="female" ${userData.gender === 'female' ? 'selected' : ''}>${t('settings_form.gender_female')}</option>
190
  <option value="other" ${userData.gender === 'other' ? 'selected' : ''}>${t('settings_form.gender_secret')}</option>
191
  </select>
192
  </div>
193
- <div style="flex: 1;">
194
- <label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">${t('settings_form.birthday')}</label>
195
- <div style="display: flex; align-items: center; gap: 8px;">
196
- <input type="date" id="setting-birthday" value="${escapeHtml(userData.birthday || '')}" style="flex: 1; padding: 8px; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; border-radius: 4px; box-sizing: border-box;">
197
- <span id="setting-age-display" style="color: #888; font-size: 12px; white-space: nowrap;">${userData.age ? userData.age + ' ' + t('profile.age_years') : ''}</span>
198
  </div>
199
  </div>
200
  </div>
201
 
202
- <div><label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">${t('settings_form.country')}</label>
203
- <select id="setting-country" style="width: 100%; padding: 8px; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; border-radius: 4px; margin-bottom: 10px;">
204
  <option value="">${t('settings_form.select_country')}</option>
205
  ${countryOptions}
206
  </select>
207
- <select id="setting-region" style="width: 100%; padding: 8px; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; border-radius: 4px;">
208
  <option value="${escapeHtml(userData.region || '')}">${escapeHtml(userData.region || t('settings_form.select_country_first'))}</option>
209
  </select>
210
  </div>
211
 
212
- <div><label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">${t('settings_form.intro')}</label><textarea id="setting-intro" rows="3" placeholder="${t('settings_form.intro_placeholder')}" style="width: 100%; padding: 8px; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; border-radius: 4px; box-sizing: border-box; resize: vertical;">${escapeHtml(userData.intro || '')}</textarea></div>
213
 
214
- <div style="margin-top: 15px; border-top: 1px solid #444; padding-top: 15px;">
215
- <div style="font-size: 14px; font-weight: bold; margin-bottom: 10px; color: #4CAF50;">🛡️ ${t('settings_form.privacy_title')}</div>
216
- <div style="font-size: 11px; color: #888; margin-bottom: 10px;">${t('settings_form.privacy_hint')}</div>
217
 
218
- <div style="display: flex; flex-direction: column; gap: 8px;">
219
- <label style="display: flex; align-items: center; gap: 8px; font-size: 13px; color: #ccc; cursor: pointer;">
220
  <input type="checkbox" id="hide-follows" ${getPrivacy('follows') ? 'checked' : ''}> ${t('settings_form.hide_follows')}
221
  </label>
222
- <label style="display: flex; align-items: center; gap: 8px; font-size: 13px; color: #ccc; cursor: pointer;">
223
  <input type="checkbox" id="hide-likes" ${getPrivacy('likes') ? 'checked' : ''}> ${t('settings_form.hide_likes')}
224
  </label>
225
- <label style="display: flex; align-items: center; gap: 8px; font-size: 13px; color: #ccc; cursor: pointer;">
226
  <input type="checkbox" id="hide-favorites" ${getPrivacy('favorites') ? 'checked' : ''}> ${t('settings_form.hide_favorites')}
227
  </label>
228
- <label style="display: flex; align-items: center; gap: 8px; font-size: 13px; color: #ccc; cursor: pointer;">
229
  <input type="checkbox" id="hide-downloads" ${getPrivacy('downloads') ? 'checked' : ''}> ${t('settings_form.hide_downloads')}
230
  </label>
231
  </div>
@@ -233,36 +238,36 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
233
 
234
  ${isAdmin ? `
235
  <!-- 🔒 管理员设置区域 -->
236
- <div id="admin-settings-section" style="margin-top: 15px; border-top: 1px solid #9C27B0; padding-top: 15px; background: linear-gradient(135deg, rgba(156,39,176,0.1), transparent); border-radius: 8px; padding: 15px; margin-top: 15px;">
237
- <div style="font-size: 14px; font-weight: bold; margin-bottom: 10px; color: #9C27B0; display: flex; align-items: center; gap: 8px;">
238
  <span>🔒</span> ${t('settings_form.admin_title')}
239
- <span style="font-size: 10px; background: #9C27B0; color: white; padding: 2px 6px; border-radius: 10px;">Admin</span>
240
  </div>
241
- <div style="font-size: 11px; color: #888; margin-bottom: 12px;">${t('settings_form.admin_hint')}</div>
242
 
243
- <div style="display: flex; flex-direction: column; gap: 10px;">
244
  <!-- 图像审核开关 -->
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>
261
  ` : ''}
262
  </div>
263
- <div style="display: flex; gap: 10px; margin-top: 20px;">
264
- <button id="btn-cancel-settings" style="flex: 1; padding: 10px; background: transparent; border: 1px solid #555; color: #ccc; border-radius: 4px; cursor: pointer;">${t('settings_form.cancel')}</button>
265
- <button id="btn-save-settings" style="flex: 2; padding: 10px; background: #4CAF50; border: none; color: white; border-radius: 4px; cursor: pointer; font-weight: bold;">${t('settings_form.save_all')}</button>
266
  </div>
267
  `;
268
 
@@ -273,6 +278,11 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
273
  const settingUiBgInput = container.querySelector("#setting-ui-bg");
274
  const bannerPreview = container.querySelector("#setting-banner-preview");
275
  const uiBgPreview = container.querySelector("#setting-ui-bg-preview");
 
 
 
 
 
276
  const birthdayInput = container.querySelector("#setting-birthday");
277
  const ageDisplay = container.querySelector("#setting-age-display");
278
  let avatarDataUrl = userData.avatarDataUrl;
@@ -293,32 +303,6 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
293
  let pendingBannerFile = null; // 待上传的背景图文件
294
  let pendingBannerDataUrl = null; // 用于预览和本地缓存
295
 
296
- // 🔒 管理员开关样式注入
297
- if (isAdmin && !document.getElementById('admin-toggle-styles')) {
298
- const toggleStyles = document.createElement('style');
299
- toggleStyles.id = 'admin-toggle-styles';
300
- toggleStyles.textContent = `
301
- .toggle-switch input:checked + .toggle-slider {
302
- background-color: #4CAF50;
303
- }
304
- .toggle-slider:before {
305
- position: absolute;
306
- content: "";
307
- height: 20px;
308
- width: 20px;
309
- left: 3px;
310
- bottom: 3px;
311
- background-color: white;
312
- transition: .3s;
313
- border-radius: 50%;
314
- }
315
- .toggle-switch input:checked + .toggle-slider:before {
316
- transform: translateX(24px);
317
- }
318
- `;
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');
@@ -405,7 +389,7 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
405
  // 加载本地界面背景预览(使用账号区分键)
406
  const savedUiBg = _safeStorageGet('localStorage', getBackgroundKey(userData.account));
407
  if (typeof savedUiBg === "string" && /^data:image\/(?:png|jpeg|webp);base64,/i.test(savedUiBg)) {
408
- uiBgPreview.style.backgroundImage = `url(${savedUiBg})`;
409
  uiBgPreview.style.backgroundSize = "cover";
410
  uiBgPreview.style.backgroundPosition = "center";
411
  }
@@ -415,7 +399,7 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
415
  const age = calculateAge(birthdayInput.value);
416
  if (age !== null) {
417
  ageDisplay.textContent = `${age} ${t('profile.age_years')}`;
418
- ageDisplay.style.color = "#4CAF50";
419
  } else {
420
  ageDisplay.textContent = "";
421
  }
@@ -488,7 +472,7 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
488
  pendingBannerFile = null;
489
  pendingBannerDataUrl = null;
490
  bannerPreview.style.backgroundImage = "none";
491
- bannerPreview.style.background = "#1a1a1a";
492
  // 同时清除本地缓存(使用账号区分键)
493
  if (!_safeLocalStorageRemove(getBannerCacheKey(userData.account))) {
494
  showToast(t('settings_form.local_storage_unavailable'), "warning");
@@ -518,7 +502,7 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
518
  return;
519
  }
520
  uiBgPreview.style.backgroundImage = "none";
521
- uiBgPreview.style.background = "#1a1a1a";
522
  // 立即应用到侧边栏
523
  window.dispatchEvent(new CustomEvent("comfy-sidebar-bg-update"));
524
  showToast(t('settings_form.ui_bg_cleared'), "success");
 
16
  try { window.localStorage.removeItem(key); return true; } catch { return false; }
17
  }
18
 
19
+ function _cssBackgroundImage(value) {
20
+ if (typeof value !== "string" || !value.trim() || /[\u0000-\u001f\u007f]/.test(value)) return "none";
21
+ return `url(${JSON.stringify(value)})`;
22
+ }
23
+
24
  // 计算年龄工具函数
25
  function calculateAge(birthDate) {
26
  if (!birthDate) return null;
 
106
  }
107
  }
108
  }
109
+ previewElement.style.backgroundImage = _cssBackgroundImage(base64);
110
  previewElement.style.backgroundSize = "cover";
111
  previewElement.style.backgroundPosition = "center";
112
  finish(base64);
 
152
  const countryOptions = sortedCountries.map(c => `<option value="${c}" ${c === userData.country ? 'selected' : ''}>${c}</option>`).join("");
153
 
154
  container.innerHTML = `
155
+ <div class="settings-heading">⚙️ ${t('settings_form.title')}</div>
156
+ <div class="settings-fields">
157
+ <div class="settings-avatar-panel">
158
+ <img alt="" class="settings-avatar" id="setting-avatar-preview" src="${escapeHtml(userData.avatarDataUrl || PLACEHOLDERS.AVATAR)}">
159
  <div>
160
+ <button type="button" class="settings-avatar-action" id="btn-trigger-avatar">${t('settings_form.change_avatar')}</button>
161
+ <div class="settings-hint">${t('settings_form.avatar_hint')}</div>
162
  </div>
163
+ <input class="settings-file-input" type="file" id="setting-avatar" accept="image/*">
164
  </div>
165
 
166
+ <div class="settings-media-panel">
167
+ <div class="settings-media-title">🇺️ ${t('settings_form.banner_title')}</div>
168
+ <div class="settings-banner-preview" id="setting-banner-preview"></div>
169
+ <div class="settings-media-actions">
170
+ <button type="button" class="settings-upload" id="btn-trigger-banner">📷 ${t('settings_form.banner_upload')}</button>
171
+ <button type="button" class="settings-clear" id="btn-clear-banner">🗑️ ${t('settings_form.banner_clear')}</button>
172
  </div>
173
+ <div class="settings-media-hint">${t('settings_form.banner_hint')}</div>
174
+ <input class="settings-file-input" type="file" id="setting-banner" accept="image/*">
175
  </div>
176
 
177
+ <div class="settings-media-panel">
178
+ <div class="settings-media-title">🎨 ${t('settings_form.ui_bg_title')}</div>
179
+ <div class="settings-background-preview" id="setting-ui-bg-preview"></div>
180
+ <div class="settings-media-actions">
181
+ <button type="button" class="settings-upload" id="btn-trigger-ui-bg">📷 ${t('settings_form.banner_upload')}</button>
182
+ <button type="button" class="settings-clear" id="btn-clear-ui-bg">🗑️ ${t('settings_form.banner_clear')}</button>
183
  </div>
184
+ <div class="settings-media-hint">${t('settings_form.ui_bg_hint')}</div>
185
+ <input class="settings-file-input" type="file" id="setting-ui-bg" accept="image/*">
186
  </div>
187
 
188
+ <div><label class="settings-label" for="setting-name">${t('settings_form.display_name')}</label><input class="settings-input" type="text" id="setting-name" value="${escapeHtml(userData.name || '')}"></div>
189
 
190
+ <div class="settings-row">
191
+ <div class="settings-field"><label class="settings-label" for="setting-gender">${t('settings_form.gender')}</label>
192
+ <select class="settings-select" id="setting-gender">
193
  <option value="male" ${userData.gender === 'male' ? 'selected' : ''}>${t('settings_form.gender_male')}</option>
194
  <option value="female" ${userData.gender === 'female' ? 'selected' : ''}>${t('settings_form.gender_female')}</option>
195
  <option value="other" ${userData.gender === 'other' ? 'selected' : ''}>${t('settings_form.gender_secret')}</option>
196
  </select>
197
  </div>
198
+ <div class="settings-field">
199
+ <label class="settings-label" for="setting-birthday">${t('settings_form.birthday')}</label>
200
+ <div class="settings-birthday-row">
201
+ <input class="settings-birthday" type="date" id="setting-birthday" value="${escapeHtml(userData.birthday || '')}">
202
+ <span class="settings-age" id="setting-age-display">${userData.age ? userData.age + ' ' + t('profile.age_years') : ''}</span>
203
  </div>
204
  </div>
205
  </div>
206
 
207
+ <div><label class="settings-label" for="setting-country">${t('settings_form.country')}</label>
208
+ <select class="settings-country" id="setting-country">
209
  <option value="">${t('settings_form.select_country')}</option>
210
  ${countryOptions}
211
  </select>
212
+ <select class="settings-select" id="setting-region" aria-label="${escapeHtml(t('auth.region'))}">
213
  <option value="${escapeHtml(userData.region || '')}">${escapeHtml(userData.region || t('settings_form.select_country_first'))}</option>
214
  </select>
215
  </div>
216
 
217
+ <div><label class="settings-label" for="setting-intro">${t('settings_form.intro')}</label><textarea class="settings-intro" id="setting-intro" rows="3" placeholder="${t('settings_form.intro_placeholder')}">${escapeHtml(userData.intro || '')}</textarea></div>
218
 
219
+ <div class="settings-privacy">
220
+ <div class="settings-section-heading">🛡️ ${t('settings_form.privacy_title')}</div>
221
+ <div class="settings-privacy-hint">${t('settings_form.privacy_hint')}</div>
222
 
223
+ <div class="settings-privacy-options">
224
+ <label class="settings-check">
225
  <input type="checkbox" id="hide-follows" ${getPrivacy('follows') ? 'checked' : ''}> ${t('settings_form.hide_follows')}
226
  </label>
227
+ <label class="settings-check">
228
  <input type="checkbox" id="hide-likes" ${getPrivacy('likes') ? 'checked' : ''}> ${t('settings_form.hide_likes')}
229
  </label>
230
+ <label class="settings-check">
231
  <input type="checkbox" id="hide-favorites" ${getPrivacy('favorites') ? 'checked' : ''}> ${t('settings_form.hide_favorites')}
232
  </label>
233
+ <label class="settings-check">
234
  <input type="checkbox" id="hide-downloads" ${getPrivacy('downloads') ? 'checked' : ''}> ${t('settings_form.hide_downloads')}
235
  </label>
236
  </div>
 
238
 
239
  ${isAdmin ? `
240
  <!-- 🔒 管理员设置区域 -->
241
+ <div class="settings-admin" id="admin-settings-section">
242
+ <div class="settings-admin-heading">
243
  <span>🔒</span> ${t('settings_form.admin_title')}
244
+ <span class="settings-admin-badge">Admin</span>
245
  </div>
246
+ <div class="settings-admin-hint">${t('settings_form.admin_hint')}</div>
247
 
248
+ <div class="settings-admin-fields">
249
  <!-- 图像审核开关 -->
250
+ <div class="settings-moderation">
251
+ <div class="settings-moderation-row">
252
  <div>
253
+ <div class="settings-moderation-title">🖼️ ${escapeHtml(t('settings_form.image_moderation'))}</div>
254
+ <div class="settings-hint" id="moderation-hint">${escapeHtml(t('settings_form.image_moderation_hint'))}</div>
255
  </div>
256
+ <label class="settings-switch toggle-switch">
257
+ <input class="settings-switch-input" type="checkbox" id="admin-image-moderation" disabled aria-label="${escapeHtml(t('settings_form.image_moderation'))}" aria-describedby="moderation-hint moderation-status"><span class="toggle-slider" aria-hidden="true"></span>
258
+
259
  </label>
260
  </div>
261
+ <div class="settings-moderation-status" id="moderation-status" role="status" aria-live="polite">${escapeHtml(t('common.loading'))}</div>
262
  <button id="moderation-retry" type="button" hidden>${escapeHtml(t('common.retry'))}</button>
263
  </div>
264
  </div>
265
  </div>
266
  ` : ''}
267
  </div>
268
+ <div class="settings-actions">
269
+ <button type="button" class="settings-cancel" id="btn-cancel-settings">${t('settings_form.cancel')}</button>
270
+ <button type="button" class="settings-save" id="btn-save-settings">${t('settings_form.save_all')}</button>
271
  </div>
272
  `;
273
 
 
278
  const settingUiBgInput = container.querySelector("#setting-ui-bg");
279
  const bannerPreview = container.querySelector("#setting-banner-preview");
280
  const uiBgPreview = container.querySelector("#setting-ui-bg-preview");
281
+ const initialBannerBackground = _cssBackgroundImage(userData.bannerUrl);
282
+ bannerPreview.style.backgroundImage = initialBannerBackground;
283
+ if (initialBannerBackground !== "none") {
284
+ bannerPreview.style.backgroundColor = "transparent";
285
+ }
286
  const birthdayInput = container.querySelector("#setting-birthday");
287
  const ageDisplay = container.querySelector("#setting-age-display");
288
  let avatarDataUrl = userData.avatarDataUrl;
 
303
  let pendingBannerFile = null; // 待上传的背景图文件
304
  let pendingBannerDataUrl = null; // 用于预览和本地缓存
305
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
  if (isAdmin) {
307
  const moderationToggle = container.querySelector('#admin-image-moderation');
308
  const statusDiv = container.querySelector('#moderation-status');
 
389
  // 加载本地界面背景预览(使用账号区分键)
390
  const savedUiBg = _safeStorageGet('localStorage', getBackgroundKey(userData.account));
391
  if (typeof savedUiBg === "string" && /^data:image\/(?:png|jpeg|webp);base64,/i.test(savedUiBg)) {
392
+ uiBgPreview.style.backgroundImage = _cssBackgroundImage(savedUiBg);
393
  uiBgPreview.style.backgroundSize = "cover";
394
  uiBgPreview.style.backgroundPosition = "center";
395
  }
 
399
  const age = calculateAge(birthdayInput.value);
400
  if (age !== null) {
401
  ageDisplay.textContent = `${age} ${t('profile.age_years')}`;
402
+ ageDisplay.classList.add("settings-age--known");
403
  } else {
404
  ageDisplay.textContent = "";
405
  }
 
472
  pendingBannerFile = null;
473
  pendingBannerDataUrl = null;
474
  bannerPreview.style.backgroundImage = "none";
475
+ bannerPreview.style.backgroundColor = "";
476
  // 同时清除本地缓存(使用账号区分键)
477
  if (!_safeLocalStorageRemove(getBannerCacheKey(userData.account))) {
478
  showToast(t('settings_form.local_storage_unavailable'), "warning");
 
502
  return;
503
  }
504
  uiBgPreview.style.backgroundImage = "none";
505
+ uiBgPreview.style.backgroundColor = "";
506
  // 立即应用到侧边栏
507
  window.dispatchEvent(new CustomEvent("comfy-sidebar-bg-update"));
508
  showToast(t('settings_form.ui_bg_cleared'), "success");
前端页面/prompt/价格校验.js CHANGED
@@ -20,3 +20,10 @@ export function validatePromptPriceInput(input, error, t) {
20
  input.removeAttribute("aria-invalid");
21
  return price;
22
  }
 
 
 
 
 
 
 
 
20
  input.removeAttribute("aria-invalid");
21
  return price;
22
  }
23
+
24
+ export function getPromptPriceState(value) {
25
+ const known = (typeof value === 'number' || (typeof value === 'string' && value.trim() !== ''))
26
+ && Number.isFinite(Number(value)) && Number(value) >= 0;
27
+ if (!known) return 'unknown';
28
+ return Number(value) === 0 ? 'free' : 'paid';
29
+ }
前端页面/prompt/发布提示词组件.js CHANGED
@@ -20,7 +20,7 @@ import { removeCacheByPrefix } from "../components/性能优化工具.js";
20
  import { createPublishEditingLock } from "../market/发布编辑锁.js";
21
  import { createPublishSubmissionScope } from "../market/发布提交生命周期.js";
22
  import { parsePromptPrice, validatePromptPriceInput } from "./价格校验.js";
23
- import { API, CACHE, PUBLISH_MEDIA_LIMITS, IMAGE_PROCESS, PROMPT_LIMITS } from "../core/全局配置.js";
24
  import { escapeHtml } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
25
 
26
  function requirePromptUpload(result) {
@@ -119,13 +119,11 @@ function formatFileSize(bytes) {
119
  }
120
 
121
  // ===== 多图预览与拖拽排序(参考讨论区发布界面) =====
122
- const PREVIEW_WRAPPER_STYLE = { position: "relative", width: "80px", height: "80px" };
123
 
124
  // 清除所有拖拽插入指示线
125
  function _clearDragIndicators(wrappers) {
126
  wrappers.forEach(w => {
127
- w.style.borderLeft = '';
128
- w.style.paddingLeft = '';
129
  });
130
  }
131
 
@@ -135,19 +133,7 @@ function _createAddButton(imagesInput, scope) {
135
  addBtn.type = "button";
136
  addBtn.setAttribute("aria-label", t("post.upload_images"));
137
  addBtn.setAttribute("data-file-picker-button", "");
138
- Object.assign(addBtn.style, {
139
- width: "80px",
140
- height: "80px",
141
- background: "transparent",
142
- border: "2px dashed var(--rk-border, var(--border-color, #444))",
143
- borderRadius: "6px",
144
- display: "flex",
145
- alignItems: "center",
146
- justifyContent: "center",
147
- cursor: "pointer",
148
- color: "var(--rk-muted, var(--descrip-text, #a4a4a4))",
149
- fontSize: "24px"
150
- });
151
  addBtn.textContent = "+";
152
  addBtn.onclick = (e) => {
153
  if (!scope.isCurrent() || imagesInput.disabled) return;
@@ -188,8 +174,9 @@ function updateCoverMark(previewContainer) {
188
  wrapper.tabIndex = 0;
189
  wrapper.setAttribute('role', 'group');
190
  wrapper.setAttribute('aria-label', t('post.image_position', { index: idx + 1, total: wrappers.length }));
191
- wrapper.setAttribute('aria-keyshortcuts', 'Alt+ArrowLeft Alt+ArrowRight');
192
- if (!wrapper.title) wrapper.title = t('post.image_move_hint');
 
193
  const remove = wrapper.querySelector('button[data-action="remove"]');
194
  if (remove) {
195
  remove.type = 'button';
@@ -198,13 +185,13 @@ function updateCoverMark(previewContainer) {
198
  if (img && !img.hasAttribute('alt')) img.alt = '';
199
 
200
  if (img) {
201
- img.style.borderColor = idx === 0 ? '#4CAF50' : 'var(--rk-border, var(--border-color, #444))';
202
  }
203
  if (idx === 0) {
204
  if (!coverLabel) {
205
  const label = document.createElement('span');
206
  label.className = 'cover-label';
207
- label.style.cssText = 'position: absolute; top: 4px; left: 4px; background: #4CAF50; color: var(--rk-text, var(--fg-color, #ddd)); font-size: 10px; padding: 2px 6px; border-radius: 3px; pointer-events: none;';
208
  label.textContent = t('post.cover');
209
  wrapper.appendChild(label);
210
  }
@@ -239,7 +226,7 @@ function setupImageDragSort(previewContainer, fileArray, options = {}) {
239
 
240
  wrappers.forEach(wrapper => {
241
  wrapper.draggable = true;
242
- wrapper.style.cursor = 'grab';
243
 
244
  // 绑定删除按钮(如果尚未绑定)
245
  const removeBtn = wrapper.querySelector('button[data-action="remove"]');
@@ -261,7 +248,7 @@ function setupImageDragSort(previewContainer, fileArray, options = {}) {
261
  }
262
 
263
  wrapper.onkeydown = (e) => {
264
- if (e.target !== wrapper || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey ||
265
  e.repeat || e.isComposing || !canInteract() ||
266
  !['ArrowLeft', 'ArrowRight'].includes(e.key)) return;
267
  const current = getWrappers();
@@ -286,7 +273,7 @@ function setupImageDragSort(previewContainer, fileArray, options = {}) {
286
  e.stopPropagation();
287
  dragSrcEl = wrapper;
288
  dragSrcIndex = getWrappers().indexOf(wrapper);
289
- wrapper.style.opacity = '0.5';
290
  e.dataTransfer.effectAllowed = 'move';
291
  e.dataTransfer.setData('text/plain', String(dragSrcIndex));
292
  };
@@ -307,13 +294,11 @@ function setupImageDragSort(previewContainer, fileArray, options = {}) {
307
  _clearDragIndicators(currentWrappers);
308
 
309
  // 在当前目标左侧显示绿色竖线指示插入位置
310
- wrapper.style.borderLeft = '3px solid #4CAF50';
311
- wrapper.style.paddingLeft = '5px';
312
  };
313
 
314
  wrapper.ondragleave = () => {
315
- wrapper.style.borderLeft = '';
316
- wrapper.style.paddingLeft = '';
317
  };
318
 
319
  wrapper.ondrop = (e) => {
@@ -347,7 +332,7 @@ function setupImageDragSort(previewContainer, fileArray, options = {}) {
347
  dragSrcEl = null;
348
  dragSrcIndex = -1;
349
  const endWrappers = getWrappers();
350
- endWrappers.forEach(w => { w.style.opacity = ''; });
351
  _clearDragIndicators(endWrappers);
352
  };
353
  });
@@ -377,10 +362,8 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
377
  const editTags = isEditMode ? (Array.isArray(editPromptData.tags) ? editPromptData.tags : []) : [];
378
 
379
  const container = document.createElement("div");
380
- const fieldLabelStyle = "display: block; font-size: 13px; font-weight: bold; color: var(--rk-text, var(--fg-color, #ddd)); margin-bottom: 8px;";
381
- const inputStyle = "";
382
 
383
- container.classList.add("ranking-publish-form");
384
  container.innerHTML = `
385
  <!-- 顶部标题栏 -->
386
  <div class="ranking-publish-header">
@@ -395,124 +378,124 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
395
  <!-- 表单内容 -->
396
  <div class="ranking-publish-body">
397
  <!-- 类型切换(图像/视频提示词) -->
398
- <div style="margin-bottom: 15px;">
399
- <label style="${fieldLabelStyle}">🎯 ${t('prompt.type_label')}</label>
400
- <div role="group" aria-label="${t('prompt.type_label')}" style="display: flex; gap: 0; background: var(--rk-panel, var(--comfy-input-bg, #292929)); border: 1px solid var(--rk-border, var(--border-color, #444)); border-radius: 6px; overflow: hidden;">
401
- <button id="ptype-image" style="flex: 1; padding: 10px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: var(--rk-on-accent, #fff); border: none; cursor: pointer; font-size: 13px; font-weight: bold; transition: 0.2s;">
402
  🎨 ${t('prompt.tab_image')}
403
  </button>
404
- <button id="ptype-video" style="flex: 1; padding: 10px; background: transparent; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); border: none; cursor: pointer; font-size: 13px; font-weight: bold; transition: 0.2s; border-left: 1px solid var(--rk-border, var(--border-color, #444));">
405
  🎬 ${t('prompt.tab_video')}
406
  </button>
407
- <button id="ptype-music" style="flex: 1; padding: 10px; background: transparent; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); border: none; cursor: pointer; font-size: 13px; font-weight: bold; transition: 0.2s; border-left: 1px solid var(--rk-border, var(--border-color, #444));">
408
  🎵 ${t('prompt.tab_music')}
409
  </button>
410
  </div>
411
  </div>
412
 
413
  <!-- 分类选择 -->
414
- <div style="margin-bottom: 15px;">
415
- <label for="category-select" style="${fieldLabelStyle}">🏷️ ${t('prompt.category_label')} <span style="color: #F44336;">*</span></label>
416
- <select id="category-select" class="ranking-publish-input" style="${inputStyle} cursor: pointer;">
417
  <option value="">${t('prompt.category_select')}</option>
418
  </select>
419
  </div>
420
 
421
  <!-- 🏷️ 标签(自定义 + 推荐快加 + 搜索过滤) -->
422
- <div style="margin-bottom: 15px;">
423
- <label for="tag-input" style="${fieldLabelStyle}">🔖 ${t('prompt.tag_label')}</label>
424
- <div id="tags-selected" style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px;"></div>
425
- <input type="text" id="tag-input" class="ranking-publish-input" placeholder="${t('prompt.tag_placeholder')}" maxlength="12" style="${inputStyle}">
426
- <div style="font-size: 11px; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); margin-top: 6px;">${t('prompt.tag_recommend')}</div>
427
- <div id="tag-recommend-list" style="display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; max-height: 92px; overflow-y: auto;"></div>
428
  </div>
429
 
430
  <!-- 标题输入 -->
431
- <div style="margin-bottom: 15px;">
432
- <label for="title-input" style="${fieldLabelStyle}">📝 ${t('prompt.title_label')} <span style="color: #F44336;">*</span></label>
433
- <input type="text" id="title-input" class="ranking-publish-input" value="${escapeAttr(editTitle)}" placeholder="${t('prompt.title_placeholder')}" maxlength="50" style="${inputStyle}">
434
- <div style="text-align: right; font-size: 11px; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); margin-top: 4px;">
435
  <span id="title-count">${editTitle.length}</span>/50
436
  </div>
437
  </div>
438
 
439
  <!-- 文案/简介输入 -->
440
- <div style="margin-bottom: 15px;">
441
- <label for="content-input" style="${fieldLabelStyle}">✍️ ${t('prompt.content_label')} <span style="color: #F44336;">*</span></label>
442
- <textarea id="content-input" class="ranking-publish-input" placeholder="${t('prompt.content_placeholder')}" maxlength="2000" style="${inputStyle} height: 100px; resize: none; line-height: 1.6;">${escapeHtml(editContent)}</textarea>
443
- <div style="text-align: right; font-size: 11px; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); margin-top: 4px;">
444
  <span id="content-count">${editContent.length}</span>/2000
445
  </div>
446
  </div>
447
 
448
  <!-- 提示词正文(付费内容) -->
449
- <div style="margin-bottom: 15px;">
450
- <label for="text-input" style="${fieldLabelStyle}">🔑 ${t('prompt.text_label')} <span style="color: #F44336;">*</span></label>
451
- <textarea id="text-input" class="ranking-publish-input" placeholder="${t('prompt.text_placeholder')}" maxlength="20000" style="${inputStyle} height: 180px; resize: none; line-height: 1.6; font-family: monospace;">${escapeHtml(editText)}</textarea>
452
- <div style="text-align: right; font-size: 11px; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); margin-top: 4px;">
453
  <span id="text-count">${editText.length}</span>/20000
454
  </div>
455
  </div>
456
 
457
  <!-- 价格设置 -->
458
- <div style="margin-bottom: 15px;">
459
- <label for="price-input" style="${fieldLabelStyle}">💎 ${t('prompt.price_label')}</label>
460
- <input type="number" id="price-input" class="ranking-publish-input" value="${escapeHtml(String(editPrice))}" min="0" max="${PROMPT_LIMITS.MAX_PRICE}" step="1" aria-describedby="price-error" style="${inputStyle}">
461
- <div id="price-error" role="alert" hidden style="color: var(--rk-danger, #ef5350); font-size: 12px; margin-top: 6px;"></div>
462
  </div>
463
 
464
  <!-- 原创作品勾选(参考讨论区) -->
465
- <div style="margin-bottom: 15px; padding: 12px; background: var(--rk-panel, var(--comfy-input-bg, #292929)); border: 1px solid var(--rk-border, var(--border-color, #444)); border-radius: 6px;">
466
- <label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
467
- <input type="checkbox" id="is-original-checkbox" ${editIsOriginal ? 'checked' : ''} style="width: 16px; height: 16px; cursor: pointer; accent-color: #764ba2;" />
468
- <span style="font-size: 13px; color: var(--rk-text, var(--fg-color, #ddd));">🎨 ${t('publish.mark_as_original')}</span>
469
  </label>
470
- <div style="font-size: 11px; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); margin-top: 6px; padding-left: 24px;">
471
  ${t('publish.original_default_hint')}
472
  </div>
473
  </div>
474
 
475
  <!-- 图片上传区(图像提示词专用:多图 + 拖拽排序,首张为封面) -->
476
- <div id="image-upload-section" style="margin-bottom: 15px;">
477
- <label style="${fieldLabelStyle}">🖼️ ${t('post.upload_images')} <span style="color: #F44336;">*</span>
478
- <span style="font-weight: normal; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); font-size: 12px;">(${t('post.max_9_images')})</span>
479
  </label>
480
  <input type="file" id="images-input" accept="image/*" multiple style="display: none;">
481
- <div id="images-preview" style="display: flex; flex-wrap: wrap; gap: 8px; min-height: 80px; padding: 15px; background: var(--rk-panel, var(--comfy-input-bg, #292929)); border: 2px dashed var(--rk-border, var(--border-color, #444)); border-radius: 8px; cursor: pointer; transition: 0.2s;">
482
- <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); font-size: 13px;">
483
- <div style="font-size: 32px; margin-bottom: 8px;">📷</div>
484
  ${t('post.click_upload')}
485
  </div>
486
  </div>
487
  </div>
488
 
489
  <!-- 视频上传区(视频提示词专用:截取当前帧或手动上传封面) -->
490
- <div id="video-upload-section" style="margin-bottom: 20px; display: none;">
491
- <label style="${fieldLabelStyle}">🎬 ${t('prompt.video_label')} <span id="video-required-mark" style="color: #F44336;">*</span><span id="video-optional-mark" style="color: var(--rk-muted, var(--descrip-text, #a4a4a4)); font-weight: normal; font-size: 12px; display: none;">${t('prompt.optional_mark')}</span></label>
492
  <input type="file" id="video-input" accept="video/mp4,video/webm,video/quicktime" style="display: none;">
493
- <div id="video-preview-area" style="padding: 15px; background: var(--rk-panel, var(--comfy-input-bg, #292929)); border: 2px dashed var(--rk-border, var(--border-color, #444)); border-radius: 8px; cursor: pointer; transition: 0.2s;">
494
- <div id="video-empty-state" style="display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); font-size: 13px;">
495
- <div style="font-size: 32px; margin-bottom: 8px;">🎬</div>
496
  ${t('post.click_upload_video')}
497
  </div>
498
  <div id="video-player-wrap" style="display: none;">
499
- <video id="video-player" controls style="width: 100%; max-height: 300px; border-radius: 8px; background: #000; display: block;"></video>
500
- <div style="display: flex; align-items: center; justify-content: space-between; margin-top: 10px;">
501
- <div style="font-size: 12px; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); line-height: 1.5;">
502
  <div id="video-name"></div>
503
  <div id="video-meta"></div>
504
  </div>
505
- <button id="btn-remove-video" style="width: 28px; height: 28px; border-radius: 50%; background: #F44336; color: var(--rk-on-accent, #fff); border: none; cursor: pointer; font-size: 16px; line-height: 1; flex-shrink: 0;">×</button>
506
  </div>
507
  <!-- 封面区域(截取当前帧 / 手动上传) -->
508
- <div id="video-cover-wrap" style="display: none; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border-color, #333);">
509
- <div style="font-size: 12px; font-weight: bold; color: var(--rk-text, var(--fg-color, #ddd)); margin-bottom: 8px;">${t('post.video_cover')}</div>
510
  <div class="ranking-publish-cover-controls">
511
- <div style="position: relative; width: 80px; height: 80px; flex-shrink: 0;">
512
- <img id="cover-thumb" src="" style="width: 80px; height: 80px; object-fit: cover; border-radius: 6px; border: 2px solid #4CAF50; display: none;">
513
- <span id="cover-label" style="position: absolute; top: 4px; left: 4px; background: #4CAF50; color: var(--rk-on-accent, #fff); font-size: 10px; padding: 2px 6px; border-radius: 3px; display: none;">${t('post.cover')}</span>
514
  </div>
515
- <div style="display: flex; flex-direction: column; gap: 6px;">
516
  <button type="button" id="btn-capture-frame" class="ranking-publish-secondary">
517
  📸 ${t('post.capture_frame')}
518
  </button>
@@ -528,30 +511,30 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
528
  </div>
529
 
530
  <!-- 🎵 音频上传区(音乐提示词专用:图片+音频方案,视频可选) -->
531
- <div id="audio-upload-section" style="margin-bottom: 20px; display: none;">
532
- <label style="${fieldLabelStyle}">🎵 ${t('prompt.audio_label')} <span style="color: #F44336;">*</span></label>
533
- <div style="font-size: 11px; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); margin: -4px 0 8px; line-height: 1.5;">${t('prompt.music_media_hint')}</div>
534
  <input type="file" id="audio-input" accept="audio/mpeg,audio/wav,audio/x-wav,audio/mp4,audio/x-m4a,audio/ogg,.mp3,.wav,.m4a,.ogg" style="display: none;">
535
- <div id="audio-preview-area" style="padding: 15px; background: var(--rk-panel, var(--comfy-input-bg, #292929)); border: 2px dashed var(--rk-border, var(--border-color, #444)); border-radius: 8px; cursor: pointer; transition: 0.2s;">
536
- <div id="audio-empty-state" style="display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); font-size: 13px;">
537
- <div style="font-size: 32px; margin-bottom: 8px;">🎵</div>
538
  ${t('post.click_upload_audio')}
539
  </div>
540
  <div id="audio-player-wrap" style="display: none;">
541
- <audio id="audio-player" controls style="width: 100%; display: block;"></audio>
542
- <div style="display: flex; align-items: center; justify-content: space-between; margin-top: 10px;">
543
- <div style="font-size: 12px; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); line-height: 1.5;">
544
  <div id="audio-name"></div>
545
  <div id="audio-meta"></div>
546
  </div>
547
- <button id="btn-remove-audio" style="width: 28px; height: 28px; border-radius: 50%; background: #F44336; color: var(--rk-on-accent, #fff); border: none; cursor: pointer; font-size: 16px; line-height: 1; flex-shrink: 0;">×</button>
548
  </div>
549
  </div>
550
  </div>
551
  </div>
552
 
553
  <!-- 提示信息 -->
554
- <div style="background: rgba(102,126,234,0.1); border: 1px solid rgba(102,126,234,0.3); border-radius: 6px; padding: 12px; font-size: 12px; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); line-height: 1.6;">
555
  💡 <strong>${t('prompt.notice_title')}:</strong><br>
556
  • ${t('prompt.notice_1')}<br>
557
  • ${t('prompt.notice_2')}<br>
@@ -559,7 +542,7 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
559
  </div>
560
 
561
  <!-- 确认发布按钮 -->
562
- <button id="btn-submit-prompt" style="width: 100%; padding: 12px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; color: var(--rk-on-accent, #fff); border-radius: 6px; cursor: pointer; font-size: 15px; font-weight: bold; transition: 0.2s; margin-top: 15px; margin-bottom: 5px;">
563
  ${isEditMode ? '💾' : '🚀'} ${isEditMode ? t('prompt.save_submit') : t('prompt.publish_submit')}
564
  </button>
565
  </div>
@@ -647,6 +630,15 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
647
  const submitBtn = container.querySelector("#btn-submit-prompt");
648
  const account = currentUser?.account;
649
  const scope = createPublishSubmissionScope(container, submitBtn, account);
 
 
 
 
 
 
 
 
 
650
  const editingLock = createPublishEditingLock(container, [submitBtn, container.querySelector("#btn-back-publish-prompt")]);
651
  scope.addCleanup(() => editingLock.dispose());
652
  const previewReaders = new Set(), temporaryVideos = new Map();
@@ -754,14 +746,6 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
754
  // ========== 类型切换 ==========
755
  function updateTypeUI() {
756
  for (const [button, type] of [[ptypeImageBtn, 'image'], [ptypeVideoBtn, 'video'], [ptypeMusicBtn, 'music']]) button.setAttribute('aria-pressed', String(promptType === type));
757
- // 先重置三个按钮样式,再高亮当前选中项
758
- [ptypeImageBtn, ptypeVideoBtn, ptypeMusicBtn].forEach(btn => {
759
- btn.style.background = 'transparent';
760
- btn.style.color = 'var(--rk-muted, var(--descrip-text, #a4a4a4))';
761
- });
762
- const activeBtn = promptType === 'image' ? ptypeImageBtn : (promptType === 'music' ? ptypeMusicBtn : ptypeVideoBtn);
763
- activeBtn.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
764
- activeBtn.style.color = 'var(--rk-on-accent, #fff)';
765
  // 图像区:图像提示词 + 音乐提示词(图片+音频方案);视频区:视频/音乐;音频区:仅音乐
766
  imageSection.style.display = promptType === 'video' ? 'none' : 'block';
767
  videoSection.style.display = promptType === 'image' ? 'none' : 'block';
@@ -879,11 +863,13 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
879
  tagsSelectedEl.innerHTML = "";
880
  selectedTags.forEach(tag => {
881
  const chip = document.createElement("span");
882
- chip.style.cssText = "display: inline-flex; align-items: center; gap: 4px; background: rgba(0,188,212,0.15); border: 1px solid rgba(0,188,212,0.4); color: #4DD0E1; font-size: 12px; padding: 3px 8px; border-radius: 10px;";
883
  chip.textContent = "#" + tag;
884
  const rm = document.createElement("button");
885
  rm.textContent = "×";
886
- rm.style.cssText = "background: none; border: none; color: #4DD0E1; cursor: pointer; font-size: 13px; line-height: 1; padding: 0;";
 
 
887
  rm.onclick = () => {
888
  if (!scope.isCurrent()) return; selectedTags = selectedTags.filter(x => x !== tag); renderSelectedTags(); renderRecommendTags(); };
889
  chip.appendChild(rm);
@@ -900,12 +886,13 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
900
  .slice(0, 20);
901
  tagRecommendList.innerHTML = "";
902
  if (!list.length) {
903
- tagRecommendList.innerHTML = `<span style="font-size: 11px; color: var(--rk-muted, var(--descrip-text, #a4a4a4));">${t('prompt.tag_no_recommend')}</span>`;
904
  return;
905
  }
906
  list.forEach(name => {
907
  const chip = document.createElement("button");
908
- chip.style.cssText = "background: rgba(118,75,162,0.2); border: 1px solid rgba(118,75,162,0.5); color: #CE93D8; font-size: 11px; padding: 3px 8px; border-radius: 10px; cursor: pointer; transition: 0.2s;";
 
909
  chip.textContent = "+ " + name;
910
  chip.onclick = () => addTag(name);
911
  tagRecommendList.appendChild(chip);
@@ -925,8 +912,11 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
925
  }
926
 
927
  tagInput.onkeydown = (e) => {
928
- if (!scope.isCurrent()) return;
929
- if (e.key === "Enter") { e.preventDefault(); addTag(tagInput.value); }
 
 
 
930
  };
931
  tagInput.oninput = () => { if (scope.isCurrent()) renderRecommendTags(); };
932
 
@@ -1009,8 +999,8 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
1009
 
1010
  if (totalImages === 0) {
1011
  imagesPreview.innerHTML = `
1012
- <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); font-size: 13px;">
1013
- <div style="font-size: 32px; margin-bottom: 8px;">📷</div>
1014
  ${t('post.click_upload')}
1015
  </div>
1016
  `;
@@ -1036,12 +1026,12 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
1036
 
1037
  existingImageUrls.forEach((url, idx) => {
1038
  const wrapper = document.createElement("div");
1039
- Object.assign(wrapper.style, PREVIEW_WRAPPER_STYLE);
1040
 
1041
  wrapper.innerHTML = `
1042
- <img src="${escapeAttr(url)}" style="width: 80px; height: 80px; object-fit: cover; border-radius: 6px; border: 2px solid ${idx === 0 ? '#4CAF50' : 'var(--rk-border, var(--border-color, #444))'};">
1043
- ${idx === 0 ? `<span class="cover-label" style="position: absolute; top: 4px; left: 4px; background: #4CAF50; color: var(--rk-on-accent, #fff); font-size: 10px; padding: 2px 6px; border-radius: 3px;">${t('post.cover')}</span>` : ''}
1044
- <button data-action="remove" data-existing-idx="${idx}" style="position: absolute; top: -6px; right: -6px; width: 24px; height: 24px; border-radius: 50%; background: #F44336; color: var(--rk-on-accent, #fff); border: none; cursor: pointer; font-size: 12px; line-height: 1;">×</button>
1045
  `;
1046
 
1047
  wrapper._imageType = 'existing';
@@ -1065,11 +1055,11 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
1065
  previewReaders.add(reader);
1066
  reader.onabort = () => finishPreviewReader(reader);
1067
  const wrapper = document.createElement("div");
1068
- Object.assign(wrapper.style, PREVIEW_WRAPPER_STYLE);
1069
  wrapper.innerHTML = `
1070
- <img src="" style="width: 80px; height: 80px; object-fit: cover; border-radius: 6px; border: 2px solid ${existingImageUrls.length === 0 && idx === 0 ? '#4CAF50' : 'var(--rk-border, var(--border-color, #444))'};">
1071
- ${existingImageUrls.length === 0 && idx === 0 ? `<span class="cover-label" style="position: absolute; top: 4px; left: 4px; background: #4CAF50; color: var(--rk-on-accent, #fff); font-size: 10px; padding: 2px 6px; border-radius: 3px;">${t('post.cover')}</span>` : ''}
1072
- <button data-action="remove" data-idx="${idx}" style="position: absolute; top: -6px; right: -6px; width: 24px; height: 24px; border-radius: 50%; background: #F44336; color: var(--rk-on-accent, #fff); border: none; cursor: pointer; font-size: 12px; line-height: 1;">×</button>
1073
  `;
1074
 
1075
  wrapper.querySelector('img').removeAttribute('src');
@@ -1116,8 +1106,8 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
1116
  const generation = beginImagePreviews();
1117
  if (imageFiles.length === 0) {
1118
  imagesPreview.innerHTML = `
1119
- <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; color: var(--rk-muted, var(--descrip-text, #a4a4a4)); font-size: 13px;">
1120
- <div style="font-size: 32px; margin-bottom: 8px;">📷</div>
1121
  ${t('post.click_upload')}
1122
  </div>
1123
  `;
@@ -1133,12 +1123,12 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
1133
  previewReaders.add(reader);
1134
  reader.onabort = () => finishPreviewReader(reader);
1135
  const wrapper = document.createElement("div");
1136
- Object.assign(wrapper.style, PREVIEW_WRAPPER_STYLE);
1137
 
1138
  wrapper.innerHTML = `
1139
- <img src="" style="width: 80px; height: 80px; object-fit: cover; border-radius: 6px; border: 2px solid ${idx === 0 ? '#4CAF50' : 'var(--rk-border, var(--border-color, #444))'};">
1140
- ${idx === 0 ? `<span class="cover-label" style="position: absolute; top: 4px; left: 4px; background: #4CAF50; color: var(--rk-on-accent, #fff); font-size: 10px; padding: 2px 6px; border-radius: 3px;">${t('post.cover')}</span>` : ''}
1141
- <button data-action="remove" data-idx="${idx}" style="position: absolute; top: -6px; right: -6px; width: 24px; height: 24px; border-radius: 50%; background: #F44336; color: var(--rk-on-accent, #fff); border: none; cursor: pointer; font-size: 12px; line-height: 1;">×</button>
1142
  `;
1143
 
1144
  wrapper.querySelector('img').removeAttribute('src');
 
20
  import { createPublishEditingLock } from "../market/发布编辑锁.js";
21
  import { createPublishSubmissionScope } from "../market/发布提交生命周期.js";
22
  import { parsePromptPrice, validatePromptPriceInput } from "./价格校验.js";
23
+ import { areKeyboardShortcutsEnabled, API, CACHE, PUBLISH_MEDIA_LIMITS, IMAGE_PROCESS, PROMPT_LIMITS } from "../core/全局配置.js";
24
  import { escapeHtml } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
25
 
26
  function requirePromptUpload(result) {
 
119
  }
120
 
121
  // ===== 多图预览与拖拽排序(参考讨论区发布界面) =====
 
122
 
123
  // 清除所有拖拽插入指示线
124
  function _clearDragIndicators(wrappers) {
125
  wrappers.forEach(w => {
126
+ w.removeAttribute('data-prompt-drop');
 
127
  });
128
  }
129
 
 
133
  addBtn.type = "button";
134
  addBtn.setAttribute("aria-label", t("post.upload_images"));
135
  addBtn.setAttribute("data-file-picker-button", "");
136
+ addBtn.className = "prompt-publish__add";
 
 
 
 
 
 
 
 
 
 
 
 
137
  addBtn.textContent = "+";
138
  addBtn.onclick = (e) => {
139
  if (!scope.isCurrent() || imagesInput.disabled) return;
 
174
  wrapper.tabIndex = 0;
175
  wrapper.setAttribute('role', 'group');
176
  wrapper.setAttribute('aria-label', t('post.image_position', { index: idx + 1, total: wrappers.length }));
177
+ if (areKeyboardShortcutsEnabled()) wrapper.setAttribute('aria-keyshortcuts', 'Alt+ArrowLeft Alt+ArrowRight');
178
+ else wrapper.removeAttribute('aria-keyshortcuts');
179
+ wrapper.title = t(areKeyboardShortcutsEnabled() ? 'post.image_move_hint' : 'post.image_drag_hint');
180
  const remove = wrapper.querySelector('button[data-action="remove"]');
181
  if (remove) {
182
  remove.type = 'button';
 
185
  if (img && !img.hasAttribute('alt')) img.alt = '';
186
 
187
  if (img) {
188
+ wrapper.setAttribute('data-prompt-cover', String(idx === 0));
189
  }
190
  if (idx === 0) {
191
  if (!coverLabel) {
192
  const label = document.createElement('span');
193
  label.className = 'cover-label';
194
+
195
  label.textContent = t('post.cover');
196
  wrapper.appendChild(label);
197
  }
 
226
 
227
  wrappers.forEach(wrapper => {
228
  wrapper.draggable = true;
229
+ wrapper.className = "prompt-publish__image";
230
 
231
  // 绑定删除按钮(如果尚未绑定)
232
  const removeBtn = wrapper.querySelector('button[data-action="remove"]');
 
248
  }
249
 
250
  wrapper.onkeydown = (e) => {
251
+ if (!areKeyboardShortcutsEnabled() || e.defaultPrevented || e.keyCode === 229 || e.target !== wrapper || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey ||
252
  e.repeat || e.isComposing || !canInteract() ||
253
  !['ArrowLeft', 'ArrowRight'].includes(e.key)) return;
254
  const current = getWrappers();
 
273
  e.stopPropagation();
274
  dragSrcEl = wrapper;
275
  dragSrcIndex = getWrappers().indexOf(wrapper);
276
+ wrapper.setAttribute('data-prompt-dragging', 'true');
277
  e.dataTransfer.effectAllowed = 'move';
278
  e.dataTransfer.setData('text/plain', String(dragSrcIndex));
279
  };
 
294
  _clearDragIndicators(currentWrappers);
295
 
296
  // 在当前目标左侧显示绿色竖线指示插入位置
297
+ wrapper.setAttribute('data-prompt-drop', 'true');
 
298
  };
299
 
300
  wrapper.ondragleave = () => {
301
+ wrapper.removeAttribute('data-prompt-drop');
 
302
  };
303
 
304
  wrapper.ondrop = (e) => {
 
332
  dragSrcEl = null;
333
  dragSrcIndex = -1;
334
  const endWrappers = getWrappers();
335
+ endWrappers.forEach(w => { w.removeAttribute('data-prompt-dragging'); });
336
  _clearDragIndicators(endWrappers);
337
  };
338
  });
 
362
  const editTags = isEditMode ? (Array.isArray(editPromptData.tags) ? editPromptData.tags : []) : [];
363
 
364
  const container = document.createElement("div");
 
 
365
 
366
+ container.classList.add("ranking-publish-form", "prompt-publish");
367
  container.innerHTML = `
368
  <!-- 顶部标题栏 -->
369
  <div class="ranking-publish-header">
 
378
  <!-- 表单内容 -->
379
  <div class="ranking-publish-body">
380
  <!-- 类型切换(图像/视频提示词) -->
381
+ <div class="prompt-publish__field">
382
+ <label class="prompt-publish__label">🎯 ${t('prompt.type_label')}</label>
383
+ <div role="group" aria-label="${t('prompt.type_label')}" class="prompt-publish__types">
384
+ <button type="button" id="ptype-image" class="prompt-publish__type">
385
  🎨 ${t('prompt.tab_image')}
386
  </button>
387
+ <button type="button" id="ptype-video" class="prompt-publish__type">
388
  🎬 ${t('prompt.tab_video')}
389
  </button>
390
+ <button type="button" id="ptype-music" class="prompt-publish__type">
391
  🎵 ${t('prompt.tab_music')}
392
  </button>
393
  </div>
394
  </div>
395
 
396
  <!-- 分类选择 -->
397
+ <div class="prompt-publish__field">
398
+ <label for="category-select" class="prompt-publish__label">🏷️ ${t('prompt.category_label')} <span class="prompt-publish__required">*</span></label>
399
+ <select id="category-select" class="ranking-publish-input prompt-publish__select">
400
  <option value="">${t('prompt.category_select')}</option>
401
  </select>
402
  </div>
403
 
404
  <!-- 🏷️ 标签(自定义 + 推荐快加 + 搜索过滤) -->
405
+ <div class="prompt-publish__field">
406
+ <label for="tag-input" class="prompt-publish__label">🔖 ${t('prompt.tag_label')}</label>
407
+ <div id="tags-selected" class="prompt-publish__tags"></div>
408
+ <input type="text" id="tag-input" class="ranking-publish-input" placeholder="${t('prompt.tag_placeholder')}" maxlength="12">
409
+ <div class="prompt-publish__hint">${t('prompt.tag_recommend')}</div>
410
+ <div id="tag-recommend-list" class="prompt-publish__recommendations"></div>
411
  </div>
412
 
413
  <!-- 标题输入 -->
414
+ <div class="prompt-publish__field">
415
+ <label for="title-input" class="prompt-publish__label">📝 ${t('prompt.title_label')} <span class="prompt-publish__required">*</span></label>
416
+ <input type="text" id="title-input" class="ranking-publish-input" value="${escapeAttr(editTitle)}" placeholder="${t('prompt.title_placeholder')}" maxlength="50">
417
+ <div class="prompt-publish__count">
418
  <span id="title-count">${editTitle.length}</span>/50
419
  </div>
420
  </div>
421
 
422
  <!-- 文案/简介输入 -->
423
+ <div class="prompt-publish__field">
424
+ <label for="content-input" class="prompt-publish__label">✍️ ${t('prompt.content_label')} <span class="prompt-publish__required">*</span></label>
425
+ <textarea id="content-input" class="ranking-publish-input prompt-publish__description" placeholder="${t('prompt.content_placeholder')}" maxlength="2000">${escapeHtml(editContent)}</textarea>
426
+ <div class="prompt-publish__count">
427
  <span id="content-count">${editContent.length}</span>/2000
428
  </div>
429
  </div>
430
 
431
  <!-- 提示词正文(付费内容) -->
432
+ <div class="prompt-publish__field">
433
+ <label for="text-input" class="prompt-publish__label">🔑 ${t('prompt.text_label')} <span class="prompt-publish__required">*</span></label>
434
+ <textarea id="text-input" class="ranking-publish-input prompt-publish__text" placeholder="${t('prompt.text_placeholder')}" maxlength="20000">${escapeHtml(editText)}</textarea>
435
+ <div class="prompt-publish__count">
436
  <span id="text-count">${editText.length}</span>/20000
437
  </div>
438
  </div>
439
 
440
  <!-- 价格设置 -->
441
+ <div class="prompt-publish__field">
442
+ <label for="price-input" class="prompt-publish__label">💎 ${t('prompt.price_label')}</label>
443
+ <input type="number" id="price-input" class="ranking-publish-input" value="${escapeHtml(String(editPrice))}" min="0" max="${PROMPT_LIMITS.MAX_PRICE}" step="1" aria-describedby="price-error">
444
+ <div id="price-error" role="alert" hidden class="prompt-publish__error"></div>
445
  </div>
446
 
447
  <!-- 原创作品勾选(参考讨论区) -->
448
+ <div class="prompt-publish__original">
449
+ <label class="prompt-publish__checkbox">
450
+ <input type="checkbox" id="is-original-checkbox" ${editIsOriginal ? 'checked' : ''} class="prompt-publish__check" />
451
+ <span class="prompt-publish__copy">🎨 ${t('publish.mark_as_original')}</span>
452
  </label>
453
+ <div class="prompt-publish__checkbox-hint">
454
  ${t('publish.original_default_hint')}
455
  </div>
456
  </div>
457
 
458
  <!-- 图片上传区(图像提示词专用:多图 + 拖拽排序,首张为封面) -->
459
+ <div id="image-upload-section" class="prompt-publish__field">
460
+ <label class="prompt-publish__label">🖼️ ${t('post.upload_images')} <span class="prompt-publish__required">*</span>
461
+ <span class="prompt-publish__optional">(${t('post.max_9_images')})</span>
462
  </label>
463
  <input type="file" id="images-input" accept="image/*" multiple style="display: none;">
464
+ <div id="images-preview" class="prompt-publish__images">
465
+ <div class="prompt-publish__empty">
466
+ <div class="prompt-publish__empty-icon">📷</div>
467
  ${t('post.click_upload')}
468
  </div>
469
  </div>
470
  </div>
471
 
472
  <!-- 视频上传区(视频提示词专用:截取当前帧或手动上传封面) -->
473
+ <div id="video-upload-section" style="display: none;" class="prompt-publish__media-section">
474
+ <label class="prompt-publish__label">🎬 ${t('prompt.video_label')} <span id="video-required-mark" class="prompt-publish__required">*</span><span id="video-optional-mark" style="display: none;" class="prompt-publish__optional">${t('prompt.optional_mark')}</span></label>
475
  <input type="file" id="video-input" accept="video/mp4,video/webm,video/quicktime" style="display: none;">
476
+ <div id="video-preview-area" class="prompt-publish__media">
477
+ <div id="video-empty-state" class="prompt-publish__empty">
478
+ <div class="prompt-publish__empty-icon">🎬</div>
479
  ${t('post.click_upload_video')}
480
  </div>
481
  <div id="video-player-wrap" style="display: none;">
482
+ <video id="video-player" controls class="prompt-publish__video"></video>
483
+ <div class="prompt-publish__media-meta">
484
+ <div class="prompt-publish__meta-copy">
485
  <div id="video-name"></div>
486
  <div id="video-meta"></div>
487
  </div>
488
+ <button type="button" aria-label="${t('common.delete')} ${t('prompt.video_label')}" id="btn-remove-video" class="prompt-publish__remove-media">×</button>
489
  </div>
490
  <!-- 封面区域(截取当前帧 / 手动上传) -->
491
+ <div id="video-cover-wrap" style="display: none;" class="prompt-publish__cover-section">
492
+ <div class="prompt-publish__cover-title">${t('post.video_cover')}</div>
493
  <div class="ranking-publish-cover-controls">
494
+ <div class="prompt-publish__cover-frame">
495
+ <img id="cover-thumb" src="" style="display: none;" class="prompt-publish__cover-image">
496
+ <span id="cover-label" style="display: none;" class="prompt-publish__cover-badge">${t('post.cover')}</span>
497
  </div>
498
+ <div class="prompt-publish__cover-actions">
499
  <button type="button" id="btn-capture-frame" class="ranking-publish-secondary">
500
  📸 ${t('post.capture_frame')}
501
  </button>
 
511
  </div>
512
 
513
  <!-- 🎵 音频上传区(音乐提示词专用:图片+音频方案,视频可选) -->
514
+ <div id="audio-upload-section" style="display: none;" class="prompt-publish__media-section">
515
+ <label class="prompt-publish__label">🎵 ${t('prompt.audio_label')} <span class="prompt-publish__required">*</span></label>
516
+ <div class="prompt-publish__media-hint">${t('prompt.music_media_hint')}</div>
517
  <input type="file" id="audio-input" accept="audio/mpeg,audio/wav,audio/x-wav,audio/mp4,audio/x-m4a,audio/ogg,.mp3,.wav,.m4a,.ogg" style="display: none;">
518
+ <div id="audio-preview-area" class="prompt-publish__media">
519
+ <div id="audio-empty-state" class="prompt-publish__empty">
520
+ <div class="prompt-publish__empty-icon">🎵</div>
521
  ${t('post.click_upload_audio')}
522
  </div>
523
  <div id="audio-player-wrap" style="display: none;">
524
+ <audio id="audio-player" controls class="prompt-publish__audio"></audio>
525
+ <div class="prompt-publish__media-meta">
526
+ <div class="prompt-publish__meta-copy">
527
  <div id="audio-name"></div>
528
  <div id="audio-meta"></div>
529
  </div>
530
+ <button type="button" aria-label="${t('common.delete')} ${t('prompt.audio_label')}" id="btn-remove-audio" class="prompt-publish__remove-media">×</button>
531
  </div>
532
  </div>
533
  </div>
534
  </div>
535
 
536
  <!-- 提示信息 -->
537
+ <div class="prompt-publish__notice">
538
  💡 <strong>${t('prompt.notice_title')}:</strong><br>
539
  • ${t('prompt.notice_1')}<br>
540
  • ${t('prompt.notice_2')}<br>
 
542
  </div>
543
 
544
  <!-- 确认发布按钮 -->
545
+ <button type="button" id="btn-submit-prompt" class="prompt-publish__submit">
546
  ${isEditMode ? '💾' : '🚀'} ${isEditMode ? t('prompt.save_submit') : t('prompt.publish_submit')}
547
  </button>
548
  </div>
 
630
  const submitBtn = container.querySelector("#btn-submit-prompt");
631
  const account = currentUser?.account;
632
  const scope = createPublishSubmissionScope(container, submitBtn, account);
633
+ const refreshImageShortcuts = () => {
634
+ if (scope.isCurrent()) updateCoverMark(imagesPreview);
635
+ };
636
+ window.addEventListener('comfy-settings-changed', refreshImageShortcuts);
637
+ window.addEventListener('storage', refreshImageShortcuts);
638
+ scope.addCleanup(() => {
639
+ window.removeEventListener('comfy-settings-changed', refreshImageShortcuts);
640
+ window.removeEventListener('storage', refreshImageShortcuts);
641
+ });
642
  const editingLock = createPublishEditingLock(container, [submitBtn, container.querySelector("#btn-back-publish-prompt")]);
643
  scope.addCleanup(() => editingLock.dispose());
644
  const previewReaders = new Set(), temporaryVideos = new Map();
 
746
  // ========== 类型切换 ==========
747
  function updateTypeUI() {
748
  for (const [button, type] of [[ptypeImageBtn, 'image'], [ptypeVideoBtn, 'video'], [ptypeMusicBtn, 'music']]) button.setAttribute('aria-pressed', String(promptType === type));
 
 
 
 
 
 
 
 
749
  // 图像区:图像提示词 + 音乐提示词(图片+音频方案);视频区:视频/音乐;音频区:仅音乐
750
  imageSection.style.display = promptType === 'video' ? 'none' : 'block';
751
  videoSection.style.display = promptType === 'image' ? 'none' : 'block';
 
863
  tagsSelectedEl.innerHTML = "";
864
  selectedTags.forEach(tag => {
865
  const chip = document.createElement("span");
866
+ chip.className = "prompt-publish__tag";
867
  chip.textContent = "#" + tag;
868
  const rm = document.createElement("button");
869
  rm.textContent = "×";
870
+ rm.className = "prompt-publish__remove-tag";
871
+ rm.type = "button";
872
+ rm.setAttribute("aria-label", `${t("common.delete")} ${tag}`);
873
  rm.onclick = () => {
874
  if (!scope.isCurrent()) return; selectedTags = selectedTags.filter(x => x !== tag); renderSelectedTags(); renderRecommendTags(); };
875
  chip.appendChild(rm);
 
886
  .slice(0, 20);
887
  tagRecommendList.innerHTML = "";
888
  if (!list.length) {
889
+ tagRecommendList.innerHTML = `<span class="prompt-publish__muted">${t('prompt.tag_no_recommend')}</span>`;
890
  return;
891
  }
892
  list.forEach(name => {
893
  const chip = document.createElement("button");
894
+ chip.className = "prompt-publish__tag";
895
+ chip.type = "button";
896
  chip.textContent = "+ " + name;
897
  chip.onclick = () => addTag(name);
898
  tagRecommendList.appendChild(chip);
 
912
  }
913
 
914
  tagInput.onkeydown = (e) => {
915
+ if (!scope.isCurrent() || e.defaultPrevented || e.key !== "Enter"
916
+ || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey
917
+ || e.isComposing || e.keyCode === 229 || e.repeat
918
+ || tagInput.disabled || tagInput.readOnly) return;
919
+ e.preventDefault(); addTag(tagInput.value);
920
  };
921
  tagInput.oninput = () => { if (scope.isCurrent()) renderRecommendTags(); };
922
 
 
999
 
1000
  if (totalImages === 0) {
1001
  imagesPreview.innerHTML = `
1002
+ <div class="prompt-publish__empty">
1003
+ <div class="prompt-publish__empty-icon">📷</div>
1004
  ${t('post.click_upload')}
1005
  </div>
1006
  `;
 
1026
 
1027
  existingImageUrls.forEach((url, idx) => {
1028
  const wrapper = document.createElement("div");
1029
+ wrapper.className = "prompt-publish__image";
1030
 
1031
  wrapper.innerHTML = `
1032
+ <img src="${escapeAttr(url)}" class="prompt-publish__thumbnail">
1033
+ ${idx === 0 ? `<span class="prompt-publish__cover-badge cover-label">${t('post.cover')}</span>` : ''}
1034
+ <button type="button" data-action="remove" data-existing-idx="${idx}" class="prompt-publish__remove-image">×</button>
1035
  `;
1036
 
1037
  wrapper._imageType = 'existing';
 
1055
  previewReaders.add(reader);
1056
  reader.onabort = () => finishPreviewReader(reader);
1057
  const wrapper = document.createElement("div");
1058
+ wrapper.className = "prompt-publish__image";
1059
  wrapper.innerHTML = `
1060
+ <img src="" class="prompt-publish__thumbnail">
1061
+ ${existingImageUrls.length === 0 && idx === 0 ? `<span class="prompt-publish__cover-badge cover-label">${t('post.cover')}</span>` : ''}
1062
+ <button type="button" data-action="remove" data-idx="${idx}" class="prompt-publish__remove-image">×</button>
1063
  `;
1064
 
1065
  wrapper.querySelector('img').removeAttribute('src');
 
1106
  const generation = beginImagePreviews();
1107
  if (imageFiles.length === 0) {
1108
  imagesPreview.innerHTML = `
1109
+ <div class="prompt-publish__empty">
1110
+ <div class="prompt-publish__empty-icon">📷</div>
1111
  ${t('post.click_upload')}
1112
  </div>
1113
  `;
 
1123
  previewReaders.add(reader);
1124
  reader.onabort = () => finishPreviewReader(reader);
1125
  const wrapper = document.createElement("div");
1126
+ wrapper.className = "prompt-publish__image";
1127
 
1128
  wrapper.innerHTML = `
1129
+ <img src="" class="prompt-publish__thumbnail">
1130
+ ${idx === 0 ? `<span class="prompt-publish__cover-badge cover-label">${t('post.cover')}</span>` : ''}
1131
+ <button type="button" data-action="remove" data-idx="${idx}" class="prompt-publish__remove-image">×</button>
1132
  `;
1133
 
1134
  wrapper.querySelector('img').removeAttribute('src');
前端页面/prompt/提示词组件.js CHANGED
@@ -15,6 +15,7 @@
15
  // ==========================================
16
 
17
  import { createPromptCategorySync, isPromptCategoryList } from "./提示词分类同步.js";
 
18
  import { createCardNavigationScope } from "../components/卡片导航生命周期.js";
19
  import { api, proxyImages, captureRequestSession } from "../core/网络请求API.js";
20
  import { showToast } from "../components/UI交互提示组件.js";
@@ -31,6 +32,20 @@ const CACHE_KEY_PREFIX = "PromptsCache";
31
  const CATEGORIES_CACHE_PREFIX = "PromptsCategoriesCache"; // 📴 分类缓存键前缀(离线容灾)
32
  const PAGE_SIZE = 20;
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  // 缓存当前用户
35
  let currentUserCache = null;
36
 
@@ -84,10 +99,10 @@ function _catLabel(name) {
84
  */
85
  function _renderEmptyState() {
86
  return `
87
- <div style="grid-column: span 2; text-align: center; padding: 60px 20px; color: #666;">
88
- <div style="font-size: 48px; margin-bottom: 15px; opacity: 0.5;">🧩</div>
89
- <div style="font-size: 14px; margin-bottom: 8px;">${t('prompt.no_prompts')}</div>
90
- <div style="font-size: 12px; color: #888;">${t('prompt.be_first')}</div>
91
  </div>
92
  `;
93
  }
@@ -101,7 +116,7 @@ function _generateAvatarHtml(avatar, name) {
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
  /**
@@ -115,18 +130,6 @@ export function createPromptsView(currentUser, keyword = "") {
115
 
116
  const container = document.createElement("div");
117
  container.className = "prompt-board";
118
- Object.assign(container.style, {
119
- display: "flex",
120
- flexDirection: "column",
121
- gap: "0",
122
- color: "var(--rk-text, var(--fg-color, #ddd))",
123
- fontSize: "14px",
124
- padding: "0",
125
- overflowY: "auto",
126
- flex: "1",
127
- boxSizing: "border-box"
128
- });
129
-
130
  container.innerHTML = `
131
  <!-- 🎨 模块切换(图像/视频/音乐) -->
132
  <div id="prompt-type-tabs" class="prompt-board__types" role="group" aria-label="${escapeHtml(t('prompt.type_label'))}">
@@ -143,19 +146,19 @@ export function createPromptsView(currentUser, keyword = "") {
143
 
144
  <!-- 🏷️ 分类选择条(换行自适应侧边栏宽度) -->
145
  <div id="prompt-category-bar" class="prompt-board__categories" role="group" aria-label="${escapeHtml(t('prompt.category_label'))}">
146
- <div style="color: #888; font-size: 12px; padding: 6px 0;">⏳</div>
147
  </div>
148
 
149
  <!-- 🎯 瀑布流容器 -->
150
- <div id="prompts-grid" style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; padding: 15px; overflow-y: auto; flex: 1;">
151
- <div id="prompts-loading" style="grid-column: span 2; text-align: center; padding: 40px; color: #888;">
152
- <div style="font-size: 24px; margin-bottom: 10px;">⏳</div>
153
  ${t('prompt.loading')}
154
  </div>
155
  </div>
156
 
157
  <!-- 加载更多 -->
158
- <div id="load-more-wrapper" style="display: none; padding: 15px; text-align: center; border-top: 1px solid var(--border-color, #333);">
159
  <button type="button" id="btn-load-more" class="prompt-board__more">
160
  ${t('post.load_more')}
161
  </button>
@@ -384,7 +387,7 @@ export function createPromptsView(currentUser, keyword = "") {
384
  // 🚀 本地��序优先:已有数据时直接本地排序渲染
385
  if (allPromptsData.length > 0) {
386
  const sorted = sortPromptsLocally(allPromptsData, currentSort);
387
- renderPromptsFromCache(sorted);
388
  // 后台静默刷新最新数据
389
  silentRefresh();
390
  } else {
@@ -407,7 +410,7 @@ export function createPromptsView(currentUser, keyword = "") {
407
  listViewGeneration++;
408
  if (allPromptsData.length > 0) {
409
  const sorted = sortPromptsLocally(allPromptsData, currentSort);
410
- renderPromptsFromCache(sorted);
411
  }
412
  silentRefresh();
413
  };
@@ -415,28 +418,20 @@ export function createPromptsView(currentUser, keyword = "") {
415
 
416
  // 显示骨架屏
417
  const showSkeleton = () => {
418
- promptsGrid.innerHTML = "";
419
  for (let i = 0; i < 4; i++) {
420
  const skeletonCard = document.createElement("div");
421
- skeletonCard.style.cssText = "background: var(--comfy-input-bg); border-radius: 10px; overflow: hidden; animation: pulse 1.5s infinite;";
 
422
  skeletonCard.innerHTML = `
423
- <div style="width: 100%; padding-top: 120%; background: linear-gradient(90deg, #2a2a2a 25%, #333 50%, #2a2a2a 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite;"></div>
424
- <div style="padding: 10px;">
425
- <div style="height: 14px; background: #333; border-radius: 4px; margin-bottom: 8px;"></div>
426
- <div style="height: 12px; background: #333; border-radius: 4px; width: 60%;"></div>
427
  </div>
428
  `;
429
  promptsGrid.appendChild(skeletonCard);
430
  }
431
- if (!document.getElementById("prompts-skeleton-style")) {
432
- const style = document.createElement("style");
433
- style.id = "prompts-skeleton-style";
434
- style.textContent = `
435
- @keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }
436
- @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } }
437
- `;
438
- document.head.appendChild(style);
439
- }
440
  };
441
 
442
  const loadPrompts = async (page = 1, append = false) => {
@@ -446,11 +441,11 @@ export function createPromptsView(currentUser, keyword = "") {
446
  const requestedType = currentType;
447
  const requestedCategory = currentCategory;
448
  const requestedSort = currentSort;
449
- const cacheKey = `${CACHE_KEY_PREFIX}_${requestedType}_${requestedCategory || 'all'}_${requestedSort}`;
450
 
451
  // ✅ 优先从本地缓存读取(含过期缓存:离线容灾,与插件榜/工作流/推荐榜策略一致)
452
  if (!append && page === 1) {
453
- const cachedData = readListCache(cacheKey);
454
  if (cachedData) {
455
  allPromptsData = proxyImages(cachedData);
456
  renderPromptsFromCache(allPromptsData, true);
@@ -557,7 +552,7 @@ export function createPromptsView(currentUser, keyword = "") {
557
  console.error("加载提示词失败");
558
  // 网络失败时尝试从缓存读取(包括过期缓存,离线容灾)
559
  if (!append) {
560
- const cachedData = readListCache(cacheKey);
561
  if (cachedData) {
562
  allPromptsData = proxyImages(cachedData);
563
  renderPromptsFromCache(allPromptsData);
@@ -572,6 +567,7 @@ export function createPromptsView(currentUser, keyword = "") {
572
  // 从缓存渲染提示词列表
573
  const renderPromptsFromCache = (prompts, deferSearchScan = false) => {
574
  firstPageReady = true;
 
575
  promptsGrid.innerHTML = "";
576
 
577
  // 🔍 搜索过滤
@@ -587,6 +583,7 @@ export function createPromptsView(currentUser, keyword = "") {
587
  promptsGrid.innerHTML = _renderEmptyState();
588
  loadMoreWrapper.style.display = "none";
589
  }
 
590
  return;
591
  }
592
 
@@ -614,6 +611,7 @@ export function createPromptsView(currentUser, keyword = "") {
614
  } else {
615
  loadMoreWrapper.style.display = "none";
616
  }
 
617
  };
618
 
619
  // 后台静默更新
@@ -623,7 +621,7 @@ export function createPromptsView(currentUser, keyword = "") {
623
  const requestedType = currentType;
624
  const requestedCategory = currentCategory;
625
  const requestedSort = currentSort;
626
- const cacheKey = `${CACHE_KEY_PREFIX}_${requestedType}_${requestedCategory || 'all'}_${requestedSort}`;
627
  const requestGeneration = ++networkRequestGeneration;
628
  try {
629
  isLoadingFromNetwork = true;
@@ -677,10 +675,23 @@ export function createPromptsView(currentUser, keyword = "") {
677
  const _promptsDataChanged = (oldData, newData) => {
678
  if (!oldData || !newData) return true;
679
  if (oldData.length !== newData.length) return true;
680
- const checkCount = Math.min(10, oldData.length);
681
  const COMPARE_FIELDS = ['likes', 'favorites', 'views', 'daily_views', 'comments'];
 
 
 
 
682
  for (let i = 0; i < checkCount; i++) {
683
  if ((oldData[i].id) !== (newData[i].id)) return true;
 
 
 
 
 
 
 
 
 
684
  for (const f of COMPARE_FIELDS) {
685
  if ((oldData[i][f] || 0) !== (newData[i][f] || 0)) return true;
686
  }
@@ -741,16 +752,22 @@ export function createPromptsView(currentUser, keyword = "") {
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);
747
 
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 = `
@@ -768,7 +785,7 @@ function createPromptCard(prompt, currentUser = currentUserCache, isCurrent = ()
768
  ${priceHtml}
769
  </div>
770
  <!-- 🔒 付费锁标识 -->
771
- ${prompt.price > 0 ? `
772
  <div class="prompt-card__lock">🔒</div>
773
  ` : ''}
774
  </div>
@@ -787,7 +804,7 @@ function createPromptCard(prompt, currentUser = currentUserCache, isCurrent = ()
787
  </div>
788
 
789
  <!-- 作者信息(SWR 缓存头像) -->
790
- <div class="business-list-card__author prompt-card__author"></div>
791
 
792
  <!-- 互动数据 -->
793
  <div class="prompt-card__stats">
@@ -806,9 +823,9 @@ function createPromptCard(prompt, currentUser = currentUserCache, isCurrent = ()
806
  if (!authorContainer) return;
807
 
808
  const account = prompt.author;
809
- const cached = getCachedProfile(account);
810
- const avatar = cached?.avatar || '';
811
- const name = cached?.name || account || '';
812
 
813
  // 渲染初始头像
814
  const avatarHtml = _generateAvatarHtml(avatar, name);
@@ -816,21 +833,23 @@ function createPromptCard(prompt, currentUser = currentUserCache, isCurrent = ()
816
  authorContainer.innerHTML = `${avatarHtml}<span class="swr-name prompt-card__name">${escapeHtml(name)}</span>`;
817
 
818
  // 后台静默校对
819
- getProfileWithSWR(account, api.getUserProfile, (profile) => {
820
  if (!isCurrent() || !card.isConnected) return;
821
  const avatarEl = authorContainer.querySelector('.swr-avatar');
822
  const nameEl = authorContainer.querySelector('.swr-name');
823
- if (avatarEl && profile.avatar) {
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) {
831
  nameEl.textContent = profile.name;
832
  }
833
- });
 
 
834
  }, 0);
835
 
836
  const cover = card.querySelector('img');
 
15
  // ==========================================
16
 
17
  import { createPromptCategorySync, isPromptCategoryList } from "./提示词分类同步.js";
18
+ import { getPromptPriceState } from "./价格校验.js";
19
  import { createCardNavigationScope } from "../components/卡片导航生命周期.js";
20
  import { api, proxyImages, captureRequestSession } from "../core/网络请求API.js";
21
  import { showToast } from "../components/UI交互提示组件.js";
 
32
  const CATEGORIES_CACHE_PREFIX = "PromptsCategoriesCache"; // 📴 分类缓存键前缀(离线容灾)
33
  const PAGE_SIZE = 20;
34
 
35
+ function promptListCacheKey(type, category, sort) {
36
+ const filter = category ? `category_${JSON.stringify([category, sort])}` : `all_${sort}`;
37
+ return `${CACHE_KEY_PREFIX}_${type}_${filter}`;
38
+ }
39
+
40
+ function readPromptListCache(type, category, sort) {
41
+ const cached = readListCache(promptListCacheKey(type, category, sort));
42
+ if (cached || !category) return cached;
43
+ const legacy = readListCache(`${CACHE_KEY_PREFIX}_${type}_${category}_${sort}`);
44
+ if (!legacy || legacy.some(item => item.category !== category ||
45
+ (item.prompt_type != null && item.prompt_type !== type))) return null;
46
+ return legacy;
47
+ }
48
+
49
  // 缓存当前用户
50
  let currentUserCache = null;
51
 
 
99
  */
100
  function _renderEmptyState() {
101
  return `
102
+ <div class="prompt-board__empty" role="status">
103
+ <div class="prompt-board__empty-icon" aria-hidden="true">🧩</div>
104
+ <div class="prompt-board__empty-title">${t('prompt.no_prompts')}</div>
105
+ <div class="prompt-board__empty-hint">${t('prompt.be_first')}</div>
106
  </div>
107
  `;
108
  }
 
116
  if (avatar) {
117
  return `<img class="swr-avatar prompt-card__avatar" alt="" src="${escapeHtml(avatar)}">`;
118
  }
119
+ return `<div class="swr-avatar prompt-card__avatar prompt-card__avatar--initial" aria-hidden="true">${escapeHtml(initial)}</div>`;
120
  }
121
 
122
  /**
 
130
 
131
  const container = document.createElement("div");
132
  container.className = "prompt-board";
 
 
 
 
 
 
 
 
 
 
 
 
133
  container.innerHTML = `
134
  <!-- 🎨 模块切换(图像/视频/音乐) -->
135
  <div id="prompt-type-tabs" class="prompt-board__types" role="group" aria-label="${escapeHtml(t('prompt.type_label'))}">
 
146
 
147
  <!-- 🏷️ 分类选择条(换行自适应侧边栏宽度) -->
148
  <div id="prompt-category-bar" class="prompt-board__categories" role="group" aria-label="${escapeHtml(t('prompt.category_label'))}">
149
+ <div class="prompt-board__category-loading" aria-hidden="true">⏳</div>
150
  </div>
151
 
152
  <!-- 🎯 瀑布流容器 -->
153
+ <div id="prompts-grid" class="prompt-board__grid">
154
+ <div id="prompts-loading" class="prompt-board__loading" role="status">
155
+ <div class="prompt-board__loading-icon" aria-hidden="true">⏳</div>
156
  ${t('prompt.loading')}
157
  </div>
158
  </div>
159
 
160
  <!-- 加载更多 -->
161
+ <div id="load-more-wrapper" class="prompt-board__pagination" style="display: none;">
162
  <button type="button" id="btn-load-more" class="prompt-board__more">
163
  ${t('post.load_more')}
164
  </button>
 
387
  // 🚀 本地��序优先:已有数据时直接本地排序渲染
388
  if (allPromptsData.length > 0) {
389
  const sorted = sortPromptsLocally(allPromptsData, currentSort);
390
+ renderPromptsFromCache(sorted, true);
391
  // 后台静默刷新最新数据
392
  silentRefresh();
393
  } else {
 
410
  listViewGeneration++;
411
  if (allPromptsData.length > 0) {
412
  const sorted = sortPromptsLocally(allPromptsData, currentSort);
413
+ renderPromptsFromCache(sorted, true);
414
  }
415
  silentRefresh();
416
  };
 
418
 
419
  // 显示骨架屏
420
  const showSkeleton = () => {
421
+ promptsGrid.innerHTML = `<div class="prompt-board__loading prompt-board__loading--skeleton" role="status">${t('prompt.loading')}</div>`;
422
  for (let i = 0; i < 4; i++) {
423
  const skeletonCard = document.createElement("div");
424
+ skeletonCard.className = "prompt-board__skeleton";
425
+ skeletonCard.setAttribute("aria-hidden", "true");
426
  skeletonCard.innerHTML = `
427
+ <div class="prompt-board__skeleton-cover"></div>
428
+ <div class="prompt-board__skeleton-body">
429
+ <div class="prompt-board__skeleton-line"></div>
430
+ <div class="prompt-board__skeleton-line prompt-board__skeleton-line--short"></div>
431
  </div>
432
  `;
433
  promptsGrid.appendChild(skeletonCard);
434
  }
 
 
 
 
 
 
 
 
 
435
  };
436
 
437
  const loadPrompts = async (page = 1, append = false) => {
 
441
  const requestedType = currentType;
442
  const requestedCategory = currentCategory;
443
  const requestedSort = currentSort;
444
+ const cacheKey = promptListCacheKey(requestedType, requestedCategory, requestedSort);
445
 
446
  // ✅ 优先从本地缓存读取(含过期缓存:离线容灾,与插件榜/工作流/推荐榜策略一致)
447
  if (!append && page === 1) {
448
+ const cachedData = readPromptListCache(requestedType, requestedCategory, requestedSort);
449
  if (cachedData) {
450
  allPromptsData = proxyImages(cachedData);
451
  renderPromptsFromCache(allPromptsData, true);
 
552
  console.error("加载提示词失败");
553
  // 网络失败时尝试从缓存读取(包括过期缓存,离线容灾)
554
  if (!append) {
555
+ const cachedData = readPromptListCache(requestedType, requestedCategory, requestedSort);
556
  if (cachedData) {
557
  allPromptsData = proxyImages(cachedData);
558
  renderPromptsFromCache(allPromptsData);
 
567
  // 从缓存渲染提示词列表
568
  const renderPromptsFromCache = (prompts, deferSearchScan = false) => {
569
  firstPageReady = true;
570
+ const restoreFocus = cardNavigation.captureListFocus(promptsGrid);
571
  promptsGrid.innerHTML = "";
572
 
573
  // 🔍 搜索过滤
 
583
  promptsGrid.innerHTML = _renderEmptyState();
584
  loadMoreWrapper.style.display = "none";
585
  }
586
+ restoreFocus();
587
  return;
588
  }
589
 
 
611
  } else {
612
  loadMoreWrapper.style.display = "none";
613
  }
614
+ restoreFocus();
615
  };
616
 
617
  // 后台静默更新
 
621
  const requestedType = currentType;
622
  const requestedCategory = currentCategory;
623
  const requestedSort = currentSort;
624
+ const cacheKey = promptListCacheKey(requestedType, requestedCategory, requestedSort);
625
  const requestGeneration = ++networkRequestGeneration;
626
  try {
627
  isLoadingFromNetwork = true;
 
675
  const _promptsDataChanged = (oldData, newData) => {
676
  if (!oldData || !newData) return true;
677
  if (oldData.length !== newData.length) return true;
678
+ const checkCount = oldData.length;
679
  const COMPARE_FIELDS = ['likes', 'favorites', 'views', 'daily_views', 'comments'];
680
+ const contentFields = ["title", "content", "category", "prompt_type", "media_type", "price", "created_at", "author", "author_name"];
681
+ const mediaFields = ["cover_image", "author_avatar"];
682
+ const normalizedImage = value => typeof value === 'string'
683
+ ? proxyImages({ cover_image: value }).cover_image : value;
684
  for (let i = 0; i < checkCount; i++) {
685
  if ((oldData[i].id) !== (newData[i].id)) return true;
686
+ for (const field of contentFields) {
687
+ if (!Object.is(oldData[i][field], newData[i][field])) return true;
688
+ }
689
+ for (const field of mediaFields) {
690
+ if (!Object.is(normalizedImage(oldData[i][field]), normalizedImage(newData[i][field]))) return true;
691
+ }
692
+ const oldTags = Array.isArray(oldData[i].tags) ? oldData[i].tags : [];
693
+ const newTags = Array.isArray(newData[i].tags) ? newData[i].tags : [];
694
+ if (oldTags.length !== newTags.length || oldTags.some((value, index) => !Object.is(value, newTags[index]))) return true;
695
  for (const f of COMPARE_FIELDS) {
696
  if ((oldData[i][f] || 0) !== (newData[i][f] || 0)) return true;
697
  }
 
752
  function createPromptCard(prompt, currentUser = currentUserCache, isCurrent = () => true, captureNavigation = () => isCurrent) {
753
  const card = document.createElement("div");
754
  card.className = "business-list-card prompt-card";
755
+ if (typeof prompt.id === 'string' || (typeof prompt.id === 'number' && Number.isFinite(prompt.id))) {
756
+ card._rankingListId = String(prompt.id);
757
+ }
758
 
759
  // 格式化时间
760
  const timeStr = formatTime(prompt.created_at);
761
 
762
  // 判断是否为视频封面
763
  const isVideo = prompt.media_type === "video";
764
+ const initialAuthor = isCurrent() && typeof prompt.author === 'string' ? getCachedProfile(prompt.author) : null;
765
+ const initialAuthorName = [initialAuthor?.name, prompt.author_name, prompt.author].find(value => typeof value === 'string' && value) || '';
766
 
767
  // 价格徽标
768
+ const priceState = getPromptPriceState(prompt.price);
769
+ const priceHtml = priceState !== 'free'
770
+ ? `<span class="prompt-card__badge prompt-card__badge--paid">💎 ${escapeHtml(priceState === 'paid' ? prompt.price : t('common.unknown'))}</span>`
771
  : `<span class="prompt-card__badge prompt-card__badge--free">${t('prompt.free')}</span>`;
772
 
773
  card.innerHTML = `
 
785
  ${priceHtml}
786
  </div>
787
  <!-- 🔒 付费锁标识 -->
788
+ ${priceState !== 'free' ? `
789
  <div class="prompt-card__lock">🔒</div>
790
  ` : ''}
791
  </div>
 
804
  </div>
805
 
806
  <!-- 作者信息(SWR 缓存头像) -->
807
+ <div class="business-list-card__author prompt-card__author">${_generateAvatarHtml('', initialAuthorName)}<span class="swr-name prompt-card__name">${escapeHtml(initialAuthorName)}</span></div>
808
 
809
  <!-- 互动数据 -->
810
  <div class="prompt-card__stats">
 
823
  if (!authorContainer) return;
824
 
825
  const account = prompt.author;
826
+ const cached = initialAuthor;
827
+ const avatar = [cached?.avatar, prompt.author_avatar].find(value => typeof value === 'string' && value) || '';
828
+ const name = initialAuthorName;
829
 
830
  // 渲染初始头像
831
  const avatarHtml = _generateAvatarHtml(avatar, name);
 
833
  authorContainer.innerHTML = `${avatarHtml}<span class="swr-name prompt-card__name">${escapeHtml(name)}</span>`;
834
 
835
  // 后台静默校对
836
+ const updateAuthor = (profile) => {
837
  if (!isCurrent() || !card.isConnected) return;
838
  const avatarEl = authorContainer.querySelector('.swr-avatar');
839
  const nameEl = authorContainer.querySelector('.swr-name');
840
+ if (avatarEl && typeof profile.avatar === 'string' && profile.avatar) {
841
  if (avatarEl.tagName === 'IMG') {
842
+ if (avatarEl.getAttribute('src') !== profile.avatar) avatarEl.src = profile.avatar;
843
  } else {
844
+ avatarEl.outerHTML = _generateAvatarHtml(profile.avatar, typeof profile.name === 'string' && profile.name ? profile.name : name);
845
  }
846
  }
847
+ if (nameEl && typeof profile.name === 'string' && profile.name && nameEl.textContent !== profile.name) {
848
  nameEl.textContent = profile.name;
849
  }
850
+ };
851
+ const latestCached = getProfileWithSWR(account, api.getUserProfile, updateAuthor);
852
+ if (latestCached) updateAuthor(latestCached);
853
  }, 0);
854
 
855
  const cover = card.querySelector('img');
前端页面/prompt/提示词详情组件.js CHANGED
@@ -12,8 +12,9 @@
12
  // ==========================================
13
 
14
  import { api, proxyImages, captureRequestSession, requestCancelManager } from "../core/网络请求API.js";
 
15
  import { showToast } from "../components/UI交互提示组件.js";
16
- import { getCoverSandboxHTML, setupImageSandboxEvents } from "../components/图片沙盒组件.js";
17
  import { getVideoPlayerHTML, setupVideoPlayerEvents, cleanupVideoPlayer } from "../components/视频播放器组件.js";
18
  import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
19
  import { t, tIfExists } from "../components/用户体验增强.js";
@@ -25,6 +26,8 @@ import { recordView, handleToggleLike, handleToggleFavorite, renderTipBoardHTML
25
  import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
26
  import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
27
 
 
 
28
  const viewContexts = new WeakMap();
29
  let promptViewSequence = 0;
30
  const isCurrent = node => !!viewContexts.get(node)?.scope.isCurrent();
@@ -44,10 +47,9 @@ function _generateAvatarHtml(avatar, name, size, extraStyle = '') {
44
  const initial = (name || 'U')[0].toUpperCase();
45
  // 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
46
  if (avatar) {
47
- return `<img class="swr-avatar" src="${escapeHtml(avatar)}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; ${extraStyle}">`;
48
  }
49
- const fontSize = Math.max(9, Math.round(size * 0.4));
50
- 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>`;
51
  }
52
 
53
  /**
@@ -58,7 +60,7 @@ function _updateAvatarElement(avatarEl, profile, size, extraStyle = '') {
58
  if (avatarEl.tagName === 'IMG') {
59
  avatarEl.src = profile.avatar;
60
  } else {
61
- avatarEl.outerHTML = `<img class="swr-avatar" src="${escapeHtml(profile.avatar)}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; ${extraStyle}">`;
62
  }
63
  }
64
 
@@ -67,31 +69,21 @@ function _updateAvatarElement(avatarEl, profile, size, extraStyle = '') {
67
  */
68
  export function createPromptDetailView(promptId, currentUser) {
69
  const container = document.createElement("div");
70
- Object.assign(container.style, {
71
- display: "flex",
72
- flexDirection: "column",
73
- gap: "0",
74
- color: "#ccc",
75
- fontSize: "14px",
76
- padding: "0",
77
- overflowY: "auto",
78
- flex: "1",
79
- boxSizing: "border-box"
80
- });
81
 
82
  container.innerHTML = `
83
  <!-- 顶部标题栏 -->
84
- <div style="display: flex; align-items: center; gap: 10px; padding: 15px; border-bottom: 1px solid #444; background: #1a1a1a;">
85
- <button id="btn-back-prompt-detail" style="background: rgba(51,51,51,0.8); border: 1px solid rgba(85,85,85,0.8); color: #fff; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: bold; display: flex; align-items: center; gap: 6px; box-shadow: 0 2px 4px rgba(0,0,0,0.3); transition: 0.2s;" onmouseover="this.style.background='#667eea'; this.style.borderColor='#667eea'" onmouseout="this.style.background='rgba(51,51,51,0.8)'; this.style.borderColor='rgba(85,85,85,0.8)'">
86
  ⬅ ${t('common.back')}
87
  </button>
88
- <span style="font-size: 16px; font-weight: bold; color: #fff;">${t('prompt.detail_title')}</span>
89
  </div>
90
 
91
  <!-- 内容区域 -->
92
- <div id="prompt-content" style="flex: 1; overflow-y: auto; padding: 15px;">
93
- <div style="text-align: center; padding: 60px; color: #888;">
94
- <div style="font-size: 24px; margin-bottom: 10px;">⏳</div>
95
  ${t('prompt.loading')}
96
  </div>
97
  </div>
@@ -115,6 +107,7 @@ export function createPromptDetailView(promptId, currentUser) {
115
  viewContexts.set(container, context); viewContexts.set(contentArea, context);
116
  const handleRouteBack = () => scope.dispose();
117
  scope.addCleanup(() => {
 
118
  cleanupVideoPlayer(contentArea);
119
  contentArea.querySelectorAll('audio').forEach(audio => { audio.pause(); audio.removeAttribute('src'); audio.load(); });
120
  contentArea.replaceChildren(); observer?.disconnect();
@@ -147,6 +140,7 @@ async function loadPromptDetail(container, promptId, currentUser) {
147
 
148
  // 先清理旧的视频播放器全局事件(如果存在)
149
  if (contentArea) {
 
150
  cleanupVideoPlayer(contentArea);
151
  }
152
 
@@ -171,8 +165,8 @@ async function loadPromptDetail(container, promptId, currentUser) {
171
 
172
  if (!prompt) {
173
  contentArea.innerHTML = `
174
- <div style="text-align: center; padding: 60px; color: #F44336;">
175
- <div style="font-size: 24px; margin-bottom: 10px;">⚠️</div>
176
  ${t('prompt.not_exist')}
177
  </div>
178
  `;
@@ -185,6 +179,7 @@ async function loadPromptDetail(container, promptId, currentUser) {
185
  // 🔓 判断提示词是否可见(免费/作者/已购买 → prompt_text 已由服务端下发)
186
  const isOwner = currentUser && currentUser.account === prompt.author;
187
  const isUnlocked = !fromCache && typeof prompt.prompt_text === "string" && prompt.locked !== true;
 
188
 
189
  // 判断是否为视频封面
190
  const isVideo = prompt.media_type === "video" && prompt.video_url;
@@ -199,9 +194,9 @@ async function loadPromptDetail(container, promptId, currentUser) {
199
 
200
  // 🎵 音乐提示词音频播放器(图片+音频方案,仅存在 audio_url 时渲染)
201
  const audioHTML = prompt.audio_url ? `
202
- <div style="margin-top: 12px; padding: 12px; background: #1a1a1a; border: 1px solid #444; border-radius: 8px;">
203
- <div style="font-size: 12px; color: #aaa; margin-bottom: 8px;">🎵 ${t('prompt.audio_label')}</div>
204
- <audio controls preload="metadata" src="${escapeHtml(prompt.audio_url)}" style="width: 100%; display: block;"></audio>
205
  </div>` : '';
206
 
207
  // 检查当前用户是否已点赞/收藏
@@ -210,100 +205,100 @@ async function loadPromptDetail(container, promptId, currentUser) {
210
 
211
  contentArea.innerHTML = `
212
  <!-- 媒体展示区 -->
213
- <div id="media-area" style="margin-bottom: 15px;">
214
  ${mediaHTML}
215
  ${audioHTML}
216
  </div>
217
 
218
  <!-- 标题 -->
219
- <div style="font-size: 18px; font-weight: bold; color: #fff; margin-bottom: 8px; line-height: 1.4;">
220
  ${escapeHtml(prompt.title)}
221
  </div>
222
 
223
  <!-- 原创标识 -->
224
  ${prompt.is_original ? `
225
- <div style="display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px;
226
- background: linear-gradient(135deg, #FF6B35, #FF8F00); border-radius: 12px;
227
- font-size: 11px; color: #fff; font-weight: 500; margin-bottom: 12px;">
228
  🎨 ${t('prompt.original_badge')}
229
  </div>
230
  ` : ''}
231
 
232
  <!-- 分类与价格标签 -->
233
- <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 12px;">
234
- <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: 11px; padding: 3px 10px; border-radius: 10px;">${prompt.prompt_type === 'video' ? '🎬' : (prompt.prompt_type === 'music' ? '🎵' : '🎨')} ${escapeHtml(tIfExists('promptcat.' + (prompt.category || ''), prompt.category || ''))}</span>
235
- ${prompt.price > 0
236
- ? `<span style="background: linear-gradient(135deg, #FFD700, #FFA000); color: #333; font-size: 11px; font-weight: bold; padding: 3px 10px; border-radius: 10px;">💎 ${prompt.price}</span>`
237
- : `<span style="background: rgba(76, 175, 80, 0.25); color: #4CAF50; font-size: 11px; font-weight: bold; padding: 3px 10px; border-radius: 10px; border: 1px solid rgba(76, 175, 80, 0.5);">${t('prompt.free')}</span>`}
238
- ${prompt.owned ? `<span style="background: rgba(33, 150, 243, 0.25); color: #64B5F6; font-size: 11px; font-weight: bold; padding: 3px 10px; border-radius: 10px; border: 1px solid rgba(33, 150, 243, 0.5);">${t('prompt.owned_mark')}</span>` : ''}
239
  </div>
240
 
241
  <!-- 🏷️ 标签展示 -->
242
  ${(Array.isArray(prompt.tags) && prompt.tags.length) ? `
243
- <div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px;">
244
- ${prompt.tags.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: 11px; padding: 2px 8px; border-radius: 10px;">#${escapeHtml(tag)}</span>`).join('')}
245
  </div>` : ''}
246
 
247
  <!-- 作者信息(SWR 缓存头像) -->
248
- <div id="author-row" style="display: flex; align-items: center; gap: 10px; margin-bottom: 15px; padding: 10px; background: #1a1a1a; border-radius: 8px; cursor: pointer;">
249
- <div class="swr-avatar-container" style="width: 40px; height: 40px;"></div>
250
- <div style="flex: 1;">
251
- <div class="swr-name" style="font-size: 14px; font-weight: 500; color: #fff;">${escapeHtml(prompt.author_name || prompt.author)}</div>
252
- <div style="font-size: 11px; color: #888;">${formatTime(prompt.created_at)}</div>
253
- </div>
 
 
254
  ${isOwner ? `
255
- <div style="display: flex; gap: 8px;" onclick="event.stopPropagation()">
256
- ${IS_WEB_MODE ? '' : `<button id="btn-edit-prompt" style="background: #2196F3; border: none; color: #fff; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;" title="${t('common.edit')}">✏️</button>`}
257
- <button id="btn-delete-prompt" style="background: #F44336; border: none; color: #fff; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;" title="${t('common.delete')}">🗑️</button>
258
  </div>
259
  ` : ''}
260
  </div>
261
 
262
  <!-- 简介内容 -->
263
- <div style="font-size: 14px; color: #ddd; line-height: 1.8; margin-bottom: 20px; white-space: pre-wrap; word-wrap: break-word;">${escapeHtml((prompt.content || '').trim())}</div>
264
 
265
  <!-- 🔑 提示词正文区(付费遮罩/解锁/复制) -->
266
- <div id="prompt-text-area" style="margin-bottom: 20px;"></div>
267
 
268
  <!-- 互动按钮栏 -->
269
- <div id="interaction-bar" style="display: flex; align-items: center; gap: 15px; padding: 15px 0; border-top: 1px solid var(--border-color, #333); border-bottom: 1px solid var(--border-color, #333);">
270
- <button id="btn-like" style="background: ${isLiked ? '#FF5722' : 'var(--comfy-input-bg)'}; border: 1px solid ${isLiked ? '#FF5722' : '#555'}; color: #fff; padding: 8px 16px; border-radius: 20px; cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 6px; transition: 0.2s;">
271
- ❤️ <span id="like-count">${prompt.likes || 0}</span>
272
  </button>
273
- <button id="btn-favorite" style="background: ${isFavorited ? '#FFC107' : 'var(--comfy-input-bg)'}; border: 1px solid ${isFavorited ? '#FFC107' : '#555'}; color: ${isFavorited ? '#000' : '#fff'}; padding: 8px 16px; border-radius: 20px; cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 6px; transition: 0.2s;">
274
- 🔖 <span id="favorite-count">${prompt.favorites || 0}</span>
275
  </button>
276
- <button id="btn-tip" style="background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 8px 16px; border-radius: 20px; cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 6px; transition: 0.2s;">
277
  ${t('prompt.tip_author')}
278
  </button>
279
  <!-- 👀 浏览量统计(纯展示) -->
280
- <div style="display: flex; align-items: center; gap: 12px; margin-left: auto; color: #888; font-size: 13px;">
281
- <span style="display: flex; align-items: center; gap: 4px;">🔥 <span id="prompt-view-total">${prompt.views || 0}</span></span>
282
- <span style="display: flex; align-items: center; gap: 4px;">📅 <span id="prompt-view-daily">${prompt.daily_views || 0}</span></span>
283
  </div>
284
  </div>
285
 
286
  <!-- 打赏榜单 -->
287
- <div id="tip-board-area" style="margin: 15px 0;">
288
  ${renderCommonTipBoardHTML(prompt.tip_board || [], 5, t('prompt.no_tips'))}
289
  </div>
290
 
291
  <!-- 评论区 -->
292
- <div style="font-size: 14px; font-weight: bold; color: #fff; margin-bottom: 10px; display: flex; align-items: center; gap: 8px;">
293
- ${t('prompt.comments_title')} <span style="color: #888; font-weight: normal;">(${prompt.comments || 0})</span>
294
  </div>
295
 
296
  <!-- 评论输入框 -->
297
- <div style="display: flex; gap: 10px; margin-bottom: 15px;">
298
- <input type="text" id="comment-input" placeholder="${t('prompt.comment_placeholder')}" style="flex: 1; padding: 10px 12px; background: var(--comfy-input-bg); border: 1px solid #444; border-radius: 20px; color: #fff; font-size: 13px; outline: none;" onfocus="this.style.borderColor='#764ba2'" onblur="this.style.borderColor='#444'">
299
- <button id="btn-send-comment" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; color: #fff; padding: 10px 20px; border-radius: 20px; cursor: pointer; font-size: 13px; font-weight: bold; transition: 0.2s;">
300
  ${t('common.send')}
301
  </button>
302
  </div>
303
 
304
  <!-- 评论列表 -->
305
- <div id="comments-list" style="display: flex; flex-direction: column; gap: 10px;">
306
- <div style="text-align: center; padding: 20px; color: #888; font-size: 12px;">⏳</div>
307
  </div>
308
  `;
309
 
@@ -349,7 +344,7 @@ async function loadPromptDetail(container, promptId, currentUser) {
349
  }
350
 
351
  // 绑定作者点击
352
- contentArea.querySelector("#author-row").onclick = (e) => {
353
  if (!isCurrent(contentArea)) return;
354
  if (e.target.id === 'btn-edit-prompt' || e.target.id === 'btn-delete-prompt') return;
355
  openOtherUserProfileModal(prompt.author, currentUser);
@@ -402,35 +397,36 @@ function renderPromptTextArea(contentArea, prompt, currentUser, isUnlocked) {
402
  if (!isCurrent(contentArea)) return;
403
  const textArea = contentArea.querySelector("#prompt-text-area");
404
  if (!textArea) return;
 
405
 
406
  if (isUnlocked && prompt.prompt_text) {
407
  // ✅ 已解锁:展示正文 + 一键复制
408
  textArea.innerHTML = `
409
- <div style="background: #1a1a1a; border: 1px solid rgba(118, 75, 162, 0.5); border-radius: 8px; overflow: hidden;">
410
- <div style="display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid #333; background: rgba(118, 75, 162, 0.15);">
411
- <span style="font-size: 13px; font-weight: bold; color: #CE93D8;">🔑 ${t('prompt.text_label')}</span>
412
- <button id="btn-copy-prompt" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; color: #fff; padding: 6px 16px; border-radius: 16px; cursor: pointer; font-size: 12px; font-weight: bold; display: flex; align-items: center; gap: 4px;">
413
  📋 ${t('prompt.copy')}
414
  </button>
415
  </div>
416
- <div style="padding: 14px; font-size: 13px; color: #ddd; line-height: 1.7; font-family: monospace; white-space: pre-wrap; word-wrap: break-word; user-select: text;">${escapeHtml(prompt.prompt_text)}</div>
417
  </div>
418
  `;
419
  textArea.querySelector("#btn-copy-prompt").onclick = () => {
420
  if (isCurrent(contentArea)) copyToClipboard(prompt.prompt_text, textArea.querySelector("#btn-copy-prompt"), () => isCurrent(contentArea));
421
  };
422
- } else if (prompt.price > 0) {
423
  // 🔒 付费遮罩:模糊预览 + 解锁按钮
424
  textArea.innerHTML = `
425
- <div style="position: relative; min-height: 240px; background: #1a1a1a; border: 1px solid #444; border-radius: 8px; overflow: hidden;">
426
- <div style="padding: 14px; min-height: 240px; box-sizing: border-box; font-size: 13px; color: #ddd; line-height: 1.7; font-family: monospace; filter: blur(8px); user-select: none; pointer-events: none;">
427
  ${escapeHtml((prompt.content || '') + '\n' + (prompt.content || ''))}
428
  </div>
429
- <div style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; background: rgba(0,0,0,0.35);">
430
- <div style="font-size: 28px;">🔒</div>
431
- <div style="font-size: 14px; font-weight: bold; color: #fff;">${t('prompt.locked_title')}</div>
432
- <div style="font-size: 12px; color: #bbb; max-width: 80%; text-align: center;">${t('prompt.locked_desc')}</div>
433
- <button id="btn-unlock-prompt" style="background: linear-gradient(135deg, #FFD700, #FFA000); border: none; color: #333; padding: 10px 28px; border-radius: 20px; cursor: pointer; font-size: 14px; font-weight: bold; display: flex; align-items: center; gap: 6px; transition: 0.2s;" onmouseover="this.style.transform='scale(1.05)'" onmouseout="this.style.transform='scale(1)'">
434
  💎 ${t('prompt.unlock', { price: prompt.price })}
435
  </button>
436
  </div>
@@ -439,6 +435,12 @@ function renderPromptTextArea(contentArea, prompt, currentUser, isUnlocked) {
439
  textArea.querySelector("#btn-unlock-prompt").onclick = () => {
440
  showPurchaseConfirmDialog(contentArea, prompt, currentUser);
441
  };
 
 
 
 
 
 
442
  }
443
  }
444
 
@@ -526,18 +528,18 @@ async function showPurchaseConfirmDialog(contentArea, prompt, currentUser) {
526
  }
527
 
528
  const content = document.createElement("div");
529
- content.style.color = "#ccc";
530
  content.innerHTML = `
531
- <div style="text-align: center;">
532
- <div style="font-size: 36px; margin-bottom: 12px;">💎</div>
533
- <div style="font-size: 15px; font-weight: bold; color: #fff; margin-bottom: 10px;">${t('prompt.purchase_confirm_title')}</div>
534
- <div style="font-size: 13px; color: #aaa; margin-bottom: 15px; line-height: 1.6;">${t('prompt.purchase_confirm_desc', { price: expectedPrice, title: escapeHtml(purchaseTitle) })}</div>
535
- <div style="margin-bottom: 15px; background: rgba(255,152,0,0.1); padding: 10px; border-radius: 4px; border: 1px solid #FF9800;">
536
- ${t('prompt.purchase_balance')}: <strong style="color:#FF9800;">${balance}</strong>
537
  </div>
538
- <div style="display: flex; gap: 10px;">
539
- <button id="purchase-cancel" style="flex: 1; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer;">${t('common.cancel')}</button>
540
- <button id="purchase-confirm" style="flex: 1; background: linear-gradient(135deg, #FFD700, #FFA000); border: none; color: #333; padding: 10px; border-radius: 6px; cursor: pointer; font-weight: bold;">💎 ${t('prompt.unlock', { price: expectedPrice })}</button>
541
  </div>
542
  </div>
543
  `;
@@ -644,19 +646,19 @@ async function showTipDialog(prompt, currentUser, container) {
644
  }
645
 
646
  const content = document.createElement("div");
647
- content.style.color = "#ccc";
648
  content.innerHTML = `
649
- <div style="margin-bottom: 12px; background: rgba(255,152,0,0.1); padding: 10px; border-radius: 4px; border: 1px solid #FF9800; text-align: center;">
650
- ${t('wallet.current_balance')}: <strong style="color:#FF9800;">${balance}</strong> ${t('wallet.credits')}
651
  </div>
652
- <div style="display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; margin-bottom: 15px;">
653
- <button class="tip-amount" data-amount="10" style="background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px 20px; border-radius: 6px; cursor: pointer;">10 ${t('task.points')}</button>
654
- <button class="tip-amount" data-amount="50" style="background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px 20px; border-radius: 6px; cursor: pointer;">50 ${t('task.points')}</button>
655
- <button class="tip-amount" data-amount="100" style="background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px 20px; border-radius: 6px; cursor: pointer;">100 ${t('task.points')}</button>
656
- <button class="tip-amount" data-amount="500" style="background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px 20px; border-radius: 6px; cursor: pointer;">500 ${t('task.points')}</button>
657
  </div>
658
- <div style="display: flex; gap: 10px;">
659
- <button id="tip-cancel" style="flex: 1; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer;">${t('common.cancel')}</button>
660
  </div>
661
  `;
662
 
@@ -705,6 +707,13 @@ async function loadComments(container, promptId, currentUser) {
705
  const sendBtn = container.querySelector("#btn-send-comment");
706
  if (!isCurrent(container)) return;
707
  const context = viewContexts.get(container);
 
 
 
 
 
 
 
708
  const generation = context.commentGeneration = (context.commentGeneration || 0) + 1;
709
  const current = () => isCurrent(container) && context.commentGeneration === generation && commentsList.isConnected;
710
 
@@ -718,7 +727,7 @@ async function loadComments(container, promptId, currentUser) {
718
 
719
  if (comments.length === 0) {
720
  commentsList.innerHTML = `
721
- <div style="text-align: center; padding: 30px; color: #666; font-size: 13px;">
722
  ${t('prompt.no_comments')}
723
  </div>
724
  `;
@@ -732,13 +741,13 @@ async function loadComments(container, promptId, currentUser) {
732
  const avatarHtml = _generateAvatarHtml(avatar, name, 24, 'background: var(--comfy-input-bg);');
733
 
734
  return `
735
- <div id="comment-${idx}" data-account="${escapeHtml(c.author)}" style="background: #1a1a1a; padding: 12px; border-radius: 8px;">
736
- <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
737
  <div class="swr-avatar-container">${avatarHtml}</div>
738
- <span class="swr-name" style="font-size: 13px; color: #fff; font-weight: 500;">${escapeHtml(name)}</span>
739
- <span style="font-size: 11px; color: #888; margin-left: auto;">${formatTime(c.created_at)}</span>
740
  </div>
741
- <div style="font-size: 13px; color: #ddd; line-height: 1.5; padding-left: 32px;">
742
  ${escapeHtml(c.content)}
743
  </div>
744
  </div>
@@ -762,45 +771,71 @@ async function loadComments(container, promptId, currentUser) {
762
  } catch (err) {
763
  if (!current()) return;
764
  commentsList.innerHTML = `
765
- <div style="text-align: center; padding: 20px; color: #F44336; font-size: 12px;">
766
  ${t('prompt.load_failed')}
767
  </div>
768
  `;
769
  }
770
 
771
- // 发送评论
 
 
 
 
 
 
 
772
  sendBtn.onclick = async () => {
773
- if (!isCurrent(container) || !sendBtn.isConnected || sendBtn.disabled) return;
774
- if (!currentUser) {
775
  showToast(t('auth.login_required'), "warning");
776
  return;
777
  }
778
- const content = commentInput.value.trim();
779
  if (!content) {
 
 
780
  showToast(t('prompt.comment_required'), "warning");
781
  return;
782
  }
 
 
 
 
 
783
  try {
784
- sendBtn.disabled = true;
785
- sendBtn.textContent = t('common.sending');
786
  const result = await api.addPromptComment(promptId, content);
787
- if (!isCurrent(container) || !sendBtn.isConnected) return;
788
- if (result?.status !== "success") throw new Error();
789
- if (commentInput.value.trim() === content) commentInput.value = "";
 
790
  showToast(t('prompt.comment_success'), "success");
791
- loadComments(container, promptId, currentUser);
 
792
  } catch (err) {
793
- if (isCurrent(container) && sendBtn.isConnected) showToast(t('prompt.comment_failed'), "error");
 
 
 
 
 
 
 
794
  } finally {
795
- if (!isCurrent(container) || !sendBtn.isConnected) return;
796
- sendBtn.disabled = false;
797
- sendBtn.textContent = t('common.send');
 
 
798
  }
799
  };
800
 
801
  // 回车发送
802
  commentInput.onkeydown = (e) => {
803
- if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
 
 
 
804
  e.preventDefault();
805
  sendBtn.click();
806
  }
@@ -813,15 +848,15 @@ async function loadComments(container, promptId, currentUser) {
813
  function showDeleteConfirmDialog(prompt, container) {
814
  if (!isCurrent(container)) return;
815
  const content = document.createElement("div");
816
- content.style.color = "#ccc";
817
  content.innerHTML = `
818
- <div style="text-align: center;">
819
- <div style="font-size: 40px; margin-bottom: 15px;">⚠️</div>
820
- <div style="font-size: 16px; font-weight: bold; color: #fff; margin-bottom: 10px;">${t('prompt.delete_confirm')}</div>
821
- <div style="font-size: 13px; color: #888; margin-bottom: 20px;">${escapeHtml(prompt.title)}</div>
822
- <div style="display: flex; gap: 10px;">
823
- <button id="delete-cancel" style="flex: 1; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer;">${t('common.cancel')}</button>
824
- <button id="delete-confirm" style="flex: 1; background: #F44336; border: none; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer; font-weight: bold;">${t('common.delete')}</button>
825
  </div>
826
  </div>
827
  `;
 
12
  // ==========================================
13
 
14
  import { api, proxyImages, captureRequestSession, requestCancelManager } from "../core/网络请求API.js";
15
+ import { getPromptPriceState } from "./价格校验.js";
16
  import { showToast } from "../components/UI交互提示组件.js";
17
+ import { getCoverSandboxHTML, setupImageSandboxEvents, cleanupImageSandbox } from "../components/图片沙盒组件.js";
18
  import { getVideoPlayerHTML, setupVideoPlayerEvents, cleanupVideoPlayer } from "../components/视频播放器组件.js";
19
  import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
20
  import { t, tIfExists } from "../components/用户体验增强.js";
 
26
  import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
27
  import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
28
 
29
+ import { isConfirmedDedicatedCommentResponse, isDefiniteCommentRejection } from "../social/评论数据工具.js";
30
+
31
  const viewContexts = new WeakMap();
32
  let promptViewSequence = 0;
33
  const isCurrent = node => !!viewContexts.get(node)?.scope.isCurrent();
 
47
  const initial = (name || 'U')[0].toUpperCase();
48
  // 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
49
  if (avatar) {
50
+ return `<img class="swr-avatar prompt-detail__avatar ${size === 24 ? 'prompt-detail__avatar--small' : 'prompt-detail__avatar--large'}" alt="" src="${escapeHtml(avatar)}">`;
51
  }
52
+ return `<div class="swr-avatar prompt-detail__avatar prompt-detail__avatar--initial ${size === 24 ? 'prompt-detail__avatar--small' : 'prompt-detail__avatar--large'}" aria-hidden="true">${escapeHtml(initial)}</div>`;
 
53
  }
54
 
55
  /**
 
60
  if (avatarEl.tagName === 'IMG') {
61
  avatarEl.src = profile.avatar;
62
  } else {
63
+ avatarEl.outerHTML = `<img class="swr-avatar prompt-detail__avatar ${size === 24 ? 'prompt-detail__avatar--small' : 'prompt-detail__avatar--large'}" alt="" src="${escapeHtml(profile.avatar)}">`;
64
  }
65
  }
66
 
 
69
  */
70
  export function createPromptDetailView(promptId, currentUser) {
71
  const container = document.createElement("div");
72
+ container.className = "prompt-detail";
 
 
 
 
 
 
 
 
 
 
73
 
74
  container.innerHTML = `
75
  <!-- 顶部标题栏 -->
76
+ <div class="prompt-detail__header">
77
+ <button type="button" id="btn-back-prompt-detail" class="prompt-detail__button">
78
  ⬅ ${t('common.back')}
79
  </button>
80
+ <span class="prompt-detail__heading">${t('prompt.detail_title')}</span>
81
  </div>
82
 
83
  <!-- 内容区域 -->
84
+ <div id="prompt-content" class="prompt-detail__content">
85
+ <div class="prompt-detail__state" role="status">
86
+ <div class="prompt-detail__state-icon" aria-hidden="true">⏳</div>
87
  ${t('prompt.loading')}
88
  </div>
89
  </div>
 
107
  viewContexts.set(container, context); viewContexts.set(contentArea, context);
108
  const handleRouteBack = () => scope.dispose();
109
  scope.addCleanup(() => {
110
+ cleanupImageSandbox(contentArea);
111
  cleanupVideoPlayer(contentArea);
112
  contentArea.querySelectorAll('audio').forEach(audio => { audio.pause(); audio.removeAttribute('src'); audio.load(); });
113
  contentArea.replaceChildren(); observer?.disconnect();
 
140
 
141
  // 先清理旧的视频播放器全局事件(如果存在)
142
  if (contentArea) {
143
+ cleanupImageSandbox(contentArea);
144
  cleanupVideoPlayer(contentArea);
145
  }
146
 
 
165
 
166
  if (!prompt) {
167
  contentArea.innerHTML = `
168
+ <div class="prompt-detail__state prompt-detail__state--error" role="alert">
169
+ <div class="prompt-detail__state-icon" aria-hidden="true">⚠️</div>
170
  ${t('prompt.not_exist')}
171
  </div>
172
  `;
 
179
  // 🔓 判断提示词是否可见(免费/作者/已购买 → prompt_text 已由服务端下发)
180
  const isOwner = currentUser && currentUser.account === prompt.author;
181
  const isUnlocked = !fromCache && typeof prompt.prompt_text === "string" && prompt.locked !== true;
182
+ const priceState = getPromptPriceState(prompt.price);
183
 
184
  // 判断是否为视频封面
185
  const isVideo = prompt.media_type === "video" && prompt.video_url;
 
194
 
195
  // 🎵 音乐提示词音频播放器(图片+音频方案,仅存在 audio_url 时渲染)
196
  const audioHTML = prompt.audio_url ? `
197
+ <div class="prompt-detail__audio">
198
+ <div class="prompt-detail__muted prompt-detail__audio-label">🎵 ${t('prompt.audio_label')}</div>
199
+ <audio controls preload="metadata" src="${escapeHtml(prompt.audio_url)}" class="prompt-detail__audio-player"></audio>
200
  </div>` : '';
201
 
202
  // 检查当前用户是否已点赞/收藏
 
205
 
206
  contentArea.innerHTML = `
207
  <!-- 媒体展示区 -->
208
+ <div id="media-area" class="prompt-detail__media">
209
  ${mediaHTML}
210
  ${audioHTML}
211
  </div>
212
 
213
  <!-- 标题 -->
214
+ <div class="prompt-detail__title">
215
  ${escapeHtml(prompt.title)}
216
  </div>
217
 
218
  <!-- 原创标识 -->
219
  ${prompt.is_original ? `
220
+ <div class="prompt-detail__badge prompt-detail__badge--original">
 
 
221
  🎨 ${t('prompt.original_badge')}
222
  </div>
223
  ` : ''}
224
 
225
  <!-- 分类与价格标签 -->
226
+ <div class="prompt-detail__badges">
227
+ <span class="prompt-detail__badge">${prompt.prompt_type === 'video' ? '🎬' : (prompt.prompt_type === 'music' ? '🎵' : '🎨')} ${escapeHtml(tIfExists('promptcat.' + (prompt.category || ''), prompt.category || ''))}</span>
228
+ ${priceState !== 'free'
229
+ ? `<span class="prompt-detail__badge prompt-detail__badge--price">💎 ${escapeHtml(priceState === 'paid' ? prompt.price : t('common.unknown'))}</span>`
230
+ : `<span class="prompt-detail__badge">${t('prompt.free')}</span>`}
231
+ ${prompt.owned ? `<span class="prompt-detail__badge">${t('prompt.owned_mark')}</span>` : ''}
232
  </div>
233
 
234
  <!-- 🏷️ 标签展示 -->
235
  ${(Array.isArray(prompt.tags) && prompt.tags.length) ? `
236
+ <div class="prompt-detail__tags">
237
+ ${prompt.tags.map(tag => `<span class="prompt-detail__badge">#${escapeHtml(tag)}</span>`).join('')}
238
  </div>` : ''}
239
 
240
  <!-- 作者信息(SWR 缓存头像) -->
241
+ <div id="author-row" class="prompt-detail__author">
242
+ <button type="button" id="btn-author-prompt" class="prompt-detail__author-link">
243
+ <span class="swr-avatar-container prompt-detail__avatar-slot"></span>
244
+ <span class="prompt-detail__author-info">
245
+ <span class="swr-name prompt-detail__author-name">${escapeHtml(prompt.author_name || prompt.author)}</span>
246
+ <span class="prompt-detail__muted prompt-detail__author-time">${escapeHtml(formatTime(prompt.created_at))}</span>
247
+ </span>
248
+ </button>
249
  ${isOwner ? `
250
+ <div class="prompt-detail__author-actions">
251
+ ${IS_WEB_MODE ? '' : `<button type="button" id="btn-edit-prompt" class="prompt-detail__button" aria-label="${t('common.edit')}" title="${t('common.edit')}">✏️</button>`}
252
+ <button type="button" id="btn-delete-prompt" class="prompt-detail__button prompt-detail__button--danger" aria-label="${t('common.delete')}" title="${t('common.delete')}">🗑️</button>
253
  </div>
254
  ` : ''}
255
  </div>
256
 
257
  <!-- 简介内容 -->
258
+ <div class="prompt-detail__description">${escapeHtml((prompt.content || '').trim())}</div>
259
 
260
  <!-- 🔑 提示词正文区(付费遮罩/解锁/复制) -->
261
+ <div id="prompt-text-area" class="prompt-detail__text-area"></div>
262
 
263
  <!-- 互动按钮栏 -->
264
+ <div id="interaction-bar" class="prompt-detail__interactions">
265
+ <button type="button" id="btn-like" aria-label="${t('market.like')}" aria-pressed="${isLiked}" class="prompt-detail__button prompt-detail__interaction">
266
+ ❤️ <span id="like-count">${escapeHtml(prompt.likes || 0)}</span>
267
  </button>
268
+ <button type="button" id="btn-favorite" aria-label="${t('market.favorite')}" aria-pressed="${isFavorited}" class="prompt-detail__button prompt-detail__interaction">
269
+ 🔖 <span id="favorite-count">${escapeHtml(prompt.favorites || 0)}</span>
270
  </button>
271
+ <button type="button" id="btn-tip" class="prompt-detail__button prompt-detail__interaction">
272
  ${t('prompt.tip_author')}
273
  </button>
274
  <!-- 👀 浏览量统计(纯展示) -->
275
+ <div class="prompt-detail__views">
276
+ <span class="prompt-detail__view-count">🔥 <span id="prompt-view-total">${escapeHtml(prompt.views || 0)}</span></span>
277
+ <span class="prompt-detail__view-count">📅 <span id="prompt-view-daily">${escapeHtml(prompt.daily_views || 0)}</span></span>
278
  </div>
279
  </div>
280
 
281
  <!-- 打赏榜单 -->
282
+ <div id="tip-board-area" class="prompt-detail__tips">
283
  ${renderCommonTipBoardHTML(prompt.tip_board || [], 5, t('prompt.no_tips'))}
284
  </div>
285
 
286
  <!-- 评论区 -->
287
+ <div class="prompt-detail__comments-heading">
288
+ ${t('prompt.comments_title')} <span class="prompt-detail__muted">(${escapeHtml(prompt.comments || 0)})</span>
289
  </div>
290
 
291
  <!-- 评论输入框 -->
292
+ <div class="prompt-detail__comment-form">
293
+ <input type="text" id="comment-input" required aria-label="${escapeHtml(t('prompt.comment_placeholder'))}" placeholder="${t('prompt.comment_placeholder')}" class="prompt-detail__comment-input">
294
+ <button type="button" id="btn-send-comment" class="prompt-detail__button prompt-detail__button--accent">
295
  ${t('common.send')}
296
  </button>
297
  </div>
298
 
299
  <!-- 评论列表 -->
300
+ <div id="comments-list" class="prompt-detail__comments">
301
+ <div class="prompt-detail__state prompt-detail__state--compact" role="status" aria-label="${t('prompt.loading')}">⏳</div>
302
  </div>
303
  `;
304
 
 
344
  }
345
 
346
  // 绑定作者点击
347
+ contentArea.querySelector("#btn-author-prompt").onclick = (e) => {
348
  if (!isCurrent(contentArea)) return;
349
  if (e.target.id === 'btn-edit-prompt' || e.target.id === 'btn-delete-prompt') return;
350
  openOtherUserProfileModal(prompt.author, currentUser);
 
397
  if (!isCurrent(contentArea)) return;
398
  const textArea = contentArea.querySelector("#prompt-text-area");
399
  if (!textArea) return;
400
+ const priceState = getPromptPriceState(prompt.price);
401
 
402
  if (isUnlocked && prompt.prompt_text) {
403
  // ✅ 已解锁:展示正文 + 一键复制
404
  textArea.innerHTML = `
405
+ <div class="prompt-detail__text-box">
406
+ <div class="prompt-detail__text-header">
407
+ <span class="prompt-detail__text-label">🔑 ${t('prompt.text_label')}</span>
408
+ <button type="button" id="btn-copy-prompt" class="prompt-detail__button prompt-detail__button--accent">
409
  📋 ${t('prompt.copy')}
410
  </button>
411
  </div>
412
+ <div class="prompt-detail__text-body">${escapeHtml(prompt.prompt_text)}</div>
413
  </div>
414
  `;
415
  textArea.querySelector("#btn-copy-prompt").onclick = () => {
416
  if (isCurrent(contentArea)) copyToClipboard(prompt.prompt_text, textArea.querySelector("#btn-copy-prompt"), () => isCurrent(contentArea));
417
  };
418
+ } else if (priceState === 'paid') {
419
  // 🔒 付费遮罩:模糊预览 + 解锁按钮
420
  textArea.innerHTML = `
421
+ <div class="prompt-detail__locked">
422
+ <div class="prompt-detail__locked-preview" aria-hidden="true">
423
  ${escapeHtml((prompt.content || '') + '\n' + (prompt.content || ''))}
424
  </div>
425
+ <div class="prompt-detail__locked-overlay">
426
+ <div class="prompt-detail__lock-icon" aria-hidden="true">🔒</div>
427
+ <div class="prompt-detail__lock-title">${t('prompt.locked_title')}</div>
428
+ <div class="prompt-detail__muted prompt-detail__locked-description">${t('prompt.locked_desc')}</div>
429
+ <button type="button" id="btn-unlock-prompt" class="prompt-detail__button prompt-detail__button--gold">
430
  💎 ${t('prompt.unlock', { price: prompt.price })}
431
  </button>
432
  </div>
 
435
  textArea.querySelector("#btn-unlock-prompt").onclick = () => {
436
  showPurchaseConfirmDialog(contentArea, prompt, currentUser);
437
  };
438
+ } else if (priceState === 'unknown') {
439
+ const message = document.createElement('p');
440
+ message.className = 'prompt-detail__muted';
441
+ message.setAttribute('role', 'status');
442
+ message.textContent = t('prompt.price_unknown');
443
+ textArea.replaceChildren(message);
444
  }
445
  }
446
 
 
528
  }
529
 
530
  const content = document.createElement("div");
531
+ content.className = "prompt-detail-dialog";
532
  content.innerHTML = `
533
+ <div class="prompt-detail-dialog__center">
534
+ <div class="prompt-detail-dialog__icon" aria-hidden="true">💎</div>
535
+ <div class="prompt-detail-dialog__title">${t('prompt.purchase_confirm_title')}</div>
536
+ <div class="prompt-detail-dialog__description">${t('prompt.purchase_confirm_desc', { price: expectedPrice, title: escapeHtml(purchaseTitle) })}</div>
537
+ <div class="prompt-detail-dialog__balance">
538
+ ${t('prompt.purchase_balance')}: <strong class="prompt-detail-dialog__value">${balance}</strong>
539
  </div>
540
+ <div class="prompt-detail-dialog__actions">
541
+ <button type="button" id="purchase-cancel" class="prompt-detail__button">${t('common.cancel')}</button>
542
+ <button type="button" id="purchase-confirm" class="prompt-detail__button prompt-detail__button--gold">💎 ${t('prompt.unlock', { price: expectedPrice })}</button>
543
  </div>
544
  </div>
545
  `;
 
646
  }
647
 
648
  const content = document.createElement("div");
649
+ content.className = "prompt-detail-dialog";
650
  content.innerHTML = `
651
+ <div class="prompt-detail-dialog__balance">
652
+ ${t('wallet.current_balance')}: <strong class="prompt-detail-dialog__value">${balance}</strong> ${t('wallet.credits')}
653
  </div>
654
+ <div class="prompt-detail__tip-amounts">
655
+ <button type="button" class="tip-amount prompt-detail__button" data-amount="10">10 ${t('task.points')}</button>
656
+ <button type="button" class="tip-amount prompt-detail__button" data-amount="50">50 ${t('task.points')}</button>
657
+ <button type="button" class="tip-amount prompt-detail__button" data-amount="100">100 ${t('task.points')}</button>
658
+ <button type="button" class="tip-amount prompt-detail__button" data-amount="500">500 ${t('task.points')}</button>
659
  </div>
660
+ <div class="prompt-detail-dialog__actions">
661
+ <button type="button" id="tip-cancel" class="prompt-detail__button">${t('common.cancel')}</button>
662
  </div>
663
  `;
664
 
 
707
  const sendBtn = container.querySelector("#btn-send-comment");
708
  if (!isCurrent(container)) return;
709
  const context = viewContexts.get(container);
710
+ const account = typeof currentUser === "string" ? currentUser : currentUser?.account;
711
+ let submission = context.commentSubmission;
712
+ if (!submission || submission.input !== commentInput || submission.button !== sendBtn || submission.account !== account) {
713
+ submission = context.commentSubmission = { input: commentInput, button: sendBtn, account, phase: "idle", bound: false };
714
+ }
715
+ const submissionCurrent = () => isCurrent(container) && context.commentSubmission === submission
716
+ && commentInput.isConnected && sendBtn.isConnected;
717
  const generation = context.commentGeneration = (context.commentGeneration || 0) + 1;
718
  const current = () => isCurrent(container) && context.commentGeneration === generation && commentsList.isConnected;
719
 
 
727
 
728
  if (comments.length === 0) {
729
  commentsList.innerHTML = `
730
+ <div class="prompt-detail__state prompt-detail__state--compact" role="status">
731
  ${t('prompt.no_comments')}
732
  </div>
733
  `;
 
741
  const avatarHtml = _generateAvatarHtml(avatar, name, 24, 'background: var(--comfy-input-bg);');
742
 
743
  return `
744
+ <div id="comment-${idx}" data-account="${escapeHtml(c.author)}" class="prompt-detail__comment">
745
+ <div class="prompt-detail__comment-header">
746
  <div class="swr-avatar-container">${avatarHtml}</div>
747
+ <span class="swr-name prompt-detail__comment-name">${escapeHtml(name)}</span>
748
+ <span class="prompt-detail__muted prompt-detail__comment-time">${escapeHtml(formatTime(c.created_at))}</span>
749
  </div>
750
+ <div class="prompt-detail__comment-body">
751
  ${escapeHtml(c.content)}
752
  </div>
753
  </div>
 
771
  } catch (err) {
772
  if (!current()) return;
773
  commentsList.innerHTML = `
774
+ <div class="prompt-detail__state prompt-detail__state--compact prompt-detail__state--error" role="alert">
775
  ${t('prompt.load_failed')}
776
  </div>
777
  `;
778
  }
779
 
780
+ if (submission.bound || !submissionCurrent()) return;
781
+ submission.bound = true;
782
+ sendBtn.disabled = false;
783
+ commentInput.readOnly = false;
784
+ sendBtn.removeAttribute("aria-busy");
785
+ commentInput.oninput = () => {
786
+ if (submissionCurrent()) commentInput.removeAttribute("aria-invalid");
787
+ };
788
  sendBtn.onclick = async () => {
789
+ if (!submissionCurrent() || submission.phase !== "idle") return;
790
+ if (typeof account !== "string" || !account.trim()) {
791
  showToast(t('auth.login_required'), "warning");
792
  return;
793
  }
794
+ const originalInput = commentInput.value, content = originalInput.trim();
795
  if (!content) {
796
+ commentInput.setAttribute("aria-invalid", "true");
797
+ commentInput.focus();
798
  showToast(t('prompt.comment_required'), "warning");
799
  return;
800
  }
801
+ commentInput.removeAttribute("aria-invalid");
802
+ submission.phase = "submitting";
803
+ sendBtn.disabled = true;
804
+ sendBtn.setAttribute("aria-busy", "true");
805
+ sendBtn.textContent = t('common.sending');
806
  try {
 
 
807
  const result = await api.addPromptComment(promptId, content);
808
+ if (!submissionCurrent()) return;
809
+ if (!isConfirmedDedicatedCommentResponse(result, account, content)) throw new Error("Unconfirmed comment");
810
+ submission.phase = "committed";
811
+ if (commentInput.value === originalInput) commentInput.value = "";
812
  showToast(t('prompt.comment_success'), "success");
813
+ await loadComments(container, promptId, currentUser);
814
+ if (submissionCurrent()) submission.phase = "idle";
815
  } catch (err) {
816
+ if (!submissionCurrent()) return;
817
+ if (submission.phase === "committed") {
818
+ try { showToast(t('social.comment_refresh_required'), "warning"); } catch (_) {}
819
+ return;
820
+ }
821
+ const rejected = isDefiniteCommentRejection(err);
822
+ submission.phase = rejected ? "idle" : "unconfirmed";
823
+ showToast(t(rejected ? 'prompt.comment_failed' : 'social.comment_unconfirmed'), rejected ? "error" : "warning");
824
  } finally {
825
+ if (!submissionCurrent()) return;
826
+ sendBtn.disabled = submission.phase !== "idle";
827
+ commentInput.readOnly = submission.phase !== "idle";
828
+ sendBtn.removeAttribute("aria-busy");
829
+ sendBtn.textContent = t(submission.phase === "unconfirmed" ? 'social.comment_pending' : 'common.send');
830
  }
831
  };
832
 
833
  // 回车发送
834
  commentInput.onkeydown = (e) => {
835
+ if (isCurrent(container) && !e.defaultPrevented && e.key === "Enter"
836
+ && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey
837
+ && !e.isComposing && e.keyCode !== 229 && !e.repeat
838
+ && !commentInput.disabled && !commentInput.readOnly) {
839
  e.preventDefault();
840
  sendBtn.click();
841
  }
 
848
  function showDeleteConfirmDialog(prompt, container) {
849
  if (!isCurrent(container)) return;
850
  const content = document.createElement("div");
851
+ content.className = "prompt-detail-dialog";
852
  content.innerHTML = `
853
+ <div class="prompt-detail-dialog__center">
854
+ <div class="prompt-detail-dialog__icon" aria-hidden="true">⚠️</div>
855
+ <div class="prompt-detail-dialog__title">${t('prompt.delete_confirm')}</div>
856
+ <div class="prompt-detail-dialog__description">${escapeHtml(prompt.title)}</div>
857
+ <div class="prompt-detail-dialog__actions">
858
+ <button type="button" id="delete-cancel" class="prompt-detail__button">${t('common.cancel')}</button>
859
+ <button type="button" id="delete-confirm" class="prompt-detail__button prompt-detail__button--danger">${t('common.delete')}</button>
860
  </div>
861
  </div>
862
  `;
前端页面/social/私信聊天组件.js CHANGED
@@ -455,7 +455,10 @@ export function openChatModal(currentUser, targetAccount = null) {
455
 
456
  // 🚀 P1-2: Enter 发送,Shift+Enter 换行
457
  inputField.onkeydown = (e) => {
458
- if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
 
 
 
459
  e.preventDefault();
460
  sendBtn.click();
461
  }
 
455
 
456
  // 🚀 P1-2: Enter 发送,Shift+Enter 换行
457
  inputField.onkeydown = (e) => {
458
+ if (scope.isCurrent() && !e.defaultPrevented && e.key === "Enter"
459
+ && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey
460
+ && !e.isComposing && e.keyCode !== 229 && !e.repeat
461
+ && !inputField.disabled && !inputField.readOnly) {
462
  e.preventDefault();
463
  sendBtn.click();
464
  }
前端页面/social/评论数据工具.js CHANGED
@@ -18,3 +18,12 @@ export function isConfirmedCommentDeletion(response) {
18
  export function isDefiniteCommentRejection(error) {
19
  return Number.isInteger(error?.status) && error.status >= 400 && error.status < 500 && error.status !== 408;
20
  }
 
 
 
 
 
 
 
 
 
 
18
  export function isDefiniteCommentRejection(error) {
19
  return Number.isInteger(error?.status) && error.status >= 400 && error.status < 500 && error.status !== 408;
20
  }
21
+
22
+ export function isConfirmedDedicatedCommentResponse(response, account, content) {
23
+ const data = response?.data;
24
+ return typeof account === 'string' && !!account.trim()
25
+ && typeof content === 'string' && !!content.trim()
26
+ && response?.status === 'success' && !!data && typeof data === 'object' && !Array.isArray(data)
27
+ && typeof data.id === 'string' && !!data.id.trim()
28
+ && data.author === account && data.content === content;
29
+ }
前端页面/styles/主题基础.css CHANGED
The diff for this file is too large to render. See raw diff
 
前端页面/task/任务榜组件.js CHANGED
@@ -212,10 +212,11 @@ export function createTasksView(currentUser, keyword = "") {
212
  sorted.sort((a, b) => (b.daily_views || 0) - (a.daily_views || 0));
213
  break;
214
  case "price":
215
- sorted.sort((a, b) => (b.price || 0) - (a.price || 0));
216
  break;
217
  case "deadline":
218
- sorted.sort((a, b) => (a.deadline || Infinity) - (b.deadline || Infinity));
 
219
  break;
220
  default: // latest
221
  sorted.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
@@ -353,6 +354,7 @@ export function createTasksView(currentUser, keyword = "") {
353
  // 从缓存渲染任务列表
354
  const renderTasksFromCache = (tasks) => {
355
  firstPageReady = true;
 
356
  tasksList.innerHTML = "";
357
 
358
  // 🔍 搜索过滤
@@ -366,6 +368,7 @@ export function createTasksView(currentUser, keyword = "") {
366
  tasksList.innerHTML = _renderNoTasksView();
367
  loadMoreWrapper.style.display = "none";
368
  }
 
369
  return;
370
  }
371
 
@@ -390,6 +393,7 @@ export function createTasksView(currentUser, keyword = "") {
390
  } else {
391
  loadMoreWrapper.style.display = "none";
392
  }
 
393
  };
394
 
395
  // 后台静默更新
@@ -450,10 +454,20 @@ export function createTasksView(currentUser, keyword = "") {
450
  const _tasksDataChanged = (oldData, newData) => {
451
  if (!oldData || !newData) return true;
452
  if (oldData.length !== newData.length) return true;
453
- const checkCount = Math.min(10, oldData.length);
 
 
 
 
454
  for (let i = 0; i < checkCount; i++) {
455
  if (oldData[i].id !== newData[i].id) return true;
456
  if (oldData[i].status !== newData[i].status) return true;
 
 
 
 
 
 
457
  for (const field of COMPARE_FIELDS) {
458
  if ((oldData[i][field] || 0) !== (newData[i][field] || 0)) return true;
459
  }
@@ -561,6 +575,9 @@ export function createTasksView(currentUser, keyword = "") {
561
  function createTaskCard(task, currentUser = currentUserCache, isCurrent = () => true, captureNavigation = () => isCurrent) {
562
  const card = document.createElement("div");
563
  card.className = "task-card";
 
 
 
564
  card.setAttribute("role", "button");
565
  card.tabIndex = 0;
566
 
@@ -583,7 +600,7 @@ function createTaskCard(task, currentUser = currentUserCache, isCurrent = () =>
583
  const deadline = formatDeadline(task.deadline);
584
 
585
  // 检查是否逾期(进行中但超过截止日期)
586
- const isOverdue = task.is_overdue || (task.status === "in_progress" && task.deadline && task.deadline < new Date().toISOString().slice(0, 10));
587
  const overdueTag = isOverdue ? `<span class="task-card__overdue">${t('task.overdue')}</span>` : "";
588
 
589
  // 过期卡片样式
@@ -591,6 +608,10 @@ function createTaskCard(task, currentUser = currentUserCache, isCurrent = () =>
591
  card.classList.add("task-card--stale");
592
  }
593
 
 
 
 
 
594
  card.innerHTML = `
595
  <!-- 顶部:状态 + 价格 -->
596
  <div class="task-card__header">
@@ -618,7 +639,7 @@ function createTaskCard(task, currentUser = currentUserCache, isCurrent = () =>
618
  <!-- 底部信息 -->
619
  <div class="task-card__footer">
620
  <!-- 发布者(SWR 缓存头像) -->
621
- <div class="task-card__author"></div>
622
 
623
  <!-- 截止日期 + 申请人数 + 访问量 + 点赞 + 收藏 -->
624
  <div class="task-card__stats">
@@ -638,12 +659,10 @@ function createTaskCard(task, currentUser = currentUserCache, isCurrent = () =>
638
  if (!authorContainer) return;
639
 
640
  const account = task.publisher;
641
- const cached = getCachedProfile(account);
642
- const avatar = cached?.avatar || task.publisher_avatar || '';
643
- const name = cached?.name || task.publisher_name || account || '';
644
 
645
- // 内联 SVG 默认头像(用户剪影图标)
646
- const DEFAULT_AVATAR_SVG = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 36 36'%3E%3Ccircle cx='18' cy='18' r='18' fill='%23333'/%3E%3Ccircle cx='18' cy='14' r='6' fill='%23666'/%3E%3Cpath d='M6 32c0-6.6 5.4-12 12-12s12 5.4 12 12' fill='%23666'/%3E%3C/svg%3E";
647
 
648
  // 渲染头像(始终使用 img 标签)
649
  const avatarSrc = avatar || DEFAULT_AVATAR_SVG;
@@ -653,17 +672,19 @@ function createTaskCard(task, currentUser = currentUserCache, isCurrent = () =>
653
  authorContainer.innerHTML = `${avatarHtml}<span class="swr-name">${escapeHtml(name)}</span>`;
654
 
655
  // 后台静默校对
656
- getProfileWithSWR(account, api.getUserProfile, (profile) => {
657
  if (!isCurrent() || !card.isConnected) return;
658
  const avatarEl = authorContainer.querySelector('.swr-avatar');
659
  const nameEl = authorContainer.querySelector('.swr-name');
660
- if (avatarEl && profile.avatar) {
661
- avatarEl.src = profile.avatar;
662
  }
663
- if (nameEl && profile.name) {
664
  nameEl.textContent = profile.name;
665
  }
666
- });
 
 
667
  }, 0);
668
 
669
  // 点击进入详情;捕获创建视图的用户和点击时的导航范围。
@@ -697,26 +718,42 @@ function createTaskCard(task, currentUser = currentUserCache, isCurrent = () =>
697
  return card;
698
  }
699
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
700
  /**
701
  * 📅 格式化截止日期
702
  */
703
  function formatDeadline(deadline) {
704
- if (!deadline) return t('time.unlimited');
 
 
705
 
706
  try {
707
- const date = new Date(deadline);
708
  const now = new Date();
709
- const diff = date - now;
710
-
711
- if (diff < 0) return t('time.deadline_ended');
712
-
713
- const days = Math.floor(diff / (1000 * 60 * 60 * 24));
714
  if (days === 0) return t('time.deadline_today');
715
  if (days === 1) return t('time.deadline_tomorrow');
716
  if (days < 7) return t('time.deadline_days', { n: days });
717
 
718
- return `${date.getMonth() + 1}/${date.getDate()}`;
719
  } catch {
720
- return deadline;
721
  }
722
  }
 
212
  sorted.sort((a, b) => (b.daily_views || 0) - (a.daily_views || 0));
213
  break;
214
  case "price":
215
+ sorted.sort((a, b) => (b.total_price || 0) - (a.total_price || 0));
216
  break;
217
  case "deadline":
218
+ const deadlineTime = value => parseTaskDeadline(value) ?? Infinity;
219
+ sorted.sort((a, b) => deadlineTime(a.deadline) - deadlineTime(b.deadline));
220
  break;
221
  default: // latest
222
  sorted.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
 
354
  // 从缓存渲染任务列表
355
  const renderTasksFromCache = (tasks) => {
356
  firstPageReady = true;
357
+ const restoreFocus = cardNavigation.captureListFocus(tasksList);
358
  tasksList.innerHTML = "";
359
 
360
  // 🔍 搜索过滤
 
368
  tasksList.innerHTML = _renderNoTasksView();
369
  loadMoreWrapper.style.display = "none";
370
  }
371
+ restoreFocus();
372
  return;
373
  }
374
 
 
393
  } else {
394
  loadMoreWrapper.style.display = "none";
395
  }
396
+ restoreFocus();
397
  };
398
 
399
  // 后台静默更新
 
454
  const _tasksDataChanged = (oldData, newData) => {
455
  if (!oldData || !newData) return true;
456
  if (oldData.length !== newData.length) return true;
457
+ const checkCount = oldData.length;
458
+ const contentFields = ["title", "description", "total_price", "deadline", "is_overdue", "publisher", "publisher_name", "created_at"];
459
+ const mediaFields = ["publisher_avatar"];
460
+ const normalizedImage = value => typeof value === 'string'
461
+ ? proxyImages({ cover_image: value }).cover_image : value;
462
  for (let i = 0; i < checkCount; i++) {
463
  if (oldData[i].id !== newData[i].id) return true;
464
  if (oldData[i].status !== newData[i].status) return true;
465
+ for (const field of contentFields) {
466
+ if (!Object.is(oldData[i][field], newData[i][field])) return true;
467
+ }
468
+ for (const field of mediaFields) {
469
+ if (!Object.is(normalizedImage(oldData[i][field]), normalizedImage(newData[i][field]))) return true;
470
+ }
471
  for (const field of COMPARE_FIELDS) {
472
  if ((oldData[i][field] || 0) !== (newData[i][field] || 0)) return true;
473
  }
 
575
  function createTaskCard(task, currentUser = currentUserCache, isCurrent = () => true, captureNavigation = () => isCurrent) {
576
  const card = document.createElement("div");
577
  card.className = "task-card";
578
+ if (typeof task.id === 'string' || (typeof task.id === 'number' && Number.isFinite(task.id))) {
579
+ card._rankingListId = String(task.id);
580
+ }
581
  card.setAttribute("role", "button");
582
  card.tabIndex = 0;
583
 
 
600
  const deadline = formatDeadline(task.deadline);
601
 
602
  // 检查是否逾期(进行中但超过截止日期)
603
+ const isOverdue = task.is_overdue === true || (task.status === "in_progress" && parseTaskDeadline(task.deadline) != null && task.deadline.slice(0, 10) < new Date().toISOString().slice(0, 10));
604
  const overdueTag = isOverdue ? `<span class="task-card__overdue">${t('task.overdue')}</span>` : "";
605
 
606
  // 过期卡片样式
 
608
  card.classList.add("task-card--stale");
609
  }
610
 
611
+ const initialAuthor = isCurrent() && typeof task.publisher === 'string' ? getCachedProfile(task.publisher) : null;
612
+ const initialAuthorName = [initialAuthor?.name, task.publisher_name, task.publisher].find(value => typeof value === 'string' && value) || '';
613
+ const DEFAULT_AVATAR_SVG = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 36 36'%3E%3Ccircle cx='18' cy='18' r='18' fill='%23333'/%3E%3Ccircle cx='18' cy='14' r='6' fill='%23666'/%3E%3Cpath d='M6 32c0-6.6 5.4-12 12-12s12 5.4 12 12' fill='%23666'/%3E%3C/svg%3E";
614
+
615
  card.innerHTML = `
616
  <!-- 顶部:状态 + 价格 -->
617
  <div class="task-card__header">
 
639
  <!-- 底部信息 -->
640
  <div class="task-card__footer">
641
  <!-- 发布者(SWR 缓存头像) -->
642
+ <div class="task-card__author"><img class="swr-avatar task-card__avatar" alt="" src="${escapeHtml(DEFAULT_AVATAR_SVG)}"><span class="swr-name">${escapeHtml(initialAuthorName)}</span></div>
643
 
644
  <!-- 截止日期 + 申请人数 + 访问量 + 点赞 + 收藏 -->
645
  <div class="task-card__stats">
 
659
  if (!authorContainer) return;
660
 
661
  const account = task.publisher;
662
+ const cached = initialAuthor;
663
+ const avatar = [cached?.avatar, task.publisher_avatar].find(value => typeof value === 'string' && value) || '';
664
+ const name = initialAuthorName;
665
 
 
 
666
 
667
  // 渲染头像(始终使用 img 标签)
668
  const avatarSrc = avatar || DEFAULT_AVATAR_SVG;
 
672
  authorContainer.innerHTML = `${avatarHtml}<span class="swr-name">${escapeHtml(name)}</span>`;
673
 
674
  // 后台静默校对
675
+ const updateAuthor = (profile) => {
676
  if (!isCurrent() || !card.isConnected) return;
677
  const avatarEl = authorContainer.querySelector('.swr-avatar');
678
  const nameEl = authorContainer.querySelector('.swr-name');
679
+ if (avatarEl && typeof profile.avatar === 'string' && profile.avatar) {
680
+ if (avatarEl.getAttribute('src') !== profile.avatar) avatarEl.src = profile.avatar;
681
  }
682
+ if (nameEl && typeof profile.name === 'string' && profile.name && nameEl.textContent !== profile.name) {
683
  nameEl.textContent = profile.name;
684
  }
685
+ };
686
+ const latestCached = getProfileWithSWR(account, api.getUserProfile, updateAuthor);
687
+ if (latestCached) updateAuthor(latestCached);
688
  }, 0);
689
 
690
  // 点击进入详情;捕获创建视图的用户和点击时的导航范围。
 
718
  return card;
719
  }
720
 
721
+ function parseTaskDeadline(value) {
722
+ if (typeof value !== 'string' || value.trim() !== value ||
723
+ !/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/.test(value)) return null;
724
+ const year = Number(value.slice(0, 4));
725
+ const month = Number(value.slice(5, 7));
726
+ const day = Number(value.slice(8, 10));
727
+ if (year < 1 || month < 1 || month > 12 || day < 1) return null;
728
+ const leap = year % 400 === 0 || (year % 4 === 0 && year % 100 !== 0);
729
+ const lastDay = month === 2 ? (leap ? 29 : 28) : ([4, 6, 9, 11].includes(month) ? 30 : 31);
730
+ if (day > lastDay) return null;
731
+ const time = Date.parse(value);
732
+ return Number.isFinite(time) ? time : null;
733
+ }
734
+
735
  /**
736
  * 📅 格式化截止日期
737
  */
738
  function formatDeadline(deadline) {
739
+ if (deadline == null || deadline === '') return t('time.unlimited');
740
+ const timestamp = parseTaskDeadline(deadline);
741
+ if (timestamp == null) return t('common.unknown');
742
 
743
  try {
744
+ const day = deadline.slice(0, 10);
745
  const now = new Date();
746
+ const today = new Date(0);
747
+ today.setUTCFullYear(now.getFullYear(), now.getMonth(), now.getDate());
748
+ const days = Math.round((Date.parse(day) - today.getTime()) / (1000 * 60 * 60 * 24));
749
+ if (!Number.isFinite(days)) return t('common.unknown');
750
+ if (days < 0) return t('time.deadline_ended');
751
  if (days === 0) return t('time.deadline_today');
752
  if (days === 1) return t('time.deadline_tomorrow');
753
  if (days < 7) return t('time.deadline_days', { n: days });
754
 
755
+ return `${Number(day.slice(5, 7))}/${Number(day.slice(8, 10))}`;
756
  } catch {
757
+ return t('common.unknown');
758
  }
759
  }
前端页面/task/任务详情组件.js CHANGED
@@ -16,7 +16,8 @@ import { API, CACHE, PLACEHOLDERS, TASK_LIMITS, getCachedProfile, getProfileWith
16
  import { removeCache, findInListCache, requireListResponseData } from "../components/性能优化工具.js";
17
  import { globalModal } from "../components/全局弹窗管理器.js";
18
  import { compressImageForUpload } from "../market/发布内容_提交引擎.js";
19
- import { getCoverSandboxHTML, setupImageSandboxEvents } from "../components/图片沙盒组件.js";
 
20
  import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
21
  import { recordView, handleToggleLike, handleToggleFavorite, renderTipBoardHTML as renderCommonTipBoardHTML, escapeHtml } from "../components/互动工具函数.js";
22
 
@@ -27,6 +28,7 @@ const taskCommentStates = new WeakMap();
27
 
28
  function openTaskModal(scope, title, content, options) {
29
  if (scope && !scope.isCurrent()) return;
 
30
  globalModal.openModal(title, content, options);
31
  const modal = globalModal.activeModals.at(-1);
32
  const release = scope?.addCleanup(() => globalModal.closeModal(modal));
@@ -87,6 +89,7 @@ export function createTaskDetailView(taskId, currentUser) {
87
  });
88
  scope.componentId = componentId;
89
  scope.root = container;
 
90
  container._taskScope = scope;
91
  container.querySelector("#task-content")._taskScope = scope;
92
  container._cleanup = () => {
@@ -187,7 +190,8 @@ function renderTaskDetail(contentEl, task, currentUser, preview = false) {
187
  submitted: { bg: "#FF9800", textKey: "task.status_submitted" },
188
  completed: { bg: "#9E9E9E", textKey: "task.status_completed" },
189
  disputed: { bg: "#F44336", textKey: "task.status_disputed" },
190
- cancelled: { bg: "#757575", textKey: "task.status_cancelled" }
 
191
  };
192
 
193
  const status = Object.prototype.hasOwnProperty.call(statusStyles, task.status)
@@ -216,123 +220,124 @@ function renderTaskDetail(contentEl, task, currentUser, preview = false) {
216
  const isLiked = Array.isArray(task.liked_by) && task.liked_by.includes(currentAccount);
217
  const isFavorited = Array.isArray(task.favorited_by) && task.favorited_by.includes(currentAccount);
218
 
 
219
  contentEl.innerHTML = `
220
  <!-- 状态 + 价格 -->
221
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
222
- <span style="background: ${status.bg}; color: #fff; padding: 5px 12px; border-radius: 6px; font-size: 13px; font-weight: bold;">
223
  ${statusText}
224
  </span>
225
- <div style="text-align: right;">
226
- <div style="color: #FF9800; font-size: 20px; font-weight: bold;">💰 ${escapeHtml(task.total_price)} ${t('common.credits')}</div>
227
- <div style="font-size: 11px; color: #888;">${t('task.deposit')} ${escapeHtml(task.deposit_ratio)}%(${escapeHtml(task.deposit_amount)} ${t('common.credits')})</div>
228
  </div>
229
  </div>
230
 
231
  <!-- 标题 -->
232
- <h2 style="font-size: 18px; font-weight: bold; color: #fff; margin: 0 0 15px 0; line-height: 1.4;">
233
  ${escapeHtml(task.title)}
234
  </h2>
235
 
236
  <!-- 发布者信息 -->
237
- <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 15px; padding: 10px; background: var(--comfy-menu-bg); border-radius: 8px;">
238
  <img src="${escapeHtml(task.publisher_avatar || PLACEHOLDERS.AVATAR_SMALL)}"
239
- style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover; border: 2px solid var(--border-color, #333); cursor: pointer;" id="publisher-avatar">
240
- <div style="flex: 1;">
241
- <div style="color: #fff; font-size: 14px; font-weight: 500;">${escapeHtml(task.publisher_name || task.publisher)}</div>
242
- <div style="color: #888; font-size: 11px;">${t('task.published_at')} ${formatTime(task.created_at)}</div>
243
  </div>
244
  ${isPublisher && ["open", "in_progress", "submitted"].includes(task.status) ? `
245
- <div style="display: flex; gap: 8px;">
246
- ${IS_WEB_MODE ? '' : `<button id="btn-edit-task" style="background: #2196F3; border: none; color: #fff; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px;" title="${t('common.edit')}">✏️ ${t('common.edit')}</button>`}
247
  ${task.status === "open" ? `
248
- <button id="btn-delete-task" style="background: #F44336; border: none; color: #fff; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px;" title="${t('common.delete')}">🗑️ ${t('common.delete')}</button>
249
  ` : ''}
250
  </div>
251
  ` : ''}
252
  ${!isPublisher && currentUser ? `
253
- <button id="btn-chat-publisher" style="background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px;">
254
  💬 ${t('common.message')}
255
  </button>
256
  ` : ""}
257
  </div>
258
 
259
  <!-- 👀 浏览量统计 -->
260
- <div style="display: flex; align-items: center; gap: 15px; margin-bottom: 15px; padding: 10px; background: #1a1a1a; border-radius: 8px; color: #888; font-size: 12px;">
261
- <span style="display: flex; align-items: center; gap: 4px;">${t('task.view_total')}: <span id="task-view-total" style="color: #fff; font-weight: 500;">${escapeHtml(task.views || 0)}</span></span>
262
- <span style="display: flex; align-items: center; gap: 4px;">${t('task.view_today')}: <span id="task-view-daily" style="color: #fff; font-weight: 500;">${escapeHtml(task.daily_views || 0)}</span></span>
263
  </div>
264
 
265
  <!-- 截止日期 -->
266
- <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 15px; padding: 10px; background: rgba(255,152,0,0.1); border: 1px solid rgba(255,152,0,0.3); border-radius: 8px;">
267
- <span style="font-size: 18px;">⏰</span>
268
  <div>
269
- <div style="color: #FF9800; font-size: 13px; font-weight: bold;">${t('task.deadline')}</div>
270
- <div style="color: #fff; font-size: 14px;">${formatDeadline(task.deadline)}</div>
271
  </div>
272
  </div>
273
 
274
  <!-- 任务描述 -->
275
- <div style="margin-bottom: 20px;">
276
- <div style="font-size: 14px; font-weight: bold; color: #fff; margin-bottom: 8px;">📄 ${t('task.description')}</div>
277
- <div style="color: #ccc; font-size: 13px; line-height: 1.6; white-space: pre-wrap; background: var(--comfy-menu-bg); padding: 12px; border-radius: 8px;">
278
  ${escapeHtml(task.description)}</div>
279
  </div>
280
 
281
  <!-- 参考图片 -->
282
  ${referenceImages.length > 0 ? `
283
- <div style="margin-bottom: 20px;" class="ref-images-sandbox">
284
- <div style="font-size: 14px; font-weight: bold; color: #fff; margin-bottom: 8px;">🖼️ ${t('task.reference_images')}</div>
285
- <div style="--sandbox-title-display: none">
286
- ${getCoverSandboxHTML(referenceImages, true).replace(/<div style="font-size: 12px[^>]*>[^<]*<\/div>/, '')}
287
  </div>
288
  </div>
289
  ` : ""}
290
 
291
  <!-- 参考链接 -->
292
  ${safeReferenceLink ? `
293
- <div style="margin-bottom: 20px;">
294
- <div style="font-size: 14px; font-weight: bold; color: #fff; margin-bottom: 8px;">🔗 ${t('task.reference_link')}</div>
295
- <a href="${escapeHtml(safeReferenceLink)}" target="_blank" rel="noopener noreferrer" style="color: #2196F3; font-size: 13px; word-break: break-all;">
296
  ${escapeHtml(task.reference_link)}
297
  </a>
298
  </div>
299
  ` : ""}
300
 
301
  <!-- 👍 互动按钮栏 -->
302
- <div id="interaction-bar" style="display: flex; align-items: center; gap: 15px; padding: 15px 0; border-top: 1px solid var(--border-color, #333); border-bottom: 1px solid var(--border-color, #333); margin-bottom: 15px;">
303
- <button id="btn-like" style="background: ${isLiked ? '#FF5722' : 'var(--comfy-input-bg)'}; border: 1px solid ${isLiked ? '#FF5722' : '#555'}; color: #fff; padding: 8px 16px; border-radius: 20px; cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 6px; transition: 0.2s;">
304
  ❤️ <span id="like-count">${escapeHtml(task.likes || 0)}</span>
305
  </button>
306
- <button id="btn-favorite" style="background: ${isFavorited ? '#FFC107' : 'var(--comfy-input-bg)'}; border: 1px solid ${isFavorited ? '#FFC107' : '#555'}; color: ${isFavorited ? '#000' : '#fff'}; padding: 8px 16px; border-radius: 20px; cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 6px; transition: 0.2s;">
307
  🔖 <span id="favorite-count">${escapeHtml(task.favorites || 0)}</span>
308
  </button>
309
- <button id="btn-tip" style="background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 8px 16px; border-radius: 20px; cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 6px; transition: 0.2s;">
310
  💰 ${t('post.tip_author')}
311
  </button>
312
  </div>
313
 
314
  <!-- 💰 打赏榜单 -->
315
- <div id="tip-board-area" style="margin-bottom: 15px;">
316
  ${renderTipBoard(tipBoard)}
317
  </div>
318
 
319
  <!-- 💬 评论区 -->
320
- <div style="margin-bottom: 15px;">
321
- <div style="font-size: 14px; font-weight: bold; color: #fff; margin-bottom: 10px; display: flex; align-items: center; gap: 8px;">
322
- 💬 ${t('post.comments_title')} <span style="color: #888; font-weight: normal;">(${escapeHtml(task.comments || 0)})</span>
323
  </div>
324
 
325
  <!-- 评论输入框 -->
326
- <div style="display: flex; gap: 10px; margin-bottom: 15px;">
327
- <input type="text" id="comment-input" aria-label="${escapeHtml(t('post.comment_placeholder'))}" aria-required="true" required placeholder="${escapeHtml(t('post.comment_placeholder'))}" style="flex: 1; padding: 10px 12px; background: var(--comfy-input-bg); border: 1px solid #444; border-radius: 20px; color: #fff; font-size: 13px; outline: none;" onfocus="this.style.borderColor='#4CAF50'" onblur="this.style.borderColor='#444'">
328
- <button type="button" id="btn-send-comment" style="background: #4CAF50; border: none; color: #fff; padding: 10px 20px; border-radius: 20px; cursor: pointer; font-size: 13px; font-weight: bold; transition: 0.2s;" onmouseover="this.style.background='#45a049'" onmouseout="this.style.background='#4CAF50'">
329
  ${t('common.send')}
330
  </button>
331
  </div>
332
 
333
  <!-- 评论列表 -->
334
- <div id="comments-list" style="display: flex; flex-direction: column; gap: 10px;">
335
- <div style="text-align: center; padding: 20px; color: #888; font-size: 12px;">
336
  ${t('post.comment_loading')}
337
  </div>
338
  </div>
@@ -340,41 +345,41 @@ ${escapeHtml(task.description)}</div>
340
 
341
  <!-- 接单者信息(如果有) -->
342
  ${task.assignee ? `
343
- <div style="margin-bottom: 20px; padding: 12px; background: rgba(33,150,243,0.1); border: 1px solid rgba(33,150,243,0.3); border-radius: 8px;">
344
- <div style="font-size: 13px; font-weight: bold; color: #2196F3; margin-bottom: 8px;">👷 ${t('task.assignee')}</div>
345
- <div style="display: flex; align-items: center; gap: 10px;">
346
- <img src="${escapeHtml(task.assignee_avatar || PLACEHOLDERS.AVATAR_SMALL)}"
347
- style="width: 32px; height: 32px; border-radius: 50%; object-fit: cover;">
348
- <span style="color: #fff;">${escapeHtml(task.assignee_name || task.assignee)}</span>
349
  </div>
350
  </div>
351
  ` : ""}
352
 
353
  <!-- 交付成果(如果有) -->
354
  ${deliverables.length > 0 ? `
355
- <div style="margin-bottom: 20px; padding: 12px; background: rgba(255,152,0,0.1); border: 1px solid rgba(255,152,0,0.3); border-radius: 8px;" class="deliverables-sandbox">
356
- <div style="font-size: 13px; font-weight: bold; color: #FF9800; margin-bottom: 8px;">📦 ${t('task.deliverables')}</div>
357
- <div style="--sandbox-title-display: none">
358
- ${getCoverSandboxHTML(deliverables, true).replace(/<div style="font-size: 12px[^>]*>[^<]*<\/div>/, '')}
359
  </div>
360
- ${task.submit_note ? `<div style="margin-top: 10px; color: #ccc; font-size: 12px;">${t('task.note')}: ${escapeHtml(task.submit_note)}</div>` : ""}
361
  </div>
362
  ` : ""}
363
 
364
  <!-- 申请者列表(仅发布者可见,且在 open 状态) -->
365
  ${isPublisher && task.status === "open" && applicants.length > 0 ? `
366
- <div style="margin-bottom: 20px;">
367
- <div style="font-size: 14px; font-weight: bold; color: #fff; margin-bottom: 8px;">👥 ${t('task.applicants')}(${applicants.length}${t('task.applicants_count')})</div>
368
- <div id="applicants-list" style="display: flex; flex-direction: column; gap: 8px;">
369
  ${applicants.map(app => `
370
- <div style="display: flex; align-items: center; gap: 10px; padding: 10px; background: var(--comfy-menu-bg); border-radius: 8px;">
371
- <img src="${escapeHtml(app.avatar || PLACEHOLDERS.AVATAR_SMALL)}"
372
- style="width: 32px; height: 32px; border-radius: 50%; object-fit: cover;">
373
- <div style="flex: 1;">
374
- <div style="color: #fff; font-size: 13px;">${escapeHtml(app.name || app.account)}</div>
375
- ${app.message ? `<div style="color: #888; font-size: 11px; margin-top: 2px;">${escapeHtml(app.message)}</div>` : ""}
376
  </div>
377
- <button class="btn-assign" data-account="${escapeHtml(app.account)}" style="background: #4CAF50; border: none; color: #fff; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px;">
378
  ${t('task.choose_assignee')}
379
  </button>
380
  </div>
@@ -384,7 +389,7 @@ ${escapeHtml(task.description)}</div>
384
  ` : ""}
385
 
386
  <!-- 操作按钮区域 -->
387
- <div id="action-buttons" style="display: flex; flex-direction: column; gap: 10px; margin-top: 20px; padding-top: 15px; border-top: 1px solid var(--border-color, #333);">
388
  </div>
389
  `;
390
 
@@ -396,7 +401,7 @@ ${escapeHtml(task.description)}</div>
396
  contentEl.querySelectorAll("button, input, textarea, select").forEach(control => { control.disabled = true; });
397
  contentEl.querySelector("#action-buttons").textContent = t('task.cached_readonly');
398
  contentEl.querySelector("#comments-list").textContent = t('task.cached_readonly');
399
- setupImageSandboxEvents(contentEl);
400
  return;
401
  }
402
 
@@ -415,6 +420,12 @@ ${escapeHtml(task.description)}</div>
415
  showDeleteTaskConfirm(task, contentEl);
416
  });
417
 
 
 
 
 
 
 
418
  // 绑定申请者列表的选择按钮
419
  contentEl.querySelectorAll(".btn-assign").forEach(btn => {
420
  btn.onclick = () => {
@@ -424,7 +435,7 @@ ${escapeHtml(task.description)}</div>
424
  });
425
 
426
  // 绑定图片沙盒交互(缩放、拖动、轮播等)
427
- setupImageSandboxEvents(contentEl);
428
 
429
  // 👍 绑定互动事件(点赞/收藏/打赏)
430
  bindTaskInteractionEvents(contentEl, task, currentUser);
@@ -440,18 +451,18 @@ function _showConfirmDialog({ scope, title, icon, message, description, confirmT
440
  let phase = "idle";
441
  const content = document.createElement("div");
442
  content._cleanup = () => { phase = "closed"; };
443
- content.style.color = "#ccc";
444
  // 🔒 XSS防护:message/description 可能嵌入其他用户的账号或昵称
445
  // (如 t('task.confirm_assign', { assignee })),写入 innerHTML 前必须转义。
446
  // 本函数的 4 个调用方传的都是词典文本/emoji/数字,转义不改变现有显示效果。
447
  content.innerHTML = `
448
- <div style="text-align: center;">
449
- <div style="font-size: 40px; margin-bottom: 15px;">${icon}</div>
450
- <div style="color: #fff; font-size: 14px; margin-bottom: 8px;">${escapeHtml(message)}</div>
451
- <div style="color: #888; font-size: 12px; margin-bottom: 20px;">${escapeHtml(description)}</div>
452
- <div style="display: flex; gap: 10px;">
453
- <button id="dialog-cancel" style="flex: 1; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer;">${t('common.cancel')}</button>
454
- <button id="dialog-confirm" style="flex: 1; ${confirmStyle}; border: none; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer; font-weight: bold;">${escapeHtml(confirmText)}</button>
455
  </div>
456
  </div>
457
  `;
@@ -566,15 +577,15 @@ function bindTaskTextSubmission({ content, taskId, input, button, confirmText, e
566
  function showApplyDialog(task, currentUser) {
567
  const taskId = task.id;
568
  const content = document.createElement("div");
569
- content.style.color = "#ccc";
570
  content.innerHTML = `
571
- <div style="margin-bottom: 15px;">
572
- <label for="apply-message" style="display: block; color: #888; font-size: 12px; margin-bottom: 8px;">${t('task.apply_message_prompt')}</label>
573
- <textarea id="apply-message" rows="4" placeholder="${t('task.apply_message_placeholder')}" style="width: 100%; padding: 10px; background: var(--comfy-input-bg); border: 1px solid #444; border-radius: 6px; color: #fff; resize: none; box-sizing: border-box; font-size: 13px;"></textarea>
574
  </div>
575
- <div style="display: flex; gap: 10px;">
576
- <button type="button" id="apply-cancel" style="flex: 1; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer;">${t('common.cancel')}</button>
577
- <button type="button" id="apply-confirm" style="flex: 1; background: #FF9800; border: none; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer; font-weight: bold;">${t('common.confirm_submit')}</button>
578
  </div>
579
  `;
580
 
@@ -680,15 +691,15 @@ function showAcceptConfirmDialog(task, currentUser) {
680
  function showRejectDialog(task, currentUser) {
681
  const taskId = task.id;
682
  const content = document.createElement("div");
683
- content.style.color = "#ccc";
684
  content.innerHTML = `
685
- <div style="margin-bottom: 15px;">
686
- <label for="reject-feedback" style="display: block; color: #888; font-size: 12px; margin-bottom: 8px;">${t('task.reject_reason_prompt')}</label>
687
- <textarea aria-required="true" id="reject-feedback" rows="4" placeholder="${t('task.reject_reason_placeholder')}" style="width: 100%; padding: 10px; background: var(--comfy-input-bg); border: 1px solid #444; border-radius: 6px; color: #fff; resize: none; box-sizing: border-box; font-size: 13px;"></textarea>
688
  </div>
689
- <div style="display: flex; gap: 10px;">
690
- <button type="button" id="reject-cancel" style="flex: 1; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer;">${t('common.cancel')}</button>
691
- <button type="button" id="reject-confirm" style="flex: 1; background: #F44336; border: none; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer; font-weight: bold;">${t('task.reject_work')}</button>
692
  </div>
693
  `;
694
 
@@ -719,15 +730,15 @@ function showDeleteCommentConfirmDialog(taskId, comment, container, currentUser)
719
  let phase = "idle";
720
  const content = document.createElement("div");
721
  content._cleanup = () => { phase = "closed"; };
722
- content.style.color = "#ccc";
723
  content.innerHTML = `
724
- <div style="text-align: center;">
725
- <div style="font-size: 40px; margin-bottom: 15px;">⚠️</div>
726
- <div style="color: #fff; font-size: 14px; margin-bottom: 8px;">${t('post.delete_comment_confirm')}</div>
727
- <div style="color: #888; font-size: 12px; margin-bottom: 20px;">${t('post.delete_comment_warning')}</div>
728
- <div style="display: flex; gap: 10px;">
729
- <button type="button" id="delete-comment-cancel" style="flex: 1; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer;">${t('common.cancel')}</button>
730
- <button type="button" id="delete-comment-confirm" style="flex: 1; background: #F44336; border: none; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer; font-weight: bold;">${t('common.delete')}</button>
731
  </div>
732
  </div>
733
  `;
@@ -773,23 +784,23 @@ function renderActionButtons(container, task, currentUser, isPublisher, isAssign
773
  let buttons = [];
774
 
775
  if (!currentUser) {
776
- buttons.push(`<div style="text-align: center; color: #888; font-size: 13px;">${t('common.login_required')}</div>`);
777
  } else if (task.status === "open") {
778
  if (isPublisher) {
779
  buttons.push(`
780
- <button id="btn-cancel-task" style="background: #F44336; border: none; color: #fff; padding: 12px; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: bold;">
781
  ❌ ${t('task.cancel_task')}
782
  </button>
783
  `);
784
  } else if (hasApplied) {
785
  buttons.push(`
786
- <button id="btn-cancel-apply" style="background: #757575; border: none; color: #fff; padding: 12px; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: bold;">
787
  ${t('task.cancel_apply')}
788
  </button>
789
  `);
790
  } else {
791
  buttons.push(`
792
- <button id="btn-apply" style="background: linear-gradient(135deg, #FF9800, #F57C00); border: none; color: #fff; padding: 12px; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: bold; box-shadow: 0 2px 8px rgba(255,152,0,0.3);">
793
  🙋 ${t('task.apply_task')}
794
  </button>
795
  `);
@@ -797,7 +808,7 @@ function renderActionButtons(container, task, currentUser, isPublisher, isAssign
797
  } else if (task.status === "in_progress") {
798
  if (isAssignee) {
799
  buttons.push(`
800
- <button id="btn-submit" style="background: linear-gradient(135deg, #4CAF50, #45a049); border: none; color: #fff; padding: 12px; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: bold;">
801
  📤 ${t('task.submit_work')}
802
  </button>
803
  `);
@@ -805,10 +816,10 @@ function renderActionButtons(container, task, currentUser, isPublisher, isAssign
805
  } else if (task.status === "submitted") {
806
  if (isPublisher) {
807
  buttons.push(`
808
- <button id="btn-accept" style="background: #4CAF50; border: none; color: #fff; padding: 12px; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: bold; margin-bottom: 8px;">
809
  ✅ ${t('task.accept_work')}
810
  </button>
811
- <button id="btn-reject" style="background: #F44336; border: none; color: #fff; padding: 12px; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: bold;">
812
  ❌ ${t('task.reject_work')}
813
  </button>
814
  `);
@@ -818,7 +829,7 @@ function renderActionButtons(container, task, currentUser, isPublisher, isAssign
818
  // 申诉按钮(进行中或待验收时,双方都可以申诉)
819
  if ((task.status === "in_progress" || task.status === "submitted") && (isPublisher || isAssignee)) {
820
  buttons.push(`
821
- <button id="btn-dispute" style="background: #757575; border: none; color: #fff; padding: 10px; border-radius: 8px; cursor: pointer; font-size: 13px; margin-top: 10px;">
822
  ⚖️ ${t('task.start_dispute')}
823
  </button>
824
  `);
@@ -827,7 +838,7 @@ function renderActionButtons(container, task, currentUser, isPublisher, isAssign
827
  // 查看申诉详情按钮(争议中状态)
828
  if (task.status === "disputed" && task.dispute_id && (isPublisher || isAssignee)) {
829
  buttons.push(`
830
- <button id="btn-view-dispute" style="background: linear-gradient(135deg, #9C27B0, #7B1FA2); border: none; color: #fff; padding: 10px 20px; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: bold; margin-top: 10px;">
831
  ⚖️ ${t('task.view_dispute')}
832
  </button>
833
  `);
@@ -904,22 +915,22 @@ function showSubmitDialog(task, currentUser) {
904
  let phase = "idle";
905
  const content = document.createElement("div");
906
  content._cleanup = () => { phase = "closed"; };
907
- content.style.color = "#ccc";
908
  content.innerHTML = `
909
- <div style="margin-bottom: 15px;">
910
- <label for="deliverable-files" style="display: block; color: #ccc; font-size: 13px; margin-bottom: 5px;">${t('task.deliverable_images')} <span aria-hidden="true">*</span></label>
911
- <input type="file" aria-required="true" aria-describedby="deliverable-hint" id="deliverable-files" multiple accept="image/*" style="width: 100%; padding: 8px; background: var(--comfy-input-bg); border: 1px solid #555; border-radius: 6px; color: #fff;">
912
- <div id="deliverable-hint" style="font-size: 11px; color: #888; margin-top: 4px;">${t('task.support_multiple_images')}</div>
913
  </div>
914
 
915
- <div style="margin-bottom: 20px;">
916
- <label for="submit-note" style="display: block; color: #ccc; font-size: 13px; margin-bottom: 5px;">${t('task.note_optional')}</label>
917
- <textarea id="submit-note" rows="3" placeholder="${t('task.note_placeholder')}" style="width: 100%; padding: 8px; background: var(--comfy-input-bg); border: 1px solid #555; border-radius: 6px; color: #fff; resize: none; box-sizing: border-box;"></textarea>
918
  </div>
919
 
920
- <div style="display: flex; gap: 10px;">
921
- <button type="button" id="btn-cancel-modal" style="flex: 1; padding: 10px; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; border-radius: 6px; cursor: pointer;">${t('common.cancel')}</button>
922
- <button type="button" id="btn-confirm-submit" style="flex: 1; padding: 10px; background: #4CAF50; border: none; color: #fff; border-radius: 6px; cursor: pointer; font-weight: bold;">${t('common.confirm_submit')}</button>
923
  </div>
924
  `;
925
 
@@ -1043,13 +1054,13 @@ function showTaskTipDialog(task, currentUser, container) {
1043
  let phase = "idle";
1044
  const content = document.createElement("div");
1045
  content._cleanup = () => { phase = "closed"; };
1046
- content.style.color = "#ccc";
1047
  content.innerHTML = `
1048
- <div style="display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; margin-bottom: 15px;">
1049
- ${TASK_LIMITS.TIP_AMOUNTS.map(amount => `<button type="button" class="tip-amount" data-amount="${amount}" style="background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px 20px; border-radius: 6px; cursor: pointer;">${amount} ${t('task.points')}</button>`).join("")}
1050
  </div>
1051
- <div style="display: flex; gap: 10px;">
1052
- <button type="button" id="tip-cancel" style="flex: 1; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer;">${t('common.cancel')}</button>
1053
  </div>
1054
  `;
1055
 
@@ -1110,8 +1121,8 @@ function _renderCommentAvatar(name, avatarUrl) {
1110
  const initial = String(name || 'U')[0].toUpperCase();
1111
  // 🔒 XSS防护:评论者昵称与头像均来自他人可修改的资料,写入 HTML 前必须转义
1112
  return avatarUrl
1113
- ? `<img class="swr-avatar" src="${escapeHtml(avatarUrl)}" style="width: 24px; height: 24px; border-radius: 50%; object-fit: cover; background: var(--comfy-input-bg);">`
1114
- : `<div class="swr-avatar" style="width: 24px; height: 24px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: 10px; font-weight: bold;">${escapeHtml(initial)}</div>`;
1115
  }
1116
 
1117
  /**
@@ -1154,7 +1165,7 @@ async function loadTaskComments(container, taskId, currentUser) {
1154
 
1155
  if (comments.length === 0) {
1156
  commentsList.innerHTML = `
1157
- <div style="text-align: center; padding: 30px; color: #666; font-size: 13px;">
1158
  ${t('post.no_comments')}
1159
  </div>
1160
  `;
@@ -1175,16 +1186,14 @@ async function loadTaskComments(container, taskId, currentUser) {
1175
  );
1176
 
1177
  return `
1178
- <div id="task-comment-${idx}" data-account="${escapeHtml(c.author)}" data-comment-id="${escapeHtml(c.id || idx)}" style="background: #1a1a1a; padding: 12px; border-radius: 8px;">
1179
- <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
1180
  <div class="swr-avatar-container">${avatarHtml}</div>
1181
- <span class="swr-name" style="font-size: 13px; color: #fff; font-weight: 500;">${escapeHtml(name)}</span>
1182
- <span style="font-size: 11px; color: #888; margin-left: auto;">${formatTime(c.created_at)}</span>
1183
- ${canDelete ? `<button class="btn-delete-comment" data-idx="${idx}" style="background: transparent; border: none; color: #f44; cursor: pointer; font-size: 11px; padding: 2px 6px;">🗑️</button>` : ''}
1184
- </div>
1185
- <div style="font-size: 13px; color: #ddd; line-height: 1.5; padding-left: 32px;">
1186
- ${escapeHtml(c.content)}
1187
  </div>
 
1188
  </div>
1189
  `;
1190
  }).join("");
@@ -1220,7 +1229,7 @@ async function loadTaskComments(container, taskId, currentUser) {
1220
  } catch (err) {
1221
  if (!readCurrent()) return;
1222
  commentsList.innerHTML = `
1223
- <div style="text-align: center; padding: 20px; color: #F44336; font-size: 12px;">
1224
  ${t('post.comment_load_failed')}
1225
  </div>
1226
  `;
@@ -1277,7 +1286,10 @@ async function loadTaskComments(container, taskId, currentUser) {
1277
  }
1278
  };
1279
  commentInput.onkeydown = event => {
1280
- if (event.key === "Enter" && !event.shiftKey && !event.isComposing && event.keyCode !== 229 && !event.repeat) {
 
 
 
1281
  event.preventDefault();
1282
  sendBtn.click();
1283
  }
@@ -1317,15 +1329,15 @@ function showDeleteTaskConfirm(task, contentEl) {
1317
  let phase = "idle";
1318
  const content = document.createElement("div");
1319
  content._cleanup = () => { phase = "closed"; };
1320
- content.style.color = "#ccc";
1321
  content.innerHTML = `
1322
- <div style="text-align: center;">
1323
- <div style="font-size: 40px; margin-bottom: 15px;">⚠️</div>
1324
- <div style="color: #fff; font-size: 14px; margin-bottom: 8px;">${t('task.delete_confirm_title')}</div>
1325
- <div style="color: #888; font-size: 12px; margin-bottom: 20px;">${t('task.delete_confirm_desc')}</div>
1326
- <div style="display: flex; gap: 10px;">
1327
- <button id="delete-cancel" style="flex: 1; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer;">${t('common.cancel')}</button>
1328
- <button id="delete-confirm" style="flex: 1; background: #F44336; border: none; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer; font-weight: bold;">${t('common.delete')}</button>
1329
  </div>
1330
  </div>
1331
  `;
@@ -1403,7 +1415,7 @@ function showDisputeDialog(task, currentUser) {
1403
  </div>
1404
  </div>
1405
 
1406
- <button id="btn-submit-dispute" class="task-dispute__submit">
1407
  ${t('dispute.submit')}
1408
  </button>
1409
  `;
 
16
  import { removeCache, findInListCache, requireListResponseData } from "../components/性能优化工具.js";
17
  import { globalModal } from "../components/全局弹窗管理器.js";
18
  import { compressImageForUpload } from "../market/发布内容_提交引擎.js";
19
+ import { getCoverSandboxHTML, setupImageSandboxEvents, cleanupImageSandbox } from "../components/图片沙盒组件.js";
20
+ import { openChatModal } from "../social/私信聊天组件.js";
21
  import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
22
  import { recordView, handleToggleLike, handleToggleFavorite, renderTipBoardHTML as renderCommonTipBoardHTML, escapeHtml } from "../components/互动工具函数.js";
23
 
 
28
 
29
  function openTaskModal(scope, title, content, options) {
30
  if (scope && !scope.isCurrent()) return;
31
+ content.classList.add("task-modal");
32
  globalModal.openModal(title, content, options);
33
  const modal = globalModal.activeModals.at(-1);
34
  const release = scope?.addCleanup(() => globalModal.closeModal(modal));
 
89
  });
90
  scope.componentId = componentId;
91
  scope.root = container;
92
+ scope.addCleanup(() => cleanupImageSandbox(container));
93
  container._taskScope = scope;
94
  container.querySelector("#task-content")._taskScope = scope;
95
  container._cleanup = () => {
 
190
  submitted: { bg: "#FF9800", textKey: "task.status_submitted" },
191
  completed: { bg: "#9E9E9E", textKey: "task.status_completed" },
192
  disputed: { bg: "#F44336", textKey: "task.status_disputed" },
193
+ cancelled: { bg: "#757575", textKey: "task.status_cancelled" },
194
+ expired: { bg: "#757575", textKey: "task.status_expired" }
195
  };
196
 
197
  const status = Object.prototype.hasOwnProperty.call(statusStyles, task.status)
 
220
  const isLiked = Array.isArray(task.liked_by) && task.liked_by.includes(currentAccount);
221
  const isFavorited = Array.isArray(task.favorited_by) && task.favorited_by.includes(currentAccount);
222
 
223
+ cleanupImageSandbox(contentEl);
224
  contentEl.innerHTML = `
225
  <!-- 状态 + 价格 -->
226
+ <div class="task-detail__summary">
227
+ <span class="task-detail__status" data-status="${escapeHtml(task.status)}">
228
  ${statusText}
229
  </span>
230
+ <div class="task-detail__price-align">
231
+ <div class="task-detail__price">💰 ${escapeHtml(task.total_price)} ${t('common.credits')}</div>
232
+ <div class="task-detail__deposit">${t('task.deposit')} ${escapeHtml(task.deposit_ratio)}%(${escapeHtml(task.deposit_amount)} ${t('common.credits')})</div>
233
  </div>
234
  </div>
235
 
236
  <!-- 标题 -->
237
+ <h2 class="task-detail__title">
238
  ${escapeHtml(task.title)}
239
  </h2>
240
 
241
  <!-- 发布者信息 -->
242
+ <div class="task-detail__publisher">
243
  <img src="${escapeHtml(task.publisher_avatar || PLACEHOLDERS.AVATAR_SMALL)}"
244
+ class="task-detail__avatar-main" id="publisher-avatar" alt="">
245
+ <div class="task-detail__grow">
246
+ <div class="task-detail__publisher-name">${escapeHtml(task.publisher_name || task.publisher)}</div>
247
+ <div class="task-detail__timestamp">${t('task.published_at')} ${formatTime(task.created_at)}</div>
248
  </div>
249
  ${isPublisher && ["open", "in_progress", "submitted"].includes(task.status) ? `
250
+ <div class="task-detail__compact-actions">
251
+ ${IS_WEB_MODE ? '' : `<button type="button" id="btn-edit-task" class="task-detail__edit" title="${t('common.edit')}">✏️ ${t('common.edit')}</button>`}
252
  ${task.status === "open" ? `
253
+ <button type="button" id="btn-delete-task" class="task-detail__delete" title="${t('common.delete')}">🗑️ ${t('common.delete')}</button>
254
  ` : ''}
255
  </div>
256
  ` : ''}
257
  ${!isPublisher && currentUser ? `
258
+ <button type="button" id="btn-chat-publisher" class="task-detail__chat">
259
  💬 ${t('common.message')}
260
  </button>
261
  ` : ""}
262
  </div>
263
 
264
  <!-- 👀 浏览量统计 -->
265
+ <div class="task-detail__stats">
266
+ <span class="task-detail__stat">${t('task.view_total')}: <span id="task-view-total" class="task-detail__value">${escapeHtml(task.views || 0)}</span></span>
267
+ <span class="task-detail__stat">${t('task.view_today')}: <span id="task-view-daily" class="task-detail__value">${escapeHtml(task.daily_views || 0)}</span></span>
268
  </div>
269
 
270
  <!-- 截止日期 -->
271
+ <div class="task-detail__deadline">
272
+ <span class="task-detail__icon">⏰</span>
273
  <div>
274
+ <div class="task-detail__deadline-title">${t('task.deadline')}</div>
275
+ <div class="task-detail__deadline-value">${formatDeadline(task.deadline)}</div>
276
  </div>
277
  </div>
278
 
279
  <!-- 任务描述 -->
280
+ <div class="task-detail__section">
281
+ <div class="task-detail__heading">📄 ${t('task.description')}</div>
282
+ <div class="task-detail__prose">
283
  ${escapeHtml(task.description)}</div>
284
  </div>
285
 
286
  <!-- 参考图片 -->
287
  ${referenceImages.length > 0 ? `
288
+ <div class="task-detail__section ref-images-sandbox" >
289
+ <div class="task-detail__heading">🖼️ ${t('task.reference_images')}</div>
290
+ <div class="task-detail__gallery-inner">
291
+ ${getCoverSandboxHTML(referenceImages, true)}
292
  </div>
293
  </div>
294
  ` : ""}
295
 
296
  <!-- 参考链接 -->
297
  ${safeReferenceLink ? `
298
+ <div class="task-detail__section">
299
+ <div class="task-detail__heading">🔗 ${t('task.reference_link')}</div>
300
+ <a href="${escapeHtml(safeReferenceLink)}" target="_blank" rel="noopener noreferrer" class="task-detail__reference-link">
301
  ${escapeHtml(task.reference_link)}
302
  </a>
303
  </div>
304
  ` : ""}
305
 
306
  <!-- 👍 互动按钮栏 -->
307
+ <div id="interaction-bar" class="task-detail__interactions">
308
+ <button type="button" id="btn-like" aria-label="${escapeHtml(t('market.like'))}" class="task-detail__like" aria-pressed="${isLiked}">
309
  ❤️ <span id="like-count">${escapeHtml(task.likes || 0)}</span>
310
  </button>
311
+ <button type="button" id="btn-favorite" aria-label="${escapeHtml(t('market.favorite'))}" class="task-detail__favorite" aria-pressed="${isFavorited}">
312
  🔖 <span id="favorite-count">${escapeHtml(task.favorites || 0)}</span>
313
  </button>
314
+ <button type="button" id="btn-tip" class="task-detail__tip">
315
  💰 ${t('post.tip_author')}
316
  </button>
317
  </div>
318
 
319
  <!-- 💰 打赏榜单 -->
320
+ <div id="tip-board-area" class="task-detail__spaced">
321
  ${renderTipBoard(tipBoard)}
322
  </div>
323
 
324
  <!-- 💬 评论区 -->
325
+ <div class="task-detail__spaced">
326
+ <div class="task-detail__comments-heading">
327
+ 💬 ${t('post.comments_title')} <span class="task-detail__count">(${escapeHtml(task.comments || 0)})</span>
328
  </div>
329
 
330
  <!-- 评论输入框 -->
331
+ <div class="task-detail__comment-compose">
332
+ <input type="text" id="comment-input" aria-label="${escapeHtml(t('post.comment_placeholder'))}" aria-required="true" required placeholder="${escapeHtml(t('post.comment_placeholder'))}" class="task-detail__comment-input">
333
+ <button type="button" id="btn-send-comment" class="task-detail__comment-send">
334
  ${t('common.send')}
335
  </button>
336
  </div>
337
 
338
  <!-- 评论列表 -->
339
+ <div id="comments-list" class="task-detail__stack">
340
+ <div class="task-detail__empty">
341
  ${t('post.comment_loading')}
342
  </div>
343
  </div>
 
345
 
346
  <!-- 接单者信息(如果有) -->
347
  ${task.assignee ? `
348
+ <div class="task-detail__assignee">
349
+ <div class="task-detail__assignee-heading">👷 ${t('task.assignee')}</div>
350
+ <div class="task-detail__person">
351
+ <img alt="" src="${escapeHtml(task.assignee_avatar || PLACEHOLDERS.AVATAR_SMALL)}"
352
+ class="task-detail__avatar">
353
+ <span class="task-detail__person-name">${escapeHtml(task.assignee_name || task.assignee)}</span>
354
  </div>
355
  </div>
356
  ` : ""}
357
 
358
  <!-- 交付成果(如果有) -->
359
  ${deliverables.length > 0 ? `
360
+ <div class="task-detail__delivery deliverables-sandbox" >
361
+ <div class="task-detail__delivery-heading">📦 ${t('task.deliverables')}</div>
362
+ <div class="task-detail__gallery-inner">
363
+ ${getCoverSandboxHTML(deliverables, true)}
364
  </div>
365
+ ${task.submit_note ? `<div class="task-detail__note">${t('task.note')}: ${escapeHtml(task.submit_note)}</div>` : ""}
366
  </div>
367
  ` : ""}
368
 
369
  <!-- 申请者列表(仅发布者可见,且在 open 状态) -->
370
  ${isPublisher && task.status === "open" && applicants.length > 0 ? `
371
+ <div class="task-detail__section">
372
+ <div class="task-detail__heading">👥 ${t('task.applicants')}(${applicants.length}${t('task.applicants_count')})</div>
373
+ <div id="applicants-list" class="task-detail__applicants">
374
  ${applicants.map(app => `
375
+ <div class="task-detail__applicant">
376
+ <img alt="" src="${escapeHtml(app.avatar || PLACEHOLDERS.AVATAR_SMALL)}"
377
+ class="task-detail__avatar">
378
+ <div class="task-detail__grow">
379
+ <div class="task-detail__applicant-name">${escapeHtml(app.name || app.account)}</div>
380
+ ${app.message ? `<div class="task-detail__applicant-message">${escapeHtml(app.message)}</div>` : ""}
381
  </div>
382
+ <button type="button" class="btn-assign task-detail__assign" data-account="${escapeHtml(app.account)}" >
383
  ${t('task.choose_assignee')}
384
  </button>
385
  </div>
 
389
  ` : ""}
390
 
391
  <!-- 操作按钮区域 -->
392
+ <div id="action-buttons" class="task-detail__actions">
393
  </div>
394
  `;
395
 
 
401
  contentEl.querySelectorAll("button, input, textarea, select").forEach(control => { control.disabled = true; });
402
  contentEl.querySelector("#action-buttons").textContent = t('task.cached_readonly');
403
  contentEl.querySelector("#comments-list").textContent = t('task.cached_readonly');
404
+ contentEl.querySelectorAll(".img-viewport").forEach(viewport => setupImageSandboxEvents(viewport));
405
  return;
406
  }
407
 
 
420
  showDeleteTaskConfirm(task, contentEl);
421
  });
422
 
423
+ contentEl.querySelector("#btn-chat-publisher")?.addEventListener("click", () => {
424
+ const scope = taskScopes.get(task);
425
+ if (!scope?.isCurrent() || typeof task.publisher !== "string" || !task.publisher.trim()) return;
426
+ openChatModal(currentUser, task.publisher);
427
+ });
428
+
429
  // 绑定申请者列表的选择按钮
430
  contentEl.querySelectorAll(".btn-assign").forEach(btn => {
431
  btn.onclick = () => {
 
435
  });
436
 
437
  // 绑定图片沙盒交互(缩放、拖动、轮播等)
438
+ contentEl.querySelectorAll(".img-viewport").forEach(viewport => setupImageSandboxEvents(viewport));
439
 
440
  // 👍 绑定互动事件(点赞/收藏/打赏)
441
  bindTaskInteractionEvents(contentEl, task, currentUser);
 
451
  let phase = "idle";
452
  const content = document.createElement("div");
453
  content._cleanup = () => { phase = "closed"; };
454
+ content.classList.add("task-modal");
455
  // 🔒 XSS防护:message/description 可能嵌入其他用户的账号或昵称
456
  // (如 t('task.confirm_assign', { assignee })),写入 innerHTML 前必须转义。
457
  // 本函数的 4 个调用方传的都是词典文本/emoji/数字,转义不改变现有显示效果。
458
  content.innerHTML = `
459
+ <div class="task-detail__centered">
460
+ <div class="task-detail__dialog-icon">${icon}</div>
461
+ <div class="task-detail__dialog-message">${escapeHtml(message)}</div>
462
+ <div class="task-detail__dialog-description">${escapeHtml(description)}</div>
463
+ <div class="task-detail__buttons">
464
+ <button type="button" id="dialog-cancel" class="task-detail__cancel">${t('common.cancel')}</button>
465
+ <button type="button" id="dialog-confirm" class="task-detail__confirm" data-tone="${confirmStyle === 'background: #F44336' ? 'danger' : confirmStyle === 'background: #FF9800' ? 'warning' : 'success'}">${escapeHtml(confirmText)}</button>
466
  </div>
467
  </div>
468
  `;
 
577
  function showApplyDialog(task, currentUser) {
578
  const taskId = task.id;
579
  const content = document.createElement("div");
580
+ content.classList.add("task-modal");
581
  content.innerHTML = `
582
+ <div class="task-detail__spaced">
583
+ <label for="apply-message" class="task-detail__label">${t('task.apply_message_prompt')}</label>
584
+ <textarea id="apply-message" rows="4" placeholder="${t('task.apply_message_placeholder')}" class="task-detail__textarea"></textarea>
585
  </div>
586
+ <div class="task-detail__buttons">
587
+ <button type="button" id="apply-cancel" class="task-detail__cancel">${t('common.cancel')}</button>
588
+ <button type="button" id="apply-confirm" class="task-detail__submit-warning">${t('common.confirm_submit')}</button>
589
  </div>
590
  `;
591
 
 
691
  function showRejectDialog(task, currentUser) {
692
  const taskId = task.id;
693
  const content = document.createElement("div");
694
+ content.classList.add("task-modal");
695
  content.innerHTML = `
696
+ <div class="task-detail__spaced">
697
+ <label for="reject-feedback" class="task-detail__label">${t('task.reject_reason_prompt')}</label>
698
+ <textarea aria-required="true" id="reject-feedback" rows="4" placeholder="${t('task.reject_reason_placeholder')}" class="task-detail__textarea"></textarea>
699
  </div>
700
+ <div class="task-detail__buttons">
701
+ <button type="button" id="reject-cancel" class="task-detail__cancel">${t('common.cancel')}</button>
702
+ <button type="button" id="reject-confirm" class="task-detail__submit-danger">${t('task.reject_work')}</button>
703
  </div>
704
  `;
705
 
 
730
  let phase = "idle";
731
  const content = document.createElement("div");
732
  content._cleanup = () => { phase = "closed"; };
733
+ content.classList.add("task-modal");
734
  content.innerHTML = `
735
+ <div class="task-detail__centered">
736
+ <div class="task-detail__dialog-icon">⚠️</div>
737
+ <div class="task-detail__dialog-message">${t('post.delete_comment_confirm')}</div>
738
+ <div class="task-detail__dialog-description">${t('post.delete_comment_warning')}</div>
739
+ <div class="task-detail__buttons">
740
+ <button type="button" id="delete-comment-cancel" class="task-detail__cancel">${t('common.cancel')}</button>
741
+ <button type="button" id="delete-comment-confirm" class="task-detail__submit-danger">${t('common.delete')}</button>
742
  </div>
743
  </div>
744
  `;
 
784
  let buttons = [];
785
 
786
  if (!currentUser) {
787
+ buttons.push(`<div class="task-detail__login">${t('common.login_required')}</div>`);
788
  } else if (task.status === "open") {
789
  if (isPublisher) {
790
  buttons.push(`
791
+ <button type="button" id="btn-cancel-task" class="task-detail__action-danger">
792
  ❌ ${t('task.cancel_task')}
793
  </button>
794
  `);
795
  } else if (hasApplied) {
796
  buttons.push(`
797
+ <button type="button" id="btn-cancel-apply" class="task-detail__action-neutral">
798
  ${t('task.cancel_apply')}
799
  </button>
800
  `);
801
  } else {
802
  buttons.push(`
803
+ <button type="button" id="btn-apply" class="task-detail__action-warning">
804
  🙋 ${t('task.apply_task')}
805
  </button>
806
  `);
 
808
  } else if (task.status === "in_progress") {
809
  if (isAssignee) {
810
  buttons.push(`
811
+ <button type="button" id="btn-submit" class="task-detail__action-success">
812
  📤 ${t('task.submit_work')}
813
  </button>
814
  `);
 
816
  } else if (task.status === "submitted") {
817
  if (isPublisher) {
818
  buttons.push(`
819
+ <button type="button" id="btn-accept" class="task-detail__action-accept">
820
  ✅ ${t('task.accept_work')}
821
  </button>
822
+ <button type="button" id="btn-reject" class="task-detail__action-danger">
823
  ❌ ${t('task.reject_work')}
824
  </button>
825
  `);
 
829
  // 申诉按钮(进行中或待验收时,双方都可以申诉)
830
  if ((task.status === "in_progress" || task.status === "submitted") && (isPublisher || isAssignee)) {
831
  buttons.push(`
832
+ <button type="button" id="btn-dispute" class="task-detail__action-dispute">
833
  ⚖️ ${t('task.start_dispute')}
834
  </button>
835
  `);
 
838
  // 查看申诉详情按钮(争议中状态)
839
  if (task.status === "disputed" && task.dispute_id && (isPublisher || isAssignee)) {
840
  buttons.push(`
841
+ <button type="button" id="btn-view-dispute" class="task-detail__action-dispute-view">
842
  ⚖️ ${t('task.view_dispute')}
843
  </button>
844
  `);
 
915
  let phase = "idle";
916
  const content = document.createElement("div");
917
  content._cleanup = () => { phase = "closed"; };
918
+ content.classList.add("task-modal");
919
  content.innerHTML = `
920
+ <div class="task-detail__spaced">
921
+ <label for="deliverable-files" class="task-detail__file-label">${t('task.deliverable_images')} <span aria-hidden="true">*</span></label>
922
+ <input type="file" aria-required="true" aria-describedby="deliverable-hint" id="deliverable-files" multiple accept="image/*" class="task-detail__file-input">
923
+ <div id="deliverable-hint" class="task-detail__hint">${t('task.support_multiple_images')}</div>
924
  </div>
925
 
926
+ <div class="task-detail__section">
927
+ <label for="submit-note" class="task-detail__file-label">${t('task.note_optional')}</label>
928
+ <textarea id="submit-note" rows="3" placeholder="${t('task.note_placeholder')}" class="task-detail__note-input"></textarea>
929
  </div>
930
 
931
+ <div class="task-detail__buttons">
932
+ <button type="button" id="btn-cancel-modal" class="task-detail__cancel-padded">${t('common.cancel')}</button>
933
+ <button type="button" id="btn-confirm-submit" class="task-detail__submit-success">${t('common.confirm_submit')}</button>
934
  </div>
935
  `;
936
 
 
1054
  let phase = "idle";
1055
  const content = document.createElement("div");
1056
  content._cleanup = () => { phase = "closed"; };
1057
+ content.classList.add("task-modal");
1058
  content.innerHTML = `
1059
+ <div class="task-detail__tip-options">
1060
+ ${TASK_LIMITS.TIP_AMOUNTS.map(amount => `<button type="button" class="tip-amount task-detail__tip-option" data-amount="${amount}" >${amount} ${t('task.points')}</button>`).join("")}
1061
  </div>
1062
+ <div class="task-detail__buttons">
1063
+ <button type="button" id="tip-cancel" class="task-detail__cancel">${t('common.cancel')}</button>
1064
  </div>
1065
  `;
1066
 
 
1121
  const initial = String(name || 'U')[0].toUpperCase();
1122
  // 🔒 XSS防护:评论者昵称与头像均来自他人可修改的资料,写入 HTML 前必须转义
1123
  return avatarUrl
1124
+ ? `<img alt="" class="swr-avatar task-detail__comment-avatar" src="${escapeHtml(avatarUrl)}" >`
1125
+ : `<div class="swr-avatar task-detail__comment-avatar-fallback" >${escapeHtml(initial)}</div>`;
1126
  }
1127
 
1128
  /**
 
1165
 
1166
  if (comments.length === 0) {
1167
  commentsList.innerHTML = `
1168
+ <div class="task-detail__comments-empty">
1169
  ${t('post.no_comments')}
1170
  </div>
1171
  `;
 
1186
  );
1187
 
1188
  return `
1189
+ <div id="task-comment-${idx}" data-account="${escapeHtml(c.author)}" data-comment-id="${escapeHtml(c.id || idx)}" class="task-detail__comment">
1190
+ <div class="task-detail__comment-meta">
1191
  <div class="swr-avatar-container">${avatarHtml}</div>
1192
+ <span class="swr-name task-detail__comment-name" >${escapeHtml(name)}</span>
1193
+ <span class="task-detail__comment-time">${formatTime(c.created_at)}</span>
1194
+ ${canDelete ? `<button type="button" aria-label="${escapeHtml(t('social.delete_comment_title'))}" class="btn-delete-comment task-detail__comment-delete" data-idx="${idx}" >🗑️</button>` : ''}
 
 
 
1195
  </div>
1196
+ <div class="task-detail__comment-body">${escapeHtml(c.content)}</div>
1197
  </div>
1198
  `;
1199
  }).join("");
 
1229
  } catch (err) {
1230
  if (!readCurrent()) return;
1231
  commentsList.innerHTML = `
1232
+ <div class="task-detail__comments-error">
1233
  ${t('post.comment_load_failed')}
1234
  </div>
1235
  `;
 
1286
  }
1287
  };
1288
  commentInput.onkeydown = event => {
1289
+ if (isCurrent() && !event.defaultPrevented && event.key === "Enter"
1290
+ && !event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey
1291
+ && !event.isComposing && event.keyCode !== 229 && !event.repeat
1292
+ && !commentInput.disabled && !commentInput.readOnly) {
1293
  event.preventDefault();
1294
  sendBtn.click();
1295
  }
 
1329
  let phase = "idle";
1330
  const content = document.createElement("div");
1331
  content._cleanup = () => { phase = "closed"; };
1332
+ content.classList.add("task-modal");
1333
  content.innerHTML = `
1334
+ <div class="task-detail__centered">
1335
+ <div class="task-detail__dialog-icon">⚠️</div>
1336
+ <div class="task-detail__dialog-message">${t('task.delete_confirm_title')}</div>
1337
+ <div class="task-detail__dialog-description">${t('task.delete_confirm_desc')}</div>
1338
+ <div class="task-detail__buttons">
1339
+ <button type="button" id="delete-cancel" class="task-detail__cancel">${t('common.cancel')}</button>
1340
+ <button type="button" id="delete-confirm" class="task-detail__submit-danger">${t('common.delete')}</button>
1341
  </div>
1342
  </div>
1343
  `;
 
1415
  </div>
1416
  </div>
1417
 
1418
+ <button type="button" id="btn-submit-dispute" class="task-dispute__submit">
1419
  ${t('dispute.submit')}
1420
  </button>
1421
  `;
前端页面/task/发布任务组件.js CHANGED
@@ -97,18 +97,7 @@ export function createPublishTaskView(currentUser, editTaskData = null) {
97
  const isLockedStatus = editTaskData && ["in_progress", "submitted"].includes(editTaskData.status);
98
 
99
  const container = document.createElement("div");
100
- Object.assign(container.style, {
101
- display: "flex",
102
- flexDirection: "column",
103
- gap: "0",
104
- color: "#ccc",
105
- fontSize: "14px",
106
- padding: "0",
107
- overflowY: "auto",
108
- flex: "1",
109
- boxSizing: "border-box",
110
- background: "#121212"
111
- });
112
 
113
  // 使用本地日历日期,不通过 UTC 转换选择器的日期。
114
  const tomorrow = new Date();
@@ -127,114 +116,114 @@ export function createPublishTaskView(currentUser, editTaskData = null) {
127
 
128
  container.innerHTML = `
129
  <!-- 顶部导航 -->
130
- <div style="display: flex; align-items: center; padding: 12px 15px; background: #1a1a1a; border-bottom: 1px solid var(--border-color, #333);">
131
- <button type="button" id="btn-back" style="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;" onmouseover="this.style.background='#4CAF50'" onmouseout="this.style.background='rgba(51,51,51,0.8)'">
132
  ⬅ ${t('common.back')}
133
  </button>
134
- <span style="flex: 1; text-align: center; font-size: 15px; font-weight: bold; color: #fff;">📝 ${isEditMode ? t('task.edit_task') : t('task.publish')}</span>
135
- <div style="width: 60px;"></div>
136
  </div>
137
 
138
  <!-- 表单内容 -->
139
- <div style="padding: 15px; display: flex; flex-direction: column; gap: 15px;">
140
 
141
  <!-- 任务标题 -->
142
  <div>
143
- <label for="task-title" style="display: block; color: #fff; font-size: 13px; font-weight: bold; margin-bottom: 6px;">
144
- 📌 ${t('task.task_title')} <span aria-hidden="true" style="color: #F44336;">*</span>
145
  </label>
146
  <input type="text" id="task-title" required maxlength="50" placeholder="${t('task.title_placeholder')}" value="${escapeHtml(editTitle)}"
147
- style="width: 100%; padding: 10px 12px; background: var(--comfy-menu-bg); border: 1px solid var(--border-color, #333); border-radius: 8px; color: #fff; font-size: 14px; box-sizing: border-box;">
148
  </div>
149
 
150
  <!-- 任务描述 -->
151
  <div>
152
- <label for="task-description" style="display: block; color: #fff; font-size: 13px; font-weight: bold; margin-bottom: 6px;">
153
- 📄 ${t('task.description')} <span aria-hidden="true" style="color: #F44336;">*</span>
154
  </label>
155
  <textarea id="task-description" required aria-describedby="desc-count" rows="6" maxlength="2000" placeholder="${t('task.description_placeholder')}"
156
- style="width: 100%; padding: 10px 12px; background: var(--comfy-menu-bg); border: 1px solid var(--border-color, #333); border-radius: 8px; color: #fff; font-size: 14px; resize: none; box-sizing: border-box; line-height: 1.5;">${escapeHtml(editDescription)}</textarea>
157
- <div style="text-align: right; font-size: 11px; color: #666; margin-top: 4px;">
158
  <span id="desc-count">${editDescription.length}</span>/2000
159
  </div>
160
  </div>
161
 
162
  <!-- 参考图片 -->
163
  <div>
164
- <label for="ref-images" style="display: block; color: #fff; font-size: 13px; font-weight: bold; margin-bottom: 6px;">
165
  🖼️ ${t('task.reference_images_optional')}
166
  </label>
167
  <input type="file" id="ref-images" multiple accept="image/*"
168
  style="display: none;">
169
- <div id="image-preview" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 8px;"></div>
170
- <button type="button" id="btn-add-image" style="width: 100%; padding: 12px; background: var(--comfy-menu-bg); border: 1px dashed #555; border-radius: 8px; color: #888; cursor: pointer; font-size: 13px;">
171
  + ${t('task.add_reference_image')}
172
  </button>
173
  </div>
174
 
175
  <!-- 参考链接 -->
176
  <div>
177
- <label for="ref-link" style="display: block; color: #fff; font-size: 13px; font-weight: bold; margin-bottom: 6px;">
178
  🔗 ${t('task.reference_link_optional')}
179
  </label>
180
  <input type="url" id="ref-link" placeholder="https://..." value="${escapeHtml(editRefLink)}"
181
- style="width: 100%; padding: 10px 12px; background: var(--comfy-menu-bg); border: 1px solid var(--border-color, #333); border-radius: 8px; color: #fff; font-size: 14px; box-sizing: border-box;">
182
  </div>
183
 
184
  <!-- 价格设置 -->
185
- <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
186
  <!-- 总价格 -->
187
  <div>
188
- <label for="total-price" style="display: block; color: #fff; font-size: 13px; font-weight: bold; margin-bottom: 6px;">
189
- 💰 ${t('task.total_price')} <span aria-hidden="true" style="color: #F44336;">*</span>
190
  </label>
191
- <div style="position: relative;">
192
  <input type="number" id="total-price" required aria-describedby="task-price-hint" min="0" max="${TASK_LIMITS.MAX_PRICE}" step="1" placeholder="100" value="${escapeHtml(editTotalPrice)}" ${isLockedStatus ? 'disabled' : ''}
193
- style="width: 100%; padding: 10px 12px; padding-right: 40px; background: ${isLockedStatus ? 'var(--comfy-input-bg)' : 'var(--comfy-menu-bg)'}; border: 1px solid var(--border-color, #333); border-radius: 8px; color: ${isLockedStatus ? '#666' : '#FF9800'}; font-size: 16px; font-weight: bold; box-sizing: border-box; cursor: ${isLockedStatus ? 'not-allowed' : 'auto'};">
194
- <span style="position: absolute; right: 12px; top: 50%; transform: translateY(-50%); color: #888; font-size: 12px;">${t('common.credits')}</span>
195
  </div>
196
- <div id="task-price-hint" style="font-size: 11px; color: #666; margin-top: 4px;">${isLockedStatus ? t('task.price_locked') : t('task.price_range', { max: TASK_LIMITS.MAX_PRICE })}</div>
197
  </div>
198
 
199
  <!-- 订金比例 -->
200
  <div>
201
- <label for="deposit-ratio" style="display: block; color: #fff; font-size: 13px; font-weight: bold; margin-bottom: 6px;">
202
- 📊 ${t('task.deposit_ratio')} <span aria-hidden="true" style="color: #F44336;">*</span>
203
  </label>
204
- <select id="deposit-ratio" required aria-describedby="task-deposit-hint" ${isLockedStatus ? 'disabled' : ''} style="width: 100%; padding: 10px 12px; background: ${isLockedStatus ? 'var(--comfy-input-bg)' : 'var(--comfy-menu-bg)'}; border: 1px solid var(--border-color, #333); border-radius: 8px; color: ${isLockedStatus ? '#666' : '#fff'}; font-size: 14px; cursor: ${isLockedStatus ? 'not-allowed' : 'pointer'};">
205
  ${TASK_LIMITS.DEPOSIT_RATIOS.map(ratio => `<option value="${ratio}" ${editDepositRatio == ratio ? 'selected' : ''}>${ratio}%</option>`).join('')}
206
  </select>
207
- <div id="task-deposit-hint" style="font-size: 11px; color: #666; margin-top: 4px;">${isLockedStatus ? t('task.deposit_locked') : t('task.deposit_deducted_on_assign')}</div>
208
  </div>
209
  </div>
210
 
211
  <!-- 价格说明 -->
212
- <div id="price-summary" style="padding: 12px; background: rgba(255,152,0,0.1); border: 1px solid rgba(255,152,0,0.3); border-radius: 8px;">
213
- <div style="font-size: 12px; color: #FF9800; margin-bottom: 6px;">💡 ${t('task.price_description')}</div>
214
- <div style="font-size: 13px; color: #ccc; line-height: 1.6;">
215
- ${t('task.total_price')}: <span id="summary-total" style="color: #FF9800; font-weight: bold;">0</span> ${t('common.credits')}<br>
216
- ${t('task.deposit_on_assign')}: <span id="summary-deposit" style="color: #2196F3;">0</span> ${t('common.credits')}<br>
217
  ${t('task.remaining_on_accept')}: <span id="summary-remaining">0</span> ${t('common.credits')}
218
  </div>
219
  </div>
220
 
221
  <!-- 截止日期 -->
222
  <div>
223
- <label for="deadline" style="display: block; color: #fff; font-size: 13px; font-weight: bold; margin-bottom: 6px;">
224
- ⏰ ${t('task.deadline')} <span aria-hidden="true" style="color: #F44336;">*</span>
225
  </label>
226
  <input type="date" id="deadline" required ${isLockedStatus ? 'aria-describedby="task-deadline-hint"' : ''} min="${minDate}" value="${defaultDate}" ${isLockedStatus ? 'disabled' : ''}
227
- style="width: 100%; padding: 10px 12px; background: ${isLockedStatus ? 'var(--comfy-input-bg)' : 'var(--comfy-menu-bg)'}; border: 1px solid var(--border-color, #333); border-radius: 8px; color: ${isLockedStatus ? '#666' : '#fff'}; font-size: 14px; box-sizing: border-box; cursor: ${isLockedStatus ? 'not-allowed' : 'auto'};">
228
- ${isLockedStatus ? `<div id="task-deadline-hint" style="font-size: 11px; color: #666; margin-top: 4px;">${t('task.deadline_locked')}</div>` : ''}
229
  </div>
230
 
231
  <!-- 提交按钮 -->
232
- <button type="button" id="btn-publish" style="margin-top: 10px; padding: 14px; background: linear-gradient(135deg, #FF9800, #F57C00); border: none; color: #fff; border-radius: 10px; cursor: pointer; font-size: 15px; font-weight: bold; box-shadow: 0 4px 12px rgba(255,152,0,0.3);">
233
  🚀 ${isEditMode ? t('task.save_changes') : t('task.publish_task')}
234
  </button>
235
 
236
  <!-- 提示 -->
237
- <div style="text-align: center; font-size: 11px; color: #666; padding: 10px 0;">
238
  ${t('task.manage_in_task_list')}
239
  </div>
240
  </div>
@@ -396,9 +385,9 @@ export function createPublishTaskView(currentUser, editTaskData = null) {
396
 
397
  const renderImagePreview = () => {
398
  imagePreview.innerHTML = selectedImages.map((img, i) => `
399
- <div style="position: relative; padding-top: 100%; background: var(--comfy-menu-bg); border-radius: 8px; overflow: hidden;">
400
- <img alt="${escapeHtml(t('task.reference_images'))} ${i + 1}" src="${escapeHtml(img.dataUrl)}" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover;">
401
- <button type="button" aria-label="${escapeHtml(t('task.remove_reference_image', { index: i + 1 }))}" class="btn-remove-img" data-index="${i}" style="position: absolute; top: 4px; right: 4px; width: 20px; height: 20px; background: rgba(244,67,54,0.9); border: none; border-radius: 50%; color: #fff; font-size: 12px; cursor: pointer; display: flex; align-items: center; justify-content: center;">×</button>
402
  </div>
403
  `).join("");
404
 
 
97
  const isLockedStatus = editTaskData && ["in_progress", "submitted"].includes(editTaskData.status);
98
 
99
  const container = document.createElement("div");
100
+ container.className = "task-publish ranking-publish-form";
 
 
 
 
 
 
 
 
 
 
 
101
 
102
  // 使用本地日历日期,不通过 UTC 转换选择器的日期。
103
  const tomorrow = new Date();
 
116
 
117
  container.innerHTML = `
118
  <!-- 顶部导航 -->
119
+ <div class="task-publish__header">
120
+ <button type="button" id="btn-back" class="task-publish__back">
121
  ⬅ ${t('common.back')}
122
  </button>
123
+ <span class="task-publish__heading">📝 ${isEditMode ? t('task.edit_task') : t('task.publish')}</span>
124
+ <div class="task-publish__header-spacer"></div>
125
  </div>
126
 
127
  <!-- 表单内容 -->
128
+ <div class="task-publish__body">
129
 
130
  <!-- 任务标题 -->
131
  <div>
132
+ <label for="task-title" class="task-publish__label">
133
+ 📌 ${t('task.task_title')} <span aria-hidden="true" class="task-publish__required">*</span>
134
  </label>
135
  <input type="text" id="task-title" required maxlength="50" placeholder="${t('task.title_placeholder')}" value="${escapeHtml(editTitle)}"
136
+ class="task-publish__input">
137
  </div>
138
 
139
  <!-- 任务描述 -->
140
  <div>
141
+ <label for="task-description" class="task-publish__label">
142
+ 📄 ${t('task.description')} <span aria-hidden="true" class="task-publish__required">*</span>
143
  </label>
144
  <textarea id="task-description" required aria-describedby="desc-count" rows="6" maxlength="2000" placeholder="${t('task.description_placeholder')}"
145
+ class="task-publish__textarea">${escapeHtml(editDescription)}</textarea>
146
+ <div class="task-publish__counter">
147
  <span id="desc-count">${editDescription.length}</span>/2000
148
  </div>
149
  </div>
150
 
151
  <!-- 参考图片 -->
152
  <div>
153
+ <label for="ref-images" class="task-publish__label">
154
  🖼️ ${t('task.reference_images_optional')}
155
  </label>
156
  <input type="file" id="ref-images" multiple accept="image/*"
157
  style="display: none;">
158
+ <div id="image-preview" class="task-publish__images"></div>
159
+ <button type="button" id="btn-add-image" class="task-publish__add-image">
160
  + ${t('task.add_reference_image')}
161
  </button>
162
  </div>
163
 
164
  <!-- 参考链接 -->
165
  <div>
166
+ <label for="ref-link" class="task-publish__label">
167
  🔗 ${t('task.reference_link_optional')}
168
  </label>
169
  <input type="url" id="ref-link" placeholder="https://..." value="${escapeHtml(editRefLink)}"
170
+ class="task-publish__input">
171
  </div>
172
 
173
  <!-- 价格设置 -->
174
+ <div class="task-publish__price-grid">
175
  <!-- 总价格 -->
176
  <div>
177
+ <label for="total-price" class="task-publish__label">
178
+ 💰 ${t('task.total_price')} <span aria-hidden="true" class="task-publish__required">*</span>
179
  </label>
180
+ <div class="task-publish__amount-wrap">
181
  <input type="number" id="total-price" required aria-describedby="task-price-hint" min="0" max="${TASK_LIMITS.MAX_PRICE}" step="1" placeholder="100" value="${escapeHtml(editTotalPrice)}" ${isLockedStatus ? 'disabled' : ''}
182
+ class="task-publish__amount">
183
+ <span class="task-publish__credits">${t('common.credits')}</span>
184
  </div>
185
+ <div id="task-price-hint" class="task-publish__hint">${isLockedStatus ? t('task.price_locked') : t('task.price_range', { max: TASK_LIMITS.MAX_PRICE })}</div>
186
  </div>
187
 
188
  <!-- 订金比例 -->
189
  <div>
190
+ <label for="deposit-ratio" class="task-publish__label">
191
+ 📊 ${t('task.deposit_ratio')} <span aria-hidden="true" class="task-publish__required">*</span>
192
  </label>
193
+ <select id="deposit-ratio" required aria-describedby="task-deposit-hint" ${isLockedStatus ? 'disabled' : ''} class="task-publish__select">
194
  ${TASK_LIMITS.DEPOSIT_RATIOS.map(ratio => `<option value="${ratio}" ${editDepositRatio == ratio ? 'selected' : ''}>${ratio}%</option>`).join('')}
195
  </select>
196
+ <div id="task-deposit-hint" class="task-publish__hint">${isLockedStatus ? t('task.deposit_locked') : t('task.deposit_deducted_on_assign')}</div>
197
  </div>
198
  </div>
199
 
200
  <!-- 价格说明 -->
201
+ <div id="price-summary" class="task-publish__summary">
202
+ <div class="task-publish__summary-title">💡 ${t('task.price_description')}</div>
203
+ <div class="task-publish__summary-body">
204
+ ${t('task.total_price')}: <span id="summary-total" class="task-publish__summary-total">0</span> ${t('common.credits')}<br>
205
+ ${t('task.deposit_on_assign')}: <span id="summary-deposit" class="task-publish__summary-deposit">0</span> ${t('common.credits')}<br>
206
  ${t('task.remaining_on_accept')}: <span id="summary-remaining">0</span> ${t('common.credits')}
207
  </div>
208
  </div>
209
 
210
  <!-- 截止日期 -->
211
  <div>
212
+ <label for="deadline" class="task-publish__label">
213
+ ⏰ ${t('task.deadline')} <span aria-hidden="true" class="task-publish__required">*</span>
214
  </label>
215
  <input type="date" id="deadline" required ${isLockedStatus ? 'aria-describedby="task-deadline-hint"' : ''} min="${minDate}" value="${defaultDate}" ${isLockedStatus ? 'disabled' : ''}
216
+ class="task-publish__date">
217
+ ${isLockedStatus ? `<div id="task-deadline-hint" class="task-publish__hint">${t('task.deadline_locked')}</div>` : ''}
218
  </div>
219
 
220
  <!-- 提交按钮 -->
221
+ <button type="button" id="btn-publish" class="task-publish__submit">
222
  🚀 ${isEditMode ? t('task.save_changes') : t('task.publish_task')}
223
  </button>
224
 
225
  <!-- 提示 -->
226
+ <div class="task-publish__footer">
227
  ${t('task.manage_in_task_list')}
228
  </div>
229
  </div>
 
385
 
386
  const renderImagePreview = () => {
387
  imagePreview.innerHTML = selectedImages.map((img, i) => `
388
+ <div class="task-publish__image">
389
+ <img alt="${escapeHtml(t('task.reference_images'))} ${i + 1}" src="${escapeHtml(img.dataUrl)}" class="task-publish__preview">
390
+ <button type="button" aria-label="${escapeHtml(t('task.remove_reference_image', { index: i + 1 }))}" class="btn-remove-img task-publish__remove" data-index="${i}">×</button>
391
  </div>
392
  `).join("");
393