Spaces:
Running
Running
Upload 101 files
Browse files- 前端页面/components/图片展示测试.js +12 -19
- 前端页面/components/图片沙盒组件.js +12 -5
- 前端页面/components/用户体验_国际化.js +60 -0
- 前端页面/components/缓存管理请求.js +2 -31
- 前端页面/core/全局配置.js +14 -2
- 前端页面/core/本机请求读取.js +30 -0
- 前端页面/market/Skill安装流程.js +202 -0
- 前端页面/market/资源安装引擎.js +78 -5
- 前端页面/post/帖子详情组件.js +8 -6
- 前端页面/post/讨论区组件.js +7 -5
- 前端页面/prompt/提示词组件.js +7 -5
- 前端页面/prompt/提示词详情组件.js +8 -6
- 前端页面/social/评论与互动组件.js +5 -4
- 前端页面/styles/主题基础.css +15 -1
- 前端页面/task/任务榜组件.js +5 -4
- 前端页面/task/任务详情组件.js +13 -11
- 前端页面/task/申诉数据工具.js +38 -39
前端页面/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 |
-
|
| 405 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 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.
|
| 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 |
-
|
| 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
|
| 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(
|
| 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'
|
|
|
|
|
|
|
| 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:
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 68 |
if (avatarEl.tagName === 'IMG') {
|
| 69 |
-
avatarEl.src =
|
| 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(
|
| 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
|
| 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
|
| 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].
|
| 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 |
-
|
|
|
|
| 688 |
if (avatarEl.tagName === 'IMG') {
|
| 689 |
-
if (avatarEl.getAttribute('src') !==
|
| 690 |
} else {
|
| 691 |
-
avatarEl.outerHTML = _generateAvatarHtml(
|
| 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].
|
| 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 |
-
|
|
|
|
| 841 |
if (avatarEl.tagName === 'IMG') {
|
| 842 |
-
if (avatarEl.getAttribute('src') !==
|
| 843 |
} else {
|
| 844 |
-
avatarEl.outerHTML = _generateAvatarHtml(
|
| 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 |
-
|
|
|
|
| 60 |
if (avatarEl.tagName === 'IMG') {
|
| 61 |
-
avatarEl.src =
|
| 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(
|
| 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
|
| 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
|
| 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
|
| 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 |
-
|
| 136 |
-
|
|
|
|
| 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 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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].
|
| 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 |
-
|
| 680 |
-
|
|
|
|
| 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
|
| 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 |
-
|
| 1212 |
-
|
|
|
|
| 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 |
-
|
| 1521 |
-
|
| 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 |
-
|
| 6 |
-
if (/^uploads\/[a-zA-Z0-9_./-]+$/.test(input) && !input.split('/').includes('..')) {
|
| 7 |
-
|
| 8 |
-
}
|
| 9 |
-
//
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 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] =
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
for (const key of ["
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 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 |
+
}
|
|
|