ZHIWEI666 commited on
Commit
7d3cbe2
·
verified ·
1 Parent(s): 9bc3427

Upload 101 files

Browse files
前端页面/components/图片展示测试.js CHANGED
@@ -6,7 +6,7 @@
6
  // 接口:openImageViewer(imageUrls, startIndex, options)
7
  // ==========================================
8
 
9
- import { escapeHtml, API, IS_WEB_MODE } from "../core/全局配置.js";
10
  import { t } from "./用户体验增强.js";
11
 
12
  // ==========================================
@@ -401,8 +401,10 @@ function _calcGridLayout(displayWidth, displayHeight, maxGridSize) {
401
  class CyberImageViewer {
402
  constructor(imageUrls, startIndex = 0) {
403
  // 按运行模式处理图片地址
404
- this.imageUrls = imageUrls.map(url => this._proxyImageUrl(url));
405
- this.currentIndex = Math.max(0, Math.min(startIndex, this.imageUrls.length - 1));
 
 
406
  this.preloadCache = new Map();
407
  this._preloadRequests = new Map();
408
  this._imageReads = new Set();
@@ -558,21 +560,10 @@ class CyberImageViewer {
558
  * 自动识别代理URL、data: URL、blob: URL和普通HTTP(S) URL
559
  */
560
  _proxyImageUrl(url) {
561
- if (typeof url !== 'string' || !url) return '';
562
- const prefix = API.LOCAL_IMAGE_PROXY_PREFIX;
563
- if (IS_WEB_MODE) {
564
- let original = url;
565
- for (let depth = 0; depth < 10 && original.startsWith(prefix); depth++) {
566
- let encoded = original.slice(prefix.length);
567
- if (encoded.endsWith(API.COMPLETE_IMAGE_QUERY_SUFFIX)) {
568
- encoded = encoded.slice(0, -API.COMPLETE_IMAGE_QUERY_SUFFIX.length);
569
- }
570
- try { original = decodeURIComponent(encoded); } catch (_) { break; }
571
- }
572
- return original;
573
- }
574
- if (url.startsWith('data:') || url.startsWith('blob:') || url.startsWith(prefix)) return url;
575
- return url.startsWith('http') ? prefix + encodeURIComponent(url) : url;
576
  }
577
 
578
  /**
@@ -582,6 +573,7 @@ class CyberImageViewer {
582
  * @param {boolean} options.opaque - 是否不透明背景,默认true
583
  */
584
  create(options = {}) {
 
585
  const { background = '#0a0a0a', opaque = true } = options;
586
 
587
  // 注入样式
@@ -1791,7 +1783,7 @@ class CyberImageViewer {
1791
  * @returns {Function} 关闭播放器的函数
1792
  */
1793
  export function openImageViewer(imageUrls, startIndex = 0, options = {}) {
1794
- if (!imageUrls || imageUrls.length === 0) {
1795
  console.warn('图片数组为空,无法打开查看器');
1796
  return () => {};
1797
  }
@@ -1800,6 +1792,7 @@ export function openImageViewer(imageUrls, startIndex = 0, options = {}) {
1800
 
1801
  const viewer = new CyberImageViewer(imageUrls, startIndex);
1802
  const container = viewer.create(options);
 
1803
 
1804
  // 添加到body
1805
  document.body.appendChild(container);
 
6
  // 接口:openImageViewer(imageUrls, startIndex, options)
7
  // ==========================================
8
 
9
+ import { escapeHtml, API, IS_WEB_MODE, normalizeProfileImageUrl } from "../core/全局配置.js";
10
  import { t } from "./用户体验增强.js";
11
 
12
  // ==========================================
 
401
  class CyberImageViewer {
402
  constructor(imageUrls, startIndex = 0) {
403
  // 按运行模式处理图片地址
404
+ const sources = Array.isArray(imageUrls) ? imageUrls.map(url => this._proxyImageUrl(url)) : [];
405
+ const requestedIndex = Number.isInteger(startIndex) ? Math.max(0, Math.min(startIndex, sources.length - 1)) : 0;
406
+ this.imageUrls = sources.filter(Boolean);
407
+ this.currentIndex = Math.max(0, Math.min(sources.slice(0, requestedIndex).filter(Boolean).length, this.imageUrls.length - 1));
408
  this.preloadCache = new Map();
409
  this._preloadRequests = new Map();
410
  this._imageReads = new Set();
 
560
  * 自动识别代理URL、data: URL、blob: URL和普通HTTP(S) URL
561
  */
562
  _proxyImageUrl(url) {
563
+ const source = normalizeProfileImageUrl(url, IS_WEB_MODE);
564
+ if (!source) return '';
565
+ return !IS_WEB_MODE && /^https?:\/\//i.test(source)
566
+ ? API.LOCAL_IMAGE_PROXY_PREFIX + encodeURIComponent(source) : source;
 
 
 
 
 
 
 
 
 
 
 
567
  }
568
 
569
  /**
 
573
  * @param {boolean} options.opaque - 是否不透明背景,默认true
574
  */
575
  create(options = {}) {
576
+ if (!this.imageUrls.length) return null;
577
  const { background = '#0a0a0a', opaque = true } = options;
578
 
579
  // 注入样式
 
1783
  * @returns {Function} 关闭播放器的函数
1784
  */
1785
  export function openImageViewer(imageUrls, startIndex = 0, options = {}) {
1786
+ if (!Array.isArray(imageUrls) || imageUrls.length === 0) {
1787
  console.warn('图片数组为空,无法打开查看器');
1788
  return () => {};
1789
  }
 
1792
 
1793
  const viewer = new CyberImageViewer(imageUrls, startIndex);
1794
  const container = viewer.create(options);
1795
+ if (!container) return () => {};
1796
 
1797
  // 添加到body
1798
  document.body.appendChild(container);
前端页面/components/图片沙盒组件.js CHANGED
@@ -12,7 +12,7 @@
12
  // ==========================================
13
 
14
  import { t } from "./用户体验增强.js";
15
- import { escapeHtml, API, UI } from "../core/全局配置.js";
16
  import { openImageViewer } from "./图片展示测试.js";
17
 
18
  // 🔧 通用SVG占位图生成函数
@@ -71,9 +71,9 @@ export function getCoverSandboxHTML(imageSource, lazyLoad = true) {
71
  // 兼容:接受单个URL或数组
72
  let imageUrls = [];
73
  if (Array.isArray(imageSource)) {
74
- imageUrls = imageSource.filter(url => typeof url === 'string' && url.trim());
75
  } else if (typeof imageSource === 'string' && imageSource.trim()) {
76
- imageUrls = [imageSource];
77
  }
78
 
79
  if (imageUrls.length === 0) return '';
@@ -134,10 +134,17 @@ export function setupImageSandboxEvents(detailView) {
134
  let imageUrls;
135
  try { imageUrls = JSON.parse(viewport.dataset.images || '[]'); } catch (_) { imageUrls = []; }
136
  if (!Array.isArray(imageUrls)) imageUrls = [];
137
- imageUrls = imageUrls.filter(url => typeof url === 'string' && url.trim());
138
- if (!imageUrls.length) return;
139
  let currentIndex = Number.parseInt(viewport.dataset.current || '0', 10);
140
  if (!Number.isInteger(currentIndex) || currentIndex < 0 || currentIndex >= imageUrls.length) currentIndex = 0;
 
 
 
 
 
 
 
 
 
141
  let disposed = false, generation = 0, readController = null, closeViewer = null;
142
  let observer = null, wasMounted = false, fallbackNode = null, fallbackTimer = null;
143
  const stops = [];
 
12
  // ==========================================
13
 
14
  import { t } from "./用户体验增强.js";
15
+ import { escapeHtml, API, UI, IS_WEB_MODE, normalizeProfileImageUrl } from "../core/全局配置.js";
16
  import { openImageViewer } from "./图片展示测试.js";
17
 
18
  // 🔧 通用SVG占位图生成函数
 
71
  // 兼容:接受单个URL或数组
72
  let imageUrls = [];
73
  if (Array.isArray(imageSource)) {
74
+ imageUrls = imageSource.map(url => normalizeProfileImageUrl(url, IS_WEB_MODE)).filter(Boolean);
75
  } else if (typeof imageSource === 'string' && imageSource.trim()) {
76
+ imageUrls = [normalizeProfileImageUrl(imageSource, IS_WEB_MODE)].filter(Boolean);
77
  }
78
 
79
  if (imageUrls.length === 0) return '';
 
134
  let imageUrls;
135
  try { imageUrls = JSON.parse(viewport.dataset.images || '[]'); } catch (_) { imageUrls = []; }
136
  if (!Array.isArray(imageUrls)) imageUrls = [];
 
 
137
  let currentIndex = Number.parseInt(viewport.dataset.current || '0', 10);
138
  if (!Number.isInteger(currentIndex) || currentIndex < 0 || currentIndex >= imageUrls.length) currentIndex = 0;
139
+ const normalizedUrls = imageUrls.map(url => normalizeProfileImageUrl(url, IS_WEB_MODE));
140
+ imageUrls = normalizedUrls.filter(Boolean);
141
+ if (!imageUrls.length) {
142
+ targetImg.removeAttribute('src');
143
+ delete targetImg.dataset.src;
144
+ return;
145
+ }
146
+ currentIndex = Math.min(normalizedUrls.slice(0, currentIndex).filter(Boolean).length, imageUrls.length - 1);
147
+ viewport.dataset.images = JSON.stringify(imageUrls);
148
  let disposed = false, generation = 0, readController = null, closeViewer = null;
149
  let observer = null, wasMounted = false, fallbackNode = null, fallbackTimer = null;
150
  const stops = [];
前端页面/components/用户体验_国际化.js CHANGED
@@ -13,6 +13,36 @@ let pendingLanguage = null;
13
 
14
  const translations = {
15
  'zh-CN': {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  // 通用
17
  'common.confirm': '确认',
18
  'common.confirm_action': '操作确认',
@@ -1773,6 +1803,36 @@ const translations = {
1773
  },
1774
 
1775
  'en-US': {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1776
  // Common
1777
  'common.confirm': 'Confirm',
1778
  'common.confirm_action': 'Confirm action',
 
13
 
14
  const translations = {
15
  'zh-CN': {
16
+ 'skill.cloud_update_required': "Skill 文件检查尚未就绪,请联系管理员更新服务后重试。",
17
+ 'skill.directory_title': "选择 Skill 安装目录",
18
+ 'skill.directory_hint': "请选择接收插件的 skills 目录;也可以输入本机完整目录地址。",
19
+ 'skill.directory_path': "接收目录",
20
+ 'skill.directory_open': "打开目录",
21
+ 'skill.directory_parent': "上一级",
22
+ 'skill.directory_select': "使用此目录",
23
+ 'skill.directory_folders': "子文件夹",
24
+ 'skill.directory_verify': "目录地址已修改,请先打开核对。",
25
+ 'skill.directory_truncated': "仅显示部分子文件夹,可直接输入完整目录地址。",
26
+ 'skill.directory_ready': "核对当前目录后,点击“使用此目录”。",
27
+ 'skill.local_update_required': "本地插件尚未支持目录选择,请更新并重启 ComfyUI。",
28
+ 'skill.invalid_package': "未找到有效的 Skill 文件,请发布者检查资源内容。",
29
+ 'skill.plan_invalid': "安装准备已失效,请重新选择目录。",
30
+ 'skill.identity_changed': "登录状态或资源权限已变化,请重新确认后安装。",
31
+ 'skill.source_changed': "资源地址已变化,请重新准备安装。",
32
+ 'skill.target_changed': "接收目录或文件发生变化,请先检查目录,再重新准备。",
33
+ 'skill.source_unavailable': "暂时无法核实资源来源,请稍后重试。",
34
+ 'skill.directory_too_large': "目录内容过多,请输入更具体的接收目录。",
35
+ 'skill.preview_target': "将安装到:{path}",
36
+ 'skill.preview_hint': "以下文件夹和共用文件将写入所选目录;已有技能会被替换,共用目录保留其它文件。请确认后继续。",
37
+ 'skill.preview_replace': "更新已有内容",
38
+ 'skill.preview_new': "新增",
39
+ 'skill.preview_skill': "技能",
40
+ 'skill.preview_shared': "共用文件",
41
+ 'skill.preparing': "正在准备 Skill 文件…",
42
+ 'skill.prepared': "文件已准备,请核对安装内容。",
43
+ 'skill.cancelled': "已取消;接收目录未写入。",
44
+ 'skill.outcome_unknown': "安装结果暂时无法确认。请先检查所选接收目录,勿重复提交。",
45
+
46
  // 通用
47
  'common.confirm': '确认',
48
  'common.confirm_action': '操作确认',
 
1803
  },
1804
 
1805
  'en-US': {
1806
+ 'skill.cloud_update_required': "Skill file checks are not ready. Ask the administrator to update the service before trying again.",
1807
+ 'skill.directory_title': "Choose Skill installation folder",
1808
+ 'skill.directory_hint': "Choose the receiving plugin’s skills folder, or enter a full local folder path.",
1809
+ 'skill.directory_path': "Destination folder",
1810
+ 'skill.directory_open': "Open folder",
1811
+ 'skill.directory_parent': "Parent folder",
1812
+ 'skill.directory_select': "Use this folder",
1813
+ 'skill.directory_folders': "Subfolders",
1814
+ 'skill.directory_verify': "The path changed. Open it to verify the folder.",
1815
+ 'skill.directory_truncated': "Only some subfolders are shown. You can enter the full folder path.",
1816
+ 'skill.directory_ready': "Check the current folder, then select “Use this folder”.",
1817
+ 'skill.local_update_required': "Update the local plugin and restart ComfyUI to enable folder selection.",
1818
+ 'skill.invalid_package': "No valid Skill files were found. Ask the publisher to check the resource.",
1819
+ 'skill.plan_invalid': "The installation preparation is no longer valid. Choose the folder again.",
1820
+ 'skill.identity_changed': "Your login or resource permissions changed. Check them before installing.",
1821
+ 'skill.source_changed': "The resource address changed. Prepare the installation again.",
1822
+ 'skill.target_changed': "The destination or its files changed. Check the folder before preparing again.",
1823
+ 'skill.source_unavailable': "The resource source could not be verified. Try again later.",
1824
+ 'skill.directory_too_large': "This folder contains too many entries. Enter a more specific destination.",
1825
+ 'skill.preview_target': "Install to: {path}",
1826
+ 'skill.preview_hint': "These folders and shared files will be written to the selected folder. Existing skills will be replaced; other files in shared folders will be preserved. Confirm to continue.",
1827
+ 'skill.preview_replace': "Update existing content",
1828
+ 'skill.preview_new': "Add",
1829
+ 'skill.preview_skill': "Skill",
1830
+ 'skill.preview_shared': "Shared files",
1831
+ 'skill.preparing': "Preparing Skill files…",
1832
+ 'skill.prepared': "Files are ready. Review the installation contents.",
1833
+ 'skill.cancelled': "Cancelled; the destination was not changed.",
1834
+ 'skill.outcome_unknown': "The installation outcome is unconfirmed. Check the selected destination before submitting again.",
1835
+
1836
  // Common
1837
  'common.confirm': 'Confirm',
1838
  'common.confirm_action': 'Confirm action',
前端页面/components/缓存管理请求.js CHANGED
@@ -1,36 +1,7 @@
1
  // 本机缓存运维请求:单次提交、有界响应;不参与云端冷却或榜单缓存。
2
  import { API } from '../core/全局配置.js';
3
 
4
- async function readSmallJSON(response, controller) {
5
- const declared = response.headers.get('Content-Length');
6
- if (declared !== null && (!/^\d+$/.test(declared) || Number(declared) > API.LOCAL_CACHE_RESPONSE_MAX_BYTES)) {
7
- throw new Error('cache_response_invalid');
8
- }
9
- const reader = response.body?.getReader();
10
- if (!reader) throw new Error('cache_response_invalid');
11
- const chunks = [];
12
- let size = 0, complete = false;
13
- try {
14
- while (true) {
15
- const chunk = await reader.read();
16
- if (chunk.done) { complete = true; break; }
17
- if (!chunk.value.byteLength) continue;
18
- size += chunk.value.byteLength;
19
- if (size > API.LOCAL_CACHE_RESPONSE_MAX_BYTES) throw new Error('cache_response_invalid');
20
- chunks.push(chunk.value);
21
- }
22
- const body = new Uint8Array(size);
23
- let offset = 0;
24
- for (const chunk of chunks) { body.set(chunk, offset); offset += chunk.byteLength; }
25
- return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body));
26
- } finally {
27
- if (!complete) {
28
- controller.abort();
29
- try { await reader.cancel(); } catch {}
30
- }
31
- reader.releaseLock();
32
- }
33
- }
34
 
35
  async function requestCache(action, { signal } = {}) {
36
  const controller = new AbortController();
@@ -47,7 +18,7 @@ async function requestCache(action, { signal } = {}) {
47
  cache: 'no-store', signal: controller.signal,
48
  });
49
  if (!response.ok) throw new Error('cache_operation_unconfirmed');
50
- const data = await readSmallJSON(response, controller);
51
  const fields = action === 'stats' ? ['image_count', 'image_size', 'video_count', 'video_size'] : ['cleared_count', 'freed_size'];
52
  if (!data || typeof data !== 'object' || Array.isArray(data)
53
  || fields.some(key => !Number.isSafeInteger(data[key]) || data[key] < 0)) {
 
1
  // 本机缓存运维请求:单次提交、有界响应;不参与云端冷却或榜单缓存。
2
  import { API } from '../core/全局配置.js';
3
 
4
+ import { readLocalJSON } from '../core/本机请求读取.js';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  async function requestCache(action, { signal } = {}) {
7
  const controller = new AbortController();
 
18
  cache: 'no-store', signal: controller.signal,
19
  });
20
  if (!response.ok) throw new Error('cache_operation_unconfirmed');
21
+ const data = await readLocalJSON(response, controller, API.LOCAL_CACHE_RESPONSE_MAX_BYTES);
22
  const fields = action === 'stats' ? ['image_count', 'image_size', 'video_count', 'video_size'] : ['cleared_count', 'freed_size'];
23
  if (!data || typeof data !== 'object' || Array.isArray(data)
24
  || fields.some(key => !Number.isSafeInteger(data[key]) || data[key] < 0)) {
前端页面/core/全局配置.js CHANGED
@@ -104,6 +104,18 @@ export const API = {
104
  };
105
 
106
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  export const CHART_LIFECYCLE = Object.freeze({
108
  WIDTH_WAIT_TIMEOUT: 5000,
109
  RESIZE_DELAY: 300
@@ -597,7 +609,7 @@ export function escapeHtml(str) {
597
  }
598
 
599
  /** 资料图片只接受明确的图片表示或网络地址,不相对宿主解析业务路径。 */
600
- export function normalizeProfileImageUrl(value, unwrapProxy = false) {
601
  if (typeof value !== 'string' || !value || value.trim() !== value || /[\u0000-\u001f\u007f]/.test(value)) return '';
602
  let source = value;
603
  let layers = 0;
@@ -627,7 +639,7 @@ export function normalizeProfileImageUrl(value, unwrapProxy = false) {
627
  const host = url.hostname.toLowerCase().replace(/\.$/, '');
628
  if (/^\d+\.\d+\.\d+\.\d+$/.test(host)
629
  && authority.replace(/:[0-9]+$/, '').replace(/\.$/, '').toLowerCase() !== host) return '';
630
- const base = new URL(API.BASE_URL);
631
  const baseHost = base.hostname.toLowerCase().replace(/\.$/, '');
632
  if (host === baseHost) {
633
  const expectedPath = base.pathname.replace(/\/$/, '') + API.IMAGE_PROXY_PATH;
 
104
  };
105
 
106
 
107
+ export const SKILL_INSTALL = Object.freeze({
108
+ PATH: '/community_hub/skill_install',
109
+ PREFLIGHT_TIMEOUT: 30000,
110
+ CONTROL_TIMEOUT: 45000,
111
+ STREAM_TIMEOUT: 1200000,
112
+ RESPONSE_MAX_BYTES: 1024 * 1024,
113
+ PATH_MAX_BYTES: 32768,
114
+ MAX_DIRECTORIES: 128,
115
+ MAX_ITEMS: 1000,
116
+ MODAL_WIDTH: '640px'
117
+ });
118
+
119
  export const CHART_LIFECYCLE = Object.freeze({
120
  WIDTH_WAIT_TIMEOUT: 5000,
121
  RESIZE_DELAY: 300
 
609
  }
610
 
611
  /** 资料图片只接受明确的图片表示或网络地址,不相对宿主解析业务路径。 */
612
+ export function normalizeProfileImageUrl(value, unwrapProxy = false, serviceBaseUrl = API.BASE_URL) {
613
  if (typeof value !== 'string' || !value || value.trim() !== value || /[\u0000-\u001f\u007f]/.test(value)) return '';
614
  let source = value;
615
  let layers = 0;
 
639
  const host = url.hostname.toLowerCase().replace(/\.$/, '');
640
  if (/^\d+\.\d+\.\d+\.\d+$/.test(host)
641
  && authority.replace(/:[0-9]+$/, '').replace(/\.$/, '').toLowerCase() !== host) return '';
642
+ const base = new URL(serviceBaseUrl);
643
  const baseHost = base.hostname.toLowerCase().replace(/\.$/, '');
644
  if (host === baseHost) {
645
  const expectedPath = base.pathname.replace(/\/$/, '') + API.IMAGE_PROXY_PATH;
前端页面/core/本机请求读取.js ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export async function readLocalJSON(response, controller, maxBytes) {
2
+ const declared = response.headers.get('Content-Length');
3
+ if (declared !== null && (!/^\d+$/.test(declared) || Number(declared) > maxBytes)) {
4
+ throw new Error('local_response_invalid');
5
+ }
6
+ const reader = response.body?.getReader();
7
+ if (!reader) throw new Error('local_response_invalid');
8
+ const chunks = [];
9
+ let size = 0, complete = false;
10
+ try {
11
+ while (true) {
12
+ const chunk = await reader.read();
13
+ if (chunk.done) { complete = true; break; }
14
+ if (!chunk.value.byteLength) continue;
15
+ size += chunk.value.byteLength;
16
+ if (size > maxBytes) throw new Error('local_response_invalid');
17
+ chunks.push(chunk.value);
18
+ }
19
+ const body = new Uint8Array(size);
20
+ let offset = 0;
21
+ for (const chunk of chunks) { body.set(chunk, offset); offset += chunk.byteLength; }
22
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body));
23
+ } finally {
24
+ if (!complete) {
25
+ controller.abort();
26
+ try { await reader.cancel(); } catch {}
27
+ }
28
+ reader.releaseLock();
29
+ }
30
+ }
前端页面/market/Skill安装流程.js ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { SKILL_INSTALL, escapeHtml } from '../core/全局配置.js';
2
+ import { readLocalJSON } from '../core/本机请求读取.js';
3
+ import { requestSSE } from '../core/网络请求_基础设施.js';
4
+ import { t } from '../components/用户体验_国际化.js';
5
+ import { globalModal } from '../components/全局弹窗管理器.js';
6
+
7
+ const object = value => value && typeof value === 'object' && !Array.isArray(value);
8
+ const pathText = value => typeof value === 'string' && value.length > 0
9
+ && !/[\u0000-\u001f\u007f]/u.test(value)
10
+ && new TextEncoder().encode(value).length <= SKILL_INSTALL.PATH_MAX_BYTES;
11
+ const planId = value => typeof value === 'string' && /^[a-f0-9]{32}$/.test(value);
12
+ const versionId = value => typeof value === 'string' && /^[a-f0-9]{40}$/.test(value);
13
+ const count = value => Number.isSafeInteger(value) && value >= 0;
14
+
15
+ function failure(code) {
16
+ const error = new Error('skill_operation_failed');
17
+ error.code = code;
18
+ return error;
19
+ }
20
+
21
+ export function skillErrorMessage(error) {
22
+ const errorKeys = {
23
+ local_update_required: 'skill.local_update_required',
24
+ invalid_target: 'install.error_invalid_target',
25
+ invalid_repository: 'install.error_invalid_repository',
26
+ invalid_skill_package: 'skill.invalid_package',
27
+ invalid_plan: 'skill.plan_invalid',
28
+ plan_expired: 'skill.plan_invalid',
29
+ plan_owner_mismatch: 'skill.identity_changed',
30
+ auth_failed: 'skill.identity_changed',
31
+ source_changed: 'skill.source_changed',
32
+ target_changed: 'skill.target_changed',
33
+ target_conflict: 'skill.target_changed',
34
+ recovery_conflict: 'skill.target_changed',
35
+ source_unavailable: 'skill.source_unavailable',
36
+ invalid_metadata: 'skill.source_unavailable',
37
+ directory_too_large: 'skill.directory_too_large',
38
+ package_too_large: 'install.error_package_too_large',
39
+ insufficient_disk_space: 'install.error_disk_space',
40
+ permission_denied: 'install.error_permission_denied',
41
+ install_queue_full: 'install.error_queue_full',
42
+ install_queue_timeout: 'install.error_queue_timeout',
43
+ };
44
+ const errorKey = Object.hasOwn(errorKeys, error?.code) ? errorKeys[error.code] : 'install.error_generic';
45
+ return t(errorKey);
46
+ }
47
+
48
+ async function control(action, body, signal) {
49
+ const controller = new AbortController();
50
+ const abort = () => controller.abort();
51
+ signal?.addEventListener('abort', abort, { once: true });
52
+ if (signal?.aborted) controller.abort();
53
+ const timer = setTimeout(abort, SKILL_INSTALL.CONTROL_TIMEOUT);
54
+ try {
55
+ if (controller.signal.aborted) throw failure('operation_cancelled');
56
+ const response = await fetch(SKILL_INSTALL.PATH, {
57
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
58
+ body: JSON.stringify({ ...body, action }), cache: 'no-store', signal: controller.signal,
59
+ });
60
+ if (response.status === 404 || response.status === 405) throw failure('local_update_required');
61
+ const value = await readLocalJSON(response, controller, SKILL_INSTALL.RESPONSE_MAX_BYTES);
62
+ if (!response.ok || !object(value) || value.status !== 'success' || value.installed !== false
63
+ || Object.hasOwn(value, 'error') || Object.hasOwn(value, 'error_code')) {
64
+ throw failure(object(value) && typeof value.error_code === 'string' ? value.error_code : 'invalid_metadata');
65
+ }
66
+ return value;
67
+ } finally {
68
+ clearTimeout(timer); signal?.removeEventListener('abort', abort); controller.abort();
69
+ }
70
+ }
71
+
72
+ function browseResult(value) {
73
+ return value.phase === 'browse' && pathText(value.path) && pathText(value.parent)
74
+ && typeof value.selectable === 'boolean' && typeof value.truncated === 'boolean'
75
+ && Array.isArray(value.directories) && value.directories.length <= SKILL_INSTALL.MAX_DIRECTORIES
76
+ && value.directories.every(row => object(row) && pathText(row.name) && pathText(row.path));
77
+ }
78
+
79
+ export function chooseSkillDirectory(scope) {
80
+ if (!scope.isCurrent()) return Promise.resolve(null);
81
+ return new Promise(resolve => {
82
+ const content = document.createElement('div'); content.className = 'skill-directory';
83
+ const hint = document.createElement('p'); hint.textContent = t('skill.directory_hint');
84
+ const label = document.createElement('label'); label.className = 'skill-directory__label';
85
+ const title = document.createElement('span'); title.textContent = t('skill.directory_path');
86
+ const input = document.createElement('input'); input.type = 'text'; input.autocomplete = 'off'; input.spellcheck = false;
87
+ label.append(title, input);
88
+ const actions = document.createElement('div'); actions.className = 'skill-directory__actions';
89
+ const button = (label, callback) => {
90
+ const node = document.createElement('button'); node.type = 'button'; node.textContent = label;
91
+ node.addEventListener('click', callback); return node;
92
+ };
93
+ const notice = document.createElement('p'); notice.setAttribute('role', 'status'); notice.setAttribute('aria-live', 'polite');
94
+ const list = document.createElement('ul'); list.className = 'skill-directory__list'; list.setAttribute('aria-label', t('skill.directory_folders'));
95
+ let closed = false, generation = 0, selected = null, controller, modal;
96
+ let releaseScope = () => {};
97
+ const current = () => !closed && scope.isCurrent();
98
+ const finish = value => {
99
+ if (closed) return;
100
+ closed = true; generation++; controller?.abort(); releaseScope();
101
+ resolve(value); if (modal) globalModal.closeModal(modal);
102
+ };
103
+ content._cleanup = () => finish(null);
104
+ const open = button(t('skill.directory_open'), () => browse(input.value));
105
+ const parent = button(t('skill.directory_parent'), () => selected && browse(selected.parent));
106
+ const select = button(t('skill.directory_select'), () => {
107
+ if (current() && selected?.selectable && input.value === selected.path) finish(selected.path);
108
+ });
109
+ select.disabled = parent.disabled = true;
110
+ const cancel = button(t('common.cancel'), () => finish(null));
111
+ actions.append(open, parent, select, cancel);
112
+ input.addEventListener('input', () => {
113
+ generation++; controller?.abort(); selected = null; select.disabled = parent.disabled = true;
114
+ content.removeAttribute('aria-busy'); list.replaceChildren(); notice.textContent = t('skill.directory_verify');
115
+ });
116
+ async function browse(path) {
117
+ if (!current()) return;
118
+ const own = ++generation; controller?.abort(); controller = new AbortController();
119
+ selected = null; select.disabled = parent.disabled = true;
120
+ content.setAttribute('aria-busy', 'true'); notice.textContent = t('common.loading'); list.replaceChildren();
121
+ try {
122
+ const value = await control('browse', path === undefined ? {} : { path }, controller.signal);
123
+ if (!current() || own !== generation) return;
124
+ if (!browseResult(value)) throw failure('invalid_metadata');
125
+ selected = value; input.value = value.path;
126
+ select.disabled = !value.selectable; parent.disabled = value.parent === value.path;
127
+ for (const row of value.directories) {
128
+ const item = document.createElement('li');
129
+ const folder = document.createElement('button'); folder.type = 'button'; folder.textContent = row.name;
130
+ folder.addEventListener('click', () => { if (current() && own === generation) browse(row.path); });
131
+ item.append(folder); list.append(item);
132
+ }
133
+ notice.textContent = t(value.truncated ? 'skill.directory_truncated' : 'skill.directory_ready');
134
+ } catch (error) {
135
+ if (current() && own === generation) notice.textContent = skillErrorMessage(error);
136
+ } finally {
137
+ if (current() && own === generation) content.removeAttribute('aria-busy');
138
+ }
139
+ }
140
+ content.append(hint, label, actions, notice, list);
141
+ modal = globalModal.openModal(t('skill.directory_title'), content, { width: SKILL_INSTALL.MODAL_WIDTH });
142
+ releaseScope = scope.addCleanup(() => finish(null));
143
+ if (!current()) { finish(null); return; }
144
+ input.focus(); browse();
145
+ });
146
+ }
147
+
148
+ function validReady(result, plan, target) {
149
+ return object(result) && result.status === 'success' && result.phase === 'ready'
150
+ && result.installed === false && (!Object.hasOwn(result, 'outcomeUnknown') || result.outcomeUnknown === false)
151
+ && !Object.hasOwn(result, 'error') && !Object.hasOwn(result, 'error_code') && versionId(result.installed_version)
152
+ && object(result.preview) && result.preview.id === plan && pathText(result.preview.target) && result.preview.target === target
153
+ && Array.isArray(result.preview.items) && result.preview.items.length > 0
154
+ && result.preview.items.length <= SKILL_INSTALL.MAX_ITEMS
155
+ && result.preview.items.every(row => object(row) && pathText(row.name)
156
+ && !/[\\/]/u.test(row.name) && typeof row.skill === 'boolean' && typeof row.exists === 'boolean'
157
+ && count(row.files) && count(row.bytes));
158
+ }
159
+
160
+ export async function runSkillInstall({ target, credentials, scope, confirm, onProgress, signal = scope.confirmationSignal }) {
161
+ let plan, committing = false;
162
+ try {
163
+ if (!scope.isCurrent() || signal.aborted) return { status: 'cancelled' };
164
+ const selected = await control('select', { ...credentials, target }, signal);
165
+ if (!planId(selected.plan_id) || selected.phase !== 'selected' || selected.target !== target
166
+ || !count(selected.expires_at)) throw failure('invalid_metadata');
167
+ plan = selected.plan_id;
168
+ if (!scope.isCurrent() || signal.aborted) return { status: 'cancelled' };
169
+ const parameters = { ...credentials, plan_id: plan };
170
+ const stream = action => requestSSE(SKILL_INSTALL.PATH, { ...parameters, action },
171
+ event => { if (scope.isCurrent()) onProgress?.({ ...event, skillAction: action }); },
172
+ { signal, timeout: SKILL_INSTALL.STREAM_TIMEOUT });
173
+ const ready = await stream('prepare');
174
+ if (!scope.isCurrent() || signal.aborted) return { status: 'cancelled' };
175
+ if (!validReady(ready, plan, target)) throw failure(ready?.error_code || 'invalid_metadata');
176
+ const html = `<div class="skill-install-preview"><p>${escapeHtml(t('skill.preview_target', { path: ready.preview.target }))}</p>`
177
+ + `<p>${escapeHtml(t('skill.preview_hint'))}</p><ul>`
178
+ + ready.preview.items.map(row => `<li>${escapeHtml(row.name)} — ${escapeHtml(t(row.exists ? 'skill.preview_replace' : 'skill.preview_new'))}`
179
+ + ` · ${escapeHtml(t(row.skill ? 'skill.preview_skill' : 'skill.preview_shared'))}</li>`).join('') + '</ul></div>';
180
+ if (!await confirm(html, { html: true }) || !scope.isCurrent() || signal.aborted) return { status: 'cancelled' };
181
+ committing = true;
182
+ let result;
183
+ try { result = await stream('commit'); }
184
+ catch (error) {
185
+ if (error?.fallbackSafe) return { status: 'error', error_code: 'local_update_required', outcomeUnknown: false };
186
+ return { status: 'error', outcomeUnknown: true };
187
+ }
188
+ if (object(result) && result.status === 'success' && result.phase === 'complete'
189
+ && result.installed === true && (!Object.hasOwn(result, 'outcomeUnknown') || result.outcomeUnknown === false)
190
+ && result.installed_version === ready.installed_version
191
+ && !Object.hasOwn(result, 'error') && !Object.hasOwn(result, 'error_code')) return result;
192
+ if (object(result) && result.status === 'error' && result.outcomeUnknown === false
193
+ && typeof result.error_code === 'string') return result;
194
+ return { status: 'error', outcomeUnknown: true };
195
+ } catch (error) {
196
+ throw failure(error?.fallbackSafe ? 'local_update_required' : error?.code || 'install_failed');
197
+ } finally {
198
+ if (plan && !committing) {
199
+ try { await control('discard', { ...credentials, plan_id: plan }); } catch {}
200
+ }
201
+ }
202
+ }
前端页面/market/资源安装引擎.js CHANGED
@@ -3,7 +3,7 @@ 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, escapeHtml } from "../core/全局配置.js";
7
  import { t } from "../components/用户体验增强.js";
8
  import { createResourceOperationScope } from "./资源操作生命周期.js";
9
  import { isValidPurchaseStatus } from "./购买状态生命周期.js";
@@ -363,13 +363,16 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
363
  activeOperations.set(operationKey, scope);
364
  ++latestWalletRefresh; // 新操作使先前余额补新失效。
365
  phase = "preflight"; btnUse.disabled = true; btnUse.setAttribute("aria-busy", "true");
 
366
  try {
367
  const price = itemData.price ?? 0;
368
  if (!Number.isSafeInteger(price) || price < 0 || !itemData.id) {
369
  showToast(t('acquire.invalid_id'), "error"); return;
370
  }
371
  const isFree = price === 0 || account === itemData.author;
372
- const isTool = itemData.type === 'tool' || itemData.type === 'recommend_tool' || itemData.type === 'skill' || itemData.type === 'recommend_skill';
 
 
373
  const isApp = itemData.type === 'app' || itemData.type === 'recommend_app';
374
  if (IS_WEB_MODE && isFree) return showToast(t('web.install_on_desktop'), "warning");
375
 
@@ -390,6 +393,14 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
390
  }
391
  if (IS_WEB_MODE && alreadyOwned) return showToast(t('item.owned_purchased'), "warning");
392
 
 
 
 
 
 
 
 
 
393
  if (!isFree && !alreadyOwned) {
394
  let balance;
395
  try {
@@ -413,7 +424,8 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
413
  inlineStatusBox.textContent = t('acquire.validation_running');
414
  const validationController = new AbortController();
415
  const removeValidationCleanup = scope.addCleanup(() => validationController.abort());
416
- const timeout = setTimeout(() => validationController.abort(), API.BACKGROUND_TIMEOUT);
 
417
  try {
418
  const response = await fetch(`${API.BASE_URL}/api/validate_resource`, {
419
  method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
@@ -423,9 +435,17 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
423
  if (!isCurrent()) return;
424
  const data = await response.json();
425
  if (!isCurrent()) return;
 
 
 
 
426
  if (!response.ok || data?.status !== "success" || data.error) throw new Error();
 
 
 
 
427
  } catch (_) {
428
- if (isCurrent()) inlineStatusBox.textContent = t('acquire.validation_blocked', { message: t('common.network_error_retry') });
429
  return;
430
  } finally { clearTimeout(timeout); removeValidationCleanup(); }
431
  }
@@ -493,6 +513,55 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
493
  return; // ← 提前返回,不进入 Git 安装流程
494
  }
495
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
496
  if (isTool) {
497
  inlineStatusBox.style.display = "block";
498
  inlineStatusBox.textContent = t('install.preview_loading');
@@ -946,7 +1015,11 @@ export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatus
946
  }
947
  } catch (_) {
948
  if (isCurrent()) { inlineStatusBox.style.display = "block"; inlineStatusBox.textContent = t('common.error'); }
949
- } finally { if (phase === "preflight") phase = "idle"; releaseButton(); }
 
 
 
 
950
  };
951
  return scope;
952
  }
 
3
  import { api } from "../core/网络请求API.js";
4
  import { requestSSE, request } from "../core/网络请求_基础设施.js";
5
  import { openUserProfileModal } from "../profile/个人中心视图.js";
6
+ import { API, CACHE, SKILL_INSTALL, IS_WEB_MODE, escapeHtml } from "../core/全局配置.js";
7
  import { t } from "../components/用户体验增强.js";
8
  import { createResourceOperationScope } from "./资源操作生命周期.js";
9
  import { isValidPurchaseStatus } from "./购买状态生命周期.js";
 
363
  activeOperations.set(operationKey, scope);
364
  ++latestWalletRefresh; // 新操作使先前余额补新失效。
365
  phase = "preflight"; btnUse.disabled = true; btnUse.setAttribute("aria-busy", "true");
366
+ let restoreFocus = false;
367
  try {
368
  const price = itemData.price ?? 0;
369
  if (!Number.isSafeInteger(price) || price < 0 || !itemData.id) {
370
  showToast(t('acquire.invalid_id'), "error"); return;
371
  }
372
  const isFree = price === 0 || account === itemData.author;
373
+ const isTool = itemData.type === 'tool' || itemData.type === 'recommend_tool';
374
+ const isSkill = itemData.type === 'skill' || itemData.type === 'recommend_skill';
375
+ let skillFlow, skillTarget;
376
  const isApp = itemData.type === 'app' || itemData.type === 'recommend_app';
377
  if (IS_WEB_MODE && isFree) return showToast(t('web.install_on_desktop'), "warning");
378
 
 
393
  }
394
  if (IS_WEB_MODE && alreadyOwned) return showToast(t('item.owned_purchased'), "warning");
395
 
396
+ if (!IS_WEB_MODE && isSkill && itemData.is_netdisk !== true) {
397
+ skillFlow = await import('./Skill安装流程.js');
398
+ if (!isCurrent()) return;
399
+ skillTarget = await skillFlow.chooseSkillDirectory(scope);
400
+ if (!isCurrent()) return;
401
+ if (!skillTarget) { restoreFocus = true; return; }
402
+ }
403
+
404
  if (!isFree && !alreadyOwned) {
405
  let balance;
406
  try {
 
424
  inlineStatusBox.textContent = t('acquire.validation_running');
425
  const validationController = new AbortController();
426
  const removeValidationCleanup = scope.addCleanup(() => validationController.abort());
427
+ const timeout = setTimeout(() => validationController.abort(), isSkill ? SKILL_INSTALL.PREFLIGHT_TIMEOUT : API.BACKGROUND_TIMEOUT);
428
+ let validationMessage = t('common.network_error_retry');
429
  try {
430
  const response = await fetch(`${API.BASE_URL}/api/validate_resource`, {
431
  method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
 
435
  if (!isCurrent()) return;
436
  const data = await response.json();
437
  if (!isCurrent()) return;
438
+ if (isSkill && !response.ok) {
439
+ validationMessage = t(data?.error_code === 'invalid_skill_package' ? 'skill.invalid_package' : 'skill.source_unavailable');
440
+ throw new Error();
441
+ }
442
  if (!response.ok || data?.status !== "success" || data.error) throw new Error();
443
+ if (isSkill && data.skill_manifest_verified !== true) {
444
+ validationMessage = t('skill.cloud_update_required');
445
+ throw new Error();
446
+ }
447
  } catch (_) {
448
+ if (isCurrent()) inlineStatusBox.textContent = t('acquire.validation_blocked', { message: validationMessage });
449
  return;
450
  } finally { clearTimeout(timeout); removeValidationCleanup(); }
451
  }
 
513
  return; // ← 提前返回,不进入 Git 安装流程
514
  }
515
 
516
+ if (isSkill) {
517
+ if (!skillFlow) {
518
+ skillFlow = await import('./Skill安装流程.js');
519
+ if (!isCurrent()) return;
520
+ skillTarget = await skillFlow.chooseSkillDirectory(scope);
521
+ if (!isCurrent()) return;
522
+ if (!skillTarget) { restoreFocus = true; return; }
523
+ }
524
+ phase = 'install';
525
+ const progress = trackProgress();
526
+ const controller = new AbortController();
527
+ const removeCancel = scope.addCleanup(() => controller.abort());
528
+ progress.addCancelButton(t('install.cancel_in_progress'), () => controller.abort());
529
+ try {
530
+ const result = await skillFlow.runSkillInstall({
531
+ target: skillTarget, credentials: { account, token, item_id: String(itemData.id) }, scope,
532
+ signal: controller.signal,
533
+ confirm: (html, options) => showConfirm(html, { ...options, signal: controller.signal }),
534
+ onProgress: event => {
535
+ if (!isCurrent()) return;
536
+ const stageKey = Object.hasOwn(INSTALL_STAGE_KEYS, event.stage) ? INSTALL_STAGE_KEYS[event.stage] : 'skill.preparing';
537
+ progress.update(event.progress, event.skillAction === 'prepare' && event.stage === 'complete' ? t('skill.prepared') : t(stageKey));
538
+ }
539
+ });
540
+ if (!isCurrent()) return;
541
+ if (result.status === 'cancelled') { restoreFocus = true; progress.error(t('skill.cancelled')); return; }
542
+ if (result.outcomeUnknown) {
543
+ phase = 'unknown'; btnUse.removeAttribute('aria-busy');
544
+ progress.error(t('skill.outcome_unknown')); return;
545
+ }
546
+ if (result.status !== 'success' || result.installed !== true) {
547
+ progress.error(skillFlow.skillErrorMessage({ code: result.error_code })); return;
548
+ }
549
+ progress.complete(t('install.complete'));
550
+ _recordToolInstalledVersion(itemData.id, result);
551
+ saveAcquiredItem(itemData);
552
+ btnUse.textContent = `✅ ${t('common.installed')}`;
553
+ btnUse.setAttribute('data-install-state', 'success');
554
+ try { await api.recordItemUse(itemData.id); if (isCurrent()) clearUsesCache(); } catch {}
555
+ } catch (error) {
556
+ if (isCurrent()) progress.error(controller.signal.aborted ? t('skill.cancelled') : skillFlow.skillErrorMessage(error));
557
+ } finally {
558
+ removeCancel(); controller.abort();
559
+ if (phase === 'install') phase = 'idle';
560
+ releaseButton();
561
+ }
562
+ return;
563
+ }
564
+
565
  if (isTool) {
566
  inlineStatusBox.style.display = "block";
567
  inlineStatusBox.textContent = t('install.preview_loading');
 
1015
  }
1016
  } catch (_) {
1017
  if (isCurrent()) { inlineStatusBox.style.display = "block"; inlineStatusBox.textContent = t('common.error'); }
1018
+ } finally {
1019
+ if (phase === "preflight") phase = "idle";
1020
+ releaseButton();
1021
+ if (restoreFocus && phase === "idle" && isCurrent()) btnUse.focus();
1022
+ }
1023
  };
1024
  return scope;
1025
  }
前端页面/post/帖子详情组件.js CHANGED
@@ -16,7 +16,7 @@ import { getVideoPlayerHTML, setupVideoPlayerEvents, cleanupVideoPlayer } from "
16
  import { renderTipLevelHTML } from "../components/打赏等级工具.js";
17
  import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
18
  import { t } from "../components/用户体验增强.js";
19
- import { PLACEHOLDERS, getCachedProfile, getProfileWithSWR, IS_WEB_MODE, API, CACHE } from "../core/全局配置.js";
20
  import { removeCache, findInListCache } from "../components/性能优化工具.js";
21
  import { globalModal } from "../components/全局弹窗管理器.js";
22
  import { recordView, handleToggleLike, handleToggleFavorite, renderTipBoardHTML as renderCommonTipBoardHTML, escapeHtml, formatTime } from "../components/互动工具函数.js"; // 🧹 P2归一:局部 formatTime 已移除
@@ -47,6 +47,7 @@ function openOwnedModal(node, title, content, options) {
47
  * @param {string} [extraStyle] - 额外内联样式(img 时追加)
48
  */
49
  function _generateAvatarHtml(avatar, name, size, extraStyle = '') {
 
50
  const initial = (name || 'U')[0].toUpperCase();
51
  // 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
52
  if (avatar) {
@@ -64,11 +65,12 @@ function _generateAvatarHtml(avatar, name, size, extraStyle = '') {
64
  * @param {string} [extraStyle] - img 额外内联样式
65
  */
66
  function _updateAvatarElement(avatarEl, profile, size, extraStyle = '') {
67
- if (!avatarEl || !profile.avatar) return;
 
68
  if (avatarEl.tagName === 'IMG') {
69
- avatarEl.src = profile.avatar;
70
  } else {
71
- avatarEl.outerHTML = `<img alt="" class="swr-avatar post-detail-avatar ${extraStyle === 'border: 2px solid #444;' ? 'post-detail-avatar--border' : extraStyle === 'background: var(--comfy-input-bg);' ? 'post-detail-avatar--panel' : ''}" src="${escapeHtml(profile.avatar)}" style="width: ${size}px; height: ${size}px; ${['border: 2px solid #444;', 'background: var(--comfy-input-bg);'].includes(extraStyle) ? '' : extraStyle}">`;
72
  }
73
  }
74
 
@@ -307,7 +309,7 @@ async function loadPostDetail(container, postId, currentUser) {
307
 
308
  const account = post.author;
309
  const cached = getCachedProfile(account);
310
- const avatar = cached?.avatar || post.author_avatar || '';
311
  const name = cached?.name || post.author_name || account || '';
312
  const initial = (name || 'U')[0].toUpperCase();
313
 
@@ -569,7 +571,7 @@ async function loadComments(container, postId, currentUser) {
569
  // 🚀 SWR 头像渲染:评论作者头像
570
  commentsList.innerHTML = comments.map((c, idx) => {
571
  const cached = getCachedProfile(c.author);
572
- const avatar = cached?.avatar || c.author_avatar || '';
573
  const name = cached?.name || c.author_name || c.author || '';
574
 
575
  const avatarHtml = _generateAvatarHtml(avatar, name, 24, 'background: var(--comfy-input-bg);');
 
16
  import { renderTipLevelHTML } from "../components/打赏等级工具.js";
17
  import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
18
  import { t } from "../components/用户体验增强.js";
19
+ import { PLACEHOLDERS, getCachedProfile, getProfileWithSWR, normalizeProfileImageUrl, IS_WEB_MODE, API, CACHE } from "../core/全局配置.js";
20
  import { removeCache, findInListCache } from "../components/性能优化工具.js";
21
  import { globalModal } from "../components/全局弹窗管理器.js";
22
  import { recordView, handleToggleLike, handleToggleFavorite, renderTipBoardHTML as renderCommonTipBoardHTML, escapeHtml, formatTime } from "../components/互动工具函数.js"; // 🧹 P2归一:局部 formatTime 已移除
 
47
  * @param {string} [extraStyle] - 额外内联样式(img 时追加)
48
  */
49
  function _generateAvatarHtml(avatar, name, size, extraStyle = '') {
50
+ avatar = normalizeProfileImageUrl(avatar);
51
  const initial = (name || 'U')[0].toUpperCase();
52
  // 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
53
  if (avatar) {
 
65
  * @param {string} [extraStyle] - img 额外内联样式
66
  */
67
  function _updateAvatarElement(avatarEl, profile, size, extraStyle = '') {
68
+ const avatar = normalizeProfileImageUrl(profile?.avatar);
69
+ if (!avatarEl || !avatar) return;
70
  if (avatarEl.tagName === 'IMG') {
71
+ avatarEl.src = avatar;
72
  } else {
73
+ avatarEl.outerHTML = `<img alt="" class="swr-avatar post-detail-avatar ${extraStyle === 'border: 2px solid #444;' ? 'post-detail-avatar--border' : extraStyle === 'background: var(--comfy-input-bg);' ? 'post-detail-avatar--panel' : ''}" src="${escapeHtml(avatar)}" style="width: ${size}px; height: ${size}px; ${['border: 2px solid #444;', 'background: var(--comfy-input-bg);'].includes(extraStyle) ? '' : extraStyle}">`;
74
  }
75
  }
76
 
 
309
 
310
  const account = post.author;
311
  const cached = getCachedProfile(account);
312
+ const avatar = [cached?.avatar, post.author_avatar].map(value => normalizeProfileImageUrl(value)).find(Boolean) || '';
313
  const name = cached?.name || post.author_name || account || '';
314
  const initial = (name || 'U')[0].toUpperCase();
315
 
 
571
  // 🚀 SWR 头像渲染:评论作者头像
572
  commentsList.innerHTML = comments.map((c, idx) => {
573
  const cached = getCachedProfile(c.author);
574
+ const avatar = [cached?.avatar, c.author_avatar].map(value => normalizeProfileImageUrl(value)).find(Boolean) || '';
575
  const name = cached?.name || c.author_name || c.author || '';
576
 
577
  const avatarHtml = _generateAvatarHtml(avatar, name, 24, 'background: var(--comfy-input-bg);');
前端页面/post/讨论区组件.js CHANGED
@@ -21,7 +21,7 @@ import { showToast } from "../components/UI交互提示组件.js";
21
  import { readListCache, writeListCache, requireListResponseData, isEmptyListRefreshWithExistingData, createSkeleton, createPaginationLoader, lazyLoadImages, renderListLoadError, renderListSearchMessage } from "../components/性能优化工具.js";
22
  import { applyViewportAnimations, stopViewportAnimations } from "../components/动画音效引擎.js";
23
  import { t } from "../components/用户体验增强.js";
24
- import { API, CACHE, getCachedProfile, getProfileWithSWR } from "../core/全局配置.js";
25
  import { createRatingStars } from "../social/评论与互动组件.js";
26
  import { escapeHtml, formatTime, getCacheTTL } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
27
 
@@ -75,6 +75,7 @@ function _renderEmptyState(t) {
75
  * @param {string} name - 用户名(用于首字母回退)
76
  */
77
  function _generateAvatarHtml(avatar, name) {
 
78
  const initial = (name || 'U')[0].toUpperCase();
79
  if (avatar) {
80
  return `<img class="swr-avatar posts-card__avatar" alt="" src="${escapeHtml(avatar)}">`;
@@ -670,7 +671,7 @@ function createPostCard(post, currentUser = currentUserCache, isCurrent = () =>
670
 
671
  const account = post.author;
672
  const cached = initialAuthor;
673
- const avatar = [cached?.avatar, post.author_avatar].find(value => typeof value === 'string' && value) || '';
674
  const name = initialAuthorName;
675
  const initial = (name || 'U')[0].toUpperCase();
676
 
@@ -684,11 +685,12 @@ function createPostCard(post, currentUser = currentUserCache, isCurrent = () =>
684
  if (!isCurrent() || !card.isConnected) return;
685
  const avatarEl = authorContainer.querySelector('.swr-avatar');
686
  const nameEl = authorContainer.querySelector('.swr-name');
687
- if (avatarEl && typeof profile.avatar === 'string' && profile.avatar) {
 
688
  if (avatarEl.tagName === 'IMG') {
689
- if (avatarEl.getAttribute('src') !== profile.avatar) avatarEl.src = profile.avatar;
690
  } else {
691
- avatarEl.outerHTML = _generateAvatarHtml(profile.avatar, typeof profile.name === 'string' && profile.name ? profile.name : name);
692
  }
693
  }
694
  if (nameEl && typeof profile.name === 'string' && profile.name && nameEl.textContent !== profile.name) {
 
21
  import { readListCache, writeListCache, requireListResponseData, isEmptyListRefreshWithExistingData, createSkeleton, createPaginationLoader, lazyLoadImages, renderListLoadError, renderListSearchMessage } from "../components/性能优化工具.js";
22
  import { applyViewportAnimations, stopViewportAnimations } from "../components/动画音效引擎.js";
23
  import { t } from "../components/用户体验增强.js";
24
+ import { API, CACHE, getCachedProfile, getProfileWithSWR, normalizeProfileImageUrl } from "../core/全局配置.js";
25
  import { createRatingStars } from "../social/评论与互动组件.js";
26
  import { escapeHtml, formatTime, getCacheTTL } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
27
 
 
75
  * @param {string} name - 用户名(用于首字母回退)
76
  */
77
  function _generateAvatarHtml(avatar, name) {
78
+ avatar = normalizeProfileImageUrl(avatar);
79
  const initial = (name || 'U')[0].toUpperCase();
80
  if (avatar) {
81
  return `<img class="swr-avatar posts-card__avatar" alt="" src="${escapeHtml(avatar)}">`;
 
671
 
672
  const account = post.author;
673
  const cached = initialAuthor;
674
+ const avatar = [cached?.avatar, post.author_avatar].map(value => normalizeProfileImageUrl(value)).find(Boolean) || '';
675
  const name = initialAuthorName;
676
  const initial = (name || 'U')[0].toUpperCase();
677
 
 
685
  if (!isCurrent() || !card.isConnected) return;
686
  const avatarEl = authorContainer.querySelector('.swr-avatar');
687
  const nameEl = authorContainer.querySelector('.swr-name');
688
+ const avatar = normalizeProfileImageUrl(profile?.avatar);
689
+ if (avatarEl && avatar) {
690
  if (avatarEl.tagName === 'IMG') {
691
+ if (avatarEl.getAttribute('src') !== avatar) avatarEl.src = avatar;
692
  } else {
693
+ avatarEl.outerHTML = _generateAvatarHtml(avatar, typeof profile.name === 'string' && profile.name ? profile.name : name);
694
  }
695
  }
696
  if (nameEl && typeof profile.name === 'string' && profile.name && nameEl.textContent !== profile.name) {
前端页面/prompt/提示词组件.js CHANGED
@@ -24,7 +24,7 @@ import { showToast } from "../components/UI交互提示组件.js";
24
  import { setCache, getCacheWithMeta, readListCache, writeListCache, requireListResponseData, isEmptyListRefreshWithExistingData, createPaginationLoader, lazyLoadImages, renderListLoadError, renderListSearchMessage } from "../components/性能优化工具.js";
25
  import { applyViewportAnimations, stopViewportAnimations } from "../components/动画音效引擎.js";
26
  import { t, tIfExists } from "../components/用户体验增强.js";
27
- import { API, CACHE, getCachedProfile, getProfileWithSWR } from "../core/全局配置.js";
28
  import { escapeHtml, formatTime, getCacheTTL } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
29
 
30
  // 缓存配置
@@ -111,6 +111,7 @@ function _renderEmptyState() {
111
  * 🖼️ 生成头像 HTML 字符串
112
  */
113
  function _generateAvatarHtml(avatar, name) {
 
114
  const initial = (name || 'U')[0].toUpperCase();
115
  // 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
116
  if (avatar) {
@@ -824,7 +825,7 @@ function createPromptCard(prompt, currentUser = currentUserCache, isCurrent = ()
824
 
825
  const account = prompt.author;
826
  const cached = initialAuthor;
827
- const avatar = [cached?.avatar, prompt.author_avatar].find(value => typeof value === 'string' && value) || '';
828
  const name = initialAuthorName;
829
 
830
  // 渲染初始头像
@@ -837,11 +838,12 @@ function createPromptCard(prompt, currentUser = currentUserCache, isCurrent = ()
837
  if (!isCurrent() || !card.isConnected) return;
838
  const avatarEl = authorContainer.querySelector('.swr-avatar');
839
  const nameEl = authorContainer.querySelector('.swr-name');
840
- if (avatarEl && typeof profile.avatar === 'string' && profile.avatar) {
 
841
  if (avatarEl.tagName === 'IMG') {
842
- if (avatarEl.getAttribute('src') !== profile.avatar) avatarEl.src = profile.avatar;
843
  } else {
844
- avatarEl.outerHTML = _generateAvatarHtml(profile.avatar, typeof profile.name === 'string' && profile.name ? profile.name : name);
845
  }
846
  }
847
  if (nameEl && typeof profile.name === 'string' && profile.name && nameEl.textContent !== profile.name) {
 
24
  import { setCache, getCacheWithMeta, readListCache, writeListCache, requireListResponseData, isEmptyListRefreshWithExistingData, createPaginationLoader, lazyLoadImages, renderListLoadError, renderListSearchMessage } from "../components/性能优化工具.js";
25
  import { applyViewportAnimations, stopViewportAnimations } from "../components/动画音效引擎.js";
26
  import { t, tIfExists } from "../components/用户体验增强.js";
27
+ import { API, CACHE, getCachedProfile, getProfileWithSWR, normalizeProfileImageUrl } from "../core/全局配置.js";
28
  import { escapeHtml, formatTime, getCacheTTL } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
29
 
30
  // 缓存配置
 
111
  * 🖼️ 生成头像 HTML 字符串
112
  */
113
  function _generateAvatarHtml(avatar, name) {
114
+ avatar = normalizeProfileImageUrl(avatar);
115
  const initial = (name || 'U')[0].toUpperCase();
116
  // 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
117
  if (avatar) {
 
825
 
826
  const account = prompt.author;
827
  const cached = initialAuthor;
828
+ const avatar = [cached?.avatar, prompt.author_avatar].map(value => normalizeProfileImageUrl(value)).find(Boolean) || '';
829
  const name = initialAuthorName;
830
 
831
  // 渲染初始头像
 
838
  if (!isCurrent() || !card.isConnected) return;
839
  const avatarEl = authorContainer.querySelector('.swr-avatar');
840
  const nameEl = authorContainer.querySelector('.swr-name');
841
+ const avatar = normalizeProfileImageUrl(profile?.avatar);
842
+ if (avatarEl && avatar) {
843
  if (avatarEl.tagName === 'IMG') {
844
+ if (avatarEl.getAttribute('src') !== avatar) avatarEl.src = avatar;
845
  } else {
846
+ avatarEl.outerHTML = _generateAvatarHtml(avatar, typeof profile.name === 'string' && profile.name ? profile.name : name);
847
  }
848
  }
849
  if (nameEl && typeof profile.name === 'string' && profile.name && nameEl.textContent !== profile.name) {
前端页面/prompt/提示词详情组件.js CHANGED
@@ -18,7 +18,7 @@ import { getCoverSandboxHTML, setupImageSandboxEvents, cleanupImageSandbox } fro
18
  import { getVideoPlayerHTML, setupVideoPlayerEvents, cleanupVideoPlayer } from "../components/视频播放器组件.js";
19
  import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
20
  import { t, tIfExists } from "../components/用户体验增强.js";
21
- import { getCachedProfile, getProfileWithSWR, IS_WEB_MODE, API, CACHE } from "../core/全局配置.js";
22
  import { findInListCache } from "../components/性能优化工具.js";
23
  import { globalModal } from "../components/全局弹窗管理器.js";
24
  import { recordView, handleToggleLike, handleToggleFavorite, renderTipBoardHTML as renderCommonTipBoardHTML, escapeHtml, formatTime } from "../components/互动工具函数.js"; // 🧹 P2归一:局部 formatTime 已移除
@@ -44,6 +44,7 @@ function openOwnedModal(node, title, content, options) {
44
  * 🖼️ 生成头像 HTML 字符串
45
  */
46
  function _generateAvatarHtml(avatar, name, size, extraStyle = '') {
 
47
  const initial = (name || 'U')[0].toUpperCase();
48
  // 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
49
  if (avatar) {
@@ -56,11 +57,12 @@ function _generateAvatarHtml(avatar, name, size, extraStyle = '') {
56
  * 🔄 用 SWR 回调更新已渲染的头像元素
57
  */
58
  function _updateAvatarElement(avatarEl, profile, size, extraStyle = '') {
59
- if (!avatarEl || !profile.avatar) return;
 
60
  if (avatarEl.tagName === 'IMG') {
61
- avatarEl.src = profile.avatar;
62
  } else {
63
- avatarEl.outerHTML = `<img class="swr-avatar prompt-detail__avatar ${size === 24 ? 'prompt-detail__avatar--small' : 'prompt-detail__avatar--large'}" alt="" src="${escapeHtml(profile.avatar)}">`;
64
  }
65
  }
66
 
@@ -327,7 +329,7 @@ async function loadPromptDetail(container, promptId, currentUser) {
327
 
328
  const account = prompt.author;
329
  const cached = getCachedProfile(account);
330
- const avatar = cached?.avatar || prompt.author_avatar || '';
331
  const name = cached?.name || prompt.author_name || account || '';
332
 
333
  if (avatarContainer) {
@@ -735,7 +737,7 @@ async function loadComments(container, promptId, currentUser) {
735
  // 🚀 SWR 头像渲染:评论作者头像
736
  commentsList.innerHTML = comments.map((c, idx) => {
737
  const cached = getCachedProfile(c.author);
738
- const avatar = cached?.avatar || c.author_avatar || '';
739
  const name = cached?.name || c.author_name || c.author || '';
740
 
741
  const avatarHtml = _generateAvatarHtml(avatar, name, 24, 'background: var(--comfy-input-bg);');
 
18
  import { getVideoPlayerHTML, setupVideoPlayerEvents, cleanupVideoPlayer } from "../components/视频播放器组件.js";
19
  import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
20
  import { t, tIfExists } from "../components/用户体验增强.js";
21
+ import { getCachedProfile, getProfileWithSWR, normalizeProfileImageUrl, IS_WEB_MODE, API, CACHE } from "../core/全局配置.js";
22
  import { findInListCache } from "../components/性能优化工具.js";
23
  import { globalModal } from "../components/全局弹窗管理器.js";
24
  import { recordView, handleToggleLike, handleToggleFavorite, renderTipBoardHTML as renderCommonTipBoardHTML, escapeHtml, formatTime } from "../components/互动工具函数.js"; // 🧹 P2归一:局部 formatTime 已移除
 
44
  * 🖼️ 生成头像 HTML 字符串
45
  */
46
  function _generateAvatarHtml(avatar, name, size, extraStyle = '') {
47
+ avatar = normalizeProfileImageUrl(avatar);
48
  const initial = (name || 'U')[0].toUpperCase();
49
  // 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
50
  if (avatar) {
 
57
  * 🔄 用 SWR 回调更新已渲染的头像元素
58
  */
59
  function _updateAvatarElement(avatarEl, profile, size, extraStyle = '') {
60
+ const avatar = normalizeProfileImageUrl(profile?.avatar);
61
+ if (!avatarEl || !avatar) return;
62
  if (avatarEl.tagName === 'IMG') {
63
+ avatarEl.src = avatar;
64
  } else {
65
+ avatarEl.outerHTML = `<img class="swr-avatar prompt-detail__avatar ${size === 24 ? 'prompt-detail__avatar--small' : 'prompt-detail__avatar--large'}" alt="" src="${escapeHtml(avatar)}">`;
66
  }
67
  }
68
 
 
329
 
330
  const account = prompt.author;
331
  const cached = getCachedProfile(account);
332
+ const avatar = [cached?.avatar, prompt.author_avatar].map(value => normalizeProfileImageUrl(value)).find(Boolean) || '';
333
  const name = cached?.name || prompt.author_name || account || '';
334
 
335
  if (avatarContainer) {
 
737
  // 🚀 SWR 头像渲染:评论作者头像
738
  commentsList.innerHTML = comments.map((c, idx) => {
739
  const cached = getCachedProfile(c.author);
740
+ const avatar = [cached?.avatar, c.author_avatar].map(value => normalizeProfileImageUrl(value)).find(Boolean) || '';
741
  const name = cached?.name || c.author_name || c.author || '';
742
 
743
  const avatarHtml = _generateAvatarHtml(avatar, name, 24, 'background: var(--comfy-input-bg);');
前端页面/social/评论与互动组件.js CHANGED
@@ -1,7 +1,7 @@
1
  // 前端页面/social/评论与互动组件.js
2
  import { api } from "../core/网络请求API.js";
3
  import { t } from "../components/用户体验增强.js";
4
- import { getCachedProfile, getProfileWithSWR, isAdmin } from "../core/全局配置.js";
5
  import { showToast, showConfirm } from "../components/UI交互提示组件.js";
6
  // 🧹 P2归一:escapeHtml 局部副本已移除,改用统一版
7
  import { escapeHtml } from "../components/互动工具函数.js";
@@ -109,7 +109,7 @@ export function createCommentSection(itemId, commentsData, currentUser, onCountC
109
  // 🚀 SWR 缓存优先获取头像
110
  const account = comment.author;
111
  const cached = getCachedProfile(account);
112
- const avatar = cached?.avatar || comment.avatar || '';
113
  const avatarSrc = avatar || DEFAULT_AVATAR_SVG;
114
 
115
  itemDiv.innerHTML = `
@@ -132,8 +132,9 @@ export function createCommentSection(itemId, commentsData, currentUser, onCountC
132
  const nameEl = itemDiv.querySelector('.swr-name');
133
  if (profileGeneration !== generation || !profileCurrent() || !profile || typeof profile !== 'object' || Array.isArray(profile)
134
  || (profile.account != null && profile.account !== account)) return;
135
- if (avatarEl && typeof profile.avatar === 'string' && profile.avatar) {
136
- avatarEl.src = profile.avatar;
 
137
  }
138
  if (nameEl && typeof profile.name === 'string' && profile.name) {
139
  nameEl.textContent = profile.name;
 
1
  // 前端页面/social/评论与互动组件.js
2
  import { api } from "../core/网络请求API.js";
3
  import { t } from "../components/用户体验增强.js";
4
+ import { getCachedProfile, getProfileWithSWR, normalizeProfileImageUrl, isAdmin } from "../core/全局配置.js";
5
  import { showToast, showConfirm } from "../components/UI交互提示组件.js";
6
  // 🧹 P2归一:escapeHtml 局部副本已移除,改用统一版
7
  import { escapeHtml } from "../components/互动工具函数.js";
 
109
  // 🚀 SWR 缓存优先获取头像
110
  const account = comment.author;
111
  const cached = getCachedProfile(account);
112
+ const avatar = [cached?.avatar, comment.avatar].map(value => normalizeProfileImageUrl(value)).find(Boolean) || '';
113
  const avatarSrc = avatar || DEFAULT_AVATAR_SVG;
114
 
115
  itemDiv.innerHTML = `
 
132
  const nameEl = itemDiv.querySelector('.swr-name');
133
  if (profileGeneration !== generation || !profileCurrent() || !profile || typeof profile !== 'object' || Array.isArray(profile)
134
  || (profile.account != null && profile.account !== account)) return;
135
+ const avatar = normalizeProfileImageUrl(profile.avatar);
136
+ if (avatarEl && avatar) {
137
+ avatarEl.src = avatar;
138
  }
139
  if (nameEl && typeof profile.name === 'string' && profile.name) {
140
  nameEl.textContent = profile.name;
前端页面/styles/主题基础.css CHANGED
@@ -2748,7 +2748,21 @@ button.ranking-rating__star { cursor: pointer; min-width: 28px; min-height: 28px
2748
 
2749
  .comfy-ranking-root .profile-view { display:flex; flex-direction:column; gap:.9375rem; padding:.9375rem; flex:1; min-width:0; min-height:0; box-sizing:border-box; overflow-y:auto; background:var(--rk-bg,var(--comfy-menu-bg,#202020)); color:var(--rk-text,var(--fg-color,#eee)); font-size:.875rem; overflow-wrap:anywhere; }
2750
 
2751
- .comfy-ranking-root .resource-trend { --resource-trend-text: var(--rk-text); --resource-trend-border: var(--rk-border); --resource-trend-line: var(--rk-text); --resource-trend-panel: var(--rk-panel); min-width:0; min-height:10rem; max-width:100%; transition-property:none; font-family:inherit; font-size:.75rem; color:var(--rk-text); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2752
  .comfy-ranking-root .resource-trend__state { display:flex; justify-content:center; align-items:center; min-height:160px; box-sizing:border-box; padding:1rem; text-align:center; font:inherit; line-height:1.5; overflow-wrap:anywhere; color:var(--rk-text); }
2753
  .comfy-ranking-root .resource-trend__state[role=alert] { border-inline-start:2px solid var(--rk-danger); }
2754
  @media (forced-colors:active) { .comfy-ranking-root .resource-trend { --resource-trend-text:CanvasText; --resource-trend-border:CanvasText; --resource-trend-line:CanvasText; --resource-trend-panel:Canvas; } .comfy-ranking-root .resource-trend__state { color:CanvasText; } .comfy-ranking-root .resource-trend__state[role=alert] { border-color:CanvasText; } }
 
2748
 
2749
  .comfy-ranking-root .profile-view { display:flex; flex-direction:column; gap:.9375rem; padding:.9375rem; flex:1; min-width:0; min-height:0; box-sizing:border-box; overflow-y:auto; background:var(--rk-bg,var(--comfy-menu-bg,#202020)); color:var(--rk-text,var(--fg-color,#eee)); font-size:.875rem; overflow-wrap:anywhere; }
2750
 
2751
+ .skill-directory { display:flex; flex-direction:column; gap:.75rem; min-width:0; color:inherit; overflow-wrap:anywhere; }
2752
+ .skill-directory p { margin:0; }
2753
+ .skill-directory__label { display:flex; flex-direction:column; gap:.375rem; min-width:0; }
2754
+ .skill-directory input { box-sizing:border-box; width:100%; min-width:0; padding:.5rem; font:inherit; color:inherit; background:var(--rk-panel,var(--comfy-input-bg,#222)); border:1px solid var(--rk-border,var(--border-color,#555)); border-radius:.375rem; }
2755
+ .skill-directory__actions { display:flex; flex-wrap:wrap; gap:.5rem; }
2756
+ .skill-directory button { min-height:2.25rem; padding:.375rem .625rem; font:inherit; text-align:start; white-space:normal; overflow-wrap:anywhere; cursor:pointer; color:inherit; background:var(--rk-panel,var(--comfy-input-bg,#222)); border:1px solid var(--rk-border,var(--border-color,#555)); border-radius:.375rem; }
2757
+ .skill-directory button:disabled { opacity:.55; cursor:default; }
2758
+ .skill-directory :is(button,input):focus-visible { outline:2px solid var(--rk-accent,#4c9ffe); outline-offset:2px; }
2759
+ .skill-directory__list { list-style:none; padding:0; margin:0; max-height:18rem; overflow-y:auto; }
2760
+ .skill-directory__list button { width:100%; margin-bottom:.25rem; }
2761
+ .skill-install-preview { min-width:0; text-align:start; overflow-wrap:anywhere; }
2762
+ .skill-install-preview ul { max-height:18rem; overflow-y:auto; padding-inline-start:1.5rem; }
2763
+ @media (forced-colors:active) { .skill-directory :is(input,button) { border-color:ButtonText; } .skill-directory :is(input,button):focus-visible { outline-color:Highlight; } }
2764
+
2765
+ .comfy-ranking-root .resource-trend { --resource-trend-text: var(--rk-text); --resource-trend-border: var(--rk-border); --resource-trend-line: var(--rk-text); --resource-trend-panel: var(--rk-panel); min-width:0; min-height:10rem; max-width:100%; transition-property:none; font-family:inherit; font-size:.75rem; color:var(--rk-text); }
2766
  .comfy-ranking-root .resource-trend__state { display:flex; justify-content:center; align-items:center; min-height:160px; box-sizing:border-box; padding:1rem; text-align:center; font:inherit; line-height:1.5; overflow-wrap:anywhere; color:var(--rk-text); }
2767
  .comfy-ranking-root .resource-trend__state[role=alert] { border-inline-start:2px solid var(--rk-danger); }
2768
  @media (forced-colors:active) { .comfy-ranking-root .resource-trend { --resource-trend-text:CanvasText; --resource-trend-border:CanvasText; --resource-trend-line:CanvasText; --resource-trend-panel:Canvas; } .comfy-ranking-root .resource-trend__state { color:CanvasText; } .comfy-ranking-root .resource-trend__state[role=alert] { border-color:CanvasText; } }
前端页面/task/任务榜组件.js CHANGED
@@ -23,7 +23,7 @@ import { showToast } from "../components/UI交互提示组件.js";
23
  import { readListCache, writeListCache, requireListResponseData, isEmptyListRefreshWithExistingData, createSkeleton, createPaginationLoader, lazyLoadImages, renderListLoadError, renderListSearchMessage } from "../components/性能优化工具.js";
24
  import { applyViewportAnimations, stopViewportAnimations } from "../components/动画音效引擎.js";
25
  import { t } from "../components/用户体验增强.js";
26
- import { API, CACHE, getCachedProfile, getProfileWithSWR } from "../core/全局配置.js";
27
  import { escapeHtml, formatTime, getCacheTTL } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
28
 
29
  // 缓存配置
@@ -660,7 +660,7 @@ function createTaskCard(task, currentUser = currentUserCache, isCurrent = () =>
660
 
661
  const account = task.publisher;
662
  const cached = initialAuthor;
663
- const avatar = [cached?.avatar, task.publisher_avatar].find(value => typeof value === 'string' && value) || '';
664
  const name = initialAuthorName;
665
 
666
 
@@ -676,8 +676,9 @@ function createTaskCard(task, currentUser = currentUserCache, isCurrent = () =>
676
  if (!isCurrent() || !card.isConnected) return;
677
  const avatarEl = authorContainer.querySelector('.swr-avatar');
678
  const nameEl = authorContainer.querySelector('.swr-name');
679
- if (avatarEl && typeof profile.avatar === 'string' && profile.avatar) {
680
- if (avatarEl.getAttribute('src') !== profile.avatar) avatarEl.src = profile.avatar;
 
681
  }
682
  if (nameEl && typeof profile.name === 'string' && profile.name && nameEl.textContent !== profile.name) {
683
  nameEl.textContent = profile.name;
 
23
  import { readListCache, writeListCache, requireListResponseData, isEmptyListRefreshWithExistingData, createSkeleton, createPaginationLoader, lazyLoadImages, renderListLoadError, renderListSearchMessage } from "../components/性能优化工具.js";
24
  import { applyViewportAnimations, stopViewportAnimations } from "../components/动画音效引擎.js";
25
  import { t } from "../components/用户体验增强.js";
26
+ import { API, CACHE, getCachedProfile, getProfileWithSWR, normalizeProfileImageUrl } from "../core/全局配置.js";
27
  import { escapeHtml, formatTime, getCacheTTL } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
28
 
29
  // 缓存配置
 
660
 
661
  const account = task.publisher;
662
  const cached = initialAuthor;
663
+ const avatar = [cached?.avatar, task.publisher_avatar].map(value => normalizeProfileImageUrl(value)).find(Boolean) || '';
664
  const name = initialAuthorName;
665
 
666
 
 
676
  if (!isCurrent() || !card.isConnected) return;
677
  const avatarEl = authorContainer.querySelector('.swr-avatar');
678
  const nameEl = authorContainer.querySelector('.swr-name');
679
+ const avatar = normalizeProfileImageUrl(profile?.avatar);
680
+ if (avatarEl && avatar) {
681
+ if (avatarEl.getAttribute('src') !== avatar) avatarEl.src = avatar;
682
  }
683
  if (nameEl && typeof profile.name === 'string' && profile.name && nameEl.textContent !== profile.name) {
684
  nameEl.textContent = profile.name;
前端页面/task/任务详情组件.js CHANGED
@@ -6,13 +6,14 @@
6
  // ==========================================
7
 
8
  import { api, proxyImages, captureRequestSession, requestCancelManager } from "../core/网络请求API.js";
 
9
  import { hasAppliedToTask } from "./任务数据工具.js";
10
  import { loadTaskSnapshot } from "./任务详情加载.js";
11
  import { eventBus, EVENTS } from "../core/状态管理.js";
12
  import { createTaskViewScope, subscribeTaskSessionChanges } from "./任务视图生命周期.js";
13
  import { showToast } from "../components/UI交互提示组件.js";
14
  import { t } from "../components/用户体验增强.js";
15
- import { API, CACHE, PLACEHOLDERS, TASK_LIMITS, getCachedProfile, getProfileWithSWR, isAdmin, IS_WEB_MODE } from "../core/全局配置.js";
16
  import { removeCache, findInListCache, requireListResponseData } from "../components/性能优化工具.js";
17
  import { globalModal } from "../components/全局弹窗管理器.js";
18
  import { compressImageForUpload } from "../market/发布内容_提交引擎.js";
@@ -240,7 +241,7 @@ function renderTaskDetail(contentEl, task, currentUser, preview = false) {
240
 
241
  <!-- 发布者信息 -->
242
  <div class="task-detail__publisher">
243
- <img src="${escapeHtml(task.publisher_avatar || PLACEHOLDERS.AVATAR_SMALL)}"
244
  class="task-detail__avatar-main" id="publisher-avatar" alt="">
245
  <div class="task-detail__grow">
246
  <div class="task-detail__publisher-name">${escapeHtml(task.publisher_name || task.publisher)}</div>
@@ -348,7 +349,7 @@ ${escapeHtml(task.description)}</div>
348
  <div class="task-detail__assignee">
349
  <div class="task-detail__assignee-heading">👷 ${t('task.assignee')}</div>
350
  <div class="task-detail__person">
351
- <img alt="" src="${escapeHtml(task.assignee_avatar || PLACEHOLDERS.AVATAR_SMALL)}"
352
  class="task-detail__avatar">
353
  <span class="task-detail__person-name">${escapeHtml(task.assignee_name || task.assignee)}</span>
354
  </div>
@@ -373,7 +374,7 @@ ${escapeHtml(task.description)}</div>
373
  <div id="applicants-list" class="task-detail__applicants">
374
  ${applicants.map(app => `
375
  <div class="task-detail__applicant">
376
- <img alt="" src="${escapeHtml(app.avatar || PLACEHOLDERS.AVATAR_SMALL)}"
377
  class="task-detail__avatar">
378
  <div class="task-detail__grow">
379
  <div class="task-detail__applicant-name">${escapeHtml(app.name || app.account)}</div>
@@ -1118,6 +1119,7 @@ function renderTipBoard(tipBoard) {
1118
  * 🖼️ 渲染评论头像HTML
1119
  */
1120
  function _renderCommentAvatar(name, avatarUrl) {
 
1121
  const initial = String(name || 'U')[0].toUpperCase();
1122
  // 🔒 XSS防护:评论者昵称与头像均来自他人可修改的资料,写入 HTML 前必须转义
1123
  return avatarUrl
@@ -1173,7 +1175,7 @@ async function loadTaskComments(container, taskId, currentUser) {
1173
  // 🚀 SWR 头像渲染:评论作者头像
1174
  commentsList.innerHTML = comments.map((c, idx) => {
1175
  const cached = getCachedProfile(c.author);
1176
- const avatar = cached?.avatar || c.author_avatar || '';
1177
  const name = cached?.name || c.author_name || c.author || '';
1178
  const avatarHtml = _renderCommentAvatar(name, avatar);
1179
 
@@ -1208,8 +1210,9 @@ async function loadTaskComments(container, taskId, currentUser) {
1208
  const avatarContainer = commentEl.querySelector('.swr-avatar-container');
1209
  const nameEl = commentEl.querySelector('.swr-name');
1210
 
1211
- if (avatarContainer && profile.avatar) {
1212
- avatarContainer.innerHTML = _renderCommentAvatar(profile.name || nameEl?.textContent || c.author_name || c.author, profile.avatar);
 
1213
  }
1214
  if (nameEl && profile.name) nameEl.textContent = profile.name;
1215
  });
@@ -1517,10 +1520,9 @@ function showDisputeDialog(task, currentUser) {
1517
  if (!taskModalCurrent(content)) return;
1518
  const res = await api.uploadFile(processedFile, "dispute");
1519
  if (!taskModalCurrent(content)) return;
1520
- if (res?.status !== "success" || typeof res.url !== "string" || !res.url.trim()) {
1521
- throw new Error("Invalid evidence upload response");
1522
- }
1523
- evidenceUrls.push(res.url);
1524
  }
1525
 
1526
  // 提交申诉
 
6
  // ==========================================
7
 
8
  import { api, proxyImages, captureRequestSession, requestCancelManager } from "../core/网络请求API.js";
9
+ import { disputeImageURL } from "./申诉数据工具.js";
10
  import { hasAppliedToTask } from "./任务数据工具.js";
11
  import { loadTaskSnapshot } from "./任务详情加载.js";
12
  import { eventBus, EVENTS } from "../core/状态管理.js";
13
  import { createTaskViewScope, subscribeTaskSessionChanges } from "./任务视图生命周期.js";
14
  import { showToast } from "../components/UI交互提示组件.js";
15
  import { t } from "../components/用户体验增强.js";
16
+ import { API, CACHE, PLACEHOLDERS, TASK_LIMITS, getCachedProfile, getProfileWithSWR, normalizeProfileImageUrl, isAdmin, IS_WEB_MODE } from "../core/全局配置.js";
17
  import { removeCache, findInListCache, requireListResponseData } from "../components/性能优化工具.js";
18
  import { globalModal } from "../components/全局弹窗管理器.js";
19
  import { compressImageForUpload } from "../market/发布内容_提交引擎.js";
 
241
 
242
  <!-- 发布者信息 -->
243
  <div class="task-detail__publisher">
244
+ <img src="${escapeHtml(normalizeProfileImageUrl(task.publisher_avatar) || PLACEHOLDERS.AVATAR_SMALL)}"
245
  class="task-detail__avatar-main" id="publisher-avatar" alt="">
246
  <div class="task-detail__grow">
247
  <div class="task-detail__publisher-name">${escapeHtml(task.publisher_name || task.publisher)}</div>
 
349
  <div class="task-detail__assignee">
350
  <div class="task-detail__assignee-heading">👷 ${t('task.assignee')}</div>
351
  <div class="task-detail__person">
352
+ <img alt="" src="${escapeHtml(normalizeProfileImageUrl(task.assignee_avatar) || PLACEHOLDERS.AVATAR_SMALL)}"
353
  class="task-detail__avatar">
354
  <span class="task-detail__person-name">${escapeHtml(task.assignee_name || task.assignee)}</span>
355
  </div>
 
374
  <div id="applicants-list" class="task-detail__applicants">
375
  ${applicants.map(app => `
376
  <div class="task-detail__applicant">
377
+ <img alt="" src="${escapeHtml(normalizeProfileImageUrl(app.avatar) || PLACEHOLDERS.AVATAR_SMALL)}"
378
  class="task-detail__avatar">
379
  <div class="task-detail__grow">
380
  <div class="task-detail__applicant-name">${escapeHtml(app.name || app.account)}</div>
 
1119
  * 🖼️ 渲染评论头像HTML
1120
  */
1121
  function _renderCommentAvatar(name, avatarUrl) {
1122
+ avatarUrl = normalizeProfileImageUrl(avatarUrl);
1123
  const initial = String(name || 'U')[0].toUpperCase();
1124
  // 🔒 XSS防护:评论者昵称与头像均来自他人可修改的资料,写入 HTML 前必须转义
1125
  return avatarUrl
 
1175
  // 🚀 SWR 头像渲染:评论作者头像
1176
  commentsList.innerHTML = comments.map((c, idx) => {
1177
  const cached = getCachedProfile(c.author);
1178
+ const avatar = [cached?.avatar, c.author_avatar].map(value => normalizeProfileImageUrl(value)).find(Boolean) || '';
1179
  const name = cached?.name || c.author_name || c.author || '';
1180
  const avatarHtml = _renderCommentAvatar(name, avatar);
1181
 
 
1210
  const avatarContainer = commentEl.querySelector('.swr-avatar-container');
1211
  const nameEl = commentEl.querySelector('.swr-name');
1212
 
1213
+ const avatar = normalizeProfileImageUrl(profile.avatar);
1214
+ if (avatarContainer && avatar) {
1215
+ avatarContainer.innerHTML = _renderCommentAvatar(profile.name || nameEl?.textContent || c.author_name || c.author, avatar);
1216
  }
1217
  if (nameEl && profile.name) nameEl.textContent = profile.name;
1218
  });
 
1520
  if (!taskModalCurrent(content)) return;
1521
  const res = await api.uploadFile(processedFile, "dispute");
1522
  if (!taskModalCurrent(content)) return;
1523
+ const url = res?.status === "success" ? disputeImageURL(res.url) : "";
1524
+ if (!url) throw new Error("Invalid evidence upload response");
1525
+ evidenceUrls.push(url);
 
1526
  }
1527
 
1528
  // 提交申诉
前端页面/task/申诉数据工具.js CHANGED
@@ -1,39 +1,38 @@
1
- import { API, UI } from "../core/全局配置.js";
2
-
3
- export function disputeImageURL(value) {
4
- if (typeof value !== "string" || !value.trim()) return "";
5
- const input = value.trim();
6
- if (/^uploads\/[a-zA-Z0-9_./-]+$/.test(input) && !input.split('/').includes('..')) {
7
- return `${API.BASE_URL}${API.IMAGE_PROXY_PATH}?path=${encodeURIComponent(input)}`;
8
- }
9
- // 只接受既有本地图片代理相对路径,不把任意同源 GET 当作图片执行。
10
- if (input.startsWith('/community_hub/image?')) return input;
11
- try {
12
- const url = new URL(input);
13
- if (!["https:", "http:"].includes(url.protocol) || url.username || url.password) return "";
14
- return url.href;
15
- } catch (_) { return ""; }
16
- }
17
-
18
- export function normalizeDispute(value, expectedId) {
19
- if (!value || typeof value !== "object" || Array.isArray(value) || value.id !== expectedId
20
- || !["pending", "responded", "resolved"].includes(value.status)
21
- || !["publisher", "assignee"].includes(value.initiator_role)
22
- || !["publisher", "assignee", "initiator"].every(key => typeof value[key] === "string" && value[key].trim())
23
- || value.initiator !== value[value.initiator_role]) return null;
24
- const result = { ...value };
25
- for (const key of ["task_id", "task_title", "publisher_name", "assignee_name", "reason", "response", "resolution_note"]) {
26
- result[key] = typeof value[key] === "string" ? value[key] : "";
27
- }
28
- for (const key of ["evidence", "response_evidence"]) {
29
- result[key] = Array.isArray(value[key])
30
- ? value[key].map(disputeImageURL).filter(Boolean).slice(0, UI.MAX_DISPUTE_EVIDENCE) : [];
31
- }
32
- for (const key of ["publisher_avatar", "assignee_avatar"]) result[key] = disputeImageURL(value[key]);
33
- for (const key of ["created_at", "responded_at", "resolved_at"]) {
34
- result[key] = typeof value[key] === "number" && Number.isFinite(value[key]) && value[key] >= 0 ? value[key] : null;
35
- }
36
- result.resolution_ratio = typeof value.resolution_ratio === "number" && Number.isFinite(value.resolution_ratio)
37
- && value.resolution_ratio >= 0 && value.resolution_ratio <= 100 ? value.resolution_ratio : null;
38
- return result;
39
- }
 
1
+ import { API, UI, normalizeProfileImageUrl } from "../core/全局配置.js";
2
+
3
+ export function disputeImageURL(value, serviceBaseUrl = API.BASE_URL) {
4
+ if (typeof value !== "string" || !value || value.trim() !== value) return "";
5
+ let input = value;
6
+ if (/^uploads\/[a-zA-Z0-9_./-]+$/.test(input) && !input.split('/').includes('..')) {
7
+ input = `${serviceBaseUrl.replace(/\/$/, "")}${API.IMAGE_PROXY_PATH}?path=${encodeURIComponent(input)}`;
8
+ }
9
+ if (/^(?:data:|blob:)/i.test(input)) return "";
10
+ return normalizeProfileImageUrl(input, false, serviceBaseUrl);
11
+ }
12
+
13
+ export function normalizeDisputeEvidence(value, serviceBaseUrl = API.BASE_URL) {
14
+ return Array.isArray(value)
15
+ ? value.map(image => disputeImageURL(image, serviceBaseUrl)).filter(Boolean).slice(0, UI.MAX_DISPUTE_EVIDENCE) : [];
16
+ }
17
+
18
+ export function normalizeDispute(value, expectedId) {
19
+ if (!value || typeof value !== "object" || Array.isArray(value) || value.id !== expectedId
20
+ || !["pending", "responded", "resolved"].includes(value.status)
21
+ || !["publisher", "assignee"].includes(value.initiator_role)
22
+ || !["publisher", "assignee", "initiator"].every(key => typeof value[key] === "string" && value[key].trim())
23
+ || value.initiator !== value[value.initiator_role]) return null;
24
+ const result = { ...value };
25
+ for (const key of ["task_id", "task_title", "publisher_name", "assignee_name", "reason", "response", "resolution_note"]) {
26
+ result[key] = typeof value[key] === "string" ? value[key] : "";
27
+ }
28
+ for (const key of ["evidence", "response_evidence"]) {
29
+ result[key] = normalizeDisputeEvidence(value[key]);
30
+ }
31
+ for (const key of ["publisher_avatar", "assignee_avatar"]) result[key] = normalizeProfileImageUrl(value[key]);
32
+ for (const key of ["created_at", "responded_at", "resolved_at"]) {
33
+ result[key] = typeof value[key] === "number" && Number.isFinite(value[key]) && value[key] >= 0 ? value[key] : null;
34
+ }
35
+ result.resolution_ratio = typeof value.resolution_ratio === "number" && Number.isFinite(value.resolution_ratio)
36
+ && value.resolution_ratio >= 0 && value.resolution_ratio <= 100 ? value.resolution_ratio : null;
37
+ return result;
38
+ }