ZHIWEI666 commited on
Commit
e354435
·
verified ·
1 Parent(s): 5810932

前端优化

Browse files
Files changed (46) hide show
  1. 前端页面/components/UI交互提示组件.js +38 -111
  2. 前端页面/components/互动工具函数.js +8 -14
  3. 前端页面/components/全局设置组件.js +1 -1
  4. 前端页面/components/图片展示测试.js +4 -1
  5. 前端页面/components/图片沙盒组件.js +6 -3
  6. 前端页面/components/图片裁剪组件.js +27 -8
  7. 前端页面/components/性能优化工具.js +38 -2
  8. 前端页面/components/打赏等级工具.js +7 -5
  9. 前端页面/components/用户体验_国际化.js +159 -6
  10. 前端页面/components/视频播放器组件.js +14 -14
  11. 前端页面/components/顶部导航组件.js +11 -11
  12. 前端页面/core/侧边栏主程序.js +7 -7
  13. 前端页面/core/侧边栏数据引擎.js +162 -62
  14. 前端页面/core/全局配置.js +51 -9
  15. 前端页面/core/状态管理.js +16 -6
  16. 前端页面/core/网络请求API.js +4 -0
  17. 前端页面/core/网络请求_业务API.js +4 -1
  18. 前端页面/core/网络请求_图片代理.js +2 -1
  19. 前端页面/core/网络请求_基础设施.js +65 -15
  20. 前端页面/core/网络请求_缓存管理.js +69 -18
  21. 前端页面/market/列表卡片组件.js +8 -5
  22. 前端页面/market/创作者卡片组件.js +11 -9
  23. 前端页面/market/发布内容_UI模板.js +3 -2
  24. 前端页面/market/发布内容_提交引擎.js +7 -4
  25. 前端页面/market/发布内容组件.js +2 -1
  26. 前端页面/market/资源安装引擎.js +30 -19
  27. 前端页面/market/资源详情页面组件.js +8 -6
  28. 前端页面/market/资金与钱包_充值组件.js +4 -4
  29. 前端页面/market/资金与钱包_提现组件.js +1 -1
  30. 前端页面/post/发布帖子组件.js +31 -29
  31. 前端页面/post/帖子详情组件.js +8 -9
  32. 前端页面/post/讨论区组件.js +10 -9
  33. 前端页面/profile/个人中心_UI模板.js +4 -2
  34. 前端页面/profile/个人中心视图.js +1 -1
  35. 前端页面/profile/个人列表组件.js +51 -47
  36. 前端页面/profile/个人设置表单组件.js +7 -7
  37. 前端页面/prompt/发布提示词组件.js +8 -10
  38. 前端页面/prompt/提示词组件.js +12 -9
  39. 前端页面/prompt/提示词详情组件.js +7 -6
  40. 前端页面/social/私信聊天组件.js +21 -19
  41. 前端页面/social/评论与互动组件.js +3 -3
  42. 前端页面/social/通知中心组件.js +5 -3
  43. 前端页面/task/任务榜组件.js +9 -8
  44. 前端页面/task/任务详情组件.js +24 -22
  45. 前端页面/task/发布任务组件.js +12 -9
  46. 前端页面/task/申诉详情组件.js +20 -13
前端页面/components/UI交互提示组件.js CHANGED
@@ -9,12 +9,13 @@
9
  // ==========================================
10
  // 🎯 P1体验优化:
11
  // - 统一错误处理与重试机制
12
- // - 网络状态检测与离线提示
13
  // - 按钮加载状态
14
  // - 全局加载遮罩
 
15
  // ===========================================
16
 
17
  import { t } from "./用户体验增强.js";
 
18
 
19
  // ==========================================
20
  // 📢 Toast 消息提示
@@ -72,9 +73,11 @@ export function showToast(message, type = "info", duration = 3000) {
72
  wordBreak: "break-word"
73
  });
74
 
 
 
75
  toast.innerHTML = `
76
  <span style="font-size: 16px;">${icons[type] || icons.info}</span>
77
- <span style="flex: 1;">${message}</span>
78
  <span class="toast-close" style="cursor: pointer; opacity: 0.7; font-size: 18px; margin-left: 8px;">×</span>
79
  `;
80
 
@@ -192,6 +195,7 @@ function _closeDialog(overlay, box, hasYTranslate = true, callback) {
192
  * @param {string} options.confirmText - 确认按钮文字
193
  * @param {string} options.cancelText - 取消按钮文字
194
  * @param {string} options.type - 类型: 'warning', 'danger', 'info'
 
195
  * @returns {Promise<boolean>}
196
  */
197
  export function showConfirm(message, options = {}) {
@@ -199,7 +203,8 @@ export function showConfirm(message, options = {}) {
199
  title = "操作确认",
200
  confirmText = "确认执行",
201
  cancelText = "取消",
202
- type = "warning"
 
203
  } = options;
204
 
205
  const typeConfig = {
@@ -216,12 +221,12 @@ export function showConfirm(message, options = {}) {
216
  box.innerHTML = `
217
  <div style="font-size: 18px; font-weight: bold; color: ${config.color}; display: flex; align-items: center; gap: 10px;">
218
  <span style="font-size: 24px;">${config.icon}</span>
219
- <span>${title}</span>
220
  </div>
221
- <div style="font-size: 14px; color: #ccc; line-height: 1.7; padding: 8px 0;">${message}</div>
222
  <div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 8px;">
223
- <button id="ui-btn-cancel" style="padding: 10px 20px; background: transparent; border: 1px solid #555; color: #aaa; border-radius: 6px; cursor: pointer; transition: 0.2s; font-size: 14px;">${cancelText}</button>
224
- <button id="ui-btn-confirm" style="padding: 10px 20px; background: ${config.btnColor}; border: none; color: #fff; border-radius: 6px; cursor: pointer; font-weight: bold; box-shadow: 0 2px 8px ${config.btnColor}44; transition: 0.2s; font-size: 14px;">${confirmText}</button>
225
  </div>
226
  `;
227
 
@@ -265,13 +270,15 @@ export function showConfirm(message, options = {}) {
265
  * @param {string} message - 错误信息
266
  * @param {Function} retryFn - 重试函数
267
  * @param {Object} options - 配置选项
 
268
  * @returns {Promise<boolean>} - 用户选择重试返回true
269
  */
270
  export function showRetryDialog(message, retryFn, options = {}) {
271
  const {
272
  title = "操作失败",
273
  retryText = "🔄 重试",
274
- cancelText = "关闭"
 
275
  } = options;
276
 
277
  return new Promise((resolve) => {
@@ -291,14 +298,14 @@ export function showRetryDialog(message, retryFn, options = {}) {
291
  box.innerHTML = `
292
  <div style="text-align: center;">
293
  <div style="font-size: 48px; margin-bottom: 16px;">😕</div>
294
- <div style="font-size: 20px; font-weight: bold; color: #F44336;">${title}</div>
295
  </div>
296
  <div style="font-size: 14px; color: #bbb; line-height: 1.7; text-align: center; padding: 0 10px; background: #1a1a1a; border-radius: 8px; padding: 16px;">
297
- ${message}
298
  </div>
299
  <div style="display: flex; gap: 12px; justify-content: center;">
300
- <button id="ui-btn-cancel" style="flex: 1; padding: 12px 20px; background: #333; border: 1px solid #555; color: #aaa; border-radius: 8px; cursor: pointer; transition: 0.2s; font-size: 14px;">${cancelText}</button>
301
- <button id="ui-btn-retry" style="flex: 1; padding: 12px 20px; background: linear-gradient(135deg, #2196F3, #1976D2); border: none; color: #fff; border-radius: 8px; cursor: pointer; font-weight: bold; transition: 0.2s; font-size: 14px;">${retryText}</button>
302
  </div>
303
  `;
304
 
@@ -327,9 +334,15 @@ export function showRetryDialog(message, retryFn, options = {}) {
327
  close();
328
  resolve(true);
329
  } catch (error) {
330
- // 重试失败,更新错误信息
331
- box.querySelector("div[style*='background: #1a1a1a']").innerHTML = error.message || t('common.retry_failed');
332
- retryBtn.innerHTML = retryText;
 
 
 
 
 
 
333
  retryBtn.disabled = false;
334
  }
335
  };
@@ -338,87 +351,15 @@ export function showRetryDialog(message, retryFn, options = {}) {
338
 
339
 
340
  // ==========================================
341
- // 🌐 网络状态检测
342
  // ==========================================
343
-
344
- let isOffline = !navigator.onLine;
345
- let offlineBanner = null;
346
-
347
- /**
348
- * 初始化网络状态监听
349
- */
350
- export function initNetworkStatusListener() {
351
- window.addEventListener("online", () => {
352
- isOffline = false;
353
- hideOfflineBanner();
354
- showToast("网络已恢复", "success");
355
- });
356
-
357
- window.addEventListener("offline", () => {
358
- isOffline = true;
359
- showOfflineBanner();
360
- });
361
-
362
- // 初始状态检测
363
- if (!navigator.onLine) {
364
- showOfflineBanner();
365
- }
366
- }
367
-
368
- function showOfflineBanner() {
369
- if (offlineBanner) return;
370
-
371
- offlineBanner = document.createElement("div");
372
- Object.assign(offlineBanner.style, {
373
- position: "fixed",
374
- bottom: "0",
375
- left: "0",
376
- right: "0",
377
- background: "linear-gradient(135deg, #FF5722, #E64A19)",
378
- color: "#fff",
379
- padding: "12px 20px",
380
- display: "flex",
381
- alignItems: "center",
382
- justifyContent: "center",
383
- gap: "12px",
384
- fontSize: "14px",
385
- fontWeight: "500",
386
- zIndex: "9999",
387
- boxShadow: "0 -4px 20px rgba(0,0,0,0.3)",
388
- transform: "translateY(100%)",
389
- transition: "transform 0.3s ease"
390
- });
391
-
392
- offlineBanner.innerHTML = `
393
- <span style="font-size: 18px;">📡</span>
394
- <span>网络连接已断开,部分功能可能不可用</span>
395
- <button onclick="location.reload()" style="background: rgba(255,255,255,0.2); border: none; color: white; padding: 6px 14px; border-radius: 4px; cursor: pointer; font-size: 12px;">刷新重试</button>
396
- `;
397
-
398
- document.body.appendChild(offlineBanner);
399
-
400
- requestAnimationFrame(() => {
401
- offlineBanner.style.transform = "translateY(0)";
402
- });
403
- }
404
-
405
- function hideOfflineBanner() {
406
- if (!offlineBanner) return;
407
-
408
- offlineBanner.style.transform = "translateY(100%)";
409
- setTimeout(() => {
410
- offlineBanner?.remove();
411
- offlineBanner = null;
412
- }, 300);
413
- }
414
-
415
- /**
416
- * 检查网络状态
417
- * @returns {boolean} - 是否在线
418
- */
419
- export function checkOnline() {
420
- return navigator.onLine;
421
- }
422
 
423
 
424
  // ==========================================
@@ -463,7 +404,7 @@ export function showLoading(message = "加载中...") {
463
 
464
  loadingOverlay.innerHTML = `
465
  <div class="loading-spinner" style="width: 48px; height: 48px; border: 4px solid var(--border-color, #333); border-top-color: #2196F3; border-radius: 50%; animation: spin 0.8s linear infinite;"></div>
466
- <div class="loading-text" style="color: #fff; font-size: 14px; font-weight: 500;">${message}</div>
467
  `;
468
 
469
  // 添加旋转动画样式
@@ -531,7 +472,7 @@ export function setButtonLoading(button, loading, loadingText = "处理中...")
531
  button.disabled = true;
532
  button.style.opacity = "0.7";
533
  button.style.cursor = "not-allowed";
534
- button.innerHTML = `<span class="btn-spinner"></span>${loadingText}`;
535
  } else {
536
  // 恢复原始状态
537
  button.disabled = button._originalDisabled || false;
@@ -624,20 +565,6 @@ export async function withFeedback(asyncFn, options = {}) {
624
  }
625
 
626
 
627
- // ==========================================
628
- // 🚀 自动初始化
629
- // ==========================================
630
-
631
- // 页面加载时初始化网络状态监听
632
- if (typeof window !== "undefined") {
633
- if (document.readyState === "loading") {
634
- document.addEventListener("DOMContentLoaded", initNetworkStatusListener);
635
- } else {
636
- initNetworkStatusListener();
637
- }
638
- }
639
-
640
-
641
  // ==========================================
642
  // 📦 安装进度条
643
  // ==========================================
 
9
  // ==========================================
10
  // 🎯 P1体验优化:
11
  // - 统一错误处理与重试机制
 
12
  // - 按钮加载状态
13
  // - 全局加载遮罩
14
+ // 🌐 网络状态检测与离线横幅已收敛至 core/状态管理.js(见下方说明),本模块不再负责
15
  // ===========================================
16
 
17
  import { t } from "./用户体验增强.js";
18
+ import { escapeHtml } from "../core/全局配置.js";
19
 
20
  // ==========================================
21
  // 📢 Toast 消息提示
 
73
  wordBreak: "break-word"
74
  });
75
 
76
+ // 提示文本统一转义:message 常携带昵称/标题/后端报错等用户可控内容,
77
+ // 全库无任何调用方故意向 showToast 传 HTML 标签,故无条件转义不会改变现有显示效果。
78
  toast.innerHTML = `
79
  <span style="font-size: 16px;">${icons[type] || icons.info}</span>
80
+ <span style="flex: 1;">${escapeHtml(message)}</span>
81
  <span class="toast-close" style="cursor: pointer; opacity: 0.7; font-size: 18px; margin-left: 8px;">×</span>
82
  `;
83
 
 
195
  * @param {string} options.confirmText - 确认按钮文字
196
  * @param {string} options.cancelText - 取消按钮文字
197
  * @param {string} options.type - 类型: 'warning', 'danger', 'info'
198
+ * @param {boolean} options.html - 是否把 message 当作可信 HTML 渲染(默认 false,即按纯文本转义)
199
  * @returns {Promise<boolean>}
200
  */
201
  export function showConfirm(message, options = {}) {
 
203
  title = "操作确认",
204
  confirmText = "确认执行",
205
  cancelText = "取消",
206
+ type = "warning",
207
+ html = false
208
  } = options;
209
 
210
  const typeConfig = {
 
221
  box.innerHTML = `
222
  <div style="font-size: 18px; font-weight: bold; color: ${config.color}; display: flex; align-items: center; gap: 10px;">
223
  <span style="font-size: 24px;">${config.icon}</span>
224
+ <span>${escapeHtml(title)}</span>
225
  </div>
226
+ <div style="font-size: 14px; color: #ccc; line-height: 1.7; padding: 8px 0;">${html ? message : escapeHtml(message)}</div>
227
  <div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 8px;">
228
+ <button id="ui-btn-cancel" style="padding: 10px 20px; background: transparent; border: 1px solid #555; color: #aaa; border-radius: 6px; cursor: pointer; transition: 0.2s; font-size: 14px;">${escapeHtml(cancelText)}</button>
229
+ <button id="ui-btn-confirm" style="padding: 10px 20px; background: ${config.btnColor}; border: none; color: #fff; border-radius: 6px; cursor: pointer; font-weight: bold; box-shadow: 0 2px 8px ${config.btnColor}44; transition: 0.2s; font-size: 14px;">${escapeHtml(confirmText)}</button>
230
  </div>
231
  `;
232
 
 
270
  * @param {string} message - 错误信息
271
  * @param {Function} retryFn - 重试函数
272
  * @param {Object} options - 配置选项
273
+ * @param {boolean} options.html - 是否把 message 当作可信 HTML 渲染(默认 false,即按纯文本转义)
274
  * @returns {Promise<boolean>} - 用户选择重试返回true
275
  */
276
  export function showRetryDialog(message, retryFn, options = {}) {
277
  const {
278
  title = "操作失败",
279
  retryText = "🔄 重试",
280
+ cancelText = "关闭",
281
+ html = false
282
  } = options;
283
 
284
  return new Promise((resolve) => {
 
298
  box.innerHTML = `
299
  <div style="text-align: center;">
300
  <div style="font-size: 48px; margin-bottom: 16px;">😕</div>
301
+ <div style="font-size: 20px; font-weight: bold; color: #F44336;">${escapeHtml(title)}</div>
302
  </div>
303
  <div style="font-size: 14px; color: #bbb; line-height: 1.7; text-align: center; padding: 0 10px; background: #1a1a1a; border-radius: 8px; padding: 16px;">
304
+ ${html ? message : escapeHtml(message)}
305
  </div>
306
  <div style="display: flex; gap: 12px; justify-content: center;">
307
+ <button id="ui-btn-cancel" style="flex: 1; padding: 12px 20px; background: #333; border: 1px solid #555; color: #aaa; border-radius: 8px; cursor: pointer; transition: 0.2s; font-size: 14px;">${escapeHtml(cancelText)}</button>
308
+ <button id="ui-btn-retry" style="flex: 1; padding: 12px 20px; background: linear-gradient(135deg, #2196F3, #1976D2); border: none; color: #fff; border-radius: 8px; cursor: pointer; font-weight: bold; transition: 0.2s; font-size: 14px;">${escapeHtml(retryText)}</button>
309
  </div>
310
  `;
311
 
 
334
  close();
335
  resolve(true);
336
  } catch (error) {
337
+ // 重试失败,更新错误信息(与初次渲染保持一致:默认纯文本,仅 html=true 时按 HTML 渲染)
338
+ const errText = error.message || t('common.retry_failed');
339
+ const errBox = box.querySelector("div[style*='background: #1a1a1a']");
340
+ if (html) {
341
+ errBox.innerHTML = errText;
342
+ } else {
343
+ errBox.textContent = errText;
344
+ }
345
+ retryBtn.innerHTML = escapeHtml(retryText);
346
  retryBtn.disabled = false;
347
  }
348
  };
 
351
 
352
 
353
  // ==========================================
354
+ // 🌐 网络状态检测(已移除)
355
  // ==========================================
356
+ // 本模块曾自行维护一份 isOffline 状态并常驻显示底部「网���连接已断开」横幅,
357
+ // 与 状态管理.js 的网络状态重复,且一条 online/offline 事件需喂多份状态。
358
+ // 现统一收敛:
359
+ // - 网络状态唯一数据源:core/状态管理.js 的 isOnline()
360
+ // - 离线反馈改由列表层承担:缓存命中渲染后弹 xxx.network_cache toast
361
+ // (「网络异常,已加载本地缓存」),比常驻横幅更贴合实际可用状态
362
+ // 原导出的 initNetworkStatusListener / checkOnline 全库无调用方,一并移除。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
 
364
 
365
  // ==========================================
 
404
 
405
  loadingOverlay.innerHTML = `
406
  <div class="loading-spinner" style="width: 48px; height: 48px; border: 4px solid var(--border-color, #333); border-top-color: #2196F3; border-radius: 50%; animation: spin 0.8s linear infinite;"></div>
407
+ <div class="loading-text" style="color: #fff; font-size: 14px; font-weight: 500;">${escapeHtml(message)}</div>
408
  `;
409
 
410
  // 添加旋转动画样式
 
472
  button.disabled = true;
473
  button.style.opacity = "0.7";
474
  button.style.cursor = "not-allowed";
475
+ button.innerHTML = `<span class="btn-spinner"></span>${escapeHtml(loadingText)}`;
476
  } else {
477
  // 恢复原始状态
478
  button.disabled = button._originalDisabled || false;
 
565
  }
566
 
567
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
568
  // ==========================================
569
  // 📦 安装进度条
570
  // ==========================================
前端页面/components/互动工具函数.js CHANGED
@@ -8,6 +8,8 @@
8
 
9
  import { showToast } from "./UI交互提示组件.js";
10
  import { t } from "./用户体验增强.js";
 
 
11
 
12
  // 开发模式检测,仅在本地环境输出调试日志
13
  const DEBUG = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
@@ -103,7 +105,7 @@ export function renderInteractionButtonsHTML(data, currentAccount, options = {})
103
 
104
  const tipBtn = showTip ? `
105
  <button id="btn-tip" style="background: #333; 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;">
106
- 💰 ${tipText}
107
  </button>
108
  ` : '';
109
 
@@ -279,20 +281,12 @@ export { renderTipBoardHTML } from './打赏等级工具.js';
279
  // ==========================================
280
  // 🔒 HTML转义(通用工具)
281
  // ==========================================
 
 
 
 
282
 
283
- /**
284
- * HTML转义(统一版:各组件局部副本已归一到此)
285
- * @param {*} str - 原始字符串(非字符串会先强制转换,兼容数字等入参)
286
- * @returns {string} 转义后的字符串
287
- */
288
- export function escapeHtml(str) {
289
- if (str === null || str === undefined || str === "") return "";
290
- return String(str).replace(/&/g, "&amp;")
291
- .replace(/</g, "&lt;")
292
- .replace(/>/g, "&gt;")
293
- .replace(/"/g, "&quot;")
294
- .replace(/'/g, "&#039;");
295
- }
296
 
297
  /**
298
  * 读取用户设置中的列表缓存 TTL(统一版:讨论区/任务榜/提示词组件局部副本已归一到此)
 
8
 
9
  import { showToast } from "./UI交互提示组件.js";
10
  import { t } from "./用户体验增强.js";
11
+ // 本模块内部也需要转义(下方 renderInteractionButtonsHTML);re-export 不会绑定本地标识符,故单独 import
12
+ import { escapeHtml } from "../core/全局配置.js";
13
 
14
  // 开发模式检测,仅在本地环境输出调试日志
15
  const DEBUG = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
 
105
 
106
  const tipBtn = showTip ? `
107
  <button id="btn-tip" style="background: #333; 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;">
108
+ 💰 ${escapeHtml(tipText)}
109
  </button>
110
  ` : '';
111
 
 
281
  // ==========================================
282
  // 🔒 HTML转义(通用工具)
283
  // ==========================================
284
+ // 实现已下沉至 core/全局配置.js(零依赖的底层叶子模块),此处以 re-export 方式
285
+ // 对外暴露同名函数,既有调用方(个人列表组件.js 等)无需改动。
286
+ // 下沉原因:UI交互提示组件.js / 打赏等级工具.js 需要转义,而本文件反向依赖
287
+ // 这两者(导入 showToast、re-export renderTipBoardHTML),若仍在此定义会形成循环依赖。
288
 
289
+ export { escapeHtml } from "../core/全局配置.js";
 
 
 
 
 
 
 
 
 
 
 
 
290
 
291
  /**
292
  * 读取用户设置中的列表缓存 TTL(统一版:讨论区/任务榜/提示词组件局部副本已归一到此)
前端页面/components/全局设置组件.js CHANGED
@@ -430,7 +430,7 @@ export function createSettingsView() {
430
  setLanguage(newLang);
431
 
432
  const langName = LANGUAGE_OPTIONS.find(opt => opt.value === newLang)?.label || newLang;
433
- showToast(`🌐 ${t('settings.language_changed') || '语言已切换为'} ${langName.split(' / ')[0]}`, "success");
434
 
435
  // 🔄 通过路由系统刷新,保持 activeInlineView 同步
436
  setTimeout(() => {
 
430
  setLanguage(newLang);
431
 
432
  const langName = LANGUAGE_OPTIONS.find(opt => opt.value === newLang)?.label || newLang;
433
+ showToast(`🌐 ${t('settings.language_changed')} ${langName.split(' / ')[0]}`, "success");
434
 
435
  // 🔄 通过路由系统刷新,保持 activeInlineView 同步
436
  setTimeout(() => {
前端页面/components/图片展示测试.js CHANGED
@@ -6,6 +6,8 @@
6
  // 接口:openImageViewer(imageUrls, startIndex, options)
7
  // ==========================================
8
 
 
 
9
  // ==========================================
10
  // 🎨 CSS样式定义(CSS-in-JS模式)
11
  // ==========================================
@@ -785,7 +787,8 @@ class CyberImageViewer {
785
  for (let i = 0; i < maxThumbs; i++) {
786
  const thumb = document.createElement('div');
787
  thumb.className = 'cyber-thumb' + (i === this.currentIndex ? ' active' : '');
788
- thumb.innerHTML = `<img src="${this.imageUrls[i]}" alt="">`;
 
789
  thumb.addEventListener('click', (e) => {
790
  this._stopAutoPlay(); // 手动切换时停止自动播放
791
  this.goTo(i, e.clientX, e.clientY);
 
6
  // 接口:openImageViewer(imageUrls, startIndex, options)
7
  // ==========================================
8
 
9
+ import { escapeHtml } from "../core/全局配置.js";
10
+
11
  // ==========================================
12
  // 🎨 CSS样式定义(CSS-in-JS模式)
13
  // ==========================================
 
787
  for (let i = 0; i < maxThumbs; i++) {
788
  const thumb = document.createElement('div');
789
  thumb.className = 'cyber-thumb' + (i === this.currentIndex ? ' active' : '');
790
+ // 🔒 XSS防护:图片 URL 来自其他用户发布的内容,写入 innerHTML 前必须转义
791
+ thumb.innerHTML = `<img src="${escapeHtml(this.imageUrls[i])}" alt="">`;
792
  thumb.addEventListener('click', (e) => {
793
  this._stopAutoPlay(); // 手动切换时停止自动播放
794
  this.goTo(i, e.clientX, e.clientY);
前端页面/components/图片沙盒组件.js CHANGED
@@ -12,6 +12,7 @@
12
  // ==========================================
13
 
14
  import { t } from "./用户体验增强.js";
 
15
  import { openImageViewer } from "./图片展示测试.js";
16
 
17
  // 🔧 通用SVG占位图生成函数
@@ -77,8 +78,10 @@ export function getCoverSandboxHTML(imageSource, lazyLoad = true) {
77
  const isLocalProxy = firstUrl && firstUrl.startsWith('/community_hub/image?url=');
78
  const shouldLazy = lazyLoad && !isLocalProxy;
79
 
 
 
80
  const imgSrc = shouldLazy ? getPlaceholderSVG() : firstUrl;
81
- const dataAttr = shouldLazy ? `data-src="${firstUrl}"` : '';
82
  const lazyClass = shouldLazy ? 'lazy-image lazy-loading' : '';
83
 
84
  // 🖼️ 多图模式:显示左右切换按钮和计数器
@@ -101,8 +104,8 @@ export function getCoverSandboxHTML(imageSource, lazyLoad = true) {
101
 
102
  return `
103
  <div style="font-size: 12px; font-weight: bold; margin-bottom: 6px; color: #aaa;">🖼️ ${t('image.gallery')}${hasMultiple ? ` (${imageUrls.length})` : ''}</div>
104
- <div class="img-viewport" data-images='${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;">
105
- <img class="target-img ${lazyClass}" src="${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}">
106
  ${navButtons}
107
  <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">
108
  <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>
 
12
  // ==========================================
13
 
14
  import { t } from "./用户体验增强.js";
15
+ import { escapeHtml } from "../core/全局配置.js";
16
  import { openImageViewer } from "./图片展示测试.js";
17
 
18
  // 🔧 通用SVG占位图生成函数
 
78
  const isLocalProxy = firstUrl && firstUrl.startsWith('/community_hub/image?url=');
79
  const shouldLazy = lazyLoad && !isLocalProxy;
80
 
81
+ // 🔒 XSS防护:图片 URL 来自其他用户发布的内容,写入 HTML 属性前必须转义
82
+ // (上方 startsWith 代理判定用的是原始值,不受影响;浏览器解码后 dataset 仍为原 URL)
83
  const imgSrc = shouldLazy ? getPlaceholderSVG() : firstUrl;
84
+ const dataAttr = shouldLazy ? `data-src="${escapeHtml(firstUrl)}"` : '';
85
  const lazyClass = shouldLazy ? 'lazy-image lazy-loading' : '';
86
 
87
  // 🖼️ 多图模式:显示左右切换按钮和计数器
 
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>
前端页面/components/图片裁剪组件.js CHANGED
@@ -9,6 +9,10 @@
9
  // 🔧 P3优化:事件监听器生命周期管理,防止内存泄漏
10
  // ==========================================
11
 
 
 
 
 
12
  // 辅助函数:将 value 限制在 [min, max] 范围内
13
  function _clamp(value, min, max) {
14
  return Math.max(min, Math.min(max, value));
@@ -59,11 +63,11 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
59
  let ratioText = '16:9';
60
  if (aspectRatio === 1) ratioText = '1:1';
61
  else if (aspectRatio < 1) ratioText = '9:16';
62
- // 📱 触控设备与鼠标设备的操作提示区分(移动端无滚轮/拖动概念)
63
- const gestureHint = _isTouchDevice() ? '单指拖动调整位置 | 双指捏合或滑块缩放' : '拖动调整位置 | 滚轮缩放';
64
  header.innerHTML = `
65
- <span style="font-size: 16px; font-weight: bold; color: #fff;">✂️ ${title}</span>
66
- <span style="font-size: 12px; color: #888;">比例 ${ratioText} | ${gestureHint}</span>
67
  `;
68
 
69
  // 裁剪区域容器
@@ -203,6 +207,9 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
203
  let pinchCenterY = 0;
204
  let pinchStartOffsetX = 0;
205
  let pinchStartOffsetY = 0;
 
 
 
206
 
207
  // 获取缩放边界(与滚轮缩放共用同一规则)
208
  function _getScaleLimits() {
@@ -234,9 +241,14 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
234
  isDragging = false;
235
  pinchStartDist = _getTouchDistance(e.touches);
236
  pinchStartScale = scale;
 
 
 
 
 
237
  const mid = _getTouchMidPoint(e.touches);
238
- pinchCenterX = mid.x;
239
- pinchCenterY = mid.y;
240
  pinchStartOffsetX = offsetX;
241
  pinchStartOffsetY = offsetY;
242
  }
@@ -255,10 +267,17 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
255
  const newScale = _clamp(pinchStartScale * (_getTouchDistance(e.touches) / pinchStartDist), minScale, maxScale);
256
  const ratio = newScale / pinchStartScale;
257
  const mid = _getTouchMidPoint(e.touches);
 
 
 
 
 
258
  const centerX = containerWidth / 2;
259
  const centerY = containerHeight / 2;
260
- offsetX = (mid.x - pinchCenterX) + (pinchStartOffsetX - (centerX - pinchCenterX)) * ratio + (centerX - pinchCenterX);
261
- offsetY = (mid.y - pinchCenterY) + (pinchStartOffsetY - (centerY - pinchCenterY)) * ratio + (centerY - pinchCenterY);
 
 
262
  scale = newScale;
263
  updateImagePosition();
264
  }
 
9
  // 🔧 P3优化:事件监听器生命周期管理,防止内存泄漏
10
  // ==========================================
11
 
12
+ import { escapeHtml } from "../core/全局配置.js";
13
+ // 🔧 i18n:头部操作提示走词典(用户体验_国际化.js 是零依赖叶子模块,引入不成环)
14
+ import { t } from "./用户体验_国际化.js";
15
+
16
  // 辅助函数:将 value 限制在 [min, max] 范围内
17
  function _clamp(value, min, max) {
18
  return Math.max(min, Math.min(max, value));
 
63
  let ratioText = '16:9';
64
  if (aspectRatio === 1) ratioText = '1:1';
65
  else if (aspectRatio < 1) ratioText = '9:16';
66
+ // 📱 触控设备与鼠标设备的操作提示区分(移动端无滚轮/拖动概念);文案走词典,随界面语言切换
67
+ const gestureHint = _isTouchDevice() ? t('crop.gesture_hint_touch') : t('crop.gesture_hint_mouse');
68
  header.innerHTML = `
69
+ <span style="font-size: 16px; font-weight: bold; color: #fff;">✂️ ${escapeHtml(title)}</span>
70
+ <span style="font-size: 12px; color: #888;">${escapeHtml(t('crop.ratio_label'))} ${ratioText} | ${escapeHtml(gestureHint)}</span>
71
  `;
72
 
73
  // 裁剪区域容器
 
207
  let pinchCenterY = 0;
208
  let pinchStartOffsetX = 0;
209
  let pinchStartOffsetY = 0;
210
+ // 🔧 手势起始时容器的视口原点(含 border),用于把 clientX/Y 换算成与 img.style.left 同源的容器内坐标
211
+ let pinchOriginX = 0;
212
+ let pinchOriginY = 0;
213
 
214
  // 获取缩放边界(与滚轮缩放共用同一规则)
215
  function _getScaleLimits() {
 
241
  isDragging = false;
242
  pinchStartDist = _getTouchDistance(e.touches);
243
  pinchStartScale = scale;
244
+ // 🔧 clientX 是视口坐标,offsetX/containerWidth 是容器内坐标,两者必须同原点才能参与锚点计算;
245
+ // clientLeft/clientTop 为 2px 边框宽度(img 的 absolute 定位原点在 padding box 左上角)
246
+ const rect = cropContainer.getBoundingClientRect();
247
+ pinchOriginX = rect.left + cropContainer.clientLeft;
248
+ pinchOriginY = rect.top + cropContainer.clientTop;
249
  const mid = _getTouchMidPoint(e.touches);
250
+ pinchCenterX = mid.x - pinchOriginX;
251
+ pinchCenterY = mid.y - pinchOriginY;
252
  pinchStartOffsetX = offsetX;
253
  pinchStartOffsetY = offsetY;
254
  }
 
267
  const newScale = _clamp(pinchStartScale * (_getTouchDistance(e.touches) / pinchStartDist), minScale, maxScale);
268
  const ratio = newScale / pinchStartScale;
269
  const mid = _getTouchMidPoint(e.touches);
270
+ // 换算到容器内坐标(与 handleTouchStart 同原点,一次捏合手势期间弹窗不移动)
271
+ const relX = mid.x - pinchOriginX;
272
+ const relY = mid.y - pinchOriginY;
273
+ // 图片 left = (W - s·imgW)/2 + o,故锚点不动的条件解出:
274
+ // o1 = (p1 - p0) + ratio·(o0 - d) + d,其中 d = p0 - W/2 为起始手指相对容器中心的偏移
275
  const centerX = containerWidth / 2;
276
  const centerY = containerHeight / 2;
277
+ const anchorX = pinchCenterX - centerX;
278
+ const anchorY = pinchCenterY - centerY;
279
+ offsetX = (relX - pinchCenterX) + (pinchStartOffsetX - anchorX) * ratio + anchorX;
280
+ offsetY = (relY - pinchCenterY) + (pinchStartOffsetY - anchorY) * ratio + anchorY;
281
  scale = newScale;
282
  updateImagePosition();
283
  }
前端页面/components/性能优化工具.js CHANGED
@@ -5,8 +5,10 @@
5
  // 作用:提供分页加载、图片懒加载、缓存管理等性能优化工具
6
  // 关联文件:
7
  // - 全局配置.js (配置中心)
 
8
  // - 列表卡片组件.js (使用分页加载)
9
  // - 创作者卡片组件.js (使用分页加载)
 
10
  // - 所有图片渲染场景 (使用懒加载)
11
  // ==========================================
12
  // 🏗️ P2架构优化:使用配置中心常量
@@ -14,6 +16,9 @@
14
  // ==========================================
15
 
16
  import { PAGINATION } from "../core/全局配置.js";
 
 
 
17
 
18
  // ==========================================
19
  // 🔒 P0安全优化:敏感数据脱敏工具
@@ -195,7 +200,7 @@ export function maskAmount(amount, hide = false) {
195
  // ==========================================
196
  // 缓存实现已统一迁移到 ../core/网络请求_缓存管理.js
197
  // 此处保持向后兼容重导出,调用方无需修改 import 路径
198
- export { setCache, getCache, getCacheWithMeta, removeCache, clearAllCache, CACHE_TTL, getRecommendedTTL, setSmartCache } from '../core/网络请求_缓存管理.js';
199
 
200
 
201
  // ==========================================
@@ -1236,9 +1241,40 @@ const eventManager = {
1236
  export { eventManager };
1237
 
1238
  // ==========================================
1239
- // 🔍 列表缓存查找工具(详情页离线回退用)
1240
  // ==========================================
1241
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1242
  /**
1243
  * 🔍 从列表缓存中查找指定项(详情页离线回退用)
1244
  * @param {string} prefix - 缓存Key前缀(如 "TasksCache_"、"PostsCache_")
 
5
  // 作用:提供分页加载、图片懒加载、缓存管理等性能优化工具
6
  // 关联文件:
7
  // - 全局配置.js (配置中心)
8
+ // - 网络请求_缓存管理.js (列表缓存工具 readListCache/writeListCache 的底层实现)
9
  // - 列表卡片组件.js (使用分页加载)
10
  // - 创作者卡片组件.js (使用分页加载)
11
+ // - 任务榜组件.js / 讨论区组件.js / 提示词组件.js (使用列表缓存工具)
12
  // - 所有图片渲染场景 (使用懒加载)
13
  // ==========================================
14
  // 🏗️ P2架构优化:使用配置中心常量
 
16
  // ==========================================
17
 
18
  import { PAGINATION } from "../core/全局配置.js";
19
+ // 📦 列表缓存工具需要本地可用的缓存实现;下方「向后兼容重导出」那行是纯转发、
20
+ // 不会生成本地绑定,故此处必须单独 import(勿误删,否则 readListCache 运行时报错)
21
+ import { setCache, getCacheWithMeta } from "../core/网络请求_缓存管理.js";
22
 
23
  // ==========================================
24
  // 🔒 P0安全优化:敏感数据脱敏工具
 
200
  // ==========================================
201
  // 缓存实现已统一迁移到 ../core/网络请求_缓存管理.js
202
  // 此处保持向后兼容重导出,调用方无需修改 import 路径
203
+ export { setCache, getCache, getCacheWithMeta, removeCache, removeCacheByPrefix, clearAllCache, CACHE_TTL, getRecommendedTTL, setSmartCache } from '../core/网络请求_缓存管理.js';
204
 
205
 
206
  // ==========================================
 
1241
  export { eventManager };
1242
 
1243
  // ==========================================
1244
+ // 🔍 列表缓存工具(列表视图缓存优先 / 详情页离线回退用)
1245
  // ==========================================
1246
 
1247
+ /**
1248
+ * 📴 读取列表首屏缓存(离线容灾口径,任务榜/讨论区/提示词统一走这里)
1249
+ *
1250
+ * 口径说明(原三处各自实现 getCacheWithMeta(key, true) + found && length>0,
1251
+ * 抄写三份容易在维护中走偏,故归一到本函数):
1252
+ * - 忽略过期:第二个参数传 true,缓存过期也算命中——断网/云端不通时首屏仍要能出内容,
1253
+ * 新鲜度交由调用方的 silentRefresh 后台补齐,不在此处拦
1254
+ * - 空列表不算命中:云端确实返回过 0 条时同样落到网络分支重新拉取,
1255
+ * 避免用户新建第一条内容后被旧的空缓存挡住
1256
+ * - 返回值是写入时的原始数据,调用方自行 proxyImages(与改造前完全一致)
1257
+ *
1258
+ * @param {string} cacheKey - 完整缓存 Key(含各自的筛选参数)
1259
+ * @returns {Array|null} 命中返回数组,未命中/空列表返回 null
1260
+ */
1261
+ export function readListCache(cacheKey) {
1262
+ const { value } = getCacheWithMeta(cacheKey, true); // true = 忽略过期
1263
+ if (!Array.isArray(value) || value.length === 0) return null;
1264
+ return value;
1265
+ }
1266
+
1267
+ /**
1268
+ * 💾 回写列表首屏缓存(持久化到 localStorage,保证重启后离线仍可展示)
1269
+ * @param {string} cacheKey - 完整缓存 Key
1270
+ * @param {Array} items - 原始数据(未经图片代理)
1271
+ * @param {number} ttl - 有效期(毫秒),由调用方传 getCacheTTL()
1272
+ */
1273
+ export function writeListCache(cacheKey, items, ttl) {
1274
+ if (!Array.isArray(items)) return;
1275
+ setCache(cacheKey, items, ttl, true); // true = 持久化 localStorage
1276
+ }
1277
+
1278
  /**
1279
  * 🔍 从列表缓存中查找指定项(详情页离线回退用)
1280
  * @param {string} prefix - 缓存Key前缀(如 "TasksCache_"、"PostsCache_")
前端页面/components/打赏等级工具.js CHANGED
@@ -11,7 +11,7 @@
11
  // ===========================================
12
 
13
  import { t } from "./用户体验增强.js";
14
- import { getCachedProfile, getProfileWithSWR, PLACEHOLDERS } from "../core/全局配置.js";
15
  import { api } from "../core/网络请求_业务API.js";
16
 
17
  /**
@@ -218,13 +218,15 @@ export function renderTipBoardItemHTML(tipData, rank, size = 'normal') {
218
  });
219
  }, 0);
220
 
 
 
221
  return `
222
- <div id="${containerId}" style="display:flex; justify-content:space-between; padding:${cfg.padding}px 0; font-size:${cfg.font}px; align-items:center; border-bottom:1px dashed #333;">
223
  <span style="display:flex; align-items:center; gap:6px;">
224
  ${badgeHtml}
225
  <span style="display:flex; align-items:center; gap:6px;">
226
- <img class="tip-board-avatar" src="${avatarUrl}" style="width:${cfg.avatar}px; height:${cfg.avatar}px; border-radius:50%; object-fit:cover; flex-shrink:0; background:#333;">
227
- <span class="tip-board-name" style="color:#ddd;">${userName}</span>
228
  </span>
229
  </span>
230
  <span style="display:flex; align-items:center; gap:6px;">
@@ -245,7 +247,7 @@ export function renderTipBoardItemHTML(tipData, rank, size = 'normal') {
245
  */
246
  export function renderTipBoardHTML(boardData, maxShow = 5, emptyText = "暂无打赏,快来成为首个赞赏人吧!", size = 'normal') {
247
  if (!boardData || boardData.length === 0) {
248
- return `<div style="color:#666; font-size:12px; text-align:center; padding:12px 0;">${emptyText}</div>`;
249
  }
250
 
251
  return boardData.slice(0, maxShow).map((t, i) => renderTipBoardItemHTML(t, i, size)).join('');
 
11
  // ===========================================
12
 
13
  import { t } from "./用户体验增强.js";
14
+ import { getCachedProfile, getProfileWithSWR, PLACEHOLDERS, escapeHtml } from "../core/全局配置.js";
15
  import { api } from "../core/网络请求_业务API.js";
16
 
17
  /**
 
218
  });
219
  }, 0);
220
 
221
+ // 🔒 XSS防护:昵称/头像来自用户可修改的资料,写入 HTML 前必须转义
222
+ // (上方 SWR 刷新路径用的是 .src / .textContent,本身就是安全的)
223
  return `
224
+ <div id="${escapeHtml(containerId)}" style="display:flex; justify-content:space-between; padding:${cfg.padding}px 0; font-size:${cfg.font}px; align-items:center; border-bottom:1px dashed #333;">
225
  <span style="display:flex; align-items:center; gap:6px;">
226
  ${badgeHtml}
227
  <span style="display:flex; align-items:center; gap:6px;">
228
+ <img class="tip-board-avatar" src="${escapeHtml(avatarUrl)}" style="width:${cfg.avatar}px; height:${cfg.avatar}px; border-radius:50%; object-fit:cover; flex-shrink:0; background:#333;">
229
+ <span class="tip-board-name" style="color:#ddd;">${escapeHtml(userName)}</span>
230
  </span>
231
  </span>
232
  <span style="display:flex; align-items:center; gap:6px;">
 
247
  */
248
  export function renderTipBoardHTML(boardData, maxShow = 5, emptyText = "暂无打赏,快来成为首个赞赏人吧!", size = 'normal') {
249
  if (!boardData || boardData.length === 0) {
250
+ return `<div style="color:#666; font-size:12px; text-align:center; padding:12px 0;">${escapeHtml(emptyText)}</div>`;
251
  }
252
 
253
  return boardData.slice(0, maxShow).map((t, i) => renderTipBoardItemHTML(t, i, size)).join('');
前端页面/components/用户体验_国际化.js CHANGED
@@ -31,6 +31,9 @@ const translations = {
31
  'common.sending': '发送中...',
32
  'common.retrying': '重试中...',
33
  'common.retry_failed': '重试失败,请稍后再试',
 
 
 
34
  'common.more': '更多',
35
  'common.back': '返回',
36
  'common.search': '搜索',
@@ -308,6 +311,7 @@ const translations = {
308
  'wallet.recharge.custom_placeholder': '请输入自定义金额 (1~10000)',
309
  'wallet.recharge.max_amount_exceeded': '单次充值金额不得超过10000元',
310
  'wallet.recharge.processing': '处理中...',
 
311
 
312
  // 提现相关
313
  'wallet.withdraw.title': '💳 收益提现中心',
@@ -326,6 +330,7 @@ const translations = {
326
  'wallet.withdraw.resend_in': '{seconds}s 后重发',
327
  'wallet.withdraw.confirm': '确认提现 (金额将转入冻结审核)',
328
  'wallet.withdraw.submitting': '⏳ 提交中...',
 
329
  'wallet.withdraw.no_balance': '您的可提现收益为 0,快去发布优质插件赚取积分吧!',
330
  'wallet.withdraw.min_amount': '最低提现额度为 1 积分!',
331
  'wallet.withdraw.fill_account': '请完整填写收款人支付宝账号与真实姓名!',
@@ -393,8 +398,6 @@ const translations = {
393
  'feedback.loading': '处理中...',
394
  'feedback.success': '操作成功',
395
  'feedback.error': '操作失败,请重试',
396
- 'feedback.network_error': '网络连接失败',
397
- 'feedback.offline': '当前处于离线状态',
398
  'feedback.timeout': '请求超时',
399
  'feedback.saved': '已保存',
400
  'feedback.logged_out': '已退出登录',
@@ -419,6 +422,7 @@ const translations = {
419
  'social.reply': '回复',
420
  'social.reply_to': '回复',
421
  'social.comment_deleted': '此条评论已由用户删除',
 
422
  'social.delete_comment_confirm': '确定要删除这条评论吗?删除后将无法恢复。',
423
  'social.no_comments': '暂无留言,来抢沙发吧~',
424
 
@@ -486,6 +490,7 @@ const translations = {
486
  'chat.date_day': '日',
487
  'chat.load_more': '加载更早的消息',
488
  'chat.msg_too_long': '消息不能超1000字',
 
489
  'chat.load_list_failed': '加载对话列表失败',
490
  'chat.load_history_failed': '加载消息记录失败',
491
  'chat.load_failed_hint': '消息加载失败',
@@ -588,6 +593,12 @@ const translations = {
588
  'item.tip_board_title': '该作品赞赏贡献榜 (TOP 10)',
589
  'item.owned_reinstall': '已拥有 (点击重新覆盖安装)',
590
  'item.owned_purchased': '已购买 (请在电脑端安装)',
 
 
 
 
 
 
591
  'item.update_available': '发现新版本 (点击静默热更新)',
592
  'item.get_now': '立即获取使用',
593
  'item.free': '完全免费',
@@ -600,7 +611,13 @@ const translations = {
600
  // 反馈补充
601
  'feedback.deleted': '已成功删除',
602
  'feedback.delete_failed': '删除失败',
 
603
  'feedback.cache_fallback': '⚠️ 网络连接失败,展示的是缓存数据',
 
 
 
 
 
604
 
605
  // 通用
606
  'common.processing': '处理中',
@@ -815,6 +832,11 @@ const translations = {
815
  'post.network_cache': '网络异常,已加载本地缓存',
816
  'post.login_first': '请先登录后再发布作品',
817
  'post.video_tag': '视频',
 
 
 
 
 
818
 
819
  // 帖子详情
820
  'post.sort_latest': '最新',
@@ -1056,6 +1078,8 @@ const translations = {
1056
  'common.uploading': '上传中',
1057
  'common.upload_progress': '上传 ({current}/{total})',
1058
  'common.submitting': '提交中',
 
 
1059
  'common.upload_image_progress': '上传图片 ({current}/{total})',
1060
  'common.unknown_task': '未知任务',
1061
 
@@ -1070,15 +1094,20 @@ const translations = {
1070
  'task.confirm_assign': '确定选择 {assignee} 作为接单者吗?',
1071
  'task.confirm_deposit': '将从您的账户扣除订金 {amount} 积分',
1072
  'task.apply_message_prompt': '请输入申请留言(可选):',
 
1073
  'task.confirm_cancel_apply': '确定撤回申请吗?',
 
1074
  'task.apply_cancelled': '已撤回申请',
1075
  'task.cancel_apply_failed': '撤回失败',
1076
  'task.confirm_cancel_task': '确定取消任务吗?此操作不可撤销。',
 
1077
  'task.task_cancelled': '任务已取消',
1078
  'task.cancel_task_failed': '取消失败',
1079
  'task.confirm_accept': '确定验收通过吗?',
1080
  'task.confirm_pay_remaining': '将支付尾款 {amount} 积分给接单者',
1081
  'task.reject_reason_prompt': '请输入不通过的原因:',
 
 
1082
  'task.work_returned': '已退回修改',
1083
  'task.submit_work': '提交成果',
1084
  'task.deliverable_images': '交付图片',
@@ -1270,6 +1299,8 @@ const translations = {
1270
  // 个人列表
1271
  'profile.privacy_hidden': '由于作者的隐私设置,该列表不对外公开',
1272
  'profile.no_following': '暂无关注的人',
 
 
1273
  'profile.no_collected': '还没有收藏任何内容',
1274
  'profile.go_discover': '去榜单页面或讨论区发现精彩内容吧!',
1275
  'profile.collected_items': '收藏的资源',
@@ -1426,6 +1457,9 @@ const translations = {
1426
  'common.sending': 'Sending...',
1427
  'common.retrying': 'Retrying...',
1428
  'common.retry_failed': 'Retry failed, please try again later',
 
 
 
1429
  'common.more': 'More',
1430
  'common.back': 'Back',
1431
  'common.search': 'Search',
@@ -1698,6 +1732,7 @@ const translations = {
1698
  'wallet.recharge.custom_placeholder': 'Enter custom amount (1~10000)',
1699
  'wallet.recharge.max_amount_exceeded': 'Single recharge cannot exceed 10000',
1700
  'wallet.recharge.processing': 'Processing...',
 
1701
 
1702
  // Withdraw
1703
  'wallet.withdraw.title': '💳 Earnings Withdrawal Center',
@@ -1716,6 +1751,7 @@ const translations = {
1716
  'wallet.withdraw.resend_in': 'Resend in {seconds}s',
1717
  'wallet.withdraw.confirm': 'Confirm Withdrawal (Funds Will Be Frozen for Review)',
1718
  'wallet.withdraw.submitting': '⏳ Submitting...',
 
1719
  'wallet.withdraw.no_balance': 'Your withdrawable balance is 0. Publish quality plugins to earn points!',
1720
  'wallet.withdraw.min_amount': 'Minimum withdrawal is 1 point!',
1721
  'wallet.withdraw.fill_account': 'Please fill in both Alipay account and real name!',
@@ -1783,8 +1819,6 @@ const translations = {
1783
  'feedback.loading': 'Processing...',
1784
  'feedback.success': 'Success',
1785
  'feedback.error': 'Failed, please retry',
1786
- 'feedback.network_error': 'Network error',
1787
- 'feedback.offline': 'Currently offline',
1788
  'feedback.timeout': 'Request timeout',
1789
  'feedback.saved': 'Saved',
1790
  'feedback.logged_out': 'Logged out',
@@ -1808,6 +1842,7 @@ const translations = {
1808
  'social.say_something': 'Say something...',
1809
  'social.reply_to': 'Reply to',
1810
  'social.comment_deleted': 'This comment has been deleted',
 
1811
  'social.delete_comment_confirm': 'Are you sure you want to delete this comment? This cannot be undone.',
1812
  'social.no_comments': 'No comments yet. Be the first!',
1813
 
@@ -1873,6 +1908,7 @@ const translations = {
1873
  'chat.date_day': '',
1874
  'chat.load_more': 'Load earlier messages',
1875
  'chat.msg_too_long': 'Message cannot exceed 1000 characters',
 
1876
  'chat.load_list_failed': 'Failed to load conversations',
1877
  'chat.load_history_failed': 'Failed to load messages',
1878
  'chat.load_failed_hint': 'Message loading failed',
@@ -1973,6 +2009,12 @@ const translations = {
1973
  'item.tip_board_title': 'Top Supporters (TOP 10)',
1974
  'item.owned_reinstall': 'Owned (Click to reinstall)',
1975
  'item.owned_purchased': 'Purchased (Install on PC)',
 
 
 
 
 
 
1976
  'item.update_available': 'Update available (Click for silent hot update)',
1977
  'item.get_now': 'Get Now',
1978
  'item.free': 'Free',
@@ -1981,7 +2023,11 @@ const translations = {
1981
  'time.hours_later': ' hours effective',
1982
  'feedback.deleted': 'Successfully deleted',
1983
  'feedback.delete_failed': 'Delete failed',
 
1984
  'feedback.cache_fallback': '⚠️ Network failed, showing cached data',
 
 
 
1985
  'common.processing': 'Processing',
1986
  'common.got_it': 'Got it',
1987
  'confirm.delete_title': 'Confirm Delete',
@@ -2127,6 +2173,63 @@ const translations = {
2127
  'task.publish_success': 'Task published!',
2128
  'task.publish_failed': 'Publishing failed, please retry',
2129
  'task.max_images': 'Max 6 images',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2130
  'task.view_total': '🔥 Total Views',
2131
  'task.view_today': '📅 Today Views',
2132
  'task.save_changes': 'Save Changes',
@@ -2174,6 +2277,26 @@ const translations = {
2174
  'dispute.notice_2': 'The other party will respond to your dispute',
2175
  'dispute.notice_3': 'Please provide valid evidence',
2176
  'dispute.max_images': 'Max 6 images',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2177
 
2178
  // Posts / Forum
2179
  'post.title': 'Forum',
@@ -2186,6 +2309,12 @@ const translations = {
2186
  'post.network_cache': 'Network error, loaded from cache',
2187
  'post.login_first': 'Please login to create posts',
2188
  'post.video_tag': 'Video',
 
 
 
 
 
 
2189
  'post.sort_latest': 'Latest',
2190
  'post.sort_likes': '👍 Most Liked',
2191
  'post.sort_favorites': '🔖 Most Favorited',
@@ -2214,6 +2343,13 @@ const translations = {
2214
  'post.comment_failed': 'Comment failed',
2215
  'post.tip_board_title': '🎁 Top Tippers',
2216
  'post.no_tips': '🎁 No tips yet',
 
 
 
 
 
 
 
2217
 
2218
  // ✨ Prompt Market
2219
  'nav.prompts': 'Prompts',
@@ -2417,6 +2553,8 @@ const translations = {
2417
  'common.uploading': 'Uploading',
2418
  'common.upload_progress': 'Upload ({current}/{total})',
2419
  'common.submitting': 'Submitting',
 
 
2420
  'common.upload_image_progress': 'Uploading ({current}/{total})',
2421
  'common.unknown_task': 'Unknown Task',
2422
 
@@ -2518,6 +2656,8 @@ const translations = {
2518
  'profile.task_earnings': 'Task Earnings',
2519
  'profile.privacy_hidden': 'This list is private due to the author\'s privacy settings',
2520
  'profile.no_following': 'Not following anyone',
 
 
2521
  'profile.no_collected': 'No favorites yet',
2522
  'profile.go_discover': 'Go discover content in rankings or forum!',
2523
  'profile.collected_items': 'Favorited Resources',
@@ -2664,7 +2804,17 @@ const translations = {
2664
  */
2665
  export function t(key, params = {}) {
2666
  const lang = translations[currentLang] || translations['zh-CN'];
2667
- let text = lang[key] || translations['zh-CN'][key] || key;
 
 
 
 
 
 
 
 
 
 
2668
 
2669
  // 处理插值 {name}
2670
  Object.entries(params).forEach(([k, v]) => {
@@ -2680,7 +2830,10 @@ export function t(key, params = {}) {
2680
  */
2681
  export function tIfExists(key, fallback = '') {
2682
  const lang = translations[currentLang] || translations['zh-CN'];
2683
- return lang[key] || translations['zh-CN'][key] || fallback;
 
 
 
2684
  }
2685
 
2686
  /**
 
31
  'common.sending': '发送中...',
32
  'common.retrying': '重试中...',
33
  'common.retry_failed': '重试失败,请稍后再试',
34
+ 'common.no_more': '没有更多了',
35
+ 'common.parse_error': '响应解析失败',
36
+ 'common.network_error_retry': '网络连接失败,请稍后重试',
37
  'common.more': '更多',
38
  'common.back': '返回',
39
  'common.search': '搜索',
 
311
  'wallet.recharge.custom_placeholder': '请输入自定义金额 (1~10000)',
312
  'wallet.recharge.max_amount_exceeded': '单次充值金额不得超过10000元',
313
  'wallet.recharge.processing': '处理中...',
314
+ 'wallet.recharge.timeout': '支付查询超时,如已支付请稍后刷新查看余额',
315
 
316
  // 提现相关
317
  'wallet.withdraw.title': '💳 收益提现中心',
 
330
  'wallet.withdraw.resend_in': '{seconds}s 后重发',
331
  'wallet.withdraw.confirm': '确认提现 (金额将转入冻结审核)',
332
  'wallet.withdraw.submitting': '⏳ 提交中...',
333
+ 'wallet.withdraw.timeout': '提现请求超时,请检查网络后重试',
334
  'wallet.withdraw.no_balance': '您的可提现收益为 0,快去发布优质插件赚取积分吧!',
335
  'wallet.withdraw.min_amount': '最低提现额度为 1 积分!',
336
  'wallet.withdraw.fill_account': '请完整填写收款人支付宝账号与真实姓名!',
 
398
  'feedback.loading': '处理中...',
399
  'feedback.success': '操作成功',
400
  'feedback.error': '操作失败,请重试',
 
 
401
  'feedback.timeout': '请求超时',
402
  'feedback.saved': '已保存',
403
  'feedback.logged_out': '已退出登录',
 
422
  'social.reply': '回复',
423
  'social.reply_to': '回复',
424
  'social.comment_deleted': '此条评论已由用户删除',
425
+ 'social.delete_comment_title': '删除评论',
426
  'social.delete_comment_confirm': '确定要删除这条评论吗?删除后将无法恢复。',
427
  'social.no_comments': '暂无留言,来抢沙发吧~',
428
 
 
490
  'chat.date_day': '日',
491
  'chat.load_more': '加载更早的消息',
492
  'chat.msg_too_long': '消息不能超1000字',
493
+ 'chat.send_failed': '发送失败: {msg}',
494
  'chat.load_list_failed': '加载对话列表失败',
495
  'chat.load_history_failed': '加载消息记录失败',
496
  'chat.load_failed_hint': '消息加载失败',
 
593
  'item.tip_board_title': '该作品赞赏贡献榜 (TOP 10)',
594
  'item.owned_reinstall': '已拥有 (点击重新覆盖安装)',
595
  'item.owned_purchased': '已购买 (请在电脑端安装)',
596
+ 'web.publish_not_supported': '⚠️ 网页版暂不支持发布/编辑功能',
597
+ 'web.install_on_desktop': '请在电脑端进行安装',
598
+ 'web.purchase_success_desktop': '购买成功!请在电脑端进行安装',
599
+ 'crop.ratio_label': '比例',
600
+ 'crop.gesture_hint_touch': '单指拖动调整位置 | 双指捏合或滑块缩放',
601
+ 'crop.gesture_hint_mouse': '拖动调整位置 | 滚轮缩放',
602
  'item.update_available': '发现新版本 (点击静默热更新)',
603
  'item.get_now': '立即获取使用',
604
  'item.free': '完全免费',
 
611
  // 反馈补充
612
  'feedback.deleted': '已成功删除',
613
  'feedback.delete_failed': '删除失败',
614
+ 'feedback.retry_suffix': ',请重试',
615
  'feedback.cache_fallback': '⚠️ 网络连接失败,展示的是缓存数据',
616
+ // 🔧 新增:以下三条原为散落在各组件里的硬编码中文,现收口到词典以支持多语言
617
+ 'feedback.offline_no_cache': '网络已断开,且无本地缓存',
618
+ 'feedback.search_local_result': '⚠️ 网络不可用,显示本地搜索结果',
619
+ // 🔍 搜索请求被新关键词取代时抛出的错误消息(详见 网络请求_基础设施.js 的主动取消分支)
620
+ 'feedback.request_cancelled': '请求已取消',
621
 
622
  // 通用
623
  'common.processing': '处理中',
 
832
  'post.network_cache': '网络异常,已加载本地缓存',
833
  'post.login_first': '请先登录后再发布作品',
834
  'post.video_tag': '视频',
835
+ 'post.images_unit': '张',
836
+ 'post.delete_comment_confirm': '确定删除这条评论吗?',
837
+ 'post.delete_comment_warning': '删除后无法恢复',
838
+ 'post.delete_comment_success': '评论已删除',
839
+ 'post.delete_comment_failed': '删除失败',
840
 
841
  // 帖子详情
842
  'post.sort_latest': '最新',
 
1078
  'common.uploading': '上传中',
1079
  'common.upload_progress': '上传 ({current}/{total})',
1080
  'common.submitting': '提交中',
1081
+ 'common.confirming': '确认中',
1082
+ 'common.cancelling': '取消中',
1083
  'common.upload_image_progress': '上传图片 ({current}/{total})',
1084
  'common.unknown_task': '未知任务',
1085
 
 
1094
  'task.confirm_assign': '确定选择 {assignee} 作为接单者吗?',
1095
  'task.confirm_deposit': '将从您的账户扣除订金 {amount} 积分',
1096
  'task.apply_message_prompt': '请输入申请留言(可选):',
1097
+ 'task.apply_message_placeholder': '简单说明你的优势与预计交付时间...',
1098
  'task.confirm_cancel_apply': '确定撤回申请吗?',
1099
+ 'task.cancel_apply_warning': '撤回后你的申请将从候选列表中移除',
1100
  'task.apply_cancelled': '已撤回申请',
1101
  'task.cancel_apply_failed': '撤回失败',
1102
  'task.confirm_cancel_task': '确定取消任务吗?此操作不可撤销。',
1103
+ 'task.cancel_task_warning': '取消后已冻结的订金将退回你的账户',
1104
  'task.task_cancelled': '任务已取消',
1105
  'task.cancel_task_failed': '取消失败',
1106
  'task.confirm_accept': '确定验收通过吗?',
1107
  'task.confirm_pay_remaining': '将支付尾款 {amount} 积分给接单者',
1108
  'task.reject_reason_prompt': '请输入不通过的原因:',
1109
+ 'task.reject_reason_placeholder': '请说明需要修改的具体问题...',
1110
+ 'task.reject_reason_required': '请输入拒绝原因',
1111
  'task.work_returned': '已退回修改',
1112
  'task.submit_work': '提交成果',
1113
  'task.deliverable_images': '交付图片',
 
1299
  // 个人列表
1300
  'profile.privacy_hidden': '由于作者的隐私设置,该列表不对外公开',
1301
  'profile.no_following': '暂无关注的人',
1302
+ 'profile.no_followers': '暂无粉丝',
1303
+ 'profile.load_more': '加载更多 ({count})',
1304
  'profile.no_collected': '还没有收藏任何内容',
1305
  'profile.go_discover': '去榜单页面或讨论区发现精彩内容吧!',
1306
  'profile.collected_items': '收藏的资源',
 
1457
  'common.sending': 'Sending...',
1458
  'common.retrying': 'Retrying...',
1459
  'common.retry_failed': 'Retry failed, please try again later',
1460
+ 'common.no_more': 'No more items',
1461
+ 'common.parse_error': 'Failed to parse response',
1462
+ 'common.network_error_retry': 'Network connection failed, please try again later',
1463
  'common.more': 'More',
1464
  'common.back': 'Back',
1465
  'common.search': 'Search',
 
1732
  'wallet.recharge.custom_placeholder': 'Enter custom amount (1~10000)',
1733
  'wallet.recharge.max_amount_exceeded': 'Single recharge cannot exceed 10000',
1734
  'wallet.recharge.processing': 'Processing...',
1735
+ 'wallet.recharge.timeout': 'Payment check timed out. If you have already paid, refresh later to see your balance',
1736
 
1737
  // Withdraw
1738
  'wallet.withdraw.title': '💳 Earnings Withdrawal Center',
 
1751
  'wallet.withdraw.resend_in': 'Resend in {seconds}s',
1752
  'wallet.withdraw.confirm': 'Confirm Withdrawal (Funds Will Be Frozen for Review)',
1753
  'wallet.withdraw.submitting': '⏳ Submitting...',
1754
+ 'wallet.withdraw.timeout': 'Withdrawal request timed out. Check your network and try again',
1755
  'wallet.withdraw.no_balance': 'Your withdrawable balance is 0. Publish quality plugins to earn points!',
1756
  'wallet.withdraw.min_amount': 'Minimum withdrawal is 1 point!',
1757
  'wallet.withdraw.fill_account': 'Please fill in both Alipay account and real name!',
 
1819
  'feedback.loading': 'Processing...',
1820
  'feedback.success': 'Success',
1821
  'feedback.error': 'Failed, please retry',
 
 
1822
  'feedback.timeout': 'Request timeout',
1823
  'feedback.saved': 'Saved',
1824
  'feedback.logged_out': 'Logged out',
 
1842
  'social.say_something': 'Say something...',
1843
  'social.reply_to': 'Reply to',
1844
  'social.comment_deleted': 'This comment has been deleted',
1845
+ 'social.delete_comment_title': 'Delete Comment',
1846
  'social.delete_comment_confirm': 'Are you sure you want to delete this comment? This cannot be undone.',
1847
  'social.no_comments': 'No comments yet. Be the first!',
1848
 
 
1908
  'chat.date_day': '',
1909
  'chat.load_more': 'Load earlier messages',
1910
  'chat.msg_too_long': 'Message cannot exceed 1000 characters',
1911
+ 'chat.send_failed': 'Send failed: {msg}',
1912
  'chat.load_list_failed': 'Failed to load conversations',
1913
  'chat.load_history_failed': 'Failed to load messages',
1914
  'chat.load_failed_hint': 'Message loading failed',
 
2009
  'item.tip_board_title': 'Top Supporters (TOP 10)',
2010
  'item.owned_reinstall': 'Owned (Click to reinstall)',
2011
  'item.owned_purchased': 'Purchased (Install on PC)',
2012
+ 'web.publish_not_supported': '⚠️ Publishing and editing are not available in the web version',
2013
+ 'web.install_on_desktop': 'Please install it in the desktop version',
2014
+ 'web.purchase_success_desktop': 'Purchase successful! Please install it in the desktop version',
2015
+ 'crop.ratio_label': 'Ratio',
2016
+ 'crop.gesture_hint_touch': 'Drag with one finger to reposition | Pinch or use the slider to zoom',
2017
+ 'crop.gesture_hint_mouse': 'Drag to reposition | Scroll to zoom',
2018
  'item.update_available': 'Update available (Click for silent hot update)',
2019
  'item.get_now': 'Get Now',
2020
  'item.free': 'Free',
 
2023
  'time.hours_later': ' hours effective',
2024
  'feedback.deleted': 'Successfully deleted',
2025
  'feedback.delete_failed': 'Delete failed',
2026
+ 'feedback.retry_suffix': ', please try again',
2027
  'feedback.cache_fallback': '⚠️ Network failed, showing cached data',
2028
+ 'feedback.offline_no_cache': 'Network disconnected, and no local cache available',
2029
+ 'feedback.search_local_result': '⚠️ Network unavailable, showing local search results',
2030
+ 'feedback.request_cancelled': 'Request cancelled',
2031
  'common.processing': 'Processing',
2032
  'common.got_it': 'Got it',
2033
  'confirm.delete_title': 'Confirm Delete',
 
2173
  'task.publish_success': 'Task published!',
2174
  'task.publish_failed': 'Publishing failed, please retry',
2175
  'task.max_images': 'Max 6 images',
2176
+ // 🔧 补齐:以下 50 条 zh-CN 已有、en-US 缺失,导致英文界面下任务榜退回中文显示
2177
+ 'task.description': 'Task Description',
2178
+ 'task.reference_images': 'Reference Images',
2179
+ 'task.reference_link': 'Reference Link',
2180
+ 'task.assignee': 'Assignee',
2181
+ 'task.note': 'Note',
2182
+ // 中文量词「人」,英文不用量词(Applicants (5)),故刻意留空;t() 已修为尊重空值
2183
+ 'task.applicants_count': '',
2184
+ 'task.choose_assignee': 'Select',
2185
+ 'task.confirm_assign': 'Confirm selecting {assignee} as the assignee?',
2186
+ 'task.confirm_deposit': 'A deposit of {amount} credits will be deducted from your account',
2187
+ 'task.apply_message_prompt': 'Enter your application message (optional):',
2188
+ 'task.apply_message_placeholder': 'Briefly describe your strengths and estimated delivery time...',
2189
+ 'task.confirm_cancel_apply': 'Withdraw your application?',
2190
+ 'task.cancel_apply_warning': 'Your application will be removed from the candidate list',
2191
+ 'task.apply_cancelled': 'Application withdrawn',
2192
+ 'task.confirm_cancel_task': 'Cancel this task? This cannot be undone.',
2193
+ 'task.cancel_task_warning': 'The frozen deposit will be refunded to your account after cancellation',
2194
+ 'task.task_cancelled': 'Task cancelled',
2195
+ 'task.cancel_task_failed': 'Cancellation failed',
2196
+ 'task.confirm_accept': 'Approve and accept the delivery?',
2197
+ 'task.confirm_pay_remaining': 'The remaining {amount} credits will be paid to the assignee',
2198
+ 'task.reject_reason_prompt': 'Enter the reason for rejection:',
2199
+ 'task.reject_reason_placeholder': 'Describe the specific issues that need revision...',
2200
+ 'task.reject_reason_required': 'Please enter a reason for rejection',
2201
+ 'task.work_returned': 'Returned for revision',
2202
+ 'task.submit_work': 'Submit Work',
2203
+ 'task.deliverable_images': 'Delivery Images',
2204
+ 'task.support_multiple_images': 'Multiple images supported',
2205
+ 'task.note_optional': 'Note (optional)',
2206
+ 'task.note_placeholder': 'Describe the delivered content...',
2207
+ 'task.please_upload_deliverables': 'Please upload delivery images',
2208
+ 'task.upload_failed_retry': 'Image upload failed, please retry',
2209
+ 'task.submit_success_waiting': 'Work submitted, awaiting acceptance',
2210
+ 'task.start_dispute': 'File Dispute',
2211
+ 'task.accept_work': 'Accept Work',
2212
+ 'task.reject_work': 'Reject Work',
2213
+ 'task.task_title': 'Task Title',
2214
+ 'task.description_placeholder': 'Describe the requirements, delivery criteria, references...',
2215
+ 'task.reference_images_optional': 'Reference images (optional, max 6)',
2216
+ 'task.add_reference_image': 'Add reference image',
2217
+ 'task.reference_link_optional': 'Reference link (optional)',
2218
+ 'task.min_10_credits': 'Minimum 10 credits',
2219
+ 'task.deposit_deducted_on_assign': 'Deducted on assignment',
2220
+ 'task.price_description': 'Price Breakdown',
2221
+ 'task.deposit_on_assign': 'Deposit (deducted when assigning)',
2222
+ 'task.remaining_on_accept': 'Balance (deducted on acceptance)',
2223
+ 'task.publish_task': 'Publish Task',
2224
+ 'task.manage_in_task_list': 'After publishing, manage your tasks in the Task Board',
2225
+ 'task.please_enter_title': 'Please enter a task title',
2226
+ 'task.please_enter_description': 'Please enter a task description',
2227
+ 'task.price_min_10': 'Task price cannot be lower than 10 credits',
2228
+ 'task.please_select_deadline': 'Please select a deadline',
2229
+ 'task.uploading_images': 'Uploading images',
2230
+ 'task.upload_progress': 'Uploading images ({current}/{total})',
2231
+ 'task.publishing': 'Publishing',
2232
+ 'task.max_6_images': 'Max 6 images',
2233
  'task.view_total': '🔥 Total Views',
2234
  'task.view_today': '📅 Today Views',
2235
  'task.save_changes': 'Save Changes',
 
2277
  'dispute.notice_2': 'The other party will respond to your dispute',
2278
  'dispute.notice_3': 'Please provide valid evidence',
2279
  'dispute.max_images': 'Max 6 images',
2280
+ // 🔧 补齐:以下 19 条 zh-CN 已有、en-US 缺失,导致英文界面下申诉流程退回中文显示
2281
+ 'dispute.start': 'File Dispute',
2282
+ 'dispute.reason': 'Dispute Reason',
2283
+ 'dispute.reason_placeholder': 'Please describe your reason in detail...',
2284
+ 'dispute.evidence_optional': 'Evidence images (optional, max 6)',
2285
+ 'dispute.click_upload_evidence': 'Click to upload evidence images',
2286
+ 'dispute.submit': 'Submit Dispute',
2287
+ 'dispute.submit_success': 'Dispute submitted, awaiting response',
2288
+ 'dispute.submit_failed': 'Dispute failed',
2289
+ 'dispute.max_6_images': 'Max 6 images',
2290
+ 'dispute.please_enter_reason': 'Please enter a dispute reason',
2291
+ 'dispute.get_detail_failed': 'Failed to load dispute details',
2292
+ 'dispute.related_task': 'Related Task',
2293
+ 'dispute.task_id': 'Task ID',
2294
+ 'dispute.click_upload_evidence_optional': 'Click to upload evidence images (optional)',
2295
+ 'dispute.resolution_result': 'Arbitration Result',
2296
+ 'dispute.upload_failed': 'Upload failed',
2297
+ 'dispute.please_enter_response': 'Please enter your response',
2298
+ 'dispute.response_submitted': 'Response submitted',
2299
+ 'dispute.split_result': 'Split by mutual agreement',
2300
 
2301
  // Posts / Forum
2302
  'post.title': 'Forum',
 
2309
  'post.network_cache': 'Network error, loaded from cache',
2310
  'post.login_first': 'Please login to create posts',
2311
  'post.video_tag': 'Video',
2312
+ // 中文量词「张」,英文不用量词(🖼️ 5),故刻意留空;t() 已修为尊重空值
2313
+ 'post.images_unit': '',
2314
+ 'post.delete_comment_confirm': 'Delete this comment?',
2315
+ 'post.delete_comment_warning': 'This cannot be undone',
2316
+ 'post.delete_comment_success': 'Comment deleted',
2317
+ 'post.delete_comment_failed': 'Delete failed',
2318
  'post.sort_latest': 'Latest',
2319
  'post.sort_likes': '👍 Most Liked',
2320
  'post.sort_favorites': '🔖 Most Favorited',
 
2343
  'post.comment_failed': 'Comment failed',
2344
  'post.tip_board_title': '🎁 Top Tippers',
2345
  'post.no_tips': '🎁 No tips yet',
2346
+ // 🔧 补齐:以下 6 条 zh-CN 已有、en-US 缺失,导致英文界面下讨论区退回中文显示
2347
+ 'post.uploading_images': 'Uploading images',
2348
+ 'post.edit_title': 'Edit Post',
2349
+ 'post.delete_confirm_title': 'Confirm Deletion',
2350
+ 'post.delete_confirm_desc': 'This cannot be undone. Delete this post?',
2351
+ 'post.delete_success': 'Deleted successfully',
2352
+ 'post.delete_failed': 'Deletion failed',
2353
 
2354
  // ✨ Prompt Market
2355
  'nav.prompts': 'Prompts',
 
2553
  'common.uploading': 'Uploading',
2554
  'common.upload_progress': 'Upload ({current}/{total})',
2555
  'common.submitting': 'Submitting',
2556
+ 'common.confirming': 'Confirming',
2557
+ 'common.cancelling': 'Cancelling',
2558
  'common.upload_image_progress': 'Uploading ({current}/{total})',
2559
  'common.unknown_task': 'Unknown Task',
2560
 
 
2656
  'profile.task_earnings': 'Task Earnings',
2657
  'profile.privacy_hidden': 'This list is private due to the author\'s privacy settings',
2658
  'profile.no_following': 'Not following anyone',
2659
+ 'profile.no_followers': 'No followers yet',
2660
+ 'profile.load_more': 'Load More ({count})',
2661
  'profile.no_collected': 'No favorites yet',
2662
  'profile.go_discover': 'Go discover content in rankings or forum!',
2663
  'profile.collected_items': 'Favorited Resources',
 
2804
  */
2805
  export function t(key, params = {}) {
2806
  const lang = translations[currentLang] || translations['zh-CN'];
2807
+ // 🔧 修复:原实现 lang[key] || zh[key] || key 会把「刻意留空的词条」当成缺失又退回中文。
2808
+ // 中文量词/助词在英文里没有对应,必须留空:
2809
+ // chat.date_day 9月8日 → 9/8
2810
+ // chat.chatting_suffix 与 X 聊天 → Chatting with X
2811
+ // creator.output.unit 产出插件 12 个 → Plugins: 12
2812
+ // profile.transactions_unit 5笔 → 5
2813
+ // 旧逻辑下英文界面实际显示为 9/8日、Chatting with X 聊天、Plugins: 12 个、(5笔)。
2814
+ // 现改为:词条存在即采用(含空串),仅在真正缺键时才回退到中文。
2815
+ let text = Object.prototype.hasOwnProperty.call(lang, key)
2816
+ ? lang[key]
2817
+ : (translations['zh-CN'][key] || key);
2818
 
2819
  // 处理插值 {name}
2820
  Object.entries(params).forEach(([k, v]) => {
 
2830
  */
2831
  export function tIfExists(key, fallback = '') {
2832
  const lang = translations[currentLang] || translations['zh-CN'];
2833
+ // 🔧 与 t() 同口径:词条存在即采用(含刻意留空),仅在两级都缺键时用 fallback
2834
+ if (Object.prototype.hasOwnProperty.call(lang, key)) return lang[key];
2835
+ if (Object.prototype.hasOwnProperty.call(translations['zh-CN'], key)) return translations['zh-CN'][key];
2836
+ return fallback;
2837
  }
2838
 
2839
  /**
前端页面/components/视频播放器组件.js CHANGED
@@ -317,7 +317,7 @@ function _updatePlayButton(btnPlay, video) {
317
  if (!btnPlay) return;
318
  const playing = !video.paused && !video.ended;
319
  btnPlay.textContent = getPlayIcon(playing);
320
- btnPlay.title = playing ? (t('video.pause') || '暂停') : (t('video.play') || '播放');
321
  }
322
 
323
  /**
@@ -394,14 +394,14 @@ export function getVideoPlayerHTML(videoUrl, posterUrl, options = {}) {
394
  <div class="video-progress-tooltip">0:00</div>
395
  </div>
396
  <div class="video-controls-row">
397
- <button class="video-btn-play" title="${t('video.play') || '播放'}">${getPlayIcon(false)}</button>
398
  <span class="video-time">0:00 / 0:00</span>
399
  <div class="video-volume-wrap">
400
- <button class="video-btn-volume" title="${t('video.volume') || '音量'}">🔊</button>
401
  <input type="range" class="video-volume-slider" min="0" max="100" value="100">
402
  </div>
403
  <div class="video-speed-wrap">
404
- <button class="video-btn-speed" title="${t('video.speed') || '播放速度'}">1x</button>
405
  <div class="video-speed-menu">
406
  <div data-speed="0.5">0.5x</div>
407
  <div data-speed="1" class="active">1x</div>
@@ -410,7 +410,7 @@ export function getVideoPlayerHTML(videoUrl, posterUrl, options = {}) {
410
  <div data-speed="2">2x</div>
411
  </div>
412
  </div>
413
- <button class="video-btn-fullscreen" title="${t('video.fullscreen') || '全屏'}">⛶</button>
414
  </div>
415
  </div>
416
  </div>
@@ -444,10 +444,10 @@ export function setupVideoPlayerEvents(container) {
444
  if (!video) return;
445
 
446
  // 设置按钮文字(fallback)
447
- if (btnPlay) btnPlay.title = t('video.play') || '播放';
448
- if (btnVolume) btnVolume.title = t('video.volume') || '音量';
449
- if (btnSpeed) btnSpeed.title = t('video.speed') || '播放速度';
450
- if (btnFullscreen) btnFullscreen.title = t('video.fullscreen') || '全屏';
451
 
452
  let isDraggingProgress = false;
453
  let wasPlayingBeforeDrag = false;
@@ -824,7 +824,7 @@ export function openFullscreenVideo(videoUrl, posterUrl, options = {}) {
824
  const startRate = options.playbackRate || 1;
825
 
826
  overlay.innerHTML = `
827
- <button class="fs-video-close" title="${t('video.close') || '关闭'}">✕</button>
828
  <video preload="metadata" poster="${safePoster}" playsinline
829
  style="width:100vw;height:100vh;object-fit:contain;">
830
  <source src="${safeVideo}">
@@ -837,14 +837,14 @@ export function openFullscreenVideo(videoUrl, posterUrl, options = {}) {
837
  <div class="video-progress-tooltip">0:00</div>
838
  </div>
839
  <div class="video-controls-row">
840
- <button class="video-btn-play" title="${t('video.play') || '播放'}">▶</button>
841
  <span class="video-time">0:00 / 0:00</span>
842
  <div class="video-volume-wrap">
843
- <button class="video-btn-volume" title="${t('video.volume') || '音量'}">🔊</button>
844
  <input type="range" class="video-volume-slider" min="0" max="100" value="100">
845
  </div>
846
  <div class="video-speed-wrap">
847
- <button class="video-btn-speed" title="${t('video.speed') || '播放速度'}">1x</button>
848
  <div class="video-speed-menu">
849
  <div data-speed="0.5">0.5x</div>
850
  <div data-speed="1" class="active">1x</div>
@@ -853,7 +853,7 @@ export function openFullscreenVideo(videoUrl, posterUrl, options = {}) {
853
  <div data-speed="2">2x</div>
854
  </div>
855
  </div>
856
- <button class="video-btn-fullscreen" title="${t('video.exit_fullscreen') || '退出全屏'}">⛶</button>
857
  </div>
858
  </div>
859
  `;
 
317
  if (!btnPlay) return;
318
  const playing = !video.paused && !video.ended;
319
  btnPlay.textContent = getPlayIcon(playing);
320
+ btnPlay.title = playing ? t('video.pause') : t('video.play');
321
  }
322
 
323
  /**
 
394
  <div class="video-progress-tooltip">0:00</div>
395
  </div>
396
  <div class="video-controls-row">
397
+ <button class="video-btn-play" title="${t('video.play')}">${getPlayIcon(false)}</button>
398
  <span class="video-time">0:00 / 0:00</span>
399
  <div class="video-volume-wrap">
400
+ <button class="video-btn-volume" title="${t('video.volume')}">🔊</button>
401
  <input type="range" class="video-volume-slider" min="0" max="100" value="100">
402
  </div>
403
  <div class="video-speed-wrap">
404
+ <button class="video-btn-speed" title="${t('video.speed')}">1x</button>
405
  <div class="video-speed-menu">
406
  <div data-speed="0.5">0.5x</div>
407
  <div data-speed="1" class="active">1x</div>
 
410
  <div data-speed="2">2x</div>
411
  </div>
412
  </div>
413
+ <button class="video-btn-fullscreen" title="${t('video.fullscreen')}">⛶</button>
414
  </div>
415
  </div>
416
  </div>
 
444
  if (!video) return;
445
 
446
  // 设置按钮文字(fallback)
447
+ if (btnPlay) btnPlay.title = t('video.play');
448
+ if (btnVolume) btnVolume.title = t('video.volume');
449
+ if (btnSpeed) btnSpeed.title = t('video.speed');
450
+ if (btnFullscreen) btnFullscreen.title = t('video.fullscreen');
451
 
452
  let isDraggingProgress = false;
453
  let wasPlayingBeforeDrag = false;
 
824
  const startRate = options.playbackRate || 1;
825
 
826
  overlay.innerHTML = `
827
+ <button class="fs-video-close" title="${t('video.close')}">✕</button>
828
  <video preload="metadata" poster="${safePoster}" playsinline
829
  style="width:100vw;height:100vh;object-fit:contain;">
830
  <source src="${safeVideo}">
 
837
  <div class="video-progress-tooltip">0:00</div>
838
  </div>
839
  <div class="video-controls-row">
840
+ <button class="video-btn-play" title="${t('video.play')}">▶</button>
841
  <span class="video-time">0:00 / 0:00</span>
842
  <div class="video-volume-wrap">
843
+ <button class="video-btn-volume" title="${t('video.volume')}">🔊</button>
844
  <input type="range" class="video-volume-slider" min="0" max="100" value="100">
845
  </div>
846
  <div class="video-speed-wrap">
847
+ <button class="video-btn-speed" title="${t('video.speed')}">1x</button>
848
  <div class="video-speed-menu">
849
  <div data-speed="0.5">0.5x</div>
850
  <div data-speed="1" class="active">1x</div>
 
853
  <div data-speed="2">2x</div>
854
  </div>
855
  </div>
856
+ <button class="video-btn-fullscreen" title="${t('video.exit_fullscreen')}">⛶</button>
857
  </div>
858
  </div>
859
  `;
前端页面/components/顶部导航组件.js CHANGED
@@ -7,7 +7,7 @@ import { showToast } from "./UI交互提示组件.js";
7
  import { showAboutInfo } from "./关于插件组件.js";
8
  import { openNotificationCenter, loadUnreadCount } from "../social/通知中心组件.js";
9
  import { openSettingsPage } from "./全局设置组件.js"; // ⚙️ 新增
10
- import { CACHE } from "../core/全局配置.js";
11
  import { t } from "./用户体验增强.js"; // 🌐 多语言支持
12
  import { createTopBanner } from "./顶部广告组件.js";
13
  import { uploadFile } from "../market/发布内容_提交引擎.js"; // 🖼️ 带压缩管线的统一上传包装器
@@ -42,7 +42,7 @@ export function createTopNav() {
42
  });
43
 
44
  const titleSpan = document.createElement("div");
45
- titleSpan.innerHTML = `<strong style='color:#4CAF50;'>ComfyUI</strong> ${t('nav.community') || '社区精选'}`;
46
 
47
  Object.assign(titleSpan.style, { fontSize: "16px", cursor: "pointer", transition: "opacity 0.2s" });
48
  titleSpan.title = "查看关于本插件与作者信息";
@@ -105,7 +105,7 @@ export function createTopNav() {
105
  // 避免每次 createTopNav 重复 addEventListener 导致闭包累积与轮询失效
106
 
107
  chatEntryBtn.onclick = () => {
108
- if (!currentUser) return showToast(t('auth.login_required') || "请先登录您的社区账号!", "warning");
109
  openChatModal(currentUser);
110
  };
111
 
@@ -114,7 +114,7 @@ export function createTopNav() {
114
 
115
  const updateUserButtonState = () => {
116
  if (currentUser) {
117
- userActionBtn.innerHTML = `👤 ${currentUser.name || currentUser.account || t('common.unknown_user') || '未知用户'}`;
118
  userActionBtn.style.backgroundColor = "#2196F3";
119
  userActionBtn.style.borderColor = "#2196F3";
120
  userActionBtn.onclick = () => openUserProfileModal(currentUser);
@@ -138,12 +138,12 @@ export function createTopNav() {
138
  let userData; let token; let isRemember = false;
139
 
140
  if (formData.type === "reset") {
141
- userActionBtn.innerHTML = `⏳ ${t('auth.resetting_password') || '修改密码中...'}`;
142
 
143
  // 🚀 核心修复:停止错误地拆解字段导致 undefined,直接将包含所有数据的 formData 对象完整传给 API 装甲!
144
  await api.resetPassword(formData);
145
 
146
- showToast(t('auth.password_reset_success') || "密码修改成功!请使用新密码重新登录。", "success");
147
  window.dispatchEvent(new CustomEvent("comfy-route-back")); updateUserButtonState(); return;
148
  }
149
  if (formData.type === "register") {
@@ -153,9 +153,9 @@ export function createTopNav() {
153
  delete formData.avatarFile;
154
  delete formData.avatarDataUrl; // base64 预览值会被后端拒绝,不随注册提交
155
 
156
- userActionBtn.innerHTML = `⏳ ${t('auth.registering') || '注册账号中...'}`;
157
  await api.register(formData);
158
- showToast(t('auth.register_success') + (t('auth.auto_login') || '!正在为您自动登录...'), "success");
159
  // 注册后自动登录,默认保持登录
160
  const res = await api.login(formData.account, formData.password, true);
161
  userData = { account: formData.account, name: formData.name, avatar: res.avatar, ...res };
@@ -163,7 +163,7 @@ export function createTopNav() {
163
 
164
  // 🖼️ 登录成功后再上传头像(此时请求可携带 Authorization)
165
  if (pendingAvatarFile && token) {
166
- userActionBtn.innerHTML = `⏳ ${t('auth.uploading_avatar') || '上传头像中...'}`;
167
  // 提前写入 token,确保上传与资料更新请求能通过认证
168
  localStorage.setItem("ComfyCommunity_Token", token);
169
  try {
@@ -174,11 +174,11 @@ export function createTopNav() {
174
  userData = { ...userData, ...(updateRes.data || {}) };
175
  if (userData.avatarDataUrl) userData.avatar = userData.avatarDataUrl;
176
  } else {
177
- showToast(t('auth.avatar_set_failed') || "⚠️ 头像设置失败,您可稍后在个人设置中重新上传", "warning");
178
  }
179
  } catch (avatarErr) {
180
  console.warn("注册后设置头像失败:", avatarErr.message || avatarErr);
181
- showToast(t('auth.avatar_set_failed') || "⚠️ 头像设置失败,您可稍后在个人设置中重新上传", "warning");
182
  }
183
  }
184
  } else if (formData.type === "login") {
 
7
  import { showAboutInfo } from "./关于插件组件.js";
8
  import { openNotificationCenter, loadUnreadCount } from "../social/通知中心组件.js";
9
  import { openSettingsPage } from "./全局设置组件.js"; // ⚙️ 新增
10
+ import { CACHE, escapeHtml } from "../core/全局配置.js";
11
  import { t } from "./用户体验增强.js"; // 🌐 多语言支持
12
  import { createTopBanner } from "./顶部广告组件.js";
13
  import { uploadFile } from "../market/发布内容_提交引擎.js"; // 🖼️ 带压缩管线的统一上传包装器
 
42
  });
43
 
44
  const titleSpan = document.createElement("div");
45
+ titleSpan.innerHTML = `<strong style='color:#4CAF50;'>ComfyUI</strong> ${t('nav.community')}`;
46
 
47
  Object.assign(titleSpan.style, { fontSize: "16px", cursor: "pointer", transition: "opacity 0.2s" });
48
  titleSpan.title = "查看关于本插件与作者信息";
 
105
  // 避免每次 createTopNav 重复 addEventListener 导致闭包累积与轮询失效
106
 
107
  chatEntryBtn.onclick = () => {
108
+ if (!currentUser) return showToast(t('auth.login_required'), "warning");
109
  openChatModal(currentUser);
110
  };
111
 
 
114
 
115
  const updateUserButtonState = () => {
116
  if (currentUser) {
117
+ userActionBtn.innerHTML = `👤 ${escapeHtml(currentUser.name || currentUser.account || t('common.unknown_user'))}`;
118
  userActionBtn.style.backgroundColor = "#2196F3";
119
  userActionBtn.style.borderColor = "#2196F3";
120
  userActionBtn.onclick = () => openUserProfileModal(currentUser);
 
138
  let userData; let token; let isRemember = false;
139
 
140
  if (formData.type === "reset") {
141
+ userActionBtn.innerHTML = `⏳ ${t('auth.resetting_password')}`;
142
 
143
  // 🚀 核心修复:停止错误地拆解字段导致 undefined,直接将包含所有数据的 formData 对象完整传给 API 装甲!
144
  await api.resetPassword(formData);
145
 
146
+ showToast(t('auth.password_reset_success'), "success");
147
  window.dispatchEvent(new CustomEvent("comfy-route-back")); updateUserButtonState(); return;
148
  }
149
  if (formData.type === "register") {
 
153
  delete formData.avatarFile;
154
  delete formData.avatarDataUrl; // base64 预览值会被后端拒绝,不随注册提交
155
 
156
+ userActionBtn.innerHTML = `⏳ ${t('auth.registering')}`;
157
  await api.register(formData);
158
+ showToast(t('auth.register_success') + t('auth.auto_login'), "success");
159
  // 注册后自动登录,默认保持登录
160
  const res = await api.login(formData.account, formData.password, true);
161
  userData = { account: formData.account, name: formData.name, avatar: res.avatar, ...res };
 
163
 
164
  // 🖼️ 登录成功后再上传头像(此时请求可携带 Authorization)
165
  if (pendingAvatarFile && token) {
166
+ userActionBtn.innerHTML = `⏳ ${t('auth.uploading_avatar')}`;
167
  // 提前写入 token,确保上传与资料更新请求能通过认证
168
  localStorage.setItem("ComfyCommunity_Token", token);
169
  try {
 
174
  userData = { ...userData, ...(updateRes.data || {}) };
175
  if (userData.avatarDataUrl) userData.avatar = userData.avatarDataUrl;
176
  } else {
177
+ showToast(t('auth.avatar_set_failed'), "warning");
178
  }
179
  } catch (avatarErr) {
180
  console.warn("注册后设置头像失败:", avatarErr.message || avatarErr);
181
+ showToast(t('auth.avatar_set_failed'), "warning");
182
  }
183
  }
184
  } else if (formData.type === "login") {
前端页面/core/侧边栏主程序.js CHANGED
@@ -125,7 +125,7 @@ export function buildSidebarDOM() {
125
  <option value="downloads">${noTr(t('market.downloads'))}</option>
126
  <option value="likes">${noTr(t('market.like'))}</option>
127
  <option value="favorites">${noTr(t('market.favorites'))}</option>
128
- <option value="tips">💰 ${noTr(t('market.tips_ranking') || '近期打赏榜')}</option>
129
  <option value="views">${noTr(t('market.views'))}</option>
130
  <option value="daily_views">${noTr(t('market.daily_views'))}</option>
131
  <option value="rating">${noTr(t('market.rating'))}</option>
@@ -156,7 +156,7 @@ export function buildSidebarDOM() {
156
  <option value="tips">${noTr(t('post.sort_tips'))}</option>
157
  <option value="views">${noTr(t('post.sort_views'))}</option>
158
  <option value="daily_views">${noTr(t('post.sort_daily_views'))}</option>
159
- <option value="rating">${noTr(t('post.sort_rating') || t('market.rating'))}</option>
160
  </select>
161
  <!-- 🧩 提示词排序控件(专用) -->
162
  <select id="prompts-sort-select" style="display: none; background: var(--comfy-input-bg); color: white; border: 1px solid #555; border-radius: 4px; outline: none; padding: 6px; width: 140px; flex-shrink: 0;">
@@ -300,13 +300,13 @@ export function buildSidebarDOM() {
300
  // 🔴 修复:编辑时先获取详情API数据,确保 has_private_token 等字段完整(列表缓存可能缺少该字段)
301
  _onSidebarWindow("comfy-route-edit-publish", async (e) => {
302
  // 📱 Web 模式中央兜底:发布族功能仅本地 ComfyUI 可用
303
- if (IS_WEB_MODE) return showToast("⚠️ 网页版暂不支持发布/编辑功能", "warning");
304
  const { itemData, currentUser } = e.detail;
305
 
306
  // 先展示加载状态,避免用户感知延迟
307
  const loadingView = document.createElement("div");
308
  loadingView.style.cssText = "display:flex;flex-direction:column;align-items:center;justify-content:center;height:400px;color:#888;font-size:14px;";
309
- loadingView.innerHTML = `<div style=\"font-size:24px;margin-bottom:10px;\">⏳</div><div>${t('common.loading') || '加载中...'}</div>`;
310
  showInlineView(loadingView);
311
 
312
  // 从详情API获取完整数据(确保 has_private_token、netdisk_password 等字段不缺失)
@@ -331,7 +331,7 @@ export function buildSidebarDOM() {
331
 
332
  // 监听进入任务编辑页面的请求
333
  _onSidebarWindow("comfy-route-edit-task", (e) => {
334
- if (IS_WEB_MODE) return showToast("⚠️ 网页版暂不支持发布/编辑功能", "warning");
335
  const { taskData, currentUser } = e.detail;
336
  try {
337
  const view = createPublishTaskView(currentUser, taskData);
@@ -343,7 +343,7 @@ export function buildSidebarDOM() {
343
 
344
  // 监听进入帖子编辑页面的请求
345
  _onSidebarWindow("comfy-route-edit-post", (e) => {
346
- if (IS_WEB_MODE) return showToast("⚠️ 网页版暂不支持发布/编辑功能", "warning");
347
  const { postData, currentUser } = e.detail;
348
  const view = createPublishPostView(currentUser, postData);
349
  showInlineView(view);
@@ -351,7 +351,7 @@ export function buildSidebarDOM() {
351
 
352
  // 监听进入提示词编辑页面的请求
353
  _onSidebarWindow("comfy-route-edit-prompt", (e) => {
354
- if (IS_WEB_MODE) return showToast("⚠️ 网页版暂不支持发布/编辑功能", "warning");
355
  const { promptData, currentUser } = e.detail;
356
  const view = createPublishPromptView(currentUser, promptData);
357
  showInlineView(view);
 
125
  <option value="downloads">${noTr(t('market.downloads'))}</option>
126
  <option value="likes">${noTr(t('market.like'))}</option>
127
  <option value="favorites">${noTr(t('market.favorites'))}</option>
128
+ <option value="tips">💰 ${noTr(t('market.tips_ranking'))}</option>
129
  <option value="views">${noTr(t('market.views'))}</option>
130
  <option value="daily_views">${noTr(t('market.daily_views'))}</option>
131
  <option value="rating">${noTr(t('market.rating'))}</option>
 
156
  <option value="tips">${noTr(t('post.sort_tips'))}</option>
157
  <option value="views">${noTr(t('post.sort_views'))}</option>
158
  <option value="daily_views">${noTr(t('post.sort_daily_views'))}</option>
159
+ <option value="rating">${noTr(t('post.sort_rating'))}</option>
160
  </select>
161
  <!-- 🧩 提示词排序控件(专用) -->
162
  <select id="prompts-sort-select" style="display: none; background: var(--comfy-input-bg); color: white; border: 1px solid #555; border-radius: 4px; outline: none; padding: 6px; width: 140px; flex-shrink: 0;">
 
300
  // 🔴 修复:编辑时先获取详情API数据,确保 has_private_token 等字段完整(列表缓存可能缺少该字段)
301
  _onSidebarWindow("comfy-route-edit-publish", async (e) => {
302
  // 📱 Web 模式中央兜底:发布族功能仅本地 ComfyUI 可用
303
+ if (IS_WEB_MODE) return showToast(t('web.publish_not_supported'), "warning");
304
  const { itemData, currentUser } = e.detail;
305
 
306
  // 先展示加载状态,避免用户感知延迟
307
  const loadingView = document.createElement("div");
308
  loadingView.style.cssText = "display:flex;flex-direction:column;align-items:center;justify-content:center;height:400px;color:#888;font-size:14px;";
309
+ loadingView.innerHTML = `<div style=\"font-size:24px;margin-bottom:10px;\">⏳</div><div>${t('common.loading')}</div>`;
310
  showInlineView(loadingView);
311
 
312
  // 从详情API获取完整数据(确保 has_private_token、netdisk_password 等字段不缺失)
 
331
 
332
  // 监听进入任务编辑页面的请求
333
  _onSidebarWindow("comfy-route-edit-task", (e) => {
334
+ if (IS_WEB_MODE) return showToast(t('web.publish_not_supported'), "warning");
335
  const { taskData, currentUser } = e.detail;
336
  try {
337
  const view = createPublishTaskView(currentUser, taskData);
 
343
 
344
  // 监听进入帖子编辑页面的请求
345
  _onSidebarWindow("comfy-route-edit-post", (e) => {
346
+ if (IS_WEB_MODE) return showToast(t('web.publish_not_supported'), "warning");
347
  const { postData, currentUser } = e.detail;
348
  const view = createPublishPostView(currentUser, postData);
349
  showInlineView(view);
 
351
 
352
  // 监听进入提示词编辑页面的请求
353
  _onSidebarWindow("comfy-route-edit-prompt", (e) => {
354
+ if (IS_WEB_MODE) return showToast(t('web.publish_not_supported'), "warning");
355
  const { promptData, currentUser } = e.detail;
356
  const view = createPublishPromptView(currentUser, promptData);
357
  showInlineView(view);
前端页面/core/侧边栏数据引擎.js CHANGED
@@ -18,6 +18,9 @@
18
 
19
  import { api } from "./网络请求API.js";
20
  import { proxyImages } from "./网络请求API.js";
 
 
 
21
  import { createItemCard } from "../market/列表卡片组件.js";
22
  import { createCreatorCard } from "../market/创作者卡片组件.js";
23
  import {
@@ -29,6 +32,7 @@ import {
29
  } from "../components/性能优化工具.js";
30
  import { applyViewportAnimations, getAnimationTypeForTab } from "../components/动画音效引擎.js";
31
  import { t } from "../components/用户体验增强.js";
 
32
 
33
  // 💬 讨论区组件(动态导入)
34
  let postsViewModule = null;
@@ -354,15 +358,25 @@ export async function loadSidebarContent({
354
  state.loader = null; // 清除引用,防止旧分页器状态残留
355
  }
356
 
357
- // ========== 渲染函数:渲染一批数据 ==========
 
 
 
 
 
 
 
 
 
 
 
 
 
358
  const renderBatch = (dataArray, append = false) => {
359
  if (renderToken !== getRenderToken()) return;
360
 
361
- // 搜索过滤(创作者Tab有keyword时不进行本地过滤,因为后端已过滤)
362
- let displayData = dataArray;
363
- if (keyword && tab !== "creators") {
364
- displayData = _filterBySearch(dataArray, keyword);
365
- }
366
 
367
  // 首次渲染清空容器
368
  if (!append) {
@@ -449,11 +463,8 @@ export async function loadSidebarContent({
449
  const start = (page - 1) * size;
450
  const end = start + size;
451
 
452
- // 从全量数据中截取(创作者Tab有keyword时不进行本地过滤,因为后端已过滤)
453
- let dataSlice = state.allData;
454
- if (keyword && tab !== "creators") {
455
- dataSlice = _filterBySearch(state.allData, keyword);
456
- }
457
 
458
  const batch = dataSlice.slice(start, end);
459
 
@@ -465,25 +476,35 @@ export async function loadSidebarContent({
465
  };
466
 
467
  // ========== 尝试从缓存加载 ==========
 
 
 
 
 
 
468
  // 获取完整缓存信息(包含过期状态)
469
  const { value: cachedData, expired: isCacheExpired, found: hasCacheData } = getCacheWithMeta(cacheKey, true);
470
 
471
  // 有缓存时直接渲染缓存(搜索时也使用缓存,renderBatch 会处理 keyword 过滤)
472
  // 🔍 创作者Tab有keyword时不使用缓存,每次都从后端搜索
473
- // 🔍 如果当前state是搜索结果且keyword为空,跳过缓存,强制从网络重新加载全量数据
474
- const skipCache = (tab === "creators" && keyword) || (tab === "creators" && !keyword && state.isSearchResult);
 
 
475
  if (!force && hasCacheData && !skipCache) {
476
  // 🚀 缓存数据也需要过一遍图片代理,确保新字段也被处理
477
  const proxiedData = proxyImages(cachedData);
478
  state.allData = proxiedData;
479
  state.isFullyLoaded = true;
 
 
480
 
481
- // 首屏渲染(仅第一页)
482
- const firstPage = proxiedData.slice(0, pageSize);
483
- renderBatch(firstPage, false);
484
 
485
  // 如果有更多数据,启动分页加载器
486
- if (proxiedData.length > pageSize) {
487
  _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
488
  }
489
 
@@ -531,15 +552,22 @@ export async function loadSidebarContent({
531
 
532
  // 完整重新渲染(替代之前只更新数字的方案)
533
  contentArea.innerHTML = "";
534
- const firstPage = newData.slice(0, pageSize);
535
- renderBatch(firstPage, false);
 
 
536
 
537
- if (newData.length > pageSize) {
538
  _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, savedTab);
539
  }
540
  } else {
541
- // 数据无变化,仅刷新缓存过期时间
542
- setCache(cacheKey, state.allData, getCacheExpireTime(), true);
 
 
 
 
 
543
  }
544
  } catch (err) {
545
  console.warn(`⚠️ ${savedTab}_${savedSort} 后台刷新失败:`, err);
@@ -565,12 +593,12 @@ export async function loadSidebarContent({
565
  state.isFullyLoaded = true;
566
  state.displayedCount = 0; // 重置已显示计数,因为数据重新排序了
567
 
568
- // 首屏渲染
569
- const firstPage = locallySorted.slice(0, pageSize);
570
- renderBatch(firstPage, false);
571
 
572
  // 启动分页加载器
573
- if (locallySorted.length > pageSize) {
574
  _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
575
  }
576
 
@@ -580,10 +608,45 @@ export async function loadSidebarContent({
580
  return; // 本地排序后直接返回,不需要后台刷新(数据是同一批)
581
  }
582
 
583
- // ========== 显示加载骨架屏 ==========
584
- contentArea.innerHTML = "";
585
- const skeleton = createSkeleton(tab === "creators" ? "list" : "card", 3);
586
- contentArea.appendChild(skeleton);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
587
 
588
  // ========== 从网络加载数据 ==========
589
  try {
@@ -597,6 +660,11 @@ export async function loadSidebarContent({
597
  } else if (tab === "creators") {
598
  // 🔍 创作者搜索:有 keyword 时调用后端搜索API,否则获取列表
599
  if (keyword) {
 
 
 
 
 
600
  response = await api.searchCreators(keyword, sort);
601
  realData = response.data || [];
602
  realData = proxyImages(realData); // 确保图片走本地缓存代理
@@ -622,37 +690,67 @@ export async function loadSidebarContent({
622
  saveCreatorsToSessionStorage(realData);
623
  }
624
 
 
 
 
 
 
 
 
 
 
625
  // 更新状态
626
  state.allData = realData;
627
- state.isFullyLoaded = true;
628
 
629
- // 渲染首屏
630
- const firstPage = realData.slice(0, pageSize);
631
- renderBatch(firstPage, false);
 
632
 
633
  // 启动分页加载器
634
- if (realData.length > pageSize) {
635
  _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
636
  }
637
 
638
  } catch (error) {
 
 
 
 
 
 
 
 
 
 
 
 
 
639
  console.error("数据加载失败:", error);
640
 
 
 
 
 
 
 
 
 
641
  // 🚀 回退到任何可用缓存(包括过期的)
642
  // 🔍 创作者Tab搜索场景跳过此分支:renderBatch 对创作者不做本地过滤,
643
  // 直接渲染全量缓存会导致搜索结果未过滤,改由下方本地搜索降级分支处理
644
  if (hasCacheData && cachedData && !(tab === "creators" && keyword)) {
645
  console.warn(`📴 网络失败,降级显示${isCacheExpired ? '过期' : ''}缓存`);
646
  state.allData = cachedData;
647
- renderBatch(cachedData.slice(0, pageSize), false);
 
 
 
648
 
649
  // 如果是过期缓存,显示提示
 
650
  if (isCacheExpired) {
651
- const toastDiv = document.createElement('div');
652
- toastDiv.style.cssText = 'position:fixed; top:20px; left:50%; transform:translateX(-50%); background:#FF9800; color:white; padding:10px 20px; border-radius:4px; z-index:10000; font-size:14px;';
653
- toastDiv.textContent = '⚠️ 网络连接失败,展示的是上次缓存的数据';
654
- document.body.appendChild(toastDiv);
655
- setTimeout(() => toastDiv.remove(), 3000);
656
  }
657
  return;
658
  }
@@ -691,11 +789,8 @@ export async function loadSidebarContent({
691
  </div>
692
  `;
693
 
694
- const toastDiv = document.createElement('div');
695
- toastDiv.style.cssText = 'position:fixed; top:20px; left:50%; transform:translateX(-50%); background:#FF9800; color:white; padding:10px 20px; border-radius:4px; z-index:10000; font-size:14px;';
696
- toastDiv.textContent = '⚠️ 网络不可用,显示本地搜索结果';
697
- document.body.appendChild(toastDiv);
698
- setTimeout(() => toastDiv.remove(), 3000);
699
  return;
700
  }
701
  }
@@ -703,26 +798,24 @@ export async function loadSidebarContent({
703
  // 按当前排序排序
704
  displayData = sortDataLocally(displayData, tab, sort);
705
  state.allData = displayData;
706
- renderBatch(displayData.slice(0, pageSize), false);
 
 
 
707
 
708
  // 启动分页加载器
709
- if (displayData.length > pageSize) {
710
  _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
711
  }
712
 
713
- const toastDiv = document.createElement('div');
714
- toastDiv.style.cssText = 'position:fixed; top:20px; left:50%; transform:translateX(-50%); background:#FF9800; color:white; padding:10px 20px; border-radius:4px; z-index:10000; font-size:14px;';
715
- toastDiv.textContent = keyword
716
- ? '⚠️ 网络不可用,显示本地搜索结果'
717
- : '⚠️ 网络连接失败,展示的是本地缓存的数据';
718
- document.body.appendChild(toastDiv);
719
- setTimeout(() => toastDiv.remove(), 3000);
720
  return;
721
  } else if (keyword) {
722
  contentArea.innerHTML = '';
723
  const emptyDiv = document.createElement('div');
724
  emptyDiv.style.cssText = 'text-align:center; padding: 40px 20px; color:#888;';
725
- emptyDiv.textContent = `🔌 ${t('common.network_error_retry') || '网络连接失败,请稍后重试'}`;
726
  contentArea.appendChild(emptyDiv);
727
  return;
728
  }
@@ -767,24 +860,24 @@ function _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyw
767
  // 添加手动加载按钮作为 fallback
768
  let nextPage = 2;
769
  const loadMoreBtn = document.createElement('button');
770
- loadMoreBtn.textContent = t('task.load_more') || '加载更多';
771
  loadMoreBtn.style.cssText = 'width:100%;padding:10px;margin-top:10px;cursor:pointer;border:1px solid #555;border-radius:6px;background:transparent;color:inherit;';
772
  loadMoreBtn.onclick = async () => {
773
  loadMoreBtn.disabled = true;
774
- loadMoreBtn.textContent = t('common.loading') || '加载中...';
775
  try {
776
  const result = await loadMoreData(nextPage, pageSize);
777
  if (result && result.length > 0) {
778
  nextPage++;
779
- loadMoreBtn.textContent = t('task.load_more') || '加载更多';
780
  } else {
781
- loadMoreBtn.textContent = t('common.no_more') || '没有更多了';
782
  loadMoreBtn.style.cursor = 'default';
783
  loadMoreBtn.onclick = null;
784
  }
785
  } catch (err) {
786
  console.error("手动加载更多失败:", err);
787
- loadMoreBtn.textContent = t('task.load_failed') || '加载失败,点击重试';
788
  }
789
  loadMoreBtn.disabled = false;
790
  };
@@ -793,6 +886,8 @@ function _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyw
793
  }
794
 
795
  // 计算数据总量(考虑搜索过滤,创作者Tab有keyword时不进行本地过滤,因为后端已过滤)
 
 
796
  const getTotalDataCount = () => {
797
  if (!keyword) return state.allData.length;
798
  // 创作者Tab后端搜索已过滤,直接返回全部数据
@@ -853,7 +948,8 @@ function _shouldUpdateData(oldData, newData) {
853
  if (oldData.length !== newData.length) return true;
854
 
855
  // 检查ID顺序是否一致(检测排序变化、新增/删除)
856
- const checkCount = Math.min(10, oldData.length);
 
857
  for (let i = 0; i < checkCount; i++) {
858
  const oldId = oldData[i].id || oldData[i].account;
859
  const newId = newData[i].id || newData[i].account;
@@ -864,6 +960,10 @@ function _shouldUpdateData(oldData, newData) {
864
  for (const field of keyFields) {
865
  if ((oldData[i][field] || 0) !== (newData[i][field] || 0)) return true;
866
  }
 
 
 
 
867
  }
868
  return false;
869
  }
 
18
 
19
  import { api } from "./网络请求API.js";
20
  import { proxyImages } from "./网络请求API.js";
21
+ // 🔍 搜索时撤销上一次在途请求所需的取消管理器与分组 ID
22
+ import { requestCancelManager } from "./网络请求API.js";
23
+ import { API } from "./全局配置.js";
24
  import { createItemCard } from "../market/列表卡片组件.js";
25
  import { createCreatorCard } from "../market/创作者卡片组件.js";
26
  import {
 
32
  } from "../components/性能优化工具.js";
33
  import { applyViewportAnimations, getAnimationTypeForTab } from "../components/动画音效引擎.js";
34
  import { t } from "../components/用户体验增强.js";
35
+ import { showToast } from "../components/UI交互提示组件.js"; // 🔧 降级提示改走统一组件(原为手写 div)
36
 
37
  // 💬 讨论区组件(动态导入)
38
  let postsViewModule = null;
 
358
  state.loader = null; // 清除引用,防止旧分页器状态残留
359
  }
360
 
361
+ // ========== 🔍 搜索过滤统一入口 ==========
362
+ // 🔧 修复:过滤职责原先分裂在三处且口径不一致 —— loadMoreData 与分页器的
363
+ // getTotalDataCount 都是「先过滤再切片」(正确),而各首屏分支是「先切 20 条再过滤」,
364
+ // renderBatch 内部又对切好的批次再过滤一次。后果不只是首屏条数偏少:首屏与分页
365
+ // 处于不同的数据空间(首屏在「原始前 20 条」里找匹配,分页在「过滤后全集」里按 20
366
+ // 步长切片),排在第 20 条之后的匹配项会永久丢失,甚至首屏直接显示「没有搜索到相关内容」。
367
+ // 现统一收口到本函数:所有取数路径都先过滤、再切片,renderBatch 只负责渲染。
368
+ // 创作者Tab的 keyword 由后端搜索接口过滤,此处不做本地过滤(与原口径一致)。
369
+ const _applySearchScope = (dataArray) => {
370
+ if (!keyword || tab === "creators") return dataArray;
371
+ return _filterBySearch(dataArray, keyword);
372
+ };
373
+
374
+ // ========== 渲染函数:渲染一批数据(只渲染,过滤由 _applySearchScope 统一负责)==========
375
  const renderBatch = (dataArray, append = false) => {
376
  if (renderToken !== getRenderToken()) return;
377
 
378
+ // 🔧 数据已由调用方经 _applySearchScope 过滤,本函数只负责渲染(不再二次过滤)
379
+ const displayData = dataArray;
 
 
 
380
 
381
  // 首次渲染清空容器
382
  if (!append) {
 
463
  const start = (page - 1) * size;
464
  const end = start + size;
465
 
466
+ // 从全量数据中截取(统一走 _applySearchScope,与首屏处于同一数据空间)
467
+ const dataSlice = _applySearchScope(state.allData);
 
 
 
468
 
469
  const batch = dataSlice.slice(start, end);
470
 
 
476
  };
477
 
478
  // ========== 尝试从缓存加载 ==========
479
+ // 📌 本处的缓存口径与 任务榜/讨论区/提示词(统一走 性能优化工具.js 的
480
+ // readListCache,命中后一律调 silentRefresh 后台补新)**刻意不同**,不是历史遗留,勿顺手归一:
481
+ // - 这里缓存的是全量数据(items 200 条 / creators 500 条),分页完全在本地做,
482
+ // 所以缓存命中即等于「数据完整」,只在 expired 时才值得再发一次网络请求;
483
+ // - 那三个列表只缓存第一页,命中后数据必然不完整,故一律后台 silentRefresh 补新。
484
+ // 若强行统一成「总是后台刷新」,插件榜会在每次 Tab 切换时多发一次 200 条的全量请求。
485
  // 获取完整缓存信息(包含过期状态)
486
  const { value: cachedData, expired: isCacheExpired, found: hasCacheData } = getCacheWithMeta(cacheKey, true);
487
 
488
  // 有缓存时直接渲染缓存(搜索时也使用缓存,renderBatch 会处理 keyword 过滤)
489
  // 🔍 创作者Tab有keyword时不使用缓存,每次都从后端搜索
490
+ // 🔧 修复:原实现还把「刚清空搜索框」也列为跳过缓存的理由,但 cacheKey 不含 keyword、
491
+ // 搜索结果也从不写入该键(见下方 isCreatorSearch 判断),缓存里存的始终是干净的全量列表,
492
+ // 清空搜索框后完全可以直接用缓存上屏;强制联网只会让用户在弱网下白等一整个重试预算
493
+ const skipCache = tab === "creators" && !!keyword;
494
  if (!force && hasCacheData && !skipCache) {
495
  // 🚀 缓存数据也需要过一遍图片代理,确保新字段也被处理
496
  const proxiedData = proxyImages(cachedData);
497
  state.allData = proxiedData;
498
  state.isFullyLoaded = true;
499
+ // 缓存是全量列表而非搜索结果,清除搜索标记,避免下次加载仍被当作搜索结果处理
500
+ if (tab === "creators" && !keyword) state.isSearchResult = false;
501
 
502
+ // 首屏渲染(仅第一页)—— 先过滤再切片,与 loadMoreData 同一数据空间
503
+ const scopedData = _applySearchScope(proxiedData);
504
+ renderBatch(scopedData.slice(0, pageSize), false);
505
 
506
  // 如果有更多数据,启动分页加载器
507
+ if (scopedData.length > pageSize) {
508
  _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
509
  }
510
 
 
552
 
553
  // 完整重新渲染(替代之前只更新数字的方案)
554
  contentArea.innerHTML = "";
555
+ // 🔧 先过滤再切片:搜索场景下静默刷新同样会触发,
556
+ // 原实现在 newData 前 20 条里过滤,会漏掉靠后的匹配项
557
+ const scopedNewData = _applySearchScope(newData);
558
+ renderBatch(scopedNewData.slice(0, pageSize), false);
559
 
560
+ if (scopedNewData.length > pageSize) {
561
  _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, savedTab);
562
  }
563
  } else {
564
+ // 关键字段无变化 → 无需重渲染。但 newData 才是刚从云端取回的权威数据,
565
+ // 缓存必须回写 newData:若仍回写旧的 state.allData,等于把旧数据固化并
566
+ // 续期(续期后 isCacheExpired 转 false,下次连静默刷新都不再触发),
567
+ // 导致缓存中的旧版本号被长期锁死、卡片徽章无法出现。
568
+ // 注意此处刻意不更新 state.allData:首屏已按旧数据渲染,保持二者一致可
569
+ // 避免分页加载与已渲染卡片错位;新数据在下次进入读取缓存时生效
570
+ setCache(cacheKey, newData, getCacheExpireTime(), true);
571
  }
572
  } catch (err) {
573
  console.warn(`⚠️ ${savedTab}_${savedSort} 后台刷新失败:`, err);
 
593
  state.isFullyLoaded = true;
594
  state.displayedCount = 0; // 重置已显示计数,因为数据重新排序了
595
 
596
+ // 首屏渲染(先过滤再切片)
597
+ const scopedSorted = _applySearchScope(locallySorted);
598
+ renderBatch(scopedSorted.slice(0, pageSize), false);
599
 
600
  // 启动分页加载器
601
+ if (scopedSorted.length > pageSize) {
602
  _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
603
  }
604
 
 
608
  return; // 本地排序后直接返回,不需要后台刷新(数据是同一批)
609
  }
610
 
611
+ // ========== 🚀 缓存先行:阻塞前先用本地已有数据上屏 ==========
612
+ // 走到这里说明上面两条快路径都没命中:要么是 force 强制刷新(刻意跳过缓存),
613
+ // 要么是本次会话还没有该 tab 的完整数据。弱网下一次列表请求最长要烧满整个重试预算,
614
+ // 期间用户只能盯骨架屏;此刻若本地已有可用数据,就先渲染首屏,网络请求照常往下走,
615
+ // 回来后按 renderToken + _shouldUpdateData 决定是否重渲染(与上方静默刷新同口径)。
616
+ // 🔍 创作者搜索场景不参与:renderBatch 对创作者不做本地过滤,先上屏全量列表会混入非搜索结果
617
+ let renderedInstant = false;
618
+ if (!isCreatorSearching) {
619
+ // 可立即上屏的数据:当前排序的列表缓存(force 时被跳过)→ 创作者 sessionStorage 降级副本
620
+ let instantData = (hasCacheData && Array.isArray(cachedData) && cachedData.length > 0) ? cachedData : null;
621
+ if (!instantData && tab === "creators") {
622
+ const sessionData = getCreatorsFromSessionStorage();
623
+ if (Array.isArray(sessionData) && sessionData.length > 0) instantData = sessionData;
624
+ }
625
+ if (instantData) {
626
+ console.log(`🚀 ${tab}_${sort} 缓存先行:先用本地数据渲染首屏,网络请求转后台`);
627
+ // 🚀 与上方缓存命中分支同口径:本地数据也过一遍图片代理(proxyImages 内部会先剥除
628
+ // 已有代理前缀,幂等),确保代理规则新增的字段同样被处理
629
+ // sessionStorage 副本的顺序不保证与当前排序一致,统一本地排一次(对已排序数据幂等)
630
+ const sortedData = sortDataLocally(proxyImages(instantData), tab, sort);
631
+ state.allData = sortedData;
632
+ state.isFullyLoaded = true;
633
+ state.isSearchResult = false;
634
+ // 🔧 先过滤再切片(tools/apps/recommends 带 keyword 时同样会进入本分支)
635
+ const scopedInstant = _applySearchScope(sortedData);
636
+ renderBatch(scopedInstant.slice(0, pageSize), false);
637
+ if (scopedInstant.length > pageSize) {
638
+ _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
639
+ }
640
+ renderedInstant = true;
641
+ }
642
+ }
643
+
644
+ // ========== 显示加载骨架屏(已有本地数据上屏时跳过,避免闪屏)==========
645
+ if (!renderedInstant) {
646
+ contentArea.innerHTML = "";
647
+ const skeleton = createSkeleton(tab === "creators" ? "list" : "card", 3);
648
+ contentArea.appendChild(skeleton);
649
+ }
650
 
651
  // ========== 从网络加载数据 ==========
652
  try {
 
660
  } else if (tab === "creators") {
661
  // 🔍 创作者搜索:有 keyword 时调用后端搜索API,否则获取列表
662
  if (keyword) {
663
+ // 🔍 先撤销上一次仍在途的搜索请求:用户继续输入后,上一次的关键词已经作废,
664
+ // 但它的请求还会继续占用并发额度(全局最多 6 个)与最长 10 秒的超时预算,
665
+ // 把最新这次搜索挤到后面排队,弱网下表现为「一直在转圈」。
666
+ // 只撤销搜索分组,不影响列表、详情、点赞等任何其它请求
667
+ requestCancelManager.cancelAll(API.SEARCH_COMPONENT_ID);
668
  response = await api.searchCreators(keyword, sort);
669
  realData = response.data || [];
670
  realData = proxyImages(realData); // 确保图片走本地缓存代理
 
690
  saveCreatorsToSessionStorage(realData);
691
  }
692
 
693
+ state.isFullyLoaded = true;
694
+
695
+ // 🚀 缓存先行场景:网络数据与已上屏的本地数据无实质差异时不重渲染,避免清屏重画
696
+ // 打断用户已经滚动的阅读位置(与上方后台静默刷新同口径)。缓存已在上面回写权威新数据,
697
+ // 此处刻意不更新 state.allData,保持与已渲染卡片一致,防止分页加载错位
698
+ if (renderedInstant && !_shouldUpdateData(state.allData, realData)) {
699
+ return;
700
+ }
701
+
702
  // 更新状态
703
  state.allData = realData;
 
704
 
705
+ // 渲染首屏(先过滤再切片:tools/apps/recommends 的 keyword 是本地搜索,
706
+ // realData 是未过滤的全量数据,原实现在前 20 条里过滤会漏掉靠后的匹配项)
707
+ const scopedReal = _applySearchScope(realData);
708
+ renderBatch(scopedReal.slice(0, pageSize), false);
709
 
710
  // 启动分页加载器
711
+ if (scopedReal.length > pageSize) {
712
  _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
713
  }
714
 
715
  } catch (error) {
716
+ // 🔍 防竞态:本次加载已被更新的加载取代(切Tab、换排序、继续输入关键词)时直接退出。
717
+ // renderBatch 内部本来就有这道守卫,但它之外的降级分支会直接写 contentArea.innerHTML
718
+ // 并弹提示,缺少守卫会让过期结果覆盖掉新内容
719
+ if (renderToken !== getRenderToken()) {
720
+ console.warn(`⏹️ ${tab}_${sort} 请求已过期,忽略本次失败`);
721
+ return;
722
+ }
723
+ // 🔍 主动撤销(上方 cancelAll)不是网络故障:用户只是又敲了一个字,
724
+ // 新一次加载已经在跑了。这里必须静默退出,否则会误弹「网络不可用」提示
725
+ if (error && error.name === 'RequestCancelledError') {
726
+ console.log(`⏹️ ${tab}_${sort} 搜索请求已被新关键词取代`);
727
+ return;
728
+ }
729
  console.error("数据加载失败:", error);
730
 
731
+ // 🚀 缓存先行场景:首屏已经用本地数据上屏,网络失败时保持当前画面即可,不再走下面的
732
+ // 降级分支(否则会把同一批数据重渲染一遍、重播卡片动画)。与上方后台静默刷新失败一致:
733
+ // 只记录日志,不额外打扰用户
734
+ if (renderedInstant) {
735
+ console.warn(`📴 ${tab}_${sort} 网络刷新失败,保持已渲染的本地数据`);
736
+ return;
737
+ }
738
+
739
  // 🚀 回退到任何可用缓存(包括过期的)
740
  // 🔍 创作者Tab搜索场景跳过此分支:renderBatch 对创作者不做本地过滤,
741
  // 直接渲染全量缓存会导致搜索结果未过滤,改由下方本地搜索降级分支处理
742
  if (hasCacheData && cachedData && !(tab === "creators" && keyword)) {
743
  console.warn(`📴 网络失败,降级显示${isCacheExpired ? '过期' : ''}缓存`);
744
  state.allData = cachedData;
745
+ // 🔧 先过滤再切片:本分支已排除 creators+keyword,但 tools/apps/recommends
746
+ // 带 keyword 时会进来;renderBatch 不再自行过滤,此处必须显式过滤
747
+ const scopedCached = _applySearchScope(cachedData);
748
+ renderBatch(scopedCached.slice(0, pageSize), false);
749
 
750
  // 如果是过期缓存,显示提示
751
+ // 🔧 改用统一提示组件 + 词典(原为手写 div、无动画不排队,且文案硬编码中文)
752
  if (isCacheExpired) {
753
+ showToast(t('feedback.cache_fallback'), 'warning');
 
 
 
 
754
  }
755
  return;
756
  }
 
789
  </div>
790
  `;
791
 
792
+ // 🔧 改用统一提示组件 + 词典(原为手写 div 且文案硬编码中文)
793
+ showToast(t('feedback.search_local_result'), 'warning');
 
 
 
794
  return;
795
  }
796
  }
 
798
  // 按当前排序排序
799
  displayData = sortDataLocally(displayData, tab, sort);
800
  state.allData = displayData;
801
+ // 统一走 _applySearchScope(创作者Tab恒等返回,数据已由上方
802
+ // searchCreatorsLocally 过滤),保证各渲染入口口径完全一致
803
+ const scopedFallback = _applySearchScope(displayData);
804
+ renderBatch(scopedFallback.slice(0, pageSize), false);
805
 
806
  // 启动分页加载器
807
+ if (scopedFallback.length > pageSize) {
808
  _setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
809
  }
810
 
811
+ // 🔧 改用统一提示组件 + 词典(原为手写 div 且两条文案均硬编码中文)
812
+ showToast(keyword ? t('feedback.search_local_result') : t('feedback.cache_fallback'), 'warning');
 
 
 
 
 
813
  return;
814
  } else if (keyword) {
815
  contentArea.innerHTML = '';
816
  const emptyDiv = document.createElement('div');
817
  emptyDiv.style.cssText = 'text-align:center; padding: 40px 20px; color:#888;';
818
+ emptyDiv.textContent = `🔌 ${t('common.network_error_retry')}`;
819
  contentArea.appendChild(emptyDiv);
820
  return;
821
  }
 
860
  // 添加手动加载按钮作为 fallback
861
  let nextPage = 2;
862
  const loadMoreBtn = document.createElement('button');
863
+ loadMoreBtn.textContent = t('task.load_more');
864
  loadMoreBtn.style.cssText = 'width:100%;padding:10px;margin-top:10px;cursor:pointer;border:1px solid #555;border-radius:6px;background:transparent;color:inherit;';
865
  loadMoreBtn.onclick = async () => {
866
  loadMoreBtn.disabled = true;
867
+ loadMoreBtn.textContent = t('common.loading');
868
  try {
869
  const result = await loadMoreData(nextPage, pageSize);
870
  if (result && result.length > 0) {
871
  nextPage++;
872
+ loadMoreBtn.textContent = t('task.load_more');
873
  } else {
874
+ loadMoreBtn.textContent = t('common.no_more');
875
  loadMoreBtn.style.cursor = 'default';
876
  loadMoreBtn.onclick = null;
877
  }
878
  } catch (err) {
879
  console.error("手动加载更多失败:", err);
880
+ loadMoreBtn.textContent = t('task.load_failed');
881
  }
882
  loadMoreBtn.disabled = false;
883
  };
 
886
  }
887
 
888
  // 计算数据总量(考虑搜索过滤,创作者Tab有keyword时不进行本地过滤,因为后端已过滤)
889
+ // 📌 口径必须与 loadSidebarContent 内的 _applySearchScope 一致(先过滤再计数);
890
+ // 本函数是模块级、拿不到那个闭包,故保留等价实现,改动其一务必同步另一处
891
  const getTotalDataCount = () => {
892
  if (!keyword) return state.allData.length;
893
  // 创作者Tab后端搜索已过滤,直接返回全部数据
 
948
  if (oldData.length !== newData.length) return true;
949
 
950
  // 检查ID顺序是否一致(检测排序变化、新增/删除)
951
+ // 比对条数覆盖首屏渲染量(PAGE_SIZE),避免首屏可见卡片(第 11~20 条)的版本/数字更新被漏检
952
+ const checkCount = Math.min(PAGE_SIZE, oldData.length);
953
  for (let i = 0; i < checkCount; i++) {
954
  const oldId = oldData[i].id || oldData[i].account;
955
  const newId = newData[i].id || newData[i].account;
 
960
  for (const field of keyFields) {
961
  if ((oldData[i][field] || 0) !== (newData[i][field] || 0)) return true;
962
  }
963
+
964
+ // 检查版本号(字符串):云端扫描到插件新版本时往往只有 latest_version 变化,
965
+ // 点赞/下载/浏览等数字字段全不动;若不比对它,卡片上的"可更新"徽章将永远刷不出来
966
+ if ((oldData[i].latest_version || '') !== (newData[i].latest_version || '')) return true;
967
  }
968
  return false;
969
  }
前端页面/core/全局配置.js CHANGED
@@ -18,7 +18,7 @@
18
  // 🌐 API 配置
19
  // ==========================================
20
 
21
- /** @type {{ BASE_URL: string, TIMEOUT: number, MAX_RETRIES: number, RETRY_DELAY: number }} */
22
  export const API = {
23
  // 云端 API 基础地址
24
  BASE_URL: "https://zhiwei666-comfyui-ranking-api.hf.space",
@@ -26,11 +26,29 @@ export const API = {
26
  // 请求超时时间(毫秒)
27
  TIMEOUT: 30000,
28
 
29
- // 重试次数
30
- MAX_RETRIES: 3,
31
-
32
- // 重试间隔(毫秒)
33
- RETRY_DELAY: 1000
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  };
35
 
36
 
@@ -425,6 +443,28 @@ const PROFILE_CACHE_PREFIX = "ComfyCommunity_ProfileCache_";
425
  const _pendingProfileRequests = new Map(); // 防止 N+1 并发请求
426
  const PENDING_TIMEOUT = 30000; // 挂起请求超时清理时间(毫秒)
427
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  /** 统一字段映射:将后端 avatarDataUrl 映射为前端 avatar */
429
  function _mapAvatarField(profile) {
430
  if (profile && profile.avatarDataUrl && !profile.avatar) {
@@ -540,12 +580,14 @@ export function renderAvatarWithSWR(options) {
540
  }
541
 
542
  // 生成初始 HTML(无头像时显示首字母背景)
 
 
543
  const initial = (name || account || 'U')[0].toUpperCase();
544
  const avatarHtml = avatar
545
- ? `<img class="swr-avatar" src="${avatar}" style="width: ${avatarSize}px; height: ${avatarSize}px; border-radius: 50%; object-fit: cover; flex-shrink: 0; background: #333;">`
546
- : `<div class="swr-avatar" style="width: ${avatarSize}px; height: ${avatarSize}px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: ${Math.floor(avatarSize * 0.5)}px; font-weight: bold; flex-shrink: 0;">${initial}</div>`;
547
 
548
- return `<span id="${containerId}" style="display: inline-flex; align-items: center; gap: 6px;">${avatarHtml}<span class="swr-name">${name}</span></span>`;
549
  }
550
 
551
 
 
18
  // 🌐 API 配置
19
  // ==========================================
20
 
21
+ /** @type {{ BASE_URL: string, TIMEOUT: number, MAX_RETRIES: number, RETRY_DELAY: number, LIST_RETRIES: number, SEARCH_TIMEOUT: number, SEARCH_RETRIES: number, SEARCH_COMPONENT_ID: string }} */
22
  export const API = {
23
  // 云端 API 基础地址
24
  BASE_URL: "https://zhiwei666-comfyui-ranking-api.hf.space",
 
26
  // 请求超时时间(毫秒)
27
  TIMEOUT: 30000,
28
 
29
+ // GET 请求重试次数(实际尝试 MAX_RETRIES + 1 次)
30
+ // 🔧 修复:此前本值为 3 且全库无人读取,真正的重试次数硬编码在 网络请求_基础设施.js 中为 2,
31
+ // 现改为由基础设施读取本配置;取值 2 以保持既有的「共 3 次尝试」行为不变
32
+ MAX_RETRIES: 2,
33
+
34
+ // 重试间隔(毫秒,指数退避基数:1s、2s、4s…)
35
+ RETRY_DELAY: 1000,
36
+
37
+ // 🐢 弱网预算分级(仅作用于 GET,由 网络请求_基础设施.js 按 endpoint 路径精确匹配)
38
+ // 默认预算下弱网一次列表请求最长要烧 3×30s + 1s + 2s = 93s,期间用户只能盯骨架屏。
39
+ // 列表类:首屏已可由本地缓存先行上屏(见 侧边栏数据引擎.js),且列表往往是会话的第一个请求、
40
+ // 可能撞上 HF Space 冷启动(本项目无预热机制),故只砍掉一次冗余重试、保留 30s 单次超时,
41
+ // 93s → 61s,冷启动仍有机会在前两次尝试内完成
42
+ LIST_RETRIES: 1,
43
+ // 搜索类:必然发生在列表已加载之后(空间已被唤醒),可放心收紧,93s → 10s×2 + 1s = 21s
44
+ SEARCH_TIMEOUT: 10000,
45
+ SEARCH_RETRIES: 1,
46
+
47
+ // 🔍 创作者搜索请求的取消分组 ID
48
+ // 用户在搜索框继续输入时,侧边栏数据引擎.js 据此撤销上一次仍在途的搜索请求。
49
+ // 不撤销的话,作废请求会持续占用并发额度(最多 6 个)与超时预算,
50
+ // 把最新那次搜索挤到后面排队。仅搜索请求归入本分组,不影响其它任何请求
51
+ SEARCH_COMPONENT_ID: "sidebar-search"
52
  };
53
 
54
 
 
443
  const _pendingProfileRequests = new Map(); // 防止 N+1 并发请求
444
  const PENDING_TIMEOUT = 30000; // 挂起请求超时清理时间(毫秒)
445
 
446
+ // ==========================================
447
+ // 🔒 HTML 转义(底层公共工具)
448
+ // ==========================================
449
+ // 放在本文件(零依赖的底层模块)而非 components/互动工具函数.js,是因为
450
+ // UI交互提示组件.js 与 打赏等级工具.js 都需要它,而互动工具函数.js 反向依赖这两者
451
+ // (导入 showToast、re-export renderTipBoardHTML),从那里导入会形成循环依赖。
452
+ // 互动工具函数.js 仍以 re-export 方式对外暴露同名函数,既有调用方无需改动。
453
+
454
+ /**
455
+ * HTML转义(统一版:各组件局部副本已归一到此)
456
+ * @param {*} str - 原始字符串(非字符串会先强制转换,兼容数字等入参)
457
+ * @returns {string} 转义后的字符串
458
+ */
459
+ export function escapeHtml(str) {
460
+ if (str === null || str === undefined || str === "") return "";
461
+ return String(str).replace(/&/g, "&amp;")
462
+ .replace(/</g, "&lt;")
463
+ .replace(/>/g, "&gt;")
464
+ .replace(/"/g, "&quot;")
465
+ .replace(/'/g, "&#039;");
466
+ }
467
+
468
  /** 统一字段映射:将后端 avatarDataUrl 映射为前端 avatar */
469
  function _mapAvatarField(profile) {
470
  if (profile && profile.avatarDataUrl && !profile.avatar) {
 
580
  }
581
 
582
  // 生成初始 HTML(无头像时显示首字母背景)
583
+ // 🔒 XSS防护:avatar/name/initial 均来自用户可修改的资料(昵称首字母可能是 < ),
584
+ // 写入 HTML 前必须转义;上方 SWR 刷新路径用的是 .src / .textContent,本身就是安全的。
585
  const initial = (name || account || 'U')[0].toUpperCase();
586
  const avatarHtml = avatar
587
+ ? `<img class="swr-avatar" src="${escapeHtml(avatar)}" style="width: ${avatarSize}px; height: ${avatarSize}px; border-radius: 50%; object-fit: cover; flex-shrink: 0; background: #333;">`
588
+ : `<div class="swr-avatar" style="width: ${avatarSize}px; height: ${avatarSize}px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: ${Math.floor(avatarSize * 0.5)}px; font-weight: bold; flex-shrink: 0;">${escapeHtml(initial)}</div>`;
589
 
590
+ return `<span id="${escapeHtml(containerId)}" style="display: inline-flex; align-items: center; gap: 6px;">${avatarHtml}<span class="swr-name">${escapeHtml(name)}</span></span>`;
591
  }
592
 
593
 
前端页面/core/状态管理.js CHANGED
@@ -2,12 +2,13 @@
2
  // ==========================================
3
  // 🏪 全局状态管理中心
4
  // ==========================================
5
- // 作用:集中管理应用状态,提供响应式更新和事件通知
6
  // 关联文件:
7
  // - 类型定义.js (类型定义)
8
  // - 所有需要共享状态的组件
9
  // - 顶部导航组件.js (用户登录状态)
10
  // - 侧边栏主程序.js (当前视图状态)
 
11
  // ==========================================
12
  // 🏗️ P2架构优化:轻量级状态管理
13
  // 🏗️ P2质量优化:JSDoc 类型注释
@@ -53,10 +54,9 @@ const state = {
53
  users: new Map()
54
  },
55
 
56
- // 网络状态
57
  network: {
58
- isOnline: navigator.onLine,
59
- lastSyncTime: null
60
  }
61
  };
62
 
@@ -432,9 +432,15 @@ export function isLoading() {
432
  // ==========================================
433
  // 🌐 网络状态管理
434
  // ==========================================
 
 
 
 
 
 
435
 
436
  /**
437
- * 更新网络状态
438
  * @param {boolean} online - 是否在线
439
  */
440
  export function setNetworkStatus(online) {
@@ -442,19 +448,23 @@ export function setNetworkStatus(online) {
442
  state.network.isOnline = online;
443
 
444
  if (wasOnline !== online) {
 
445
  eventBus.emit(EVENTS.NETWORK_CHANGE, { online });
446
  }
447
  }
448
 
449
  /**
450
  * 检查是否在线
 
 
 
451
  * @returns {boolean}
452
  */
453
  export function isOnline() {
454
  return state.network.isOnline;
455
  }
456
 
457
- // 自动监听网络状态
458
  if (typeof window !== "undefined") {
459
  window.addEventListener("online", () => setNetworkStatus(true));
460
  window.addEventListener("offline", () => setNetworkStatus(false));
 
2
  // ==========================================
3
  // 🏪 全局状态管理中心
4
  // ==========================================
5
+ // 作用:集中管理应用状态(含全项目网络状态),提供响应式更新和事件通知
6
  // 关联文件:
7
  // - 类型定义.js (类型定义)
8
  // - 所有需要共享状态的组件
9
  // - 顶部导航组件.js (用户登录状态)
10
  // - 侧边栏主程序.js (当前视图状态)
11
+ // - 网络请求_基础设施.js / 网络请求_图片代理.js (调用 isOnline() 决定是否走缓存回退)
12
  // ==========================================
13
  // 🏗️ P2架构优化:轻量级状态管理
14
  // 🏗️ P2质量优化:JSDoc 类型注释
 
54
  users: new Map()
55
  },
56
 
57
+ // 网络状态(全项目唯一数据源,见下方「🌐 网络状态管理」段落)
58
  network: {
59
+ isOnline: navigator.onLine
 
60
  }
61
  };
62
 
 
432
  // ==========================================
433
  // 🌐 网络状态管理
434
  // ==========================================
435
+ // 🏗️ 全项目网络状态的唯一数据源。
436
+ // online/offline 事件只在本模块注册一次,下列消费方一律调用 isOnline(),
437
+ // 不再各自维护状态副本与重复监听,避免出现「横幅显示离线但列表仍在发请求」
438
+ // 这类多份状态不同步的分裂表现:
439
+ // - 网络请求_基础设施.js —— 离线 GET 直返缓存、重试耗尽回退过期缓存
440
+ // - 网络请求_图片代理.js —— 离线时不构造远程直链,保持相对路径原样
441
 
442
  /**
443
+ * 更新网络状态(仅供内部 online/offline 监听调用)
444
  * @param {boolean} online - 是否在线
445
  */
446
  export function setNetworkStatus(online) {
 
448
  state.network.isOnline = online;
449
 
450
  if (wasOnline !== online) {
451
+ console.log(online ? '🌐 网络已恢复' : '📴 网络已断开');
452
  eventBus.emit(EVENTS.NETWORK_CHANGE, { online });
453
  }
454
  }
455
 
456
  /**
457
  * 检查是否在线
458
+ *
459
+ * 注意:navigator.onLine 仅反映本机网络接口状态,不代表云端服务可达;
460
+ * 云端不可达(502 / DNS 污染 / 证书失败)由 request() 的重试耗尽兜底负责。
461
  * @returns {boolean}
462
  */
463
  export function isOnline() {
464
  return state.network.isOnline;
465
  }
466
 
467
+ // 自动监听网络状态(全项目唯一一次注册)
468
  if (typeof window !== "undefined") {
469
  window.addEventListener("online", () => setNetworkStatus(true));
470
  window.addEventListener("offline", () => setNetworkStatus(false));
前端页面/core/网络请求API.js CHANGED
@@ -8,6 +8,10 @@
8
  // - 网络请求_缓存管理.js (缓存配置、TTL、精确失效映射)
9
  // - 网络请求_业务API.js (api对象:40+个业务方法)
10
  // ==========================================
 
 
 
 
11
 
12
  export { request, requestCancelManager, invalidateRelatedCache, requestSSE } from './网络请求_基础设施.js';
13
  export { proxyImages, unproxyImages } from './网络请求_图片代理.js';
 
8
  // - 网络请求_缓存管理.js (缓存配置、TTL、精确失效映射)
9
  // - 网络请求_业务API.js (api对象:40+个业务方法)
10
  // ==========================================
11
+ // 📌 本文件只 re-export 下面三行的符号;缓存读写工具(getCache / setCache /
12
+ // getCacheWithMeta / removeCache / removeCacheByPrefix,以及列表缓存
13
+ // readListCache / writeListCache)不在这里,统一从 components/性能优化工具.js 导入。
14
+ // ==========================================
15
 
16
  export { request, requestCancelManager, invalidateRelatedCache, requestSSE } from './网络请求_基础设施.js';
17
  export { proxyImages, unproxyImages } from './网络请求_图片代理.js';
前端页面/core/网络请求_业务API.js CHANGED
@@ -8,6 +8,7 @@
8
  // ==========================================
9
 
10
  import { request } from "./网络请求_基础设施.js";
 
11
 
12
  // ============== 业务 API 导出 ==============
13
  const api = {
@@ -54,7 +55,9 @@ const api = {
54
  async updatePrivacy(account, privacy) { return request(`/api/users/${account}/privacy`, { method: "PUT", body: privacy }); },
55
  async toggleFollow(userId, targetAccount, isActive) { return request("/api/users/follow", { method: "POST", body: { user_id: userId, target_account: targetAccount, is_active: isActive } }); },
56
  async getCreators(sort, limit) { return request(`/api/creators?sort=${sort}&limit=${limit}`); },
57
- async searchCreators(keyword, sort, limit = 50) { return request(`/api/creators/search?keyword=${encodeURIComponent(keyword)}&sort=${sort}&limit=${limit}`); },
 
 
58
  async getCreatorDetails(account) { return request(`/api/creators/${account}/details`); },
59
  async uploadFile(file, fileType, timeout = 120000) {
60
  const formData = new FormData();
 
8
  // ==========================================
9
 
10
  import { request } from "./网络请求_基础设施.js";
11
+ import { API } from "./全局配置.js";
12
 
13
  // ============== 业务 API 导出 ==============
14
  const api = {
 
55
  async updatePrivacy(account, privacy) { return request(`/api/users/${account}/privacy`, { method: "PUT", body: privacy }); },
56
  async toggleFollow(userId, targetAccount, isActive) { return request("/api/users/follow", { method: "POST", body: { user_id: userId, target_account: targetAccount, is_active: isActive } }); },
57
  async getCreators(sort, limit) { return request(`/api/creators?sort=${sort}&limit=${limit}`); },
58
+ // 🔍 带上取消分组 ID:用户继续输入时,上一次仍在途的搜索请求可被 requestCancelManager 撤销,
59
+ // 不再占用并发额度与超时预算(调用时机见 侧边栏数据引擎.js 的创作者搜索分支)
60
+ async searchCreators(keyword, sort, limit = 50) { return request(`/api/creators/search?keyword=${encodeURIComponent(keyword)}&sort=${sort}&limit=${limit}`, { componentId: API.SEARCH_COMPONENT_ID }); },
61
  async getCreatorDetails(account) { return request(`/api/creators/${account}/details`); },
62
  async uploadFile(file, fileType, timeout = 120000) {
63
  const formData = new FormData();
前端页面/core/网络请求_图片代理.js CHANGED
@@ -6,6 +6,7 @@
6
  // ==========================================
7
 
8
  import { API, IS_WEB_MODE } from './全局配置.js';
 
9
 
10
  // 🟢 入口清洗:接收云端数据时,转换为本地代理,并带【自愈机制】清理被污染的历史数据
11
  // 🚀 统一缓存:所有头像字段都走同一个缓存代理,无需重复下载
@@ -68,7 +69,7 @@ export function proxyImages(obj) {
68
  } else if (originalUrl && !originalUrl.startsWith('/') && !originalUrl.startsWith('data:')) {
69
  // 🎬 相对路径(如 uploads/post_video/...)
70
  // 离线时不构造远程 URL,保持原样让浏览器从当前域尝试加载
71
- if (navigator.onLine === false) {
72
  obj[key] = originalUrl;
73
  } else {
74
  const fullUrl = `${API.BASE_URL}/api/image_proxy?path=${encodeURIComponent(originalUrl)}`;
 
6
  // ==========================================
7
 
8
  import { API, IS_WEB_MODE } from './全局配置.js';
9
+ import { isOnline } from './状态管理.js'; // 🌐 网络状态统一取自 状态管理.js,不再直读 navigator.onLine
10
 
11
  // 🟢 入口清洗:接收云端数据时,转换为本地代理,并带【自愈机制】清理被污染的历史数据
12
  // 🚀 统一缓存:所有头像字段都走同一个缓存代理,无需重复下载
 
69
  } else if (originalUrl && !originalUrl.startsWith('/') && !originalUrl.startsWith('data:')) {
70
  // 🎬 相对路径(如 uploads/post_video/...)
71
  // 离线时不构造远程 URL,保持原样让浏览器从当前域尝试加载
72
+ if (!isOnline()) {
73
  obj[key] = originalUrl;
74
  } else {
75
  const fullUrl = `${API.BASE_URL}/api/image_proxy?path=${encodeURIComponent(originalUrl)}`;
前端页面/core/网络请求_基础设施.js CHANGED
@@ -11,9 +11,12 @@
11
 
12
  import { removeCache, getCacheWithMeta } from "../components/性能优化工具.js";
13
  import { API, CACHE } from "./全局配置.js";
14
- import { logoutAndClearUserData } from "./状态管理.js";
15
  import { CACHE_CONFIG, CACHE_INVALIDATION_MAP, invalidateRelatedCache, _getCacheTTL, getCache, setCache } from "./网络请求_缓存管理.js";
16
  import { unproxyImages } from "./网络请求_图片代理.js";
 
 
 
17
 
18
  // 🚀 动态时序导入:proxyImages 通过动态 import() 获取,避免模块初始化时序问题
19
  let _proxyImages = null;
@@ -31,10 +34,8 @@ const BASE_URL = API.BASE_URL;
31
  // ⚡ P1性能优化:请求去重(相同GET请求只发一次)
32
  const pendingRequests = new Map();
33
 
34
- // 🚀 P3优化:网络状态监控
35
- let isOnline = navigator.onLine;
36
- window.addEventListener('online', () => { isOnline = true; console.log('🌐 网络已恢复'); });
37
- window.addEventListener('offline', () => { isOnline = false; console.log('📴 网络已断开'); });
38
 
39
  // 🔧 修复:401(Token 过期/签名无效)统一处理——清除本地凭证并提示重新登录。
40
  // 背景:服务端更换 JWT_SECRET 或 Token 过期后,旧 Token 被云端拒签,若不清除
@@ -106,11 +107,16 @@ const requestCancelManager = {
106
  cancelAll(componentId) {
107
  const controllers = this._controllers.get(componentId);
108
  if (controllers) {
 
109
  for (const controller of controllers) {
110
  controller.abort();
111
  }
112
  controllers.clear();
113
- console.log(`🚫 已取消组件 [${componentId}] 的所有请求`);
 
 
 
 
114
  }
115
  },
116
 
@@ -226,15 +232,17 @@ async function request(endpoint, options = {}) {
226
  }
227
  }
228
 
229
- // 🚀 P3优化:离线模式支持
230
- if (!isOnline && method === "GET") {
231
  const { value, expired, found } = getCacheWithMeta(cacheKey, true); // 忽略过期
232
  if (found) {
233
  console.log(`📴 离线模式:返回${expired ? '过期' : ''}缓存 (${endpoint})`);
234
  const proxyFn2 = await getProxyImages();
235
  return proxyFn2(value);
236
  }
237
- throw new Error('网络已断开,且无本地缓存');
 
 
238
  }
239
 
240
  // ⚡ P1性能优化:请求去重(相同GET请求只发一次)
@@ -253,9 +261,26 @@ async function request(endpoint, options = {}) {
253
  fetchOptions.body = options.body;
254
  }
255
 
256
- // 🚀 P1优化:重试配置
257
- const maxRetries = options.retries ?? (method === "GET" ? 2 : 0); // GET 默认重试 2 次
258
- const retryDelay = options.retryDelay ?? 1000; // 初始延迟 1 秒
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
 
260
  // ⚡ P1性能优化:封装请求 Promise(支持去重 + 重试)
261
  const requestPromise = (async () => {
@@ -271,12 +296,20 @@ async function request(endpoint, options = {}) {
271
 
272
  // 🚀 P4优化:使用请求取消管理器(支持超时 + 组件级取消)
273
  const controller = requestCancelManager.create(componentId);
274
- const timeoutId = setTimeout(() => controller.abort(), options.timeout || API.TIMEOUT);
275
  const currentFetchOptions = { ...fetchOptions, signal: controller.signal };
 
 
 
 
 
 
276
 
277
  try {
278
  // 🚀 P3优化:使用请求队列限制并发
279
- const response = await requestQueue.add(() => fetch(url, currentFetchOptions));
 
 
 
280
  clearTimeout(timeoutId); // 清除超时计时器
281
  requestCancelManager.remove(componentId, controller); // 🚀 P4: 移除已完成的 controller
282
  let responseData = await response.json().catch(() => ({}));
@@ -336,6 +369,20 @@ async function request(endpoint, options = {}) {
336
  return responseData;
337
  } catch (error) {
338
  clearTimeout(timeoutId); // 清除超时计时器
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
 
340
  // 🔧 P1优化:可重试的错误类型
341
  const isRetryable = (
@@ -381,9 +428,12 @@ async function request(endpoint, options = {}) {
381
  }
382
 
383
  // 确保请求完成后清除去重记录
 
 
 
384
  requestPromise.finally(() => {
385
  pendingRequests.delete(cacheKey);
386
- });
387
 
388
  return requestPromise;
389
  }
 
11
 
12
  import { removeCache, getCacheWithMeta } from "../components/性能优化工具.js";
13
  import { API, CACHE } from "./全局配置.js";
14
+ import { logoutAndClearUserData, isOnline } from "./状态管理.js";
15
  import { CACHE_CONFIG, CACHE_INVALIDATION_MAP, invalidateRelatedCache, _getCacheTTL, getCache, setCache } from "./网络请求_缓存管理.js";
16
  import { unproxyImages } from "./网络请求_图片代理.js";
17
+ // 🔧 直接引入 i18n 叶子模块(而非 用户体验增强.js):本文件处于依赖链最上游,
18
+ // 引入带 DOM 初始化副作用的增强层可能造成启动时序问题;国际化模块无副作用、零循环依赖
19
+ import { t } from "../components/用户体验_国际化.js";
20
 
21
  // 🚀 动态时序导入:proxyImages 通过动态 import() 获取,避免模块初始化时序问题
22
  let _proxyImages = null;
 
34
  // ⚡ P1性能优化:请求去重(相同GET请求只发一次)
35
  const pendingRequests = new Map();
36
 
37
+ // 🚀 P3优化:网络状态由 状态管理.js 的 isOnline() 统一提供(见下方离线模式支持),
38
+ // 本模块不再自行注册 online/offline 监听。
 
 
39
 
40
  // 🔧 修复:401(Token 过期/签名无效)统一处理——清除本地凭证并提示重新登录。
41
  // 背景:服务端更换 JWT_SECRET 或 Token 过期后,旧 Token 被云端拒签,若不清除
 
107
  cancelAll(componentId) {
108
  const controllers = this._controllers.get(componentId);
109
  if (controllers) {
110
+ const count = controllers.size;
111
  for (const controller of controllers) {
112
  controller.abort();
113
  }
114
  controllers.clear();
115
+ // 🔧 仅在实际撤销了请求时打日志:本方法会被搜索框的每一次输入触发,
116
+ // 绝大多数时候并没有在途请求,无条件打印会刷出大量无意义日志
117
+ if (count > 0) {
118
+ console.log(`🚫 已取消组件 [${componentId}] 的 ${count} 个在途请求`);
119
+ }
120
  }
121
  },
122
 
 
232
  }
233
  }
234
 
235
+ // 🚀 P3优化:离线模式支持(网络状态取自 状态管理.js 的 isOnline())
236
+ if (!isOnline() && method === "GET") {
237
  const { value, expired, found } = getCacheWithMeta(cacheKey, true); // 忽略过期
238
  if (found) {
239
  console.log(`📴 离线模式:返回${expired ? '过期' : ''}缓存 (${endpoint})`);
240
  const proxyFn2 = await getProxyImages();
241
  return proxyFn2(value);
242
  }
243
+ // 🔧 修复:原为硬编码中文,而本错误会被 12+ 处调用方直接 showToast(err.message) 弹给用户,
244
+ // 导致英文界面下仍弹中文提示;现改走词典
245
+ throw new Error(t('feedback.offline_no_cache'));
246
  }
247
 
248
  // ⚡ P1性能优化:请求去重(相同GET请求只发一次)
 
261
  fetchOptions.body = options.body;
262
  }
263
 
264
+ // 🚀 P1优化:重试与超时预算
265
+ // 🔧 修复:重试次数/间隔此前硬编码在本文件(2 / 1000),使 全局配置.js 的
266
+ // API.MAX_RETRIES、API.RETRY_DELAY 成为死配置,现统一读取配置中心
267
+ // 🐢 弱网预算分级:列表/搜索类 GET 有本地缓存兜底,失败上限适当收紧,
268
+ // 避免弱网下用户长时间盯骨架屏;按去掉查询串的路径全匹配,
269
+ // 不会误伤 /api/items/{id}、/api/creators/{account}/details 等详情请求
270
+ const endpointPath = endpoint.split("?")[0];
271
+ let defaultTimeout = API.TIMEOUT;
272
+ let defaultRetries = method === "GET" ? API.MAX_RETRIES : 0; // 非 GET 不重试
273
+ if (method === "GET") {
274
+ if (endpointPath === "/api/creators/search") {
275
+ defaultTimeout = API.SEARCH_TIMEOUT;
276
+ defaultRetries = API.SEARCH_RETRIES;
277
+ } else if (endpointPath === "/api/items" || endpointPath === "/api/creators") {
278
+ defaultRetries = API.LIST_RETRIES;
279
+ }
280
+ }
281
+ const requestTimeout = options.timeout || defaultTimeout;
282
+ const maxRetries = options.retries ?? defaultRetries;
283
+ const retryDelay = options.retryDelay ?? API.RETRY_DELAY; // 指数退避基数
284
 
285
  // ⚡ P1性能优化:封装请求 Promise(支持去重 + 重试)
286
  const requestPromise = (async () => {
 
296
 
297
  // 🚀 P4优化:使用请求取消管理器(支持超时 + 组件级取消)
298
  const controller = requestCancelManager.create(componentId);
 
299
  const currentFetchOptions = { ...fetchOptions, signal: controller.signal };
300
+ // 🔧 修复:超时计时器改到真正发出 fetch 时才启动。原实现在入队前就 setTimeout,
301
+ // 并发满 6 个时排队等待会白烧超时预算,排队时长超过超时值会导致 fetch 尚未发出就被 abort
302
+ let timeoutId = null;
303
+ // 🔍 区分「超时中止」与「调用方主动撤销」:两者抛出的都是 AbortError,
304
+ // 但前者应该重试、后者绝不能重试(详见下方 catch 的主动取消分支)
305
+ let timedOut = false;
306
 
307
  try {
308
  // 🚀 P3优化:使用请求队列限制并发
309
+ const response = await requestQueue.add(() => {
310
+ timeoutId = setTimeout(() => { timedOut = true; controller.abort(); }, requestTimeout);
311
+ return fetch(url, currentFetchOptions);
312
+ });
313
  clearTimeout(timeoutId); // 清除超时计时器
314
  requestCancelManager.remove(componentId, controller); // 🚀 P4: 移除已完成的 controller
315
  let responseData = await response.json().catch(() => ({}));
 
369
  return responseData;
370
  } catch (error) {
371
  clearTimeout(timeoutId); // 清除超时计时器
372
+ // 🔍 失败/超时同样摘除 controller:成功路径在上方已 remove,这里不补的话,
373
+ // 取消分组里会堆积已失效的 controller,使 cancelAll 的在途计数虚高
374
+ requestCancelManager.remove(componentId, controller);
375
+
376
+ // 🔍 主动撤销(组件级取消)与超时都表现为 AbortError,但语义完全相反:
377
+ // 超时该重试;主动撤销是调用方明确表示「这次结果我不要了」,绝不能重试,
378
+ // 否则会白烧一轮超时预算(搜索场景下用户每敲一次键都可能触发一次撤销)。
379
+ // 直接抛出而不走循环后的「过期缓存兜底」,避免把作废请求的结果上屏
380
+ if (error.name === 'AbortError' && !timedOut) {
381
+ const cancelErr = new Error(t('feedback.request_cancelled'));
382
+ cancelErr.name = 'RequestCancelledError';
383
+ cancelErr.isCancelled = true;
384
+ throw cancelErr;
385
+ }
386
 
387
  // 🔧 P1优化:可重试的错误类型
388
  const isRetryable = (
 
428
  }
429
 
430
  // 确保请求完成后清除去重记录
431
+ // 🔧 补 .catch 空处理:finally 会派生出一个新的 Promise,而它从未被返回也无人接收,
432
+ // 一旦请求失败(含主动撤销)就会在控制台抛 Uncaught (in promise)。
433
+ // 原始 requestPromise 仍在下方正常返回给调用方处理,此处只是吞掉这份重复的拒绝
434
  requestPromise.finally(() => {
435
  pendingRequests.delete(cacheKey);
436
+ }).catch(() => {});
437
 
438
  return requestPromise;
439
  }
前端页面/core/网络请求_缓存管理.js CHANGED
@@ -120,8 +120,8 @@ export function getCache(key) {
120
  _updateLRU(fk);
121
  return cached.value;
122
  }
123
- // 过期,清除
124
- memoryCache.delete(fk);
125
  }
126
 
127
  // 降级到 localStorage
@@ -164,8 +164,8 @@ export function getCacheWithMeta(key, ignoreExpiry = false) {
164
  _updateLRU(fk);
165
  return { value: cached.value, expired, found: true };
166
  }
167
- // 过期且不忽略,清除
168
- memoryCache.delete(fk);
169
  }
170
 
171
  // 降级到 localStorage
@@ -192,17 +192,70 @@ export function getCacheWithMeta(key, ignoreExpiry = false) {
192
  }
193
 
194
  /**
195
- * 删除缓存
 
 
 
 
 
 
 
 
 
 
 
 
196
  * @param {string} key - 缓存键
197
  */
198
  export function removeCache(key) {
199
  const fk = _fullKey(key);
200
- memoryCache.delete(fk);
201
  try {
202
  localStorage.removeItem(fk);
203
  } catch {}
204
  }
205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  /**
207
  * 清除所有缓存
208
  */
@@ -460,19 +513,17 @@ export function invalidateRelatedCache(endpoint, method = null) {
460
  patterns = ["ComfyRanking_ListCache_"];
461
  }
462
 
 
 
 
 
 
 
 
463
  let cleared = 0;
464
- Object.keys(localStorage).forEach(key => {
465
- for (const pattern of patterns) {
466
- // 自动补上缓存前缀,确保能匹配到实际的 localStorage 键
467
- // localStorage 中的 key 形如 "ComfyRanking_api_/api/tasks?..."
468
- const prefixedPattern = pattern.startsWith(CACHE.PREFIX) ? pattern : CACHE.PREFIX + pattern;
469
- if (key.startsWith(prefixedPattern)) {
470
- localStorage.removeItem(key);
471
- cleared++;
472
- break;
473
- }
474
- }
475
- });
476
 
477
  if (cleared > 0) {
478
  console.log(`🗑️ 精确清除缓存: ${cleared} 个项 (${endpoint}${method ? ` ${method}` : ''})`);
 
120
  _updateLRU(fk);
121
  return cached.value;
122
  }
123
+ // 过期,清除(走 _deleteMemoryEntry 同步摘除 LRU 顺序数组,避免幽灵键挤占名额)
124
+ _deleteMemoryEntry(fk);
125
  }
126
 
127
  // 降级到 localStorage
 
164
  _updateLRU(fk);
165
  return { value: cached.value, expired, found: true };
166
  }
167
+ // 过期且不忽略,清除(同上:必须同步维护 memoryCacheOrder)
168
+ _deleteMemoryEntry(fk);
169
  }
170
 
171
  // 降级到 localStorage
 
192
  }
193
 
194
  /**
195
+ * 删除内存缓存条目(同步维护 LRU 顺序数组)
196
+ * 🔧 修复:原先各处只调 memoryCache.delete(fk),残留在 memoryCacheOrder 里的幽灵键会
197
+ * 占用 MAX_MEMORY_ITEMS 名额,使 _enforceCacheLimit 淘汰到已不存在的键,内存缓存实际容量缩水
198
+ * @param {string} fk - 完整缓存键(已含前缀)
199
+ */
200
+ function _deleteMemoryEntry(fk) {
201
+ memoryCache.delete(fk);
202
+ const idx = memoryCacheOrder.indexOf(fk);
203
+ if (idx > -1) memoryCacheOrder.splice(idx, 1);
204
+ }
205
+
206
+ /**
207
+ * 删除缓存(精确键,内存 + localStorage 两级)
208
  * @param {string} key - 缓存键
209
  */
210
  export function removeCache(key) {
211
  const fk = _fullKey(key);
212
+ _deleteMemoryEntry(fk);
213
  try {
214
  localStorage.removeItem(fk);
215
  } catch {}
216
  }
217
 
218
+ /**
219
+ * 🧹 按前缀删除缓存(内存 + localStorage 两级)
220
+ * removeCache 是精确键删除,而 API 层缓存键总是带查询串(形如
221
+ * api_/api/items?type=tool&sort=time&limit=200),调用方无法预知完整键,
222
+ * 用 removeCache('api_/api/items') 删除会全部落空(no-op),故提供前缀删除。
223
+ * 键空间兼容:本模块 setCache 写入的键带 CACHE.PREFIX;少数历史缓存
224
+ * (如 SWR 头像 ComfyCommunity_ProfileCache_*)是直接写 localStorage 的裸键,
225
+ * 两种都要匹配,避免无条件补前缀导致裸键类前缀永远命中不到。
226
+ * @param {string} prefix - 缓存键前缀
227
+ * @returns {number} 实际删除的缓存项数(两级按完整键去重)
228
+ */
229
+ export function removeCacheByPrefix(prefix) {
230
+ if (!prefix) return 0;
231
+ const candidates = prefix.startsWith(CACHE_PREFIX) ? [prefix] : [_fullKey(prefix), prefix];
232
+ const isMatch = (key) => candidates.some(p => key.startsWith(p));
233
+ const removed = new Set();
234
+
235
+ // 🔧 内存层必须先清:getCache / getCacheWithMeta 都优先读 memoryCache,
236
+ // 只清 localStorage 的话旧数据仍会命中内存并被直接返回(请求根本不会发出)
237
+ for (const fk of Array.from(memoryCache.keys())) {
238
+ if (isMatch(fk)) {
239
+ _deleteMemoryEntry(fk);
240
+ removed.add(fk);
241
+ }
242
+ }
243
+
244
+ try {
245
+ const keysToRemove = [];
246
+ for (let i = 0; i < localStorage.length; i++) {
247
+ const k = localStorage.key(i);
248
+ if (k && isMatch(k)) keysToRemove.push(k);
249
+ }
250
+ keysToRemove.forEach(k => {
251
+ localStorage.removeItem(k);
252
+ removed.add(k);
253
+ });
254
+ } catch {}
255
+
256
+ return removed.size;
257
+ }
258
+
259
  /**
260
  * 清除所有缓存
261
  */
 
513
  patterns = ["ComfyRanking_ListCache_"];
514
  }
515
 
516
+ // 🔧 修复:原实现只遍历 localStorage,漏清 memoryCache —— 而 getCache/getCacheWithMeta
517
+ // 都优先读内存 Map,POST 成功后内存里的旧列表仍会命中并被直接返回(连请求都不发),
518
+ // 表现为「发布/点赞已成功但界面还是旧数据」,要等内存条目 TTL 到期才自愈。
519
+ // 🔧 同时修复:原实现无条件给 pattern 补 CACHE.PREFIX,使上方映射表中的裸键前缀
520
+ // (ComfyCommunity_ProfileCache_,SWR 头像缓存直接写 localStorage、不带前缀)被拼成
521
+ // ComfyRanking_ComfyCommunity_ProfileCache_,永远匹配不到,改头像/昵称后该缓存清不掉。
522
+ // 现统一走 removeCacheByPrefix:两级缓存一起清,且兼容带前缀与裸键两种键空间。
523
  let cleared = 0;
524
+ for (const pattern of patterns) {
525
+ cleared += removeCacheByPrefix(pattern);
526
+ }
 
 
 
 
 
 
 
 
 
527
 
528
  if (cleared > 0) {
529
  console.log(`🗑️ 精确清除缓存: ${cleared} 个项 (${endpoint}${method ? ` ${method}` : ''})`);
前端页面/market/列表卡片组件.js CHANGED
@@ -80,9 +80,11 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
80
  const initialCommentCount = itemData.commentsData ? calcActiveComments(itemData.commentsData) : (itemData.comments || 0);
81
 
82
  // 🚀 检查插件安装状态(用于显示更新徽章)
 
 
83
  const installStatus = checkItemStatus(itemData.id, itemData.latest_version);
84
  const updateBadgeHtml = installStatus.hasUpdate
85
- ? `<span style="margin-left: 6px; background: linear-gradient(135deg, #FF9800, #F44336); color: #fff; font-size: 10px; padding: 2px 6px; border-radius: 10px; font-weight: bold; animation: pulse 2s infinite;">🔄 ${t('market.update_available')}</span>`
86
  : '';
87
 
88
  const summaryView = document.createElement("div");
@@ -92,10 +94,11 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
92
  // 🚀 核心修改:为评论计数的 span 加上专属的 class,并填入过滤后的真实数量
93
  // 🚀 布局重构:第1行标题+使用次数,第2行描述,第3行互动数据+发布者信息
94
  summaryView.innerHTML = `
95
- <!-- 第1行: 标题 + 使用次数 -->
96
- <div style="display: flex; align-items: center; margin-bottom: 4px;">
97
- <div style="font-weight: bold; font-size: 14px; color: #4CAF50; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(itemData.title)}${updateBadgeHtml}</div>
98
- <span style="margin-left: auto; font-size: 11px; color: #888; display: flex; align-items: center; gap: 6px;">
 
99
  <span data-stat="uses" style="display: flex; align-items: center; gap: 2px;">📥 ${itemData.uses || 0}</span>
100
  <span data-stat="views" style="display: flex; align-items: center; gap: 2px;">🔥 ${itemData.views || 0}</span>
101
  </span>
 
80
  const initialCommentCount = itemData.commentsData ? calcActiveComments(itemData.commentsData) : (itemData.comments || 0);
81
 
82
  // 🚀 检查插件安装状态(用于显示更新徽章)
83
+ // 徽章作为标题行的独立 flex 项(flex: none),不再追加在标题文本之后,
84
+ // 避免标题容器的 overflow: hidden + ellipsis 在长标题时把徽章一并裁掉
85
  const installStatus = checkItemStatus(itemData.id, itemData.latest_version);
86
  const updateBadgeHtml = installStatus.hasUpdate
87
+ ? `<span style="flex: none; white-space: nowrap; background: linear-gradient(135deg, #FF9800, #F44336); color: #fff; font-size: 10px; padding: 2px 6px; border-radius: 10px; font-weight: bold; animation: pulse 2s infinite;">🔄 ${t('market.update_available')}</span>`
88
  : '';
89
 
90
  const summaryView = document.createElement("div");
 
94
  // 🚀 核心修改:为评论计数的 span 加上专属的 class,并填入过滤后的真实数量
95
  // 🚀 布局重构:第1行标题+使用次数,第2行描述,第3行互动数据+发布者信息
96
  summaryView.innerHTML = `
97
+ <!-- 第1行: 标题 + 更新徽章 + 使用次数 -->
98
+ <div style="display: flex; align-items: center; margin-bottom: 4px; gap: 6px;">
99
+ <div style="font-weight: bold; font-size: 14px; color: #4CAF50; flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(itemData.title)}</div>
100
+ ${updateBadgeHtml}
101
+ <span style="margin-left: auto; flex: none; font-size: 11px; color: #888; display: flex; align-items: center; gap: 6px;">
102
  <span data-stat="uses" style="display: flex; align-items: center; gap: 2px;">📥 ${itemData.uses || 0}</span>
103
  <span data-stat="views" style="display: flex; align-items: center; gap: 2px;">🔥 ${itemData.views || 0}</span>
104
  </span>
前端页面/market/创作者卡片组件.js CHANGED
@@ -11,7 +11,7 @@
11
  import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
12
  import { createCommentSection } from "../social/评论与互动组件.js";
13
  import { renderTipLevelHTML, getTipLevelRuleShort } from "../components/打赏等级工具.js";
14
- import { getBannerCacheKey, PLACEHOLDERS, getCachedProfile, getProfileWithSWR } from "../core/全局配置.js";
15
  import { api } from "../core/网络请求_业务API.js";
16
  import { getSettings } from "../components/全局设置组件.js";
17
  import { t } from "../components/用户体验增强.js";
@@ -51,7 +51,7 @@ let currentExpandedCard = null;
51
  async function handleTipCreator(creatorData, currentUser, onTipSuccess) {
52
  // 检查用户是否登录
53
  if (!currentUser) {
54
- showToast(t('creator.tip_login_required') || "请先登录", "warning");
55
  return;
56
  }
57
 
@@ -92,7 +92,7 @@ function loadECharts() {
92
  * 渲染打赏榜单条目HTML(匿名/非匿名共用外层结构)
93
  */
94
  function _renderTipBoardEntry(entry, idx, rankIcon, levelHtml, userHtml, containerId = '') {
95
- const idAttr = containerId ? ` id="${containerId}"` : '';
96
  return `<div${idAttr} style="display:flex;align-items:center;justify-content:space-between;padding:6px 8px;background:${idx % 2 === 0 ? '#222' : 'var(--comfy-menu-bg)'};border-radius:4px;margin-bottom:4px;">` +
97
  `<div style="display:flex;align-items:center;gap:8px;"><span style="font-size:14px;min-width:24px;">${rankIcon}</span>${userHtml}</div>` +
98
  `<div style="display:flex;align-items:center;gap:6px;">${levelHtml}<span style="color:#4CAF50;font-weight:bold;font-size:11px;">${entry.amount}</span></div></div>`;
@@ -125,7 +125,7 @@ export function createTipBoardSection(tipBoard = [], title = "🎁 赞赏贡献
125
  container.innerHTML = `
126
  <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;">
127
  <div style="font-size: 12px; font-weight: bold; color: #FF9800; display: flex; align-items: center; gap: 6px;">
128
- ${title}
129
  <span style="font-size: 10px; color: #888; font-weight: normal;">(${t('creator.tip_board.count', {count: 0})})</span>
130
  </div>
131
  ${creatorData ? `<button id="btn-tip-creator-empty" class="creator-tip-btn" style="${_TIP_BUTTON_STYLE}">${t('creator.tip_this_creator')}</button>` : ''}
@@ -188,14 +188,16 @@ export function createTipBoardSection(tipBoard = [], title = "🎁 赞赏贡献
188
  });
189
  }, 0);
190
 
191
- const userHtml = `<span class="tip-board-user" data-account="${entry.account}" data-anon="false" style="display: flex; align-items: center; gap: 6px; color: #4CAF50; cursor: pointer; font-size: 13px;"><img class="tip-board-avatar" src="${avatarUrl}" style="width: 20px; height: 20px; border-radius: 50%; object-fit: cover; flex-shrink: 0; background: var(--comfy-input-bg);"><span class="tip-board-name">${userName}</span></span>`;
 
 
192
  return _renderTipBoardEntry(entry, idx, rankIcon, levelHtml, userHtml, containerId);
193
  }).join("");
194
 
195
  container.innerHTML = `
196
  <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px;">
197
  <div style="font-size: 12px; font-weight: bold; color: #FF9800; display: flex; align-items: center; gap: 6px;">
198
- ${title}
199
  <span style="font-size: 10px; color: #888; font-weight: normal;">(${t('creator.tip_board.count', {count: tipBoard.length})})</span>
200
  </div>
201
  ${creatorData ? `<button id="btn-tip-creator" class="creator-tip-btn" style="${_TIP_BUTTON_STYLE}">${t('creator.tip_this_creator')}</button>` : ''}
@@ -264,11 +266,11 @@ export function createCreatorCard(creatorData, currentUser = null) {
264
  ${hasBanner ? '<div style="position: absolute; inset: 0; background: rgba(0,0,0,0.2); border-radius: 8px;"></div>' : ''}
265
  <div style="position: relative; z-index: 1;">
266
  <div style="display: flex; align-items: center; gap: 15px; margin-bottom: 10px; padding: 5px 0;">
267
- <img class="creator-avatar-link" src="${avatarSrc}" title="${t('creator.visit_profile')}" style="width: 50px; height: 50px; border-radius: 50%; border: 2px solid ${hasBanner ? 'rgba(255,255,255,0.8)' : '#555'}; object-fit: cover; cursor: pointer; transition: 0.2s; ${hasBanner ? 'box-shadow: 0 2px 8px rgba(0,0,0,0.3);' : ''}" onmouseover="this.style.borderColor='#4CAF50'" onmouseout="this.style.borderColor='${hasBanner ? 'rgba(255,255,255,0.8)' : '#555'}'">
268
- <div class="creator-name-link" title="${t('creator.visit_profile')}" style="font-weight: bold; font-size: 16px; cursor: pointer; transition: 0.2s; color: #FFD700; text-shadow: 0 1px 4px rgba(0,0,0,0.8), 0 0 8px rgba(255,215,0,0.3);" onmouseover="this.style.color='#FFA500'" onmouseout="this.style.color='#FFD700'">${creatorData.name}</div>
269
  <div data-stat="downloads" style="font-size: 12px; color: #FF6B6B; margin-left: auto; text-shadow: 0 1px 3px rgba(0,0,0,0.6);">${t('creator.usage_count', {count: creatorData.downloads || 0})}</div>
270
  </div>
271
- <div style="font-size: 12px; color: ${hasBanner ? '#ccc' : '#aaa'}; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 10px;">${creatorData.shortDesc && creatorData.shortDesc !== "null" ? creatorData.shortDesc : t('profile.no_intro') || "这个人很懒,什么都没写..."}</div>
272
  <div style="background: rgba(34,34,34,${hasBanner ? '0.8' : '1'}); border-radius: 6px; padding: 8px 10px; border: 1px dashed #555;">
273
  <div style="display: flex; gap: 15px; font-size: 12px; color: #eee; justify-content: space-between; margin-bottom: 8px;">
274
  <span data-stat="likes" style="color: #FF5722;">👍 ${t('creator.stats.likes')}: <strong>${creatorData.likes}</strong></span>
 
11
  import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
12
  import { createCommentSection } from "../social/评论与互动组件.js";
13
  import { renderTipLevelHTML, getTipLevelRuleShort } from "../components/打赏等级工具.js";
14
+ import { getBannerCacheKey, PLACEHOLDERS, getCachedProfile, getProfileWithSWR, escapeHtml } from "../core/全局配置.js";
15
  import { api } from "../core/网络请求_业务API.js";
16
  import { getSettings } from "../components/全局设置组件.js";
17
  import { t } from "../components/用户体验增强.js";
 
51
  async function handleTipCreator(creatorData, currentUser, onTipSuccess) {
52
  // 检查用户是否登录
53
  if (!currentUser) {
54
+ showToast(t('creator.tip_login_required'), "warning");
55
  return;
56
  }
57
 
 
92
  * 渲染打赏榜单条目HTML(匿名/非匿名共用外层结构)
93
  */
94
  function _renderTipBoardEntry(entry, idx, rankIcon, levelHtml, userHtml, containerId = '') {
95
+ const idAttr = containerId ? ` id="${escapeHtml(containerId)}"` : '';
96
  return `<div${idAttr} style="display:flex;align-items:center;justify-content:space-between;padding:6px 8px;background:${idx % 2 === 0 ? '#222' : 'var(--comfy-menu-bg)'};border-radius:4px;margin-bottom:4px;">` +
97
  `<div style="display:flex;align-items:center;gap:8px;"><span style="font-size:14px;min-width:24px;">${rankIcon}</span>${userHtml}</div>` +
98
  `<div style="display:flex;align-items:center;gap:6px;">${levelHtml}<span style="color:#4CAF50;font-weight:bold;font-size:11px;">${entry.amount}</span></div></div>`;
 
125
  container.innerHTML = `
126
  <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;">
127
  <div style="font-size: 12px; font-weight: bold; color: #FF9800; display: flex; align-items: center; gap: 6px;">
128
+ ${escapeHtml(title)}
129
  <span style="font-size: 10px; color: #888; font-weight: normal;">(${t('creator.tip_board.count', {count: 0})})</span>
130
  </div>
131
  ${creatorData ? `<button id="btn-tip-creator-empty" class="creator-tip-btn" style="${_TIP_BUTTON_STYLE}">${t('creator.tip_this_creator')}</button>` : ''}
 
188
  });
189
  }, 0);
190
 
191
+ // 🔒 XSS防护:昵称/头像均来自用户可修改的资料,写入 HTML 前必须转义
192
+ // (下方 SWR 刷新路径用的是 .src / .textContent,本身就是安全的)
193
+ const userHtml = `<span class="tip-board-user" data-account="${escapeHtml(entry.account)}" data-anon="false" style="display: flex; align-items: center; gap: 6px; color: #4CAF50; cursor: pointer; font-size: 13px;"><img class="tip-board-avatar" src="${escapeHtml(avatarUrl)}" style="width: 20px; height: 20px; border-radius: 50%; object-fit: cover; flex-shrink: 0; background: var(--comfy-input-bg);"><span class="tip-board-name">${escapeHtml(userName)}</span></span>`;
194
  return _renderTipBoardEntry(entry, idx, rankIcon, levelHtml, userHtml, containerId);
195
  }).join("");
196
 
197
  container.innerHTML = `
198
  <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px;">
199
  <div style="font-size: 12px; font-weight: bold; color: #FF9800; display: flex; align-items: center; gap: 6px;">
200
+ ${escapeHtml(title)}
201
  <span style="font-size: 10px; color: #888; font-weight: normal;">(${t('creator.tip_board.count', {count: tipBoard.length})})</span>
202
  </div>
203
  ${creatorData ? `<button id="btn-tip-creator" class="creator-tip-btn" style="${_TIP_BUTTON_STYLE}">${t('creator.tip_this_creator')}</button>` : ''}
 
266
  ${hasBanner ? '<div style="position: absolute; inset: 0; background: rgba(0,0,0,0.2); border-radius: 8px;"></div>' : ''}
267
  <div style="position: relative; z-index: 1;">
268
  <div style="display: flex; align-items: center; gap: 15px; margin-bottom: 10px; padding: 5px 0;">
269
+ <img class="creator-avatar-link" src="${escapeHtml(avatarSrc)}" title="${t('creator.visit_profile')}" style="width: 50px; height: 50px; border-radius: 50%; border: 2px solid ${hasBanner ? 'rgba(255,255,255,0.8)' : '#555'}; object-fit: cover; cursor: pointer; transition: 0.2s; ${hasBanner ? 'box-shadow: 0 2px 8px rgba(0,0,0,0.3);' : ''}" onmouseover="this.style.borderColor='#4CAF50'" onmouseout="this.style.borderColor='${hasBanner ? 'rgba(255,255,255,0.8)' : '#555'}'">
270
+ <div class="creator-name-link" title="${t('creator.visit_profile')}" style="font-weight: bold; font-size: 16px; cursor: pointer; transition: 0.2s; color: #FFD700; text-shadow: 0 1px 4px rgba(0,0,0,0.8), 0 0 8px rgba(255,215,0,0.3);" onmouseover="this.style.color='#FFA500'" onmouseout="this.style.color='#FFD700'">${escapeHtml(creatorData.name)}</div>
271
  <div data-stat="downloads" style="font-size: 12px; color: #FF6B6B; margin-left: auto; text-shadow: 0 1px 3px rgba(0,0,0,0.6);">${t('creator.usage_count', {count: creatorData.downloads || 0})}</div>
272
  </div>
273
+ <div style="font-size: 12px; color: ${hasBanner ? '#ccc' : '#aaa'}; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 10px;">${escapeHtml(creatorData.shortDesc && creatorData.shortDesc !== "null" ? creatorData.shortDesc : t('profile.no_intro'))}</div>
274
  <div style="background: rgba(34,34,34,${hasBanner ? '0.8' : '1'}); border-radius: 6px; padding: 8px 10px; border: 1px dashed #555;">
275
  <div style="display: flex; gap: 15px; font-size: 12px; color: #eee; justify-content: space-between; margin-bottom: 8px;">
276
  <span data-stat="likes" style="color: #FF5722;">👍 ${t('creator.stats.likes')}: <strong>${creatorData.likes}</strong></span>
前端页面/market/发布内容_UI模板.js CHANGED
@@ -1,4 +1,5 @@
1
  // 前端页面/market/发布内容_UI模板.js
 
2
 
3
  export function generatePublishHTML(isEditMode, viewTitle, submitBtnText, hasExistingToken, editItemData, t) {
4
  const safeData = editItemData || {};
@@ -10,7 +11,7 @@ export function generatePublishHTML(isEditMode, viewTitle, submitBtnText, hasExi
10
  <button id="btn-back" style="margin-left: 15px; margin-top: 15px; background: rgba(51,51,51,0.8); border: 1px solid rgba(85,85,85,0.8); color: #fff; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: bold; display: flex; align-items: center; gap: 6px; box-shadow: 0 2px 4px rgba(0,0,0,0.3); transition: 0.2s;" onmouseover="this.style.background='#4CAF50'; this.style.borderColor='#4CAF50'" onmouseout="this.style.background='rgba(51,51,51,0.8)'; this.style.borderColor='rgba(85,85,85,0.8)'">
11
  ⬅ ${t('common.back')}
12
  </button>
13
- <span style="font-size: 16px; font-weight: bold; color: #fff;">${viewTitle}</span>
14
  </div>
15
 
16
  <div style="display: flex; gap: 10px; margin-bottom: 5px;">
@@ -135,6 +136,6 @@ export function generatePublishHTML(isEditMode, viewTitle, submitBtnText, hasExi
135
  </div>
136
  </div>
137
 
138
- <button id="btn-submit-publish" style="width: 100%; padding: 12px; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 15px; transition: 0.3s; margin-bottom: 20px;">${submitBtnText}</button>
139
  `;
140
  }
 
1
  // 前端页面/market/发布内容_UI模板.js
2
+ import { escapeHtml } from "../core/全局配置.js";
3
 
4
  export function generatePublishHTML(isEditMode, viewTitle, submitBtnText, hasExistingToken, editItemData, t) {
5
  const safeData = editItemData || {};
 
11
  <button id="btn-back" style="margin-left: 15px; margin-top: 15px; background: rgba(51,51,51,0.8); border: 1px solid rgba(85,85,85,0.8); color: #fff; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: bold; display: flex; align-items: center; gap: 6px; box-shadow: 0 2px 4px rgba(0,0,0,0.3); transition: 0.2s;" onmouseover="this.style.background='#4CAF50'; this.style.borderColor='#4CAF50'" onmouseout="this.style.background='rgba(51,51,51,0.8)'; this.style.borderColor='rgba(85,85,85,0.8)'">
12
  ⬅ ${t('common.back')}
13
  </button>
14
+ <span style="font-size: 16px; font-weight: bold; color: #fff;">${escapeHtml(viewTitle)}</span>
15
  </div>
16
 
17
  <div style="display: flex; gap: 10px; margin-bottom: 5px;">
 
136
  </div>
137
  </div>
138
 
139
+ <button id="btn-submit-publish" style="width: 100%; padding: 12px; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 15px; transition: 0.3s; margin-bottom: 20px;">${escapeHtml(submitBtnText)}</button>
140
  `;
141
  }
前端页面/market/发布内容_提交引擎.js CHANGED
@@ -2,7 +2,7 @@
2
  import { api } from "../core/网络请求API.js";
3
  import { showToast } from "../components/UI交互提示组件.js";
4
  import { t } from "../components/用户体验增强.js";
5
- import { removeCache } from "../components/性能优化工具.js";
6
  import { TYPE_RULES } from "./发布内容组件.js"; // 📋 三榜发布表单声明式规则(与 UI 联动同源)
7
 
8
  /**
@@ -27,9 +27,12 @@ function clearItemCacheByType(type) {
27
  }
28
 
29
  // 清除 API 缓存
30
- removeCache('api_/api/items');
31
- removeCache(`api_/api/items?type=${type.replace('recommend_', '')}`);
32
- removeCache('api_/api/creators'); // 创作者列表也需要刷新
 
 
 
33
 
34
  // 清除侧边栏数据引擎缓存(只清除对应类型)
35
  const sorts = ['time', 'downloads', 'likes', 'favorites', 'tips', 'views', 'daily_views', 'rating'];
 
2
  import { api } from "../core/网络请求API.js";
3
  import { showToast } from "../components/UI交互提示组件.js";
4
  import { t } from "../components/用户体验增强.js";
5
+ import { removeCache, removeCacheByPrefix } from "../components/性能优化工具.js";
6
  import { TYPE_RULES } from "./发布内容组件.js"; // 📋 三榜发布表单声明式规则(与 UI 联动同源)
7
 
8
  /**
 
27
  }
28
 
29
  // 清除 API 缓存
30
+ // 🔧 修复:API 层缓存键总是带完整查询串(api_/api/items?type=tool&sort=time&limit=200),
31
+ // 而 removeCache 是精确键删除,原先这几个调用一个也命中不到(no-op),发布成功后
32
+ // request() 仍会从缓存直接返回旧列表、看不到刚发布的内容。改用前缀删除,
33
+ // 一次覆盖全部 type/sort/limit 组合(原先按 type 单独删的那行已被本前缀覆盖,属冗余)
34
+ removeCacheByPrefix('api_/api/items');
35
+ removeCacheByPrefix('api_/api/creators'); // 创作者列表也需要刷新
36
 
37
  // 清除侧边栏数据引擎缓存(只清除对应类型)
38
  const sorts = ['time', 'downloads', 'likes', 'favorites', 'tips', 'views', 'daily_views', 'rating'];
前端页面/market/发布内容组件.js CHANGED
@@ -4,6 +4,7 @@ import { handlePublishSubmit } from "./发布内容_提交引擎.js";
4
  import { t } from "../components/用户体验增强.js";
5
  import { showConfirm, showToast } from "../components/UI交互提示组件.js";
6
  import { globalModal } from "../components/全局弹窗管理器.js";
 
7
 
8
  // ==========================================
9
  // 📌 模块级常量
@@ -40,7 +41,7 @@ function _createImagePreviewWrapper(imgUrl, idx) {
40
  const wrapper = document.createElement('div');
41
  wrapper.style.cssText = 'position: relative; width: 80px; height: 80px;';
42
  wrapper.innerHTML = `
43
- <img src="${imgUrl}" style="width: 80px; height: 80px; object-fit: cover; border-radius: 4px; border: 2px solid ${idx === 0 ? '#4CAF50' : '#444'};">
44
  ${idx === 0 ? `<span class="cover-label" style="${_COVER_LABEL_STYLE}">${t('post.cover')}</span>` : ''}
45
  <button data-action="remove" style="position: absolute; top: -6px; right: -6px; width: 18px; height: 18px; border-radius: 50%; background: #F44336; color: #fff; border: none; cursor: pointer; font-size: 12px; line-height: 1;">×</button>
46
  `;
 
4
  import { t } from "../components/用户体验增强.js";
5
  import { showConfirm, showToast } from "../components/UI交互提示组件.js";
6
  import { globalModal } from "../components/全局弹窗管理器.js";
7
+ import { escapeHtml } from "../core/全局配置.js";
8
 
9
  // ==========================================
10
  // 📌 模块级常量
 
41
  const wrapper = document.createElement('div');
42
  wrapper.style.cssText = 'position: relative; width: 80px; height: 80px;';
43
  wrapper.innerHTML = `
44
+ <img src="${escapeHtml(imgUrl)}" style="width: 80px; height: 80px; object-fit: cover; border-radius: 4px; border: 2px solid ${idx === 0 ? '#4CAF50' : '#444'};">
45
  ${idx === 0 ? `<span class="cover-label" style="${_COVER_LABEL_STYLE}">${t('post.cover')}</span>` : ''}
46
  <button data-action="remove" style="position: absolute; top: -6px; right: -6px; width: 18px; height: 18px; border-radius: 50%; background: #F44336; color: #fff; border: none; cursor: pointer; font-size: 12px; line-height: 1;">×</button>
47
  `;
前端页面/market/资源安装引擎.js CHANGED
@@ -3,7 +3,8 @@ import { showToast, showConfirm, createInstallProgress } from "../components/UI
3
  import { api } from "../core/网络请求API.js";
4
  import { requestSSE, request } from "../core/网络请求_基础设施.js";
5
  import { openUserProfileModal } from "../profile/个人中心视图.js";
6
- import { API, CACHE, IS_WEB_MODE } from "../core/全局配置.js";
 
7
 
8
  // 📱 Web 模式兼容:ComfyUI 的 app 对象仅存在于本地环境,改为受保护动态导入
9
  // (Web 模式不暴露安装/下载入口,不会走到此路径;万一走到也不抛错)
@@ -17,7 +18,7 @@ async function _loadGraphToCanvas(graphData) {
17
  return false;
18
  }
19
  }
20
- import { removeCache } from "../components/性能优化工具.js";
21
 
22
  // ==========================================
23
  // 📦 已获取资源记录管理
@@ -27,8 +28,10 @@ import { removeCache } from "../components/性能优化工具.js";
27
  * 🗑️ 清除使用量相关缓存
28
  */
29
  function clearUsesCache() {
30
- removeCache('api_/api/items');
31
- removeCache('api_/api/creators');
 
 
32
  const tabs = ['tools', 'apps', 'recommends', 'creators'];
33
  const sorts = ['time', 'downloads', 'likes', 'favorites', 'tips', 'views', 'daily_views', 'rating'];
34
  for (const tab of tabs) {
@@ -199,7 +202,7 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
199
 
200
  // 📱 Web 模式:不支持下载/安装。免费(含作者本人)或已购买 → 仅提醒电脑端安装;未购买 → 放行到下方纯购买流程
201
  if (IS_WEB_MODE) {
202
- if (isFree) return showToast("请在电脑端进行安装", "warning");
203
  let webOwned = false;
204
  try {
205
  const st = await api.getPurchaseStatus(currentUser.account, itemData.id);
@@ -207,7 +210,7 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
207
  } catch (e) {
208
  console.warn('购买状态查询失败,继续购买流程', e);
209
  }
210
- if (webOwned) return showToast("请在电脑端进行安装", "warning");
211
  }
212
 
213
  // 记录是否已经拥有,决定是否涨销量
@@ -267,7 +270,7 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
267
  clearTimeout(valTimeout);
268
  const valData = await valRes.json();
269
  if (!valRes.ok || valData.error) {
270
- inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 拦截提示: ${valData.error || '无法访问源地址'}</span>`;
271
  return; // 发现死链,强行阻断用户付款
272
  }
273
  } catch(e) {
@@ -280,7 +283,7 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
280
 
281
  // 已购买用户跳过购买确认弹窗,直接进入后续流程
282
  if (!alreadyOwned) {
283
- const confirmPay = await showConfirm(`该资源标价为 <strong style="color:#FF9800;">${itemData.price} 积分</strong>。<br><br>确认获取吗?<br><span style="font-size:12px;color:#aaa;">(注:一次购买永久免费,如果已购买过系统将直接放行,不会重复扣款)</span>`);
284
  if (!confirmPay) {
285
  inlineStatusBox.style.display = "none";
286
  return;
@@ -329,7 +332,7 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
329
  }
330
  return; // 直接退出函数,不会走到下方的安装流程
331
  } else {
332
- inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 获取失败: ${err.message}</span>`;
333
  showToast("获取失败:" + err.message, "error");
334
  return;
335
  }
@@ -339,9 +342,9 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
339
  if (IS_WEB_MODE) {
340
  inlineStatusBox.style.display = "block";
341
  inlineStatusBox.innerHTML = alreadyOwned
342
- ? `<span style="color: #4CAF50;">✅ 您已购买过此资源,请在电脑端进行安装。</span>`
343
- : `<span style="color: #4CAF50;">✅ 购买成功!请在电脑端进行安装。</span>`;
344
- showToast("请在电脑端进行安装", "info");
345
  return;
346
  }
347
 
@@ -449,8 +452,16 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
449
  if (data.error) {
450
  const isLinkError = data.error.includes('不是有效的 Git');
451
  inlineStatusBox.innerHTML = isLinkError
452
- ? `<span style="color: #F44336;">❌ ${data.error}</span><br><button onclick="window.open('${itemData.link}', '_blank')" style="margin-top:8px;padding:6px 14px;background:#2563eb;color:white;border:none;border-radius:4px;cursor:pointer;font-weight:bold;">🔗 前往资源原始页面</button>`
453
- : `<span style="color: #F44336;">❌ 安装失败: ${data.error}</span>`;
 
 
 
 
 
 
 
 
454
  showToast(`插件 [${itemData.title}] 安装失败: ${data.error}`, "error");
455
  } else {
456
  // 根据后端返回的 message 判断是否为自更新场景
@@ -517,7 +528,7 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
517
  btnUse.style.background = "#4CAF50";
518
  } else {
519
  progress.error(result.message);
520
- inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 加载失败:${result.message}</span>`;
521
  showToast(`工作流加载失败:${result.message}`, "error");
522
  }
523
  } catch(err) {
@@ -533,7 +544,7 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
533
  });
534
  if (!res.ok) {
535
  const errText = await res.text().catch(() => '未知服务端错误');
536
- inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 加载失败 (${res.status}):${errText}</span>`;
537
  return;
538
  }
539
  let data;
@@ -541,11 +552,11 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
541
  data = await res.json();
542
  } catch (parseErr) {
543
  inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 响应解析失败,请重试</span>`;
544
- showToast(t('common.parse_error') || '响应解析失败', "error");
545
  return;
546
  }
547
  if (data.error) {
548
- inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 加载失败:${data.error}</span>`;
549
  showToast(`工作流加载失败:${data.error}`, "error");
550
  } else {
551
  await _loadGraphToCanvas(data.data);
@@ -567,7 +578,7 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
567
  }
568
  } catch(err2) {
569
  console.error('❌ 应用下载失败:', err2);
570
- inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 无法连接到本地服务:${err2.message || '网络错误'}</span>`;
571
  }
572
  }
573
  } else {
 
3
  import { api } from "../core/网络请求API.js";
4
  import { requestSSE, request } from "../core/网络请求_基础设施.js";
5
  import { openUserProfileModal } from "../profile/个人中心视图.js";
6
+ import { API, CACHE, IS_WEB_MODE, escapeHtml } from "../core/全局配置.js";
7
+ import { t } from "../components/用户体验增强.js";
8
 
9
  // 📱 Web 模式兼容:ComfyUI 的 app 对象仅存在于本地环境,改为受保护动态导入
10
  // (Web 模式不暴露安装/下载入口,不会走到此路径;万一走到也不抛错)
 
18
  return false;
19
  }
20
  }
21
+ import { removeCache, removeCacheByPrefix } from "../components/性能优化工具.js";
22
 
23
  // ==========================================
24
  // 📦 已获取资源记录管理
 
28
  * 🗑️ 清除使用量相关缓存
29
  */
30
  function clearUsesCache() {
31
+ // 🔧 修复:API 层缓存键带完整查询串,removeCache 精确删除命中不到(no-op),
32
+ // 安装后使用量/下载数仍会从旧缓存返回,改用前缀删除
33
+ removeCacheByPrefix('api_/api/items');
34
+ removeCacheByPrefix('api_/api/creators');
35
  const tabs = ['tools', 'apps', 'recommends', 'creators'];
36
  const sorts = ['time', 'downloads', 'likes', 'favorites', 'tips', 'views', 'daily_views', 'rating'];
37
  for (const tab of tabs) {
 
202
 
203
  // 📱 Web 模式:不支持下载/安装。免费(含作者本人)或已购买 → 仅提醒电脑端安装;未购买 → 放行到下方纯购买流程
204
  if (IS_WEB_MODE) {
205
+ if (isFree) return showToast(t('web.install_on_desktop'), "warning");
206
  let webOwned = false;
207
  try {
208
  const st = await api.getPurchaseStatus(currentUser.account, itemData.id);
 
210
  } catch (e) {
211
  console.warn('购买状态查询失败,继续购买流程', e);
212
  }
213
+ if (webOwned) return showToast(t('item.owned_purchased'), "warning");
214
  }
215
 
216
  // 记录是否已经拥有,决定是否涨销量
 
270
  clearTimeout(valTimeout);
271
  const valData = await valRes.json();
272
  if (!valRes.ok || valData.error) {
273
+ inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 拦截提示: ${escapeHtml(valData.error || '无法访问源地址')}</span>`;
274
  return; // 发现死链,强行阻断用户付款
275
  }
276
  } catch(e) {
 
283
 
284
  // 已购买用户跳过购买确认弹窗,直接进入后续流程
285
  if (!alreadyOwned) {
286
+ const confirmPay = await showConfirm(`该资源标价为 <strong style="color:#FF9800;">${itemData.price} 积分</strong>。<br><br>确认获取吗?<br><span style="font-size:12px;color:#aaa;">(注:一次购买永久免费,如果已购买过系统将直接放行,不会重复扣款)</span>`, { html: true });
287
  if (!confirmPay) {
288
  inlineStatusBox.style.display = "none";
289
  return;
 
332
  }
333
  return; // 直接退出函数,不会走到下方的安装流程
334
  } else {
335
+ inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 获取失败: ${escapeHtml(err.message)}</span>`;
336
  showToast("获取失败:" + err.message, "error");
337
  return;
338
  }
 
342
  if (IS_WEB_MODE) {
343
  inlineStatusBox.style.display = "block";
344
  inlineStatusBox.innerHTML = alreadyOwned
345
+ ? `<span style="color: #4CAF50;">✅ ${escapeHtml(t('item.owned_purchased'))}</span>`
346
+ : `<span style="color: #4CAF50;">✅ ${escapeHtml(t('web.purchase_success_desktop'))}</span>`;
347
+ showToast(t('web.install_on_desktop'), "info");
348
  return;
349
  }
350
 
 
452
  if (data.error) {
453
  const isLinkError = data.error.includes('不是有效的 Git');
454
  inlineStatusBox.innerHTML = isLinkError
455
+ ? `<span style="color: #F44336;">❌ ${escapeHtml(data.error)}</span><br><button id="btn-open-source-link" style="margin-top:8px;padding:6px 14px;background:#2563eb;color:white;border:none;border-radius:4px;cursor:pointer;font-weight:bold;">🔗 前往资源原始页面</button>`
456
+ : `<span style="color: #F44336;">❌ 安装失败: ${escapeHtml(data.error)}</span>`;
457
+ // 🔒 XSS防护:链接改为 DOM 事件绑定(内联 onclick 里的引号即使转义,
458
+ // 也会被浏览器先还原再交给 JS 解析,依然能被注入;本文件其他分支均用此写法)
459
+ if (isLinkError) {
460
+ const btnOpenSource = inlineStatusBox.querySelector('#btn-open-source-link');
461
+ if (btnOpenSource) {
462
+ btnOpenSource.onclick = () => window.open(itemData.link, '_blank');
463
+ }
464
+ }
465
  showToast(`插件 [${itemData.title}] 安装失败: ${data.error}`, "error");
466
  } else {
467
  // 根据后端返回的 message 判断是否为自更新场景
 
528
  btnUse.style.background = "#4CAF50";
529
  } else {
530
  progress.error(result.message);
531
+ inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 加载失败:${escapeHtml(result.message)}</span>`;
532
  showToast(`工作流加载失败:${result.message}`, "error");
533
  }
534
  } catch(err) {
 
544
  });
545
  if (!res.ok) {
546
  const errText = await res.text().catch(() => '未知服务端错误');
547
+ inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 加载失败 (${res.status}):${escapeHtml(errText)}</span>`;
548
  return;
549
  }
550
  let data;
 
552
  data = await res.json();
553
  } catch (parseErr) {
554
  inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 响应解析失败,请重试</span>`;
555
+ showToast(t('common.parse_error'), "error");
556
  return;
557
  }
558
  if (data.error) {
559
+ inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 加载失败:${escapeHtml(data.error)}</span>`;
560
  showToast(`工作流加载失败:${data.error}`, "error");
561
  } else {
562
  await _loadGraphToCanvas(data.data);
 
578
  }
579
  } catch(err2) {
580
  console.error('❌ 应用下载失败:', err2);
581
+ inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 无法连接到本地服务:${escapeHtml(err2.message || '网络错误')}</span>`;
582
  }
583
  }
584
  } else {
前端页面/market/资源详情页面组件.js CHANGED
@@ -15,7 +15,7 @@ import { openTipModal } from "../profile/个人中心_赞赏组件.js";
15
  import { setupResourceInstall } from "./资源安装引擎.js";
16
  import { renderTipBoardHTML } from "../components/打赏等级工具.js";
17
  import { t } from "../components/用户体验增强.js";
18
- import { removeCache } from "../components/性能优化工具.js";
19
  import { invalidateRelatedCache } from "../core/网络请求API.js";
20
  import { showToast } from "../components/UI交互提示组件.js";
21
  // 🧹 P2归一:escapeHtml 局部副本已移除,改用统一版(转义更严格,防XSS语义不变)
@@ -101,7 +101,7 @@ function _showConfirmOverlay({ title, titleColor, infoHtml, warningHtml, warning
101
  overlay.innerHTML = `
102
  <div style="background: #1e2233; border-radius: 12px; padding: 25px; max-width: 420px; width: 90%; color: #fff; box-shadow: 0 8px 32px rgba(0,0,0,0.5);">
103
  <div style="font-size: 18px; font-weight: bold; margin-bottom: 20px; color: ${titleColor}; display: flex; align-items: center; gap: 10px;">
104
- ⚠️ ${title}
105
  </div>
106
 
107
  <div style="background: #2a2d3e; border-radius: 8px; padding: 15px; margin-bottom: 15px;">
@@ -119,7 +119,7 @@ function _showConfirmOverlay({ title, titleColor, infoHtml, warningHtml, warning
119
  ${t('common.cancel')}
120
  </button>
121
  <button id="btn-confirm-action" style="background: ${confirmColor}; font-weight: bold; ${_btnBaseStyle}">
122
- ${confirmText}
123
  </button>
124
  </div>
125
  </div>
@@ -212,8 +212,10 @@ function burnLocalFiles(itemId) {
212
  removeCache(`ItemOwnership_${itemId}`);
213
 
214
  // 清除列表缓存(强制下次刷新)
215
- removeCache('api_/api/items');
216
- removeCache('api_/api/creators');
 
 
217
 
218
  // 清除工具/应用/推荐列表缓存(使用标准 ListCache 格式)
219
  const tabs = ['tools', 'apps', 'recommends'];
@@ -328,7 +330,7 @@ export function createItemDetailView(itemData, currentUser) {
328
  let authorInfoHtml = `
329
  <div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
330
  <div>
331
- <strong>${t('item.author')}:</strong> <span id="detail-author-name">${authorName}</span>
332
  <!-- 原创标识 -->
333
  ${itemData.is_original ? `
334
  <div style="display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px;
 
15
  import { setupResourceInstall } from "./资源安装引擎.js";
16
  import { renderTipBoardHTML } from "../components/打赏等级工具.js";
17
  import { t } from "../components/用户体验增强.js";
18
+ import { removeCache, removeCacheByPrefix } from "../components/性能优化工具.js";
19
  import { invalidateRelatedCache } from "../core/网络请求API.js";
20
  import { showToast } from "../components/UI交互提示组件.js";
21
  // 🧹 P2归一:escapeHtml 局部副本已移除,改用统一版(转义更严格,防XSS语义不变)
 
101
  overlay.innerHTML = `
102
  <div style="background: #1e2233; border-radius: 12px; padding: 25px; max-width: 420px; width: 90%; color: #fff; box-shadow: 0 8px 32px rgba(0,0,0,0.5);">
103
  <div style="font-size: 18px; font-weight: bold; margin-bottom: 20px; color: ${titleColor}; display: flex; align-items: center; gap: 10px;">
104
+ ⚠️ ${escapeHtml(title)}
105
  </div>
106
 
107
  <div style="background: #2a2d3e; border-radius: 8px; padding: 15px; margin-bottom: 15px;">
 
119
  ${t('common.cancel')}
120
  </button>
121
  <button id="btn-confirm-action" style="background: ${confirmColor}; font-weight: bold; ${_btnBaseStyle}">
122
+ ${escapeHtml(confirmText)}
123
  </button>
124
  </div>
125
  </div>
 
212
  removeCache(`ItemOwnership_${itemId}`);
213
 
214
  // 清除列表缓存(强制下次刷新)
215
+ // 🔧 修复:API 层缓存键带完整查询串,removeCache 精确删除命中不到(no-op),
216
+ // 购买/下载后返回列表仍会看到旧数据,改用前缀删除
217
+ removeCacheByPrefix('api_/api/items');
218
+ removeCacheByPrefix('api_/api/creators');
219
 
220
  // 清除工具/应用/推荐列表缓存(使用标准 ListCache 格式)
221
  const tabs = ['tools', 'apps', 'recommends'];
 
330
  let authorInfoHtml = `
331
  <div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
332
  <div>
333
+ <strong>${t('item.author')}:</strong> <span id="detail-author-name">${escapeHtml(authorName)}</span>
334
  <!-- 原创标识 -->
335
  ${itemData.is_original ? `
336
  <div style="display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px;
前端页面/market/资金与钱包_充值组件.js CHANGED
@@ -149,7 +149,7 @@ export async function openRechargeModal(currentUser, onBalanceChange) {
149
  return showToast(t('wallet.recharge.invalid_amount'), "warning");
150
  }
151
  if (customVal > 10000) {
152
- return showToast(t('wallet.recharge.max_amount_exceeded') || "单次充值金额不得超过10000元", "warning");
153
  }
154
  finalPoints = customVal;
155
  finalPrice = customVal;
@@ -167,7 +167,7 @@ export async function openRechargeModal(currentUser, onBalanceChange) {
167
  });
168
  if (!confirmed) return;
169
 
170
- _setButtonLoading(btnCreateOrder, true, t('wallet.recharge.processing') || "处理中...", t('wallet.recharge.get_qr'));
171
  qrContainer.style.display = "block";
172
  qrLoading.style.display = "block";
173
  qrImage.style.display = "none";
@@ -201,7 +201,7 @@ qrImage.src = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${e
201
  if (pollCount >= MAX_POLL_COUNT) {
202
  clearInterval(pollingInterval);
203
  pollingInterval = null;
204
- showToast(t('wallet.recharge.timeout') || '支付查询超时,如已支付请稍后刷新查看余额', "warning");
205
  return;
206
  }
207
  try {
@@ -244,7 +244,7 @@ qrImage.src = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${e
244
  qrLoading.innerText = t('wallet.recharge.order_failed') + error.message;
245
  qrLoading.style.color = "#F44336";
246
  } finally {
247
- _setButtonLoading(btnCreateOrder, false, t('wallet.recharge.processing') || "处理中...", t('wallet.recharge.get_qr'));
248
  }
249
  };
250
 
 
149
  return showToast(t('wallet.recharge.invalid_amount'), "warning");
150
  }
151
  if (customVal > 10000) {
152
+ return showToast(t('wallet.recharge.max_amount_exceeded'), "warning");
153
  }
154
  finalPoints = customVal;
155
  finalPrice = customVal;
 
167
  });
168
  if (!confirmed) return;
169
 
170
+ _setButtonLoading(btnCreateOrder, true, t('wallet.recharge.processing'), t('wallet.recharge.get_qr'));
171
  qrContainer.style.display = "block";
172
  qrLoading.style.display = "block";
173
  qrImage.style.display = "none";
 
201
  if (pollCount >= MAX_POLL_COUNT) {
202
  clearInterval(pollingInterval);
203
  pollingInterval = null;
204
+ showToast(t('wallet.recharge.timeout'), "warning");
205
  return;
206
  }
207
  try {
 
244
  qrLoading.innerText = t('wallet.recharge.order_failed') + error.message;
245
  qrLoading.style.color = "#F44336";
246
  } finally {
247
+ _setButtonLoading(btnCreateOrder, false, t('wallet.recharge.processing'), t('wallet.recharge.get_qr'));
248
  }
249
  };
250
 
前端页面/market/资金与钱包_提现组件.js CHANGED
@@ -178,7 +178,7 @@ export async function openWithdrawModal(currentUser, onSuccess) {
178
  const timeoutId = setTimeout(() => {
179
  isSubmitting = false;
180
  _setButtonState(btnSubmit, false, "#4CAF50", t('wallet.withdraw.confirm'));
181
- showToast(t('wallet.withdraw.timeout') || "提现请求超时,请检查网络后重试", "warning");
182
  }, 30000);
183
 
184
  try {
 
178
  const timeoutId = setTimeout(() => {
179
  isSubmitting = false;
180
  _setButtonState(btnSubmit, false, "#4CAF50", t('wallet.withdraw.confirm'));
181
+ showToast(t('wallet.withdraw.timeout'), "warning");
182
  }, 30000);
183
 
184
  try {
前端页面/post/发布帖子组件.js CHANGED
@@ -11,12 +11,14 @@
11
  import { api } from "../core/网络请求API.js";
12
  import { showToast, showConfirm } from "../components/UI交互提示组件.js";
13
  import { t } from "../components/用户体验增强.js";
14
- import { removeCache } from "../components/性能优化工具.js";
15
- import { API } from "../core/全局配置.js";
16
 
17
  // 📦 清除帖子列表缓存
18
  function clearPostListCache() {
19
- removeCache('api_/api/posts');
 
 
20
  // 🔧 P1修复:讨论区组件实际使用 PostsCache_${sort} 键(非 ListCache_*),按真实键格式精准清除
21
  // 讨论区排序值:latest, likes, favorites, tips, views, daily_views, rating
22
  const sorts = ['latest', 'likes', 'favorites', 'tips', 'views', 'daily_views', 'rating'];
@@ -372,14 +374,14 @@ export function createPublishPostView(currentUser, editPostData = null) {
372
  <!-- 模式切换 -->
373
  <div style="margin-bottom: 15px;">
374
  <label style="display: block; font-size: 13px; font-weight: bold; color: #fff; margin-bottom: 8px;">
375
- ${t('post.mode_label') || '发布模式'}
376
  </label>
377
  <div style="display: flex; gap: 0; background: #1a1a1a; border: 1px solid #444; border-radius: 6px; overflow: hidden;">
378
  <button id="mode-image" style="flex: 1; padding: 10px; background: #4CAF50; color: #fff; border: none; cursor: pointer; font-size: 13px; font-weight: bold; transition: 0.2s;">
379
- 📷 ${t('post.mode_image') || '图文'}
380
  </button>
381
  <button id="mode-video" style="flex: 1; padding: 10px; background: transparent; color: #888; border: none; cursor: pointer; font-size: 13px; font-weight: bold; transition: 0.2s; border-left: 1px solid #444;">
382
- 🎬 ${t('post.mode_video') || '视频'}
383
  </button>
384
  </div>
385
  </div>
@@ -402,14 +404,14 @@ export function createPublishPostView(currentUser, editPostData = null) {
402
  <!-- 视频上传区 -->
403
  <div id="video-upload-section" style="margin-bottom: 20px; display: none;">
404
  <label style="display: block; font-size: 13px; font-weight: bold; color: #fff; margin-bottom: 8px;">
405
- 🎬 ${t('post.upload_video') || '上传视频'} <span style="color: #F44336;">*</span>
406
  <span style="font-weight: normal; color: #888; font-size: 12px;">(mp4/webm/mov,≤50MB,≤3分钟)</span>
407
  </label>
408
  <input type="file" id="video-input" accept="video/mp4,video/webm,video/quicktime" style="display: none;">
409
  <div id="video-preview-area" style="padding: 15px; background: #1a1a1a; border: 2px dashed #444; border-radius: 8px; cursor: pointer; transition: 0.2s;">
410
  <div id="video-empty-state" style="display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; color: #666; font-size: 13px;">
411
  <div style="font-size: 32px; margin-bottom: 8px;">🎬</div>
412
- ${t('post.click_upload_video') || '点击选择视频'}
413
  </div>
414
  <div id="video-player-wrap" style="display: none;">
415
  <video id="video-player" controls style="width: 100%; max-height: 300px; border-radius: 8px; background: #000; display: block;"></video>
@@ -422,7 +424,7 @@ export function createPublishPostView(currentUser, editPostData = null) {
422
  </div>
423
  <!-- 封面区域 -->
424
  <div id="video-cover-wrap" style="display: none; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border-color, #333);">
425
- <div style="font-size: 12px; font-weight: bold; color: #fff; margin-bottom: 8px;">${t('post.video_cover') || '视频封面'}</div>
426
  <div style="display: flex; align-items: center; gap: 12px;">
427
  <div style="position: relative; width: 80px; height: 80px; flex-shrink: 0;">
428
  <img id="cover-thumb" src="" style="width: 80px; height: 80px; object-fit: cover; border-radius: 6px; border: 2px solid #4CAF50; display: none;">
@@ -430,10 +432,10 @@ export function createPublishPostView(currentUser, editPostData = null) {
430
  </div>
431
  <div style="display: flex; flex-direction: column; gap: 6px;">
432
  <button id="btn-capture-frame" style="padding: 6px 12px; background: #333; border: 1px solid #555; color: #ccc; border-radius: 4px; cursor: pointer; font-size: 12px; transition: 0.2s;" onmouseover="this.style.background='#444'" onmouseout="this.style.background='#333'">
433
- 📸 ${t('post.capture_frame') || '截取当前帧'}
434
  </button>
435
  <button id="btn-upload-cover" style="padding: 6px 12px; background: #333; border: 1px solid #555; color: #ccc; border-radius: 4px; cursor: pointer; font-size: 12px; transition: 0.2s;" onmouseover="this.style.background='#444'" onmouseout="this.style.background='#333'">
436
- 🖼️ ${t('post.upload_cover') || '手动上传封面'}
437
  </button>
438
  <input type="file" id="cover-input" accept="image/*" style="display: none;">
439
  </div>
@@ -448,7 +450,7 @@ export function createPublishPostView(currentUser, editPostData = null) {
448
  <label style="display: block; font-size: 13px; font-weight: bold; color: #fff; margin-bottom: 8px;">
449
  📝 ${t('post.title_label')} <span style="color: #F44336;">*</span>
450
  </label>
451
- <input type="text" id="title-input" value="${editTitle}" placeholder="${t('post.title_placeholder')}" maxlength="50" style="width: 100%; padding: 12px; background: #1a1a1a; border: 1px solid #444; border-radius: 6px; color: #fff; font-size: 14px; box-sizing: border-box; outline: none;" onfocus="this.style.borderColor='#4CAF50'" onblur="this.style.borderColor='#444'">
452
  <div style="text-align: right; font-size: 11px; color: #666; margin-top: 4px;">
453
  <span id="title-count">${editTitle.length}</span>/50
454
  </div>
@@ -459,7 +461,7 @@ export function createPublishPostView(currentUser, editPostData = null) {
459
  <label style="display: block; font-size: 13px; font-weight: bold; color: #fff; margin-bottom: 8px;">
460
  ✍️ ${t('post.content_label')}
461
  </label>
462
- <textarea id="content-input" placeholder="${t('post.content_placeholder')}" maxlength="2000" style="width: 100%; height: 200px; padding: 12px; background: #1a1a1a; border: 1px solid #444; border-radius: 6px; color: #fff; font-size: 14px; box-sizing: border-box; outline: none; resize: none; line-height: 1.6;" onfocus="this.style.borderColor='#4CAF50'" onblur="this.style.borderColor='#444'">${editContent}</textarea>
463
  <div style="text-align: right; font-size: 11px; color: #666; margin-top: 4px;">
464
  <span id="content-count">${editContent.length}</span>/2000
465
  </div>
@@ -684,7 +686,7 @@ export function createPublishPostView(currentUser, editPostData = null) {
684
  Object.assign(wrapper.style, PREVIEW_WRAPPER_STYLE);
685
 
686
  wrapper.innerHTML = `
687
- <img src="${url}" style="width: 80px; height: 80px; object-fit: cover; border-radius: 6px; border: 2px solid ${idx === 0 ? '#4CAF50' : '#444'};">
688
  ${idx === 0 ? `<span class="cover-label" style="position: absolute; top: 4px; left: 4px; background: #4CAF50; color: #fff; font-size: 10px; padding: 2px 6px; border-radius: 3px;">${t('post.cover')}</span>` : ''}
689
  <button data-action="remove" data-existing-idx="${idx}" style="position: absolute; top: -6px; right: -6px; width: 20px; height: 20px; border-radius: 50%; background: #F44336; color: #fff; border: none; cursor: pointer; font-size: 12px; line-height: 1;">×</button>
690
  `;
@@ -792,14 +794,14 @@ export function createPublishPostView(currentUser, editPostData = null) {
792
  const validExts = ['.mp4', '.webm', '.mov'];
793
  const isValidType = validTypes.includes(file.type) || validExts.some(ext => file.name.toLowerCase().endsWith(ext));
794
  if (!isValidType) {
795
- _showVideoErrorAndReset(t('post.error_video_format') || '仅支持 mp4、webm、mov 格式的视频', videoInput);
796
  return;
797
  }
798
 
799
  // 大小检查
800
  const MAX_SIZE = 100 * 1024 * 1024;
801
  if (file.size > MAX_SIZE) {
802
- _showVideoErrorAndReset(t('post.error_video_size') || '视频大小不能超过 100MB', videoInput);
803
  return;
804
  }
805
 
@@ -812,7 +814,7 @@ export function createPublishPostView(currentUser, editPostData = null) {
812
  tempVideo.onloadedmetadata = () => {
813
  URL.revokeObjectURL(tempUrl);
814
  if (tempVideo.duration > 180) {
815
- _showVideoErrorAndReset(t('post.error_video_duration') || '视频时长不能超过 3 分钟', videoInput);
816
  return;
817
  }
818
 
@@ -826,7 +828,7 @@ export function createPublishPostView(currentUser, editPostData = null) {
826
 
827
  tempVideo.onerror = () => {
828
  URL.revokeObjectURL(tempUrl);
829
- _showVideoErrorAndReset(t('post.error_video_load') || '无法读取视频信息', videoInput);
830
  };
831
  };
832
 
@@ -854,7 +856,7 @@ export function createPublishPostView(currentUser, editPostData = null) {
854
  videoNameEl.textContent = videoFile.name;
855
  videoMetaEl.textContent = `${formatFileSize(videoFile.size)} · ${Math.round(videoDuration)}秒`;
856
  } else if (currentVideoUrl) {
857
- videoNameEl.textContent = t('post.existing_video') || '已有视频';
858
  videoMetaEl.textContent = '';
859
  }
860
 
@@ -930,7 +932,7 @@ export function createPublishPostView(currentUser, editPostData = null) {
930
  btnCaptureFrame.onclick = async (e) => {
931
  e.stopPropagation();
932
  if (!videoPlayer.videoWidth) {
933
- showToast(t('post.error_video_not_ready') || '视频尚未加载完成', "warning");
934
  return;
935
  }
936
  const cover = await generateCoverFromVideo(videoPlayer);
@@ -938,9 +940,9 @@ export function createPublishPostView(currentUser, editPostData = null) {
938
  videoCoverFile = cover;
939
  currentCoverUrl = '';
940
  renderCoverThumb();
941
- showToast(t('post.cover_captured') || '封面已更新', "success");
942
  } else {
943
- showToast(t('post.cover_capture_failed') || '封面截取失败', "error");
944
  }
945
  };
946
 
@@ -954,7 +956,7 @@ export function createPublishPostView(currentUser, editPostData = null) {
954
  const file = e.target.files?.[0];
955
  if (!file) return;
956
  if (!file.type.startsWith('image/')) {
957
- _showVideoErrorAndReset(t('post.error_cover_format') || '请选择图片文件作为封面', coverInput);
958
  return;
959
  }
960
  videoCoverFile = file;
@@ -984,15 +986,15 @@ export function createPublishPostView(currentUser, editPostData = null) {
984
  return;
985
  }
986
  if (!content) {
987
- showToast(t('post.error_no_content') || '请输入正文内容', "warning");
988
  return;
989
  }
990
  if (!videoFile && !currentVideoUrl) {
991
- showToast(t('post.error_no_video') || '请选择视频文件', "warning");
992
  return;
993
  }
994
  if (!videoCoverFile && !currentCoverUrl) {
995
- showToast(t('post.error_no_cover') || '请等待封面生成或手动上传封面', "warning");
996
  return;
997
  }
998
 
@@ -1002,7 +1004,7 @@ export function createPublishPostView(currentUser, editPostData = null) {
1002
  // 上传封面
1003
  let coverUrl = currentCoverUrl;
1004
  if (videoCoverFile) {
1005
- submitBtn.textContent = `⏳ ${t('post.uploading_cover') || '上传封面中...'}`;
1006
  const res = await api.uploadFile(videoCoverFile, "post");
1007
  coverUrl = res.url;
1008
  }
@@ -1010,9 +1012,9 @@ export function createPublishPostView(currentUser, editPostData = null) {
1010
  // 上传视频(带进度的 XMLHttpRequest)
1011
  let videoUrl = currentVideoUrl;
1012
  if (videoFile) {
1013
- submitBtn.textContent = `⏳ ${t('post.uploading_video') || '上传视频中...'} 0%`;
1014
  const res = await uploadVideoWithProgress(videoFile, (percent) => {
1015
- submitBtn.textContent = `⏳ ${t('post.uploading_video') || '上传视频中...'} ${percent}%`;
1016
  });
1017
  videoUrl = res.url;
1018
  }
 
11
  import { api } from "../core/网络请求API.js";
12
  import { showToast, showConfirm } from "../components/UI交互提示组件.js";
13
  import { t } from "../components/用户体验增强.js";
14
+ import { removeCache, removeCacheByPrefix } from "../components/性能优化工具.js";
15
+ import { API, escapeHtml } from "../core/全局配置.js";
16
 
17
  // 📦 清除帖子列表缓存
18
  function clearPostListCache() {
19
+ // 🔧 修复:API 层缓存键带完整查询串(api_/api/posts?sort=..&limit=..),
20
+ // removeCache 精确删除命中不到(no-op),发帖后讨论区仍会从旧缓存返回,改用前缀删除
21
+ removeCacheByPrefix('api_/api/posts');
22
  // 🔧 P1修复:讨论区组件实际使用 PostsCache_${sort} 键(非 ListCache_*),按真实键格式精准清除
23
  // 讨论区排序值:latest, likes, favorites, tips, views, daily_views, rating
24
  const sorts = ['latest', 'likes', 'favorites', 'tips', 'views', 'daily_views', 'rating'];
 
374
  <!-- 模式切换 -->
375
  <div style="margin-bottom: 15px;">
376
  <label style="display: block; font-size: 13px; font-weight: bold; color: #fff; margin-bottom: 8px;">
377
+ ${t('post.mode_label')}
378
  </label>
379
  <div style="display: flex; gap: 0; background: #1a1a1a; border: 1px solid #444; border-radius: 6px; overflow: hidden;">
380
  <button id="mode-image" style="flex: 1; padding: 10px; background: #4CAF50; color: #fff; border: none; cursor: pointer; font-size: 13px; font-weight: bold; transition: 0.2s;">
381
+ 📷 ${t('post.mode_image')}
382
  </button>
383
  <button id="mode-video" style="flex: 1; padding: 10px; background: transparent; color: #888; border: none; cursor: pointer; font-size: 13px; font-weight: bold; transition: 0.2s; border-left: 1px solid #444;">
384
+ 🎬 ${t('post.mode_video')}
385
  </button>
386
  </div>
387
  </div>
 
404
  <!-- 视频上传区 -->
405
  <div id="video-upload-section" style="margin-bottom: 20px; display: none;">
406
  <label style="display: block; font-size: 13px; font-weight: bold; color: #fff; margin-bottom: 8px;">
407
+ 🎬 ${t('post.upload_video')} <span style="color: #F44336;">*</span>
408
  <span style="font-weight: normal; color: #888; font-size: 12px;">(mp4/webm/mov,≤50MB,≤3分钟)</span>
409
  </label>
410
  <input type="file" id="video-input" accept="video/mp4,video/webm,video/quicktime" style="display: none;">
411
  <div id="video-preview-area" style="padding: 15px; background: #1a1a1a; border: 2px dashed #444; border-radius: 8px; cursor: pointer; transition: 0.2s;">
412
  <div id="video-empty-state" style="display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; color: #666; font-size: 13px;">
413
  <div style="font-size: 32px; margin-bottom: 8px;">🎬</div>
414
+ ${t('post.click_upload_video')}
415
  </div>
416
  <div id="video-player-wrap" style="display: none;">
417
  <video id="video-player" controls style="width: 100%; max-height: 300px; border-radius: 8px; background: #000; display: block;"></video>
 
424
  </div>
425
  <!-- 封面区域 -->
426
  <div id="video-cover-wrap" style="display: none; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border-color, #333);">
427
+ <div style="font-size: 12px; font-weight: bold; color: #fff; margin-bottom: 8px;">${t('post.video_cover')}</div>
428
  <div style="display: flex; align-items: center; gap: 12px;">
429
  <div style="position: relative; width: 80px; height: 80px; flex-shrink: 0;">
430
  <img id="cover-thumb" src="" style="width: 80px; height: 80px; object-fit: cover; border-radius: 6px; border: 2px solid #4CAF50; display: none;">
 
432
  </div>
433
  <div style="display: flex; flex-direction: column; gap: 6px;">
434
  <button id="btn-capture-frame" style="padding: 6px 12px; background: #333; border: 1px solid #555; color: #ccc; border-radius: 4px; cursor: pointer; font-size: 12px; transition: 0.2s;" onmouseover="this.style.background='#444'" onmouseout="this.style.background='#333'">
435
+ 📸 ${t('post.capture_frame')}
436
  </button>
437
  <button id="btn-upload-cover" style="padding: 6px 12px; background: #333; border: 1px solid #555; color: #ccc; border-radius: 4px; cursor: pointer; font-size: 12px; transition: 0.2s;" onmouseover="this.style.background='#444'" onmouseout="this.style.background='#333'">
438
+ 🖼️ ${t('post.upload_cover')}
439
  </button>
440
  <input type="file" id="cover-input" accept="image/*" style="display: none;">
441
  </div>
 
450
  <label style="display: block; font-size: 13px; font-weight: bold; color: #fff; margin-bottom: 8px;">
451
  📝 ${t('post.title_label')} <span style="color: #F44336;">*</span>
452
  </label>
453
+ <input type="text" id="title-input" value="${escapeHtml(editTitle)}" placeholder="${t('post.title_placeholder')}" maxlength="50" style="width: 100%; padding: 12px; background: #1a1a1a; border: 1px solid #444; border-radius: 6px; color: #fff; font-size: 14px; box-sizing: border-box; outline: none;" onfocus="this.style.borderColor='#4CAF50'" onblur="this.style.borderColor='#444'">
454
  <div style="text-align: right; font-size: 11px; color: #666; margin-top: 4px;">
455
  <span id="title-count">${editTitle.length}</span>/50
456
  </div>
 
461
  <label style="display: block; font-size: 13px; font-weight: bold; color: #fff; margin-bottom: 8px;">
462
  ✍️ ${t('post.content_label')}
463
  </label>
464
+ <textarea id="content-input" placeholder="${t('post.content_placeholder')}" maxlength="2000" style="width: 100%; height: 200px; padding: 12px; background: #1a1a1a; border: 1px solid #444; border-radius: 6px; color: #fff; font-size: 14px; box-sizing: border-box; outline: none; resize: none; line-height: 1.6;" onfocus="this.style.borderColor='#4CAF50'" onblur="this.style.borderColor='#444'">${escapeHtml(editContent)}</textarea>
465
  <div style="text-align: right; font-size: 11px; color: #666; margin-top: 4px;">
466
  <span id="content-count">${editContent.length}</span>/2000
467
  </div>
 
686
  Object.assign(wrapper.style, PREVIEW_WRAPPER_STYLE);
687
 
688
  wrapper.innerHTML = `
689
+ <img src="${escapeHtml(url)}" style="width: 80px; height: 80px; object-fit: cover; border-radius: 6px; border: 2px solid ${idx === 0 ? '#4CAF50' : '#444'};">
690
  ${idx === 0 ? `<span class="cover-label" style="position: absolute; top: 4px; left: 4px; background: #4CAF50; color: #fff; font-size: 10px; padding: 2px 6px; border-radius: 3px;">${t('post.cover')}</span>` : ''}
691
  <button data-action="remove" data-existing-idx="${idx}" style="position: absolute; top: -6px; right: -6px; width: 20px; height: 20px; border-radius: 50%; background: #F44336; color: #fff; border: none; cursor: pointer; font-size: 12px; line-height: 1;">×</button>
692
  `;
 
794
  const validExts = ['.mp4', '.webm', '.mov'];
795
  const isValidType = validTypes.includes(file.type) || validExts.some(ext => file.name.toLowerCase().endsWith(ext));
796
  if (!isValidType) {
797
+ _showVideoErrorAndReset(t('post.error_video_format'), videoInput);
798
  return;
799
  }
800
 
801
  // 大小检查
802
  const MAX_SIZE = 100 * 1024 * 1024;
803
  if (file.size > MAX_SIZE) {
804
+ _showVideoErrorAndReset(t('post.error_video_size'), videoInput);
805
  return;
806
  }
807
 
 
814
  tempVideo.onloadedmetadata = () => {
815
  URL.revokeObjectURL(tempUrl);
816
  if (tempVideo.duration > 180) {
817
+ _showVideoErrorAndReset(t('post.error_video_duration'), videoInput);
818
  return;
819
  }
820
 
 
828
 
829
  tempVideo.onerror = () => {
830
  URL.revokeObjectURL(tempUrl);
831
+ _showVideoErrorAndReset(t('post.error_video_load'), videoInput);
832
  };
833
  };
834
 
 
856
  videoNameEl.textContent = videoFile.name;
857
  videoMetaEl.textContent = `${formatFileSize(videoFile.size)} · ${Math.round(videoDuration)}秒`;
858
  } else if (currentVideoUrl) {
859
+ videoNameEl.textContent = t('post.existing_video');
860
  videoMetaEl.textContent = '';
861
  }
862
 
 
932
  btnCaptureFrame.onclick = async (e) => {
933
  e.stopPropagation();
934
  if (!videoPlayer.videoWidth) {
935
+ showToast(t('post.error_video_not_ready'), "warning");
936
  return;
937
  }
938
  const cover = await generateCoverFromVideo(videoPlayer);
 
940
  videoCoverFile = cover;
941
  currentCoverUrl = '';
942
  renderCoverThumb();
943
+ showToast(t('post.cover_captured'), "success");
944
  } else {
945
+ showToast(t('post.cover_capture_failed'), "error");
946
  }
947
  };
948
 
 
956
  const file = e.target.files?.[0];
957
  if (!file) return;
958
  if (!file.type.startsWith('image/')) {
959
+ _showVideoErrorAndReset(t('post.error_cover_format'), coverInput);
960
  return;
961
  }
962
  videoCoverFile = file;
 
986
  return;
987
  }
988
  if (!content) {
989
+ showToast(t('post.error_no_content'), "warning");
990
  return;
991
  }
992
  if (!videoFile && !currentVideoUrl) {
993
+ showToast(t('post.error_no_video'), "warning");
994
  return;
995
  }
996
  if (!videoCoverFile && !currentCoverUrl) {
997
+ showToast(t('post.error_no_cover'), "warning");
998
  return;
999
  }
1000
 
 
1004
  // 上传封面
1005
  let coverUrl = currentCoverUrl;
1006
  if (videoCoverFile) {
1007
+ submitBtn.textContent = `⏳ ${t('post.uploading_cover')}`;
1008
  const res = await api.uploadFile(videoCoverFile, "post");
1009
  coverUrl = res.url;
1010
  }
 
1012
  // 上传视频(带进度的 XMLHttpRequest)
1013
  let videoUrl = currentVideoUrl;
1014
  if (videoFile) {
1015
+ submitBtn.textContent = `⏳ ${t('post.uploading_video')} 0%`;
1016
  const res = await uploadVideoWithProgress(videoFile, (percent) => {
1017
+ submitBtn.textContent = `⏳ ${t('post.uploading_video')} ${percent}%`;
1018
  });
1019
  videoUrl = res.url;
1020
  }
前端页面/post/帖子详情组件.js CHANGED
@@ -31,11 +31,12 @@ import { createRatingStars } from "../social/评论与互动组件.js";
31
  */
32
  function _generateAvatarHtml(avatar, name, size, extraStyle = '') {
33
  const initial = (name || 'U')[0].toUpperCase();
 
34
  if (avatar) {
35
- return `<img class="swr-avatar" src="${avatar}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; ${extraStyle}">`;
36
  }
37
  const fontSize = Math.max(9, Math.round(size * 0.4));
38
- 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;">${initial}</div>`;
39
  }
40
 
41
  /**
@@ -50,7 +51,7 @@ function _updateAvatarElement(avatarEl, profile, size, extraStyle = '') {
50
  if (avatarEl.tagName === 'IMG') {
51
  avatarEl.src = profile.avatar;
52
  } else {
53
- avatarEl.outerHTML = `<img class="swr-avatar" src="${profile.avatar}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; ${extraStyle}">`;
54
  }
55
  }
56
 
@@ -360,11 +361,9 @@ async function loadPostDetail(container, postId, currentUser) {
360
  loadComments(contentArea, postId, currentUser);
361
 
362
  if (fromCache) {
363
- const toast = document.createElement('div');
364
- toast.style.cssText = 'position:fixed; top:20px; left:50%; transform:translateX(-50%); background:#FF9800; color:white; padding:10px 20px; border-radius:4px; z-index:10000; font-size:14px;';
365
- toast.textContent = '⚠️ 网络连接失败,展示的是缓存的数据';
366
- document.body.appendChild(toast);
367
- setTimeout(() => toast.remove(), 3000);
368
  } else {
369
  // 👀 记录浏览量(fire-and-forget,不阻塞渲染)
370
  recordPostView(contentArea, postId);
@@ -470,7 +469,7 @@ async function showTipDialog(post, currentUser, container) {
470
 
471
  // 本地前置余额校验
472
  if ((currentUser.balance || 0) < amount) {
473
- showToast(t('wallet.insufficient_balance') || "余额不足,请前往充值", "warning");
474
  return;
475
  }
476
 
 
31
  */
32
  function _generateAvatarHtml(avatar, name, size, extraStyle = '') {
33
  const initial = (name || 'U')[0].toUpperCase();
34
+ // 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
35
  if (avatar) {
36
+ return `<img class="swr-avatar" src="${escapeHtml(avatar)}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; ${extraStyle}">`;
37
  }
38
  const fontSize = Math.max(9, Math.round(size * 0.4));
39
+ 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>`;
40
  }
41
 
42
  /**
 
51
  if (avatarEl.tagName === 'IMG') {
52
  avatarEl.src = profile.avatar;
53
  } else {
54
+ avatarEl.outerHTML = `<img class="swr-avatar" src="${escapeHtml(profile.avatar)}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; ${extraStyle}">`;
55
  }
56
  }
57
 
 
361
  loadComments(contentArea, postId, currentUser);
362
 
363
  if (fromCache) {
364
+ // 🔧 改用统一提示组件 + 词典:原为手写 div(无动画、不排队、不避重叠)且文案硬编码中文;
365
+ // 原文「展示的是缓存的数据」与已有词条 feedback.cache_fallback 语义一致,直接复用避免重复建键
366
+ showToast(t('feedback.cache_fallback'), 'warning');
 
 
367
  } else {
368
  // 👀 记录浏览量(fire-and-forget,不阻塞渲染)
369
  recordPostView(contentArea, postId);
 
469
 
470
  // 本地前置余额校验
471
  if ((currentUser.balance || 0) < amount) {
472
+ showToast(t('wallet.insufficient_balance'), "warning");
473
  return;
474
  }
475
 
前端页面/post/讨论区组件.js CHANGED
@@ -17,7 +17,7 @@
17
  import { api } from "../core/网络请求API.js";
18
  import { proxyImages } from "../core/网络请求API.js";
19
  import { showToast } from "../components/UI交互提示组件.js";
20
- import { setCache, getCacheWithMeta, createSkeleton, createPaginationLoader, lazyLoadImages } from "../components/性能优化工具.js";
21
  import { applyViewportAnimations } from "../components/动画音效引擎.js";
22
  import { t } from "../components/用户体验增强.js";
23
  import { getCachedProfile, getProfileWithSWR } from "../core/全局配置.js";
@@ -76,11 +76,12 @@ function _renderEmptyState(t) {
76
  */
77
  function _generateAvatarHtml(avatar, name, size) {
78
  const initial = (name || 'U')[0].toUpperCase();
 
79
  if (avatar) {
80
- return `<img class="swr-avatar" src="${avatar}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; border: 1px solid #444; background: #333;">`;
81
  }
82
  const fontSize = Math.max(9, Math.round(size * 0.5));
83
- 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;">${initial}</div>`;
84
  }
85
 
86
  /**
@@ -312,8 +313,8 @@ export function createPostsView(currentUser, keyword = "") {
312
 
313
  // ✅ 优先从本地缓存读取(含过期缓存:离线容灾,与插件榜/工作流/推荐榜策略一致)
314
  if (!append && page === 1) {
315
- const { value: cachedData, found: hasCacheData } = getCacheWithMeta(cacheKey, true);
316
- if (hasCacheData && cachedData && cachedData.length > 0) {
317
  // 🚀 缓存数据也需要过一遍图片代理,确保新字段也被处理
318
  allPostsData = proxyImages(cachedData);
319
  renderPostsFromCache(allPostsData);
@@ -337,7 +338,7 @@ export function createPostsView(currentUser, keyword = "") {
337
  // 缓存第一页数据
338
  if (page === 1) {
339
  allPostsData = proxyImages(posts);
340
- setCache(cacheKey, posts, getCacheTTL(), true);
341
  } else {
342
  allPostsData = [...allPostsData, ...proxyImages(posts)];
343
  }
@@ -388,8 +389,8 @@ export function createPostsView(currentUser, keyword = "") {
388
  isLoadingFromNetwork = false;
389
  // 网络失败时尝试从缓存读取(包括过期缓存,离线容灾)
390
  if (!append) {
391
- const { value: cachedData, found: hasCacheData } = getCacheWithMeta(cacheKey, true);
392
- if (hasCacheData && cachedData && cachedData.length > 0) {
393
  // 🚀 缓存数据也需要过一遍图片代理
394
  allPostsData = proxyImages(cachedData);
395
  renderPostsFromCache(allPostsData);
@@ -457,7 +458,7 @@ export function createPostsView(currentUser, keyword = "") {
457
 
458
  // 更新缓存
459
  const cacheKey = getCacheKey();
460
- setCache(cacheKey, posts, getCacheTTL(), true);
461
 
462
  // 对比新旧数据,有变化时重新渲染
463
  if (_postsDataChanged(allPostsData, posts)) {
 
17
  import { api } from "../core/网络请求API.js";
18
  import { proxyImages } from "../core/网络请求API.js";
19
  import { showToast } from "../components/UI交互提示组件.js";
20
+ import { readListCache, writeListCache, createSkeleton, createPaginationLoader, lazyLoadImages } from "../components/性能优化工具.js";
21
  import { applyViewportAnimations } from "../components/动画音效引擎.js";
22
  import { t } from "../components/用户体验增强.js";
23
  import { getCachedProfile, getProfileWithSWR } from "../core/全局配置.js";
 
76
  */
77
  function _generateAvatarHtml(avatar, name, size) {
78
  const initial = (name || 'U')[0].toUpperCase();
79
+ // 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
80
  if (avatar) {
81
+ return `<img class="swr-avatar" src="${escapeHtml(avatar)}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; border: 1px solid #444; background: #333;">`;
82
  }
83
  const fontSize = Math.max(9, Math.round(size * 0.5));
84
+ 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>`;
85
  }
86
 
87
  /**
 
313
 
314
  // ✅ 优先从本地缓存读取(含过期缓存:离线容灾,与插件榜/工作流/推荐榜策略一致)
315
  if (!append && page === 1) {
316
+ const cachedData = readListCache(cacheKey);
317
+ if (cachedData) {
318
  // 🚀 缓存数据也需要过一遍图片代理,确保新字段也被处理
319
  allPostsData = proxyImages(cachedData);
320
  renderPostsFromCache(allPostsData);
 
338
  // 缓存第一页数据
339
  if (page === 1) {
340
  allPostsData = proxyImages(posts);
341
+ writeListCache(cacheKey, posts, getCacheTTL()); // 存原始数据,离线时经图片代理后可展示
342
  } else {
343
  allPostsData = [...allPostsData, ...proxyImages(posts)];
344
  }
 
389
  isLoadingFromNetwork = false;
390
  // 网络失败时尝试从缓存读取(包括过期缓存,离线容灾)
391
  if (!append) {
392
+ const cachedData = readListCache(cacheKey);
393
+ if (cachedData) {
394
  // 🚀 缓存数据也需要过一遍图片代理
395
  allPostsData = proxyImages(cachedData);
396
  renderPostsFromCache(allPostsData);
 
458
 
459
  // 更新缓存
460
  const cacheKey = getCacheKey();
461
+ writeListCache(cacheKey, posts, getCacheTTL());
462
 
463
  // 对比新旧数据,有变化时重新渲染
464
  if (_postsDataChanged(allPostsData, posts)) {
前端页面/profile/个人中心_UI模板.js CHANGED
@@ -54,8 +54,10 @@ export function buildProfileHTML(userData, isMe, isSettingsView, isFollowing, fo
54
  }
55
  }
56
 
 
 
57
  const bannerStyle = bannerImageUrl
58
- ? `background-image: url(${bannerImageUrl}); background-size: cover; background-position: center;`
59
  : `background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);`;
60
 
61
  // 设置视图时返回简化的返回按钮(与个人资料页按钮样式一致,但不显示背景图)
@@ -114,7 +116,7 @@ export function buildProfileHTML(userData, isMe, isSettingsView, isFollowing, fo
114
 
115
  <!-- 用户信息区域 -->
116
  <div style="display: flex; align-items: flex-start; gap: 15px; position: relative;">
117
- <img src="${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);">
118
  <div style="flex: 1; padding-right: 150px;">
119
  <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);">
120
  ${escapeHtml(userData.name)}
 
54
  }
55
  }
56
 
57
+ // 🔒 XSS防护:他人查看时 bannerUrl 直接来自云端(用户可自行填写),
58
+ // 写入 CSS url() 前剔除引号与括号,避免突破属性(与创作者卡片、个人设置表单同一写法)
59
  const bannerStyle = bannerImageUrl
60
+ ? `background-image: url(${String(bannerImageUrl).replace(/['"()\\]/g, '')}); background-size: cover; background-position: center;`
61
  : `background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);`;
62
 
63
  // 设置视图时返回简化的返回按钮(与个人资料页按钮样式一致,但不显示背景图)
 
116
 
117
  <!-- 用户信息区域 -->
118
  <div style="display: flex; align-items: flex-start; gap: 15px; position: relative;">
119
+ <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);">
120
  <div style="flex: 1; padding-right: 150px;">
121
  <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);">
122
  ${escapeHtml(userData.name)}
前端页面/profile/个人中心视图.js CHANGED
@@ -159,7 +159,7 @@ export function showUserProfile(initialUserData, currentUser = null, isMe = true
159
  if (isMe) tabs.push({ id: "collected", label: t('profile.my_collected') });
160
  if (isMe || !privacy.likes) tabs.push({ id: "liked", label: t('profile.recent_likes') });
161
  if (isMe || !privacy.follows) tabs.push({ id: "following", label: t('profile.following') });
162
- if (isMe || !privacy.followers) tabs.push({ id: "followers", label: t('profile.followers') || "粉丝" });
163
 
164
  if (!tabs.find(t => t.id === activeTab)) activeTab = "published";
165
 
 
159
  if (isMe) tabs.push({ id: "collected", label: t('profile.my_collected') });
160
  if (isMe || !privacy.likes) tabs.push({ id: "liked", label: t('profile.recent_likes') });
161
  if (isMe || !privacy.follows) tabs.push({ id: "following", label: t('profile.following') });
162
+ if (isMe || !privacy.followers) tabs.push({ id: "followers", label: t('profile.followers') });
163
 
164
  if (!tabs.find(t => t.id === activeTab)) activeTab = "published";
165
 
前端页面/profile/个人列表组件.js CHANGED
@@ -1,7 +1,7 @@
1
  // 前端页面/profile/个人列表组件.js
2
  import { api } from "../core/网络请求API.js";
3
  import { createItemCard } from "../market/列表卡片组件.js";
4
- import { getAcquiredItems, checkItemStatus } from "../market/资源安装引擎.js";
5
  import { showToast } from "../components/UI交互提示组件.js";
6
  import { t, tIfExists, getLanguage } from "../components/用户体验增强.js";
7
  import { PLACEHOLDERS, getCachedProfile, getProfileWithSWR } from "../core/全局配置.js";
@@ -28,11 +28,13 @@ function renderUserCard(account, currentUser, openOtherUserModalCb) {
28
  const name = cached?.name || account;
29
  const intro = cached?.intro || '';
30
 
 
 
31
  card.innerHTML = `
32
- <img id="user-avatar-${account}" src="${avatar}" style="width: 48px; height: 48px; border-radius: 50%; object-fit: cover; border: 2px solid #4CAF50; flex-shrink: 0;">
33
  <div style="flex: 1; min-width: 0;">
34
- <div id="user-name-${account}" style="font-weight: bold; color: #4CAF50; font-size: 14px; margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">@${name}</div>
35
- <div id="user-intro-${account}" style="font-size: 12px; color: #888; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${intro || t('profile.no_intro') || '暂无简介'}</div>
36
  </div>
37
  <button style="padding: 6px 12px; background: #2196F3; border: none; border-radius: 4px; color: white; cursor: pointer; font-size: 12px; flex-shrink: 0;">${t('profile.homepage')}</button>
38
  `;
@@ -50,7 +52,7 @@ function renderUserCard(account, currentUser, openOtherUserModalCb) {
50
  nameDiv.textContent = `@${profile.name}`;
51
  }
52
  if (introDiv && profile.intro !== undefined) {
53
- introDiv.textContent = profile.intro || t('profile.no_intro') || '暂无简介';
54
  }
55
  });
56
 
@@ -87,7 +89,7 @@ function renderPaginatedUserList(userList, containerDiv, currentUser, openOtherU
87
  containerDiv.innerHTML = "";
88
 
89
  if (userList.length === 0) {
90
- containerDiv.innerHTML = `<div style='text-align:center; padding: 20px; color:#666;'>${emptyMessage}</div>`;
91
  return;
92
  }
93
 
@@ -110,7 +112,7 @@ function renderPaginatedUserList(userList, containerDiv, currentUser, openOtherU
110
  // 如果有更多,显示"加载更多"按钮
111
  if (displayedCount < userList.length) {
112
  const loadMoreBtn = document.createElement("button");
113
- loadMoreBtn.textContent = t('profile.load_more') || `加载更多 (${userList.length - displayedCount})`;
114
  loadMoreBtn.style.cssText = "width: 100%; padding: 12px; background: var(--comfy-input-bg); border: 1px solid #555; border-radius: 6px; color: #4CAF50; cursor: pointer; font-size: 13px; margin-top: 8px; transition: all 0.2s;";
115
  loadMoreBtn.onmouseover = () => { loadMoreBtn.style.background = "#3a3a3a"; };
116
  loadMoreBtn.onmouseout = () => { loadMoreBtn.style.background = "var(--comfy-input-bg)"; };
@@ -142,13 +144,13 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
142
 
143
  // 👥 关注列表(使用 SWR 缓存技术)
144
  if (tabId === "following") {
145
- _renderUserListTab(userData.following || [], domElement, currentUser, openOtherUserModalCb, t('profile.no_following') || '还没有关注任何人');
146
  return;
147
  }
148
 
149
  // 👥 粉丝列表(使用 SWR 缓存技术)
150
  if (tabId === "followers") {
151
- _renderUserListTab(userData.followers || [], domElement, currentUser, openOtherUserModalCb, t('profile.no_followers') || '还没有粉丝');
152
  return;
153
  }
154
 
@@ -331,12 +333,14 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
331
  containerDiv.style.cssText = "display: flex; flex-direction: column; gap: 15px;";
332
 
333
  // 💰 销售统计卡片(放在最上方,与打赏统计样式一致)
 
 
334
  containerDiv.appendChild(_createStatsCard(
335
- `💰 ${t('profile.sales_stats') || '销售统计'}`,
336
  [
337
- { value: `+${salesStats.total_sales || 0}`, color: '#4CAF50', bgRgb: '76,175,80', label: t('profile.sales_income') || '销售收入' },
338
- { value: `-${salesStats.total_purchase || 0}`, color: '#F44336', bgRgb: '244,67,54', label: `${t('profile.purchase_expense') || '购买支出'} (${salesStats.purchase_count || 0}${t('profile.transactions_unit') || '笔'})` },
339
- { value: `${(salesStats.net_sales || 0) > 0 ? '+' : ''}${salesStats.net_sales || 0}`, color: '#2196F3', bgRgb: '33,150,243', label: t('profile.net_sales') || '净销售' }
340
  ],
341
  ' margin-bottom: 15px;'
342
  ));
@@ -353,11 +357,11 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
353
 
354
  // 🎁 打赏统计卡片
355
  containerDiv.appendChild(_createStatsCard(
356
- `🎁 ${t('profile.tip_stats') || '打赏统计'}`,
357
  [
358
- { value: `+${tipStats.total_tip_in || 0}`, color: '#4CAF50', bgRgb: '76,175,80', label: `${t('profile.tip_received') || '收到打赏'} (${tipStats.tip_in_count || 0}${t('profile.transactions_unit') || '笔'})` },
359
- { value: `-${tipStats.total_tip_out || 0}`, color: '#F44336', bgRgb: '244,67,54', label: `${t('profile.tip_sent') || '打赏支出'} (${tipStats.tip_out_count || 0}${t('profile.transactions_unit') || '笔'})` },
360
- { value: `${(tipStats.net_tips || 0) > 0 ? '+' : ''}${tipStats.net_tips || 0}`, color: '#2196F3', bgRgb: '33,150,243', label: t('profile.net_tips') || '净打赏' }
361
  ],
362
  ' margin-bottom: 15px;'
363
  ));
@@ -464,7 +468,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
464
  const applyDOM = (items, posts, prompts) => {
465
  domElement.innerHTML = "";
466
  if ((!items || items.length === 0) && (!posts || posts.length === 0) && (!prompts || prompts.length === 0)) {
467
- domElement.innerHTML = _renderEmptyState('🔖', t('profile.no_collected') || '还没有收藏任何内容', t('profile.go_discover') || '去榜单页面或讨论区发现精彩内容吧!');
468
  return;
469
  }
470
  const listDiv = document.createElement("div");
@@ -473,7 +477,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
473
  if (items && items.length > 0) {
474
  const sectionTitle = document.createElement("div");
475
  sectionTitle.style.cssText = "font-size: 13px; font-weight: bold; color: #FF9800; margin-bottom: 5px; padding: 5px 0;";
476
- sectionTitle.textContent = `📦 ${t('profile.collected_items') || '收藏的资源'} (${items.length})`;
477
  listDiv.appendChild(sectionTitle);
478
  items.forEach(item => { listDiv.appendChild(createItemCard(item, currentUser)); });
479
  }
@@ -481,7 +485,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
481
  if (posts && posts.length > 0) {
482
  const sectionTitle = document.createElement("div");
483
  sectionTitle.style.cssText = "font-size: 13px; font-weight: bold; color: #9C27B0; margin-bottom: 5px; padding: 5px 0;" + (items && items.length > 0 ? " margin-top: 15px;" : "");
484
- sectionTitle.textContent = `💬 ${t('profile.collected_posts') || '收藏的帖子'} (${posts.length})`;
485
  listDiv.appendChild(sectionTitle);
486
  posts.forEach(post => { listDiv.appendChild(createPostItem(post, currentUser)); });
487
  }
@@ -489,7 +493,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
489
  if (prompts && prompts.length > 0) {
490
  const sectionTitle = document.createElement("div");
491
  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;" : "");
492
- sectionTitle.textContent = `🧩 ${t('profile.collected_prompts') || '收藏的提示词'} (${prompts.length})`;
493
  listDiv.appendChild(sectionTitle);
494
  prompts.forEach(prompt => { listDiv.appendChild(createPromptItem(prompt, currentUser)); });
495
  }
@@ -528,7 +532,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
528
  }
529
  } catch (error) {
530
  console.error("加载收藏失败:", error);
531
- if (!cachedStr) domElement.innerHTML = `<div style='text-align:center; padding: 20px; color:#F44336;'>${t('profile.load_collected_failed') || '收藏加载失败'}</div>`;
532
  }
533
  return;
534
  }
@@ -702,7 +706,7 @@ function createPostItem(post, currentUser) {
702
  <div style="display: flex; align-items: flex-start; gap: 10px;">
703
  ${coverImageUrl ? `
704
  <div style="position: relative; flex-shrink: 0;">
705
- <img src="${coverImageUrl}" style="width: 60px; height: 60px; object-fit: cover; border-radius: 6px; display: block;">
706
  ${isVideo ? `
707
  <div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
708
  width: 28px; height: 28px; background: rgba(0,0,0,0.6); border-radius: 50%;
@@ -721,7 +725,7 @@ function createPostItem(post, currentUser) {
721
  <div style="display: flex; align-items: center; gap: 12px; font-size: 11px; color: #888;">
722
  <span>❤️ ${post.like_count || 0}</span>
723
  <span>💬 ${post.comment_count || 0}</span>
724
- ${isVideo ? `<span>🎬 ${t('post.video_tag') || '视频'}</span>` : ((post.images || []).length > 1 ? `<span>🖼️ ${(post.images || []).length}张</span>` : "")}
725
  <span style="margin-left: auto;">${timeStr}</span>
726
  </div>
727
  </div>
@@ -770,13 +774,13 @@ function createPromptItem(prompt, currentUser) {
770
  // 价格徽标
771
  const priceBadge = (prompt.price > 0)
772
  ? `<span style="background: rgba(255,193,7,0.15); color: #FFC107; padding: 1px 6px; border-radius: 4px; font-size: 10px;">💎 ${prompt.price}</span>`
773
- : `<span style="background: rgba(76,175,80,0.15); color: #4CAF50; padding: 1px 6px; border-radius: 4px; font-size: 10px;">${t('prompt.free') || '免费'}</span>`;
774
 
775
  promptDiv.innerHTML = `
776
  <div style="display: flex; align-items: flex-start; gap: 10px;">
777
  ${coverImageUrl ? `
778
  <div style="position: relative; flex-shrink: 0;">
779
- <img src="${coverImageUrl}" style="width: 60px; height: 60px; object-fit: cover; border-radius: 6px; display: block;">
780
  ${isVideo ? `
781
  <div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
782
  width: 28px; height: 28px; background: rgba(0,0,0,0.6); border-radius: 50%;
@@ -865,9 +869,9 @@ function createTransactionCard(tx) {
865
  }
866
  // 状态标签
867
  if (tx.withdraw_status === 'pending') {
868
- detailLine = `⏳ ${t('tx.withdraw_pending') || '待打款'}`;
869
  } else if (tx.withdraw_status === 'completed') {
870
- detailLine = `✅ ${t('tx.withdraw_completed') || '已完成'}`;
871
  }
872
  break;
873
 
@@ -927,7 +931,7 @@ function createTransactionCard(tx) {
927
 
928
  case 'RECHARGE':
929
  // 充值
930
- description = t('tx.recharge_desc') || '支付宝充值';
931
  break;
932
 
933
  case 'REFUND':
@@ -937,7 +941,7 @@ function createTransactionCard(tx) {
937
 
938
  case 'WITHDRAW_FEE':
939
  // 提现手续费
940
- description = t('tx.withdraw_fee_desc') || '提现手续费';
941
  break;
942
 
943
  default:
@@ -959,7 +963,7 @@ function createTransactionCard(tx) {
959
 
960
  // 实到金额(仅提现类型)
961
  const netAmountHtml = (tx.tx_type === 'WITHDRAW' && tx.net_amount !== null && tx.net_amount !== undefined)
962
- ? `<div style="font-size: 11px; color: #888; text-align: right;">${t('tx.net_amount') || '实到'}: ${tx.net_amount}</div>`
963
  : '';
964
 
965
  if (hasDetailedInfo) {
@@ -973,7 +977,7 @@ function createTransactionCard(tx) {
973
  ${itemTitle}
974
  </div>
975
  ${description ? `<div style="font-size: 11px; color: #aaa; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(description)}</div>` : ''}
976
- ${detailText ? `<div style="font-size: 11px; color: #888; margin-top: 3px;">${detailText}</div>` : ''}
977
  </div>
978
  </div>
979
  <div style="text-align: right; flex-shrink: 0; margin-left: 10px;">
@@ -990,7 +994,7 @@ function createTransactionCard(tx) {
990
  <span style="font-size: 20px;">${typeConfig.icon}</span>
991
  <div>
992
  <div style="font-weight: bold; color: #eee; font-size: 13px;">${typeConfig.title}</div>
993
- <div style="font-size: 11px; color: #888;">${timeStr}${tx.related_account ? ' · ' + tx.related_account.substring(0, 8) + '...' : ''}</div>
994
  </div>
995
  </div>
996
  <div style="font-weight: bold; font-size: 15px; color: ${amountColor};">
@@ -1007,20 +1011,20 @@ function createTransactionCard(tx) {
1007
  */
1008
  function getTransactionTypeConfig(txType) {
1009
  const configs = {
1010
- 'WITHDRAW': { icon: '💸', title: t('tx.withdraw') || '提现申请' },
1011
- 'TIP_IN': { icon: '🎁', title: t('tx.tip_in') || '收到打赏' },
1012
- 'TIP_OUT': { icon: '🎁', title: t('tx.tip_out') || '打赏支出' },
1013
- 'PURCHASE': { icon: '🛒', title: t('tx.purchase') || '购买资源' },
1014
- 'TASK_DEPOSIT': { icon: '📋', title: t('tx.task_deposit') || '任务订金' },
1015
- 'TASK_PAYMENT': { icon: '📋', title: t('tx.task_payment') || '任务尾款' },
1016
- 'TASK_INCOME': { icon: '📋', title: t('tx.task_income') || '任务收入' },
1017
- 'TASK_FREEZE': { icon: '🔒', title: t('tx.task_freeze') || '任务冻结' },
1018
- 'TASK_REFUND': { icon: '↩️', title: t('tx.task_refund') || '任务退款' },
1019
- 'TASK_CANCEL_REFUND': { icon: '🔄', title: t('tx.task_cancel_refund') || '任务取消退款' },
1020
- 'SALE': { icon: '💎', title: t('tx.sale') || '销售收入', color: '#28a745' },
1021
- 'RECHARGE': { icon: '💰', title: t('tx.recharge') || '充值' },
1022
- 'REFUND': { icon: '↩️', title: t('tx.refund') || '退款' },
1023
- 'WITHDRAW_FEE': { icon: '💸', title: t('tx.withdraw_fee') || '手续费' }
1024
  };
1025
 
1026
  return configs[txType] || { icon: '💰', title: txType };
 
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";
6
  import { t, tIfExists, getLanguage } from "../components/用户体验增强.js";
7
  import { PLACEHOLDERS, getCachedProfile, getProfileWithSWR } from "../core/全局配置.js";
 
28
  const name = cached?.name || account;
29
  const intro = cached?.intro || '';
30
 
31
+ // 🔒 XSS防护:头像/昵称/简介均来自用户可修改的资料(下方 SWR 刷新路径用的是
32
+ // .src / .textContent,本身就是安全的,仅初次渲染的模板串需要转义)
33
  card.innerHTML = `
34
+ <img id="user-avatar-${account}" src="${escapeHtml(avatar)}" style="width: 48px; height: 48px; border-radius: 50%; object-fit: cover; border: 2px solid #4CAF50; flex-shrink: 0;">
35
  <div style="flex: 1; min-width: 0;">
36
+ <div id="user-name-${account}" style="font-weight: bold; color: #4CAF50; font-size: 14px; margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">@${escapeHtml(name)}</div>
37
+ <div id="user-intro-${account}" style="font-size: 12px; color: #888; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(intro || t('profile.no_intro'))}</div>
38
  </div>
39
  <button style="padding: 6px 12px; background: #2196F3; border: none; border-radius: 4px; color: white; cursor: pointer; font-size: 12px; flex-shrink: 0;">${t('profile.homepage')}</button>
40
  `;
 
52
  nameDiv.textContent = `@${profile.name}`;
53
  }
54
  if (introDiv && profile.intro !== undefined) {
55
+ introDiv.textContent = profile.intro || t('profile.no_intro');
56
  }
57
  });
58
 
 
89
  containerDiv.innerHTML = "";
90
 
91
  if (userList.length === 0) {
92
+ containerDiv.innerHTML = `<div style='text-align:center; padding: 20px; color:#666;'>${escapeHtml(emptyMessage)}</div>`;
93
  return;
94
  }
95
 
 
112
  // 如果有更多,显示"加载更多"按钮
113
  if (displayedCount < userList.length) {
114
  const loadMoreBtn = document.createElement("button");
115
+ loadMoreBtn.textContent = t('profile.load_more', { count: userList.length - displayedCount });
116
  loadMoreBtn.style.cssText = "width: 100%; padding: 12px; background: var(--comfy-input-bg); border: 1px solid #555; border-radius: 6px; color: #4CAF50; cursor: pointer; font-size: 13px; margin-top: 8px; transition: all 0.2s;";
117
  loadMoreBtn.onmouseover = () => { loadMoreBtn.style.background = "#3a3a3a"; };
118
  loadMoreBtn.onmouseout = () => { loadMoreBtn.style.background = "var(--comfy-input-bg)"; };
 
144
 
145
  // 👥 关注列表(使用 SWR 缓存技术)
146
  if (tabId === "following") {
147
+ _renderUserListTab(userData.following || [], domElement, currentUser, openOtherUserModalCb, t('profile.no_following'));
148
  return;
149
  }
150
 
151
  // 👥 粉丝列表(使用 SWR 缓存技术)
152
  if (tabId === "followers") {
153
+ _renderUserListTab(userData.followers || [], domElement, currentUser, openOtherUserModalCb, t('profile.no_followers'));
154
  return;
155
  }
156
 
 
333
  containerDiv.style.cssText = "display: flex; flex-direction: column; gap: 15px;";
334
 
335
  // 💰 销售统计卡片(放在最上方,与打赏统计样式一致)
336
+ // 🔧 下方销售/打赏两处的 profile.transactions_unit 不再加 || '笔' 兜底:
337
+ // 该词条英文值刻意留空(中文 5笔 → 英文 5),兜底会把中文量词引回英文界面
338
  containerDiv.appendChild(_createStatsCard(
339
+ `💰 ${t('profile.sales_stats')}`,
340
  [
341
+ { value: `+${salesStats.total_sales || 0}`, color: '#4CAF50', bgRgb: '76,175,80', label: t('profile.sales_income') },
342
+ { value: `-${salesStats.total_purchase || 0}`, color: '#F44336', bgRgb: '244,67,54', label: `${t('profile.purchase_expense')} (${salesStats.purchase_count || 0}${t('profile.transactions_unit')})` },
343
+ { value: `${(salesStats.net_sales || 0) > 0 ? '+' : ''}${salesStats.net_sales || 0}`, color: '#2196F3', bgRgb: '33,150,243', label: t('profile.net_sales') }
344
  ],
345
  ' margin-bottom: 15px;'
346
  ));
 
357
 
358
  // 🎁 打赏统计卡片
359
  containerDiv.appendChild(_createStatsCard(
360
+ `🎁 ${t('profile.tip_stats')}`,
361
  [
362
+ { value: `+${tipStats.total_tip_in || 0}`, color: '#4CAF50', bgRgb: '76,175,80', label: `${t('profile.tip_received')} (${tipStats.tip_in_count || 0}${t('profile.transactions_unit')})` },
363
+ { value: `-${tipStats.total_tip_out || 0}`, color: '#F44336', bgRgb: '244,67,54', label: `${t('profile.tip_sent')} (${tipStats.tip_out_count || 0}${t('profile.transactions_unit')})` },
364
+ { value: `${(tipStats.net_tips || 0) > 0 ? '+' : ''}${tipStats.net_tips || 0}`, color: '#2196F3', bgRgb: '33,150,243', label: t('profile.net_tips') }
365
  ],
366
  ' margin-bottom: 15px;'
367
  ));
 
468
  const applyDOM = (items, posts, prompts) => {
469
  domElement.innerHTML = "";
470
  if ((!items || items.length === 0) && (!posts || posts.length === 0) && (!prompts || prompts.length === 0)) {
471
+ domElement.innerHTML = _renderEmptyState('🔖', t('profile.no_collected'), t('profile.go_discover'));
472
  return;
473
  }
474
  const listDiv = document.createElement("div");
 
477
  if (items && items.length > 0) {
478
  const sectionTitle = document.createElement("div");
479
  sectionTitle.style.cssText = "font-size: 13px; font-weight: bold; color: #FF9800; margin-bottom: 5px; padding: 5px 0;";
480
+ sectionTitle.textContent = `📦 ${t('profile.collected_items')} (${items.length})`;
481
  listDiv.appendChild(sectionTitle);
482
  items.forEach(item => { listDiv.appendChild(createItemCard(item, currentUser)); });
483
  }
 
485
  if (posts && posts.length > 0) {
486
  const sectionTitle = document.createElement("div");
487
  sectionTitle.style.cssText = "font-size: 13px; font-weight: bold; color: #9C27B0; margin-bottom: 5px; padding: 5px 0;" + (items && items.length > 0 ? " margin-top: 15px;" : "");
488
+ sectionTitle.textContent = `💬 ${t('profile.collected_posts')} (${posts.length})`;
489
  listDiv.appendChild(sectionTitle);
490
  posts.forEach(post => { listDiv.appendChild(createPostItem(post, currentUser)); });
491
  }
 
493
  if (prompts && prompts.length > 0) {
494
  const sectionTitle = document.createElement("div");
495
  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;" : "");
496
+ sectionTitle.textContent = `🧩 ${t('profile.collected_prompts')} (${prompts.length})`;
497
  listDiv.appendChild(sectionTitle);
498
  prompts.forEach(prompt => { listDiv.appendChild(createPromptItem(prompt, currentUser)); });
499
  }
 
532
  }
533
  } catch (error) {
534
  console.error("加载收藏失败:", error);
535
+ if (!cachedStr) domElement.innerHTML = `<div style='text-align:center; padding: 20px; color:#F44336;'>${t('profile.load_collected_failed')}</div>`;
536
  }
537
  return;
538
  }
 
706
  <div style="display: flex; align-items: flex-start; gap: 10px;">
707
  ${coverImageUrl ? `
708
  <div style="position: relative; flex-shrink: 0;">
709
+ <img src="${escapeHtml(coverImageUrl)}" style="width: 60px; height: 60px; object-fit: cover; border-radius: 6px; display: block;">
710
  ${isVideo ? `
711
  <div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
712
  width: 28px; height: 28px; background: rgba(0,0,0,0.6); border-radius: 50%;
 
725
  <div style="display: flex; align-items: center; gap: 12px; font-size: 11px; color: #888;">
726
  <span>❤️ ${post.like_count || 0}</span>
727
  <span>💬 ${post.comment_count || 0}</span>
728
+ ${isVideo ? `<span>🎬 ${t('post.video_tag')}</span>` : ((post.images || []).length > 1 ? `<span>🖼️ ${(post.images || []).length}${t('post.images_unit')}</span>` : "")}
729
  <span style="margin-left: auto;">${timeStr}</span>
730
  </div>
731
  </div>
 
774
  // 价格徽标
775
  const priceBadge = (prompt.price > 0)
776
  ? `<span style="background: rgba(255,193,7,0.15); color: #FFC107; padding: 1px 6px; border-radius: 4px; font-size: 10px;">💎 ${prompt.price}</span>`
777
+ : `<span style="background: rgba(76,175,80,0.15); color: #4CAF50; padding: 1px 6px; border-radius: 4px; font-size: 10px;">${t('prompt.free')}</span>`;
778
 
779
  promptDiv.innerHTML = `
780
  <div style="display: flex; align-items: flex-start; gap: 10px;">
781
  ${coverImageUrl ? `
782
  <div style="position: relative; flex-shrink: 0;">
783
+ <img src="${escapeHtml(coverImageUrl)}" style="width: 60px; height: 60px; object-fit: cover; border-radius: 6px; display: block;">
784
  ${isVideo ? `
785
  <div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
786
  width: 28px; height: 28px; background: rgba(0,0,0,0.6); border-radius: 50%;
 
869
  }
870
  // 状态标签
871
  if (tx.withdraw_status === 'pending') {
872
+ detailLine = `⏳ ${t('tx.withdraw_pending')}`;
873
  } else if (tx.withdraw_status === 'completed') {
874
+ detailLine = `✅ ${t('tx.withdraw_completed')}`;
875
  }
876
  break;
877
 
 
931
 
932
  case 'RECHARGE':
933
  // 充值
934
+ description = t('tx.recharge_desc');
935
  break;
936
 
937
  case 'REFUND':
 
941
 
942
  case 'WITHDRAW_FEE':
943
  // 提现手续费
944
+ description = t('tx.withdraw_fee_desc');
945
  break;
946
 
947
  default:
 
963
 
964
  // 实到金额(仅提现类型)
965
  const netAmountHtml = (tx.tx_type === 'WITHDRAW' && tx.net_amount !== null && tx.net_amount !== undefined)
966
+ ? `<div style="font-size: 11px; color: #888; text-align: right;">${t('tx.net_amount')}: ${tx.net_amount}</div>`
967
  : '';
968
 
969
  if (hasDetailedInfo) {
 
977
  ${itemTitle}
978
  </div>
979
  ${description ? `<div style="font-size: 11px; color: #aaa; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(description)}</div>` : ''}
980
+ ${detailText ? `<div style="font-size: 11px; color: #888; margin-top: 3px;">${escapeHtml(detailText)}</div>` : ''}
981
  </div>
982
  </div>
983
  <div style="text-align: right; flex-shrink: 0; margin-left: 10px;">
 
994
  <span style="font-size: 20px;">${typeConfig.icon}</span>
995
  <div>
996
  <div style="font-weight: bold; color: #eee; font-size: 13px;">${typeConfig.title}</div>
997
+ <div style="font-size: 11px; color: #888;">${timeStr}${tx.related_account ? ' · ' + escapeHtml(tx.related_account.substring(0, 8)) + '...' : ''}</div>
998
  </div>
999
  </div>
1000
  <div style="font-weight: bold; font-size: 15px; color: ${amountColor};">
 
1011
  */
1012
  function getTransactionTypeConfig(txType) {
1013
  const configs = {
1014
+ 'WITHDRAW': { icon: '💸', title: t('tx.withdraw') },
1015
+ 'TIP_IN': { icon: '🎁', title: t('tx.tip_in') },
1016
+ 'TIP_OUT': { icon: '🎁', title: t('tx.tip_out') },
1017
+ 'PURCHASE': { icon: '🛒', title: t('tx.purchase') },
1018
+ 'TASK_DEPOSIT': { icon: '📋', title: t('tx.task_deposit') },
1019
+ 'TASK_PAYMENT': { icon: '📋', title: t('tx.task_payment') },
1020
+ 'TASK_INCOME': { icon: '📋', title: t('tx.task_income') },
1021
+ 'TASK_FREEZE': { icon: '🔒', title: t('tx.task_freeze') },
1022
+ 'TASK_REFUND': { icon: '↩️', title: t('tx.task_refund') },
1023
+ 'TASK_CANCEL_REFUND': { icon: '🔄', title: t('tx.task_cancel_refund') },
1024
+ 'SALE': { icon: '💎', title: t('tx.sale'), color: '#28a745' },
1025
+ 'RECHARGE': { icon: '💰', title: t('tx.recharge') },
1026
+ 'REFUND': { icon: '↩️', title: t('tx.refund') },
1027
+ 'WITHDRAW_FEE': { icon: '💸', title: t('tx.withdraw_fee') }
1028
  };
1029
 
1030
  return configs[txType] || { icon: '💰', title: txType };
前端页面/profile/个人设置表单组件.js CHANGED
@@ -4,7 +4,7 @@ import { regionData, getSortedCountries } from "../auth/国家地区数据.js";
4
  import { showToast } from "../components/UI交互提示组件.js";
5
  import { uploadFile } from "../market/发布内容_提交引擎.js";
6
  import { openImageCropper } from "../components/图片裁剪组件.js";
7
- import { CACHE, getBackgroundKey, getBannerCacheKey, PLACEHOLDERS } from "../core/全局配置.js";
8
  import { t } from "../components/用户体验增强.js";
9
 
10
  // 计算年龄工具函数
@@ -90,7 +90,7 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
90
  <div style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #444; font-weight: bold; font-size: 16px;">⚙️ ${t('settings_form.title')}</div>
91
  <div style="display: flex; flex-direction: column; gap: 10px; margin-bottom: 20px;">
92
  <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;">
93
- <img id="setting-avatar-preview" src="${userData.avatarDataUrl || PLACEHOLDERS.AVATAR}" style="width: 60px; height: 60px; border-radius: 50%; object-fit: cover; border: 2px solid #666;">
94
  <div>
95
  <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>
96
  <div style="font-size: 11px; color: #888;">${t('settings_form.avatar_hint')}</div>
@@ -100,7 +100,7 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
100
 
101
  <div style="background: var(--comfy-input-bg); padding: 10px; border-radius: 6px; border: 1px dashed #555;">
102
  <div style="font-size: 12px; color: #aaa; margin-bottom: 8px;">🇺️ ${t('settings_form.banner_title')}</div>
103
- <div id="setting-banner-preview" style="width: 100%; height: 80px; border-radius: 4px; background: ${userData.bannerUrl ? `url(${userData.bannerUrl})` : '#1a1a1a'}; background-size: cover; background-position: center; margin-bottom: 8px; border: 1px solid #444;"></div>
104
  <div style="display: flex; gap: 8px;">
105
  <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>
106
  <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>
@@ -120,7 +120,7 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
120
  <input type="file" id="setting-ui-bg" accept="image/*" style="display: none;">
121
  </div>
122
 
123
- <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="${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>
124
 
125
  <div style="display: flex; gap: 10px;">
126
  <div style="flex: 1;"><label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">${t('settings_form.gender')}</label>
@@ -133,7 +133,7 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
133
  <div style="flex: 1;">
134
  <label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">${t('settings_form.birthday')}</label>
135
  <div style="display: flex; align-items: center; gap: 8px;">
136
- <input type="date" id="setting-birthday" value="${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;">
137
  <span id="setting-age-display" style="color: #888; font-size: 12px; white-space: nowrap;">${userData.age ? userData.age + ' ' + t('profile.age_years') : ''}</span>
138
  </div>
139
  </div>
@@ -145,11 +145,11 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
145
  ${countryOptions}
146
  </select>
147
  <select id="setting-region" style="width: 100%; padding: 8px; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; border-radius: 4px;">
148
- <option value="${userData.region || ''}">${userData.region || t('settings_form.select_country_first')}</option>
149
  </select>
150
  </div>
151
 
152
- <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;">${userData.intro || ''}</textarea></div>
153
 
154
  <div style="margin-top: 15px; border-top: 1px solid #444; padding-top: 15px;">
155
  <div style="font-size: 14px; font-weight: bold; margin-bottom: 10px; color: #4CAF50;">🛡️ ${t('settings_form.privacy_title')}</div>
 
4
  import { showToast } from "../components/UI交互提示组件.js";
5
  import { uploadFile } from "../market/发布内容_提交引擎.js";
6
  import { openImageCropper } from "../components/图片裁剪组件.js";
7
+ import { CACHE, getBackgroundKey, getBannerCacheKey, PLACEHOLDERS, escapeHtml } from "../core/全局配置.js";
8
  import { t } from "../components/用户体验增强.js";
9
 
10
  // 计算年龄工具函数
 
90
  <div style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #444; font-weight: bold; font-size: 16px;">⚙️ ${t('settings_form.title')}</div>
91
  <div style="display: flex; flex-direction: column; gap: 10px; margin-bottom: 20px;">
92
  <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;">
93
+ <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;">
94
  <div>
95
  <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>
96
  <div style="font-size: 11px; color: #888;">${t('settings_form.avatar_hint')}</div>
 
100
 
101
  <div style="background: var(--comfy-input-bg); padding: 10px; border-radius: 6px; border: 1px dashed #555;">
102
  <div style="font-size: 12px; color: #aaa; margin-bottom: 8px;">🇺️ ${t('settings_form.banner_title')}</div>
103
+ <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>
104
  <div style="display: flex; gap: 8px;">
105
  <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>
106
  <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>
 
120
  <input type="file" id="setting-ui-bg" accept="image/*" style="display: none;">
121
  </div>
122
 
123
+ <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>
124
 
125
  <div style="display: flex; gap: 10px;">
126
  <div style="flex: 1;"><label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">${t('settings_form.gender')}</label>
 
133
  <div style="flex: 1;">
134
  <label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">${t('settings_form.birthday')}</label>
135
  <div style="display: flex; align-items: center; gap: 8px;">
136
+ <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;">
137
  <span id="setting-age-display" style="color: #888; font-size: 12px; white-space: nowrap;">${userData.age ? userData.age + ' ' + t('profile.age_years') : ''}</span>
138
  </div>
139
  </div>
 
145
  ${countryOptions}
146
  </select>
147
  <select id="setting-region" style="width: 100%; padding: 8px; background: var(--comfy-input-bg); border: 1px solid #555; color: #fff; border-radius: 4px;">
148
+ <option value="${escapeHtml(userData.region || '')}">${escapeHtml(userData.region || t('settings_form.select_country_first'))}</option>
149
  </select>
150
  </div>
151
 
152
+ <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>
153
 
154
  <div style="margin-top: 15px; border-top: 1px solid #444; padding-top: 15px;">
155
  <div style="font-size: 14px; font-weight: bold; margin-bottom: 10px; color: #4CAF50;">🛡️ ${t('settings_form.privacy_title')}</div>
前端页面/prompt/发布提示词组件.js CHANGED
@@ -13,21 +13,19 @@
13
  import { api } from "../core/网络请求API.js";
14
  import { showToast, showConfirm } from "../components/UI交互提示组件.js";
15
  import { t, tIfExists } from "../components/用户体验增强.js";
16
- import { removeCache } from "../components/性能优化工具.js";
17
  import { API } from "../core/全局配置.js";
18
  import { escapeHtml } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
19
 
20
  // 📦 清除提示词列表缓存(前缀扫描,覆盖全部 类型/分类/排序 组合)
21
  function clearPromptsListCache() {
22
- try {
23
- const keysToRemove = [];
24
- for (let i = 0; i < localStorage.length; i++) {
25
- const k = localStorage.key(i);
26
- if (k && k.includes('PromptsCache')) keysToRemove.push(k);
27
- }
28
- keysToRemove.forEach(k => localStorage.removeItem(k));
29
- } catch (e) {}
30
- removeCache('api_/api/prompts');
31
  console.log('🗑️ 已清除提示词列表缓存');
32
  }
33
 
 
13
  import { api } from "../core/网络请求API.js";
14
  import { showToast, showConfirm } from "../components/UI交互提示组件.js";
15
  import { t, tIfExists } from "../components/用户体验增强.js";
16
+ import { removeCacheByPrefix } from "../components/性能优化工具.js";
17
  import { API } from "../core/全局配置.js";
18
  import { escapeHtml } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
19
 
20
  // 📦 清除提示词列表缓存(前缀扫描,覆盖全部 类型/分类/排序 组合)
21
  function clearPromptsListCache() {
22
+ // 🔧 改用缓存模块的前缀删除原语:原先手写的 localStorage 扫描 + removeCache 组合
23
+ // 与 removeCacheByPrefix 等价(同样内存 + localStorage 两级清除,且同样能匹配不带
24
+ // CACHE.PREFIX 的裸键),统一收口避免各发布组件各写一遍易漏清内存的遍历
25
+ removeCacheByPrefix('PromptsCache');
26
+ // 🔧 修复:API 层缓存键带完整查询串(api_/api/prompts?type=..&category=..&sort=..),
27
+ // 原先的 removeCache 精确删除命中不到(no-op),改用前缀删除
28
+ removeCacheByPrefix('api_/api/prompts');
 
 
29
  console.log('🗑️ 已清除提示词列表缓存');
30
  }
31
 
前端页面/prompt/提示词组件.js CHANGED
@@ -16,7 +16,9 @@
16
 
17
  import { api, proxyImages } from "../core/网络请求API.js";
18
  import { showToast } from "../components/UI交互提示组件.js";
19
- import { setCache, getCacheWithMeta, createPaginationLoader, lazyLoadImages } from "../components/性能优化工具.js";
 
 
20
  import { applyViewportAnimations } from "../components/动画音效引擎.js";
21
  import { t, tIfExists } from "../components/用户体验增强.js";
22
  import { getCachedProfile, getProfileWithSWR } from "../core/全局配置.js";
@@ -92,11 +94,12 @@ function _renderEmptyState() {
92
  */
93
  function _generateAvatarHtml(avatar, name, size) {
94
  const initial = (name || 'U')[0].toUpperCase();
 
95
  if (avatar) {
96
- return `<img class="swr-avatar" src="${avatar}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; border: 1px solid #444; background: #333;">`;
97
  }
98
  const fontSize = Math.max(9, Math.round(size * 0.5));
99
- 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;">${initial}</div>`;
100
  }
101
 
102
  /**
@@ -437,8 +440,8 @@ export function createPromptsView(currentUser, keyword = "") {
437
 
438
  // ✅ 优先从本地缓存读取(含过期缓存:离线容灾,与插件榜/工作流/推荐榜策略一致)
439
  if (!append && page === 1) {
440
- const { value: cachedData, found: hasCacheData } = getCacheWithMeta(cacheKey, true);
441
- if (hasCacheData && cachedData && cachedData.length > 0) {
442
  allPromptsData = proxyImages(cachedData);
443
  renderPromptsFromCache(allPromptsData);
444
  // 后台静默更新
@@ -461,7 +464,7 @@ export function createPromptsView(currentUser, keyword = "") {
461
  // 缓存第一页数据
462
  if (page === 1) {
463
  allPromptsData = proxyImages(prompts);
464
- setCache(cacheKey, prompts, getCacheTTL(), true);
465
  } else {
466
  allPromptsData = [...allPromptsData, ...proxyImages(prompts)];
467
  }
@@ -512,8 +515,8 @@ export function createPromptsView(currentUser, keyword = "") {
512
  isLoadingFromNetwork = false;
513
  // 网络失败时尝试从缓存读取(包括过期缓存,离线容灾)
514
  if (!append) {
515
- const { value: cachedData, found: hasCacheData } = getCacheWithMeta(cacheKey, true);
516
- if (hasCacheData && cachedData && cachedData.length > 0) {
517
  allPromptsData = proxyImages(cachedData);
518
  renderPromptsFromCache(allPromptsData);
519
  showToast(t('prompt.network_cache'), "warning");
@@ -580,7 +583,7 @@ export function createPromptsView(currentUser, keyword = "") {
580
 
581
  // 更新缓存
582
  const cacheKey = getCacheKey();
583
- setCache(cacheKey, prompts, getCacheTTL(), true);
584
 
585
  // 对比新旧数据,有变化时重新渲染
586
  if (_promptsDataChanged(allPromptsData, prompts)) {
 
16
 
17
  import { api, proxyImages } from "../core/网络请求API.js";
18
  import { showToast } from "../components/UI交互提示组件.js";
19
+ // 📦 列表缓存走 readListCache/writeListCache 统一口径;分类缓存形态不同(空数组也是
20
+ // 有效缓存态),仍用底层 setCache/getCacheWithMeta
21
+ import { setCache, getCacheWithMeta, readListCache, writeListCache, createPaginationLoader, lazyLoadImages } from "../components/性能优化工具.js";
22
  import { applyViewportAnimations } from "../components/动画音效引擎.js";
23
  import { t, tIfExists } from "../components/用户体验增强.js";
24
  import { getCachedProfile, getProfileWithSWR } from "../core/全局配置.js";
 
94
  */
95
  function _generateAvatarHtml(avatar, name, size) {
96
  const initial = (name || 'U')[0].toUpperCase();
97
+ // 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
98
  if (avatar) {
99
+ return `<img class="swr-avatar" src="${escapeHtml(avatar)}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; border: 1px solid #444; background: #333;">`;
100
  }
101
  const fontSize = Math.max(9, Math.round(size * 0.5));
102
+ 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>`;
103
  }
104
 
105
  /**
 
440
 
441
  // ✅ 优先从本地缓存读取(含过期缓存:离线容灾,与插件榜/工作流/推荐榜策略一致)
442
  if (!append && page === 1) {
443
+ const cachedData = readListCache(cacheKey);
444
+ if (cachedData) {
445
  allPromptsData = proxyImages(cachedData);
446
  renderPromptsFromCache(allPromptsData);
447
  // 后台静默更新
 
464
  // 缓存第一页数据
465
  if (page === 1) {
466
  allPromptsData = proxyImages(prompts);
467
+ writeListCache(cacheKey, prompts, getCacheTTL()); // 存原始数据,离线时经图片代理后可展示
468
  } else {
469
  allPromptsData = [...allPromptsData, ...proxyImages(prompts)];
470
  }
 
515
  isLoadingFromNetwork = false;
516
  // 网络失败时尝试从缓存读取(包括过期缓存,离线容灾)
517
  if (!append) {
518
+ const cachedData = readListCache(cacheKey);
519
+ if (cachedData) {
520
  allPromptsData = proxyImages(cachedData);
521
  renderPromptsFromCache(allPromptsData);
522
  showToast(t('prompt.network_cache'), "warning");
 
583
 
584
  // 更新缓存
585
  const cacheKey = getCacheKey();
586
+ writeListCache(cacheKey, prompts, getCacheTTL());
587
 
588
  // 对比新旧数据,有变化时重新渲染
589
  if (_promptsDataChanged(allPromptsData, prompts)) {
前端页面/prompt/提示词详情组件.js CHANGED
@@ -27,11 +27,12 @@ import { recordView, handleToggleLike, handleToggleFavorite, renderTipBoardHTML
27
  */
28
  function _generateAvatarHtml(avatar, name, size, extraStyle = '') {
29
  const initial = (name || 'U')[0].toUpperCase();
 
30
  if (avatar) {
31
- return `<img class="swr-avatar" src="${avatar}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; ${extraStyle}">`;
32
  }
33
  const fontSize = Math.max(9, Math.round(size * 0.4));
34
- 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;">${initial}</div>`;
35
  }
36
 
37
  /**
@@ -42,7 +43,7 @@ function _updateAvatarElement(avatarEl, profile, size, extraStyle = '') {
42
  if (avatarEl.tagName === 'IMG') {
43
  avatarEl.src = profile.avatar;
44
  } else {
45
- avatarEl.outerHTML = `<img class="swr-avatar" src="${profile.avatar}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; ${extraStyle}">`;
46
  }
47
  }
48
 
@@ -464,7 +465,7 @@ async function showPurchaseConfirmDialog(contentArea, prompt, currentUser) {
464
  <div style="text-align: center;">
465
  <div style="font-size: 36px; margin-bottom: 12px;">💎</div>
466
  <div style="font-size: 15px; font-weight: bold; color: #fff; margin-bottom: 10px;">${t('prompt.purchase_confirm_title')}</div>
467
- <div style="font-size: 13px; color: #aaa; margin-bottom: 15px; line-height: 1.6;">${t('prompt.purchase_confirm_desc', { price: prompt.price, title: prompt.title })}</div>
468
  <div style="margin-bottom: 15px; background: rgba(255,152,0,0.1); padding: 10px; border-radius: 4px; border: 1px solid #FF9800;">
469
  ${t('prompt.purchase_balance')}: <strong style="color:#FF9800;">${balance}</strong>
470
  </div>
@@ -593,7 +594,7 @@ async function showTipDialog(prompt, currentUser, container) {
593
 
594
  // 本地前置余额校验
595
  if ((currentUser.balance || 0) < amount) {
596
- showToast(t('wallet.insufficient_balance') || "余额不足,请前往充值", "warning");
597
  return;
598
  }
599
 
@@ -686,7 +687,7 @@ async function loadComments(container, promptId, currentUser) {
686
  }
687
  try {
688
  sendBtn.disabled = true;
689
- sendBtn.textContent = t('common.sending') || '...';
690
  await api.addPromptComment(promptId, content);
691
  commentInput.value = "";
692
  showToast(t('prompt.comment_success'), "success");
 
27
  */
28
  function _generateAvatarHtml(avatar, name, size, extraStyle = '') {
29
  const initial = (name || 'U')[0].toUpperCase();
30
+ // 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
31
  if (avatar) {
32
+ return `<img class="swr-avatar" src="${escapeHtml(avatar)}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; ${extraStyle}">`;
33
  }
34
  const fontSize = Math.max(9, Math.round(size * 0.4));
35
+ 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>`;
36
  }
37
 
38
  /**
 
43
  if (avatarEl.tagName === 'IMG') {
44
  avatarEl.src = profile.avatar;
45
  } else {
46
+ avatarEl.outerHTML = `<img class="swr-avatar" src="${escapeHtml(profile.avatar)}" style="width: ${size}px; height: ${size}px; border-radius: 50%; object-fit: cover; ${extraStyle}">`;
47
  }
48
  }
49
 
 
465
  <div style="text-align: center;">
466
  <div style="font-size: 36px; margin-bottom: 12px;">💎</div>
467
  <div style="font-size: 15px; font-weight: bold; color: #fff; margin-bottom: 10px;">${t('prompt.purchase_confirm_title')}</div>
468
+ <div style="font-size: 13px; color: #aaa; margin-bottom: 15px; line-height: 1.6;">${t('prompt.purchase_confirm_desc', { price: prompt.price, title: escapeHtml(prompt.title) })}</div>
469
  <div style="margin-bottom: 15px; background: rgba(255,152,0,0.1); padding: 10px; border-radius: 4px; border: 1px solid #FF9800;">
470
  ${t('prompt.purchase_balance')}: <strong style="color:#FF9800;">${balance}</strong>
471
  </div>
 
594
 
595
  // 本地前置余额校验
596
  if ((currentUser.balance || 0) < amount) {
597
+ showToast(t('wallet.insufficient_balance'), "warning");
598
  return;
599
  }
600
 
 
687
  }
688
  try {
689
  sendBtn.disabled = true;
690
+ sendBtn.textContent = t('common.sending');
691
  await api.addPromptComment(promptId, content);
692
  commentInput.value = "";
693
  showToast(t('prompt.comment_success'), "success");
前端页面/social/私信聊天组件.js CHANGED
@@ -26,12 +26,14 @@ const formatRelativeTime = (timestamp) => {
26
  const minutes = Math.floor(diff / 60000);
27
  const hours = Math.floor(diff / 3600000);
28
  const days = Math.floor(diff / 86400000);
29
- if (minutes < 1) return t('chat.time_just_now') || '刚刚';
30
- if (minutes < 60) return `${minutes}${t('chat.time_min_ago') || '分钟前'}`;
31
- if (hours < 24) return `${hours}${t('chat.time_hour_ago') || '小时前'}`;
32
- if (days < 7) return `${days}${t('chat.time_day_ago') || '天前'}`;
33
  const date = new Date(msgTime);
34
- return `${date.getMonth() + 1}${t('chat.date_month') || '月'}${date.getDate()}${t('chat.date_day') || '日'} ${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}`;
 
 
35
  };
36
  // 🚀 P1-5: 超过5分钟则显示时间分隔符
37
  const TIME_GAP_THRESHOLD = 5 * 60; // 秒
@@ -109,7 +111,7 @@ export function openChatModal(currentUser, targetAccount = null) {
109
  resize: "none", maxHeight: "100px", minHeight: "40px", overflow: "auto",
110
  lineHeight: "1.4", boxSizing: "border-box"
111
  });
112
- inputField.placeholder = t('chat.input_placeholder') || '输入消息...';
113
  // 🚀 P1-2: 自适应高度
114
  const adjustHeight = () => {
115
  inputField.style.height = "auto";
@@ -194,7 +196,7 @@ export function openChatModal(currentUser, targetAccount = null) {
194
  }
195
 
196
  chatItem.innerHTML = `
197
- <img src="${avatar}" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover; border: 1px solid #555; flex-shrink: 0;">
198
  <div style="flex: 1; min-width: 0;">
199
  <div style="display: flex; justify-content: space-between; align-items: center;">
200
  <div style="font-weight: bold; font-size: 13px; color: #fff;">${escapeHtml(name)}</div>
@@ -233,7 +235,7 @@ export function openChatModal(currentUser, targetAccount = null) {
233
  } catch(e) {
234
  // 🚀 P1-7: loadChatList 错误处理
235
  console.error('[私信] 加载对话列表失败:', e);
236
- showToast(t('chat.load_list_failed') || '加载对话列表失败', 'error');
237
  }
238
  };
239
 
@@ -254,7 +256,7 @@ export function openChatModal(currentUser, targetAccount = null) {
254
  textAlign: "center", padding: "10px", cursor: "pointer",
255
  color: "#888", fontSize: "12px", transition: "0.2s"
256
  });
257
- loadMoreBtn.textContent = t('chat.load_more') || `加载更早的消息(剩余${startIdx}条)`;
258
  loadMoreBtn.onmouseover = () => { loadMoreBtn.style.color = "#2196F3"; };
259
  loadMoreBtn.onmouseout = () => { loadMoreBtn.style.color = "#888"; };
260
  loadMoreBtn.onclick = () => {
@@ -378,9 +380,9 @@ export function openChatModal(currentUser, targetAccount = null) {
378
  localStorage.setItem(getLastChatKey(currentUser.account), JSON.stringify(currentTargetInfo));
379
 
380
  chatHeader.innerHTML = `
381
- <img id="chat-target-avatar" src="${avatar}" style="width: 36px; height: 36px; border-radius: 50%; object-fit: cover; border: 1px solid #555; cursor: pointer; margin-right: 10px; transition: 0.2s;" title="${t('chat.view_profile') || '点击查看个人资料'}">
382
  <div style="flex: 1;">
383
- <div style="font-weight: bold; font-size: 14px; color: #fff;">${t('chat.chatting_with') || '与'} ${escapeHtml(displayName)} ${t('chat.chatting_suffix') || '聊天'}</div>
384
  <div style="font-size: 11px; color: #888;">@${escapeHtml(targetAccount)}</div>
385
  </div>
386
  `;
@@ -442,11 +444,11 @@ export function openChatModal(currentUser, targetAccount = null) {
442
  } catch(e) {
443
  // 🚀 P1-7: loadChatHistory 错误处理
444
  console.error('[私信] 加载消息失败:', e);
445
- showToast(t('chat.load_history_failed') || '加载消息记录失败', 'error');
446
  messagesArea.innerHTML = `<div style="text-align:center; padding:40px; color:#888;">
447
- <div style="margin-bottom:10px;">${t('chat.load_failed_hint') || '消息加载失败'}</div>
448
  <button id="btn-retry-chat" style="padding:6px 16px; border-radius:4px; border:1px solid #555; background:var(--comfy-input-bg); color:#fff; cursor:pointer;">
449
- ${t('chat.retry') || '重试'}
450
  </button>
451
  </div>`;
452
  const retryBtn = messagesArea.querySelector('#btn-retry-chat');
@@ -455,11 +457,11 @@ export function openChatModal(currentUser, targetAccount = null) {
455
 
456
  // 🚀 绑定顶部清空消息按钮事件
457
  clearBtnTop.onclick = async () => {
458
- if (await showConfirm(t('chat.confirm_clear') || '确定要清空与该用户的本地聊天记录吗?')) {
459
  localStorage.setItem(clearKey, Date.now().toString());
460
  localStorage.setItem(cacheKey, "[]");
461
  renderMsgs([], avatar);
462
- showToast(t('chat.cleared') || '消息已清空', "success");
463
  }
464
  };
465
  }
@@ -469,7 +471,7 @@ export function openChatModal(currentUser, targetAccount = null) {
469
  const txt = inputField.value.trim();
470
  if (!txt) return;
471
  if (txt.length > MAX_MSG_LENGTH) {
472
- showToast(t('chat.msg_too_long') || `消息不能超过${MAX_MSG_LENGTH}字`, "warning");
473
  return;
474
  }
475
  if (!currentChatTarget) return;
@@ -481,8 +483,8 @@ export function openChatModal(currentUser, targetAccount = null) {
481
  clearDraft(); // 🚀 P1-4: 发送成功后清除草稿
482
  loadChatHistory(currentChatTarget, currentTargetInfo?.name, currentTargetInfo?.avatar);
483
  loadChatList();
484
- } catch(e) { showToast(t('chat.send_failed') || `发送失败: ${e.message}`, "error"); }
485
- sendBtn.disabled = false; sendBtn.innerText = t('common.send') || "发送";
486
  };
487
 
488
  // 🚀 P1-2: Enter 发送,Shift+Enter 换行
 
26
  const minutes = Math.floor(diff / 60000);
27
  const hours = Math.floor(diff / 3600000);
28
  const days = Math.floor(diff / 86400000);
29
+ if (minutes < 1) return t('chat.time_just_now');
30
+ if (minutes < 60) return `${minutes}${t('chat.time_min_ago')}`;
31
+ if (hours < 24) return `${hours}${t('chat.time_hour_ago')}`;
32
+ if (days < 7) return `${days}${t('chat.time_day_ago')}`;
33
  const date = new Date(msgTime);
34
+ // 🔧 去掉 || '月' / || '日' 兜底:这两个词条的英文值是刻意留空的(中文 9月8日 → 英文 9/8),
35
+ // 加了兜底反而会把中文量词引回英文界面(同文件下方的 chat.chatting_suffix 同理)
36
+ return `${date.getMonth() + 1}${t('chat.date_month')}${date.getDate()}${t('chat.date_day')} ${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}`;
37
  };
38
  // 🚀 P1-5: 超过5分钟则显示时间分隔符
39
  const TIME_GAP_THRESHOLD = 5 * 60; // 秒
 
111
  resize: "none", maxHeight: "100px", minHeight: "40px", overflow: "auto",
112
  lineHeight: "1.4", boxSizing: "border-box"
113
  });
114
+ inputField.placeholder = t('chat.input_placeholder');
115
  // 🚀 P1-2: 自适应高度
116
  const adjustHeight = () => {
117
  inputField.style.height = "auto";
 
196
  }
197
 
198
  chatItem.innerHTML = `
199
+ <img src="${escapeHtml(avatar)}" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover; border: 1px solid #555; flex-shrink: 0;">
200
  <div style="flex: 1; min-width: 0;">
201
  <div style="display: flex; justify-content: space-between; align-items: center;">
202
  <div style="font-weight: bold; font-size: 13px; color: #fff;">${escapeHtml(name)}</div>
 
235
  } catch(e) {
236
  // 🚀 P1-7: loadChatList 错误处理
237
  console.error('[私信] 加载对话列表失败:', e);
238
+ showToast(t('chat.load_list_failed'), 'error');
239
  }
240
  };
241
 
 
256
  textAlign: "center", padding: "10px", cursor: "pointer",
257
  color: "#888", fontSize: "12px", transition: "0.2s"
258
  });
259
+ loadMoreBtn.textContent = t('chat.load_more');
260
  loadMoreBtn.onmouseover = () => { loadMoreBtn.style.color = "#2196F3"; };
261
  loadMoreBtn.onmouseout = () => { loadMoreBtn.style.color = "#888"; };
262
  loadMoreBtn.onclick = () => {
 
380
  localStorage.setItem(getLastChatKey(currentUser.account), JSON.stringify(currentTargetInfo));
381
 
382
  chatHeader.innerHTML = `
383
+ <img id="chat-target-avatar" src="${escapeHtml(avatar)}" style="width: 36px; height: 36px; border-radius: 50%; object-fit: cover; border: 1px solid #555; cursor: pointer; margin-right: 10px; transition: 0.2s;" title="${t('chat.view_profile')}">
384
  <div style="flex: 1;">
385
+ <div style="font-weight: bold; font-size: 14px; color: #fff;">${t('chat.chatting_with')} ${escapeHtml(displayName)} ${t('chat.chatting_suffix')}</div>
386
  <div style="font-size: 11px; color: #888;">@${escapeHtml(targetAccount)}</div>
387
  </div>
388
  `;
 
444
  } catch(e) {
445
  // 🚀 P1-7: loadChatHistory 错误处理
446
  console.error('[私信] 加载消息失败:', e);
447
+ showToast(t('chat.load_history_failed'), 'error');
448
  messagesArea.innerHTML = `<div style="text-align:center; padding:40px; color:#888;">
449
+ <div style="margin-bottom:10px;">${t('chat.load_failed_hint')}</div>
450
  <button id="btn-retry-chat" style="padding:6px 16px; border-radius:4px; border:1px solid #555; background:var(--comfy-input-bg); color:#fff; cursor:pointer;">
451
+ ${t('chat.retry')}
452
  </button>
453
  </div>`;
454
  const retryBtn = messagesArea.querySelector('#btn-retry-chat');
 
457
 
458
  // 🚀 绑定顶部清空消息按钮事件
459
  clearBtnTop.onclick = async () => {
460
+ if (await showConfirm(t('chat.confirm_clear'))) {
461
  localStorage.setItem(clearKey, Date.now().toString());
462
  localStorage.setItem(cacheKey, "[]");
463
  renderMsgs([], avatar);
464
+ showToast(t('chat.cleared'), "success");
465
  }
466
  };
467
  }
 
471
  const txt = inputField.value.trim();
472
  if (!txt) return;
473
  if (txt.length > MAX_MSG_LENGTH) {
474
+ showToast(t('chat.msg_too_long'), "warning");
475
  return;
476
  }
477
  if (!currentChatTarget) return;
 
483
  clearDraft(); // 🚀 P1-4: 发送成功后清除草稿
484
  loadChatHistory(currentChatTarget, currentTargetInfo?.name, currentTargetInfo?.avatar);
485
  loadChatList();
486
+ } catch(e) { showToast(t('chat.send_failed', { msg: e.message }), "error"); }
487
+ sendBtn.disabled = false; sendBtn.innerText = t('common.send');
488
  };
489
 
490
  // 🚀 P1-2: Enter 发送,Shift+Enter 换行
前端页面/social/评论与互动组件.js CHANGED
@@ -108,7 +108,7 @@ export function createCommentSection(itemId, commentsData, currentUser, onCountC
108
  const avatarSrc = avatar || DEFAULT_AVATAR_SVG;
109
 
110
  itemDiv.innerHTML = `
111
- <img class="swr-avatar" src="${avatarSrc}" style="width: ${avatarSize}; height: ${avatarSize}; border-radius: 50%; object-fit: cover; background: var(--comfy-input-bg);">
112
  <div style="flex: 1;">
113
  <div style="display: flex; justify-content: space-between; margin-bottom: 2px;">
114
  <span class="swr-name" style="color: #aaa; font-weight: bold; font-size: 11px;">${escapeHtml(comment.authorName || comment.author)}</span>
@@ -144,7 +144,7 @@ export function createCommentSection(itemId, commentsData, currentUser, onCountC
144
  if (canDelete) {
145
  itemDiv.querySelector(".delete-btn").onclick = async () => {
146
  const confirmed = await showConfirm(t('social.delete_comment_confirm'), {
147
- title: t('social.delete_comment_title') || t('common.confirm'),
148
  confirmText: t('common.confirm'),
149
  cancelText: t('common.cancel'),
150
  type: 'danger'
@@ -156,7 +156,7 @@ export function createCommentSection(itemId, commentsData, currentUser, onCountC
156
  comment.isDeleted = true;
157
  renderList();
158
  triggerCountUpdate();
159
- } catch(e) { showToast(t('feedback.delete_failed') + t('feedback.retry_suffix', ',请重试'), "error"); }
160
  };
161
  }
162
  }
 
108
  const avatarSrc = avatar || DEFAULT_AVATAR_SVG;
109
 
110
  itemDiv.innerHTML = `
111
+ <img class="swr-avatar" src="${escapeHtml(avatarSrc)}" style="width: ${avatarSize}; height: ${avatarSize}; border-radius: 50%; object-fit: cover; background: var(--comfy-input-bg);">
112
  <div style="flex: 1;">
113
  <div style="display: flex; justify-content: space-between; margin-bottom: 2px;">
114
  <span class="swr-name" style="color: #aaa; font-weight: bold; font-size: 11px;">${escapeHtml(comment.authorName || comment.author)}</span>
 
144
  if (canDelete) {
145
  itemDiv.querySelector(".delete-btn").onclick = async () => {
146
  const confirmed = await showConfirm(t('social.delete_comment_confirm'), {
147
+ title: t('social.delete_comment_title'),
148
  confirmText: t('common.confirm'),
149
  cancelText: t('common.cancel'),
150
  type: 'danger'
 
156
  comment.isDeleted = true;
157
  renderList();
158
  triggerCountUpdate();
159
+ } catch(e) { showToast(t('feedback.delete_failed') + t('feedback.retry_suffix'), "error"); }
160
  };
161
  }
162
  }
前端页面/social/通知中心组件.js CHANGED
@@ -155,15 +155,17 @@ export async function openNotificationCenter(currentUser, bellBtn) {
155
  } else if (isAnonymous) {
156
  nameLabel = `<strong style="color: #fff;">${escapeHtml(msg.from_name || msg.from_user)}</strong>`;
157
  } else {
158
- nameLabel = `<span class="notif-user-link" data-account="${msg.from_user}"
 
 
159
  style="color:#4FC3F7; cursor:pointer; text-decoration:underline dotted; font-weight:bold;">
160
  ${escapeHtml(msg.from_name || msg.from_user)}
161
  </span>`;
162
  }
163
 
164
  html += `
165
- <div class="notif-item" data-account="${msg.from_user}" data-type="${msg.type}" data-item-id="${msg.target_item_id || ''}" style="padding: 12px; border-radius: 8px; background: ${bg}; border: ${border}; display: flex; gap: 12px; align-items: flex-start; cursor: pointer; transition: 0.2s;" onmouseover="this.style.transform='scale(1.01)'" onmouseout="this.style.transform='scale(1)'">
166
- <img src="${msg.from_avatar || PLACEHOLDERS.AVATAR}" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover; flex-shrink: 0; border: 1px solid #555;">
167
  <div style="flex: 1; min-width: 0;">
168
  <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
169
  ${nameLabel}
 
155
  } else if (isAnonymous) {
156
  nameLabel = `<strong style="color: #fff;">${escapeHtml(msg.from_name || msg.from_user)}</strong>`;
157
  } else {
158
+ // 🔒 XSS防护:from_user 是他人账号,写入 HTML 属性前必须转义
159
+ // (dataset.account 经浏览器解码后仍是原始值,下方跳转行为不变)
160
+ nameLabel = `<span class="notif-user-link" data-account="${escapeHtml(msg.from_user)}"
161
  style="color:#4FC3F7; cursor:pointer; text-decoration:underline dotted; font-weight:bold;">
162
  ${escapeHtml(msg.from_name || msg.from_user)}
163
  </span>`;
164
  }
165
 
166
  html += `
167
+ <div class="notif-item" data-account="${escapeHtml(msg.from_user)}" data-type="${escapeHtml(msg.type)}" data-item-id="${escapeHtml(msg.target_item_id || '')}" style="padding: 12px; border-radius: 8px; background: ${bg}; border: ${border}; display: flex; gap: 12px; align-items: flex-start; cursor: pointer; transition: 0.2s;" onmouseover="this.style.transform='scale(1.01)'" onmouseout="this.style.transform='scale(1)'">
168
+ <img src="${escapeHtml(msg.from_avatar || PLACEHOLDERS.AVATAR)}" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover; flex-shrink: 0; border: 1px solid #555;">
169
  <div style="flex: 1; min-width: 0;">
170
  <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
171
  ${nameLabel}
前端页面/task/任务榜组件.js CHANGED
@@ -18,7 +18,7 @@
18
  import { api } from "../core/网络请求API.js";
19
  import { proxyImages } from "../core/网络请求API.js";
20
  import { showToast } from "../components/UI交互提示组件.js";
21
- import { setCache, getCacheWithMeta, createSkeleton, createPaginationLoader, lazyLoadImages } from "../components/性能优化工具.js";
22
  import { applyViewportAnimations } from "../components/动画音效引擎.js";
23
  import { t } from "../components/用户体验增强.js";
24
  import { getCachedProfile, getProfileWithSWR } from "../core/全局配置.js";
@@ -221,8 +221,8 @@ export function createTasksView(currentUser, keyword = "") {
221
 
222
  // ✅ 优先从本地缓存读取(含过期缓存:离线容灾,与插件榜/工作流/推荐榜策略一致)
223
  if (!append && page === 1) {
224
- const { value: cachedData, found: hasCacheData } = getCacheWithMeta(cacheKey, true);
225
- if (hasCacheData && cachedData && cachedData.length > 0) {
226
  // 🚀 缓存数据也需要过一遍图片代理,确保新字段也被处理
227
  allTasksData = proxyImages(cachedData);
228
  renderTasksFromCache(allTasksData);
@@ -247,7 +247,7 @@ export function createTasksView(currentUser, keyword = "") {
247
  // 缓存第一页数据
248
  if (page === 1) {
249
  allTasksData = tasks;
250
- setCache(cacheKey, tasks, getCacheTTL(), true); // 持久化到localStorage
251
  } else {
252
  allTasksData = [...allTasksData, ...tasks];
253
  }
@@ -293,8 +293,8 @@ export function createTasksView(currentUser, keyword = "") {
293
  isLoadingFromNetwork = false;
294
  // 网络失败时尝试从缓存读取(包括过期缓存,离线容灾)
295
  if (!append) {
296
- const { value: cachedData, found: hasCacheData } = getCacheWithMeta(cacheKey, true);
297
- if (hasCacheData && cachedData && cachedData.length > 0) {
298
  // 🚀 缓存数据也需要过一遍图片代理
299
  allTasksData = proxyImages(cachedData);
300
  renderTasksFromCache(allTasksData);
@@ -359,7 +359,7 @@ export function createTasksView(currentUser, keyword = "") {
359
 
360
  // 更新缓存
361
  const cacheKey = getCacheKey();
362
- setCache(cacheKey, tasks, getCacheTTL(), true);
363
 
364
  // 对比新旧数据,有变化时重新渲染
365
  if (_tasksDataChanged(allTasksData, tasks)) {
@@ -559,7 +559,8 @@ function createTaskCard(task) {
559
 
560
  // 渲染头像(始终使用 img 标签)
561
  const avatarSrc = avatar || DEFAULT_AVATAR_SVG;
562
- const avatarHtml = `<img class="swr-avatar" src="${avatarSrc}" style="width: 18px; height: 18px; border-radius: 50%; object-fit: cover; border: 1px solid #444; background: var(--comfy-input-bg);">`;
 
563
 
564
  authorContainer.innerHTML = `${avatarHtml}<span class="swr-name">${escapeHtml(name)}</span>`;
565
 
 
18
  import { api } from "../core/网络请求API.js";
19
  import { proxyImages } from "../core/网络请求API.js";
20
  import { showToast } from "../components/UI交互提示组件.js";
21
+ import { readListCache, writeListCache, createSkeleton, createPaginationLoader, lazyLoadImages } from "../components/性能优化工具.js";
22
  import { applyViewportAnimations } from "../components/动画音效引擎.js";
23
  import { t } from "../components/用户体验增强.js";
24
  import { getCachedProfile, getProfileWithSWR } from "../core/全局配置.js";
 
221
 
222
  // ✅ 优先从本地缓存读取(含过期缓存:离线容灾,与插件榜/工作流/推荐榜策略一致)
223
  if (!append && page === 1) {
224
+ const cachedData = readListCache(cacheKey);
225
+ if (cachedData) {
226
  // 🚀 缓存数据也需要过一遍图片代理,确保新字段也被处理
227
  allTasksData = proxyImages(cachedData);
228
  renderTasksFromCache(allTasksData);
 
247
  // 缓存第一页数据
248
  if (page === 1) {
249
  allTasksData = tasks;
250
+ writeListCache(cacheKey, tasks, getCacheTTL()); // 持久化到 localStorage,供离线容灾
251
  } else {
252
  allTasksData = [...allTasksData, ...tasks];
253
  }
 
293
  isLoadingFromNetwork = false;
294
  // 网络失败时尝试从缓存读取(包括过期缓存,离线容灾)
295
  if (!append) {
296
+ const cachedData = readListCache(cacheKey);
297
+ if (cachedData) {
298
  // 🚀 缓存数据也需要过一遍图片代理
299
  allTasksData = proxyImages(cachedData);
300
  renderTasksFromCache(allTasksData);
 
359
 
360
  // 更新缓存
361
  const cacheKey = getCacheKey();
362
+ writeListCache(cacheKey, tasks, getCacheTTL());
363
 
364
  // 对比新旧数据,有变化时重新渲染
365
  if (_tasksDataChanged(allTasksData, tasks)) {
 
559
 
560
  // 渲染头像(始终使用 img 标签)
561
  const avatarSrc = avatar || DEFAULT_AVATAR_SVG;
562
+ // 🔒 XSS防护:头像 URL 来自他人可修改的资料,写入 HTML 属性前必须转义
563
+ const avatarHtml = `<img class="swr-avatar" src="${escapeHtml(avatarSrc)}" style="width: 18px; height: 18px; border-radius: 50%; object-fit: cover; border: 1px solid #444; background: var(--comfy-input-bg);">`;
564
 
565
  authorContainer.innerHTML = `${avatarHtml}<span class="swr-name">${escapeHtml(name)}</span>`;
566
 
前端页面/task/任务详情组件.js CHANGED
@@ -103,11 +103,9 @@ async function loadTaskDetail(container, taskId, currentUser) {
103
  renderTaskDetail(contentEl, task, currentUser);
104
 
105
  if (fromCache) {
106
- const toast = document.createElement('div');
107
- toast.style.cssText = 'position:fixed; top:20px; left:50%; transform:translateX(-50%); background:#FF9800; color:white; padding:10px 20px; border-radius:4px; z-index:10000; font-size:14px;';
108
- toast.textContent = t('feedback.cache_fallback');
109
- document.body.appendChild(toast);
110
- setTimeout(() => toast.remove(), 3000);
111
  } else {
112
  // 只有在线加载成功时才记录浏览量
113
  recordTaskView(contentEl, taskId);
@@ -188,7 +186,7 @@ function renderTaskDetail(contentEl, task, currentUser) {
188
 
189
  <!-- 发布者信息 -->
190
  <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 15px; padding: 10px; background: var(--comfy-menu-bg); border-radius: 8px;">
191
- <img src="${task.publisher_avatar || PLACEHOLDERS.AVATAR_SMALL}"
192
  style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover; border: 2px solid var(--border-color, #333); cursor: pointer;" id="publisher-avatar">
193
  <div style="flex: 1;">
194
  <div style="color: #fff; font-size: 14px; font-weight: 500;">${escapeHtml(task.publisher_name || task.publisher)}</div>
@@ -296,7 +294,7 @@ ${escapeHtml(task.description)}</div>
296
  <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;">
297
  <div style="font-size: 13px; font-weight: bold; color: #2196F3; margin-bottom: 8px;">👷 ${t('task.assignee')}</div>
298
  <div style="display: flex; align-items: center; gap: 10px;">
299
- <img src="${task.assignee_avatar || PLACEHOLDERS.AVATAR_SMALL}"
300
  style="width: 32px; height: 32px; border-radius: 50%; object-fit: cover;">
301
  <span style="color: #fff;">${escapeHtml(task.assignee_name || task.assignee)}</span>
302
  </div>
@@ -321,7 +319,7 @@ ${escapeHtml(task.description)}</div>
321
  <div id="applicants-list" style="display: flex; flex-direction: column; gap: 8px;">
322
  ${task.applicants.map(app => `
323
  <div style="display: flex; align-items: center; gap: 10px; padding: 10px; background: var(--comfy-menu-bg); border-radius: 8px;">
324
- <img src="${app.avatar || PLACEHOLDERS.AVATAR_SMALL}"
325
  style="width: 32px; height: 32px; border-radius: 50%; object-fit: cover;">
326
  <div style="flex: 1;">
327
  <div style="color: #fff; font-size: 13px;">${escapeHtml(app.name || app.account)}</div>
@@ -383,14 +381,17 @@ ${escapeHtml(task.description)}</div>
383
  function _showConfirmDialog({ title, icon, message, description, confirmText, confirmStyle, loadingText, errorText, width, onConfirm }) {
384
  const content = document.createElement("div");
385
  content.style.color = "#ccc";
 
 
 
386
  content.innerHTML = `
387
  <div style="text-align: center;">
388
  <div style="font-size: 40px; margin-bottom: 15px;">${icon}</div>
389
- <div style="color: #fff; font-size: 14px; margin-bottom: 8px;">${message}</div>
390
- <div style="color: #888; font-size: 12px; margin-bottom: 20px;">${description}</div>
391
  <div style="display: flex; gap: 10px;">
392
  <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>
393
- <button id="dialog-confirm" style="flex: 1; ${confirmStyle}; border: none; color: #fff; padding: 10px; border-radius: 6px; cursor: pointer; font-weight: bold;">${confirmText}</button>
394
  </div>
395
  </div>
396
  `;
@@ -444,7 +445,7 @@ function showApplyDialog(task, currentUser) {
444
  content.innerHTML = `
445
  <div style="margin-bottom: 15px;">
446
  <div style="color: #888; font-size: 12px; margin-bottom: 8px;">${t('task.apply_message_prompt')}</div>
447
- <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>
448
  </div>
449
  <div style="display: flex; gap: 10px;">
450
  <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>
@@ -487,7 +488,7 @@ function showCancelApplyConfirmDialog(task, currentUser) {
487
  title: t('task.cancel_apply'),
488
  icon: '↩️',
489
  message: t('task.confirm_cancel_apply'),
490
- description: t('task.cancel_apply_warning') || '',
491
  confirmText: t('common.confirm'),
492
  confirmStyle: 'background: #FF9800',
493
  errorText: t('task.cancel_apply_failed'),
@@ -511,7 +512,7 @@ function showCancelTaskConfirmDialog(task) {
511
  title: t('task.cancel_task'),
512
  icon: '⚠️',
513
  message: t('task.confirm_cancel_task'),
514
- description: t('task.cancel_task_warning') || '',
515
  confirmText: t('task.cancel_task'),
516
  confirmStyle: 'background: #F44336',
517
  loadingText: `⏳ ${t('common.cancelling')}...`,
@@ -558,7 +559,7 @@ function showRejectDialog(task, currentUser) {
558
  content.innerHTML = `
559
  <div style="margin-bottom: 15px;">
560
  <div style="color: #888; font-size: 12px; margin-bottom: 8px;">${t('task.reject_reason_prompt')}</div>
561
- <textarea 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>
562
  </div>
563
  <div style="display: flex; gap: 10px;">
564
  <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>
@@ -571,7 +572,7 @@ function showRejectDialog(task, currentUser) {
571
  content.querySelector("#reject-confirm").onclick = async () => {
572
  const feedback = content.querySelector("#reject-feedback").value.trim();
573
  if (!feedback) {
574
- showToast(t('task.reject_reason_required') || '请输入拒绝原因', "warning");
575
  return;
576
  }
577
 
@@ -607,8 +608,8 @@ function showDeleteCommentConfirmDialog(taskId, comment, container, currentUser)
607
  content.innerHTML = `
608
  <div style="text-align: center;">
609
  <div style="font-size: 40px; margin-bottom: 15px;">⚠️</div>
610
- <div style="color: #fff; font-size: 14px; margin-bottom: 8px;">${t('post.delete_comment_confirm') || '确定删除这条评论吗?'}</div>
611
- <div style="color: #888; font-size: 12px; margin-bottom: 20px;">${t('post.delete_comment_warning') || '删除后无法恢复'}</div>
612
  <div style="display: flex; gap: 10px;">
613
  <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>
614
  <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>
@@ -625,11 +626,11 @@ function showDeleteCommentConfirmDialog(taskId, comment, container, currentUser)
625
 
626
  try {
627
  await api.deleteComment(taskId, comment.id);
628
- showToast(t('post.delete_comment_success') || '评论已删除', "success");
629
  globalModal.closeTopModal();
630
  loadTaskComments(container, taskId, currentUser);
631
  } catch (err) {
632
- showToast((t('post.delete_comment_failed') || '删除失败') + ": " + err.message, "error");
633
  confirmBtn.disabled = false;
634
  confirmBtn.textContent = t('common.delete');
635
  }
@@ -935,9 +936,10 @@ function renderTipBoard(tipBoard) {
935
  */
936
  function _renderCommentAvatar(name, avatarUrl) {
937
  const initial = (name || 'U')[0].toUpperCase();
 
938
  return avatarUrl
939
- ? `<img class="swr-avatar" src="${avatarUrl}" style="width: 24px; height: 24px; border-radius: 50%; object-fit: cover; background: var(--comfy-input-bg);">`
940
- : `<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;">${initial}</div>`;
941
  }
942
 
943
  /**
 
103
  renderTaskDetail(contentEl, task, currentUser);
104
 
105
  if (fromCache) {
106
+ // 🔧 改用统一提示组件:原为手写 div(无动画、不排队、不避重叠,样式与其他提示不一致);
107
+ // warning 类型的背景色与原手写值同为 #FF9800,视觉保持一致
108
+ showToast(t('feedback.cache_fallback'), 'warning');
 
 
109
  } else {
110
  // 只有在线加载成功时才记录浏览量
111
  recordTaskView(contentEl, taskId);
 
186
 
187
  <!-- 发布者信息 -->
188
  <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 15px; padding: 10px; background: var(--comfy-menu-bg); border-radius: 8px;">
189
+ <img src="${escapeHtml(task.publisher_avatar || PLACEHOLDERS.AVATAR_SMALL)}"
190
  style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover; border: 2px solid var(--border-color, #333); cursor: pointer;" id="publisher-avatar">
191
  <div style="flex: 1;">
192
  <div style="color: #fff; font-size: 14px; font-weight: 500;">${escapeHtml(task.publisher_name || task.publisher)}</div>
 
294
  <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;">
295
  <div style="font-size: 13px; font-weight: bold; color: #2196F3; margin-bottom: 8px;">👷 ${t('task.assignee')}</div>
296
  <div style="display: flex; align-items: center; gap: 10px;">
297
+ <img src="${escapeHtml(task.assignee_avatar || PLACEHOLDERS.AVATAR_SMALL)}"
298
  style="width: 32px; height: 32px; border-radius: 50%; object-fit: cover;">
299
  <span style="color: #fff;">${escapeHtml(task.assignee_name || task.assignee)}</span>
300
  </div>
 
319
  <div id="applicants-list" style="display: flex; flex-direction: column; gap: 8px;">
320
  ${task.applicants.map(app => `
321
  <div style="display: flex; align-items: center; gap: 10px; padding: 10px; background: var(--comfy-menu-bg); border-radius: 8px;">
322
+ <img src="${escapeHtml(app.avatar || PLACEHOLDERS.AVATAR_SMALL)}"
323
  style="width: 32px; height: 32px; border-radius: 50%; object-fit: cover;">
324
  <div style="flex: 1;">
325
  <div style="color: #fff; font-size: 13px;">${escapeHtml(app.name || app.account)}</div>
 
381
  function _showConfirmDialog({ title, icon, message, description, confirmText, confirmStyle, loadingText, errorText, width, onConfirm }) {
382
  const content = document.createElement("div");
383
  content.style.color = "#ccc";
384
+ // 🔒 XSS防护:message/description 可能嵌入其他用户的账号或昵称
385
+ // (如 t('task.confirm_assign', { assignee })),写入 innerHTML 前必须转义。
386
+ // 本函数的 4 个调用方传的都是词典文本/emoji/数字,转义不改变现有显示效果。
387
  content.innerHTML = `
388
  <div style="text-align: center;">
389
  <div style="font-size: 40px; margin-bottom: 15px;">${icon}</div>
390
+ <div style="color: #fff; font-size: 14px; margin-bottom: 8px;">${escapeHtml(message)}</div>
391
+ <div style="color: #888; font-size: 12px; margin-bottom: 20px;">${escapeHtml(description)}</div>
392
  <div style="display: flex; gap: 10px;">
393
  <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>
394
+ <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>
395
  </div>
396
  </div>
397
  `;
 
445
  content.innerHTML = `
446
  <div style="margin-bottom: 15px;">
447
  <div style="color: #888; font-size: 12px; margin-bottom: 8px;">${t('task.apply_message_prompt')}</div>
448
+ <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>
449
  </div>
450
  <div style="display: flex; gap: 10px;">
451
  <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>
 
488
  title: t('task.cancel_apply'),
489
  icon: '↩️',
490
  message: t('task.confirm_cancel_apply'),
491
+ description: t('task.cancel_apply_warning'),
492
  confirmText: t('common.confirm'),
493
  confirmStyle: 'background: #FF9800',
494
  errorText: t('task.cancel_apply_failed'),
 
512
  title: t('task.cancel_task'),
513
  icon: '⚠️',
514
  message: t('task.confirm_cancel_task'),
515
+ description: t('task.cancel_task_warning'),
516
  confirmText: t('task.cancel_task'),
517
  confirmStyle: 'background: #F44336',
518
  loadingText: `⏳ ${t('common.cancelling')}...`,
 
559
  content.innerHTML = `
560
  <div style="margin-bottom: 15px;">
561
  <div style="color: #888; font-size: 12px; margin-bottom: 8px;">${t('task.reject_reason_prompt')}</div>
562
+ <textarea 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>
563
  </div>
564
  <div style="display: flex; gap: 10px;">
565
  <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>
 
572
  content.querySelector("#reject-confirm").onclick = async () => {
573
  const feedback = content.querySelector("#reject-feedback").value.trim();
574
  if (!feedback) {
575
+ showToast(t('task.reject_reason_required'), "warning");
576
  return;
577
  }
578
 
 
608
  content.innerHTML = `
609
  <div style="text-align: center;">
610
  <div style="font-size: 40px; margin-bottom: 15px;">⚠️</div>
611
+ <div style="color: #fff; font-size: 14px; margin-bottom: 8px;">${t('post.delete_comment_confirm')}</div>
612
+ <div style="color: #888; font-size: 12px; margin-bottom: 20px;">${t('post.delete_comment_warning')}</div>
613
  <div style="display: flex; gap: 10px;">
614
  <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>
615
  <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>
 
626
 
627
  try {
628
  await api.deleteComment(taskId, comment.id);
629
+ showToast(t('post.delete_comment_success'), "success");
630
  globalModal.closeTopModal();
631
  loadTaskComments(container, taskId, currentUser);
632
  } catch (err) {
633
+ showToast(t('post.delete_comment_failed') + ": " + err.message, "error");
634
  confirmBtn.disabled = false;
635
  confirmBtn.textContent = t('common.delete');
636
  }
 
936
  */
937
  function _renderCommentAvatar(name, avatarUrl) {
938
  const initial = (name || 'U')[0].toUpperCase();
939
+ // 🔒 XSS防护:评论者昵称与头像均来自他人可修改的资料,写入 HTML 前必须转义
940
  return avatarUrl
941
+ ? `<img class="swr-avatar" src="${escapeHtml(avatarUrl)}" style="width: 24px; height: 24px; border-radius: 50%; object-fit: cover; background: var(--comfy-input-bg);">`
942
+ : `<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>`;
943
  }
944
 
945
  /**
前端页面/task/发布任务组件.js CHANGED
@@ -8,14 +8,17 @@
8
  import { api } from "../core/网络请求API.js";
9
  import { showToast, showConfirm } from "../components/UI交互提示组件.js";
10
  import { t } from "../components/用户体验增强.js";
11
- import { removeCache } from "../components/性能优化工具.js";
12
  import { compressImageForUpload } from "../market/发布内容_提交引擎.js";
 
13
 
14
  /**
15
  * 🚀 清除任务列表缓存
16
  */
17
  function clearTaskListCache() {
18
- removeCache('api_/api/tasks');
 
 
19
  // 🔧 P1修复:任务榜组件实际使用 TasksCache_${status}_${sort} 键(非 ListCache_*),按真实键格式精准清除
20
  const statuses = ['', 'open', 'in_progress', 'submitted', 'completed', 'disputed'];
21
  // 任务榜排序值:latest, price, deadline, views, daily_views, likes, favorites
@@ -102,7 +105,7 @@ export function createPublishTaskView(currentUser, editTaskData = null) {
102
  <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)'">
103
  ⬅ ${t('common.back')}
104
  </button>
105
- <span style="flex: 1; text-align: center; font-size: 15px; font-weight: bold; color: #fff;">📝 ${isEditMode ? (t('task.edit_task') || '编辑任务') : t('task.publish')}</span>
106
  <div style="width: 60px;"></div>
107
  </div>
108
 
@@ -114,7 +117,7 @@ export function createPublishTaskView(currentUser, editTaskData = null) {
114
  <label style="display: block; color: #fff; font-size: 13px; font-weight: bold; margin-bottom: 6px;">
115
  📌 ${t('task.task_title')} <span style="color: #F44336;">*</span>
116
  </label>
117
- <input type="text" id="task-title" maxlength="50" placeholder="${t('task.title_placeholder')}" value="${editTitle}"
118
  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;">
119
  </div>
120
 
@@ -124,7 +127,7 @@ export function createPublishTaskView(currentUser, editTaskData = null) {
124
  📄 ${t('task.description')} <span style="color: #F44336;">*</span>
125
  </label>
126
  <textarea id="task-description" rows="6" maxlength="2000" placeholder="${t('task.description_placeholder')}"
127
- 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;">${editDescription}</textarea>
128
  <div style="text-align: right; font-size: 11px; color: #666; margin-top: 4px;">
129
  <span id="desc-count">${editDescription.length}</span>/2000
130
  </div>
@@ -148,7 +151,7 @@ export function createPublishTaskView(currentUser, editTaskData = null) {
148
  <label style="display: block; color: #fff; font-size: 13px; font-weight: bold; margin-bottom: 6px;">
149
  🔗 ${t('task.reference_link_optional')}
150
  </label>
151
- <input type="url" id="ref-link" placeholder="https://..." value="${editRefLink}"
152
  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;">
153
  </div>
154
 
@@ -308,7 +311,7 @@ export function createPublishTaskView(currentUser, editTaskData = null) {
308
  const renderImagePreview = () => {
309
  imagePreview.innerHTML = selectedImages.map((img, i) => `
310
  <div style="position: relative; padding-top: 100%; background: var(--comfy-menu-bg); border-radius: 8px; overflow: hidden;">
311
- <img src="${img.dataUrl}" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover;">
312
  <button 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>
313
  </div>
314
  `).join("");
@@ -383,7 +386,7 @@ export function createPublishTaskView(currentUser, editTaskData = null) {
383
 
384
  await api.updateTask(editTaskData.id, updateData);
385
 
386
- showToast(t('task.edit_success') || '任务修改成功', "success");
387
  } else {
388
  // 新建模式:创建任务
389
  publishBtn.textContent = `⏳ ${t('task.publishing')}...`;
@@ -411,7 +414,7 @@ export function createPublishTaskView(currentUser, editTaskData = null) {
411
 
412
  } catch (err) {
413
  console.error(isEditMode ? "修改任务失败:" : "发布任务失败:", err);
414
- showToast(err.message || (isEditMode ? t('task.edit_failed') || '修改失败' : t('task.publish_failed')), "error");
415
  } finally {
416
  const publishBtn = container.querySelector("#btn-publish");
417
  publishBtn.disabled = false;
 
8
  import { api } from "../core/网络请求API.js";
9
  import { showToast, showConfirm } from "../components/UI交互提示组件.js";
10
  import { t } from "../components/用户体验增强.js";
11
+ import { removeCache, removeCacheByPrefix } from "../components/性能优化工具.js";
12
  import { compressImageForUpload } from "../market/发布内容_提交引擎.js";
13
+ import { escapeHtml } from "../core/全局配置.js";
14
 
15
  /**
16
  * 🚀 清除任务列表缓存
17
  */
18
  function clearTaskListCache() {
19
+ // 🔧 修复:API 层缓存键带完整查询串(api_/api/tasks?status=..&sort=..),
20
+ // removeCache 精确删除命中不到(no-op),发任务后任务榜仍会从旧缓存返回,改用前缀删除
21
+ removeCacheByPrefix('api_/api/tasks');
22
  // 🔧 P1修复:任务榜组件实际使用 TasksCache_${status}_${sort} 键(非 ListCache_*),按真实键格式精准清除
23
  const statuses = ['', 'open', 'in_progress', 'submitted', 'completed', 'disputed'];
24
  // 任务榜排序值:latest, price, deadline, views, daily_views, likes, favorites
 
105
  <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)'">
106
  ⬅ ${t('common.back')}
107
  </button>
108
+ <span style="flex: 1; text-align: center; font-size: 15px; font-weight: bold; color: #fff;">📝 ${isEditMode ? t('task.edit_task') : t('task.publish')}</span>
109
  <div style="width: 60px;"></div>
110
  </div>
111
 
 
117
  <label style="display: block; color: #fff; font-size: 13px; font-weight: bold; margin-bottom: 6px;">
118
  📌 ${t('task.task_title')} <span style="color: #F44336;">*</span>
119
  </label>
120
+ <input type="text" id="task-title" maxlength="50" placeholder="${t('task.title_placeholder')}" value="${escapeHtml(editTitle)}"
121
  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;">
122
  </div>
123
 
 
127
  📄 ${t('task.description')} <span style="color: #F44336;">*</span>
128
  </label>
129
  <textarea id="task-description" rows="6" maxlength="2000" placeholder="${t('task.description_placeholder')}"
130
+ 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>
131
  <div style="text-align: right; font-size: 11px; color: #666; margin-top: 4px;">
132
  <span id="desc-count">${editDescription.length}</span>/2000
133
  </div>
 
151
  <label style="display: block; color: #fff; font-size: 13px; font-weight: bold; margin-bottom: 6px;">
152
  🔗 ${t('task.reference_link_optional')}
153
  </label>
154
+ <input type="url" id="ref-link" placeholder="https://..." value="${escapeHtml(editRefLink)}"
155
  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;">
156
  </div>
157
 
 
311
  const renderImagePreview = () => {
312
  imagePreview.innerHTML = selectedImages.map((img, i) => `
313
  <div style="position: relative; padding-top: 100%; background: var(--comfy-menu-bg); border-radius: 8px; overflow: hidden;">
314
+ <img src="${escapeHtml(img.dataUrl)}" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover;">
315
  <button 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>
316
  </div>
317
  `).join("");
 
386
 
387
  await api.updateTask(editTaskData.id, updateData);
388
 
389
+ showToast(t('task.edit_success'), "success");
390
  } else {
391
  // 新建模式:创建任务
392
  publishBtn.textContent = `⏳ ${t('task.publishing')}...`;
 
414
 
415
  } catch (err) {
416
  console.error(isEditMode ? "修改任务失败:" : "发布任务失败:", err);
417
+ showToast(err.message || (isEditMode ? t('task.edit_failed') : t('task.publish_failed')), "error");
418
  } finally {
419
  const publishBtn = container.querySelector("#btn-publish");
420
  publishBtn.disabled = false;
前端页面/task/申诉详情组件.js CHANGED
@@ -8,7 +8,7 @@
8
  import { api } from "../core/网络请求API.js";
9
  import { showToast } from "../components/UI交互提示组件.js";
10
  import { t, getLanguage } from "../components/用户体验增强.js";
11
- import { isAdmin, IS_WEB_MODE } from "../core/全局配置.js";
12
 
13
  /**
14
  * 创建申诉详情视图
@@ -37,7 +37,7 @@ async function loadDisputeDetail(container, disputeId, currentUser, onBack) {
37
  const dispute = res.data;
38
  renderDisputeDetail(container, dispute, currentUser, onBack);
39
  } catch (err) {
40
- container.innerHTML = `<div style="text-align: center; padding: 40px; color: #f44;">❌ ${err.message}</div>`;
41
  }
42
  }
43
 
@@ -110,7 +110,7 @@ function renderDisputeDetail(container, dispute, currentUser, onBack) {
110
  <!-- 任务信息 -->
111
  <div class="dispute-section">
112
  <div class="dispute-section-title"><span class="icon">📋</span> ${t('dispute.related_task')}</div>
113
- <div style="color: var(--input-text); font-size: 15px;">${dispute.task_title || t('common.unknown_task')}</div>
114
  <div style="font-size: 12px; color: #888; margin-top: 4px;">${t('dispute.task_id')}: ${dispute.task_id}</div>
115
  </div>
116
 
@@ -118,16 +118,16 @@ function renderDisputeDetail(container, dispute, currentUser, onBack) {
118
  <div class="dispute-section">
119
  <div class="dispute-section-title"><span class="icon">👥</span> ${t('dispute.parties')}</div>
120
  <div class="dispute-party">
121
- <img class="dispute-party-avatar" src="${dispute.publisher_avatar || (IS_WEB_MODE ? 'https://api.dicebear.com/7.x/avataaars/svg' : '/community_hub/image?url=https://api.dicebear.com/7.x/avataaars/svg')}" alt="">
122
  <div class="dispute-party-info">
123
- <div class="dispute-party-name">${dispute.publisher_name || dispute.publisher}</div>
124
  <div class="dispute-party-role">${t('dispute.publisher')} ${dispute.initiator_role === "publisher" ? `(${t('dispute.initiator')})` : `(${t('dispute.respondent')})`}</div>
125
  </div>
126
  </div>
127
  <div class="dispute-party">
128
- <img class="dispute-party-avatar" src="${dispute.assignee_avatar || (IS_WEB_MODE ? 'https://api.dicebear.com/7.x/avataaars/svg' : '/community_hub/image?url=https://api.dicebear.com/7.x/avataaars/svg')}" alt="">
129
  <div class="dispute-party-info">
130
- <div class="dispute-party-name">${dispute.assignee_name || dispute.assignee}</div>
131
  <div class="dispute-party-role">${t('task.assignee')} ${dispute.initiator_role === "assignee" ? `(${t('dispute.initiator')})` : `(${t('dispute.respondent')})`}</div>
132
  </div>
133
  </div>
@@ -137,7 +137,7 @@ function renderDisputeDetail(container, dispute, currentUser, onBack) {
137
  <div class="dispute-section">
138
  <div class="dispute-section-title"><span class="icon">📝</span> ${t('dispute.initiator_statement')}</div>
139
  <div class="dispute-content initiator">
140
- <div style="color: var(--input-text);">${dispute.reason || t('common.none')}</div>
141
  ${_renderEvidenceGallery(dispute.evidence)}
142
  <div class="dispute-time">${t('dispute.submitted_at')} ${formatTime(dispute.created_at)}</div>
143
  </div>
@@ -148,7 +148,7 @@ function renderDisputeDetail(container, dispute, currentUser, onBack) {
148
  <div class="dispute-section-title"><span class="icon">💬</span> ${t('dispute.respondent_response')}</div>
149
  ${dispute.response ? `
150
  <div class="dispute-content respondent">
151
- <div style="color: var(--input-text);">${dispute.response}</div>
152
  ${_renderEvidenceGallery(dispute.response_evidence)}
153
  <div class="dispute-time">${t('dispute.responded_at')} ${formatTime(dispute.responded_at)}</div>
154
  </div>
@@ -156,7 +156,7 @@ function renderDisputeDetail(container, dispute, currentUser, onBack) {
156
  <div style="color: #888; padding: 12px; text-align: center;">${t('dispute.no_response')}</div>
157
  ${canRespond ? `
158
  <div class="dispute-respond-form">
159
- <textarea id="respondText" placeholder="${t('dispute.response_placeholder')}"></textarea>
160
  <div class="dispute-upload-area" id="uploadArea">📷 ${t('dispute.click_upload_evidence_optional')}</div>
161
  <input type="file" id="fileInput" accept="image/*" multiple style="display: none;">
162
  <div class="dispute-uploaded-images" id="uploadedImages"></div>
@@ -174,7 +174,7 @@ function renderDisputeDetail(container, dispute, currentUser, onBack) {
174
  <div class="dispute-resolution-title">⚖️ ${t('dispute.resolution_result')}</div>
175
  <div class="dispute-resolution-result">${getResolutionText(dispute.resolution, dispute.resolution_ratio)}</div>
176
  ${dispute.resolution_note ? `
177
- <div class="dispute-resolution-note">${dispute.resolution_note}</div>
178
  ` : ""}
179
  <div class="dispute-time">${t('dispute.resolved_at')} ${formatTime(dispute.resolved_at)}</div>
180
  </div>
@@ -184,6 +184,13 @@ function renderDisputeDetail(container, dispute, currentUser, onBack) {
184
  // 返回按钮
185
  container.querySelector("#backBtn").onclick = () => onBack && onBack();
186
 
 
 
 
 
 
 
 
187
  // 回应表单逻辑
188
  if (canRespond) {
189
  const uploadedEvidence = [];
@@ -212,7 +219,7 @@ function renderDisputeDetail(container, dispute, currentUser, onBack) {
212
  function renderUploadedImages() {
213
  uploadedImages.innerHTML = uploadedEvidence.map((url, idx) => `
214
  <div class="img-wrapper">
215
- <img src="${url}">
216
  <button class="remove-btn" data-idx="${idx}">×</button>
217
  </div>
218
  `).join("");
@@ -266,6 +273,6 @@ function formatTime(timestamp) {
266
  function _renderEvidenceGallery(images) {
267
  if (!images || images.length === 0) return "";
268
  return `<div class="dispute-evidence">
269
- ${images.map(img => `<img src="${img}" onclick="window.open('${img}')">`).join("")}
270
  </div>`;
271
  }
 
8
  import { api } from "../core/网络请求API.js";
9
  import { showToast } from "../components/UI交互提示组件.js";
10
  import { t, getLanguage } from "../components/用户体验增强.js";
11
+ import { isAdmin, IS_WEB_MODE, escapeHtml } from "../core/全局配置.js";
12
 
13
  /**
14
  * 创建申诉详情视图
 
37
  const dispute = res.data;
38
  renderDisputeDetail(container, dispute, currentUser, onBack);
39
  } catch (err) {
40
+ container.innerHTML = `<div style="text-align: center; padding: 40px; color: #f44;">❌ ${escapeHtml(err.message)}</div>`;
41
  }
42
  }
43
 
 
110
  <!-- 任务信息 -->
111
  <div class="dispute-section">
112
  <div class="dispute-section-title"><span class="icon">📋</span> ${t('dispute.related_task')}</div>
113
+ <div style="color: var(--input-text); font-size: 15px;">${escapeHtml(dispute.task_title || t('common.unknown_task'))}</div>
114
  <div style="font-size: 12px; color: #888; margin-top: 4px;">${t('dispute.task_id')}: ${dispute.task_id}</div>
115
  </div>
116
 
 
118
  <div class="dispute-section">
119
  <div class="dispute-section-title"><span class="icon">👥</span> ${t('dispute.parties')}</div>
120
  <div class="dispute-party">
121
+ <img class="dispute-party-avatar" src="${escapeHtml(dispute.publisher_avatar || (IS_WEB_MODE ? 'https://api.dicebear.com/7.x/avataaars/svg' : '/community_hub/image?url=https://api.dicebear.com/7.x/avataaars/svg'))}" alt="">
122
  <div class="dispute-party-info">
123
+ <div class="dispute-party-name">${escapeHtml(dispute.publisher_name || dispute.publisher)}</div>
124
  <div class="dispute-party-role">${t('dispute.publisher')} ${dispute.initiator_role === "publisher" ? `(${t('dispute.initiator')})` : `(${t('dispute.respondent')})`}</div>
125
  </div>
126
  </div>
127
  <div class="dispute-party">
128
+ <img class="dispute-party-avatar" src="${escapeHtml(dispute.assignee_avatar || (IS_WEB_MODE ? 'https://api.dicebear.com/7.x/avataaars/svg' : '/community_hub/image?url=https://api.dicebear.com/7.x/avataaars/svg'))}" alt="">
129
  <div class="dispute-party-info">
130
+ <div class="dispute-party-name">${escapeHtml(dispute.assignee_name || dispute.assignee)}</div>
131
  <div class="dispute-party-role">${t('task.assignee')} ${dispute.initiator_role === "assignee" ? `(${t('dispute.initiator')})` : `(${t('dispute.respondent')})`}</div>
132
  </div>
133
  </div>
 
137
  <div class="dispute-section">
138
  <div class="dispute-section-title"><span class="icon">📝</span> ${t('dispute.initiator_statement')}</div>
139
  <div class="dispute-content initiator">
140
+ <div style="color: var(--input-text);">${escapeHtml(dispute.reason || t('common.none'))}</div>
141
  ${_renderEvidenceGallery(dispute.evidence)}
142
  <div class="dispute-time">${t('dispute.submitted_at')} ${formatTime(dispute.created_at)}</div>
143
  </div>
 
148
  <div class="dispute-section-title"><span class="icon">💬</span> ${t('dispute.respondent_response')}</div>
149
  ${dispute.response ? `
150
  <div class="dispute-content respondent">
151
+ <div style="color: var(--input-text);">${escapeHtml(dispute.response)}</div>
152
  ${_renderEvidenceGallery(dispute.response_evidence)}
153
  <div class="dispute-time">${t('dispute.responded_at')} ${formatTime(dispute.responded_at)}</div>
154
  </div>
 
156
  <div style="color: #888; padding: 12px; text-align: center;">${t('dispute.no_response')}</div>
157
  ${canRespond ? `
158
  <div class="dispute-respond-form">
159
+ <textarea id="respondText" placeholder="${t('dispute.respond_placeholder')}"></textarea>
160
  <div class="dispute-upload-area" id="uploadArea">📷 ${t('dispute.click_upload_evidence_optional')}</div>
161
  <input type="file" id="fileInput" accept="image/*" multiple style="display: none;">
162
  <div class="dispute-uploaded-images" id="uploadedImages"></div>
 
174
  <div class="dispute-resolution-title">⚖️ ${t('dispute.resolution_result')}</div>
175
  <div class="dispute-resolution-result">${getResolutionText(dispute.resolution, dispute.resolution_ratio)}</div>
176
  ${dispute.resolution_note ? `
177
+ <div class="dispute-resolution-note">${escapeHtml(dispute.resolution_note)}</div>
178
  ` : ""}
179
  <div class="dispute-time">${t('dispute.resolved_at')} ${formatTime(dispute.resolved_at)}</div>
180
  </div>
 
184
  // 返回按钮
185
  container.querySelector("#backBtn").onclick = () => onBack && onBack();
186
 
187
+ // 🔒 XSS防护:证据图片的点击放大改为 DOM 事件绑定
188
+ // (原内联 onclick="window.open('${img}')" 中的引号即使转义,也会被浏览器先还原
189
+ // 再交给 JS 解析,依然能被注入;data-full 经 HTML 解码后仍是原始 URL,行为不变)
190
+ container.querySelectorAll(".dispute-evidence-img").forEach(imgEl => {
191
+ imgEl.onclick = () => window.open(imgEl.dataset.full, '_blank');
192
+ });
193
+
194
  // 回应表单逻辑
195
  if (canRespond) {
196
  const uploadedEvidence = [];
 
219
  function renderUploadedImages() {
220
  uploadedImages.innerHTML = uploadedEvidence.map((url, idx) => `
221
  <div class="img-wrapper">
222
+ <img src="${escapeHtml(url)}">
223
  <button class="remove-btn" data-idx="${idx}">×</button>
224
  </div>
225
  `).join("");
 
273
  function _renderEvidenceGallery(images) {
274
  if (!images || images.length === 0) return "";
275
  return `<div class="dispute-evidence">
276
+ ${images.map(img => `<img class="dispute-evidence-img" src="${escapeHtml(img)}" data-full="${escapeHtml(img)}">`).join("")}
277
  </div>`;
278
  }