Spaces:
Running
Running
Upload 95 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- 前端页面/auth/用户注册登录组件.js +41 -9
- 前端页面/components/UI交互提示组件.js +313 -145
- 前端页面/components/互动工具函数.js +38 -23
- 前端页面/components/全局弹窗管理器.js +72 -118
- 前端页面/components/关于插件组件.js +180 -352
- 前端页面/components/动画音效引擎.js +23 -7
- 前端页面/components/卡片导航生命周期.js +32 -0
- 前端页面/components/图片展示测试.js +64 -18
- 前端页面/components/图片沙盒组件.js +4 -4
- 前端页面/components/图片裁剪组件.js +39 -9
- 前端页面/components/彩蛋动画引擎.js +11 -9
- 前端页面/components/性能优化工具.js +167 -58
- 前端页面/components/打赏等级工具.js +27 -18
- 前端页面/components/用户体验_国际化.js +578 -51
- 前端页面/components/用户体验增强.js +62 -16
- 前端页面/components/顶部导航组件.js +121 -180
- 前端页面/components/顶部广告组件.js +178 -80
- 前端页面/core/侧边栏主程序.js +165 -149
- 前端页面/core/侧边栏入口注册.js +10 -1
- 前端页面/core/侧边栏数据引擎.js +191 -496
- 前端页面/core/全局配置.js +165 -36
- 前端页面/core/榜单分页控制.js +95 -0
- 前端页面/core/榜单分页状态.js +39 -0
- 前端页面/core/榜单数据来源.js +71 -0
- 前端页面/core/榜单本地查询.js +99 -0
- 前端页面/core/榜单视图切换.js +49 -0
- 前端页面/core/状态管理.js +66 -60
- 前端页面/core/用户会话存储.js +45 -0
- 前端页面/core/网络请求API.js +1 -1
- 前端页面/core/网络请求_业务API.js +56 -44
- 前端页面/core/网络请求_基础设施.js +274 -78
- 前端页面/core/网络请求_缓存管理.js +224 -39
- 前端页面/market/列表卡片组件.js +167 -214
- 前端页面/market/创作者卡片组件.js +4 -10
- 前端页面/market/发布内容_UI模板.js +84 -98
- 前端页面/market/发布内容_提交引擎.js +84 -162
- 前端页面/market/发布内容组件.js +64 -35
- 前端页面/market/发布字段错误.js +41 -0
- 前端页面/market/发布提交生命周期.js +55 -0
- 前端页面/market/发布文件选择可访问性.js +44 -0
- 前端页面/market/发布编辑锁.js +43 -0
- 前端页面/market/图片文件检查.js +77 -0
- 前端页面/market/支付二维码工具.js +22 -0
- 前端页面/market/有界图片处理.js +152 -0
- 前端页面/market/购买状态生命周期.js +37 -0
- 前端页面/market/资源删除组件.js +59 -0
- 前端页面/market/资源安装引擎.js +757 -473
- 前端页面/market/资源操作生命周期.js +52 -0
- 前端页面/market/资源获取记录.js +67 -0
- 前端页面/market/资源详情页面组件.js +96 -261
前端页面/auth/用户注册登录组件.js
CHANGED
|
@@ -2,10 +2,13 @@ import { renderLoginForm } from "./登录表单组件.js";
|
|
| 2 |
import { renderRegisterForm } from "./注册表单组件.js";
|
| 3 |
import { renderResetForm } from "./重置密码表单组件.js";
|
| 4 |
import { renderRecoverForm } from "./找回账号表单组件.js";
|
|
|
|
| 5 |
import { t } from "../components/用户体验增强.js";
|
| 6 |
|
| 7 |
export function createAuthView(onSuccessCallback) {
|
| 8 |
const container = document.createElement("div");
|
|
|
|
|
|
|
| 9 |
// 🔧 自适应高度:flex 填充剩余空间,内容超出时在容器内部滚动(与主界面保持一致)
|
| 10 |
Object.assign(container.style, {
|
| 11 |
display: "flex", flexDirection: "column", gap: "15px", color: "#ccc",
|
|
@@ -22,30 +25,59 @@ export function createAuthView(onSuccessCallback) {
|
|
| 22 |
`;
|
| 23 |
|
| 24 |
container.querySelector("#btn-back-auth").onclick = () => {
|
|
|
|
| 25 |
window.dispatchEvent(new CustomEvent("comfy-route-back"));
|
| 26 |
};
|
| 27 |
|
| 28 |
const formContainer = container.querySelector("#auth-form-container");
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
const switchView = (viewState, options = {}) => {
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
formContainer.innerHTML = "";
|
| 37 |
if (viewState === "login") {
|
| 38 |
-
renderLoginForm(formContainer, switchView,
|
| 39 |
} else if (viewState === "register") {
|
| 40 |
-
renderRegisterForm(formContainer, switchView,
|
| 41 |
} else if (viewState === "reset") {
|
| 42 |
-
renderResetForm(formContainer, switchView,
|
| 43 |
} else if (viewState === "recover") {
|
| 44 |
renderRecoverForm(formContainer, switchView);
|
| 45 |
}
|
| 46 |
};
|
| 47 |
|
| 48 |
-
switchView("login");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
return container;
|
| 51 |
}
|
|
|
|
| 2 |
import { renderRegisterForm } from "./注册表单组件.js";
|
| 3 |
import { renderResetForm } from "./重置密码表单组件.js";
|
| 4 |
import { renderRecoverForm } from "./找回账号表单组件.js";
|
| 5 |
+
import { showToast } from "../components/UI交互提示组件.js";
|
| 6 |
import { t } from "../components/用户体验增强.js";
|
| 7 |
|
| 8 |
export function createAuthView(onSuccessCallback) {
|
| 9 |
const container = document.createElement("div");
|
| 10 |
+
let disposed = false, generation = 0, mounted = false, busy = false;
|
| 11 |
+
let observer;
|
| 12 |
// 🔧 自适应高度:flex 填充剩余空间,内容超出时在容器内部滚动(与主界面保持一致)
|
| 13 |
Object.assign(container.style, {
|
| 14 |
display: "flex", flexDirection: "column", gap: "15px", color: "#ccc",
|
|
|
|
| 25 |
`;
|
| 26 |
|
| 27 |
container.querySelector("#btn-back-auth").onclick = () => {
|
| 28 |
+
container._cleanup();
|
| 29 |
window.dispatchEvent(new CustomEvent("comfy-route-back"));
|
| 30 |
};
|
| 31 |
|
| 32 |
const formContainer = container.querySelector("#auth-form-container");
|
| 33 |
|
| 34 |
+
const releaseForm = () => {
|
| 35 |
+
formContainer.querySelectorAll('input, textarea').forEach(input => { input.value = ''; });
|
| 36 |
+
const cleanup = formContainer._onCleanup; formContainer._onCleanup = null;
|
| 37 |
+
try { cleanup?.(); } catch (_) {}
|
| 38 |
+
};
|
| 39 |
+
container._cleanup = () => {
|
| 40 |
+
if (disposed) return;
|
| 41 |
+
disposed = true; generation++; busy = false;
|
| 42 |
+
observer?.disconnect(); releaseForm();
|
| 43 |
+
};
|
| 44 |
const switchView = (viewState, options = {}) => {
|
| 45 |
+
if (disposed) return;
|
| 46 |
+
releaseForm(); generation++; busy = false;
|
| 47 |
+
const currentGeneration = generation;
|
| 48 |
+
const current = () => !disposed && generation === currentGeneration && container.isConnected;
|
| 49 |
+
const submit = async formData => {
|
| 50 |
+
if (!current() || busy) return;
|
| 51 |
+
busy = true;
|
| 52 |
+
const button = formContainer.querySelector('button[id^="btn-submit"]');
|
| 53 |
+
if (button) { button.disabled = true; button.setAttribute('aria-busy', 'true'); }
|
| 54 |
+
try { await onSuccessCallback?.(formData, { isCurrent: current }); }
|
| 55 |
+
catch (_) { if (current()) showToast(t('common.error'), 'error'); }
|
| 56 |
+
finally {
|
| 57 |
+
if (current()) {
|
| 58 |
+
busy = false;
|
| 59 |
+
if (button) { button.disabled = false; button.setAttribute('aria-busy', 'false'); }
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
};
|
| 63 |
formContainer.innerHTML = "";
|
| 64 |
if (viewState === "login") {
|
| 65 |
+
renderLoginForm(formContainer, switchView, submit);
|
| 66 |
} else if (viewState === "register") {
|
| 67 |
+
renderRegisterForm(formContainer, switchView, submit);
|
| 68 |
} else if (viewState === "reset") {
|
| 69 |
+
renderResetForm(formContainer, switchView, submit, options.prefill);
|
| 70 |
} else if (viewState === "recover") {
|
| 71 |
renderRecoverForm(formContainer, switchView);
|
| 72 |
}
|
| 73 |
};
|
| 74 |
|
| 75 |
+
switchView("login");
|
| 76 |
+
observer = new MutationObserver(() => {
|
| 77 |
+
if (container.isConnected) mounted = true;
|
| 78 |
+
else if (mounted) container._cleanup();
|
| 79 |
+
});
|
| 80 |
+
observer.observe(document.body, { childList: true, subtree: true });
|
| 81 |
|
| 82 |
return container;
|
| 83 |
}
|
前端页面/components/UI交互提示组件.js
CHANGED
|
@@ -25,6 +25,14 @@ import { escapeHtml } from "../core/全局配置.js";
|
|
| 25 |
// Toast 队列管理(避免重叠)
|
| 26 |
let toastQueue = [];
|
| 27 |
let toastOffset = 50;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
/**
|
| 30 |
* 显示 Toast 消息提示
|
|
@@ -34,63 +42,35 @@ let toastOffset = 50;
|
|
| 34 |
*/
|
| 35 |
export function showToast(message, type = "info", duration = 3000) {
|
| 36 |
const toast = document.createElement("div");
|
| 37 |
-
const
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
info: "ℹ️",
|
| 47 |
-
warning: "⚠️"
|
| 48 |
-
};
|
| 49 |
-
|
| 50 |
-
// 计算位置(避免重叠)
|
| 51 |
-
const topPosition = toastOffset + toastQueue.length * 60;
|
| 52 |
-
|
| 53 |
-
Object.assign(toast.style, {
|
| 54 |
-
position: "fixed",
|
| 55 |
-
top: `${topPosition - 30}px`,
|
| 56 |
-
left: "50%",
|
| 57 |
-
transform: "translateX(-50%)",
|
| 58 |
-
background: colors[type] || colors.info,
|
| 59 |
-
color: "#fff",
|
| 60 |
-
padding: "12px 24px",
|
| 61 |
-
borderRadius: "8px",
|
| 62 |
-
fontSize: "14px",
|
| 63 |
-
fontWeight: "500",
|
| 64 |
-
zIndex: "10000",
|
| 65 |
-
boxShadow: "0 4px 20px rgba(0,0,0,0.3)",
|
| 66 |
-
opacity: "0",
|
| 67 |
-
transition: "all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)",
|
| 68 |
-
pointerEvents: "auto",
|
| 69 |
-
display: "flex",
|
| 70 |
-
alignItems: "center",
|
| 71 |
-
gap: "10px",
|
| 72 |
-
maxWidth: "400px",
|
| 73 |
-
wordBreak: "break-word"
|
| 74 |
-
});
|
| 75 |
|
| 76 |
// 提示文本统一转义:message 常携带昵称/标题/后端报错等用户可控内容,
|
| 77 |
// 全库无任何调用方故意向 showToast 传 HTML 标签,故无条件转义不会改变现有显示效果。
|
|
|
|
| 78 |
toast.innerHTML = `
|
| 79 |
-
<span
|
| 80 |
-
<span
|
| 81 |
-
<
|
| 82 |
`;
|
| 83 |
|
| 84 |
document.body.appendChild(toast);
|
| 85 |
toastQueue.push(toast);
|
| 86 |
|
| 87 |
// 点击关闭
|
| 88 |
-
toast.querySelector(".toast-close").
|
| 89 |
|
| 90 |
// 入场动画
|
| 91 |
requestAnimationFrame(() => {
|
| 92 |
toast.style.opacity = "1";
|
| 93 |
-
|
| 94 |
});
|
| 95 |
|
| 96 |
// 自动关闭
|
|
@@ -99,16 +79,17 @@ export function showToast(message, type = "info", duration = 3000) {
|
|
| 99 |
}
|
| 100 |
|
| 101 |
function closeToast(toast) {
|
|
|
|
| 102 |
if (toast._autoCloseTimer) clearTimeout(toast._autoCloseTimer);
|
| 103 |
|
| 104 |
-
toast.
|
| 105 |
-
toast.style.transform = "translateX(-50%) translateY(-20px)";
|
| 106 |
|
| 107 |
setTimeout(() => {
|
| 108 |
toast.remove();
|
| 109 |
const idx = toastQueue.indexOf(toast);
|
| 110 |
if (idx > -1) toastQueue.splice(idx, 1);
|
| 111 |
-
|
|
|
|
| 112 |
}
|
| 113 |
|
| 114 |
|
|
@@ -124,12 +105,9 @@ function closeToast(toast) {
|
|
| 124 |
*/
|
| 125 |
function _createDialogOverlay(bgOpacity = 0.6, blurPx = 3) {
|
| 126 |
const overlay = document.createElement("div");
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
alignItems: "center", justifyContent: "center", backdropFilter: `blur(${blurPx}px)`,
|
| 131 |
-
opacity: "0", transition: "opacity 0.2s"
|
| 132 |
-
});
|
| 133 |
return overlay;
|
| 134 |
}
|
| 135 |
|
|
@@ -144,16 +122,16 @@ function _createDialogOverlay(bgOpacity = 0.6, blurPx = 3) {
|
|
| 144 |
* @param {string} [transformOrigin] - 变换基点(影响初始 transform)
|
| 145 |
* @returns {HTMLElement}
|
| 146 |
*/
|
| 147 |
-
function _createDialogBox({ width = "360px", background = "#222", borderRadius = "12px", padding = "24px", boxShadow = "0 10px 40px rgba(0,0,0,0.5)", gap = "16px", hasYTranslate = true } = {}) {
|
| 148 |
const box = document.createElement("div");
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
return box;
|
| 158 |
}
|
| 159 |
|
|
@@ -164,6 +142,11 @@ function _createDialogBox({ width = "360px", background = "#222", borderRadius =
|
|
| 164 |
* @param {boolean} hasYTranslate - 是否包含 Y 轴位移
|
| 165 |
*/
|
| 166 |
function _showDialogAnimation(overlay, box, hasYTranslate = true) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
requestAnimationFrame(() => {
|
| 168 |
overlay.style.opacity = "1";
|
| 169 |
box.style.transform = hasYTranslate ? "scale(1) translateY(0)" : "scale(1)";
|
|
@@ -178,11 +161,75 @@ function _showDialogAnimation(overlay, box, hasYTranslate = true) {
|
|
| 178 |
* @param {Function} [callback] - 关闭完成后的回调
|
| 179 |
*/
|
| 180 |
function _closeDialog(overlay, box, hasYTranslate = true, callback) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
overlay.style.opacity = "0";
|
| 182 |
box.style.transform = hasYTranslate ? "scale(0.9) translateY(-20px)" : "scale(0.9)";
|
| 183 |
-
setTimeout(() => { overlay.remove(); if (callback) callback(); }, 200);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
}
|
| 185 |
|
|
|
|
|
|
|
|
|
|
| 186 |
// ==========================================
|
| 187 |
// ⚠️ 确认对话框
|
| 188 |
// ==========================================
|
|
@@ -196,37 +243,49 @@ function _closeDialog(overlay, box, hasYTranslate = true, callback) {
|
|
| 196 |
* @param {string} options.cancelText - 取消按钮文字
|
| 197 |
* @param {string} options.type - 类型: 'warning', 'danger', 'info'
|
| 198 |
* @param {boolean} options.html - 是否把 message 当作可信 HTML 渲染(默认 false,即按纯文本转义)
|
|
|
|
|
|
|
|
|
|
| 199 |
* @returns {Promise<boolean>}
|
| 200 |
*/
|
| 201 |
export function showConfirm(message, options = {}) {
|
| 202 |
const {
|
| 203 |
-
title =
|
| 204 |
-
confirmText =
|
| 205 |
-
cancelText =
|
| 206 |
type = "warning",
|
| 207 |
-
html = false
|
|
|
|
|
|
|
|
|
|
| 208 |
} = options;
|
| 209 |
|
| 210 |
const typeConfig = {
|
| 211 |
-
warning: { icon: "⚠️"
|
| 212 |
-
danger: { icon: "🚨"
|
| 213 |
-
info: { icon: "ℹ️"
|
| 214 |
};
|
| 215 |
-
const
|
|
|
|
| 216 |
|
|
|
|
| 217 |
return new Promise((resolve) => {
|
| 218 |
const overlay = _createDialogOverlay(0.6, 3);
|
| 219 |
const box = _createDialogBox({ width: "360px", hasYTranslate: true });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
|
| 221 |
box.innerHTML = `
|
| 222 |
-
<div
|
| 223 |
-
<span
|
| 224 |
<span>${escapeHtml(title)}</span>
|
| 225 |
</div>
|
| 226 |
-
<div
|
| 227 |
-
<div
|
| 228 |
-
<button id="ui-btn-cancel"
|
| 229 |
-
<button id="ui-btn-confirm"
|
| 230 |
</div>
|
| 231 |
`;
|
| 232 |
|
|
@@ -236,26 +295,65 @@ export function showConfirm(message, options = {}) {
|
|
| 236 |
// 入场动画
|
| 237 |
_showDialogAnimation(overlay, box, true);
|
| 238 |
|
| 239 |
-
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
};
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
| 249 |
};
|
| 250 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
|
| 252 |
box.querySelector("#ui-btn-cancel").onclick = () => {
|
| 253 |
-
document.removeEventListener("keydown", handleKeydown);
|
| 254 |
close(false);
|
| 255 |
};
|
| 256 |
-
box.querySelector("#ui-btn-confirm").onclick = () => {
|
| 257 |
-
|
| 258 |
-
close(true);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
};
|
| 260 |
});
|
| 261 |
}
|
|
@@ -275,14 +373,17 @@ export function showConfirm(message, options = {}) {
|
|
| 275 |
*/
|
| 276 |
export function showRetryDialog(message, retryFn, options = {}) {
|
| 277 |
const {
|
| 278 |
-
title =
|
| 279 |
-
retryText =
|
| 280 |
-
cancelText =
|
| 281 |
html = false
|
| 282 |
} = options;
|
| 283 |
|
| 284 |
return new Promise((resolve) => {
|
| 285 |
const overlay = _createDialogOverlay(0.7, 4);
|
|
|
|
|
|
|
|
|
|
| 286 |
const box = _createDialogBox({
|
| 287 |
width: "400px",
|
| 288 |
background: "linear-gradient(145deg, #2a2a2a, #1f1f1f)",
|
|
@@ -292,20 +393,19 @@ export function showRetryDialog(message, retryFn, options = {}) {
|
|
| 292 |
gap: "20px",
|
| 293 |
hasYTranslate: false
|
| 294 |
});
|
| 295 |
-
|
| 296 |
-
box.style.transition = "transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)";
|
| 297 |
|
| 298 |
box.innerHTML = `
|
| 299 |
-
<div
|
| 300 |
-
<div
|
| 301 |
-
<div
|
| 302 |
</div>
|
| 303 |
-
<div
|
| 304 |
${html ? message : escapeHtml(message)}
|
| 305 |
</div>
|
| 306 |
-
<div
|
| 307 |
-
<button id="ui-btn-cancel"
|
| 308 |
-
<button id="ui-btn-retry"
|
| 309 |
</div>
|
| 310 |
`;
|
| 311 |
|
|
@@ -315,35 +415,49 @@ export function showRetryDialog(message, retryFn, options = {}) {
|
|
| 315 |
// 入场动画
|
| 316 |
_showDialogAnimation(overlay, box, false);
|
| 317 |
|
| 318 |
-
|
| 319 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
|
| 322 |
box.querySelector("#ui-btn-cancel").onclick = () => {
|
| 323 |
-
|
| 324 |
-
resolve(false);
|
| 325 |
};
|
| 326 |
|
| 327 |
box.querySelector("#ui-btn-retry").onclick = async () => {
|
| 328 |
const retryBtn = box.querySelector("#ui-btn-retry");
|
| 329 |
retryBtn.innerHTML = `<span class="btn-spinner"></span> ${t('common.retrying')}`;
|
| 330 |
retryBtn.disabled = true;
|
|
|
|
| 331 |
|
| 332 |
try {
|
| 333 |
await retryFn();
|
| 334 |
-
|
| 335 |
-
resolve(true);
|
| 336 |
} catch (error) {
|
| 337 |
-
// 重试
|
| 338 |
const errText = error.message || t('common.retry_failed');
|
| 339 |
-
const errBox = box.querySelector("
|
| 340 |
if (html) {
|
| 341 |
-
errBox.innerHTML = errText;
|
| 342 |
} else {
|
| 343 |
errBox.textContent = errText;
|
| 344 |
}
|
| 345 |
retryBtn.innerHTML = escapeHtml(retryText);
|
| 346 |
retryBtn.disabled = false;
|
|
|
|
| 347 |
}
|
| 348 |
};
|
| 349 |
});
|
|
@@ -373,7 +487,7 @@ let loadingCount = 0;
|
|
| 373 |
* 显示全局加载遮罩
|
| 374 |
* @param {string} message - 加载提示文字
|
| 375 |
*/
|
| 376 |
-
export function showLoading(message =
|
| 377 |
loadingCount++;
|
| 378 |
|
| 379 |
if (loadingOverlay) {
|
|
@@ -461,7 +575,7 @@ export function hideLoading() {
|
|
| 461 |
* @param {boolean} loading - 是否加载中
|
| 462 |
* @param {string} loadingText - 加载时显示的文字
|
| 463 |
*/
|
| 464 |
-
export function setButtonLoading(button, loading, loadingText =
|
| 465 |
if (!button) return;
|
| 466 |
|
| 467 |
if (loading) {
|
|
@@ -494,7 +608,7 @@ export function setButtonLoading(button, loading, loadingText = "处理中...")
|
|
| 494 |
* @param {string} text - 要复制的文本
|
| 495 |
* @param {string} successMsg - 成功提示
|
| 496 |
*/
|
| 497 |
-
export async function copyToClipboard(text, successMsg =
|
| 498 |
try {
|
| 499 |
if (navigator.clipboard && navigator.clipboard.writeText) {
|
| 500 |
await navigator.clipboard.writeText(text);
|
|
@@ -510,7 +624,7 @@ export async function copyToClipboard(text, successMsg = "已复制到剪贴板"
|
|
| 510 |
}
|
| 511 |
showToast(successMsg, "success");
|
| 512 |
} catch (error) {
|
| 513 |
-
showToast(
|
| 514 |
}
|
| 515 |
}
|
| 516 |
|
|
@@ -527,9 +641,9 @@ export async function copyToClipboard(text, successMsg = "已复制到剪贴板"
|
|
| 527 |
*/
|
| 528 |
export async function withFeedback(asyncFn, options = {}) {
|
| 529 |
const {
|
| 530 |
-
loadingMsg =
|
| 531 |
-
successMsg =
|
| 532 |
-
errorMsg =
|
| 533 |
showLoadingOverlay = false,
|
| 534 |
button = null
|
| 535 |
} = options;
|
|
@@ -551,8 +665,7 @@ export async function withFeedback(asyncFn, options = {}) {
|
|
| 551 |
|
| 552 |
return result;
|
| 553 |
} catch (error) {
|
| 554 |
-
|
| 555 |
-
showToast(msg, "error");
|
| 556 |
throw error;
|
| 557 |
} finally {
|
| 558 |
if (showLoadingOverlay) {
|
|
@@ -572,58 +685,113 @@ export async function withFeedback(asyncFn, options = {}) {
|
|
| 572 |
/**
|
| 573 |
* 创建安装进度条
|
| 574 |
* @param {HTMLElement} container - 进度条容器元素
|
| 575 |
-
* @returns {{update: Function, complete: Function, error: Function, destroy: Function}}
|
| 576 |
*/
|
| 577 |
export function createInstallProgress(container) {
|
| 578 |
// 创建进度条DOM
|
|
|
|
| 579 |
const wrapper = document.createElement("div");
|
| 580 |
wrapper.className = "install-progress-wrapper";
|
| 581 |
wrapper.innerHTML = `
|
| 582 |
-
<div
|
| 583 |
-
<div
|
| 584 |
-
<span class="
|
| 585 |
-
<span class="
|
|
|
|
| 586 |
</div>
|
| 587 |
-
<div
|
| 588 |
-
<div class="
|
| 589 |
-
width: 0%;
|
| 590 |
-
height: 100%;
|
| 591 |
-
background: linear-gradient(90deg, #2196F3, #4CAF50);
|
| 592 |
-
border-radius: 3px;
|
| 593 |
-
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
| 594 |
-
"></div>
|
| 595 |
</div>
|
| 596 |
</div>
|
| 597 |
`;
|
| 598 |
container.innerHTML = "";
|
| 599 |
container.appendChild(wrapper);
|
| 600 |
|
| 601 |
-
const fill = wrapper.querySelector(".
|
| 602 |
-
const stage = wrapper.querySelector(".
|
| 603 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 604 |
|
| 605 |
return {
|
| 606 |
update(progress, message) {
|
| 607 |
const p = Math.max(0, Math.min(100, progress));
|
| 608 |
fill.style.width = `${p}%`;
|
| 609 |
percent.textContent = `${p}%`;
|
| 610 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 611 |
},
|
| 612 |
complete(message) {
|
|
|
|
|
|
|
| 613 |
fill.style.width = "100%";
|
| 614 |
-
fill.style.background = "linear-gradient(90deg, #4CAF50, #8BC34A)";
|
| 615 |
percent.textContent = "100%";
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
stage.
|
|
|
|
| 619 |
},
|
| 620 |
error(message) {
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
stage.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 625 |
},
|
| 626 |
destroy() {
|
|
|
|
| 627 |
wrapper.remove();
|
| 628 |
}
|
| 629 |
};
|
|
|
|
| 25 |
// Toast 队列管理(避免重叠)
|
| 26 |
let toastQueue = [];
|
| 27 |
let toastOffset = 50;
|
| 28 |
+
let dialogInstanceId = 0;
|
| 29 |
+
const dialogStack = [];
|
| 30 |
+
|
| 31 |
+
function repositionToasts() {
|
| 32 |
+
toastQueue.forEach((toast, index) => {
|
| 33 |
+
toast.style.top = `${toastOffset + index * 60}px`;
|
| 34 |
+
});
|
| 35 |
+
}
|
| 36 |
|
| 37 |
/**
|
| 38 |
* 显示 Toast 消息提示
|
|
|
|
| 42 |
*/
|
| 43 |
export function showToast(message, type = "info", duration = 3000) {
|
| 44 |
const toast = document.createElement("div");
|
| 45 |
+
const safeType = ["success", "error", "warning", "info"].includes(type) ? type : "info";
|
| 46 |
+
toast.className = `comfy-ranking-toast comfy-ranking-toast--${safeType}`;
|
| 47 |
+
toast.setAttribute("role", type === "error" ? "alert" : "status");
|
| 48 |
+
toast.setAttribute("aria-live", safeType === "error" ? "assertive" : "polite");
|
| 49 |
+
toast.setAttribute("aria-atomic", "true");
|
| 50 |
+
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
|
| 51 |
+
if (reduceMotion) toast.classList.add("comfy-ranking-toast--reduced-motion");
|
| 52 |
+
// 新提示从目标位置上方滑入;关闭后再按当前队列位置重排。
|
| 53 |
+
toast.style.top = `${toastOffset + toastQueue.length * 60 - 30}px`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
// 提示文本统一转义:message 常携带昵称/标题/后端报错等用户可控内容,
|
| 56 |
// 全库无任何调用方故意向 showToast 传 HTML 标签,故无条件转义不会改变现有显示效果。
|
| 57 |
+
const icons = { success: "✅", error: "❌", info: "ℹ️", warning: "⚠️" };
|
| 58 |
toast.innerHTML = `
|
| 59 |
+
<span class="comfy-ranking-toast__icon" aria-hidden="true">${icons[safeType]}</span>
|
| 60 |
+
<span class="comfy-ranking-toast__message">${escapeHtml(message)}</span>
|
| 61 |
+
<button type="button" class="toast-close comfy-ranking-toast__close" aria-label="${escapeHtml(t('common.close'))}">×</button>
|
| 62 |
`;
|
| 63 |
|
| 64 |
document.body.appendChild(toast);
|
| 65 |
toastQueue.push(toast);
|
| 66 |
|
| 67 |
// 点击关闭
|
| 68 |
+
toast.querySelector(".toast-close").addEventListener("click", () => closeToast(toast));
|
| 69 |
|
| 70 |
// 入场动画
|
| 71 |
requestAnimationFrame(() => {
|
| 72 |
toast.style.opacity = "1";
|
| 73 |
+
repositionToasts();
|
| 74 |
});
|
| 75 |
|
| 76 |
// 自动关闭
|
|
|
|
| 79 |
}
|
| 80 |
|
| 81 |
function closeToast(toast) {
|
| 82 |
+
if (!toast?.isConnected || toast.classList.contains("comfy-ranking-toast--closing")) return;
|
| 83 |
if (toast._autoCloseTimer) clearTimeout(toast._autoCloseTimer);
|
| 84 |
|
| 85 |
+
toast.classList.add("comfy-ranking-toast--closing");
|
|
|
|
| 86 |
|
| 87 |
setTimeout(() => {
|
| 88 |
toast.remove();
|
| 89 |
const idx = toastQueue.indexOf(toast);
|
| 90 |
if (idx > -1) toastQueue.splice(idx, 1);
|
| 91 |
+
repositionToasts();
|
| 92 |
+
}, window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ? 0 : 300);
|
| 93 |
}
|
| 94 |
|
| 95 |
|
|
|
|
| 105 |
*/
|
| 106 |
function _createDialogOverlay(bgOpacity = 0.6, blurPx = 3) {
|
| 107 |
const overlay = document.createElement("div");
|
| 108 |
+
overlay.className = "comfy-ranking-dialog-overlay";
|
| 109 |
+
overlay.style.setProperty("--dialog-overlay-opacity", String(bgOpacity));
|
| 110 |
+
overlay.style.setProperty("--dialog-blur", `${blurPx}px`);
|
|
|
|
|
|
|
|
|
|
| 111 |
return overlay;
|
| 112 |
}
|
| 113 |
|
|
|
|
| 122 |
* @param {string} [transformOrigin] - 变换基点(影响初始 transform)
|
| 123 |
* @returns {HTMLElement}
|
| 124 |
*/
|
| 125 |
+
function _createDialogBox({ width = "360px", background = "var(--comfy-menu-bg, #222)", borderRadius = "12px", padding = "24px", boxShadow = "0 10px 40px rgba(0,0,0,0.5)", gap = "16px", hasYTranslate = true } = {}) {
|
| 126 |
const box = document.createElement("div");
|
| 127 |
+
box.className = "comfy-ranking-dialog";
|
| 128 |
+
box.dataset.motion = hasYTranslate ? "translate" : "scale";
|
| 129 |
+
box.style.setProperty("--dialog-width", width);
|
| 130 |
+
box.style.setProperty("--dialog-background", background);
|
| 131 |
+
box.style.setProperty("--dialog-radius", borderRadius);
|
| 132 |
+
box.style.setProperty("--dialog-padding", padding);
|
| 133 |
+
box.style.setProperty("--dialog-shadow", boxShadow);
|
| 134 |
+
box.style.setProperty("--dialog-gap", gap);
|
| 135 |
return box;
|
| 136 |
}
|
| 137 |
|
|
|
|
| 142 |
* @param {boolean} hasYTranslate - 是否包含 Y 轴位移
|
| 143 |
*/
|
| 144 |
function _showDialogAnimation(overlay, box, hasYTranslate = true) {
|
| 145 |
+
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
|
| 146 |
+
overlay.style.opacity = "1";
|
| 147 |
+
box.style.transform = hasYTranslate ? "scale(1) translateY(0)" : "scale(1)";
|
| 148 |
+
return;
|
| 149 |
+
}
|
| 150 |
requestAnimationFrame(() => {
|
| 151 |
overlay.style.opacity = "1";
|
| 152 |
box.style.transform = hasYTranslate ? "scale(1) translateY(0)" : "scale(1)";
|
|
|
|
| 161 |
* @param {Function} [callback] - 关闭完成后的回调
|
| 162 |
*/
|
| 163 |
function _closeDialog(overlay, box, hasYTranslate = true, callback) {
|
| 164 |
+
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
|
| 165 |
+
if (reduceMotion) {
|
| 166 |
+
overlay.style.transition = "none";
|
| 167 |
+
box.style.transition = "none";
|
| 168 |
+
}
|
| 169 |
overlay.style.opacity = "0";
|
| 170 |
box.style.transform = hasYTranslate ? "scale(0.9) translateY(-20px)" : "scale(0.9)";
|
| 171 |
+
setTimeout(() => { overlay.remove(); if (callback) callback(); }, reduceMotion ? 0 : 200);
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
/** 为共用弹窗提供模态语义、焦点约束与关闭后的焦点恢复。 */
|
| 175 |
+
function _setupDialogAccessibility(box, titleId, descriptionId, initialFocusSelector, onEscape) {
|
| 176 |
+
const previousFocus = document.activeElement;
|
| 177 |
+
box.setAttribute("role", "dialog");
|
| 178 |
+
box.setAttribute("aria-modal", "true");
|
| 179 |
+
box.setAttribute("aria-labelledby", titleId);
|
| 180 |
+
if (descriptionId) box.setAttribute("aria-describedby", descriptionId);
|
| 181 |
+
else box.removeAttribute("aria-describedby");
|
| 182 |
+
box.setAttribute("tabindex", "-1");
|
| 183 |
+
|
| 184 |
+
const getFocusable = () => Array.from(box.querySelectorAll(
|
| 185 |
+
'button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'
|
| 186 |
+
)).filter(element => !element.closest('[inert], [hidden], [aria-hidden="true"]')
|
| 187 |
+
&& element.getClientRects().length > 0 && getComputedStyle(element).visibility !== "hidden");
|
| 188 |
+
|
| 189 |
+
const handleKeydown = event => {
|
| 190 |
+
if (dialogStack[dialogStack.length - 1] !== box) return;
|
| 191 |
+
if (event.key === "Escape") {
|
| 192 |
+
event.preventDefault();
|
| 193 |
+
onEscape();
|
| 194 |
+
return;
|
| 195 |
+
}
|
| 196 |
+
if (event.key !== "Tab") return;
|
| 197 |
+
const focusable = getFocusable();
|
| 198 |
+
if (!focusable.length) {
|
| 199 |
+
event.preventDefault();
|
| 200 |
+
box.focus();
|
| 201 |
+
return;
|
| 202 |
+
}
|
| 203 |
+
const first = focusable[0];
|
| 204 |
+
const last = focusable[focusable.length - 1];
|
| 205 |
+
if (event.shiftKey && (document.activeElement === first || !box.contains(document.activeElement))) {
|
| 206 |
+
event.preventDefault();
|
| 207 |
+
last.focus();
|
| 208 |
+
} else if (!event.shiftKey && (document.activeElement === last || !box.contains(document.activeElement))) {
|
| 209 |
+
event.preventDefault();
|
| 210 |
+
first.focus();
|
| 211 |
+
}
|
| 212 |
+
};
|
| 213 |
+
dialogStack.push(box);
|
| 214 |
+
document.addEventListener("keydown", handleKeydown);
|
| 215 |
+
const initialFocus = box.querySelector(initialFocusSelector) || getFocusable()[0] || box;
|
| 216 |
+
initialFocus.focus();
|
| 217 |
+
|
| 218 |
+
return {
|
| 219 |
+
cleanup() {
|
| 220 |
+
document.removeEventListener("keydown", handleKeydown);
|
| 221 |
+
const stackIndex = dialogStack.lastIndexOf(box);
|
| 222 |
+
if (stackIndex >= 0) dialogStack.splice(stackIndex, 1);
|
| 223 |
+
},
|
| 224 |
+
restoreFocus() {
|
| 225 |
+
if (previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus();
|
| 226 |
+
}
|
| 227 |
+
};
|
| 228 |
}
|
| 229 |
|
| 230 |
+
// 业务弹窗复用相同的模态栈、焦点循环与恢复行为;调用方负责在关闭时 cleanup。
|
| 231 |
+
export { _setupDialogAccessibility as setupDialogAccessibility };
|
| 232 |
+
|
| 233 |
// ==========================================
|
| 234 |
// ⚠️ 确认对话框
|
| 235 |
// ==========================================
|
|
|
|
| 243 |
* @param {string} options.cancelText - 取消按钮文字
|
| 244 |
* @param {string} options.type - 类型: 'warning', 'danger', 'info'
|
| 245 |
* @param {boolean} options.html - 是否把 message 当作可信 HTML 渲染(默认 false,即按纯文本转义)
|
| 246 |
+
* @param {Function} options.onConfirm - 可选异步操作,返回 false 时按未成功关闭;调用方负责业务错误提示
|
| 247 |
+
* @param {string} options.processingText - 异步操作期间的本地化状态文案
|
| 248 |
+
* @param {AbortSignal} [options.signal] - 强制释放原范围弹窗,不取消已提交业务操作
|
| 249 |
* @returns {Promise<boolean>}
|
| 250 |
*/
|
| 251 |
export function showConfirm(message, options = {}) {
|
| 252 |
const {
|
| 253 |
+
title = t('common.confirm_action'),
|
| 254 |
+
confirmText = t('common.confirm_execute'),
|
| 255 |
+
cancelText = t('common.cancel'),
|
| 256 |
type = "warning",
|
| 257 |
+
html = false,
|
| 258 |
+
onConfirm,
|
| 259 |
+
processingText = t('common.processing'),
|
| 260 |
+
signal
|
| 261 |
} = options;
|
| 262 |
|
| 263 |
const typeConfig = {
|
| 264 |
+
warning: { icon: "⚠️" },
|
| 265 |
+
danger: { icon: "🚨" },
|
| 266 |
+
info: { icon: "ℹ️" }
|
| 267 |
};
|
| 268 |
+
const safeType = Object.hasOwn(typeConfig, type) ? type : "warning";
|
| 269 |
+
const config = typeConfig[safeType];
|
| 270 |
|
| 271 |
+
if (signal?.aborted) return Promise.resolve(false);
|
| 272 |
return new Promise((resolve) => {
|
| 273 |
const overlay = _createDialogOverlay(0.6, 3);
|
| 274 |
const box = _createDialogBox({ width: "360px", hasYTranslate: true });
|
| 275 |
+
box.dataset.type = safeType;
|
| 276 |
+
const dialogId = ++dialogInstanceId;
|
| 277 |
+
const titleId = `ui-dialog-title-${dialogId}`;
|
| 278 |
+
const descriptionId = `ui-dialog-description-${dialogId}`;
|
| 279 |
|
| 280 |
box.innerHTML = `
|
| 281 |
+
<div id="${titleId}" class="comfy-ranking-dialog__title" role="heading" aria-level="2">
|
| 282 |
+
<span class="comfy-ranking-dialog__icon" aria-hidden="true">${config.icon}</span>
|
| 283 |
<span>${escapeHtml(title)}</span>
|
| 284 |
</div>
|
| 285 |
+
<div id="${descriptionId}" class="comfy-ranking-dialog__message${html ? '' : ' comfy-ranking-dialog__message--plain'}">${html ? message : escapeHtml(message)}</div>
|
| 286 |
+
<div class="comfy-ranking-dialog__actions">
|
| 287 |
+
<button id="ui-btn-cancel" class="comfy-ranking-dialog__button comfy-ranking-dialog__button--cancel" type="button">${escapeHtml(cancelText)}</button>
|
| 288 |
+
<button id="ui-btn-confirm" class="comfy-ranking-dialog__button comfy-ranking-dialog__button--confirm" type="button">${escapeHtml(confirmText)}</button>
|
| 289 |
</div>
|
| 290 |
`;
|
| 291 |
|
|
|
|
| 295 |
// 入场动画
|
| 296 |
_showDialogAnimation(overlay, box, true);
|
| 297 |
|
| 298 |
+
let settled = false, aborted = false;
|
| 299 |
+
let pending = false;
|
| 300 |
+
let dialogAccessibility, observer;
|
| 301 |
+
const abort = () => {
|
| 302 |
+
if (aborted) return;
|
| 303 |
+
aborted = true; settled = true; pending = false;
|
| 304 |
+
observer?.disconnect(); dialogAccessibility?.cleanup(); overlay.remove();
|
| 305 |
+
signal?.removeEventListener("abort", abort);
|
| 306 |
+
resolve(false);
|
| 307 |
};
|
| 308 |
+
const close = result => {
|
| 309 |
+
if (settled || pending) return;
|
| 310 |
+
settled = true;
|
| 311 |
+
observer?.disconnect();
|
| 312 |
+
dialogAccessibility?.cleanup();
|
| 313 |
+
_closeDialog(overlay, box, true, () => {
|
| 314 |
+
signal?.removeEventListener("abort", abort);
|
| 315 |
+
if (!aborted) dialogAccessibility?.restoreFocus();
|
| 316 |
+
resolve(aborted ? false : result);
|
| 317 |
+
});
|
| 318 |
};
|
| 319 |
+
|
| 320 |
+
dialogAccessibility = _setupDialogAccessibility(
|
| 321 |
+
box, titleId, descriptionId, "#ui-btn-cancel", () => close(false)
|
| 322 |
+
);
|
| 323 |
+
|
| 324 |
+
observer = new MutationObserver(() => { if (!overlay.isConnected) abort(); });
|
| 325 |
+
observer.observe(document.body, { childList: true, subtree: true });
|
| 326 |
+
signal?.addEventListener("abort", abort, { once: true });
|
| 327 |
+
if (signal?.aborted) { abort(); return; }
|
| 328 |
|
| 329 |
box.querySelector("#ui-btn-cancel").onclick = () => {
|
|
|
|
| 330 |
close(false);
|
| 331 |
};
|
| 332 |
+
box.querySelector("#ui-btn-confirm").onclick = async () => {
|
| 333 |
+
if (settled || pending) return;
|
| 334 |
+
if (typeof onConfirm !== "function") { close(true); return; }
|
| 335 |
+
pending = true;
|
| 336 |
+
box.setAttribute("aria-busy", "true");
|
| 337 |
+
box.querySelector("#ui-btn-cancel").disabled = true;
|
| 338 |
+
const confirmButton = box.querySelector("#ui-btn-confirm");
|
| 339 |
+
confirmButton.disabled = true;
|
| 340 |
+
confirmButton.textContent = processingText;
|
| 341 |
+
const progress = document.createElement("p");
|
| 342 |
+
progress.textContent = processingText;
|
| 343 |
+
progress.setAttribute("role", "status");
|
| 344 |
+
progress.setAttribute("aria-live", "polite");
|
| 345 |
+
box.querySelector(".comfy-ranking-dialog__message").appendChild(progress);
|
| 346 |
+
box.focus();
|
| 347 |
+
let succeeded = false;
|
| 348 |
+
try { succeeded = (await onConfirm()) !== false; }
|
| 349 |
+
catch {
|
| 350 |
+
if (!settled) showToast(t('common.operation_failed'), "error");
|
| 351 |
+
} finally {
|
| 352 |
+
if (settled) return;
|
| 353 |
+
pending = false;
|
| 354 |
+
box.removeAttribute("aria-busy");
|
| 355 |
+
close(succeeded);
|
| 356 |
+
}
|
| 357 |
};
|
| 358 |
});
|
| 359 |
}
|
|
|
|
| 373 |
*/
|
| 374 |
export function showRetryDialog(message, retryFn, options = {}) {
|
| 375 |
const {
|
| 376 |
+
title = t('common.error'),
|
| 377 |
+
retryText = `🔄 ${t('common.retry')}`,
|
| 378 |
+
cancelText = t('common.close'),
|
| 379 |
html = false
|
| 380 |
} = options;
|
| 381 |
|
| 382 |
return new Promise((resolve) => {
|
| 383 |
const overlay = _createDialogOverlay(0.7, 4);
|
| 384 |
+
const dialogId = ++dialogInstanceId;
|
| 385 |
+
const titleId = `ui-dialog-title-${dialogId}`;
|
| 386 |
+
const descriptionId = `ui-dialog-description-${dialogId}`;
|
| 387 |
const box = _createDialogBox({
|
| 388 |
width: "400px",
|
| 389 |
background: "linear-gradient(145deg, #2a2a2a, #1f1f1f)",
|
|
|
|
| 393 |
gap: "20px",
|
| 394 |
hasYTranslate: false
|
| 395 |
});
|
| 396 |
+
box.dataset.type = "retry";
|
|
|
|
| 397 |
|
| 398 |
box.innerHTML = `
|
| 399 |
+
<div class="comfy-ranking-dialog__header comfy-ranking-dialog__header--center">
|
| 400 |
+
<div class="comfy-ranking-dialog__illustration" aria-hidden="true">😕</div>
|
| 401 |
+
<div id="${titleId}" class="comfy-ranking-dialog__title comfy-ranking-dialog__title--center" role="heading" aria-level="2">${escapeHtml(title)}</div>
|
| 402 |
</div>
|
| 403 |
+
<div id="${descriptionId}" class="comfy-ranking-dialog__message comfy-ranking-dialog__message--center">
|
| 404 |
${html ? message : escapeHtml(message)}
|
| 405 |
</div>
|
| 406 |
+
<div class="comfy-ranking-dialog__actions comfy-ranking-dialog__actions--center">
|
| 407 |
+
<button id="ui-btn-cancel" class="comfy-ranking-dialog__button comfy-ranking-dialog__button--cancel" type="button">${escapeHtml(cancelText)}</button>
|
| 408 |
+
<button id="ui-btn-retry" class="comfy-ranking-dialog__button comfy-ranking-dialog__button--retry" type="button">${escapeHtml(retryText)}</button>
|
| 409 |
</div>
|
| 410 |
`;
|
| 411 |
|
|
|
|
| 415 |
// 入场动画
|
| 416 |
_showDialogAnimation(overlay, box, false);
|
| 417 |
|
| 418 |
+
let settled = false;
|
| 419 |
+
let retryInProgress = false;
|
| 420 |
+
let dialogAccessibility;
|
| 421 |
+
const settle = result => {
|
| 422 |
+
if (settled) return;
|
| 423 |
+
settled = true;
|
| 424 |
+
dialogAccessibility?.cleanup();
|
| 425 |
+
_closeDialog(overlay, box, false, () => {
|
| 426 |
+
dialogAccessibility?.restoreFocus();
|
| 427 |
+
resolve(result);
|
| 428 |
+
});
|
| 429 |
};
|
| 430 |
+
dialogAccessibility = _setupDialogAccessibility(
|
| 431 |
+
box, titleId, descriptionId, "#ui-btn-cancel", () => {
|
| 432 |
+
if (!retryInProgress) settle(false);
|
| 433 |
+
}
|
| 434 |
+
);
|
| 435 |
|
| 436 |
box.querySelector("#ui-btn-cancel").onclick = () => {
|
| 437 |
+
settle(false);
|
|
|
|
| 438 |
};
|
| 439 |
|
| 440 |
box.querySelector("#ui-btn-retry").onclick = async () => {
|
| 441 |
const retryBtn = box.querySelector("#ui-btn-retry");
|
| 442 |
retryBtn.innerHTML = `<span class="btn-spinner"></span> ${t('common.retrying')}`;
|
| 443 |
retryBtn.disabled = true;
|
| 444 |
+
retryInProgress = true;
|
| 445 |
|
| 446 |
try {
|
| 447 |
await retryFn();
|
| 448 |
+
settle(true);
|
|
|
|
| 449 |
} catch (error) {
|
| 450 |
+
// 重试错误可能来自远端或扩展,即使初始提示允许富文本也必须作为纯文本处理
|
| 451 |
const errText = error.message || t('common.retry_failed');
|
| 452 |
+
const errBox = box.querySelector(".comfy-ranking-dialog__message");
|
| 453 |
if (html) {
|
| 454 |
+
errBox.innerHTML = escapeHtml(errText);
|
| 455 |
} else {
|
| 456 |
errBox.textContent = errText;
|
| 457 |
}
|
| 458 |
retryBtn.innerHTML = escapeHtml(retryText);
|
| 459 |
retryBtn.disabled = false;
|
| 460 |
+
retryInProgress = false;
|
| 461 |
}
|
| 462 |
};
|
| 463 |
});
|
|
|
|
| 487 |
* 显示全局加载遮罩
|
| 488 |
* @param {string} message - 加载提示文字
|
| 489 |
*/
|
| 490 |
+
export function showLoading(message = t('feedback.processing')) {
|
| 491 |
loadingCount++;
|
| 492 |
|
| 493 |
if (loadingOverlay) {
|
|
|
|
| 575 |
* @param {boolean} loading - 是否加载中
|
| 576 |
* @param {string} loadingText - 加载时显示的文字
|
| 577 |
*/
|
| 578 |
+
export function setButtonLoading(button, loading, loadingText = t('feedback.processing')) {
|
| 579 |
if (!button) return;
|
| 580 |
|
| 581 |
if (loading) {
|
|
|
|
| 608 |
* @param {string} text - 要复制的文本
|
| 609 |
* @param {string} successMsg - 成功提示
|
| 610 |
*/
|
| 611 |
+
export async function copyToClipboard(text, successMsg = t('feedback.copied')) {
|
| 612 |
try {
|
| 613 |
if (navigator.clipboard && navigator.clipboard.writeText) {
|
| 614 |
await navigator.clipboard.writeText(text);
|
|
|
|
| 624 |
}
|
| 625 |
showToast(successMsg, "success");
|
| 626 |
} catch (error) {
|
| 627 |
+
showToast(t('feedback.copy_failed'), "error");
|
| 628 |
}
|
| 629 |
}
|
| 630 |
|
|
|
|
| 641 |
*/
|
| 642 |
export async function withFeedback(asyncFn, options = {}) {
|
| 643 |
const {
|
| 644 |
+
loadingMsg = t('feedback.processing'),
|
| 645 |
+
successMsg = t('feedback.operation_success'),
|
| 646 |
+
errorMsg = t('feedback.operation_failed'),
|
| 647 |
showLoadingOverlay = false,
|
| 648 |
button = null
|
| 649 |
} = options;
|
|
|
|
| 665 |
|
| 666 |
return result;
|
| 667 |
} catch (error) {
|
| 668 |
+
showToast(errorMsg, "error");
|
|
|
|
| 669 |
throw error;
|
| 670 |
} finally {
|
| 671 |
if (showLoadingOverlay) {
|
|
|
|
| 685 |
/**
|
| 686 |
* 创建安装进度条
|
| 687 |
* @param {HTMLElement} container - 进度条容器元素
|
| 688 |
+
* @returns {{update: Function, complete: Function, error: Function, showDiagnostics: Function, destroy: Function}}
|
| 689 |
*/
|
| 690 |
export function createInstallProgress(container) {
|
| 691 |
// 创建进度条DOM
|
| 692 |
+
const preparingLabel = t('install.preparing');
|
| 693 |
const wrapper = document.createElement("div");
|
| 694 |
wrapper.className = "install-progress-wrapper";
|
| 695 |
wrapper.innerHTML = `
|
| 696 |
+
<div class="install-progress">
|
| 697 |
+
<div class="install-progress__header">
|
| 698 |
+
<span class="install-progress__stage" role="status" aria-live="polite">${escapeHtml(preparingLabel)}</span>
|
| 699 |
+
<span class="install-progress__elapsed" role="timer" aria-live="off"></span>
|
| 700 |
+
<span class="install-progress__percent">0%</span>
|
| 701 |
</div>
|
| 702 |
+
<div class="install-progress__track" role="progressbar" aria-label="${escapeHtml(t('install.progress_label'))}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-valuetext="${escapeHtml(preparingLabel)}">
|
| 703 |
+
<div class="install-progress__fill"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 704 |
</div>
|
| 705 |
</div>
|
| 706 |
`;
|
| 707 |
container.innerHTML = "";
|
| 708 |
container.appendChild(wrapper);
|
| 709 |
|
| 710 |
+
const fill = wrapper.querySelector(".install-progress__fill");
|
| 711 |
+
const stage = wrapper.querySelector(".install-progress__stage");
|
| 712 |
+
const elapsed = wrapper.querySelector(".install-progress__elapsed");
|
| 713 |
+
const percent = wrapper.querySelector(".install-progress__percent");
|
| 714 |
+
const progressTrack = wrapper.querySelector(".install-progress__track");
|
| 715 |
+
const startedAt = Date.now();
|
| 716 |
+
let cancelButton = null;
|
| 717 |
+
const updateElapsed = () => {
|
| 718 |
+
const totalSeconds = Math.floor((Date.now() - startedAt) / 1000);
|
| 719 |
+
const time = `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, "0")}`;
|
| 720 |
+
const elapsedLabel = t('install.elapsed', { time });
|
| 721 |
+
elapsed.textContent = elapsedLabel;
|
| 722 |
+
elapsed.setAttribute("aria-label", elapsedLabel);
|
| 723 |
+
};
|
| 724 |
+
const elapsedTimer = setInterval(updateElapsed, 1000);
|
| 725 |
+
const stopElapsedTimer = () => clearInterval(elapsedTimer);
|
| 726 |
+
updateElapsed();
|
| 727 |
|
| 728 |
return {
|
| 729 |
update(progress, message) {
|
| 730 |
const p = Math.max(0, Math.min(100, progress));
|
| 731 |
fill.style.width = `${p}%`;
|
| 732 |
percent.textContent = `${p}%`;
|
| 733 |
+
progressTrack.setAttribute("aria-valuenow", String(p));
|
| 734 |
+
if (message) {
|
| 735 |
+
stage.textContent = message;
|
| 736 |
+
progressTrack.setAttribute("aria-valuetext", message);
|
| 737 |
+
}
|
| 738 |
},
|
| 739 |
complete(message) {
|
| 740 |
+
stopElapsedTimer();
|
| 741 |
+
cancelButton?.remove();
|
| 742 |
fill.style.width = "100%";
|
|
|
|
| 743 |
percent.textContent = "100%";
|
| 744 |
+
progressTrack.setAttribute("aria-valuenow", "100");
|
| 745 |
+
wrapper.classList.add("is-complete");
|
| 746 |
+
stage.textContent = message || `✅ ${t('install.complete')}`;
|
| 747 |
+
progressTrack.setAttribute("aria-valuetext", stage.textContent);
|
| 748 |
},
|
| 749 |
error(message) {
|
| 750 |
+
stopElapsedTimer();
|
| 751 |
+
cancelButton?.remove();
|
| 752 |
+
wrapper.classList.add("is-error");
|
| 753 |
+
stage.textContent = message || `❌ ${t('install.failed')}`;
|
| 754 |
+
progressTrack.setAttribute("aria-valuetext", stage.textContent);
|
| 755 |
+
},
|
| 756 |
+
showDiagnostics(entries) {
|
| 757 |
+
if (!Array.isArray(entries) || entries.length === 0) return;
|
| 758 |
+
const details = document.createElement("details");
|
| 759 |
+
details.className = "install-progress__diagnostics";
|
| 760 |
+
const summary = document.createElement("summary");
|
| 761 |
+
summary.textContent = t('install.diagnostics');
|
| 762 |
+
details.appendChild(summary);
|
| 763 |
+
|
| 764 |
+
const list = document.createElement("ol");
|
| 765 |
+
for (const entry of entries.slice(-50)) {
|
| 766 |
+
const item = document.createElement("li");
|
| 767 |
+
const stageKey = entry.stageKey;
|
| 768 |
+
const stageLabel = stageKey ? t(stageKey) : (entry.stage || t('install.stage_unknown'));
|
| 769 |
+
item.textContent = t('install.diagnostic_entry', {
|
| 770 |
+
time: entry.time,
|
| 771 |
+
stage: stageLabel,
|
| 772 |
+
progress: Number.isFinite(entry.progress) ? `${entry.progress}%` : "—"
|
| 773 |
+
});
|
| 774 |
+
list.appendChild(item);
|
| 775 |
+
}
|
| 776 |
+
details.appendChild(list);
|
| 777 |
+
wrapper.appendChild(details);
|
| 778 |
+
},
|
| 779 |
+
addCancelButton(label, onCancel) {
|
| 780 |
+
if (cancelButton || typeof onCancel !== "function") return;
|
| 781 |
+
cancelButton = document.createElement("button");
|
| 782 |
+
cancelButton.type = "button";
|
| 783 |
+
cancelButton.className = "install-progress__cancel";
|
| 784 |
+
cancelButton.textContent = label;
|
| 785 |
+
cancelButton.addEventListener("click", () => {
|
| 786 |
+
if (cancelButton.disabled) return;
|
| 787 |
+
cancelButton.disabled = true;
|
| 788 |
+
cancelButton.textContent = t('install.cancelling');
|
| 789 |
+
onCancel();
|
| 790 |
+
});
|
| 791 |
+
wrapper.appendChild(cancelButton);
|
| 792 |
},
|
| 793 |
destroy() {
|
| 794 |
+
stopElapsedTimer();
|
| 795 |
wrapper.remove();
|
| 796 |
}
|
| 797 |
};
|
前端页面/components/互动工具函数.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
| 8 |
|
| 9 |
import { showToast } from "./UI交互提示组件.js";
|
| 10 |
import { t } from "./用户体验增强.js";
|
|
|
|
|
|
|
| 11 |
// 本模块内部也需要转义(下方 renderInteractionButtonsHTML);re-export 不会绑定本地标识符,故单独 import
|
| 12 |
import { escapeHtml } from "../core/全局配置.js";
|
| 13 |
|
|
@@ -23,7 +25,7 @@ const VIEW_DEBOUNCE_MS = 300000; // 5分钟成功冷却
|
|
| 23 |
const VIEW_ERROR_COOLDOWN_MS = 30000; // 30秒错误冷却
|
| 24 |
|
| 25 |
// 防重复提交状态跟踪
|
| 26 |
-
const _pendingActions = new
|
| 27 |
|
| 28 |
/**
|
| 29 |
* 记录内容浏览量(带5分钟防抖)
|
|
@@ -131,7 +133,8 @@ export function renderInteractionButtonsHTML(data, currentAccount, options = {})
|
|
| 131 |
* @param {string} options.activeToastKey - 激活时 toast 的 i18n key
|
| 132 |
* @param {string} [options.activeTextColor] - 激活时的按钮文字色(可选)
|
| 133 |
* @param {string} [options.inactiveTextColor] - 取消激活时的按钮文字色(可选)
|
| 134 |
-
* @param {string}
|
|
|
|
| 135 |
* @returns {Promise<Object|null>}
|
| 136 |
*/
|
| 137 |
async function _handleToggleAction(apiMethod, contentId, buttonEl, countEl, currentUser, options) {
|
|
@@ -143,29 +146,37 @@ async function _handleToggleAction(apiMethod, contentId, buttonEl, countEl, curr
|
|
| 143 |
activeToastKey,
|
| 144 |
activeTextColor = null,
|
| 145 |
inactiveTextColor = null,
|
| 146 |
-
|
|
|
|
| 147 |
} = options;
|
| 148 |
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
showToast(t('auth.login_required'), "warning");
|
| 151 |
return null;
|
| 152 |
}
|
| 153 |
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
_pendingActions.
|
|
|
|
|
|
|
|
|
|
| 157 |
|
| 158 |
if (buttonEl) buttonEl.disabled = true;
|
| 159 |
|
| 160 |
try {
|
| 161 |
const res = await apiMethod(contentId);
|
| 162 |
-
|
| 163 |
-
if (
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
}
|
| 167 |
-
|
| 168 |
-
const count = typeof res[countField] === 'number' ? res[countField] : 0;
|
| 169 |
if (countEl) countEl.textContent = count;
|
| 170 |
|
| 171 |
if (res.action === activeAction) {
|
|
@@ -178,14 +189,14 @@ async function _handleToggleAction(apiMethod, contentId, buttonEl, countEl, curr
|
|
| 178 |
if (buttonEl) buttonEl.style.borderColor = "#555";
|
| 179 |
if (buttonEl && inactiveTextColor) buttonEl.style.color = inactiveTextColor;
|
| 180 |
}
|
|
|
|
| 181 |
return res;
|
| 182 |
-
} catch (
|
| 183 |
-
|
| 184 |
-
showToast(t('task.operation_failed'), "error");
|
| 185 |
return null;
|
| 186 |
} finally {
|
| 187 |
-
|
| 188 |
-
if (buttonEl) buttonEl.disabled = false;
|
| 189 |
}
|
| 190 |
}
|
| 191 |
|
|
@@ -196,16 +207,18 @@ async function _handleToggleAction(apiMethod, contentId, buttonEl, countEl, curr
|
|
| 196 |
* @param {HTMLElement} buttonEl - 按钮元素
|
| 197 |
* @param {HTMLElement} countEl - 计数元素
|
| 198 |
* @param {Object} currentUser - 当前用户
|
|
|
|
| 199 |
* @returns {Promise<Object|null>}
|
| 200 |
*/
|
| 201 |
-
export async function handleToggleLike(apiMethod, contentId, buttonEl, countEl, currentUser) {
|
| 202 |
return _handleToggleAction(apiMethod, contentId, buttonEl, countEl, currentUser, {
|
| 203 |
actionPrefix: 'like',
|
| 204 |
countField: 'likes',
|
| 205 |
activeAction: 'liked',
|
| 206 |
activeColor: '#FF5722',
|
| 207 |
activeToastKey: 'post.liked',
|
| 208 |
-
|
|
|
|
| 209 |
});
|
| 210 |
}
|
| 211 |
|
|
@@ -216,9 +229,10 @@ export async function handleToggleLike(apiMethod, contentId, buttonEl, countEl,
|
|
| 216 |
* @param {HTMLElement} buttonEl - 按钮元素
|
| 217 |
* @param {HTMLElement} countEl - 计数元素
|
| 218 |
* @param {Object} currentUser - 当前用户
|
|
|
|
| 219 |
* @returns {Promise<Object|null>}
|
| 220 |
*/
|
| 221 |
-
export async function handleToggleFavorite(apiMethod, contentId, buttonEl, countEl, currentUser) {
|
| 222 |
return _handleToggleAction(apiMethod, contentId, buttonEl, countEl, currentUser, {
|
| 223 |
actionPrefix: 'favorite',
|
| 224 |
countField: 'favorites',
|
|
@@ -227,7 +241,8 @@ export async function handleToggleFavorite(apiMethod, contentId, buttonEl, count
|
|
| 227 |
activeToastKey: 'post.favorited',
|
| 228 |
activeTextColor: '#000',
|
| 229 |
inactiveTextColor: '#fff',
|
| 230 |
-
|
|
|
|
| 231 |
});
|
| 232 |
}
|
| 233 |
|
|
|
|
| 8 |
|
| 9 |
import { showToast } from "./UI交互提示组件.js";
|
| 10 |
import { t } from "./用户体验增强.js";
|
| 11 |
+
import { captureRequestSession } from "../core/网络请求_基础设施.js";
|
| 12 |
+
import { getUser } from "../core/状态管理.js";
|
| 13 |
// 本模块内部也需要转义(下方 renderInteractionButtonsHTML);re-export 不会绑定本地标识符,故单独 import
|
| 14 |
import { escapeHtml } from "../core/全局配置.js";
|
| 15 |
|
|
|
|
| 25 |
const VIEW_ERROR_COOLDOWN_MS = 30000; // 30秒错误冷却
|
| 26 |
|
| 27 |
// 防重复提交状态跟踪
|
| 28 |
+
const _pendingActions = new WeakMap();
|
| 29 |
|
| 30 |
/**
|
| 31 |
* 记录内容浏览量(带5分钟防抖)
|
|
|
|
| 133 |
* @param {string} options.activeToastKey - 激活时 toast 的 i18n key
|
| 134 |
* @param {string} [options.activeTextColor] - 激活时的按钮文字色(可选)
|
| 135 |
* @param {string} [options.inactiveTextColor] - 取消激活时的按钮文字色(可选)
|
| 136 |
+
* @param {string} options.inactiveAction - 服务端取消动作值
|
| 137 |
+
* @param {Function} [options.isCurrent] - 原视图仍有效的检查
|
| 138 |
* @returns {Promise<Object|null>}
|
| 139 |
*/
|
| 140 |
async function _handleToggleAction(apiMethod, contentId, buttonEl, countEl, currentUser, options) {
|
|
|
|
| 146 |
activeToastKey,
|
| 147 |
activeTextColor = null,
|
| 148 |
inactiveTextColor = null,
|
| 149 |
+
inactiveAction,
|
| 150 |
+
isCurrent: viewCurrent = () => true
|
| 151 |
} = options;
|
| 152 |
|
| 153 |
+
const sessionMatches = captureRequestSession();
|
| 154 |
+
const originalHandler = buttonEl?.onclick;
|
| 155 |
+
const current = () => sessionMatches() && viewCurrent()
|
| 156 |
+
&& (!buttonEl || (buttonEl.isConnected && buttonEl.onclick === originalHandler))
|
| 157 |
+
&& (!countEl || countEl.isConnected)
|
| 158 |
+
&& (getUser()?.account || null) === (currentUser?.account || null);
|
| 159 |
+
if (!current()) return null;
|
| 160 |
+
if (!currentUser?.account) {
|
| 161 |
showToast(t('auth.login_required'), "warning");
|
| 162 |
return null;
|
| 163 |
}
|
| 164 |
|
| 165 |
+
// 同类内容ID可在不同业务存在;按实际API方法和账号隔离在途动作。
|
| 166 |
+
const actionKey = JSON.stringify([currentUser.account, actionPrefix, contentId]);
|
| 167 |
+
let pending = _pendingActions.get(apiMethod);
|
| 168 |
+
if (!pending) { pending = new Set(); _pendingActions.set(apiMethod, pending); }
|
| 169 |
+
if (pending.has(actionKey)) return null;
|
| 170 |
+
pending.add(actionKey);
|
| 171 |
|
| 172 |
if (buttonEl) buttonEl.disabled = true;
|
| 173 |
|
| 174 |
try {
|
| 175 |
const res = await apiMethod(contentId);
|
| 176 |
+
if (!current()) return null;
|
| 177 |
+
if (res?.status !== "success" || !Number.isSafeInteger(res[countField]) || res[countField] < 0
|
| 178 |
+
|| (res.action !== activeAction && res.action !== inactiveAction)) throw new Error();
|
| 179 |
+
const count = res[countField];
|
|
|
|
|
|
|
|
|
|
| 180 |
if (countEl) countEl.textContent = count;
|
| 181 |
|
| 182 |
if (res.action === activeAction) {
|
|
|
|
| 189 |
if (buttonEl) buttonEl.style.borderColor = "#555";
|
| 190 |
if (buttonEl && inactiveTextColor) buttonEl.style.color = inactiveTextColor;
|
| 191 |
}
|
| 192 |
+
if (buttonEl) buttonEl.setAttribute("aria-pressed", String(res.action === activeAction));
|
| 193 |
return res;
|
| 194 |
+
} catch (_) {
|
| 195 |
+
if (current()) showToast(t('task.operation_failed'), "error");
|
|
|
|
| 196 |
return null;
|
| 197 |
} finally {
|
| 198 |
+
pending.delete(actionKey);
|
| 199 |
+
if (current() && buttonEl) buttonEl.disabled = false;
|
| 200 |
}
|
| 201 |
}
|
| 202 |
|
|
|
|
| 207 |
* @param {HTMLElement} buttonEl - 按钮元素
|
| 208 |
* @param {HTMLElement} countEl - 计数元素
|
| 209 |
* @param {Object} currentUser - 当前用户
|
| 210 |
+
* @param {Object} [options] - 可选视图检查,原五参数调用保持
|
| 211 |
* @returns {Promise<Object|null>}
|
| 212 |
*/
|
| 213 |
+
export async function handleToggleLike(apiMethod, contentId, buttonEl, countEl, currentUser, options = {}) {
|
| 214 |
return _handleToggleAction(apiMethod, contentId, buttonEl, countEl, currentUser, {
|
| 215 |
actionPrefix: 'like',
|
| 216 |
countField: 'likes',
|
| 217 |
activeAction: 'liked',
|
| 218 |
activeColor: '#FF5722',
|
| 219 |
activeToastKey: 'post.liked',
|
| 220 |
+
inactiveAction: 'unliked',
|
| 221 |
+
isCurrent: options.isCurrent
|
| 222 |
});
|
| 223 |
}
|
| 224 |
|
|
|
|
| 229 |
* @param {HTMLElement} buttonEl - 按钮元素
|
| 230 |
* @param {HTMLElement} countEl - 计数元素
|
| 231 |
* @param {Object} currentUser - 当前用户
|
| 232 |
+
* @param {Object} [options] - 可选视图检查,原五参数调用保持
|
| 233 |
* @returns {Promise<Object|null>}
|
| 234 |
*/
|
| 235 |
+
export async function handleToggleFavorite(apiMethod, contentId, buttonEl, countEl, currentUser, options = {}) {
|
| 236 |
return _handleToggleAction(apiMethod, contentId, buttonEl, countEl, currentUser, {
|
| 237 |
actionPrefix: 'favorite',
|
| 238 |
countField: 'favorites',
|
|
|
|
| 241 |
activeToastKey: 'post.favorited',
|
| 242 |
activeTextColor: '#000',
|
| 243 |
inactiveTextColor: '#fff',
|
| 244 |
+
inactiveAction: 'unfavorited',
|
| 245 |
+
isCurrent: options.isCurrent
|
| 246 |
});
|
| 247 |
}
|
| 248 |
|
前端页面/components/全局弹窗管理器.js
CHANGED
|
@@ -1,138 +1,92 @@
|
|
| 1 |
-
//
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
export class ModalManager {
|
| 4 |
constructor() {
|
| 5 |
-
this.activeModals = [];
|
|
|
|
| 6 |
this.overlay = null;
|
|
|
|
| 7 |
this.initOverlay();
|
| 8 |
}
|
| 9 |
|
| 10 |
-
// 初始化全局半透明遮罩层
|
| 11 |
initOverlay() {
|
| 12 |
-
if (document.getElementById(
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
// 点击遮罩层时,关闭最顶层的一个弹窗
|
| 29 |
-
this.overlay.onclick = () => {
|
| 30 |
-
try { this.closeTopModal(); } catch(e) { console.warn('[ModalManager] closeTopModal error:', e); }
|
| 31 |
-
};
|
| 32 |
-
|
| 33 |
-
document.body.appendChild(this.overlay);
|
| 34 |
}
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
const modalContainer = document.createElement("div");
|
| 44 |
-
Object.assign(modalContainer.style, {
|
| 45 |
-
position: "fixed",
|
| 46 |
-
top: "50%",
|
| 47 |
-
left: "50%",
|
| 48 |
-
transform: "translate(-50%, -50%)",
|
| 49 |
-
width: options.width,
|
| 50 |
-
backgroundColor: "var(--comfy-menu-bg)",
|
| 51 |
-
border: "1px solid #555",
|
| 52 |
-
borderRadius: "12px",
|
| 53 |
-
boxShadow: "0 10px 30px rgba(0,0,0,0.8)",
|
| 54 |
-
zIndex: (9999 + this.activeModals.length).toString(), // 动态计算 z-index,实现层级覆盖
|
| 55 |
-
display: "flex",
|
| 56 |
-
flexDirection: "column",
|
| 57 |
-
overflow: "hidden"
|
| 58 |
-
});
|
| 59 |
-
|
| 60 |
-
// 弹窗头部 (包含标题和关闭按钮)
|
| 61 |
-
const header = document.createElement("div");
|
| 62 |
-
Object.assign(header.style, {
|
| 63 |
-
display: "flex",
|
| 64 |
-
justifyContent: "space-between",
|
| 65 |
-
alignItems: "center",
|
| 66 |
-
padding: "15px 20px",
|
| 67 |
-
borderBottom: "1px solid #444",
|
| 68 |
-
backgroundColor: "var(--comfy-input-bg)",
|
| 69 |
-
});
|
| 70 |
-
|
| 71 |
-
const titleSpan = document.createElement("span");
|
| 72 |
-
titleSpan.innerText = title;
|
| 73 |
-
Object.assign(titleSpan.style, {
|
| 74 |
-
fontWeight: "bold",
|
| 75 |
-
fontSize: "16px",
|
| 76 |
-
color: "#fff"
|
| 77 |
-
});
|
| 78 |
-
|
| 79 |
-
const closeBtn = document.createElement("button");
|
| 80 |
-
closeBtn.innerHTML = "✖";
|
| 81 |
-
Object.assign(closeBtn.style, {
|
| 82 |
-
background: "transparent",
|
| 83 |
-
border: "none",
|
| 84 |
-
color: "#aaa",
|
| 85 |
-
cursor: "pointer",
|
| 86 |
-
fontSize: "14px"
|
| 87 |
});
|
| 88 |
-
|
| 89 |
-
closeBtn.onclick = (e) => {
|
| 90 |
-
e.stopPropagation();
|
| 91 |
-
this.closeModal(modalContainer);
|
| 92 |
-
};
|
| 93 |
-
|
| 94 |
-
header.appendChild(titleSpan);
|
| 95 |
-
header.appendChild(closeBtn);
|
| 96 |
-
|
| 97 |
-
// 弹窗内容区
|
| 98 |
-
const contentWrapper = document.createElement("div");
|
| 99 |
-
Object.assign(contentWrapper.style, {
|
| 100 |
-
padding: "20px",
|
| 101 |
-
maxHeight: "70vh", // 防止弹窗过高,限制最大高度
|
| 102 |
-
overflowY: "auto",
|
| 103 |
-
color: "#eee"
|
| 104 |
-
});
|
| 105 |
-
contentWrapper.appendChild(contentElement);
|
| 106 |
-
|
| 107 |
-
modalContainer.appendChild(header);
|
| 108 |
-
modalContainer.appendChild(contentWrapper);
|
| 109 |
-
document.body.appendChild(modalContainer);
|
| 110 |
-
|
| 111 |
-
// 记录状态并显示遮罩
|
| 112 |
-
this.activeModals.push(modalContainer);
|
| 113 |
-
this.overlay.style.display = "block";
|
| 114 |
}
|
| 115 |
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
}
|
|
|
|
| 126 |
}
|
| 127 |
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
if (
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
}
|
| 134 |
}
|
|
|
|
|
|
|
| 135 |
}
|
| 136 |
|
| 137 |
-
|
| 138 |
-
export const globalModal = new ModalManager();
|
|
|
|
| 1 |
+
// 共用业务弹窗:与确认/重试提示复用同一个可访问性栈。
|
| 2 |
+
import { setupDialogAccessibility } from "./UI交互提示组件.js";
|
| 3 |
+
import { t } from "./用户体验增强.js";
|
| 4 |
+
let modalSequence = 0;
|
| 5 |
|
| 6 |
export class ModalManager {
|
| 7 |
constructor() {
|
| 8 |
+
this.activeModals = [];
|
| 9 |
+
this.entries = new Map();
|
| 10 |
this.overlay = null;
|
| 11 |
+
this.observer = null;
|
| 12 |
this.initOverlay();
|
| 13 |
}
|
| 14 |
|
|
|
|
| 15 |
initOverlay() {
|
| 16 |
+
if (!document.getElementById('hub-theme-style')) {
|
| 17 |
+
const link = document.createElement('link');
|
| 18 |
+
link.id = 'hub-theme-style'; link.rel = 'stylesheet';
|
| 19 |
+
link.href = new URL('../styles/主题基础.css', import.meta.url).href;
|
| 20 |
+
document.head.appendChild(link);
|
| 21 |
+
}
|
| 22 |
+
this.overlay = document.getElementById('community-hub-overlay');
|
| 23 |
+
if (!this.overlay) {
|
| 24 |
+
this.overlay = document.createElement('div');
|
| 25 |
+
this.overlay.id = 'community-hub-overlay';
|
| 26 |
+
this.overlay.className = 'ranking-modal-overlay';
|
| 27 |
+
this.overlay.hidden = true;
|
| 28 |
+
document.body.appendChild(this.overlay);
|
| 29 |
+
}
|
| 30 |
+
this.overlay.onclick = () => this.closeTopModal();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
}
|
| 32 |
|
| 33 |
+
syncLayers() {
|
| 34 |
+
const top = this.activeModals.at(-1);
|
| 35 |
+
this.activeModals.forEach((modal, index) => {
|
| 36 |
+
modal.style.zIndex = String(9999 + index);
|
| 37 |
+
modal.inert = modal !== top;
|
| 38 |
+
if (modal === top) { modal.removeAttribute('aria-hidden'); modal.setAttribute('aria-modal', 'true'); }
|
| 39 |
+
else { modal.setAttribute('aria-hidden', 'true'); modal.removeAttribute('aria-modal'); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
});
|
| 41 |
+
this.overlay.hidden = !top;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
}
|
| 43 |
|
| 44 |
+
openModal(title, contentElement, options = { width: '500px' }) {
|
| 45 |
+
const modal = document.createElement('div');
|
| 46 |
+
modal.className = 'ranking-modal';
|
| 47 |
+
modal.style.setProperty('--ranking-modal-width', options.width || '500px');
|
| 48 |
+
const header = document.createElement('div'); header.className = 'ranking-modal__header';
|
| 49 |
+
const titleNode = document.createElement('span'); titleNode.className = 'ranking-modal__title';
|
| 50 |
+
titleNode.id = `ranking-modal-title-${++modalSequence}`; titleNode.textContent = title;
|
| 51 |
+
const close = document.createElement('button'); close.type = 'button';
|
| 52 |
+
close.className = 'ranking-modal__close'; close.textContent = '✖';
|
| 53 |
+
close.setAttribute('aria-label', t('common.close'));
|
| 54 |
+
close.onclick = event => { event.stopPropagation(); this.closeModal(modal); };
|
| 55 |
+
header.append(titleNode, close);
|
| 56 |
+
const body = document.createElement('div'); body.className = 'ranking-modal__body'; body.appendChild(contentElement);
|
| 57 |
+
modal.append(header, body); document.body.appendChild(modal);
|
| 58 |
+
this.activeModals.push(modal); this.syncLayers();
|
| 59 |
+
const accessibility = setupDialogAccessibility(modal, titleNode.id, null, '.ranking-modal__close', () => this.closeModal(modal));
|
| 60 |
+
this.entries.set(modal, { content: contentElement, accessibility });
|
| 61 |
+
if (!this.observer) {
|
| 62 |
+
this.observer = new MutationObserver(() => {
|
| 63 |
+
for (const current of [...this.activeModals]) if (!current.isConnected) this.closeModal(current);
|
| 64 |
+
});
|
| 65 |
+
this.observer.observe(document.body, { childList: true, subtree: true });
|
| 66 |
}
|
| 67 |
+
return modal;
|
| 68 |
}
|
| 69 |
|
| 70 |
+
closeModal(modal) {
|
| 71 |
+
const entry = this.entries.get(modal);
|
| 72 |
+
if (!entry) return;
|
| 73 |
+
const wasTop = this.activeModals.at(-1) === modal;
|
| 74 |
+
const restore = wasTop && (modal.contains(document.activeElement) || document.activeElement === document.body);
|
| 75 |
+
this.entries.delete(modal);
|
| 76 |
+
entry.accessibility.cleanup();
|
| 77 |
+
try { entry.content._cleanup?.(); } catch (_) {}
|
| 78 |
+
modal.remove();
|
| 79 |
+
this.activeModals = this.activeModals.filter(current => current !== modal);
|
| 80 |
+
this.syncLayers();
|
| 81 |
+
if (!this.activeModals.length) { this.observer?.disconnect(); this.observer = null; }
|
| 82 |
+
if (restore) {
|
| 83 |
+
entry.accessibility.restoreFocus();
|
| 84 |
+
const top = this.activeModals.at(-1);
|
| 85 |
+
if (top && !top.contains(document.activeElement)) top.querySelector('.ranking-modal__close').focus();
|
| 86 |
}
|
| 87 |
}
|
| 88 |
+
|
| 89 |
+
closeTopModal() { this.closeModal(this.activeModals.at(-1)); }
|
| 90 |
}
|
| 91 |
|
| 92 |
+
export const globalModal = new ModalManager();
|
|
|
前端页面/components/关于插件组件.js
CHANGED
|
@@ -1,352 +1,180 @@
|
|
| 1 |
-
//
|
| 2 |
-
import {
|
| 3 |
-
import {
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
}
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
const
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
//
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
<div style="font-size: 13px; font-weight: bold; color: #ccc; margin-bottom: 2px;">${t('about.guide_update_title')}</div>
|
| 182 |
-
<div style="font-size: 12px; color: #888; line-height: 1.6;">${t('about.guide_update_desc')}</div>
|
| 183 |
-
</div>
|
| 184 |
-
</div>
|
| 185 |
-
<div style="display: flex; align-items: flex-start; gap: 8px;">
|
| 186 |
-
<span style="color: #00d4aa; font-weight: bold; font-size: 13px; flex-shrink: 0;">4.</span>
|
| 187 |
-
<div>
|
| 188 |
-
<div style="font-size: 13px; font-weight: bold; color: #ccc; margin-bottom: 2px;">${t('about.guide_publish_title')}</div>
|
| 189 |
-
<div style="font-size: 12px; color: #888; line-height: 1.6;">${t('about.guide_publish_desc')}</div>
|
| 190 |
-
</div>
|
| 191 |
-
</div>
|
| 192 |
-
<div style="display: flex; align-items: flex-start; gap: 8px;">
|
| 193 |
-
<span style="color: #00d4aa; font-weight: bold; font-size: 13px; flex-shrink: 0;">5.</span>
|
| 194 |
-
<div>
|
| 195 |
-
<div style="font-size: 13px; font-weight: bold; color: #ccc; margin-bottom: 2px;">${t('about.guide_buy_title')}</div>
|
| 196 |
-
<div style="font-size: 12px; color: #888; line-height: 1.6;">${t('about.guide_buy_desc')}</div>
|
| 197 |
-
</div>
|
| 198 |
-
</div>
|
| 199 |
-
<div style="display: flex; align-items: flex-start; gap: 8px;">
|
| 200 |
-
<span style="color: #00d4aa; font-weight: bold; font-size: 13px; flex-shrink: 0;">6.</span>
|
| 201 |
-
<div>
|
| 202 |
-
<div style="font-size: 13px; font-weight: bold; color: #ccc; margin-bottom: 2px;">${t('about.guide_refund_title')}</div>
|
| 203 |
-
<div style="font-size: 12px; color: #888; line-height: 1.6;">${t('about.guide_refund_desc')}</div>
|
| 204 |
-
</div>
|
| 205 |
-
</div>
|
| 206 |
-
<div style="display: flex; align-items: flex-start; gap: 8px;">
|
| 207 |
-
<span style="color: #00d4aa; font-weight: bold; font-size: 13px; flex-shrink: 0;">7.</span>
|
| 208 |
-
<div>
|
| 209 |
-
<div style="font-size: 13px; font-weight: bold; color: #ccc; margin-bottom: 2px;">${t('about.guide_withdraw_title')}</div>
|
| 210 |
-
<div style="font-size: 12px; color: #888; line-height: 1.6;">${t('about.guide_withdraw_desc')}</div>
|
| 211 |
-
</div>
|
| 212 |
-
</div>
|
| 213 |
-
</div>
|
| 214 |
-
`);
|
| 215 |
-
|
| 216 |
-
// ===== 5. 用户价值区 =====
|
| 217 |
-
const userValueSection = createCardSection("👤 " + t('about.benefits_title'), `
|
| 218 |
-
<div style="display: flex; flex-direction: column; gap: 10px; padding-left: 4px;">
|
| 219 |
-
<div style="display: flex; align-items: flex-start; gap: 8px;">
|
| 220 |
-
<span style="color: #00d4aa; font-weight: bold; font-size: 13px; flex-shrink: 0;">1.</span>
|
| 221 |
-
<span style="font-size: 13px; color: #ccc; line-height: 1.6;">${t('about.benefit_1')}</span>
|
| 222 |
-
</div>
|
| 223 |
-
<div style="display: flex; align-items: flex-start; gap: 8px;">
|
| 224 |
-
<span style="color: #00d4aa; font-weight: bold; font-size: 13px; flex-shrink: 0;">2.</span>
|
| 225 |
-
<span style="font-size: 13px; color: #ccc; line-height: 1.6;">${t('about.benefit_2')}</span>
|
| 226 |
-
</div>
|
| 227 |
-
<div style="display: flex; align-items: flex-start; gap: 8px;">
|
| 228 |
-
<span style="color: #00d4aa; font-weight: bold; font-size: 13px; flex-shrink: 0;">3.</span>
|
| 229 |
-
<span style="font-size: 13px; color: #ccc; line-height: 1.6;">${t('about.benefit_3')}</span>
|
| 230 |
-
</div>
|
| 231 |
-
<div style="display: flex; align-items: flex-start; gap: 8px;">
|
| 232 |
-
<span style="color: #00d4aa; font-weight: bold; font-size: 13px; flex-shrink: 0;">4.</span>
|
| 233 |
-
<span style="font-size: 13px; color: #ccc; line-height: 1.6;">${t('about.benefit_4')}</span>
|
| 234 |
-
</div>
|
| 235 |
-
<div style="display: flex; align-items: flex-start; gap: 8px;">
|
| 236 |
-
<span style="color: #00d4aa; font-weight: bold; font-size: 13px; flex-shrink: 0;">5.</span>
|
| 237 |
-
<span style="font-size: 13px; color: #ccc; line-height: 1.6;">${t('about.benefit_5')}</span>
|
| 238 |
-
</div>
|
| 239 |
-
</div>
|
| 240 |
-
`);
|
| 241 |
-
|
| 242 |
-
// ===== 6. 注意事项区 =====
|
| 243 |
-
const warningSection = document.createElement("div");
|
| 244 |
-
Object.assign(warningSection.style, {
|
| 245 |
-
background: "rgba(240, 160, 48, 0.05)",
|
| 246 |
-
borderRadius: "10px",
|
| 247 |
-
padding: "16px",
|
| 248 |
-
borderTop: "1px solid rgba(240, 160, 48, 0.15)",
|
| 249 |
-
borderRight: "1px solid rgba(240, 160, 48, 0.15)",
|
| 250 |
-
borderBottom: "1px solid rgba(240, 160, 48, 0.15)",
|
| 251 |
-
borderLeft: "3px solid #f0a030"
|
| 252 |
-
});
|
| 253 |
-
warningSection.innerHTML = `
|
| 254 |
-
<div style="font-size: 15px; font-weight: bold; color: #f0a030; margin-bottom: 10px; display: flex; align-items: center; gap: 6px;">
|
| 255 |
-
<span>⚠️</span> ${t('about.warning_title')}
|
| 256 |
-
</div>
|
| 257 |
-
<div style="display: flex; flex-direction: column; gap: 8px;">
|
| 258 |
-
<div style="font-size: 13px; color: #ccc; line-height: 1.6;">
|
| 259 |
-
<strong style="color: #f0a030;">${t('about.warning_login_title')}</strong>${t('about.warning_login_desc')}
|
| 260 |
-
</div>
|
| 261 |
-
<div style="font-size: 13px; color: #ccc; line-height: 1.6;">
|
| 262 |
-
<strong style="color: #f0a030;">${t('about.warning_account_title')}</strong>${t('about.warning_account_desc')}
|
| 263 |
-
</div>
|
| 264 |
-
</div>
|
| 265 |
-
`;
|
| 266 |
-
|
| 267 |
-
// ===== 7. 关于作者区 =====
|
| 268 |
-
const authorSection = document.createElement("div");
|
| 269 |
-
Object.assign(authorSection.style, { ...CARD_BASE_STYLE, textAlign: "center" });
|
| 270 |
-
authorSection.innerHTML = `
|
| 271 |
-
<div style="font-size: 15px; font-weight: bold; color: #00d4aa; margin-bottom: 12px;">${t('about.author_title')}</div>
|
| 272 |
-
<a href="https://space.bilibili.com/2114638644" target="_blank" style="text-decoration: none; display: inline-block;">
|
| 273 |
-
<img src="https://img.shields.io/badge/bilibili-猪的飞行梦-00A1D6?logo=bilibili&logoColor=white" alt="Bilibili" style="border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.3); transition: transform 0.2s;" onmouseover="this.style.transform='scale(1.05)'" onmouseout="this.style.transform='scale(1)'">
|
| 274 |
-
</a>
|
| 275 |
-
<div style="margin-top: 12px; font-size: 12px; color: #666;">
|
| 276 |
-
${t('about.author_thanks')}
|
| 277 |
-
</div>
|
| 278 |
-
`;
|
| 279 |
-
|
| 280 |
-
// ===== 返回按钮 =====
|
| 281 |
-
const backBtn = document.createElement("button");
|
| 282 |
-
Object.assign(backBtn.style, {
|
| 283 |
-
background: "rgba(51,51,51,0.8)",
|
| 284 |
-
border: "1px solid rgba(85,85,85,0.8)",
|
| 285 |
-
color: "#fff",
|
| 286 |
-
padding: "6px 14px",
|
| 287 |
-
borderRadius: "6px",
|
| 288 |
-
cursor: "pointer",
|
| 289 |
-
fontSize: "13px",
|
| 290 |
-
fontWeight: "bold",
|
| 291 |
-
display: "flex",
|
| 292 |
-
alignItems: "center",
|
| 293 |
-
gap: "6px",
|
| 294 |
-
boxShadow: "0 2px 4px rgba(0,0,0,0.3)",
|
| 295 |
-
width: "fit-content",
|
| 296 |
-
transition: "0.2s",
|
| 297 |
-
marginLeft: "14px",
|
| 298 |
-
marginTop: "14px",
|
| 299 |
-
marginBottom: "4px"
|
| 300 |
-
});
|
| 301 |
-
backBtn.innerHTML = "⬅ " + t('common.back');
|
| 302 |
-
backBtn.onmouseover = () => {
|
| 303 |
-
backBtn.style.background = "#4CAF50";
|
| 304 |
-
backBtn.style.borderColor = "#4CAF50";
|
| 305 |
-
};
|
| 306 |
-
backBtn.onmouseout = () => {
|
| 307 |
-
backBtn.style.background = "rgba(51,51,51,0.8)";
|
| 308 |
-
backBtn.style.borderColor = "rgba(85,85,85,0.8)";
|
| 309 |
-
};
|
| 310 |
-
backBtn.onclick = () => {
|
| 311 |
-
window.dispatchEvent(new CustomEvent("comfy-route-back"));
|
| 312 |
-
};
|
| 313 |
-
|
| 314 |
-
// 组装
|
| 315 |
-
container.appendChild(backBtn);
|
| 316 |
-
container.appendChild(heroSection);
|
| 317 |
-
container.appendChild(advantageSection);
|
| 318 |
-
container.appendChild(featureSection);
|
| 319 |
-
container.appendChild(guideSection);
|
| 320 |
-
container.appendChild(userValueSection);
|
| 321 |
-
container.appendChild(warningSection);
|
| 322 |
-
container.appendChild(authorSection);
|
| 323 |
-
|
| 324 |
-
return container;
|
| 325 |
-
}
|
| 326 |
-
|
| 327 |
-
// 辅助函数:创建带标题的卡片分区(titleHtml 支持纯文本或 HTML)
|
| 328 |
-
function createCardSection(titleHtml, contentHtml) {
|
| 329 |
-
const section = document.createElement("div");
|
| 330 |
-
Object.assign(section.style, CARD_BASE_STYLE);
|
| 331 |
-
section.innerHTML = `
|
| 332 |
-
<div style="font-size: 15px; font-weight: bold; color: #00d4aa; margin-bottom: 12px;">${titleHtml}</div>
|
| 333 |
-
${contentHtml}
|
| 334 |
-
`;
|
| 335 |
-
return section;
|
| 336 |
-
}
|
| 337 |
-
|
| 338 |
-
// ==========================================
|
| 339 |
-
// 🚀 显示关于信息(通过路由系统)
|
| 340 |
-
// ==========================================
|
| 341 |
-
export async function showAboutInfo(currentUser) {
|
| 342 |
-
// 动态获取版本配置
|
| 343 |
-
const versionConfig = await getVersionConfig();
|
| 344 |
-
const versionString = formatVersionString(versionConfig);
|
| 345 |
-
const stageLabel = getStageLabel(versionConfig.stage);
|
| 346 |
-
|
| 347 |
-
const view = createAboutView(versionString, stageLabel);
|
| 348 |
-
window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } }));
|
| 349 |
-
}
|
| 350 |
-
|
| 351 |
-
// 导出版本获取函数供其他组件使用
|
| 352 |
-
export { getVersionConfig };
|
|
|
|
| 1 |
+
// 关于页面:纯文本内容、共享主题与受控的公开版本刷新。
|
| 2 |
+
import { request } from "../core/网络请求API.js";
|
| 3 |
+
import { UI } from "../core/全局配置.js";
|
| 4 |
+
import { removeCacheByPrefix } from "../core/网络请求_缓存管理.js";
|
| 5 |
+
import { t } from "./用户体验增强.js";
|
| 6 |
+
|
| 7 |
+
let cachedVersionConfig = null;
|
| 8 |
+
let pendingVersionRequest = null;
|
| 9 |
+
let versionGeneration = 0;
|
| 10 |
+
let nextViewId = 0;
|
| 11 |
+
|
| 12 |
+
function normalizeVersion(config) {
|
| 13 |
+
if (!config || typeof config !== "object" || Array.isArray(config)) return null;
|
| 14 |
+
if (!["alpha", "beta", "rc", "stable"].includes(config.stage)) return null;
|
| 15 |
+
if (![config.major, config.minor, config.patch].every(value => Number.isSafeInteger(value) && value >= 0)) return null;
|
| 16 |
+
return { stage: config.stage, major: config.major, minor: config.minor, patch: config.patch };
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export function clearVersionCache() {
|
| 20 |
+
versionGeneration++;
|
| 21 |
+
cachedVersionConfig = null;
|
| 22 |
+
pendingVersionRequest = null;
|
| 23 |
+
removeCacheByPrefix('api_/api/public/project-version');
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export async function getVersionConfig() {
|
| 27 |
+
if (cachedVersionConfig) return { ...cachedVersionConfig };
|
| 28 |
+
if (!pendingVersionRequest) {
|
| 29 |
+
const generation = versionGeneration;
|
| 30 |
+
const pending = Promise.resolve().then(() => request('/api/public/project-version',
|
| 31 |
+
generation > 0 ? { noCache: true } : undefined)).then(res => {
|
| 32 |
+
const config = res?.status === "success" ? normalizeVersion(res.data) : null;
|
| 33 |
+
if (!config) throw new Error("Invalid project version");
|
| 34 |
+
if (generation !== versionGeneration) return cachedVersionConfig || UI.DEFAULT_PROJECT_VERSION;
|
| 35 |
+
cachedVersionConfig = Object.freeze(config);
|
| 36 |
+
return config;
|
| 37 |
+
}).catch(() => {
|
| 38 |
+
console.warn('公开版本信息无法刷新');
|
| 39 |
+
return { ...UI.DEFAULT_PROJECT_VERSION };
|
| 40 |
+
}).finally(() => {
|
| 41 |
+
if (pendingVersionRequest === pending) pendingVersionRequest = null;
|
| 42 |
+
});
|
| 43 |
+
pendingVersionRequest = pending;
|
| 44 |
+
}
|
| 45 |
+
return { ...await pendingVersionRequest };
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
export function getStageLabel(stage) {
|
| 49 |
+
switch (stage) {
|
| 50 |
+
case "alpha": return t('about.stage_alpha');
|
| 51 |
+
case "beta": return t('about.stage_beta');
|
| 52 |
+
case "rc": return t('about.stage_rc');
|
| 53 |
+
case "stable": return t('about.stage_stable');
|
| 54 |
+
default: return t('about.stage_unknown');
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
export function formatVersionString(config) {
|
| 59 |
+
const version = normalizeVersion(config);
|
| 60 |
+
if (!version) return t('about.version_unknown');
|
| 61 |
+
return `V${version.major}.${version.minor}.${version.patch} ${getStageLabel(version.stage)}`;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
function element(tag, className, text) {
|
| 65 |
+
const node = document.createElement(tag);
|
| 66 |
+
if (className) node.className = className;
|
| 67 |
+
if (text !== undefined) node.textContent = text;
|
| 68 |
+
return node;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
function section(title, children) {
|
| 72 |
+
const node = element('section', 'about-section');
|
| 73 |
+
const heading = element('h2', 'about-section__title', title);
|
| 74 |
+
heading.id = `ranking-about-heading-${++nextViewId}`;
|
| 75 |
+
node.setAttribute('aria-labelledby', heading.id);
|
| 76 |
+
node.append(heading, ...children);
|
| 77 |
+
return node;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
function grid(entries, ordered = false) {
|
| 81 |
+
const node = element(ordered ? 'ol' : 'ul', 'about-grid');
|
| 82 |
+
for (const [title, description, icon] of entries) {
|
| 83 |
+
const item = element('li', 'about-grid__item');
|
| 84 |
+
if (icon) {
|
| 85 |
+
const decoration = element('span', 'about-grid__icon', icon);
|
| 86 |
+
decoration.setAttribute('aria-hidden', 'true');
|
| 87 |
+
item.appendChild(decoration);
|
| 88 |
+
}
|
| 89 |
+
item.append(element('h3', 'about-item__title', title), element('p', 'about-description', description));
|
| 90 |
+
node.appendChild(item);
|
| 91 |
+
}
|
| 92 |
+
return node;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
export function createAboutView(versionString, stageLabel) {
|
| 96 |
+
const container = element('div', 'about-view');
|
| 97 |
+
const back = element('button', 'about-back', '⬅ ' + t('common.back'));
|
| 98 |
+
back.type = 'button';
|
| 99 |
+
back.addEventListener('click', () => window.dispatchEvent(new CustomEvent('comfy-route-back')));
|
| 100 |
+
const hero = element('header', 'about-hero');
|
| 101 |
+
const title = element('h1', 'about-hero__title', t('about.title'));
|
| 102 |
+
title.id = `ranking-about-title-${++nextViewId}`;
|
| 103 |
+
container.setAttribute('aria-labelledby', title.id);
|
| 104 |
+
hero.append(title, element('p', 'about-hero__brand', t('about.brand')),
|
| 105 |
+
element('p', 'about-version', typeof versionString === 'string' ? versionString : t('about.version_unknown')),
|
| 106 |
+
element('p', 'about-description', t('about.subtitle')));
|
| 107 |
+
const advantages = section(t('about.advantages_title'), [grid([
|
| 108 |
+
[t('about.adv_native_title'), t('about.adv_native_desc'), '🔗'],
|
| 109 |
+
[t('about.adv_speed_title'), t('about.adv_speed_desc'), '⚡'],
|
| 110 |
+
[t('about.adv_im_title'), t('about.adv_im_desc'), '💬'],
|
| 111 |
+
[t('about.adv_sandbox_title'), t('about.adv_sandbox_desc'), '🛡️']
|
| 112 |
+
])]);
|
| 113 |
+
const features = section(t('about.features_title'), [grid([
|
| 114 |
+
[t('about.feat_publish_title'), t('about.feat_publish_desc')],
|
| 115 |
+
[t('about.feat_social_title'), t('about.feat_social_desc')],
|
| 116 |
+
[t('about.feat_install_title'), t('about.feat_install_desc')],
|
| 117 |
+
[t('about.feat_download_title'), t('about.feat_download_desc')],
|
| 118 |
+
[t('about.feat_chat_title'), t('about.feat_chat_desc')],
|
| 119 |
+
[t('about.feat_account_title'), t('about.feat_account_desc')],
|
| 120 |
+
[t('about.feat_payment_title'), t('about.feat_payment_desc')],
|
| 121 |
+
[t('about.feat_protection_title'), t('about.feat_protection_desc')]
|
| 122 |
+
], true)]);
|
| 123 |
+
const guideList = element('ol', 'about-list');
|
| 124 |
+
for (const [heading, description] of [
|
| 125 |
+
[t('about.guide_install_title'), t('about.guide_install_desc')],
|
| 126 |
+
[t('about.guide_path_title'), t('about.guide_path_desc')],
|
| 127 |
+
[t('about.guide_update_title'), t('about.guide_update_desc')],
|
| 128 |
+
[t('about.guide_publish_title'), t('about.guide_publish_desc')],
|
| 129 |
+
[t('about.guide_buy_title'), t('about.guide_buy_desc')],
|
| 130 |
+
[t('about.guide_refund_title'), t('about.guide_refund_desc')],
|
| 131 |
+
[t('about.guide_withdraw_title'), t('about.guide_withdraw_desc')]
|
| 132 |
+
]) {
|
| 133 |
+
const item = element('li');
|
| 134 |
+
item.append(element('h3', 'about-item__title', heading), element('p', 'about-description', description));
|
| 135 |
+
guideList.appendChild(item);
|
| 136 |
+
}
|
| 137 |
+
const benefits = element('ol', 'about-list');
|
| 138 |
+
for (const text of [t('about.benefit_1'), t('about.benefit_2'), t('about.benefit_3'), t('about.benefit_4'), t('about.benefit_5')]) {
|
| 139 |
+
benefits.appendChild(element('li', 'about-benefit', text));
|
| 140 |
+
}
|
| 141 |
+
const warningCopy = [];
|
| 142 |
+
for (const [heading, description] of [
|
| 143 |
+
[t('about.warning_login_title'), t('about.warning_login_desc')],
|
| 144 |
+
[t('about.warning_account_title'), t('about.warning_account_desc')]
|
| 145 |
+
]) {
|
| 146 |
+
const paragraph = element('p', 'about-warning__copy');
|
| 147 |
+
paragraph.append(element('strong', '', heading), document.createTextNode(description));
|
| 148 |
+
warningCopy.push(paragraph);
|
| 149 |
+
}
|
| 150 |
+
const warning = section(t('about.warning_title'), warningCopy);
|
| 151 |
+
warning.classList.add('about-section--warning');
|
| 152 |
+
const authorLink = element('a', 'about-author__link');
|
| 153 |
+
authorLink.href = UI.ABOUT.AUTHOR_URL;
|
| 154 |
+
authorLink.target = '_blank';
|
| 155 |
+
authorLink.rel = 'noopener noreferrer';
|
| 156 |
+
const badge = element('img', 'about-author__badge');
|
| 157 |
+
badge.src = UI.ABOUT.AUTHOR_BADGE_URL;
|
| 158 |
+
badge.alt = '';
|
| 159 |
+
badge.loading = 'lazy';
|
| 160 |
+
authorLink.appendChild(badge);
|
| 161 |
+
// 保留文字入口,即使远端徽章图片无法加载也可操作。
|
| 162 |
+
authorLink.appendChild(element('span', '', t('about.author_link')));
|
| 163 |
+
const author = section(t('about.author_title'), [authorLink, element('p', 'about-description', t('about.author_thanks'))]);
|
| 164 |
+
author.classList.add('about-section--author');
|
| 165 |
+
container.append(back, hero, advantages, features, section(t('about.guide_title'), [guideList]),
|
| 166 |
+
section(t('about.benefits_title'), [benefits]), warning, author);
|
| 167 |
+
return container;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
export async function showAboutInfo(currentUser) {
|
| 171 |
+
const generation = versionGeneration;
|
| 172 |
+
const view = createAboutView(formatVersionString(cachedVersionConfig || UI.DEFAULT_PROJECT_VERSION));
|
| 173 |
+
window.dispatchEvent(new CustomEvent('comfy-route-view', { detail: { view } }));
|
| 174 |
+
// 先显示完整页面,网络只补新原节点;不在请求完成后重新导航。
|
| 175 |
+
getVersionConfig().then(config => {
|
| 176 |
+
if (view.isConnected && generation === versionGeneration) {
|
| 177 |
+
view.querySelector('.about-version').textContent = formatVersionString(config);
|
| 178 |
+
}
|
| 179 |
+
});
|
| 180 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
前端页面/components/动画音效引擎.js
CHANGED
|
@@ -13,6 +13,16 @@
|
|
| 13 |
|
| 14 |
import { getSettings } from "./全局设置组件.js";
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
// ==========================================
|
| 17 |
// 🔊 音效管理
|
| 18 |
// ==========================================
|
|
@@ -360,7 +370,7 @@ function injectAnimationStyles() {
|
|
| 360 |
*/
|
| 361 |
export function applyCardAnimation(card, animationType, index, totalVisible = 10, silent = false) {
|
| 362 |
const settings = getSettings();
|
| 363 |
-
if (!settings.enableAnimations) return;
|
| 364 |
|
| 365 |
// 确保样式已注入
|
| 366 |
injectAnimationStyles();
|
|
@@ -436,6 +446,12 @@ export function applyCardAnimation(card, animationType, index, totalVisible = 10
|
|
| 436 |
// 容器 -> 观察器映射(同一视图的首次渲染与分页追加复用同一观察器)
|
| 437 |
const _viewportObservers = new WeakMap();
|
| 438 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 439 |
/**
|
| 440 |
* ✨ 视口感知卡片动画:
|
| 441 |
* - 已在视口内的卡片立即依次错开播放载入动画
|
|
@@ -448,23 +464,23 @@ const _viewportObservers = new WeakMap();
|
|
| 448 |
*/
|
| 449 |
export function applyViewportAnimations(container, cards, animationType, isFirstBatch = false) {
|
| 450 |
const settings = getSettings();
|
| 451 |
-
if (!settings.enableAnimations)
|
|
|
|
|
|
|
|
|
|
| 452 |
|
| 453 |
// 确保样式已注入
|
| 454 |
injectAnimationStyles();
|
| 455 |
|
| 456 |
// 视图重建:停止上一批残留卡片的补播观察
|
| 457 |
if (isFirstBatch) {
|
| 458 |
-
|
| 459 |
-
if (oldObserver) {
|
| 460 |
-
oldObserver.disconnect();
|
| 461 |
-
_viewportObservers.delete(container);
|
| 462 |
-
}
|
| 463 |
}
|
| 464 |
|
| 465 |
let observer = _viewportObservers.get(container);
|
| 466 |
if (!observer) {
|
| 467 |
observer = new IntersectionObserver((entries) => {
|
|
|
|
| 468 |
// 本批进入视口的卡片,按文档顺序(从上到下)排列后错开播放
|
| 469 |
const entering = entries
|
| 470 |
.filter(e => e.isIntersecting)
|
|
|
|
| 13 |
|
| 14 |
import { getSettings } from "./全局设置组件.js";
|
| 15 |
|
| 16 |
+
function prefersReducedMotion() {
|
| 17 |
+
try { return window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true; }
|
| 18 |
+
catch { return false; }
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export function scrollCardIntoView(card) {
|
| 22 |
+
const instant = !getSettings().enableAnimations || prefersReducedMotion();
|
| 23 |
+
card.scrollIntoView({ behavior: instant ? 'instant' : 'smooth', block: 'center' });
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
// ==========================================
|
| 27 |
// 🔊 音效管理
|
| 28 |
// ==========================================
|
|
|
|
| 370 |
*/
|
| 371 |
export function applyCardAnimation(card, animationType, index, totalVisible = 10, silent = false) {
|
| 372 |
const settings = getSettings();
|
| 373 |
+
if (!settings.enableAnimations || prefersReducedMotion()) return;
|
| 374 |
|
| 375 |
// 确保样式已注入
|
| 376 |
injectAnimationStyles();
|
|
|
|
| 446 |
// 容器 -> 观察器映射(同一视图的首次渲染与分页追加复用同一观察器)
|
| 447 |
const _viewportObservers = new WeakMap();
|
| 448 |
|
| 449 |
+
export function stopViewportAnimations(container) {
|
| 450 |
+
const observer = _viewportObservers.get(container);
|
| 451 |
+
_viewportObservers.delete(container);
|
| 452 |
+
observer?.disconnect();
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
/**
|
| 456 |
* ✨ 视口感知卡片动画:
|
| 457 |
* - 已在视口内的卡片立即依次错开播放载入动画
|
|
|
|
| 464 |
*/
|
| 465 |
export function applyViewportAnimations(container, cards, animationType, isFirstBatch = false) {
|
| 466 |
const settings = getSettings();
|
| 467 |
+
if (!settings.enableAnimations || prefersReducedMotion()) {
|
| 468 |
+
stopViewportAnimations(container);
|
| 469 |
+
return;
|
| 470 |
+
}
|
| 471 |
|
| 472 |
// 确保样式已注入
|
| 473 |
injectAnimationStyles();
|
| 474 |
|
| 475 |
// 视图重建:停止上一批残留卡片的补播观察
|
| 476 |
if (isFirstBatch) {
|
| 477 |
+
stopViewportAnimations(container);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
}
|
| 479 |
|
| 480 |
let observer = _viewportObservers.get(container);
|
| 481 |
if (!observer) {
|
| 482 |
observer = new IntersectionObserver((entries) => {
|
| 483 |
+
if (_viewportObservers.get(container) !== observer) return;
|
| 484 |
// 本批进入视口的卡片,按文档顺序(从上到下)排列后错开播放
|
| 485 |
const entering = entries
|
| 486 |
.filter(e => e.isIntersecting)
|
前端页面/components/卡片导航生命周期.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 按视图管理导航代次,不清理榜单数据或滚动位置。
|
| 2 |
+
export function createCardNavigationScope(target, isCurrent, { captureSession = () => () => true, storageKeys = [] } = {}) {
|
| 3 |
+
let generation = 0;
|
| 4 |
+
let disposed = false;
|
| 5 |
+
let identityRevoked = false;
|
| 6 |
+
const isViewSessionCurrent = captureSession();
|
| 7 |
+
const events = ['comfy-route-view', 'comfy-route-back'];
|
| 8 |
+
const identityEvents = ['comfy-user-login', 'comfy-user-logout', 'comfy-ranking-auth-expired'];
|
| 9 |
+
const invalidate = () => { generation++; };
|
| 10 |
+
const revokeIdentity = () => { identityRevoked = true; invalidate(); };
|
| 11 |
+
const onStorage = event => {
|
| 12 |
+
if (event.key === null || storageKeys.includes(event.key)) revokeIdentity();
|
| 13 |
+
};
|
| 14 |
+
for (const event of events) target.addEventListener(event, invalidate);
|
| 15 |
+
for (const event of identityEvents) target.addEventListener(event, revokeIdentity);
|
| 16 |
+
target.addEventListener('storage', onStorage);
|
| 17 |
+
return {
|
| 18 |
+
capture() {
|
| 19 |
+
const captured = generation;
|
| 20 |
+
const isSessionCurrent = captureSession();
|
| 21 |
+
return () => !disposed && !identityRevoked && captured === generation && isCurrent() && isViewSessionCurrent() && isSessionCurrent();
|
| 22 |
+
},
|
| 23 |
+
dispose() {
|
| 24 |
+
if (disposed) return;
|
| 25 |
+
disposed = true;
|
| 26 |
+
generation++;
|
| 27 |
+
for (const event of events) target.removeEventListener(event, invalidate);
|
| 28 |
+
for (const event of identityEvents) target.removeEventListener(event, revokeIdentity);
|
| 29 |
+
target.removeEventListener('storage', onStorage);
|
| 30 |
+
},
|
| 31 |
+
};
|
| 32 |
+
}
|
前端页面/components/图片展示测试.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
| 7 |
// ==========================================
|
| 8 |
|
| 9 |
import { escapeHtml } from "../core/全局配置.js";
|
|
|
|
| 10 |
|
| 11 |
// ==========================================
|
| 12 |
// 🎨 CSS样式定义(CSS-in-JS模式)
|
|
@@ -176,12 +177,24 @@ const VIEWER_STYLES = `
|
|
| 176 |
opacity: 0;
|
| 177 |
transition: opacity 0.3s ease;
|
| 178 |
z-index: 15;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
}
|
| 180 |
|
| 181 |
.cyber-nav-zone:hover {
|
| 182 |
opacity: 1;
|
| 183 |
}
|
| 184 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
.cyber-nav-zone.left {
|
| 186 |
left: 0;
|
| 187 |
background: linear-gradient(90deg, rgba(255, 255, 255, 0.05) 0%, transparent 100%);
|
|
@@ -256,6 +269,9 @@ const VIEWER_STYLES = `
|
|
| 256 |
border-radius: 4px;
|
| 257 |
overflow: hidden;
|
| 258 |
cursor: pointer;
|
|
|
|
|
|
|
|
|
|
| 259 |
border: 2px solid transparent;
|
| 260 |
transition: all 0.3s ease;
|
| 261 |
opacity: 0.5;
|
|
@@ -277,6 +293,11 @@ const VIEWER_STYLES = `
|
|
| 277 |
opacity: 1;
|
| 278 |
}
|
| 279 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
/* 键盘提示 */
|
| 281 |
.cyber-keyboard-hint {
|
| 282 |
font-size: 12px;
|
|
@@ -393,6 +414,7 @@ class CyberImageViewer {
|
|
| 393 |
this._autoPlayTimerId = null;
|
| 394 |
this._autoPlayInterval = 1000; // 默认1秒展示时间
|
| 395 |
this._autoPlayStateChangeCallbacks = []; // 状态变化回调
|
|
|
|
| 396 |
}
|
| 397 |
|
| 398 |
/**
|
|
@@ -436,11 +458,15 @@ class CyberImageViewer {
|
|
| 436 |
// 创建主容器
|
| 437 |
this.container = document.createElement('div');
|
| 438 |
this.container.className = 'cyber-image-viewer';
|
|
|
|
|
|
|
|
|
|
|
|
|
| 439 |
this.container.style.background = background;
|
| 440 |
|
| 441 |
this.container.innerHTML = `
|
| 442 |
<!-- 关闭按钮 -->
|
| 443 |
-
<button class="cyber-close-btn" title="
|
| 444 |
|
| 445 |
<!-- 图片主容器 -->
|
| 446 |
<div class="cyber-image-container">
|
|
@@ -451,23 +477,23 @@ class CyberImageViewer {
|
|
| 451 |
</div>
|
| 452 |
|
| 453 |
<!-- 左右导航区域 -->
|
| 454 |
-
<
|
| 455 |
<div class="cyber-nav-arrow">❮</div>
|
| 456 |
-
</
|
| 457 |
-
<
|
| 458 |
<div class="cyber-nav-arrow">❯</div>
|
| 459 |
-
</
|
| 460 |
|
| 461 |
<!-- 底部信息栏 -->
|
| 462 |
<div class="cyber-info-bar">
|
| 463 |
-
<div class="cyber-counter">
|
| 464 |
<span class="current">1</span>
|
| 465 |
<span class="separator"> / </span>
|
| 466 |
<span class="total">${this.imageUrls.length}</span>
|
| 467 |
</div>
|
| 468 |
<div class="cyber-thumbnails"></div>
|
| 469 |
<div style="display: flex; align-items: center; gap: 15px;">
|
| 470 |
-
<button class="cyber-autoplay-btn" title="
|
| 471 |
<svg viewBox="0 0 24 24" class="autoplay-icon-play">
|
| 472 |
<path d="M8 5v14l11-7z"/>
|
| 473 |
</svg>
|
|
@@ -476,9 +502,9 @@ class CyberImageViewer {
|
|
| 476 |
</svg>
|
| 477 |
</button>
|
| 478 |
<div class="cyber-keyboard-hint">
|
| 479 |
-
<span><span class="cyber-key">←</span>
|
| 480 |
-
<span><span class="cyber-key">→</span>
|
| 481 |
-
<span><span class="cyber-key">ESC</span>
|
| 482 |
</div>
|
| 483 |
</div>
|
| 484 |
</div>
|
|
@@ -587,12 +613,12 @@ class CyberImageViewer {
|
|
| 587 |
|
| 588 |
if (this._autoPlayEnabled) {
|
| 589 |
btn.classList.add('active');
|
| 590 |
-
btn.title =
|
| 591 |
playIcon.style.display = 'none';
|
| 592 |
pauseIcon.style.display = 'block';
|
| 593 |
} else {
|
| 594 |
btn.classList.remove('active');
|
| 595 |
-
btn.title =
|
| 596 |
playIcon.style.display = 'block';
|
| 597 |
pauseIcon.style.display = 'none';
|
| 598 |
}
|
|
@@ -736,6 +762,18 @@ class CyberImageViewer {
|
|
| 736 |
_bindEvents() {
|
| 737 |
// 键盘导航
|
| 738 |
this._keydownHandler = (e) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 739 |
if (e.key === 'Escape') {
|
| 740 |
this.close();
|
| 741 |
} else if (e.key === 'ArrowLeft') {
|
|
@@ -785,8 +823,11 @@ class CyberImageViewer {
|
|
| 785 |
const maxThumbs = Math.min(this.imageUrls.length, 7);
|
| 786 |
|
| 787 |
for (let i = 0; i < maxThumbs; i++) {
|
| 788 |
-
const thumb = document.createElement('
|
|
|
|
| 789 |
thumb.className = 'cyber-thumb' + (i === this.currentIndex ? ' active' : '');
|
|
|
|
|
|
|
| 790 |
// 🔒 XSS防护:图片 URL 来自其他用户发布的内容,写入 innerHTML 前必须转义
|
| 791 |
thumb.innerHTML = `<img src="${escapeHtml(this.imageUrls[i])}" alt="">`;
|
| 792 |
thumb.addEventListener('click', (e) => {
|
|
@@ -812,6 +853,7 @@ class CyberImageViewer {
|
|
| 812 |
const thumbs = this.elements.thumbnails.querySelectorAll('.cyber-thumb');
|
| 813 |
thumbs.forEach((thumb, index) => {
|
| 814 |
thumb.classList.toggle('active', index === this.currentIndex);
|
|
|
|
| 815 |
});
|
| 816 |
}
|
| 817 |
|
|
@@ -851,6 +893,7 @@ class CyberImageViewer {
|
|
| 851 |
|
| 852 |
// 更新计数器
|
| 853 |
this.elements.counterCurrent.textContent = index + 1;
|
|
|
|
| 854 |
this._updateThumbnails();
|
| 855 |
|
| 856 |
// 预加载相邻图片
|
|
@@ -863,13 +906,13 @@ class CyberImageViewer {
|
|
| 863 |
_loadImageAsync(url) {
|
| 864 |
return new Promise((resolve, reject) => {
|
| 865 |
const img = new Image();
|
| 866 |
-
console.log('📥 开始加载图片
|
| 867 |
img.onload = () => {
|
| 868 |
-
console.log('✅ 图片加载成功
|
| 869 |
resolve(img);
|
| 870 |
};
|
| 871 |
img.onerror = (err) => {
|
| 872 |
-
console.error('❌ 图片加载失败:',
|
| 873 |
reject(new Error('图片加载失败'));
|
| 874 |
};
|
| 875 |
img.src = url;
|
|
@@ -911,7 +954,7 @@ class CyberImageViewer {
|
|
| 911 |
const imgSrc = img.src;
|
| 912 |
|
| 913 |
// 调试日志
|
| 914 |
-
console.log('🎬 方块聚合动画开始
|
| 915 |
|
| 916 |
// 获取wrapper的实际尺寸
|
| 917 |
const rect = wrapper.getBoundingClientRect();
|
|
@@ -1530,6 +1573,8 @@ class CyberImageViewer {
|
|
| 1530 |
* 关闭查看器
|
| 1531 |
*/
|
| 1532 |
close() {
|
|
|
|
|
|
|
| 1533 |
// 停止自动播放并清除定时器
|
| 1534 |
this._stopAutoPlay();
|
| 1535 |
|
|
@@ -1546,6 +1591,7 @@ class CyberImageViewer {
|
|
| 1546 |
if (this.container.parentNode) {
|
| 1547 |
this.container.parentNode.removeChild(this.container);
|
| 1548 |
}
|
|
|
|
| 1549 |
}, 300);
|
| 1550 |
});
|
| 1551 |
}
|
|
@@ -1571,13 +1617,13 @@ export function openImageViewer(imageUrls, startIndex = 0, options = {}) {
|
|
| 1571 |
}
|
| 1572 |
|
| 1573 |
console.log('🎬 openImageViewer 被调用:', 'URL数量:', imageUrls.length, '起始索引:', startIndex);
|
| 1574 |
-
console.log('🖼️ 图片URLs:', imageUrls.map(u => u?.substring(0, 80) + '...'));
|
| 1575 |
|
| 1576 |
const viewer = new CyberImageViewer(imageUrls, startIndex);
|
| 1577 |
const container = viewer.create(options);
|
| 1578 |
|
| 1579 |
// 添加到body
|
| 1580 |
document.body.appendChild(container);
|
|
|
|
| 1581 |
|
| 1582 |
// 添加入场动画
|
| 1583 |
requestAnimationFrame(() => {
|
|
|
|
| 7 |
// ==========================================
|
| 8 |
|
| 9 |
import { escapeHtml } from "../core/全局配置.js";
|
| 10 |
+
import { t } from "./用户体验增强.js";
|
| 11 |
|
| 12 |
// ==========================================
|
| 13 |
// 🎨 CSS样式定义(CSS-in-JS模式)
|
|
|
|
| 177 |
opacity: 0;
|
| 178 |
transition: opacity 0.3s ease;
|
| 179 |
z-index: 15;
|
| 180 |
+
border: 0;
|
| 181 |
+
padding: 0;
|
| 182 |
+
color: inherit;
|
| 183 |
+
font: inherit;
|
| 184 |
}
|
| 185 |
|
| 186 |
.cyber-nav-zone:hover {
|
| 187 |
opacity: 1;
|
| 188 |
}
|
| 189 |
|
| 190 |
+
.cyber-nav-zone:focus-visible,
|
| 191 |
+
.cyber-close-btn:focus-visible,
|
| 192 |
+
.cyber-autoplay-btn:focus-visible {
|
| 193 |
+
opacity: 1;
|
| 194 |
+
outline: 3px solid #00ff96;
|
| 195 |
+
outline-offset: -4px;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
.cyber-nav-zone.left {
|
| 199 |
left: 0;
|
| 200 |
background: linear-gradient(90deg, rgba(255, 255, 255, 0.05) 0%, transparent 100%);
|
|
|
|
| 269 |
border-radius: 4px;
|
| 270 |
overflow: hidden;
|
| 271 |
cursor: pointer;
|
| 272 |
+
border: 0;
|
| 273 |
+
padding: 0;
|
| 274 |
+
background: transparent;
|
| 275 |
border: 2px solid transparent;
|
| 276 |
transition: all 0.3s ease;
|
| 277 |
opacity: 0.5;
|
|
|
|
| 293 |
opacity: 1;
|
| 294 |
}
|
| 295 |
|
| 296 |
+
.cyber-thumb:focus-visible {
|
| 297 |
+
outline: 3px solid #00ff96;
|
| 298 |
+
outline-offset: 2px;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
/* 键盘提示 */
|
| 302 |
.cyber-keyboard-hint {
|
| 303 |
font-size: 12px;
|
|
|
|
| 414 |
this._autoPlayTimerId = null;
|
| 415 |
this._autoPlayInterval = 1000; // 默认1秒展示时间
|
| 416 |
this._autoPlayStateChangeCallbacks = []; // 状态变化回调
|
| 417 |
+
this._returnFocus = document.activeElement;
|
| 418 |
}
|
| 419 |
|
| 420 |
/**
|
|
|
|
| 458 |
// 创建主容器
|
| 459 |
this.container = document.createElement('div');
|
| 460 |
this.container.className = 'cyber-image-viewer';
|
| 461 |
+
this.container.setAttribute('role', 'dialog');
|
| 462 |
+
this.container.setAttribute('aria-modal', 'true');
|
| 463 |
+
this.container.setAttribute('aria-label', t('image.viewer.dialog'));
|
| 464 |
+
this.container.tabIndex = -1;
|
| 465 |
this.container.style.background = background;
|
| 466 |
|
| 467 |
this.container.innerHTML = `
|
| 468 |
<!-- 关闭按钮 -->
|
| 469 |
+
<button type="button" class="cyber-close-btn" title="${escapeHtml(t('image.viewer.close'))}" aria-label="${escapeHtml(t('image.viewer.close'))}">×</button>
|
| 470 |
|
| 471 |
<!-- 图片主容器 -->
|
| 472 |
<div class="cyber-image-container">
|
|
|
|
| 477 |
</div>
|
| 478 |
|
| 479 |
<!-- 左右导航区域 -->
|
| 480 |
+
<button type="button" class="cyber-nav-zone left" title="${escapeHtml(t('image.viewer.previous'))}" aria-label="${escapeHtml(t('image.viewer.previous'))}">
|
| 481 |
<div class="cyber-nav-arrow">❮</div>
|
| 482 |
+
</button>
|
| 483 |
+
<button type="button" class="cyber-nav-zone right" title="${escapeHtml(t('image.viewer.next'))}" aria-label="${escapeHtml(t('image.viewer.next'))}">
|
| 484 |
<div class="cyber-nav-arrow">❯</div>
|
| 485 |
+
</button>
|
| 486 |
|
| 487 |
<!-- 底部信息栏 -->
|
| 488 |
<div class="cyber-info-bar">
|
| 489 |
+
<div class="cyber-counter" role="status" aria-live="polite" aria-atomic="true">
|
| 490 |
<span class="current">1</span>
|
| 491 |
<span class="separator"> / </span>
|
| 492 |
<span class="total">${this.imageUrls.length}</span>
|
| 493 |
</div>
|
| 494 |
<div class="cyber-thumbnails"></div>
|
| 495 |
<div style="display: flex; align-items: center; gap: 15px;">
|
| 496 |
+
<button type="button" class="cyber-autoplay-btn" title="${escapeHtml(t('image.viewer.autoplay'))}" aria-label="${escapeHtml(t('image.viewer.autoplay'))}">
|
| 497 |
<svg viewBox="0 0 24 24" class="autoplay-icon-play">
|
| 498 |
<path d="M8 5v14l11-7z"/>
|
| 499 |
</svg>
|
|
|
|
| 502 |
</svg>
|
| 503 |
</button>
|
| 504 |
<div class="cyber-keyboard-hint">
|
| 505 |
+
<span><span class="cyber-key">←</span> ${escapeHtml(t('image.viewer.previous_short'))}</span>
|
| 506 |
+
<span><span class="cyber-key">→</span> ${escapeHtml(t('image.viewer.next_short'))}</span>
|
| 507 |
+
<span><span class="cyber-key">ESC</span> ${escapeHtml(t('image.viewer.close_short'))}</span>
|
| 508 |
</div>
|
| 509 |
</div>
|
| 510 |
</div>
|
|
|
|
| 613 |
|
| 614 |
if (this._autoPlayEnabled) {
|
| 615 |
btn.classList.add('active');
|
| 616 |
+
btn.title = t('image.viewer.pause');
|
| 617 |
playIcon.style.display = 'none';
|
| 618 |
pauseIcon.style.display = 'block';
|
| 619 |
} else {
|
| 620 |
btn.classList.remove('active');
|
| 621 |
+
btn.title = t('image.viewer.autoplay');
|
| 622 |
playIcon.style.display = 'block';
|
| 623 |
pauseIcon.style.display = 'none';
|
| 624 |
}
|
|
|
|
| 762 |
_bindEvents() {
|
| 763 |
// 键盘导航
|
| 764 |
this._keydownHandler = (e) => {
|
| 765 |
+
if (e.key === 'Tab') {
|
| 766 |
+
const controls = [...this.container.querySelectorAll('button:not([disabled]), [tabindex="0"]')];
|
| 767 |
+
if (!controls.length) { e.preventDefault(); this.container.focus(); return; }
|
| 768 |
+
const first = controls[0];
|
| 769 |
+
const last = controls[controls.length - 1];
|
| 770 |
+
if (e.shiftKey && (document.activeElement === first || !this.container.contains(document.activeElement))) {
|
| 771 |
+
e.preventDefault(); last.focus();
|
| 772 |
+
} else if (!e.shiftKey && (document.activeElement === last || !this.container.contains(document.activeElement))) {
|
| 773 |
+
e.preventDefault(); first.focus();
|
| 774 |
+
}
|
| 775 |
+
return;
|
| 776 |
+
}
|
| 777 |
if (e.key === 'Escape') {
|
| 778 |
this.close();
|
| 779 |
} else if (e.key === 'ArrowLeft') {
|
|
|
|
| 823 |
const maxThumbs = Math.min(this.imageUrls.length, 7);
|
| 824 |
|
| 825 |
for (let i = 0; i < maxThumbs; i++) {
|
| 826 |
+
const thumb = document.createElement('button');
|
| 827 |
+
thumb.type = 'button';
|
| 828 |
thumb.className = 'cyber-thumb' + (i === this.currentIndex ? ' active' : '');
|
| 829 |
+
thumb.setAttribute('aria-label', t('image.viewer.image_position', { current: i + 1, total: this.imageUrls.length }));
|
| 830 |
+
thumb.setAttribute('aria-current', i === this.currentIndex ? 'true' : 'false');
|
| 831 |
// 🔒 XSS防护:图片 URL 来自其他用户发布的内容,写入 innerHTML 前必须转义
|
| 832 |
thumb.innerHTML = `<img src="${escapeHtml(this.imageUrls[i])}" alt="">`;
|
| 833 |
thumb.addEventListener('click', (e) => {
|
|
|
|
| 853 |
const thumbs = this.elements.thumbnails.querySelectorAll('.cyber-thumb');
|
| 854 |
thumbs.forEach((thumb, index) => {
|
| 855 |
thumb.classList.toggle('active', index === this.currentIndex);
|
| 856 |
+
thumb.setAttribute('aria-current', index === this.currentIndex ? 'true' : 'false');
|
| 857 |
});
|
| 858 |
}
|
| 859 |
|
|
|
|
| 893 |
|
| 894 |
// 更新计数器
|
| 895 |
this.elements.counterCurrent.textContent = index + 1;
|
| 896 |
+
this.elements.mainImage.alt = t('image.viewer.image_position', { current: index + 1, total: this.imageUrls.length });
|
| 897 |
this._updateThumbnails();
|
| 898 |
|
| 899 |
// 预加载相邻图片
|
|
|
|
| 906 |
_loadImageAsync(url) {
|
| 907 |
return new Promise((resolve, reject) => {
|
| 908 |
const img = new Image();
|
| 909 |
+
console.log('📥 开始加载图片');
|
| 910 |
img.onload = () => {
|
| 911 |
+
console.log('✅ 图片加载成功');
|
| 912 |
resolve(img);
|
| 913 |
};
|
| 914 |
img.onerror = (err) => {
|
| 915 |
+
console.error('❌ 图片加载失败:', err?.name || 'Error');
|
| 916 |
reject(new Error('图片加载失败'));
|
| 917 |
};
|
| 918 |
img.src = url;
|
|
|
|
| 954 |
const imgSrc = img.src;
|
| 955 |
|
| 956 |
// 调试日志
|
| 957 |
+
console.log('🎬 方块聚合动画开始');
|
| 958 |
|
| 959 |
// 获取wrapper的实际尺寸
|
| 960 |
const rect = wrapper.getBoundingClientRect();
|
|
|
|
| 1573 |
* 关闭查看器
|
| 1574 |
*/
|
| 1575 |
close() {
|
| 1576 |
+
if (this._isClosing) return;
|
| 1577 |
+
this._isClosing = true;
|
| 1578 |
// 停止自动播放并清除定时器
|
| 1579 |
this._stopAutoPlay();
|
| 1580 |
|
|
|
|
| 1591 |
if (this.container.parentNode) {
|
| 1592 |
this.container.parentNode.removeChild(this.container);
|
| 1593 |
}
|
| 1594 |
+
if (this._returnFocus?.isConnected && typeof this._returnFocus.focus === 'function') this._returnFocus.focus();
|
| 1595 |
}, 300);
|
| 1596 |
});
|
| 1597 |
}
|
|
|
|
| 1617 |
}
|
| 1618 |
|
| 1619 |
console.log('🎬 openImageViewer 被调用:', 'URL数量:', imageUrls.length, '起始索引:', startIndex);
|
|
|
|
| 1620 |
|
| 1621 |
const viewer = new CyberImageViewer(imageUrls, startIndex);
|
| 1622 |
const container = viewer.create(options);
|
| 1623 |
|
| 1624 |
// 添加到body
|
| 1625 |
document.body.appendChild(container);
|
| 1626 |
+
viewer.elements.closeBtn.focus();
|
| 1627 |
|
| 1628 |
// 添加入场动画
|
| 1629 |
requestAnimationFrame(() => {
|
前端页面/components/图片沙盒组件.js
CHANGED
|
@@ -111,7 +111,7 @@ export function getCoverSandboxHTML(imageSource, lazyLoad = true) {
|
|
| 111 |
<button class="btn-zoom-out" style="background: #333; color: #fff; border: 1px solid #555; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-weight: bold;">-</button>
|
| 112 |
<button class="btn-zoom-reset" style="background: #333; color: #fff; border: 1px solid #555; height: 28px; padding: 0 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">${t('common.reset')}</button>
|
| 113 |
<button class="btn-zoom-in" style="background: #333; color: #fff; border: 1px solid #555; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-weight: bold;">+</button>
|
| 114 |
-
<button class="btn-fullscreen" style="background: #333; color: #fff; border: 1px solid #555; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-weight: bold; display: flex; align-items: center; justify-content: center; font-size: 14px; margin-left: 5px;" title="
|
| 115 |
</div>
|
| 116 |
<div class="sliders-wrapper" style="opacity: 0.2; transition: 0.3s; position: absolute; width: 100%; height: 100%; top:0; left:0; pointer-events: none;" onmouseover="this.style.opacity=0.8" onmouseout="this.style.opacity=0.2">
|
| 117 |
<input type="range" class="slider-x" min="-400" max="400" value="0" style="position: absolute; bottom: 0px; left: 50%; transform: translateX(-50%); width: 80%; pointer-events: auto; z-index: 10;">
|
|
@@ -262,7 +262,7 @@ export function setupImageSandboxEvents(detailView) {
|
|
| 262 |
const currentIndex = parseInt(viewport.getAttribute('data-current') || '0', 10);
|
| 263 |
|
| 264 |
// 🔍 调试日志:输出收集到的图片URL
|
| 265 |
-
console.log('🖼️ 全屏播放图片:', imageUrls, '当前索引:', currentIndex);
|
| 266 |
|
| 267 |
if (imageUrls.length > 0) {
|
| 268 |
// 打开全屏图像播放器(使用静态import,避免动态import路径问题)
|
|
@@ -272,11 +272,11 @@ export function setupImageSandboxEvents(detailView) {
|
|
| 272 |
console.error('无法打开图像播放器:', err);
|
| 273 |
// 显示用户可见的错误提示
|
| 274 |
import('./UI交互提示组件.js').then(module => {
|
| 275 |
-
module.showToast('
|
| 276 |
}).catch(() => {
|
| 277 |
// 动态import也失败时,创建简单的内联提示
|
| 278 |
const fallback = document.createElement('div');
|
| 279 |
-
fallback.textContent = '
|
| 280 |
Object.assign(fallback.style, { position: 'fixed', top: '50px', left: '50%', transform: 'translateX(-50%)', background: '#F44336', color: '#fff', padding: '10px 20px', borderRadius: '8px', zIndex: '10000', fontSize: '14px' });
|
| 281 |
document.body.appendChild(fallback);
|
| 282 |
setTimeout(() => fallback.remove(), 3000);
|
|
|
|
| 111 |
<button class="btn-zoom-out" style="background: #333; color: #fff; border: 1px solid #555; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-weight: bold;">-</button>
|
| 112 |
<button class="btn-zoom-reset" style="background: #333; color: #fff; border: 1px solid #555; height: 28px; padding: 0 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">${t('common.reset')}</button>
|
| 113 |
<button class="btn-zoom-in" style="background: #333; color: #fff; border: 1px solid #555; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-weight: bold;">+</button>
|
| 114 |
+
<button class="btn-fullscreen" style="background: #333; color: #fff; border: 1px solid #555; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-weight: bold; display: flex; align-items: center; justify-content: center; font-size: 14px; margin-left: 5px;" title="${escapeHtml(t('image.viewer.fullscreen'))}">⛶</button>
|
| 115 |
</div>
|
| 116 |
<div class="sliders-wrapper" style="opacity: 0.2; transition: 0.3s; position: absolute; width: 100%; height: 100%; top:0; left:0; pointer-events: none;" onmouseover="this.style.opacity=0.8" onmouseout="this.style.opacity=0.2">
|
| 117 |
<input type="range" class="slider-x" min="-400" max="400" value="0" style="position: absolute; bottom: 0px; left: 50%; transform: translateX(-50%); width: 80%; pointer-events: auto; z-index: 10;">
|
|
|
|
| 262 |
const currentIndex = parseInt(viewport.getAttribute('data-current') || '0', 10);
|
| 263 |
|
| 264 |
// 🔍 调试日志:输出收集到的图片URL
|
| 265 |
+
console.log('🖼️ 全屏播放图片:', '图片数量:', imageUrls.length, '当前索引:', currentIndex);
|
| 266 |
|
| 267 |
if (imageUrls.length > 0) {
|
| 268 |
// 打开全屏图像播放器(使用静态import,避免动态import路径问题)
|
|
|
|
| 272 |
console.error('无法打开图像播放器:', err);
|
| 273 |
// 显示用户可见的错误提示
|
| 274 |
import('./UI交互提示组件.js').then(module => {
|
| 275 |
+
module.showToast(t('image.viewer.load_failed'), 'error');
|
| 276 |
}).catch(() => {
|
| 277 |
// 动态import也失败时,创建简单的内联提示
|
| 278 |
const fallback = document.createElement('div');
|
| 279 |
+
fallback.textContent = t('image.viewer.load_failed');
|
| 280 |
Object.assign(fallback.style, { position: 'fixed', top: '50px', left: '50%', transform: 'translateX(-50%)', background: '#F44336', color: '#fff', padding: '10px 20px', borderRadius: '8px', zIndex: '10000', fontSize: '14px' });
|
| 281 |
document.body.appendChild(fallback);
|
| 282 |
setTimeout(() => fallback.remove(), 3000);
|
前端页面/components/图片裁剪组件.js
CHANGED
|
@@ -35,8 +35,10 @@ function _isTouchDevice() {
|
|
| 35 |
* @param {number} maxSizeMB - 最大输出大小(MB)
|
| 36 |
* @returns {Promise<File|null>} - 裁剪后的文件或 null(取消)
|
| 37 |
*/
|
| 38 |
-
export function openImageCropper(file, aspectRatio = 16/9, title =
|
| 39 |
return new Promise((resolve) => {
|
|
|
|
|
|
|
| 40 |
// 创建遮罩层
|
| 41 |
const overlay = document.createElement("div");
|
| 42 |
Object.assign(overlay.style, {
|
|
@@ -47,6 +49,11 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
|
|
| 47 |
|
| 48 |
// 创建弹窗容器
|
| 49 |
const modal = document.createElement("div");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
Object.assign(modal.style, {
|
| 51 |
background: "var(--comfy-input-bg)", borderRadius: "12px", padding: "20px",
|
| 52 |
maxWidth: "90vw", maxHeight: "90vh", display: "flex", flexDirection: "column",
|
|
@@ -66,8 +73,8 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
|
|
| 66 |
// 📱 触控设备与鼠标设备的操作提示区分(移动端无滚轮/拖动概念);文案走词典,随界面语言切换
|
| 67 |
const gestureHint = _isTouchDevice() ? t('crop.gesture_hint_touch') : t('crop.gesture_hint_mouse');
|
| 68 |
header.innerHTML = `
|
| 69 |
-
<span style="font-size: 16px; font-weight: bold; color: #fff;">✂️ ${escapeHtml(title)}</span>
|
| 70 |
-
<span style="font-size: 12px; color: #888;">${escapeHtml(t('crop.ratio_label'))} ${ratioText} | ${escapeHtml(gestureHint)}</span>
|
| 71 |
`;
|
| 72 |
|
| 73 |
// 裁剪区域容器
|
|
@@ -166,8 +173,28 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
|
|
| 166 |
// 🔧 P3优化:ESC 键关闭
|
| 167 |
function handleKeyDown(e) {
|
| 168 |
if (e.key === 'Escape') {
|
|
|
|
| 169 |
cleanup();
|
| 170 |
resolve(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
}
|
| 172 |
}
|
| 173 |
document.addEventListener("keydown", handleKeyDown);
|
|
@@ -308,8 +335,8 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
|
|
| 308 |
display: "flex", alignItems: "center", gap: "10px", marginTop: "15px", padding: "0 10px"
|
| 309 |
});
|
| 310 |
sliderContainer.innerHTML = `
|
| 311 |
-
<span style="color: #888; font-size: 12px;">🔍
|
| 312 |
-
<input type="range" id="crop-scale-slider" min="100" max="500" value="100"
|
| 313 |
style="flex: 1; accent-color: #4CAF50;">
|
| 314 |
<span id="crop-scale-value" style="color: #4CAF50; font-size: 12px; min-width: 40px;">100%</span>
|
| 315 |
`;
|
|
@@ -320,8 +347,8 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
|
|
| 320 |
display: "flex", gap: "10px", marginTop: "15px", justifyContent: "flex-end"
|
| 321 |
});
|
| 322 |
buttonContainer.innerHTML = `
|
| 323 |
-
<button id="btn-crop-cancel" style="padding: 10px 20px; background: transparent; border: 1px solid #555; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px;">
|
| 324 |
-
<button id="btn-crop-confirm" style="padding: 10px 30px; background: #4CAF50; border: none; color: white; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: bold;">✂️
|
| 325 |
`;
|
| 326 |
|
| 327 |
// 组装DOM
|
|
@@ -332,6 +359,7 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
|
|
| 332 |
modal.appendChild(buttonContainer);
|
| 333 |
overlay.appendChild(modal);
|
| 334 |
document.body.appendChild(overlay);
|
|
|
|
| 335 |
|
| 336 |
// 绑定缩放滑块事件
|
| 337 |
setTimeout(() => {
|
|
@@ -345,6 +373,7 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
|
|
| 345 |
|
| 346 |
scale = minScale * (parseInt(slider.value) / 100);
|
| 347 |
scaleValue.textContent = `${slider.value}%`;
|
|
|
|
| 348 |
updateImagePosition();
|
| 349 |
};
|
| 350 |
|
|
@@ -357,7 +386,7 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
|
|
| 357 |
// 确认裁剪按钮
|
| 358 |
modal.querySelector("#btn-crop-confirm").onclick = async () => {
|
| 359 |
const btn = modal.querySelector("#btn-crop-confirm");
|
| 360 |
-
btn.textContent =
|
| 361 |
btn.disabled = true;
|
| 362 |
|
| 363 |
try {
|
|
@@ -365,7 +394,7 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
|
|
| 365 |
cleanup();
|
| 366 |
resolve(croppedFile);
|
| 367 |
} catch (err) {
|
| 368 |
-
btn.textContent =
|
| 369 |
btn.disabled = false;
|
| 370 |
console.error("裁剪失败:", err);
|
| 371 |
}
|
|
@@ -385,6 +414,7 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片
|
|
| 385 |
cropContainer.removeEventListener("touchend", handleTouchEnd);
|
| 386 |
cropContainer.removeEventListener("touchcancel", handleTouchEnd);
|
| 387 |
overlay.remove();
|
|
|
|
| 388 |
}
|
| 389 |
|
| 390 |
// 点击遮罩关闭
|
|
|
|
| 35 |
* @param {number} maxSizeMB - 最大输出大小(MB)
|
| 36 |
* @returns {Promise<File|null>} - 裁剪后的文件或 null(取消)
|
| 37 |
*/
|
| 38 |
+
export function openImageCropper(file, aspectRatio = 16/9, title = t('crop.title'), maxSizeMB = 3) {
|
| 39 |
return new Promise((resolve) => {
|
| 40 |
+
const previousFocus = document.activeElement;
|
| 41 |
+
const dialogId = `image-crop-dialog-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
| 42 |
// 创建遮罩层
|
| 43 |
const overlay = document.createElement("div");
|
| 44 |
Object.assign(overlay.style, {
|
|
|
|
| 49 |
|
| 50 |
// 创建弹窗容器
|
| 51 |
const modal = document.createElement("div");
|
| 52 |
+
modal.setAttribute("role", "dialog");
|
| 53 |
+
modal.setAttribute("aria-modal", "true");
|
| 54 |
+
modal.setAttribute("aria-labelledby", `${dialogId}-title`);
|
| 55 |
+
modal.setAttribute("aria-describedby", `${dialogId}-hint`);
|
| 56 |
+
modal.setAttribute("tabindex", "-1");
|
| 57 |
Object.assign(modal.style, {
|
| 58 |
background: "var(--comfy-input-bg)", borderRadius: "12px", padding: "20px",
|
| 59 |
maxWidth: "90vw", maxHeight: "90vh", display: "flex", flexDirection: "column",
|
|
|
|
| 73 |
// 📱 触控设备与鼠标设备的操作提示区分(移动端无滚轮/拖动概念);文案走词典,随界面语言切换
|
| 74 |
const gestureHint = _isTouchDevice() ? t('crop.gesture_hint_touch') : t('crop.gesture_hint_mouse');
|
| 75 |
header.innerHTML = `
|
| 76 |
+
<span id="${dialogId}-title" role="heading" aria-level="2" style="font-size: 16px; font-weight: bold; color: #fff;">✂️ ${escapeHtml(title)}</span>
|
| 77 |
+
<span id="${dialogId}-hint" style="font-size: 12px; color: #888;">${escapeHtml(t('crop.ratio_label'))} ${ratioText} | ${escapeHtml(gestureHint)}</span>
|
| 78 |
`;
|
| 79 |
|
| 80 |
// 裁剪区域容器
|
|
|
|
| 173 |
// 🔧 P3优化:ESC 键关闭
|
| 174 |
function handleKeyDown(e) {
|
| 175 |
if (e.key === 'Escape') {
|
| 176 |
+
e.preventDefault();
|
| 177 |
cleanup();
|
| 178 |
resolve(null);
|
| 179 |
+
return;
|
| 180 |
+
}
|
| 181 |
+
if (e.key !== 'Tab') return;
|
| 182 |
+
const focusable = Array.from(modal.querySelectorAll(
|
| 183 |
+
'button:not(:disabled), input:not(:disabled), [tabindex]:not([tabindex="-1"])'
|
| 184 |
+
));
|
| 185 |
+
if (!focusable.length) {
|
| 186 |
+
e.preventDefault();
|
| 187 |
+
modal.focus();
|
| 188 |
+
return;
|
| 189 |
+
}
|
| 190 |
+
const first = focusable[0];
|
| 191 |
+
const last = focusable[focusable.length - 1];
|
| 192 |
+
if (e.shiftKey && (document.activeElement === first || !modal.contains(document.activeElement))) {
|
| 193 |
+
e.preventDefault();
|
| 194 |
+
last.focus();
|
| 195 |
+
} else if (!e.shiftKey && (document.activeElement === last || !modal.contains(document.activeElement))) {
|
| 196 |
+
e.preventDefault();
|
| 197 |
+
first.focus();
|
| 198 |
}
|
| 199 |
}
|
| 200 |
document.addEventListener("keydown", handleKeyDown);
|
|
|
|
| 335 |
display: "flex", alignItems: "center", gap: "10px", marginTop: "15px", padding: "0 10px"
|
| 336 |
});
|
| 337 |
sliderContainer.innerHTML = `
|
| 338 |
+
<span style="color: #888; font-size: 12px;">🔍 ${escapeHtml(t('crop.zoom_label'))}</span>
|
| 339 |
+
<input type="range" id="crop-scale-slider" min="100" max="500" value="100" aria-label="${escapeHtml(t('crop.zoom_label'))}" aria-valuetext="100%"
|
| 340 |
style="flex: 1; accent-color: #4CAF50;">
|
| 341 |
<span id="crop-scale-value" style="color: #4CAF50; font-size: 12px; min-width: 40px;">100%</span>
|
| 342 |
`;
|
|
|
|
| 347 |
display: "flex", gap: "10px", marginTop: "15px", justifyContent: "flex-end"
|
| 348 |
});
|
| 349 |
buttonContainer.innerHTML = `
|
| 350 |
+
<button id="btn-crop-cancel" type="button" style="padding: 10px 20px; background: transparent; border: 1px solid #555; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px;">${escapeHtml(t('common.cancel'))}</button>
|
| 351 |
+
<button id="btn-crop-confirm" type="button" style="padding: 10px 30px; background: #4CAF50; border: none; color: white; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: bold;">✂️ ${escapeHtml(t('crop.confirm'))}</button>
|
| 352 |
`;
|
| 353 |
|
| 354 |
// 组装DOM
|
|
|
|
| 359 |
modal.appendChild(buttonContainer);
|
| 360 |
overlay.appendChild(modal);
|
| 361 |
document.body.appendChild(overlay);
|
| 362 |
+
modal.querySelector("#btn-crop-cancel").focus();
|
| 363 |
|
| 364 |
// 绑定缩放滑块事件
|
| 365 |
setTimeout(() => {
|
|
|
|
| 373 |
|
| 374 |
scale = minScale * (parseInt(slider.value) / 100);
|
| 375 |
scaleValue.textContent = `${slider.value}%`;
|
| 376 |
+
slider.setAttribute("aria-valuetext", `${slider.value}%`);
|
| 377 |
updateImagePosition();
|
| 378 |
};
|
| 379 |
|
|
|
|
| 386 |
// 确认裁剪按钮
|
| 387 |
modal.querySelector("#btn-crop-confirm").onclick = async () => {
|
| 388 |
const btn = modal.querySelector("#btn-crop-confirm");
|
| 389 |
+
btn.textContent = `⏳ ${t('crop.processing')}`;
|
| 390 |
btn.disabled = true;
|
| 391 |
|
| 392 |
try {
|
|
|
|
| 394 |
cleanup();
|
| 395 |
resolve(croppedFile);
|
| 396 |
} catch (err) {
|
| 397 |
+
btn.textContent = t('crop.process_failed');
|
| 398 |
btn.disabled = false;
|
| 399 |
console.error("裁剪失败:", err);
|
| 400 |
}
|
|
|
|
| 414 |
cropContainer.removeEventListener("touchend", handleTouchEnd);
|
| 415 |
cropContainer.removeEventListener("touchcancel", handleTouchEnd);
|
| 416 |
overlay.remove();
|
| 417 |
+
if (previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus();
|
| 418 |
}
|
| 419 |
|
| 420 |
// 点击遮罩关闭
|
前端页面/components/彩蛋动画引擎.js
CHANGED
|
@@ -8,9 +8,8 @@
|
|
| 8 |
/**
|
| 9 |
* 在新标签页打开彩蛋动画页面
|
| 10 |
*/
|
| 11 |
-
export function openEasterEggPage() {
|
| 12 |
-
const html = generateFullHTML();
|
| 13 |
-
const newWindow = window.open('', '_blank');
|
| 14 |
if (newWindow) {
|
| 15 |
newWindow.document.write(html);
|
| 16 |
newWindow.document.close();
|
|
@@ -20,13 +19,15 @@ export function openEasterEggPage() {
|
|
| 20 |
/**
|
| 21 |
* 生成完整的彩蛋动画HTML页面
|
| 22 |
*/
|
| 23 |
-
function generateFullHTML() {
|
|
|
|
|
|
|
| 24 |
return `<!DOCTYPE html>
|
| 25 |
-
<html lang="
|
| 26 |
<head>
|
| 27 |
<meta charset="UTF-8">
|
| 28 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 29 |
-
<title>🐷
|
| 30 |
<style>
|
| 31 |
* {
|
| 32 |
margin: 0;
|
|
@@ -70,9 +71,10 @@ function generateFullHTML() {
|
|
| 70 |
</head>
|
| 71 |
<body>
|
| 72 |
<canvas id="canvas"></canvas>
|
| 73 |
-
<div id="hint">
|
| 74 |
-
<div id="speed-indicator">
|
| 75 |
<script>
|
|
|
|
| 76 |
// ==================== 配置参数 ====================
|
| 77 |
// 千字文(用于星空星星)
|
| 78 |
const QIANZIWEN = '天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈剑号巨阙珠称夜光果珍李柰菜重芥姜海咸河淡鳞潜羽翔龙师火帝鸟官人皇始制文字乃服衣裳推位让国有虞陶唐吊民伐罪周发殷汤坐朝问道垂拱平章爱育黎首臣伏戎羌遐迩一体率宾归王鸣凤在竹白驹食场化被草木赖及万方盖此身发四大五常恭惟鞠养岂敢毁伤女慕贞洁男效才良知过必改得能莫忘罔谈彼短靡恃己长信使可覆器欲难量墨悲丝染诗赞羔羊景行维贤克念作圣德建名立形端表正空谷传声虚堂习听祸因恶积福缘善庆尺璧非宝寸阴是竞资父事君曰严与敬孝当竭力忠则尽命临深履薄夙兴温凊似兰斯馨如松之盛川流不息渊澄取映容止若思言辞安定笃初诚美慎终宜令荣业所基籍甚无竟学优登仕摄职从政存以甘棠去而益咏乐殊贵贱礼别尊卑上和下睦夫唱妇随外受傅训入奉母仪诸姑伯叔犹子比儿孔怀兄弟同气连枝交友投分切磨箴规仁慈隐恻造次弗离节义廉退颠沛匪亏性静情逸心动神疲守真志满逐物意移坚持雅操好爵自縻都邑华夏东西二京背邙面洛浮渭据泾宫殿盘郁楼观飞惊图写禽兽画彩仙灵丙舍旁启甲帐对楹肆筵设席鼓瑟吹笙升阶纳陛弁转疑星右通广内左达承明既集坟典亦聚群英杜稿钟隶漆书壁经府罗将相路侠槐卿户封八县家给千兵高冠陪辇驱毂振缨世禄侈富车驾肥轻策功茂实勒碑刻铭盘溪伊尹佐时阿衡奄宅曲阜微旦孰营桓公匡合济弱扶倾绮回汉惠说感武丁俊乂密勿多士寔宁晋楚更霸赵魏困横假途灭虢践土会盟何遵约法韩弊烦刑起翦颇牧用军最精宣威沙漠驰誉丹青九州禹迹百郡秦并岳宗泰岱禅主云亭雁门紫塞鸡田赤城昆池碣石巨野洞庭旷远绵邈岩岫杳冥治本于农务资稼穑俶载南亩我艺黍稷税熟贡新劝赏黜陟孟轲敦素史鱼秉直庶几中庸劳谦谨敕聆音察理鉴貌辨色贻厥嘉猷勉其祗植省躬讥诫宠增抗极殆辱近耻林皋幸即两疏见机解组谁逼索居闲处沉默寂寥求古寻论散虑逍遥欣奏累遣戚谢欢招渠荷的历园莽抽条枇杷晚翠梧桐早凋陈根委翳落叶飘摇游鹍独运凌摩绛霄耽读玩市寓目囊箱易輶攸畏属耳垣墙具膳餐饭适口充肠饱饫烹宰饥厌糟糠亲戚故旧老少异粮妾御绩纺侍巾帷房纨扇圆洁银烛炜煌昼眠夕寐蓝笋象床弦歌酒宴接杯举觞矫手顿足悦豫且康嫡后嗣续祭祀烝尝稽颡再拜悚惧恐惶笺牒简要顾答审详骸垢想浴执热愿凉驴骡犊特骇跃超骧诛斩贼盗捕获叛亡布射僚丸嵇琴阮啸恬笔伦纸钧巧任钓释纷利俗并皆佳妙毛施淑姿工颦妍笑年矢每催曦晖朗曜璇玑悬斡晦魄环照指薪修祜永绥吉劭矩步引领俯仰廊庙束带矜庄徘徊瞻眺孤陋寡闻愚蒙等诮谓语助者焉哉乎也';
|
|
@@ -763,7 +765,7 @@ function generateFullHTML() {
|
|
| 763 |
cx = lerp(cx, targetCx, ease1);
|
| 764 |
cy = lerp(cy, targetCy, ease1);
|
| 765 |
currentSpeed = lerp(currentSpeed, targetSpeed, 0.05);
|
| 766 |
-
speedIndicator.textContent =
|
| 767 |
}
|
| 768 |
}
|
| 769 |
|
|
|
|
| 8 |
/**
|
| 9 |
* 在新标签页打开彩蛋动画页面
|
| 10 |
*/
|
| 11 |
+
export function openEasterEggPage(newWindow, { language, title, hint, speedLabel } = {}) {
|
| 12 |
+
const html = generateFullHTML({ language, title, hint, speedLabel });
|
|
|
|
| 13 |
if (newWindow) {
|
| 14 |
newWindow.document.write(html);
|
| 15 |
newWindow.document.close();
|
|
|
|
| 19 |
/**
|
| 20 |
* 生成完整的彩蛋动画HTML页面
|
| 21 |
*/
|
| 22 |
+
function generateFullHTML({ language = 'zh-CN', title = '猪的飞行梦', hint = '鼠标移动改变视角 | PgUp加速 PgDn减速 | ESC关闭', speedLabel = '速度' } = {}) {
|
| 23 |
+
const safeLanguage = language === 'en-US' ? 'en-US' : 'zh-CN';
|
| 24 |
+
const speedLabelLiteral = JSON.stringify(speedLabel);
|
| 25 |
return `<!DOCTYPE html>
|
| 26 |
+
<html lang="${safeLanguage}">
|
| 27 |
<head>
|
| 28 |
<meta charset="UTF-8">
|
| 29 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 30 |
+
<title>🐷 ${title}</title>
|
| 31 |
<style>
|
| 32 |
* {
|
| 33 |
margin: 0;
|
|
|
|
| 71 |
</head>
|
| 72 |
<body>
|
| 73 |
<canvas id="canvas"></canvas>
|
| 74 |
+
<div id="hint">${hint}</div>
|
| 75 |
+
<div id="speed-indicator">${speedLabel}: 1.0x</div>
|
| 76 |
<script>
|
| 77 |
+
const SPEED_LABEL = ${speedLabelLiteral};
|
| 78 |
// ==================== 配置参数 ====================
|
| 79 |
// 千字文(用于星空星星)
|
| 80 |
const QIANZIWEN = '天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈剑号巨阙珠称夜光果珍李柰菜重芥姜海咸河淡鳞潜羽翔龙师火帝鸟官人皇始制文字乃服衣裳推位让国有虞陶唐吊民伐罪周发殷汤坐朝问道垂拱平章爱育黎首臣伏戎羌遐迩一体率宾归王鸣凤在竹白驹食场化被草木赖及万方盖此身发四大五常恭惟鞠养岂敢毁伤女慕贞洁男效才良知过必改得能莫忘罔谈彼短靡恃己长信使可覆器欲难量墨悲丝染诗赞羔羊景行维贤克念作圣德建名立形端表正空谷传声虚堂习听祸因恶积福缘善庆尺璧非宝寸阴是竞资父事君曰严与敬孝当竭力忠则尽命临深履薄夙兴温凊似兰斯馨如松之盛川流不息渊澄取映容止若思言辞安定笃初诚美慎终宜令荣业所基籍甚无竟学优登仕摄职从政存以甘棠去而益咏乐殊贵贱礼别尊卑上和下睦夫唱妇随外受傅训入奉母仪诸姑伯叔犹子比儿孔怀兄弟同气连枝交友投分切磨箴规仁慈隐恻造次弗离节义廉退颠沛匪亏性静情逸心动神疲守真志满逐物意移坚持雅操好爵自縻都邑华夏东西二京背邙面洛浮渭据泾宫殿盘郁楼观飞惊图写禽兽画彩仙灵丙舍旁启甲帐对楹肆筵设席鼓瑟吹笙升阶纳陛弁转疑星右通广内左达承明既集坟典亦聚群英杜稿钟隶漆书壁经府罗将相路侠槐卿户封八县家给千兵高冠陪辇驱毂振缨世禄侈富车驾肥轻策功茂实勒碑刻铭盘溪伊尹佐时阿衡奄宅曲阜微旦孰营桓公匡合济弱扶倾绮回汉惠说感武丁俊乂密勿多士寔宁晋楚更霸赵魏困横假途灭虢践土会盟何遵约法韩弊烦刑起翦颇牧用军最精宣威沙漠驰誉丹青九州禹迹百郡秦并岳宗泰岱禅主云亭雁门紫塞鸡田赤城昆池碣石巨野洞庭旷远绵邈岩岫杳冥治本于农务资稼穑俶载南亩我艺黍稷税熟贡新劝赏黜陟孟轲敦素史鱼秉直庶几中庸劳谦谨敕聆音察理鉴貌辨色贻厥嘉猷勉其祗植省躬讥诫宠增抗极殆辱近耻林皋幸即两疏见机解组谁逼索居闲处沉默寂寥求古寻论散虑逍遥欣奏累遣戚谢欢招渠荷的历园莽抽条枇杷晚翠梧桐早凋陈根委翳落叶飘摇游鹍独运凌摩绛霄耽读玩市寓目囊箱易輶攸畏属耳垣墙具膳餐饭适口充肠饱饫烹宰饥厌糟糠亲戚故旧老少异粮妾御绩纺侍巾帷房纨扇圆洁银烛炜煌昼眠夕寐蓝笋象床弦歌酒宴接杯举觞矫手顿足悦豫且康嫡后嗣续祭祀烝尝稽颡再拜悚惧恐惶笺牒简要顾答审详骸垢想浴执热愿凉驴骡犊特骇跃超骧诛斩贼盗捕获叛亡布射僚丸嵇琴阮啸恬笔伦纸钧巧任钓释纷利俗并皆佳妙毛施淑姿工颦妍笑年矢每催曦晖朗曜璇玑悬斡晦魄环照指薪修祜永绥吉劭矩步引领俯仰廊庙束带矜庄徘徊瞻眺孤陋寡闻愚蒙等诮谓语助者焉哉乎也';
|
|
|
|
| 765 |
cx = lerp(cx, targetCx, ease1);
|
| 766 |
cy = lerp(cy, targetCy, ease1);
|
| 767 |
currentSpeed = lerp(currentSpeed, targetSpeed, 0.05);
|
| 768 |
+
speedIndicator.textContent = SPEED_LABEL + ': ' + currentSpeed.toFixed(1) + 'x';
|
| 769 |
}
|
| 770 |
}
|
| 771 |
|
前端页面/components/性能优化工具.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
| 18 |
import { PAGINATION } from "../core/全局配置.js";
|
| 19 |
// 📦 列表缓存工具需要本地可用的缓存实现;下方「向后兼容重导出」那行是纯转发、
|
| 20 |
// 不会生成本地绑定,故此处必须单独 import(勿误删,否则 readListCache 运行时报错)
|
| 21 |
-
import { setCache, getCacheWithMeta } from "../core/网络请求_缓存管理.js";
|
| 22 |
|
| 23 |
// ==========================================
|
| 24 |
// 🔒 P0安全优化:敏感数据脱敏工具
|
|
@@ -216,8 +216,10 @@ export { setCache, getCache, getCacheWithMeta, removeCache, removeCacheByPrefix,
|
|
| 216 |
* @param {Object} options - 配置选项
|
| 217 |
* @param {HTMLElement} options.container - 滚动容器
|
| 218 |
* @param {Function} options.loadMore - 加载更多数据的函数,返回 Promise
|
|
|
|
| 219 |
* @param {number} options.pageSize - 每页数量,默认 20
|
| 220 |
* @param {number} options.threshold - 触发阈值(距底部像素),默认 200
|
|
|
|
| 221 |
* @returns {Object} 分页加载器实例
|
| 222 |
*/
|
| 223 |
export function createPaginationLoader(options) {
|
|
@@ -227,42 +229,58 @@ export function createPaginationLoader(options) {
|
|
| 227 |
pageSize = 20,
|
| 228 |
threshold = 200
|
| 229 |
} = options;
|
|
|
|
|
|
|
| 230 |
|
| 231 |
let currentPage = 1; // 首屏已渲染第1页,loadMore前先递增,从第2页开始加载
|
| 232 |
let isLoading = false;
|
| 233 |
let hasMore = true;
|
| 234 |
let debounceTimer = null;
|
| 235 |
-
let consecutiveErrors = 0;
|
| 236 |
-
let retryTimer = null;
|
| 237 |
let generation = 0; // 🔄 轻量级取消令牌,reset 时递增以丢弃飞行中请求
|
|
|
|
| 238 |
|
| 239 |
// 加载指示器
|
| 240 |
const loadingIndicator = document.createElement("div");
|
| 241 |
loadingIndicator.className = "pagination-loading";
|
| 242 |
-
loadingIndicator.
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
| 248 |
loadingIndicator.style.display = "none";
|
| 249 |
|
| 250 |
// "没有更多"提示
|
| 251 |
const endIndicator = document.createElement("div");
|
| 252 |
endIndicator.className = "pagination-end";
|
| 253 |
-
endIndicator.
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
</div>
|
| 257 |
-
`;
|
| 258 |
endIndicator.style.display = "none";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
|
| 260 |
// 滚动事件处理(带防抖)
|
| 261 |
const handleScroll = () => {
|
|
|
|
| 262 |
if (debounceTimer) clearTimeout(debounceTimer);
|
| 263 |
|
| 264 |
debounceTimer = setTimeout(() => {
|
| 265 |
-
if (isLoading || !hasMore) return;
|
| 266 |
|
| 267 |
const { scrollTop, scrollHeight, clientHeight } = container;
|
| 268 |
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
|
|
@@ -275,67 +293,85 @@ export function createPaginationLoader(options) {
|
|
| 275 |
|
| 276 |
// 加载下一页
|
| 277 |
const _loadNextPage = async () => {
|
| 278 |
-
if (isLoading || !hasMore) return;
|
| 279 |
|
| 280 |
isLoading = true;
|
| 281 |
loadingIndicator.style.display = "block";
|
|
|
|
| 282 |
|
| 283 |
try {
|
| 284 |
currentPage++; // 先递增,再加载(首屏已渲染第1页,从第2页开始)
|
| 285 |
-
const
|
| 286 |
-
|
|
|
|
| 287 |
if (savedGen !== generation) return; // reset 被调用了,丢弃结果
|
| 288 |
|
| 289 |
// 加载失败,允许重试
|
| 290 |
if (result === null) {
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
if (retryTimer) clearTimeout(retryTimer);
|
| 296 |
-
retryTimer = setTimeout(() => {
|
| 297 |
-
consecutiveErrors = 0;
|
| 298 |
-
hasMore = true;
|
| 299 |
-
}, 5000);
|
| 300 |
-
}
|
| 301 |
return;
|
| 302 |
}
|
| 303 |
|
| 304 |
-
consecutiveErrors = 0;
|
| 305 |
-
if (retryTimer) {
|
| 306 |
-
clearTimeout(retryTimer);
|
| 307 |
-
retryTimer = null;
|
| 308 |
-
}
|
| 309 |
-
|
| 310 |
// 判断是否还有更多 - 返回空数组则停止
|
| 311 |
if (!result || result.length === 0) {
|
| 312 |
hasMore = false;
|
| 313 |
loadingIndicator.style.display = "none";
|
|
|
|
|
|
|
|
|
|
| 314 |
}
|
| 315 |
} catch (error) {
|
| 316 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
} finally {
|
| 318 |
-
|
| 319 |
-
|
| 320 |
loadingIndicator.style.display = "none";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
}
|
| 322 |
}
|
| 323 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
|
| 325 |
// 启动监听
|
| 326 |
const start = () => {
|
|
|
|
| 327 |
container.addEventListener("scroll", handleScroll);
|
| 328 |
// 添加指示器到容器末尾
|
| 329 |
const scrollContent = container.querySelector(".scroll-content") || container;
|
| 330 |
scrollContent.appendChild(loadingIndicator);
|
| 331 |
scrollContent.appendChild(endIndicator);
|
|
|
|
| 332 |
};
|
| 333 |
|
| 334 |
// 停止监听
|
| 335 |
const stop = () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
container.removeEventListener("scroll", handleScroll);
|
| 337 |
if (debounceTimer) clearTimeout(debounceTimer);
|
| 338 |
-
|
|
|
|
| 339 |
};
|
| 340 |
|
| 341 |
// 重置状态
|
|
@@ -344,13 +380,9 @@ export function createPaginationLoader(options) {
|
|
| 344 |
currentPage = 1; // 首屏已渲染第1页,分页器从第2页开始
|
| 345 |
hasMore = true;
|
| 346 |
isLoading = false;
|
| 347 |
-
consecutiveErrors = 0;
|
| 348 |
-
if (retryTimer) {
|
| 349 |
-
clearTimeout(retryTimer);
|
| 350 |
-
retryTimer = null;
|
| 351 |
-
}
|
| 352 |
loadingIndicator.style.display = "none";
|
| 353 |
endIndicator.style.display = "none";
|
|
|
|
| 354 |
};
|
| 355 |
|
| 356 |
// 手动触发加载
|
|
@@ -367,6 +399,24 @@ export function createPaginationLoader(options) {
|
|
| 367 |
};
|
| 368 |
}
|
| 369 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
|
| 371 |
// ==========================================
|
| 372 |
// 🖼️ 图片懒加载
|
|
@@ -1259,11 +1309,23 @@ export { eventManager };
|
|
| 1259 |
* @returns {Array|null} 命中返回数组,未命中/空列表返回 null
|
| 1260 |
*/
|
| 1261 |
export function readListCache(cacheKey) {
|
| 1262 |
-
const { value } = getCacheWithMeta(cacheKey, true); // true = 忽略过期
|
| 1263 |
-
if (!Array.isArray(value)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1264 |
return value;
|
| 1265 |
}
|
| 1266 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1267 |
/**
|
| 1268 |
* 💾 回写列表首屏缓存(持久化到 localStorage,保证重启后离线仍可展示)
|
| 1269 |
* @param {string} cacheKey - 完整缓存 Key
|
|
@@ -1271,10 +1333,63 @@ export function readListCache(cacheKey) {
|
|
| 1271 |
* @param {number} ttl - 有效期(毫秒),由调用方传 getCacheTTL()
|
| 1272 |
*/
|
| 1273 |
export function writeListCache(cacheKey, items, ttl) {
|
| 1274 |
-
if (!Array.isArray(items)) return;
|
| 1275 |
setCache(cacheKey, items, ttl, true); // true = 持久化 localStorage
|
| 1276 |
}
|
| 1277 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1278 |
/**
|
| 1279 |
* 🔍 从列表缓存中查找指定项(详情页离线回退用)
|
| 1280 |
* @param {string} prefix - 缓存Key前缀(如 "TasksCache_"、"PostsCache_")
|
|
@@ -1283,20 +1398,14 @@ export function writeListCache(cacheKey, items, ttl) {
|
|
| 1283 |
*/
|
| 1284 |
export function findInListCache(prefix, id) {
|
| 1285 |
try {
|
| 1286 |
-
const
|
| 1287 |
-
|
| 1288 |
-
|
| 1289 |
-
|
| 1290 |
-
|
| 1291 |
-
const items = raw?.value;
|
| 1292 |
-
if (Array.isArray(items)) {
|
| 1293 |
-
const found = items.find(item => item.id === id);
|
| 1294 |
-
if (found) return found;
|
| 1295 |
-
}
|
| 1296 |
-
}
|
| 1297 |
}
|
| 1298 |
} catch (e) {
|
| 1299 |
-
console.warn("列表缓存查找失败
|
| 1300 |
}
|
| 1301 |
return null;
|
| 1302 |
}
|
|
|
|
| 18 |
import { PAGINATION } from "../core/全局配置.js";
|
| 19 |
// 📦 列表缓存工具需要本地可用的缓存实现;下方「向后兼容重导出」那行是纯转发、
|
| 20 |
// 不会生成本地绑定,故此处必须单独 import(勿误删,否则 readListCache 运行时报错)
|
| 21 |
+
import { setCache, getCacheWithMeta, removeCache, getCacheKeysByPrefix } from "../core/网络请求_缓存管理.js";
|
| 22 |
|
| 23 |
// ==========================================
|
| 24 |
// 🔒 P0安全优化:敏感数据脱敏工具
|
|
|
|
| 216 |
* @param {Object} options - 配置选项
|
| 217 |
* @param {HTMLElement} options.container - 滚动容器
|
| 218 |
* @param {Function} options.loadMore - 加载更多数据的函数,返回 Promise
|
| 219 |
+
* @param {Function} options.t - 本地化函数(分页状态和失败重试文案)
|
| 220 |
* @param {number} options.pageSize - 每页数量,默认 20
|
| 221 |
* @param {number} options.threshold - 触发阈值(距底部像素),默认 200
|
| 222 |
+
* @param {Function} [options.autoFill] - 可选;返回 true 时继续搜索分页,无滚动空间时也会填充到可滚动
|
| 223 |
* @returns {Object} 分页加载器实例
|
| 224 |
*/
|
| 225 |
export function createPaginationLoader(options) {
|
|
|
|
| 229 |
pageSize = 20,
|
| 230 |
threshold = 200
|
| 231 |
} = options;
|
| 232 |
+
const translate = options.t;
|
| 233 |
+
const autoFill = options.autoFill;
|
| 234 |
|
| 235 |
let currentPage = 1; // 首屏已渲染第1页,loadMore前先递增,从第2页开始加载
|
| 236 |
let isLoading = false;
|
| 237 |
let hasMore = true;
|
| 238 |
let debounceTimer = null;
|
|
|
|
|
|
|
| 239 |
let generation = 0; // 🔄 轻量级取消令牌,reset 时递增以丢弃飞行中请求
|
| 240 |
+
let stopped = false; // 保持创建后可手动加载;stop 后仅 start 能重新启用。
|
| 241 |
|
| 242 |
// 加载指示器
|
| 243 |
const loadingIndicator = document.createElement("div");
|
| 244 |
loadingIndicator.className = "pagination-loading";
|
| 245 |
+
loadingIndicator.setAttribute("role", "status");
|
| 246 |
+
loadingIndicator.setAttribute("aria-live", "polite");
|
| 247 |
+
loadingIndicator.setAttribute("aria-atomic", "true");
|
| 248 |
+
const loadingSpinner = document.createElement("span");
|
| 249 |
+
loadingSpinner.className = "pagination-loading__spinner";
|
| 250 |
+
loadingSpinner.setAttribute("aria-hidden", "true");
|
| 251 |
+
const loadingText = document.createElement("span");
|
| 252 |
+
loadingText.textContent = translate("pagination.loading");
|
| 253 |
+
loadingIndicator.append(loadingSpinner, loadingText);
|
| 254 |
loadingIndicator.style.display = "none";
|
| 255 |
|
| 256 |
// "没有更多"提示
|
| 257 |
const endIndicator = document.createElement("div");
|
| 258 |
endIndicator.className = "pagination-end";
|
| 259 |
+
endIndicator.setAttribute("role", "status");
|
| 260 |
+
endIndicator.setAttribute("aria-live", "polite");
|
| 261 |
+
endIndicator.textContent = translate("pagination.end");
|
|
|
|
|
|
|
| 262 |
endIndicator.style.display = "none";
|
| 263 |
+
|
| 264 |
+
const retryIndicator = document.createElement("div");
|
| 265 |
+
retryIndicator.className = "pagination-error";
|
| 266 |
+
retryIndicator.setAttribute("role", "alert");
|
| 267 |
+
retryIndicator.setAttribute("aria-live", "assertive");
|
| 268 |
+
const retryMessage = document.createElement("span");
|
| 269 |
+
retryMessage.textContent = translate("pagination.failed");
|
| 270 |
+
const retryButton = document.createElement("button");
|
| 271 |
+
retryButton.type = "button";
|
| 272 |
+
retryButton.className = "pagination-error__retry";
|
| 273 |
+
retryButton.textContent = translate("common.retry");
|
| 274 |
+
retryIndicator.append(retryMessage, retryButton);
|
| 275 |
+
retryIndicator.style.display = "none";
|
| 276 |
|
| 277 |
// 滚动事件处理(带防抖)
|
| 278 |
const handleScroll = () => {
|
| 279 |
+
if (stopped) return;
|
| 280 |
if (debounceTimer) clearTimeout(debounceTimer);
|
| 281 |
|
| 282 |
debounceTimer = setTimeout(() => {
|
| 283 |
+
if (stopped || isLoading || !hasMore) return;
|
| 284 |
|
| 285 |
const { scrollTop, scrollHeight, clientHeight } = container;
|
| 286 |
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
|
|
|
|
| 293 |
|
| 294 |
// 加载下一页
|
| 295 |
const _loadNextPage = async () => {
|
| 296 |
+
if (stopped || isLoading || !hasMore) return;
|
| 297 |
|
| 298 |
isLoading = true;
|
| 299 |
loadingIndicator.style.display = "block";
|
| 300 |
+
const savedGen = generation;
|
| 301 |
|
| 302 |
try {
|
| 303 |
currentPage++; // 先递增,再加载(首屏已渲染第1页,从第2页开始)
|
| 304 |
+
const result = await loadMore(currentPage, pageSize, {
|
| 305 |
+
isCurrent: () => savedGen === generation
|
| 306 |
+
});
|
| 307 |
if (savedGen !== generation) return; // reset 被调用了,丢弃结果
|
| 308 |
|
| 309 |
// 加载失败,允许重试
|
| 310 |
if (result === null) {
|
| 311 |
+
currentPage = Math.max(1, currentPage - 1);
|
| 312 |
+
hasMore = false;
|
| 313 |
+
loadingIndicator.style.display = "none";
|
| 314 |
+
retryIndicator.style.display = "flex";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
return;
|
| 316 |
}
|
| 317 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
// 判断是否还有更多 - 返回空数组则停止
|
| 319 |
if (!result || result.length === 0) {
|
| 320 |
hasMore = false;
|
| 321 |
loadingIndicator.style.display = "none";
|
| 322 |
+
endIndicator.style.display = "block";
|
| 323 |
+
} else {
|
| 324 |
+
endIndicator.style.display = "none";
|
| 325 |
}
|
| 326 |
} catch (error) {
|
| 327 |
+
if (savedGen === generation) {
|
| 328 |
+
currentPage = Math.max(1, currentPage - 1);
|
| 329 |
+
hasMore = false;
|
| 330 |
+
retryIndicator.style.display = "flex";
|
| 331 |
+
console.error("分页加载失败");
|
| 332 |
+
}
|
| 333 |
} finally {
|
| 334 |
+
if (savedGen === generation) {
|
| 335 |
+
isLoading = false;
|
| 336 |
loadingIndicator.style.display = "none";
|
| 337 |
+
// Search callers can scan pages with no local matches without
|
| 338 |
+
// changing pagination behavior for other lists.
|
| 339 |
+
if (hasMore && typeof autoFill === "function" &&
|
| 340 |
+
(autoFill() || container.scrollHeight <= container.clientHeight + threshold)) {
|
| 341 |
+
_loadNextPage();
|
| 342 |
+
}
|
| 343 |
}
|
| 344 |
}
|
| 345 |
};
|
| 346 |
+
|
| 347 |
+
retryButton.addEventListener("click", () => {
|
| 348 |
+
if (stopped || isLoading) return;
|
| 349 |
+
retryIndicator.style.display = "none";
|
| 350 |
+
hasMore = true;
|
| 351 |
+
_loadNextPage();
|
| 352 |
+
});
|
| 353 |
|
| 354 |
// 启动监听
|
| 355 |
const start = () => {
|
| 356 |
+
stopped = false;
|
| 357 |
container.addEventListener("scroll", handleScroll);
|
| 358 |
// 添加指示器到容器末尾
|
| 359 |
const scrollContent = container.querySelector(".scroll-content") || container;
|
| 360 |
scrollContent.appendChild(loadingIndicator);
|
| 361 |
scrollContent.appendChild(endIndicator);
|
| 362 |
+
scrollContent.appendChild(retryIndicator);
|
| 363 |
};
|
| 364 |
|
| 365 |
// 停止监听
|
| 366 |
const stop = () => {
|
| 367 |
+
stopped = true;
|
| 368 |
+
generation++;
|
| 369 |
+
if (isLoading) currentPage = Math.max(1, currentPage - 1);
|
| 370 |
+
isLoading = false;
|
| 371 |
container.removeEventListener("scroll", handleScroll);
|
| 372 |
if (debounceTimer) clearTimeout(debounceTimer);
|
| 373 |
+
loadingIndicator.style.display = "none";
|
| 374 |
+
retryIndicator.style.display = "none";
|
| 375 |
};
|
| 376 |
|
| 377 |
// 重置状态
|
|
|
|
| 380 |
currentPage = 1; // 首屏已渲染第1页,分页器从第2页开始
|
| 381 |
hasMore = true;
|
| 382 |
isLoading = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
loadingIndicator.style.display = "none";
|
| 384 |
endIndicator.style.display = "none";
|
| 385 |
+
retryIndicator.style.display = "none";
|
| 386 |
};
|
| 387 |
|
| 388 |
// 手动触发加载
|
|
|
|
| 399 |
};
|
| 400 |
}
|
| 401 |
|
| 402 |
+
/** Render an accessible, localized search state inside a list grid. */
|
| 403 |
+
export function renderListSearchMessage(container, message) {
|
| 404 |
+
container.querySelector('.list-search-message')?.remove();
|
| 405 |
+
const status = document.createElement("div");
|
| 406 |
+
status.className = "list-search-message";
|
| 407 |
+
status.setAttribute("role", "status");
|
| 408 |
+
status.setAttribute("aria-live", "polite");
|
| 409 |
+
status.textContent = message;
|
| 410 |
+
Object.assign(status.style, {
|
| 411 |
+
gridColumn: "1 / -1",
|
| 412 |
+
textAlign: "center",
|
| 413 |
+
padding: "24px 16px",
|
| 414 |
+
color: "var(--descrip-text, #999)"
|
| 415 |
+
});
|
| 416 |
+
container.appendChild(status);
|
| 417 |
+
return status;
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
|
| 421 |
// ==========================================
|
| 422 |
// 🖼️ 图片懒加载
|
|
|
|
| 1309 |
* @returns {Array|null} 命中返回数组,未命中/空列表返回 null
|
| 1310 |
*/
|
| 1311 |
export function readListCache(cacheKey) {
|
| 1312 |
+
const { value, found } = getCacheWithMeta(cacheKey, true); // true = 忽略过期
|
| 1313 |
+
if (!Array.isArray(value)) {
|
| 1314 |
+
if (found) removeCache(cacheKey);
|
| 1315 |
+
return null;
|
| 1316 |
+
}
|
| 1317 |
+
if (value.length === 0) return null;
|
| 1318 |
+
if (_hasInvalidListItem(value)) {
|
| 1319 |
+
removeCache(cacheKey);
|
| 1320 |
+
return null;
|
| 1321 |
+
}
|
| 1322 |
return value;
|
| 1323 |
}
|
| 1324 |
|
| 1325 |
+
function _hasInvalidListItem(items) {
|
| 1326 |
+
return items.some(item => !item || typeof item !== "object" || Array.isArray(item));
|
| 1327 |
+
}
|
| 1328 |
+
|
| 1329 |
/**
|
| 1330 |
* 💾 回写列表首屏缓存(持久化到 localStorage,保证重启后离线仍可展示)
|
| 1331 |
* @param {string} cacheKey - 完整缓存 Key
|
|
|
|
| 1333 |
* @param {number} ttl - 有效期(毫秒),由调用方传 getCacheTTL()
|
| 1334 |
*/
|
| 1335 |
export function writeListCache(cacheKey, items, ttl) {
|
| 1336 |
+
if (!Array.isArray(items) || _hasInvalidListItem(items)) return;
|
| 1337 |
setCache(cacheKey, items, ttl, true); // true = 持久化 localStorage
|
| 1338 |
}
|
| 1339 |
|
| 1340 |
+
/**
|
| 1341 |
+
* Validate a successful list endpoint envelope before data reaches UI state or cache.
|
| 1342 |
+
* @param {Object} response - API response envelope
|
| 1343 |
+
* @returns {Array} validated list data
|
| 1344 |
+
*/
|
| 1345 |
+
export function requireListResponseData(response) {
|
| 1346 |
+
if (response?.status !== "success" || !Array.isArray(response.data)
|
| 1347 |
+
|| _hasInvalidListItem(response.data)) {
|
| 1348 |
+
const error = new Error("Invalid list response");
|
| 1349 |
+
error.name = "InvalidListResponseError";
|
| 1350 |
+
throw error;
|
| 1351 |
+
}
|
| 1352 |
+
return response.data;
|
| 1353 |
+
}
|
| 1354 |
+
|
| 1355 |
+
/**
|
| 1356 |
+
* 判断后台成功响应的空数组是否会覆盖当前榜单已有内容。
|
| 1357 |
+
* 列表服务在冷启动/数据同步窗口可能暂时返回空数组;已有结果应继续显示,
|
| 1358 |
+
* 直到非空新数据到达。无缓存时的真实空列表仍按正常结果处理。
|
| 1359 |
+
* @param {Array} nextItems 本次请求得到的列表
|
| 1360 |
+
* @param {Array} currentItems 当前榜单已显示的数据
|
| 1361 |
+
* @returns {boolean} 是否应跳过此次缓存回写和列表替换
|
| 1362 |
+
*/
|
| 1363 |
+
export function isEmptyListRefreshWithExistingData(nextItems, currentItems) {
|
| 1364 |
+
return Array.isArray(nextItems) && nextItems.length === 0
|
| 1365 |
+
&& Array.isArray(currentItems) && currentItems.length > 0;
|
| 1366 |
+
}
|
| 1367 |
+
|
| 1368 |
+
/**
|
| 1369 |
+
* 渲染可访问的列表首屏失败状态,并提供显式重试入口。
|
| 1370 |
+
* 使用 DOM/textContent,避免错误文案进入 HTML sink。
|
| 1371 |
+
*/
|
| 1372 |
+
export function renderListLoadError(container, message, onRetry, t) {
|
| 1373 |
+
if (!container) return;
|
| 1374 |
+
const status = document.createElement("div");
|
| 1375 |
+
status.className = "comfy-ranking-list-error";
|
| 1376 |
+
status.setAttribute("role", "alert");
|
| 1377 |
+
status.setAttribute("aria-live", "assertive");
|
| 1378 |
+
|
| 1379 |
+
const text = document.createElement("p");
|
| 1380 |
+
text.className = "comfy-ranking-list-error__message";
|
| 1381 |
+
text.textContent = message;
|
| 1382 |
+
|
| 1383 |
+
const retry = document.createElement("button");
|
| 1384 |
+
retry.type = "button";
|
| 1385 |
+
retry.className = "comfy-ranking-list-error__retry";
|
| 1386 |
+
retry.textContent = t("common.retry");
|
| 1387 |
+
retry.addEventListener("click", () => onRetry?.());
|
| 1388 |
+
|
| 1389 |
+
status.append(text, retry);
|
| 1390 |
+
container.replaceChildren(status);
|
| 1391 |
+
}
|
| 1392 |
+
|
| 1393 |
/**
|
| 1394 |
* 🔍 从列表缓存中查找指定项(详情页离线回退用)
|
| 1395 |
* @param {string} prefix - 缓存Key前缀(如 "TasksCache_"、"PostsCache_")
|
|
|
|
| 1398 |
*/
|
| 1399 |
export function findInListCache(prefix, id) {
|
| 1400 |
try {
|
| 1401 |
+
for (const cacheKey of getCacheKeysByPrefix(prefix)) {
|
| 1402 |
+
const items = readListCache(cacheKey);
|
| 1403 |
+
if (!items) continue;
|
| 1404 |
+
const item = items.find(entry => entry.id === id);
|
| 1405 |
+
if (item) return item;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1406 |
}
|
| 1407 |
} catch (e) {
|
| 1408 |
+
console.warn("列表缓存查找失败");
|
| 1409 |
}
|
| 1410 |
return null;
|
| 1411 |
}
|
前端页面/components/打赏等级工具.js
CHANGED
|
@@ -14,6 +14,8 @@ import { t } from "./用户体验增强.js";
|
|
| 14 |
import { getCachedProfile, getProfileWithSWR, PLACEHOLDERS, escapeHtml } from "../core/全局配置.js";
|
| 15 |
import { api } from "../core/网络请求_业务API.js";
|
| 16 |
|
|
|
|
|
|
|
| 17 |
/**
|
| 18 |
* 等级规则(参考QQ等级系统):
|
| 19 |
* - 1-100 积分 = 1 颗星星 ⭐,101-200 积分 = 2 颗星星,以此类推
|
|
@@ -134,17 +136,17 @@ export function isMaxTipLevel(amount) {
|
|
| 134 |
* @returns {string} - 等级描述
|
| 135 |
*/
|
| 136 |
export function getTipLevelDescription(amount) {
|
| 137 |
-
if (!amount || amount <= 0) return
|
| 138 |
|
| 139 |
const level = calculateTipLevel(amount);
|
| 140 |
|
| 141 |
-
if (level.isMaxLevel) return
|
| 142 |
-
if (level.suns >= 5) return
|
| 143 |
-
if (level.suns >= 1) return
|
| 144 |
-
if (level.moons >= 3) return
|
| 145 |
-
if (level.moons >= 1) return
|
| 146 |
-
if (level.stars >= 3) return
|
| 147 |
-
return
|
| 148 |
}
|
| 149 |
|
| 150 |
/**
|
|
@@ -155,7 +157,11 @@ export function getTipLevelDescription(amount) {
|
|
| 155 |
* @returns {string} - 榜单项HTML
|
| 156 |
*/
|
| 157 |
export function renderTipBoardItemHTML(tipData, rank, size = 'normal') {
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
|
| 160 |
// 排名徽章颜色
|
| 161 |
const badgeColors = ['#FFD700', '#C0C0C0', '#CD7F32']; // 金银铜
|
|
@@ -182,7 +188,7 @@ export function renderTipBoardItemHTML(tipData, rank, size = 'normal') {
|
|
| 182 |
<div style="display:flex; justify-content:space-between; padding:${cfg.padding}px 0; font-size:${cfg.font}px; align-items:center; border-bottom:1px dashed #333;">
|
| 183 |
<span style="display:flex; align-items:center; gap:6px;">
|
| 184 |
${badgeHtml}
|
| 185 |
-
<span style="color:#888;">🔒
|
| 186 |
</span>
|
| 187 |
<span style="display:flex; align-items:center; gap:6px;">
|
| 188 |
${levelHtml}
|
|
@@ -193,18 +199,19 @@ export function renderTipBoardItemHTML(tipData, rank, size = 'normal') {
|
|
| 193 |
}
|
| 194 |
|
| 195 |
// 🚀 SWR 模式:非匿名用户显示头像+用户名
|
| 196 |
-
const containerId = `tip-board-
|
| 197 |
|
| 198 |
// 从缓存获取初始数据(0延迟渲染)
|
| 199 |
const cached = getCachedProfile(account);
|
| 200 |
const avatarUrl = cached?.avatar || cached?.avatarDataUrl || PLACEHOLDERS.AVATAR_SMALL;
|
| 201 |
-
const userName = cached?.name || account;
|
| 202 |
|
| 203 |
// 后台静默校对并更新 DOM
|
| 204 |
setTimeout(() => {
|
|
|
|
|
|
|
| 205 |
getProfileWithSWR(account, api.getUserProfile, (profile) => {
|
| 206 |
-
|
| 207 |
-
if (!container) return;
|
| 208 |
|
| 209 |
const avatarImg = container.querySelector('.tip-board-avatar');
|
| 210 |
const nameSpan = container.querySelector('.tip-board-name');
|
|
@@ -225,7 +232,7 @@ export function renderTipBoardItemHTML(tipData, rank, size = 'normal') {
|
|
| 225 |
<span style="display:flex; align-items:center; gap:6px;">
|
| 226 |
${badgeHtml}
|
| 227 |
<span style="display:flex; align-items:center; gap:6px;">
|
| 228 |
-
<img class="tip-board-avatar" src="${escapeHtml(avatarUrl)}" style="width:${cfg.avatar}px; height:${cfg.avatar}px; border-radius:50%; object-fit:cover; flex-shrink:0; background:#333;">
|
| 229 |
<span class="tip-board-name" style="color:#ddd;">${escapeHtml(userName)}</span>
|
| 230 |
</span>
|
| 231 |
</span>
|
|
@@ -245,12 +252,14 @@ export function renderTipBoardItemHTML(tipData, rank, size = 'normal') {
|
|
| 245 |
* @param {string} size - 尺寸
|
| 246 |
* @returns {string} - 完整榜单HTML
|
| 247 |
*/
|
| 248 |
-
export function renderTipBoardHTML(boardData, maxShow = 5, emptyText =
|
| 249 |
-
|
|
|
|
|
|
|
| 250 |
return `<div style="color:#666; font-size:12px; text-align:center; padding:12px 0;">${escapeHtml(emptyText)}</div>`;
|
| 251 |
}
|
| 252 |
|
| 253 |
-
return
|
| 254 |
}
|
| 255 |
|
| 256 |
/**
|
|
|
|
| 14 |
import { getCachedProfile, getProfileWithSWR, PLACEHOLDERS, escapeHtml } from "../core/全局配置.js";
|
| 15 |
import { api } from "../core/网络请求_业务API.js";
|
| 16 |
|
| 17 |
+
let nextTipBoardItemId = 0;
|
| 18 |
+
|
| 19 |
/**
|
| 20 |
* 等级规则(参考QQ等级系统):
|
| 21 |
* - 1-100 积分 = 1 颗星星 ⭐,101-200 积分 = 2 颗星星,以此类推
|
|
|
|
| 136 |
* @returns {string} - 等级描述
|
| 137 |
*/
|
| 138 |
export function getTipLevelDescription(amount) {
|
| 139 |
+
if (!amount || amount <= 0) return t('tip.new_fan');
|
| 140 |
|
| 141 |
const level = calculateTipLevel(amount);
|
| 142 |
|
| 143 |
+
if (level.isMaxLevel) return t('tip.level_max');
|
| 144 |
+
if (level.suns >= 5) return t('tip.level_super');
|
| 145 |
+
if (level.suns >= 1) return t('tip.level_gold');
|
| 146 |
+
if (level.moons >= 3) return t('tip.level_silver');
|
| 147 |
+
if (level.moons >= 1) return t('tip.level_bronze');
|
| 148 |
+
if (level.stars >= 3) return t('tip.level_fan');
|
| 149 |
+
return t('tip.level_supporter');
|
| 150 |
}
|
| 151 |
|
| 152 |
/**
|
|
|
|
| 157 |
* @returns {string} - 榜单项HTML
|
| 158 |
*/
|
| 159 |
export function renderTipBoardItemHTML(tipData, rank, size = 'normal') {
|
| 160 |
+
if (!tipData || typeof tipData !== 'object' || Array.isArray(tipData)) return '';
|
| 161 |
+
const { account, is_anon } = tipData;
|
| 162 |
+
const amount = Number(tipData.amount);
|
| 163 |
+
if (!Number.isFinite(amount) || amount < 0) return '';
|
| 164 |
+
rank = Number.isSafeInteger(rank) && rank >= 0 ? rank : 0;
|
| 165 |
|
| 166 |
// 排名徽章颜色
|
| 167 |
const badgeColors = ['#FFD700', '#C0C0C0', '#CD7F32']; // 金银铜
|
|
|
|
| 188 |
<div style="display:flex; justify-content:space-between; padding:${cfg.padding}px 0; font-size:${cfg.font}px; align-items:center; border-bottom:1px dashed #333;">
|
| 189 |
<span style="display:flex; align-items:center; gap:6px;">
|
| 190 |
${badgeHtml}
|
| 191 |
+
<span style="color:#888;">🔒 ${t('creator.anonymous')}</span>
|
| 192 |
</span>
|
| 193 |
<span style="display:flex; align-items:center; gap:6px;">
|
| 194 |
${levelHtml}
|
|
|
|
| 199 |
}
|
| 200 |
|
| 201 |
// 🚀 SWR 模式:非匿名用户显示头像+用户名
|
| 202 |
+
const containerId = `ranking-tip-board-${++nextTipBoardItemId}`;
|
| 203 |
|
| 204 |
// 从缓存获取初始数据(0延迟渲染)
|
| 205 |
const cached = getCachedProfile(account);
|
| 206 |
const avatarUrl = cached?.avatar || cached?.avatarDataUrl || PLACEHOLDERS.AVATAR_SMALL;
|
| 207 |
+
const userName = cached?.name || account || t('common.unknown_user');
|
| 208 |
|
| 209 |
// 后台静默校对并更新 DOM
|
| 210 |
setTimeout(() => {
|
| 211 |
+
const container = document.getElementById(containerId);
|
| 212 |
+
if (!container) return;
|
| 213 |
getProfileWithSWR(account, api.getUserProfile, (profile) => {
|
| 214 |
+
if (!container.isConnected || document.getElementById(containerId) !== container) return;
|
|
|
|
| 215 |
|
| 216 |
const avatarImg = container.querySelector('.tip-board-avatar');
|
| 217 |
const nameSpan = container.querySelector('.tip-board-name');
|
|
|
|
| 232 |
<span style="display:flex; align-items:center; gap:6px;">
|
| 233 |
${badgeHtml}
|
| 234 |
<span style="display:flex; align-items:center; gap:6px;">
|
| 235 |
+
<img class="tip-board-avatar" alt="" src="${escapeHtml(avatarUrl)}" style="width:${cfg.avatar}px; height:${cfg.avatar}px; border-radius:50%; object-fit:cover; flex-shrink:0; background:#333;">
|
| 236 |
<span class="tip-board-name" style="color:#ddd;">${escapeHtml(userName)}</span>
|
| 237 |
</span>
|
| 238 |
</span>
|
|
|
|
| 252 |
* @param {string} size - 尺寸
|
| 253 |
* @returns {string} - 完整榜单HTML
|
| 254 |
*/
|
| 255 |
+
export function renderTipBoardHTML(boardData, maxShow = 5, emptyText = t('tip.no_tips_yet'), size = 'normal') {
|
| 256 |
+
const entries = Array.isArray(boardData) ? boardData.filter(entry => entry && typeof entry === 'object'
|
| 257 |
+
&& !Array.isArray(entry) && Number.isFinite(Number(entry.amount)) && Number(entry.amount) >= 0) : [];
|
| 258 |
+
if (entries.length === 0) {
|
| 259 |
return `<div style="color:#666; font-size:12px; text-align:center; padding:12px 0;">${escapeHtml(emptyText)}</div>`;
|
| 260 |
}
|
| 261 |
|
| 262 |
+
return entries.slice(0, maxShow).map((entry, i) => renderTipBoardItemHTML(entry, i, size)).join('');
|
| 263 |
}
|
| 264 |
|
| 265 |
/**
|
前端页面/components/用户体验_国际化.js
CHANGED
|
@@ -14,14 +14,17 @@ const translations = {
|
|
| 14 |
'zh-CN': {
|
| 15 |
// 通用
|
| 16 |
'common.confirm': '确认',
|
|
|
|
|
|
|
| 17 |
'common.cancel': '取消',
|
| 18 |
'common.save': '保存',
|
| 19 |
'common.saving': '保存中...',
|
| 20 |
-
'common.delete': '删除',
|
| 21 |
'common.deleting': '删除中...',
|
| 22 |
'common.edit': '编辑',
|
| 23 |
'common.close': '关闭',
|
| 24 |
-
'common.
|
|
|
|
|
|
|
| 25 |
'common.loading': '加载中...',
|
| 26 |
'common.chars': '字符',
|
| 27 |
'common.digits': '位数',
|
|
@@ -32,11 +35,135 @@ const translations = {
|
|
| 32 |
'common.retrying': '重试中...',
|
| 33 |
'common.retry_failed': '重试失败,请稍后再试',
|
| 34 |
'common.no_more': '没有更多了',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
'common.parse_error': '响应解析失败',
|
| 36 |
'common.network_error_retry': '网络连接失败,请稍后重试',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
'common.more': '更多',
|
| 38 |
-
'common.back': '返回',
|
| 39 |
'common.search': '搜索',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
'common.refresh': '刷新',
|
| 41 |
'common.copy': '复制',
|
| 42 |
'common.copied': '已复制',
|
|
@@ -49,7 +176,6 @@ const translations = {
|
|
| 49 |
'common.yes': '是',
|
| 50 |
'common.no': '否',
|
| 51 |
'common.ok': '好的',
|
| 52 |
-
'common.send': '发送',
|
| 53 |
'common.reset': '重置',
|
| 54 |
'common.install': '安装',
|
| 55 |
'common.installed': '已安装',
|
|
@@ -58,6 +184,7 @@ const translations = {
|
|
| 58 |
'common.paid': '付费',
|
| 59 |
|
| 60 |
// 导航
|
|
|
|
| 61 |
'nav.tools': '插件榜',
|
| 62 |
'nav.skills': 'Skill',
|
| 63 |
'nav.apps': '工作流',
|
|
@@ -71,6 +198,8 @@ const translations = {
|
|
| 71 |
'nav.logout': '退出登录',
|
| 72 |
'nav.notifications': '消息',
|
| 73 |
'nav.community': '社区精选',
|
|
|
|
|
|
|
| 74 |
|
| 75 |
// 设置页面
|
| 76 |
'settings.title': '界面设置',
|
|
@@ -119,7 +248,6 @@ const translations = {
|
|
| 119 |
'auth.token_expired': '登录凭证已失效,请重新登录',
|
| 120 |
'auth.email': '邮箱',
|
| 121 |
'auth.password': '密码',
|
| 122 |
-
'auth.confirm_password': '确认密码',
|
| 123 |
'auth.username': '用户名',
|
| 124 |
'auth.nickname': '昵称',
|
| 125 |
'auth.forgot_password': '忘记密码?',
|
|
@@ -130,13 +258,11 @@ const translations = {
|
|
| 130 |
'auth.your_account': '您的账号',
|
| 131 |
'auth.go_reset_password': '去重置密码',
|
| 132 |
'auth.reset_password': '重置密码',
|
| 133 |
-
'auth.verification_code': '验证码',
|
| 134 |
'auth.send_code': '发送验证码',
|
| 135 |
'auth.resend_code': '重新发送',
|
| 136 |
'auth.login_success': '登录成功',
|
| 137 |
'auth.register_success': '注册成功',
|
| 138 |
'auth.logout_success': '已退出登录',
|
| 139 |
-
'auth.password_mismatch': '两次密码不一致',
|
| 140 |
'auth.weak_password': '密码强度不足',
|
| 141 |
'auth.account_exists': '账号已存在',
|
| 142 |
'auth.invalid_credentials': '账号或密码错误',
|
|
@@ -211,11 +337,9 @@ const translations = {
|
|
| 211 |
'profile.their_published': 'TA的发布',
|
| 212 |
'profile.my_purchased': '我购买的',
|
| 213 |
'profile.my_tasks': '我的任务',
|
| 214 |
-
'profile.transactions': '交易明细',
|
| 215 |
'profile.my_posts': '我的帖子',
|
| 216 |
'profile.my_collected': '我的收藏',
|
| 217 |
'profile.recent_likes': '近期点赞',
|
| 218 |
-
'profile.following': '关注的人',
|
| 219 |
'profile.max_privilege': '最高特权',
|
| 220 |
'profile.beta_max_privilege': '内测最高特权',
|
| 221 |
'profile.edit_profile': '编辑资料',
|
|
@@ -277,11 +401,13 @@ const translations = {
|
|
| 277 |
'tx.task_payment': '📋 任务尾款',
|
| 278 |
'tx.task_income': '📋 任务收入',
|
| 279 |
'tx.task_freeze': '🔒 任务冻结',
|
|
|
|
| 280 |
'tx.task_refund': '↩️ 任务退款',
|
| 281 |
'tx.task_cancel_refund': '🔄 任务取消退款',
|
| 282 |
'tx.sale': '销售收入',
|
| 283 |
'tx.recharge': '💰 充值',
|
| 284 |
'tx.refund': '↩️ 退款',
|
|
|
|
| 285 |
'tx.withdraw_fee': '💸 手续费',
|
| 286 |
'tx.withdraw_pending': '待打款',
|
| 287 |
'tx.withdraw_completed': '已完成',
|
|
@@ -313,6 +439,9 @@ const translations = {
|
|
| 313 |
'wallet.recharge.max_amount_exceeded': '单次充值金额不得超过10000元',
|
| 314 |
'wallet.recharge.processing': '处理中...',
|
| 315 |
'wallet.recharge.timeout': '支付查询超时,如已支付请稍后刷新查看余额',
|
|
|
|
|
|
|
|
|
|
| 316 |
|
| 317 |
// 提现相关
|
| 318 |
'wallet.withdraw.title': '💳 收益提现中心',
|
|
@@ -331,7 +460,8 @@ const translations = {
|
|
| 331 |
'wallet.withdraw.resend_in': '{seconds}s 后重发',
|
| 332 |
'wallet.withdraw.confirm': '确认提现 (金额将转入冻结审核)',
|
| 333 |
'wallet.withdraw.submitting': '⏳ 提交中...',
|
| 334 |
-
'wallet.withdraw.timeout': '提现
|
|
|
|
| 335 |
'wallet.withdraw.no_balance': '您的可提现收益为 0,快去发布优质插件赚取积分吧!',
|
| 336 |
'wallet.withdraw.min_amount': '最低提现额度为 1 积分!',
|
| 337 |
'wallet.withdraw.fill_account': '请完整填写收款人支付宝账号与真实姓名!',
|
|
@@ -357,10 +487,6 @@ const translations = {
|
|
| 357 |
'market.detail': '详情',
|
| 358 |
'market.comments': '评论',
|
| 359 |
'market.reviews': '评价',
|
| 360 |
-
'market.like': '点赞',
|
| 361 |
-
'market.liked': '已赞',
|
| 362 |
-
'market.favorite': '收藏',
|
| 363 |
-
'market.favorited': '已收藏',
|
| 364 |
'market.views': '🔥 浏览总量',
|
| 365 |
'market.daily_views': '🔥 今日热门',
|
| 366 |
'market.rating': '★ 评分最高',
|
|
@@ -375,7 +501,6 @@ const translations = {
|
|
| 375 |
'social.messages': '消息',
|
| 376 |
'social.notifications': '通知',
|
| 377 |
'social.comment': '评论',
|
| 378 |
-
'social.reply': '回复',
|
| 379 |
'social.share': '分享',
|
| 380 |
'social.mention': '@提及',
|
| 381 |
'social.new_message': '新消息',
|
|
@@ -400,6 +525,7 @@ const translations = {
|
|
| 400 |
'feedback.success': '操作成功',
|
| 401 |
'feedback.error': '操作失败,请重试',
|
| 402 |
'feedback.timeout': '请求超时',
|
|
|
|
| 403 |
'feedback.saved': '已保存',
|
| 404 |
'feedback.logged_out': '已退出登录',
|
| 405 |
'feedback.login_required': '请先登录您的账号!',
|
|
@@ -433,9 +559,11 @@ const translations = {
|
|
| 433 |
|
| 434 |
// 反馈补充
|
| 435 |
'feedback.send_failed': '发送失败',
|
|
|
|
| 436 |
|
| 437 |
// 评分
|
| 438 |
'rating.stars': '评分',
|
|
|
|
| 439 |
'rating.count': '{count}人评分',
|
| 440 |
'rating.your_score': '你的评分',
|
| 441 |
'rating.login_required': '登录后评分',
|
|
@@ -449,6 +577,7 @@ const translations = {
|
|
| 449 |
'notif.title': '消息与通知',
|
| 450 |
'notif.clear': '清空记录',
|
| 451 |
'notif.no_messages': '暂无任何消息通知',
|
|
|
|
| 452 |
'notif.followed_you': '关注了您',
|
| 453 |
'notif.liked': '点赞了您的作品',
|
| 454 |
'notif.favorited': '收藏了您的作品',
|
|
@@ -552,6 +681,10 @@ const translations = {
|
|
| 552 |
'market.delete_permanently': '永久删除',
|
| 553 |
'market.delete_confirm': '🚨 警告:确定要彻底删除该发布内容吗?<br><br><span style="color:#aaa;">该操作不可逆,且相关的所有评论数据也将被同步永久清空。</span>',
|
| 554 |
'item.refundable': '支持退款',
|
|
|
|
|
|
|
|
|
|
|
|
|
| 555 |
'item.confirm_refund_title': '确认退款',
|
| 556 |
'item.confirm_refund': '确认退款',
|
| 557 |
'item.refund_amount': '退款金额:',
|
|
@@ -571,6 +704,8 @@ const translations = {
|
|
| 571 |
'item.refund_consequence_2': '已下载的文件将被自动删除',
|
| 572 |
'item.refund_consequence_3': '30天内禁止再次购买此商品',
|
| 573 |
'item.refund_success': '退款成功',
|
|
|
|
|
|
|
| 574 |
'item.credits_refunded': '积分已退还到您的账户',
|
| 575 |
'item.ban_days_notice': '{days}天内禁止再次购买此商品',
|
| 576 |
'item.refund_failed_retry': '退款失败,请重试',
|
|
@@ -587,6 +722,7 @@ const translations = {
|
|
| 587 |
'item.delete_request_failed': '删除请求失败,请检查网络',
|
| 588 |
'item.no_tips_yet': '该资源暂无专属打赏,快来成为首个赞赏人吧!',
|
| 589 |
'item.author': '作者',
|
|
|
|
| 590 |
'item.original_notice': '原创内容,请勿商用',
|
| 591 |
'item.tip_encourage': '赞赏鼓励该作品',
|
| 592 |
'item.tip_board_title': '该作品赞赏贡献榜 (TOP 10)',
|
|
@@ -596,6 +732,20 @@ const translations = {
|
|
| 596 |
'web.install_on_desktop': '请在电脑端进行安装',
|
| 597 |
'web.purchase_success_desktop': '购买成功!请在电脑端进行安装',
|
| 598 |
'crop.ratio_label': '比例',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 599 |
'crop.gesture_hint_touch': '单指拖动调整位置 | 双指捏合或滑块缩放',
|
| 600 |
'crop.gesture_hint_mouse': '拖动调整位置 | 滚轮缩放',
|
| 601 |
'item.update_available': '发现新版本 (点击静默热更新)',
|
|
@@ -611,7 +761,19 @@ const translations = {
|
|
| 611 |
'feedback.deleted': '已成功删除',
|
| 612 |
'feedback.delete_failed': '删除失败',
|
| 613 |
'feedback.retry_suffix': ',请重试',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 614 |
'feedback.cache_fallback': '⚠️ 网络连接失败,展示的是缓存数据',
|
|
|
|
| 615 |
// 🔧 新增:以下三条原为散落在各组件里的硬编码中文,现收口到词典以支持多语言
|
| 616 |
'feedback.offline_no_cache': '网络已断开,且无本地缓存',
|
| 617 |
// ☁️ 云端不可达冷却期内、且本地无缓存可兜底时抛出(详见 网络请求_基础设施.js)。
|
|
@@ -682,10 +844,8 @@ const translations = {
|
|
| 682 |
|
| 683 |
// 任务详情
|
| 684 |
'task.detail_title': '任务详情',
|
| 685 |
-
'task.deposit_ratio': '订金',
|
| 686 |
'task.published_at': '发布于',
|
| 687 |
'task.deadline_label': '截止日期',
|
| 688 |
-
'task.description_label': '📄 任务描述',
|
| 689 |
'task.ref_images': '🖼️ 参考图片',
|
| 690 |
'task.ref_link': '🔗 参考链接',
|
| 691 |
'task.assignee_label': '👷 接单者',
|
|
@@ -699,9 +859,7 @@ const translations = {
|
|
| 699 |
'task.login_to_operate': '请登录后操作',
|
| 700 |
'task.cancel_task': '❌ 取消任务',
|
| 701 |
'task.edit_task': '编辑任务',
|
| 702 |
-
'task.title_label': '任务标题',
|
| 703 |
'task.description_label': '任务描述',
|
| 704 |
-
'task.title_required': '请输入任务标题',
|
| 705 |
'task.edit_success': '任务修改成功',
|
| 706 |
'task.edit_failed': '任务修改失败',
|
| 707 |
'task.edit_limited_notice': '任务已有人接单,仅可修改标题、描述和参考链接',
|
|
@@ -715,13 +873,11 @@ const translations = {
|
|
| 715 |
'task.accept': '✅ 验收通过',
|
| 716 |
'task.reject': '❌ 验收不通过',
|
| 717 |
'task.dispute': '⚖️ 发起申诉',
|
| 718 |
-
'task.view_dispute': '⚖️ 查看申诉详情',
|
| 719 |
'task.apply_message': '请输入申请留言(可选):',
|
| 720 |
'task.apply_success': '申请成功!',
|
| 721 |
'task.apply_failed': '申请失败',
|
| 722 |
'task.cancel_apply_confirm': '确定撤回申请吗?',
|
| 723 |
'task.cancel_apply_success': '已撤回申请',
|
| 724 |
-
'task.cancel_apply_failed': '撤��失败',
|
| 725 |
'task.cancel_confirm': '确定取消任务吗?此操作不可撤销。',
|
| 726 |
'task.cancel_success': '任务已取消',
|
| 727 |
'task.cancel_failed': '取消失败',
|
|
@@ -751,7 +907,6 @@ const translations = {
|
|
| 751 |
// 发布任务
|
| 752 |
'task.publish_title': '📝 发布任务',
|
| 753 |
'task.title_label': '📌 任务标题',
|
| 754 |
-
'task.title_placeholder': '简洁描述您的需求,50字以内',
|
| 755 |
'task.desc_label': '📄 任务描述',
|
| 756 |
'task.desc_placeholder': '详细描述任务要求、交付标准、参考说明等...',
|
| 757 |
'task.images_label': '🖼️ 参考图片(可选,最多6张)',
|
|
@@ -780,6 +935,9 @@ const translations = {
|
|
| 780 |
'task.max_images': '最多上传6张图片',
|
| 781 |
|
| 782 |
// 申诉相关
|
|
|
|
|
|
|
|
|
|
| 783 |
'dispute.title': '申诉详情',
|
| 784 |
'dispute.status_pending': '等待回应',
|
| 785 |
'dispute.status_responded': '等待仲裁',
|
|
@@ -960,6 +1118,7 @@ const translations = {
|
|
| 960 |
'prompt.text_label': '提示词正文(付费内容)',
|
| 961 |
'prompt.text_placeholder': '输入完整提示词,发布后仅购买者可查看...',
|
| 962 |
'prompt.price_label': '定价',
|
|
|
|
| 963 |
'prompt.video_label': '示例视频',
|
| 964 |
'prompt.title_required': '请输入标题',
|
| 965 |
'prompt.content_required': '请输入介绍文案',
|
|
@@ -988,15 +1147,12 @@ const translations = {
|
|
| 988 |
'post.images_label': '🖼️ 上传图片',
|
| 989 |
'post.images_required': '(最多9张,第一张为封面)',
|
| 990 |
'post.images_placeholder': '点击或拖拽上传图片',
|
| 991 |
-
'post.title_label': '📝 标题',
|
| 992 |
'post.title_placeholder': '给你的作品起个标题...',
|
| 993 |
-
'post.content_label': '✍️ 正文',
|
| 994 |
'post.content_placeholder': '分享你的创作心得、使用技巧...',
|
| 995 |
'post.notice_title': '💡 发布须知:',
|
| 996 |
'post.notice_1': '请上传原创作品或标明出处',
|
| 997 |
'post.notice_2': '图片将自动压缩优化',
|
| 998 |
'post.notice_3': '发布后可在个人中心管理',
|
| 999 |
-
'post.cover': '封面',
|
| 1000 |
'post.images_required_error': '请至少上传一张图片',
|
| 1001 |
'post.title_required': '请输入标题',
|
| 1002 |
'post.uploading': '⏳ 上传图片中...',
|
|
@@ -1007,20 +1163,14 @@ const translations = {
|
|
| 1007 |
'post.mode_label': '发布模式',
|
| 1008 |
'post.mode_image': '图文',
|
| 1009 |
'post.mode_video': '视频',
|
| 1010 |
-
'post.upload_images': '上传图片',
|
| 1011 |
-
'post.max_9_images': '最多9张',
|
| 1012 |
-
'post.click_upload': '点击上传',
|
| 1013 |
'post.upload_video': '上传视频',
|
| 1014 |
'post.click_upload_video': '点击选择视频',
|
| 1015 |
'post.video_cover': '视频封面',
|
| 1016 |
'post.capture_frame': '截取当前帧',
|
| 1017 |
'post.upload_cover': '手动上传封面',
|
| 1018 |
-
'post.publish_notice': '发布须知',
|
| 1019 |
-
'post.notice_original': '请上传原创作品或标明出处',
|
| 1020 |
-
'post.notice_compress': '图片将自动压缩优化',
|
| 1021 |
-
'post.notice_manage': '发布后可在个人中心管理',
|
| 1022 |
'post.error_video_format': '仅支持 mp4、webm、mov 格式的视频',
|
| 1023 |
'post.error_video_size': '视频大小不能超过 100MB',
|
|
|
|
| 1024 |
'post.error_video_duration': '视频时长不能超过 3 分钟',
|
| 1025 |
'post.error_video_load': '无法读取视频信息',
|
| 1026 |
'post.existing_video': '已有视频',
|
|
@@ -1028,7 +1178,6 @@ const translations = {
|
|
| 1028 |
'post.cover_captured': '封面已更新',
|
| 1029 |
'post.cover_capture_failed': '封面截取失败',
|
| 1030 |
'post.error_cover_format': '请选择图片文件作为封面',
|
| 1031 |
-
'post.error_no_title': '请输入标题',
|
| 1032 |
'post.error_no_content': '请输入正文内容',
|
| 1033 |
'post.error_no_video': '请选择视频文件',
|
| 1034 |
'post.error_no_cover': '请等待封面生成或手动上传封面',
|
|
@@ -1045,24 +1194,42 @@ const translations = {
|
|
| 1045 |
'post.error_no_audio': '请选择音频文件',
|
| 1046 |
'post.uploading_audio': '上传音频中...',
|
| 1047 |
'post.media_exclusive': '视频与「图片 + 音频」二选一,已自动清空另一组',
|
| 1048 |
-
'post.publishing': '发布中...',
|
| 1049 |
-
'post.edit_success': '保存成功',
|
| 1050 |
-
'post.edit_failed': '保存失败',
|
| 1051 |
-
'post.error_no_image': '请至少上传一张图片',
|
| 1052 |
-
'post.compressing_images': '压缩图片中...',
|
| 1053 |
-
'post.compressing_progress': '压缩图片 ({current}/{total})',
|
| 1054 |
-
'post.uploading_progress': '上传图片 ({current}/{total})',
|
| 1055 |
|
| 1056 |
// 通用补充 - 组件使用
|
| 1057 |
'common.message': '私信',
|
| 1058 |
'common.credits': '积分',
|
| 1059 |
'common.unknown': '未知',
|
| 1060 |
'common.load_failed': '加载失败',
|
|
|
|
| 1061 |
|
| 1062 |
// 🎰 打赏等级
|
| 1063 |
'tip.new_fan': '新粉丝',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1064 |
'tip.supreme_sponsor': '至尊赞助者',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1065 |
'tip.points': '积分',
|
|
|
|
| 1066 |
|
| 1067 |
// 📊 图表
|
| 1068 |
'chart.loading': '正在加载图表...',
|
|
@@ -1073,6 +1240,18 @@ const translations = {
|
|
| 1073 |
'image.load_failed': '图片加载失败',
|
| 1074 |
'image.load_timeout': '加载超时',
|
| 1075 |
'image.gallery': '效果展示图',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1076 |
'common.operation_failed': '操作失败',
|
| 1077 |
'common.login_required': '请登录后操作',
|
| 1078 |
'common.publish': '发布',
|
|
@@ -1133,6 +1312,8 @@ const translations = {
|
|
| 1133 |
'task.reference_link_optional': '参考链接(可选)',
|
| 1134 |
'task.deposit_ratio': '订金比例',
|
| 1135 |
'task.min_10_credits': '最低10积分',
|
|
|
|
|
|
|
| 1136 |
'task.deposit_deducted_on_assign': '接单时扣除',
|
| 1137 |
'task.price_description': '价格说明',
|
| 1138 |
'task.deposit_on_assign': '订金(指派接单时扣除)',
|
|
@@ -1143,10 +1324,17 @@ const translations = {
|
|
| 1143 |
'task.please_enter_description': '请输入任务描述',
|
| 1144 |
'task.price_min_10': '任务价格不能低于10积分',
|
| 1145 |
'task.please_select_deadline': '请选择截止日期',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1146 |
'task.uploading_images': '上传图片中',
|
| 1147 |
'task.upload_progress': '上传图片 ({current}/{total})',
|
| 1148 |
'task.publishing': '发布中',
|
| 1149 |
'task.max_6_images': '最多上传6张图片',
|
|
|
|
|
|
|
| 1150 |
'task.view_total': '🔥 浏览总量',
|
| 1151 |
'task.view_today': '📅 今日浏览',
|
| 1152 |
'task.save_changes': '保存修改',
|
|
@@ -1175,9 +1363,12 @@ const translations = {
|
|
| 1175 |
// 发布帖子补充
|
| 1176 |
'post.upload_images': '上传图片',
|
| 1177 |
'post.max_9_images': '最多9张,第一张为封面',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1178 |
'post.click_upload': '点击或拖拽上传图片',
|
| 1179 |
-
'post.title_label': '标题',
|
| 1180 |
-
'post.content_label': '正文',
|
| 1181 |
'post.publish_notice': '发布须知',
|
| 1182 |
'post.notice_original': '请上传原创作品或标明出处',
|
| 1183 |
'post.notice_compress': '图片将自动压缩优化',
|
|
@@ -1287,6 +1478,10 @@ const translations = {
|
|
| 1287 |
'publish.uploading_images': '正在上传图片 ({current}/{total})...',
|
| 1288 |
'publish.syncing': '正在同步全网数据库...',
|
| 1289 |
'publish.save_success': '修改已保存并同步全网!',
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1290 |
'publish.publish_success': '发布成功!您的作品已全网同步。',
|
| 1291 |
|
| 1292 |
// 🌐 个人中心页面
|
|
@@ -1304,6 +1499,32 @@ const translations = {
|
|
| 1304 |
'profile.privacy_hidden': '由于作者的隐私设置,该列表不对外公开',
|
| 1305 |
'profile.no_following': '暂无关注的人',
|
| 1306 |
'profile.no_followers': '暂无粉丝',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1307 |
'profile.load_more': '加载更多 ({count})',
|
| 1308 |
'profile.no_collected': '还没有收藏任何内容',
|
| 1309 |
'profile.go_discover': '去榜单页面或讨论区发现精彩内容吧!',
|
|
@@ -1380,6 +1601,28 @@ const translations = {
|
|
| 1380 |
'settings_form.save_failed': '设置失败',
|
| 1381 |
'settings_form.avatar_upload_success': '头像上传成功!请点击底部保存设置生效。',
|
| 1382 |
'settings_form.banner_cleared': '已清除背景图,请点击底部保存设置生效。',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1383 |
// 视频播放器
|
| 1384 |
'video.play': '播放',
|
| 1385 |
'video.pause': '暂停',
|
|
@@ -1394,6 +1637,10 @@ const translations = {
|
|
| 1394 |
'video.volume': '音量',
|
| 1395 |
|
| 1396 |
// 关于插件
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1397 |
'about.title': 'ComfyUI 社区精选',
|
| 1398 |
'about.subtitle': '专为 ComfyUI 打造的现代化 Web3.0 级别生态引擎',
|
| 1399 |
'about.stage_alpha': 'Alpha 内测',
|
|
@@ -1459,14 +1706,17 @@ const translations = {
|
|
| 1459 |
'en-US': {
|
| 1460 |
// Common
|
| 1461 |
'common.confirm': 'Confirm',
|
|
|
|
|
|
|
| 1462 |
'common.cancel': 'Cancel',
|
| 1463 |
'common.save': 'Save',
|
| 1464 |
'common.saving': 'Saving...',
|
| 1465 |
-
'common.delete': 'Delete',
|
| 1466 |
'common.deleting': 'Deleting...',
|
| 1467 |
'common.edit': 'Edit',
|
| 1468 |
'common.close': 'Close',
|
| 1469 |
-
'common.
|
|
|
|
|
|
|
| 1470 |
'common.loading': 'Loading...',
|
| 1471 |
'common.chars': 'chars',
|
| 1472 |
'common.digits': 'digits',
|
|
@@ -1477,11 +1727,135 @@ const translations = {
|
|
| 1477 |
'common.retrying': 'Retrying...',
|
| 1478 |
'common.retry_failed': 'Retry failed, please try again later',
|
| 1479 |
'common.no_more': 'No more items',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1480 |
'common.parse_error': 'Failed to parse response',
|
| 1481 |
'common.network_error_retry': 'Network connection failed, please try again later',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1482 |
'common.more': 'More',
|
| 1483 |
-
'common.back': 'Back',
|
| 1484 |
'common.search': 'Search',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1485 |
'common.refresh': 'Refresh',
|
| 1486 |
'common.copy': 'Copy',
|
| 1487 |
'common.copied': 'Copied',
|
|
@@ -1494,7 +1868,6 @@ const translations = {
|
|
| 1494 |
'common.yes': 'Yes',
|
| 1495 |
'common.no': 'No',
|
| 1496 |
'common.ok': 'OK',
|
| 1497 |
-
'common.send': 'Send',
|
| 1498 |
'common.reset': 'Reset',
|
| 1499 |
'common.install': 'Install',
|
| 1500 |
'common.installed': 'Installed',
|
|
@@ -1503,6 +1876,7 @@ const translations = {
|
|
| 1503 |
'common.paid': 'Paid',
|
| 1504 |
|
| 1505 |
// Navigation
|
|
|
|
| 1506 |
'nav.tools': 'Plugins',
|
| 1507 |
'nav.skills': 'Skills',
|
| 1508 |
'nav.apps': 'Workflows',
|
|
@@ -1516,6 +1890,8 @@ const translations = {
|
|
| 1516 |
'nav.logout': 'Logout',
|
| 1517 |
'nav.notifications': 'Notifications',
|
| 1518 |
'nav.community': 'Ranking',
|
|
|
|
|
|
|
| 1519 |
|
| 1520 |
// Settings
|
| 1521 |
'settings.title': 'Settings',
|
|
@@ -1717,11 +2093,13 @@ const translations = {
|
|
| 1717 |
'tx.task_payment': '📋 Task Payment',
|
| 1718 |
'tx.task_income': '📋 Task Income',
|
| 1719 |
'tx.task_freeze': '🔒 Task Frozen',
|
|
|
|
| 1720 |
'tx.task_refund': '↩️ Task Refund',
|
| 1721 |
'tx.task_cancel_refund': '🔄 Task Cancel Refund',
|
| 1722 |
'tx.sale': 'Sales Income',
|
| 1723 |
'tx.recharge': '💰 Recharge',
|
| 1724 |
'tx.refund': '↩️ Refund',
|
|
|
|
| 1725 |
'tx.withdraw_fee': '💸 Withdrawal Fee',
|
| 1726 |
'tx.withdraw_pending': 'Pending',
|
| 1727 |
'tx.withdraw_completed': 'Completed',
|
|
@@ -1753,6 +2131,9 @@ const translations = {
|
|
| 1753 |
'wallet.recharge.max_amount_exceeded': 'Single recharge cannot exceed 10000',
|
| 1754 |
'wallet.recharge.processing': 'Processing...',
|
| 1755 |
'wallet.recharge.timeout': 'Payment check timed out. If you have already paid, refresh later to see your balance',
|
|
|
|
|
|
|
|
|
|
| 1756 |
|
| 1757 |
// Withdraw
|
| 1758 |
'wallet.withdraw.title': '💳 Earnings Withdrawal Center',
|
|
@@ -1771,7 +2152,8 @@ const translations = {
|
|
| 1771 |
'wallet.withdraw.resend_in': 'Resend in {seconds}s',
|
| 1772 |
'wallet.withdraw.confirm': 'Confirm Withdrawal (Funds Will Be Frozen for Review)',
|
| 1773 |
'wallet.withdraw.submitting': '⏳ Submitting...',
|
| 1774 |
-
'wallet.withdraw.timeout': 'Withdrawal
|
|
|
|
| 1775 |
'wallet.withdraw.no_balance': 'Your withdrawable balance is 0. Publish quality plugins to earn points!',
|
| 1776 |
'wallet.withdraw.min_amount': 'Minimum withdrawal is 1 point!',
|
| 1777 |
'wallet.withdraw.fill_account': 'Please fill in both Alipay account and real name!',
|
|
@@ -1797,10 +2179,6 @@ const translations = {
|
|
| 1797 |
'market.detail': 'Details',
|
| 1798 |
'market.comments': 'Comments',
|
| 1799 |
'market.reviews': 'Reviews',
|
| 1800 |
-
'market.like': 'Like',
|
| 1801 |
-
'market.liked': 'Liked',
|
| 1802 |
-
'market.favorite': 'Favorite',
|
| 1803 |
-
'market.favorited': 'Favorited',
|
| 1804 |
'market.views': '🔥 Most Viewed',
|
| 1805 |
'market.daily_views': '🔥 Trending Today',
|
| 1806 |
'market.rating': '★ Highest Rated',
|
|
@@ -1840,6 +2218,7 @@ const translations = {
|
|
| 1840 |
'feedback.success': 'Success',
|
| 1841 |
'feedback.error': 'Failed, please retry',
|
| 1842 |
'feedback.timeout': 'Request timeout',
|
|
|
|
| 1843 |
'feedback.saved': 'Saved',
|
| 1844 |
'feedback.logged_out': 'Logged out',
|
| 1845 |
'feedback.login_required': 'Please log in first!',
|
|
@@ -1870,9 +2249,11 @@ const translations = {
|
|
| 1870 |
'common.send': 'Send',
|
| 1871 |
'common.delete': 'Delete',
|
| 1872 |
'feedback.send_failed': 'Send failed',
|
|
|
|
| 1873 |
|
| 1874 |
// Rating
|
| 1875 |
'rating.stars': 'Rating',
|
|
|
|
| 1876 |
'rating.count': '{count} ratings',
|
| 1877 |
'rating.your_score': 'Your rating',
|
| 1878 |
'rating.login_required': 'Login to rate',
|
|
@@ -1886,6 +2267,7 @@ const translations = {
|
|
| 1886 |
'notif.title': 'Messages & Notifications',
|
| 1887 |
'notif.clear': 'Clear All',
|
| 1888 |
'notif.no_messages': 'No messages yet',
|
|
|
|
| 1889 |
'notif.followed_you': 'followed you',
|
| 1890 |
'notif.liked': 'liked your work',
|
| 1891 |
'notif.favorited': 'favorited your work',
|
|
@@ -1987,6 +2369,10 @@ const translations = {
|
|
| 1987 |
'market.delete_permanently': 'Delete',
|
| 1988 |
'market.delete_confirm': '🚨 Warning: Are you sure you want to permanently delete this content?<br><br><span style="color:#aaa;">This action is irreversible and all related comments will be deleted.</span>',
|
| 1989 |
'item.refundable': 'Refundable',
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1990 |
'item.confirm_refund_title': 'Confirm Refund',
|
| 1991 |
'item.confirm_refund': 'Confirm Refund',
|
| 1992 |
'item.refund_amount': 'Refund Amount: ',
|
|
@@ -2006,6 +2392,8 @@ const translations = {
|
|
| 2006 |
'item.refund_consequence_2': 'Downloaded files will be automatically deleted',
|
| 2007 |
'item.refund_consequence_3': 'Re-purchase is prohibited for 30 days',
|
| 2008 |
'item.refund_success': 'Refund Successful',
|
|
|
|
|
|
|
| 2009 |
'item.credits_refunded': ' credits have been refunded to your account',
|
| 2010 |
'item.ban_days_notice': 'Re-purchase prohibited for {days} days',
|
| 2011 |
'item.refund_failed_retry': 'Refund failed, please retry',
|
|
@@ -2022,6 +2410,7 @@ const translations = {
|
|
| 2022 |
'item.delete_request_failed': 'Delete request failed, please check network',
|
| 2023 |
'item.no_tips_yet': 'No tips yet for this resource. Be the first to support!',
|
| 2024 |
'item.author': 'Author',
|
|
|
|
| 2025 |
'item.original_notice': 'Original content, do not use commercially',
|
| 2026 |
'item.tip_encourage': 'Tip to encourage this work',
|
| 2027 |
'item.tip_board_title': 'Top Supporters (TOP 10)',
|
|
@@ -2031,6 +2420,20 @@ const translations = {
|
|
| 2031 |
'web.install_on_desktop': 'Please install it in the desktop version',
|
| 2032 |
'web.purchase_success_desktop': 'Purchase successful! Please install it in the desktop version',
|
| 2033 |
'crop.ratio_label': 'Ratio',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2034 |
'crop.gesture_hint_touch': 'Drag with one finger to reposition | Pinch or use the slider to zoom',
|
| 2035 |
'crop.gesture_hint_mouse': 'Drag to reposition | Scroll to zoom',
|
| 2036 |
'item.update_available': 'Update available (Click for silent hot update)',
|
|
@@ -2042,7 +2445,19 @@ const translations = {
|
|
| 2042 |
'feedback.deleted': 'Successfully deleted',
|
| 2043 |
'feedback.delete_failed': 'Delete failed',
|
| 2044 |
'feedback.retry_suffix': ', please try again',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2045 |
'feedback.cache_fallback': '⚠️ Network failed, showing cached data',
|
|
|
|
| 2046 |
'feedback.offline_no_cache': 'Network disconnected, and no local cache available',
|
| 2047 |
'feedback.cloud_unreachable_no_cache': 'Cloud service is unreachable, and there is no local content to show',
|
| 2048 |
'feedback.search_local_result': '⚠️ Network unavailable, showing local search results',
|
|
@@ -2235,6 +2650,8 @@ const translations = {
|
|
| 2235 |
'task.add_reference_image': 'Add reference image',
|
| 2236 |
'task.reference_link_optional': 'Reference link (optional)',
|
| 2237 |
'task.min_10_credits': 'Minimum 10 credits',
|
|
|
|
|
|
|
| 2238 |
'task.deposit_deducted_on_assign': 'Deducted on assignment',
|
| 2239 |
'task.price_description': 'Price Breakdown',
|
| 2240 |
'task.deposit_on_assign': 'Deposit (deducted when assigning)',
|
|
@@ -2245,15 +2662,25 @@ const translations = {
|
|
| 2245 |
'task.please_enter_description': 'Please enter a task description',
|
| 2246 |
'task.price_min_10': 'Task price cannot be lower than 10 credits',
|
| 2247 |
'task.please_select_deadline': 'Please select a deadline',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2248 |
'task.uploading_images': 'Uploading images',
|
| 2249 |
'task.upload_progress': 'Uploading images ({current}/{total})',
|
| 2250 |
'task.publishing': 'Publishing',
|
| 2251 |
'task.max_6_images': 'Max 6 images',
|
|
|
|
|
|
|
| 2252 |
'task.view_total': '🔥 Total Views',
|
| 2253 |
'task.view_today': '📅 Today Views',
|
| 2254 |
'task.save_changes': 'Save Changes',
|
| 2255 |
|
| 2256 |
// Dispute
|
|
|
|
|
|
|
|
|
|
| 2257 |
'dispute.title': 'Dispute Details',
|
| 2258 |
'dispute.status_pending': 'Pending Response',
|
| 2259 |
'dispute.status_responded': 'Pending Arbitration',
|
|
@@ -2460,6 +2887,7 @@ const translations = {
|
|
| 2460 |
'prompt.text_label': 'Prompt Text (Paid Content)',
|
| 2461 |
'prompt.text_placeholder': 'Enter the full prompt, only visible to buyers after publishing...',
|
| 2462 |
'prompt.price_label': 'Price',
|
|
|
|
| 2463 |
'prompt.video_label': 'Sample Video',
|
| 2464 |
'prompt.title_required': 'Please enter a title',
|
| 2465 |
'prompt.content_required': 'Please enter the intro',
|
|
@@ -2507,6 +2935,11 @@ const translations = {
|
|
| 2507 |
'post.mode_video': 'Video',
|
| 2508 |
'post.upload_images': 'Upload Images',
|
| 2509 |
'post.max_9_images': 'Max 9',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2510 |
'post.click_upload': 'Click to upload',
|
| 2511 |
'post.upload_video': 'Upload Video',
|
| 2512 |
'post.click_upload_video': 'Click to select video',
|
|
@@ -2519,6 +2952,7 @@ const translations = {
|
|
| 2519 |
'post.notice_manage': 'Manage in profile after publishing',
|
| 2520 |
'post.error_video_format': 'Only mp4, webm, mov supported',
|
| 2521 |
'post.error_video_size': 'Video cannot exceed 100MB',
|
|
|
|
| 2522 |
'post.error_video_duration': 'Video cannot exceed 3 minutes',
|
| 2523 |
'post.error_video_load': 'Cannot read video info',
|
| 2524 |
'post.existing_video': 'Existing video',
|
|
@@ -2556,15 +2990,52 @@ const translations = {
|
|
| 2556 |
'common.credits': 'Points',
|
| 2557 |
'common.unknown': 'Unknown',
|
| 2558 |
'common.load_failed': 'Load failed',
|
|
|
|
| 2559 |
'tip.new_fan': 'New Fan',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2560 |
'tip.supreme_sponsor': 'Supreme Sponsor',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2561 |
'tip.points': 'pts',
|
|
|
|
| 2562 |
'chart.loading': 'Loading chart...',
|
| 2563 |
'chart.load_failed': 'Chart failed to load',
|
| 2564 |
'image.loading': 'Loading...',
|
| 2565 |
'image.load_failed': 'Image load failed',
|
| 2566 |
'image.load_timeout': 'Load timeout',
|
| 2567 |
'image.gallery': 'Gallery',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2568 |
'common.operation_failed': 'Operation failed',
|
| 2569 |
'common.login_required': 'Please login to continue',
|
| 2570 |
'common.publish': 'Publish',
|
|
@@ -2663,6 +3134,10 @@ const translations = {
|
|
| 2663 |
'publish.uploading_images': 'Uploading images ({current}/{total})...',
|
| 2664 |
'publish.syncing': 'Syncing to global database...',
|
| 2665 |
'publish.save_success': 'Changes saved and synced!',
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2666 |
'publish.publish_success': 'Published! Your work has been synced globally.',
|
| 2667 |
|
| 2668 |
// Profile page
|
|
@@ -2678,6 +3153,32 @@ const translations = {
|
|
| 2678 |
'profile.privacy_hidden': 'This list is private due to the author\'s privacy settings',
|
| 2679 |
'profile.no_following': 'Not following anyone',
|
| 2680 |
'profile.no_followers': 'No followers yet',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2681 |
'profile.load_more': 'Load More ({count})',
|
| 2682 |
'profile.no_collected': 'No favorites yet',
|
| 2683 |
'profile.go_discover': 'Go discover content in rankings or forum!',
|
|
@@ -2754,6 +3255,28 @@ const translations = {
|
|
| 2754 |
'settings_form.save_failed': 'Settings failed',
|
| 2755 |
'settings_form.avatar_upload_success': 'Avatar uploaded! Click Save below to apply.',
|
| 2756 |
'settings_form.banner_cleared': 'Banner cleared. Click Save below to apply.',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2757 |
|
| 2758 |
// Video player
|
| 2759 |
'video.play': 'Play',
|
|
@@ -2769,6 +3292,10 @@ const translations = {
|
|
| 2769 |
'video.volume': 'Volume',
|
| 2770 |
|
| 2771 |
// About plugin
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2772 |
'about.title': 'ComfyUI Community Hub',
|
| 2773 |
'about.subtitle': 'A modern Web3.0-level ecosystem engine built for ComfyUI',
|
| 2774 |
'about.stage_alpha': 'Alpha',
|
|
|
|
| 14 |
'zh-CN': {
|
| 15 |
// 通用
|
| 16 |
'common.confirm': '确认',
|
| 17 |
+
'common.confirm_action': '操作确认',
|
| 18 |
+
'common.confirm_execute': '确认执行',
|
| 19 |
'common.cancel': '取消',
|
| 20 |
'common.save': '保存',
|
| 21 |
'common.saving': '保存中...',
|
|
|
|
| 22 |
'common.deleting': '删除中...',
|
| 23 |
'common.edit': '编辑',
|
| 24 |
'common.close': '关闭',
|
| 25 |
+
'common.advertisement': '广告',
|
| 26 |
+
'common.advertisement_details': '广告详情',
|
| 27 |
+
'common.advertisement_details_image': '广告详情图',
|
| 28 |
'common.loading': '加载中...',
|
| 29 |
'common.chars': '字符',
|
| 30 |
'common.digits': '位数',
|
|
|
|
| 35 |
'common.retrying': '重试中...',
|
| 36 |
'common.retry_failed': '重试失败,请稍后再试',
|
| 37 |
'common.no_more': '没有更多了',
|
| 38 |
+
'pagination.loading': '正在加载更多内容',
|
| 39 |
+
'pagination.end': '已经到底了',
|
| 40 |
+
'pagination.failed': '加载更多失败,请重试',
|
| 41 |
+
'pagination.searching': '正在继续查找匹配内容…',
|
| 42 |
+
'pagination.search_empty': '没有找到匹配的内容',
|
| 43 |
'common.parse_error': '响应解析失败',
|
| 44 |
'common.network_error_retry': '网络连接失败,请稍后重试',
|
| 45 |
+
'install.connection_uncertain': '安装连接中断,结果暂时无法确认。请先检查本地插件目录,再决定是否重试,避免重复安装。',
|
| 46 |
+
'install.cancel_in_progress': '取消安装',
|
| 47 |
+
'install.cancelling': '正在请求取消…',
|
| 48 |
+
'install.cancel_outcome_uncertain': '已请求取消安装,但最终结果可能无法确认。请检查本地插件目录后再决定是否重试。',
|
| 49 |
+
'install.error_missing_params': '安装请求信息不完整,请刷新页面后重试。',
|
| 50 |
+
'install.error_invalid_repository': '仓库地址无效或不受支持,请检查资源链接。',
|
| 51 |
+
'install.error_invalid_target': '安装目标目录无效,已阻止写入。',
|
| 52 |
+
'install.error_git_missing': '未检测到 Git。请安装 Git 并重启 ComfyUI 后重试。',
|
| 53 |
+
'install.error_timeout': '安装等待超时。请检查网络和仓库大小,再确认目录状态后重试。',
|
| 54 |
+
'install.error_git_clone': '镜像源和直连均无法克隆仓库。请检查网络、代理或仓库权限。',
|
| 55 |
+
'install.error_disk_space': '磁盘空间不足。释放空间后再重试。',
|
| 56 |
+
'install.error_remote_rejected': '云端拒绝或无法获取资源。请检查购买权限、仓库权限和资源地址。',
|
| 57 |
+
'install.error_package_too_large': '下载包超过本地安全上限,已停止安装。',
|
| 58 |
+
'install.error_download': '下载多次中断。请检查网络后重试。',
|
| 59 |
+
'install.error_target_occupied': '旧版本文件仍被占用。关闭 ComfyUI 后再重试更新。',
|
| 60 |
+
'install.error_invalid_archive': '下载内容不是有效安装包,或压缩包结构不受支持。',
|
| 61 |
+
'install.error_generic': '安装失败。请检查本地目录和网络后重试。',
|
| 62 |
+
'install.diagnostics': '查看安装过程记录',
|
| 63 |
+
'install.diagnostic_entry': '{time} · {stage} · {progress}',
|
| 64 |
+
'install.stage_unknown': '未知阶段',
|
| 65 |
+
'install.stage_validate': '校验参数',
|
| 66 |
+
'install.stage_queued': '等待其它安装任务完成',
|
| 67 |
+
'install.error_queue_timeout': '安装任务较多,请稍后重试。',
|
| 68 |
+
'install.error_queue_full': '安装队列已满,请稍后再试。',
|
| 69 |
+
'install.error_repository_too_large': '仓库体积或文件数量超过安装上限,请查看资源说明并手动安装。',
|
| 70 |
+
'install.error_process_guard': '无法建立安装进程保护,请检查插件文件和 ComfyUI 运行环境后重试。',
|
| 71 |
+
'install.stage_auth': '验证权限',
|
| 72 |
+
'install.stage_cleanup': '准备安装目录',
|
| 73 |
+
'install.stage_git_mirror': '连接镜像源',
|
| 74 |
+
'install.stage_git_clone': '克隆仓库',
|
| 75 |
+
'install.stage_git_fallback': '切换下载源',
|
| 76 |
+
'install.stage_git_direct': '直连仓库',
|
| 77 |
+
'install.stage_downloading': '下载资源包',
|
| 78 |
+
'install.stage_download_done': '下载完成',
|
| 79 |
+
'install.stage_extracting': '解压文件',
|
| 80 |
+
'install.stage_installing': '写入安装目录',
|
| 81 |
+
'install.stage_complete': '安装完成',
|
| 82 |
+
'install.stage_failed': '安装失败',
|
| 83 |
+
'acquire.balance_short': '积分余额不足(当前:{balance},需要:{price})。',
|
| 84 |
+
'acquire.balance_recharge_prompt': '是否前往个人中心充值?',
|
| 85 |
+
'acquire.validation_running': '正在检测云端资源有效性...',
|
| 86 |
+
'acquire.validation_blocked': '资源校验未通过:{message}',
|
| 87 |
+
'acquire.purchase_confirmation_html': '该资源标价为 <strong style="color:#FF9800;">{price} 积分</strong>。<br><br>确认获取吗?<br><span style="font-size:12px;color:#aaa;">(一次购买永久免费,已购买的资源不会重复扣款)</span>',
|
| 88 |
+
'acquire.purchase_running': '正在校验购买状态与扣款安全...',
|
| 89 |
+
'acquire.invalid_id': '获取失败:资源 ID 无效。',
|
| 90 |
+
'acquire.already_owned_no_charge': '您已购买过此商品,此次使用不会收费!',
|
| 91 |
+
'acquire.purchase_success': '支付成功!已扣除 {price} 积分。',
|
| 92 |
+
'acquire.purchase_balance_short': '获取失败:积分余额不足。',
|
| 93 |
+
'acquire.purchase_failed': '获取失败:{message}',
|
| 94 |
+
'acquire.purchase_verification_unavailable': '暂时无法确认安全购买通道,本次未扣款。请稍后重试或联系维护者更新服务。',
|
| 95 |
+
'acquire.price_changed': '价格已变化或尚未确认,本次未扣款。请刷新资源并重新确认价格。',
|
| 96 |
+
'acquire.purchase_unknown': '暂未确认购买结果。请先查看钱包和购买记录,重新打开资源确认状态后再操作。',
|
| 97 |
+
'acquire.pure_link_authorized': '✅ 授权成功,请前往资源原始页面获取:',
|
| 98 |
+
'install.background_started': '正在后台安装;关闭侧边栏不会影响安装。',
|
| 99 |
+
'install.open_source': '前往资源原始页面',
|
| 100 |
+
'install.completed_update': '插件「{title}」的新版本已就绪,重启 ComfyUI 后生效!',
|
| 101 |
+
'install.completed_restart': '插件「{title}」安装成功!请重启 ComfyUI。',
|
| 102 |
+
'install.failed_for_item': '插件「{title}」安装失败:{message}',
|
| 103 |
+
'install.restart_to_apply': '重启 ComfyUI 后即可应用更新。',
|
| 104 |
+
'install.restart_to_load': '重启 ComfyUI 以加载新节点。',
|
| 105 |
+
'workflow.loaded_for_item': '工作流「{title}」已成功加载到画布!',
|
| 106 |
+
'workflow.failed': '工作流加载失败:{message}',
|
| 107 |
+
'workflow.loaded_warning': '工作流已加载到画布。由于本地节点差异,部分节点可能显示异常;请使用管理器补全节点。',
|
| 108 |
+
'workflow.local_connection_failed': '无法连接到本地服务:{message}',
|
| 109 |
+
'netdisk.authorized_with_password': '✅ 授权成功,网盘资源信息如下:',
|
| 110 |
+
'netdisk.authorized_no_password': '✅ 授权成功,请前往网盘下载:',
|
| 111 |
+
'netdisk.code_label': '提取码:',
|
| 112 |
+
'netdisk.open': '打开网盘链接',
|
| 113 |
+
'netdisk.copy': '复制提取码',
|
| 114 |
+
'netdisk.download_hint': '复制提取码后前往网盘下载资源。',
|
| 115 |
+
'netdisk.invalid_link': '资源链接无效或不受支持。',
|
| 116 |
+
'netdisk.copy_failed': '无法复制提取码,请手动选择并复制。',
|
| 117 |
+
'workflow.fallback_loading': '授权已通过,正在安全鉴权并加载工作流...',
|
| 118 |
+
'workflow.http_failed': '工作流加载失败({status}):{message}',
|
| 119 |
+
'workflow.parse_failed': '响应解析失败,请重试。',
|
| 120 |
+
'common.downloaded': '已下载',
|
| 121 |
+
'common.acquired': '已获取',
|
| 122 |
+
'workflow.canvas_load_failed': '工作流已下载,但无法加载到当前画布。请检查 ComfyUI 页面状态后重试。',
|
| 123 |
+
'workflow.error_missing_params': '下载请求缺少必要信息,请刷新页面后重试。',
|
| 124 |
+
'workflow.error_invalid_request': '下载请求格式无效,请刷新页面后重试。',
|
| 125 |
+
'workflow.error_invalid_id': '工作流资源 ID 无效。',
|
| 126 |
+
'workflow.error_invalid_data': '云端返回的工作流格式无效,请稍后重试或联系资源发布者。',
|
| 127 |
+
'workflow.error_auth': '云端未授权此次下载。请检查购买状态或重新登录。',
|
| 128 |
+
'workflow.error_remote': '云端拒绝或无法获取该工作流。请检查资源链接和访问权限。',
|
| 129 |
+
'workflow.error_network': '连接云端失败。请检查网络后重试。',
|
| 130 |
+
'workflow.error_cloud_cooldown': '云端暂时无法连接,请稍后重试。',
|
| 131 |
+
'workflow.error_generic': '工作流下载失败,请检查网络与本地服务后重试。',
|
| 132 |
+
'workflow.stage_validate': '校验下载请求',
|
| 133 |
+
'workflow.stage_cache_check': '检查本地缓存',
|
| 134 |
+
'workflow.stage_cache_hit': '从本地缓存加载',
|
| 135 |
+
'workflow.stage_downloading': '从云端下载工作流',
|
| 136 |
+
'workflow.stage_saving': '保存本地缓存',
|
| 137 |
+
'workflow.stage_download_complete': '下载完成,正在加载画布',
|
| 138 |
+
'workflow.stage_failed': '工作流下载失败',
|
| 139 |
+
'install.progress_label': '安装进度',
|
| 140 |
+
'install.preparing': '准备中...',
|
| 141 |
+
'install.complete': '安装完成!',
|
| 142 |
+
'install.failed': '安装失败',
|
| 143 |
+
'install.elapsed': '已用时 {time}',
|
| 144 |
+
'install.preview_loading': '正在检查安装目标...',
|
| 145 |
+
'install.confirm_git': '授权已通过。确认通过 Git 安装到:{folder}?',
|
| 146 |
+
'install.confirm_zip': '授权已通过。确认下载并更新到:{folder}?',
|
| 147 |
+
'install.target_unknown': '本地目标目录待核验',
|
| 148 |
+
'install.target_self_update': '这是当前插件自身的更新;新版本将在重启 ComfyUI 后应用。',
|
| 149 |
+
'install.target_exists': '目标目录已存在。安装先写入暂存目录,成功后替换;失败时保留旧版本。',
|
| 150 |
+
'install.target_new': '目标目录当前不存在,将作为首次安装。',
|
| 151 |
+
'install.current_git_version': '检测到本地 Git 版本:{version}',
|
| 152 |
+
'install.recorded_local_version': '前端记录的已安装版本:{version}',
|
| 153 |
+
'install.version_record_unavailable': '安装成功,但浏览器存储不可用,无法保存本地版本标记。',
|
| 154 |
+
'install.version_unconfirmed': '安装成功,但本次安装版本未能确认,仍可重新安装。',
|
| 155 |
+
'install.version_status_unavailable': '浏览器本地存储不可用,暂时无法确认此资源的本地安装版本。',
|
| 156 |
+
'install.preview_unavailable': '暂时无法读取目录状态;本地安装引擎开始安装时仍会再次校验。',
|
| 157 |
+
'install.confirm_button': '同意并安装',
|
| 158 |
+
'install.confirmation_region': '安装目标确认',
|
| 159 |
+
'workflow.connection_uncertain': '工作流连接中断,加载结果暂时无法确认。请检查画布后再决定是否重试。',
|
| 160 |
'common.more': '更多',
|
|
|
|
| 161 |
'common.search': '搜索',
|
| 162 |
+
'common.sort_items': '排序榜单内容',
|
| 163 |
+
'common.filter_task_status': '按任务状态筛选',
|
| 164 |
+
'common.sort_tasks': '任务排序方式',
|
| 165 |
+
'common.sort_posts': '帖子排序方式',
|
| 166 |
+
'common.sort_prompts': '提示词排序方式',
|
| 167 |
'common.refresh': '刷新',
|
| 168 |
'common.copy': '复制',
|
| 169 |
'common.copied': '已复制',
|
|
|
|
| 176 |
'common.yes': '是',
|
| 177 |
'common.no': '否',
|
| 178 |
'common.ok': '好的',
|
|
|
|
| 179 |
'common.reset': '重置',
|
| 180 |
'common.install': '安装',
|
| 181 |
'common.installed': '已安装',
|
|
|
|
| 184 |
'common.paid': '付费',
|
| 185 |
|
| 186 |
// 导航
|
| 187 |
+
'nav.board_navigation': '榜单导航',
|
| 188 |
'nav.tools': '插件榜',
|
| 189 |
'nav.skills': 'Skill',
|
| 190 |
'nav.apps': '工作流',
|
|
|
|
| 198 |
'nav.logout': '退出登录',
|
| 199 |
'nav.notifications': '消息',
|
| 200 |
'nav.community': '社区精选',
|
| 201 |
+
'nav.about_title': '查看关于本插件与作者信息',
|
| 202 |
+
'footer.community_name': 'ComfyUI精选社区',
|
| 203 |
|
| 204 |
// 设置页面
|
| 205 |
'settings.title': '界面设置',
|
|
|
|
| 248 |
'auth.token_expired': '登录凭证已失效,请重新登录',
|
| 249 |
'auth.email': '邮箱',
|
| 250 |
'auth.password': '密码',
|
|
|
|
| 251 |
'auth.username': '用户名',
|
| 252 |
'auth.nickname': '昵称',
|
| 253 |
'auth.forgot_password': '忘记密码?',
|
|
|
|
| 258 |
'auth.your_account': '您的账号',
|
| 259 |
'auth.go_reset_password': '去重置密码',
|
| 260 |
'auth.reset_password': '重置密码',
|
|
|
|
| 261 |
'auth.send_code': '发送验证码',
|
| 262 |
'auth.resend_code': '重新发送',
|
| 263 |
'auth.login_success': '登录成功',
|
| 264 |
'auth.register_success': '注册成功',
|
| 265 |
'auth.logout_success': '已退出登录',
|
|
|
|
| 266 |
'auth.weak_password': '密码强度不足',
|
| 267 |
'auth.account_exists': '账号已存在',
|
| 268 |
'auth.invalid_credentials': '账号或密码错误',
|
|
|
|
| 337 |
'profile.their_published': 'TA的发布',
|
| 338 |
'profile.my_purchased': '我购买的',
|
| 339 |
'profile.my_tasks': '我的任务',
|
|
|
|
| 340 |
'profile.my_posts': '我的帖子',
|
| 341 |
'profile.my_collected': '我的收藏',
|
| 342 |
'profile.recent_likes': '近期点赞',
|
|
|
|
| 343 |
'profile.max_privilege': '最高特权',
|
| 344 |
'profile.beta_max_privilege': '内测最高特权',
|
| 345 |
'profile.edit_profile': '编辑资料',
|
|
|
|
| 401 |
'tx.task_payment': '📋 任务尾款',
|
| 402 |
'tx.task_income': '📋 任务收入',
|
| 403 |
'tx.task_freeze': '🔒 任务冻结',
|
| 404 |
+
'tx.task_price_adjust': '🔒 任务价格调整',
|
| 405 |
'tx.task_refund': '↩️ 任务退款',
|
| 406 |
'tx.task_cancel_refund': '🔄 任务取消退款',
|
| 407 |
'tx.sale': '销售收入',
|
| 408 |
'tx.recharge': '💰 充值',
|
| 409 |
'tx.refund': '↩️ 退款',
|
| 410 |
+
'tx.sale_refund': '↩️ 销售退款扣回',
|
| 411 |
'tx.withdraw_fee': '💸 手续费',
|
| 412 |
'tx.withdraw_pending': '待打款',
|
| 413 |
'tx.withdraw_completed': '已完成',
|
|
|
|
| 439 |
'wallet.recharge.max_amount_exceeded': '单次充值金额不得超过10000元',
|
| 440 |
'wallet.recharge.processing': '处理中...',
|
| 441 |
'wallet.recharge.timeout': '支付查询超时,如已支付请稍后刷新查看余额',
|
| 442 |
+
'wallet.recharge.confirm_amount': '确认充值 {points} 积分(¥{price})?',
|
| 443 |
+
'wallet.recharge.balance_unavailable': '余额暂未更新,请稍后刷新查看',
|
| 444 |
+
'wallet.recharge.order_uncertain': '订单结果尚未确认,请勿重复创建,如已支付请查询余额',
|
| 445 |
|
| 446 |
// 提现相关
|
| 447 |
'wallet.withdraw.title': '💳 收益提现中心',
|
|
|
|
| 460 |
'wallet.withdraw.resend_in': '{seconds}s 后重发',
|
| 461 |
'wallet.withdraw.confirm': '确认提现 (金额将转入冻结审核)',
|
| 462 |
'wallet.withdraw.submitting': '⏳ 提交中...',
|
| 463 |
+
'wallet.withdraw.timeout': '提现结果尚未确认,请查看交易记录,勿重复提交',
|
| 464 |
+
'wallet.withdraw.balance_unavailable': '暂时无法读取最新余额,请稍后再打开提现',
|
| 465 |
'wallet.withdraw.no_balance': '您的可提现收益为 0,快去发布优质插件赚取积分吧!',
|
| 466 |
'wallet.withdraw.min_amount': '最低提现额度为 1 积分!',
|
| 467 |
'wallet.withdraw.fill_account': '请完整填写收款人支付宝账号与真实姓名!',
|
|
|
|
| 487 |
'market.detail': '详情',
|
| 488 |
'market.comments': '评论',
|
| 489 |
'market.reviews': '评价',
|
|
|
|
|
|
|
|
|
|
|
|
|
| 490 |
'market.views': '🔥 浏览总量',
|
| 491 |
'market.daily_views': '🔥 今日热门',
|
| 492 |
'market.rating': '★ 评分最高',
|
|
|
|
| 501 |
'social.messages': '消息',
|
| 502 |
'social.notifications': '通知',
|
| 503 |
'social.comment': '评论',
|
|
|
|
| 504 |
'social.share': '分享',
|
| 505 |
'social.mention': '@提及',
|
| 506 |
'social.new_message': '新消息',
|
|
|
|
| 525 |
'feedback.success': '操作成功',
|
| 526 |
'feedback.error': '操作失败,请重试',
|
| 527 |
'feedback.timeout': '请求超时',
|
| 528 |
+
'feedback.response_format_error': '服务器响应格式错误',
|
| 529 |
'feedback.saved': '已保存',
|
| 530 |
'feedback.logged_out': '已退出登录',
|
| 531 |
'feedback.login_required': '请先登录您的账号!',
|
|
|
|
| 559 |
|
| 560 |
// 反馈补充
|
| 561 |
'feedback.send_failed': '发送失败',
|
| 562 |
+
'feedback.send_failed_retry': '发送失败,请重试',
|
| 563 |
|
| 564 |
// 评分
|
| 565 |
'rating.stars': '评分',
|
| 566 |
+
'rating.choose_score': '给出 {score} 星评分',
|
| 567 |
'rating.count': '{count}人评分',
|
| 568 |
'rating.your_score': '你的评分',
|
| 569 |
'rating.login_required': '登录后评分',
|
|
|
|
| 577 |
'notif.title': '消息与通知',
|
| 578 |
'notif.clear': '清空记录',
|
| 579 |
'notif.no_messages': '暂无任何消息通知',
|
| 580 |
+
'notif.open_target': '查看通知关联内容',
|
| 581 |
'notif.followed_you': '关注了您',
|
| 582 |
'notif.liked': '点赞了您的作品',
|
| 583 |
'notif.favorited': '收藏了您的作品',
|
|
|
|
| 681 |
'market.delete_permanently': '永久删除',
|
| 682 |
'market.delete_confirm': '🚨 警告:确定要彻底删除该发布内容吗?<br><br><span style="color:#aaa;">该操作不可逆,且相关的所有评论数据也将被同步永久清空。</span>',
|
| 683 |
'item.refundable': '支持退款',
|
| 684 |
+
'item.purchase_status_loading': '正在加载购买状态...',
|
| 685 |
+
'easter.title': '猪的飞行梦',
|
| 686 |
+
'easter.hint': '鼠标移动改变视角 | PgUp 加速 PgDn 减速 | ESC 关闭',
|
| 687 |
+
'easter.speed': '速度',
|
| 688 |
'item.confirm_refund_title': '确认退款',
|
| 689 |
'item.confirm_refund': '确认退款',
|
| 690 |
'item.refund_amount': '退款金额:',
|
|
|
|
| 704 |
'item.refund_consequence_2': '已下载的文件将被自动删除',
|
| 705 |
'item.refund_consequence_3': '30天内禁止再次购买此商品',
|
| 706 |
'item.refund_success': '退款成功',
|
| 707 |
+
'item.refund_success_toast': '退款成功!积分已退还',
|
| 708 |
+
'item.refund_amount_returned': '{amount} 积分已退还到您的账户',
|
| 709 |
'item.credits_refunded': '积分已退还到您的账户',
|
| 710 |
'item.ban_days_notice': '{days}天内禁止再次购买此商品',
|
| 711 |
'item.refund_failed_retry': '退款失败,请重试',
|
|
|
|
| 722 |
'item.delete_request_failed': '删除请求失败,请检查网络',
|
| 723 |
'item.no_tips_yet': '该资源暂无专属打赏,快来成为首个赞赏人吧!',
|
| 724 |
'item.author': '作者',
|
| 725 |
+
'item.sponsor_thanks': '感谢砚影科技的支持!',
|
| 726 |
'item.original_notice': '原创内容,请勿商用',
|
| 727 |
'item.tip_encourage': '赞赏鼓励该作品',
|
| 728 |
'item.tip_board_title': '该作品赞赏贡献榜 (TOP 10)',
|
|
|
|
| 732 |
'web.install_on_desktop': '请在电脑端进行安装',
|
| 733 |
'web.purchase_success_desktop': '购买成功!请在电脑端进行安装',
|
| 734 |
'crop.ratio_label': '比例',
|
| 735 |
+
'crop.title': '裁剪图片',
|
| 736 |
+
'crop.zoom_label': '缩放',
|
| 737 |
+
'crop.confirm': '确认裁剪并上传',
|
| 738 |
+
'crop.processing': '处理中...',
|
| 739 |
+
'crop.process_failed': '处理失败,重试',
|
| 740 |
+
'feedback.processing': '处理中...',
|
| 741 |
+
'feedback.copied': '已复制到剪贴板',
|
| 742 |
+
'feedback.copy_failed': '复制失败',
|
| 743 |
+
'feedback.operation_success': '操作成功',
|
| 744 |
+
'profile.post_detail_load_failed': '加载帖子详情失败',
|
| 745 |
+
'profile.prompt_detail_load_failed': '加载提示词详情失败',
|
| 746 |
+
'tip.invalid_amount': '打赏金额必须大于 0',
|
| 747 |
+
'tip.amount_limit': '单次打赏上限为 {max} 积分',
|
| 748 |
+
'tip.balance_short_recharge': '您的积分余额不足,是否立刻前往个人中心充值?',
|
| 749 |
'crop.gesture_hint_touch': '单指拖动调整位置 | 双指捏合或滑块缩放',
|
| 750 |
'crop.gesture_hint_mouse': '拖动调整位置 | 滚轮缩放',
|
| 751 |
'item.update_available': '发现新版本 (点击静默热更新)',
|
|
|
|
| 761 |
'feedback.deleted': '已成功删除',
|
| 762 |
'feedback.delete_failed': '删除失败',
|
| 763 |
'feedback.retry_suffix': ',请重试',
|
| 764 |
+
'feedback.no_search_results': '🔍 没有搜索到相关内容',
|
| 765 |
+
'feedback.no_list_data': '📭 暂无数据,快来抢沙发吧!',
|
| 766 |
+
'feedback.load_posts_failed': '讨论区暂时加载失败',
|
| 767 |
+
'feedback.load_tasks_failed': '任务榜暂时加载失败',
|
| 768 |
+
'feedback.load_prompts_failed': '提示词列表暂时加载失败',
|
| 769 |
+
'auth.session_changed': '登录状态已变化,请重试。',
|
| 770 |
+
'auth.reopen_list': '登录状态已变化,请重新打开榜单后重试。',
|
| 771 |
+
'auth.form_data_incomplete': '表单信息不完整,请检查必填项。',
|
| 772 |
+
'task.session_changed': '登录状态已变化,请返回列表后重新打开任务。',
|
| 773 |
+
'task.cached_readonly': '正在显示上次保存的公开内容,联网确认后可操作。',
|
| 774 |
+
'task.cached_refresh_failed': '暂时无法刷新,仍显示公开缓存内容;操作需联网确认。',
|
| 775 |
'feedback.cache_fallback': '⚠️ 网络连接失败,展示的是缓存数据',
|
| 776 |
+
'feedback.refresh_failed_cached': '网络刷新失败,仍显示上次结果',
|
| 777 |
// 🔧 新增:以下三条原为散落在各组件里的硬编码中文,现收口到词典以支持多语言
|
| 778 |
'feedback.offline_no_cache': '网络已断开,且无本地缓存',
|
| 779 |
// ☁️ 云端不可达冷却期内、且本地无缓存可兜底时抛出(详见 网络请求_基础设施.js)。
|
|
|
|
| 844 |
|
| 845 |
// 任务详情
|
| 846 |
'task.detail_title': '任务详情',
|
|
|
|
| 847 |
'task.published_at': '发布于',
|
| 848 |
'task.deadline_label': '截止日期',
|
|
|
|
| 849 |
'task.ref_images': '🖼️ 参考图片',
|
| 850 |
'task.ref_link': '🔗 参考链接',
|
| 851 |
'task.assignee_label': '👷 接单者',
|
|
|
|
| 859 |
'task.login_to_operate': '请登录后操作',
|
| 860 |
'task.cancel_task': '❌ 取消任务',
|
| 861 |
'task.edit_task': '编辑任务',
|
|
|
|
| 862 |
'task.description_label': '任务描述',
|
|
|
|
| 863 |
'task.edit_success': '任务修改成功',
|
| 864 |
'task.edit_failed': '任务修改失败',
|
| 865 |
'task.edit_limited_notice': '任务已有人接单,仅可修改标题、描述和参考链接',
|
|
|
|
| 873 |
'task.accept': '✅ 验收通过',
|
| 874 |
'task.reject': '❌ 验收不通过',
|
| 875 |
'task.dispute': '⚖️ 发起申诉',
|
|
|
|
| 876 |
'task.apply_message': '请输入申请留言(可选):',
|
| 877 |
'task.apply_success': '申请成功!',
|
| 878 |
'task.apply_failed': '申请失败',
|
| 879 |
'task.cancel_apply_confirm': '确定撤回申请吗?',
|
| 880 |
'task.cancel_apply_success': '已撤回申请',
|
|
|
|
| 881 |
'task.cancel_confirm': '确定取消任务吗?此操作不可撤销。',
|
| 882 |
'task.cancel_success': '任务已取消',
|
| 883 |
'task.cancel_failed': '取消失败',
|
|
|
|
| 907 |
// 发布任务
|
| 908 |
'task.publish_title': '📝 发布任务',
|
| 909 |
'task.title_label': '📌 任务标题',
|
|
|
|
| 910 |
'task.desc_label': '📄 任务描述',
|
| 911 |
'task.desc_placeholder': '详细描述任务要求、交付标准、参考说明等...',
|
| 912 |
'task.images_label': '🖼️ 参考图片(可选,最多6张)',
|
|
|
|
| 935 |
'task.max_images': '最多上传6张图片',
|
| 936 |
|
| 937 |
// 申诉相关
|
| 938 |
+
'dispute.session_changed': '登录状态已变化,请返回后重新打开申诉。',
|
| 939 |
+
'dispute.open_evidence': '打开证据图片 {index}',
|
| 940 |
+
'dispute.remove_evidence': '移除证据图片 {index}',
|
| 941 |
'dispute.title': '申诉详情',
|
| 942 |
'dispute.status_pending': '等待回应',
|
| 943 |
'dispute.status_responded': '等待仲裁',
|
|
|
|
| 1118 |
'prompt.text_label': '提示词正文(付费内容)',
|
| 1119 |
'prompt.text_placeholder': '输入完整提示词,发布后仅购买者可查看...',
|
| 1120 |
'prompt.price_label': '定价',
|
| 1121 |
+
'prompt.price_invalid': '请输入 0–{max} 的整数价格。',
|
| 1122 |
'prompt.video_label': '示例视频',
|
| 1123 |
'prompt.title_required': '请输入标题',
|
| 1124 |
'prompt.content_required': '请输入介绍文案',
|
|
|
|
| 1147 |
'post.images_label': '🖼️ 上传图片',
|
| 1148 |
'post.images_required': '(最多9张,第一张为封面)',
|
| 1149 |
'post.images_placeholder': '点击或拖拽上传图片',
|
|
|
|
| 1150 |
'post.title_placeholder': '给你的作品起个标题...',
|
|
|
|
| 1151 |
'post.content_placeholder': '分享你的创作心得、使用技巧...',
|
| 1152 |
'post.notice_title': '💡 发布须知:',
|
| 1153 |
'post.notice_1': '请上传原创作品或标明出处',
|
| 1154 |
'post.notice_2': '图片将自动压缩优化',
|
| 1155 |
'post.notice_3': '发布后可在个人中心管理',
|
|
|
|
| 1156 |
'post.images_required_error': '请至少上传一张图片',
|
| 1157 |
'post.title_required': '请输入标题',
|
| 1158 |
'post.uploading': '⏳ 上传图片中...',
|
|
|
|
| 1163 |
'post.mode_label': '发布模式',
|
| 1164 |
'post.mode_image': '图文',
|
| 1165 |
'post.mode_video': '视频',
|
|
|
|
|
|
|
|
|
|
| 1166 |
'post.upload_video': '上传视频',
|
| 1167 |
'post.click_upload_video': '点击选择视频',
|
| 1168 |
'post.video_cover': '视频封面',
|
| 1169 |
'post.capture_frame': '截取当前帧',
|
| 1170 |
'post.upload_cover': '手动上传封面',
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1171 |
'post.error_video_format': '仅支持 mp4、webm、mov 格式的视频',
|
| 1172 |
'post.error_video_size': '视频大小不能超过 100MB',
|
| 1173 |
+
'post.video_limits_hint': 'mp4/webm/mov,最多 {megabytes}MB、{minutes} 分钟',
|
| 1174 |
'post.error_video_duration': '视频时长不能超过 3 分钟',
|
| 1175 |
'post.error_video_load': '无法读取视频信息',
|
| 1176 |
'post.existing_video': '已有视频',
|
|
|
|
| 1178 |
'post.cover_captured': '封面已更新',
|
| 1179 |
'post.cover_capture_failed': '封面截取失败',
|
| 1180 |
'post.error_cover_format': '请选择图片文件作为封面',
|
|
|
|
| 1181 |
'post.error_no_content': '请输入正文内容',
|
| 1182 |
'post.error_no_video': '请选择视频文件',
|
| 1183 |
'post.error_no_cover': '请等待封面生成或手动上传封面',
|
|
|
|
| 1194 |
'post.error_no_audio': '请选择音频文件',
|
| 1195 |
'post.uploading_audio': '上传音频中...',
|
| 1196 |
'post.media_exclusive': '视频与「图片 + 音频」二选一,已自动清空另一组',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1197 |
|
| 1198 |
// 通用补充 - 组件使用
|
| 1199 |
'common.message': '私信',
|
| 1200 |
'common.credits': '积分',
|
| 1201 |
'common.unknown': '未知',
|
| 1202 |
'common.load_failed': '加载失败',
|
| 1203 |
+
'common.sidebar_load_failed': '侧边栏加载失败,请刷新页面重试',
|
| 1204 |
|
| 1205 |
// 🎰 打赏等级
|
| 1206 |
'tip.new_fan': '新粉丝',
|
| 1207 |
+
'tip.level_max': '至尊赞助者 (已满级)',
|
| 1208 |
+
'tip.level_super': '超级赞助者',
|
| 1209 |
+
'tip.level_gold': '黄金赞助者',
|
| 1210 |
+
'tip.level_silver': '白银赞助者',
|
| 1211 |
+
'tip.level_bronze': '青铜赞助者',
|
| 1212 |
+
'tip.level_fan': '热心粉丝',
|
| 1213 |
+
'tip.level_supporter': '支持者',
|
| 1214 |
+
'tip.no_tips_yet': '暂无打赏,快来成为首个赞赏人吧!',
|
| 1215 |
'tip.supreme_sponsor': '至尊赞助者',
|
| 1216 |
+
'tip.supreme_sponsor_success': '恭喜您成为至尊赞助者!已达最高等级。',
|
| 1217 |
+
'tip.success_thanks': '打赏成功!感谢您的慷慨支持。',
|
| 1218 |
+
'tip.confirm': '确认打赏',
|
| 1219 |
+
'tip.amount_label': '输入打赏金额',
|
| 1220 |
+
'tip.level_preview': '打赏后等级预览',
|
| 1221 |
+
'tip.rules_title': '等级规则',
|
| 1222 |
+
'tip.rule_star': '每 {points} 积分为一档星星',
|
| 1223 |
+
'tip.rule_moon': '每 {stars} 颗星星组成一个月亮',
|
| 1224 |
+
'tip.rule_sun': '每 {moons} 个月亮组成一个太阳',
|
| 1225 |
+
'tip.rule_max': '最高 {suns} 个太阳,共 {points} 积分',
|
| 1226 |
+
'tip.anonymous': '匿名打赏(隐藏我的名字)',
|
| 1227 |
+
'tip.dialog_title': '赞赏 {name}',
|
| 1228 |
+
'tip.integer_amount': '打赏金额必须是大于 0 的整数',
|
| 1229 |
+
'tip.unknown_result': '暂未确认打赏结果。请先查看钱包和赞赏记录,再进行下一次操作。',
|
| 1230 |
+
'tip.balance_refresh_failed': '打赏已成功,余额暂未取得,请刷新钱包确认。',
|
| 1231 |
'tip.points': '积分',
|
| 1232 |
+
'common.seconds': '秒',
|
| 1233 |
|
| 1234 |
// 📊 图表
|
| 1235 |
'chart.loading': '正在加载图表...',
|
|
|
|
| 1240 |
'image.load_failed': '图片加载失败',
|
| 1241 |
'image.load_timeout': '加载超时',
|
| 1242 |
'image.gallery': '效果展示图',
|
| 1243 |
+
'image.viewer.fullscreen': '全屏播放',
|
| 1244 |
+
'image.viewer.load_failed': '图像播放器加载失败,请刷新页面重试',
|
| 1245 |
+
'image.viewer.close': '关闭 (ESC)',
|
| 1246 |
+
'image.viewer.previous': '上一张 (←)',
|
| 1247 |
+
'image.viewer.next': '下一张 (→)',
|
| 1248 |
+
'image.viewer.autoplay': '自动播放 (空格)',
|
| 1249 |
+
'image.viewer.pause': '暂停自动播放 (空格)',
|
| 1250 |
+
'image.viewer.dialog': '图片查看器',
|
| 1251 |
+
'image.viewer.previous_short': '上一张',
|
| 1252 |
+
'image.viewer.next_short': '下一张',
|
| 1253 |
+
'image.viewer.close_short': '关闭',
|
| 1254 |
+
'image.viewer.image_position': '第 {current} 张,共 {total} 张',
|
| 1255 |
'common.operation_failed': '操作失败',
|
| 1256 |
'common.login_required': '请登录后操作',
|
| 1257 |
'common.publish': '发布',
|
|
|
|
| 1312 |
'task.reference_link_optional': '参考链接(可选)',
|
| 1313 |
'task.deposit_ratio': '订金比例',
|
| 1314 |
'task.min_10_credits': '最低10积分',
|
| 1315 |
+
'task.price_range': '请输入 0–{max} 的整数任务赏金。',
|
| 1316 |
+
'task.deposit_invalid': '请选择有效的订金比例。',
|
| 1317 |
'task.deposit_deducted_on_assign': '接单时扣除',
|
| 1318 |
'task.price_description': '价格说明',
|
| 1319 |
'task.deposit_on_assign': '订金(指派接单时扣除)',
|
|
|
|
| 1324 |
'task.please_enter_description': '请输入任务描述',
|
| 1325 |
'task.price_min_10': '任务价格不能低于10积分',
|
| 1326 |
'task.please_select_deadline': '请选择截止日期',
|
| 1327 |
+
'task.deadline_invalid': '请选择有效的截止日期。',
|
| 1328 |
+
'task.price_locked': '⚠️ 任务进行中,价格不可修改',
|
| 1329 |
+
'task.deposit_locked': '⚠️ 任务进行中,订金比例不可修改',
|
| 1330 |
+
'task.deadline_locked': '⚠️ 任务进行中,截止日期不可修改',
|
| 1331 |
+
'task.remove_reference_image': '删除第 {index} 张参考图片',
|
| 1332 |
'task.uploading_images': '上传图片中',
|
| 1333 |
'task.upload_progress': '上传图片 ({current}/{total})',
|
| 1334 |
'task.publishing': '发布中',
|
| 1335 |
'task.max_6_images': '最多上传6张图片',
|
| 1336 |
+
'task.images_reading': '参考图片仍在读取,请稍候再提交。',
|
| 1337 |
+
'task.image_size_limit': '请选择非空且不超过 {max} MB 的参考图片。',
|
| 1338 |
'task.view_total': '🔥 浏览总量',
|
| 1339 |
'task.view_today': '📅 今日浏览',
|
| 1340 |
'task.save_changes': '保存修改',
|
|
|
|
| 1363 |
// 发布帖子补充
|
| 1364 |
'post.upload_images': '上传图片',
|
| 1365 |
'post.max_9_images': '最多9张,第一张为封面',
|
| 1366 |
+
'post.image_invalid_file': '图片格式无效或超出处理限制,请重新选择图片',
|
| 1367 |
+
'post.image_preview_failed': '图片预览失败,请移除后重新选择',
|
| 1368 |
+
'post.image_position': '第 {index} 张图片,共 {total} 张',
|
| 1369 |
+
'post.remove_image': '删除第 {index} 张图片',
|
| 1370 |
+
'post.image_move_hint': '按 Alt 加左右方向键,在已有图片或新图片组内调整顺序;第一张为封面。',
|
| 1371 |
'post.click_upload': '点击或拖拽上传图片',
|
|
|
|
|
|
|
| 1372 |
'post.publish_notice': '发布须知',
|
| 1373 |
'post.notice_original': '请上传原创作品或标明出处',
|
| 1374 |
'post.notice_compress': '图片将自动压缩优化',
|
|
|
|
| 1478 |
'publish.uploading_images': '正在上传图片 ({current}/{total})...',
|
| 1479 |
'publish.syncing': '正在同步全网数据库...',
|
| 1480 |
'publish.save_success': '修改已保存并同步全网!',
|
| 1481 |
+
'publish.save_unconfirmed': '保存结果尚未确认。请先返回个人作品核对,避免重复发布或修改。',
|
| 1482 |
+
'publish.result_pending': '保存结果待确认',
|
| 1483 |
+
'publish.edit_conflict': '这条作品已被其他页面修改。请返回并重新打开编辑,核对最新内容后再保存。',
|
| 1484 |
+
'publish.edit_reopen': '请重新打开编辑',
|
| 1485 |
'publish.publish_success': '发布成功!您的作品已全网同步。',
|
| 1486 |
|
| 1487 |
// 🌐 个人中心页面
|
|
|
|
| 1499 |
'profile.privacy_hidden': '由于作者的隐私设置,该列表不对外公开',
|
| 1500 |
'profile.no_following': '暂无关注的人',
|
| 1501 |
'profile.no_followers': '暂无粉丝',
|
| 1502 |
+
'profile.no_purchased_resources': '还没有购买任何资源',
|
| 1503 |
+
'profile.discover_resources': '去榜单页面发现优秀插件吧!',
|
| 1504 |
+
'profile.loading_resources': '正在加载资源...',
|
| 1505 |
+
'profile.loading_tasks': '正在加载任务...',
|
| 1506 |
+
'profile.no_task_records': '还没有任何任务记录',
|
| 1507 |
+
'profile.go_tasks': '去任务榜发布或接受任务吧!',
|
| 1508 |
+
'profile.published_tasks': '我发布的任务',
|
| 1509 |
+
'profile.assigned_tasks': '我接的任务',
|
| 1510 |
+
'profile.load_tasks_failed': '任务加载失败',
|
| 1511 |
+
'profile.loading_transactions': '正在加载交易记录...',
|
| 1512 |
+
'profile.task_earnings_stats': '任务收益统计',
|
| 1513 |
+
'profile.task_income': '任务收入',
|
| 1514 |
+
'profile.task_expense': '任务支出',
|
| 1515 |
+
'profile.net_earnings': '净收益',
|
| 1516 |
+
'profile.no_transactions': '暂无交易记录',
|
| 1517 |
+
'profile.load_transactions_failed': '交易记录加载失败',
|
| 1518 |
+
'profile.loading_posts': '正在加载帖子...',
|
| 1519 |
+
'profile.no_posts': '还没有发布任何帖子',
|
| 1520 |
+
'profile.go_posts': '去讨论区分享你的想法吧!',
|
| 1521 |
+
'profile.load_posts_failed': '帖子加载失败',
|
| 1522 |
+
'profile.loading_prompts': '正在加载提示词...',
|
| 1523 |
+
'profile.no_prompts': '还没有发布任何提示词',
|
| 1524 |
+
'profile.go_prompts': '去提示词广场分享你的创作吧!',
|
| 1525 |
+
'profile.load_prompts_failed': '提示词加载失败',
|
| 1526 |
+
'profile.fetching_data': '正在拉取数据...',
|
| 1527 |
+
'profile.no_records': '暂无记录',
|
| 1528 |
'profile.load_more': '加载更多 ({count})',
|
| 1529 |
'profile.no_collected': '还没有收藏任何内容',
|
| 1530 |
'profile.go_discover': '去榜单页面或讨论区发现精彩内容吧!',
|
|
|
|
| 1601 |
'settings_form.save_failed': '设置失败',
|
| 1602 |
'settings_form.avatar_upload_success': '头像上传成功!请点击底部保存设置生效。',
|
| 1603 |
'settings_form.banner_cleared': '已清除背景图,请点击底部保存设置生效。',
|
| 1604 |
+
'settings_form.file_read_failed': '文件读取失败,请重试。',
|
| 1605 |
+
'settings_form.crop_avatar': '裁剪头像',
|
| 1606 |
+
'settings_form.crop_banner': '裁剪个人资料卡背景',
|
| 1607 |
+
'settings_form.crop_ui_bg': '裁剪界面背景',
|
| 1608 |
+
'settings_form.uploading': '上传中...',
|
| 1609 |
+
'settings_form.avatar_process_failed': '头像处理失败,请重新选择图片后重试。',
|
| 1610 |
+
'settings_form.banner_selected': '背景图已选择,点击底部保存设置后才会上传。',
|
| 1611 |
+
'settings_form.ui_bg_applied': '界面背景设置成功,已立即生效!',
|
| 1612 |
+
'settings_form.ui_bg_cleared': '已清除界面背景,已立即生效。',
|
| 1613 |
+
'settings_form.saving': '保存中...',
|
| 1614 |
+
'settings_form.uploading_banner': '上传背景图...',
|
| 1615 |
+
'settings_form.banner_upload_failed': '背景图上传失败,请检查网络后重试。',
|
| 1616 |
+
'settings_form.saving_settings': '保存设置...',
|
| 1617 |
+
'settings_form.settings_saved': '设置已保存并同步至云端!',
|
| 1618 |
+
'settings_form.settings_save_failed': '设置保存失败,请检查网络后重试。',
|
| 1619 |
+
'settings_form.local_storage_unavailable': '浏览器本地存储操作失败,请检查存储空间或权限。',
|
| 1620 |
+
'settings_form.enabled': '已启用',
|
| 1621 |
+
'settings_form.disabled': '未启用',
|
| 1622 |
+
'settings_form.moderation_quota': '本月已用:阿里云 {aliyun}/3000,腾讯云 {tencent}/10000',
|
| 1623 |
+
'settings_form.moderation_skipped': '上传图片将跳过审核',
|
| 1624 |
+
'settings_form.moderation_enabled': '图像审核已启用',
|
| 1625 |
+
'settings_form.moderation_disabled': '图像审核已关闭',
|
| 1626 |
// 视频播放器
|
| 1627 |
'video.play': '播放',
|
| 1628 |
'video.pause': '暂停',
|
|
|
|
| 1637 |
'video.volume': '音量',
|
| 1638 |
|
| 1639 |
// 关于插件
|
| 1640 |
+
'about.brand': '社区精选',
|
| 1641 |
+
'about.stage_unknown': '阶段未知',
|
| 1642 |
+
'about.version_unknown': '版本未知',
|
| 1643 |
+
'about.author_link': '访问作者的哔哩哔哩主页',
|
| 1644 |
'about.title': 'ComfyUI 社区精选',
|
| 1645 |
'about.subtitle': '专为 ComfyUI 打造的现代化 Web3.0 级别生态引擎',
|
| 1646 |
'about.stage_alpha': 'Alpha 内测',
|
|
|
|
| 1706 |
'en-US': {
|
| 1707 |
// Common
|
| 1708 |
'common.confirm': 'Confirm',
|
| 1709 |
+
'common.confirm_action': 'Confirm action',
|
| 1710 |
+
'common.confirm_execute': 'Confirm and continue',
|
| 1711 |
'common.cancel': 'Cancel',
|
| 1712 |
'common.save': 'Save',
|
| 1713 |
'common.saving': 'Saving...',
|
|
|
|
| 1714 |
'common.deleting': 'Deleting...',
|
| 1715 |
'common.edit': 'Edit',
|
| 1716 |
'common.close': 'Close',
|
| 1717 |
+
'common.advertisement': 'Advertisement',
|
| 1718 |
+
'common.advertisement_details': 'Advertisement details',
|
| 1719 |
+
'common.advertisement_details_image': 'Advertisement detail image',
|
| 1720 |
'common.loading': 'Loading...',
|
| 1721 |
'common.chars': 'chars',
|
| 1722 |
'common.digits': 'digits',
|
|
|
|
| 1727 |
'common.retrying': 'Retrying...',
|
| 1728 |
'common.retry_failed': 'Retry failed, please try again later',
|
| 1729 |
'common.no_more': 'No more items',
|
| 1730 |
+
'pagination.loading': 'Loading more items',
|
| 1731 |
+
'pagination.end': 'You have reached the end',
|
| 1732 |
+
'pagination.failed': 'Could not load more items. Please retry.',
|
| 1733 |
+
'pagination.searching': 'Searching more items…',
|
| 1734 |
+
'pagination.search_empty': 'No matching items were found',
|
| 1735 |
'common.parse_error': 'Failed to parse response',
|
| 1736 |
'common.network_error_retry': 'Network connection failed, please try again later',
|
| 1737 |
+
'install.connection_uncertain': 'The install connection was interrupted, so its result is unknown. Check the local plugin folder before retrying to avoid duplicate installs.',
|
| 1738 |
+
'install.cancel_in_progress': 'Cancel installation',
|
| 1739 |
+
'install.cancelling': 'Requesting cancellation…',
|
| 1740 |
+
'install.cancel_outcome_uncertain': 'Cancellation was requested, but the final result may be unknown. Check the local plugin folder before retrying.',
|
| 1741 |
+
'install.error_missing_params': 'The install request is incomplete. Refresh the page and try again.',
|
| 1742 |
+
'install.error_invalid_repository': 'The repository URL is invalid or unsupported. Check the resource link.',
|
| 1743 |
+
'install.error_invalid_target': 'The install target is invalid. No files were written.',
|
| 1744 |
+
'install.error_git_missing': 'Git was not found. Install Git and restart ComfyUI before retrying.',
|
| 1745 |
+
'install.error_timeout': 'The install timed out. Check the network and repository size, then verify the folder before retrying.',
|
| 1746 |
+
'install.error_git_clone': 'Neither the mirror nor direct Git connection could clone the repository. Check the network, proxy, or repository access.',
|
| 1747 |
+
'install.error_disk_space': 'There is not enough disk space. Free space before retrying.',
|
| 1748 |
+
'install.error_remote_rejected': 'The cloud service rejected or could not fetch the resource. Check purchase access, repository permissions, and the resource URL.',
|
| 1749 |
+
'install.error_package_too_large': 'The download exceeded the local safety limit. Installation was stopped.',
|
| 1750 |
+
'install.error_download': 'The download failed repeatedly. Check the network before retrying.',
|
| 1751 |
+
'install.error_target_occupied': 'Files from the previous version are in use. Close ComfyUI before retrying the update.',
|
| 1752 |
+
'install.error_invalid_archive': 'The download is not a valid package or its archive structure is unsupported.',
|
| 1753 |
+
'install.error_generic': 'Installation failed. Check the local folder and network before retrying.',
|
| 1754 |
+
'install.diagnostics': 'View installation log',
|
| 1755 |
+
'install.diagnostic_entry': '{time} · {stage} · {progress}',
|
| 1756 |
+
'install.stage_unknown': 'Unknown stage',
|
| 1757 |
+
'install.stage_validate': 'Validate request',
|
| 1758 |
+
'install.stage_queued': 'Waiting for other installations',
|
| 1759 |
+
'install.error_queue_timeout': 'There are too many installation tasks. Please try again later.',
|
| 1760 |
+
'install.error_queue_full': 'The installation queue is full. Please try again later.',
|
| 1761 |
+
'install.error_repository_too_large': 'The repository exceeds the installation size or file count limit. Check the resource instructions and install it manually.',
|
| 1762 |
+
'install.error_process_guard': 'Installation process protection could not be set up. Check the plugin files and ComfyUI environment, then retry.',
|
| 1763 |
+
'install.stage_auth': 'Verify access',
|
| 1764 |
+
'install.stage_cleanup': 'Prepare target folder',
|
| 1765 |
+
'install.stage_git_mirror': 'Connect to mirror',
|
| 1766 |
+
'install.stage_git_clone': 'Clone repository',
|
| 1767 |
+
'install.stage_git_fallback': 'Switch download source',
|
| 1768 |
+
'install.stage_git_direct': 'Connect directly to repository',
|
| 1769 |
+
'install.stage_downloading': 'Download package',
|
| 1770 |
+
'install.stage_download_done': 'Download complete',
|
| 1771 |
+
'install.stage_extracting': 'Extract files',
|
| 1772 |
+
'install.stage_installing': 'Write install folder',
|
| 1773 |
+
'install.stage_complete': 'Installation complete',
|
| 1774 |
+
'install.stage_failed': 'Installation failed',
|
| 1775 |
+
'acquire.balance_short': 'Insufficient points (current: {balance}, required: {price}).',
|
| 1776 |
+
'acquire.balance_recharge_prompt': 'Go to your profile to add funds?',
|
| 1777 |
+
'acquire.validation_running': 'Checking resource availability...',
|
| 1778 |
+
'acquire.validation_blocked': 'Resource validation failed: {message}',
|
| 1779 |
+
'acquire.purchase_confirmation_html': 'This resource costs <strong style="color:#FF9800;">{price} points</strong>.<br><br>Continue?<br><span style="font-size:12px;color:#aaa;">(Each purchase is permanent. Previously purchased resources are not charged again.)</span>',
|
| 1780 |
+
'acquire.purchase_running': 'Verifying purchase status and payment...',
|
| 1781 |
+
'acquire.invalid_id': 'Could not continue: the resource ID is invalid.',
|
| 1782 |
+
'acquire.already_owned_no_charge': 'You already own this item. This use will not charge you again.',
|
| 1783 |
+
'acquire.purchase_success': 'Payment successful. {price} points were charged.',
|
| 1784 |
+
'acquire.purchase_balance_short': 'Could not continue: insufficient points.',
|
| 1785 |
+
'acquire.purchase_failed': 'Could not continue: {message}',
|
| 1786 |
+
'acquire.purchase_verification_unavailable': 'A safe purchase cannot be verified. No charge was made. Try later or ask the maintainer to update the service.',
|
| 1787 |
+
'acquire.price_changed': 'The price changed or was not confirmed. No charge was made. Refresh the resource and confirm its price again.',
|
| 1788 |
+
'acquire.purchase_unknown': 'The purchase result is unconfirmed. Check your wallet and purchases, then reopen the resource to confirm its status before continuing.',
|
| 1789 |
+
'acquire.pure_link_authorized': '✅ Access granted. Continue on the resource source page:',
|
| 1790 |
+
'install.background_started': 'Installing in the background. Closing the sidebar will not stop it.',
|
| 1791 |
+
'install.open_source': 'Open the resource source page',
|
| 1792 |
+
'install.completed_update': 'The new version of “{title}” is ready. Restart ComfyUI to apply it.',
|
| 1793 |
+
'install.completed_restart': '“{title}” installed. Restart ComfyUI to load it.',
|
| 1794 |
+
'install.failed_for_item': 'Could not install “{title}”: {message}',
|
| 1795 |
+
'install.restart_to_apply': 'Restart ComfyUI to apply the update.',
|
| 1796 |
+
'install.restart_to_load': 'Restart ComfyUI to load the new nodes.',
|
| 1797 |
+
'workflow.loaded_for_item': 'Workflow “{title}” loaded onto the canvas.',
|
| 1798 |
+
'workflow.failed': 'Could not load workflow: {message}',
|
| 1799 |
+
'workflow.loaded_warning': 'Workflow loaded. Some nodes may be unavailable locally; use the manager to install missing nodes.',
|
| 1800 |
+
'workflow.local_connection_failed': 'Could not connect to the local service: {message}',
|
| 1801 |
+
'netdisk.authorized_with_password': '✅ Access granted. Net disk details:',
|
| 1802 |
+
'netdisk.authorized_no_password': '✅ Access granted. Open the net disk to download:',
|
| 1803 |
+
'netdisk.code_label': 'Access code:',
|
| 1804 |
+
'netdisk.open': 'Open net disk link',
|
| 1805 |
+
'netdisk.copy': 'Copy access code',
|
| 1806 |
+
'netdisk.download_hint': 'Copy the access code, then use it to download from the net disk.',
|
| 1807 |
+
'netdisk.invalid_link': 'The resource link is invalid or unsupported.',
|
| 1808 |
+
'netdisk.copy_failed': 'Could not copy the access code. Select and copy it manually.',
|
| 1809 |
+
'workflow.fallback_loading': 'Authorization passed. Verifying access and loading the workflow...',
|
| 1810 |
+
'workflow.http_failed': 'Could not load workflow ({status}): {message}',
|
| 1811 |
+
'workflow.parse_failed': 'Could not parse the response. Please retry.',
|
| 1812 |
+
'common.downloaded': 'Downloaded',
|
| 1813 |
+
'common.acquired': 'Acquired',
|
| 1814 |
+
'workflow.canvas_load_failed': 'The workflow was downloaded but could not be loaded onto the current canvas. Check the ComfyUI page and try again.',
|
| 1815 |
+
'workflow.error_missing_params': 'The download request is incomplete. Refresh the page and try again.',
|
| 1816 |
+
'workflow.error_invalid_request': 'The download request is invalid. Refresh the page and try again.',
|
| 1817 |
+
'workflow.error_invalid_id': 'The workflow resource ID is invalid.',
|
| 1818 |
+
'workflow.error_invalid_data': 'The cloud service returned an invalid workflow. Try again later or contact the publisher.',
|
| 1819 |
+
'workflow.error_auth': 'The cloud service did not authorize this download. Check the purchase status or sign in again.',
|
| 1820 |
+
'workflow.error_remote': 'The cloud service rejected or could not fetch this workflow. Check the resource URL and access permissions.',
|
| 1821 |
+
'workflow.error_network': 'Could not connect to the cloud service. Check the network and try again.',
|
| 1822 |
+
'workflow.error_cloud_cooldown': 'The cloud service is temporarily unreachable. Try again later.',
|
| 1823 |
+
'workflow.error_generic': 'Workflow download failed. Check the network and local service before retrying.',
|
| 1824 |
+
'workflow.stage_validate': 'Validate download request',
|
| 1825 |
+
'workflow.stage_cache_check': 'Check local cache',
|
| 1826 |
+
'workflow.stage_cache_hit': 'Load from local cache',
|
| 1827 |
+
'workflow.stage_downloading': 'Download workflow from cloud',
|
| 1828 |
+
'workflow.stage_saving': 'Save local cache',
|
| 1829 |
+
'workflow.stage_download_complete': 'Download complete; loading canvas',
|
| 1830 |
+
'workflow.stage_failed': 'Workflow download failed',
|
| 1831 |
+
'install.progress_label': 'Installation progress',
|
| 1832 |
+
'install.preparing': 'Preparing...',
|
| 1833 |
+
'install.complete': 'Installation complete!',
|
| 1834 |
+
'install.failed': 'Installation failed',
|
| 1835 |
+
'install.elapsed': 'Elapsed {time}',
|
| 1836 |
+
'install.preview_loading': 'Checking the install target...',
|
| 1837 |
+
'install.confirm_git': 'Authorization passed. Install with Git to: {folder}?',
|
| 1838 |
+
'install.confirm_zip': 'Authorization passed. Download and update: {folder}?',
|
| 1839 |
+
'install.target_unknown': 'Local target folder needs verification',
|
| 1840 |
+
'install.target_self_update': 'This updates the current plugin. The new version will apply after ComfyUI restarts.',
|
| 1841 |
+
'install.target_exists': 'The folder exists. Installation uses a staging folder, replaces it only after success, and preserves the old version on failure.',
|
| 1842 |
+
'install.target_new': 'The target folder does not exist; this will be a first-time install.',
|
| 1843 |
+
'install.current_git_version': 'Detected local Git version: {version}',
|
| 1844 |
+
'install.recorded_local_version': 'Installed version recorded by the frontend: {version}',
|
| 1845 |
+
'install.version_record_unavailable': 'Installation succeeded, but browser storage is unavailable; the local version marker could not be saved.',
|
| 1846 |
+
'install.version_unconfirmed': 'Installation succeeded, but this installed version could not be confirmed. You can still reinstall.',
|
| 1847 |
+
'install.version_status_unavailable': 'Browser storage is unavailable, so the locally installed version cannot be confirmed.',
|
| 1848 |
+
'install.preview_unavailable': 'Folder status is unavailable. The local installer will validate the target again before installation.',
|
| 1849 |
+
'install.confirm_button': 'Confirm install',
|
| 1850 |
+
'install.confirmation_region': 'Install target confirmation',
|
| 1851 |
+
'workflow.connection_uncertain': 'The workflow connection was interrupted, so its result is unknown. Check the canvas before retrying.',
|
| 1852 |
'common.more': 'More',
|
|
|
|
| 1853 |
'common.search': 'Search',
|
| 1854 |
+
'common.sort_items': 'Sort board items',
|
| 1855 |
+
'common.filter_task_status': 'Filter tasks by status',
|
| 1856 |
+
'common.sort_tasks': 'Task sort order',
|
| 1857 |
+
'common.sort_posts': 'Post sort order',
|
| 1858 |
+
'common.sort_prompts': 'Prompt sort order',
|
| 1859 |
'common.refresh': 'Refresh',
|
| 1860 |
'common.copy': 'Copy',
|
| 1861 |
'common.copied': 'Copied',
|
|
|
|
| 1868 |
'common.yes': 'Yes',
|
| 1869 |
'common.no': 'No',
|
| 1870 |
'common.ok': 'OK',
|
|
|
|
| 1871 |
'common.reset': 'Reset',
|
| 1872 |
'common.install': 'Install',
|
| 1873 |
'common.installed': 'Installed',
|
|
|
|
| 1876 |
'common.paid': 'Paid',
|
| 1877 |
|
| 1878 |
// Navigation
|
| 1879 |
+
'nav.board_navigation': 'Board navigation',
|
| 1880 |
'nav.tools': 'Plugins',
|
| 1881 |
'nav.skills': 'Skills',
|
| 1882 |
'nav.apps': 'Workflows',
|
|
|
|
| 1890 |
'nav.logout': 'Logout',
|
| 1891 |
'nav.notifications': 'Notifications',
|
| 1892 |
'nav.community': 'Ranking',
|
| 1893 |
+
'nav.about_title': 'About this plugin and its author',
|
| 1894 |
+
'footer.community_name': 'ComfyUI Community',
|
| 1895 |
|
| 1896 |
// Settings
|
| 1897 |
'settings.title': 'Settings',
|
|
|
|
| 2093 |
'tx.task_payment': '📋 Task Payment',
|
| 2094 |
'tx.task_income': '📋 Task Income',
|
| 2095 |
'tx.task_freeze': '🔒 Task Frozen',
|
| 2096 |
+
'tx.task_price_adjust': '🔒 Task Price Adjustment',
|
| 2097 |
'tx.task_refund': '↩️ Task Refund',
|
| 2098 |
'tx.task_cancel_refund': '🔄 Task Cancel Refund',
|
| 2099 |
'tx.sale': 'Sales Income',
|
| 2100 |
'tx.recharge': '💰 Recharge',
|
| 2101 |
'tx.refund': '↩️ Refund',
|
| 2102 |
+
'tx.sale_refund': '↩️ Sales refund reversal',
|
| 2103 |
'tx.withdraw_fee': '💸 Withdrawal Fee',
|
| 2104 |
'tx.withdraw_pending': 'Pending',
|
| 2105 |
'tx.withdraw_completed': 'Completed',
|
|
|
|
| 2131 |
'wallet.recharge.max_amount_exceeded': 'Single recharge cannot exceed 10000',
|
| 2132 |
'wallet.recharge.processing': 'Processing...',
|
| 2133 |
'wallet.recharge.timeout': 'Payment check timed out. If you have already paid, refresh later to see your balance',
|
| 2134 |
+
'wallet.recharge.confirm_amount': 'Recharge {points} points (¥{price})?',
|
| 2135 |
+
'wallet.recharge.balance_unavailable': 'Balance is not updated yet. Refresh later',
|
| 2136 |
+
'wallet.recharge.order_uncertain': 'Order is not confirmed. Do not create another order; check balance if paid',
|
| 2137 |
|
| 2138 |
// Withdraw
|
| 2139 |
'wallet.withdraw.title': '💳 Earnings Withdrawal Center',
|
|
|
|
| 2152 |
'wallet.withdraw.resend_in': 'Resend in {seconds}s',
|
| 2153 |
'wallet.withdraw.confirm': 'Confirm Withdrawal (Funds Will Be Frozen for Review)',
|
| 2154 |
'wallet.withdraw.submitting': '⏳ Submitting...',
|
| 2155 |
+
'wallet.withdraw.timeout': 'Withdrawal is not confirmed. Check transactions before submitting again',
|
| 2156 |
+
'wallet.withdraw.balance_unavailable': 'Current balance is unavailable. Reopen withdrawal later',
|
| 2157 |
'wallet.withdraw.no_balance': 'Your withdrawable balance is 0. Publish quality plugins to earn points!',
|
| 2158 |
'wallet.withdraw.min_amount': 'Minimum withdrawal is 1 point!',
|
| 2159 |
'wallet.withdraw.fill_account': 'Please fill in both Alipay account and real name!',
|
|
|
|
| 2179 |
'market.detail': 'Details',
|
| 2180 |
'market.comments': 'Comments',
|
| 2181 |
'market.reviews': 'Reviews',
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2182 |
'market.views': '🔥 Most Viewed',
|
| 2183 |
'market.daily_views': '🔥 Trending Today',
|
| 2184 |
'market.rating': '★ Highest Rated',
|
|
|
|
| 2218 |
'feedback.success': 'Success',
|
| 2219 |
'feedback.error': 'Failed, please retry',
|
| 2220 |
'feedback.timeout': 'Request timeout',
|
| 2221 |
+
'feedback.response_format_error': 'The server response format is invalid',
|
| 2222 |
'feedback.saved': 'Saved',
|
| 2223 |
'feedback.logged_out': 'Logged out',
|
| 2224 |
'feedback.login_required': 'Please log in first!',
|
|
|
|
| 2249 |
'common.send': 'Send',
|
| 2250 |
'common.delete': 'Delete',
|
| 2251 |
'feedback.send_failed': 'Send failed',
|
| 2252 |
+
'feedback.send_failed_retry': 'Failed to send. Please try again.',
|
| 2253 |
|
| 2254 |
// Rating
|
| 2255 |
'rating.stars': 'Rating',
|
| 2256 |
+
'rating.choose_score': 'Rate {score} stars',
|
| 2257 |
'rating.count': '{count} ratings',
|
| 2258 |
'rating.your_score': 'Your rating',
|
| 2259 |
'rating.login_required': 'Login to rate',
|
|
|
|
| 2267 |
'notif.title': 'Messages & Notifications',
|
| 2268 |
'notif.clear': 'Clear All',
|
| 2269 |
'notif.no_messages': 'No messages yet',
|
| 2270 |
+
'notif.open_target': 'Open related content',
|
| 2271 |
'notif.followed_you': 'followed you',
|
| 2272 |
'notif.liked': 'liked your work',
|
| 2273 |
'notif.favorited': 'favorited your work',
|
|
|
|
| 2369 |
'market.delete_permanently': 'Delete',
|
| 2370 |
'market.delete_confirm': '🚨 Warning: Are you sure you want to permanently delete this content?<br><br><span style="color:#aaa;">This action is irreversible and all related comments will be deleted.</span>',
|
| 2371 |
'item.refundable': 'Refundable',
|
| 2372 |
+
'item.purchase_status_loading': 'Loading purchase status...',
|
| 2373 |
+
'easter.title': 'Flying Pig Dream',
|
| 2374 |
+
'easter.hint': 'Move the mouse to look | PgUp speeds up, PgDn slows down | ESC closes',
|
| 2375 |
+
'easter.speed': 'Speed',
|
| 2376 |
'item.confirm_refund_title': 'Confirm Refund',
|
| 2377 |
'item.confirm_refund': 'Confirm Refund',
|
| 2378 |
'item.refund_amount': 'Refund Amount: ',
|
|
|
|
| 2392 |
'item.refund_consequence_2': 'Downloaded files will be automatically deleted',
|
| 2393 |
'item.refund_consequence_3': 'Re-purchase is prohibited for 30 days',
|
| 2394 |
'item.refund_success': 'Refund Successful',
|
| 2395 |
+
'item.refund_success_toast': 'Refund successful. Points have been returned.',
|
| 2396 |
+
'item.refund_amount_returned': '{amount} points have been refunded to your account',
|
| 2397 |
'item.credits_refunded': ' credits have been refunded to your account',
|
| 2398 |
'item.ban_days_notice': 'Re-purchase prohibited for {days} days',
|
| 2399 |
'item.refund_failed_retry': 'Refund failed, please retry',
|
|
|
|
| 2410 |
'item.delete_request_failed': 'Delete request failed, please check network',
|
| 2411 |
'item.no_tips_yet': 'No tips yet for this resource. Be the first to support!',
|
| 2412 |
'item.author': 'Author',
|
| 2413 |
+
'item.sponsor_thanks': 'With thanks to Yanying Technology for its support.',
|
| 2414 |
'item.original_notice': 'Original content, do not use commercially',
|
| 2415 |
'item.tip_encourage': 'Tip to encourage this work',
|
| 2416 |
'item.tip_board_title': 'Top Supporters (TOP 10)',
|
|
|
|
| 2420 |
'web.install_on_desktop': 'Please install it in the desktop version',
|
| 2421 |
'web.purchase_success_desktop': 'Purchase successful! Please install it in the desktop version',
|
| 2422 |
'crop.ratio_label': 'Ratio',
|
| 2423 |
+
'crop.title': 'Crop image',
|
| 2424 |
+
'crop.zoom_label': 'Zoom',
|
| 2425 |
+
'crop.confirm': 'Crop and upload',
|
| 2426 |
+
'crop.processing': 'Processing...',
|
| 2427 |
+
'crop.process_failed': 'Processing failed. Try again.',
|
| 2428 |
+
'feedback.processing': 'Processing...',
|
| 2429 |
+
'feedback.copied': 'Copied to clipboard',
|
| 2430 |
+
'feedback.copy_failed': 'Could not copy',
|
| 2431 |
+
'feedback.operation_success': 'Operation completed',
|
| 2432 |
+
'profile.post_detail_load_failed': 'Could not load post details',
|
| 2433 |
+
'profile.prompt_detail_load_failed': 'Could not load prompt details',
|
| 2434 |
+
'tip.invalid_amount': 'Tip amount must be greater than 0',
|
| 2435 |
+
'tip.amount_limit': 'The maximum tip per transaction is {max} points',
|
| 2436 |
+
'tip.balance_short_recharge': 'Your points balance is too low. Go to your profile to recharge now?',
|
| 2437 |
'crop.gesture_hint_touch': 'Drag with one finger to reposition | Pinch or use the slider to zoom',
|
| 2438 |
'crop.gesture_hint_mouse': 'Drag to reposition | Scroll to zoom',
|
| 2439 |
'item.update_available': 'Update available (Click for silent hot update)',
|
|
|
|
| 2445 |
'feedback.deleted': 'Successfully deleted',
|
| 2446 |
'feedback.delete_failed': 'Delete failed',
|
| 2447 |
'feedback.retry_suffix': ', please try again',
|
| 2448 |
+
'feedback.no_search_results': '🔍 No matching results found',
|
| 2449 |
+
'feedback.no_list_data': '📭 No data yet',
|
| 2450 |
+
'feedback.load_posts_failed': 'Could not load the forum right now',
|
| 2451 |
+
'feedback.load_tasks_failed': 'Could not load tasks right now',
|
| 2452 |
+
'feedback.load_prompts_failed': 'Could not load prompts right now',
|
| 2453 |
+
'auth.session_changed': 'Your sign-in status changed. Please try again.',
|
| 2454 |
+
'auth.reopen_list': 'Your sign-in status changed. Reopen the list and try again.',
|
| 2455 |
+
'auth.form_data_incomplete': 'The form is incomplete. Check the required fields.',
|
| 2456 |
+
'task.session_changed': 'Your sign-in status changed. Return to the list and reopen this task.',
|
| 2457 |
+
'task.cached_readonly': 'Showing saved public content. Actions become available after an online check.',
|
| 2458 |
+
'task.cached_refresh_failed': 'Refresh unavailable. Saved public content remains visible; actions require an online check.',
|
| 2459 |
'feedback.cache_fallback': '⚠️ Network failed, showing cached data',
|
| 2460 |
+
'feedback.refresh_failed_cached': 'Refresh failed. The previous results are still shown.',
|
| 2461 |
'feedback.offline_no_cache': 'Network disconnected, and no local cache available',
|
| 2462 |
'feedback.cloud_unreachable_no_cache': 'Cloud service is unreachable, and there is no local content to show',
|
| 2463 |
'feedback.search_local_result': '⚠️ Network unavailable, showing local search results',
|
|
|
|
| 2650 |
'task.add_reference_image': 'Add reference image',
|
| 2651 |
'task.reference_link_optional': 'Reference link (optional)',
|
| 2652 |
'task.min_10_credits': 'Minimum 10 credits',
|
| 2653 |
+
'task.price_range': 'Enter a whole-number task reward from 0 to {max}.',
|
| 2654 |
+
'task.deposit_invalid': 'Select a valid deposit ratio.',
|
| 2655 |
'task.deposit_deducted_on_assign': 'Deducted on assignment',
|
| 2656 |
'task.price_description': 'Price Breakdown',
|
| 2657 |
'task.deposit_on_assign': 'Deposit (deducted when assigning)',
|
|
|
|
| 2662 |
'task.please_enter_description': 'Please enter a task description',
|
| 2663 |
'task.price_min_10': 'Task price cannot be lower than 10 credits',
|
| 2664 |
'task.please_select_deadline': 'Please select a deadline',
|
| 2665 |
+
'task.deadline_invalid': 'Select a valid deadline date.',
|
| 2666 |
+
'task.price_locked': '⚠️ The task is in progress. Its reward cannot be changed.',
|
| 2667 |
+
'task.deposit_locked': '⚠️ The task is in progress. Its deposit ratio cannot be changed.',
|
| 2668 |
+
'task.deadline_locked': '⚠️ The task is in progress. Its deadline cannot be changed.',
|
| 2669 |
+
'task.remove_reference_image': 'Remove reference image {index}',
|
| 2670 |
'task.uploading_images': 'Uploading images',
|
| 2671 |
'task.upload_progress': 'Uploading images ({current}/{total})',
|
| 2672 |
'task.publishing': 'Publishing',
|
| 2673 |
'task.max_6_images': 'Max 6 images',
|
| 2674 |
+
'task.images_reading': 'Reference images are still loading. Wait before submitting.',
|
| 2675 |
+
'task.image_size_limit': 'Choose a non-empty reference image no larger than {max} MB.',
|
| 2676 |
'task.view_total': '🔥 Total Views',
|
| 2677 |
'task.view_today': '📅 Today Views',
|
| 2678 |
'task.save_changes': 'Save Changes',
|
| 2679 |
|
| 2680 |
// Dispute
|
| 2681 |
+
'dispute.session_changed': 'Your sign-in status changed. Go back and reopen this dispute.',
|
| 2682 |
+
'dispute.open_evidence': 'Open evidence image {index}',
|
| 2683 |
+
'dispute.remove_evidence': 'Remove evidence image {index}',
|
| 2684 |
'dispute.title': 'Dispute Details',
|
| 2685 |
'dispute.status_pending': 'Pending Response',
|
| 2686 |
'dispute.status_responded': 'Pending Arbitration',
|
|
|
|
| 2887 |
'prompt.text_label': 'Prompt Text (Paid Content)',
|
| 2888 |
'prompt.text_placeholder': 'Enter the full prompt, only visible to buyers after publishing...',
|
| 2889 |
'prompt.price_label': 'Price',
|
| 2890 |
+
'prompt.price_invalid': 'Enter a whole-number price from 0 to {max}.',
|
| 2891 |
'prompt.video_label': 'Sample Video',
|
| 2892 |
'prompt.title_required': 'Please enter a title',
|
| 2893 |
'prompt.content_required': 'Please enter the intro',
|
|
|
|
| 2935 |
'post.mode_video': 'Video',
|
| 2936 |
'post.upload_images': 'Upload Images',
|
| 2937 |
'post.max_9_images': 'Max 9',
|
| 2938 |
+
'post.image_invalid_file': 'Invalid image or image exceeds processing limits. Choose another image.',
|
| 2939 |
+
'post.image_preview_failed': 'Image preview failed. Remove it and choose the image again.',
|
| 2940 |
+
'post.image_position': 'Image {index} of {total}',
|
| 2941 |
+
'post.remove_image': 'Remove image {index}',
|
| 2942 |
+
'post.image_move_hint': 'Press Alt and Left or Right to reorder within existing or new images. The first image is the cover.',
|
| 2943 |
'post.click_upload': 'Click to upload',
|
| 2944 |
'post.upload_video': 'Upload Video',
|
| 2945 |
'post.click_upload_video': 'Click to select video',
|
|
|
|
| 2952 |
'post.notice_manage': 'Manage in profile after publishing',
|
| 2953 |
'post.error_video_format': 'Only mp4, webm, mov supported',
|
| 2954 |
'post.error_video_size': 'Video cannot exceed 100MB',
|
| 2955 |
+
'post.video_limits_hint': 'mp4/webm/mov, up to {megabytes}MB and {minutes} minutes',
|
| 2956 |
'post.error_video_duration': 'Video cannot exceed 3 minutes',
|
| 2957 |
'post.error_video_load': 'Cannot read video info',
|
| 2958 |
'post.existing_video': 'Existing video',
|
|
|
|
| 2990 |
'common.credits': 'Points',
|
| 2991 |
'common.unknown': 'Unknown',
|
| 2992 |
'common.load_failed': 'Load failed',
|
| 2993 |
+
'common.sidebar_load_failed': 'Could not load the sidebar. Refresh the page and try again.',
|
| 2994 |
'tip.new_fan': 'New Fan',
|
| 2995 |
+
'tip.level_max': 'Supreme Sponsor (Maximum Level)',
|
| 2996 |
+
'tip.level_super': 'Super Sponsor',
|
| 2997 |
+
'tip.level_gold': 'Gold Sponsor',
|
| 2998 |
+
'tip.level_silver': 'Silver Sponsor',
|
| 2999 |
+
'tip.level_bronze': 'Bronze Sponsor',
|
| 3000 |
+
'tip.level_fan': 'Dedicated Fan',
|
| 3001 |
+
'tip.level_supporter': 'Supporter',
|
| 3002 |
+
'tip.no_tips_yet': 'No tips yet. Be the first to support!',
|
| 3003 |
'tip.supreme_sponsor': 'Supreme Sponsor',
|
| 3004 |
+
'tip.supreme_sponsor_success': 'Congratulations, you reached the highest sponsor level!',
|
| 3005 |
+
'tip.success_thanks': 'Tip sent successfully. Thank you for your generous support!',
|
| 3006 |
+
'tip.confirm': 'Confirm Tip',
|
| 3007 |
+
'tip.amount_label': 'Tip amount',
|
| 3008 |
+
'tip.level_preview': 'Tip level preview',
|
| 3009 |
+
'tip.rules_title': 'Level rules',
|
| 3010 |
+
'tip.rule_star': 'One star per block of {points} points',
|
| 3011 |
+
'tip.rule_moon': 'Every {stars} stars forms one moon',
|
| 3012 |
+
'tip.rule_sun': 'Every {moons} moons forms one sun',
|
| 3013 |
+
'tip.rule_max': 'Maximum {suns} suns at {points} points',
|
| 3014 |
+
'tip.anonymous': 'Tip anonymously (hide my name)',
|
| 3015 |
+
'tip.dialog_title': 'Support {name}',
|
| 3016 |
+
'tip.integer_amount': 'Tip amount must be a positive whole number',
|
| 3017 |
+
'tip.unknown_result': 'The tip result is unconfirmed. Check your wallet and tip history before another action.',
|
| 3018 |
+
'tip.balance_refresh_failed': 'The tip succeeded, but the balance is unavailable. Refresh your wallet to confirm.',
|
| 3019 |
'tip.points': 'pts',
|
| 3020 |
+
'common.seconds': 'sec',
|
| 3021 |
'chart.loading': 'Loading chart...',
|
| 3022 |
'chart.load_failed': 'Chart failed to load',
|
| 3023 |
'image.loading': 'Loading...',
|
| 3024 |
'image.load_failed': 'Image load failed',
|
| 3025 |
'image.load_timeout': 'Load timeout',
|
| 3026 |
'image.gallery': 'Gallery',
|
| 3027 |
+
'image.viewer.fullscreen': 'Open fullscreen viewer',
|
| 3028 |
+
'image.viewer.load_failed': 'Could not load the image viewer. Refresh and try again.',
|
| 3029 |
+
'image.viewer.close': 'Close (Esc)',
|
| 3030 |
+
'image.viewer.previous': 'Previous image (←)',
|
| 3031 |
+
'image.viewer.next': 'Next image (→)',
|
| 3032 |
+
'image.viewer.autoplay': 'Start slideshow (Space)',
|
| 3033 |
+
'image.viewer.pause': 'Pause slideshow (Space)',
|
| 3034 |
+
'image.viewer.dialog': 'Image viewer',
|
| 3035 |
+
'image.viewer.previous_short': 'Previous',
|
| 3036 |
+
'image.viewer.next_short': 'Next',
|
| 3037 |
+
'image.viewer.close_short': 'Close',
|
| 3038 |
+
'image.viewer.image_position': 'Image {current} of {total}',
|
| 3039 |
'common.operation_failed': 'Operation failed',
|
| 3040 |
'common.login_required': 'Please login to continue',
|
| 3041 |
'common.publish': 'Publish',
|
|
|
|
| 3134 |
'publish.uploading_images': 'Uploading images ({current}/{total})...',
|
| 3135 |
'publish.syncing': 'Syncing to global database...',
|
| 3136 |
'publish.save_success': 'Changes saved and synced!',
|
| 3137 |
+
'publish.save_unconfirmed': 'The save result is unconfirmed. Check your works before publishing or editing again.',
|
| 3138 |
+
'publish.result_pending': 'Save result unconfirmed',
|
| 3139 |
+
'publish.edit_conflict': 'This work was changed in another view. Reopen the editor and check the latest content before saving.',
|
| 3140 |
+
'publish.edit_reopen': 'Reopen the editor',
|
| 3141 |
'publish.publish_success': 'Published! Your work has been synced globally.',
|
| 3142 |
|
| 3143 |
// Profile page
|
|
|
|
| 3153 |
'profile.privacy_hidden': 'This list is private due to the author\'s privacy settings',
|
| 3154 |
'profile.no_following': 'Not following anyone',
|
| 3155 |
'profile.no_followers': 'No followers yet',
|
| 3156 |
+
'profile.no_purchased_resources': 'You have not purchased any resources yet',
|
| 3157 |
+
'profile.discover_resources': 'Explore the rankings to find useful plugins.',
|
| 3158 |
+
'profile.loading_resources': 'Loading resources...',
|
| 3159 |
+
'profile.loading_tasks': 'Loading tasks...',
|
| 3160 |
+
'profile.no_task_records': 'No task records yet',
|
| 3161 |
+
'profile.go_tasks': 'Publish a task or accept one from the task board.',
|
| 3162 |
+
'profile.published_tasks': 'Tasks I published',
|
| 3163 |
+
'profile.assigned_tasks': 'Tasks I accepted',
|
| 3164 |
+
'profile.load_tasks_failed': 'Failed to load tasks',
|
| 3165 |
+
'profile.loading_transactions': 'Loading transactions...',
|
| 3166 |
+
'profile.task_earnings_stats': 'Task Earnings Statistics',
|
| 3167 |
+
'profile.task_income': 'Task Income',
|
| 3168 |
+
'profile.task_expense': 'Task Expenses',
|
| 3169 |
+
'profile.net_earnings': 'Net Earnings',
|
| 3170 |
+
'profile.no_transactions': 'No transactions yet',
|
| 3171 |
+
'profile.load_transactions_failed': 'Failed to load transactions',
|
| 3172 |
+
'profile.loading_posts': 'Loading posts...',
|
| 3173 |
+
'profile.no_posts': 'You have not published any posts yet',
|
| 3174 |
+
'profile.go_posts': 'Share your thoughts in the forum.',
|
| 3175 |
+
'profile.load_posts_failed': 'Failed to load posts',
|
| 3176 |
+
'profile.loading_prompts': 'Loading prompts...',
|
| 3177 |
+
'profile.no_prompts': 'You have not published any prompts yet',
|
| 3178 |
+
'profile.go_prompts': 'Share your creations in the prompt gallery.',
|
| 3179 |
+
'profile.load_prompts_failed': 'Failed to load prompts',
|
| 3180 |
+
'profile.fetching_data': 'Fetching data...',
|
| 3181 |
+
'profile.no_records': 'No records yet',
|
| 3182 |
'profile.load_more': 'Load More ({count})',
|
| 3183 |
'profile.no_collected': 'No favorites yet',
|
| 3184 |
'profile.go_discover': 'Go discover content in rankings or forum!',
|
|
|
|
| 3255 |
'settings_form.save_failed': 'Settings failed',
|
| 3256 |
'settings_form.avatar_upload_success': 'Avatar uploaded! Click Save below to apply.',
|
| 3257 |
'settings_form.banner_cleared': 'Banner cleared. Click Save below to apply.',
|
| 3258 |
+
'settings_form.file_read_failed': 'Could not read the file. Please try again.',
|
| 3259 |
+
'settings_form.crop_avatar': 'Crop avatar',
|
| 3260 |
+
'settings_form.crop_banner': 'Crop profile banner',
|
| 3261 |
+
'settings_form.crop_ui_bg': 'Crop interface background',
|
| 3262 |
+
'settings_form.uploading': 'Uploading...',
|
| 3263 |
+
'settings_form.avatar_process_failed': 'Could not process the avatar. Choose another image and try again.',
|
| 3264 |
+
'settings_form.banner_selected': 'Banner selected. Save settings below to upload it.',
|
| 3265 |
+
'settings_form.ui_bg_applied': 'Interface background updated.',
|
| 3266 |
+
'settings_form.ui_bg_cleared': 'Interface background cleared.',
|
| 3267 |
+
'settings_form.saving': 'Saving...',
|
| 3268 |
+
'settings_form.uploading_banner': 'Uploading banner...',
|
| 3269 |
+
'settings_form.banner_upload_failed': 'Could not upload the banner. Check the network and try again.',
|
| 3270 |
+
'settings_form.saving_settings': 'Saving settings...',
|
| 3271 |
+
'settings_form.settings_saved': 'Settings saved and synced to the cloud.',
|
| 3272 |
+
'settings_form.settings_save_failed': 'Could not save settings. Check the network and try again.',
|
| 3273 |
+
'settings_form.local_storage_unavailable': 'Browser storage operation failed. Check available storage and permissions.',
|
| 3274 |
+
'settings_form.enabled': 'Enabled',
|
| 3275 |
+
'settings_form.disabled': 'Disabled',
|
| 3276 |
+
'settings_form.moderation_quota': 'Used this month: Aliyun {aliyun}/3000, Tencent Cloud {tencent}/10000',
|
| 3277 |
+
'settings_form.moderation_skipped': 'Uploaded images will skip moderation',
|
| 3278 |
+
'settings_form.moderation_enabled': 'Image moderation enabled',
|
| 3279 |
+
'settings_form.moderation_disabled': 'Image moderation disabled',
|
| 3280 |
|
| 3281 |
// Video player
|
| 3282 |
'video.play': 'Play',
|
|
|
|
| 3292 |
'video.volume': 'Volume',
|
| 3293 |
|
| 3294 |
// About plugin
|
| 3295 |
+
'about.brand': 'Community Hub',
|
| 3296 |
+
'about.stage_unknown': 'Unknown stage',
|
| 3297 |
+
'about.version_unknown': 'Unknown version',
|
| 3298 |
+
'about.author_link': 'Visit the author on Bilibili',
|
| 3299 |
'about.title': 'ComfyUI Community Hub',
|
| 3300 |
'about.subtitle': 'A modern Web3.0-level ecosystem engine built for ComfyUI',
|
| 3301 |
'about.stage_alpha': 'Alpha',
|
前端页面/components/用户体验增强.js
CHANGED
|
@@ -38,16 +38,27 @@ export function createAutoSaver(formId, formElement, options = {}) {
|
|
| 38 |
} = options;
|
| 39 |
|
| 40 |
let debounceTimer = null;
|
|
|
|
|
|
|
|
|
|
| 41 |
const storageKey = DRAFT_PREFIX + formId;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
// 获取表单数据
|
| 44 |
const getFormData = () => {
|
| 45 |
-
const data =
|
| 46 |
const inputs = formElement.querySelectorAll('input, textarea, select');
|
| 47 |
|
| 48 |
inputs.forEach(input => {
|
| 49 |
const name = input.name || input.id;
|
| 50 |
-
if (!
|
| 51 |
|
| 52 |
if (input.type === 'checkbox') {
|
| 53 |
data[name] = input.checked;
|
|
@@ -63,22 +74,34 @@ export function createAutoSaver(formId, formElement, options = {}) {
|
|
| 63 |
|
| 64 |
// 保存草稿
|
| 65 |
const saveDraft = () => {
|
|
|
|
| 66 |
try {
|
| 67 |
const data = getFormData();
|
| 68 |
data._savedAt = Date.now();
|
| 69 |
localStorage.setItem(storageKey, JSON.stringify(data));
|
| 70 |
-
console.log(
|
| 71 |
} catch (e) {
|
| 72 |
-
console.warn('草稿保存失败
|
| 73 |
}
|
| 74 |
};
|
| 75 |
|
| 76 |
// 加载草稿
|
| 77 |
const loadDraft = () => {
|
|
|
|
| 78 |
try {
|
| 79 |
const saved = localStorage.getItem(storageKey);
|
| 80 |
if (!saved) return null;
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
} catch (e) {
|
| 83 |
return null;
|
| 84 |
}
|
|
@@ -86,28 +109,37 @@ export function createAutoSaver(formId, formElement, options = {}) {
|
|
| 86 |
|
| 87 |
// 恢复草稿到表单
|
| 88 |
const restoreDraft = (data) => {
|
| 89 |
-
if (!data) return;
|
|
|
|
|
|
|
| 90 |
|
| 91 |
const inputs = formElement.querySelectorAll('input, textarea, select');
|
| 92 |
inputs.forEach(input => {
|
| 93 |
const name = input.name || input.id;
|
| 94 |
-
if (!
|
|
|
|
|
|
|
| 95 |
|
| 96 |
if (input.type === 'checkbox') {
|
| 97 |
input.checked = !!data[name];
|
| 98 |
} else if (input.type === 'radio') {
|
| 99 |
input.checked = input.value === data[name];
|
| 100 |
} else {
|
| 101 |
-
input.value = data[name]
|
| 102 |
}
|
| 103 |
});
|
| 104 |
|
| 105 |
-
if (onRestore) onRestore(
|
| 106 |
};
|
| 107 |
|
| 108 |
// 清除草稿
|
| 109 |
const clearDraft = () => {
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
};
|
| 112 |
|
| 113 |
// 检查是否有草稿
|
|
@@ -118,8 +150,14 @@ export function createAutoSaver(formId, formElement, options = {}) {
|
|
| 118 |
|
| 119 |
// 输入事件监听(防抖保存)
|
| 120 |
const handleInput = () => {
|
|
|
|
| 121 |
if (debounceTimer) clearTimeout(debounceTimer);
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
};
|
| 124 |
|
| 125 |
// 页面离开前保存
|
|
@@ -129,12 +167,16 @@ export function createAutoSaver(formId, formElement, options = {}) {
|
|
| 129 |
|
| 130 |
// 初始化
|
| 131 |
const init = () => {
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
-
|
| 137 |
-
|
|
|
|
| 138 |
|
| 139 |
// 检查是否有草稿需要恢复
|
| 140 |
const draft = loadDraft();
|
|
@@ -151,10 +193,14 @@ export function createAutoSaver(formId, formElement, options = {}) {
|
|
| 151 |
// 防止内存泄漏和已卸载 DOM 上的事件监听器残留。
|
| 152 |
// 调用时机:弹窗关闭回调、组件 unmount 钩子、或 beforeunload 兜底。
|
| 153 |
const destroy = () => {
|
|
|
|
|
|
|
|
|
|
| 154 |
formElement.removeEventListener('input', handleInput);
|
| 155 |
formElement.removeEventListener('change', handleInput);
|
| 156 |
window.removeEventListener('beforeunload', handleBeforeUnload);
|
| 157 |
if (debounceTimer) clearTimeout(debounceTimer);
|
|
|
|
| 158 |
};
|
| 159 |
|
| 160 |
return {
|
|
|
|
| 38 |
} = options;
|
| 39 |
|
| 40 |
let debounceTimer = null;
|
| 41 |
+
let initialized = false;
|
| 42 |
+
let disposed = false;
|
| 43 |
+
let saveGeneration = 0;
|
| 44 |
const storageKey = DRAFT_PREFIX + formId;
|
| 45 |
+
|
| 46 |
+
const canSaveInput = input => {
|
| 47 |
+
const name = input.name || input.id;
|
| 48 |
+
return name && name !== '_savedAt' && !excludeFields.includes(name)
|
| 49 |
+
&& !['password', 'file', 'hidden', 'submit', 'button', 'reset'].includes(input.type)
|
| 50 |
+
&& !['current-password', 'new-password', 'one-time-code'].includes(input.autocomplete)
|
| 51 |
+
&& (!fields.length || fields.some(selector => input.matches(selector)));
|
| 52 |
+
};
|
| 53 |
|
| 54 |
// 获取表单数据
|
| 55 |
const getFormData = () => {
|
| 56 |
+
const data = Object.create(null);
|
| 57 |
const inputs = formElement.querySelectorAll('input, textarea, select');
|
| 58 |
|
| 59 |
inputs.forEach(input => {
|
| 60 |
const name = input.name || input.id;
|
| 61 |
+
if (!canSaveInput(input)) return;
|
| 62 |
|
| 63 |
if (input.type === 'checkbox') {
|
| 64 |
data[name] = input.checked;
|
|
|
|
| 74 |
|
| 75 |
// 保存草稿
|
| 76 |
const saveDraft = () => {
|
| 77 |
+
if (disposed) return;
|
| 78 |
try {
|
| 79 |
const data = getFormData();
|
| 80 |
data._savedAt = Date.now();
|
| 81 |
localStorage.setItem(storageKey, JSON.stringify(data));
|
| 82 |
+
console.log('📝 草稿已自动保存');
|
| 83 |
} catch (e) {
|
| 84 |
+
console.warn('草稿保存失败');
|
| 85 |
}
|
| 86 |
};
|
| 87 |
|
| 88 |
// 加载草稿
|
| 89 |
const loadDraft = () => {
|
| 90 |
+
if (disposed) return null;
|
| 91 |
try {
|
| 92 |
const saved = localStorage.getItem(storageKey);
|
| 93 |
if (!saved) return null;
|
| 94 |
+
const data = JSON.parse(saved);
|
| 95 |
+
if (!data || typeof data !== 'object' || Array.isArray(data)
|
| 96 |
+
|| !Number.isFinite(data._savedAt) || data._savedAt <= 0) return null;
|
| 97 |
+
const projected = Object.create(null);
|
| 98 |
+
projected._savedAt = data._savedAt;
|
| 99 |
+
formElement.querySelectorAll('input, textarea, select').forEach(input => {
|
| 100 |
+
const name = input.name || input.id;
|
| 101 |
+
if (canSaveInput(input) && Object.prototype.hasOwnProperty.call(data, name)
|
| 102 |
+
&& ['string', 'number', 'boolean'].includes(typeof data[name])) projected[name] = data[name];
|
| 103 |
+
});
|
| 104 |
+
return projected;
|
| 105 |
} catch (e) {
|
| 106 |
return null;
|
| 107 |
}
|
|
|
|
| 109 |
|
| 110 |
// 恢复草稿到表单
|
| 111 |
const restoreDraft = (data) => {
|
| 112 |
+
if (disposed || !data || typeof data !== 'object' || Array.isArray(data)) return;
|
| 113 |
+
const restored = Object.create(null);
|
| 114 |
+
if (Number.isFinite(data._savedAt) && data._savedAt > 0) restored._savedAt = data._savedAt;
|
| 115 |
|
| 116 |
const inputs = formElement.querySelectorAll('input, textarea, select');
|
| 117 |
inputs.forEach(input => {
|
| 118 |
const name = input.name || input.id;
|
| 119 |
+
if (!canSaveInput(input) || !Object.prototype.hasOwnProperty.call(data, name)) return;
|
| 120 |
+
if (!['string', 'number', 'boolean'].includes(typeof data[name])) return;
|
| 121 |
+
restored[name] = data[name];
|
| 122 |
|
| 123 |
if (input.type === 'checkbox') {
|
| 124 |
input.checked = !!data[name];
|
| 125 |
} else if (input.type === 'radio') {
|
| 126 |
input.checked = input.value === data[name];
|
| 127 |
} else {
|
| 128 |
+
input.value = data[name] ?? '';
|
| 129 |
}
|
| 130 |
});
|
| 131 |
|
| 132 |
+
if (onRestore) onRestore(restored);
|
| 133 |
};
|
| 134 |
|
| 135 |
// 清除草稿
|
| 136 |
const clearDraft = () => {
|
| 137 |
+
if (disposed) return;
|
| 138 |
+
saveGeneration++;
|
| 139 |
+
if (debounceTimer) clearTimeout(debounceTimer);
|
| 140 |
+
debounceTimer = null;
|
| 141 |
+
try { localStorage.removeItem(storageKey); }
|
| 142 |
+
catch { console.warn('草稿清除失败'); }
|
| 143 |
};
|
| 144 |
|
| 145 |
// 检查是否有草稿
|
|
|
|
| 150 |
|
| 151 |
// 输入事件监听(防抖保存)
|
| 152 |
const handleInput = () => {
|
| 153 |
+
if (disposed) return;
|
| 154 |
if (debounceTimer) clearTimeout(debounceTimer);
|
| 155 |
+
const generation = ++saveGeneration;
|
| 156 |
+
debounceTimer = setTimeout(() => {
|
| 157 |
+
if (disposed || generation !== saveGeneration) return;
|
| 158 |
+
debounceTimer = null;
|
| 159 |
+
saveDraft();
|
| 160 |
+
}, DRAFT_DEBOUNCE);
|
| 161 |
};
|
| 162 |
|
| 163 |
// 页面离开前保存
|
|
|
|
| 167 |
|
| 168 |
// 初始化
|
| 169 |
const init = () => {
|
| 170 |
+
if (disposed) return { hasDraft: false };
|
| 171 |
+
if (!initialized) {
|
| 172 |
+
initialized = true;
|
| 173 |
+
// 监听输入事件
|
| 174 |
+
formElement.addEventListener('input', handleInput);
|
| 175 |
+
formElement.addEventListener('change', handleInput);
|
| 176 |
|
| 177 |
+
// 页面离开前保存
|
| 178 |
+
window.addEventListener('beforeunload', handleBeforeUnload);
|
| 179 |
+
}
|
| 180 |
|
| 181 |
// 检查是否有草稿需要恢复
|
| 182 |
const draft = loadDraft();
|
|
|
|
| 193 |
// 防止内存泄漏和已卸载 DOM 上的事件监听器残留。
|
| 194 |
// 调用时机:弹窗关闭回调、组件 unmount 钩子、或 beforeunload 兜底。
|
| 195 |
const destroy = () => {
|
| 196 |
+
if (disposed) return;
|
| 197 |
+
disposed = true;
|
| 198 |
+
saveGeneration++;
|
| 199 |
formElement.removeEventListener('input', handleInput);
|
| 200 |
formElement.removeEventListener('change', handleInput);
|
| 201 |
window.removeEventListener('beforeunload', handleBeforeUnload);
|
| 202 |
if (debounceTimer) clearTimeout(debounceTimer);
|
| 203 |
+
debounceTimer = null;
|
| 204 |
};
|
| 205 |
|
| 206 |
return {
|
前端页面/components/顶部导航组件.js
CHANGED
|
@@ -2,18 +2,21 @@
|
|
| 2 |
import { createAuthView } from "../auth/用户注册登录组件.js";
|
| 3 |
import { openUserProfileModal } from "../profile/个人中心视图.js";
|
| 4 |
import { openChatModal, loadChatUnreadCount } from "../social/私信聊天组件.js";
|
| 5 |
-
import { api } from "../core/网络请求API.js";
|
| 6 |
import { showToast } from "./UI交互提示组件.js";
|
| 7 |
import { showAboutInfo } from "./关于插件组件.js";
|
| 8 |
import { openNotificationCenter, loadUnreadCount } from "../social/通知中心组件.js";
|
| 9 |
import { openSettingsPage } from "./全局设置组件.js"; // ⚙️ 新增
|
| 10 |
-
import {
|
|
|
|
|
|
|
| 11 |
import { t } from "./用户体验增强.js"; // 🌐 多语言支持
|
| 12 |
import { createTopBanner } from "./顶部广告组件.js";
|
| 13 |
import { uploadFile } from "../market/发布内容_提交引擎.js"; // 🖼️ 带压缩管线的统一上传包装器
|
| 14 |
|
| 15 |
// 🚀 新增:消息轮询定时器
|
| 16 |
let messagePollingTimer = null;
|
|
|
|
| 17 |
let isPollingActive = false; // 🔧 P3优化:防止重复启动轮询
|
| 18 |
|
| 19 |
// 🔧 P1修复:window 监听只安装一次并始终指向最新导航实例
|
|
@@ -37,89 +40,59 @@ function _registerNavWindowListeners() {
|
|
| 37 |
}
|
| 38 |
|
| 39 |
export function createTopNav() {
|
|
|
|
| 40 |
const userHeader = document.createElement("div");
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
titleSpan.
|
| 48 |
-
|
| 49 |
-
Object.assign(titleSpan.style, { fontSize: "16px", cursor: "pointer", transition: "opacity 0.2s" });
|
| 50 |
-
titleSpan.title = "查看关于本插件与作者信息";
|
| 51 |
-
titleSpan.onmouseover = () => titleSpan.style.opacity = "0.8";
|
| 52 |
-
titleSpan.onmouseout = () => titleSpan.style.opacity = "1";
|
| 53 |
titleSpan.onclick = () => showAboutInfo(currentUser);
|
| 54 |
|
| 55 |
-
// ⚙️ 设置按钮(放在社区精选旁边)
|
| 56 |
const settingsBtn = document.createElement("button");
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
color: "#888",
|
| 61 |
-
cursor: "pointer",
|
| 62 |
-
fontSize: "16px",
|
| 63 |
-
marginLeft: "8px",
|
| 64 |
-
padding: "4px",
|
| 65 |
-
transition: "0.2s",
|
| 66 |
-
verticalAlign: "middle"
|
| 67 |
-
});
|
| 68 |
-
settingsBtn.innerHTML = "⚙️";
|
| 69 |
settingsBtn.title = t('nav.settings');
|
| 70 |
-
settingsBtn.
|
| 71 |
-
settingsBtn.onmouseout = () => { settingsBtn.style.color = "#888"; settingsBtn.style.transform = "rotate(0deg)"; };
|
| 72 |
settingsBtn.onclick = (e) => { e.stopPropagation(); openSettingsPage(); };
|
| 73 |
|
| 74 |
-
// 📦 将标题和设置按钮包裹在一起
|
| 75 |
const titleWrapper = document.createElement("div");
|
| 76 |
-
titleWrapper.
|
| 77 |
-
titleWrapper.style.alignItems = "center";
|
| 78 |
titleWrapper.appendChild(titleSpan);
|
| 79 |
titleWrapper.appendChild(settingsBtn);
|
| 80 |
|
| 81 |
const userActionBtn = document.createElement("button");
|
| 82 |
-
|
|
|
|
| 83 |
|
| 84 |
const chatEntryBtn = document.createElement("button");
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
chatEntryBtn.innerHTML = `✉️<span id="chat-unread-badge"
|
| 88 |
chatEntryBtn.title = t('social.chat');
|
|
|
|
| 89 |
|
| 90 |
const bellBtn = document.createElement("button");
|
| 91 |
-
|
| 92 |
-
bellBtn.
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
const savedUserStr = localStorage.getItem("ComfyCommunity_User");
|
| 97 |
-
if (savedUserStr) {
|
| 98 |
-
const data = JSON.parse(savedUserStr);
|
| 99 |
-
if (data.expireAt && Date.now() < data.expireAt) { currentUser = data.user; }
|
| 100 |
-
else { localStorage.removeItem("ComfyCommunity_User"); localStorage.removeItem("ComfyCommunity_Token"); }
|
| 101 |
-
} else {
|
| 102 |
-
const tempUserStr = sessionStorage.getItem("ComfyCommunity_User");
|
| 103 |
-
if (tempUserStr) { currentUser = JSON.parse(tempUserStr).user; }
|
| 104 |
-
}
|
| 105 |
-
} catch (e) {}
|
| 106 |
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
if (!currentUser) return showToast(t('auth.login_required'), "warning");
|
| 112 |
-
openChatModal(currentUser);
|
| 113 |
-
};
|
| 114 |
-
|
| 115 |
-
// 【核心瘦身】:直接调用拆分出去的独立组件模块
|
| 116 |
-
bellBtn.onclick = () => openNotificationCenter(currentUser, bellBtn);
|
| 117 |
|
| 118 |
const updateUserButtonState = () => {
|
| 119 |
if (currentUser) {
|
| 120 |
userActionBtn.innerHTML = `👤 ${escapeHtml(currentUser.name || currentUser.account || t('common.unknown_user'))}`;
|
| 121 |
-
userActionBtn.
|
| 122 |
-
userActionBtn.
|
| 123 |
userActionBtn.onclick = () => openUserProfileModal(currentUser);
|
| 124 |
loadUnreadCount(currentUser, bellBtn);
|
| 125 |
loadChatUnreadCount(currentUser, chatEntryBtn);
|
|
@@ -131,85 +104,71 @@ export function createTopNav() {
|
|
| 131 |
stopMessagePolling();
|
| 132 |
|
| 133 |
userActionBtn.innerHTML = `🔑 ${t('nav.login')}`;
|
| 134 |
-
userActionBtn.
|
| 135 |
-
userActionBtn.
|
| 136 |
bellBtn.querySelector("#unread-badge").style.display = "none";
|
| 137 |
chatEntryBtn.querySelector("#chat-unread-badge").style.display = "none";
|
| 138 |
|
| 139 |
userActionBtn.onclick = () => {
|
| 140 |
-
const view = createAuthView(async (formData) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
try {
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
// 🚀 核心修复:停止错误地拆解字段导致 undefined,直接将包含所有数据的 formData 对象完整传给 API 装甲!
|
| 149 |
-
await api.resetPassword(formData);
|
| 150 |
-
|
| 151 |
-
showToast(t('auth.password_reset_success'), "success");
|
| 152 |
-
window.dispatchEvent(new CustomEvent("comfy-route-back")); updateUserButtonState(); return;
|
| 153 |
}
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
showToast(t('auth.register_success') + t('auth.auto_login'), "success");
|
| 164 |
-
// 注册后自动登录,默认保持登录
|
| 165 |
-
const res = await api.login(formData.account, formData.password, true);
|
| 166 |
-
userData = { account: formData.account, name: formData.name, avatar: res.avatar, ...res };
|
| 167 |
-
token = res.token; isRemember = true;
|
| 168 |
-
|
| 169 |
-
// 🖼️ 登录成功后再上传头像(此时请求可携带 Authorization)
|
| 170 |
-
if (pendingAvatarFile && token) {
|
| 171 |
-
userActionBtn.innerHTML = `⏳ ${t('auth.uploading_avatar')}`;
|
| 172 |
-
// 提前写入 token,确保上传与资料更新请求能通过认证
|
| 173 |
-
localStorage.setItem("ComfyCommunity_Token", token);
|
| 174 |
-
try {
|
| 175 |
-
// 统一走带 processAvatar 压缩的包装器,规避后端 2MB 头像限制
|
| 176 |
-
const cloudAvatarUrl = await uploadFile(pendingAvatarFile, "avatar");
|
| 177 |
-
if (cloudAvatarUrl) {
|
| 178 |
-
const updateRes = await api.updateUserProfile(formData.account, { avatarDataUrl: cloudAvatarUrl });
|
| 179 |
-
userData = { ...userData, ...(updateRes.data || {}) };
|
| 180 |
-
if (userData.avatarDataUrl) userData.avatar = userData.avatarDataUrl;
|
| 181 |
-
} else {
|
| 182 |
-
showToast(t('auth.avatar_set_failed'), "warning");
|
| 183 |
-
}
|
| 184 |
-
} catch (avatarErr) {
|
| 185 |
-
console.warn("注册后设置头像失败:", avatarErr.message || avatarErr);
|
| 186 |
-
showToast(t('auth.avatar_set_failed'), "warning");
|
| 187 |
-
}
|
| 188 |
-
}
|
| 189 |
-
} else if (formData.type === "login") {
|
| 190 |
-
const res = await api.login(formData.account, formData.password, formData.remember);
|
| 191 |
-
token = res.token; isRemember = formData.remember;
|
| 192 |
-
try {
|
| 193 |
-
if (isRemember) localStorage.setItem("ComfyCommunity_Token", token);
|
| 194 |
-
else sessionStorage.setItem("ComfyCommunity_Token", token);
|
| 195 |
-
const profileRes = await api.getUserProfile(formData.account);
|
| 196 |
-
userData = { account: formData.account, ...profileRes.data };
|
| 197 |
-
} catch (e) {
|
| 198 |
-
userData = { account: formData.account, name: res.name || res.data?.name || formData.account, avatar: res.avatar || res.data?.avatar };
|
| 199 |
-
}
|
| 200 |
-
showToast(t('auth.login_success'), "success");
|
| 201 |
}
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
if (
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
}
|
| 214 |
});
|
| 215 |
window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } }));
|
|
@@ -219,31 +178,19 @@ export function createTopNav() {
|
|
| 219 |
|
| 220 |
// 🔄 跨标签页登录状态同步:其他标签页修改 localStorage 时触发
|
| 221 |
// 🔧 P1修复:改为模块级统一注册的 storage 监听回调(onStorage),不再逐实例 addEventListener
|
| 222 |
-
const handleCrossTabStorage =
|
| 223 |
-
if (event.key ===
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
currentUser = null;
|
| 228 |
-
updateUserButtonState();
|
| 229 |
-
}
|
| 230 |
-
} else if (event.key === 'ComfyCommunity_User') {
|
| 231 |
-
// 其他标签页登入了不同账号 → 刷新本标签页用户状态
|
| 232 |
-
try {
|
| 233 |
-
const data = JSON.parse(event.newValue);
|
| 234 |
-
if (data.user && data.user.account !== (currentUser && currentUser.account)) {
|
| 235 |
-
currentUser = data.user;
|
| 236 |
-
updateUserButtonState();
|
| 237 |
-
}
|
| 238 |
-
} catch (e) {}
|
| 239 |
-
}
|
| 240 |
}
|
| 241 |
};
|
| 242 |
-
|
| 243 |
updateUserButtonState();
|
| 244 |
|
| 245 |
// 🔧 P1修复:注册为当前活跃实例,模块级监听将路由到本实例的回调
|
| 246 |
_navInstance = {
|
|
|
|
| 247 |
onLogout: () => { currentUser = null; updateUserButtonState(); },
|
| 248 |
onStorage: handleCrossTabStorage,
|
| 249 |
onChatUnreadChanged: () => loadChatUnreadCount(currentUser, chatEntryBtn)
|
|
@@ -251,16 +198,16 @@ export function createTopNav() {
|
|
| 251 |
_registerNavWindowListeners();
|
| 252 |
|
| 253 |
const actionWrapper = document.createElement("div");
|
| 254 |
-
actionWrapper.
|
| 255 |
actionWrapper.appendChild(chatEntryBtn);
|
| 256 |
actionWrapper.appendChild(bellBtn);
|
| 257 |
actionWrapper.appendChild(userActionBtn);
|
| 258 |
|
| 259 |
-
userHeader.appendChild(titleWrapper);
|
| 260 |
-
|
|
|
|
| 261 |
const banner = createTopBanner();
|
| 262 |
if (banner) userHeader.appendChild(banner);
|
| 263 |
-
userHeader.appendChild(actionWrapper);
|
| 264 |
|
| 265 |
return {
|
| 266 |
dom: userHeader,
|
|
@@ -270,38 +217,32 @@ export function createTopNav() {
|
|
| 270 |
|
| 271 |
// 🚀 新增:启动消息定时轮询
|
| 272 |
function startMessagePolling(currentUser, bellBtn, chatEntryBtn) {
|
| 273 |
-
if (!CACHE.MESSAGE_POLL.ENABLED) return;
|
| 274 |
-
if (isPollingActive) return; // 🔧 P3优化:防止重复启动
|
| 275 |
-
|
| 276 |
-
// 清除旧的定时器
|
| 277 |
stopMessagePolling();
|
| 278 |
isPollingActive = true;
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
|
|
|
| 285 |
return;
|
| 286 |
}
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
// 静默失败,不影响用户体验
|
| 293 |
-
}
|
| 294 |
-
}, CACHE.MESSAGE_POLL.INTERVAL);
|
| 295 |
-
|
| 296 |
-
console.log(`📨 消息轮询已启动,间隔 ${CACHE.MESSAGE_POLL.INTERVAL / 1000} 秒`);
|
| 297 |
}
|
| 298 |
|
| 299 |
// 🚀 新增:停止消息定时轮询
|
| 300 |
function stopMessagePolling() {
|
|
|
|
| 301 |
isPollingActive = false; // 🔧 P3优化:先标记为停止
|
| 302 |
if (messagePollingTimer) {
|
| 303 |
-
|
| 304 |
messagePollingTimer = null;
|
| 305 |
console.log('📨 消息轮询已停止');
|
| 306 |
}
|
| 307 |
-
}
|
|
|
|
| 2 |
import { createAuthView } from "../auth/用户注册登录组件.js";
|
| 3 |
import { openUserProfileModal } from "../profile/个人中心视图.js";
|
| 4 |
import { openChatModal, loadChatUnreadCount } from "../social/私信聊天组件.js";
|
| 5 |
+
import { api, captureRequestSession } from "../core/网络请求API.js";
|
| 6 |
import { showToast } from "./UI交互提示组件.js";
|
| 7 |
import { showAboutInfo } from "./关于插件组件.js";
|
| 8 |
import { openNotificationCenter, loadUnreadCount } from "../social/通知中心组件.js";
|
| 9 |
import { openSettingsPage } from "./全局设置组件.js"; // ⚙️ 新增
|
| 10 |
+
import { setUser, updateUser, initUserState } from "../core/状态管理.js";
|
| 11 |
+
import { readUserSession, sessionProfile } from "../core/用户会话存储.js";
|
| 12 |
+
import { API, CACHE, escapeHtml } from "../core/全局配置.js";
|
| 13 |
import { t } from "./用户体验增强.js"; // 🌐 多语言支持
|
| 14 |
import { createTopBanner } from "./顶部广告组件.js";
|
| 15 |
import { uploadFile } from "../market/发布内容_提交引擎.js"; // 🖼️ 带压缩管线的统一上传包装器
|
| 16 |
|
| 17 |
// 🚀 新增:消息轮询定时器
|
| 18 |
let messagePollingTimer = null;
|
| 19 |
+
let pollingGeneration = 0;
|
| 20 |
let isPollingActive = false; // 🔧 P3优化:防止重复启动轮询
|
| 21 |
|
| 22 |
// 🔧 P1修复:window 监听只安装一次并始终指向最新导航实例
|
|
|
|
| 40 |
}
|
| 41 |
|
| 42 |
export function createTopNav() {
|
| 43 |
+
stopMessagePolling();
|
| 44 |
const userHeader = document.createElement("div");
|
| 45 |
+
userHeader.className = "top-nav";
|
| 46 |
+
|
| 47 |
+
const titleSpan = document.createElement("button");
|
| 48 |
+
titleSpan.type = "button";
|
| 49 |
+
titleSpan.className = "top-nav__brand";
|
| 50 |
+
titleSpan.innerHTML = `<strong>ComfyUI</strong> ${t('nav.community')}`;
|
| 51 |
+
titleSpan.title = t('nav.about_title');
|
| 52 |
+
titleSpan.setAttribute("aria-label", t('nav.about_title'));
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
titleSpan.onclick = () => showAboutInfo(currentUser);
|
| 54 |
|
|
|
|
| 55 |
const settingsBtn = document.createElement("button");
|
| 56 |
+
settingsBtn.type = "button";
|
| 57 |
+
settingsBtn.className = "top-nav__icon";
|
| 58 |
+
settingsBtn.textContent = "⚙️";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
settingsBtn.title = t('nav.settings');
|
| 60 |
+
settingsBtn.setAttribute("aria-label", t('nav.settings'));
|
|
|
|
| 61 |
settingsBtn.onclick = (e) => { e.stopPropagation(); openSettingsPage(); };
|
| 62 |
|
|
|
|
| 63 |
const titleWrapper = document.createElement("div");
|
| 64 |
+
titleWrapper.className = "top-nav__heading";
|
|
|
|
| 65 |
titleWrapper.appendChild(titleSpan);
|
| 66 |
titleWrapper.appendChild(settingsBtn);
|
| 67 |
|
| 68 |
const userActionBtn = document.createElement("button");
|
| 69 |
+
userActionBtn.type = "button";
|
| 70 |
+
userActionBtn.className = "top-nav__identity";
|
| 71 |
|
| 72 |
const chatEntryBtn = document.createElement("button");
|
| 73 |
+
chatEntryBtn.type = "button";
|
| 74 |
+
chatEntryBtn.className = "top-nav__icon";
|
| 75 |
+
chatEntryBtn.innerHTML = `✉️<span id="chat-unread-badge" class="top-nav__badge" style="display:none">0</span>`;
|
| 76 |
chatEntryBtn.title = t('social.chat');
|
| 77 |
+
chatEntryBtn.setAttribute("aria-label", t('social.chat'));
|
| 78 |
|
| 79 |
const bellBtn = document.createElement("button");
|
| 80 |
+
bellBtn.type = "button";
|
| 81 |
+
bellBtn.className = "top-nav__icon";
|
| 82 |
+
bellBtn.innerHTML = `🔔<span id="unread-badge" class="top-nav__badge" style="display:none">0</span>`;
|
| 83 |
+
bellBtn.title = t('social.notifications');
|
| 84 |
+
bellBtn.setAttribute("aria-label", t('social.notifications'));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
+
let currentUser = null;
|
| 87 |
+
const sessionStorages = () => ['localStorage', 'sessionStorage'].map(name => { try { return window[name]; } catch (_) { return null; } });
|
| 88 |
+
const restoredSession = readUserSession(sessionStorages(), CACHE.LEGACY_KEYS);
|
| 89 |
+
currentUser = restoredSession?.user || null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
const updateUserButtonState = () => {
|
| 92 |
if (currentUser) {
|
| 93 |
userActionBtn.innerHTML = `👤 ${escapeHtml(currentUser.name || currentUser.account || t('common.unknown_user'))}`;
|
| 94 |
+
userActionBtn.dataset.authenticated = "true";
|
| 95 |
+
userActionBtn.title = currentUser.name || currentUser.account || t('common.unknown_user');
|
| 96 |
userActionBtn.onclick = () => openUserProfileModal(currentUser);
|
| 97 |
loadUnreadCount(currentUser, bellBtn);
|
| 98 |
loadChatUnreadCount(currentUser, chatEntryBtn);
|
|
|
|
| 104 |
stopMessagePolling();
|
| 105 |
|
| 106 |
userActionBtn.innerHTML = `🔑 ${t('nav.login')}`;
|
| 107 |
+
userActionBtn.dataset.authenticated = "false";
|
| 108 |
+
userActionBtn.title = t('nav.login');
|
| 109 |
bellBtn.querySelector("#unread-badge").style.display = "none";
|
| 110 |
chatEntryBtn.querySelector("#chat-unread-badge").style.display = "none";
|
| 111 |
|
| 112 |
userActionBtn.onclick = () => {
|
| 113 |
+
const view = createAuthView(async (formData, context) => {
|
| 114 |
+
const originalSession = captureRequestSession();
|
| 115 |
+
const current = () => context.isCurrent() && userHeader.isConnected
|
| 116 |
+
&& _navInstance?.dom === userHeader && originalSession();
|
| 117 |
+
if (!current()) return;
|
| 118 |
try {
|
| 119 |
+
if (formData.type === 'reset') {
|
| 120 |
+
const reset = await api.resetPassword(formData);
|
| 121 |
+
if (reset?.status !== 'success') throw new Error('Unconfirmed reset');
|
| 122 |
+
if (!current()) return;
|
| 123 |
+
showToast(t('auth.password_reset_success'), 'success');
|
| 124 |
+
window.dispatchEvent(new CustomEvent('comfy-route-back')); updateUserButtonState(); return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
}
|
| 126 |
+
const account = formData.account;
|
| 127 |
+
const isRegister = formData.type === 'register';
|
| 128 |
+
if (!isRegister && formData.type !== 'login') return;
|
| 129 |
+
const pendingAvatarFile = isRegister ? formData.avatarFile : null;
|
| 130 |
+
if (isRegister) {
|
| 131 |
+
const registration = { ...formData }; delete registration.avatarFile; delete registration.avatarDataUrl;
|
| 132 |
+
const registered = await api.register(registration);
|
| 133 |
+
if (registered?.status !== 'success') throw new Error('Unconfirmed registration');
|
| 134 |
+
if (!current()) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
}
|
| 136 |
+
const remember = isRegister || formData.remember === true;
|
| 137 |
+
const result = await api.login(account, formData.password, remember);
|
| 138 |
+
if (!current()) return;
|
| 139 |
+
if (result?.status !== 'success' || typeof result.token !== 'string' || !result.token
|
| 140 |
+
|| (result.account && result.account !== account)) throw new Error('Invalid login response');
|
| 141 |
+
currentUser = setUser({ account, name: result.name || formData.name || account, avatar: result.avatar }, result.token, remember);
|
| 142 |
+
const authenticatedSession = captureRequestSession();
|
| 143 |
+
const sameLogin = () => authenticatedSession() && userHeader.isConnected
|
| 144 |
+
&& _navInstance?.dom === userHeader && currentUser?.account === account;
|
| 145 |
+
showToast(t(isRegister ? 'auth.register_success' : 'auth.login_success'), 'success');
|
| 146 |
+
// 凭证已发布即完成登录,资料补充不阻塞页面返回。
|
| 147 |
+
window.dispatchEvent(new CustomEvent('comfy-route-back'));
|
| 148 |
+
updateUserButtonState(); window.dispatchEvent(new CustomEvent('comfy-user-login'));
|
| 149 |
+
const applyProfile = data => {
|
| 150 |
+
if (!sameLogin() || data?.account !== account) return;
|
| 151 |
+
currentUser = sessionProfile({ ...currentUser, ...data });
|
| 152 |
+
if (currentUser.avatarDataUrl) currentUser.avatar = currentUser.avatarDataUrl;
|
| 153 |
+
updateUser(currentUser); updateUserButtonState();
|
| 154 |
+
};
|
| 155 |
+
try {
|
| 156 |
+
const profile = await api.getUserProfile(account, { timeout: API.BACKGROUND_TIMEOUT,
|
| 157 |
+
retries: API.BACKGROUND_RETRIES, componentId: `login-profile-${account}` });
|
| 158 |
+
if (profile?.status === 'success') applyProfile(profile.data);
|
| 159 |
+
} catch (_) {}
|
| 160 |
+
if (!sameLogin() || !pendingAvatarFile) return;
|
| 161 |
+
try {
|
| 162 |
+
const avatar = await uploadFile(pendingAvatarFile, 'avatar', { isCurrent: sameLogin, silent: true });
|
| 163 |
+
if (!sameLogin()) return;
|
| 164 |
+
if (!avatar) { showToast(t('auth.avatar_set_failed'), 'warning'); return; }
|
| 165 |
+
const updated = await api.updateUserProfile(account, { avatarDataUrl: avatar });
|
| 166 |
+
if (!sameLogin()) return;
|
| 167 |
+
if (updated?.status === 'success') applyProfile(updated.data);
|
| 168 |
+
else showToast(t('auth.avatar_set_failed'), 'warning');
|
| 169 |
+
} catch (_) { if (sameLogin()) showToast(t('auth.avatar_set_failed'), 'warning'); }
|
| 170 |
+
} catch (_) {
|
| 171 |
+
if (current()) { showToast(t('common.error'), 'error'); updateUserButtonState(); }
|
| 172 |
}
|
| 173 |
});
|
| 174 |
window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } }));
|
|
|
|
| 178 |
|
| 179 |
// 🔄 跨标签页登录状态同步:其他标签页修改 localStorage 时触发
|
| 180 |
// 🔧 P1修复:改为模块级统一注册的 storage 监听回调(onStorage),不再逐实例 addEventListener
|
| 181 |
+
const handleCrossTabStorage = event => {
|
| 182 |
+
if (event.key === null || [CACHE.LEGACY_KEYS.USER, CACHE.LEGACY_KEYS.TOKEN].includes(event.key)) {
|
| 183 |
+
currentUser = readUserSession(sessionStorages(), CACHE.LEGACY_KEYS)?.user || null;
|
| 184 |
+
stopMessagePolling();
|
| 185 |
+
initUserState(); updateUserButtonState();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
}
|
| 187 |
};
|
| 188 |
+
|
| 189 |
updateUserButtonState();
|
| 190 |
|
| 191 |
// 🔧 P1修复:注册为当前活跃实例,模块级监听将路由到本实例的回调
|
| 192 |
_navInstance = {
|
| 193 |
+
dom: userHeader,
|
| 194 |
onLogout: () => { currentUser = null; updateUserButtonState(); },
|
| 195 |
onStorage: handleCrossTabStorage,
|
| 196 |
onChatUnreadChanged: () => loadChatUnreadCount(currentUser, chatEntryBtn)
|
|
|
|
| 198 |
_registerNavWindowListeners();
|
| 199 |
|
| 200 |
const actionWrapper = document.createElement("div");
|
| 201 |
+
actionWrapper.className = "top-nav__actions";
|
| 202 |
actionWrapper.appendChild(chatEntryBtn);
|
| 203 |
actionWrapper.appendChild(bellBtn);
|
| 204 |
actionWrapper.appendChild(userActionBtn);
|
| 205 |
|
| 206 |
+
userHeader.appendChild(titleWrapper);
|
| 207 |
+
userHeader.appendChild(actionWrapper);
|
| 208 |
+
// 广告独占下一行,DOM/键盘顺序与视觉顺序一致。
|
| 209 |
const banner = createTopBanner();
|
| 210 |
if (banner) userHeader.appendChild(banner);
|
|
|
|
| 211 |
|
| 212 |
return {
|
| 213 |
dom: userHeader,
|
|
|
|
| 217 |
|
| 218 |
// 🚀 新增:启动消息定时轮询
|
| 219 |
function startMessagePolling(currentUser, bellBtn, chatEntryBtn) {
|
| 220 |
+
if (!CACHE.MESSAGE_POLL.ENABLED || isPollingActive) return;
|
|
|
|
|
|
|
|
|
|
| 221 |
stopMessagePolling();
|
| 222 |
isPollingActive = true;
|
| 223 |
+
const generation = pollingGeneration;
|
| 224 |
+
const sessionMatches = captureRequestSession();
|
| 225 |
+
const current = () => isPollingActive && generation === pollingGeneration
|
| 226 |
+
&& sessionMatches() && _navInstance?.dom?.contains(bellBtn) && bellBtn.isConnected && chatEntryBtn.isConnected;
|
| 227 |
+
const poll = async () => {
|
| 228 |
+
if (!current()) {
|
| 229 |
+
if (generation === pollingGeneration) stopMessagePolling();
|
| 230 |
return;
|
| 231 |
}
|
| 232 |
+
await Promise.allSettled([loadUnreadCount(currentUser, bellBtn), loadChatUnreadCount(currentUser, chatEntryBtn)]);
|
| 233 |
+
if (current()) messagePollingTimer = setTimeout(poll, CACHE.MESSAGE_POLL.INTERVAL);
|
| 234 |
+
else if (generation === pollingGeneration) stopMessagePolling();
|
| 235 |
+
};
|
| 236 |
+
messagePollingTimer = setTimeout(poll, CACHE.MESSAGE_POLL.INTERVAL);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
}
|
| 238 |
|
| 239 |
// 🚀 新增:停止消息定时轮询
|
| 240 |
function stopMessagePolling() {
|
| 241 |
+
pollingGeneration++;
|
| 242 |
isPollingActive = false; // 🔧 P3优化:先标记为停止
|
| 243 |
if (messagePollingTimer) {
|
| 244 |
+
clearTimeout(messagePollingTimer);
|
| 245 |
messagePollingTimer = null;
|
| 246 |
console.log('📨 消息轮询已停止');
|
| 247 |
}
|
| 248 |
+
}
|
前端页面/components/顶部广告组件.js
CHANGED
|
@@ -1,21 +1,23 @@
|
|
| 1 |
// 前端页面/components/顶部广告组件.js
|
| 2 |
|
| 3 |
import { api } from "../core/网络请求API.js";
|
|
|
|
|
|
|
| 4 |
|
| 5 |
// ========== 广告配置数据(默认值 / fallback) ==========
|
| 6 |
const BANNER_CONFIG = {
|
| 7 |
// 横幅图片URL(支持GIF)
|
| 8 |
bannerImage: "",
|
| 9 |
// 广告标题
|
| 10 |
-
title: "
|
| 11 |
// 广告描述
|
| 12 |
-
description: "
|
| 13 |
// 详情页内容(HTML字符串或纯文本)
|
| 14 |
-
detailContent: "
|
| 15 |
// 详情页大图
|
| 16 |
detailImage: "",
|
| 17 |
// 是否启用广告
|
| 18 |
-
enabled:
|
| 19 |
};
|
| 20 |
|
| 21 |
// 运行时配置(异步加载后更新)
|
|
@@ -25,25 +27,82 @@ let activeConfig = { ...BANNER_CONFIG };
|
|
| 25 |
let bannerContainer = null;
|
| 26 |
|
| 27 |
// 本地缓存配置
|
| 28 |
-
const BANNER_CACHE_KEY =
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
/**
|
| 32 |
* 读取本地广告配置缓存(非破坏性,不删除过期数据)
|
| 33 |
-
* @returns {
|
| 34 |
*/
|
| 35 |
function readBannerCache() {
|
| 36 |
try {
|
| 37 |
const cached = localStorage.getItem(BANNER_CACHE_KEY);
|
| 38 |
if (cached) {
|
| 39 |
-
const { data
|
| 40 |
-
return
|
| 41 |
}
|
| 42 |
} catch (parseErr) {
|
| 43 |
// 缓存数据损坏,清除
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
}
|
| 46 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
}
|
| 48 |
|
| 49 |
/**
|
|
@@ -51,26 +110,43 @@ function readBannerCache() {
|
|
| 51 |
* 成功后更新缓存并刷新 DOM;失败时保留当前已展示的内容
|
| 52 |
*/
|
| 53 |
async function syncBannerInBackground() {
|
|
|
|
| 54 |
try {
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
| 56 |
if (res && res.status === "success") {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
if (res.data) {
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
| 64 |
} else {
|
| 65 |
-
//
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
| 68 |
}
|
|
|
|
|
|
|
|
|
|
| 69 |
updateBannerDOM();
|
| 70 |
}
|
| 71 |
} catch (e) {
|
| 72 |
-
// 网络失败:
|
| 73 |
-
console.warn("后台更新广告配置失败(保留当前内容)
|
| 74 |
}
|
| 75 |
}
|
| 76 |
|
|
@@ -80,19 +156,10 @@ async function syncBannerInBackground() {
|
|
| 80 |
*/
|
| 81 |
export function createTopBanner() {
|
| 82 |
// 创建广告容器(初始隐藏,异步加载后决定是否显示)
|
| 83 |
-
const container = document.createElement("
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
justifyContent: "center",
|
| 88 |
-
alignItems: "center",
|
| 89 |
-
margin: "0 15px",
|
| 90 |
-
maxHeight: "42px",
|
| 91 |
-
overflow: "hidden",
|
| 92 |
-
borderRadius: "6px",
|
| 93 |
-
cursor: "pointer",
|
| 94 |
-
transition: "opacity 0.2s"
|
| 95 |
-
});
|
| 96 |
|
| 97 |
// 保存模块级引用
|
| 98 |
bannerContainer = container;
|
|
@@ -100,15 +167,16 @@ export function createTopBanner() {
|
|
| 100 |
// ⚡ 完全不阻塞首屏:先读本地缓存(过期也立即使用)并立即渲染,
|
| 101 |
// 网络请求一律退到后台静默同步,保证断网/云端不通时横幅不受影响
|
| 102 |
// (与插件榜/提示词等榜单的缓存优先策略一致)
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
if (!fresh) {
|
| 110 |
-
syncBannerInBackground();
|
| 111 |
}
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
return container;
|
| 114 |
}
|
|
@@ -129,52 +197,82 @@ function updateBannerDOM() {
|
|
| 129 |
}
|
| 130 |
|
| 131 |
// 创建横幅图片
|
| 132 |
-
const img = document.createElement("img");
|
| 133 |
-
img.src = activeConfig.bannerImage;
|
| 134 |
-
img.alt = activeConfig.title || "
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
maxHeight: "100%",
|
| 138 |
-
objectFit: "contain"
|
| 139 |
-
});
|
| 140 |
|
| 141 |
bannerContainer.appendChild(img);
|
| 142 |
bannerContainer.style.display = "flex";
|
| 143 |
|
| 144 |
-
// 鼠标悬停效��
|
| 145 |
-
bannerContainer.onmouseover = () => { bannerContainer.style.opacity = "0.85"; };
|
| 146 |
-
bannerContainer.onmouseout = () => { bannerContainer.style.opacity = "1"; };
|
| 147 |
-
|
| 148 |
// 点击打开广告详情页
|
| 149 |
-
|
| 150 |
const view = createBannerDetailView();
|
| 151 |
window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } }));
|
| 152 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
}
|
| 154 |
|
| 155 |
/**
|
| 156 |
* 刷新顶部广告横幅(重新加载配置并更新DOM)
|
| 157 |
*/
|
| 158 |
export async function refreshBanner() {
|
| 159 |
-
//
|
| 160 |
-
|
| 161 |
-
try {
|
| 162 |
-
// 绕过请求级缓存,强制从服务器获取最新配置
|
| 163 |
-
const res = await api.getPublicBannerConfig({ noCache: true });
|
| 164 |
-
if (res && res.status === "success" && res.data) {
|
| 165 |
-
activeConfig = { ...BANNER_CONFIG, ...res.data };
|
| 166 |
-
// 更新本地缓存
|
| 167 |
-
localStorage.setItem(BANNER_CACHE_KEY, JSON.stringify({
|
| 168 |
-
data: res.data,
|
| 169 |
-
timestamp: Date.now()
|
| 170 |
-
}));
|
| 171 |
-
} else {
|
| 172 |
-
activeConfig = { ...BANNER_CONFIG };
|
| 173 |
-
}
|
| 174 |
-
} catch (e) {
|
| 175 |
-
console.warn("刷新广告配置失败", e);
|
| 176 |
-
}
|
| 177 |
-
updateBannerDOM();
|
| 178 |
}
|
| 179 |
|
| 180 |
/**
|
|
@@ -220,7 +318,7 @@ function createBannerDetailView() {
|
|
| 220 |
boxShadow: "0 2px 4px rgba(0,0,0,0.3)",
|
| 221 |
transition: "0.2s"
|
| 222 |
});
|
| 223 |
-
backBtn.textContent =
|
| 224 |
backBtn.onmouseover = () => {
|
| 225 |
backBtn.style.background = "#4CAF50";
|
| 226 |
backBtn.style.borderColor = "#4CAF50";
|
|
@@ -234,7 +332,7 @@ function createBannerDetailView() {
|
|
| 234 |
};
|
| 235 |
|
| 236 |
const pageTitle = document.createElement("span");
|
| 237 |
-
pageTitle.textContent = activeConfig.title || "
|
| 238 |
Object.assign(pageTitle.style, {
|
| 239 |
marginLeft: "12px",
|
| 240 |
fontSize: "16px",
|
|
@@ -287,7 +385,7 @@ function createBannerDetailView() {
|
|
| 287 |
if (activeConfig.detailImage) {
|
| 288 |
const detailImg = document.createElement("img");
|
| 289 |
detailImg.src = activeConfig.detailImage;
|
| 290 |
-
detailImg.alt = activeConfig.title || "
|
| 291 |
Object.assign(detailImg.style, {
|
| 292 |
maxWidth: "100%",
|
| 293 |
borderRadius: "8px"
|
|
@@ -311,7 +409,7 @@ function createBannerDetailView() {
|
|
| 311 |
wordWrap: "break-word"
|
| 312 |
});
|
| 313 |
// 支持HTML内容
|
| 314 |
-
detailEl.
|
| 315 |
content.appendChild(detailEl);
|
| 316 |
}
|
| 317 |
|
|
|
|
| 1 |
// 前端页面/components/顶部广告组件.js
|
| 2 |
|
| 3 |
import { api } from "../core/网络请求API.js";
|
| 4 |
+
import { API, CACHE } from "../core/全局配置.js";
|
| 5 |
+
import { t } from "./用户体验_国际化.js";
|
| 6 |
|
| 7 |
// ========== 广告配置数据(默认值 / fallback) ==========
|
| 8 |
const BANNER_CONFIG = {
|
| 9 |
// 横幅图片URL(支持GIF)
|
| 10 |
bannerImage: "",
|
| 11 |
// 广告标题
|
| 12 |
+
title: "",
|
| 13 |
// 广告描述
|
| 14 |
+
description: "",
|
| 15 |
// 详情页内容(HTML字符串或纯文本)
|
| 16 |
+
detailContent: "",
|
| 17 |
// 详情页大图
|
| 18 |
detailImage: "",
|
| 19 |
// 是否启用广告
|
| 20 |
+
enabled: false
|
| 21 |
};
|
| 22 |
|
| 23 |
// 运行时配置(异步加载后更新)
|
|
|
|
| 27 |
let bannerContainer = null;
|
| 28 |
|
| 29 |
// 本地缓存配置
|
| 30 |
+
const BANNER_CACHE_KEY = CACHE.LOCAL_KEYS.BANNER_CONFIG;
|
| 31 |
+
let bannerSyncGeneration = 0;
|
| 32 |
+
let hasActiveConfig = false;
|
| 33 |
+
// 保留已下载的横幅节点,提交时直接显示,避免重新赋值 src 再次联网。
|
| 34 |
+
let activeBannerImage = null;
|
| 35 |
+
|
| 36 |
+
/** 缓存与云端共用配置结构检查,异常结果不能覆盖已有横幅。 */
|
| 37 |
+
function validateBannerConfig(data) {
|
| 38 |
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
| 39 |
+
throw new Error("Invalid banner configuration");
|
| 40 |
+
}
|
| 41 |
+
if (typeof data.enabled !== "boolean") throw new Error("Invalid banner enabled state");
|
| 42 |
+
for (const field of ["bannerImage", "title", "description", "detailContent", "detailImage"]) {
|
| 43 |
+
if (data[field] !== undefined && typeof data[field] !== "string") {
|
| 44 |
+
throw new Error("Invalid banner field type");
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
const config = { ...BANNER_CONFIG, ...data };
|
| 48 |
+
if (config.enabled && !config.bannerImage.trim()) throw new Error("Missing banner image");
|
| 49 |
+
return config;
|
| 50 |
+
}
|
| 51 |
|
| 52 |
/**
|
| 53 |
* 读取本地广告配置缓存(非破坏性,不删除过期数据)
|
| 54 |
+
* @returns {Object|null} data 为缓存内容,无缓存时为 null
|
| 55 |
*/
|
| 56 |
function readBannerCache() {
|
| 57 |
try {
|
| 58 |
const cached = localStorage.getItem(BANNER_CACHE_KEY);
|
| 59 |
if (cached) {
|
| 60 |
+
const { data } = JSON.parse(cached);
|
| 61 |
+
return validateBannerConfig(data);
|
| 62 |
}
|
| 63 |
} catch (parseErr) {
|
| 64 |
// 缓存数据损坏,清除
|
| 65 |
+
try {
|
| 66 |
+
localStorage.removeItem(BANNER_CACHE_KEY);
|
| 67 |
+
} catch (storageErr) {
|
| 68 |
+
console.warn("广告缓存不可清理,继续使用默认显示流程");
|
| 69 |
+
}
|
| 70 |
}
|
| 71 |
+
return null;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/** 确认新配置引用的图片均可下载后,再允许替换当前横幅。 */
|
| 75 |
+
function preloadImage(url) {
|
| 76 |
+
if (!url) return Promise.resolve();
|
| 77 |
+
return new Promise((resolve, reject) => {
|
| 78 |
+
const image = new Image();
|
| 79 |
+
const finish = (error) => {
|
| 80 |
+
clearTimeout(timer);
|
| 81 |
+
image.onload = null;
|
| 82 |
+
image.onerror = null;
|
| 83 |
+
if (error) reject(error);
|
| 84 |
+
else resolve(image);
|
| 85 |
+
};
|
| 86 |
+
const timer = setTimeout(() => {
|
| 87 |
+
finish(new Error("Banner image loading timed out"));
|
| 88 |
+
image.removeAttribute("src");
|
| 89 |
+
}, API.BACKGROUND_TIMEOUT);
|
| 90 |
+
image.onload = () => finish();
|
| 91 |
+
image.onerror = () => finish(new Error("Banner image download failed"));
|
| 92 |
+
image.src = url;
|
| 93 |
+
});
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
async function prepareBannerConfig(data) {
|
| 97 |
+
// 只有明确的 null 表示服务端关闭广告;缺失或异常数据视为更新失败。
|
| 98 |
+
if (data === null) return { config: { ...BANNER_CONFIG }, image: null };
|
| 99 |
+
const config = validateBannerConfig(data);
|
| 100 |
+
if (!config.enabled) return { config, image: null };
|
| 101 |
+
const [image] = await Promise.all([
|
| 102 |
+
preloadImage(config.bannerImage),
|
| 103 |
+
preloadImage(config.detailImage)
|
| 104 |
+
]);
|
| 105 |
+
return { config, image };
|
| 106 |
}
|
| 107 |
|
| 108 |
/**
|
|
|
|
| 110 |
* 成功后更新缓存并刷新 DOM;失败时保留当前已展示的内容
|
| 111 |
*/
|
| 112 |
async function syncBannerInBackground() {
|
| 113 |
+
const generation = ++bannerSyncGeneration;
|
| 114 |
try {
|
| 115 |
+
// 绕过请求层缓存,确保拿到的确实是后台新配置。
|
| 116 |
+
const res = await api.getPublicBannerConfig({
|
| 117 |
+
noCache: true, timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES
|
| 118 |
+
});
|
| 119 |
if (res && res.status === "success") {
|
| 120 |
+
if (generation !== bannerSyncGeneration) return;
|
| 121 |
+
const prepared = await prepareBannerConfig(res.data);
|
| 122 |
+
const nextConfig = prepared.config;
|
| 123 |
+
// 图片加载期间可能又发起了一次刷新,只让最新请求提交结果。
|
| 124 |
+
if (generation !== bannerSyncGeneration) return;
|
| 125 |
if (res.data) {
|
| 126 |
+
try {
|
| 127 |
+
localStorage.setItem(BANNER_CACHE_KEY, JSON.stringify({
|
| 128 |
+
data: nextConfig,
|
| 129 |
+
timestamp: Date.now()
|
| 130 |
+
}));
|
| 131 |
+
} catch (storageError) {
|
| 132 |
+
console.warn("新广告配置无法写入本地缓存,仍显示已下载的新内容");
|
| 133 |
+
}
|
| 134 |
} else {
|
| 135 |
+
// 服务端明确返回无配置,作为成功的新结果清除旧配置。
|
| 136 |
+
try {
|
| 137 |
+
localStorage.removeItem(BANNER_CACHE_KEY);
|
| 138 |
+
} catch (storageError) {
|
| 139 |
+
console.warn("无法清除本地广告缓存,仍应用服务端的无广告状态");
|
| 140 |
+
}
|
| 141 |
}
|
| 142 |
+
activeConfig = nextConfig;
|
| 143 |
+
activeBannerImage = prepared.image;
|
| 144 |
+
hasActiveConfig = true;
|
| 145 |
updateBannerDOM();
|
| 146 |
}
|
| 147 |
} catch (e) {
|
| 148 |
+
// 网络失败或图片下载失败:不写缓存、不替换当前已显示的缓存横幅。
|
| 149 |
+
console.warn("后台更新广告配置失败(保留当前内容)");
|
| 150 |
}
|
| 151 |
}
|
| 152 |
|
|
|
|
| 156 |
*/
|
| 157 |
export function createTopBanner() {
|
| 158 |
// 创建广告容器(初始隐藏,异步加载后决定是否显示)
|
| 159 |
+
const container = document.createElement("button");
|
| 160 |
+
container.type = "button";
|
| 161 |
+
container.className = "top-banner";
|
| 162 |
+
container.style.display = "none";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
|
| 164 |
// 保存模块级引用
|
| 165 |
bannerContainer = container;
|
|
|
|
| 167 |
// ⚡ 完全不阻塞首屏:先读本地缓存(过期也立即使用)并立即渲染,
|
| 168 |
// 网络请求一律退到后台静默同步,保证断网/云端不通时横幅不受影响
|
| 169 |
// (与插件榜/提示词等榜单的缓存优先策略一致)
|
| 170 |
+
if (!hasActiveConfig) {
|
| 171 |
+
const cachedData = readBannerCache();
|
| 172 |
+
if (cachedData) {
|
| 173 |
+
activeConfig = cachedData;
|
| 174 |
+
hasActiveConfig = true;
|
| 175 |
+
}
|
|
|
|
|
|
|
| 176 |
}
|
| 177 |
+
updateBannerDOM();
|
| 178 |
+
// 始终在后台尝试获取新配置;当前缓存先显示,成功且图片下载完成后再整体替换。
|
| 179 |
+
syncBannerInBackground();
|
| 180 |
|
| 181 |
return container;
|
| 182 |
}
|
|
|
|
| 197 |
}
|
| 198 |
|
| 199 |
// 创建横幅图片
|
| 200 |
+
const img = activeBannerImage || document.createElement("img");
|
| 201 |
+
if (!activeBannerImage) img.src = activeConfig.bannerImage;
|
| 202 |
+
img.alt = activeConfig.title || t("common.advertisement");
|
| 203 |
+
bannerContainer.setAttribute("aria-label", activeConfig.title || t("common.advertisement"));
|
| 204 |
+
img.className = "top-banner__image";
|
|
|
|
|
|
|
|
|
|
| 205 |
|
| 206 |
bannerContainer.appendChild(img);
|
| 207 |
bannerContainer.style.display = "flex";
|
| 208 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
// 点击打开广告详情页
|
| 210 |
+
const openBannerDetail = () => {
|
| 211 |
const view = createBannerDetailView();
|
| 212 |
window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } }));
|
| 213 |
};
|
| 214 |
+
bannerContainer.onclick = openBannerDetail;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
const BANNER_ALLOWED_TAGS = new Set([
|
| 218 |
+
"a", "b", "blockquote", "br", "code", "em", "h2", "h3", "h4",
|
| 219 |
+
"hr", "i", "li", "ol", "p", "pre", "s", "strong", "u", "ul"
|
| 220 |
+
]);
|
| 221 |
+
const BANNER_BLOCKED_TAGS = new Set([
|
| 222 |
+
"audio", "button", "embed", "form", "iframe", "img", "input", "math",
|
| 223 |
+
"object", "script", "style", "svg", "textarea", "video"
|
| 224 |
+
]);
|
| 225 |
+
|
| 226 |
+
/** Copy only simple formatting and safe HTTP(S) links from administrator-provided content. */
|
| 227 |
+
function sanitizeBannerDetailContent(value) {
|
| 228 |
+
const source = new DOMParser().parseFromString(String(value ?? ""), "text/html");
|
| 229 |
+
const fragment = document.createDocumentFragment();
|
| 230 |
+
|
| 231 |
+
const copyNodes = (sourceNode, targetNode) => {
|
| 232 |
+
for (const node of sourceNode.childNodes) {
|
| 233 |
+
if (node.nodeType === Node.TEXT_NODE) {
|
| 234 |
+
targetNode.appendChild(document.createTextNode(node.nodeValue || ""));
|
| 235 |
+
continue;
|
| 236 |
+
}
|
| 237 |
+
if (node.nodeType !== Node.ELEMENT_NODE) continue;
|
| 238 |
+
|
| 239 |
+
const tag = node.tagName.toLowerCase();
|
| 240 |
+
if (BANNER_BLOCKED_TAGS.has(tag)) continue;
|
| 241 |
+
if (!BANNER_ALLOWED_TAGS.has(tag)) {
|
| 242 |
+
copyNodes(node, targetNode);
|
| 243 |
+
continue;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
const cleanElement = document.createElement(tag);
|
| 247 |
+
if (tag === "a") {
|
| 248 |
+
const rawHref = node.getAttribute("href");
|
| 249 |
+
try {
|
| 250 |
+
if (!rawHref?.trim()) throw new Error("Missing link target");
|
| 251 |
+
const url = new URL(rawHref.trim(), document.baseURI);
|
| 252 |
+
if (["http:", "https:"].includes(url.protocol) && !url.username && !url.password) {
|
| 253 |
+
cleanElement.href = url.href;
|
| 254 |
+
cleanElement.target = "_blank";
|
| 255 |
+
cleanElement.rel = "noopener noreferrer";
|
| 256 |
+
}
|
| 257 |
+
} catch (_) {
|
| 258 |
+
// Invalid links keep their label but receive no navigable href.
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
targetNode.appendChild(cleanElement);
|
| 262 |
+
copyNodes(node, cleanElement);
|
| 263 |
+
}
|
| 264 |
+
};
|
| 265 |
+
|
| 266 |
+
copyNodes(source.body, fragment);
|
| 267 |
+
return fragment;
|
| 268 |
}
|
| 269 |
|
| 270 |
/**
|
| 271 |
* 刷新顶部广告横幅(重新加载配置并更新DOM)
|
| 272 |
*/
|
| 273 |
export async function refreshBanner() {
|
| 274 |
+
// 手动刷新也保留当前缓存横幅,直到新配置和图片都准备好。
|
| 275 |
+
await syncBannerInBackground();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
}
|
| 277 |
|
| 278 |
/**
|
|
|
|
| 318 |
boxShadow: "0 2px 4px rgba(0,0,0,0.3)",
|
| 319 |
transition: "0.2s"
|
| 320 |
});
|
| 321 |
+
backBtn.textContent = `⬅ ${t("common.back")}`;
|
| 322 |
backBtn.onmouseover = () => {
|
| 323 |
backBtn.style.background = "#4CAF50";
|
| 324 |
backBtn.style.borderColor = "#4CAF50";
|
|
|
|
| 332 |
};
|
| 333 |
|
| 334 |
const pageTitle = document.createElement("span");
|
| 335 |
+
pageTitle.textContent = activeConfig.title || t("common.advertisement_details");
|
| 336 |
Object.assign(pageTitle.style, {
|
| 337 |
marginLeft: "12px",
|
| 338 |
fontSize: "16px",
|
|
|
|
| 385 |
if (activeConfig.detailImage) {
|
| 386 |
const detailImg = document.createElement("img");
|
| 387 |
detailImg.src = activeConfig.detailImage;
|
| 388 |
+
detailImg.alt = activeConfig.title || t("common.advertisement_details_image");
|
| 389 |
Object.assign(detailImg.style, {
|
| 390 |
maxWidth: "100%",
|
| 391 |
borderRadius: "8px"
|
|
|
|
| 409 |
wordWrap: "break-word"
|
| 410 |
});
|
| 411 |
// 支持HTML内容
|
| 412 |
+
detailEl.replaceChildren(sanitizeBannerDetailContent(activeConfig.detailContent));
|
| 413 |
content.appendChild(detailEl);
|
| 414 |
}
|
| 415 |
|
前端页面/core/侧边栏主程序.js
CHANGED
|
@@ -11,16 +11,17 @@ import { createPublishTaskView } from "../task/发布任务组件.js"; // 🎯
|
|
| 11 |
import { createPublishPostView } from "../post/发布帖子组件.js"; // 🎯 新增:帖子发布
|
| 12 |
import { createPublishPromptView } from "../prompt/发布提示词组件.js"; // 🎯 新增:提示词发布
|
| 13 |
import { createTopNav } from "../components/顶部导航组件.js";
|
| 14 |
-
import { loadSidebarContent } from "./侧边栏数据引擎.js";
|
| 15 |
import { createItemDetailView } from "../market/资源详情页面组件.js";
|
| 16 |
import { showToast } from "../components/UI交互提示组件.js";
|
| 17 |
-
import { api } from "./网络请求API.js"; // 🔴 编辑模式需调用详情API获取完整数据
|
| 18 |
-
import { CACHE, getBackgroundKey, IS_WEB_MODE } from "./全局配置.js";
|
| 19 |
import { debounce } from "../components/性能优化工具.js";
|
|
|
|
| 20 |
import { cleanupImageSandbox } from "../components/图片沙盒组件.js"; // 🔧 P3优化:导入清理函数
|
| 21 |
import { getVersionConfig, formatVersionString } from "../components/关于插件组件.js"; // 🏷️ 动态版本号
|
| 22 |
// 🎯 P2 用户体验增强
|
| 23 |
-
import { initUXEnhancements, t } from "../components/用户体验增强.js";
|
| 24 |
|
| 25 |
// 初始化 UX 增强
|
| 26 |
try {
|
|
@@ -29,16 +30,14 @@ try {
|
|
| 29 |
console.warn('🎯 UX 增强初始化失败:', e);
|
| 30 |
}
|
| 31 |
|
| 32 |
-
//
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
style.textContent = '.hub-tab-btn::before { content: var(--tab-label); }';
|
| 41 |
-
document.head.appendChild(style);
|
| 42 |
})();
|
| 43 |
|
| 44 |
// 🚫 筛选框选项防翻译:原生 <option> 不支持伪元素,无法用上面那套方案,
|
|
@@ -72,7 +71,18 @@ const BackgroundStore = {
|
|
| 72 |
|
| 73 |
// 🔧 P1修复:window 监听注册表(语言切换会重建 buildSidebarDOM,需先移除旧监听防止累积泄漏)
|
| 74 |
let _sidebarWindowListeners = [];
|
|
|
|
|
|
|
|
|
|
| 75 |
function _cleanupSidebarWindowListeners() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
for (const { event, handler } of _sidebarWindowListeners) {
|
| 77 |
window.removeEventListener(event, handler);
|
| 78 |
}
|
|
@@ -88,6 +98,7 @@ export function buildSidebarDOM() {
|
|
| 88 |
_cleanupSidebarWindowListeners();
|
| 89 |
|
| 90 |
const container = document.createElement("div");
|
|
|
|
| 91 |
|
| 92 |
// 加载本地背景图
|
| 93 |
const savedBg = BackgroundStore.load();
|
|
@@ -95,16 +106,17 @@ export function buildSidebarDOM() {
|
|
| 95 |
? `background-image: url(${savedBg}); background-size: cover; background-position: center;`
|
| 96 |
: `background-color: var(--comfy-menu-bg);`;
|
| 97 |
|
| 98 |
-
Object.assign(container.style, {
|
| 99 |
-
display: "flex", flexDirection: "column", height: "100%", width: "100%",
|
| 100 |
-
color: "var(--fg-color, #fff)", fontFamily: "sans-serif"
|
| 101 |
-
});
|
| 102 |
container.style.cssText += bgStyle;
|
| 103 |
|
| 104 |
const topNav = createTopNav();
|
|
|
|
| 105 |
|
| 106 |
const tabsContainer = document.createElement("div");
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
// 🌐 多语言支持:Tab 名称使用翻译函数
|
| 109 |
const tabs = [
|
| 110 |
{ id: "tools", label: t('nav.tools') },
|
|
@@ -118,10 +130,10 @@ export function buildSidebarDOM() {
|
|
| 118 |
];
|
| 119 |
|
| 120 |
const sortContainer = document.createElement("div");
|
| 121 |
-
|
| 122 |
sortContainer.innerHTML = `
|
| 123 |
<!-- 通用排序选择框(工具/应用/推荐/创作者) -->
|
| 124 |
-
<select id="hub-sort-select"
|
| 125 |
<option value="time">${noTr(t('market.latest'))}</option>
|
| 126 |
<option value="downloads">${noTr(t('market.downloads'))}</option>
|
| 127 |
<option value="likes">${noTr(t('market.like'))}</option>
|
|
@@ -132,7 +144,7 @@ export function buildSidebarDOM() {
|
|
| 132 |
<option value="rating">${noTr(t('market.rating'))}</option>
|
| 133 |
</select>
|
| 134 |
<!-- 任务榜筛选控件(状态+排序) -->
|
| 135 |
-
<select id="task-status-filter"
|
| 136 |
<option value="">${noTr(t('task.filter_all'))}</option>
|
| 137 |
<option value="open">${noTr(t('task.filter_open'))}</option>
|
| 138 |
<option value="in_progress">${noTr(t('task.filter_in_progress'))}</option>
|
|
@@ -140,7 +152,7 @@ export function buildSidebarDOM() {
|
|
| 140 |
<option value="completed">${noTr(t('task.filter_completed'))}</option>
|
| 141 |
<option value="disputed">${noTr(t('task.filter_disputed'))}</option>
|
| 142 |
</select>
|
| 143 |
-
<select id="task-sort-select"
|
| 144 |
<option value="latest">${noTr(t('task.sort_latest'))}</option>
|
| 145 |
<option value="price">${noTr(t('task.sort_price'))}</option>
|
| 146 |
<option value="deadline">${noTr(t('task.sort_deadline'))}</option>
|
|
@@ -150,7 +162,7 @@ export function buildSidebarDOM() {
|
|
| 150 |
<option value="favorites">${noTr(t('task.sort_favorites'))}</option>
|
| 151 |
</select>
|
| 152 |
<!-- 🎯 讨论区排序控件(专用) -->
|
| 153 |
-
<select id="posts-sort-select"
|
| 154 |
<option value="latest">${noTr(t('post.sort_latest'))}</option>
|
| 155 |
<option value="likes">${noTr(t('post.sort_likes'))}</option>
|
| 156 |
<option value="favorites">${noTr(t('post.sort_favorites'))}</option>
|
|
@@ -160,17 +172,29 @@ export function buildSidebarDOM() {
|
|
| 160 |
<option value="rating">${noTr(t('post.sort_rating'))}</option>
|
| 161 |
</select>
|
| 162 |
<!-- 🧩 提示词排序控件(专用) -->
|
| 163 |
-
<select id="prompts-sort-select"
|
| 164 |
<option value="latest">${noTr(t('prompt.sort_latest'))}</option>
|
| 165 |
<option value="likes">${noTr(t('prompt.sort_likes'))}</option>
|
| 166 |
<option value="favorites">${noTr(t('prompt.sort_favorites'))}</option>
|
| 167 |
<option value="views">${noTr(t('prompt.sort_views'))}</option>
|
| 168 |
<option value="daily_views">${noTr(t('prompt.sort_daily_views'))}</option>
|
| 169 |
</select>
|
| 170 |
-
<input type="
|
| 171 |
-
<button id="btn-open-publish"
|
| 172 |
`;
|
| 173 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
// 📱 Web 模式:隐藏发布入口(网页版不支持发布族功能,编辑路由监听处另有中央兜底)
|
| 175 |
if (IS_WEB_MODE) {
|
| 176 |
const publishBtn = sortContainer.querySelector("#btn-open-publish");
|
|
@@ -178,43 +202,34 @@ export function buildSidebarDOM() {
|
|
| 178 |
}
|
| 179 |
|
| 180 |
const contentBoxWrapper = document.createElement("div");
|
| 181 |
-
|
| 182 |
|
| 183 |
const contentArea = document.createElement("div");
|
| 184 |
contentArea.className = "sidebar-scroll-container";
|
|
|
|
|
|
|
|
|
|
| 185 |
// 🔧 自适应高度:flex 填充剩余空间,内容少时随内容收缩,避免容器框与底部页脚间出现空白
|
| 186 |
-
Object.assign(contentArea.style, {
|
| 187 |
-
flex: "1 1 auto", minHeight: "0", overflowY: "auto", padding: "10px",
|
| 188 |
-
backgroundColor: "#1c1c1c", border: "1px solid #444", borderRadius: "8px",
|
| 189 |
-
boxShadow: "inset 0 4px 10px rgba(0,0,0,0.3)"
|
| 190 |
-
});
|
| 191 |
contentBoxWrapper.appendChild(contentArea);
|
| 192 |
|
| 193 |
// =========================================================================
|
| 194 |
// 【新增】底部专业版权与合作伙伴信息区域 (Footer)
|
| 195 |
// =========================================================================
|
| 196 |
const footerContainer = document.createElement("div");
|
| 197 |
-
|
| 198 |
-
padding: "15px 10px 20px",
|
| 199 |
-
textAlign: "center",
|
| 200 |
-
fontSize: "12px",
|
| 201 |
-
color: "#666",
|
| 202 |
-
flexShrink: "0",
|
| 203 |
-
background: "transparent"
|
| 204 |
-
});
|
| 205 |
|
| 206 |
footerContainer.innerHTML = `
|
| 207 |
-
<div
|
| 208 |
-
<a href="https://github.com/a63976659/ComfyUI-Ranking" target="_blank"
|
| 209 |
-
<a href="https://github.com/a63976659" target="_blank"
|
| 210 |
-
<a href="https://huggingface.co/ZHIWEI666" target="_blank"
|
| 211 |
-
<a href="#" target="_blank"
|
| 212 |
-
<a href="https://www.bilibili.com/video/BV1x4XzBXEk9" target="_blank"
|
| 213 |
</div>
|
| 214 |
-
<div
|
| 215 |
-
<span>MIT License Copyright (c) 2026 <a href="#" id="easter-egg-trigger"
|
| 216 |
-
<span
|
| 217 |
-
<span class="footer-version-label">
|
| 218 |
</div>
|
| 219 |
`;
|
| 220 |
|
|
@@ -223,7 +238,14 @@ export function buildSidebarDOM() {
|
|
| 223 |
if (easterEggLink) {
|
| 224 |
easterEggLink.addEventListener('click', (e) => {
|
| 225 |
e.preventDefault();
|
| 226 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
});
|
| 228 |
}
|
| 229 |
|
|
@@ -235,7 +257,7 @@ export function buildSidebarDOM() {
|
|
| 235 |
}
|
| 236 |
}).catch(() => {
|
| 237 |
const versionLabel = footerContainer.querySelector('.footer-version-label');
|
| 238 |
-
if (versionLabel) versionLabel.textContent =
|
| 239 |
});
|
| 240 |
|
| 241 |
// 监听从设置页面触发的背景更新事件
|
|
@@ -253,13 +275,26 @@ export function buildSidebarDOM() {
|
|
| 253 |
});
|
| 254 |
// =========================================================================
|
| 255 |
|
| 256 |
-
let activeInlineView = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
|
| 258 |
const showInlineView = (viewDOM) => {
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
activeInlineView.remove();
|
| 262 |
-
}
|
| 263 |
tabsContainer.style.display = "none";
|
| 264 |
sortContainer.style.display = "none";
|
| 265 |
contentBoxWrapper.style.display = "none";
|
|
@@ -269,14 +304,8 @@ export function buildSidebarDOM() {
|
|
| 269 |
};
|
| 270 |
|
| 271 |
const hideInlineView = () => {
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
if (activeInlineView._cleanup) activeInlineView._cleanup();
|
| 275 |
-
cleanupImageSandbox(activeInlineView);
|
| 276 |
-
|
| 277 |
-
activeInlineView.remove();
|
| 278 |
-
activeInlineView = null;
|
| 279 |
-
}
|
| 280 |
tabsContainer.style.display = "flex";
|
| 281 |
sortContainer.style.display = "flex";
|
| 282 |
contentBoxWrapper.style.display = "flex";
|
|
@@ -303,6 +332,8 @@ export function buildSidebarDOM() {
|
|
| 303 |
// 📱 Web 模式中央兜底:发布族功能仅本地 ComfyUI 可用
|
| 304 |
if (IS_WEB_MODE) return showToast(t('web.publish_not_supported'), "warning");
|
| 305 |
const { itemData, currentUser } = e.detail;
|
|
|
|
|
|
|
| 306 |
|
| 307 |
// 先展示加载状态,避免用户感知延迟
|
| 308 |
const loadingView = document.createElement("div");
|
|
@@ -310,17 +341,23 @@ export function buildSidebarDOM() {
|
|
| 310 |
loadingView.innerHTML = `<div style=\"font-size:24px;margin-bottom:10px;\">⏳</div><div>${t('common.loading')}</div>`;
|
| 311 |
showInlineView(loadingView);
|
| 312 |
|
| 313 |
-
|
| 314 |
-
|
|
|
|
| 315 |
try {
|
| 316 |
-
const detailRes = await api.getItemById(itemData.id);
|
| 317 |
-
if (
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
}
|
|
|
|
| 324 |
|
| 325 |
const publishView = createPublishView(currentUser,
|
| 326 |
() => hideInlineView(),
|
|
@@ -360,27 +397,31 @@ export function buildSidebarDOM() {
|
|
| 360 |
|
| 361 |
// 🔔 通知跳转:切换到对应Tab并展开指定卡片
|
| 362 |
_onSidebarWindow("comfy-route-to-item", async (e) => {
|
| 363 |
-
const
|
| 364 |
-
if (!
|
|
|
|
|
|
|
|
|
|
| 365 |
|
| 366 |
// 1. 根据 itemType 确定 Tab
|
| 367 |
let targetTab;
|
| 368 |
if (itemType === "tool") targetTab = "tools";
|
| 369 |
else if (itemType === "skill") targetTab = "skills";
|
| 370 |
else if (itemType === "app") targetTab = "apps";
|
| 371 |
-
else if (itemType.startsWith("recommend")) targetTab = "recommends";
|
| 372 |
else targetTab = "tools"; // 兜底
|
| 373 |
|
| 374 |
// 【问题2修复】如果已经在目标 Tab,先尝试在当前 DOM 中查找卡片
|
| 375 |
if (currentTab === targetTab) {
|
| 376 |
-
const existingCard = contentArea.
|
|
|
|
| 377 |
if (existingCard) {
|
| 378 |
// 关闭可能存在的通知中心等全屏视图
|
| 379 |
if (activeInlineView) hideInlineView();
|
| 380 |
|
| 381 |
const summary = existingCard.querySelector('.item-summary') || existingCard.firstElementChild;
|
| 382 |
// 先确保卡片是折叠状态再展开(避免点击已展开的卡片把它收起来)
|
| 383 |
-
|
| 384 |
// 检查详情区域是否已展开
|
| 385 |
const detailView = existingCard.children[1]; // detailView 是第二个子元素
|
| 386 |
if (detailView && detailView.style.display === 'none' && summary) {
|
|
@@ -395,23 +436,8 @@ export function buildSidebarDOM() {
|
|
| 395 |
currentTab = targetTab;
|
| 396 |
Store.save("activeTab", currentTab);
|
| 397 |
|
| 398 |
-
// 3.
|
| 399 |
-
|
| 400 |
-
tabBtns.forEach((btn, index) => {
|
| 401 |
-
const tabId = tabs[index].id;
|
| 402 |
-
const tabColor = tabColors[tabId];
|
| 403 |
-
if (tabId === currentTab) {
|
| 404 |
-
btn.style.background = "rgba(0,0,0,0.3)";
|
| 405 |
-
btn.style.color = tabColor.active;
|
| 406 |
-
btn.style.borderBottom = `2px solid ${tabColor.active}`;
|
| 407 |
-
btn.style.textShadow = getTextShadow(tabColor.active, true);
|
| 408 |
-
} else {
|
| 409 |
-
btn.style.background = "rgba(0,0,0,0.15)";
|
| 410 |
-
btn.style.color = tabColor.inactive;
|
| 411 |
-
btn.style.borderBottom = "none";
|
| 412 |
-
btn.style.textShadow = getTextShadow(tabColor.active, false);
|
| 413 |
-
}
|
| 414 |
-
});
|
| 415 |
|
| 416 |
// 4. 更新筛选控件可见性
|
| 417 |
updateFilterVisibility(currentTab);
|
|
@@ -430,15 +456,33 @@ export function buildSidebarDOM() {
|
|
| 430 |
currentUser: topNav.getCurrentUser(),
|
| 431 |
renderToken: currentRenderToken,
|
| 432 |
getRenderToken: getRenderToken,
|
|
|
|
| 433 |
force: false,
|
| 434 |
expandItemId: itemId // 新增:要展开的卡片ID
|
| 435 |
});
|
| 436 |
});
|
| 437 |
|
| 438 |
-
|
|
|
|
| 439 |
let currentSort = Store.load("activeSort", "time");
|
| 440 |
let currentRenderToken = 0;
|
| 441 |
const getRenderToken = () => currentRenderToken;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
|
| 443 |
const triggerLoad = (forceRefresh = false) => {
|
| 444 |
currentRenderToken++;
|
|
@@ -447,6 +491,7 @@ export function buildSidebarDOM() {
|
|
| 447 |
keyword: sortContainer.querySelector("#hub-search-input").value.trim().toLowerCase(),
|
| 448 |
contentArea: contentArea, currentUser: topNav.getCurrentUser(),
|
| 449 |
renderToken: currentRenderToken, getRenderToken: getRenderToken,
|
|
|
|
| 450 |
force: forceRefresh
|
| 451 |
});
|
| 452 |
};
|
|
@@ -461,7 +506,7 @@ export function buildSidebarDOM() {
|
|
| 461 |
|
| 462 |
sortContainer.querySelector("#btn-open-publish").onclick = () => {
|
| 463 |
const currentUser = topNav.getCurrentUser();
|
| 464 |
-
if (!currentUser) return showToast(
|
| 465 |
|
| 466 |
// 🎯 根据当前Tab打开对应的发布界面
|
| 467 |
if (currentTab === "tasks") {
|
|
@@ -488,69 +533,40 @@ export function buildSidebarDOM() {
|
|
| 488 |
}
|
| 489 |
};
|
| 490 |
|
| 491 |
-
// Tab 颜色配置:按色环渐变顺序排列(绿→青→蓝→紫→粉→红→橙→黄),相邻 Tab 色相平滑过渡
|
| 492 |
-
const tabColors = {
|
| 493 |
-
tools: { active: "#4CAF50", inactive: "#81C784" }, // 插件榜 - 绿(渐变起点)
|
| 494 |
-
skills: { active: "#00BCD4", inactive: "#4DD0E1" }, // Skill - 青
|
| 495 |
-
apps: { active: "#2196F3", inactive: "#64B5F6" }, // 工作流 - 蓝
|
| 496 |
-
recommends: { active: "#7C4DFF", inactive: "#B388FF" }, // 推荐榜 - 紫
|
| 497 |
-
creators: { active: "#E91E63", inactive: "#F06292" }, // 创作者 - 粉
|
| 498 |
-
tasks: { active: "#F44336", inactive: "#E57373" }, // 任务榜 - 红
|
| 499 |
-
posts: { active: "#FF9800", inactive: "#FFB74D" }, // 讨论区 - 橙
|
| 500 |
-
prompts: { active: "#FFC107", inactive: "#FFD54F" } // 提示词 - 黄(渐变终点)
|
| 501 |
-
};
|
| 502 |
-
|
| 503 |
-
// 文字阴影效果:多层阴影确保任何背景下都清晰可见
|
| 504 |
-
const getTextShadow = (color, isActive) => {
|
| 505 |
-
const baseShadow = "0 1px 2px rgba(0,0,0,0.8), 0 0 4px rgba(0,0,0,0.6), 1px 1px 3px rgba(0,0,0,0.5)";
|
| 506 |
-
return isActive
|
| 507 |
-
? `${baseShadow}, 0 0 10px ${color}, 0 0 20px ${color}`
|
| 508 |
-
: baseShadow;
|
| 509 |
-
};
|
| 510 |
-
|
| 511 |
tabs.forEach(tab => {
|
| 512 |
const btn = document.createElement("button");
|
| 513 |
-
//
|
| 514 |
-
// 伪元素内容不参与可访问名计算,用 aria-label 补回无障碍名称
|
| 515 |
btn.className = "hub-tab-btn";
|
| 516 |
-
btn.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 517 |
btn.setAttribute("aria-label", tab.label);
|
| 518 |
-
const isActive = currentTab === tab.id;
|
| 519 |
-
const color = tabColors[tab.id];
|
| 520 |
-
Object.assign(btn.style, {
|
| 521 |
-
flex: "1",
|
| 522 |
-
padding: "8px",
|
| 523 |
-
cursor: "pointer",
|
| 524 |
-
background: isActive ? "rgba(0,0,0,0.3)" : "rgba(0,0,0,0.15)",
|
| 525 |
-
color: isActive ? color.active : color.inactive,
|
| 526 |
-
border: "none",
|
| 527 |
-
borderBottom: isActive ? `2px solid ${color.active}` : "none",
|
| 528 |
-
fontWeight: "bold",
|
| 529 |
-
textShadow: getTextShadow(color.active, isActive),
|
| 530 |
-
transition: "all 0.2s ease",
|
| 531 |
-
fontSize: "13px"
|
| 532 |
-
});
|
| 533 |
btn.onclick = () => {
|
| 534 |
currentTab = tab.id; Store.save("activeTab", currentTab);
|
| 535 |
-
Array.from(tabsContainer.children).forEach((c, i) => {
|
| 536 |
-
const tabId = tabs[i].id;
|
| 537 |
-
const tabColor = tabColors[tabId];
|
| 538 |
-
c.style.background = "rgba(0,0,0,0.15)";
|
| 539 |
-
c.style.color = tabColor.inactive;
|
| 540 |
-
c.style.borderBottom = "none";
|
| 541 |
-
c.style.textShadow = getTextShadow(tabColor.active, false);
|
| 542 |
-
});
|
| 543 |
-
btn.style.background = "rgba(0,0,0,0.3)";
|
| 544 |
-
btn.style.color = color.active;
|
| 545 |
-
btn.style.borderBottom = `2px solid ${color.active}`;
|
| 546 |
-
btn.style.textShadow = getTextShadow(color.active, true);
|
| 547 |
sortContainer.querySelector("#hub-search-input").value = "";
|
| 548 |
// 🎯 根据 Tab 切换筛选控件显示
|
| 549 |
updateFilterVisibility(currentTab);
|
|
|
|
| 550 |
triggerLoad();
|
| 551 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 552 |
tabsContainer.appendChild(btn);
|
| 553 |
});
|
|
|
|
| 554 |
|
| 555 |
// 🎯 筛选控件显示切换函数
|
| 556 |
const updateFilterVisibility = (tabId) => {
|
|
|
|
| 11 |
import { createPublishPostView } from "../post/发布帖子组件.js"; // 🎯 新增:帖子发布
|
| 12 |
import { createPublishPromptView } from "../prompt/发布提示词组件.js"; // 🎯 新增:提示词发布
|
| 13 |
import { createTopNav } from "../components/顶部导航组件.js";
|
| 14 |
+
import { loadSidebarContent, disposeSidebarContent } from "./侧边栏数据引擎.js";
|
| 15 |
import { createItemDetailView } from "../market/资源详情页面组件.js";
|
| 16 |
import { showToast } from "../components/UI交互提示组件.js";
|
| 17 |
+
import { api, captureRequestSession } from "./网络请求API.js"; // 🔴 编辑模式需调用详情API获取完整数据
|
| 18 |
+
import { CACHE, UI, getBackgroundKey, IS_WEB_MODE } from "./全局配置.js";
|
| 19 |
import { debounce } from "../components/性能优化工具.js";
|
| 20 |
+
import { scrollCardIntoView } from "../components/动画音效引擎.js";
|
| 21 |
import { cleanupImageSandbox } from "../components/图片沙盒组件.js"; // 🔧 P3优化:导入清理函数
|
| 22 |
import { getVersionConfig, formatVersionString } from "../components/关于插件组件.js"; // 🏷️ 动态版本号
|
| 23 |
// 🎯 P2 用户体验增强
|
| 24 |
+
import { initUXEnhancements, t, getLanguage } from "../components/用户体验增强.js";
|
| 25 |
|
| 26 |
// 初始化 UX 增强
|
| 27 |
try {
|
|
|
|
| 30 |
console.warn('🎯 UX 增强初始化失败:', e);
|
| 31 |
}
|
| 32 |
|
| 33 |
+
// 统一设计 tokens 与键盘焦点样式;样式表只挂载一次,保持 ComfyUI 主题变量适配。
|
| 34 |
+
(function _injectSharedThemeStyles() {
|
| 35 |
+
if (document.getElementById('hub-theme-style')) return;
|
| 36 |
+
const link = document.createElement('link');
|
| 37 |
+
link.id = 'hub-theme-style';
|
| 38 |
+
link.rel = 'stylesheet';
|
| 39 |
+
link.href = new URL('../styles/主题基础.css', import.meta.url).href;
|
| 40 |
+
document.head.appendChild(link);
|
|
|
|
|
|
|
| 41 |
})();
|
| 42 |
|
| 43 |
// 🚫 筛选框选项防翻译:原生 <option> 不支持伪元素,无法用上面那套方案,
|
|
|
|
| 71 |
|
| 72 |
// 🔧 P1修复:window 监听注册表(语言切换会重建 buildSidebarDOM,需先移除旧监听防止累积泄漏)
|
| 73 |
let _sidebarWindowListeners = [];
|
| 74 |
+
let _sidebarInstanceSequence = 0;
|
| 75 |
+
let _sidebarInlineViewCleanup = null;
|
| 76 |
+
let _sidebarListCleanup = null;
|
| 77 |
function _cleanupSidebarWindowListeners() {
|
| 78 |
+
const cleanupList = _sidebarListCleanup;
|
| 79 |
+
const cleanupInline = _sidebarInlineViewCleanup;
|
| 80 |
+
_sidebarListCleanup = null;
|
| 81 |
+
_sidebarInlineViewCleanup = null;
|
| 82 |
+
for (const cleanup of [cleanupList, cleanupInline]) {
|
| 83 |
+
try { cleanup?.(); }
|
| 84 |
+
catch { console.warn('Sidebar cleanup failed'); }
|
| 85 |
+
}
|
| 86 |
for (const { event, handler } of _sidebarWindowListeners) {
|
| 87 |
window.removeEventListener(event, handler);
|
| 88 |
}
|
|
|
|
| 98 |
_cleanupSidebarWindowListeners();
|
| 99 |
|
| 100 |
const container = document.createElement("div");
|
| 101 |
+
container.className = "comfy-ranking-root";
|
| 102 |
|
| 103 |
// 加载本地背景图
|
| 104 |
const savedBg = BackgroundStore.load();
|
|
|
|
| 106 |
? `background-image: url(${savedBg}); background-size: cover; background-position: center;`
|
| 107 |
: `background-color: var(--comfy-menu-bg);`;
|
| 108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
container.style.cssText += bgStyle;
|
| 110 |
|
| 111 |
const topNav = createTopNav();
|
| 112 |
+
const sidebarInstanceId = `comfy-ranking-${++_sidebarInstanceSequence}`;
|
| 113 |
|
| 114 |
const tabsContainer = document.createElement("div");
|
| 115 |
+
tabsContainer.id = `${sidebarInstanceId}-tablist`;
|
| 116 |
+
tabsContainer.className = "hub-tablist";
|
| 117 |
+
tabsContainer.setAttribute("role", "tablist");
|
| 118 |
+
tabsContainer.setAttribute("aria-label", t("nav.board_navigation"));
|
| 119 |
+
tabsContainer.setAttribute("aria-orientation", "horizontal");
|
| 120 |
// 🌐 多语言支持:Tab 名称使用翻译函数
|
| 121 |
const tabs = [
|
| 122 |
{ id: "tools", label: t('nav.tools') },
|
|
|
|
| 130 |
];
|
| 131 |
|
| 132 |
const sortContainer = document.createElement("div");
|
| 133 |
+
sortContainer.className = "hub-controls";
|
| 134 |
sortContainer.innerHTML = `
|
| 135 |
<!-- 通用排序选择框(工具/应用/推荐/创作者) -->
|
| 136 |
+
<select id="hub-sort-select" class="hub-control-select hub-control-select--wide">
|
| 137 |
<option value="time">${noTr(t('market.latest'))}</option>
|
| 138 |
<option value="downloads">${noTr(t('market.downloads'))}</option>
|
| 139 |
<option value="likes">${noTr(t('market.like'))}</option>
|
|
|
|
| 144 |
<option value="rating">${noTr(t('market.rating'))}</option>
|
| 145 |
</select>
|
| 146 |
<!-- 任务榜筛选控件(状态+排序) -->
|
| 147 |
+
<select id="task-status-filter" class="hub-control-select hub-control-select--compact" style="display: none;">
|
| 148 |
<option value="">${noTr(t('task.filter_all'))}</option>
|
| 149 |
<option value="open">${noTr(t('task.filter_open'))}</option>
|
| 150 |
<option value="in_progress">${noTr(t('task.filter_in_progress'))}</option>
|
|
|
|
| 152 |
<option value="completed">${noTr(t('task.filter_completed'))}</option>
|
| 153 |
<option value="disputed">${noTr(t('task.filter_disputed'))}</option>
|
| 154 |
</select>
|
| 155 |
+
<select id="task-sort-select" class="hub-control-select hub-control-select--compact" style="display: none;">
|
| 156 |
<option value="latest">${noTr(t('task.sort_latest'))}</option>
|
| 157 |
<option value="price">${noTr(t('task.sort_price'))}</option>
|
| 158 |
<option value="deadline">${noTr(t('task.sort_deadline'))}</option>
|
|
|
|
| 162 |
<option value="favorites">${noTr(t('task.sort_favorites'))}</option>
|
| 163 |
</select>
|
| 164 |
<!-- 🎯 讨论区排序控件(专用) -->
|
| 165 |
+
<select id="posts-sort-select" class="hub-control-select hub-control-select--wide" style="display: none;">
|
| 166 |
<option value="latest">${noTr(t('post.sort_latest'))}</option>
|
| 167 |
<option value="likes">${noTr(t('post.sort_likes'))}</option>
|
| 168 |
<option value="favorites">${noTr(t('post.sort_favorites'))}</option>
|
|
|
|
| 172 |
<option value="rating">${noTr(t('post.sort_rating'))}</option>
|
| 173 |
</select>
|
| 174 |
<!-- 🧩 提示词排序控件(专用) -->
|
| 175 |
+
<select id="prompts-sort-select" class="hub-control-select hub-control-select--wide" style="display: none;">
|
| 176 |
<option value="latest">${noTr(t('prompt.sort_latest'))}</option>
|
| 177 |
<option value="likes">${noTr(t('prompt.sort_likes'))}</option>
|
| 178 |
<option value="favorites">${noTr(t('prompt.sort_favorites'))}</option>
|
| 179 |
<option value="views">${noTr(t('prompt.sort_views'))}</option>
|
| 180 |
<option value="daily_views">${noTr(t('prompt.sort_daily_views'))}</option>
|
| 181 |
</select>
|
| 182 |
+
<input type="search" id="hub-search-input" class="hub-search-input" autocomplete="off" placeholder="🔍 ${t('common.search')}...">
|
| 183 |
+
<button id="btn-open-publish" class="hub-publish-button" type="button">➕ ${t('market.publish')}</button>
|
| 184 |
`;
|
| 185 |
|
| 186 |
+
const filterLabels = {
|
| 187 |
+
"hub-sort-select": "common.sort_items",
|
| 188 |
+
"task-status-filter": "common.filter_task_status",
|
| 189 |
+
"task-sort-select": "common.sort_tasks",
|
| 190 |
+
"posts-sort-select": "common.sort_posts",
|
| 191 |
+
"prompts-sort-select": "common.sort_prompts",
|
| 192 |
+
"hub-search-input": "common.search"
|
| 193 |
+
};
|
| 194 |
+
for (const [id, labelKey] of Object.entries(filterLabels)) {
|
| 195 |
+
sortContainer.querySelector(`#${id}`)?.setAttribute("aria-label", t(labelKey));
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
// 📱 Web 模式:隐藏发布入口(网页版不支持发布族功能,编辑路由监听处另有中央兜底)
|
| 199 |
if (IS_WEB_MODE) {
|
| 200 |
const publishBtn = sortContainer.querySelector("#btn-open-publish");
|
|
|
|
| 202 |
}
|
| 203 |
|
| 204 |
const contentBoxWrapper = document.createElement("div");
|
| 205 |
+
contentBoxWrapper.className = "sidebar-content-wrapper";
|
| 206 |
|
| 207 |
const contentArea = document.createElement("div");
|
| 208 |
contentArea.className = "sidebar-scroll-container";
|
| 209 |
+
contentArea.id = `${sidebarInstanceId}-tabpanel`;
|
| 210 |
+
contentArea.setAttribute("role", "tabpanel");
|
| 211 |
+
contentArea.setAttribute("tabindex", "0");
|
| 212 |
// 🔧 自适应高度:flex 填充剩余空间,内容少时随内容收缩,避免容器框与底部页脚间出现空白
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
contentBoxWrapper.appendChild(contentArea);
|
| 214 |
|
| 215 |
// =========================================================================
|
| 216 |
// 【新增】底部专业版权与合作伙伴信息区域 (Footer)
|
| 217 |
// =========================================================================
|
| 218 |
const footerContainer = document.createElement("div");
|
| 219 |
+
footerContainer.className = "sidebar-footer";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
|
| 221 |
footerContainer.innerHTML = `
|
| 222 |
+
<div class="sidebar-footer__links">
|
| 223 |
+
<a href="https://github.com/a63976659/ComfyUI-Ranking" target="_blank" rel="noopener noreferrer" class="sidebar-footer__link sidebar-footer__link--community">🌍 ${t('footer.community_name')}</a>
|
| 224 |
+
<a href="https://github.com/a63976659" target="_blank" rel="noopener noreferrer" class="sidebar-footer__link sidebar-footer__link--github">🐙 GitHub</a>
|
| 225 |
+
<a href="https://huggingface.co/ZHIWEI666" target="_blank" rel="noopener noreferrer" class="sidebar-footer__link sidebar-footer__link--huggingface">🤗 Hugging Face</a>
|
| 226 |
+
<a href="#" target="_blank" rel="noopener noreferrer" class="sidebar-footer__link sidebar-footer__link--company">🏢 砚影科技</a>
|
| 227 |
+
<a href="https://www.bilibili.com/video/BV1x4XzBXEk9" target="_blank" rel="noopener noreferrer" class="sidebar-footer__link sidebar-footer__link--bilibili">📺 Bilibili</a>
|
| 228 |
</div>
|
| 229 |
+
<div class="sidebar-footer__meta">
|
| 230 |
+
<span>MIT License Copyright (c) 2026 <a href="#" id="easter-egg-trigger" class="sidebar-footer__link sidebar-footer__link--easter-egg">猪的飞行梦</a></span>
|
| 231 |
+
<span class="sidebar-footer__separator" aria-hidden="true">|</span>
|
| 232 |
+
<span class="footer-version-label">${t('common.loading')}</span>
|
| 233 |
</div>
|
| 234 |
`;
|
| 235 |
|
|
|
|
| 238 |
if (easterEggLink) {
|
| 239 |
easterEggLink.addEventListener('click', (e) => {
|
| 240 |
e.preventDefault();
|
| 241 |
+
const popup = window.open('', '_blank');
|
| 242 |
+
if (!popup) return;
|
| 243 |
+
import('../components/彩蛋动画引擎.js').then(mod => mod.openEasterEggPage(popup, {
|
| 244 |
+
language: getLanguage(),
|
| 245 |
+
title: t('easter.title'),
|
| 246 |
+
hint: t('easter.hint'),
|
| 247 |
+
speedLabel: t('easter.speed')
|
| 248 |
+
})).catch(() => popup.close());
|
| 249 |
});
|
| 250 |
}
|
| 251 |
|
|
|
|
| 257 |
}
|
| 258 |
}).catch(() => {
|
| 259 |
const versionLabel = footerContainer.querySelector('.footer-version-label');
|
| 260 |
+
if (versionLabel) versionLabel.textContent = formatVersionString(UI.DEFAULT_PROJECT_VERSION);
|
| 261 |
});
|
| 262 |
|
| 263 |
// 监听从设置页面触发的背景更新事件
|
|
|
|
| 275 |
});
|
| 276 |
// =========================================================================
|
| 277 |
|
| 278 |
+
let activeInlineView = null;
|
| 279 |
+
let listNavigationGeneration = 0;
|
| 280 |
+
const captureListNavigation = () => {
|
| 281 |
+
const generation = listNavigationGeneration;
|
| 282 |
+
return () => !activeInlineView && generation === listNavigationGeneration;
|
| 283 |
+
};
|
| 284 |
+
const releaseInlineView = () => {
|
| 285 |
+
if (!activeInlineView) return;
|
| 286 |
+
const view = activeInlineView;
|
| 287 |
+
activeInlineView = null;
|
| 288 |
+
try { view._cleanup?.(); }
|
| 289 |
+
catch { console.warn('Sidebar inline view cleanup failed'); }
|
| 290 |
+
try { cleanupImageSandbox(view); }
|
| 291 |
+
finally { view.remove(); }
|
| 292 |
+
};
|
| 293 |
+
_sidebarInlineViewCleanup = releaseInlineView;
|
| 294 |
|
| 295 |
const showInlineView = (viewDOM) => {
|
| 296 |
+
listNavigationGeneration++;
|
| 297 |
+
releaseInlineView();
|
|
|
|
|
|
|
| 298 |
tabsContainer.style.display = "none";
|
| 299 |
sortContainer.style.display = "none";
|
| 300 |
contentBoxWrapper.style.display = "none";
|
|
|
|
| 304 |
};
|
| 305 |
|
| 306 |
const hideInlineView = () => {
|
| 307 |
+
listNavigationGeneration++;
|
| 308 |
+
releaseInlineView();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
tabsContainer.style.display = "flex";
|
| 310 |
sortContainer.style.display = "flex";
|
| 311 |
contentBoxWrapper.style.display = "flex";
|
|
|
|
| 332 |
// 📱 Web 模式中央兜底:发布族功能仅本地 ComfyUI 可用
|
| 333 |
if (IS_WEB_MODE) return showToast(t('web.publish_not_supported'), "warning");
|
| 334 |
const { itemData, currentUser } = e.detail;
|
| 335 |
+
const matchesSession = captureRequestSession();
|
| 336 |
+
if (!currentUser?.account || topNav.getCurrentUser()?.account !== currentUser.account) return;
|
| 337 |
|
| 338 |
// 先展示加载状态,避免用户感知延迟
|
| 339 |
const loadingView = document.createElement("div");
|
|
|
|
| 341 |
loadingView.innerHTML = `<div style=\"font-size:24px;margin-bottom:10px;\">⏳</div><div>${t('common.loading')}</div>`;
|
| 342 |
showInlineView(loadingView);
|
| 343 |
|
| 344 |
+
const isCurrent = () => matchesSession() && topNav.getCurrentUser()?.account === currentUser.account
|
| 345 |
+
&& activeInlineView === loadingView && loadingView.isConnected;
|
| 346 |
+
let enrichedItemData;
|
| 347 |
try {
|
| 348 |
+
const detailRes = await api.getItemById(itemData.id, { noStore: true });
|
| 349 |
+
if (!isCurrent()) return;
|
| 350 |
+
const data = detailRes?.data, revision = data?.edit_revision === undefined ? 0 : data.edit_revision;
|
| 351 |
+
if (detailRes?.status !== 'success' || data?.id !== itemData.id || data?.author !== currentUser.account
|
| 352 |
+
|| !Number.isSafeInteger(revision) || revision < 0) throw new Error('Invalid edit detail');
|
| 353 |
+
enrichedItemData = { ...data, edit_revision: revision };
|
| 354 |
+
} catch (_) {
|
| 355 |
+
if (!isCurrent()) return;
|
| 356 |
+
loadingView.textContent = t('common.operation_failed');
|
| 357 |
+
loadingView.setAttribute('role', 'alert');
|
| 358 |
+
return;
|
| 359 |
}
|
| 360 |
+
if (!isCurrent()) return;
|
| 361 |
|
| 362 |
const publishView = createPublishView(currentUser,
|
| 363 |
() => hideInlineView(),
|
|
|
|
| 397 |
|
| 398 |
// 🔔 通知跳转:切换到对应Tab并展开指定卡片
|
| 399 |
_onSidebarWindow("comfy-route-to-item", async (e) => {
|
| 400 |
+
const detail = e.detail;
|
| 401 |
+
if (!detail || typeof detail !== 'object' || Array.isArray(detail)) return;
|
| 402 |
+
const { itemId, itemType } = detail;
|
| 403 |
+
if (!(typeof itemId === 'string' && itemId.trim()) &&
|
| 404 |
+
!(Number.isSafeInteger(itemId) && itemId > 0)) return;
|
| 405 |
|
| 406 |
// 1. 根据 itemType 确定 Tab
|
| 407 |
let targetTab;
|
| 408 |
if (itemType === "tool") targetTab = "tools";
|
| 409 |
else if (itemType === "skill") targetTab = "skills";
|
| 410 |
else if (itemType === "app") targetTab = "apps";
|
| 411 |
+
else if (typeof itemType === 'string' && itemType.startsWith("recommend")) targetTab = "recommends";
|
| 412 |
else targetTab = "tools"; // 兜底
|
| 413 |
|
| 414 |
// 【问题2修复】如果已经在目标 Tab,先尝试在当前 DOM 中查找卡片
|
| 415 |
if (currentTab === targetTab) {
|
| 416 |
+
const existingCard = Array.from(contentArea.querySelectorAll('[data-item-id]'))
|
| 417 |
+
.find(card => card.getAttribute('data-item-id') === String(itemId));
|
| 418 |
if (existingCard) {
|
| 419 |
// 关闭可能存在的通知中心等全屏视图
|
| 420 |
if (activeInlineView) hideInlineView();
|
| 421 |
|
| 422 |
const summary = existingCard.querySelector('.item-summary') || existingCard.firstElementChild;
|
| 423 |
// 先确保卡片是折叠状态再展开(避免点击已展开的卡片把它收起来)
|
| 424 |
+
scrollCardIntoView(existingCard);
|
| 425 |
// 检查详情区域是否已展开
|
| 426 |
const detailView = existingCard.children[1]; // detailView 是第二个子元素
|
| 427 |
if (detailView && detailView.style.display === 'none' && summary) {
|
|
|
|
| 436 |
currentTab = targetTab;
|
| 437 |
Store.save("activeTab", currentTab);
|
| 438 |
|
| 439 |
+
// 3. 同步选中态;视觉状态由共享主题 CSS 根据 aria-selected 绘制
|
| 440 |
+
syncTabsA11y();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
|
| 442 |
// 4. 更新筛选控件可见性
|
| 443 |
updateFilterVisibility(currentTab);
|
|
|
|
| 456 |
currentUser: topNav.getCurrentUser(),
|
| 457 |
renderToken: currentRenderToken,
|
| 458 |
getRenderToken: getRenderToken,
|
| 459 |
+
isNavigationCurrent: captureListNavigation(),
|
| 460 |
force: false,
|
| 461 |
expandItemId: itemId // 新增:要展开的卡片ID
|
| 462 |
});
|
| 463 |
});
|
| 464 |
|
| 465 |
+
const savedTab = Store.load("activeTab", "tools");
|
| 466 |
+
let currentTab = tabs.some(tab => tab.id === savedTab) ? savedTab : tabs[0].id;
|
| 467 |
let currentSort = Store.load("activeSort", "time");
|
| 468 |
let currentRenderToken = 0;
|
| 469 |
const getRenderToken = () => currentRenderToken;
|
| 470 |
+
_sidebarListCleanup = () => {
|
| 471 |
+
currentRenderToken++;
|
| 472 |
+
try { disposeSidebarContent(contentArea); }
|
| 473 |
+
finally { cleanupImageSandbox(contentArea); }
|
| 474 |
+
};
|
| 475 |
+
const syncTabsA11y = () => {
|
| 476 |
+
const selectedTab = tabs.find(tab => tab.id === currentTab) || tabs[0];
|
| 477 |
+
for (const tab of tabs) {
|
| 478 |
+
const button = tabsContainer.querySelector(`[data-tab-id="${tab.id}"]`);
|
| 479 |
+
if (!button) continue;
|
| 480 |
+
const selected = tab.id === selectedTab.id;
|
| 481 |
+
button.setAttribute("aria-selected", String(selected));
|
| 482 |
+
button.tabIndex = selected ? 0 : -1;
|
| 483 |
+
}
|
| 484 |
+
contentArea.setAttribute("aria-labelledby", `${sidebarInstanceId}-tab-${selectedTab.id}`);
|
| 485 |
+
};
|
| 486 |
|
| 487 |
const triggerLoad = (forceRefresh = false) => {
|
| 488 |
currentRenderToken++;
|
|
|
|
| 491 |
keyword: sortContainer.querySelector("#hub-search-input").value.trim().toLowerCase(),
|
| 492 |
contentArea: contentArea, currentUser: topNav.getCurrentUser(),
|
| 493 |
renderToken: currentRenderToken, getRenderToken: getRenderToken,
|
| 494 |
+
isNavigationCurrent: captureListNavigation(),
|
| 495 |
force: forceRefresh
|
| 496 |
});
|
| 497 |
};
|
|
|
|
| 506 |
|
| 507 |
sortContainer.querySelector("#btn-open-publish").onclick = () => {
|
| 508 |
const currentUser = topNav.getCurrentUser();
|
| 509 |
+
if (!currentUser) return showToast(t('auth.login_required'), "warning");
|
| 510 |
|
| 511 |
// 🎯 根据当前Tab打开对应的发布界面
|
| 512 |
if (currentTab === "tasks") {
|
|
|
|
| 533 |
}
|
| 534 |
};
|
| 535 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 536 |
tabs.forEach(tab => {
|
| 537 |
const btn = document.createElement("button");
|
| 538 |
+
// 共享主题 CSS 从 aria-label 绘制伪元素文案,避免翻译插件改写标签。
|
|
|
|
| 539 |
btn.className = "hub-tab-btn";
|
| 540 |
+
btn.type = "button";
|
| 541 |
+
btn.id = `${sidebarInstanceId}-tab-${tab.id}`;
|
| 542 |
+
btn.dataset.tabId = tab.id;
|
| 543 |
+
btn.setAttribute("role", "tab");
|
| 544 |
+
btn.setAttribute("aria-controls", contentArea.id);
|
| 545 |
btn.setAttribute("aria-label", tab.label);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 546 |
btn.onclick = () => {
|
| 547 |
currentTab = tab.id; Store.save("activeTab", currentTab);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 548 |
sortContainer.querySelector("#hub-search-input").value = "";
|
| 549 |
// 🎯 根据 Tab 切换筛选控件显示
|
| 550 |
updateFilterVisibility(currentTab);
|
| 551 |
+
syncTabsA11y();
|
| 552 |
triggerLoad();
|
| 553 |
};
|
| 554 |
+
btn.addEventListener("keydown", (event) => {
|
| 555 |
+
const currentIndex = tabs.findIndex(item => item.id === tab.id);
|
| 556 |
+
let nextIndex = currentIndex;
|
| 557 |
+
if (event.key === "ArrowRight") nextIndex = (currentIndex + 1) % tabs.length;
|
| 558 |
+
else if (event.key === "ArrowLeft") nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
| 559 |
+
else if (event.key === "Home") nextIndex = 0;
|
| 560 |
+
else if (event.key === "End") nextIndex = tabs.length - 1;
|
| 561 |
+
else return;
|
| 562 |
+
event.preventDefault();
|
| 563 |
+
const nextButton = tabsContainer.querySelector(`[data-tab-id="${tabs[nextIndex].id}"]`);
|
| 564 |
+
nextButton?.focus();
|
| 565 |
+
nextButton?.click();
|
| 566 |
+
});
|
| 567 |
tabsContainer.appendChild(btn);
|
| 568 |
});
|
| 569 |
+
syncTabsA11y();
|
| 570 |
|
| 571 |
// 🎯 筛选控件显示切换函数
|
| 572 |
const updateFilterVisibility = (tabId) => {
|
前端页面/core/侧边栏入口注册.js
CHANGED
|
@@ -63,7 +63,16 @@ app.registerExtension({
|
|
| 63 |
container.appendChild(globalSidebarDOM);
|
| 64 |
} catch (e) {
|
| 65 |
console.error('侧边栏功能加载失败:', e);
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
}
|
| 68 |
}
|
| 69 |
});
|
|
|
|
| 63 |
container.appendChild(globalSidebarDOM);
|
| 64 |
} catch (e) {
|
| 65 |
console.error('侧边栏功能加载失败:', e);
|
| 66 |
+
const failure = document.createElement("div");
|
| 67 |
+
failure.setAttribute("role", "alert");
|
| 68 |
+
failure.style.cssText = "text-align:center; padding:40px; color:#F44336;";
|
| 69 |
+
try {
|
| 70 |
+
const { t } = await import("../components/用户体验增强.js");
|
| 71 |
+
failure.textContent = `⚠️ ${t('common.sidebar_load_failed')}`;
|
| 72 |
+
} catch {
|
| 73 |
+
failure.textContent = "⚠️";
|
| 74 |
+
}
|
| 75 |
+
container.replaceChildren(failure);
|
| 76 |
}
|
| 77 |
}
|
| 78 |
});
|
前端页面/core/侧边栏数据引擎.js
CHANGED
|
@@ -16,21 +16,24 @@
|
|
| 16 |
// - 增强缓存策略
|
| 17 |
// ==========================================
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
import { api } from "./网络请求API.js";
|
| 20 |
import { proxyImages } from "./网络请求API.js";
|
| 21 |
-
// 🔍 搜索时撤销上一次在途请求所需的取消管理器与分组 ID
|
| 22 |
-
import { requestCancelManager } from "./网络请求API.js";
|
| 23 |
-
import { API } from "./全局配置.js";
|
| 24 |
import { createItemCard } from "../market/列表卡片组件.js";
|
| 25 |
import { createCreatorCard } from "../market/创作者卡片组件.js";
|
| 26 |
-
import {
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
lazyLoadImages
|
| 32 |
} from "../components/性能优化工具.js";
|
| 33 |
-
import { applyViewportAnimations, getAnimationTypeForTab } from "../components/动画音效引擎.js";
|
| 34 |
import { t } from "../components/用户体验增强.js";
|
| 35 |
import { showToast } from "../components/UI交互提示组件.js"; // 🔧 降级提示改走统一组件(原为手写 div)
|
| 36 |
|
|
@@ -88,276 +91,90 @@ function getCacheExpireTime() {
|
|
| 88 |
}
|
| 89 |
|
| 90 |
// ==========================================
|
| 91 |
-
// 🔄
|
| 92 |
// ==========================================
|
| 93 |
-
let _lastSortInput = null;
|
| 94 |
-
let _lastSortKey = '';
|
| 95 |
-
let _lastSortResult = null;
|
| 96 |
-
|
| 97 |
-
function sortDataLocally(data, tab, sort) {
|
| 98 |
-
const sortKey = `${tab}_${sort}`;
|
| 99 |
-
// 如果源数据引用相同且排序参数相同,返回缓存结果(避免重复创建数组拷贝)
|
| 100 |
-
if (data === _lastSortInput && sortKey === _lastSortKey && _lastSortResult) {
|
| 101 |
-
return _lastSortResult;
|
| 102 |
-
}
|
| 103 |
-
|
| 104 |
-
const sorted = [...data]; // 不修改原数组
|
| 105 |
-
|
| 106 |
-
if (tab === "creators") {
|
| 107 |
-
// 创作者排序(与后端 get_creators 一致)
|
| 108 |
-
switch (sort) {
|
| 109 |
-
case "likes": sorted.sort((a, b) => (b.likes || 0) - (a.likes || 0)); break;
|
| 110 |
-
case "favorites": sorted.sort((a, b) => (b.favorites || 0) - (a.favorites || 0)); break;
|
| 111 |
-
case "downloads": sorted.sort((a, b) => (b.downloads || 0) - (a.downloads || 0)); break;
|
| 112 |
-
case "tips": sorted.sort((a, b) => (b.recent_tips || 0) - (a.recent_tips || 0)); break;
|
| 113 |
-
case "views": sorted.sort((a, b) => (b.views || 0) - (a.views || 0)); break;
|
| 114 |
-
case "daily_views": sorted.sort((a, b) => (b.daily_views || 0) - (a.daily_views || 0)); break;
|
| 115 |
-
default: sorted.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); break; // time
|
| 116 |
-
}
|
| 117 |
-
} else {
|
| 118 |
-
// 工具/Skill/应用/推荐排序(与后端 get_items 一致)
|
| 119 |
-
switch (sort) {
|
| 120 |
-
case "downloads": sorted.sort((a, b) => (b.uses || 0) - (a.uses || 0)); break;
|
| 121 |
-
case "likes": sorted.sort((a, b) => (b.likes || 0) - (a.likes || 0)); break;
|
| 122 |
-
case "favorites": sorted.sort((a, b) => (b.favorites || 0) - (a.favorites || 0)); break;
|
| 123 |
-
case "tips":
|
| 124 |
-
// 后端使用 tip_history 的当月数据排序
|
| 125 |
-
sorted.sort((a, b) => {
|
| 126 |
-
const now = new Date();
|
| 127 |
-
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
| 128 |
-
const tipA = (a.tip_history || {})[currentMonth] || 0;
|
| 129 |
-
const tipB = (b.tip_history || {})[currentMonth] || 0;
|
| 130 |
-
return tipB - tipA;
|
| 131 |
-
});
|
| 132 |
-
break;
|
| 133 |
-
case "views": sorted.sort((a, b) => (b.views || 0) - (a.views || 0)); break;
|
| 134 |
-
case "daily_views": sorted.sort((a, b) => (b.daily_views || 0) - (a.daily_views || 0)); break;
|
| 135 |
-
case "rating":
|
| 136 |
-
sorted.sort((a, b) => {
|
| 137 |
-
const ratingA = a.rating_avg || 0;
|
| 138 |
-
const ratingB = b.rating_avg || 0;
|
| 139 |
-
if (ratingA === ratingB) {
|
| 140 |
-
return (b.rating_count || 0) - (a.rating_count || 0);
|
| 141 |
-
}
|
| 142 |
-
return ratingB - ratingA;
|
| 143 |
-
});
|
| 144 |
-
break;
|
| 145 |
-
default: sorted.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); break; // time
|
| 146 |
-
}
|
| 147 |
-
}
|
| 148 |
-
|
| 149 |
-
// 缓存本次排序结果
|
| 150 |
-
_lastSortInput = data;
|
| 151 |
-
_lastSortKey = sortKey;
|
| 152 |
-
_lastSortResult = sorted;
|
| 153 |
-
return sorted;
|
| 154 |
-
}
|
| 155 |
-
|
| 156 |
/**
|
| 157 |
* 从同 tab 的任意排序状态中获取已有数据
|
| 158 |
*/
|
| 159 |
-
|
| 160 |
-
for (const [key, state] of paginationStates) {
|
| 161 |
-
if (key.startsWith(tab + "_") && state.allData.length > 0 && state.isFullyLoaded) {
|
| 162 |
-
return state.allData;
|
| 163 |
-
}
|
| 164 |
-
}
|
| 165 |
-
return null;
|
| 166 |
-
}
|
| 167 |
-
|
| 168 |
-
/**
|
| 169 |
-
* 从 sessionStorage 读取创作者缓存
|
| 170 |
-
*/
|
| 171 |
-
function getCreatorsFromSessionStorage() {
|
| 172 |
-
try {
|
| 173 |
-
const data = sessionStorage.getItem('ComfyRanking_CreatorsCache');
|
| 174 |
-
if (data) {
|
| 175 |
-
return JSON.parse(data);
|
| 176 |
-
}
|
| 177 |
-
} catch (e) {
|
| 178 |
-
console.warn('读取创作者 sessionStorage 缓存失败:', e);
|
| 179 |
-
}
|
| 180 |
-
return null;
|
| 181 |
-
}
|
| 182 |
-
|
| 183 |
-
/**
|
| 184 |
-
* 将创作者数据保存到 sessionStorage
|
| 185 |
-
*/
|
| 186 |
-
function saveCreatorsToSessionStorage(data) {
|
| 187 |
-
try {
|
| 188 |
-
sessionStorage.setItem('ComfyRanking_CreatorsCache', JSON.stringify(data));
|
| 189 |
-
} catch (e) {
|
| 190 |
-
console.warn('创作者数据写入 sessionStorage 失败:', e);
|
| 191 |
-
}
|
| 192 |
-
}
|
| 193 |
-
|
| 194 |
-
/**
|
| 195 |
-
* 本地搜索创作者数据
|
| 196 |
-
* @param {string} keyword - 搜索关键词
|
| 197 |
-
* @param {Array} data - 创作者数据数组
|
| 198 |
-
* @returns {Array} 匹配的数据
|
| 199 |
-
*/
|
| 200 |
-
/**
|
| 201 |
-
* 按关键词过滤列表数据(工具/应用/推荐Tab本地搜索)
|
| 202 |
-
* @param {Array} items - 数据数组
|
| 203 |
-
* @param {string} keyword - 搜索关键词
|
| 204 |
-
* @returns {Array} 过滤后的数据
|
| 205 |
-
*/
|
| 206 |
-
function _filterBySearch(items, keyword) {
|
| 207 |
-
const lowerKeyword = keyword.toLowerCase();
|
| 208 |
-
return items.filter(item => {
|
| 209 |
-
const textStr = `${item.title||''} ${item.shortDesc||''} ${item.name||''} ${item.account||''}`.toLowerCase();
|
| 210 |
-
return textStr.includes(lowerKeyword);
|
| 211 |
-
});
|
| 212 |
-
}
|
| 213 |
-
|
| 214 |
-
/**
|
| 215 |
-
* 本地搜索创作者数据
|
| 216 |
-
* @param {string} keyword - 搜索关键词
|
| 217 |
-
* @param {Array} data - 创作者数据数组
|
| 218 |
-
* @returns {Array} 匹配的数据
|
| 219 |
-
*/
|
| 220 |
-
function searchCreatorsLocally(keyword, data) {
|
| 221 |
-
const lowerKeyword = keyword.toLowerCase();
|
| 222 |
-
return data.filter(item => {
|
| 223 |
-
const name = (item.name || '').toLowerCase();
|
| 224 |
-
const account = (item.account || '').toLowerCase();
|
| 225 |
-
const shortDesc = (item.shortDesc || '').toLowerCase();
|
| 226 |
-
return name.includes(lowerKeyword) ||
|
| 227 |
-
account.includes(lowerKeyword) ||
|
| 228 |
-
shortDesc.includes(lowerKeyword);
|
| 229 |
-
});
|
| 230 |
-
}
|
| 231 |
-
|
| 232 |
|
| 233 |
// ==========================================
|
| 234 |
// 📦 分页状态管理
|
| 235 |
// ==========================================
|
| 236 |
// 每个标签页独立的分页状态
|
| 237 |
-
const
|
|
|
|
| 238 |
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
const
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
loader: null, // 分页加载器实例
|
| 249 |
-
isFullyLoaded: false, // 是否已加载完所有数据
|
| 250 |
-
isSearchResult: false // 标记是否为搜索结果(用于区分正常数据和搜索结果)
|
| 251 |
-
});
|
| 252 |
}
|
| 253 |
-
return paginationStates.get(key);
|
| 254 |
}
|
| 255 |
|
| 256 |
|
| 257 |
// ==========================================
|
| 258 |
// 🚀 主加载函数
|
| 259 |
// ==========================================
|
| 260 |
-
export async function loadSidebarContent({
|
| 261 |
-
tab,
|
| 262 |
-
sort,
|
| 263 |
-
keyword,
|
| 264 |
-
contentArea,
|
| 265 |
-
currentUser,
|
| 266 |
-
renderToken,
|
| 267 |
getRenderToken,
|
| 268 |
force = false,
|
| 269 |
-
expandItemId = null // 新增:要自动展开的卡片ID
|
|
|
|
| 270 |
}) {
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
}
|
| 286 |
-
} catch (error) {
|
| 287 |
-
console.error("讨论区加载失败:", error);
|
| 288 |
-
// 使用安全的DOM操作替代innerHTML,防止XSS
|
| 289 |
-
contentArea.innerHTML = '';
|
| 290 |
-
const errorDiv = document.createElement('div');
|
| 291 |
-
errorDiv.style.cssText = 'text-align:center; padding: 40px 20px; color:#F44336;';
|
| 292 |
-
errorDiv.textContent = `❌ 讨论区加载失败: ${error.message}`;
|
| 293 |
-
contentArea.appendChild(errorDiv);
|
| 294 |
-
}
|
| 295 |
-
return;
|
| 296 |
-
}
|
| 297 |
-
|
| 298 |
-
// ========== 📝 任务榜特殊处理 ==========
|
| 299 |
-
if (tab === "tasks") {
|
| 300 |
-
try {
|
| 301 |
-
const tasksModule = await getTasksView();
|
| 302 |
-
const tasksView = tasksModule.createTasksView(currentUser, keyword);
|
| 303 |
-
// 🔧 清理旧视图事件监听器
|
| 304 |
-
Array.from(contentArea.children).forEach(child => {
|
| 305 |
-
if (child._cleanup) child._cleanup();
|
| 306 |
-
});
|
| 307 |
-
contentArea.innerHTML = "";
|
| 308 |
-
contentArea.appendChild(tasksView);
|
| 309 |
-
} catch (error) {
|
| 310 |
-
console.error("任务榜加载失败:", error);
|
| 311 |
-
// 使用安全的DOM操作替代innerHTML,防止XSS
|
| 312 |
-
contentArea.innerHTML = '';
|
| 313 |
-
const errorDiv = document.createElement('div');
|
| 314 |
-
errorDiv.style.cssText = 'text-align:center; padding: 40px 20px; color:#F44336;';
|
| 315 |
-
errorDiv.textContent = `❌ 任务榜加载失败: ${error.message}`;
|
| 316 |
-
contentArea.appendChild(errorDiv);
|
| 317 |
-
}
|
| 318 |
-
return;
|
| 319 |
-
}
|
| 320 |
-
|
| 321 |
-
// ========== 🧩 提示词特殊处理 ==========
|
| 322 |
-
if (tab === "prompts") {
|
| 323 |
-
try {
|
| 324 |
-
const promptsModule = await getPromptsView();
|
| 325 |
-
const promptsView = promptsModule.createPromptsView(currentUser, keyword);
|
| 326 |
-
// 🔧 清理旧视图事件监听器
|
| 327 |
-
Array.from(contentArea.children).forEach(child => {
|
| 328 |
-
if (child._cleanup) child._cleanup();
|
| 329 |
-
});
|
| 330 |
-
contentArea.innerHTML = "";
|
| 331 |
-
contentArea.appendChild(promptsView);
|
| 332 |
-
} catch (error) {
|
| 333 |
-
console.error("提示词加载失败:", error);
|
| 334 |
-
// 使用安全的DOM操作替代innerHTML,防止XSS
|
| 335 |
-
contentArea.innerHTML = '';
|
| 336 |
-
const errorDiv = document.createElement('div');
|
| 337 |
-
errorDiv.style.cssText = 'text-align:center; padding: 40px 20px; color:#F44336;';
|
| 338 |
-
errorDiv.textContent = `❌ 提示词加载失败: ${error.message}`;
|
| 339 |
-
contentArea.appendChild(errorDiv);
|
| 340 |
-
}
|
| 341 |
return;
|
| 342 |
}
|
| 343 |
-
|
| 344 |
const cacheKey = `ListCache_${tab}_${sort}`;
|
| 345 |
const state = getPaginationState(tab, sort);
|
| 346 |
const pageSize = tab === "creators" ? CREATORS_PAGE_SIZE : PAGE_SIZE;
|
| 347 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
// ========== 🔄 强制刷新:清除当前 tab/sort 的内存缓存 ==========
|
| 349 |
if (force) {
|
| 350 |
state.allData = [];
|
| 351 |
state.displayedCount = 0;
|
| 352 |
state.isFullyLoaded = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
}
|
| 354 |
-
|
| 355 |
// 停止之前的分页加载器
|
| 356 |
if (state.loader) {
|
| 357 |
state.loader.stop();
|
| 358 |
state.loader = null; // 清除引用,防止旧分页器状态残留
|
| 359 |
}
|
| 360 |
-
|
| 361 |
// ========== 🔍 搜索过滤统一入口 ==========
|
| 362 |
// 🔧 修复:过滤职责原先分裂在三处且口径不一致 —— loadMoreData 与分页器的
|
| 363 |
// getTotalDataCount 都是「先过滤再切片」(正确),而各首屏分支���「先切 20 条再过滤」,
|
|
@@ -368,38 +185,38 @@ export async function loadSidebarContent({
|
|
| 368 |
// 创作者Tab的 keyword 由后端搜索接口过滤,此处不做本地过滤(与原口径一致)。
|
| 369 |
const _applySearchScope = (dataArray) => {
|
| 370 |
if (!keyword || tab === "creators") return dataArray;
|
| 371 |
-
return
|
| 372 |
};
|
| 373 |
-
|
| 374 |
// ========== 渲染函数:渲染一批数据(只渲染,过滤由 _applySearchScope 统一负责)==========
|
| 375 |
const renderBatch = (dataArray, append = false) => {
|
| 376 |
if (renderToken !== getRenderToken()) return;
|
| 377 |
-
|
| 378 |
// 🔧 数据已由调用方经 _applySearchScope 过滤,本函数只负责渲染(不再二次过滤)
|
| 379 |
const displayData = dataArray;
|
| 380 |
-
|
| 381 |
// 首次渲染清空容器
|
| 382 |
if (!append) {
|
| 383 |
contentArea.innerHTML = "";
|
| 384 |
state.displayedCount = 0;
|
| 385 |
}
|
| 386 |
-
|
| 387 |
// 空数据处理
|
| 388 |
if (displayData.length === 0 && state.displayedCount === 0) {
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
return 0;
|
| 395 |
}
|
| 396 |
-
|
| 397 |
// 渲染卡片
|
| 398 |
const fragment = document.createDocumentFragment();
|
| 399 |
const animationType = getAnimationTypeForTab(tab);
|
| 400 |
const cards = [];
|
| 401 |
let targetCard = null; // 用于保存需要展开的目标卡片
|
| 402 |
-
|
| 403 |
if (tab === "tools" || tab === "skills" || tab === "apps" || tab === "recommends") {
|
| 404 |
displayData.forEach(data => {
|
| 405 |
const card = createItemCard(data, currentUser);
|
|
@@ -417,64 +234,73 @@ export async function loadSidebarContent({
|
|
| 417 |
fragment.appendChild(card);
|
| 418 |
});
|
| 419 |
}
|
| 420 |
-
|
| 421 |
contentArea.appendChild(fragment);
|
| 422 |
-
|
| 423 |
// ✨ 应用视口感知动画:可见卡片依次错开载入,滚动/分页进入视口的卡片自动补播动画
|
| 424 |
applyViewportAnimations(contentArea, cards, animationType, !append);
|
| 425 |
-
|
| 426 |
// 对新渲染的图片启用懒加载
|
| 427 |
lazyLoadImages(contentArea, "img:not(.lazy-loaded):not(.lazy-loading)");
|
| 428 |
-
|
| 429 |
// 🔔 自动展开目标卡片(如果有)
|
| 430 |
if (targetCard && !append) {
|
| 431 |
requestAnimationFrame(() => {
|
|
|
|
| 432 |
// 滚动到卡片位置
|
| 433 |
-
|
| 434 |
// 模拟点击摘要区域展开卡片
|
| 435 |
const summary = targetCard.querySelector('.item-summary') || targetCard.firstElementChild;
|
| 436 |
if (summary) summary.click();
|
| 437 |
});
|
| 438 |
}
|
| 439 |
-
|
| 440 |
// 【问题1修复】首屏未找到目标卡片,fallback到独立详情页
|
| 441 |
-
if (expandItemId && !targetCard && !append) {
|
| 442 |
(async () => {
|
| 443 |
try {
|
| 444 |
const res = await api.getItemById(expandItemId);
|
|
|
|
| 445 |
if (res.status === "success" && res.data) {
|
| 446 |
const module = await import("../market/资源详情页面组件.js");
|
|
|
|
| 447 |
const view = module.createItemDetailView(res.data, currentUser);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } }));
|
| 449 |
}
|
| 450 |
} catch (e) {
|
| 451 |
-
|
|
|
|
| 452 |
}
|
| 453 |
})();
|
| 454 |
}
|
| 455 |
-
|
| 456 |
state.displayedCount += displayData.length;
|
| 457 |
return displayData.length;
|
| 458 |
};
|
| 459 |
-
|
| 460 |
// ========== 分页加载更多函数 ==========
|
| 461 |
-
const loadMoreData = async (page, size) => {
|
|
|
|
| 462 |
// 计算要显示的数据范围
|
| 463 |
const start = (page - 1) * size;
|
| 464 |
const end = start + size;
|
| 465 |
-
|
| 466 |
// 从全量数据中截取(统一走 _applySearchScope,与首屏处于同一数据空间)
|
| 467 |
const dataSlice = _applySearchScope(state.allData);
|
| 468 |
-
|
| 469 |
const batch = dataSlice.slice(start, end);
|
| 470 |
-
|
| 471 |
if (batch.length > 0) {
|
| 472 |
renderBatch(batch, true);
|
| 473 |
}
|
| 474 |
-
|
| 475 |
return batch;
|
| 476 |
};
|
| 477 |
-
|
| 478 |
// ========== 尝试从缓存加载 ==========
|
| 479 |
// 📌 本处的缓存口径与 任务榜/讨论区/提示词(统一走 性能优化工具.js 的
|
| 480 |
// readListCache,命中后一律调 silentRefresh 后台补新)**刻意不同**,不是历史遗留,勿顺手归一:
|
|
@@ -483,8 +309,9 @@ export async function loadSidebarContent({
|
|
| 483 |
// - 那三个列表只缓存第一页,命中后数据必然不完整,故一律后台 silentRefresh 补新。
|
| 484 |
// 若强行统一成「总是后台刷新」,插件榜会在每次 Tab 切换时多发一次 200 条的全量请求。
|
| 485 |
// 获取完整缓存信息(包含过期状态)
|
| 486 |
-
const { value: cachedData, expired: isCacheExpired, found: hasCacheData } =
|
| 487 |
-
|
|
|
|
| 488 |
// 有缓存时直接渲染缓存(搜索时也使用缓存,renderBatch 会处理 keyword 过滤)
|
| 489 |
// 🔍 创作者Tab有keyword时不使用缓存,每次都从后端搜索
|
| 490 |
// 🔧 修复:原实现还把「刚清空搜索框」也列为跳过缓存的理由,但 cacheKey 不含 keyword、
|
|
@@ -497,46 +324,41 @@ export async function loadSidebarContent({
|
|
| 497 |
state.allData = proxiedData;
|
| 498 |
state.isFullyLoaded = true;
|
| 499 |
// 缓存是全量列表而非搜索结果,清除搜索标记,避免下次加载仍被当作搜索结果处理
|
| 500 |
-
if (tab === "creators" && !keyword)
|
| 501 |
-
|
|
|
|
|
|
|
|
|
|
| 502 |
// 首屏渲染(仅第一页)—— 先过滤再切片,与 loadMoreData 同一数据空间
|
| 503 |
const scopedData = _applySearchScope(proxiedData);
|
| 504 |
renderBatch(scopedData.slice(0, pageSize), false);
|
| 505 |
-
|
| 506 |
// 如果有更多数据,启动分页加载器
|
| 507 |
if (scopedData.length > pageSize) {
|
| 508 |
-
_setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
|
| 509 |
}
|
| 510 |
-
|
| 511 |
// 【新增】Tab 切换后台静默刷新 —— 仅缓存过期时触发
|
| 512 |
if (isCacheExpired) {
|
| 513 |
console.log(`🔄 缓存已过期,${tab}_${sort} 启动后台静默刷新...`);
|
| 514 |
-
|
| 515 |
setTimeout(async () => {
|
| 516 |
// 保存当前上下文快照,防止网络延迟期间用户切换Tab导致竞态
|
| 517 |
const savedToken = renderToken;
|
| 518 |
const savedTab = tab;
|
| 519 |
const savedSort = sort;
|
| 520 |
-
|
| 521 |
// 防竞态检查1:回调开始执行时验证
|
| 522 |
if (savedToken !== getRenderToken()) return;
|
| 523 |
-
|
| 524 |
try {
|
| 525 |
-
|
| 526 |
-
if (
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
newData = proxyImages(response.data || []);
|
| 530 |
-
} else if (savedTab === "creators") {
|
| 531 |
-
const response = await api.getCreators(savedSort, 500);
|
| 532 |
-
newData = proxyImages(response.data || []);
|
| 533 |
-
} else {
|
| 534 |
-
return; // posts/tasks 不走这个路径
|
| 535 |
-
}
|
| 536 |
-
|
| 537 |
// 防竞态检查2:网络请求完成后再次验证
|
| 538 |
if (savedToken !== getRenderToken()) return;
|
| 539 |
-
|
| 540 |
// 🛡️ 空响应防护:云端返回空列表时不回写缓存、也不清屏重渲染。
|
| 541 |
// 本分支由「缓存过期」触发,是切 Tab 时最常走到的路径,若不��,
|
| 542 |
// 下方 state.allData = newData + contentArea.innerHTML = "" 会把
|
|
@@ -545,7 +367,7 @@ export async function loadSidebarContent({
|
|
| 545 |
console.warn(`🛡️ ${savedTab}_${savedSort} 云端返回空列表,保留本地数据,跳过缓存回写`);
|
| 546 |
return;
|
| 547 |
}
|
| 548 |
-
|
| 549 |
// 只有token匹配时才同时更新state.allData和setCache
|
| 550 |
if (_shouldUpdateData(state.allData, newData)) {
|
| 551 |
console.log(`✅ ${savedTab}_${savedSort} 检测到新数据,执行静默更新`);
|
|
@@ -553,21 +375,21 @@ export async function loadSidebarContent({
|
|
| 553 |
state.displayedCount = 0; // 重置分页计数
|
| 554 |
// 📴 持久化到 localStorage,保证重启后离线仍可展示
|
| 555 |
setCache(cacheKey, newData, getCacheExpireTime(), true);
|
| 556 |
-
|
| 557 |
// 更新创作者 sessionStorage 缓存
|
| 558 |
if (savedTab === "creators") {
|
| 559 |
saveCreatorsToSessionStorage(newData);
|
| 560 |
}
|
| 561 |
-
|
| 562 |
// 完整重新渲染(替代之前只更新数字的方案)
|
| 563 |
contentArea.innerHTML = "";
|
| 564 |
// 🔧 先过滤再切片:搜索场景下静默刷新同样会触发,
|
| 565 |
// 原实现在 newData 前 20 条里过滤,会漏掉靠后的匹配项
|
| 566 |
const scopedNewData = _applySearchScope(newData);
|
| 567 |
renderBatch(scopedNewData.slice(0, pageSize), false);
|
| 568 |
-
|
| 569 |
if (scopedNewData.length > pageSize) {
|
| 570 |
-
_setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, savedTab);
|
| 571 |
}
|
| 572 |
} else {
|
| 573 |
// 关键字段无变化 → 无需重渲染。但 newData 才是刚从云端取回的权威数据,
|
|
@@ -579,14 +401,15 @@ export async function loadSidebarContent({
|
|
| 579 |
setCache(cacheKey, newData, getCacheExpireTime(), true);
|
| 580 |
}
|
| 581 |
} catch (err) {
|
| 582 |
-
|
|
|
|
| 583 |
}
|
| 584 |
}, 0);
|
| 585 |
}
|
| 586 |
-
|
| 587 |
return;
|
| 588 |
}
|
| 589 |
-
|
| 590 |
// ========== 🔄 本地排序优先:同 tab 已有数据时直接排序渲染(强制刷新时跳过)==========
|
| 591 |
// 🔍 创作者Tab有keyword时不走本地排序优先,必须调用后端搜索API
|
| 592 |
// 🔍 创作者Tab清空搜索框后也不走本地排序优先,避免使用搜索时的部分结果
|
|
@@ -601,32 +424,34 @@ export async function loadSidebarContent({
|
|
| 601 |
state.allData = locallySorted;
|
| 602 |
state.isFullyLoaded = true;
|
| 603 |
state.displayedCount = 0; // 重置已显示计数,因为数据重新排序了
|
| 604 |
-
|
| 605 |
// 首屏渲染(先过滤再切片)
|
| 606 |
const scopedSorted = _applySearchScope(locallySorted);
|
| 607 |
renderBatch(scopedSorted.slice(0, pageSize), false);
|
| 608 |
-
|
| 609 |
// 启动分页加载器
|
| 610 |
if (scopedSorted.length > pageSize) {
|
| 611 |
-
_setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
|
| 612 |
}
|
| 613 |
-
|
| 614 |
// 存入当前排序的缓存(📴 创作者数据同样持久化到 localStorage,保证重启后离线可展示)
|
| 615 |
setCache(cacheKey, locallySorted, getCacheExpireTime(), true);
|
| 616 |
-
|
| 617 |
return; // 本地排序后直接返回,不需要后台刷新(数据是同一批)
|
| 618 |
}
|
| 619 |
-
|
| 620 |
// ========== 🚀 缓存先行:阻塞前先用本地已有数据上屏 ==========
|
| 621 |
// 走到这里说明上面两条快路径都没命中:要么是 force 强制刷新(刻意跳过缓存),
|
| 622 |
// 要么是本次会话还没有该 tab 的完整数据。弱网下一次列表请求最长要烧满整个重试预算,
|
| 623 |
// 期间用户只能盯骨架屏;此刻若本地已有可用数据,就先渲染首屏,网络请求照常往下走,
|
| 624 |
// 回来后按 renderToken + _shouldUpdateData 决定是否重渲染(与上方静默刷新同口径)。
|
| 625 |
-
// 🔍 创作者搜索
|
|
|
|
| 626 |
let renderedInstant = false;
|
| 627 |
-
if (!isCreatorSearching) {
|
| 628 |
// 可立即上屏的数据:当前排序的列表缓存(force 时被跳过)→ 创作者 sessionStorage 降级副本
|
| 629 |
-
let instantData =
|
|
|
|
| 630 |
if (!instantData && tab === "creators") {
|
| 631 |
const sessionData = getCreatorsFromSessionStorage();
|
| 632 |
if (Array.isArray(sessionData) && sessionData.length > 0) instantData = sessionData;
|
|
@@ -639,55 +464,36 @@ export async function loadSidebarContent({
|
|
| 639 |
const sortedData = sortDataLocally(proxyImages(instantData), tab, sort);
|
| 640 |
state.allData = sortedData;
|
| 641 |
state.isFullyLoaded = true;
|
| 642 |
-
state.isSearchResult =
|
|
|
|
| 643 |
// 🔧 先过滤再切片(tools/skills/apps/recommends 带 keyword 时同样会进入本分支)
|
| 644 |
const scopedInstant = _applySearchScope(sortedData);
|
| 645 |
renderBatch(scopedInstant.slice(0, pageSize), false);
|
| 646 |
if (scopedInstant.length > pageSize) {
|
| 647 |
-
_setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
|
| 648 |
}
|
| 649 |
renderedInstant = true;
|
| 650 |
}
|
| 651 |
}
|
| 652 |
-
|
| 653 |
// ========== 显示加载骨架屏(已有本地数据上屏时跳过,避免闪屏)==========
|
| 654 |
if (!renderedInstant) {
|
| 655 |
contentArea.innerHTML = "";
|
| 656 |
const skeleton = createSkeleton(tab === "creators" ? "list" : "card", 3);
|
| 657 |
contentArea.appendChild(skeleton);
|
| 658 |
}
|
| 659 |
-
|
| 660 |
// ========== 从网络加载数据 ==========
|
| 661 |
try {
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
} else if (tab === "creators") {
|
| 670 |
-
// 🔍 创作者搜索:有 keyword 时调用后端搜索API,否则获取列表
|
| 671 |
-
if (keyword) {
|
| 672 |
-
// 🔍 先撤销上一次仍在途的搜索请求:用户继续输入后,上一次的关键词已经作废,
|
| 673 |
-
// 但它的请求还会继续占用并发额度(全局最多 6 个)与最长 10 秒的超时预算,
|
| 674 |
-
// 把最新这次搜索挤到后面排队,弱网下表现为「一直在转圈」。
|
| 675 |
-
// 只撤销搜索分组,不影响列表、详情、点赞等任何其它请求
|
| 676 |
-
requestCancelManager.cancelAll(API.SEARCH_COMPONENT_ID);
|
| 677 |
-
response = await api.searchCreators(keyword, sort);
|
| 678 |
-
realData = response.data || [];
|
| 679 |
-
realData = proxyImages(realData); // 确保图片走本地缓存代理
|
| 680 |
-
// 标记为搜索结果,不存入缓存,避免污染正常数据
|
| 681 |
-
state.isSearchResult = true;
|
| 682 |
-
} else {
|
| 683 |
-
response = await api.getCreators(sort, 500);
|
| 684 |
-
realData = response.data || [];
|
| 685 |
-
realData = proxyImages(realData); // 确保图片走本地缓存代理
|
| 686 |
-
// 正常数据,清除搜索标记
|
| 687 |
-
state.isSearchResult = false;
|
| 688 |
-
}
|
| 689 |
}
|
| 690 |
-
|
| 691 |
// 存入缓存(📴 创作者数据同样持久化到 localStorage,保证重启后离线仍可展示;搜索结果不存入缓存)
|
| 692 |
const isCreatorSearch = tab === "creators" && keyword;
|
| 693 |
// 🛡️ 空响应防护:创作者搜索命中 0 条是有效答案(下方要渲染「没有搜索到相关内容」),
|
|
@@ -698,39 +504,39 @@ export async function loadSidebarContent({
|
|
| 698 |
} else if (!isCreatorSearch) {
|
| 699 |
setCache(cacheKey, realData, getCacheExpireTime(), true);
|
| 700 |
}
|
| 701 |
-
|
| 702 |
// 创作者非搜索数据存入 sessionStorage(用于离线降级)
|
| 703 |
if (tab === "creators" && !isCreatorSearch && !isEmptyOverwrite) {
|
| 704 |
saveCreatorsToSessionStorage(realData);
|
| 705 |
}
|
| 706 |
-
|
| 707 |
state.isFullyLoaded = true;
|
| 708 |
-
|
| 709 |
// 🛡️ 空响应且本地数据已上屏:保持当前画面,不用空列表清屏(与缓存同口径不回写)
|
| 710 |
if (isEmptyOverwrite && renderedInstant) {
|
| 711 |
return;
|
| 712 |
}
|
| 713 |
-
|
| 714 |
// 🚀 缓存先行场景:网络数据与已上屏的本地数据无实质差异时不重渲染,避免清屏重画
|
| 715 |
// 打断用户已经滚动的阅读位置(与上方后台静默刷新同口径)。缓存已在上面回写权威新数据,
|
| 716 |
// 此处刻意不更新 state.allData,保持与已渲染卡片一致,防止分页加载错位
|
| 717 |
if (renderedInstant && !_shouldUpdateData(state.allData, realData)) {
|
| 718 |
return;
|
| 719 |
}
|
| 720 |
-
|
| 721 |
// 更新状态
|
| 722 |
state.allData = realData;
|
| 723 |
-
|
| 724 |
// 渲染首屏(先过滤再切片:tools/skills/apps/recommends 的 keyword 是本地搜索,
|
| 725 |
// realData 是未过滤的全量数据,原实现在前 20 条里过滤会漏掉靠后的匹配项)
|
| 726 |
const scopedReal = _applySearchScope(realData);
|
| 727 |
renderBatch(scopedReal.slice(0, pageSize), false);
|
| 728 |
-
|
| 729 |
// 启动分页加载器
|
| 730 |
if (scopedReal.length > pageSize) {
|
| 731 |
-
_setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
|
| 732 |
}
|
| 733 |
-
|
| 734 |
} catch (error) {
|
| 735 |
// 🔍 防竞态:本次加载已被更新的加载取代(切Tab、换排序、继续输入关键词)时直接退出。
|
| 736 |
// renderBatch 内部本来就有这道守卫,但它之外的降级分支会直接写 contentArea.innerHTML
|
|
@@ -745,16 +551,17 @@ export async function loadSidebarContent({
|
|
| 745 |
console.log(`⏹️ ${tab}_${sort} 搜索请求已被新关键词取代`);
|
| 746 |
return;
|
| 747 |
}
|
| 748 |
-
console.error("数据加载失败
|
| 749 |
-
|
| 750 |
// 🚀 缓存先行场景:首屏已经用本地数据上屏,网络失败时保持当前画面即可,不再走下面的
|
| 751 |
// 降级分支(否则会把同一批数据重渲染一遍、重播卡片动画)。与上方后台静默刷新失败一致:
|
| 752 |
// 只记录日志,不额外打扰用户
|
| 753 |
if (renderedInstant) {
|
| 754 |
console.warn(`📴 ${tab}_${sort} 网络刷新失败,保持已渲染的本地数据`);
|
|
|
|
| 755 |
return;
|
| 756 |
}
|
| 757 |
-
|
| 758 |
// 🚀 回退到任何可用缓存(包括过期的)
|
| 759 |
// 🔍 创作者Tab搜索场景跳过此分支:renderBatch 对创作者不做本地过滤,
|
| 760 |
// 直接渲染全量缓存会导致搜索结果未过滤,改由下方本地搜索降级分支处理
|
|
@@ -765,7 +572,7 @@ export async function loadSidebarContent({
|
|
| 765 |
// 带 keyword 时会进来;renderBatch 不再自行过滤,此处必须显式过滤
|
| 766 |
const scopedCached = _applySearchScope(cachedData);
|
| 767 |
renderBatch(scopedCached.slice(0, pageSize), false);
|
| 768 |
-
|
| 769 |
// 如果是过期缓存,显示提示
|
| 770 |
// 🔧 改用统一提示组件 + 词典(原为手写 div、无动画不排队,且文案硬编码中文)
|
| 771 |
if (isCacheExpired) {
|
|
@@ -773,13 +580,14 @@ export async function loadSidebarContent({
|
|
| 773 |
}
|
| 774 |
return;
|
| 775 |
}
|
| 776 |
-
|
| 777 |
// 🚀 创作者Tab:尝试从 sessionStorage 或内存中降级
|
| 778 |
if (tab === "creators") {
|
| 779 |
let fallbackData = null;
|
| 780 |
-
|
| 781 |
// 1. 优先从当前内存状态获取
|
| 782 |
-
if (state.allData && state.allData.length > 0
|
|
|
|
| 783 |
fallbackData = state.allData;
|
| 784 |
}
|
| 785 |
// 2. 从本地列表缓存获取(含过期缓存,搜索结果不入库故不会污染)
|
|
@@ -794,26 +602,26 @@ export async function loadSidebarContent({
|
|
| 794 |
if (!fallbackData) {
|
| 795 |
fallbackData = findExistingTabData("creators");
|
| 796 |
}
|
| 797 |
-
|
| 798 |
if (fallbackData && fallbackData.length > 0) {
|
| 799 |
let displayData = fallbackData;
|
| 800 |
-
|
| 801 |
// 搜索场景:执行本地搜索
|
| 802 |
if (keyword) {
|
| 803 |
displayData = searchCreatorsLocally(keyword, fallbackData);
|
| 804 |
if (displayData.length === 0) {
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
// 🔧 改用统一提示组件 + 词典(原为手写 div 且文案硬编码中文)
|
| 812 |
showToast(t('feedback.search_local_result'), 'warning');
|
| 813 |
return;
|
| 814 |
}
|
| 815 |
}
|
| 816 |
-
|
| 817 |
// 按当前排序排序
|
| 818 |
displayData = sortDataLocally(displayData, tab, sort);
|
| 819 |
state.allData = displayData;
|
|
@@ -821,143 +629,30 @@ export async function loadSidebarContent({
|
|
| 821 |
// searchCreatorsLocally 过滤),保证各渲染入口口径完全一致
|
| 822 |
const scopedFallback = _applySearchScope(displayData);
|
| 823 |
renderBatch(scopedFallback.slice(0, pageSize), false);
|
| 824 |
-
|
| 825 |
// 启动分页加载器
|
| 826 |
if (scopedFallback.length > pageSize) {
|
| 827 |
-
_setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab);
|
| 828 |
}
|
| 829 |
-
|
| 830 |
// 🔧 改用统一提示组件 + 词典(原为手写 div 且两条文案均硬编码中文)
|
| 831 |
showToast(keyword ? t('feedback.search_local_result') : t('feedback.cache_fallback'), 'warning');
|
| 832 |
return;
|
| 833 |
} else if (keyword) {
|
| 834 |
-
contentArea.
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
contentArea.appendChild(emptyDiv);
|
| 839 |
return;
|
| 840 |
}
|
| 841 |
}
|
| 842 |
-
|
| 843 |
-
// 无任何缓存,显示原有的错误信息
|
| 844 |
-
// 使用安全的DOM操作替代innerHTML,防止XSS
|
| 845 |
-
contentArea.innerHTML = '';
|
| 846 |
-
const errorDiv = document.createElement('div');
|
| 847 |
-
errorDiv.style.cssText = 'text-align:center; padding: 40px 20px; color:#F44336;';
|
| 848 |
-
const errorText = document.createElement('div');
|
| 849 |
-
errorText.textContent = `❌ 数据加载失败: ${error.message}`;
|
| 850 |
-
errorDiv.appendChild(errorText);
|
| 851 |
-
const br = document.createElement('br');
|
| 852 |
-
errorDiv.appendChild(br);
|
| 853 |
-
const retryBtn = document.createElement('button');
|
| 854 |
-
retryBtn.style.cssText = 'padding:8px 16px; background:#2196F3; color:white; border:none; border-radius:4px; cursor:pointer; margin-top:16px;';
|
| 855 |
-
retryBtn.textContent = '🔄 点击重试';
|
| 856 |
-
retryBtn.onclick = () => location.reload();
|
| 857 |
-
errorDiv.appendChild(retryBtn);
|
| 858 |
-
contentArea.appendChild(errorDiv);
|
| 859 |
-
}
|
| 860 |
-
}
|
| 861 |
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
// 安全清理旧分页器,防止状态残留和重复事件监听
|
| 868 |
-
if (state.loader) {
|
| 869 |
-
state.loader.stop();
|
| 870 |
}
|
| 871 |
-
state.loader = null;
|
| 872 |
-
|
| 873 |
-
// 获取滚动容器(侧边栏主容器)
|
| 874 |
-
const scrollContainer = contentArea.closest(".sidebar-scroll-container") || contentArea.parentElement;
|
| 875 |
-
|
| 876 |
-
if (!scrollContainer) {
|
| 877 |
-
console.warn("⚠️ 未找到滚动容器,分页加载无法启动");
|
| 878 |
-
|
| 879 |
-
// 添加手动加载按钮作为 fallback
|
| 880 |
-
let nextPage = 2;
|
| 881 |
-
const loadMoreBtn = document.createElement('button');
|
| 882 |
-
loadMoreBtn.textContent = t('task.load_more');
|
| 883 |
-
loadMoreBtn.style.cssText = 'width:100%;padding:10px;margin-top:10px;cursor:pointer;border:1px solid #555;border-radius:6px;background:transparent;color:inherit;';
|
| 884 |
-
loadMoreBtn.onclick = async () => {
|
| 885 |
-
loadMoreBtn.disabled = true;
|
| 886 |
-
loadMoreBtn.textContent = t('common.loading');
|
| 887 |
-
try {
|
| 888 |
-
const result = await loadMoreData(nextPage, pageSize);
|
| 889 |
-
if (result && result.length > 0) {
|
| 890 |
-
nextPage++;
|
| 891 |
-
loadMoreBtn.textContent = t('task.load_more');
|
| 892 |
-
} else {
|
| 893 |
-
loadMoreBtn.textContent = t('common.no_more');
|
| 894 |
-
loadMoreBtn.style.cursor = 'default';
|
| 895 |
-
loadMoreBtn.onclick = null;
|
| 896 |
-
}
|
| 897 |
-
} catch (err) {
|
| 898 |
-
console.error("手动加载更多失败:", err);
|
| 899 |
-
loadMoreBtn.textContent = t('task.load_failed');
|
| 900 |
-
}
|
| 901 |
-
loadMoreBtn.disabled = false;
|
| 902 |
-
};
|
| 903 |
-
contentArea.appendChild(loadMoreBtn);
|
| 904 |
-
return;
|
| 905 |
-
}
|
| 906 |
-
|
| 907 |
-
// 计算数据总量(考虑搜索过滤,创作者Tab有keyword时不进行本地过滤,因为后端已过滤)
|
| 908 |
-
// 📌 口径必须与 loadSidebarContent 内的 _applySearchScope 一致(先过滤再计数);
|
| 909 |
-
// 本函数是模块级、拿不到那个闭包,故保留等价实现,改动其一务必同步另一处
|
| 910 |
-
const getTotalDataCount = () => {
|
| 911 |
-
if (!keyword) return state.allData.length;
|
| 912 |
-
// 创作者Tab后端搜索已过滤,直接返回全部数据
|
| 913 |
-
if (tab === "creators") return state.allData.length;
|
| 914 |
-
return _filterBySearch(state.allData, keyword).length;
|
| 915 |
-
};
|
| 916 |
-
|
| 917 |
-
// 创建分页加载器
|
| 918 |
-
const loader = createPaginationLoader({
|
| 919 |
-
container: scrollContainer,
|
| 920 |
-
pageSize: pageSize,
|
| 921 |
-
threshold: 300,
|
| 922 |
-
loadMore: async (page, size) => {
|
| 923 |
-
const totalCount = getTotalDataCount();
|
| 924 |
-
|
| 925 |
-
// 如果已显示完所有数据,返回空数组停止加载
|
| 926 |
-
if (state.displayedCount >= totalCount) {
|
| 927 |
-
return [];
|
| 928 |
-
}
|
| 929 |
-
|
| 930 |
-
return loadMoreData(page, size);
|
| 931 |
-
},
|
| 932 |
-
onEnd: () => {
|
| 933 |
-
// 显示"已经到底了"提示
|
| 934 |
-
const totalCount = getTotalDataCount();
|
| 935 |
-
// 只有在有数据且显示数量 >= 总数量时才显示
|
| 936 |
-
if (totalCount > 0 && state.displayedCount >= totalCount) {
|
| 937 |
-
_showEndIndicator(contentArea);
|
| 938 |
-
}
|
| 939 |
-
}
|
| 940 |
-
});
|
| 941 |
-
|
| 942 |
-
state.loader = loader;
|
| 943 |
-
loader.start();
|
| 944 |
-
}
|
| 945 |
-
|
| 946 |
-
// ==========================================
|
| 947 |
-
// 🏁 显示"已经到底了"提示
|
| 948 |
-
// ==========================================
|
| 949 |
-
function _showEndIndicator(contentArea) {
|
| 950 |
-
// 检查是否已存在
|
| 951 |
-
if (contentArea.querySelector('.end-indicator')) return;
|
| 952 |
-
|
| 953 |
-
const endIndicator = document.createElement('div');
|
| 954 |
-
endIndicator.className = 'end-indicator';
|
| 955 |
-
endIndicator.innerHTML = `
|
| 956 |
-
<div style="text-align: center; padding: 20px; color: #666; font-size: 13px;">
|
| 957 |
-
— 已经到底了 —
|
| 958 |
-
</div>
|
| 959 |
-
`;
|
| 960 |
-
contentArea.appendChild(endIndicator);
|
| 961 |
}
|
| 962 |
|
| 963 |
|
|
@@ -985,7 +680,7 @@ function _isEmptyOverwrite(newData, ...localData) {
|
|
| 985 |
function _shouldUpdateData(oldData, newData) {
|
| 986 |
if (!oldData || !newData) return true;
|
| 987 |
if (oldData.length !== newData.length) return true;
|
| 988 |
-
|
| 989 |
// 检查ID顺序是否一致(检测排序变化、新增/删除)
|
| 990 |
// 比对条数覆盖首屏渲染量(PAGE_SIZE),避免首屏可见卡片(第 11~20 条)的版本/数字更新被漏检
|
| 991 |
const checkCount = Math.min(PAGE_SIZE, oldData.length);
|
|
@@ -993,7 +688,7 @@ function _shouldUpdateData(oldData, newData) {
|
|
| 993 |
const oldId = oldData[i].id || oldData[i].account;
|
| 994 |
const newId = newData[i].id || newData[i].account;
|
| 995 |
if (oldId !== newId) return true;
|
| 996 |
-
|
| 997 |
// 检查关键数字字段
|
| 998 |
const keyFields = ['likes', 'downloads', 'uses', 'views', 'daily_views', 'recent_tips', 'favorites', 'rating_avg', 'rating_count'];
|
| 999 |
for (const field of keyFields) {
|
|
|
|
| 16 |
// - 增强缓存策略
|
| 17 |
// ==========================================
|
| 18 |
|
| 19 |
+
import { fetchSidebarList, readSidebarListCache, readCreatorsSessionCache as getCreatorsFromSessionStorage,
|
| 20 |
+
writeCreatorsSessionCache as saveCreatorsToSessionStorage } from "./榜单数据来源.js";
|
| 21 |
+
import { mountSidebarDomainView } from "./榜单视图切换.js";
|
| 22 |
+
import { setupSidebarPagination as _setupPaginationLoader } from "./榜单分页控制.js";
|
| 23 |
+
import { createSidebarPaginationStore } from "./榜单分页状态.js";
|
| 24 |
+
import { sortDataLocally, filterItemsByKeyword, searchCreatorsLocally } from "./榜单本地查询.js";
|
| 25 |
+
|
| 26 |
import { api } from "./网络请求API.js";
|
| 27 |
import { proxyImages } from "./网络请求API.js";
|
|
|
|
|
|
|
|
|
|
| 28 |
import { createItemCard } from "../market/列表卡片组件.js";
|
| 29 |
import { createCreatorCard } from "../market/创作者卡片组件.js";
|
| 30 |
+
import {
|
| 31 |
+
createSkeleton,
|
| 32 |
+
setCache,
|
| 33 |
+
lazyLoadImages,
|
| 34 |
+
renderListLoadError
|
|
|
|
| 35 |
} from "../components/性能优化工具.js";
|
| 36 |
+
import { applyViewportAnimations, getAnimationTypeForTab, scrollCardIntoView, stopViewportAnimations } from "../components/动画音效引擎.js";
|
| 37 |
import { t } from "../components/用户体验增强.js";
|
| 38 |
import { showToast } from "../components/UI交互提示组件.js"; // 🔧 降级提示改走统一组件(原为手写 div)
|
| 39 |
|
|
|
|
| 91 |
}
|
| 92 |
|
| 93 |
// ==========================================
|
| 94 |
+
// 🔄 同榜单数据与创作者会话缓存
|
| 95 |
// ==========================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
/**
|
| 97 |
* 从同 tab 的任意排序状态中获取已有数据
|
| 98 |
*/
|
| 99 |
+
const findExistingTabData = tab => paginationStore.findCompleteData(tab);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
// ==========================================
|
| 102 |
// 📦 分页状态管理
|
| 103 |
// ==========================================
|
| 104 |
// 每个标签页独立的分页状态
|
| 105 |
+
const paginationStore = createSidebarPaginationStore();
|
| 106 |
+
const getPaginationState = (tab, sort) => paginationStore.get(tab, sort);
|
| 107 |
|
| 108 |
+
export function disposeSidebarContent(contentArea) {
|
| 109 |
+
stopViewportAnimations(contentArea);
|
| 110 |
+
try { paginationStore.stopLoaders(); }
|
| 111 |
+
catch { console.warn('Sidebar pagination cleanup failed'); }
|
| 112 |
+
for (const child of Array.from(contentArea.children)) {
|
| 113 |
+
const cleanup = child._cleanup;
|
| 114 |
+
child._cleanup = null;
|
| 115 |
+
try { cleanup?.call(child); }
|
| 116 |
+
catch { console.warn('Sidebar list view cleanup failed'); }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
}
|
|
|
|
| 118 |
}
|
| 119 |
|
| 120 |
|
| 121 |
// ==========================================
|
| 122 |
// 🚀 主加载函数
|
| 123 |
// ==========================================
|
| 124 |
+
export async function loadSidebarContent({
|
| 125 |
+
tab,
|
| 126 |
+
sort,
|
| 127 |
+
keyword,
|
| 128 |
+
contentArea,
|
| 129 |
+
currentUser,
|
| 130 |
+
renderToken,
|
| 131 |
getRenderToken,
|
| 132 |
force = false,
|
| 133 |
+
expandItemId = null, // 新增:要自动展开的卡片ID
|
| 134 |
+
isNavigationCurrent = () => true
|
| 135 |
}) {
|
| 136 |
+
if (renderToken !== getRenderToken()) return;
|
| 137 |
+
const canNavigate = () => renderToken === getRenderToken() && isNavigationCurrent();
|
| 138 |
+
// 新榜单取得视图所有权时停止所有旧分页器,保留各榜单已加载数据。
|
| 139 |
+
paginationStore.stopLoaders();
|
| 140 |
+
|
| 141 |
+
const domainViews = {
|
| 142 |
+
posts: { loadModule: getPostsView, factory: 'createPostsView', errorKey: 'feedback.load_posts_failed',
|
| 143 |
+
afterMount: module => { if (module.loadPosts) module.loadPosts(); } },
|
| 144 |
+
tasks: { loadModule: getTasksView, factory: 'createTasksView', errorKey: 'feedback.load_tasks_failed' },
|
| 145 |
+
prompts: { loadModule: getPromptsView, factory: 'createPromptsView', errorKey: 'feedback.load_prompts_failed' },
|
| 146 |
+
};
|
| 147 |
+
if (Object.hasOwn(domainViews, tab)) {
|
| 148 |
+
await mountSidebarDomainView({ contentArea, currentUser, keyword, renderToken, getRenderToken,
|
| 149 |
+
...domainViews[tab] });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
return;
|
| 151 |
}
|
| 152 |
+
|
| 153 |
const cacheKey = `ListCache_${tab}_${sort}`;
|
| 154 |
const state = getPaginationState(tab, sort);
|
| 155 |
const pageSize = tab === "creators" ? CREATORS_PAGE_SIZE : PAGE_SIZE;
|
| 156 |
+
const sameCreatorSearchData = tab === "creators" && keyword && state.isSearchResult &&
|
| 157 |
+
state.searchKeyword === keyword && Array.isArray(state.allData) && state.allData.length > 0
|
| 158 |
+
? state.allData
|
| 159 |
+
: null;
|
| 160 |
+
|
| 161 |
// ========== 🔄 强制刷新:清除当前 tab/sort 的内存缓存 ==========
|
| 162 |
if (force) {
|
| 163 |
state.allData = [];
|
| 164 |
state.displayedCount = 0;
|
| 165 |
state.isFullyLoaded = false;
|
| 166 |
+
if (!sameCreatorSearchData) {
|
| 167 |
+
state.isSearchResult = false;
|
| 168 |
+
state.searchKeyword = "";
|
| 169 |
+
}
|
| 170 |
}
|
| 171 |
+
|
| 172 |
// 停止之前的分页加载器
|
| 173 |
if (state.loader) {
|
| 174 |
state.loader.stop();
|
| 175 |
state.loader = null; // 清除引用,防止旧分页器状态残留
|
| 176 |
}
|
| 177 |
+
|
| 178 |
// ========== 🔍 搜索过滤统一入口 ==========
|
| 179 |
// 🔧 修复:过滤职责原先分裂在三处且口径不一致 —— loadMoreData 与分页器的
|
| 180 |
// getTotalDataCount 都是「先过滤再切片」(正确),而各首屏分支���「先切 20 条再过滤」,
|
|
|
|
| 185 |
// 创作者Tab的 keyword 由后端搜索接口过滤,此处不做本地过滤(与原口径一致)。
|
| 186 |
const _applySearchScope = (dataArray) => {
|
| 187 |
if (!keyword || tab === "creators") return dataArray;
|
| 188 |
+
return filterItemsByKeyword(dataArray, keyword);
|
| 189 |
};
|
| 190 |
+
|
| 191 |
// ========== 渲染函数:渲染一批数据(只渲染,过滤由 _applySearchScope 统一负责)==========
|
| 192 |
const renderBatch = (dataArray, append = false) => {
|
| 193 |
if (renderToken !== getRenderToken()) return;
|
| 194 |
+
|
| 195 |
// 🔧 数据已由调用方经 _applySearchScope 过滤,本函数只负责渲染(不再二次过滤)
|
| 196 |
const displayData = dataArray;
|
| 197 |
+
|
| 198 |
// 首次渲染清空容器
|
| 199 |
if (!append) {
|
| 200 |
contentArea.innerHTML = "";
|
| 201 |
state.displayedCount = 0;
|
| 202 |
}
|
| 203 |
+
|
| 204 |
// 空数据处理
|
| 205 |
if (displayData.length === 0 && state.displayedCount === 0) {
|
| 206 |
+
const emptyState = document.createElement('div');
|
| 207 |
+
emptyState.style.cssText = 'text-align:center; padding:40px 20px; color:#888;';
|
| 208 |
+
emptyState.textContent = t(keyword ? 'feedback.no_search_results' : 'feedback.no_list_data');
|
| 209 |
+
emptyState.setAttribute('role', 'status');
|
| 210 |
+
contentArea.replaceChildren(emptyState);
|
| 211 |
return 0;
|
| 212 |
}
|
| 213 |
+
|
| 214 |
// 渲染卡片
|
| 215 |
const fragment = document.createDocumentFragment();
|
| 216 |
const animationType = getAnimationTypeForTab(tab);
|
| 217 |
const cards = [];
|
| 218 |
let targetCard = null; // 用于保存需要展开的目标卡片
|
| 219 |
+
|
| 220 |
if (tab === "tools" || tab === "skills" || tab === "apps" || tab === "recommends") {
|
| 221 |
displayData.forEach(data => {
|
| 222 |
const card = createItemCard(data, currentUser);
|
|
|
|
| 234 |
fragment.appendChild(card);
|
| 235 |
});
|
| 236 |
}
|
| 237 |
+
|
| 238 |
contentArea.appendChild(fragment);
|
| 239 |
+
|
| 240 |
// ✨ 应用视口感知动画:可见卡片依次错开载入,滚动/分页进入视口的卡片自动补播动画
|
| 241 |
applyViewportAnimations(contentArea, cards, animationType, !append);
|
| 242 |
+
|
| 243 |
// 对新渲染的图片启用懒加载
|
| 244 |
lazyLoadImages(contentArea, "img:not(.lazy-loaded):not(.lazy-loading)");
|
| 245 |
+
|
| 246 |
// 🔔 自动展开目标卡片(如果有)
|
| 247 |
if (targetCard && !append) {
|
| 248 |
requestAnimationFrame(() => {
|
| 249 |
+
if (!canNavigate()) return;
|
| 250 |
// 滚动到卡片位置
|
| 251 |
+
scrollCardIntoView(targetCard);
|
| 252 |
// 模拟点击摘要区域展开卡片
|
| 253 |
const summary = targetCard.querySelector('.item-summary') || targetCard.firstElementChild;
|
| 254 |
if (summary) summary.click();
|
| 255 |
});
|
| 256 |
}
|
| 257 |
+
|
| 258 |
// 【问题1修复】首屏未找到目标卡片,fallback到独立详情页
|
| 259 |
+
if (expandItemId && !targetCard && !append && canNavigate()) {
|
| 260 |
(async () => {
|
| 261 |
try {
|
| 262 |
const res = await api.getItemById(expandItemId);
|
| 263 |
+
if (!canNavigate()) return;
|
| 264 |
if (res.status === "success" && res.data) {
|
| 265 |
const module = await import("../market/资源详情页面组件.js");
|
| 266 |
+
if (!canNavigate()) return;
|
| 267 |
const view = module.createItemDetailView(res.data, currentUser);
|
| 268 |
+
if (!canNavigate()) {
|
| 269 |
+
view._cleanup?.();
|
| 270 |
+
return;
|
| 271 |
+
}
|
| 272 |
window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } }));
|
| 273 |
}
|
| 274 |
} catch (e) {
|
| 275 |
+
if (!canNavigate()) return;
|
| 276 |
+
console.warn("展开卡片详情失败");
|
| 277 |
}
|
| 278 |
})();
|
| 279 |
}
|
| 280 |
+
|
| 281 |
state.displayedCount += displayData.length;
|
| 282 |
return displayData.length;
|
| 283 |
};
|
| 284 |
+
|
| 285 |
// ========== 分页加载更多函数 ==========
|
| 286 |
+
const loadMoreData = async (page, size, { isCurrent = () => true } = {}) => {
|
| 287 |
+
if (!isCurrent()) return [];
|
| 288 |
// 计算要显示的数据范围
|
| 289 |
const start = (page - 1) * size;
|
| 290 |
const end = start + size;
|
| 291 |
+
|
| 292 |
// 从全量数据中截取(统一走 _applySearchScope,与首屏处于同一数据空间)
|
| 293 |
const dataSlice = _applySearchScope(state.allData);
|
| 294 |
+
|
| 295 |
const batch = dataSlice.slice(start, end);
|
| 296 |
+
|
| 297 |
if (batch.length > 0) {
|
| 298 |
renderBatch(batch, true);
|
| 299 |
}
|
| 300 |
+
|
| 301 |
return batch;
|
| 302 |
};
|
| 303 |
+
|
| 304 |
// ========== 尝试从缓存加载 ==========
|
| 305 |
// 📌 本处的缓存口径与 任务榜/讨论区/提示词(统一走 性能优化工具.js 的
|
| 306 |
// readListCache,命中后一律调 silentRefresh 后台补新)**刻意不同**,不是历史遗留,勿顺手归一:
|
|
|
|
| 309 |
// - 那三个列表只缓存第一页,命中后数据必然不完整,故一律后台 silentRefresh 补新。
|
| 310 |
// 若强行统一成「总是后台刷新」,插件榜会在每次 Tab 切换时多发一次 200 条的全量请求。
|
| 311 |
// 获取完整缓存信息(包含过期状态)
|
| 312 |
+
const { value: cachedData, expired: isCacheExpired, found: hasCacheData } =
|
| 313 |
+
readSidebarListCache(cacheKey, { creators: tab === 'creators' });
|
| 314 |
+
|
| 315 |
// 有缓存时直接渲染缓存(搜索时也使用缓存,renderBatch 会处理 keyword 过滤)
|
| 316 |
// 🔍 创作者Tab有keyword时不使用缓存,每次都从后端搜索
|
| 317 |
// 🔧 修复:原实现还把「刚清空搜索框」也列为跳过缓存的理由,但 cacheKey 不含 keyword、
|
|
|
|
| 324 |
state.allData = proxiedData;
|
| 325 |
state.isFullyLoaded = true;
|
| 326 |
// 缓存是全量列表而非搜索结果,清除搜索标记,避免下次加载仍被当作搜索结果处理
|
| 327 |
+
if (tab === "creators" && !keyword) {
|
| 328 |
+
state.isSearchResult = false;
|
| 329 |
+
state.searchKeyword = "";
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
// 首屏渲染(仅第一页)—— 先过滤再切片,与 loadMoreData 同一数据空间
|
| 333 |
const scopedData = _applySearchScope(proxiedData);
|
| 334 |
renderBatch(scopedData.slice(0, pageSize), false);
|
| 335 |
+
|
| 336 |
// 如果有更多数据,启动分页加载器
|
| 337 |
if (scopedData.length > pageSize) {
|
| 338 |
+
_setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab, renderToken, getRenderToken);
|
| 339 |
}
|
| 340 |
+
|
| 341 |
// 【新增】Tab 切换后台静默刷新 —— 仅缓存过期时触发
|
| 342 |
if (isCacheExpired) {
|
| 343 |
console.log(`🔄 缓存已过期,${tab}_${sort} 启动后台静默刷新...`);
|
| 344 |
+
|
| 345 |
setTimeout(async () => {
|
| 346 |
// 保存当前上下文快照,防止网络延迟期间用户切换Tab导致竞态
|
| 347 |
const savedToken = renderToken;
|
| 348 |
const savedTab = tab;
|
| 349 |
const savedSort = sort;
|
| 350 |
+
|
| 351 |
// 防竞态检查1:回调开始执行时验证
|
| 352 |
if (savedToken !== getRenderToken()) return;
|
| 353 |
+
|
| 354 |
try {
|
| 355 |
+
const rawData = await fetchSidebarList({ tab: savedTab, sort: savedSort, background: true });
|
| 356 |
+
if (savedToken !== getRenderToken()) return;
|
| 357 |
+
const newData = proxyImages(rawData);
|
| 358 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
// 防竞态检查2:网络请求完成后再次验证
|
| 360 |
if (savedToken !== getRenderToken()) return;
|
| 361 |
+
|
| 362 |
// 🛡️ 空响应防护:云端返回空列表时不回写缓存、也不清屏重渲染。
|
| 363 |
// 本分支由「缓存过期」触发,是切 Tab 时最常走到的路径,若不��,
|
| 364 |
// 下方 state.allData = newData + contentArea.innerHTML = "" 会把
|
|
|
|
| 367 |
console.warn(`🛡️ ${savedTab}_${savedSort} 云端返回空列表,保留本地数据,跳过缓存回写`);
|
| 368 |
return;
|
| 369 |
}
|
| 370 |
+
|
| 371 |
// 只有token匹配时才同时更新state.allData和setCache
|
| 372 |
if (_shouldUpdateData(state.allData, newData)) {
|
| 373 |
console.log(`✅ ${savedTab}_${savedSort} 检测到新数据,执行静默更新`);
|
|
|
|
| 375 |
state.displayedCount = 0; // 重置分页计数
|
| 376 |
// 📴 持久化到 localStorage,保证重启后离线仍可展示
|
| 377 |
setCache(cacheKey, newData, getCacheExpireTime(), true);
|
| 378 |
+
|
| 379 |
// 更新创作者 sessionStorage 缓存
|
| 380 |
if (savedTab === "creators") {
|
| 381 |
saveCreatorsToSessionStorage(newData);
|
| 382 |
}
|
| 383 |
+
|
| 384 |
// 完整重新渲染(替代之前只更新数字的方案)
|
| 385 |
contentArea.innerHTML = "";
|
| 386 |
// 🔧 先过滤再切片:搜索场景下静默刷新同样会触发,
|
| 387 |
// 原实现在 newData 前 20 条里过滤,会漏掉靠后的匹配项
|
| 388 |
const scopedNewData = _applySearchScope(newData);
|
| 389 |
renderBatch(scopedNewData.slice(0, pageSize), false);
|
| 390 |
+
|
| 391 |
if (scopedNewData.length > pageSize) {
|
| 392 |
+
_setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, savedTab, savedToken, getRenderToken);
|
| 393 |
}
|
| 394 |
} else {
|
| 395 |
// 关键字段无变化 → 无需重渲染。但 newData 才是刚从云端取回的权威数据,
|
|
|
|
| 401 |
setCache(cacheKey, newData, getCacheExpireTime(), true);
|
| 402 |
}
|
| 403 |
} catch (err) {
|
| 404 |
+
if (savedToken !== getRenderToken()) return;
|
| 405 |
+
console.warn(`⚠️ ${savedTab}_${savedSort} 后台刷新失败`);
|
| 406 |
}
|
| 407 |
}, 0);
|
| 408 |
}
|
| 409 |
+
|
| 410 |
return;
|
| 411 |
}
|
| 412 |
+
|
| 413 |
// ========== 🔄 本地排序优先:同 tab 已有数据时直接排序渲染(强制刷新时跳过)==========
|
| 414 |
// 🔍 创作者Tab有keyword时不走本地排序优先,必须调用后端搜索API
|
| 415 |
// 🔍 创作者Tab清空搜索框后也不走本地排序优先,避免使用搜索时的部分结果
|
|
|
|
| 424 |
state.allData = locallySorted;
|
| 425 |
state.isFullyLoaded = true;
|
| 426 |
state.displayedCount = 0; // 重置已显示计数,因为数据重新排序了
|
| 427 |
+
|
| 428 |
// 首屏渲染(先过滤再切片)
|
| 429 |
const scopedSorted = _applySearchScope(locallySorted);
|
| 430 |
renderBatch(scopedSorted.slice(0, pageSize), false);
|
| 431 |
+
|
| 432 |
// 启动分页加载器
|
| 433 |
if (scopedSorted.length > pageSize) {
|
| 434 |
+
_setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab, renderToken, getRenderToken);
|
| 435 |
}
|
| 436 |
+
|
| 437 |
// 存入当前排序的缓存(📴 创作者数据同样持久化到 localStorage,保证重启后离线可展示)
|
| 438 |
setCache(cacheKey, locallySorted, getCacheExpireTime(), true);
|
| 439 |
+
|
| 440 |
return; // 本地排序后直接返回,不需要后台刷新(数据是同一批)
|
| 441 |
}
|
| 442 |
+
|
| 443 |
// ========== 🚀 缓存先行:阻塞前先用本地已有数据上屏 ==========
|
| 444 |
// 走到这里说明上面两条快路径都没命中:要么是 force 强制刷新(刻意跳过缓存),
|
| 445 |
// 要么是本次会话还没有该 tab 的完整数据。弱网下一次列表请求最长要烧满整个重试预算,
|
| 446 |
// 期间用户只能盯骨架屏;此刻若本地已有可用数据,就先渲染首屏,网络请求照常往下走,
|
| 447 |
// 回来后按 renderToken + _shouldUpdateData 决定是否重渲染(与上方静默刷新同口径)。
|
| 448 |
+
// 🔍 新关键词的创作者搜索不参与缓存先行,避免显示其它查询结果;只��强制刷新当前同一关键词时,
|
| 449 |
+
// 才临时复用精确匹配的上一批搜索结果(state.searchKeyword),失败时继续保留它。
|
| 450 |
let renderedInstant = false;
|
| 451 |
+
if (!isCreatorSearching || sameCreatorSearchData) {
|
| 452 |
// 可立即上屏的数据:当前排序的列表缓存(force 时被跳过)→ 创作者 sessionStorage 降级副本
|
| 453 |
+
let instantData = sameCreatorSearchData ||
|
| 454 |
+
((hasCacheData && Array.isArray(cachedData) && cachedData.length > 0) ? cachedData : null);
|
| 455 |
if (!instantData && tab === "creators") {
|
| 456 |
const sessionData = getCreatorsFromSessionStorage();
|
| 457 |
if (Array.isArray(sessionData) && sessionData.length > 0) instantData = sessionData;
|
|
|
|
| 464 |
const sortedData = sortDataLocally(proxyImages(instantData), tab, sort);
|
| 465 |
state.allData = sortedData;
|
| 466 |
state.isFullyLoaded = true;
|
| 467 |
+
state.isSearchResult = !!sameCreatorSearchData;
|
| 468 |
+
state.searchKeyword = sameCreatorSearchData ? keyword : "";
|
| 469 |
// 🔧 先过滤再切片(tools/skills/apps/recommends 带 keyword 时同样会进入本分支)
|
| 470 |
const scopedInstant = _applySearchScope(sortedData);
|
| 471 |
renderBatch(scopedInstant.slice(0, pageSize), false);
|
| 472 |
if (scopedInstant.length > pageSize) {
|
| 473 |
+
_setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab, renderToken, getRenderToken);
|
| 474 |
}
|
| 475 |
renderedInstant = true;
|
| 476 |
}
|
| 477 |
}
|
| 478 |
+
|
| 479 |
// ========== 显示加载骨架屏(已有本地数据上屏时跳过,避免闪屏)==========
|
| 480 |
if (!renderedInstant) {
|
| 481 |
contentArea.innerHTML = "";
|
| 482 |
const skeleton = createSkeleton(tab === "creators" ? "list" : "card", 3);
|
| 483 |
contentArea.appendChild(skeleton);
|
| 484 |
}
|
| 485 |
+
|
| 486 |
// ========== 从网络加载数据 ==========
|
| 487 |
try {
|
| 488 |
+
const rawData = await fetchSidebarList({ tab, sort, keyword, force });
|
| 489 |
+
if (renderToken !== getRenderToken()) return;
|
| 490 |
+
const realData = proxyImages(rawData);
|
| 491 |
+
if (renderToken !== getRenderToken()) return;
|
| 492 |
+
if (tab === 'creators') {
|
| 493 |
+
state.isSearchResult = !!keyword;
|
| 494 |
+
state.searchKeyword = keyword || '';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 495 |
}
|
| 496 |
+
|
| 497 |
// 存入缓存(📴 创作者数据同样持久化到 localStorage,保证重启后离线仍可展示;搜索结果不存入缓存)
|
| 498 |
const isCreatorSearch = tab === "creators" && keyword;
|
| 499 |
// 🛡️ 空响应防护:创作者搜索命中 0 条是有效答案(下方要渲染「没有搜索到相关内容」),
|
|
|
|
| 504 |
} else if (!isCreatorSearch) {
|
| 505 |
setCache(cacheKey, realData, getCacheExpireTime(), true);
|
| 506 |
}
|
| 507 |
+
|
| 508 |
// 创作者非搜索数据存入 sessionStorage(用于离线降级)
|
| 509 |
if (tab === "creators" && !isCreatorSearch && !isEmptyOverwrite) {
|
| 510 |
saveCreatorsToSessionStorage(realData);
|
| 511 |
}
|
| 512 |
+
|
| 513 |
state.isFullyLoaded = true;
|
| 514 |
+
|
| 515 |
// 🛡️ 空响应且本地数据已上屏:保持当前画面,不用空列表清屏(与缓存同口径不回写)
|
| 516 |
if (isEmptyOverwrite && renderedInstant) {
|
| 517 |
return;
|
| 518 |
}
|
| 519 |
+
|
| 520 |
// 🚀 缓存先行场景:网络数据与已上屏的本地数据无实质差异时不重渲染,避免清屏重画
|
| 521 |
// 打断用户已经滚动的阅读位置(与上方后台静默刷新同口径)。缓存已在上面回写权威新数据,
|
| 522 |
// 此处刻意不更新 state.allData,保持与已渲染卡片一致,防止分页加载错位
|
| 523 |
if (renderedInstant && !_shouldUpdateData(state.allData, realData)) {
|
| 524 |
return;
|
| 525 |
}
|
| 526 |
+
|
| 527 |
// 更新状态
|
| 528 |
state.allData = realData;
|
| 529 |
+
|
| 530 |
// 渲染首屏(先过滤再切片:tools/skills/apps/recommends 的 keyword 是本地搜索,
|
| 531 |
// realData 是未过滤的全量数据,原实现在前 20 条里过滤会漏掉靠后的匹配项)
|
| 532 |
const scopedReal = _applySearchScope(realData);
|
| 533 |
renderBatch(scopedReal.slice(0, pageSize), false);
|
| 534 |
+
|
| 535 |
// 启动分页加载器
|
| 536 |
if (scopedReal.length > pageSize) {
|
| 537 |
+
_setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab, renderToken, getRenderToken);
|
| 538 |
}
|
| 539 |
+
|
| 540 |
} catch (error) {
|
| 541 |
// 🔍 防竞态:本次加载已被更新的加载取代(切Tab、换排序、继续输入关键词)时直接退出。
|
| 542 |
// renderBatch 内部本来就有这道守卫,但它之外的降级分支会直接写 contentArea.innerHTML
|
|
|
|
| 551 |
console.log(`⏹️ ${tab}_${sort} 搜索请求已被新关键词取代`);
|
| 552 |
return;
|
| 553 |
}
|
| 554 |
+
console.error("数据加载失败");
|
| 555 |
+
|
| 556 |
// 🚀 缓存先行场景:首屏已经用本地数据上屏,网络失败时保持当前画面即可,不再走下面的
|
| 557 |
// 降级分支(否则会把同一批数据重渲染一遍、重播卡片动画)。与上方后台静默刷新失败一致:
|
| 558 |
// 只记录日志,不额外打扰用户
|
| 559 |
if (renderedInstant) {
|
| 560 |
console.warn(`📴 ${tab}_${sort} 网络刷新失败,保持已渲染的本地数据`);
|
| 561 |
+
if (force) showToast(t('feedback.refresh_failed_cached'), 'warning');
|
| 562 |
return;
|
| 563 |
}
|
| 564 |
+
|
| 565 |
// 🚀 回退到任何可用缓存(包括过期的)
|
| 566 |
// 🔍 创作者Tab搜索场景跳过此分支:renderBatch 对创作者不做本地过滤,
|
| 567 |
// 直接渲染全量缓存会导致搜索结果未过滤,改由下方本地搜索降级分支处理
|
|
|
|
| 572 |
// 带 keyword 时会进来;renderBatch 不再自行过滤,此处必须显式过滤
|
| 573 |
const scopedCached = _applySearchScope(cachedData);
|
| 574 |
renderBatch(scopedCached.slice(0, pageSize), false);
|
| 575 |
+
|
| 576 |
// 如果是过期缓存,显示提示
|
| 577 |
// 🔧 改用统一提示组件 + 词典(原为手写 div、无动画不排队,且文案硬编码中文)
|
| 578 |
if (isCacheExpired) {
|
|
|
|
| 580 |
}
|
| 581 |
return;
|
| 582 |
}
|
| 583 |
+
|
| 584 |
// 🚀 创作者Tab:尝试从 sessionStorage 或内存中降级
|
| 585 |
if (tab === "creators") {
|
| 586 |
let fallbackData = null;
|
| 587 |
+
|
| 588 |
// 1. 优先从当前内存状态获取
|
| 589 |
+
if (state.allData && state.allData.length > 0 &&
|
| 590 |
+
(!state.isSearchResult || state.searchKeyword === keyword)) {
|
| 591 |
fallbackData = state.allData;
|
| 592 |
}
|
| 593 |
// 2. 从本地列表缓存获取(含过期缓存,搜索结果不入库故不会污染)
|
|
|
|
| 602 |
if (!fallbackData) {
|
| 603 |
fallbackData = findExistingTabData("creators");
|
| 604 |
}
|
| 605 |
+
|
| 606 |
if (fallbackData && fallbackData.length > 0) {
|
| 607 |
let displayData = fallbackData;
|
| 608 |
+
|
| 609 |
// 搜索场景:执行本地搜索
|
| 610 |
if (keyword) {
|
| 611 |
displayData = searchCreatorsLocally(keyword, fallbackData);
|
| 612 |
if (displayData.length === 0) {
|
| 613 |
+
const emptyState = document.createElement('div');
|
| 614 |
+
emptyState.style.cssText = 'text-align:center; padding:40px 20px; color:#888;';
|
| 615 |
+
emptyState.textContent = t('feedback.no_search_results');
|
| 616 |
+
emptyState.setAttribute('role', 'status');
|
| 617 |
+
contentArea.replaceChildren(emptyState);
|
| 618 |
+
|
| 619 |
// 🔧 改用统一提示组件 + 词典(原为手写 div 且文案硬编码中文)
|
| 620 |
showToast(t('feedback.search_local_result'), 'warning');
|
| 621 |
return;
|
| 622 |
}
|
| 623 |
}
|
| 624 |
+
|
| 625 |
// 按当前排序排序
|
| 626 |
displayData = sortDataLocally(displayData, tab, sort);
|
| 627 |
state.allData = displayData;
|
|
|
|
| 629 |
// searchCreatorsLocally 过滤),保证各渲染入口口径完全一致
|
| 630 |
const scopedFallback = _applySearchScope(displayData);
|
| 631 |
renderBatch(scopedFallback.slice(0, pageSize), false);
|
| 632 |
+
|
| 633 |
// 启动分页加载器
|
| 634 |
if (scopedFallback.length > pageSize) {
|
| 635 |
+
_setupPaginationLoader(contentArea, state, pageSize, loadMoreData, keyword, tab, renderToken, getRenderToken);
|
| 636 |
}
|
| 637 |
+
|
| 638 |
// 🔧 改用统一提示组件 + 词典(原为手写 div 且两条文案均硬编码中文)
|
| 639 |
showToast(keyword ? t('feedback.search_local_result') : t('feedback.cache_fallback'), 'warning');
|
| 640 |
return;
|
| 641 |
} else if (keyword) {
|
| 642 |
+
renderListLoadError(contentArea, t('common.network_error_retry'), () => loadSidebarContent({
|
| 643 |
+
tab, sort, keyword, contentArea, currentUser, renderToken, getRenderToken,
|
| 644 |
+
force: true, expandItemId, isNavigationCurrent
|
| 645 |
+
}), t);
|
|
|
|
| 646 |
return;
|
| 647 |
}
|
| 648 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 649 |
|
| 650 |
+
// 无本地数据时提供原位重试,保留当前 ComfyUI 页面与榜单上下文。
|
| 651 |
+
renderListLoadError(contentArea, t('common.network_error_retry'), () => loadSidebarContent({
|
| 652 |
+
tab, sort, keyword, contentArea, currentUser, renderToken, getRenderToken,
|
| 653 |
+
force: true, expandItemId, isNavigationCurrent
|
| 654 |
+
}), t);
|
|
|
|
|
|
|
|
|
|
| 655 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 656 |
}
|
| 657 |
|
| 658 |
|
|
|
|
| 680 |
function _shouldUpdateData(oldData, newData) {
|
| 681 |
if (!oldData || !newData) return true;
|
| 682 |
if (oldData.length !== newData.length) return true;
|
| 683 |
+
|
| 684 |
// 检查ID顺序是否一致(检测排序变化、新增/删除)
|
| 685 |
// 比对条数覆盖首屏渲染量(PAGE_SIZE),避免首屏可见卡片(第 11~20 条)的版本/数字更新被漏检
|
| 686 |
const checkCount = Math.min(PAGE_SIZE, oldData.length);
|
|
|
|
| 688 |
const oldId = oldData[i].id || oldData[i].account;
|
| 689 |
const newId = newData[i].id || newData[i].account;
|
| 690 |
if (oldId !== newId) return true;
|
| 691 |
+
|
| 692 |
// 检查关键数字字段
|
| 693 |
const keyFields = ['likes', 'downloads', 'uses', 'views', 'daily_views', 'recent_tips', 'favorites', 'rating_avg', 'rating_count'];
|
| 694 |
for (const field of keyFields) {
|
前端页面/core/全局配置.js
CHANGED
|
@@ -18,22 +18,28 @@
|
|
| 18 |
// 🌐 API 配置
|
| 19 |
// ==========================================
|
| 20 |
|
| 21 |
-
/** @type {{ BASE_URL: string, TIMEOUT: number, MAX_RETRIES: number, RETRY_DELAY: number, LIST_RETRIES: number, SEARCH_TIMEOUT: number, SEARCH_RETRIES: number, BACKGROUND_TIMEOUT: number, BACKGROUND_RETRIES: number, CLOUD_DOWN_COOLDOWN: number, SEARCH_COMPONENT_ID: string }} */
|
| 22 |
export const API = {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
// 云端 API 基础地址
|
| 24 |
BASE_URL: "https://zhiwei666-comfyui-ranking-api.hf.space",
|
| 25 |
|
| 26 |
// 请求超时时间(毫秒)
|
| 27 |
TIMEOUT: 30000,
|
| 28 |
-
|
|
|
|
|
|
|
| 29 |
// GET 请求重试次数(实际尝试 MAX_RETRIES + 1 次)
|
| 30 |
// 🔧 修复:此前本值为 3 且全库无人读取,真正的重试次数硬编码在 网络请求_基础设施.js 中为 2,
|
| 31 |
// 现改为由基础设施读取本配置;取值 2 以保持既有的「共 3 次尝试」行为不变
|
| 32 |
MAX_RETRIES: 2,
|
| 33 |
-
|
| 34 |
// 重试间隔(毫秒,指数退避基数:1s、2s、4s…)
|
| 35 |
RETRY_DELAY: 1000,
|
| 36 |
-
|
| 37 |
// 🐢 弱网预算分级(仅作用于 GET,由 网络请求_基础设施.js 按 endpoint 路径精确匹配)
|
| 38 |
// 默认预算下弱网一次列表请求最长要烧 3×30s + 1s + 2s = 93s,期间用户只能盯骨架屏。
|
| 39 |
// 列表类:首屏已可由本地缓存先行上屏(见 侧边栏数据引擎.js),且列表往往是会话的第一个请求、
|
|
@@ -43,6 +49,7 @@ export const API = {
|
|
| 43 |
// 搜索类:必然发生在列表已加载之后(空间已被唤醒),可放心收紧,93s → 10s×2 + 1s = 21s
|
| 44 |
SEARCH_TIMEOUT: 10000,
|
| 45 |
SEARCH_RETRIES: 1,
|
|
|
|
| 46 |
|
| 47 |
// 🐢 后台补新预算(仅作用于 GET,由 网络请求_基础设施.js 按「本地是否已有可兜底数据」判定)
|
| 48 |
// 七大列表视图都是「本地数据先上屏 → 再发请求补新」,此时界面上已经有内容,
|
|
@@ -50,6 +57,7 @@ export const API = {
|
|
| 50 |
// 云端不通时会把前台请求(详情页、点赞、轮询)全部挤到排队,表现为「切哪个界面都要等」。
|
| 51 |
// 故一旦本地已有该请求的缓存(含过期),单次超时压到 10s 且不再重试,最坏耗时 10s
|
| 52 |
BACKGROUND_TIMEOUT: 10000,
|
|
|
|
| 53 |
BACKGROUND_RETRIES: 0,
|
| 54 |
|
| 55 |
// ☁️ 云端不可达冷却(消费方见 状态管理.js 的「云端可达性」段落)
|
|
@@ -59,15 +67,48 @@ export const API = {
|
|
| 59 |
// 并发额度全部让给前台。被动探测——冷却一过,下一个真实请求充当探针;
|
| 60 |
// 期间任意一次成功响应(含 POST/PUT/DELETE)立即清零,不需等到冷却结束
|
| 61 |
CLOUD_DOWN_COOLDOWN: 60 * 1000,
|
| 62 |
-
|
| 63 |
// 🔍 创作者搜索请求的取消分组 ID
|
| 64 |
// 用户在搜索框继续输入时,侧边栏数据引擎.js 据此撤销上一次仍在途的搜索请求。
|
| 65 |
// 不撤销的话,作废请求会持续占用并发额度(最多 6 个)与超时预算,
|
| 66 |
// 把最新那次搜索挤到后面排队。仅搜索请求归入本分组,不影响其它任何请求
|
| 67 |
-
SEARCH_COMPONENT_ID: "sidebar-search"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
};
|
| 69 |
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
// ==========================================
|
| 72 |
// 🔐 管理员配置 (P0安全修复:管理员账号配置化)
|
| 73 |
// ==========================================
|
|
@@ -88,6 +129,9 @@ export function isAdmin(account) {
|
|
| 88 |
// ==========================================
|
| 89 |
// 💾 缓存配置
|
| 90 |
// ==========================================
|
|
|
|
|
|
|
|
|
|
| 91 |
export const CACHE = {
|
| 92 |
// 缓存键前缀
|
| 93 |
PREFIX: "ComfyRanking_",
|
|
@@ -107,8 +151,14 @@ export const CACHE = {
|
|
| 107 |
|
| 108 |
// 内存缓存最大条目数
|
| 109 |
MAX_MEMORY_ITEMS: 100,
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
// localStorage 键名(旧版兼容)
|
|
|
|
|
|
|
|
|
|
| 112 |
LEGACY_KEYS: {
|
| 113 |
USER: "ComfyCommunity_User",
|
| 114 |
TOKEN: "ComfyCommunity_Token",
|
|
@@ -119,9 +169,14 @@ export const CACHE = {
|
|
| 119 |
|
| 120 |
// 本地背景存储键(不上传云端)
|
| 121 |
LOCAL_KEYS: {
|
|
|
|
|
|
|
| 122 |
SIDEBAR_BACKGROUND: "ComfyRanking_SidebarBackground", // 工具背景图 Base64
|
| 123 |
PROFILE_BANNER_CACHE: "ComfyRanking_ProfileBannerCache", // 个人资料背景图本地缓存
|
| 124 |
ACQUIRED_ITEMS: "ComfyRanking_AcquiredItems", // 已获取的资源记录
|
|
|
|
|
|
|
|
|
|
| 125 |
// 🚀 新增:私信和通知本地缓存
|
| 126 |
NOTIFICATIONS: "ComfyRanking_Notifications", // 通知消息缓存
|
| 127 |
CHAT_LIST: "ComfyRanking_ChatList", // 对话列表缓存
|
|
@@ -130,6 +185,12 @@ export const CACHE = {
|
|
| 130 |
CHAT_CLEAR_PREFIX: "ComfyRanking_ChatCleared_" // 聊天清空时间戳前缀
|
| 131 |
},
|
| 132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
// 🚀 新增:消息轮询配置
|
| 134 |
MESSAGE_POLL: {
|
| 135 |
INTERVAL: 30 * 1000, // 轮询间隔:30秒
|
|
@@ -147,12 +208,18 @@ export const PAGINATION = {
|
|
| 147 |
|
| 148 |
// 创作者每页数量
|
| 149 |
CREATORS_PAGE_SIZE: 12,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
// 评论每页数量
|
| 152 |
COMMENTS_PAGE_SIZE: 10,
|
| 153 |
|
| 154 |
// 消息每页数量
|
| 155 |
MESSAGES_PAGE_SIZE: 20,
|
|
|
|
| 156 |
|
| 157 |
// 滚动加载触发阈值(距底部像素)
|
| 158 |
SCROLL_THRESHOLD: 300,
|
|
@@ -206,6 +273,19 @@ export const TIP_LEVELS = {
|
|
| 206 |
// 🎨 UI 配置
|
| 207 |
// ==========================================
|
| 208 |
export const UI = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
// Toast 消息配置
|
| 210 |
TOAST: {
|
| 211 |
DURATION: 3000, // 显示时长(毫秒)
|
|
@@ -390,7 +470,7 @@ export function getCurrentAccount() {
|
|
| 390 |
return userData?.user?.account || null;
|
| 391 |
}
|
| 392 |
} catch (e) {
|
| 393 |
-
console.warn("获取当前账号失败
|
| 394 |
}
|
| 395 |
return null;
|
| 396 |
}
|
|
@@ -456,8 +536,9 @@ export function getTipLevelConfig() {
|
|
| 456 |
// 缓存键:ComfyCommunity_ProfileCache_{account}
|
| 457 |
|
| 458 |
const PROFILE_CACHE_PREFIX = "ComfyCommunity_ProfileCache_";
|
| 459 |
-
const _pendingProfileRequests = new Map(); //
|
| 460 |
const PENDING_TIMEOUT = 30000; // 挂起请求超时清理时间(毫秒)
|
|
|
|
| 461 |
|
| 462 |
// ==========================================
|
| 463 |
// 🔒 HTML 转义(底层公共工具)
|
|
@@ -488,6 +569,19 @@ function _mapAvatarField(profile) {
|
|
| 488 |
}
|
| 489 |
}
|
| 490 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 491 |
/**
|
| 492 |
* 🚀 获取缓存的用户资料(同步)
|
| 493 |
* @param {string} account - 用户账号
|
|
@@ -499,8 +593,14 @@ export function getCachedProfile(account) {
|
|
| 499 |
const cached = localStorage.getItem(PROFILE_CACHE_PREFIX + account);
|
| 500 |
if (!cached) return null;
|
| 501 |
const profile = JSON.parse(cached);
|
| 502 |
-
|
| 503 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 504 |
} catch {
|
| 505 |
return null;
|
| 506 |
}
|
|
@@ -512,9 +612,9 @@ export function getCachedProfile(account) {
|
|
| 512 |
* @param {Object} profile - 用户资料
|
| 513 |
*/
|
| 514 |
export function setCachedProfile(account, profile) {
|
| 515 |
-
if (!account || !profile) return;
|
| 516 |
try {
|
| 517 |
-
localStorage.setItem(PROFILE_CACHE_PREFIX + account, JSON.stringify(profile));
|
| 518 |
} catch {}
|
| 519 |
}
|
| 520 |
|
|
@@ -531,27 +631,32 @@ export function getProfileWithSWR(account, apiFn, onUpdate) {
|
|
| 531 |
// 第一轨:同步返回缓存
|
| 532 |
const cached = getCachedProfile(account);
|
| 533 |
|
| 534 |
-
// 第二轨:
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 538 |
_mapAvatarField(profile);
|
| 539 |
setCachedProfile(account, profile);
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
}
|
| 543 |
-
return profile;
|
| 544 |
-
}).catch(() => null).finally(() => {
|
| 545 |
-
_pendingProfileRequests.delete(account);
|
| 546 |
-
});
|
| 547 |
-
_pendingProfileRequests.set(account, promise);
|
| 548 |
-
// ⏱️ 超时自动清理:防止请求永不完成导致内存泄漏
|
| 549 |
-
setTimeout(() => {
|
| 550 |
-
if (_pendingProfileRequests.has(account)) {
|
| 551 |
-
_pendingProfileRequests.delete(account);
|
| 552 |
}
|
| 553 |
-
}
|
| 554 |
}
|
|
|
|
| 555 |
|
| 556 |
return cached;
|
| 557 |
}
|
|
@@ -569,6 +674,9 @@ export function getProfileWithSWR(account, apiFn, onUpdate) {
|
|
| 569 |
*/
|
| 570 |
export function renderAvatarWithSWR(options) {
|
| 571 |
const { account, fallbackAvatar, fallbackName, avatarSize = 24, apiFn, containerId } = options;
|
|
|
|
|
|
|
|
|
|
| 572 |
|
| 573 |
// 从缓存获取
|
| 574 |
const cached = getCachedProfile(account);
|
|
@@ -578,15 +686,28 @@ export function renderAvatarWithSWR(options) {
|
|
| 578 |
// 后台静默校对并更新 DOM
|
| 579 |
if (apiFn && containerId) {
|
| 580 |
setTimeout(() => {
|
|
|
|
|
|
|
| 581 |
getProfileWithSWR(account, apiFn, (profile) => {
|
| 582 |
-
|
| 583 |
-
|
| 584 |
|
| 585 |
const avatarImg = container.querySelector('.swr-avatar');
|
| 586 |
const nameSpan = container.querySelector('.swr-name');
|
| 587 |
|
| 588 |
if (avatarImg && profile.avatar) {
|
| 589 |
-
avatarImg.src = profile.avatar;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 590 |
}
|
| 591 |
if (nameSpan && profile.name) {
|
| 592 |
nameSpan.textContent = profile.name;
|
|
@@ -598,12 +719,12 @@ export function renderAvatarWithSWR(options) {
|
|
| 598 |
// 生成初始 HTML(无头像时显示首字母背景)
|
| 599 |
// 🔒 XSS防护:avatar/name/initial 均来自用户可修改的资料(昵称首字母可能是 < ),
|
| 600 |
// 写入 HTML 前必须转义;上方 SWR 刷新路径用的是 .src / .textContent,本身就是安全的。
|
| 601 |
-
const initial = (name || account || 'U')[0].toUpperCase();
|
| 602 |
const avatarHtml = avatar
|
| 603 |
-
? `<img class="swr-avatar" src="${escapeHtml(avatar)}" style="width: ${
|
| 604 |
-
: `<div class="swr-avatar" style="width: ${
|
| 605 |
|
| 606 |
-
return `<span id="${escapeHtml(containerId)}" style="display: inline-flex; align-items: center; gap: 6px;">${avatarHtml}<span class="swr-name">${escapeHtml(name)}</span></span>`;
|
| 607 |
}
|
| 608 |
|
| 609 |
|
|
@@ -611,6 +732,7 @@ export function renderAvatarWithSWR(options) {
|
|
| 611 |
// 📦 导出默认配置对象
|
| 612 |
// ==========================================
|
| 613 |
export default {
|
|
|
|
| 614 |
API,
|
| 615 |
CACHE,
|
| 616 |
PAGINATION,
|
|
@@ -632,3 +754,10 @@ export default {
|
|
| 632 |
getProfileWithSWR,
|
| 633 |
renderAvatarWithSWR
|
| 634 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
// 🌐 API 配置
|
| 19 |
// ==========================================
|
| 20 |
|
| 21 |
+
/** @type {{ BASE_URL: string, TIMEOUT: number, VIDEO_UPLOAD_TIMEOUT: number, VIDEO_UPLOAD_RESPONSE_MAX_CHARS: number, MAX_RETRIES: number, RETRY_DELAY: number, LIST_RETRIES: number, SEARCH_TIMEOUT: number, SEARCH_RETRIES: number, PROFILE_RESOURCE_TIMEOUT: number, BACKGROUND_TIMEOUT: number, BACKGROUND_RETRIES: number, WORKFLOW_DOWNLOAD_TIMEOUT: number, CLOUD_DOWN_COOLDOWN: number, SEARCH_COMPONENT_ID: string, IMAGE_PROXY_PATH: string, PRIVATE_GET_PATTERNS: RegExp[], PUBLIC_CONDITIONAL_GET_PATHS: string[], CONDITIONAL_ETAG_PATTERN: RegExp }} */
|
| 22 |
export const API = {
|
| 23 |
+
// 仅公开榜单使用由正文派生的条件验证器,私有响应仍禁止留存。
|
| 24 |
+
PUBLIC_CONDITIONAL_GET_PATHS: ['/api/items', '/api/creators', '/api/creators/search', '/api/posts', '/api/prompts', '/api/tasks'],
|
| 25 |
+
CONDITIONAL_ETAG_PATTERN: /^W\/"rk-[a-f0-9]{64}"$/,
|
| 26 |
+
|
| 27 |
// 云端 API 基础地址
|
| 28 |
BASE_URL: "https://zhiwei666-comfyui-ranking-api.hf.space",
|
| 29 |
|
| 30 |
// 请求超时时间(毫秒)
|
| 31 |
TIMEOUT: 30000,
|
| 32 |
+
VIDEO_UPLOAD_TIMEOUT: 300000,
|
| 33 |
+
VIDEO_UPLOAD_RESPONSE_MAX_CHARS: 65536,
|
| 34 |
+
|
| 35 |
// GET 请求重试次数(实际尝试 MAX_RETRIES + 1 次)
|
| 36 |
// 🔧 修复:此前本值为 3 且全库无人读取,真正的重试次数硬编码在 网络请求_基础设施.js 中为 2,
|
| 37 |
// 现改为由基础设施读取本配置;取值 2 以保持既有的「共 3 次尝试」行为不变
|
| 38 |
MAX_RETRIES: 2,
|
| 39 |
+
|
| 40 |
// 重试间隔(毫秒,指数退避基数:1s、2s、4s…)
|
| 41 |
RETRY_DELAY: 1000,
|
| 42 |
+
|
| 43 |
// 🐢 弱网预算分级(仅作用于 GET,由 网络请求_基础设施.js 按 endpoint 路径精确匹配)
|
| 44 |
// 默认预算下弱网一次列表请求最长要烧 3×30s + 1s + 2s = 93s,期间用户只能盯骨架屏。
|
| 45 |
// 列表类:首屏已可由本地缓存先行上屏(见 侧边栏数据引擎.js),且列表往往是会话的第一个请求、
|
|
|
|
| 49 |
// 搜索类:必然发生在列表已加载之后(空间已被唤醒),可放心收紧,93s → 10s×2 + 1s = 21s
|
| 50 |
SEARCH_TIMEOUT: 10000,
|
| 51 |
SEARCH_RETRIES: 1,
|
| 52 |
+
PROFILE_RESOURCE_TIMEOUT: 5000,
|
| 53 |
|
| 54 |
// 🐢 后台补新预算(仅作用于 GET,由 网络请求_基础设施.js 按「本地是否已有可兜底数据」判定)
|
| 55 |
// 七大列表视图都是「本地数据先上屏 → 再发请求补新」,此时界面上已经有内容,
|
|
|
|
| 57 |
// 云端不通时会把前台请求(详情页、点赞、轮询)全部挤到排队,表现为「切哪个界面都要等」。
|
| 58 |
// 故一旦本地已有该请求的缓存(含过期),单次超时压到 10s 且不再重试,最坏耗时 10s
|
| 59 |
BACKGROUND_TIMEOUT: 10000,
|
| 60 |
+
WORKFLOW_DOWNLOAD_TIMEOUT: 120000, // 工作流流式下载预算(2 分钟)
|
| 61 |
BACKGROUND_RETRIES: 0,
|
| 62 |
|
| 63 |
// ☁️ 云端不可达冷却(消费方见 状态管理.js 的「云端可达性」段落)
|
|
|
|
| 67 |
// 并发额度全部让给前台。被动探测——冷却一过,下一个真实请求充当探针;
|
| 68 |
// 期间任意一次成功响应(含 POST/PUT/DELETE)立即清零,不需等到冷却结束
|
| 69 |
CLOUD_DOWN_COOLDOWN: 60 * 1000,
|
| 70 |
+
|
| 71 |
// 🔍 创作者搜索请求的取消分组 ID
|
| 72 |
// 用户在搜索框继续输入时,侧边栏数据引擎.js 据此撤销上一次仍在途的搜索请求。
|
| 73 |
// 不撤销的话,作废请求会持续占用并发额度(最多 6 个)与超时预算,
|
| 74 |
// 把最新那次搜索挤到后面排队。仅搜索请求归入本分组,不影响其它任何请求
|
| 75 |
+
SEARCH_COMPONENT_ID: "sidebar-search",
|
| 76 |
+
// 上传相对路径的云端图片读取入口。
|
| 77 |
+
IMAGE_PROXY_PATH: "/api/image_proxy",
|
| 78 |
+
// 身份相关或私有 GET 禁止通用响应缓存;公开列表仍使用原缓存策略。
|
| 79 |
+
PRIVATE_GET_PATTERNS: [
|
| 80 |
+
/^\/api\/(wallet|messages|chats|admin|disputes|users)(?:\/|$)/,
|
| 81 |
+
/^\/api\/my-(posts|prompts|tasks)(?:\/|$)/,
|
| 82 |
+
/^\/api\/(tasks|prompts)\/[^/]+$/
|
| 83 |
+
]
|
| 84 |
};
|
| 85 |
|
| 86 |
|
| 87 |
+
// 发布视频的本地读取预算,不改变云端上传额度。
|
| 88 |
+
export const PUBLISH_MEDIA_LIMITS = Object.freeze({
|
| 89 |
+
MAX_IMAGES: 9,
|
| 90 |
+
IMAGE_MAX_EDGE: 1920,
|
| 91 |
+
IMAGE_QUALITY: 0.85,
|
| 92 |
+
VIDEO_MAX_BYTES: 100 * 1024 * 1024,
|
| 93 |
+
VIDEO_MAX_SECONDS: 180,
|
| 94 |
+
VIDEO_METADATA_TIMEOUT: 15000
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
// 上传前处理预算;浏览器解码本身仍由宿主管理。
|
| 98 |
+
export const IMAGE_PROCESS = {
|
| 99 |
+
HEADER_BYTES: 256 * 1024,
|
| 100 |
+
MAX_INPUT_BYTES: 50 * 1024 * 1024,
|
| 101 |
+
MAX_PIXELS: 40 * 1024 * 1024,
|
| 102 |
+
MAX_SOURCE_EDGE: 16384,
|
| 103 |
+
MAX_OUTPUT_EDGE: 4096,
|
| 104 |
+
MAX_OUTPUT_BYTES: 5 * 1024 * 1024,
|
| 105 |
+
TIMEOUT: 15000,
|
| 106 |
+
MIN_QUALITY: 0.3,
|
| 107 |
+
QUALITY_STEP: 0.1,
|
| 108 |
+
AVATAR_MAX_BYTES: 2 * 1024 * 1024,
|
| 109 |
+
AVATAR_SIZE: 512
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
// ==========================================
|
| 113 |
// 🔐 管理员配置 (P0安全修复:管理员账号配置化)
|
| 114 |
// ==========================================
|
|
|
|
| 129 |
// ==========================================
|
| 130 |
// 💾 缓存配置
|
| 131 |
// ==========================================
|
| 132 |
+
const RESOURCE_LIST_PREFIXES = [
|
| 133 |
+
"ListCache_tools_", "ListCache_skills_", "ListCache_apps_", "ListCache_recommends_"
|
| 134 |
+
];
|
| 135 |
export const CACHE = {
|
| 136 |
// 缓存键前缀
|
| 137 |
PREFIX: "ComfyRanking_",
|
|
|
|
| 151 |
|
| 152 |
// 内存缓存最大条目数
|
| 153 |
MAX_MEMORY_ITEMS: 100,
|
| 154 |
+
|
| 155 |
+
// 本插件 localStorage 持久缓存的估算容量上限(不影响用户/会话等其它键)
|
| 156 |
+
MAX_STORAGE_BYTES: 2 * 1024 * 1024,
|
| 157 |
|
| 158 |
// localStorage 键名(旧版兼容)
|
| 159 |
+
SESSION_REMEMBER_TTL: 30 * 24 * 60 * 60 * 1000,
|
| 160 |
+
INSTALLED_VERSION_UNCONFIRMED: "__installed_unconfirmed__", // 安装成功但没有实际版本证明
|
| 161 |
+
SESSION_TEMP_TTL: 24 * 60 * 60 * 1000,
|
| 162 |
LEGACY_KEYS: {
|
| 163 |
USER: "ComfyCommunity_User",
|
| 164 |
TOKEN: "ComfyCommunity_Token",
|
|
|
|
| 169 |
|
| 170 |
// 本地背景存储键(不上传云端)
|
| 171 |
LOCAL_KEYS: {
|
| 172 |
+
BANNER_CONFIG: "ComfyRanking_BannerConfig", // 广告配置持久缓存
|
| 173 |
+
CREATORS_SESSION: "ComfyRanking_CreatorsCache", // 沿用创作者临时缓存键
|
| 174 |
SIDEBAR_BACKGROUND: "ComfyRanking_SidebarBackground", // 工具背景图 Base64
|
| 175 |
PROFILE_BANNER_CACHE: "ComfyRanking_ProfileBannerCache", // 个人资料背景图本地缓存
|
| 176 |
ACQUIRED_ITEMS: "ComfyRanking_AcquiredItems", // 已获取的资源记录
|
| 177 |
+
PROFILE_LIST_PREFIX: "ProfileList_", // 兼容旧个人列表业务键
|
| 178 |
+
INSTALLED_VERSION_PREFIX: "ComfyCommunity_LocalVer_", // 沿用既有安装版本键
|
| 179 |
+
ITEM_OWNERSHIP_PREFIX: "ItemOwnership_", // 缓存管理器业务键前缀
|
| 180 |
// 🚀 新增:私信和通知本地缓存
|
| 181 |
NOTIFICATIONS: "ComfyRanking_Notifications", // 通知消息缓存
|
| 182 |
CHAT_LIST: "ComfyRanking_ChatList", // 对话列表缓存
|
|
|
|
| 185 |
CHAT_CLEAR_PREFIX: "ComfyRanking_ChatCleared_" // 聊天清空时间戳前缀
|
| 186 |
},
|
| 187 |
|
| 188 |
+
REFUND_CACHE_PREFIXES: [
|
| 189 |
+
"api_/api/items", "api_/api/creators", "api_/api/wallet",
|
| 190 |
+
...RESOURCE_LIST_PREFIXES
|
| 191 |
+
],
|
| 192 |
+
RESOURCE_LIST_PREFIXES,
|
| 193 |
+
|
| 194 |
// 🚀 新增:消息轮询配置
|
| 195 |
MESSAGE_POLL: {
|
| 196 |
INTERVAL: 30 * 1000, // 轮询间隔:30秒
|
|
|
|
| 208 |
|
| 209 |
// 创作者每页数量
|
| 210 |
CREATORS_PAGE_SIZE: 12,
|
| 211 |
+
SIDEBAR_ITEM_FETCH_LIMIT: 200,
|
| 212 |
+
SIDEBAR_CREATOR_FETCH_LIMIT: 500,
|
| 213 |
+
SIDEBAR_CREATOR_SEARCH_LIMIT: 50,
|
| 214 |
+
PROFILE_USERS_PAGE_SIZE: 20,
|
| 215 |
+
PROFILE_RESOURCE_FETCH_LIMIT: 200,
|
| 216 |
|
| 217 |
// 评论每页数量
|
| 218 |
COMMENTS_PAGE_SIZE: 10,
|
| 219 |
|
| 220 |
// 消息每页数量
|
| 221 |
MESSAGES_PAGE_SIZE: 20,
|
| 222 |
+
CHAT_MESSAGES_PAGE_SIZE: 50,
|
| 223 |
|
| 224 |
// 滚动加载触发阈值(距底部像素)
|
| 225 |
SCROLL_THRESHOLD: 300,
|
|
|
|
| 273 |
// 🎨 UI 配置
|
| 274 |
// ==========================================
|
| 275 |
export const UI = {
|
| 276 |
+
CHAT_MAX_MSG_LENGTH: 1000,
|
| 277 |
+
CHAT_TIME_GAP_SECONDS: 5 * 60,
|
| 278 |
+
MAX_DISPUTE_EVIDENCE: 6,
|
| 279 |
+
RECHARGE_POLL_INTERVAL: 3000,
|
| 280 |
+
RECHARGE_POLL_LIMIT: 100,
|
| 281 |
+
RECHARGE_QR_MAX_BYTES: 2048,
|
| 282 |
+
PROFILE_BANNER_CACHE_BYTES: 512 * 1024,
|
| 283 |
+
// 与云端公开版本接口的缺省值一致;用于网络不可用时的本地展示。
|
| 284 |
+
DEFAULT_PROJECT_VERSION: Object.freeze({ stage: "beta", major: 2, minor: 0, patch: 0 }),
|
| 285 |
+
ABOUT: {
|
| 286 |
+
AUTHOR_URL: "https://space.bilibili.com/2114638644",
|
| 287 |
+
AUTHOR_BADGE_URL: "https://img.shields.io/badge/bilibili-猪的飞行梦-00A1D6?logo=bilibili&logoColor=white"
|
| 288 |
+
},
|
| 289 |
// Toast 消息配置
|
| 290 |
TOAST: {
|
| 291 |
DURATION: 3000, // 显示时长(毫秒)
|
|
|
|
| 470 |
return userData?.user?.account || null;
|
| 471 |
}
|
| 472 |
} catch (e) {
|
| 473 |
+
console.warn("获取当前账号失败。");
|
| 474 |
}
|
| 475 |
return null;
|
| 476 |
}
|
|
|
|
| 536 |
// 缓存键:ComfyCommunity_ProfileCache_{account}
|
| 537 |
|
| 538 |
const PROFILE_CACHE_PREFIX = "ComfyCommunity_ProfileCache_";
|
| 539 |
+
const _pendingProfileRequests = new Map(); // 每账号共享请求与全部订阅者
|
| 540 |
const PENDING_TIMEOUT = 30000; // 挂起请求超时清理时间(毫秒)
|
| 541 |
+
let nextAvatarRenderId = 0;
|
| 542 |
|
| 543 |
// ==========================================
|
| 544 |
// 🔒 HTML 转义(底层公共工具)
|
|
|
|
| 569 |
}
|
| 570 |
}
|
| 571 |
|
| 572 |
+
const PUBLIC_PROFILE_FIELDS = ["account", "name", "avatar", "avatarDataUrl", "intro", "gender", "age",
|
| 573 |
+
"country", "region", "created_at", "followers", "following", "privacy", "is_admin", "bannerUrl",
|
| 574 |
+
"receivedLikes", "receivedFavorites", "receivedUses"];
|
| 575 |
+
function _publicProfile(profile) {
|
| 576 |
+
return Object.fromEntries(PUBLIC_PROFILE_FIELDS.filter(key => Object.prototype.hasOwnProperty.call(profile, key))
|
| 577 |
+
.map(key => [key, profile[key]]));
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
function _validProfile(profile) {
|
| 581 |
+
return !!profile && typeof profile === 'object' && !Array.isArray(profile)
|
| 582 |
+
&& ['name', 'avatar', 'avatarDataUrl'].every(field => profile[field] == null || typeof profile[field] === 'string');
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
/**
|
| 586 |
* 🚀 获取缓存的用户资料(同步)
|
| 587 |
* @param {string} account - 用户账号
|
|
|
|
| 593 |
const cached = localStorage.getItem(PROFILE_CACHE_PREFIX + account);
|
| 594 |
if (!cached) return null;
|
| 595 |
const profile = JSON.parse(cached);
|
| 596 |
+
if (!_validProfile(profile)) return null;
|
| 597 |
+
const publicProfile = _publicProfile(profile);
|
| 598 |
+
_mapAvatarField(publicProfile);
|
| 599 |
+
// 旧资料缓存可能含联系方式;读取返回过滤副本并尝试迁移,不因迁移失败隐藏公开内容。
|
| 600 |
+
if (JSON.stringify(publicProfile) !== cached) {
|
| 601 |
+
try { localStorage.setItem(PROFILE_CACHE_PREFIX + account, JSON.stringify(publicProfile)); } catch (_) {}
|
| 602 |
+
}
|
| 603 |
+
return publicProfile;
|
| 604 |
} catch {
|
| 605 |
return null;
|
| 606 |
}
|
|
|
|
| 612 |
* @param {Object} profile - 用户资料
|
| 613 |
*/
|
| 614 |
export function setCachedProfile(account, profile) {
|
| 615 |
+
if (!account || !_validProfile(profile)) return;
|
| 616 |
try {
|
| 617 |
+
localStorage.setItem(PROFILE_CACHE_PREFIX + account, JSON.stringify(_publicProfile(profile)));
|
| 618 |
} catch {}
|
| 619 |
}
|
| 620 |
|
|
|
|
| 631 |
// 第一轨:同步返回缓存
|
| 632 |
const cached = getCachedProfile(account);
|
| 633 |
|
| 634 |
+
// 第二轨:同账号只取一次,但每个视图都订阅结果。
|
| 635 |
+
let entry = _pendingProfileRequests.get(account);
|
| 636 |
+
if (!entry && typeof apiFn === 'function') {
|
| 637 |
+
entry = { listeners: new Set(), timer: null };
|
| 638 |
+
_pendingProfileRequests.set(account, entry);
|
| 639 |
+
const release = () => {
|
| 640 |
+
if (_pendingProfileRequests.get(account) === entry) _pendingProfileRequests.delete(account);
|
| 641 |
+
clearTimeout(entry.timer);
|
| 642 |
+
entry.listeners.clear();
|
| 643 |
+
};
|
| 644 |
+
entry.timer = setTimeout(release, PENDING_TIMEOUT);
|
| 645 |
+
Promise.resolve().then(() => apiFn(account)).then(res => {
|
| 646 |
+
// 超时后的旧结果不能覆盖新请求,也不能重新写入旧资料。
|
| 647 |
+
if (_pendingProfileRequests.get(account) !== entry) return;
|
| 648 |
+
if (res?.status && res.status !== 'success') return;
|
| 649 |
+
const data = res && Object.hasOwn(res, 'data') ? res.data : res;
|
| 650 |
+
if (!_validProfile(data)) return;
|
| 651 |
+
const profile = _publicProfile(data);
|
| 652 |
_mapAvatarField(profile);
|
| 653 |
setCachedProfile(account, profile);
|
| 654 |
+
for (const listener of Array.from(entry.listeners)) {
|
| 655 |
+
try { listener(profile); } catch { /* 单个视图异常不阻断其它订阅者 */ }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 656 |
}
|
| 657 |
+
}).catch(() => {}).finally(release);
|
| 658 |
}
|
| 659 |
+
if (entry && typeof onUpdate === 'function') entry.listeners.add(onUpdate);
|
| 660 |
|
| 661 |
return cached;
|
| 662 |
}
|
|
|
|
| 674 |
*/
|
| 675 |
export function renderAvatarWithSWR(options) {
|
| 676 |
const { account, fallbackAvatar, fallbackName, avatarSize = 24, apiFn, containerId } = options;
|
| 677 |
+
const renderId = String(++nextAvatarRenderId);
|
| 678 |
+
const size = typeof avatarSize === 'number' || typeof avatarSize === 'string' ? Number(avatarSize) : NaN;
|
| 679 |
+
const safeSize = Number.isFinite(size) && size > 0 ? size : 24;
|
| 680 |
|
| 681 |
// 从缓存获取
|
| 682 |
const cached = getCachedProfile(account);
|
|
|
|
| 686 |
// 后台静默校对并更新 DOM
|
| 687 |
if (apiFn && containerId) {
|
| 688 |
setTimeout(() => {
|
| 689 |
+
const container = document.getElementById(containerId);
|
| 690 |
+
if (!container || container.dataset.profileRender !== renderId) return;
|
| 691 |
getProfileWithSWR(account, apiFn, (profile) => {
|
| 692 |
+
if (!container.isConnected || document.getElementById(containerId) !== container
|
| 693 |
+
|| container.dataset.profileRender !== renderId) return;
|
| 694 |
|
| 695 |
const avatarImg = container.querySelector('.swr-avatar');
|
| 696 |
const nameSpan = container.querySelector('.swr-name');
|
| 697 |
|
| 698 |
if (avatarImg && profile.avatar) {
|
| 699 |
+
if (avatarImg.tagName === 'IMG') avatarImg.src = profile.avatar;
|
| 700 |
+
else {
|
| 701 |
+
const image = document.createElement('img');
|
| 702 |
+
image.className = 'swr-avatar';
|
| 703 |
+
image.alt = '';
|
| 704 |
+
image.style.cssText = avatarImg.style.cssText;
|
| 705 |
+
image.style.objectFit = 'cover';
|
| 706 |
+
image.src = profile.avatar;
|
| 707 |
+
avatarImg.replaceWith(image);
|
| 708 |
+
}
|
| 709 |
+
} else if (avatarImg && avatarImg.tagName !== 'IMG' && profile.name) {
|
| 710 |
+
avatarImg.textContent = Array.from(profile.name)[0]?.toUpperCase() || 'U';
|
| 711 |
}
|
| 712 |
if (nameSpan && profile.name) {
|
| 713 |
nameSpan.textContent = profile.name;
|
|
|
|
| 719 |
// 生成初始 HTML(无头像时显示首字母背景)
|
| 720 |
// 🔒 XSS防护:avatar/name/initial 均来自用户可修改的资料(昵称首字母可能是 < ),
|
| 721 |
// 写入 HTML 前必须转义;上方 SWR 刷新路径用的是 .src / .textContent,本身就是安全的。
|
| 722 |
+
const initial = Array.from(String(name || account || 'U'))[0].toUpperCase();
|
| 723 |
const avatarHtml = avatar
|
| 724 |
+
? `<img class="swr-avatar" alt="" src="${escapeHtml(avatar)}" style="width: ${safeSize}px; height: ${safeSize}px; border-radius: 50%; object-fit: cover; flex-shrink: 0; background: #333;">`
|
| 725 |
+
: `<div class="swr-avatar" aria-hidden="true" style="width: ${safeSize}px; height: ${safeSize}px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: ${Math.floor(safeSize * 0.5)}px; font-weight: bold; flex-shrink: 0;">${escapeHtml(initial)}</div>`;
|
| 726 |
|
| 727 |
+
return `<span id="${escapeHtml(containerId)}" data-profile-render="${renderId}" style="display: inline-flex; align-items: center; gap: 6px;">${avatarHtml}<span class="swr-name">${escapeHtml(name)}</span></span>`;
|
| 728 |
}
|
| 729 |
|
| 730 |
|
|
|
|
| 732 |
// 📦 导出默认配置对象
|
| 733 |
// ==========================================
|
| 734 |
export default {
|
| 735 |
+
IMAGE_PROCESS,
|
| 736 |
API,
|
| 737 |
CACHE,
|
| 738 |
PAGINATION,
|
|
|
|
| 754 |
getProfileWithSWR,
|
| 755 |
renderAvatarWithSWR
|
| 756 |
};
|
| 757 |
+
|
| 758 |
+
|
| 759 |
+
// 提示词定价输入边界,与云端 PromptCreate/PromptUpdate 对应。
|
| 760 |
+
export const PROMPT_LIMITS = Object.freeze({ MAX_PRICE: 10000 });
|
| 761 |
+
|
| 762 |
+
// 任务定价边界,与云端 task_publication/task_terms 对应。
|
| 763 |
+
export const TASK_LIMITS = Object.freeze({ MAX_PRICE: 2147483647, MAX_REFERENCE_IMAGES: 6, DEPOSIT_RATIOS: Object.freeze([10, 20, 30, 50]) });
|
前端页面/core/榜单分页控制.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 侧边栏分页控制:滚动加载与无滚动容器时的手动降级。
|
| 2 |
+
import { createPaginationLoader } from "../components/性能优化工具.js";
|
| 3 |
+
import { filterItemsByKeyword } from "./榜单本地查询.js";
|
| 4 |
+
import { t } from "../components/用户体验增强.js";
|
| 5 |
+
|
| 6 |
+
// ==========================================
|
| 7 |
+
// 📜 设置分页加载器
|
| 8 |
+
// ==========================================
|
| 9 |
+
export function setupSidebarPagination(contentArea, state, pageSize, loadMoreData, keyword, tab, renderToken, getRenderToken) {
|
| 10 |
+
if (renderToken !== getRenderToken()) return;
|
| 11 |
+
// 安全清理旧分页器,防止状态残留和重复事件监听
|
| 12 |
+
if (state.loader) {
|
| 13 |
+
state.loader.stop();
|
| 14 |
+
}
|
| 15 |
+
state.loader = null;
|
| 16 |
+
|
| 17 |
+
// 获取滚动容器(侧边栏主容器)
|
| 18 |
+
const scrollContainer = contentArea.closest(".sidebar-scroll-container") || contentArea.parentElement;
|
| 19 |
+
|
| 20 |
+
if (!scrollContainer) {
|
| 21 |
+
console.warn("⚠️ 未找到滚动容器,分页加载无法启动");
|
| 22 |
+
|
| 23 |
+
// 添加手动加载按钮作为 fallback
|
| 24 |
+
let nextPage = 2;
|
| 25 |
+
let stopped = false;
|
| 26 |
+
const isCurrent = () => !stopped && renderToken === getRenderToken();
|
| 27 |
+
const loadMoreBtn = document.createElement('button');
|
| 28 |
+
loadMoreBtn.textContent = t('task.load_more');
|
| 29 |
+
loadMoreBtn.style.cssText = 'width:100%;padding:10px;margin-top:10px;cursor:pointer;border:1px solid #555;border-radius:6px;background:transparent;color:inherit;';
|
| 30 |
+
state.loader = { stop() {
|
| 31 |
+
stopped = true;
|
| 32 |
+
loadMoreBtn.onclick = null;
|
| 33 |
+
loadMoreBtn.disabled = true;
|
| 34 |
+
} };
|
| 35 |
+
loadMoreBtn.onclick = async () => {
|
| 36 |
+
if (!isCurrent()) return;
|
| 37 |
+
loadMoreBtn.disabled = true;
|
| 38 |
+
loadMoreBtn.textContent = t('common.loading');
|
| 39 |
+
try {
|
| 40 |
+
const result = await loadMoreData(nextPage, pageSize, { isCurrent });
|
| 41 |
+
if (!isCurrent()) return;
|
| 42 |
+
if (result && result.length > 0) {
|
| 43 |
+
nextPage++;
|
| 44 |
+
loadMoreBtn.textContent = t('task.load_more');
|
| 45 |
+
} else {
|
| 46 |
+
loadMoreBtn.textContent = t('common.no_more');
|
| 47 |
+
loadMoreBtn.style.cursor = 'default';
|
| 48 |
+
loadMoreBtn.onclick = null;
|
| 49 |
+
}
|
| 50 |
+
} catch (err) {
|
| 51 |
+
if (!isCurrent()) return;
|
| 52 |
+
console.error("手动加载更多失败");
|
| 53 |
+
loadMoreBtn.textContent = t('task.load_failed');
|
| 54 |
+
}
|
| 55 |
+
loadMoreBtn.disabled = false;
|
| 56 |
+
};
|
| 57 |
+
contentArea.appendChild(loadMoreBtn);
|
| 58 |
+
return;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// 计算数据总量(考虑搜索过滤,创作者Tab有keyword时不进行本地过滤,因为后端已过滤)
|
| 62 |
+
// 📌 口径必须与 loadSidebarContent 内的 _applySearchScope 一致(先过滤再计数);
|
| 63 |
+
// 本函数是模块级、拿不到那个闭包,故保留等价实现,改动其一务必同步另一处
|
| 64 |
+
const getTotalDataCount = () => {
|
| 65 |
+
if (!keyword) return state.allData.length;
|
| 66 |
+
// 创作者Tab后端搜索已过滤,直接返回全部数据
|
| 67 |
+
if (tab === "creators") return state.allData.length;
|
| 68 |
+
return filterItemsByKeyword(state.allData, keyword).length;
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
// 创建分页加载器
|
| 72 |
+
const loader = createPaginationLoader({
|
| 73 |
+
container: scrollContainer,
|
| 74 |
+
pageSize: pageSize,
|
| 75 |
+
threshold: 300,
|
| 76 |
+
t,
|
| 77 |
+
loadMore: async (page, size, { isCurrent: isLoaderCurrent = () => true } = {}) => {
|
| 78 |
+
const isCurrent = () => renderToken === getRenderToken() &&
|
| 79 |
+
state.loader === loader && isLoaderCurrent();
|
| 80 |
+
if (!isCurrent()) return [];
|
| 81 |
+
const totalCount = getTotalDataCount();
|
| 82 |
+
|
| 83 |
+
// 如果已显示完所有数据,返回空数组停止加载
|
| 84 |
+
if (state.displayedCount >= totalCount) {
|
| 85 |
+
return [];
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
return loadMoreData(page, size, { isCurrent });
|
| 89 |
+
},
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
state.loader = loader;
|
| 93 |
+
loader.start();
|
| 94 |
+
}
|
| 95 |
+
|
前端页面/core/榜单分页状态.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 仅管理榜单内存状态与分页器所有权,不读取存储或访问宿主。
|
| 2 |
+
export function createSidebarPaginationStore() {
|
| 3 |
+
const states = new Map();
|
| 4 |
+
return {
|
| 5 |
+
get(tab, sort) {
|
| 6 |
+
const key = `${tab}_${sort}`;
|
| 7 |
+
if (!states.has(key)) {
|
| 8 |
+
states.set(key, {
|
| 9 |
+
allData: [],
|
| 10 |
+
displayedCount: 0,
|
| 11 |
+
loader: null,
|
| 12 |
+
isFullyLoaded: false,
|
| 13 |
+
isSearchResult: false,
|
| 14 |
+
searchKeyword: '',
|
| 15 |
+
});
|
| 16 |
+
}
|
| 17 |
+
return states.get(key);
|
| 18 |
+
},
|
| 19 |
+
findCompleteData(tab) {
|
| 20 |
+
for (const [key, state] of states) {
|
| 21 |
+
if (key.startsWith(`${tab}_`) && state.allData.length > 0 && state.isFullyLoaded) {
|
| 22 |
+
return state.allData;
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
return null;
|
| 26 |
+
},
|
| 27 |
+
stopLoaders() {
|
| 28 |
+
let failure;
|
| 29 |
+
for (const state of states.values()) {
|
| 30 |
+
const loader = state.loader;
|
| 31 |
+
state.loader = null;
|
| 32 |
+
try { loader?.stop(); }
|
| 33 |
+
catch (error) { failure ??= error; }
|
| 34 |
+
}
|
| 35 |
+
// 单个停止失败也必须继续清理其它分页器,随后交回调用方处理。
|
| 36 |
+
if (failure) throw failure;
|
| 37 |
+
},
|
| 38 |
+
};
|
| 39 |
+
}
|
前端页面/core/榜单数据来源.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { api, requestCancelManager } from './网络请求API.js';
|
| 2 |
+
import { API, CACHE, PAGINATION } from './全局配置.js';
|
| 3 |
+
import { requireListResponseData, getCacheWithMeta, removeCache } from '../components/性能优化工具.js';
|
| 4 |
+
|
| 5 |
+
const itemTypes = Object.freeze({ tools: 'tool', skills: 'skill', apps: 'app', recommends: 'recommend' });
|
| 6 |
+
|
| 7 |
+
// 只读取与校验,不提交视图状态或缓存;提交时由调用方核查界面代次。
|
| 8 |
+
export async function fetchSidebarList({ tab, sort, keyword = '', force = false, background = false }) {
|
| 9 |
+
const options = background
|
| 10 |
+
? { noCache: true, timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES }
|
| 11 |
+
: (force ? { noCache: true } : {});
|
| 12 |
+
let response;
|
| 13 |
+
if (Object.hasOwn(itemTypes, tab)) {
|
| 14 |
+
response = await api.getItems(itemTypes[tab], sort, PAGINATION.SIDEBAR_ITEM_FETCH_LIMIT, options);
|
| 15 |
+
} else if (tab === 'creators') {
|
| 16 |
+
if (keyword) {
|
| 17 |
+
requestCancelManager.cancelAll(API.SEARCH_COMPONENT_ID);
|
| 18 |
+
response = await api.searchCreators(keyword, sort, PAGINATION.SIDEBAR_CREATOR_SEARCH_LIMIT, options);
|
| 19 |
+
} else {
|
| 20 |
+
response = await api.getCreators(sort, PAGINATION.SIDEBAR_CREATOR_FETCH_LIMIT, options);
|
| 21 |
+
}
|
| 22 |
+
} else {
|
| 23 |
+
throw new TypeError('Unsupported sidebar list');
|
| 24 |
+
}
|
| 25 |
+
return requireListResponseData(response);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function validCreatorList(data) {
|
| 29 |
+
try { requireListResponseData({ status: 'success', data }); }
|
| 30 |
+
catch { return false; }
|
| 31 |
+
return data.every(item => ['name', 'account', 'shortDesc'].every(key =>
|
| 32 |
+
item[key] == null || typeof item[key] === 'string'));
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export function readSidebarListCache(key, { creators = false } = {}) {
|
| 36 |
+
const miss = { found: false, expired: true, value: null };
|
| 37 |
+
let metadata;
|
| 38 |
+
try {
|
| 39 |
+
metadata = getCacheWithMeta(key, true); // 过期副本仍可显示。
|
| 40 |
+
if (!metadata.found) return metadata;
|
| 41 |
+
} catch { return miss; } // 读取失败不代表数据损坏,不删除未知副本。
|
| 42 |
+
try {
|
| 43 |
+
requireListResponseData({ status: 'success', data: metadata.value });
|
| 44 |
+
if (!creators || validCreatorList(metadata.value)) return metadata;
|
| 45 |
+
} catch { /* 确定无效的列表不进入代理、排序或视图状态。 */ }
|
| 46 |
+
try { removeCache(key); }
|
| 47 |
+
catch { /* 删除失败仍按未命中处理,避免损坏内容阻断界面。 */ }
|
| 48 |
+
return miss;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export function readCreatorsSessionCache() {
|
| 52 |
+
let raw;
|
| 53 |
+
try { raw = sessionStorage.getItem(CACHE.LOCAL_KEYS.CREATORS_SESSION); }
|
| 54 |
+
catch { return null; } // 无法读取不等于损坏,保留未知内容。
|
| 55 |
+
if (raw === null) return null;
|
| 56 |
+
try {
|
| 57 |
+
const data = JSON.parse(raw);
|
| 58 |
+
if (validCreatorList(data)) return data;
|
| 59 |
+
} catch { /* 仅已读到的损坏记录可尝试删除。 */ }
|
| 60 |
+
try { sessionStorage.removeItem(CACHE.LOCAL_KEYS.CREATORS_SESSION); }
|
| 61 |
+
catch { /* 无法删除不阻断界面。 */ }
|
| 62 |
+
return null;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export function writeCreatorsSessionCache(data) {
|
| 66 |
+
if (!validCreatorList(data)) return false;
|
| 67 |
+
try {
|
| 68 |
+
sessionStorage.setItem(CACHE.LOCAL_KEYS.CREATORS_SESSION, JSON.stringify(data));
|
| 69 |
+
return true;
|
| 70 |
+
} catch { return false; }
|
| 71 |
+
}
|
前端页面/core/榜单本地查询.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 本地榜单查询:不依赖 DOM、存储、网络或图片代理。
|
| 2 |
+
// 保留数据引擎原有排序/搜索规则;仅缓存最近一次排序的数组引用。
|
| 3 |
+
|
| 4 |
+
let _lastSortInput = null;
|
| 5 |
+
let _lastSortKey = '';
|
| 6 |
+
let _lastSortResult = null;
|
| 7 |
+
|
| 8 |
+
export function sortDataLocally(data, tab, sort) {
|
| 9 |
+
const sortKey = `${tab}_${sort}`;
|
| 10 |
+
// 如果源数据引用相同且排序参数相同,返回缓存结果(避免重复创建数组拷贝)
|
| 11 |
+
if (data === _lastSortInput && sortKey === _lastSortKey && _lastSortResult) {
|
| 12 |
+
return _lastSortResult;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const sorted = [...data]; // 不修改原数组
|
| 16 |
+
|
| 17 |
+
if (tab === "creators") {
|
| 18 |
+
// 创作者排序(与后端 get_creators 一致)
|
| 19 |
+
switch (sort) {
|
| 20 |
+
case "likes": sorted.sort((a, b) => (b.likes || 0) - (a.likes || 0)); break;
|
| 21 |
+
case "favorites": sorted.sort((a, b) => (b.favorites || 0) - (a.favorites || 0)); break;
|
| 22 |
+
case "downloads": sorted.sort((a, b) => (b.downloads || 0) - (a.downloads || 0)); break;
|
| 23 |
+
case "tips": sorted.sort((a, b) => (b.recent_tips || 0) - (a.recent_tips || 0)); break;
|
| 24 |
+
case "views": sorted.sort((a, b) => (b.views || 0) - (a.views || 0)); break;
|
| 25 |
+
case "daily_views": sorted.sort((a, b) => (b.daily_views || 0) - (a.daily_views || 0)); break;
|
| 26 |
+
default: sorted.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); break; // time
|
| 27 |
+
}
|
| 28 |
+
} else {
|
| 29 |
+
// 工具/Skill/应用/推荐排序(与后端 get_items 一致)
|
| 30 |
+
switch (sort) {
|
| 31 |
+
case "downloads": sorted.sort((a, b) => (b.uses || 0) - (a.uses || 0)); break;
|
| 32 |
+
case "likes": sorted.sort((a, b) => (b.likes || 0) - (a.likes || 0)); break;
|
| 33 |
+
case "favorites": sorted.sort((a, b) => (b.favorites || 0) - (a.favorites || 0)); break;
|
| 34 |
+
case "tips":
|
| 35 |
+
// 后端使用 tip_history 的当月数据排序
|
| 36 |
+
sorted.sort((a, b) => {
|
| 37 |
+
const now = new Date();
|
| 38 |
+
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
| 39 |
+
const tipA = (a.tip_history || {})[currentMonth] || 0;
|
| 40 |
+
const tipB = (b.tip_history || {})[currentMonth] || 0;
|
| 41 |
+
return tipB - tipA;
|
| 42 |
+
});
|
| 43 |
+
break;
|
| 44 |
+
case "views": sorted.sort((a, b) => (b.views || 0) - (a.views || 0)); break;
|
| 45 |
+
case "daily_views": sorted.sort((a, b) => (b.daily_views || 0) - (a.daily_views || 0)); break;
|
| 46 |
+
case "rating":
|
| 47 |
+
sorted.sort((a, b) => {
|
| 48 |
+
const ratingA = a.rating_avg || 0;
|
| 49 |
+
const ratingB = b.rating_avg || 0;
|
| 50 |
+
if (ratingA === ratingB) {
|
| 51 |
+
return (b.rating_count || 0) - (a.rating_count || 0);
|
| 52 |
+
}
|
| 53 |
+
return ratingB - ratingA;
|
| 54 |
+
});
|
| 55 |
+
break;
|
| 56 |
+
default: sorted.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); break; // time
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
// 缓存本次排序结果
|
| 61 |
+
_lastSortInput = data;
|
| 62 |
+
_lastSortKey = sortKey;
|
| 63 |
+
_lastSortResult = sorted;
|
| 64 |
+
return sorted;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/**
|
| 68 |
+
* 按关键词过滤列表数据(工具/应用/推荐Tab本地搜索)
|
| 69 |
+
* @param {Array} items - 数据数组
|
| 70 |
+
* @param {string} keyword - 搜索关键词
|
| 71 |
+
* @returns {Array} 过滤后的数据
|
| 72 |
+
*/
|
| 73 |
+
export function filterItemsByKeyword(items, keyword) {
|
| 74 |
+
const lowerKeyword = keyword.toLowerCase();
|
| 75 |
+
return items.filter(item => {
|
| 76 |
+
const textStr = `${item.title||''} ${item.shortDesc||''} ${item.name||''} ${item.account||''}`.toLowerCase();
|
| 77 |
+
return textStr.includes(lowerKeyword);
|
| 78 |
+
});
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
/**
|
| 82 |
+
* 本地搜索创作者数据
|
| 83 |
+
* @param {string} keyword - 搜索关键词
|
| 84 |
+
* @param {Array} data - 创作者数据数组
|
| 85 |
+
* @returns {Array} 匹配的数据
|
| 86 |
+
*/
|
| 87 |
+
export function searchCreatorsLocally(keyword, data) {
|
| 88 |
+
const lowerKeyword = keyword.toLowerCase();
|
| 89 |
+
return data.filter(item => {
|
| 90 |
+
const name = (item.name || '').toLowerCase();
|
| 91 |
+
const account = (item.account || '').toLowerCase();
|
| 92 |
+
const shortDesc = (item.shortDesc || '').toLowerCase();
|
| 93 |
+
return name.includes(lowerKeyword) ||
|
| 94 |
+
account.includes(lowerKeyword) ||
|
| 95 |
+
shortDesc.includes(lowerKeyword);
|
| 96 |
+
});
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
|
前端页面/core/榜单视图切换.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { t } from '../components/用户体验增强.js';
|
| 2 |
+
|
| 3 |
+
function cleanupView(view) {
|
| 4 |
+
try { view._cleanup?.(); }
|
| 5 |
+
catch { console.warn('Sidebar view cleanup failed'); }
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
// 业务视图仍负责自己的请求/事件;此边界负责原容器的挂载与代次。
|
| 9 |
+
export async function mountSidebarDomainView({
|
| 10 |
+
contentArea, currentUser, keyword, renderToken, getRenderToken,
|
| 11 |
+
loadModule, factory, errorKey, afterMount,
|
| 12 |
+
}) {
|
| 13 |
+
const isCurrent = () => renderToken === getRenderToken();
|
| 14 |
+
if (!isCurrent()) return;
|
| 15 |
+
let cleaned = false;
|
| 16 |
+
let view;
|
| 17 |
+
const cleanupOld = () => {
|
| 18 |
+
if (cleaned) return;
|
| 19 |
+
cleaned = true;
|
| 20 |
+
Array.from(contentArea.children).forEach(cleanupView);
|
| 21 |
+
};
|
| 22 |
+
try {
|
| 23 |
+
const module = await loadModule();
|
| 24 |
+
if (!isCurrent()) return;
|
| 25 |
+
cleanupOld();
|
| 26 |
+
if (!isCurrent()) return;
|
| 27 |
+
view = module[factory](currentUser, keyword);
|
| 28 |
+
if (!isCurrent()) {
|
| 29 |
+
cleanupView(view);
|
| 30 |
+
return;
|
| 31 |
+
}
|
| 32 |
+
contentArea.innerHTML = '';
|
| 33 |
+
contentArea.appendChild(view);
|
| 34 |
+
afterMount?.(module);
|
| 35 |
+
} catch {
|
| 36 |
+
if (!isCurrent()) return;
|
| 37 |
+
cleanupOld();
|
| 38 |
+
if (view) cleanupView(view);
|
| 39 |
+
if (!isCurrent()) return;
|
| 40 |
+
// 不输出异常原文,可能包含远端正文、路径或凭据。
|
| 41 |
+
console.error('Sidebar view load failed');
|
| 42 |
+
contentArea.innerHTML = '';
|
| 43 |
+
const error = document.createElement('div');
|
| 44 |
+
error.setAttribute('role', 'alert');
|
| 45 |
+
error.style.cssText = 'text-align:center; padding: 40px 20px; color:#F44336;';
|
| 46 |
+
error.textContent = `❌ ${t(errorKey)}`;
|
| 47 |
+
contentArea.appendChild(error);
|
| 48 |
+
}
|
| 49 |
+
}
|
前端页面/core/状态管理.js
CHANGED
|
@@ -21,9 +21,20 @@
|
|
| 21 |
* @typedef {import('./类型定义.js').WalletData} WalletData
|
| 22 |
*/
|
| 23 |
|
|
|
|
| 24 |
import { API, CACHE } from "./全局配置.js";
|
| 25 |
import { clearSensitiveCache, clearAllCache } from "../components/性能优化工具.js";
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
// ==========================================
|
| 29 |
// 📦 状态存储
|
|
@@ -124,7 +135,7 @@ const eventBus = {
|
|
| 124 |
try {
|
| 125 |
callback(data);
|
| 126 |
} catch (error) {
|
| 127 |
-
console.error(
|
| 128 |
}
|
| 129 |
});
|
| 130 |
}
|
|
@@ -203,28 +214,14 @@ function _createUserState(userData, token) {
|
|
| 203 |
/**
|
| 204 |
* 初始化用户状态(从本地存储恢复)
|
| 205 |
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
export function initUserState() {
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
const token = localStorage.getItem(CACHE.LEGACY_KEYS.TOKEN);
|
| 211 |
-
|
| 212 |
-
if (userStr && token) {
|
| 213 |
-
const userData = JSON.parse(userStr);
|
| 214 |
-
state.user = _createUserState(userData, token);
|
| 215 |
-
} else {
|
| 216 |
-
// 尝试从 sessionStorage 恢复
|
| 217 |
-
const sessionUserStr = sessionStorage.getItem(CACHE.LEGACY_KEYS.USER);
|
| 218 |
-
const sessionToken = sessionStorage.getItem(CACHE.LEGACY_KEYS.TOKEN);
|
| 219 |
-
|
| 220 |
-
if (sessionUserStr && sessionToken) {
|
| 221 |
-
const userData = JSON.parse(sessionUserStr);
|
| 222 |
-
state.user = _createUserState(userData, sessionToken);
|
| 223 |
-
}
|
| 224 |
-
}
|
| 225 |
-
} catch (error) {
|
| 226 |
-
console.warn("用户状态恢复失败:", error);
|
| 227 |
-
}
|
| 228 |
}
|
| 229 |
|
| 230 |
/**
|
|
@@ -234,15 +231,14 @@ export function initUserState() {
|
|
| 234 |
* @param {boolean} remember - 是否记住登录
|
| 235 |
*/
|
| 236 |
export function setUser(userData, token, remember = true) {
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
// 触发事件
|
| 245 |
eventBus.emit(EVENTS.USER_LOGIN, state.user);
|
|
|
|
| 246 |
}
|
| 247 |
|
| 248 |
/**
|
|
@@ -274,20 +270,19 @@ export function getToken() {
|
|
| 274 |
* @param {Object} updates - 更新的字段
|
| 275 |
*/
|
| 276 |
export function updateUser(updates) {
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
|
|
|
| 283 |
try {
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
} catch {}
|
| 289 |
}
|
| 290 |
-
|
| 291 |
eventBus.emit(EVENTS.USER_UPDATE, state.user);
|
| 292 |
}
|
| 293 |
|
|
@@ -310,11 +305,16 @@ export function logout() {
|
|
| 310 |
// 清除敏感数据内存缓存
|
| 311 |
clearSensitiveCache();
|
| 312 |
|
| 313 |
-
// 清除存储
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
|
| 319 |
// 清除资源安装版本戳(ComfyCommunity_LocalVer_*)
|
| 320 |
// 避免切换账号后残留的版本戳导致详情页按钮状态错误
|
|
@@ -327,10 +327,12 @@ export function logout() {
|
|
| 327 |
}
|
| 328 |
}
|
| 329 |
keysToRemove.forEach(key => localStorage.removeItem(key));
|
| 330 |
-
} catch (
|
| 331 |
-
|
| 332 |
}
|
| 333 |
|
|
|
|
|
|
|
| 334 |
eventBus.emit(EVENTS.USER_LOGOUT);
|
| 335 |
}
|
| 336 |
|
|
@@ -343,17 +345,21 @@ export function logout() {
|
|
| 343 |
export function logoutAndClearUserData(account = null) {
|
| 344 |
// 清除当前账号的 Profile 级缓存
|
| 345 |
if (account) {
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
}
|
| 350 |
// 清除通用用户数据缓存(列表/聊天记录/通知)
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
// 清除所有 ComfyRanking_ 前缀的数据缓存与内存敏感数据
|
| 356 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
// logout() 内部已含 clearSensitiveCache 与 Token/User/版本戳清理
|
| 358 |
logout();
|
| 359 |
}
|
|
@@ -465,7 +471,7 @@ export function setNetworkStatus(online) {
|
|
| 465 |
|
| 466 |
/**
|
| 467 |
* 检查是否在线
|
| 468 |
-
*
|
| 469 |
* 注意:navigator.onLine 仅反映本机网络接口状态,不代表云端服务可达;
|
| 470 |
* 云端可达性请一律改问 isCloudReachable()(见下方段落)。
|
| 471 |
* @returns {boolean}
|
|
@@ -505,14 +511,14 @@ export function isCloudReachable() {
|
|
| 505 |
|
| 506 |
/**
|
| 507 |
* 标记云端不可达,进入冷却
|
| 508 |
-
* @param {string} [reason] -
|
| 509 |
*/
|
| 510 |
export function markCloudDown(reason = '') {
|
| 511 |
const wasReachable = isCloudReachable();
|
| 512 |
state.network.cloudDownUntil = Date.now() + API.CLOUD_DOWN_COOLDOWN;
|
| 513 |
// 只在「可达 → 不可达」的跃迁时打一条日志:冷却期内的重复失败不再刷屏
|
| 514 |
if (wasReachable) {
|
| 515 |
-
console.warn(`☁️ 云端不可达,${API.CLOUD_DOWN_COOLDOWN / 1000} 秒内不再联网
|
| 516 |
}
|
| 517 |
}
|
| 518 |
|
|
|
|
| 21 |
* @typedef {import('./类型定义.js').WalletData} WalletData
|
| 22 |
*/
|
| 23 |
|
| 24 |
+
import { readUserSession, writeUserSession, sessionProfile } from "./用户会话存储.js";
|
| 25 |
import { API, CACHE } from "./全局配置.js";
|
| 26 |
import { clearSensitiveCache, clearAllCache } from "../components/性能优化工具.js";
|
| 27 |
|
| 28 |
+
function safeRemoveStorageItem(storageKind, key) {
|
| 29 |
+
try {
|
| 30 |
+
const storage = storageKind === 'session' ? window.sessionStorage : window.localStorage;
|
| 31 |
+
storage.removeItem(key);
|
| 32 |
+
return true;
|
| 33 |
+
} catch (_) {
|
| 34 |
+
return false;
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
|
| 39 |
// ==========================================
|
| 40 |
// 📦 状态存储
|
|
|
|
| 135 |
try {
|
| 136 |
callback(data);
|
| 137 |
} catch (error) {
|
| 138 |
+
console.error('事件处理错误,继续通知其它订阅者。');
|
| 139 |
}
|
| 140 |
});
|
| 141 |
}
|
|
|
|
| 214 |
/**
|
| 215 |
* 初始化用户状态(从本地存储恢复)
|
| 216 |
*/
|
| 217 |
+
function sessionStorages() {
|
| 218 |
+
return ['localStorage', 'sessionStorage'].map(name => { try { return window[name]; } catch (_) { return null; } });
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
export function initUserState() {
|
| 222 |
+
const session = readUserSession(sessionStorages(), CACHE.LEGACY_KEYS);
|
| 223 |
+
state.user = session ? _createUserState(session.user, session.token)
|
| 224 |
+
: { isLoggedIn: false, account: null, name: null, avatar: null, token: null, wallet: null };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
}
|
| 226 |
|
| 227 |
/**
|
|
|
|
| 231 |
* @param {boolean} remember - 是否记住登录
|
| 232 |
*/
|
| 233 |
export function setUser(userData, token, remember = true) {
|
| 234 |
+
let profile;
|
| 235 |
+
try {
|
| 236 |
+
profile = writeUserSession(sessionStorages(), CACHE.LEGACY_KEYS, userData, token, remember,
|
| 237 |
+
Date.now() + (remember ? CACHE.SESSION_REMEMBER_TTL : CACHE.SESSION_TEMP_TTL));
|
| 238 |
+
} catch (error) { logout(); throw error; }
|
| 239 |
+
state.user = _createUserState(profile, token);
|
|
|
|
|
|
|
| 240 |
eventBus.emit(EVENTS.USER_LOGIN, state.user);
|
| 241 |
+
return profile;
|
| 242 |
}
|
| 243 |
|
| 244 |
/**
|
|
|
|
| 270 |
* @param {Object} updates - 更新的字段
|
| 271 |
*/
|
| 272 |
export function updateUser(updates) {
|
| 273 |
+
if (!state.user.isLoggedIn) return;
|
| 274 |
+
const storages = sessionStorages();
|
| 275 |
+
const session = readUserSession(storages, CACHE.LEGACY_KEYS);
|
| 276 |
+
if (!session || session.user.account !== state.user.account || session.token !== state.user.token) return;
|
| 277 |
+
const profile = sessionProfile({ ...session.user, ...updates, account: session.user.account });
|
| 278 |
+
state.user = _createUserState(profile, session.token);
|
| 279 |
+
for (const storage of storages) {
|
| 280 |
try {
|
| 281 |
+
if (storage?.getItem(CACHE.LEGACY_KEYS.TOKEN) === session.token) {
|
| 282 |
+
storage.setItem(CACHE.LEGACY_KEYS.USER, JSON.stringify({ user: profile, expireAt: session.expireAt }));
|
| 283 |
+
}
|
| 284 |
+
} catch (_) {}
|
|
|
|
| 285 |
}
|
|
|
|
| 286 |
eventBus.emit(EVENTS.USER_UPDATE, state.user);
|
| 287 |
}
|
| 288 |
|
|
|
|
| 305 |
// 清除敏感数据内存缓存
|
| 306 |
clearSensitiveCache();
|
| 307 |
|
| 308 |
+
// 清除存储;即使浏览器禁用了 Storage,也必须完成内存登出与事件广播。
|
| 309 |
+
let storageCleanupFailed = false;
|
| 310 |
+
for (const [storageKind, key] of [
|
| 311 |
+
['local', CACHE.LEGACY_KEYS.USER],
|
| 312 |
+
['local', CACHE.LEGACY_KEYS.TOKEN],
|
| 313 |
+
['session', CACHE.LEGACY_KEYS.USER],
|
| 314 |
+
['session', CACHE.LEGACY_KEYS.TOKEN]
|
| 315 |
+
]) {
|
| 316 |
+
if (!safeRemoveStorageItem(storageKind, key)) storageCleanupFailed = true;
|
| 317 |
+
}
|
| 318 |
|
| 319 |
// 清除资源安装版本戳(ComfyCommunity_LocalVer_*)
|
| 320 |
// 避免切换账号后残留的版本戳导致详情页按钮状态错误
|
|
|
|
| 327 |
}
|
| 328 |
}
|
| 329 |
keysToRemove.forEach(key => localStorage.removeItem(key));
|
| 330 |
+
} catch (_) {
|
| 331 |
+
storageCleanupFailed = true;
|
| 332 |
}
|
| 333 |
|
| 334 |
+
if (storageCleanupFailed) console.warn('浏览器存储不可用,部分登出缓存无法清理;内存会话已清除。');
|
| 335 |
+
|
| 336 |
eventBus.emit(EVENTS.USER_LOGOUT);
|
| 337 |
}
|
| 338 |
|
|
|
|
| 345 |
export function logoutAndClearUserData(account = null) {
|
| 346 |
// 清除当前账号的 Profile 级缓存
|
| 347 |
if (account) {
|
| 348 |
+
safeRemoveStorageItem('local', `ComfyCommunity_ProfileCache_${account}`);
|
| 349 |
+
safeRemoveStorageItem('local', `ComfyRanking_SidebarBackground_${account}`);
|
| 350 |
+
safeRemoveStorageItem('local', `ComfyRanking_ProfileBannerCache_${account}`);
|
| 351 |
}
|
| 352 |
// 清除通用用户数据缓存(列表/聊天记录/通知)
|
| 353 |
+
safeRemoveStorageItem('local', "ComfyCommunity_ListCache");
|
| 354 |
+
safeRemoveStorageItem('local', "ComfyCommunity_ChatHistory");
|
| 355 |
+
safeRemoveStorageItem('local', "ComfyRanking_Notifications");
|
| 356 |
+
safeRemoveStorageItem('local', "ComfyRanking_ChatList");
|
| 357 |
// 清除所有 ComfyRanking_ 前缀的数据缓存与内存敏感数据
|
| 358 |
+
try {
|
| 359 |
+
clearAllCache();
|
| 360 |
+
} catch (error) {
|
| 361 |
+
console.warn('清除持久化缓存失败,继续完成内存登出。');
|
| 362 |
+
}
|
| 363 |
// logout() 内部已含 clearSensitiveCache 与 Token/User/版本戳清理
|
| 364 |
logout();
|
| 365 |
}
|
|
|
|
| 471 |
|
| 472 |
/**
|
| 473 |
* 检查是否在线
|
| 474 |
+
*
|
| 475 |
* 注意:navigator.onLine 仅反映本机网络接口状态,不代表云端服务可达;
|
| 476 |
* 云端可达性请一律改问 isCloudReachable()(见下方段落)。
|
| 477 |
* @returns {boolean}
|
|
|
|
| 511 |
|
| 512 |
/**
|
| 513 |
* 标记云端不可达,进入冷却
|
| 514 |
+
* @param {string} [reason] - 保留调用兼容;不输出可能含私有信息的失败原因
|
| 515 |
*/
|
| 516 |
export function markCloudDown(reason = '') {
|
| 517 |
const wasReachable = isCloudReachable();
|
| 518 |
state.network.cloudDownUntil = Date.now() + API.CLOUD_DOWN_COOLDOWN;
|
| 519 |
// 只在「可达 → 不可达」的跃迁时打一条日志:冷却期内的重复失败不再刷屏
|
| 520 |
if (wasReachable) {
|
| 521 |
+
console.warn(`☁️ 云端不可达,${API.CLOUD_DOWN_COOLDOWN / 1000} 秒内不再联网`);
|
| 522 |
}
|
| 523 |
}
|
| 524 |
|
前端页面/core/用户会话存储.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { normalizeProfileData } from '../profile/个人资料工具.js';
|
| 2 |
+
|
| 3 |
+
/** 持久化只保留资料字段;钱包、凭证和未知字段不能混入用户记录。 */
|
| 4 |
+
export function sessionProfile(value) {
|
| 5 |
+
if (!value || typeof value.account !== 'string' || !value.account.trim()) throw new Error('Invalid user session');
|
| 6 |
+
const profile = normalizeProfileData(value, value.account, true);
|
| 7 |
+
for (const key of ['balance', 'earn_balance', 'tip_balance', 'task_balance', 'frozen_balance', 'total_withdrawn']) delete profile[key];
|
| 8 |
+
return profile;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export function readUserSession(storages, keys, now = Date.now()) {
|
| 12 |
+
for (const storage of storages) {
|
| 13 |
+
try {
|
| 14 |
+
const raw = storage?.getItem(keys.USER), token = storage?.getItem(keys.TOKEN);
|
| 15 |
+
if (!raw || typeof token !== 'string' || !token.trim()) continue;
|
| 16 |
+
const saved = JSON.parse(raw);
|
| 17 |
+
if (saved.expireAt !== undefined && (!Number.isFinite(saved.expireAt) || saved.expireAt <= now)) {
|
| 18 |
+
storage.removeItem(keys.USER); storage.removeItem(keys.TOKEN); continue;
|
| 19 |
+
}
|
| 20 |
+
return { user: sessionProfile(saved.user || saved), token, expireAt: saved.expireAt };
|
| 21 |
+
} catch (_) {}
|
| 22 |
+
}
|
| 23 |
+
return null;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export function writeUserSession(storages, keys, user, token, remember, expireAt) {
|
| 27 |
+
const profile = sessionProfile(user);
|
| 28 |
+
if (typeof token !== 'string' || !token.trim() || !Number.isFinite(expireAt)) throw new Error('Invalid user session');
|
| 29 |
+
const selected = storages[remember ? 0 : 1];
|
| 30 |
+
// 清除另一个登录方式的旧凭证,再发布完整的新记录。失败不能继续创建混合身份。
|
| 31 |
+
try {
|
| 32 |
+
for (const storage of storages) {
|
| 33 |
+
if (!storage) throw new Error('Unavailable storage');
|
| 34 |
+
storage.removeItem(keys.USER); storage.removeItem(keys.TOKEN);
|
| 35 |
+
}
|
| 36 |
+
selected.setItem(keys.USER, JSON.stringify({ user: profile, expireAt }));
|
| 37 |
+
selected.setItem(keys.TOKEN, token);
|
| 38 |
+
} catch (_) {
|
| 39 |
+
for (const storage of storages) {
|
| 40 |
+
try { storage?.removeItem(keys.USER); storage?.removeItem(keys.TOKEN); } catch (_) {}
|
| 41 |
+
}
|
| 42 |
+
throw new Error('Session storage unavailable');
|
| 43 |
+
}
|
| 44 |
+
return profile;
|
| 45 |
+
}
|
前端页面/core/网络请求API.js
CHANGED
|
@@ -13,6 +13,6 @@
|
|
| 13 |
// readListCache / writeListCache)不在这里,统一从 components/性能优化工具.js 导入。
|
| 14 |
// ==========================================
|
| 15 |
|
| 16 |
-
export { request, requestCancelManager, invalidateRelatedCache, requestSSE } from './网络请求_基础设施.js';
|
| 17 |
export { proxyImages, unproxyImages } from './网络请求_图片代理.js';
|
| 18 |
export { api } from './网络请求_业务API.js';
|
|
|
|
| 13 |
// readListCache / writeListCache)不在这里,统一从 components/性能优化工具.js 导入。
|
| 14 |
// ==========================================
|
| 15 |
|
| 16 |
+
export { request, captureRequestSession, requestCancelManager, invalidateRelatedCache, requestSSE } from './网络请求_基础设施.js';
|
| 17 |
export { proxyImages, unproxyImages } from './网络请求_图片代理.js';
|
| 18 |
export { api } from './网络请求_业务API.js';
|
前端页面/core/网络请求_业务API.js
CHANGED
|
@@ -9,6 +9,14 @@
|
|
| 9 |
|
| 10 |
import { request } from "./网络请求_基础设施.js";
|
| 11 |
import { API } from "./全局配置.js";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
// ============== 业务 API 导出 ==============
|
| 14 |
const api = {
|
|
@@ -42,35 +50,34 @@ const api = {
|
|
| 42 |
|
| 43 |
// 4. 🚨 终极拦截门:如果到了这一步数据还是没找齐,直接弹窗指认"内鬼"!
|
| 44 |
if (!payload.account || !payload.new_password || !payload.verifyContact || !payload.code) {
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
throw new Error("被前端参数拦截装甲阻断:数据不完整");
|
| 48 |
}
|
| 49 |
|
| 50 |
return request("/api/users/reset_password", { method: "POST", body: payload });
|
| 51 |
},
|
| 52 |
|
| 53 |
-
async getUserProfile(account) { return request(`/api/users/${account}`); },
|
| 54 |
async updateUserProfile(account, data) { return request(`/api/users/${account}`, { method: "PUT", body: data }); },
|
| 55 |
async updatePrivacy(account, privacy) { return request(`/api/users/${account}/privacy`, { method: "PUT", body: privacy }); },
|
| 56 |
async toggleFollow(userId, targetAccount, isActive) { return request("/api/users/follow", { method: "POST", body: { user_id: userId, target_account: targetAccount, is_active: isActive } }); },
|
| 57 |
-
async getCreators(sort, limit) { return request(`/api/creators?sort=${sort}&limit=${limit}`); },
|
| 58 |
// 🔍 带上取消分组 ID:用户继续输入时,上一次仍在途的搜索请求可被 requestCancelManager 撤销,
|
| 59 |
// 不再占用并发额度与超时预算(调用时机见 侧边栏数据引擎.js 的创作者搜索分支)
|
| 60 |
-
async searchCreators(keyword, sort, limit = 50) { return request(`/api/creators/search?keyword=${encodeURIComponent(keyword)}&sort=${sort}&limit=${limit}`, { componentId: API.SEARCH_COMPONENT_ID }); },
|
| 61 |
async getCreatorDetails(account) { return request(`/api/creators/${account}/details`); },
|
| 62 |
async uploadFile(file, fileType, timeout = 120000) {
|
| 63 |
const formData = new FormData();
|
| 64 |
formData.append("file", file);
|
| 65 |
formData.append("file_type", fileType);
|
| 66 |
-
return request("/api/upload", { method: "POST", body: formData, timeout, retries:
|
| 67 |
},
|
| 68 |
async publishItem(data) { return request("/api/items", { method: "POST", body: data }); },
|
| 69 |
// NOTE: author 未 encodeURIComponent,与 assignee/account 等编码不一致;
|
| 70 |
// 但 account 通常为纯字母数字,不影响功能,暂不改动以避免破坏线上缓存匹配
|
| 71 |
async updateItem(itemId, author, data) { return request(`/api/items/${itemId}?author=${author}`, { method: "PUT", body: data }); },
|
| 72 |
-
async getItems(type, sort, limit) { return request(`/api/items?type=${type}&sort=${sort}&limit=${limit}`); },
|
| 73 |
-
async getItemById(itemId) { return request(`/api/items/${itemId}`); },
|
| 74 |
async deleteItem(itemId) { return request(`/api/items/${itemId}`, { method: "DELETE" }); },
|
| 75 |
async recordItemUse(itemId) { return request(`/api/items/${itemId}/use`, { method: "POST" }); },
|
| 76 |
async recordItemView(itemId) { return request(`/api/items/${itemId}/view`, { method: "POST" }); },
|
|
@@ -93,13 +100,13 @@ const api = {
|
|
| 93 |
async toggleInteraction(itemId, userId, actionType, isActive) { return request("/api/interactions/toggle", { method: "POST", body: { item_id: itemId, user_id: userId, action_type: actionType, is_active: isActive } }); },
|
| 94 |
async postComment(itemId, author, content, replyTo = null, parentId = null) { return request("/api/comments", { method: "POST", body: { item_id: itemId, author, content, reply_to_user: replyTo, parent_id: parentId } }); },
|
| 95 |
async deleteComment(itemId, commentId) { return request(`/api/comments/${itemId}/${commentId}`, { method: "DELETE" }); },
|
| 96 |
-
async getMessages(account) { return request(`/api/messages/${account}`); },
|
| 97 |
// 🔥 新增:轻量级获取未读消息数量(不标记已读,用于轮询红点)
|
| 98 |
async getUnreadCount(account) { return request(`/api/messages/${account}?count_only=true`); },
|
| 99 |
async markMessagesRead(account) { return request(`/api/messages/${account}/read`, { method: "POST" }); },
|
| 100 |
async sendPrivateMessage(sender, receiver, content) { return request("/api/messages/private", { method: "POST", body: { sender, receiver, content } }); },
|
| 101 |
-
async getChatList(account) { return request(`/api/chats/${account}`); },
|
| 102 |
-
async getChatHistory(account, targetAccount) { return request(`/api/chats/${account}/${targetAccount}`); },
|
| 103 |
async getWallet(account, options = {}) { return request(`/api/wallet/${account}`, options); },
|
| 104 |
|
| 105 |
// 🛒 获取用户购买记录(从云端 ownerships 表)
|
|
@@ -139,33 +146,35 @@ const api = {
|
|
| 139 |
body: { sender_account: senderAccount, target_account: targetAccount, amount: amount, is_anonymous: isAnonymous, item_id: itemId }
|
| 140 |
});
|
| 141 |
},
|
| 142 |
-
async purchaseItem(account, itemId) {
|
| 143 |
// 🐛 Bug修复:防御性校验,防止 itemId 为空导致请求 /purchase/undefined
|
| 144 |
if (!itemId) {
|
| 145 |
console.warn('⚠️ purchaseItem: itemId 为空,已拦截无效请求');
|
| 146 |
return { success: false, error: 'itemId 不能为空' };
|
| 147 |
}
|
| 148 |
-
|
|
|
|
|
|
|
| 149 |
},
|
| 150 |
async submitWithdraw(data) { return request("/api/wallet/withdraw", { method: "POST", body: data }); },
|
| 151 |
|
| 152 |
// ==========================================
|
| 153 |
// 💬 讨论区 API
|
| 154 |
// ==========================================
|
| 155 |
-
async getPosts(page = 1, limit = 20, sort = "latest") {
|
| 156 |
-
return request(`/api/posts?page=${page}&limit=${limit}&sort=${sort}`);
|
| 157 |
},
|
| 158 |
-
async getPostDetail(postId
|
| 159 |
-
return request(`/api/posts/${postId}`);
|
| 160 |
},
|
| 161 |
-
async createPost(data
|
| 162 |
-
return request("/api/posts", { method: "POST", body: data });
|
| 163 |
},
|
| 164 |
async updatePost(postId, data) {
|
| 165 |
-
return request(`/api/posts/${postId}`, { method: "PUT", body: data });
|
| 166 |
},
|
| 167 |
async deletePost(postId) {
|
| 168 |
-
return request(`/api/posts/${postId}`, { method: "DELETE" });
|
| 169 |
},
|
| 170 |
async togglePostLike(postId) {
|
| 171 |
return request(`/api/posts/${postId}/like`, { method: "POST" });
|
|
@@ -176,8 +185,8 @@ const api = {
|
|
| 176 |
async tipPost(postId, amount, isAnon = false) {
|
| 177 |
return request(`/api/posts/${postId}/tip?amount=${amount}&is_anon=${isAnon}`, { method: "POST" });
|
| 178 |
},
|
| 179 |
-
async getPostComments(postId
|
| 180 |
-
return request(`/api/posts/${postId}/comments`);
|
| 181 |
},
|
| 182 |
async addPostComment(postId, content) {
|
| 183 |
return request(`/api/posts/${postId}/comments?content=${encodeURIComponent(content)}`, { method: "POST" });
|
|
@@ -189,17 +198,17 @@ const api = {
|
|
| 189 |
// ==========================================
|
| 190 |
// ✨ 提示词市场 API
|
| 191 |
// ==========================================
|
| 192 |
-
async getPrompts(promptType = null, category = null, page = 1, limit = 20, sort = "latest") {
|
| 193 |
let url = `/api/prompts?page=${page}&limit=${limit}&sort=${sort}`;
|
| 194 |
if (promptType) url += `&prompt_type=${promptType}`;
|
| 195 |
if (category) url += `&category=${encodeURIComponent(category)}`;
|
| 196 |
-
return request(url);
|
| 197 |
},
|
| 198 |
-
async getPromptDetail(promptId) {
|
| 199 |
-
return request(`/api/prompts/${promptId}`);
|
| 200 |
},
|
| 201 |
-
async createPrompt(data) {
|
| 202 |
-
return request("/api/prompts", { method: "POST", body: data });
|
| 203 |
},
|
| 204 |
async updatePrompt(promptId, data) {
|
| 205 |
return request(`/api/prompts/${promptId}`, { method: "PUT", body: data });
|
|
@@ -216,8 +225,8 @@ const api = {
|
|
| 216 |
async tipPrompt(promptId, amount, isAnon = false) {
|
| 217 |
return request(`/api/prompts/${promptId}/tip?amount=${amount}&is_anon=${isAnon}`, { method: "POST" });
|
| 218 |
},
|
| 219 |
-
async getPromptComments(promptId) {
|
| 220 |
-
return request(`/api/prompts/${promptId}/comments`);
|
| 221 |
},
|
| 222 |
async addPromptComment(promptId, content) {
|
| 223 |
return request(`/api/prompts/${promptId}/comments?content=${encodeURIComponent(content)}`, { method: "POST" });
|
|
@@ -225,13 +234,15 @@ const api = {
|
|
| 225 |
async recordPromptView(promptId) {
|
| 226 |
return request(`/api/prompts/${promptId}/view`, { method: "POST" });
|
| 227 |
},
|
| 228 |
-
async purchasePrompt(promptId) {
|
| 229 |
// 🐛 防御性校验,防止 promptId 为空导致请求 /purchase/undefined
|
| 230 |
if (!promptId) {
|
| 231 |
console.warn('⚠️ purchasePrompt: promptId 为空,已拦截无效请求');
|
| 232 |
return { success: false, error: 'promptId 不能为空' };
|
| 233 |
}
|
| 234 |
-
|
|
|
|
|
|
|
| 235 |
},
|
| 236 |
async getMyPrompts() {
|
| 237 |
return request("/api/my-prompts");
|
|
@@ -248,13 +259,14 @@ const api = {
|
|
| 248 |
// ==========================================
|
| 249 |
// 📝 任务榜 API
|
| 250 |
// ==========================================
|
| 251 |
-
async getTasks(page = 1, limit = 20, status = null, sort = "latest") {
|
| 252 |
let url = `/api/tasks?page=${page}&limit=${limit}&sort=${sort}`;
|
| 253 |
if (status) url += `&status=${status}`;
|
| 254 |
-
return request(url);
|
| 255 |
},
|
| 256 |
-
async getTaskDetail(taskId) {
|
| 257 |
-
|
|
|
|
| 258 |
},
|
| 259 |
async createTask(data) {
|
| 260 |
return request("/api/tasks", { method: "POST", body: data });
|
|
@@ -307,8 +319,8 @@ const api = {
|
|
| 307 |
async tipTask(taskId, amount, isAnon = false) {
|
| 308 |
return request(`/api/tasks/${taskId}/tip?amount=${amount}&is_anon=${isAnon}`, { method: "POST" });
|
| 309 |
},
|
| 310 |
-
async getTaskComments(taskId) {
|
| 311 |
-
return request(`/api/tasks/${taskId}/comments`);
|
| 312 |
},
|
| 313 |
async addTaskComment(taskId, content) {
|
| 314 |
return request(`/api/tasks/${taskId}/comments?content=${encodeURIComponent(content)}`, { method: "POST" });
|
|
@@ -317,8 +329,8 @@ const api = {
|
|
| 317 |
// ==========================================
|
| 318 |
// ⚖️ 申诉仲裁 API
|
| 319 |
// ==========================================
|
| 320 |
-
async getDisputeDetail(disputeId) {
|
| 321 |
-
return request(`/api/disputes/${disputeId}`);
|
| 322 |
},
|
| 323 |
async respondDispute(disputeId, response, evidence = null) {
|
| 324 |
return request(`/api/disputes/${disputeId}/respond`, {
|
|
@@ -330,8 +342,8 @@ const api = {
|
|
| 330 |
// ==========================================
|
| 331 |
// 🔄 P7后悔模式:退款API
|
| 332 |
// ==========================================
|
| 333 |
-
async getPurchaseStatus(account, itemId) {
|
| 334 |
-
return request(`/api/wallet/${account}/purchase/${itemId}`);
|
| 335 |
},
|
| 336 |
async requestRefund(account, itemId) {
|
| 337 |
return request(`/api/wallet/refund?account=${encodeURIComponent(account)}&item_id=${encodeURIComponent(itemId)}`, {
|
|
|
|
| 9 |
|
| 10 |
import { request } from "./网络请求_基础设施.js";
|
| 11 |
import { API } from "./全局配置.js";
|
| 12 |
+
import { t } from "../components/用户体验_国际化.js";
|
| 13 |
+
import { removeCache } from "./网络请求_缓存管理.js";
|
| 14 |
+
|
| 15 |
+
function publicationHeaders(options) {
|
| 16 |
+
if (options?.publicationId === undefined) return undefined;
|
| 17 |
+
if (typeof options.publicationId !== 'string' || !/^[0-9a-f]{32}$/.test(options.publicationId)) throw new Error('Invalid publication identity');
|
| 18 |
+
return { 'Idempotency-Key': options.publicationId };
|
| 19 |
+
}
|
| 20 |
|
| 21 |
// ============== 业务 API 导出 ==============
|
| 22 |
const api = {
|
|
|
|
| 50 |
|
| 51 |
// 4. 🚨 终极拦截门:如果到了这一步数据还是没找齐,直接弹窗指认"内鬼"!
|
| 52 |
if (!payload.account || !payload.new_password || !payload.verifyContact || !payload.code) {
|
| 53 |
+
console.warn("密码重置请求缺少必要字段");
|
| 54 |
+
throw new Error(t('auth.form_data_incomplete'));
|
|
|
|
| 55 |
}
|
| 56 |
|
| 57 |
return request("/api/users/reset_password", { method: "POST", body: payload });
|
| 58 |
},
|
| 59 |
|
| 60 |
+
async getUserProfile(account, options = {}) { return request(`/api/users/${account}`, options); },
|
| 61 |
async updateUserProfile(account, data) { return request(`/api/users/${account}`, { method: "PUT", body: data }); },
|
| 62 |
async updatePrivacy(account, privacy) { return request(`/api/users/${account}/privacy`, { method: "PUT", body: privacy }); },
|
| 63 |
async toggleFollow(userId, targetAccount, isActive) { return request("/api/users/follow", { method: "POST", body: { user_id: userId, target_account: targetAccount, is_active: isActive } }); },
|
| 64 |
+
async getCreators(sort, limit, options = {}) { return request(`/api/creators?sort=${sort}&limit=${limit}`, options); },
|
| 65 |
// 🔍 带上取消分组 ID:用户继续输入时,上一次仍在途的搜索请求可被 requestCancelManager 撤销,
|
| 66 |
// 不再占用并发额度与超时预算(调用时机见 侧边栏数据引擎.js 的创作者搜索分支)
|
| 67 |
+
async searchCreators(keyword, sort, limit = 50, options = {}) { return request(`/api/creators/search?keyword=${encodeURIComponent(keyword)}&sort=${sort}&limit=${limit}`, { componentId: API.SEARCH_COMPONENT_ID, ...options }); },
|
| 68 |
async getCreatorDetails(account) { return request(`/api/creators/${account}/details`); },
|
| 69 |
async uploadFile(file, fileType, timeout = 120000) {
|
| 70 |
const formData = new FormData();
|
| 71 |
formData.append("file", file);
|
| 72 |
formData.append("file_type", fileType);
|
| 73 |
+
return request("/api/upload", { method: "POST", body: formData, timeout, retries: 0 });
|
| 74 |
},
|
| 75 |
async publishItem(data) { return request("/api/items", { method: "POST", body: data }); },
|
| 76 |
// NOTE: author 未 encodeURIComponent,与 assignee/account 等编码不一致;
|
| 77 |
// 但 account 通常为纯字母数字,不影响功能,暂不改动以避免破坏线上缓存匹配
|
| 78 |
async updateItem(itemId, author, data) { return request(`/api/items/${itemId}?author=${author}`, { method: "PUT", body: data }); },
|
| 79 |
+
async getItems(type, sort, limit, options = {}) { return request(`/api/items?type=${type}&sort=${sort}&limit=${limit}`, options); },
|
| 80 |
+
async getItemById(itemId, options = {}) { return request(`/api/items/${itemId}`, options); },
|
| 81 |
async deleteItem(itemId) { return request(`/api/items/${itemId}`, { method: "DELETE" }); },
|
| 82 |
async recordItemUse(itemId) { return request(`/api/items/${itemId}/use`, { method: "POST" }); },
|
| 83 |
async recordItemView(itemId) { return request(`/api/items/${itemId}/view`, { method: "POST" }); },
|
|
|
|
| 100 |
async toggleInteraction(itemId, userId, actionType, isActive) { return request("/api/interactions/toggle", { method: "POST", body: { item_id: itemId, user_id: userId, action_type: actionType, is_active: isActive } }); },
|
| 101 |
async postComment(itemId, author, content, replyTo = null, parentId = null) { return request("/api/comments", { method: "POST", body: { item_id: itemId, author, content, reply_to_user: replyTo, parent_id: parentId } }); },
|
| 102 |
async deleteComment(itemId, commentId) { return request(`/api/comments/${itemId}/${commentId}`, { method: "DELETE" }); },
|
| 103 |
+
async getMessages(account, options = {}) { return request(`/api/messages/${account}`, options); },
|
| 104 |
// 🔥 新增:轻量级获取未读消息数量(不标记已读,用于轮询红点)
|
| 105 |
async getUnreadCount(account) { return request(`/api/messages/${account}?count_only=true`); },
|
| 106 |
async markMessagesRead(account) { return request(`/api/messages/${account}/read`, { method: "POST" }); },
|
| 107 |
async sendPrivateMessage(sender, receiver, content) { return request("/api/messages/private", { method: "POST", body: { sender, receiver, content } }); },
|
| 108 |
+
async getChatList(account, options = {}) { return request(`/api/chats/${account}`, options); },
|
| 109 |
+
async getChatHistory(account, targetAccount, options = {}) { return request(`/api/chats/${account}/${targetAccount}`, options); },
|
| 110 |
async getWallet(account, options = {}) { return request(`/api/wallet/${account}`, options); },
|
| 111 |
|
| 112 |
// 🛒 获取用户购买记录(从云端 ownerships 表)
|
|
|
|
| 146 |
body: { sender_account: senderAccount, target_account: targetAccount, amount: amount, is_anonymous: isAnonymous, item_id: itemId }
|
| 147 |
});
|
| 148 |
},
|
| 149 |
+
async purchaseItem(account, itemId, expectedPrice) {
|
| 150 |
// 🐛 Bug修复:防御性校验,防止 itemId 为空导致请求 /purchase/undefined
|
| 151 |
if (!itemId) {
|
| 152 |
console.warn('⚠️ purchaseItem: itemId 为空,已拦截无效请求');
|
| 153 |
return { success: false, error: 'itemId 不能为空' };
|
| 154 |
}
|
| 155 |
+
const body = { account, item_id: itemId };
|
| 156 |
+
if (expectedPrice !== undefined) body.expected_price = expectedPrice;
|
| 157 |
+
return request("/api/wallet/purchase", { method: "POST", body });
|
| 158 |
},
|
| 159 |
async submitWithdraw(data) { return request("/api/wallet/withdraw", { method: "POST", body: data }); },
|
| 160 |
|
| 161 |
// ==========================================
|
| 162 |
// 💬 讨论区 API
|
| 163 |
// ==========================================
|
| 164 |
+
async getPosts(page = 1, limit = 20, sort = "latest", options = {}) {
|
| 165 |
+
return request(`/api/posts?page=${page}&limit=${limit}&sort=${sort}`, options);
|
| 166 |
},
|
| 167 |
+
async getPostDetail(postId, options = {}) {
|
| 168 |
+
return request(`/api/posts/${postId}`, options);
|
| 169 |
},
|
| 170 |
+
async createPost(data, options = {}) {
|
| 171 |
+
return request("/api/posts", { method: "POST", body: data, headers: publicationHeaders(options) });
|
| 172 |
},
|
| 173 |
async updatePost(postId, data) {
|
| 174 |
+
return request(`/api/posts/${postId}`, { method: "PUT", body: data });
|
| 175 |
},
|
| 176 |
async deletePost(postId) {
|
| 177 |
+
return request(`/api/posts/${postId}`, { method: "DELETE" });
|
| 178 |
},
|
| 179 |
async togglePostLike(postId) {
|
| 180 |
return request(`/api/posts/${postId}/like`, { method: "POST" });
|
|
|
|
| 185 |
async tipPost(postId, amount, isAnon = false) {
|
| 186 |
return request(`/api/posts/${postId}/tip?amount=${amount}&is_anon=${isAnon}`, { method: "POST" });
|
| 187 |
},
|
| 188 |
+
async getPostComments(postId, options = {}) {
|
| 189 |
+
return request(`/api/posts/${postId}/comments`, options);
|
| 190 |
},
|
| 191 |
async addPostComment(postId, content) {
|
| 192 |
return request(`/api/posts/${postId}/comments?content=${encodeURIComponent(content)}`, { method: "POST" });
|
|
|
|
| 198 |
// ==========================================
|
| 199 |
// ✨ 提示词市场 API
|
| 200 |
// ==========================================
|
| 201 |
+
async getPrompts(promptType = null, category = null, page = 1, limit = 20, sort = "latest", options = {}) {
|
| 202 |
let url = `/api/prompts?page=${page}&limit=${limit}&sort=${sort}`;
|
| 203 |
if (promptType) url += `&prompt_type=${promptType}`;
|
| 204 |
if (category) url += `&category=${encodeURIComponent(category)}`;
|
| 205 |
+
return request(url, options);
|
| 206 |
},
|
| 207 |
+
async getPromptDetail(promptId, options = {}) {
|
| 208 |
+
return request(`/api/prompts/${promptId}`, options);
|
| 209 |
},
|
| 210 |
+
async createPrompt(data, options = {}) {
|
| 211 |
+
return request("/api/prompts", { method: "POST", body: data, headers: publicationHeaders(options) });
|
| 212 |
},
|
| 213 |
async updatePrompt(promptId, data) {
|
| 214 |
return request(`/api/prompts/${promptId}`, { method: "PUT", body: data });
|
|
|
|
| 225 |
async tipPrompt(promptId, amount, isAnon = false) {
|
| 226 |
return request(`/api/prompts/${promptId}/tip?amount=${amount}&is_anon=${isAnon}`, { method: "POST" });
|
| 227 |
},
|
| 228 |
+
async getPromptComments(promptId, options = {}) {
|
| 229 |
+
return request(`/api/prompts/${promptId}/comments`, options);
|
| 230 |
},
|
| 231 |
async addPromptComment(promptId, content) {
|
| 232 |
return request(`/api/prompts/${promptId}/comments?content=${encodeURIComponent(content)}`, { method: "POST" });
|
|
|
|
| 234 |
async recordPromptView(promptId) {
|
| 235 |
return request(`/api/prompts/${promptId}/view`, { method: "POST" });
|
| 236 |
},
|
| 237 |
+
async purchasePrompt(promptId, expectedPrice) {
|
| 238 |
// 🐛 防御性校验,防止 promptId 为空导致请求 /purchase/undefined
|
| 239 |
if (!promptId) {
|
| 240 |
console.warn('⚠️ purchasePrompt: promptId 为空,已拦截无效请求');
|
| 241 |
return { success: false, error: 'promptId 不能为空' };
|
| 242 |
}
|
| 243 |
+
const options = { method: "POST" };
|
| 244 |
+
if (expectedPrice !== undefined) options.body = { expected_price: expectedPrice };
|
| 245 |
+
return request(`/api/prompts/${promptId}/purchase`, options);
|
| 246 |
},
|
| 247 |
async getMyPrompts() {
|
| 248 |
return request("/api/my-prompts");
|
|
|
|
| 259 |
// ==========================================
|
| 260 |
// 📝 任务榜 API
|
| 261 |
// ==========================================
|
| 262 |
+
async getTasks(page = 1, limit = 20, status = null, sort = "latest", options = {}) {
|
| 263 |
let url = `/api/tasks?page=${page}&limit=${limit}&sort=${sort}`;
|
| 264 |
if (status) url += `&status=${status}`;
|
| 265 |
+
return request(url, options);
|
| 266 |
},
|
| 267 |
+
async getTaskDetail(taskId, options = {}) {
|
| 268 |
+
removeCache(`api_/api/tasks/${taskId}`);
|
| 269 |
+
return request(`/api/tasks/${taskId}`, { ...options, noStore: true });
|
| 270 |
},
|
| 271 |
async createTask(data) {
|
| 272 |
return request("/api/tasks", { method: "POST", body: data });
|
|
|
|
| 319 |
async tipTask(taskId, amount, isAnon = false) {
|
| 320 |
return request(`/api/tasks/${taskId}/tip?amount=${amount}&is_anon=${isAnon}`, { method: "POST" });
|
| 321 |
},
|
| 322 |
+
async getTaskComments(taskId, options = {}) {
|
| 323 |
+
return request(`/api/tasks/${taskId}/comments`, options);
|
| 324 |
},
|
| 325 |
async addTaskComment(taskId, content) {
|
| 326 |
return request(`/api/tasks/${taskId}/comments?content=${encodeURIComponent(content)}`, { method: "POST" });
|
|
|
|
| 329 |
// ==========================================
|
| 330 |
// ⚖️ 申诉仲裁 API
|
| 331 |
// ==========================================
|
| 332 |
+
async getDisputeDetail(disputeId, options = {}) {
|
| 333 |
+
return request(`/api/disputes/${disputeId}`, options);
|
| 334 |
},
|
| 335 |
async respondDispute(disputeId, response, evidence = null) {
|
| 336 |
return request(`/api/disputes/${disputeId}/respond`, {
|
|
|
|
| 342 |
// ==========================================
|
| 343 |
// 🔄 P7后悔模式:退款API
|
| 344 |
// ==========================================
|
| 345 |
+
async getPurchaseStatus(account, itemId, options = {}) {
|
| 346 |
+
return request(`/api/wallet/${account}/purchase/${itemId}`, options);
|
| 347 |
},
|
| 348 |
async requestRefund(account, itemId) {
|
| 349 |
return request(`/api/wallet/refund?account=${encodeURIComponent(account)}&item_id=${encodeURIComponent(itemId)}`, {
|
前端页面/core/网络请求_基础设施.js
CHANGED
|
@@ -11,8 +11,8 @@
|
|
| 11 |
|
| 12 |
import { removeCache, getCacheWithMeta } from "../components/性能优化工具.js";
|
| 13 |
import { API, CACHE } from "./全局配置.js";
|
| 14 |
-
import { logoutAndClearUserData, isOnline, isCloudReachable, markCloudDown, markCloudUp } from "./状态管理.js";
|
| 15 |
-
import { CACHE_CONFIG, CACHE_INVALIDATION_MAP, invalidateRelatedCache, _getCacheTTL, getCache, setCache } from "./网络请求_缓存管理.js";
|
| 16 |
import { unproxyImages } from "./网络请求_图片代理.js";
|
| 17 |
// 🔧 直接引入 i18n 叶子模块(而非 用户体验增强.js):本文件处于依赖链最上游,
|
| 18 |
// 引入带 DOM 初始化副作用的增强层可能造成启动时序问题;国际化模块无副作用、零循环依赖
|
|
@@ -42,11 +42,64 @@ const pendingRequests = new Map();
|
|
| 42 |
// 本地凭证会导致所有登录态操作反复报「Token 签名无效」。去重窗口:并发请求
|
| 43 |
// 同时收到 401 时只提示一次。
|
| 44 |
let _authExpiredAt = 0;
|
| 45 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
const now = Date.now();
|
| 47 |
if (now - _authExpiredAt < 5000) return;
|
| 48 |
_authExpiredAt = now;
|
| 49 |
-
console.warn(
|
| 50 |
// 动态 import UI 组件,与 proxyImages 同样避免模块初始化时序问题
|
| 51 |
import("../components/UI交互提示组件.js").then(async ({ showToast }) => {
|
| 52 |
const { t } = await import("../components/用户体验_国际化.js");
|
|
@@ -54,21 +107,22 @@ function notifyTokenExpired(serverMsg) {
|
|
| 54 |
}).catch(() => {});
|
| 55 |
}
|
| 56 |
|
| 57 |
-
function handleAuthExpired(
|
| 58 |
// 竞态防护:若用户在 401 返回前已重新登录(存储中的 Token 已更换),
|
| 59 |
// 不清理新凭证;失败请求未携带 Token 时 401 与登录态无关,同样不打扰
|
| 60 |
if (!failedToken) return false;
|
| 61 |
-
const currentToken =
|
| 62 |
if (currentToken !== failedToken) return false;
|
| 63 |
// 清除前取出账号,用于清理账号级 Profile 缓存
|
| 64 |
let account = null;
|
| 65 |
try {
|
| 66 |
-
const userStr =
|
|
|
|
| 67 |
if (userStr) account = JSON.parse(userStr)?.user?.account || null;
|
| 68 |
} catch (e) {}
|
| 69 |
// 与手动登出同深度的完整清理:Token/User、私有数据缓存、内存缓存与安装版本戳
|
| 70 |
logoutAndClearUserData(account);
|
| 71 |
-
notifyTokenExpired(
|
| 72 |
return true;
|
| 73 |
}
|
| 74 |
|
|
@@ -151,7 +205,8 @@ const requestQueue = {
|
|
| 151 |
running: 0,
|
| 152 |
pending: [],
|
| 153 |
|
| 154 |
-
async add(fn) {
|
|
|
|
| 155 |
// 如果还有槽位,直接执行
|
| 156 |
if (this.running < this.maxConcurrent) {
|
| 157 |
this.running++;
|
|
@@ -165,13 +220,22 @@ const requestQueue = {
|
|
| 165 |
|
| 166 |
// 否则加入队列等待
|
| 167 |
return new Promise((resolve, reject) => {
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
try {
|
|
|
|
| 170 |
resolve(await fn());
|
| 171 |
} catch (e) {
|
| 172 |
reject(e);
|
| 173 |
}
|
| 174 |
-
}
|
|
|
|
|
|
|
| 175 |
});
|
| 176 |
},
|
| 177 |
|
|
@@ -192,11 +256,27 @@ const requestQueue = {
|
|
| 192 |
};
|
| 193 |
|
| 194 |
async function request(endpoint, options = {}) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
const url = `${BASE_URL}${endpoint}`;
|
| 196 |
const method = (options.method || "GET").toUpperCase();
|
| 197 |
const headers = { ...options.headers };
|
| 198 |
if (!(options.body instanceof FormData)) { headers["Content-Type"] = "application/json"; }
|
| 199 |
-
const token =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
if (token) {
|
| 201 |
const tokenParts = token.split(".");
|
| 202 |
if (tokenParts.length === 3) {
|
|
@@ -208,12 +288,9 @@ async function request(endpoint, options = {}) {
|
|
| 208 |
// 🔧 修复:凭证既已清除,同步广播登出事件重置导航登录态/停止轮询,
|
| 209 |
// 并给用户明确提示(原实现仅 console.warn 静默清理)
|
| 210 |
console.warn("⚠️ 检测到无效/已过期的旧版 Token,已自动清除,请重新登录");
|
| 211 |
-
|
| 212 |
-
localStorage.removeItem("ComfyCommunity_User");
|
| 213 |
-
sessionStorage.removeItem("ComfyCommunity_Token");
|
| 214 |
-
sessionStorage.removeItem("ComfyCommunity_User");
|
| 215 |
window.dispatchEvent(new CustomEvent('comfy-ranking-auth-expired'));
|
| 216 |
-
notifyTokenExpired(
|
| 217 |
}
|
| 218 |
}
|
| 219 |
|
|
@@ -224,6 +301,7 @@ async function request(endpoint, options = {}) {
|
|
| 224 |
// 🔑 P0修复:cacheKey 必须包含完整 endpoint(含查询参数),不能截断 '?' 之后的部分
|
| 225 |
// 例:'/api/items?type=tool&sort=time' 和 '/api/items?type=tool&sort=views' 必须产生不同的缓存 Key
|
| 226 |
const cacheKey = `api_${endpoint}`;
|
|
|
|
| 227 |
if (method === "GET" && !options.noCache) {
|
| 228 |
const cached = getCache(cacheKey);
|
| 229 |
if (cached) {
|
|
@@ -235,19 +313,29 @@ async function request(endpoint, options = {}) {
|
|
| 235 |
// 📴 本地兜底数据(含过期缓存)——一次读取,供下面三处复用:
|
| 236 |
// ① 离线/云端不可达时直接返回它;② 判定本次 GET 是否属于「后台补新」(走短预算);
|
| 237 |
// ③ 重试耗尽后的最终回退仍按原口径自行重读一次(期间可能有并发请求写入更新的数据)
|
| 238 |
-
const fallbackMeta = (method === "GET")
|
| 239 |
? getCacheWithMeta(cacheKey, true) // 忽略过期
|
| 240 |
: { value: null, expired: false, found: false };
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
// 🚀 P3优化:离线模式支持(网络状态取自 状态管理.js 的 isOnline())
|
| 243 |
// ☁️ 云端不可达冷却与本机断网同等对待:两者的结论都是「这次联网注定拿不到东西」,
|
| 244 |
// 区别只在判据——前者看 navigator.onLine(本机网卡通不通),后者看上一次请求的
|
| 245 |
// 失败结论是否还在冷却期内。没有这层的话,HF Space 单独连不上时 isOnline() 恒为 true,
|
| 246 |
// 本分支永远不触发,于是每个界面、每轮 30 秒的消息轮询都要各自烧满 61~93 秒重试预算,
|
| 247 |
// 而全局并发额度只有 6 个,挂死的后台请求会把前台请求全部挤到排队
|
|
|
|
|
|
|
| 248 |
if (method === "GET" && (!isOnline() || !isCloudReachable())) {
|
| 249 |
if (fallbackMeta.found) {
|
| 250 |
-
console.log(`📴 ${isOnline() ? '☁️ 云端不可达' : '离线模式'}:返回${fallbackMeta.expired ? '过期' : ''}缓存
|
| 251 |
const proxyFn2 = await getProxyImages();
|
| 252 |
return proxyFn2(fallbackMeta.value);
|
| 253 |
}
|
|
@@ -258,13 +346,13 @@ async function request(endpoint, options = {}) {
|
|
| 258 |
}
|
| 259 |
|
| 260 |
// ⚡ P1性能优化:请求去重(相同GET请求只发一次)
|
| 261 |
-
if (
|
| 262 |
if (pendingRequests.has(cacheKey)) {
|
| 263 |
return pendingRequests.get(cacheKey);
|
| 264 |
}
|
| 265 |
}
|
| 266 |
|
| 267 |
-
const fetchOptions = {
|
| 268 |
|
| 269 |
// 🚀 核心修改:在将 body 转为 JSON 字符串发送前,执行无情剥离!
|
| 270 |
if (options.body && !(options.body instanceof FormData) && typeof options.body !== "string") {
|
|
@@ -296,7 +384,8 @@ async function request(endpoint, options = {}) {
|
|
| 296 |
// 不会把搜索类已经收紧过的 10s 预算又放宽回去。
|
| 297 |
// noCache 请求不参与:调用方明确要权威新数据(余额、安装前的资源详情、广告配置),
|
| 298 |
// 把它的超时砍短会把「慢但能成功」变成「快速失败」,违背调用方意图
|
| 299 |
-
|
|
|
|
| 300 |
defaultTimeout = Math.min(defaultTimeout, API.BACKGROUND_TIMEOUT);
|
| 301 |
defaultRetries = Math.min(defaultRetries, API.BACKGROUND_RETRIES);
|
| 302 |
}
|
|
@@ -305,6 +394,7 @@ async function request(endpoint, options = {}) {
|
|
| 305 |
const retryDelay = options.retryDelay ?? API.RETRY_DELAY; // 指数退避基数
|
| 306 |
|
| 307 |
// ⚡ P1性能优化:封装请求 Promise(支持去重 + 重试)
|
|
|
|
| 308 |
const requestPromise = (async () => {
|
| 309 |
let lastError = null;
|
| 310 |
// ☁️ 本次失败是否属于「网络层不可达」(超时 / 连不上),用于循环结束后决定是否打冷却。
|
|
@@ -312,6 +402,7 @@ async function request(endpoint, options = {}) {
|
|
| 312 |
let networkLevelFailure = false;
|
| 313 |
|
| 314 |
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
|
|
| 315 |
// 🚀 P1优化:指数退避延迟
|
| 316 |
if (attempt > 0) {
|
| 317 |
// ☁️ 冷却已生效就停止重试:另一个并发请求可能刚刚确证云端不可达,
|
|
@@ -324,25 +415,75 @@ async function request(endpoint, options = {}) {
|
|
| 324 |
|
| 325 |
// 🚀 P4优化:使用请求取消管理器(支持超时 + 组件级取消)
|
| 326 |
const controller = requestCancelManager.create(componentId);
|
|
|
|
| 327 |
const currentFetchOptions = { ...fetchOptions, signal: controller.signal };
|
| 328 |
-
// 🔧 修复:超时计时器改到真正发出 fetch 时才启动。原实现在入队前就 setTimeout,
|
| 329 |
-
// 并发满 6 个时排队等待会白烧超时预算,排队时长超过超时值会导致 fetch 尚未发出就被 abort
|
| 330 |
let timeoutId = null;
|
| 331 |
-
// 🔍 区分「超时中止」与「调用方主动撤销」:两者抛出的都是 AbortError,
|
| 332 |
-
// 但前者应该重试、后者绝不能重试(详见下方 catch 的主动取消分支)
|
| 333 |
let timedOut = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
|
| 335 |
try {
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
|
| 345 |
-
if (!response.ok) {
|
|
|
|
| 346 |
let errorMsg = `请求失败 (${response.status})`;
|
| 347 |
|
| 348 |
// 🚀 核心修改:增加对 FastAPI 422 报错数组的解析
|
|
@@ -365,11 +506,25 @@ async function request(endpoint, options = {}) {
|
|
| 365 |
// 迟到的旧 401 或匿名请求的 401 不再误触发登录态重置。
|
| 366 |
// 登录接口的 401 语义是「密码错误」而非凭证失效,不走全局清理,
|
| 367 |
// 避免与登录表单自身的错误提示重复叠加
|
| 368 |
-
if (endpoint !== "/api/users/login" && handleAuthExpired(
|
| 369 |
window.dispatchEvent(new CustomEvent('comfy-ranking-auth-expired'));
|
| 370 |
}
|
| 371 |
}
|
| 372 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
}
|
| 374 |
|
| 375 |
lastError = new Error(errorMsg);
|
|
@@ -386,17 +541,19 @@ async function request(endpoint, options = {}) {
|
|
| 386 |
}
|
| 387 |
|
| 388 |
// ⚡ P1性能优化:GET 请求结果缓存
|
| 389 |
-
if (method === "GET") {
|
| 390 |
const ttl = _getCacheTTL(endpoint);
|
| 391 |
if (ttl > 0) {
|
| 392 |
// 创作者列表不持久化到 localStorage,避免空间不足
|
| 393 |
const persist = !endpoint.includes('/api/creators');
|
| 394 |
-
setCache(cacheKey, responseData, ttl, persist
|
|
|
|
| 395 |
}
|
| 396 |
}
|
| 397 |
|
| 398 |
// 入口数据挂载代理
|
| 399 |
const proxyFn3 = await getProxyImages();
|
|
|
|
| 400 |
responseData = proxyFn3(responseData);
|
| 401 |
return responseData;
|
| 402 |
} catch (error) {
|
|
@@ -404,7 +561,8 @@ async function request(endpoint, options = {}) {
|
|
| 404 |
// 🔍 失败/超时同样摘除 controller:成功路径在上方已 remove,这里不补的话,
|
| 405 |
// 取消分组里会堆积已失效的 controller,使 cancelAll 的在途计数虚高
|
| 406 |
requestCancelManager.remove(componentId, controller);
|
| 407 |
-
|
|
|
|
| 408 |
// 🔍 主动撤销(组件级取消)与超时都表现为 AbortError,但语义完全相反:
|
| 409 |
// 超时该重试;主动撤销是调用方明确表示「这次结果我不要了」,绝不能重试,
|
| 410 |
// 否则会白烧一轮超时预算(搜索场景下用户每敲一次键都可能触发一次撤销)。
|
|
@@ -415,11 +573,18 @@ async function request(endpoint, options = {}) {
|
|
| 415 |
cancelErr.isCancelled = true;
|
| 416 |
throw cancelErr;
|
| 417 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
|
| 419 |
// 🔧 P1优化:可重试的错误类型
|
| 420 |
const isRetryable = (
|
| 421 |
-
error.name === 'AbortError' ||
|
| 422 |
-
|
| 423 |
);
|
| 424 |
|
| 425 |
if (isRetryable) {
|
|
@@ -430,7 +595,7 @@ async function request(endpoint, options = {}) {
|
|
| 430 |
lastError = isTimeout
|
| 431 |
? new Error('网络请求超时,请检查网络连接')
|
| 432 |
: new Error('网络连接失败,请检查网络');
|
| 433 |
-
networkLevelFailure = true;
|
| 434 |
// ☁️ 连接层失败(被墙 / DNS 污染 / 拒绝连接 / 证书错误)几乎不会是偶发抖动,
|
| 435 |
// 打上冷却后其它界面与轮询就不用再各自烧一遍预算。但单次失败不足以定论,
|
| 436 |
// 故分两种口径:已配重试时要求第二次尝试仍失败才定论(连接层失败是毫秒级的,
|
|
@@ -444,7 +609,7 @@ async function request(endpoint, options = {}) {
|
|
| 444 |
|
| 445 |
// 🔧 P3优化:错误分类处理,提供更清晰的错误信息(不可重试错误直接抛出)
|
| 446 |
if (error instanceof SyntaxError) {
|
| 447 |
-
throw new Error('
|
| 448 |
}
|
| 449 |
throw error;
|
| 450 |
}
|
|
@@ -453,12 +618,12 @@ async function request(endpoint, options = {}) {
|
|
| 453 |
// ☁️ 所有尝试都因网络层原因失败(超时耗尽重试也走这里)→ 打上冷却,
|
| 454 |
// 接下来 API.CLOUD_DOWN_COOLDOWN 内的 GET 全部就地走缓存,不再重复等待
|
| 455 |
if (networkLevelFailure) markCloudDown(endpoint);
|
| 456 |
-
|
| 457 |
// 所有重试都失败,尝试回退到过期缓存
|
| 458 |
-
if (method === "GET") {
|
| 459 |
const fallback = getCacheWithMeta(cacheKey, true); // true = 允许过期
|
| 460 |
if (fallback.found) {
|
| 461 |
-
console.warn(
|
| 462 |
const proxyFn4 = await getProxyImages();
|
| 463 |
return proxyFn4(fallback.value); // 返回过期缓存数据
|
| 464 |
}
|
|
@@ -467,8 +632,10 @@ async function request(endpoint, options = {}) {
|
|
| 467 |
throw lastError || new Error('请求失败');
|
| 468 |
})();
|
| 469 |
|
|
|
|
|
|
|
| 470 |
// 记录进行中的请求
|
| 471 |
-
if (
|
| 472 |
pendingRequests.set(cacheKey, requestPromise);
|
| 473 |
}
|
| 474 |
|
|
@@ -476,9 +643,11 @@ async function request(endpoint, options = {}) {
|
|
| 476 |
// 🔧 补 .catch 空处理:finally 会派生出一个新的 Promise,而它从未被返回也无人接收,
|
| 477 |
// 一旦请求失败(含主动撤销)就会在控制台抛 Uncaught (in promise)。
|
| 478 |
// 原始 requestPromise 仍在下方正常返回给调用方处理,此处只是吞掉这份重复的拒绝
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
|
|
|
|
|
|
| 482 |
|
| 483 |
return requestPromise;
|
| 484 |
}
|
|
@@ -499,12 +668,17 @@ export async function requestSSE(endpoint, body, onProgress, options = {}) {
|
|
| 499 |
const timeout = options.timeout || 1200000; // 20分钟全局超时(大型工具克隆需要较长时间)
|
| 500 |
const controller = new AbortController();
|
| 501 |
const globalTimer = setTimeout(() => controller.abort(), timeout);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 502 |
|
| 503 |
// 📡 空闲超时检测:长时间无数据视为连接已断
|
| 504 |
const IDLE_TIMEOUT_DEFAULT = 30000; // 30 秒无数据视为空闲
|
| 505 |
const IDLE_TIMEOUT_GRACE = 5000; // 收到 finalResult 后的宽限期 5 秒
|
| 506 |
let currentIdleTimeout = options.idleTimeout || IDLE_TIMEOUT_DEFAULT;
|
| 507 |
let idleTimer = null;
|
|
|
|
| 508 |
|
| 509 |
const clearIdleTimer = () => {
|
| 510 |
if (idleTimer !== null) {
|
|
@@ -522,14 +696,41 @@ export async function requestSSE(endpoint, body, onProgress, options = {}) {
|
|
| 522 |
});
|
| 523 |
|
| 524 |
if (!response.ok) {
|
| 525 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
}
|
| 527 |
|
| 528 |
const reader = response.body.getReader();
|
| 529 |
const decoder = new TextDecoder();
|
| 530 |
let buffer = "";
|
| 531 |
-
|
| 532 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 533 |
while (true) {
|
| 534 |
// 使用 Promise.race 实现空闲超时:reader.read() 与空闲定时器竞争
|
| 535 |
const idlePromise = new Promise((_, reject) => {
|
|
@@ -549,48 +750,43 @@ export async function requestSSE(endpoint, body, onProgress, options = {}) {
|
|
| 549 |
// 如果已收到 finalResult,优先返回它(服务器只是没正确关闭连接)
|
| 550 |
if (finalResult) return finalResult;
|
| 551 |
// 否则返回空闲超时错误
|
| 552 |
-
return { status: "error", message: "连接超时:30秒未收到数据" };
|
| 553 |
}
|
| 554 |
throw e;
|
| 555 |
}
|
| 556 |
clearIdleTimer();
|
| 557 |
|
| 558 |
const { done, value } = readResult;
|
| 559 |
-
if (done)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 560 |
|
| 561 |
buffer += decoder.decode(value, { stream: true });
|
| 562 |
const lines = buffer.split("\n");
|
| 563 |
buffer = lines.pop() || ""; // 保留不完整行
|
| 564 |
-
|
| 565 |
-
for (const line of lines) {
|
| 566 |
-
if (line.startsWith("data: ")) {
|
| 567 |
-
try {
|
| 568 |
-
const data = JSON.parse(line.slice(6));
|
| 569 |
-
onProgress?.(data);
|
| 570 |
-
|
| 571 |
-
if (data.status === "success" || data.status === "error") {
|
| 572 |
-
finalResult = data;
|
| 573 |
-
// 收到最终结果后,缩短空闲超时为宽限期,避免无谓等待连接关闭
|
| 574 |
-
currentIdleTimeout = IDLE_TIMEOUT_GRACE;
|
| 575 |
-
}
|
| 576 |
-
} catch (e) {
|
| 577 |
-
console.warn("[SSE] 解析事件失败:", line);
|
| 578 |
-
}
|
| 579 |
-
}
|
| 580 |
-
}
|
| 581 |
}
|
| 582 |
|
| 583 |
-
return finalResult || { status: "error", message: "未收到最终结果" };
|
| 584 |
} catch (err) {
|
|
|
|
|
|
|
| 585 |
if (err.name === "AbortError") {
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
|
|
|
|
|
|
|
|
|
| 589 |
}
|
| 590 |
throw err;
|
| 591 |
} finally {
|
| 592 |
clearTimeout(globalTimer);
|
| 593 |
clearIdleTimer();
|
|
|
|
| 594 |
}
|
| 595 |
}
|
| 596 |
|
|
|
|
| 11 |
|
| 12 |
import { removeCache, getCacheWithMeta } from "../components/性能优化工具.js";
|
| 13 |
import { API, CACHE } from "./全局配置.js";
|
| 14 |
+
import { eventBus, EVENTS, logoutAndClearUserData, isOnline, isCloudReachable, markCloudDown, markCloudUp } from "./状态管理.js";
|
| 15 |
+
import { CACHE_CONFIG, CACHE_INVALIDATION_MAP, invalidateRelatedCache, _getCacheTTL, getCache, setCache, isPrivateApiGet } from "./网络请求_缓存管理.js";
|
| 16 |
import { unproxyImages } from "./网络请求_图片代理.js";
|
| 17 |
// 🔧 直接引入 i18n 叶子模块(而非 用户体验增强.js):本文件处于依赖链最上游,
|
| 18 |
// 引入带 DOM 初始化副作用的增强层可能造成启动时序问题;国际化模块无副作用、零循环依赖
|
|
|
|
| 42 |
// 本地凭证会导致所有登录态操作反复报「Token 签名无效」。去重窗口:并发请求
|
| 43 |
// 同时收到 401 时只提示一次。
|
| 44 |
let _authExpiredAt = 0;
|
| 45 |
+
function _safeStorageGet(storageName, key) {
|
| 46 |
+
try {
|
| 47 |
+
const storage = window[storageName];
|
| 48 |
+
return storage ? storage.getItem(key) : null;
|
| 49 |
+
} catch (_) {
|
| 50 |
+
return null;
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
function _safeGetAuthToken() {
|
| 55 |
+
return _safeStorageGet('localStorage', 'ComfyCommunity_Token')
|
| 56 |
+
|| _safeStorageGet('sessionStorage', 'ComfyCommunity_Token')
|
| 57 |
+
|| '';
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/** 与实际 Authorization 使用同一存储读取;仅返回比较函数,不暴露凭证。 */
|
| 61 |
+
export function captureRequestSession() {
|
| 62 |
+
const originalToken = _safeGetAuthToken();
|
| 63 |
+
return () => originalToken === _safeGetAuthToken();
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// 仅跟踪私有 GET;会话撤销不取消或重放已提交的业务写操作。
|
| 67 |
+
const activePrivateReads = new Set();
|
| 68 |
+
function revokePrivateReads(force = false) {
|
| 69 |
+
for (const entry of activePrivateReads) {
|
| 70 |
+
if (force || !entry.matches()) {
|
| 71 |
+
entry.revoked = true;
|
| 72 |
+
entry.controller?.abort();
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
for (const event of ['comfy-user-login', 'comfy-user-logout', 'comfy-ranking-auth-expired']) {
|
| 77 |
+
window.addEventListener(event, () => revokePrivateReads(true));
|
| 78 |
+
}
|
| 79 |
+
window.addEventListener('storage', event => {
|
| 80 |
+
if (event.key === null || event.key === CACHE.LEGACY_KEYS.USER) revokePrivateReads(true);
|
| 81 |
+
else if (event.key === CACHE.LEGACY_KEYS.TOKEN) revokePrivateReads();
|
| 82 |
+
});
|
| 83 |
+
eventBus.on(EVENTS.USER_LOGIN, () => revokePrivateReads(true));
|
| 84 |
+
eventBus.on(EVENTS.USER_LOGOUT, () => revokePrivateReads(true));
|
| 85 |
+
|
| 86 |
+
function _safeClearAuthStorage() {
|
| 87 |
+
for (const storageName of ['localStorage', 'sessionStorage']) {
|
| 88 |
+
try {
|
| 89 |
+
const storage = window[storageName];
|
| 90 |
+
storage?.removeItem('ComfyCommunity_Token');
|
| 91 |
+
storage?.removeItem('ComfyCommunity_User');
|
| 92 |
+
} catch (_) {
|
| 93 |
+
// 浏览器禁用 Storage 时仍继续完成内存登出和事件通知。
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
function notifyTokenExpired() {
|
| 99 |
const now = Date.now();
|
| 100 |
if (now - _authExpiredAt < 5000) return;
|
| 101 |
_authExpiredAt = now;
|
| 102 |
+
console.warn("⚠️ 登录凭证已失效,本地凭证已清除,请重新登录");
|
| 103 |
// 动态 import UI 组件,与 proxyImages 同样避免模块初始化时序问题
|
| 104 |
import("../components/UI交互提示组件.js").then(async ({ showToast }) => {
|
| 105 |
const { t } = await import("../components/用户体验_国际化.js");
|
|
|
|
| 107 |
}).catch(() => {});
|
| 108 |
}
|
| 109 |
|
| 110 |
+
function handleAuthExpired(failedToken) {
|
| 111 |
// 竞态防护:若用户在 401 返回前已重新登录(存储中的 Token 已更换),
|
| 112 |
// 不清理新凭证;失败请求未携带 Token 时 401 与登录态无关,同样不打扰
|
| 113 |
if (!failedToken) return false;
|
| 114 |
+
const currentToken = _safeGetAuthToken();
|
| 115 |
if (currentToken !== failedToken) return false;
|
| 116 |
// 清除前取出账号,用于清理账号级 Profile 缓存
|
| 117 |
let account = null;
|
| 118 |
try {
|
| 119 |
+
const userStr = _safeStorageGet('localStorage', "ComfyCommunity_User")
|
| 120 |
+
|| _safeStorageGet('sessionStorage', "ComfyCommunity_User");
|
| 121 |
if (userStr) account = JSON.parse(userStr)?.user?.account || null;
|
| 122 |
} catch (e) {}
|
| 123 |
// 与手动登出同深度的完整清理:Token/User、私有数据缓存、内存缓存与安装版本戳
|
| 124 |
logoutAndClearUserData(account);
|
| 125 |
+
notifyTokenExpired();
|
| 126 |
return true;
|
| 127 |
}
|
| 128 |
|
|
|
|
| 205 |
running: 0,
|
| 206 |
pending: [],
|
| 207 |
|
| 208 |
+
async add(fn, signal) {
|
| 209 |
+
signal.throwIfAborted();
|
| 210 |
// 如果还有槽位,直接执行
|
| 211 |
if (this.running < this.maxConcurrent) {
|
| 212 |
this.running++;
|
|
|
|
| 220 |
|
| 221 |
// 否则加入队列等待
|
| 222 |
return new Promise((resolve, reject) => {
|
| 223 |
+
const onAbort = () => {
|
| 224 |
+
const index = this.pending.indexOf(run);
|
| 225 |
+
if (index !== -1) this.pending.splice(index, 1);
|
| 226 |
+
reject(signal.reason);
|
| 227 |
+
};
|
| 228 |
+
const run = async () => {
|
| 229 |
+
signal.removeEventListener('abort', onAbort);
|
| 230 |
try {
|
| 231 |
+
signal.throwIfAborted();
|
| 232 |
resolve(await fn());
|
| 233 |
} catch (e) {
|
| 234 |
reject(e);
|
| 235 |
}
|
| 236 |
+
};
|
| 237 |
+
this.pending.push(run);
|
| 238 |
+
signal.addEventListener('abort', onAbort, { once: true });
|
| 239 |
});
|
| 240 |
},
|
| 241 |
|
|
|
|
| 256 |
};
|
| 257 |
|
| 258 |
async function request(endpoint, options = {}) {
|
| 259 |
+
const privateRead = (options.method || "GET").toUpperCase() === "GET" && isPrivateApiGet(endpoint);
|
| 260 |
+
if (privateRead) {
|
| 261 |
+
removeCache(`api_${endpoint}`);
|
| 262 |
+
options = { ...options, noStore: true };
|
| 263 |
+
}
|
| 264 |
+
// 私有详情显式不留存:同时绕过响应命中/回退/去重、插件写缓存和浏览器缓存。
|
| 265 |
+
if (options.noStore) options = { ...options, noCache: true, cache: "no-store" };
|
| 266 |
const url = `${BASE_URL}${endpoint}`;
|
| 267 |
const method = (options.method || "GET").toUpperCase();
|
| 268 |
const headers = { ...options.headers };
|
| 269 |
if (!(options.body instanceof FormData)) { headers["Content-Type"] = "application/json"; }
|
| 270 |
+
const token = _safeGetAuthToken();
|
| 271 |
+
const privateEntry = privateRead ? { revoked: false, controller: null, matches: () => token === _safeGetAuthToken() } : null;
|
| 272 |
+
const assertReadSession = () => {
|
| 273 |
+
if (privateRead && (privateEntry.revoked || !privateEntry.matches())) {
|
| 274 |
+
const error = new Error(t('auth.session_changed'));
|
| 275 |
+
error.name = 'SessionChangedError';
|
| 276 |
+
error.isCancelled = true;
|
| 277 |
+
throw error;
|
| 278 |
+
}
|
| 279 |
+
};
|
| 280 |
if (token) {
|
| 281 |
const tokenParts = token.split(".");
|
| 282 |
if (tokenParts.length === 3) {
|
|
|
|
| 288 |
// 🔧 修复:凭证既已清除,同步广播登出事件重置导航登录态/停止轮询,
|
| 289 |
// 并给用户明确提示(原实现仅 console.warn 静默清理)
|
| 290 |
console.warn("⚠️ 检测到无效/已过期的旧版 Token,已自动清除,请重新登录");
|
| 291 |
+
_safeClearAuthStorage();
|
|
|
|
|
|
|
|
|
|
| 292 |
window.dispatchEvent(new CustomEvent('comfy-ranking-auth-expired'));
|
| 293 |
+
notifyTokenExpired();
|
| 294 |
}
|
| 295 |
}
|
| 296 |
|
|
|
|
| 301 |
// 🔑 P0修复:cacheKey 必须包含完整 endpoint(含查询参数),不能截断 '?' 之后的部分
|
| 302 |
// 例:'/api/items?type=tool&sort=time' 和 '/api/items?type=tool&sort=views' 必须产生不同的缓存 Key
|
| 303 |
const cacheKey = `api_${endpoint}`;
|
| 304 |
+
const trackPendingRequest = method === "GET" && !options.noCache;
|
| 305 |
if (method === "GET" && !options.noCache) {
|
| 306 |
const cached = getCache(cacheKey);
|
| 307 |
if (cached) {
|
|
|
|
| 313 |
// 📴 本地兜底数据(含过期缓存)——一次读取,供下面三处复用:
|
| 314 |
// ① 离线/云端不可达时直接返回它;② 判定本次 GET 是否属于「后台补新」(走短预算);
|
| 315 |
// ③ 重试耗尽后的最终回退仍按原口径自行重读一次(期间可能有并发请求写入更新的数据)
|
| 316 |
+
const fallbackMeta = (method === "GET" && !options.noCache)
|
| 317 |
? getCacheWithMeta(cacheKey, true) // 忽略过期
|
| 318 |
: { value: null, expired: false, found: false };
|
| 319 |
+
const conditionalRead = method === "GET" && !options.noStore && options.credentials !== "include"
|
| 320 |
+
&& !Object.keys(headers).some(name => name.toLowerCase() === "authorization")
|
| 321 |
+
&& API.PUBLIC_CONDITIONAL_GET_PATHS.includes(endpoint.split(/[?#]/)[0]);
|
| 322 |
+
const conditionalMeta = conditionalRead ? getCacheWithMeta(cacheKey, true) : null;
|
| 323 |
+
if (conditionalRead) {
|
| 324 |
+
for (const name of Object.keys(headers)) if (name.toLowerCase() === "if-none-match") delete headers[name];
|
| 325 |
+
if (conditionalMeta?.found && conditionalMeta.etag) headers["If-None-Match"] = conditionalMeta.etag;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
// 🚀 P3优化:离线模式支持(网络状态取自 状态管理.js 的 isOnline())
|
| 329 |
// ☁️ 云端不可达冷却与本机断网同等对待:两者的结论都是「这次联网注定拿不到东西」,
|
| 330 |
// 区别只在判据——前者看 navigator.onLine(本机网卡通不通),后者看上一次请求的
|
| 331 |
// 失败结论是否还在冷却期内。没有这层的话,HF Space 单独连不上时 isOnline() 恒为 true,
|
| 332 |
// 本分支永远不触发,于是每个界面、每轮 30 秒的消息轮询都要各自烧满 61~93 秒重试预算,
|
| 333 |
// 而全局并发额度只有 6 个,挂死的后台请求会把前台请求全部挤到排队
|
| 334 |
+
// noCache 只代表绕过该 endpoint 的响应缓存,不得绕过共享网络熔断;榜单强制刷新
|
| 335 |
+
// 仍由列表层先显示自己的缓存,冷却期内直接失败返回,避免切换榜单逐个重试 HF。
|
| 336 |
if (method === "GET" && (!isOnline() || !isCloudReachable())) {
|
| 337 |
if (fallbackMeta.found) {
|
| 338 |
+
console.log(`📴 ${isOnline() ? '☁️ 云端不可达' : '离线模式'}:返回${fallbackMeta.expired ? '过期' : ''}缓存`);
|
| 339 |
const proxyFn2 = await getProxyImages();
|
| 340 |
return proxyFn2(fallbackMeta.value);
|
| 341 |
}
|
|
|
|
| 346 |
}
|
| 347 |
|
| 348 |
// ⚡ P1性能优化:请求去重(相同GET请求只发一次)
|
| 349 |
+
if (trackPendingRequest) {
|
| 350 |
if (pendingRequests.has(cacheKey)) {
|
| 351 |
return pendingRequests.get(cacheKey);
|
| 352 |
}
|
| 353 |
}
|
| 354 |
|
| 355 |
+
const fetchOptions = { ...options, method, headers };
|
| 356 |
|
| 357 |
// 🚀 核心修改:在将 body 转为 JSON 字符串发送前,执行无情剥离!
|
| 358 |
if (options.body && !(options.body instanceof FormData) && typeof options.body !== "string") {
|
|
|
|
| 384 |
// 不会把搜索类已经收紧过的 10s 预算又放宽回去。
|
| 385 |
// noCache 请求不参与:调用方明确要权威新数据(余额、安装前的资源详情、广告配置),
|
| 386 |
// 把它的超时砍短会把「慢但能成功」变成「快速失败」,违背调用方意图
|
| 387 |
+
const useCacheBudget = method === "GET" && !options.noCache && fallbackMeta.found;
|
| 388 |
+
if (useCacheBudget) {
|
| 389 |
defaultTimeout = Math.min(defaultTimeout, API.BACKGROUND_TIMEOUT);
|
| 390 |
defaultRetries = Math.min(defaultRetries, API.BACKGROUND_RETRIES);
|
| 391 |
}
|
|
|
|
| 394 |
const retryDelay = options.retryDelay ?? API.RETRY_DELAY; // 指数退避基数
|
| 395 |
|
| 396 |
// ⚡ P1性能优化:封装请求 Promise(支持去重 + 重试)
|
| 397 |
+
if (privateEntry) activePrivateReads.add(privateEntry);
|
| 398 |
const requestPromise = (async () => {
|
| 399 |
let lastError = null;
|
| 400 |
// ☁️ 本次失败是否属于「网络层不可达」(超时 / 连不上),用于循环结束后决定是否打冷却。
|
|
|
|
| 402 |
let networkLevelFailure = false;
|
| 403 |
|
| 404 |
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
| 405 |
+
assertReadSession();
|
| 406 |
// 🚀 P1优化:指数退避延迟
|
| 407 |
if (attempt > 0) {
|
| 408 |
// ☁️ 冷却已生效就停止重试:另一个并发请求可能刚刚确证云端不可达,
|
|
|
|
| 415 |
|
| 416 |
// 🚀 P4优化:使用请求取消管理器(支持超时 + 组件级取消)
|
| 417 |
const controller = requestCancelManager.create(componentId);
|
| 418 |
+
if (privateEntry) privateEntry.controller = controller;
|
| 419 |
const currentFetchOptions = { ...fetchOptions, signal: controller.signal };
|
|
|
|
|
|
|
| 420 |
let timeoutId = null;
|
|
|
|
|
|
|
| 421 |
let timedOut = false;
|
| 422 |
+
let fetchStarted = false;
|
| 423 |
+
let transportFailed = false;
|
| 424 |
+
const onTimeout = () => { timedOut = true; controller.abort(); };
|
| 425 |
+
// 已有缓存时排队也计入预算,避免被无缓存的慢请求无限拖住。
|
| 426 |
+
if (useCacheBudget) timeoutId = setTimeout(onTimeout, requestTimeout);
|
| 427 |
|
| 428 |
try {
|
| 429 |
+
const result = await requestQueue.add(async () => {
|
| 430 |
+
if (!useCacheBudget) timeoutId = setTimeout(onTimeout, requestTimeout);
|
| 431 |
+
// 请求可能在云端故障被另一个并发请求确认前进入队列;出队发出 fetch
|
| 432 |
+
// 前复查共享状态,避免队列继续逐个访问已知不可达的 HF endpoint。
|
| 433 |
+
if (method === "GET" && (!isOnline() || !isCloudReachable())) {
|
| 434 |
+
const unavailableError = new Error(
|
| 435 |
+
isOnline() ? t('feedback.cloud_unreachable_no_cache') : t('feedback.offline_no_cache')
|
| 436 |
+
);
|
| 437 |
+
unavailableError.name = 'CloudUnavailableError';
|
| 438 |
+
throw unavailableError;
|
| 439 |
+
}
|
| 440 |
+
assertReadSession();
|
| 441 |
+
fetchStarted = true;
|
| 442 |
+
try {
|
| 443 |
+
let response = await fetch(url, currentFetchOptions);
|
| 444 |
+
let validatedData;
|
| 445 |
+
if (response.status === 304 && conditionalRead) {
|
| 446 |
+
const currentMeta = getCacheWithMeta(cacheKey, true);
|
| 447 |
+
if (conditionalMeta?.found && conditionalMeta.etag
|
| 448 |
+
&& response.headers?.get?.('ETag') === conditionalMeta.etag
|
| 449 |
+
&& currentMeta.found && currentMeta.etag === conditionalMeta.etag
|
| 450 |
+
&& currentMeta.value === conditionalMeta.value) {
|
| 451 |
+
validatedData = currentMeta.value;
|
| 452 |
+
} else {
|
| 453 |
+
// 缓存可能在等待期间被清理/写操作失效;同一预算内补取完整响应。
|
| 454 |
+
const fullHeaders = { ...currentFetchOptions.headers };
|
| 455 |
+
for (const name of Object.keys(fullHeaders)) if (name.toLowerCase() === "if-none-match") delete fullHeaders[name];
|
| 456 |
+
if (controller.signal.aborted) throw new DOMException('Aborted', 'AbortError');
|
| 457 |
+
response = await fetch(url, { ...currentFetchOptions, headers: fullHeaders, cache: "no-store" });
|
| 458 |
+
if (response.status === 304) throw new SyntaxError('Unexpected not-modified response');
|
| 459 |
+
}
|
| 460 |
+
}
|
| 461 |
+
let data;
|
| 462 |
+
try {
|
| 463 |
+
data = validatedData !== undefined ? validatedData
|
| 464 |
+
: response.status === 204 ? {} : await response.json();
|
| 465 |
+
} catch (error) {
|
| 466 |
+
// 错误响应的正文不可读时仍按 HTTP 状态处理,不能用缓存掩盖 401/403。
|
| 467 |
+
if (!response.ok && (error instanceof SyntaxError || error instanceof TypeError || (timedOut && error.name === 'AbortError'))) {
|
| 468 |
+
data = {};
|
| 469 |
+
} else {
|
| 470 |
+
throw error;
|
| 471 |
+
}
|
| 472 |
+
}
|
| 473 |
+
return { response, data, validated: validatedData !== undefined };
|
| 474 |
+
} catch (error) {
|
| 475 |
+
transportFailed = error instanceof TypeError;
|
| 476 |
+
throw error;
|
| 477 |
+
}
|
| 478 |
+
}, controller.signal);
|
| 479 |
+
clearTimeout(timeoutId);
|
| 480 |
+
requestCancelManager.remove(componentId, controller);
|
| 481 |
+
const { response } = result;
|
| 482 |
+
let responseData = result.data;
|
| 483 |
+
assertReadSession();
|
| 484 |
|
| 485 |
+
if (!response.ok && !result.validated) {
|
| 486 |
+
if (!responseData || typeof responseData !== "object" || Array.isArray(responseData)) responseData = {};
|
| 487 |
let errorMsg = `请求失败 (${response.status})`;
|
| 488 |
|
| 489 |
// 🚀 核心修改:增加对 FastAPI 422 报错数组的解析
|
|
|
|
| 506 |
// 迟到的旧 401 或匿名请求的 401 不再误触发登录态重置。
|
| 507 |
// 登录接口的 401 语义是「密码错误」而非凭证失效,不走全局清理,
|
| 508 |
// 避免与登录表单自身的错误提示重复叠加
|
| 509 |
+
if (endpoint !== "/api/users/login" && handleAuthExpired(token)) {
|
| 510 |
window.dispatchEvent(new CustomEvent('comfy-ranking-auth-expired'));
|
| 511 |
}
|
| 512 |
}
|
| 513 |
+
const error = new Error(errorMsg);
|
| 514 |
+
error.status = response.status;
|
| 515 |
+
if (typeof responseData.detail === "string" && /^[a-z][a-z0-9_]{0,79}$/.test(responseData.detail)) error.code = responseData.detail;
|
| 516 |
+
throw error;
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
// Hugging Face Space / 网关不可用通常以 502、503 或 504 响应,
|
| 520 |
+
// 这类响应证明请求到达了代理层,但云端当前无法提供服务。
|
| 521 |
+
// 与超时/连接失败一样共享冷却,避免切换榜单时每个 endpoint
|
| 522 |
+
// 都重复访问一次;普通 500 仍只视为单接口服务端错误。
|
| 523 |
+
if ([502, 503, 504].includes(response.status)) {
|
| 524 |
+
lastError = new Error(errorMsg);
|
| 525 |
+
networkLevelFailure = true;
|
| 526 |
+
markCloudDown(endpoint);
|
| 527 |
+
break;
|
| 528 |
}
|
| 529 |
|
| 530 |
lastError = new Error(errorMsg);
|
|
|
|
| 541 |
}
|
| 542 |
|
| 543 |
// ⚡ P1性能优化:GET 请求结果缓存
|
| 544 |
+
if (method === "GET" && !options.noStore) {
|
| 545 |
const ttl = _getCacheTTL(endpoint);
|
| 546 |
if (ttl > 0) {
|
| 547 |
// 创作者列表不持久化到 localStorage,避免空间不足
|
| 548 |
const persist = !endpoint.includes('/api/creators');
|
| 549 |
+
setCache(cacheKey, responseData, ttl, persist,
|
| 550 |
+
conditionalRead ? { etag: response.headers?.get?.('ETag') } : {});
|
| 551 |
}
|
| 552 |
}
|
| 553 |
|
| 554 |
// 入口数据挂载代理
|
| 555 |
const proxyFn3 = await getProxyImages();
|
| 556 |
+
assertReadSession();
|
| 557 |
responseData = proxyFn3(responseData);
|
| 558 |
return responseData;
|
| 559 |
} catch (error) {
|
|
|
|
| 561 |
// 🔍 失败/超时同样摘除 controller:成功路径在上方已 remove,这里不补的话,
|
| 562 |
// 取消分组里会堆积已失效的 controller,使 cancelAll 的在途计数虚高
|
| 563 |
requestCancelManager.remove(componentId, controller);
|
| 564 |
+
assertReadSession();
|
| 565 |
+
|
| 566 |
// 🔍 主动撤销(组件级取消)与超时都表现为 AbortError,但语义完全相反:
|
| 567 |
// 超时该重试;主动撤销是调用方明确表示「这次结果我不要了」,绝不能重试,
|
| 568 |
// 否则会白烧一轮超时预算(搜索场景下用户每敲一次键都可能触发一次撤销)。
|
|
|
|
| 573 |
cancelErr.isCancelled = true;
|
| 574 |
throw cancelErr;
|
| 575 |
}
|
| 576 |
+
|
| 577 |
+
// 队列等待期间已有并发请求确证网络不可达:该请求尚未发出 fetch,
|
| 578 |
+
// 直接交给统一缓存兜底,不重试、不延长冷却,也不把队列错误当作网络探测。
|
| 579 |
+
if (error.name === 'CloudUnavailableError') {
|
| 580 |
+
lastError = error;
|
| 581 |
+
break;
|
| 582 |
+
}
|
| 583 |
|
| 584 |
// 🔧 P1优化:可重试的错误类型
|
| 585 |
const isRetryable = (
|
| 586 |
+
error.name === 'AbortError' ||
|
| 587 |
+
transportFailed
|
| 588 |
);
|
| 589 |
|
| 590 |
if (isRetryable) {
|
|
|
|
| 595 |
lastError = isTimeout
|
| 596 |
? new Error('网络请求超时,请检查网络连接')
|
| 597 |
: new Error('网络连接失败,请检查网络');
|
| 598 |
+
if (fetchStarted) networkLevelFailure = true;
|
| 599 |
// ☁️ 连接层失败(被墙 / DNS 污染 / 拒绝连接 / 证书错误)几乎不会是偶发抖动,
|
| 600 |
// 打上冷却后其它界面与轮询就不用再各自烧一遍预算。但单次失败不足以定论,
|
| 601 |
// 故分两种口径:已配重试时要求第二次尝试仍失败才定论(连接层失败是毫秒级的,
|
|
|
|
| 609 |
|
| 610 |
// 🔧 P3优化:错误分类处理,提供更清晰的错误信息(不可重试错误直接抛出)
|
| 611 |
if (error instanceof SyntaxError) {
|
| 612 |
+
throw new Error(t('feedback.response_format_error'));
|
| 613 |
}
|
| 614 |
throw error;
|
| 615 |
}
|
|
|
|
| 618 |
// ☁️ 所有尝试都因网络层原因失败(超时耗尽重试也走这里)→ 打上冷却,
|
| 619 |
// 接下来 API.CLOUD_DOWN_COOLDOWN 内的 GET 全部就地走缓存,不再重复等待
|
| 620 |
if (networkLevelFailure) markCloudDown(endpoint);
|
| 621 |
+
|
| 622 |
// 所有重试都失败,尝试回退到过期缓存
|
| 623 |
+
if (method === "GET" && !options.noCache) {
|
| 624 |
const fallback = getCacheWithMeta(cacheKey, true); // true = 允许过期
|
| 625 |
if (fallback.found) {
|
| 626 |
+
console.warn('📴 网络请求失败,回退到本地缓存');
|
| 627 |
const proxyFn4 = await getProxyImages();
|
| 628 |
return proxyFn4(fallback.value); // 返回过期缓存数据
|
| 629 |
}
|
|
|
|
| 632 |
throw lastError || new Error('请求失败');
|
| 633 |
})();
|
| 634 |
|
| 635 |
+
if (privateEntry) requestPromise.finally(() => activePrivateReads.delete(privateEntry)).catch(() => {});
|
| 636 |
+
|
| 637 |
// 记录进行中的请求
|
| 638 |
+
if (trackPendingRequest) {
|
| 639 |
pendingRequests.set(cacheKey, requestPromise);
|
| 640 |
}
|
| 641 |
|
|
|
|
| 643 |
// 🔧 补 .catch 空处理:finally 会派生出一个新的 Promise,而它从未被返回也无人接收,
|
| 644 |
// 一旦请求失败(含主动撤销)就会在控制台抛 Uncaught (in promise)。
|
| 645 |
// 原始 requestPromise 仍在下方正常返回给调用方处理,此处只是吞掉这份重复的拒绝
|
| 646 |
+
if (trackPendingRequest) {
|
| 647 |
+
requestPromise.finally(() => {
|
| 648 |
+
pendingRequests.delete(cacheKey);
|
| 649 |
+
}).catch(() => {});
|
| 650 |
+
}
|
| 651 |
|
| 652 |
return requestPromise;
|
| 653 |
}
|
|
|
|
| 668 |
const timeout = options.timeout || 1200000; // 20分钟全局超时(大型工具克隆需要较长时间)
|
| 669 |
const controller = new AbortController();
|
| 670 |
const globalTimer = setTimeout(() => controller.abort(), timeout);
|
| 671 |
+
const externalSignal = options.signal;
|
| 672 |
+
const abortFromCaller = () => controller.abort();
|
| 673 |
+
if (externalSignal?.aborted) controller.abort();
|
| 674 |
+
else externalSignal?.addEventListener("abort", abortFromCaller, { once: true });
|
| 675 |
|
| 676 |
// 📡 空闲超时检测:长时间无数据视为连接已断
|
| 677 |
const IDLE_TIMEOUT_DEFAULT = 30000; // 30 秒无数据视为空闲
|
| 678 |
const IDLE_TIMEOUT_GRACE = 5000; // 收到 finalResult 后的宽限期 5 秒
|
| 679 |
let currentIdleTimeout = options.idleTimeout || IDLE_TIMEOUT_DEFAULT;
|
| 680 |
let idleTimer = null;
|
| 681 |
+
let finalResult = null;
|
| 682 |
|
| 683 |
const clearIdleTimer = () => {
|
| 684 |
if (idleTimer !== null) {
|
|
|
|
| 696 |
});
|
| 697 |
|
| 698 |
if (!response.ok) {
|
| 699 |
+
const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
|
| 700 |
+
error.status = response.status;
|
| 701 |
+
// 仅当流式路由明确不存在/不接受该方法时,调用方可安全回退到旧端点。
|
| 702 |
+
// 网络错误、超时和服务端错误都可能发生在操作已开始后,不可自动重发。
|
| 703 |
+
error.fallbackSafe = response.status === 404 || response.status === 405;
|
| 704 |
+
throw error;
|
| 705 |
}
|
| 706 |
|
| 707 |
const reader = response.body.getReader();
|
| 708 |
const decoder = new TextDecoder();
|
| 709 |
let buffer = "";
|
| 710 |
+
const processSSELine = (line) => {
|
| 711 |
+
if (!line.startsWith("data: ")) return;
|
| 712 |
+
let data;
|
| 713 |
+
try {
|
| 714 |
+
data = JSON.parse(line.slice(6));
|
| 715 |
+
} catch (parseError) {
|
| 716 |
+
console.warn("[SSE] 解析事件失败:", parseError?.name || 'SyntaxError');
|
| 717 |
+
return;
|
| 718 |
+
}
|
| 719 |
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
| 720 |
+
console.warn("[SSE] 忽略非对象事件");
|
| 721 |
+
return;
|
| 722 |
+
}
|
| 723 |
+
if (data.status === "success" || data.status === "error") {
|
| 724 |
+
// 先保存最终结果,UI 更新回调即使异常也不能丢失服务端结果。
|
| 725 |
+
finalResult = data;
|
| 726 |
+
currentIdleTimeout = IDLE_TIMEOUT_GRACE;
|
| 727 |
+
}
|
| 728 |
+
try {
|
| 729 |
+
onProgress?.(data);
|
| 730 |
+
} catch (callbackError) {
|
| 731 |
+
console.warn("[SSE] 进度回调失败");
|
| 732 |
+
}
|
| 733 |
+
};
|
| 734 |
while (true) {
|
| 735 |
// 使用 Promise.race 实现空闲超时:reader.read() 与空闲定时器竞争
|
| 736 |
const idlePromise = new Promise((_, reject) => {
|
|
|
|
| 750 |
// 如果已收到 finalResult,优先返回它(服务器只是没正确关闭连接)
|
| 751 |
if (finalResult) return finalResult;
|
| 752 |
// 否则返回空闲超时错误
|
| 753 |
+
return { status: "error", outcomeUnknown: true, message: "连接超时:30秒未收到数据" };
|
| 754 |
}
|
| 755 |
throw e;
|
| 756 |
}
|
| 757 |
clearIdleTimer();
|
| 758 |
|
| 759 |
const { done, value } = readResult;
|
| 760 |
+
if (done) {
|
| 761 |
+
buffer += decoder.decode();
|
| 762 |
+
for (const line of buffer.split("\n")) processSSELine(line);
|
| 763 |
+
buffer = "";
|
| 764 |
+
break;
|
| 765 |
+
}
|
| 766 |
|
| 767 |
buffer += decoder.decode(value, { stream: true });
|
| 768 |
const lines = buffer.split("\n");
|
| 769 |
buffer = lines.pop() || ""; // 保留不完整行
|
| 770 |
+
for (const line of lines) processSSELine(line);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 771 |
}
|
| 772 |
|
| 773 |
+
return finalResult || { status: "error", outcomeUnknown: true, message: "未收到最终结果" };
|
| 774 |
} catch (err) {
|
| 775 |
+
// 已收到最终事件时,后续连接关闭错误不应覆盖操作结果或触发重复提交。
|
| 776 |
+
if (finalResult) return finalResult;
|
| 777 |
if (err.name === "AbortError") {
|
| 778 |
+
return {
|
| 779 |
+
status: "error",
|
| 780 |
+
outcomeUnknown: true,
|
| 781 |
+
cancelRequested: !!externalSignal?.aborted,
|
| 782 |
+
message: externalSignal?.aborted ? t('feedback.request_cancelled') : "连接超时,操作结果暂时无法确认"
|
| 783 |
+
};
|
| 784 |
}
|
| 785 |
throw err;
|
| 786 |
} finally {
|
| 787 |
clearTimeout(globalTimer);
|
| 788 |
clearIdleTimer();
|
| 789 |
+
externalSignal?.removeEventListener("abort", abortFromCaller);
|
| 790 |
}
|
| 791 |
}
|
| 792 |
|
前端页面/core/网络请求_缓存管理.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
| 9 |
// - 性能优化工具.js (重导出缓存函数,保持向后兼容)
|
| 10 |
// ==========================================
|
| 11 |
|
| 12 |
-
import { CACHE } from "./全局配置.js";
|
| 13 |
|
| 14 |
// ==========================================
|
| 15 |
// 📦 缓存底层实现
|
|
@@ -23,6 +23,10 @@ import { CACHE } from "./全局配置.js";
|
|
| 23 |
const CACHE_PREFIX = CACHE.PREFIX;
|
| 24 |
const DEFAULT_TTL = CACHE.DEFAULT_TTL;
|
| 25 |
const MAX_MEMORY_ITEMS = CACHE.MAX_MEMORY_ITEMS;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
// 内存缓存(最快)
|
| 28 |
const memoryCache = new Map();
|
|
@@ -46,6 +50,21 @@ function _isExpired(cached) {
|
|
| 46 |
return Date.now() >= cached.expireAt;
|
| 47 |
}
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
/**
|
| 50 |
* 安全写入 localStorage,配额不足时先清理过期项再重试,仍失败则 LRU 淘汰后重试
|
| 51 |
* @param {string} fullKey - 完整缓存键
|
|
@@ -54,12 +73,15 @@ function _isExpired(cached) {
|
|
| 54 |
function _safeStorageWrite(fullKey, jsonStr) {
|
| 55 |
try {
|
| 56 |
localStorage.setItem(fullKey, jsonStr);
|
|
|
|
| 57 |
} catch (e) {
|
| 58 |
if (e.name === 'QuotaExceededError' || e.name === 'NS_ERROR_DOM_QUOTA_REACHED') {
|
|
|
|
| 59 |
console.warn('⚠️ localStorage 已满,尝试清理过期缓存...');
|
| 60 |
_cleanExpiredStorage();
|
| 61 |
try {
|
| 62 |
localStorage.setItem(fullKey, jsonStr);
|
|
|
|
| 63 |
} catch {
|
| 64 |
let stored = false;
|
| 65 |
for (let attempt = 0; attempt < 5 && !stored; attempt++) {
|
|
@@ -73,11 +95,13 @@ function _safeStorageWrite(fullKey, jsonStr) {
|
|
| 73 |
}
|
| 74 |
}
|
| 75 |
if (!stored) {
|
| 76 |
-
console.warn(
|
| 77 |
}
|
|
|
|
| 78 |
}
|
| 79 |
} else {
|
| 80 |
-
console.warn('
|
|
|
|
| 81 |
}
|
| 82 |
}
|
| 83 |
}
|
|
@@ -88,11 +112,13 @@ function _safeStorageWrite(fullKey, jsonStr) {
|
|
| 88 |
* @param {any} value - 缓存值
|
| 89 |
* @param {number} ttl - 过期时间(毫秒),默认2小时
|
| 90 |
* @param {boolean} persist - 是否持久化到 localStorage
|
|
|
|
| 91 |
*/
|
| 92 |
-
export function setCache(key, value, ttl = DEFAULT_TTL, persist = true) {
|
| 93 |
const fk = _fullKey(key);
|
| 94 |
const expireAt = Date.now() + ttl;
|
| 95 |
-
const cacheData = { value, expireAt };
|
|
|
|
| 96 |
|
| 97 |
// 内存缓存
|
| 98 |
memoryCache.set(fk, cacheData);
|
|
@@ -101,7 +127,22 @@ export function setCache(key, value, ttl = DEFAULT_TTL, persist = true) {
|
|
| 101 |
|
| 102 |
// 持久化缓存
|
| 103 |
if (persist) {
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
}
|
| 106 |
}
|
| 107 |
|
|
@@ -116,12 +157,15 @@ export function getCache(key) {
|
|
| 116 |
// 优先从内存获取
|
| 117 |
if (memoryCache.has(fk)) {
|
| 118 |
const cached = memoryCache.get(fk);
|
| 119 |
-
if (!
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
}
|
| 123 |
-
// 过期,清除(走 _deleteMemoryEntry 同步摘除 LRU 顺序数组,避免幽灵键挤占名额)
|
| 124 |
-
_deleteMemoryEntry(fk);
|
| 125 |
}
|
| 126 |
|
| 127 |
// 降级到 localStorage
|
|
@@ -129,10 +173,15 @@ export function getCache(key) {
|
|
| 129 |
const stored = localStorage.getItem(fk);
|
| 130 |
if (stored) {
|
| 131 |
const cached = JSON.parse(stored);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
if (!_isExpired(cached)) {
|
| 133 |
// 回填内存缓存
|
| 134 |
memoryCache.set(fk, cached);
|
| 135 |
_updateLRU(fk);
|
|
|
|
| 136 |
return cached.value;
|
| 137 |
}
|
| 138 |
// 🔧 修复:过期条目不再物理删除,保留给断网时的过期缓存兜底使用;
|
|
@@ -140,7 +189,7 @@ export function getCache(key) {
|
|
| 140 |
// (原实现此处删除会导致 request() 新鲜检查先删、离线兜底后读而落空)
|
| 141 |
}
|
| 142 |
} catch (e) {
|
| 143 |
-
console.warn("⚠️ 缓存读取失败
|
| 144 |
}
|
| 145 |
|
| 146 |
return null;
|
|
@@ -158,14 +207,17 @@ export function getCacheWithMeta(key, ignoreExpiry = false) {
|
|
| 158 |
// 优先从内存获取
|
| 159 |
if (memoryCache.has(fk)) {
|
| 160 |
const cached = memoryCache.get(fk);
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
}
|
| 167 |
-
// 过期且不忽略,清除(同上:必须同步维护 memoryCacheOrder)
|
| 168 |
-
_deleteMemoryEntry(fk);
|
| 169 |
}
|
| 170 |
|
| 171 |
// 降级到 localStorage
|
|
@@ -173,19 +225,23 @@ export function getCacheWithMeta(key, ignoreExpiry = false) {
|
|
| 173 |
const stored = localStorage.getItem(fk);
|
| 174 |
if (stored) {
|
| 175 |
const cached = JSON.parse(stored);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
const expired = _isExpired(cached);
|
| 177 |
|
| 178 |
if (!expired || ignoreExpiry) {
|
| 179 |
// 回填内存缓存
|
| 180 |
memoryCache.set(fk, cached);
|
| 181 |
_updateLRU(fk);
|
| 182 |
-
|
|
|
|
| 183 |
}
|
| 184 |
-
//
|
| 185 |
-
// 内存中的过期副本已在上方删除,下次可重新从 storage 回填
|
| 186 |
}
|
| 187 |
} catch (e) {
|
| 188 |
-
console.warn("⚠️ 缓存读取失败
|
| 189 |
}
|
| 190 |
|
| 191 |
return { value: null, expired: false, found: false };
|
|
@@ -203,6 +259,99 @@ function _deleteMemoryEntry(fk) {
|
|
| 203 |
if (idx > -1) memoryCacheOrder.splice(idx, 1);
|
| 204 |
}
|
| 205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
/**
|
| 207 |
* 删除缓存(精确键,内存 + localStorage 两级)
|
| 208 |
* @param {string} key - 缓存键
|
|
@@ -213,6 +362,7 @@ export function removeCache(key) {
|
|
| 213 |
try {
|
| 214 |
localStorage.removeItem(fk);
|
| 215 |
} catch {}
|
|
|
|
| 216 |
}
|
| 217 |
|
| 218 |
/**
|
|
@@ -231,7 +381,7 @@ export function removeCacheByPrefix(prefix) {
|
|
| 231 |
const candidates = prefix.startsWith(CACHE_PREFIX) ? [prefix] : [_fullKey(prefix), prefix];
|
| 232 |
const isMatch = (key) => candidates.some(p => key.startsWith(p));
|
| 233 |
const removed = new Set();
|
| 234 |
-
|
| 235 |
// 🔧 内存层必须先清:getCache / getCacheWithMeta 都优先读 memoryCache,
|
| 236 |
// 只清 localStorage 的话旧数据仍会命中内存并被直接返回(请求根本不会发出)
|
| 237 |
for (const fk of Array.from(memoryCache.keys())) {
|
|
@@ -240,7 +390,7 @@ export function removeCacheByPrefix(prefix) {
|
|
| 240 |
removed.add(fk);
|
| 241 |
}
|
| 242 |
}
|
| 243 |
-
|
| 244 |
try {
|
| 245 |
const keysToRemove = [];
|
| 246 |
for (let i = 0; i < localStorage.length; i++) {
|
|
@@ -252,7 +402,8 @@ export function removeCacheByPrefix(prefix) {
|
|
| 252 |
removed.add(k);
|
| 253 |
});
|
| 254 |
} catch {}
|
| 255 |
-
|
|
|
|
| 256 |
return removed.size;
|
| 257 |
}
|
| 258 |
|
|
@@ -262,6 +413,7 @@ export function removeCacheByPrefix(prefix) {
|
|
| 262 |
export function clearAllCache() {
|
| 263 |
memoryCache.clear();
|
| 264 |
memoryCacheOrder.length = 0;
|
|
|
|
| 265 |
|
| 266 |
try {
|
| 267 |
const keysToRemove = [];
|
|
@@ -298,7 +450,7 @@ function _cleanExpiredStorage(aggressive = false) {
|
|
| 298 |
const keysToRemove = [];
|
| 299 |
for (let i = 0; i < localStorage.length; i++) {
|
| 300 |
const key = localStorage.key(i);
|
| 301 |
-
if (
|
| 302 |
if (aggressive) {
|
| 303 |
// 激进模式:清除所有缓存项
|
| 304 |
keysToRemove.push(key);
|
|
@@ -327,10 +479,15 @@ function _evictOldestStorage() {
|
|
| 327 |
const entries = [];
|
| 328 |
for (let i = 0; i < localStorage.length; i++) {
|
| 329 |
const key = localStorage.key(i);
|
| 330 |
-
if (
|
| 331 |
try {
|
| 332 |
const cached = JSON.parse(localStorage.getItem(key));
|
| 333 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
} catch {
|
| 335 |
localStorage.removeItem(key);
|
| 336 |
}
|
|
@@ -339,7 +496,7 @@ function _evictOldestStorage() {
|
|
| 339 |
|
| 340 |
if (entries.length === 0) return false;
|
| 341 |
|
| 342 |
-
entries.sort((a, b) => a.
|
| 343 |
|
| 344 |
// 🧹 批量淘汰策略:每次淘汰最旧的 30%(温和策略,减少频繁全量扫描)
|
| 345 |
const evictCount = Math.max(1, Math.ceil(entries.length * 0.3));
|
|
@@ -436,10 +593,10 @@ const CACHE_INVALIDATION_MAP = {
|
|
| 436 |
},
|
| 437 |
// 帖子相关
|
| 438 |
"/api/posts": {
|
| 439 |
-
"POST": ["api_/api/posts", "ComfyRanking_ListCache_posts"], // 创建帖子,清除列表缓存
|
| 440 |
-
"PUT": ["api_/api/posts"], // 更新帖子,清除列表缓存
|
| 441 |
-
"DELETE": ["api_/api/posts", "ComfyRanking_ListCache_posts"], // 删除帖子,清除列表缓存
|
| 442 |
-
"INTERACTION": ["api_/api/posts", "ComfyRanking_ListCache_posts"] // 👍 互动操作(view/like/favorite/tip)
|
| 443 |
},
|
| 444 |
// 评论相关
|
| 445 |
"/api/comments": {
|
|
@@ -448,11 +605,18 @@ const CACHE_INVALIDATION_MAP = {
|
|
| 448 |
},
|
| 449 |
// 商品相关
|
| 450 |
"/api/items": {
|
| 451 |
-
"POST": ["api_/api/items", "api_/api/creators"], // 创建商品,清除商品列表和创作者列表
|
| 452 |
-
"PUT": ["api_/api/items"], // 更新商品,仅清除商品列表(不碰创作者)
|
| 453 |
-
"DELETE": ["api_/api/items", "api_/api/creators"],
|
| 454 |
-
"INTERACTION": ["api_/api/items", "api_/api/creators"] // 👍 互动操作(view/like)
|
| 455 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
// 用户相关
|
| 457 |
"/api/users": {
|
| 458 |
"PUT": [
|
|
@@ -526,7 +690,7 @@ export function invalidateRelatedCache(endpoint, method = null) {
|
|
| 526 |
}
|
| 527 |
|
| 528 |
if (cleared > 0) {
|
| 529 |
-
console.log(`🗑️ 精确清除缓存: ${cleared} 个项
|
| 530 |
}
|
| 531 |
}
|
| 532 |
|
|
@@ -544,3 +708,24 @@ export function _getCacheTTL(endpoint) {
|
|
| 544 |
|
| 545 |
// 导出配置和映射
|
| 546 |
export { CACHE_CONFIG, CACHE_INVALIDATION_MAP };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
// - 性能优化工具.js (重导出缓存函数,保持向后兼容)
|
| 10 |
// ==========================================
|
| 11 |
|
| 12 |
+
import { API, CACHE } from "./全局配置.js";
|
| 13 |
|
| 14 |
// ==========================================
|
| 15 |
// 📦 缓存底层实现
|
|
|
|
| 23 |
const CACHE_PREFIX = CACHE.PREFIX;
|
| 24 |
const DEFAULT_TTL = CACHE.DEFAULT_TTL;
|
| 25 |
const MAX_MEMORY_ITEMS = CACHE.MAX_MEMORY_ITEMS;
|
| 26 |
+
const CACHE_SCHEMA_VERSION = 2;
|
| 27 |
+
const MAX_STORAGE_BYTES = CACHE.MAX_STORAGE_BYTES;
|
| 28 |
+
let storageUsageBytes = null;
|
| 29 |
+
let lastStorageBudgetSweepAt = 0;
|
| 30 |
|
| 31 |
// 内存缓存(最快)
|
| 32 |
const memoryCache = new Map();
|
|
|
|
| 50 |
return Date.now() >= cached.expireAt;
|
| 51 |
}
|
| 52 |
|
| 53 |
+
function _isValidCacheEntry(cached) {
|
| 54 |
+
return !!cached && typeof cached === "object"
|
| 55 |
+
&& Object.prototype.hasOwnProperty.call(cached, "value")
|
| 56 |
+
&& Number.isFinite(cached.expireAt)
|
| 57 |
+
&& (cached.schemaVersion == null || (Number.isInteger(cached.schemaVersion)
|
| 58 |
+
&& cached.schemaVersion >= 1 && cached.schemaVersion <= CACHE_SCHEMA_VERSION));
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// 同前缀的本地记录/配置使用独立格式;自动缓存清理不能处理它们。
|
| 62 |
+
function _isManagedStorageKey(key) {
|
| 63 |
+
if (!key || !key.startsWith(CACHE_PREFIX)) return false;
|
| 64 |
+
return !Object.values(CACHE.LOCAL_KEYS).some(localKey => key === localKey
|
| 65 |
+
|| key.startsWith(localKey.endsWith('_') ? localKey : localKey + '_'));
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
/**
|
| 69 |
* 安全写入 localStorage,配额不足时先清理过期项再重试,仍失败则 LRU 淘汰后重试
|
| 70 |
* @param {string} fullKey - 完整缓存键
|
|
|
|
| 73 |
function _safeStorageWrite(fullKey, jsonStr) {
|
| 74 |
try {
|
| 75 |
localStorage.setItem(fullKey, jsonStr);
|
| 76 |
+
return true;
|
| 77 |
} catch (e) {
|
| 78 |
if (e.name === 'QuotaExceededError' || e.name === 'NS_ERROR_DOM_QUOTA_REACHED') {
|
| 79 |
+
storageUsageBytes = null;
|
| 80 |
console.warn('⚠️ localStorage 已满,尝试清理过期缓存...');
|
| 81 |
_cleanExpiredStorage();
|
| 82 |
try {
|
| 83 |
localStorage.setItem(fullKey, jsonStr);
|
| 84 |
+
return true;
|
| 85 |
} catch {
|
| 86 |
let stored = false;
|
| 87 |
for (let attempt = 0; attempt < 5 && !stored; attempt++) {
|
|
|
|
| 95 |
}
|
| 96 |
}
|
| 97 |
if (!stored) {
|
| 98 |
+
console.warn('localStorage 持久化失败,保留内存缓存');
|
| 99 |
}
|
| 100 |
+
return stored;
|
| 101 |
}
|
| 102 |
} else {
|
| 103 |
+
console.warn('localStorage 存储不可用,保留内存缓存');
|
| 104 |
+
return false;
|
| 105 |
}
|
| 106 |
}
|
| 107 |
}
|
|
|
|
| 112 |
* @param {any} value - 缓存值
|
| 113 |
* @param {number} ttl - 过期时间(毫秒),默认2小时
|
| 114 |
* @param {boolean} persist - 是否持久化到 localStorage
|
| 115 |
+
* @param {{etag?: string}} metadata - 可选的正文验证器,与value一同失效
|
| 116 |
*/
|
| 117 |
+
export function setCache(key, value, ttl = DEFAULT_TTL, persist = true, metadata = {}) {
|
| 118 |
const fk = _fullKey(key);
|
| 119 |
const expireAt = Date.now() + ttl;
|
| 120 |
+
const cacheData = { schemaVersion: CACHE_SCHEMA_VERSION, value, expireAt, lastAccessAt: Date.now() };
|
| 121 |
+
if (typeof metadata?.etag === "string" && API.CONDITIONAL_ETAG_PATTERN.test(metadata.etag)) cacheData.etag = metadata.etag;
|
| 122 |
|
| 123 |
// 内存缓存
|
| 124 |
memoryCache.set(fk, cacheData);
|
|
|
|
| 127 |
|
| 128 |
// 持久化缓存
|
| 129 |
if (persist) {
|
| 130 |
+
const jsonStr = JSON.stringify(cacheData);
|
| 131 |
+
let previousBytes = 0;
|
| 132 |
+
let previousLengthKnown = false;
|
| 133 |
+
try {
|
| 134 |
+
const previous = localStorage.getItem(fk);
|
| 135 |
+
previousBytes = previous === null ? 0 : (fk.length + previous.length) * 2;
|
| 136 |
+
previousLengthKnown = true;
|
| 137 |
+
} catch {}
|
| 138 |
+
if (_safeStorageWrite(fk, jsonStr)) {
|
| 139 |
+
if (storageUsageBytes !== null && previousLengthKnown) {
|
| 140 |
+
storageUsageBytes += (fk.length + jsonStr.length) * 2 - previousBytes;
|
| 141 |
+
} else {
|
| 142 |
+
storageUsageBytes = null;
|
| 143 |
+
}
|
| 144 |
+
_enforceStorageBudget(fk);
|
| 145 |
+
}
|
| 146 |
}
|
| 147 |
}
|
| 148 |
|
|
|
|
| 157 |
// 优先从内存获取
|
| 158 |
if (memoryCache.has(fk)) {
|
| 159 |
const cached = memoryCache.get(fk);
|
| 160 |
+
if (!_isValidCacheEntry(cached)) _deleteMemoryEntry(fk);
|
| 161 |
+
else {
|
| 162 |
+
if (!_isExpired(cached)) {
|
| 163 |
+
_updateLRU(fk);
|
| 164 |
+
_touchStorageEntry(fk, cached);
|
| 165 |
+
return cached.value;
|
| 166 |
+
}
|
| 167 |
+
// 仅内存缓存也需要保留过期副本,供弱网回退使用。
|
| 168 |
}
|
|
|
|
|
|
|
| 169 |
}
|
| 170 |
|
| 171 |
// 降级到 localStorage
|
|
|
|
| 173 |
const stored = localStorage.getItem(fk);
|
| 174 |
if (stored) {
|
| 175 |
const cached = JSON.parse(stored);
|
| 176 |
+
if (!_isValidCacheEntry(cached)) {
|
| 177 |
+
localStorage.removeItem(fk);
|
| 178 |
+
return null;
|
| 179 |
+
}
|
| 180 |
if (!_isExpired(cached)) {
|
| 181 |
// 回填内存缓存
|
| 182 |
memoryCache.set(fk, cached);
|
| 183 |
_updateLRU(fk);
|
| 184 |
+
_touchStorageEntry(fk, cached);
|
| 185 |
return cached.value;
|
| 186 |
}
|
| 187 |
// 🔧 修复:过期条目不再物理删除,保留给断网时的过期缓存兜底使用;
|
|
|
|
| 189 |
// (原实现此处删除会导致 request() 新鲜检查先删、离线兜底后读而落空)
|
| 190 |
}
|
| 191 |
} catch (e) {
|
| 192 |
+
console.warn("⚠️ 缓存读取失败。");
|
| 193 |
}
|
| 194 |
|
| 195 |
return null;
|
|
|
|
| 207 |
// 优先从内存获取
|
| 208 |
if (memoryCache.has(fk)) {
|
| 209 |
const cached = memoryCache.get(fk);
|
| 210 |
+
if (!_isValidCacheEntry(cached)) _deleteMemoryEntry(fk);
|
| 211 |
+
else {
|
| 212 |
+
const expired = _isExpired(cached);
|
| 213 |
+
|
| 214 |
+
if (!expired || ignoreExpiry) {
|
| 215 |
+
_updateLRU(fk);
|
| 216 |
+
_touchStorageEntry(fk, cached);
|
| 217 |
+
return { value: cached.value, expired, found: true, ...(typeof cached.etag === "string" && API.CONDITIONAL_ETAG_PATTERN.test(cached.etag) ? { etag: cached.etag } : {}) };
|
| 218 |
+
}
|
| 219 |
+
// 新鲜度检查不删除数据;淘汰仍由 LRU 和显式失效负责。
|
| 220 |
}
|
|
|
|
|
|
|
| 221 |
}
|
| 222 |
|
| 223 |
// 降级到 localStorage
|
|
|
|
| 225 |
const stored = localStorage.getItem(fk);
|
| 226 |
if (stored) {
|
| 227 |
const cached = JSON.parse(stored);
|
| 228 |
+
if (!_isValidCacheEntry(cached)) {
|
| 229 |
+
localStorage.removeItem(fk);
|
| 230 |
+
return { value: null, expired: false, found: false };
|
| 231 |
+
}
|
| 232 |
const expired = _isExpired(cached);
|
| 233 |
|
| 234 |
if (!expired || ignoreExpiry) {
|
| 235 |
// 回填内存缓存
|
| 236 |
memoryCache.set(fk, cached);
|
| 237 |
_updateLRU(fk);
|
| 238 |
+
_touchStorageEntry(fk, cached);
|
| 239 |
+
return { value: cached.value, expired, found: true, ...(typeof cached.etag === "string" && API.CONDITIONAL_ETAG_PATTERN.test(cached.etag) ? { etag: cached.etag } : {}) };
|
| 240 |
}
|
| 241 |
+
// 与内存层一致,保留过期条目供离线兜底。
|
|
|
|
| 242 |
}
|
| 243 |
} catch (e) {
|
| 244 |
+
console.warn("⚠️ 缓存读取失败。");
|
| 245 |
}
|
| 246 |
|
| 247 |
return { value: null, expired: false, found: false };
|
|
|
|
| 259 |
if (idx > -1) memoryCacheOrder.splice(idx, 1);
|
| 260 |
}
|
| 261 |
|
| 262 |
+
/**
|
| 263 |
+
* 按业务前缀取得两级缓存键快照,返回不含 CACHE.PREFIX 的键。
|
| 264 |
+
* 内存键排在前面并去重;存储访问失败不影响仅内存缓存的离线查找。
|
| 265 |
+
* 此处只枚举键,条目结构与 TTL 仍由 getCacheWithMeta 校验。
|
| 266 |
+
*/
|
| 267 |
+
export function getCacheKeysByPrefix(prefix) {
|
| 268 |
+
if (typeof prefix !== "string" || !prefix) return [];
|
| 269 |
+
const fullPrefix = _fullKey(prefix);
|
| 270 |
+
const keys = new Set();
|
| 271 |
+
for (const key of memoryCache.keys()) {
|
| 272 |
+
if (key.startsWith(fullPrefix)) keys.add(key.slice(CACHE_PREFIX.length));
|
| 273 |
+
}
|
| 274 |
+
try {
|
| 275 |
+
for (let index = 0; index < localStorage.length; index++) {
|
| 276 |
+
const key = localStorage.key(index);
|
| 277 |
+
if (key && key.startsWith(fullPrefix)) keys.add(key.slice(CACHE_PREFIX.length));
|
| 278 |
+
}
|
| 279 |
+
} catch {
|
| 280 |
+
// 禁用存储或浏览器隐私策略下,继续使用上面已取得的内存键。
|
| 281 |
+
}
|
| 282 |
+
return [...keys];
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
function _touchStorageEntry(key, cached) {
|
| 286 |
+
try {
|
| 287 |
+
if (localStorage.getItem(key) === null) return;
|
| 288 |
+
const now = Date.now();
|
| 289 |
+
if (Number.isFinite(cached.lastAccessAt) && now - cached.lastAccessAt < 60_000) return;
|
| 290 |
+
cached.lastAccessAt = now;
|
| 291 |
+
localStorage.setItem(key, JSON.stringify(cached));
|
| 292 |
+
} catch {}
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
/** Keep this plugin's persistent cache within its estimated UTF-16 storage budget. */
|
| 296 |
+
function _enforceStorageBudget(protectedKey = null) {
|
| 297 |
+
const now = Date.now();
|
| 298 |
+
if (storageUsageBytes !== null && storageUsageBytes <= MAX_STORAGE_BYTES
|
| 299 |
+
&& now - lastStorageBudgetSweepAt < 60_000) return;
|
| 300 |
+
|
| 301 |
+
try {
|
| 302 |
+
const entries = [];
|
| 303 |
+
const invalidKeys = [];
|
| 304 |
+
let totalBytes = 0;
|
| 305 |
+
for (let i = 0; i < localStorage.length; i++) {
|
| 306 |
+
const key = localStorage.key(i);
|
| 307 |
+
if (!_isManagedStorageKey(key)) continue;
|
| 308 |
+
const raw = localStorage.getItem(key);
|
| 309 |
+
if (raw === null) continue;
|
| 310 |
+
let cached;
|
| 311 |
+
try {
|
| 312 |
+
cached = JSON.parse(raw);
|
| 313 |
+
} catch {
|
| 314 |
+
invalidKeys.push(key);
|
| 315 |
+
continue;
|
| 316 |
+
}
|
| 317 |
+
if (!_isValidCacheEntry(cached)) {
|
| 318 |
+
invalidKeys.push(key);
|
| 319 |
+
continue;
|
| 320 |
+
}
|
| 321 |
+
const sizeBytes = (key.length + raw.length) * 2;
|
| 322 |
+
const writtenAt = Number.isFinite(cached.expireAt) ? cached.expireAt - DEFAULT_TTL : 0;
|
| 323 |
+
entries.push({ key, sizeBytes, lastAccessAt: Number.isFinite(cached.lastAccessAt) ? cached.lastAccessAt : writtenAt });
|
| 324 |
+
totalBytes += sizeBytes;
|
| 325 |
+
}
|
| 326 |
+
invalidKeys.forEach(key => localStorage.removeItem(key));
|
| 327 |
+
|
| 328 |
+
let evicted = 0;
|
| 329 |
+
for (const entry of [...entries]) {
|
| 330 |
+
if (entry.key === protectedKey && entry.sizeBytes > MAX_STORAGE_BYTES) {
|
| 331 |
+
localStorage.removeItem(entry.key);
|
| 332 |
+
totalBytes -= entry.sizeBytes;
|
| 333 |
+
entries.splice(entries.indexOf(entry), 1);
|
| 334 |
+
evicted++;
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
entries.sort((a, b) => a.lastAccessAt - b.lastAccessAt);
|
| 338 |
+
while (totalBytes > MAX_STORAGE_BYTES && entries.length > 0) {
|
| 339 |
+
const oldest = entries.shift();
|
| 340 |
+
localStorage.removeItem(oldest.key);
|
| 341 |
+
totalBytes -= oldest.sizeBytes;
|
| 342 |
+
evicted++;
|
| 343 |
+
}
|
| 344 |
+
storageUsageBytes = totalBytes;
|
| 345 |
+
lastStorageBudgetSweepAt = now;
|
| 346 |
+
if (evicted > 0) {
|
| 347 |
+
console.warn(`⚠️ 本地缓存超过约 ${Math.round(MAX_STORAGE_BYTES / 1024)} KiB 预算,按最近访问时间淘汰 ${evicted} 项`);
|
| 348 |
+
}
|
| 349 |
+
} catch {
|
| 350 |
+
// 存储不可用时保留内存缓存;下一次持久化再重试预算扫描。
|
| 351 |
+
storageUsageBytes = null;
|
| 352 |
+
}
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
/**
|
| 356 |
* 删除缓存(精确键,内存 + localStorage 两级)
|
| 357 |
* @param {string} key - 缓存键
|
|
|
|
| 362 |
try {
|
| 363 |
localStorage.removeItem(fk);
|
| 364 |
} catch {}
|
| 365 |
+
storageUsageBytes = null;
|
| 366 |
}
|
| 367 |
|
| 368 |
/**
|
|
|
|
| 381 |
const candidates = prefix.startsWith(CACHE_PREFIX) ? [prefix] : [_fullKey(prefix), prefix];
|
| 382 |
const isMatch = (key) => candidates.some(p => key.startsWith(p));
|
| 383 |
const removed = new Set();
|
| 384 |
+
|
| 385 |
// 🔧 内存层必须先清:getCache / getCacheWithMeta 都优先读 memoryCache,
|
| 386 |
// 只清 localStorage 的话旧数据仍会命中内存并被直接返回(请求根本不会发出)
|
| 387 |
for (const fk of Array.from(memoryCache.keys())) {
|
|
|
|
| 390 |
removed.add(fk);
|
| 391 |
}
|
| 392 |
}
|
| 393 |
+
|
| 394 |
try {
|
| 395 |
const keysToRemove = [];
|
| 396 |
for (let i = 0; i < localStorage.length; i++) {
|
|
|
|
| 402 |
removed.add(k);
|
| 403 |
});
|
| 404 |
} catch {}
|
| 405 |
+
|
| 406 |
+
if (removed.size > 0) storageUsageBytes = null;
|
| 407 |
return removed.size;
|
| 408 |
}
|
| 409 |
|
|
|
|
| 413 |
export function clearAllCache() {
|
| 414 |
memoryCache.clear();
|
| 415 |
memoryCacheOrder.length = 0;
|
| 416 |
+
storageUsageBytes = null;
|
| 417 |
|
| 418 |
try {
|
| 419 |
const keysToRemove = [];
|
|
|
|
| 450 |
const keysToRemove = [];
|
| 451 |
for (let i = 0; i < localStorage.length; i++) {
|
| 452 |
const key = localStorage.key(i);
|
| 453 |
+
if (_isManagedStorageKey(key)) {
|
| 454 |
if (aggressive) {
|
| 455 |
// 激进模式:清除所有缓存项
|
| 456 |
keysToRemove.push(key);
|
|
|
|
| 479 |
const entries = [];
|
| 480 |
for (let i = 0; i < localStorage.length; i++) {
|
| 481 |
const key = localStorage.key(i);
|
| 482 |
+
if (_isManagedStorageKey(key)) {
|
| 483 |
try {
|
| 484 |
const cached = JSON.parse(localStorage.getItem(key));
|
| 485 |
+
if (!_isValidCacheEntry(cached)) {
|
| 486 |
+
localStorage.removeItem(key);
|
| 487 |
+
continue;
|
| 488 |
+
}
|
| 489 |
+
const writtenAt = Number.isFinite(cached.expireAt) ? cached.expireAt - DEFAULT_TTL : 0;
|
| 490 |
+
entries.push({ key, lastAccessAt: Number.isFinite(cached.lastAccessAt) ? cached.lastAccessAt : writtenAt });
|
| 491 |
} catch {
|
| 492 |
localStorage.removeItem(key);
|
| 493 |
}
|
|
|
|
| 496 |
|
| 497 |
if (entries.length === 0) return false;
|
| 498 |
|
| 499 |
+
entries.sort((a, b) => a.lastAccessAt - b.lastAccessAt);
|
| 500 |
|
| 501 |
// 🧹 批量淘汰策略:每次淘汰最旧的 30%(温和策略,减少频繁全量扫描)
|
| 502 |
const evictCount = Math.max(1, Math.ceil(entries.length * 0.3));
|
|
|
|
| 593 |
},
|
| 594 |
// 帖子相关
|
| 595 |
"/api/posts": {
|
| 596 |
+
"POST": ["api_/api/posts", "ComfyRanking_ListCache_posts", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX], // 创建帖子,清除列表缓存
|
| 597 |
+
"PUT": ["api_/api/posts", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX], // 更新帖子,清除列表缓存
|
| 598 |
+
"DELETE": ["api_/api/posts", "ComfyRanking_ListCache_posts", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX], // 删除帖子,清除列表缓存
|
| 599 |
+
"INTERACTION": ["api_/api/posts", "ComfyRanking_ListCache_posts", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX] // 👍 互动操作(view/like/favorite/tip)
|
| 600 |
},
|
| 601 |
// 评论相关
|
| 602 |
"/api/comments": {
|
|
|
|
| 605 |
},
|
| 606 |
// 商品相关
|
| 607 |
"/api/items": {
|
| 608 |
+
"POST": ["api_/api/items", "api_/api/creators", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX], // 创建商品,清除商品列表和创作者列表
|
| 609 |
+
"PUT": ["api_/api/items", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX], // 更新商品,仅清除商品列表(不碰创作者)
|
| 610 |
+
"DELETE": ["api_/api/items", "api_/api/creators", ...CACHE.RESOURCE_LIST_PREFIXES, CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX], // 删除商品也清除四类排序缓存
|
| 611 |
+
"INTERACTION": ["api_/api/items", "api_/api/creators", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX] // 👍 互动操作(view/like)
|
| 612 |
},
|
| 613 |
+
"/api/prompts": {
|
| 614 |
+
"POST": ["api_/api/prompts", "PromptsCache_", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX],
|
| 615 |
+
"PUT": ["api_/api/prompts", "PromptsCache_", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX],
|
| 616 |
+
"DELETE": ["api_/api/prompts", "PromptsCache_", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX],
|
| 617 |
+
"INTERACTION": ["api_/api/prompts", "PromptsCache_", CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX]
|
| 618 |
+
},
|
| 619 |
+
"/api/interactions": { "POST": ["api_/api/items", "api_/api/creators", ...CACHE.RESOURCE_LIST_PREFIXES, CACHE.LOCAL_KEYS.PROFILE_LIST_PREFIX] },
|
| 620 |
// 用户相关
|
| 621 |
"/api/users": {
|
| 622 |
"PUT": [
|
|
|
|
| 690 |
}
|
| 691 |
|
| 692 |
if (cleared > 0) {
|
| 693 |
+
console.log(`🗑️ 精确清除缓存: ${cleared} 个项`);
|
| 694 |
}
|
| 695 |
}
|
| 696 |
|
|
|
|
| 708 |
|
| 709 |
// 导出配置和映射
|
| 710 |
export { CACHE_CONFIG, CACHE_INVALIDATION_MAP };
|
| 711 |
+
|
| 712 |
+
/** 私有 GET 策略只匹配路径,不受分页/筛选查询串影响。 */
|
| 713 |
+
export function isPrivateApiGet(endpoint) {
|
| 714 |
+
const path = endpoint.split(/[?#]/)[0];
|
| 715 |
+
return API.PRIVATE_GET_PATTERNS.some(pattern => pattern.test(path));
|
| 716 |
+
}
|
| 717 |
+
|
| 718 |
+
/** 一次性移除旧通用私有响应;不处理独立本地记录或公开榜单缓存。 */
|
| 719 |
+
export function purgePrivateApiCache() {
|
| 720 |
+
const keys = new Set(memoryCache.keys());
|
| 721 |
+
try {
|
| 722 |
+
for (let index = 0; index < localStorage.length; index++) keys.add(localStorage.key(index));
|
| 723 |
+
} catch (_) {}
|
| 724 |
+
const prefix = CACHE_PREFIX + "api_";
|
| 725 |
+
for (const fullKey of keys) {
|
| 726 |
+
if (fullKey?.startsWith(prefix) && isPrivateApiGet(fullKey.slice(prefix.length))) {
|
| 727 |
+
removeCache(fullKey.slice(CACHE_PREFIX.length));
|
| 728 |
+
}
|
| 729 |
+
}
|
| 730 |
+
}
|
| 731 |
+
purgePrivateApiCache();
|
前端页面/market/列表卡片组件.js
CHANGED
|
@@ -1,23 +1,29 @@
|
|
| 1 |
// 前端页面/market/列表卡片组件.js
|
| 2 |
-
import { createCommentSection, setupToggleButton, createRatingStars } from "../social/评论与互动组件.js";
|
| 3 |
-
import { api } from "../core/网络请求API.js";
|
| 4 |
import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
|
| 5 |
import { recordView, escapeHtml } from "../components/互动工具函数.js"; // 🧹 P2归一:escapeHtml 局部副本已移除
|
| 6 |
import { renderItemTrendChart } from "../components/图表渲染组件.js";
|
| 7 |
import { getCoverSandboxHTML, setupImageSandboxEvents } from "../components/图片沙盒组件.js";
|
| 8 |
import { setupResourceInstall, checkItemStatus } from "./资源安装引擎.js";
|
| 9 |
-
import { showToast
|
| 10 |
import { openTipModal } from "../profile/个人中心_赞赏组件.js";
|
| 11 |
import { renderTipBoardHTML, isMaxTipLevel } from "../components/打赏等级工具.js";
|
| 12 |
import { lazyLoadImages } from "../components/性能优化工具.js";
|
| 13 |
import { t } from "../components/用户体验增强.js";
|
| 14 |
-
import { getCachedProfile, getProfileWithSWR,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
/**
|
| 17 |
* 生成价格显示HTML(含待生效价格提示)
|
| 18 |
*/
|
| 19 |
function _generatePriceHTML(itemData, t) {
|
| 20 |
-
const formatPrice = (price) => price > 0 ? `${price} ${t('market.points')}` : t('market.free');
|
| 21 |
|
| 22 |
if (itemData.pending_price !== null && itemData.pending_price !== undefined && itemData.pending_price_effective_at) {
|
| 23 |
const effectiveTime = new Date(itemData.pending_price_effective_at);
|
|
@@ -25,18 +31,18 @@ function _generatePriceHTML(itemData, t) {
|
|
| 25 |
if (effectiveTime > now) {
|
| 26 |
const hoursLeft = Math.ceil((effectiveTime - now) / (1000 * 60 * 60));
|
| 27 |
return `
|
| 28 |
-
<span
|
| 29 |
-
<span
|
| 30 |
-
<span
|
| 31 |
-
→ ${itemData.pending_price}${t('market.points')}
|
| 32 |
</span>
|
| 33 |
</span>
|
| 34 |
`;
|
| 35 |
} else {
|
| 36 |
-
return `<span
|
| 37 |
}
|
| 38 |
} else {
|
| 39 |
-
return `<span
|
| 40 |
}
|
| 41 |
}
|
| 42 |
|
|
@@ -46,6 +52,10 @@ function _generatePriceHTML(itemData, t) {
|
|
| 46 |
* @param {Object} callbacks - 回调集合 { onName, onAvatar, fallbackName }
|
| 47 |
*/
|
| 48 |
function _loadAuthorProfile(account, { onName, onAvatar, fallbackName } = {}) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
const cached = getCachedProfile(account);
|
| 50 |
if (cached) {
|
| 51 |
if (cached.name && onName) onName(cached.name);
|
|
@@ -55,6 +65,7 @@ function _loadAuthorProfile(account, { onName, onAvatar, fallbackName } = {}) {
|
|
| 55 |
}
|
| 56 |
|
| 57 |
getProfileWithSWR(account, api.getUserProfile, (profile) => {
|
|
|
|
| 58 |
if (profile.name && onName) onName(profile.name);
|
| 59 |
if (profile.avatar && onAvatar) onAvatar(profile.avatar);
|
| 60 |
});
|
|
@@ -62,18 +73,22 @@ function _loadAuthorProfile(account, { onName, onAvatar, fallbackName } = {}) {
|
|
| 62 |
|
| 63 |
export function createItemCard(itemData, currentUser = null, contextType = null) {
|
| 64 |
const card = document.createElement("div");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
card.setAttribute("data-item-id", itemData.id);
|
| 66 |
-
Object.assign(card.style, {
|
| 67 |
-
backgroundColor: "var(--comfy-input-bg, #2b2b2b)", borderRadius: "8px", padding: "10px",
|
| 68 |
-
marginBottom: "12px", border: "1px solid #444", color: "#fff", fontFamily: "sans-serif"
|
| 69 |
-
});
|
| 70 |
|
| 71 |
// 🚀 核心新增:初始渲染时,动态计算真实的有效评论数(过滤软删除的废弃数据)
|
| 72 |
const calcActiveComments = (data) => {
|
| 73 |
if (!data || !Array.isArray(data)) return 0;
|
| 74 |
return data.reduce((acc, c) => {
|
|
|
|
| 75 |
const parentCnt = c.isDeleted ? 0 : 1;
|
| 76 |
-
const repliesCnt = (c.replies
|
|
|
|
|
|
|
| 77 |
return acc + parentCnt + repliesCnt;
|
| 78 |
}, 0);
|
| 79 |
};
|
|
@@ -84,34 +99,33 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
|
|
| 84 |
// 避免标题容器的 overflow: hidden + ellipsis 在长标题时把徽章一并裁掉
|
| 85 |
const installStatus = checkItemStatus(itemData.id, itemData.latest_version);
|
| 86 |
const updateBadgeHtml = installStatus.hasUpdate
|
| 87 |
-
? `<span
|
| 88 |
: '';
|
| 89 |
|
| 90 |
const summaryView = document.createElement("div");
|
| 91 |
summaryView.className = "item-summary";
|
| 92 |
-
summaryView.style.cursor = "pointer";
|
| 93 |
|
| 94 |
// 🚀 核心修改:为评论计数的 span 加上专属的 class,并填入过滤后的真实数量
|
| 95 |
// 🚀 布局重构:第1行标题+使用次数,第2行描述,第3行互动数据+发布者信息
|
| 96 |
summaryView.innerHTML = `
|
| 97 |
<!-- 第1行: 标题 + 更新徽章 + 使用次数 -->
|
| 98 |
-
<div
|
| 99 |
-
<
|
| 100 |
${updateBadgeHtml}
|
| 101 |
-
<span
|
| 102 |
-
<span data-stat="uses"
|
| 103 |
-
<span data-stat="views"
|
| 104 |
</span>
|
| 105 |
</div>
|
| 106 |
<!-- 第2行: 描述 -->
|
| 107 |
-
<div
|
| 108 |
<!-- 第3行: 互动数据 + 发布者信息 -->
|
| 109 |
-
<div
|
| 110 |
-
<span data-stat="likes">👍 ${itemData.likes || 0}</span> <span data-stat="favorites">🔖 ${itemData.favorites || 0}</span> <span class="card-comment-count">💬 ${initialCommentCount}</span>
|
| 111 |
-
<
|
| 112 |
-
<img class="card-author-avatar" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 40'%3E%3Crect fill='%23333' width='40' height='40' rx='20'/%3E%3Ctext x='20' y='25' text-anchor='middle' fill='%23666' font-size='16'%3E%3F%3C/text%3E%3C/svg%3E"
|
| 113 |
-
<span class="card-author-name
|
| 114 |
-
</
|
| 115 |
</div>
|
| 116 |
`;
|
| 117 |
|
|
@@ -125,7 +139,7 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
|
|
| 125 |
},
|
| 126 |
compact: true
|
| 127 |
});
|
| 128 |
-
ratingCompact.
|
| 129 |
const authorInfoInSummary = summaryView.querySelector(".card-author-info");
|
| 130 |
if (authorInfoInSummary && authorInfoInSummary.parentNode) {
|
| 131 |
authorInfoInSummary.parentNode.insertBefore(ratingCompact, authorInfoInSummary);
|
|
@@ -143,14 +157,6 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
|
|
| 143 |
openOtherUserProfileModal(itemData.author, currentUser);
|
| 144 |
};
|
| 145 |
|
| 146 |
-
// hover效果:发布者名称颜色变亮
|
| 147 |
-
authorInfoEl.onmouseenter = () => {
|
| 148 |
-
if (authorNameEl) authorNameEl.style.color = '#4CAF50';
|
| 149 |
-
};
|
| 150 |
-
authorInfoEl.onmouseleave = () => {
|
| 151 |
-
if (authorNameEl) authorNameEl.style.color = '#aaa';
|
| 152 |
-
};
|
| 153 |
-
|
| 154 |
// 使用统一辅助函数加载作者资料
|
| 155 |
_loadAuthorProfile(itemData.author, {
|
| 156 |
onName: (name) => { authorNameEl.textContent = name; },
|
|
@@ -160,15 +166,20 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
|
|
| 160 |
}
|
| 161 |
|
| 162 |
const detailView = document.createElement("div");
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
const authorInfo = document.createElement("div");
|
| 166 |
-
|
| 167 |
|
| 168 |
// 🔄 P7后悔模式:检查是否有待生效价格
|
| 169 |
const priceHtml = _generatePriceHTML(itemData, t);
|
| 170 |
|
| 171 |
-
authorInfo.innerHTML = `${t('market.author')}: <
|
| 172 |
detailView.appendChild(authorInfo);
|
| 173 |
|
| 174 |
const nameDOM = authorInfo.querySelector('.author-name-link');
|
|
@@ -183,12 +194,7 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
|
|
| 183 |
// 原创内容标识
|
| 184 |
if (itemData.is_original) {
|
| 185 |
const originalBadge = document.createElement("div");
|
| 186 |
-
|
| 187 |
-
display: "inline-flex", alignItems: "center", gap: "6px",
|
| 188 |
-
padding: "4px 12px", background: "linear-gradient(135deg, #FF6B35, #FF8F00)",
|
| 189 |
-
borderRadius: "12px", fontSize: "11px", color: "#fff", fontWeight: "500",
|
| 190 |
-
marginBottom: "10px"
|
| 191 |
-
});
|
| 192 |
originalBadge.textContent = "🎨 " + t('publish.mark_original');
|
| 193 |
detailView.appendChild(originalBadge);
|
| 194 |
}
|
|
@@ -196,38 +202,35 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
|
|
| 196 |
// 支持退款标识(仅工具、Skill和应用类型显示,且允许退款时)
|
| 197 |
if (itemData.allow_refund !== false && (itemData.type === 'tool' || itemData.type === 'skill' || itemData.type === 'app')) {
|
| 198 |
const refundBadge = document.createElement("div");
|
| 199 |
-
|
| 200 |
-
display: "inline-flex", alignItems: "center", gap: "6px",
|
| 201 |
-
padding: "4px 12px", background: "linear-gradient(135deg, #4CAF50, #2E7D32)",
|
| 202 |
-
borderRadius: "12px", fontSize: "11px", color: "#fff", fontWeight: "500",
|
| 203 |
-
marginBottom: "10px", marginLeft: "8px"
|
| 204 |
-
});
|
| 205 |
refundBadge.textContent = `🛡️ ${t('item.refundable')}`;
|
| 206 |
detailView.appendChild(refundBadge);
|
| 207 |
}
|
| 208 |
|
| 209 |
const actionArea = document.createElement("div");
|
| 210 |
-
|
| 211 |
const btnLike = document.createElement("button");
|
| 212 |
-
|
| 213 |
const btnFav = document.createElement("button");
|
| 214 |
-
|
| 215 |
|
| 216 |
// 🚀 根据安装状态显示不同的按钮样式(installStatus 已在上方声明)
|
| 217 |
const btnUse = document.createElement("button");
|
|
|
|
|
|
|
| 218 |
|
| 219 |
if (installStatus.hasUpdate) {
|
| 220 |
// 有更新可用
|
| 221 |
-
|
| 222 |
btnUse.innerHTML = `♻️ ${t('market.update_available')}`;
|
| 223 |
} else if (installStatus.installed) {
|
| 224 |
// 已安装
|
| 225 |
-
|
| 226 |
btnUse.innerHTML = `✅ ${t('market.installed')}`;
|
| 227 |
} else {
|
| 228 |
// 未安装
|
| 229 |
-
|
| 230 |
-
btnUse.
|
| 231 |
}
|
| 232 |
|
| 233 |
actionArea.appendChild(btnLike); actionArea.appendChild(btnFav); actionArea.appendChild(btnUse);
|
|
@@ -243,9 +246,13 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
|
|
| 243 |
},
|
| 244 |
currentUser: currentUser,
|
| 245 |
authorAccount: itemData.author,
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
| 247 |
const res = await api.rateItem(itemData.id, score);
|
| 248 |
-
if (
|
|
|
|
| 249 |
itemData.rating_avg = res.rating_avg;
|
| 250 |
itemData.rating_count = res.rating_count;
|
| 251 |
itemData.rating_dist = res.rating_dist;
|
|
@@ -264,7 +271,7 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
|
|
| 264 |
},
|
| 265 |
compact: true
|
| 266 |
});
|
| 267 |
-
newCompact.
|
| 268 |
oldCompact.parentNode.replaceChild(newCompact, oldCompact);
|
| 269 |
}
|
| 270 |
}
|
|
@@ -278,49 +285,67 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
|
|
| 278 |
const isFav = currentUser && Array.isArray(itemData.favorited_by) && itemData.favorited_by.includes(currentUser.account);
|
| 279 |
|
| 280 |
const handleInteraction = async (type, isActive) => {
|
| 281 |
-
if (!currentUser
|
| 282 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
};
|
| 284 |
|
| 285 |
setupToggleButton(btnLike, isLiked, itemData.likes || 0, `👍 ${t('market.liked')}`, `👍 ${t('market.like')}`, "#FF5722", (isActive) => handleInteraction("like", isActive));
|
| 286 |
setupToggleButton(btnFav, isFav, itemData.favorites || 0, `🔖 ${t('market.favorited')}`, `🔖 ${t('market.favorite')}`, "#FFC107", (isActive) => handleInteraction("favorite", isActive));
|
| 287 |
|
| 288 |
const inlineStatusBox = document.createElement("div");
|
| 289 |
-
|
|
|
|
| 290 |
detailView.appendChild(inlineStatusBox);
|
| 291 |
setupResourceInstall(btnUse, itemData, currentUser, inlineStatusBox);
|
| 292 |
|
| 293 |
const mediaArea = document.createElement("div");
|
| 294 |
const chartContainerId = `chart-${Math.random().toString(36).substr(2, 9)}`;
|
| 295 |
-
const chartHtml = `<div
|
| 296 |
|
| 297 |
mediaArea.innerHTML = `
|
| 298 |
${chartHtml}
|
| 299 |
${getCoverSandboxHTML(itemData.imageUrls?.length > 0 ? itemData.imageUrls : itemData.coverUrl)}
|
| 300 |
-
<div
|
| 301 |
-
<div
|
| 302 |
`;
|
| 303 |
detailView.appendChild(mediaArea);
|
| 304 |
|
| 305 |
// 🚀 新增:赞赏贡献榜单与打赏按钮(带等级显示)
|
| 306 |
const tipArea = document.createElement("div");
|
| 307 |
-
|
| 308 |
|
| 309 |
// 使用统一的打赏榜单渲染工具(带星星/月亮/太阳等级)
|
| 310 |
-
const boardData = itemData.tip_board
|
|
|
|
|
|
|
| 311 |
const boardHtml = renderTipBoardHTML(boardData, 5, t('market.no_tips_yet'), "small");
|
| 312 |
|
| 313 |
tipArea.innerHTML = `
|
| 314 |
-
<div
|
| 315 |
-
<div
|
| 316 |
-
<button
|
| 317 |
</div>
|
| 318 |
${boardHtml}
|
| 319 |
`;
|
| 320 |
detailView.appendChild(tipArea);
|
| 321 |
|
| 322 |
// 🚀 绑定打赏按钮事件
|
| 323 |
-
tipArea.querySelector(
|
| 324 |
e.stopPropagation();
|
| 325 |
if (!currentUser) return showToast(t('feedback.login_required'), "warning");
|
| 326 |
// 🐛 Bug修复:防御性校验,防止 itemId 为空
|
|
@@ -328,32 +353,31 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
|
|
| 328 |
console.warn('⚠️ 列表卡片: itemData.id 为空,已拦截无效打赏请求');
|
| 329 |
return showToast(t('item.invalid_id_tip'), "error");
|
| 330 |
}
|
| 331 |
-
openTipModal(currentUser, { account: itemData.author },
|
| 332 |
-
|
| 333 |
-
if (newBalance !== undefined && newBalance !== null) {
|
| 334 |
-
currentUser.balance = newBalance;
|
| 335 |
-
} else {
|
| 336 |
-
// 后备:如果回调未传余额,主动调用getWallet刷新
|
| 337 |
-
try {
|
| 338 |
-
const walletRes = await api.getWallet(currentUser.account, { noCache: true });
|
| 339 |
-
if (walletRes && walletRes.status === "success") {
|
| 340 |
-
currentUser.balance = walletRes.balance;
|
| 341 |
-
}
|
| 342 |
-
} catch (e) { /* 静默失败 */ }
|
| 343 |
-
}
|
| 344 |
-
}, itemData.id);
|
| 345 |
};
|
| 346 |
|
| 347 |
// 🔄 P7后悔模式:退款区域(仅在"我购买的"列表中显示)
|
| 348 |
const refundArea = document.createElement("div");
|
| 349 |
refundArea.id = `refund-area-${itemData.id}`;
|
| 350 |
-
|
|
|
|
| 351 |
detailView.appendChild(refundArea);
|
| 352 |
|
| 353 |
let isRendered = false;
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
detailView.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
|
| 358 |
if (isHidden && !isRendered) {
|
| 359 |
renderItemTrendChart(detailView.querySelector(`#${chartContainerId}`), itemData);
|
|
@@ -367,7 +391,7 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
|
|
| 367 |
recordView(api.recordItemView, itemData.id, 'item', (result) => {
|
| 368 |
if (result?.views !== undefined) {
|
| 369 |
const viewsEl = summaryView.querySelector('[data-stat="views"]');
|
| 370 |
-
if (viewsEl) viewsEl.
|
| 371 |
}
|
| 372 |
});
|
| 373 |
|
|
@@ -377,9 +401,16 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
|
|
| 377 |
}
|
| 378 |
}
|
| 379 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
|
| 381 |
const commentsContainer = document.createElement("div");
|
| 382 |
-
commentsContainer.
|
| 383 |
|
| 384 |
// 🚀 核心修改:传入一个回调函数,当组件内部发送/删除留言时,外部的数字能实时响应刷新!
|
| 385 |
// 🚀 P0安全修复:传递 itemData.author 作为 contentAuthor,使内容作者可以删除其内容下的评论
|
|
@@ -393,12 +424,12 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
|
|
| 393 |
|
| 394 |
if (currentUser && currentUser.account === itemData.author) {
|
| 395 |
const authorActionArea = document.createElement("div");
|
| 396 |
-
|
| 397 |
|
| 398 |
authorActionArea.innerHTML = `
|
| 399 |
-
<div
|
| 400 |
-
${IS_WEB_MODE ? '' : `<button id="btn-edit-item"
|
| 401 |
-
<button id="btn-del-item"
|
| 402 |
`;
|
| 403 |
|
| 404 |
// 📱 Web 模式不渲染编辑按钮,此处需空值保护
|
|
@@ -410,15 +441,8 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
|
|
| 410 |
|
| 411 |
authorActionArea.querySelector("#btn-del-item").onclick = async (e) => {
|
| 412 |
e.stopPropagation();
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
await api.deleteItem(itemData.id, currentUser.account);
|
| 416 |
-
showToast(t('feedback.deleted'), "success");
|
| 417 |
-
window.dispatchEvent(new CustomEvent("comfy-trigger-sidebar-reload"));
|
| 418 |
-
} catch (err) {
|
| 419 |
-
showToast(`${t('feedback.delete_failed')}: ${err.message}`, "error");
|
| 420 |
-
}
|
| 421 |
-
}
|
| 422 |
};
|
| 423 |
detailView.appendChild(authorActionArea);
|
| 424 |
}
|
|
@@ -440,12 +464,16 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
|
|
| 440 |
*/
|
| 441 |
async function loadRefundStatus(refundArea, itemData, currentUser, card) {
|
| 442 |
if (!refundArea || !currentUser) return;
|
|
|
|
|
|
|
| 443 |
|
| 444 |
refundArea.style.display = "block";
|
| 445 |
-
refundArea.innerHTML = `<div
|
| 446 |
|
| 447 |
try {
|
| 448 |
-
const statusRes = await api.getPurchaseStatus(currentUser.account, itemData.id);
|
|
|
|
|
|
|
| 449 |
|
| 450 |
if (!statusRes.owned) {
|
| 451 |
refundArea.innerHTML = '';
|
|
@@ -453,40 +481,42 @@ async function loadRefundStatus(refundArea, itemData, currentUser, card) {
|
|
| 453 |
return;
|
| 454 |
}
|
| 455 |
|
| 456 |
-
if (statusRes.can_refund) {
|
| 457 |
-
const hoursLeft = statusRes.refund_hours_left;
|
|
|
|
| 458 |
refundArea.innerHTML = `
|
| 459 |
-
<div
|
| 460 |
-
<div
|
| 461 |
-
<div
|
| 462 |
-
🔄
|
| 463 |
</div>
|
| 464 |
-
<button
|
| 465 |
-
💸
|
| 466 |
</button>
|
| 467 |
</div>
|
| 468 |
-
<div
|
| 469 |
-
⚠️
|
| 470 |
</div>
|
| 471 |
</div>
|
| 472 |
`;
|
| 473 |
|
| 474 |
-
const refundBtn = refundArea.querySelector(
|
| 475 |
if (refundBtn) {
|
| 476 |
refundBtn.onclick = (e) => {
|
| 477 |
e.stopPropagation();
|
|
|
|
| 478 |
showRefundConfirm(itemData, currentUser, statusRes.price_paid, card);
|
| 479 |
};
|
| 480 |
}
|
| 481 |
} else {
|
| 482 |
refundArea.innerHTML = `
|
| 483 |
-
<div
|
| 484 |
-
✅
|
| 485 |
</div>
|
| 486 |
`;
|
| 487 |
}
|
| 488 |
} catch (e) {
|
| 489 |
-
|
| 490 |
refundArea.innerHTML = '';
|
| 491 |
refundArea.style.display = "none";
|
| 492 |
}
|
|
@@ -496,93 +526,16 @@ async function loadRefundStatus(refundArea, itemData, currentUser, card) {
|
|
| 496 |
* 显示退款确认弹窗
|
| 497 |
*/
|
| 498 |
function showRefundConfirm(itemData, currentUser, pricePaid, card) {
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
<div style="font-size: 18px; font-weight: bold; margin-bottom: 20px; color: #FF5722; display: flex; align-items: center; gap: 10px;">
|
| 510 |
-
⚠️ 确认退款
|
| 511 |
-
</div>
|
| 512 |
-
|
| 513 |
-
<div style="background: #2a2d3e; border-radius: 8px; padding: 15px; margin-bottom: 15px;">
|
| 514 |
-
<div style="font-size: 14px; margin-bottom: 10px;">商品:<strong>${escapeHtml(itemData.title || '未命名资源')}</strong></div>
|
| 515 |
-
<div style="font-size: 14px; color: #4CAF50;">退款金额:<strong>${pricePaid}</strong> 积分</div>
|
| 516 |
-
</div>
|
| 517 |
-
|
| 518 |
-
<div style="background: rgba(255,87,34,0.1); border: 1px solid rgba(255,87,34,0.3); border-radius: 8px; padding: 15px; margin-bottom: 20px;">
|
| 519 |
-
<div style="font-size: 13px; color: #FF9800; line-height: 1.8;">
|
| 520 |
-
<div>🚨 <strong>退款后果:</strong></div>
|
| 521 |
-
<div style="margin-left: 20px; margin-top: 5px;">
|
| 522 |
-
• 您将失去此资源的访问权限<br>
|
| 523 |
-
• 已下载的文件将被自动删除<br>
|
| 524 |
-
• <strong style="color: #F44336;">30天内禁止再次购买此商品</strong>
|
| 525 |
-
</div>
|
| 526 |
-
</div>
|
| 527 |
-
</div>
|
| 528 |
-
|
| 529 |
-
<div style="display: flex; gap: 12px; justify-content: flex-end;">
|
| 530 |
-
<button id="btn-cancel-refund" style="background: #555; color: #fff; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-size: 14px; transition: 0.2s;" onmouseover="this.style.background='#666'" onmouseout="this.style.background='#555'">
|
| 531 |
-
取消
|
| 532 |
-
</button>
|
| 533 |
-
<button id="btn-confirm-refund" style="background: #FF5722; color: #fff; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: bold; transition: 0.2s;" onmouseover="this.style.background='#E64A19'" onmouseout="this.style.background='#FF5722'">
|
| 534 |
-
确认退款
|
| 535 |
-
</button>
|
| 536 |
-
</div>
|
| 537 |
-
</div>
|
| 538 |
-
`;
|
| 539 |
-
|
| 540 |
-
document.body.appendChild(overlay);
|
| 541 |
-
|
| 542 |
-
overlay.querySelector("#btn-cancel-refund").onclick = () => overlay.remove();
|
| 543 |
-
|
| 544 |
-
overlay.querySelector("#btn-confirm-refund").onclick = async () => {
|
| 545 |
-
const confirmBtn = overlay.querySelector("#btn-confirm-refund");
|
| 546 |
-
confirmBtn.disabled = true;
|
| 547 |
-
confirmBtn.innerText = "处理中...";
|
| 548 |
-
|
| 549 |
-
try {
|
| 550 |
-
const res = await api.requestRefund(currentUser.account, itemData.id);
|
| 551 |
-
if (res.status === "success") {
|
| 552 |
-
// 清除本地购买记录
|
| 553 |
-
const key = CACHE.LOCAL_KEYS.ACQUIRED_ITEMS;
|
| 554 |
-
const acquiredItems = JSON.parse(localStorage.getItem(key) || '[]');
|
| 555 |
-
const updated = acquiredItems.filter(i => i.id !== itemData.id);
|
| 556 |
-
localStorage.setItem(key, JSON.stringify(updated));
|
| 557 |
-
|
| 558 |
-
// 从DOM中移除卡片
|
| 559 |
-
if (card && card.parentNode) {
|
| 560 |
-
card.remove();
|
| 561 |
-
}
|
| 562 |
-
|
| 563 |
-
overlay.innerHTML = `
|
| 564 |
-
<div style="background: #1e2233; border-radius: 12px; padding: 25px; max-width: 380px; width: 90%; color: #fff; text-align: center;">
|
| 565 |
-
<div style="font-size: 48px; margin-bottom: 15px;">✅</div>
|
| 566 |
-
<div style="font-size: 18px; font-weight: bold; margin-bottom: 10px; color: #4CAF50;">退款成功</div>
|
| 567 |
-
<div style="font-size: 14px; color: #aaa; margin-bottom: 20px;">
|
| 568 |
-
${res.refund_amount} 积分已退还到您的账户<br>
|
| 569 |
-
<span style="color: #FF9800;">${res.ban_days}天内禁止再次购买此商品</span>
|
| 570 |
-
</div>
|
| 571 |
-
<button id="btn-close-refund" style="background: #4CAF50; color: #fff; border: none; padding: 10px 30px; border-radius: 6px; cursor: pointer; font-size: 14px;">
|
| 572 |
-
知道了
|
| 573 |
-
</button>
|
| 574 |
-
</div>
|
| 575 |
-
`;
|
| 576 |
-
overlay.querySelector("#btn-close-refund").onclick = () => overlay.remove();
|
| 577 |
-
|
| 578 |
-
showToast("退款成功!积分已退还", "success");
|
| 579 |
-
} else {
|
| 580 |
-
showToast("退款失败:" + (res.detail || "未知错误"), "error");
|
| 581 |
-
overlay.remove();
|
| 582 |
-
}
|
| 583 |
-
} catch (e) {
|
| 584 |
-
showToast("退款请求失败:" + (e.message || "网络错误"), "error");
|
| 585 |
-
overlay.remove();
|
| 586 |
}
|
| 587 |
-
};
|
| 588 |
-
}
|
|
|
|
| 1 |
// 前端页面/market/列表卡片组件.js
|
| 2 |
+
import { createCommentSection, setupToggleButton, createRatingStars, isValidRatingResponse } from "../social/评论与互动组件.js";
|
| 3 |
+
import { api, captureRequestSession } from "../core/网络请求API.js";
|
| 4 |
import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
|
| 5 |
import { recordView, escapeHtml } from "../components/互动工具函数.js"; // 🧹 P2归一:escapeHtml 局部副本已移除
|
| 6 |
import { renderItemTrendChart } from "../components/图表渲染组件.js";
|
| 7 |
import { getCoverSandboxHTML, setupImageSandboxEvents } from "../components/图片沙盒组件.js";
|
| 8 |
import { setupResourceInstall, checkItemStatus } from "./资源安装引擎.js";
|
| 9 |
+
import { showToast } from "../components/UI交互提示组件.js";
|
| 10 |
import { openTipModal } from "../profile/个人中心_赞赏组件.js";
|
| 11 |
import { renderTipBoardHTML, isMaxTipLevel } from "../components/打赏等级工具.js";
|
| 12 |
import { lazyLoadImages } from "../components/性能优化工具.js";
|
| 13 |
import { t } from "../components/用户体验增强.js";
|
| 14 |
+
import { getCachedProfile, getProfileWithSWR, IS_WEB_MODE } from "../core/全局配置.js";
|
| 15 |
+
import { getUser } from "../core/状态管理.js";
|
| 16 |
+
import { createPurchaseStatusScope, isValidPurchaseStatus } from "./购买状态生命周期.js";
|
| 17 |
+
import { showResourceDeleteDialog } from "./资源删除组件.js";
|
| 18 |
+
import { showResourceRefundDialog } from "./资源退款组件.js";
|
| 19 |
+
|
| 20 |
+
let nextCardId = 0;
|
| 21 |
|
| 22 |
/**
|
| 23 |
* 生成价格显示HTML(含待生效价格提示)
|
| 24 |
*/
|
| 25 |
function _generatePriceHTML(itemData, t) {
|
| 26 |
+
const formatPrice = (price) => price > 0 ? `${escapeHtml(price)} ${t('market.points')}` : t('market.free');
|
| 27 |
|
| 28 |
if (itemData.pending_price !== null && itemData.pending_price !== undefined && itemData.pending_price_effective_at) {
|
| 29 |
const effectiveTime = new Date(itemData.pending_price_effective_at);
|
|
|
|
| 31 |
if (effectiveTime > now) {
|
| 32 |
const hoursLeft = Math.ceil((effectiveTime - now) / (1000 * 60 * 60));
|
| 33 |
return `
|
| 34 |
+
<span class="resource-detail__price">
|
| 35 |
+
<span>💰 ${formatPrice(itemData.price)}</span>
|
| 36 |
+
<span class="resource-detail__pending-price" title="${hoursLeft}${t('time.hours_later')}">
|
| 37 |
+
→ ${escapeHtml(itemData.pending_price)}${t('market.points')}
|
| 38 |
</span>
|
| 39 |
</span>
|
| 40 |
`;
|
| 41 |
} else {
|
| 42 |
+
return `<span class="resource-detail__price">💰 ${formatPrice(itemData.pending_price)}</span>`;
|
| 43 |
}
|
| 44 |
} else {
|
| 45 |
+
return `<span class="resource-detail__price">💰 ${formatPrice(itemData.price)}</span>`;
|
| 46 |
}
|
| 47 |
}
|
| 48 |
|
|
|
|
| 52 |
* @param {Object} callbacks - 回调集合 { onName, onAvatar, fallbackName }
|
| 53 |
*/
|
| 54 |
function _loadAuthorProfile(account, { onName, onAvatar, fallbackName } = {}) {
|
| 55 |
+
if (!account) {
|
| 56 |
+
onName?.(t('common.unknown_user'));
|
| 57 |
+
return;
|
| 58 |
+
}
|
| 59 |
const cached = getCachedProfile(account);
|
| 60 |
if (cached) {
|
| 61 |
if (cached.name && onName) onName(cached.name);
|
|
|
|
| 65 |
}
|
| 66 |
|
| 67 |
getProfileWithSWR(account, api.getUserProfile, (profile) => {
|
| 68 |
+
if (!profile || typeof profile !== "object") return;
|
| 69 |
if (profile.name && onName) onName(profile.name);
|
| 70 |
if (profile.avatar && onAvatar) onAvatar(profile.avatar);
|
| 71 |
});
|
|
|
|
| 73 |
|
| 74 |
export function createItemCard(itemData, currentUser = null, contextType = null) {
|
| 75 |
const card = document.createElement("div");
|
| 76 |
+
const ratingMatches = captureRequestSession();
|
| 77 |
+
const ratingAccount = currentUser?.account || null;
|
| 78 |
+
const ratingCurrent = () => card.isConnected && ratingMatches() && (getUser()?.account || null) === ratingAccount;
|
| 79 |
+
const cardId = `ranking-resource-${++nextCardId}`;
|
| 80 |
+
card.className = "ranking-item-card";
|
| 81 |
card.setAttribute("data-item-id", itemData.id);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
// 🚀 核心新增:初始渲染时,动态计算真实的有效评论数(过滤软删除的废弃数据)
|
| 84 |
const calcActiveComments = (data) => {
|
| 85 |
if (!data || !Array.isArray(data)) return 0;
|
| 86 |
return data.reduce((acc, c) => {
|
| 87 |
+
if (!c || typeof c !== "object" || Array.isArray(c)) return acc;
|
| 88 |
const parentCnt = c.isDeleted ? 0 : 1;
|
| 89 |
+
const repliesCnt = Array.isArray(c.replies)
|
| 90 |
+
? c.replies.filter(r => r && typeof r === "object" && !Array.isArray(r) && !r.isDeleted).length
|
| 91 |
+
: 0;
|
| 92 |
return acc + parentCnt + repliesCnt;
|
| 93 |
}, 0);
|
| 94 |
};
|
|
|
|
| 99 |
// 避免标题容器的 overflow: hidden + ellipsis 在长标题时把徽章一并裁掉
|
| 100 |
const installStatus = checkItemStatus(itemData.id, itemData.latest_version);
|
| 101 |
const updateBadgeHtml = installStatus.hasUpdate
|
| 102 |
+
? `<span class="card-update-badge">🔄 ${t('market.update_available')}</span>`
|
| 103 |
: '';
|
| 104 |
|
| 105 |
const summaryView = document.createElement("div");
|
| 106 |
summaryView.className = "item-summary";
|
|
|
|
| 107 |
|
| 108 |
// 🚀 核心修改:为评论计数的 span 加上专属的 class,并填入过滤后的真实数量
|
| 109 |
// 🚀 布局重构:第1行标题+使用次数,第2行描述,第3行互动数据+发布者信息
|
| 110 |
summaryView.innerHTML = `
|
| 111 |
<!-- 第1行: 标题 + 更新徽章 + 使用次数 -->
|
| 112 |
+
<div class="card-summary__title-row">
|
| 113 |
+
<button type="button" id="${cardId}-toggle" class="card-summary__title card-summary__toggle" aria-expanded="false" aria-controls="${cardId}-details">${escapeHtml(itemData.title || t('item.untitled'))}</button>
|
| 114 |
${updateBadgeHtml}
|
| 115 |
+
<span class="card-summary__top-stats">
|
| 116 |
+
<span data-stat="uses" class="card-summary__stat">📥 ${escapeHtml(itemData.uses || 0)}</span>
|
| 117 |
+
<span data-stat="views" class="card-summary__stat">🔥 ${escapeHtml(itemData.views || 0)}</span>
|
| 118 |
</span>
|
| 119 |
</div>
|
| 120 |
<!-- 第2行: 描述 -->
|
| 121 |
+
<div class="card-summary__description">${escapeHtml(itemData.shortDesc)}</div>
|
| 122 |
<!-- 第3行: 互动数据 + 发布者信息 -->
|
| 123 |
+
<div class="card-summary__footer">
|
| 124 |
+
<span data-stat="likes">👍 ${escapeHtml(itemData.likes || 0)}</span> <span data-stat="favorites">🔖 ${escapeHtml(itemData.favorites || 0)}</span> <span class="card-comment-count">💬 ${escapeHtml(initialCommentCount)}</span>
|
| 125 |
+
<button type="button" class="card-author-info card-summary__author" ${itemData.author ? '' : 'disabled'}>
|
| 126 |
+
<img class="card-author-avatar card-summary__avatar" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 40'%3E%3Crect fill='%23333' width='40' height='40' rx='20'/%3E%3Ctext x='20' y='25' text-anchor='middle' fill='%23666' font-size='16'%3E%3F%3C/text%3E%3C/svg%3E">
|
| 127 |
+
<span class="card-author-name card-summary__author-name">${escapeHtml(itemData.author || t('common.unknown_user'))}</span>
|
| 128 |
+
</button>
|
| 129 |
</div>
|
| 130 |
`;
|
| 131 |
|
|
|
|
| 139 |
},
|
| 140 |
compact: true
|
| 141 |
});
|
| 142 |
+
ratingCompact.classList.add("card-rating-compact");
|
| 143 |
const authorInfoInSummary = summaryView.querySelector(".card-author-info");
|
| 144 |
if (authorInfoInSummary && authorInfoInSummary.parentNode) {
|
| 145 |
authorInfoInSummary.parentNode.insertBefore(ratingCompact, authorInfoInSummary);
|
|
|
|
| 157 |
openOtherUserProfileModal(itemData.author, currentUser);
|
| 158 |
};
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
// 使用统一辅助函数加载作者资料
|
| 161 |
_loadAuthorProfile(itemData.author, {
|
| 162 |
onName: (name) => { authorNameEl.textContent = name; },
|
|
|
|
| 166 |
}
|
| 167 |
|
| 168 |
const detailView = document.createElement("div");
|
| 169 |
+
detailView.id = `${cardId}-details`;
|
| 170 |
+
detailView.hidden = true;
|
| 171 |
+
detailView.setAttribute("role", "region");
|
| 172 |
+
detailView.setAttribute("aria-labelledby", `${cardId}-toggle`);
|
| 173 |
+
detailView.className = "resource-detail";
|
| 174 |
+
detailView.style.display = "none";
|
| 175 |
|
| 176 |
const authorInfo = document.createElement("div");
|
| 177 |
+
authorInfo.className = "resource-detail__author";
|
| 178 |
|
| 179 |
// 🔄 P7后悔模式:检查是否有待生效价格
|
| 180 |
const priceHtml = _generatePriceHTML(itemData, t);
|
| 181 |
|
| 182 |
+
authorInfo.innerHTML = `${t('market.author')}: <button type="button" class="author-name-link card-author-link" ${itemData.author ? '' : 'disabled'}>${t('common.loading')}...</button> ${priceHtml}`;
|
| 183 |
detailView.appendChild(authorInfo);
|
| 184 |
|
| 185 |
const nameDOM = authorInfo.querySelector('.author-name-link');
|
|
|
|
| 194 |
// 原创内容标识
|
| 195 |
if (itemData.is_original) {
|
| 196 |
const originalBadge = document.createElement("div");
|
| 197 |
+
originalBadge.className = "resource-detail__badge resource-detail__badge--original";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
originalBadge.textContent = "🎨 " + t('publish.mark_original');
|
| 199 |
detailView.appendChild(originalBadge);
|
| 200 |
}
|
|
|
|
| 202 |
// 支持退款标识(仅工具、Skill和应用类型显示,且允许退款时)
|
| 203 |
if (itemData.allow_refund !== false && (itemData.type === 'tool' || itemData.type === 'skill' || itemData.type === 'app')) {
|
| 204 |
const refundBadge = document.createElement("div");
|
| 205 |
+
refundBadge.className = "resource-detail__badge resource-detail__badge--refundable";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
refundBadge.textContent = `🛡️ ${t('item.refundable')}`;
|
| 207 |
detailView.appendChild(refundBadge);
|
| 208 |
}
|
| 209 |
|
| 210 |
const actionArea = document.createElement("div");
|
| 211 |
+
actionArea.className = "resource-detail__actions";
|
| 212 |
const btnLike = document.createElement("button");
|
| 213 |
+
btnLike.type = "button";
|
| 214 |
const btnFav = document.createElement("button");
|
| 215 |
+
btnFav.type = "button";
|
| 216 |
|
| 217 |
// 🚀 根据安装状态显示不同的按钮样式(installStatus 已在上方声明)
|
| 218 |
const btnUse = document.createElement("button");
|
| 219 |
+
btnUse.type = "button";
|
| 220 |
+
btnUse.className = "resource-detail__button resource-detail__use";
|
| 221 |
|
| 222 |
if (installStatus.hasUpdate) {
|
| 223 |
// 有更新可用
|
| 224 |
+
btnUse.classList.add("resource-detail__button--warning");
|
| 225 |
btnUse.innerHTML = `♻️ ${t('market.update_available')}`;
|
| 226 |
} else if (installStatus.installed) {
|
| 227 |
// 已安装
|
| 228 |
+
btnUse.classList.add("resource-detail__button--success");
|
| 229 |
btnUse.innerHTML = `✅ ${t('market.installed')}`;
|
| 230 |
} else {
|
| 231 |
// 未安装
|
| 232 |
+
btnUse.classList.add("resource-detail__button--primary");
|
| 233 |
+
btnUse.textContent = `🚀 ${t('market.get_now')} ${itemData.uses ? '(' + itemData.uses + ')' : ''}`;
|
| 234 |
}
|
| 235 |
|
| 236 |
actionArea.appendChild(btnLike); actionArea.appendChild(btnFav); actionArea.appendChild(btnUse);
|
|
|
|
| 246 |
},
|
| 247 |
currentUser: currentUser,
|
| 248 |
authorAccount: itemData.author,
|
| 249 |
+
isCurrent: ratingCurrent,
|
| 250 |
+
onRate: async (score, requestContext) => {
|
| 251 |
+
const current = () => ratingCurrent() && (!requestContext || requestContext.isCurrent());
|
| 252 |
+
if (!current()) return null;
|
| 253 |
const res = await api.rateItem(itemData.id, score);
|
| 254 |
+
if (!current()) return null;
|
| 255 |
+
if (isValidRatingResponse(res, score)) {
|
| 256 |
itemData.rating_avg = res.rating_avg;
|
| 257 |
itemData.rating_count = res.rating_count;
|
| 258 |
itemData.rating_dist = res.rating_dist;
|
|
|
|
| 271 |
},
|
| 272 |
compact: true
|
| 273 |
});
|
| 274 |
+
newCompact.classList.add("card-rating-compact");
|
| 275 |
oldCompact.parentNode.replaceChild(newCompact, oldCompact);
|
| 276 |
}
|
| 277 |
}
|
|
|
|
| 285 |
const isFav = currentUser && Array.isArray(itemData.favorited_by) && itemData.favorited_by.includes(currentUser.account);
|
| 286 |
|
| 287 |
const handleInteraction = async (type, isActive) => {
|
| 288 |
+
if (!currentUser?.account) {
|
| 289 |
+
showToast(t('feedback.login_required'), "warning");
|
| 290 |
+
return { status: "cancelled" };
|
| 291 |
+
}
|
| 292 |
+
const response = await api.toggleInteraction(itemData.id, currentUser.account, type, isActive);
|
| 293 |
+
if (response?.status === "success" && Number.isSafeInteger(response.new_count) && response.new_count >= 0) {
|
| 294 |
+
const membershipKey = type === "like" ? "liked_by" : "favorited_by";
|
| 295 |
+
const countKey = type === "like" ? "likes" : "favorites";
|
| 296 |
+
const previousMembers = Array.isArray(itemData[membershipKey]) ? itemData[membershipKey] : [];
|
| 297 |
+
const members = previousMembers.filter(account => account !== currentUser.account);
|
| 298 |
+
if (isActive) members.push(currentUser.account);
|
| 299 |
+
itemData[membershipKey] = members;
|
| 300 |
+
itemData[countKey] = response.new_count;
|
| 301 |
+
const summaryCount = summaryView.querySelector(type === "like" ? '[data-stat="likes"]' : '[data-stat="favorites"]');
|
| 302 |
+
if (summaryCount) summaryCount.textContent = `${type === "like" ? "👍" : "🔖"} ${response.new_count}`;
|
| 303 |
+
}
|
| 304 |
+
return response ?? { status: "error" };
|
| 305 |
};
|
| 306 |
|
| 307 |
setupToggleButton(btnLike, isLiked, itemData.likes || 0, `👍 ${t('market.liked')}`, `👍 ${t('market.like')}`, "#FF5722", (isActive) => handleInteraction("like", isActive));
|
| 308 |
setupToggleButton(btnFav, isFav, itemData.favorites || 0, `🔖 ${t('market.favorited')}`, `🔖 ${t('market.favorite')}`, "#FFC107", (isActive) => handleInteraction("favorite", isActive));
|
| 309 |
|
| 310 |
const inlineStatusBox = document.createElement("div");
|
| 311 |
+
inlineStatusBox.className = "resource-detail__status";
|
| 312 |
+
inlineStatusBox.style.display = "none";
|
| 313 |
detailView.appendChild(inlineStatusBox);
|
| 314 |
setupResourceInstall(btnUse, itemData, currentUser, inlineStatusBox);
|
| 315 |
|
| 316 |
const mediaArea = document.createElement("div");
|
| 317 |
const chartContainerId = `chart-${Math.random().toString(36).substr(2, 9)}`;
|
| 318 |
+
const chartHtml = `<div class="resource-detail__heading">📈 ${t('market.usage_trend')}</div><div id="${chartContainerId}" class="resource-detail__chart"></div>`;
|
| 319 |
|
| 320 |
mediaArea.innerHTML = `
|
| 321 |
${chartHtml}
|
| 322 |
${getCoverSandboxHTML(itemData.imageUrls?.length > 0 ? itemData.imageUrls : itemData.coverUrl)}
|
| 323 |
+
<div class="resource-detail__heading">📝 ${t('market.full_description')}</div>
|
| 324 |
+
<div class="resource-detail__description">${escapeHtml(itemData.fullDesc)}</div>
|
| 325 |
`;
|
| 326 |
detailView.appendChild(mediaArea);
|
| 327 |
|
| 328 |
// 🚀 新增:赞赏贡献榜单与打赏按钮(带等级显示)
|
| 329 |
const tipArea = document.createElement("div");
|
| 330 |
+
tipArea.className = "resource-detail__tips";
|
| 331 |
|
| 332 |
// 使用统一的打赏榜单渲染工具(带星星/月亮/太阳等级)
|
| 333 |
+
const boardData = Array.isArray(itemData.tip_board)
|
| 334 |
+
? itemData.tip_board.filter(entry => entry && typeof entry === "object" && !Array.isArray(entry))
|
| 335 |
+
: [];
|
| 336 |
const boardHtml = renderTipBoardHTML(boardData, 5, t('market.no_tips_yet'), "small");
|
| 337 |
|
| 338 |
tipArea.innerHTML = `
|
| 339 |
+
<div class="resource-detail__tip-header">
|
| 340 |
+
<div class="resource-detail__heading resource-detail__tip-heading">💖 ${t('market.tip_board')} (TOP 5)</div>
|
| 341 |
+
<button type="button" class="btn-tip-item resource-detail__button resource-detail__button--tip">💰 ${t('market.tip')}</button>
|
| 342 |
</div>
|
| 343 |
${boardHtml}
|
| 344 |
`;
|
| 345 |
detailView.appendChild(tipArea);
|
| 346 |
|
| 347 |
// 🚀 绑定打赏按钮事件
|
| 348 |
+
tipArea.querySelector('.btn-tip-item').onclick = (e) => {
|
| 349 |
e.stopPropagation();
|
| 350 |
if (!currentUser) return showToast(t('feedback.login_required'), "warning");
|
| 351 |
// 🐛 Bug修复:防御性校验,防止 itemId 为空
|
|
|
|
| 353 |
console.warn('⚠️ 列表卡片: itemData.id 为空,已拦截无效打赏请求');
|
| 354 |
return showToast(t('item.invalid_id_tip'), "error");
|
| 355 |
}
|
| 356 |
+
openTipModal(currentUser, { account: itemData.author }, undefined, itemData.id,
|
| 357 |
+
{ isCurrent: () => card.isConnected });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
};
|
| 359 |
|
| 360 |
// 🔄 P7后悔模式:退款区域(仅在"我购买的"列表中显示)
|
| 361 |
const refundArea = document.createElement("div");
|
| 362 |
refundArea.id = `refund-area-${itemData.id}`;
|
| 363 |
+
refundArea.className = "resource-detail__refund";
|
| 364 |
+
refundArea.style.display = "none";
|
| 365 |
detailView.appendChild(refundArea);
|
| 366 |
|
| 367 |
let isRendered = false;
|
| 368 |
+
const toggleButton = summaryView.querySelector('.card-summary__toggle');
|
| 369 |
+
const setExpanded = (expanded) => {
|
| 370 |
+
if (!expanded && detailView.contains(document.activeElement)) toggleButton.focus();
|
| 371 |
+
detailView.hidden = !expanded;
|
| 372 |
+
detailView.style.display = expanded ? "block" : "none";
|
| 373 |
+
toggleButton.setAttribute("aria-expanded", String(expanded));
|
| 374 |
+
};
|
| 375 |
+
summaryView.onclick = (event) => {
|
| 376 |
+
// 摘要中的作者、评分等独立控件不触发折叠;标题按钮使用浏览器原生键盘行为。
|
| 377 |
+
const control = event.target.closest('button, a, input, select, textarea, [role="button"]');
|
| 378 |
+
if (control && control !== toggleButton) return;
|
| 379 |
+
const isHidden = detailView.hidden;
|
| 380 |
+
setExpanded(isHidden);
|
| 381 |
|
| 382 |
if (isHidden && !isRendered) {
|
| 383 |
renderItemTrendChart(detailView.querySelector(`#${chartContainerId}`), itemData);
|
|
|
|
| 391 |
recordView(api.recordItemView, itemData.id, 'item', (result) => {
|
| 392 |
if (result?.views !== undefined) {
|
| 393 |
const viewsEl = summaryView.querySelector('[data-stat="views"]');
|
| 394 |
+
if (viewsEl) viewsEl.textContent = `🔥 ${result.views}`;
|
| 395 |
}
|
| 396 |
});
|
| 397 |
|
|
|
|
| 401 |
}
|
| 402 |
}
|
| 403 |
};
|
| 404 |
+
detailView.addEventListener("keydown", (event) => {
|
| 405 |
+
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
|
| 406 |
+
event.preventDefault();
|
| 407 |
+
event.stopPropagation();
|
| 408 |
+
setExpanded(false);
|
| 409 |
+
toggleButton.focus();
|
| 410 |
+
});
|
| 411 |
|
| 412 |
const commentsContainer = document.createElement("div");
|
| 413 |
+
commentsContainer.className = "resource-detail__comments";
|
| 414 |
|
| 415 |
// 🚀 核心修改:传入一个回调函数,当组件内部发送/删除留言时,外部的数字能实时响应刷新!
|
| 416 |
// 🚀 P0安全修复:传递 itemData.author 作为 contentAuthor,使内容作者可以删除其内容下的评论
|
|
|
|
| 424 |
|
| 425 |
if (currentUser && currentUser.account === itemData.author) {
|
| 426 |
const authorActionArea = document.createElement("div");
|
| 427 |
+
authorActionArea.className = "resource-detail__manage";
|
| 428 |
|
| 429 |
authorActionArea.innerHTML = `
|
| 430 |
+
<div class="resource-detail__manage-title">👑 ${t('market.creator_manage')}</div>
|
| 431 |
+
${IS_WEB_MODE ? '' : `<button type="button" id="btn-edit-item" class="resource-detail__button resource-detail__button--primary">✏️ ${t('market.edit_content')}</button>`}
|
| 432 |
+
<button type="button" id="btn-del-item" class="resource-detail__button resource-detail__button--danger">🗑️ ${t('market.delete_permanently')}</button>
|
| 433 |
`;
|
| 434 |
|
| 435 |
// 📱 Web 模式不渲染编辑按钮,此处需空值保护
|
|
|
|
| 441 |
|
| 442 |
authorActionArea.querySelector("#btn-del-item").onclick = async (e) => {
|
| 443 |
e.stopPropagation();
|
| 444 |
+
await showResourceDeleteDialog({ itemData, currentUser, isCurrent: () => card.isConnected,
|
| 445 |
+
onDeleted: () => window.dispatchEvent(new CustomEvent("comfy-trigger-sidebar-reload")) });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
};
|
| 447 |
detailView.appendChild(authorActionArea);
|
| 448 |
}
|
|
|
|
| 464 |
*/
|
| 465 |
async function loadRefundStatus(refundArea, itemData, currentUser, card) {
|
| 466 |
if (!refundArea || !currentUser) return;
|
| 467 |
+
const scope = createPurchaseStatusScope(refundArea, currentUser.account);
|
| 468 |
+
if (!scope.isCurrent()) return;
|
| 469 |
|
| 470 |
refundArea.style.display = "block";
|
| 471 |
+
refundArea.innerHTML = `<div class="resource-detail__refund-loading">⏳ ${t('item.purchase_status_loading')}</div>`;
|
| 472 |
|
| 473 |
try {
|
| 474 |
+
const statusRes = await api.getPurchaseStatus(currentUser.account, itemData.id, scope.readOptions);
|
| 475 |
+
if (!scope.isCurrent()) return;
|
| 476 |
+
if (!isValidPurchaseStatus(statusRes)) throw new Error();
|
| 477 |
|
| 478 |
if (!statusRes.owned) {
|
| 479 |
refundArea.innerHTML = '';
|
|
|
|
| 481 |
return;
|
| 482 |
}
|
| 483 |
|
| 484 |
+
if (statusRes.can_refund && statusRes.allow_refund) {
|
| 485 |
+
const hoursLeft = Number(statusRes.refund_hours_left);
|
| 486 |
+
if (!Number.isFinite(hoursLeft) || hoursLeft < 0) throw new Error("Invalid refund hours");
|
| 487 |
refundArea.innerHTML = `
|
| 488 |
+
<div class="resource-detail__refund-panel">
|
| 489 |
+
<div class="resource-detail__refund-header">
|
| 490 |
+
<div class="resource-detail__refund-copy">
|
| 491 |
+
🔄 ${t('item.regret_mode')}${t('item.hours_left', { hours: hoursLeft.toFixed(1) })}
|
| 492 |
</div>
|
| 493 |
+
<button type="button" class="btn-refund-item resource-detail__button resource-detail__button--warning">
|
| 494 |
+
💸 ${t('item.apply_refund')}
|
| 495 |
</button>
|
| 496 |
</div>
|
| 497 |
+
<div class="resource-detail__refund-warning">
|
| 498 |
+
⚠️ ${t('item.refund_warning')}
|
| 499 |
</div>
|
| 500 |
</div>
|
| 501 |
`;
|
| 502 |
|
| 503 |
+
const refundBtn = refundArea.querySelector('.btn-refund-item');
|
| 504 |
if (refundBtn) {
|
| 505 |
refundBtn.onclick = (e) => {
|
| 506 |
e.stopPropagation();
|
| 507 |
+
if (!scope.isCurrent()) return;
|
| 508 |
showRefundConfirm(itemData, currentUser, statusRes.price_paid, card);
|
| 509 |
};
|
| 510 |
}
|
| 511 |
} else {
|
| 512 |
refundArea.innerHTML = `
|
| 513 |
+
<div class="resource-detail__refund-expired">
|
| 514 |
+
✅ ${t('item.refund_expired')}
|
| 515 |
</div>
|
| 516 |
`;
|
| 517 |
}
|
| 518 |
} catch (e) {
|
| 519 |
+
if (!scope.isCurrent()) return;
|
| 520 |
refundArea.innerHTML = '';
|
| 521 |
refundArea.style.display = "none";
|
| 522 |
}
|
|
|
|
| 526 |
* 显示退款确认弹窗
|
| 527 |
*/
|
| 528 |
function showRefundConfirm(itemData, currentUser, pricePaid, card) {
|
| 529 |
+
let nextFocus;
|
| 530 |
+
return showResourceRefundDialog({
|
| 531 |
+
itemData, currentUser, pricePaid, isCurrent: () => !!card?.isConnected,
|
| 532 |
+
onRefunded: () => {
|
| 533 |
+
nextFocus = (card?.nextElementSibling || card?.previousElementSibling)?.querySelector('.card-summary__toggle');
|
| 534 |
+
card?.remove();
|
| 535 |
+
},
|
| 536 |
+
onClosed: () => {
|
| 537 |
+
const target = nextFocus?.isConnected ? nextFocus : document.querySelector('.comfy-ranking-root .card-summary__toggle');
|
| 538 |
+
target?.focus();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
}
|
| 540 |
+
});
|
| 541 |
+
}
|
前端页面/market/创作者卡片组件.js
CHANGED
|
@@ -48,7 +48,7 @@ let currentExpandedCard = null;
|
|
| 48 |
* @param {object} currentUser - 当前登录用户
|
| 49 |
* @param {Function} onTipSuccess - 打赏成功回调
|
| 50 |
*/
|
| 51 |
-
async function handleTipCreator(creatorData, currentUser, onTipSuccess) {
|
| 52 |
// 检查用户是否登录
|
| 53 |
if (!currentUser) {
|
| 54 |
showToast(t('creator.tip_login_required'), "warning");
|
|
@@ -66,15 +66,9 @@ async function handleTipCreator(creatorData, currentUser, onTipSuccess) {
|
|
| 66 |
account: creatorData.account,
|
| 67 |
name: creatorData.name
|
| 68 |
}, (newBalance) => {
|
| 69 |
-
// 🐛 Bug修复:打赏成功后同步余额并通知其他组件
|
| 70 |
-
if (newBalance !== undefined && newBalance !== null) {
|
| 71 |
-
currentUser.balance = newBalance;
|
| 72 |
-
}
|
| 73 |
-
// 派发全局余额更新事件
|
| 74 |
-
window.dispatchEvent(new CustomEvent('comfy-wallet-update', { detail: { balance: currentUser.balance } }));
|
| 75 |
// 执行原有成功回调
|
| 76 |
if (onTipSuccess) onTipSuccess();
|
| 77 |
-
});
|
| 78 |
}
|
| 79 |
|
| 80 |
function loadECharts() {
|
|
@@ -138,7 +132,7 @@ export function createTipBoardSection(tipBoard = [], title = "🎁 赞赏贡献
|
|
| 138 |
if (creatorData) {
|
| 139 |
const tipBtn = container.querySelector('#btn-tip-creator-empty');
|
| 140 |
if (tipBtn) {
|
| 141 |
-
tipBtn.onclick = async () => await handleTipCreator(creatorData, currentUser, onTipSuccess);
|
| 142 |
}
|
| 143 |
}
|
| 144 |
|
|
@@ -210,7 +204,7 @@ export function createTipBoardSection(tipBoard = [], title = "🎁 赞赏贡献
|
|
| 210 |
if (creatorData) {
|
| 211 |
const tipBtn = container.querySelector('#btn-tip-creator');
|
| 212 |
if (tipBtn) {
|
| 213 |
-
tipBtn.onclick = async () => await handleTipCreator(creatorData, currentUser, onTipSuccess);
|
| 214 |
}
|
| 215 |
}
|
| 216 |
|
|
|
|
| 48 |
* @param {object} currentUser - 当前登录用户
|
| 49 |
* @param {Function} onTipSuccess - 打赏成功回调
|
| 50 |
*/
|
| 51 |
+
async function handleTipCreator(creatorData, currentUser, onTipSuccess, parentCurrent) {
|
| 52 |
// 检查用户是否登录
|
| 53 |
if (!currentUser) {
|
| 54 |
showToast(t('creator.tip_login_required'), "warning");
|
|
|
|
| 66 |
account: creatorData.account,
|
| 67 |
name: creatorData.name
|
| 68 |
}, (newBalance) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
// 执行原有成功回调
|
| 70 |
if (onTipSuccess) onTipSuccess();
|
| 71 |
+
}, null, { isCurrent: parentCurrent });
|
| 72 |
}
|
| 73 |
|
| 74 |
function loadECharts() {
|
|
|
|
| 132 |
if (creatorData) {
|
| 133 |
const tipBtn = container.querySelector('#btn-tip-creator-empty');
|
| 134 |
if (tipBtn) {
|
| 135 |
+
tipBtn.onclick = async () => await handleTipCreator(creatorData, currentUser, onTipSuccess, () => container.isConnected);
|
| 136 |
}
|
| 137 |
}
|
| 138 |
|
|
|
|
| 204 |
if (creatorData) {
|
| 205 |
const tipBtn = container.querySelector('#btn-tip-creator');
|
| 206 |
if (tipBtn) {
|
| 207 |
+
tipBtn.onclick = async () => await handleTipCreator(creatorData, currentUser, onTipSuccess, () => container.isConnected);
|
| 208 |
}
|
| 209 |
}
|
| 210 |
|
前端页面/market/发布内容_UI模板.js
CHANGED
|
@@ -3,141 +3,127 @@ import { escapeHtml } from "../core/全局配置.js";
|
|
| 3 |
|
| 4 |
export function generatePublishHTML(isEditMode, viewTitle, submitBtnText, hasExistingToken, editItemData, t) {
|
| 5 |
const safeData = editItemData || {};
|
| 6 |
-
|
| 7 |
return `
|
| 8 |
-
<
|
| 9 |
-
<
|
| 10 |
-
<
|
| 11 |
-
<
|
| 12 |
-
|
| 13 |
-
</button>
|
| 14 |
-
<span style="font-size: 16px; font-weight: bold; color: #fff;">${escapeHtml(viewTitle)}</span>
|
| 15 |
-
</div>
|
| 16 |
|
| 17 |
-
<div
|
| 18 |
-
<div
|
| 19 |
-
<label
|
| 20 |
-
<select id="pub-type" class="form-input">
|
| 21 |
-
<option value="tool">🧰 ${t('publish.type_tool')}</option>
|
| 22 |
-
<option value="skill">⚡ ${t('publish.type_skill')}</option>
|
| 23 |
-
<option value="app">📦 ${t('publish.type_app')}</option>
|
| 24 |
-
<option value="recommend">🌟 ${t('publish.type_recommend')}</option>
|
| 25 |
</select>
|
| 26 |
</div>
|
| 27 |
-
<div id="box-recommend-type" style="display: none;
|
| 28 |
-
<label
|
| 29 |
-
<select id="pub-recommend-type" class="form-input">
|
| 30 |
-
<option value="recommend_tool">🧰 ${t('publish.recommend_as_tool')}</option>
|
| 31 |
-
<option value="recommend_skill">⚡ ${t('publish.recommend_as_skill')}</option>
|
| 32 |
-
<option value="recommend_app">📦 ${t('publish.recommend_as_app')}</option>
|
| 33 |
-
<option value="recommend_link">🔗 ${t('publish.recommend_as_link')}</option>
|
| 34 |
</select>
|
| 35 |
</div>
|
| 36 |
-
<div
|
| 37 |
-
<label
|
| 38 |
-
<input type="text" id="pub-title" placeholder="${t('publish.name_placeholder')}" required class="form-input">
|
| 39 |
</div>
|
| 40 |
</div>
|
| 41 |
|
| 42 |
-
<div
|
| 43 |
-
<label
|
| 44 |
-
<input type="text" id="pub-short" placeholder="${t('publish.short_desc_placeholder')}" maxlength="50" required class="form-input">
|
| 45 |
</div>
|
| 46 |
|
| 47 |
-
<div
|
| 48 |
-
<div
|
| 49 |
-
<label
|
| 50 |
-
<select id="pub-resource-type" class="form-input">
|
| 51 |
-
<option value="link">🔗 ${t('publish.resource_link')}</option>
|
| 52 |
-
<option value="json">📄 ${t('publish.resource_json')}</option>
|
| 53 |
-
<option value="netdisk">☁️ ${t('publish.resource_netdisk')}</option>
|
| 54 |
</select>
|
| 55 |
</div>
|
| 56 |
-
<div
|
| 57 |
-
<label
|
| 58 |
-
<input type="text" id="pub-link" placeholder="${t('publish.source_url_placeholder')}" class="form-input">
|
| 59 |
</div>
|
| 60 |
-
<div
|
| 61 |
-
<label
|
| 62 |
-
<input type="file" id="pub-json" accept=".json"
|
| 63 |
</div>
|
| 64 |
-
<div
|
| 65 |
-
<label
|
| 66 |
-
<input type="text" id="pub-netdisk-link" placeholder="${t('publish.netdisk_placeholder')}" class="form-input">
|
| 67 |
</div>
|
| 68 |
</div>
|
| 69 |
|
| 70 |
-
<div id="netdisk-password-settings"
|
| 71 |
-
<div
|
| 72 |
-
<div
|
| 73 |
-
<label
|
| 74 |
-
<input type="text" id="pub-netdisk-password" placeholder="${t('publish.netdisk_password_placeholder')}" maxlength="20" class="form-input"
|
| 75 |
-
</div>
|
| 76 |
-
<div style="flex: 2; padding-top: 28px;">
|
| 77 |
-
<div style="font-size: 11px; color: #888; line-height: 1.4;">🔒 <span style="color: #4CAF50; font-weight: bold;">${t('publish.security_guarantee')}</span>:${t('publish.password_encrypted_hint')}</div>
|
| 78 |
</div>
|
|
|
|
| 79 |
</div>
|
| 80 |
</div>
|
| 81 |
|
| 82 |
-
<div id="private-repo-settings"
|
| 83 |
-
<label
|
| 84 |
-
<input type="checkbox" id="pub-is-private" ${hasExistingToken ? 'checked' : ''}
|
| 85 |
-
${t('publish.private_repo_hint')}
|
| 86 |
</label>
|
| 87 |
-
<div id="github-token-container" style="display: ${hasExistingToken ? 'block' : 'none'};
|
| 88 |
-
<label
|
| 89 |
-
<div
|
| 90 |
-
<input type="password" id="pub-github-token" value="${isEditMode ? (safeData.github_token || '') : ''}" placeholder="${hasExistingToken && !safeData.github_token ? t('publish.token_keep_hint') : t('publish.token_placeholder')}" class="form-input"
|
| 91 |
</div>
|
| 92 |
</div>
|
| 93 |
|
| 94 |
-
<div id="box-cover"
|
| 95 |
-
<label
|
| 96 |
-
<input type="file" id="pub-images" accept="image/*" multiple
|
| 97 |
-
<div
|
| 98 |
-
<div id="pub-images-preview"
|
| 99 |
</div>
|
| 100 |
|
| 101 |
-
<div id="box-price"
|
| 102 |
-
<div
|
| 103 |
-
<label
|
| 104 |
-
<input type="number" id="pub-price" value="0" min="0" class="form-input"
|
| 105 |
-
<
|
| 106 |
-
<div id="price-pending-hint" style="display: none; margin-top: 6px; padding: 6px 8px; background: rgba(255,152,0,0.15); border: 1px dashed #FF9800; border-radius: 4px; font-size: 11px; color: #FF9800;">
|
| 107 |
-
</div>
|
| 108 |
-
</div>
|
| 109 |
-
<div style="flex: 2;">
|
| 110 |
-
<div style="font-size: 11px; color: #888; margin-top: 25px; line-height: 1.4;">${t('publish.price_hint')}<br>⚠️ <strong>${t('publish.price_delay_hint')}</strong></div>
|
| 111 |
</div>
|
|
|
|
| 112 |
</div>
|
| 113 |
|
| 114 |
-
<div
|
| 115 |
-
<label
|
| 116 |
-
<textarea id="pub-full" rows="36" placeholder="${t('publish.full_desc_placeholder')}" class="form-input
|
| 117 |
</div>
|
| 118 |
|
| 119 |
-
<
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
<
|
| 123 |
-
<span style="font-size: 13px; color: #ccc;">🎨 ${t('publish.mark_as_original')}</span>
|
| 124 |
</label>
|
| 125 |
-
<div id="original-hint-text"
|
| 126 |
-
${t('publish.original_default_hint')}
|
| 127 |
-
</div>
|
| 128 |
</div>
|
| 129 |
|
| 130 |
-
<
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
<
|
| 134 |
-
<span style="font-size: 13px; color: #ccc;">💸 ${t('publish.allow_refund')}</span>
|
| 135 |
</label>
|
| 136 |
-
<div
|
| 137 |
-
${t('publish.refund_hint')}
|
| 138 |
-
</div>
|
| 139 |
</div>
|
| 140 |
|
| 141 |
-
<button id="btn-submit-publish"
|
|
|
|
| 142 |
`;
|
| 143 |
-
}
|
|
|
|
| 3 |
|
| 4 |
export function generatePublishHTML(isEditMode, viewTitle, submitBtnText, hasExistingToken, editItemData, t) {
|
| 5 |
const safeData = editItemData || {};
|
| 6 |
+
|
| 7 |
return `
|
| 8 |
+
<section class="ranking-publish" aria-labelledby="publish-view-title">
|
| 9 |
+
<header class="ranking-publish__header">
|
| 10 |
+
<button type="button" id="btn-back" class="ranking-publish__back">⬅ ${escapeHtml(t('common.back'))}</button>
|
| 11 |
+
<h2 id="publish-view-title" class="ranking-publish__title">${escapeHtml(viewTitle)}</h2>
|
| 12 |
+
</header>
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
<div class="ranking-publish__row">
|
| 15 |
+
<div class="ranking-publish__field">
|
| 16 |
+
<label for="pub-type">${escapeHtml(t('publish.main_category'))} <span class="ranking-publish__required" aria-hidden="true">*</span></label>
|
| 17 |
+
<select id="pub-type" class="form-input" aria-required="true">
|
| 18 |
+
<option value="tool">🧰 ${escapeHtml(t('publish.type_tool'))}</option>
|
| 19 |
+
<option value="skill">⚡ ${escapeHtml(t('publish.type_skill'))}</option>
|
| 20 |
+
<option value="app">📦 ${escapeHtml(t('publish.type_app'))}</option>
|
| 21 |
+
<option value="recommend">🌟 ${escapeHtml(t('publish.type_recommend'))}</option>
|
| 22 |
</select>
|
| 23 |
</div>
|
| 24 |
+
<div id="box-recommend-type" class="ranking-publish__field" style="display: none;">
|
| 25 |
+
<label for="pub-recommend-type">${escapeHtml(t('publish.recommend_form'))} <span class="ranking-publish__required" aria-hidden="true">*</span></label>
|
| 26 |
+
<select id="pub-recommend-type" class="form-input" aria-required="true">
|
| 27 |
+
<option value="recommend_tool">🧰 ${escapeHtml(t('publish.recommend_as_tool'))}</option>
|
| 28 |
+
<option value="recommend_skill">⚡ ${escapeHtml(t('publish.recommend_as_skill'))}</option>
|
| 29 |
+
<option value="recommend_app">📦 ${escapeHtml(t('publish.recommend_as_app'))}</option>
|
| 30 |
+
<option value="recommend_link">🔗 ${escapeHtml(t('publish.recommend_as_link'))}</option>
|
| 31 |
</select>
|
| 32 |
</div>
|
| 33 |
+
<div class="ranking-publish__field ranking-publish__field--wide">
|
| 34 |
+
<label for="pub-title">${escapeHtml(t('publish.name'))} <span class="ranking-publish__required" aria-hidden="true">*</span></label>
|
| 35 |
+
<input type="text" id="pub-title" placeholder="${escapeHtml(t('publish.name_placeholder'))}" required class="form-input">
|
| 36 |
</div>
|
| 37 |
</div>
|
| 38 |
|
| 39 |
+
<div class="ranking-publish__field">
|
| 40 |
+
<label for="pub-short">${escapeHtml(t('publish.short_desc'))} <span class="ranking-publish__required" aria-hidden="true">*</span></label>
|
| 41 |
+
<input type="text" id="pub-short" placeholder="${escapeHtml(t('publish.short_desc_placeholder'))}" maxlength="50" required class="form-input">
|
| 42 |
</div>
|
| 43 |
|
| 44 |
+
<div class="ranking-publish__row ranking-publish__panel">
|
| 45 |
+
<div class="ranking-publish__field" id="box-resource-select">
|
| 46 |
+
<label for="pub-resource-type">${escapeHtml(t('publish.resource_type'))} <span class="ranking-publish__required" aria-hidden="true">*</span></label>
|
| 47 |
+
<select id="pub-resource-type" class="form-input" aria-required="true">
|
| 48 |
+
<option value="link">🔗 ${escapeHtml(t('publish.resource_link'))}</option>
|
| 49 |
+
<option value="json">📄 ${escapeHtml(t('publish.resource_json'))}</option>
|
| 50 |
+
<option value="netdisk">☁️ ${escapeHtml(t('publish.resource_netdisk'))}</option>
|
| 51 |
</select>
|
| 52 |
</div>
|
| 53 |
+
<div class="ranking-publish__field ranking-publish__field--wide" id="box-link">
|
| 54 |
+
<label for="pub-link">${escapeHtml(t('publish.source_url'))} <span class="ranking-publish__required" aria-hidden="true">*</span></label>
|
| 55 |
+
<input type="text" id="pub-link" placeholder="${escapeHtml(t('publish.source_url_placeholder'))}" class="form-input" aria-required="true">
|
| 56 |
</div>
|
| 57 |
+
<div class="ranking-publish__field ranking-publish__field--wide" id="box-json" style="display: none;">
|
| 58 |
+
<label for="pub-json">${escapeHtml(t('publish.select_json'))} <span class="ranking-publish__required" aria-hidden="true">*</span> <span id="json-file-hint" class="ranking-publish__file-hint" style="display: none;">(${escapeHtml(t('publish.has_cloud_file'))})</span></label>
|
| 59 |
+
<input type="file" id="pub-json" accept=".json" aria-describedby="json-file-hint" class="ranking-publish__file">
|
| 60 |
</div>
|
| 61 |
+
<div class="ranking-publish__field ranking-publish__field--wide" id="box-netdisk" style="display: none;">
|
| 62 |
+
<label for="pub-netdisk-link">${escapeHtml(t('publish.netdisk_link'))} <span class="ranking-publish__required" aria-hidden="true">*</span></label>
|
| 63 |
+
<input type="text" id="pub-netdisk-link" placeholder="${escapeHtml(t('publish.netdisk_placeholder'))}" class="form-input" aria-required="true">
|
| 64 |
</div>
|
| 65 |
</div>
|
| 66 |
|
| 67 |
+
<div id="netdisk-password-settings" class="ranking-publish__panel" style="display: none;">
|
| 68 |
+
<div class="ranking-publish__row">
|
| 69 |
+
<div class="ranking-publish__field">
|
| 70 |
+
<label for="pub-netdisk-password" class="ranking-publish__security-label">🔐 ${escapeHtml(t('publish.netdisk_password'))}</label>
|
| 71 |
+
<input type="text" id="pub-netdisk-password" placeholder="${escapeHtml(t('publish.netdisk_password_placeholder'))}" maxlength="20" class="form-input" aria-describedby="publish-password-hint">
|
|
|
|
|
|
|
|
|
|
| 72 |
</div>
|
| 73 |
+
<div id="publish-password-hint" class="ranking-publish__hint ranking-publish__field--wide ranking-publish__aligned-hint">🔒 <strong>${escapeHtml(t('publish.security_guarantee'))}</strong>:${escapeHtml(t('publish.password_encrypted_hint'))}</div>
|
| 74 |
</div>
|
| 75 |
</div>
|
| 76 |
|
| 77 |
+
<div id="private-repo-settings" class="ranking-publish__panel" style="display: none;">
|
| 78 |
+
<label class="ranking-publish__checkbox">
|
| 79 |
+
<input type="checkbox" id="pub-is-private" ${hasExistingToken ? 'checked' : ''}>
|
| 80 |
+
<span>${escapeHtml(t('publish.private_repo_hint'))}</span>
|
| 81 |
</label>
|
| 82 |
+
<div id="github-token-container" class="ranking-publish__token" style="display: ${hasExistingToken ? 'block' : 'none'};">
|
| 83 |
+
<label for="pub-github-token" class="ranking-publish__security-label">🔒 ${escapeHtml(t('publish.pat_label'))} <span class="ranking-publish__required" aria-hidden="true">*</span></label>
|
| 84 |
+
<div id="publish-pat-hint" class="ranking-publish__hint">${escapeHtml(t('publish.pat_hint'))}</div>
|
| 85 |
+
<input type="password" id="pub-github-token" value="${escapeHtml(isEditMode ? (safeData.github_token || '') : '')}" placeholder="${hasExistingToken && !safeData.github_token ? escapeHtml(t('publish.token_keep_hint')) : escapeHtml(t('publish.token_placeholder'))}" class="form-input" aria-describedby="publish-pat-hint">
|
| 86 |
</div>
|
| 87 |
</div>
|
| 88 |
|
| 89 |
+
<div id="box-cover" class="ranking-publish__panel ranking-publish__field">
|
| 90 |
+
<label for="pub-images">🖼️ ${escapeHtml(t('publish.effect_images'))} <span id="cover-file-hint" class="ranking-publish__file-hint" style="display: none;">(${escapeHtml(t('publish.reselect_override'))})</span></label>
|
| 91 |
+
<input type="file" id="pub-images" accept="image/*" multiple class="ranking-publish__file" aria-describedby="publish-images-hint cover-file-hint">
|
| 92 |
+
<div id="publish-images-hint" class="ranking-publish__hint">${escapeHtml(t('publish.drag_select_hint'))}</div>
|
| 93 |
+
<div id="pub-images-preview" class="ranking-publish__preview"></div>
|
| 94 |
</div>
|
| 95 |
|
| 96 |
+
<div id="box-price" class="ranking-publish__row">
|
| 97 |
+
<div class="ranking-publish__field">
|
| 98 |
+
<label for="pub-price">${escapeHtml(t('publish.price'))} <span class="ranking-publish__required" aria-hidden="true">*</span></label>
|
| 99 |
+
<input type="number" id="pub-price" value="0" min="0" class="form-input ranking-publish__price" aria-required="true" aria-describedby="publish-price-hint price-pending-hint">
|
| 100 |
+
<div id="price-pending-hint" class="ranking-publish__pending" role="status" style="display: none;"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
</div>
|
| 102 |
+
<div id="publish-price-hint" class="ranking-publish__hint ranking-publish__field--wide ranking-publish__aligned-hint">${escapeHtml(t('publish.price_hint'))}<br>⚠️ <strong>${escapeHtml(t('publish.price_delay_hint'))}</strong></div>
|
| 103 |
</div>
|
| 104 |
|
| 105 |
+
<div class="ranking-publish__field">
|
| 106 |
+
<label for="pub-full">${escapeHtml(t('publish.full_desc'))}</label>
|
| 107 |
+
<textarea id="pub-full" rows="36" placeholder="${escapeHtml(t('publish.full_desc_placeholder'))}" class="form-input ranking-publish__description"></textarea>
|
| 108 |
</div>
|
| 109 |
|
| 110 |
+
<div class="ranking-publish__panel">
|
| 111 |
+
<label class="ranking-publish__checkbox">
|
| 112 |
+
<input type="checkbox" id="is-original-checkbox" ${safeData.is_original ? 'checked' : ''} aria-describedby="original-hint-text" />
|
| 113 |
+
<span>🎨 ${escapeHtml(t('publish.mark_as_original'))}</span>
|
|
|
|
| 114 |
</label>
|
| 115 |
+
<div id="original-hint-text" class="ranking-publish__hint ranking-publish__checkbox-hint">${escapeHtml(t('publish.original_default_hint'))}</div>
|
|
|
|
|
|
|
| 116 |
</div>
|
| 117 |
|
| 118 |
+
<div id="box-allow-refund" class="ranking-publish__panel">
|
| 119 |
+
<label class="ranking-publish__checkbox">
|
| 120 |
+
<input type="checkbox" id="allow-refund-checkbox" ${safeData.allow_refund !== false ? 'checked' : ''} aria-describedby="publish-refund-hint" />
|
| 121 |
+
<span>💸 ${escapeHtml(t('publish.allow_refund'))}</span>
|
|
|
|
| 122 |
</label>
|
| 123 |
+
<div id="publish-refund-hint" class="ranking-publish__hint ranking-publish__checkbox-hint">${escapeHtml(t('publish.refund_hint'))}</div>
|
|
|
|
|
|
|
| 124 |
</div>
|
| 125 |
|
| 126 |
+
<button type="button" id="btn-submit-publish" class="ranking-publish__submit">${escapeHtml(submitBtnText)}</button>
|
| 127 |
+
</section>
|
| 128 |
`;
|
| 129 |
+
}
|
前端页面/market/发布内容_提交引擎.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
// 前端页面/market/发布内容_提交引擎.js
|
|
|
|
| 2 |
import { api } from "../core/网络请求API.js";
|
| 3 |
import { showToast } from "../components/UI交互提示组件.js";
|
| 4 |
import { t } from "../components/用户体验增强.js";
|
|
@@ -46,112 +50,6 @@ function clearItemCacheByType(type) {
|
|
| 46 |
console.log(`🗑️ 已清除 [${tab}] 类型的列表缓存`);
|
| 47 |
}
|
| 48 |
|
| 49 |
-
/**
|
| 50 |
-
* 通用图片处理核心函数:加载图片 → Canvas绘制 → 转换为JPG文件
|
| 51 |
-
* @param {File} file - 原始图片文件
|
| 52 |
-
* @param {Object} options - 配置选项
|
| 53 |
-
* @param {boolean} options.cropToSquare - 是否中心裁剪为正方形
|
| 54 |
-
* @param {number|null} options.targetSize - 目标尺寸(正方形边长),null则保持原始尺寸
|
| 55 |
-
* @param {number} options.quality - 初始JPG质量 (0-1)
|
| 56 |
-
* @param {string|null} options.fillBackground - 背景填充颜色,null则不填充
|
| 57 |
-
* @param {number|null} options.maxFileSize - 最大文件大小(字节),超限则迭代降低质量
|
| 58 |
-
* @param {boolean} options.skipIfSmallJpeg - 是否跳过已是小体积JPEG的文件
|
| 59 |
-
* @param {string} options.suffix - 输出文件名后缀
|
| 60 |
-
* @returns {Promise<File>} 处理后的文件
|
| 61 |
-
*/
|
| 62 |
-
function _processImageCore(file, options = {}) {
|
| 63 |
-
const {
|
| 64 |
-
cropToSquare = false,
|
| 65 |
-
targetSize = null,
|
| 66 |
-
quality = 0.85,
|
| 67 |
-
fillBackground = null,
|
| 68 |
-
maxFileSize = null,
|
| 69 |
-
skipIfSmallJpeg = false,
|
| 70 |
-
suffix = '.jpg'
|
| 71 |
-
} = options;
|
| 72 |
-
|
| 73 |
-
return new Promise((resolve, reject) => {
|
| 74 |
-
const img = new Image();
|
| 75 |
-
const url = URL.createObjectURL(file);
|
| 76 |
-
|
| 77 |
-
img.onload = () => {
|
| 78 |
-
URL.revokeObjectURL(url);
|
| 79 |
-
|
| 80 |
-
const canvas = document.createElement('canvas');
|
| 81 |
-
const ctx = canvas.getContext('2d');
|
| 82 |
-
|
| 83 |
-
let sourceX = 0, sourceY = 0;
|
| 84 |
-
let sourceW = img.naturalWidth, sourceH = img.naturalHeight;
|
| 85 |
-
let destW = img.naturalWidth, destH = img.naturalHeight;
|
| 86 |
-
|
| 87 |
-
// 中心裁剪为正方形
|
| 88 |
-
if (cropToSquare) {
|
| 89 |
-
const sourceMin = Math.min(sourceW, sourceH);
|
| 90 |
-
sourceX = (sourceW - sourceMin) / 2;
|
| 91 |
-
sourceY = (sourceH - sourceMin) / 2;
|
| 92 |
-
sourceW = sourceMin;
|
| 93 |
-
sourceH = sourceMin;
|
| 94 |
-
destW = sourceMin;
|
| 95 |
-
destH = sourceMin;
|
| 96 |
-
}
|
| 97 |
-
|
| 98 |
-
// 指定目标尺寸时缩放
|
| 99 |
-
if (targetSize) {
|
| 100 |
-
destW = targetSize;
|
| 101 |
-
destH = targetSize;
|
| 102 |
-
}
|
| 103 |
-
|
| 104 |
-
canvas.width = destW;
|
| 105 |
-
canvas.height = destH;
|
| 106 |
-
|
| 107 |
-
// 填充背景色
|
| 108 |
-
if (fillBackground) {
|
| 109 |
-
ctx.fillStyle = fillBackground;
|
| 110 |
-
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
| 111 |
-
}
|
| 112 |
-
|
| 113 |
-
ctx.drawImage(img, sourceX, sourceY, sourceW, sourceH, 0, 0, destW, destH);
|
| 114 |
-
|
| 115 |
-
// 跳过已是小体积JPEG的文件
|
| 116 |
-
if (skipIfSmallJpeg && maxFileSize &&
|
| 117 |
-
(file.type === 'image/jpeg' || file.type === 'image/jpg') &&
|
| 118 |
-
file.size <= maxFileSize) {
|
| 119 |
-
resolve(file);
|
| 120 |
-
return;
|
| 121 |
-
}
|
| 122 |
-
|
| 123 |
-
// 转换为JPG Blob,超限时迭代降低质量
|
| 124 |
-
let currentQuality = quality;
|
| 125 |
-
const tryCompress = () => {
|
| 126 |
-
canvas.toBlob((blob) => {
|
| 127 |
-
if (!blob) {
|
| 128 |
-
reject(new Error('Canvas 转换 Blob 失败'));
|
| 129 |
-
return;
|
| 130 |
-
}
|
| 131 |
-
|
| 132 |
-
if (maxFileSize && blob.size > maxFileSize && currentQuality > 0.3) {
|
| 133 |
-
currentQuality -= 0.1;
|
| 134 |
-
tryCompress();
|
| 135 |
-
return;
|
| 136 |
-
}
|
| 137 |
-
|
| 138 |
-
const newFileName = file.name.replace(/\.[^/.]+$/, '') + suffix;
|
| 139 |
-
resolve(new File([blob], newFileName, { type: 'image/jpeg' }));
|
| 140 |
-
}, 'image/jpeg', currentQuality);
|
| 141 |
-
};
|
| 142 |
-
|
| 143 |
-
tryCompress();
|
| 144 |
-
};
|
| 145 |
-
|
| 146 |
-
img.onerror = () => {
|
| 147 |
-
URL.revokeObjectURL(url);
|
| 148 |
-
reject(new Error('图片加载失败'));
|
| 149 |
-
};
|
| 150 |
-
|
| 151 |
-
img.src = url;
|
| 152 |
-
});
|
| 153 |
-
}
|
| 154 |
-
|
| 155 |
/**
|
| 156 |
* 🟢 利用 HTML5 Canvas 纯前端裁剪并压缩头像
|
| 157 |
* 功能:强制 1:1 比例中心裁剪,缩放到 512x512,转换为 JPG 格式。
|
|
@@ -159,7 +57,8 @@ function _processImageCore(file, options = {}) {
|
|
| 159 |
function processAvatar(file) {
|
| 160 |
return _processImageCore(file, {
|
| 161 |
cropToSquare: true,
|
| 162 |
-
|
|
|
|
| 163 |
quality: 0.85,
|
| 164 |
suffix: '_cropped.jpg'
|
| 165 |
}).then(processedFile => {
|
|
@@ -171,7 +70,7 @@ function processAvatar(file) {
|
|
| 171 |
/**
|
| 172 |
* 上传前图片压缩处理
|
| 173 |
* - 转为 JPG 格式
|
| 174 |
-
* -
|
| 175 |
* @param {File} file - 原始图片文件
|
| 176 |
* @returns {Promise<File>} - 处理后的文件
|
| 177 |
*/
|
|
@@ -181,65 +80,54 @@ export async function compressImageForUpload(file) {
|
|
| 181 |
return file;
|
| 182 |
}
|
| 183 |
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
const GIF_MAX_SIZE = 50 * 1024 * 1024; // 50MB
|
| 187 |
-
if (file.size > GIF_MAX_SIZE) {
|
| 188 |
-
throw new Error(`GIF文件过大(${(file.size/1024/1024).toFixed(1)}MB),最大允许50MB`);
|
| 189 |
-
}
|
| 190 |
-
console.log(`🎞️ GIF动图保留原始格式: ${(file.size/1024/1024).toFixed(2)}MB`);
|
| 191 |
-
return file;
|
| 192 |
-
}
|
| 193 |
-
|
| 194 |
-
try {
|
| 195 |
-
const processedFile = await _processImageCore(file, {
|
| 196 |
-
quality: 0.92,
|
| 197 |
-
fillBackground: '#FFFFFF',
|
| 198 |
-
maxFileSize: 5 * 1024 * 1024,
|
| 199 |
-
skipIfSmallJpeg: true,
|
| 200 |
-
suffix: '.jpg'
|
| 201 |
-
});
|
| 202 |
-
console.log(`📦 图片压缩: ${(file.size/1024/1024).toFixed(2)}MB → ${(processedFile.size/1024/1024).toFixed(2)}MB`);
|
| 203 |
-
return processedFile;
|
| 204 |
-
} catch (e) {
|
| 205 |
-
// 加载失败,返回原文件
|
| 206 |
-
return file;
|
| 207 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
}
|
| 209 |
|
| 210 |
/**
|
| 211 |
* 🟢 新增:全局统一的文件上传包装器(供个人设置等其它组件调用)
|
| 212 |
* 会在上传前自动拦截并处理头像图片
|
| 213 |
*/
|
| 214 |
-
export async function uploadFile(file, file_type) {
|
|
|
|
|
|
|
|
|
|
| 215 |
try {
|
| 216 |
let fileToUpload = file;
|
| 217 |
|
| 218 |
// 拦截点:如果是头像,就在纯内存中先进行裁剪、缩放、压缩
|
| 219 |
if (file_type === "avatar" && file.type && file.type.startsWith("image/")) {
|
| 220 |
-
|
| 221 |
fileToUpload = await processAvatar(file);
|
| 222 |
}
|
| 223 |
|
| 224 |
-
|
|
|
|
| 225 |
const res = await api.uploadFile(fileToUpload, file_type);
|
| 226 |
|
|
|
|
| 227 |
if (res.status === "success") {
|
| 228 |
-
|
| 229 |
return res.url;
|
| 230 |
} else {
|
| 231 |
-
|
| 232 |
return null;
|
| 233 |
}
|
| 234 |
} catch (error) {
|
| 235 |
-
|
| 236 |
-
showToast(`❌ ${t('publish.upload_error')}: ${error.message}`, "error");
|
| 237 |
return null;
|
| 238 |
}
|
| 239 |
}
|
| 240 |
|
| 241 |
// ==========================================
|
| 242 |
-
//
|
| 243 |
// ==========================================
|
| 244 |
export async function handlePublishSubmit(params) {
|
| 245 |
const {
|
|
@@ -247,6 +135,13 @@ export async function handlePublishSubmit(params) {
|
|
| 247 |
imageFiles, jsonFile, onSuccessCallback, submitBtn, submitBtnText // 🖼️ imageFiles 替换 coverFile
|
| 248 |
} = params;
|
| 249 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
const typeSelect = container.querySelector("#pub-type");
|
| 251 |
const recommendTypeSelect = container.querySelector("#pub-recommend-type");
|
| 252 |
const resTypeSelect = container.querySelector("#pub-resource-type");
|
|
@@ -264,7 +159,8 @@ export async function handlePublishSubmit(params) {
|
|
| 264 |
const title = container.querySelector("#pub-title").value.trim();
|
| 265 |
const shortDesc = container.querySelector("#pub-short").value.trim();
|
| 266 |
const fullDesc = container.querySelector("#pub-full").value.trim();
|
| 267 |
-
const price = rules.price ? (
|
|
|
|
| 268 |
|
| 269 |
let finalLink = inputLink.value.trim();
|
| 270 |
let isJsonUpload = (resTypeSelect.value === "json") || (type === "recommend_app");
|
|
@@ -315,15 +211,18 @@ export async function handlePublishSubmit(params) {
|
|
| 315 |
const allowRefundCheckbox = container.querySelector("#allow-refund-checkbox");
|
| 316 |
const allowRefund = rules.refund ? (allowRefundCheckbox?.checked ?? true) : null;
|
| 317 |
|
|
|
|
|
|
|
| 318 |
submitBtn.innerHTML = `⏳ ${t('publish.connecting')}`;
|
| 319 |
submitBtn.disabled = true;
|
| 320 |
-
submitBtn.style.background = "#555";
|
| 321 |
|
| 322 |
try {
|
| 323 |
if (isJsonUpload && jsonFile) {
|
| 324 |
submitBtn.innerHTML = `⏳ ${t('publish.uploading_secure')}`;
|
| 325 |
const uploadType = type.includes("app") ? "app" : (type.includes("tool") || type === "skill" ? "tool" : "recommend");
|
| 326 |
const jsonUploadRes = await api.uploadFile(jsonFile, uploadType);
|
|
|
|
|
|
|
| 327 |
finalLink = jsonUploadRes.url;
|
| 328 |
}
|
| 329 |
|
|
@@ -331,19 +230,21 @@ export async function handlePublishSubmit(params) {
|
|
| 331 |
let coverUrl = isEditMode ? editItemData.coverUrl : null;
|
| 332 |
let imageUrls = isEditMode ? (editItemData.imageUrls || []) : [];
|
| 333 |
|
| 334 |
-
if (
|
| 335 |
-
submitBtn.innerHTML = `⏳ ${t('publish.uploading_images', { current: 0, total:
|
| 336 |
const uploadedUrls = [];
|
| 337 |
|
| 338 |
-
for (let i = 0; i <
|
| 339 |
-
submitBtn.innerHTML = `⏳ ${t('publish.uploading_images', { current: i + 1, total:
|
| 340 |
// 上传前压缩:转JPG,超5MB压缩
|
| 341 |
-
const processedFile = await compressImageForUpload(
|
|
|
|
| 342 |
const uploadRes = await api.uploadFile(processedFile, "cover");
|
| 343 |
-
if (
|
|
|
|
| 344 |
uploadedUrls.push(uploadRes.url);
|
| 345 |
} else {
|
| 346 |
-
throw new Error(
|
| 347 |
}
|
| 348 |
}
|
| 349 |
|
|
@@ -351,12 +252,15 @@ export async function handlePublishSubmit(params) {
|
|
| 351 |
imageUrls = uploadedUrls; // 全部图片URL
|
| 352 |
}
|
| 353 |
|
|
|
|
| 354 |
submitBtn.innerHTML = `⏳ ${t('publish.syncing')}`;
|
| 355 |
// 🖼️ 统一封面逻辑:第一张图片始终作为封面(兑容拖拽排序后的顺序变化)
|
| 356 |
if (imageUrls && imageUrls.length > 0) {
|
| 357 |
coverUrl = imageUrls[0];
|
| 358 |
}
|
| 359 |
const submitData = {
|
|
|
|
|
|
|
| 360 |
type, title, shortDesc, fullDesc, price, link: finalLink, coverUrl, imageUrls, // 🖼️ 添加 imageUrls
|
| 361 |
author: currentUser.account, github_token,
|
| 362 |
netdisk_password, // ☁️ 网盘密码(后端加密存储)
|
|
@@ -369,22 +273,40 @@ export async function handlePublishSubmit(params) {
|
|
| 369 |
submitData.allow_refund = allowRefund;
|
| 370 |
}
|
| 371 |
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
}
|
| 379 |
-
|
| 380 |
-
// 🚀 精确清除:只清除当前发布类型的缓存
|
| 381 |
-
clearItemCacheByType(type);
|
| 382 |
-
|
| 383 |
-
if (onSuccessCallback) onSuccessCallback();
|
| 384 |
} catch (err) {
|
| 385 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
submitBtn.disabled = false;
|
| 387 |
submitBtn.innerHTML = submitBtnText;
|
| 388 |
-
|
|
|
|
| 389 |
}
|
| 390 |
-
}
|
|
|
|
| 1 |
+
import { processImageCore as _processImageCore } from "./有界图片处理.js";
|
| 2 |
+
import { validateImageFile } from "./图片文件检查.js";
|
| 3 |
+
import { IMAGE_PROCESS } from "../core/全局配置.js";
|
| 4 |
// 前端页面/market/发布内容_提交引擎.js
|
| 5 |
+
import { createPublishSubmissionScope } from "./发布提交生命周期.js";
|
| 6 |
import { api } from "../core/网络请求API.js";
|
| 7 |
import { showToast } from "../components/UI交互提示组件.js";
|
| 8 |
import { t } from "../components/用户体验增强.js";
|
|
|
|
| 50 |
console.log(`🗑️ 已清除 [${tab}] 类型的列表缓存`);
|
| 51 |
}
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
/**
|
| 54 |
* 🟢 利用 HTML5 Canvas 纯前端裁剪并压缩头像
|
| 55 |
* 功能:强制 1:1 比例中心裁剪,缩放到 512x512,转换为 JPG 格式。
|
|
|
|
| 57 |
function processAvatar(file) {
|
| 58 |
return _processImageCore(file, {
|
| 59 |
cropToSquare: true,
|
| 60 |
+
maxFileSize: IMAGE_PROCESS.AVATAR_MAX_BYTES,
|
| 61 |
+
targetSize: IMAGE_PROCESS.AVATAR_SIZE,
|
| 62 |
quality: 0.85,
|
| 63 |
suffix: '_cropped.jpg'
|
| 64 |
}).then(processedFile => {
|
|
|
|
| 70 |
/**
|
| 71 |
* 上传前图片压缩处理
|
| 72 |
* - 转为 JPG 格式
|
| 73 |
+
* - 最长边至多4096并保持比例;质量压缩后仍超过5MB则拒绝
|
| 74 |
* @param {File} file - 原始图片文件
|
| 75 |
* @returns {Promise<File>} - 处理后的文件
|
| 76 |
*/
|
|
|
|
| 80 |
return file;
|
| 81 |
}
|
| 82 |
|
| 83 |
+
if (!Number.isFinite(file.size) || file.size < 0 || file.size > IMAGE_PROCESS.MAX_INPUT_BYTES) {
|
| 84 |
+
throw new Error('Image exceeds upload budget');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
}
|
| 86 |
+
// 动图保持原格式;不由Canvas变为静态图片。
|
| 87 |
+
if (file.type === 'image/gif') { await validateImageFile(file); return file; }
|
| 88 |
+
return _processImageCore(file, {
|
| 89 |
+
quality: 0.92, fillBackground: '#FFFFFF', maxFileSize: IMAGE_PROCESS.MAX_OUTPUT_BYTES,
|
| 90 |
+
skipIfSmallJpeg: true, suffix: '.jpg'
|
| 91 |
+
});
|
| 92 |
}
|
| 93 |
|
| 94 |
/**
|
| 95 |
* 🟢 新增:全局统一的文件上传包装器(供个人设置等其它组件调用)
|
| 96 |
* 会在上传前自动拦截并处理头像图片
|
| 97 |
*/
|
| 98 |
+
export async function uploadFile(file, file_type, options = {}) {
|
| 99 |
+
const current = options.isCurrent || (() => true);
|
| 100 |
+
const feedback = (message, level) => { if (!options.silent && current()) showToast(message, level); };
|
| 101 |
+
if (!current()) return null;
|
| 102 |
try {
|
| 103 |
let fileToUpload = file;
|
| 104 |
|
| 105 |
// 拦截点:如果是头像,就在纯内存中先进行裁剪、缩放、压缩
|
| 106 |
if (file_type === "avatar" && file.type && file.type.startsWith("image/")) {
|
| 107 |
+
feedback(`🖼️ ${t('publish.processing_avatar')}`);
|
| 108 |
fileToUpload = await processAvatar(file);
|
| 109 |
}
|
| 110 |
|
| 111 |
+
feedback(`🌐 ${t('publish.uploading_file', { size: (fileToUpload.size/1024).toFixed(1) })}`);
|
| 112 |
+
if (!current()) return null;
|
| 113 |
const res = await api.uploadFile(fileToUpload, file_type);
|
| 114 |
|
| 115 |
+
if (!current()) return null;
|
| 116 |
if (res.status === "success") {
|
| 117 |
+
feedback(`✅ ${t('publish.upload_success')}`);
|
| 118 |
return res.url;
|
| 119 |
} else {
|
| 120 |
+
feedback(t('publish.upload_failed'), 'error');
|
| 121 |
return null;
|
| 122 |
}
|
| 123 |
} catch (error) {
|
| 124 |
+
feedback(t('publish.upload_error'), 'error');
|
|
|
|
| 125 |
return null;
|
| 126 |
}
|
| 127 |
}
|
| 128 |
|
| 129 |
// ==========================================
|
| 130 |
+
// 发布表单提交:原会话范围与保存结果核实
|
| 131 |
// ==========================================
|
| 132 |
export async function handlePublishSubmit(params) {
|
| 133 |
const {
|
|
|
|
| 135 |
imageFiles, jsonFile, onSuccessCallback, submitBtn, submitBtnText // 🖼️ imageFiles 替换 coverFile
|
| 136 |
} = params;
|
| 137 |
|
| 138 |
+
const scope = params.submissionScope || createPublishSubmissionScope(container, submitBtn, currentUser?.account);
|
| 139 |
+
if (!scope.isCurrent()) return;
|
| 140 |
+
const expectedRevision = isEditMode ? (editItemData?.edit_revision === undefined ? 0 : editItemData.edit_revision) : null;
|
| 141 |
+
if (isEditMode && (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0)) {
|
| 142 |
+
showToast(t('common.operation_failed'), 'error'); return;
|
| 143 |
+
}
|
| 144 |
+
const selectedImages = Array.from(imageFiles || []);
|
| 145 |
const typeSelect = container.querySelector("#pub-type");
|
| 146 |
const recommendTypeSelect = container.querySelector("#pub-recommend-type");
|
| 147 |
const resTypeSelect = container.querySelector("#pub-resource-type");
|
|
|
|
| 159 |
const title = container.querySelector("#pub-title").value.trim();
|
| 160 |
const shortDesc = container.querySelector("#pub-short").value.trim();
|
| 161 |
const fullDesc = container.querySelector("#pub-full").value.trim();
|
| 162 |
+
const price = rules.price ? Number(container.querySelector("#pub-price").value) : 0;
|
| 163 |
+
if (!Number.isSafeInteger(price) || price < 0) return showToast(t('common.operation_failed'), "warning");
|
| 164 |
|
| 165 |
let finalLink = inputLink.value.trim();
|
| 166 |
let isJsonUpload = (resTypeSelect.value === "json") || (type === "recommend_app");
|
|
|
|
| 211 |
const allowRefundCheckbox = container.querySelector("#allow-refund-checkbox");
|
| 212 |
const allowRefund = rules.refund ? (allowRefundCheckbox?.checked ?? true) : null;
|
| 213 |
|
| 214 |
+
if (!scope.begin()) return;
|
| 215 |
+
let committed = false, saveAttempted = false, outcomeUnknown = false;
|
| 216 |
submitBtn.innerHTML = `⏳ ${t('publish.connecting')}`;
|
| 217 |
submitBtn.disabled = true;
|
|
|
|
| 218 |
|
| 219 |
try {
|
| 220 |
if (isJsonUpload && jsonFile) {
|
| 221 |
submitBtn.innerHTML = `⏳ ${t('publish.uploading_secure')}`;
|
| 222 |
const uploadType = type.includes("app") ? "app" : (type.includes("tool") || type === "skill" ? "tool" : "recommend");
|
| 223 |
const jsonUploadRes = await api.uploadFile(jsonFile, uploadType);
|
| 224 |
+
if (!scope.isCurrent()) return;
|
| 225 |
+
if (jsonUploadRes?.status !== "success" || typeof jsonUploadRes.url !== "string" || !jsonUploadRes.url.trim()) throw new Error("Invalid upload result");
|
| 226 |
finalLink = jsonUploadRes.url;
|
| 227 |
}
|
| 228 |
|
|
|
|
| 230 |
let coverUrl = isEditMode ? editItemData.coverUrl : null;
|
| 231 |
let imageUrls = isEditMode ? (editItemData.imageUrls || []) : [];
|
| 232 |
|
| 233 |
+
if (selectedImages.length > 0) {
|
| 234 |
+
submitBtn.innerHTML = `⏳ ${t('publish.uploading_images', { current: 0, total: selectedImages.length })}`;
|
| 235 |
const uploadedUrls = [];
|
| 236 |
|
| 237 |
+
for (let i = 0; i < selectedImages.length; i++) {
|
| 238 |
+
submitBtn.innerHTML = `⏳ ${t('publish.uploading_images', { current: i + 1, total: selectedImages.length })}`;
|
| 239 |
// 上传前压缩:转JPG,超5MB压缩
|
| 240 |
+
const processedFile = await compressImageForUpload(selectedImages[i]);
|
| 241 |
+
if (!scope.isCurrent()) return;
|
| 242 |
const uploadRes = await api.uploadFile(processedFile, "cover");
|
| 243 |
+
if (!scope.isCurrent()) return;
|
| 244 |
+
if (uploadRes?.status === "success" && typeof uploadRes.url === "string" && uploadRes.url.trim()) {
|
| 245 |
uploadedUrls.push(uploadRes.url);
|
| 246 |
} else {
|
| 247 |
+
throw new Error("Invalid image upload result");
|
| 248 |
}
|
| 249 |
}
|
| 250 |
|
|
|
|
| 252 |
imageUrls = uploadedUrls; // 全部图片URL
|
| 253 |
}
|
| 254 |
|
| 255 |
+
if (!scope.isCurrent()) return;
|
| 256 |
submitBtn.innerHTML = `⏳ ${t('publish.syncing')}`;
|
| 257 |
// 🖼️ 统一封面逻辑:第一张图片始终作为封面(兑容拖拽排序后的顺序变化)
|
| 258 |
if (imageUrls && imageUrls.length > 0) {
|
| 259 |
coverUrl = imageUrls[0];
|
| 260 |
}
|
| 261 |
const submitData = {
|
| 262 |
+
...(!isEditMode && scope.publicationId ? { request_id: scope.publicationId } : {}),
|
| 263 |
+
...(isEditMode ? { expected_revision: expectedRevision } : {}),
|
| 264 |
type, title, shortDesc, fullDesc, price, link: finalLink, coverUrl, imageUrls, // 🖼️ 添加 imageUrls
|
| 265 |
author: currentUser.account, github_token,
|
| 266 |
netdisk_password, // ☁️ 网盘密码(后端加密存储)
|
|
|
|
| 273 |
submitData.allow_refund = allowRefund;
|
| 274 |
}
|
| 275 |
|
| 276 |
+
saveAttempted = true;
|
| 277 |
+
const result = isEditMode
|
| 278 |
+
? await api.updateItem(editItemData.id, currentUser.account, submitData)
|
| 279 |
+
: await api.publishItem(submitData);
|
| 280 |
+
if (!scope.isCurrent()) return;
|
| 281 |
+
if (result?.status !== "success") throw new Error("Invalid save result");
|
| 282 |
+
committed = true;
|
| 283 |
+
showToast(isEditMode ? `✅ ${t('publish.save_success')}` : `🎉 ${t('publish.publish_success')}`, "success");
|
| 284 |
+
// 主保存已确认;派生缓存或界面回调异常不重新开放提交。
|
| 285 |
+
try { clearItemCacheByType(type); } catch (_) {}
|
| 286 |
+
if (scope.isCurrent() && onSuccessCallback) {
|
| 287 |
+
try { await onSuccessCallback(); } catch (_) {}
|
| 288 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
} catch (err) {
|
| 290 |
+
if (!scope.isCurrent() || committed) return;
|
| 291 |
+
if (isEditMode && err?.status === 409 && err?.code === 'resource_revision_conflict') {
|
| 292 |
+
outcomeUnknown = true; // 保持入口锁,要求重开取得新版本;不自动覆盖。
|
| 293 |
+
showToast(t('publish.edit_conflict'), 'warning');
|
| 294 |
+
submitBtn.disabled = true; submitBtn.innerHTML = t('publish.edit_reopen');
|
| 295 |
+
return;
|
| 296 |
+
}
|
| 297 |
+
// 已发出的保存可能在服务端提交;仅已知前置拒绝允许原表单重试。
|
| 298 |
+
const rejected = [400, 401, 403, 404, 409, 422, 429].includes(err?.status);
|
| 299 |
+
outcomeUnknown = saveAttempted && !rejected;
|
| 300 |
+
if (outcomeUnknown) {
|
| 301 |
+
showToast(t('publish.save_unconfirmed'), "warning");
|
| 302 |
+
submitBtn.disabled = true;
|
| 303 |
+
submitBtn.innerHTML = t('publish.result_pending');
|
| 304 |
+
return;
|
| 305 |
+
}
|
| 306 |
+
showToast(t('common.operation_failed'), "error");
|
| 307 |
submitBtn.disabled = false;
|
| 308 |
submitBtn.innerHTML = submitBtnText;
|
| 309 |
+
} finally {
|
| 310 |
+
scope.finish(committed, outcomeUnknown);
|
| 311 |
}
|
| 312 |
+
}
|
前端页面/market/发布内容组件.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
|
|
| 1 |
// 前端页面/market/发布内容组件.js
|
| 2 |
import { generatePublishHTML } from "./发布内容_UI模板.js";
|
|
|
|
| 3 |
import { handlePublishSubmit } from "./发布内容_提交引擎.js";
|
| 4 |
import { t } from "../components/用户体验增强.js";
|
| 5 |
import { showConfirm, showToast } from "../components/UI交互提示组件.js";
|
|
@@ -206,7 +208,9 @@ export function createPublishView(currentUser, onBackCallback, onSuccessCallback
|
|
| 206 |
// 1. 渲染分离出去的视图模板
|
| 207 |
container.innerHTML = generatePublishHTML(isEditMode, viewTitle, submitBtnText, hasExistingToken, editItemData, t);
|
| 208 |
|
|
|
|
| 209 |
container.querySelector("#btn-back").onclick = async () => {
|
|
|
|
| 210 |
// 检查是否有已编辑内容
|
| 211 |
const hasChanges = !!(
|
| 212 |
container.querySelector("#pub-title")?.value?.trim() ||
|
|
@@ -222,12 +226,13 @@ export function createPublishView(currentUser, onBackCallback, onSuccessCallback
|
|
| 222 |
title: isEditMode ? t('publish.unsaved_changes_title') : t('publish.leave_confirm_title'),
|
| 223 |
confirmText: t('common.leave'),
|
| 224 |
cancelText: t('common.stay'),
|
| 225 |
-
type: 'warning'
|
| 226 |
}
|
| 227 |
);
|
| 228 |
-
if (!confirmed) return;
|
| 229 |
}
|
| 230 |
|
|
|
|
| 231 |
if (onBackCallback) onBackCallback();
|
| 232 |
};
|
| 233 |
|
|
@@ -449,36 +454,49 @@ export function createPublishView(currentUser, onBackCallback, onSuccessCallback
|
|
| 449 |
|
| 450 |
updateFormView();
|
| 451 |
|
| 452 |
-
//
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
}
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
files.forEach((file, idx) => {
|
| 475 |
-
const objectUrl = URL.createObjectURL(file);
|
| 476 |
-
imagesPreview.appendChild(_createImagePreviewWrapper(objectUrl, idx));
|
| 477 |
-
});
|
| 478 |
-
updateCoverMark(imagesPreview);
|
| 479 |
-
setupImageDragSort(imagesPreview, imageFiles);
|
| 480 |
};
|
| 481 |
-
|
| 482 |
inputJson.onchange = (e) => { jsonFile = e.target.files[0]; };
|
| 483 |
|
| 484 |
// 🎨 原创勾选框逻辑:工具/应用类型强制确认弹窗
|
|
@@ -490,15 +508,18 @@ export function createPublishView(currentUser, onBackCallback, onSuccessCallback
|
|
| 490 |
const rules = TYPE_RULES[typeSelect.value];
|
| 491 |
if (originalHintText) {
|
| 492 |
if (rules?.originalRequired) {
|
| 493 |
-
originalHintText.
|
|
|
|
| 494 |
} else {
|
| 495 |
originalHintText.textContent = t('publish.original_default_hint');
|
|
|
|
| 496 |
}
|
| 497 |
}
|
| 498 |
};
|
| 499 |
|
| 500 |
// 显示原创确认弹窗
|
| 501 |
const showOriginalConfirmModal = (onConfirm, onCancel) => {
|
|
|
|
| 502 |
const contentDiv = document.createElement("div");
|
| 503 |
contentDiv.innerHTML = `
|
| 504 |
<div style="line-height: 1.6; color: #ccc;">
|
|
@@ -519,20 +540,25 @@ export function createPublishView(currentUser, onBackCallback, onSuccessCallback
|
|
| 519 |
</div>
|
| 520 |
`;
|
| 521 |
|
| 522 |
-
globalModal.openModal(t('publish.original_confirm_title'), contentDiv, { width: "450px" });
|
|
|
|
|
|
|
| 523 |
|
| 524 |
contentDiv.querySelector("#btn-original-confirm").onclick = () => {
|
| 525 |
-
|
|
|
|
| 526 |
onConfirm();
|
| 527 |
};
|
| 528 |
contentDiv.querySelector("#btn-original-cancel").onclick = () => {
|
| 529 |
-
|
|
|
|
| 530 |
onCancel();
|
| 531 |
};
|
| 532 |
};
|
| 533 |
|
| 534 |
// 监听原创勾选框变化
|
| 535 |
isOriginalCheckbox.onchange = (e) => {
|
|
|
|
| 536 |
const rules = TYPE_RULES[typeSelect.value];
|
| 537 |
// 只有规则要求原创强制的类型才需要确认弹窗
|
| 538 |
if (rules?.originalRequired && e.target.checked) {
|
|
@@ -552,7 +578,10 @@ export function createPublishView(currentUser, onBackCallback, onSuccessCallback
|
|
| 552 |
|
| 553 |
// 4. 将提取的参数交接给分离出去的提交引擎
|
| 554 |
const submitBtn = container.querySelector("#btn-submit-publish");
|
| 555 |
-
|
|
|
|
|
|
|
|
|
|
| 556 |
container, currentUser, isEditMode, editItemData,
|
| 557 |
imageFiles, jsonFile, onSuccessCallback, submitBtn, submitBtnText // 🖼️ imageFiles 替换 coverFile
|
| 558 |
});
|
|
|
|
| 1 |
+
import { validateImageFile } from "./图片文件检查.js";
|
| 2 |
// 前端页面/market/发布内容组件.js
|
| 3 |
import { generatePublishHTML } from "./发布内容_UI模板.js";
|
| 4 |
+
import { createPublishSubmissionScope } from "./发布提交生命周期.js";
|
| 5 |
import { handlePublishSubmit } from "./发布内容_提交引擎.js";
|
| 6 |
import { t } from "../components/用户体验增强.js";
|
| 7 |
import { showConfirm, showToast } from "../components/UI交互提示组件.js";
|
|
|
|
| 208 |
// 1. 渲染分离出去的视图模板
|
| 209 |
container.innerHTML = generatePublishHTML(isEditMode, viewTitle, submitBtnText, hasExistingToken, editItemData, t);
|
| 210 |
|
| 211 |
+
let submissionScope;
|
| 212 |
container.querySelector("#btn-back").onclick = async () => {
|
| 213 |
+
if (!submissionScope?.isCurrent()) return;
|
| 214 |
// 检查是否有已编辑内容
|
| 215 |
const hasChanges = !!(
|
| 216 |
container.querySelector("#pub-title")?.value?.trim() ||
|
|
|
|
| 226 |
title: isEditMode ? t('publish.unsaved_changes_title') : t('publish.leave_confirm_title'),
|
| 227 |
confirmText: t('common.leave'),
|
| 228 |
cancelText: t('common.stay'),
|
| 229 |
+
type: 'warning', signal: submissionScope.confirmationSignal
|
| 230 |
}
|
| 231 |
);
|
| 232 |
+
if (!confirmed || !submissionScope.isCurrent()) return;
|
| 233 |
}
|
| 234 |
|
| 235 |
+
submissionScope.dispose();
|
| 236 |
if (onBackCallback) onBackCallback();
|
| 237 |
};
|
| 238 |
|
|
|
|
| 454 |
|
| 455 |
updateFormView();
|
| 456 |
|
| 457 |
+
// 文件头确认后才创建预览,迟到选择不覆盖后一次选择。
|
| 458 |
+
let previewGeneration = 0, previewPending = false, resumeDisabled = false;
|
| 459 |
+
const previewUrls = new Set();
|
| 460 |
+
const releasePreviews = () => {
|
| 461 |
+
for (const url of previewUrls) URL.revokeObjectURL(url);
|
| 462 |
+
previewUrls.clear();
|
| 463 |
+
};
|
| 464 |
+
imagesInput.onchange = async (e) => {
|
| 465 |
+
if (!submissionScope?.isCurrent()) return;
|
| 466 |
+
const rawFiles = Array.from(e.target.files).slice(0, 6);
|
| 467 |
+
if (!rawFiles.length) return;
|
| 468 |
+
const generation = ++previewGeneration;
|
| 469 |
+
if (!previewPending) resumeDisabled = submitBtn.disabled;
|
| 470 |
+
previewPending = true; submitBtn.disabled = true;
|
| 471 |
+
try {
|
| 472 |
+
const files = [];
|
| 473 |
+
for (const file of rawFiles) {
|
| 474 |
+
try { await validateImageFile(file); files.push(file); }
|
| 475 |
+
catch (_) {
|
| 476 |
+
if (generation === previewGeneration && submissionScope.isCurrent()) showToast(t('common.operation_failed'), 'warning');
|
| 477 |
+
}
|
| 478 |
+
if (generation !== previewGeneration || !submissionScope.isCurrent()) return;
|
| 479 |
+
}
|
| 480 |
+
if (!files.length) return;
|
| 481 |
+
releasePreviews(); imageFiles = files; imagesPreview.replaceChildren();
|
| 482 |
+
for (const [index, file] of files.entries()) {
|
| 483 |
+
const url = URL.createObjectURL(file); previewUrls.add(url);
|
| 484 |
+
imagesPreview.appendChild(_createImagePreviewWrapper(url, index));
|
| 485 |
+
}
|
| 486 |
+
updateCoverMark(imagesPreview); setupImageDragSort(imagesPreview, imageFiles);
|
| 487 |
+
} catch (_) {
|
| 488 |
+
if (generation === previewGeneration && submissionScope.isCurrent()) {
|
| 489 |
+
releasePreviews(); imageFiles = []; imagesPreview.replaceChildren();
|
| 490 |
+
showToast(t('common.operation_failed'), 'warning');
|
| 491 |
}
|
| 492 |
+
} finally {
|
| 493 |
+
if (generation === previewGeneration) {
|
| 494 |
+
previewPending = false;
|
| 495 |
+
if (submissionScope.isCurrent()) submitBtn.disabled = resumeDisabled;
|
| 496 |
+
}
|
| 497 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 498 |
};
|
| 499 |
+
|
| 500 |
inputJson.onchange = (e) => { jsonFile = e.target.files[0]; };
|
| 501 |
|
| 502 |
// 🎨 原创勾选框逻辑:工具/应用类型强制确认弹窗
|
|
|
|
| 508 |
const rules = TYPE_RULES[typeSelect.value];
|
| 509 |
if (originalHintText) {
|
| 510 |
if (rules?.originalRequired) {
|
| 511 |
+
originalHintText.textContent = `⚠️ ${t('publish.original_required_hint')}`;
|
| 512 |
+
originalHintText.classList.add('ranking-publish__original-required');
|
| 513 |
} else {
|
| 514 |
originalHintText.textContent = t('publish.original_default_hint');
|
| 515 |
+
originalHintText.classList.remove('ranking-publish__original-required');
|
| 516 |
}
|
| 517 |
}
|
| 518 |
};
|
| 519 |
|
| 520 |
// 显示原创确认弹窗
|
| 521 |
const showOriginalConfirmModal = (onConfirm, onCancel) => {
|
| 522 |
+
if (!submissionScope?.isCurrent()) return;
|
| 523 |
const contentDiv = document.createElement("div");
|
| 524 |
contentDiv.innerHTML = `
|
| 525 |
<div style="line-height: 1.6; color: #ccc;">
|
|
|
|
| 540 |
</div>
|
| 541 |
`;
|
| 542 |
|
| 543 |
+
const modal = globalModal.openModal(t('publish.original_confirm_title'), contentDiv, { width: "450px" });
|
| 544 |
+
const release = submissionScope.addCleanup(() => globalModal.closeModal(modal));
|
| 545 |
+
contentDiv._cleanup = release;
|
| 546 |
|
| 547 |
contentDiv.querySelector("#btn-original-confirm").onclick = () => {
|
| 548 |
+
if (!submissionScope.isCurrent()) return;
|
| 549 |
+
globalModal.closeModal(modal);
|
| 550 |
onConfirm();
|
| 551 |
};
|
| 552 |
contentDiv.querySelector("#btn-original-cancel").onclick = () => {
|
| 553 |
+
if (!submissionScope.isCurrent()) return;
|
| 554 |
+
globalModal.closeModal(modal);
|
| 555 |
onCancel();
|
| 556 |
};
|
| 557 |
};
|
| 558 |
|
| 559 |
// 监听原创勾选框变化
|
| 560 |
isOriginalCheckbox.onchange = (e) => {
|
| 561 |
+
if (!submissionScope?.isCurrent()) return;
|
| 562 |
const rules = TYPE_RULES[typeSelect.value];
|
| 563 |
// 只有规则要求原创强制的类型才需要确认弹窗
|
| 564 |
if (rules?.originalRequired && e.target.checked) {
|
|
|
|
| 578 |
|
| 579 |
// 4. 将提取的参数交接给分离出去的提交引擎
|
| 580 |
const submitBtn = container.querySelector("#btn-submit-publish");
|
| 581 |
+
submissionScope = createPublishSubmissionScope(container, submitBtn, currentUser?.account);
|
| 582 |
+
submissionScope.addCleanup(() => { ++previewGeneration; releasePreviews(); imageFiles = []; jsonFile = null; imagesPreview.replaceChildren(); });
|
| 583 |
+
submitBtn.onclick = () => previewPending ? undefined : handlePublishSubmit({
|
| 584 |
+
submissionScope,
|
| 585 |
container, currentUser, isEditMode, editItemData,
|
| 586 |
imageFiles, jsonFile, onSuccessCallback, submitBtn, submitBtnText // 🖼️ imageFiles 替换 coverFile
|
| 587 |
});
|
前端页面/market/发布字段错误.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** 发布表单本地校验提示;不保存草稿,也不展示远端异常正文。 */
|
| 2 |
+
export function createPublishFieldErrors(inputs, signal) {
|
| 3 |
+
const entries = new Map(); let disposed = false;
|
| 4 |
+
const clear = input => {
|
| 5 |
+
const entry = entries.get(input); if (!entry) return;
|
| 6 |
+
entry.error.hidden = true; entry.error.textContent = '';
|
| 7 |
+
const tokens = (input.getAttribute('aria-describedby') || '').split(/\s+/).filter(token => token && token !== entry.error.id);
|
| 8 |
+
if (tokens.length) input.setAttribute('aria-describedby', tokens.join(' ')); else input.removeAttribute('aria-describedby');
|
| 9 |
+
if (entry.active && input.getAttribute('aria-invalid') === 'true') {
|
| 10 |
+
if (entry.previousInvalid === null) input.removeAttribute('aria-invalid');
|
| 11 |
+
else input.setAttribute('aria-invalid', entry.previousInvalid);
|
| 12 |
+
}
|
| 13 |
+
entry.active = false;
|
| 14 |
+
};
|
| 15 |
+
const clearAll = () => { for (const input of entries.keys()) clear(input); };
|
| 16 |
+
const dispose = () => {
|
| 17 |
+
if (disposed) return; disposed = true;
|
| 18 |
+
clearAll();
|
| 19 |
+
for (const [input, entry] of entries) {
|
| 20 |
+
input.removeEventListener('input', entry.onChange); input.removeEventListener('change', entry.onChange); entry.error.remove();
|
| 21 |
+
}
|
| 22 |
+
entries.clear(); signal?.removeEventListener('abort', dispose);
|
| 23 |
+
};
|
| 24 |
+
const show = (input, message) => {
|
| 25 |
+
if (disposed || signal?.aborted) return;
|
| 26 |
+
const entry = entries.get(input); if (!entry) return;
|
| 27 |
+
if (!entry.active) entry.previousInvalid = input.getAttribute('aria-invalid');
|
| 28 |
+
entry.active = true; entry.error.textContent = message; entry.error.hidden = false;
|
| 29 |
+
const tokens = new Set((input.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean)); tokens.add(entry.error.id);
|
| 30 |
+
input.setAttribute('aria-describedby', [...tokens].join(' ')); input.setAttribute('aria-invalid', 'true'); input.focus();
|
| 31 |
+
};
|
| 32 |
+
if (signal?.aborted) { disposed = true; return { show, clearAll, dispose }; }
|
| 33 |
+
for (const input of inputs) {
|
| 34 |
+
const error = document.createElement('div'); error.id = `${input.id}-error`; error.className = 'ranking-publish-field-error';
|
| 35 |
+
error.hidden = true; error.setAttribute('role', 'alert'); input.insertAdjacentElement('afterend', error);
|
| 36 |
+
const entry = { error, active: false, previousInvalid: null, onChange: () => clear(input) };
|
| 37 |
+
entries.set(input, entry); input.addEventListener('input', entry.onChange); input.addEventListener('change', entry.onChange);
|
| 38 |
+
}
|
| 39 |
+
signal?.addEventListener('abort', dispose, { once: true });
|
| 40 |
+
return { show, clearAll, dispose };
|
| 41 |
+
}
|
前端页面/market/发布提交生命周期.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** 发布表单绑定原账号与凭证,已经发送的写操作不取消或重放。 */
|
| 2 |
+
import { captureRequestSession } from "../core/网络请求API.js";
|
| 3 |
+
import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
|
| 4 |
+
import { CACHE } from "../core/全局配置.js";
|
| 5 |
+
import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
|
| 6 |
+
|
| 7 |
+
const bindings = new WeakMap();
|
| 8 |
+
|
| 9 |
+
export function createPublishSubmissionScope(container, button, account) {
|
| 10 |
+
const previous = bindings.get(container);
|
| 11 |
+
if (previous?.button === button && previous.account === account) return previous.binding;
|
| 12 |
+
previous?.binding.dispose();
|
| 13 |
+
const matches = captureRequestSession(), confirmation = new AbortController();
|
| 14 |
+
let phase = 'idle', observer;
|
| 15 |
+
const scope = createTaskViewScope({
|
| 16 |
+
sessionMatches: () => !!account && matches() && getUser()?.account === account,
|
| 17 |
+
connected: () => container.isConnected && button.isConnected,
|
| 18 |
+
subscribe: notify => subscribeTaskSessionChanges(notify, { target: window, eventBus, events: EVENTS,
|
| 19 |
+
userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN }),
|
| 20 |
+
cancelReads: () => {},
|
| 21 |
+
invalidate: () => {}
|
| 22 |
+
});
|
| 23 |
+
scope.addCleanup(() => {
|
| 24 |
+
phase = 'disposed'; observer?.disconnect(); confirmation.abort();
|
| 25 |
+
button.disabled = true; button.removeAttribute('aria-busy');
|
| 26 |
+
for (const selector of ['#pub-github-token', '#pub-netdisk-password']) {
|
| 27 |
+
const input = container.querySelector(selector); if (input) input.value = '';
|
| 28 |
+
}
|
| 29 |
+
});
|
| 30 |
+
let wasConnected = container.isConnected;
|
| 31 |
+
observer = new MutationObserver(() => {
|
| 32 |
+
if (container.isConnected) wasConnected = true;
|
| 33 |
+
if (wasConnected) scope.isCurrent();
|
| 34 |
+
});
|
| 35 |
+
observer.observe(document.body, { childList: true, subtree: true });
|
| 36 |
+
container._cleanup = () => scope.dispose();
|
| 37 |
+
const publicationId = Array.from(crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, '0')).join('');
|
| 38 |
+
const binding = {
|
| 39 |
+
publicationId,
|
| 40 |
+
confirmationSignal: confirmation.signal,
|
| 41 |
+
addCleanup: cleanup => scope.addCleanup(cleanup),
|
| 42 |
+
isCurrent: () => scope.isCurrent(),
|
| 43 |
+
dispose: () => scope.dispose(),
|
| 44 |
+
begin() {
|
| 45 |
+
if (phase !== 'idle' || !scope.isCurrent()) return false;
|
| 46 |
+
phase = 'submitting'; button.setAttribute('aria-busy', 'true'); return true;
|
| 47 |
+
},
|
| 48 |
+
finish(committed, outcomeUnknown = false) {
|
| 49 |
+
if (!scope.isCurrent()) return;
|
| 50 |
+
phase = committed ? 'committed' : (outcomeUnknown ? 'unconfirmed' : 'idle'); button.removeAttribute('aria-busy');
|
| 51 |
+
}
|
| 52 |
+
};
|
| 53 |
+
bindings.set(container, { button, account, binding });
|
| 54 |
+
return binding;
|
| 55 |
+
}
|
前端页面/market/发布文件选择可访问性.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const bindings = new WeakMap();
|
| 2 |
+
|
| 3 |
+
/** onclick属性变化不触发MutationObserver,渲染时显式同步空态语义。 */
|
| 4 |
+
export function setPublishFilePickerAction(area, action) {
|
| 5 |
+
area.onclick = action;
|
| 6 |
+
bindings.get(area)?.();
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
/** 上传区域只在空态作为一个按钮;已有媒体中的原生控件保留独立语义。 */
|
| 10 |
+
export function bindPublishFilePicker(area, input, label, signal) {
|
| 11 |
+
let disposed = false;
|
| 12 |
+
input.setAttribute('aria-label', label);
|
| 13 |
+
const sync = () => {
|
| 14 |
+
if (disposed) return;
|
| 15 |
+
const active = typeof area.onclick === 'function';
|
| 16 |
+
if (active) {
|
| 17 |
+
area.setAttribute('role', 'button'); area.tabIndex = 0;
|
| 18 |
+
area.setAttribute('aria-label', label);
|
| 19 |
+
area.setAttribute('aria-disabled', String(input.disabled));
|
| 20 |
+
if (input.getAttribute('aria-busy') === 'true') area.setAttribute('aria-busy', 'true');
|
| 21 |
+
else area.removeAttribute('aria-busy');
|
| 22 |
+
} else {
|
| 23 |
+
for (const name of ['role', 'tabindex', 'aria-label', 'aria-disabled', 'aria-busy']) area.removeAttribute(name);
|
| 24 |
+
}
|
| 25 |
+
for (const button of area.querySelectorAll('[data-file-picker-button]')) button.disabled = input.disabled;
|
| 26 |
+
};
|
| 27 |
+
const onKey = event => {
|
| 28 |
+
if (disposed || signal?.aborted || input.disabled || event.target !== area || typeof area.onclick !== 'function'
|
| 29 |
+
|| event.isComposing || event.repeat || !['Enter', ' '].includes(event.key)) return;
|
| 30 |
+
event.preventDefault(); event.stopPropagation(); area.click();
|
| 31 |
+
};
|
| 32 |
+
const observer = new MutationObserver(sync);
|
| 33 |
+
const dispose = () => {
|
| 34 |
+
if (disposed) return;
|
| 35 |
+
disposed = true; observer.disconnect();
|
| 36 |
+
if (bindings.get(area) === sync) bindings.delete(area); area.removeEventListener('keydown', onKey);
|
| 37 |
+
signal?.removeEventListener('abort', dispose);
|
| 38 |
+
};
|
| 39 |
+
if (signal?.aborted) { dispose(); return dispose; }
|
| 40 |
+
observer.observe(area, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] });
|
| 41 |
+
observer.observe(input, { attributes: true, attributeFilter: ['disabled', 'aria-busy'] });
|
| 42 |
+
area.addEventListener('keydown', onKey); signal?.addEventListener('abort', dispose, { once: true });
|
| 43 |
+
bindings.set(area, sync); sync(); return dispose;
|
| 44 |
+
}
|
前端页面/market/发布编辑锁.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** 锁住本次发布的编辑入口;返回、提交按钮由原生命周期管理。 */
|
| 2 |
+
export function createPublishEditingLock(container, excluded = []) {
|
| 3 |
+
let locked = false, disposed = false;
|
| 4 |
+
const controls = new Map();
|
| 5 |
+
const except = target => excluded.some(node => node === target || node?.contains(target));
|
| 6 |
+
const captureControls = () => {
|
| 7 |
+
if (!locked || disposed) return;
|
| 8 |
+
for (const control of container.querySelectorAll('input, textarea, select, button')) {
|
| 9 |
+
if (except(control)) continue;
|
| 10 |
+
if (!controls.has(control)) controls.set(control, control.disabled);
|
| 11 |
+
control.disabled = true;
|
| 12 |
+
}
|
| 13 |
+
};
|
| 14 |
+
const preventEditing = event => {
|
| 15 |
+
if (!locked || disposed || except(event.target)) return;
|
| 16 |
+
// 媒体播放不改变待发布内容,仍可预览;其它点击/键盘/拖拽入口锁定。
|
| 17 |
+
if (event.target?.closest?.('video, audio')) return;
|
| 18 |
+
event.preventDefault(); event.stopImmediatePropagation();
|
| 19 |
+
};
|
| 20 |
+
const events = ['click', 'keydown', 'dragstart', 'drop', 'input', 'change'];
|
| 21 |
+
for (const name of events) container.addEventListener(name, preventEditing, true);
|
| 22 |
+
const observer = new MutationObserver(captureControls);
|
| 23 |
+
return {
|
| 24 |
+
lock() {
|
| 25 |
+
if (disposed || locked) return;
|
| 26 |
+
locked = true; captureControls();
|
| 27 |
+
observer.observe(container, { childList: true, subtree: true });
|
| 28 |
+
},
|
| 29 |
+
unlock() {
|
| 30 |
+
if (disposed || !locked) return;
|
| 31 |
+
locked = false; observer.disconnect();
|
| 32 |
+
for (const [control, disabled] of controls) {
|
| 33 |
+
if (container.contains(control)) control.disabled = disabled;
|
| 34 |
+
}
|
| 35 |
+
controls.clear();
|
| 36 |
+
},
|
| 37 |
+
dispose() {
|
| 38 |
+
if (disposed) return;
|
| 39 |
+
disposed = true; observer.disconnect(); controls.clear();
|
| 40 |
+
for (const name of events) container.removeEventListener(name, preventEditing, true);
|
| 41 |
+
}
|
| 42 |
+
};
|
| 43 |
+
}
|
前端页面/market/图片文件检查.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** 有界文件头检查;仅确认格式/尺寸,不代替浏览器解码和服务端审核。 */
|
| 2 |
+
import { IMAGE_PROCESS } from "../core/全局配置.js";
|
| 3 |
+
|
| 4 |
+
export function readImageDimensions(buffer) {
|
| 5 |
+
const b = new Uint8Array(buffer), v = new DataView(buffer);
|
| 6 |
+
const text = (offset, count) => String.fromCharCode(...b.subarray(offset, offset + count));
|
| 7 |
+
const requireBytes = end => { if (end > b.length) throw new Error('Truncated image header'); };
|
| 8 |
+
let width, height, type;
|
| 9 |
+
if (b.length >= 8 && b[0] === 137 && text(1, 3) === 'PNG' && text(4, 4) === '\r\n\x1a\n') {
|
| 10 |
+
requireBytes(33);
|
| 11 |
+
if (v.getUint32(8) !== 13 || text(12, 4) !== 'IHDR') throw new Error('Invalid PNG header');
|
| 12 |
+
width = v.getUint32(16); height = v.getUint32(20); type = 'image/png';
|
| 13 |
+
} else if (text(0, 6) === 'GIF87a' || text(0, 6) === 'GIF89a') {
|
| 14 |
+
requireBytes(13); width = v.getUint16(6, true); height = v.getUint16(8, true); type = 'image/gif';
|
| 15 |
+
} else if (text(0, 4) === 'RIFF' && text(8, 4) === 'WEBP') {
|
| 16 |
+
requireBytes(20); const chunk = text(12, 4), size = v.getUint32(16, true);
|
| 17 |
+
if (v.getUint32(4, true) < size + 12) throw new Error('Invalid WebP container');
|
| 18 |
+
if (chunk === 'VP8X' && size === 10) {
|
| 19 |
+
requireBytes(30); width = 1 + b[24] + (b[25] << 8) + (b[26] << 16);
|
| 20 |
+
height = 1 + b[27] + (b[28] << 8) + (b[29] << 16);
|
| 21 |
+
} else if (chunk === 'VP8L' && size >= 5) {
|
| 22 |
+
requireBytes(25); if (b[20] !== 47 || (b[24] >> 5) !== 0) throw new Error('Invalid lossless header');
|
| 23 |
+
const bits = v.getUint32(21, true); width = 1 + (bits & 16383); height = 1 + ((bits >>> 14) & 16383);
|
| 24 |
+
} else if (chunk === 'VP8 ' && size >= 10) {
|
| 25 |
+
requireBytes(30);
|
| 26 |
+
if ((b[20] & 1) || b[23] !== 157 || b[24] !== 1 || b[25] !== 42) throw new Error('Invalid VP8 frame');
|
| 27 |
+
width = v.getUint16(26, true) & 16383; height = v.getUint16(28, true) & 16383;
|
| 28 |
+
} else throw new Error('Unsupported WebP header');
|
| 29 |
+
type = 'image/webp';
|
| 30 |
+
} else if (b[0] === 255 && b[1] === 216) {
|
| 31 |
+
let offset = 2;
|
| 32 |
+
while (offset < b.length) {
|
| 33 |
+
if (b[offset++] !== 255) throw new Error('Invalid JPEG marker');
|
| 34 |
+
while (b[offset] === 255) offset++;
|
| 35 |
+
requireBytes(offset + 1); const marker = b[offset++];
|
| 36 |
+
if (marker === 1) continue;
|
| 37 |
+
if (marker === 0 || marker === 216 || marker === 217 || marker === 218 || (marker >= 208 && marker <= 215)) break;
|
| 38 |
+
requireBytes(offset + 2); const length = v.getUint16(offset);
|
| 39 |
+
if (length < 2) throw new Error('Invalid JPEG segment');
|
| 40 |
+
requireBytes(offset + length);
|
| 41 |
+
if (marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker)) {
|
| 42 |
+
if (length < 8 || b[offset + 7] === 0 || length !== 8 + 3 * b[offset + 7]) throw new Error('Invalid JPEG frame');
|
| 43 |
+
height = v.getUint16(offset + 3); width = v.getUint16(offset + 5); type = 'image/jpeg'; break;
|
| 44 |
+
}
|
| 45 |
+
offset += length;
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
if (!type || !Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0
|
| 49 |
+
|| Math.max(width, height) > IMAGE_PROCESS.MAX_SOURCE_EDGE || width * height > IMAGE_PROCESS.MAX_PIXELS) {
|
| 50 |
+
throw new Error('Unsupported image or dimensions exceed budget');
|
| 51 |
+
}
|
| 52 |
+
return { width, height, type };
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export async function validateImageFile(file, { signal } = {}) {
|
| 56 |
+
if (!Number.isSafeInteger(file?.size) || file.size <= 0 || file.size > IMAGE_PROCESS.MAX_INPUT_BYTES
|
| 57 |
+
|| typeof file.slice !== 'function') throw new Error('Invalid image file');
|
| 58 |
+
let timer, abort;
|
| 59 |
+
const aborted = () => Object.assign(new Error('Image header read aborted'), { name: 'AbortError' });
|
| 60 |
+
if (signal?.aborted) throw aborted();
|
| 61 |
+
try {
|
| 62 |
+
const cancellation = new Promise((_, reject) => {
|
| 63 |
+
abort = () => reject(aborted());
|
| 64 |
+
signal?.addEventListener('abort', abort, { once: true });
|
| 65 |
+
});
|
| 66 |
+
const buffer = await Promise.race([
|
| 67 |
+
cancellation,
|
| 68 |
+
file.slice(0, IMAGE_PROCESS.HEADER_BYTES).arrayBuffer(),
|
| 69 |
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Image header read timed out')), IMAGE_PROCESS.TIMEOUT); })
|
| 70 |
+
]);
|
| 71 |
+
const dimensions = readImageDimensions(buffer);
|
| 72 |
+
if (file.type && file.type !== dimensions.type && !(file.type === 'image/jpg' && dimensions.type === 'image/jpeg')) {
|
| 73 |
+
throw new Error('Image MIME does not match header');
|
| 74 |
+
}
|
| 75 |
+
return dimensions;
|
| 76 |
+
} finally { clearTimeout(timer); signal?.removeEventListener('abort', abort); }
|
| 77 |
+
}
|
前端页面/market/支付二维码工具.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { QrCode } from '../vendor/qrcodegen.js';
|
| 2 |
+
|
| 3 |
+
/** 支付链接仅用于本地二维码编码,SVG 中不插入链接或文本。 */
|
| 4 |
+
export function createPaymentQRCode(value, maxBytes) {
|
| 5 |
+
if (typeof value !== 'string' || !Number.isSafeInteger(maxBytes) || maxBytes < 1
|
| 6 |
+
|| new TextEncoder().encode(value).length > maxBytes) throw new Error('invalid payment data');
|
| 7 |
+
const url = new URL(value);
|
| 8 |
+
if (url.protocol !== 'https:' || url.username || url.password || url.port
|
| 9 |
+
|| !(url.hostname === 'alipay.com' || url.hostname.endsWith('.alipay.com'))) {
|
| 10 |
+
throw new Error('invalid payment address');
|
| 11 |
+
}
|
| 12 |
+
const qr = QrCode.encodeText(value, QrCode.Ecc.MEDIUM);
|
| 13 |
+
const border = 4, size = qr.size + border * 2;
|
| 14 |
+
const paths = [];
|
| 15 |
+
for (let y = 0; y < qr.size; y++) {
|
| 16 |
+
for (let x = 0; x < qr.size; x++) {
|
| 17 |
+
if (qr.getModule(x, y)) paths.push(`M${x + border},${y + border}h1v1h-1z`);
|
| 18 |
+
}
|
| 19 |
+
}
|
| 20 |
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" shape-rendering="crispEdges"><rect width="100%" height="100%" fill="white"/><path d="${paths.join('')}" fill="black"/></svg>`;
|
| 21 |
+
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
| 22 |
+
}
|
前端页面/market/有界图片处理.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { validateImageFile } from "./图片文件检查.js";
|
| 2 |
+
import { IMAGE_PROCESS, PUBLISH_MEDIA_LIMITS } from "../core/全局配置.js";
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* 通用图片处理核心函数:加载图片 → Canvas绘制 → 转换为JPG文件
|
| 6 |
+
* @param {File} file - 原始图片文件
|
| 7 |
+
* @param {Object} options - 配置选项
|
| 8 |
+
* @param {boolean} options.cropToSquare - 是否中心裁剪为正方形
|
| 9 |
+
* @param {number|null} options.targetSize - 目标尺寸(正方形边长),null则按输出边长预算等比缩小
|
| 10 |
+
* @param {number} options.quality - 初始JPG质量 (0-1)
|
| 11 |
+
* @param {string|null} options.fillBackground - 背景填充颜色,null则不填充
|
| 12 |
+
* @param {number|null} options.maxFileSize - 最大文件大小(字节),超限则迭代降低质量
|
| 13 |
+
* @param {boolean} options.skipIfSmallJpeg - 是否跳过已是小体积JPEG的文件
|
| 14 |
+
* @param {number} options.maxEdge - 可选输出最长边(不超过全局输出预算)
|
| 15 |
+
* @param {AbortSignal} options.signal - 所属会话的取消信号
|
| 16 |
+
* @param {string} options.suffix - 输出文件名后缀
|
| 17 |
+
* @returns {Promise<File>} 处理后的文件
|
| 18 |
+
*/
|
| 19 |
+
export function processImageCore(file, options = {}) {
|
| 20 |
+
if (!Number.isFinite(file?.size) || file.size < 0 || file.size > IMAGE_PROCESS.MAX_INPUT_BYTES) {
|
| 21 |
+
return Promise.reject(new Error('Invalid image size'));
|
| 22 |
+
}
|
| 23 |
+
return validateImageFile(file, { signal: options.signal }).then(() => new Promise((resolve, reject) => {
|
| 24 |
+
let img, url, canvas, timer, settled = false;
|
| 25 |
+
const signal = options.signal;
|
| 26 |
+
const abort = () => finish(Object.assign(new Error('Image processing aborted'), { name: 'AbortError' }));
|
| 27 |
+
const finish = (error, result) => {
|
| 28 |
+
if (settled) return;
|
| 29 |
+
settled = true; clearTimeout(timer); signal?.removeEventListener('abort', abort);
|
| 30 |
+
if (img) { img.onload = null; img.onerror = null; img.src = ''; }
|
| 31 |
+
if (url) URL.revokeObjectURL(url);
|
| 32 |
+
if (canvas) { canvas.width = 0; canvas.height = 0; }
|
| 33 |
+
if (error) reject(error); else resolve(result);
|
| 34 |
+
};
|
| 35 |
+
if (signal?.aborted) { abort(); return; }
|
| 36 |
+
signal?.addEventListener('abort', abort, { once: true });
|
| 37 |
+
timer = setTimeout(() => finish(new Error('Image processing timed out')), IMAGE_PROCESS.TIMEOUT);
|
| 38 |
+
try {
|
| 39 |
+
img = new Image(); url = URL.createObjectURL(file);
|
| 40 |
+
img.onerror = () => finish(new Error('Image decode failed'));
|
| 41 |
+
img.onload = () => {
|
| 42 |
+
if (settled) return;
|
| 43 |
+
try {
|
| 44 |
+
const width = img.naturalWidth, height = img.naturalHeight;
|
| 45 |
+
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0
|
| 46 |
+
|| Math.max(width, height) > IMAGE_PROCESS.MAX_SOURCE_EDGE || width * height > IMAGE_PROCESS.MAX_PIXELS) {
|
| 47 |
+
throw new Error('Image dimensions exceed budget');
|
| 48 |
+
}
|
| 49 |
+
const cap = options.maxFileSize || IMAGE_PROCESS.MAX_OUTPUT_BYTES;
|
| 50 |
+
if (options.skipIfSmallJpeg && file.type === 'image/jpeg' && file.size <= cap
|
| 51 |
+
&& Math.max(width, height) <= IMAGE_PROCESS.MAX_OUTPUT_EDGE) { finish(null, file); return; }
|
| 52 |
+
let sourceX = 0, sourceY = 0, sourceW = width, sourceH = height;
|
| 53 |
+
if (options.cropToSquare) {
|
| 54 |
+
sourceW = sourceH = Math.min(width, height);
|
| 55 |
+
sourceX = (width - sourceW) / 2; sourceY = (height - sourceH) / 2;
|
| 56 |
+
}
|
| 57 |
+
const outputEdge = Math.min(options.maxEdge || IMAGE_PROCESS.MAX_OUTPUT_EDGE, IMAGE_PROCESS.MAX_OUTPUT_EDGE);
|
| 58 |
+
const scale = Math.min(1, outputEdge / Math.max(sourceW, sourceH));
|
| 59 |
+
const destW = options.targetSize || Math.max(1, Math.round(sourceW * scale));
|
| 60 |
+
const destH = options.targetSize || Math.max(1, Math.round(sourceH * scale));
|
| 61 |
+
canvas = document.createElement('canvas'); canvas.width = destW; canvas.height = destH;
|
| 62 |
+
const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('Canvas unavailable');
|
| 63 |
+
if (options.fillBackground) { ctx.fillStyle = options.fillBackground; ctx.fillRect(0, 0, destW, destH); }
|
| 64 |
+
ctx.drawImage(img, sourceX, sourceY, sourceW, sourceH, 0, 0, destW, destH);
|
| 65 |
+
let quality = options.quality || 0.85;
|
| 66 |
+
const encode = () => {
|
| 67 |
+
if (settled) return;
|
| 68 |
+
try {
|
| 69 |
+
canvas.toBlob(blob => {
|
| 70 |
+
if (settled) return;
|
| 71 |
+
try {
|
| 72 |
+
if (!blob || !Number.isSafeInteger(blob.size) || blob.size <= 0) throw new Error('Image encoding failed');
|
| 73 |
+
if (blob.size > cap) {
|
| 74 |
+
if (quality <= IMAGE_PROCESS.MIN_QUALITY) throw new Error('Compressed image exceeds budget');
|
| 75 |
+
quality = Math.max(IMAGE_PROCESS.MIN_QUALITY, quality - IMAGE_PROCESS.QUALITY_STEP);
|
| 76 |
+
encode(); return;
|
| 77 |
+
}
|
| 78 |
+
finish(null, new File([blob], file.name.replace(/\.[^/.]+$/, '') + (options.suffix || '.jpg'), { type: 'image/jpeg' }));
|
| 79 |
+
} catch (error) { finish(error); }
|
| 80 |
+
}, 'image/jpeg', quality);
|
| 81 |
+
} catch (error) { finish(error); }
|
| 82 |
+
};
|
| 83 |
+
encode();
|
| 84 |
+
} catch (error) { finish(error); }
|
| 85 |
+
};
|
| 86 |
+
img.src = url;
|
| 87 |
+
} catch (error) { finish(error); }
|
| 88 |
+
}));
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
// 只记住本模块成功生成的不可变File;弱引用不会延长表单文件生命周期。
|
| 92 |
+
const preparedPublishImages = new WeakSet();
|
| 93 |
+
|
| 94 |
+
/** 帖子/提示词保持原1920最长边与JPEG转换策略,不保留动画。 */
|
| 95 |
+
export function compressPublishImage(file, { signal } = {}) {
|
| 96 |
+
if (signal?.aborted) return Promise.reject(Object.assign(new Error('Image processing aborted'), { name: 'AbortError' }));
|
| 97 |
+
if (preparedPublishImages.has(file)) return Promise.resolve(file);
|
| 98 |
+
return processImageCore(file, {
|
| 99 |
+
maxEdge: PUBLISH_MEDIA_LIMITS.IMAGE_MAX_EDGE,
|
| 100 |
+
quality: PUBLISH_MEDIA_LIMITS.IMAGE_QUALITY,
|
| 101 |
+
maxFileSize: IMAGE_PROCESS.MAX_OUTPUT_BYTES,
|
| 102 |
+
suffix: '.jpg', signal
|
| 103 |
+
}).then(result => { preparedPublishImages.add(result); return result; });
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
/** 按发布输出预算截取当前视频帧,终态释放画布。 */
|
| 107 |
+
export function capturePublishVideoFrame(video, { signal } = {}) {
|
| 108 |
+
return new Promise((resolve, reject) => {
|
| 109 |
+
let canvas, timer, settled = false;
|
| 110 |
+
const abort = () => finish(Object.assign(new Error('Video frame capture aborted'), { name: 'AbortError' }));
|
| 111 |
+
const finish = (error, result) => {
|
| 112 |
+
if (settled) return;
|
| 113 |
+
settled = true; clearTimeout(timer); signal?.removeEventListener('abort', abort);
|
| 114 |
+
if (canvas) { canvas.width = 0; canvas.height = 0; }
|
| 115 |
+
if (error) reject(error); else resolve(result);
|
| 116 |
+
};
|
| 117 |
+
if (signal?.aborted) { abort(); return; }
|
| 118 |
+
signal?.addEventListener('abort', abort, { once: true });
|
| 119 |
+
timer = setTimeout(() => finish(new Error('Video frame capture timed out')), IMAGE_PROCESS.TIMEOUT);
|
| 120 |
+
try {
|
| 121 |
+
const width = video.videoWidth, height = video.videoHeight;
|
| 122 |
+
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0 ||
|
| 123 |
+
Math.max(width, height) > IMAGE_PROCESS.MAX_SOURCE_EDGE || width * height > IMAGE_PROCESS.MAX_PIXELS) {
|
| 124 |
+
throw new Error('Video frame dimensions exceed budget');
|
| 125 |
+
}
|
| 126 |
+
const scale = Math.min(1, PUBLISH_MEDIA_LIMITS.IMAGE_MAX_EDGE / Math.max(width, height));
|
| 127 |
+
canvas = document.createElement('canvas');
|
| 128 |
+
canvas.width = Math.max(1, Math.round(width * scale)); canvas.height = Math.max(1, Math.round(height * scale));
|
| 129 |
+
const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('Canvas unavailable');
|
| 130 |
+
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
| 131 |
+
let quality = PUBLISH_MEDIA_LIMITS.IMAGE_QUALITY;
|
| 132 |
+
const encode = () => {
|
| 133 |
+
if (settled) return;
|
| 134 |
+
try {
|
| 135 |
+
canvas.toBlob(blob => {
|
| 136 |
+
if (settled) return;
|
| 137 |
+
try {
|
| 138 |
+
if (!blob || !Number.isSafeInteger(blob.size) || blob.size <= 0) throw new Error('Invalid video frame output');
|
| 139 |
+
if (blob.size > IMAGE_PROCESS.MAX_OUTPUT_BYTES) {
|
| 140 |
+
if (quality <= IMAGE_PROCESS.MIN_QUALITY) throw new Error('Video frame output exceeds budget');
|
| 141 |
+
quality = Math.max(IMAGE_PROCESS.MIN_QUALITY, quality - IMAGE_PROCESS.QUALITY_STEP);
|
| 142 |
+
encode(); return;
|
| 143 |
+
}
|
| 144 |
+
finish(null, new File([blob], 'cover.jpg', { type: 'image/jpeg' }));
|
| 145 |
+
} catch (error) { finish(error); }
|
| 146 |
+
}, 'image/jpeg', quality);
|
| 147 |
+
} catch (error) { finish(error); }
|
| 148 |
+
};
|
| 149 |
+
encode();
|
| 150 |
+
} catch (error) { finish(error); }
|
| 151 |
+
});
|
| 152 |
+
}
|
前端页面/market/购买状态生命周期.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 购买/退款状态DOM属于原账号;两种资源入口共用取消和身份释放。
|
| 2 |
+
import { captureRequestSession, requestCancelManager } from "../core/网络请求API.js";
|
| 3 |
+
import { API, CACHE } from "../core/全局配置.js";
|
| 4 |
+
import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
|
| 5 |
+
import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
|
| 6 |
+
|
| 7 |
+
const scopes = new WeakMap();
|
| 8 |
+
let sequence = 0;
|
| 9 |
+
export function createPurchaseStatusScope(node, account) {
|
| 10 |
+
scopes.get(node)?.dispose();
|
| 11 |
+
const matches = captureRequestSession(), componentId = `purchase-status-${++sequence}`;
|
| 12 |
+
let constructing = true, observer;
|
| 13 |
+
const scope = createTaskViewScope({
|
| 14 |
+
sessionMatches: () => matches() && getUser()?.account === account,
|
| 15 |
+
connected: () => constructing || node.isConnected,
|
| 16 |
+
subscribe: notify => subscribeTaskSessionChanges(notify, { target: window, eventBus, events: EVENTS,
|
| 17 |
+
userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN }),
|
| 18 |
+
cancelReads: () => requestCancelManager.cancelAll(componentId), invalidate: () => {}
|
| 19 |
+
});
|
| 20 |
+
scope.readOptions = { noCache: true, componentId, timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES };
|
| 21 |
+
scope.addCleanup(() => { observer?.disconnect(); node.replaceChildren(); node.style.display = "none"; });
|
| 22 |
+
node._cleanup = () => scope.dispose(); scopes.set(node, scope);
|
| 23 |
+
queueMicrotask(() => {
|
| 24 |
+
constructing = false;
|
| 25 |
+
if (!scope.isCurrent()) return;
|
| 26 |
+
observer = new MutationObserver(() => { scope.isCurrent(); });
|
| 27 |
+
observer.observe(document.body, { childList: true, subtree: true });
|
| 28 |
+
});
|
| 29 |
+
return scope;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export function isValidPurchaseStatus(response) {
|
| 33 |
+
if (response?.status !== "success" || typeof response.owned !== "boolean") return false;
|
| 34 |
+
if (!response.owned) return true;
|
| 35 |
+
return typeof response.allow_refund === "boolean" && typeof response.can_refund === "boolean" && Number.isSafeInteger(response.price_paid) && response.price_paid >= 0
|
| 36 |
+
&& typeof response.refund_hours_left === "number" && Number.isFinite(response.refund_hours_left) && response.refund_hours_left >= 0;
|
| 37 |
+
}
|
前端页面/market/资源删除组件.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 资源卡片与详情共用删除确认;取消页面不取消或重放已经提交的DELETE。
|
| 2 |
+
import { api, captureRequestSession, invalidateRelatedCache } from "../core/网络请求API.js";
|
| 3 |
+
import { CACHE } from "../core/全局配置.js";
|
| 4 |
+
import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
|
| 5 |
+
import { showConfirm, showToast } from "../components/UI交互提示组件.js";
|
| 6 |
+
import { t } from "../components/用户体验增强.js";
|
| 7 |
+
import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
|
| 8 |
+
|
| 9 |
+
const active = new Set();
|
| 10 |
+
export async function showResourceDeleteDialog({ itemData, currentUser, isCurrent = () => true, onDeleted }) {
|
| 11 |
+
const account = currentUser?.account, itemId = itemData?.id;
|
| 12 |
+
const matches = captureRequestSession();
|
| 13 |
+
const sessionMatches = () => matches() && getUser()?.account === account;
|
| 14 |
+
if (!account || !itemId || !sessionMatches() || !isCurrent()
|
| 15 |
+
|| (itemData.author !== account && currentUser.is_admin !== true)) return false;
|
| 16 |
+
const key = JSON.stringify([account, itemId]);
|
| 17 |
+
if (active.has(key)) return false;
|
| 18 |
+
active.add(key);
|
| 19 |
+
const controller = new AbortController();
|
| 20 |
+
let observer;
|
| 21 |
+
const scope = createTaskViewScope({
|
| 22 |
+
sessionMatches, connected: isCurrent,
|
| 23 |
+
subscribe: notify => subscribeTaskSessionChanges(notify, { target: window, eventBus, events: EVENTS,
|
| 24 |
+
userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN }),
|
| 25 |
+
cancelReads: () => {}, invalidate: () => {}
|
| 26 |
+
});
|
| 27 |
+
scope.addCleanup(() => { observer?.disconnect(); controller.abort(); active.delete(key); });
|
| 28 |
+
observer = new MutationObserver(() => { scope.isCurrent(); });
|
| 29 |
+
observer.observe(document.body, { childList: true, subtree: true });
|
| 30 |
+
const message = [t('item.content_label') + (itemData.title || t('item.untitled')),
|
| 31 |
+
t('item.type_label') + (itemData.type || t('item.type_unknown')), '', t('item.delete_consequence'),
|
| 32 |
+
t('item.delete_consequence_1'), t('item.delete_consequence_2'), t('item.delete_consequence_3')].join('\n');
|
| 33 |
+
try {
|
| 34 |
+
const succeeded = await showConfirm(message, {
|
| 35 |
+
title: t('item.confirm_delete_title'), type: 'danger', confirmText: t('item.confirm_delete'),
|
| 36 |
+
processingText: t('item.deleting'), signal: controller.signal,
|
| 37 |
+
onConfirm: async () => {
|
| 38 |
+
if (!scope.isCurrent()) return false;
|
| 39 |
+
try {
|
| 40 |
+
const result = await api.deleteItem(itemId);
|
| 41 |
+
if (!scope.isCurrent()) return false;
|
| 42 |
+
if (result?.status !== 'success') {
|
| 43 |
+
showToast(t('item.delete_failed_retry'), 'error'); return false;
|
| 44 |
+
}
|
| 45 |
+
return true;
|
| 46 |
+
} catch (_) {
|
| 47 |
+
if (scope.isCurrent()) showToast(t('item.delete_request_failed'), 'error');
|
| 48 |
+
return false;
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
});
|
| 52 |
+
if (!succeeded || !scope.isCurrent()) return false;
|
| 53 |
+
try { invalidateRelatedCache(`/api/items/${itemId}`, 'DELETE'); }
|
| 54 |
+
catch (_) { console.warn('删除成功,部分本地缓存无法清理'); }
|
| 55 |
+
showToast(t('item.delete_success'), 'success');
|
| 56 |
+
try { onDeleted?.(); } catch (_) { console.warn('删除成功,视图更新失败'); }
|
| 57 |
+
return true;
|
| 58 |
+
} finally { scope.dispose(); }
|
| 59 |
+
}
|
前端页面/market/资源安装引擎.js
CHANGED
|
@@ -5,16 +5,22 @@ import { requestSSE, request } from "../core/网络请求_基础设施.js";
|
|
| 5 |
import { openUserProfileModal } from "../profile/个人中心视图.js";
|
| 6 |
import { API, CACHE, IS_WEB_MODE, escapeHtml } from "../core/全局配置.js";
|
| 7 |
import { t } from "../components/用户体验增强.js";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
// 📱 Web 模式兼容:ComfyUI 的 app 对象仅存在于本地环境,改为受保护动态导入
|
| 10 |
// (Web 模式不暴露安装/下载入口,不会走到此路径;万一走到也不抛错)
|
| 11 |
-
async function _loadGraphToCanvas(graphData) {
|
|
|
|
| 12 |
try {
|
| 13 |
const { app } = await import("../../../scripts/app.js");
|
| 14 |
-
|
|
|
|
| 15 |
return true;
|
| 16 |
} catch (e) {
|
| 17 |
-
console.warn("当前环境
|
| 18 |
return false;
|
| 19 |
}
|
| 20 |
}
|
|
@@ -47,7 +53,87 @@ function clearUsesCache() {
|
|
| 47 |
* @returns {string} JWT Token(未登录时为空字符串)
|
| 48 |
*/
|
| 49 |
function _getAuthToken() {
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
}
|
| 52 |
|
| 53 |
/**
|
|
@@ -56,563 +142,761 @@ function _getAuthToken() {
|
|
| 56 |
* @param {Object} itemData - 资源数据
|
| 57 |
* @param {string} netdiskPassword - 网盘密码(为空则显示无密码版本)
|
| 58 |
*/
|
| 59 |
-
function
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
inlineStatusBox.style.display = 'block';
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
if (netdiskPassword) {
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
// 绑定复制按钮事件
|
| 88 |
-
const copyBtn = container.querySelector(`#btn-copy-pwd-${itemData.id}`);
|
| 89 |
-
if (copyBtn) {
|
| 90 |
-
copyBtn.onclick = () => {
|
| 91 |
-
navigator.clipboard.writeText(netdiskPassword);
|
| 92 |
-
copyBtn.textContent = '✅ 已复制';
|
| 93 |
-
};
|
| 94 |
-
}
|
| 95 |
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
btnOpen.onclick = () => {
|
| 100 |
-
window.open(itemData.link, '_blank');
|
| 101 |
-
api.recordItemUse(itemData.id).then(() => {
|
| 102 |
-
clearUsesCache();
|
| 103 |
-
window.dispatchEvent(new CustomEvent("comfy-trigger-sidebar-reload", { detail: { force: true } }));
|
| 104 |
-
}).catch(err => console.warn('📊 使用量记录失败:', err));
|
| 105 |
-
};
|
| 106 |
-
}
|
| 107 |
-
} else {
|
| 108 |
-
// 无密码网盘资源
|
| 109 |
-
inlineStatusBox.innerHTML = `
|
| 110 |
-
<div style="color: #4CAF50; font-weight: bold; margin-bottom: 10px;">✅ 授权通过,请前往网盘下载:</div>
|
| 111 |
-
<button id="btn-netdisk-open-${itemData.id}" style="padding: 6px 16px; background: #2563eb; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 13px;">🔗 打开网盘链接</button>
|
| 112 |
-
`;
|
| 113 |
-
// 绑定打开网盘按钮事件
|
| 114 |
-
const btnOpen = inlineStatusBox.querySelector(`#btn-netdisk-open-${itemData.id}`);
|
| 115 |
-
if (btnOpen) {
|
| 116 |
-
btnOpen.onclick = () => {
|
| 117 |
-
window.open(itemData.link, '_blank');
|
| 118 |
-
api.recordItemUse(itemData.id).then(() => {
|
| 119 |
-
clearUsesCache();
|
| 120 |
-
window.dispatchEvent(new CustomEvent("comfy-trigger-sidebar-reload", { detail: { force: true } }));
|
| 121 |
-
}).catch(err => console.warn('📊 使用量记录失败:', err));
|
| 122 |
-
};
|
| 123 |
-
}
|
| 124 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
}
|
| 126 |
|
| 127 |
/**
|
| 128 |
-
*
|
| 129 |
-
* @param {
|
|
|
|
| 130 |
*/
|
| 131 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
try {
|
| 133 |
-
const
|
| 134 |
-
const
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
type: itemData.type,
|
| 142 |
-
author: itemData.author,
|
| 143 |
-
shortDesc: itemData.shortDesc || itemData.short_desc || '',
|
| 144 |
-
coverUrl: itemData.coverUrl || itemData.cover_url || '', // 新增
|
| 145 |
-
coverBase64: itemData.coverBase64,
|
| 146 |
-
imageUrls: itemData.imageUrls || itemData.image_urls || [], // 新增
|
| 147 |
-
link: itemData.link || itemData.github_url || '',
|
| 148 |
-
price: itemData.price || 0,
|
| 149 |
-
likes: itemData.likes || 0, // 新增
|
| 150 |
-
uses: itemData.uses || 0, // 新增
|
| 151 |
-
local_version: itemData.latest_version || itemData.local_version || itemData.version || '',
|
| 152 |
-
acquired_at: Date.now()
|
| 153 |
-
};
|
| 154 |
-
|
| 155 |
-
if (index >= 0) {
|
| 156 |
-
existing[index] = record;
|
| 157 |
-
} else {
|
| 158 |
-
existing.unshift(record); // 新记���放在最前面
|
| 159 |
-
}
|
| 160 |
-
|
| 161 |
-
localStorage.setItem(key, JSON.stringify(existing));
|
| 162 |
-
} catch (e) {
|
| 163 |
-
console.warn("保存已获取资源记录失败:", e);
|
| 164 |
}
|
| 165 |
}
|
| 166 |
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
* @returns {Array}
|
| 170 |
-
*/
|
| 171 |
-
export function getAcquiredItems() {
|
| 172 |
try {
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
| 177 |
}
|
| 178 |
}
|
| 179 |
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
const localVersion = localStorage.getItem(`ComfyCommunity_LocalVer_${itemId}`);
|
| 187 |
-
if (!localVersion) {
|
| 188 |
-
return { installed: false, hasUpdate: false, localVersion: null };
|
| 189 |
}
|
| 190 |
-
|
| 191 |
-
return { installed: true, hasUpdate, localVersion };
|
| 192 |
}
|
| 193 |
|
| 194 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
btnUse.onclick = async (e) => {
|
| 196 |
e.stopPropagation();
|
| 197 |
-
if (!
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
let webOwned = false;
|
| 207 |
-
try {
|
| 208 |
-
const st = await api.getPurchaseStatus(currentUser.account, itemData.id);
|
| 209 |
-
webOwned = !!(st && st.owned);
|
| 210 |
-
} catch (e) {
|
| 211 |
-
console.warn('购买状态查询失败,继续购买流程', e);
|
| 212 |
}
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
let alreadyOwned = false;
|
| 218 |
|
| 219 |
-
|
| 220 |
-
|
| 221 |
try {
|
| 222 |
-
const
|
| 223 |
-
if (
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
| 225 |
}
|
| 226 |
-
|
| 227 |
-
|
|
|
|
|
|
|
| 228 |
}
|
| 229 |
-
|
| 230 |
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
}
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
if (
|
| 244 |
-
if (
|
| 245 |
-
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
}
|
| 248 |
-
|
|
|
|
|
|
|
| 249 |
}
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
// 防线 1:扣费前进行死链探测 (解决问题 1)
|
| 253 |
-
if (!isFree) {
|
| 254 |
inlineStatusBox.style.display = "block";
|
| 255 |
-
inlineStatusBox.
|
| 256 |
-
|
| 257 |
try {
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
})
|
| 270 |
-
|
| 271 |
-
const valData = await valRes.json();
|
| 272 |
-
if (!valRes.ok || valData.error) {
|
| 273 |
-
inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 拦截提示: ${escapeHtml(valData.error || '无法访问源地址')}</span>`;
|
| 274 |
-
return; // 发现死链,强行阻断用户付款
|
| 275 |
-
}
|
| 276 |
-
} catch(e) {
|
| 277 |
-
if (e.name === 'AbortError') {
|
| 278 |
-
console.warn("[资源验证] 超时,跳过验证");
|
| 279 |
} else {
|
| 280 |
-
|
|
|
|
| 281 |
}
|
|
|
|
| 282 |
}
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
return;
|
| 290 |
-
|
|
|
|
|
|
|
| 291 |
}
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
if (!itemData.id) {
|
| 302 |
-
console.warn('⚠️ 资源安装: itemData.id 为空,已拦截无效请求');
|
| 303 |
-
inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 获取失败:资源ID无效</span>`;
|
| 304 |
-
return;
|
| 305 |
-
}
|
| 306 |
-
try {
|
| 307 |
-
purchaseRes = await api.purchaseItem(currentUser.account, itemData.id);
|
| 308 |
-
|
| 309 |
-
// 【需求2修改核心】:如果返回 already_owned,改变提示
|
| 310 |
-
if (purchaseRes && purchaseRes.already_owned) {
|
| 311 |
-
alreadyOwned = true;
|
| 312 |
-
if (!isFree) showToast("您已购买过此商品,此次使用不会收费!", "info");
|
| 313 |
-
} else if (!isFree) {
|
| 314 |
-
showToast(`支付成功!已扣除 ${itemData.price} 积分。`, "success");
|
| 315 |
-
}
|
| 316 |
-
|
| 317 |
-
// 更新用户本地显示的余额缓存
|
| 318 |
-
try {
|
| 319 |
-
const profileRes = await api.getUserProfile(currentUser.account);
|
| 320 |
-
if (profileRes.data && profileRes.data.balance !== undefined) {
|
| 321 |
-
currentUser.balance = profileRes.data.balance;
|
| 322 |
-
}
|
| 323 |
-
} catch(e) {}
|
| 324 |
-
|
| 325 |
-
} catch (err) {
|
| 326 |
-
// 【需求3修改核心】:捕获余额不足异常,阻断流程并引导充值
|
| 327 |
-
if (err.message && err.message.includes("余额不足")) {
|
| 328 |
-
inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 获取失败:积分余额不足。</span>`;
|
| 329 |
-
if (await showConfirm("您的积分余额不足,是否立刻前往个人中心充值?")) {
|
| 330 |
-
// 【修复点】:直接调用函数唤起个人中心,解决白屏 Bug
|
| 331 |
-
openUserProfileModal(currentUser);
|
| 332 |
-
}
|
| 333 |
-
return; // 直接退出函数,不会走到下方的安装流程
|
| 334 |
-
} else {
|
| 335 |
-
inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 获取失败: ${escapeHtml(err.message)}</span>`;
|
| 336 |
-
showToast("获取失败:" + err.message, "error");
|
| 337 |
return;
|
| 338 |
}
|
| 339 |
-
}
|
| 340 |
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
inlineStatusBox.innerHTML = alreadyOwned
|
| 345 |
-
? `<span style="color: #4CAF50;">✅ ${escapeHtml(t('item.owned_purchased'))}</span>`
|
| 346 |
-
: `<span style="color: #4CAF50;">✅ ${escapeHtml(t('web.purchase_success_desktop'))}</span>`;
|
| 347 |
-
showToast(t('web.install_on_desktop'), "info");
|
| 348 |
-
return;
|
| 349 |
-
}
|
| 350 |
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
btnUse.innerHTML = `✅ 已获取`;
|
| 360 |
-
btnUse.style.background = "#4CAF50";
|
| 361 |
-
return; // ← 提前返回,不进入 Git 安装流程
|
| 362 |
-
}
|
| 363 |
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
<div style="display: flex; gap: 10px;">
|
| 372 |
-
<button id="btn-confirm-install" style="padding: 6px 15px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">✔️ 同意并安装</button>
|
| 373 |
-
<button id="btn-cancel-install" style="padding: 6px 15px; background: transparent; color: #aaa; border: 1px solid #555; border-radius: 4px; cursor: pointer;">✖ 取消</button>
|
| 374 |
-
</div>
|
| 375 |
-
`;
|
| 376 |
-
inlineStatusBox.querySelector('#btn-cancel-install').onclick = () => inlineStatusBox.style.display = "none";
|
| 377 |
-
inlineStatusBox.querySelector('#btn-confirm-install').onclick = async () => {
|
| 378 |
-
// 获取最新的 item 数据以确保路由决策准确
|
| 379 |
-
// noCache: true 绕过前端 GET 请求缓存(5min TTL),确保读取服务器最新 has_private_token 值
|
| 380 |
-
let freshHasPrivateToken = !!itemData.has_private_token;
|
| 381 |
-
let apiFetchFailed = false;
|
| 382 |
try {
|
| 383 |
-
const
|
| 384 |
-
|
| 385 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
}
|
| 387 |
-
} catch (e) {
|
| 388 |
-
apiFetchFailed = true;
|
| 389 |
-
console.warn("获取最新 item 数据失败,使用缓存值:", e);
|
| 390 |
}
|
| 391 |
-
const hasPrivateToken = freshHasPrivateToken;
|
| 392 |
-
// 路由决策:仅当确认是免费+无私有Token时才走 git clone
|
| 393 |
-
// 如果 API 获取失败且缓存中无 has_private_token 信息,保守地走 ZIP 代理路径
|
| 394 |
-
const useGitClone = isFree && !hasPrivateToken && !apiFetchFailed;
|
| 395 |
-
const localApiEndpoint = useGitClone
|
| 396 |
-
? "/community_hub/install_tool"
|
| 397 |
-
: "/community_hub/install_private_tool";
|
| 398 |
|
| 399 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
try {
|
| 402 |
-
// 发起 SSE 流式安装请求
|
| 403 |
const result = await requestSSE(
|
| 404 |
-
|
| 405 |
-
{ url: itemData.link, id: itemData.id, account
|
| 406 |
-
(event) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
);
|
| 408 |
|
|
|
|
| 409 |
if (result.status === "success") {
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
}
|
|
|
|
|
|
|
| 418 |
|
| 419 |
// 🚀 安装成功后,盖上本地版本戳
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
localStorage.setItem(`ComfyCommunity_LocalVer_${itemData.id}`, _installedVer);
|
| 425 |
}
|
| 426 |
|
| 427 |
// 记录使用量(后端自动去重)
|
| 428 |
-
|
| 429 |
-
|
| 430 |
clearUsesCache();
|
| 431 |
-
}
|
| 432 |
|
| 433 |
// 🚀 保存到已获取记录
|
| 434 |
saveAcquiredItem(itemData);
|
| 435 |
|
| 436 |
// 🚀 更新按钮状态
|
| 437 |
-
btnUse.innerHTML = `✅
|
| 438 |
-
btnUse.
|
| 439 |
} else {
|
| 440 |
-
|
| 441 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
}
|
| 443 |
} catch(err) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
// 降级到原接口
|
| 445 |
progress.destroy();
|
| 446 |
-
inlineStatusBox.innerHTML = `<span
|
| 447 |
|
| 448 |
try {
|
| 449 |
-
const res = await fetch(
|
| 450 |
-
method: "POST",
|
| 451 |
-
|
|
|
|
| 452 |
});
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
}
|
| 468 |
-
showToast(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
} else {
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
if (
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
showToast(`🎉 插件 [${itemData.title}] 安装成功!请重启 ComfyUI。`, "success");
|
| 478 |
}
|
|
|
|
|
|
|
| 479 |
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
|
|
|
| 483 |
}
|
| 484 |
|
| 485 |
-
|
| 486 |
-
|
| 487 |
clearUsesCache();
|
| 488 |
-
}
|
| 489 |
|
| 490 |
saveAcquiredItem(itemData);
|
| 491 |
|
| 492 |
-
btnUse.innerHTML = `✅
|
| 493 |
-
btnUse.
|
| 494 |
}
|
| 495 |
} catch(err2) {
|
| 496 |
-
|
|
|
|
|
|
|
| 497 |
}
|
| 498 |
}
|
| 499 |
-
}
|
| 500 |
-
|
| 501 |
-
const progress = createInstallProgress(inlineStatusBox);
|
| 502 |
-
|
| 503 |
-
// 附带 account 凭证,解决问题 2 (一键鉴权加载)
|
| 504 |
-
try {
|
| 505 |
-
const result = await requestSSE(
|
| 506 |
-
"/community_hub/download_app_stream",
|
| 507 |
-
{ url: itemData.link, id: itemData.id, account: currentUser.account, token: _getAuthToken() },
|
| 508 |
-
(event) => progress.update(event.progress, event.message),
|
| 509 |
-
{ timeout: 120000 } // 应用JSON下载2分钟足够
|
| 510 |
-
);
|
| 511 |
-
|
| 512 |
-
if (result.status === "success") {
|
| 513 |
-
progress.complete(result.message);
|
| 514 |
-
await _loadGraphToCanvas(result.data);
|
| 515 |
-
showToast(`✅ 工作流 [${itemData.title}] 已成功加载到画布!`, "success");
|
| 516 |
-
|
| 517 |
-
// 🚀 安装成功后,盖上本地版本戳
|
| 518 |
-
if (itemData.latest_version) {
|
| 519 |
-
localStorage.setItem(`ComfyCommunity_LocalVer_${itemData.id}`, itemData.latest_version);
|
| 520 |
-
}
|
| 521 |
-
|
| 522 |
-
// 记录使用量(后端自动去重)
|
| 523 |
-
api.recordItemUse(itemData.id).then(() => {
|
| 524 |
-
clearUsesCache();
|
| 525 |
-
}).catch(err => console.warn('📊 使用量记录失败:', err));
|
| 526 |
|
| 527 |
-
|
| 528 |
-
|
|
|
|
| 529 |
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
} else {
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
const errText = await res.text().catch(() => '未知服务端错误');
|
| 551 |
-
inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 加载失败 (${res.status}):${escapeHtml(errText)}</span>`;
|
| 552 |
-
return;
|
| 553 |
-
}
|
| 554 |
-
let data;
|
| 555 |
-
try {
|
| 556 |
-
data = await res.json();
|
| 557 |
-
} catch (parseErr) {
|
| 558 |
-
inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 响应解析失败,请重试</span>`;
|
| 559 |
-
showToast(t('common.parse_error'), "error");
|
| 560 |
-
return;
|
| 561 |
-
}
|
| 562 |
-
if (data.error) {
|
| 563 |
-
inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 加载失败:${escapeHtml(data.error)}</span>`;
|
| 564 |
-
showToast(`工作流加载失败:${data.error}`, "error");
|
| 565 |
-
} else {
|
| 566 |
-
await _loadGraphToCanvas(data.data);
|
| 567 |
-
inlineStatusBox.innerHTML = `<div style="color: #4CAF50; font-weight: bold;">✅ 工作流已加载到画布!</div><div style="color: #888; margin-top: 4px;">由于本地节点差异可能出现飘红,请使用管理器补全节点。</div>`;
|
| 568 |
-
showToast(`✅ 工作流 [${itemData.title}] 已成功加载到画布!`, "success");
|
| 569 |
-
|
| 570 |
-
if (itemData.latest_version) {
|
| 571 |
-
localStorage.setItem(`ComfyCommunity_LocalVer_${itemData.id}`, itemData.latest_version);
|
| 572 |
-
}
|
| 573 |
-
|
| 574 |
-
api.recordItemUse(itemData.id).then(() => {
|
| 575 |
-
clearUsesCache();
|
| 576 |
-
}).catch(err => console.warn('📊 使用量记录失败:', err));
|
| 577 |
-
|
| 578 |
-
saveAcquiredItem(itemData);
|
| 579 |
-
|
| 580 |
-
btnUse.innerHTML = `✅ 已下载`;
|
| 581 |
-
btnUse.style.background = "#4CAF50";
|
| 582 |
-
}
|
| 583 |
-
} catch(err2) {
|
| 584 |
-
console.error('❌ 应用下载失败:', err2);
|
| 585 |
-
inlineStatusBox.innerHTML = `<span style="color: #F44336;">❌ 无法连接到本地服务:${escapeHtml(err2.message || '网络错误')}</span>`;
|
| 586 |
}
|
| 587 |
}
|
| 588 |
-
}
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
// ☁️ 从购买响应中获取网盘信息(安全:只有购买成功后才返回)
|
| 592 |
-
const isNetdisk = purchaseRes?.is_netdisk || itemData.is_netdisk;
|
| 593 |
-
const netdiskPassword = purchaseRes?.netdisk_password || itemData.netdisk_password;
|
| 594 |
-
|
| 595 |
-
// ☁️ 网盘资源:购买后显示密码
|
| 596 |
-
if (isNetdisk) {
|
| 597 |
-
_renderNetdiskUI(inlineStatusBox, itemData, netdiskPassword);
|
| 598 |
-
} else {
|
| 599 |
-
// 纯链接模式
|
| 600 |
-
inlineStatusBox.innerHTML = `
|
| 601 |
-
<div style="color: #4CAF50; font-weight: bold; margin-bottom: 10px;">✅ 授权通过,该资源需手动访问源地址获取��</div>
|
| 602 |
-
<button id="btn-pure-link-${itemData.id}" style="padding: 6px 16px; background: #2563eb; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 13px;">🔗 前往获取</button>
|
| 603 |
-
`;
|
| 604 |
-
// 绑定按钮点击事件
|
| 605 |
-
const btnOpen = inlineStatusBox.querySelector(`#btn-pure-link-${itemData.id}`);
|
| 606 |
-
if (btnOpen) {
|
| 607 |
-
btnOpen.onclick = () => {
|
| 608 |
-
window.open(itemData.link, '_blank');
|
| 609 |
-
api.recordItemUse(itemData.id).then(() => {
|
| 610 |
-
clearUsesCache();
|
| 611 |
-
window.dispatchEvent(new CustomEvent("comfy-trigger-sidebar-reload", { detail: { force: true } }));
|
| 612 |
-
}).catch(err => console.warn('📊 使用量记录失败:', err));
|
| 613 |
-
};
|
| 614 |
-
}
|
| 615 |
-
}
|
| 616 |
-
}
|
| 617 |
};
|
| 618 |
-
|
|
|
|
|
|
| 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";
|
| 10 |
+
import { saveAcquiredItem } from "./资源获取记录.js";
|
| 11 |
+
export { getAcquiredItems } from "./资源获取记录.js";
|
| 12 |
|
| 13 |
// 📱 Web 模式兼容:ComfyUI 的 app 对象仅存在于本地环境,改为受保护动态导入
|
| 14 |
// (Web 模式不暴露安装/下载入口,不会走到此路径;万一走到也不抛错)
|
| 15 |
+
async function _loadGraphToCanvas(graphData, isCurrent) {
|
| 16 |
+
if (!isCurrent() || !graphData || typeof graphData !== "object" || Array.isArray(graphData) || Object.hasOwn(graphData, "error")) return false;
|
| 17 |
try {
|
| 18 |
const { app } = await import("../../../scripts/app.js");
|
| 19 |
+
if (!isCurrent()) return false;
|
| 20 |
+
await app.loadGraphData(graphData);
|
| 21 |
return true;
|
| 22 |
} catch (e) {
|
| 23 |
+
console.warn("当前环境无法加载工作流到画布");
|
| 24 |
return false;
|
| 25 |
}
|
| 26 |
}
|
|
|
|
| 53 |
* @returns {string} JWT Token(未登录时为空字符串)
|
| 54 |
*/
|
| 55 |
function _getAuthToken() {
|
| 56 |
+
try {
|
| 57 |
+
const localToken = localStorage.getItem("ComfyCommunity_Token");
|
| 58 |
+
if (localToken) return localToken;
|
| 59 |
+
} catch (error) {
|
| 60 |
+
console.warn('无法读取本地登录凭证存储');
|
| 61 |
+
}
|
| 62 |
+
try {
|
| 63 |
+
return sessionStorage.getItem("ComfyCommunity_Token") || "";
|
| 64 |
+
} catch (error) {
|
| 65 |
+
console.warn('无法读取会话登录凭证存储');
|
| 66 |
+
return "";
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
function _getInstallErrorMessage(result) {
|
| 71 |
+
if (result?.outcomeUnknown) return t('install.connection_uncertain');
|
| 72 |
+
const errorKeys = {
|
| 73 |
+
missing_params: 'install.error_missing_params',
|
| 74 |
+
invalid_repository: 'install.error_invalid_repository',
|
| 75 |
+
invalid_target: 'install.error_invalid_target',
|
| 76 |
+
git_missing: 'install.error_git_missing',
|
| 77 |
+
install_timeout: 'install.error_timeout',
|
| 78 |
+
install_queue_timeout: 'install.error_queue_timeout',
|
| 79 |
+
install_queue_full: 'install.error_queue_full',
|
| 80 |
+
git_clone_failed: 'install.error_git_clone',
|
| 81 |
+
insufficient_disk_space: 'install.error_disk_space',
|
| 82 |
+
remote_rejected: 'install.error_remote_rejected',
|
| 83 |
+
package_too_large: 'install.error_package_too_large',
|
| 84 |
+
repository_too_large: 'install.error_repository_too_large',
|
| 85 |
+
process_guard_unavailable: 'install.error_process_guard',
|
| 86 |
+
download_failed: 'install.error_download',
|
| 87 |
+
target_occupied: 'install.error_target_occupied',
|
| 88 |
+
invalid_archive: 'install.error_invalid_archive',
|
| 89 |
+
install_failed: 'install.error_generic'
|
| 90 |
+
};
|
| 91 |
+
const errorKey = Object.hasOwn(errorKeys, result?.error_code) ? errorKeys[result.error_code] : "";
|
| 92 |
+
return errorKey ? t(errorKey) : t('install.error_generic');
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
const INSTALL_STAGE_KEYS = {
|
| 96 |
+
queued: 'install.stage_queued',
|
| 97 |
+
validate: 'install.stage_validate',
|
| 98 |
+
auth: 'install.stage_auth',
|
| 99 |
+
cleanup: 'install.stage_cleanup',
|
| 100 |
+
git_mirror: 'install.stage_git_mirror',
|
| 101 |
+
git_cloning: 'install.stage_git_clone',
|
| 102 |
+
git_fallback: 'install.stage_git_fallback',
|
| 103 |
+
git_direct: 'install.stage_git_direct',
|
| 104 |
+
downloading: 'install.stage_downloading',
|
| 105 |
+
download_done: 'install.stage_download_done',
|
| 106 |
+
extracting: 'install.stage_extracting',
|
| 107 |
+
installing: 'install.stage_installing',
|
| 108 |
+
complete: 'install.stage_complete',
|
| 109 |
+
error: 'install.stage_failed'
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
const WORKFLOW_STAGE_KEYS = {
|
| 113 |
+
validate: 'workflow.stage_validate',
|
| 114 |
+
cache_check: 'workflow.stage_cache_check',
|
| 115 |
+
cache_hit: 'workflow.stage_cache_hit',
|
| 116 |
+
downloading: 'workflow.stage_downloading',
|
| 117 |
+
saving: 'workflow.stage_saving',
|
| 118 |
+
complete: 'workflow.stage_download_complete',
|
| 119 |
+
error: 'workflow.stage_failed'
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
function _getWorkflowErrorMessage(result) {
|
| 123 |
+
if (result?.outcomeUnknown) return t('workflow.connection_uncertain');
|
| 124 |
+
const errorKeys = {
|
| 125 |
+
invalid_request: 'workflow.error_invalid_request',
|
| 126 |
+
missing_params: 'workflow.error_missing_params',
|
| 127 |
+
invalid_app_id: 'workflow.error_invalid_id',
|
| 128 |
+
invalid_workflow_data: 'workflow.error_invalid_data',
|
| 129 |
+
auth_failed: 'workflow.error_auth',
|
| 130 |
+
remote_rejected: 'workflow.error_remote',
|
| 131 |
+
network_failed: 'workflow.error_network',
|
| 132 |
+
cloud_cooldown: 'workflow.error_cloud_cooldown',
|
| 133 |
+
download_failed: 'workflow.error_generic'
|
| 134 |
+
};
|
| 135 |
+
const errorKey = Object.hasOwn(errorKeys, result?.error_code) ? errorKeys[result.error_code] : "";
|
| 136 |
+
return errorKey ? t(errorKey) : t('workflow.error_generic');
|
| 137 |
}
|
| 138 |
|
| 139 |
/**
|
|
|
|
| 142 |
* @param {Object} itemData - 资源数据
|
| 143 |
* @param {string} netdiskPassword - 网盘密码(为空则显示无密码版本)
|
| 144 |
*/
|
| 145 |
+
function _getSafeExternalUrl(link) {
|
| 146 |
+
let parsedUrl;
|
| 147 |
+
try {
|
| 148 |
+
if (typeof link !== 'string' || !link.trim()) throw new Error('Missing resource link');
|
| 149 |
+
parsedUrl = new URL(link.trim());
|
| 150 |
+
} catch (_) {
|
| 151 |
+
showToast(t('netdisk.invalid_link'), 'error');
|
| 152 |
+
return null;
|
| 153 |
+
}
|
| 154 |
+
if (!['http:', 'https:'].includes(parsedUrl.protocol) || parsedUrl.username || parsedUrl.password) {
|
| 155 |
+
showToast(t('netdisk.invalid_link'), 'error');
|
| 156 |
+
return null;
|
| 157 |
+
}
|
| 158 |
+
return parsedUrl.href;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
function _openExternalResource(link, itemId, isCurrent) {
|
| 162 |
+
if (!isCurrent()) return;
|
| 163 |
+
const safeUrl = _getSafeExternalUrl(link);
|
| 164 |
+
if (!safeUrl) return;
|
| 165 |
+
window.open(safeUrl, '_blank', 'noopener,noreferrer');
|
| 166 |
+
api.recordItemUse(itemId).then(() => {
|
| 167 |
+
if (!isCurrent()) return;
|
| 168 |
+
clearUsesCache();
|
| 169 |
+
window.dispatchEvent(new CustomEvent('comfy-trigger-sidebar-reload', { detail: { force: true } }));
|
| 170 |
+
}).catch(() => {});
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
function _renderNetdiskUI(inlineStatusBox, itemData, netdiskPassword, isCurrent) {
|
| 174 |
+
if (!isCurrent()) return;
|
| 175 |
+
inlineStatusBox.replaceChildren();
|
| 176 |
inlineStatusBox.style.display = 'block';
|
| 177 |
|
| 178 |
+
const container = document.createElement('div');
|
| 179 |
+
container.className = 'netdisk-card';
|
| 180 |
+
const summary = document.createElement('div');
|
| 181 |
+
summary.className = 'netdisk-card__summary';
|
| 182 |
+
summary.textContent = t(netdiskPassword ? 'netdisk.authorized_with_password' : 'netdisk.authorized_no_password');
|
| 183 |
+
container.appendChild(summary);
|
| 184 |
+
|
| 185 |
+
const panel = document.createElement('div');
|
| 186 |
+
panel.className = 'netdisk-card__panel';
|
| 187 |
+
const openButton = document.createElement('button');
|
| 188 |
+
openButton.type = 'button';
|
| 189 |
+
openButton.className = 'netdisk-card__open';
|
| 190 |
+
openButton.textContent = t('netdisk.open');
|
| 191 |
+
openButton.addEventListener('click', () => _openExternalResource(itemData.link, itemData.id, isCurrent));
|
| 192 |
+
|
| 193 |
+
let hint = null;
|
| 194 |
if (netdiskPassword) {
|
| 195 |
+
const row = document.createElement('div');
|
| 196 |
+
row.className = 'netdisk-card__row';
|
| 197 |
+
const codeLabel = document.createElement('span');
|
| 198 |
+
codeLabel.className = 'netdisk-card__label';
|
| 199 |
+
codeLabel.textContent = t('netdisk.code_label');
|
| 200 |
+
const code = document.createElement('code');
|
| 201 |
+
code.className = 'netdisk-card__code';
|
| 202 |
+
code.textContent = netdiskPassword;
|
| 203 |
+
const copyButton = document.createElement('button');
|
| 204 |
+
copyButton.type = 'button';
|
| 205 |
+
copyButton.className = 'netdisk-card__copy';
|
| 206 |
+
copyButton.textContent = t('netdisk.copy');
|
| 207 |
+
copyButton.addEventListener('click', async () => {
|
| 208 |
+
if (!isCurrent()) return;
|
| 209 |
+
try {
|
| 210 |
+
await navigator.clipboard.writeText(netdiskPassword);
|
| 211 |
+
if (isCurrent()) copyButton.textContent = t('common.copied');
|
| 212 |
+
} catch (error) {
|
| 213 |
+
if (isCurrent()) showToast(t('netdisk.copy_failed'), 'error');
|
| 214 |
+
}
|
| 215 |
+
});
|
| 216 |
+
row.append(codeLabel, code, copyButton);
|
| 217 |
+
panel.append(row);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
|
| 219 |
+
hint = document.createElement('div');
|
| 220 |
+
hint.className = 'netdisk-card__hint';
|
| 221 |
+
hint.textContent = t('netdisk.download_hint');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
}
|
| 223 |
+
|
| 224 |
+
panel.prepend(openButton);
|
| 225 |
+
container.appendChild(panel);
|
| 226 |
+
if (hint) container.appendChild(hint);
|
| 227 |
+
inlineStatusBox.appendChild(container);
|
| 228 |
}
|
| 229 |
|
| 230 |
/**
|
| 231 |
+
* 检查资源是否已安装
|
| 232 |
+
* @param {string} itemId - 资源ID
|
| 233 |
+
* @returns {{installed: boolean, hasUpdate: boolean, localVersion: string|null, storageAvailable: boolean}}
|
| 234 |
*/
|
| 235 |
+
export function checkItemStatus(itemId, cloudVersion) {
|
| 236 |
+
const versionStatus = getInstalledVersionStatus(itemId);
|
| 237 |
+
const localVersion = versionStatus.version;
|
| 238 |
+
if (!localVersion) {
|
| 239 |
+
return { installed: versionStatus.installed === true, hasUpdate: false, localVersion: null, storageAvailable: versionStatus.available };
|
| 240 |
+
}
|
| 241 |
+
const hasUpdate = cloudVersion && localVersion !== cloudVersion;
|
| 242 |
+
return { installed: true, hasUpdate, localVersion, storageAvailable: versionStatus.available };
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
const failedInstalledVersionWrites = new Map();
|
| 246 |
+
|
| 247 |
+
export function getInstalledVersionStatus(itemId) {
|
| 248 |
try {
|
| 249 |
+
const failedWrite = failedInstalledVersionWrites.has(itemId);
|
| 250 |
+
const recorded = failedWrite ? failedInstalledVersionWrites.get(itemId)
|
| 251 |
+
: localStorage.getItem(CACHE.LOCAL_KEYS.INSTALLED_VERSION_PREFIX + itemId);
|
| 252 |
+
const unconfirmed = recorded === CACHE.INSTALLED_VERSION_UNCONFIRMED;
|
| 253 |
+
return { version: unconfirmed ? null : recorded, installed: !!recorded, versionConfirmed: !!recorded && !unconfirmed, available: !failedWrite };
|
| 254 |
+
} catch (error) {
|
| 255 |
+
console.warn('无法读取本地安装版本标记');
|
| 256 |
+
return { version: null, installed: false, versionConfirmed: false, available: false };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
}
|
| 258 |
}
|
| 259 |
|
| 260 |
+
export function saveInstalledVersion(itemId, version) {
|
| 261 |
+
if (!version) return true;
|
|
|
|
|
|
|
|
|
|
| 262 |
try {
|
| 263 |
+
localStorage.setItem(CACHE.LOCAL_KEYS.INSTALLED_VERSION_PREFIX + itemId, version);
|
| 264 |
+
failedInstalledVersionWrites.delete(itemId);
|
| 265 |
+
return true;
|
| 266 |
+
} catch (error) {
|
| 267 |
+
failedInstalledVersionWrites.set(itemId, version);
|
| 268 |
+
console.warn('无法保存本地安装版本标记');
|
| 269 |
+
return false;
|
| 270 |
}
|
| 271 |
}
|
| 272 |
|
| 273 |
+
function _recordToolInstalledVersion(itemId, result) {
|
| 274 |
+
const value = result?.installed_version;
|
| 275 |
+
const confirmed = typeof value === "string" && /^(?:[a-f0-9]{7}|[a-f0-9]{40})$/i.test(value.trim());
|
| 276 |
+
const version = confirmed ? value.trim().slice(0, 7).toLowerCase() : CACHE.INSTALLED_VERSION_UNCONFIRMED;
|
| 277 |
+
if (!saveInstalledVersion(itemId, version)) {
|
| 278 |
+
showToast(t('install.version_record_unavailable'), 'warning');
|
|
|
|
|
|
|
|
|
|
| 279 |
}
|
| 280 |
+
if (!confirmed) showToast(t('install.version_unconfirmed'), 'warning');
|
|
|
|
| 281 |
}
|
| 282 |
|
| 283 |
+
function _guessInstallTargetName(itemUrl) {
|
| 284 |
+
try {
|
| 285 |
+
const lastSegment = String(itemUrl).replace(/\/+$/, '').split('/').pop() || '';
|
| 286 |
+
const name = lastSegment.replace(/\.git$/i, '');
|
| 287 |
+
return /^[A-Za-z0-9_.-]+$/.test(name) && name !== '.' && name !== '..' && !name.includes('..')
|
| 288 |
+
? name
|
| 289 |
+
: null;
|
| 290 |
+
} catch (error) {
|
| 291 |
+
return null;
|
| 292 |
+
}
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
const activeOperations = new Map();
|
| 296 |
+
let latestWalletRefresh = 0;
|
| 297 |
+
|
| 298 |
+
export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatusBox, options = {}) {
|
| 299 |
+
btnUse.type = "button";
|
| 300 |
+
btnUse.classList.add("resource-install__button");
|
| 301 |
+
for (const property of ["background", "color", "border", "border-color"]) btnUse.style.removeProperty(property);
|
| 302 |
+
inlineStatusBox.classList.add("resource-install__status");
|
| 303 |
+
const account = currentUser?.account, token = _getAuthToken();
|
| 304 |
+
const scope = createResourceOperationScope(btnUse, inlineStatusBox, account, options);
|
| 305 |
+
const isCurrent = () => scope.isCurrent();
|
| 306 |
+
const confirm = (message, options = {}) => showConfirm(message, { ...options, signal: scope.confirmationSignal });
|
| 307 |
+
let phase = "idle", previousProgress, removeProgressCleanup = () => {};
|
| 308 |
+
const operationKey = JSON.stringify([account, String(itemData.id)]);
|
| 309 |
+
scope.addCleanup(() => { if (activeOperations.get(operationKey) === scope) activeOperations.delete(operationKey); });
|
| 310 |
+
const trackProgress = () => {
|
| 311 |
+
removeProgressCleanup(); previousProgress?.destroy();
|
| 312 |
+
previousProgress = createInstallProgress(inlineStatusBox);
|
| 313 |
+
const progress = previousProgress;
|
| 314 |
+
removeProgressCleanup = scope.addCleanup(() => progress.destroy());
|
| 315 |
+
return progress;
|
| 316 |
+
};
|
| 317 |
+
const releaseButton = () => {
|
| 318 |
+
if (phase !== "idle") return;
|
| 319 |
+
if (activeOperations.get(operationKey) === scope) activeOperations.delete(operationKey);
|
| 320 |
+
if (isCurrent()) { btnUse.disabled = false; btnUse.removeAttribute("aria-busy"); }
|
| 321 |
+
};
|
| 322 |
btnUse.onclick = async (e) => {
|
| 323 |
e.stopPropagation();
|
| 324 |
+
if (!isCurrent() || phase !== "idle" || activeOperations.has(operationKey)) return;
|
| 325 |
+
if (!account) return showToast(t('auth.login_required'), "warning");
|
| 326 |
+
activeOperations.set(operationKey, scope);
|
| 327 |
+
++latestWalletRefresh; // 新操作使先前余额补新失效。
|
| 328 |
+
phase = "preflight"; btnUse.disabled = true; btnUse.setAttribute("aria-busy", "true");
|
| 329 |
+
try {
|
| 330 |
+
const price = itemData.price ?? 0;
|
| 331 |
+
if (!Number.isSafeInteger(price) || price < 0 || !itemData.id) {
|
| 332 |
+
showToast(t('acquire.invalid_id'), "error"); return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
}
|
| 334 |
+
const isFree = price === 0 || account === itemData.author;
|
| 335 |
+
const isTool = itemData.type === 'tool' || itemData.type === 'recommend_tool' || itemData.type === 'skill' || itemData.type === 'recommend_skill';
|
| 336 |
+
const isApp = itemData.type === 'app' || itemData.type === 'recommend_app';
|
| 337 |
+
if (IS_WEB_MODE && isFree) return showToast(t('web.install_on_desktop'), "warning");
|
|
|
|
| 338 |
|
| 339 |
+
// 同一次新读取决定是否拥有;查询失败不能继续到付款。
|
| 340 |
+
let alreadyOwned = false;
|
| 341 |
try {
|
| 342 |
+
const status = await api.getPurchaseStatus(account, itemData.id, scope.readOptions);
|
| 343 |
+
if (!isCurrent()) return;
|
| 344 |
+
if (!isValidPurchaseStatus(status)) throw new Error();
|
| 345 |
+
if (status.price_confirmation_supported !== true) {
|
| 346 |
+
showToast(t('acquire.purchase_verification_unavailable'), "warning");
|
| 347 |
+
return;
|
| 348 |
}
|
| 349 |
+
alreadyOwned = status.owned;
|
| 350 |
+
} catch (_) {
|
| 351 |
+
if (isCurrent()) showToast(t('common.network_error_retry'), "error");
|
| 352 |
+
return;
|
| 353 |
}
|
| 354 |
+
if (IS_WEB_MODE && alreadyOwned) return showToast(t('item.owned_purchased'), "warning");
|
| 355 |
|
| 356 |
+
if (!isFree && !alreadyOwned) {
|
| 357 |
+
let balance;
|
| 358 |
+
try {
|
| 359 |
+
const wallet = await api.getWallet(account, scope.readOptions);
|
| 360 |
+
if (!isCurrent()) return;
|
| 361 |
+
if (wallet?.status !== "success" || !Number.isSafeInteger(wallet.balance) || wallet.balance < 0) throw new Error();
|
| 362 |
+
balance = wallet.balance;
|
| 363 |
+
} catch (_) {
|
| 364 |
+
if (isCurrent()) showToast(t('common.network_error_retry'), "error");
|
| 365 |
+
return;
|
| 366 |
}
|
| 367 |
+
if (balance < price) {
|
| 368 |
+
const recharge = await confirm(`${t('acquire.balance_short', { balance, price })} ${t('acquire.balance_recharge_prompt')}`);
|
| 369 |
+
if (recharge && isCurrent()) await openUserProfileModal(currentUser);
|
| 370 |
+
return;
|
| 371 |
+
}
|
| 372 |
+
if (itemData.is_netdisk === true) {
|
| 373 |
+
if (!_getSafeExternalUrl(itemData.link)) return;
|
| 374 |
+
} else {
|
| 375 |
+
inlineStatusBox.style.display = "block";
|
| 376 |
+
inlineStatusBox.textContent = t('acquire.validation_running');
|
| 377 |
+
const validationController = new AbortController();
|
| 378 |
+
const removeValidationCleanup = scope.addCleanup(() => validationController.abort());
|
| 379 |
+
const timeout = setTimeout(() => validationController.abort(), API.BACKGROUND_TIMEOUT);
|
| 380 |
+
try {
|
| 381 |
+
const response = await fetch(`${API.BASE_URL}/api/validate_resource`, {
|
| 382 |
+
method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
|
| 383 |
+
body: JSON.stringify({ url: itemData.link, item_id: itemData.id, account }),
|
| 384 |
+
signal: validationController.signal
|
| 385 |
+
});
|
| 386 |
+
if (!isCurrent()) return;
|
| 387 |
+
const data = await response.json();
|
| 388 |
+
if (!isCurrent()) return;
|
| 389 |
+
if (!response.ok || data?.status !== "success" || data.error) throw new Error();
|
| 390 |
+
} catch (_) {
|
| 391 |
+
if (isCurrent()) inlineStatusBox.textContent = t('acquire.validation_blocked', { message: t('common.network_error_retry') });
|
| 392 |
+
return;
|
| 393 |
+
} finally { clearTimeout(timeout); removeValidationCleanup(); }
|
| 394 |
}
|
| 395 |
+
const confirmPay = await confirm(t('acquire.purchase_confirmation_html', { price }), { html: true });
|
| 396 |
+
if (!isCurrent()) return;
|
| 397 |
+
if (!confirmPay) { inlineStatusBox.style.display = "none"; return; }
|
| 398 |
}
|
| 399 |
+
if (!isCurrent()) return;
|
|
|
|
|
|
|
|
|
|
| 400 |
inlineStatusBox.style.display = "block";
|
| 401 |
+
inlineStatusBox.textContent = t('acquire.purchase_running');
|
| 402 |
+
let purchaseRes;
|
| 403 |
try {
|
| 404 |
+
purchaseRes = await api.purchaseItem(account, itemData.id, isFree ? 0 : price);
|
| 405 |
+
if (!isCurrent()) return;
|
| 406 |
+
if (purchaseRes?.status !== "success" || typeof purchaseRes.already_owned !== "boolean"
|
| 407 |
+
|| typeof purchaseRes.is_netdisk !== "boolean"
|
| 408 |
+
|| (purchaseRes.netdisk_password != null && typeof purchaseRes.netdisk_password !== "string")) throw new Error();
|
| 409 |
+
} catch (error) {
|
| 410 |
+
if (!isCurrent()) return;
|
| 411 |
+
inlineStatusBox.setAttribute("role", "alert");
|
| 412 |
+
if (["purchase_price_changed", "purchase_price_confirmation_required"].includes(error?.code)) {
|
| 413 |
+
phase = "unknown"; btnUse.removeAttribute("aria-busy");
|
| 414 |
+
inlineStatusBox.textContent = t('acquire.price_changed');
|
| 415 |
+
} else if (error?.status >= 400 && error.status < 500) {
|
| 416 |
+
inlineStatusBox.textContent = t(error.status === 402 ? 'acquire.purchase_balance_short' : 'common.error');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
} else {
|
| 418 |
+
phase = "unknown"; btnUse.removeAttribute("aria-busy");
|
| 419 |
+
inlineStatusBox.textContent = t('acquire.purchase_unknown');
|
| 420 |
}
|
| 421 |
+
return;
|
| 422 |
}
|
| 423 |
+
alreadyOwned = purchaseRes.already_owned;
|
| 424 |
+
if (!isFree) showToast(t(alreadyOwned ? 'acquire.already_owned_no_charge' : 'acquire.purchase_success', { price }), alreadyOwned ? "info" : "success");
|
| 425 |
+
// 权威余额来自 SQL 钱包;补新不阻塞已经确认的资源交付。
|
| 426 |
+
if (!isFree) {
|
| 427 |
+
const walletVersion = ++latestWalletRefresh;
|
| 428 |
+
api.getWallet(account, scope.readOptions).then(wallet => {
|
| 429 |
+
if (walletVersion !== latestWalletRefresh || !isCurrent() || wallet?.status !== "success" || !Number.isSafeInteger(wallet.balance) || wallet.balance < 0) return;
|
| 430 |
+
currentUser.balance = wallet.balance;
|
| 431 |
+
window.dispatchEvent(new CustomEvent("comfy-wallet-update", { detail: { account, balance: wallet.balance } }));
|
| 432 |
+
}).catch(() => {});
|
| 433 |
}
|
| 434 |
+
if (!isCurrent()) return;
|
| 435 |
+
|
| 436 |
+
// 📱 Web 模式:购买完成即止,不进入任何下载/安装/网盘交付分支
|
| 437 |
+
if (IS_WEB_MODE) {
|
| 438 |
+
inlineStatusBox.style.display = "block";
|
| 439 |
+
inlineStatusBox.innerHTML = alreadyOwned
|
| 440 |
+
? `<span class="resource-install__message resource-install__message--success">✅ ${escapeHtml(t('item.owned_purchased'))}</span>`
|
| 441 |
+
: `<span class="resource-install__message resource-install__message--success">✅ ${escapeHtml(t('web.purchase_success_desktop'))}</span>`;
|
| 442 |
+
showToast(t('web.install_on_desktop'), "info");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 443 |
return;
|
| 444 |
}
|
|
|
|
| 445 |
|
| 446 |
+
// ☁️ 优先处理网盘资源(不论是工具还是应用)
|
| 447 |
+
const isNetdisk = purchaseRes.is_netdisk;
|
| 448 |
+
const netdiskPassword = purchaseRes.netdisk_password;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
|
| 450 |
+
if (isNetdisk) {
|
| 451 |
+
// 网盘资源:显示链接和密码(recordItemUse 延迟到用户点击按钮后)
|
| 452 |
+
saveAcquiredItem(itemData);
|
| 453 |
+
_renderNetdiskUI(inlineStatusBox, itemData, netdiskPassword, isCurrent);
|
| 454 |
+
btnUse.innerHTML = `✅ ${escapeHtml(t('common.acquired'))}`;
|
| 455 |
+
btnUse.setAttribute("data-install-state", "success");
|
| 456 |
+
return; // ← 提前返回,不进入 Git 安装流程
|
| 457 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
|
| 459 |
+
if (isTool) {
|
| 460 |
+
inlineStatusBox.style.display = "block";
|
| 461 |
+
inlineStatusBox.textContent = t('install.preview_loading');
|
| 462 |
+
let installPreview = null;
|
| 463 |
+
const previewController = new AbortController();
|
| 464 |
+
const removePreviewCleanup = scope.addCleanup(() => previewController.abort());
|
| 465 |
+
const previewTimeout = setTimeout(() => previewController.abort(), API.BACKGROUND_TIMEOUT);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
try {
|
| 467 |
+
const previewResponse = await fetch("/community_hub/install_preview", {
|
| 468 |
+
method: "POST",
|
| 469 |
+
headers: { "Content-Type": "application/json" },
|
| 470 |
+
body: JSON.stringify({ url: itemData.link }), signal: previewController.signal
|
| 471 |
+
});
|
| 472 |
+
if (!isCurrent()) return;
|
| 473 |
+
if (previewResponse.ok) {
|
| 474 |
+
const previewData = await previewResponse.json();
|
| 475 |
+
if (!isCurrent()) return;
|
| 476 |
+
if (previewData.status === "success") installPreview = previewData;
|
| 477 |
+
}
|
| 478 |
+
} catch (error) {
|
| 479 |
+
if (!isCurrent()) return;
|
| 480 |
+
} finally { clearTimeout(previewTimeout); removePreviewCleanup(); }
|
| 481 |
+
if (!isCurrent()) return;
|
| 482 |
+
const guessedTargetName = _guessInstallTargetName(itemData.link);
|
| 483 |
+
const targetDisplay = installPreview?.target_display
|
| 484 |
+
|| (guessedTargetName ? `custom_nodes/${guessedTargetName}` : null);
|
| 485 |
+
const installPromptText = t(
|
| 486 |
+
isFree ? 'install.confirm_git' : 'install.confirm_zip',
|
| 487 |
+
{ folder: targetDisplay || t('install.target_unknown') }
|
| 488 |
+
);
|
| 489 |
+
const targetStateText = installPreview
|
| 490 |
+
? (installPreview.self_update
|
| 491 |
+
? t('install.target_self_update')
|
| 492 |
+
: (installPreview.already_exists
|
| 493 |
+
? t('install.target_exists')
|
| 494 |
+
: t('install.target_new')))
|
| 495 |
+
: t('install.preview_unavailable');
|
| 496 |
+
let localVersionText = '';
|
| 497 |
+
if (installPreview?.already_exists) {
|
| 498 |
+
if (installPreview.current_git_version) {
|
| 499 |
+
localVersionText = t('install.current_git_version', {
|
| 500 |
+
version: installPreview.current_git_version
|
| 501 |
+
});
|
| 502 |
+
} else if (!isFree) {
|
| 503 |
+
try {
|
| 504 |
+
const recordedVersion = getInstalledVersionStatus(itemData.id).version;
|
| 505 |
+
if (recordedVersion) {
|
| 506 |
+
localVersionText = t('install.recorded_local_version', { version: recordedVersion });
|
| 507 |
+
}
|
| 508 |
+
} catch (error) {
|
| 509 |
+
console.warn('无法读取本地已安装版本记录');
|
| 510 |
+
}
|
| 511 |
}
|
|
|
|
|
|
|
|
|
|
| 512 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 513 |
|
| 514 |
+
phase = "confirm";
|
| 515 |
+
inlineStatusBox.innerHTML = `
|
| 516 |
+
<div class="resource-install__confirmation" role="group" aria-label="${escapeHtml(t('install.confirmation_region'))}">
|
| 517 |
+
<div role="heading" aria-level="3" class="resource-install__heading">${escapeHtml(installPromptText)}</div>
|
| 518 |
+
<div role="status" aria-live="polite" class="resource-install__preview">${escapeHtml(targetStateText)}</div>
|
| 519 |
+
${localVersionText ? `<div role="status" aria-live="polite" class="resource-install__preview">${escapeHtml(localVersionText)}</div>` : ''}
|
| 520 |
+
<div class="resource-install__actions">
|
| 521 |
+
<button id="btn-confirm-install" type="button" class="resource-install__button resource-install__button--confirm">${escapeHtml(t('install.confirm_button'))}</button>
|
| 522 |
+
<button id="btn-cancel-install" type="button" class="resource-install__button">${escapeHtml(t('common.cancel'))}</button>
|
| 523 |
+
</div>
|
| 524 |
+
</div>
|
| 525 |
+
`;
|
| 526 |
+
const confirmInstallButton = inlineStatusBox.querySelector('#btn-confirm-install');
|
| 527 |
+
const cancelInstallButton = inlineStatusBox.querySelector('#btn-cancel-install');
|
| 528 |
+
let removeEscapeCleanup = () => {};
|
| 529 |
+
const removeInstallEscapeHandler = () => {
|
| 530 |
+
inlineStatusBox.removeEventListener("keydown", handleInstallEscape); removeEscapeCleanup();
|
| 531 |
+
};
|
| 532 |
+
const cancelInstallPrompt = () => {
|
| 533 |
+
if (!isCurrent() || phase !== "confirm") return;
|
| 534 |
+
phase = "idle"; releaseButton();
|
| 535 |
+
removeInstallEscapeHandler();
|
| 536 |
+
inlineStatusBox.style.display = "none";
|
| 537 |
+
btnUse.focus();
|
| 538 |
+
};
|
| 539 |
+
const handleInstallEscape = (event) => {
|
| 540 |
+
if (event.key === "Escape") {
|
| 541 |
+
event.preventDefault();
|
| 542 |
+
cancelInstallPrompt();
|
| 543 |
+
}
|
| 544 |
+
};
|
| 545 |
+
cancelInstallButton.onclick = cancelInstallPrompt;
|
| 546 |
+
inlineStatusBox.addEventListener("keydown", handleInstallEscape);
|
| 547 |
+
removeEscapeCleanup = scope.addCleanup(removeInstallEscapeHandler);
|
| 548 |
+
confirmInstallButton.focus();
|
| 549 |
+
confirmInstallButton.onclick = async () => {
|
| 550 |
+
if (!isCurrent() || phase !== "confirm") return;
|
| 551 |
+
phase = "install";
|
| 552 |
+
try {
|
| 553 |
+
removeInstallEscapeHandler();
|
| 554 |
+
confirmInstallButton.disabled = true;
|
| 555 |
+
cancelInstallButton.disabled = true;
|
| 556 |
+
// 获取最新的 item 数据以确保路由决策准确
|
| 557 |
+
// noCache: true 绕过前端 GET 请求缓存(5min TTL),确保读取服务器最新 has_private_token 值
|
| 558 |
+
let freshHasPrivateToken = !!itemData.has_private_token;
|
| 559 |
+
let apiFetchFailed = false;
|
| 560 |
+
try {
|
| 561 |
+
const freshRes = await request(`/api/items/${itemData.id}`, scope.readOptions);
|
| 562 |
+
if (!isCurrent()) return;
|
| 563 |
+
if (freshRes.status === "success" && freshRes.data) {
|
| 564 |
+
freshHasPrivateToken = !!freshRes.data.has_private_token;
|
| 565 |
+
}
|
| 566 |
+
} catch (e) {
|
| 567 |
+
apiFetchFailed = true;
|
| 568 |
+
if (!isCurrent()) return;
|
| 569 |
+
}
|
| 570 |
+
if (!isCurrent()) return;
|
| 571 |
+
const hasPrivateToken = freshHasPrivateToken;
|
| 572 |
+
// 路由决策:仅当确认是免费+无私有Token时才走 git clone
|
| 573 |
+
// 如果 API 获取失败且缓存中无 has_private_token 信息,保守地走 ZIP 代理路径
|
| 574 |
+
const useGitClone = isFree && !hasPrivateToken && !apiFetchFailed;
|
| 575 |
+
const localApiEndpoint = useGitClone
|
| 576 |
+
? "/community_hub/install_tool"
|
| 577 |
+
: "/community_hub/install_private_tool";
|
| 578 |
+
|
| 579 |
+
const progress = trackProgress();
|
| 580 |
+
const cancelController = new AbortController();
|
| 581 |
+
progress.addCancelButton(t('install.cancel_in_progress'), () => cancelController.abort());
|
| 582 |
+
const diagnosticEvents = [];
|
| 583 |
+
const installStartedAt = Date.now();
|
| 584 |
|
| 585 |
+
try {
|
| 586 |
+
// 发起 SSE 流式安装请求
|
| 587 |
+
const result = await requestSSE(
|
| 588 |
+
`${localApiEndpoint}_stream`,
|
| 589 |
+
{ url: itemData.link, id: itemData.id, account, token },
|
| 590 |
+
(event) => {
|
| 591 |
+
if (!isCurrent()) return;
|
| 592 |
+
const elapsedSeconds = Math.floor((Date.now() - installStartedAt) / 1000);
|
| 593 |
+
const rawStage = typeof event.stage === "string" ? event.stage : "";
|
| 594 |
+
diagnosticEvents.push({
|
| 595 |
+
stageKey: Object.hasOwn(INSTALL_STAGE_KEYS, rawStage) ? INSTALL_STAGE_KEYS[rawStage] : "",
|
| 596 |
+
stage: /^[a-z0-9_]{1,40}$/i.test(rawStage) ? rawStage : "",
|
| 597 |
+
progress: Number.isFinite(event.progress) ? Math.max(0, Math.min(100, event.progress)) : null,
|
| 598 |
+
time: `${Math.floor(elapsedSeconds / 60)}:${String(elapsedSeconds % 60).padStart(2, "0")}`
|
| 599 |
+
});
|
| 600 |
+
if (diagnosticEvents.length > 50) diagnosticEvents.shift();
|
| 601 |
+
const stageKey = Object.hasOwn(INSTALL_STAGE_KEYS, rawStage) ? INSTALL_STAGE_KEYS[rawStage] : "";
|
| 602 |
+
progress.update(event.progress, event.status === "error"
|
| 603 |
+
? _getInstallErrorMessage(event)
|
| 604 |
+
: t(stageKey || 'install.stage_installing'));
|
| 605 |
+
},
|
| 606 |
+
{ signal: cancelController.signal }
|
| 607 |
+
);
|
| 608 |
+
|
| 609 |
+
if (!isCurrent()) return;
|
| 610 |
+
if (result.cancelRequested) {
|
| 611 |
+
phase = "unknown"; btnUse.removeAttribute("aria-busy");
|
| 612 |
+
const message = t('install.cancel_outcome_uncertain');
|
| 613 |
+
progress.error(message);
|
| 614 |
+
showToast(message, "warning");
|
| 615 |
+
return;
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
if (result.status === "success") {
|
| 619 |
+
progress.complete(t('install.complete'));
|
| 620 |
+
// 根据后端返回的 message 判断是否为自更新场景
|
| 621 |
+
const isSelfUpdate = result.message && result.message.includes("重启") && result.message.includes("生效");
|
| 622 |
+
if (isSelfUpdate) {
|
| 623 |
+
showToast(t('install.completed_update', { title: itemData.title }), "success");
|
| 624 |
+
} else {
|
| 625 |
+
showToast(t('install.completed_restart', { title: itemData.title }), "success");
|
| 626 |
+
}
|
| 627 |
+
|
| 628 |
+
// 仅记录本次引擎证明的commit;榜单版本不能证明已经安装。
|
| 629 |
+
if (!isSelfUpdate) _recordToolInstalledVersion(itemData.id, result);
|
| 630 |
+
|
| 631 |
+
// 记录使用量(后端自动去重)
|
| 632 |
+
try {
|
| 633 |
+
await api.recordItemUse(itemData.id);
|
| 634 |
+
if (!isCurrent()) return;
|
| 635 |
+
clearUsesCache();
|
| 636 |
+
} catch (_) {}
|
| 637 |
+
if (!isCurrent()) return;
|
| 638 |
+
|
| 639 |
+
// 🚀 保存到已获取记录
|
| 640 |
+
saveAcquiredItem(itemData);
|
| 641 |
+
|
| 642 |
+
// 🚀 更新按钮状态
|
| 643 |
+
btnUse.innerHTML = `✅ ${escapeHtml(t('common.installed'))}`;
|
| 644 |
+
btnUse.setAttribute("data-install-state", "success");
|
| 645 |
+
} else {
|
| 646 |
+
if (result?.outcomeUnknown) { phase = "unknown"; btnUse.removeAttribute("aria-busy"); }
|
| 647 |
+
const message = _getInstallErrorMessage(result);
|
| 648 |
+
progress.error(message);
|
| 649 |
+
progress.showDiagnostics(diagnosticEvents);
|
| 650 |
+
showToast(t('install.failed_for_item', { title: itemData.title, message }), "error");
|
| 651 |
+
}
|
| 652 |
+
} catch(err) {
|
| 653 |
+
if (!isCurrent()) return;
|
| 654 |
+
if (!err?.fallbackSafe) {
|
| 655 |
+
phase = "unknown"; btnUse.removeAttribute("aria-busy");
|
| 656 |
+
const message = t('install.connection_uncertain');
|
| 657 |
+
progress.error(message);
|
| 658 |
+
progress.showDiagnostics(diagnosticEvents);
|
| 659 |
+
showToast(message, "error");
|
| 660 |
+
return;
|
| 661 |
+
}
|
| 662 |
+
// 降级到原接口
|
| 663 |
+
progress.destroy();
|
| 664 |
+
inlineStatusBox.innerHTML = `<span class="resource-install__message resource-install__message--info">⏳ ${escapeHtml(t('install.background_started'))}</span>`;
|
| 665 |
+
|
| 666 |
+
try {
|
| 667 |
+
const res = await fetch(localApiEndpoint, {
|
| 668 |
+
method: "POST", headers: { "Content-Type": "application/json" },
|
| 669 |
+
body: JSON.stringify({ url: itemData.link, id: itemData.id, account, token })
|
| 670 |
+
});
|
| 671 |
+
if (!isCurrent()) return;
|
| 672 |
+
const data = await res.json();
|
| 673 |
+
if (!isCurrent()) return;
|
| 674 |
+
if (!res.ok || data?.status !== "success") {
|
| 675 |
+
const isLinkError = typeof data?.error === "string" && data.error.includes('不是有效的 Git');
|
| 676 |
+
const safeMessage = isLinkError ? t('install.error_invalid_repository') : t('install.error_generic');
|
| 677 |
+
inlineStatusBox.innerHTML = isLinkError
|
| 678 |
+
? `<span class="resource-install__message resource-install__message--error">❌ ${escapeHtml(safeMessage)}</span><br><button id="btn-open-source-link" type="button" class="resource-install__button resource-install__source">🔗 ${escapeHtml(t('install.open_source'))}</button>`
|
| 679 |
+
: `<span class="resource-install__message resource-install__message--error">❌ ${escapeHtml(t('install.failed_for_item', { title: itemData.title, message: safeMessage }))}</span>`;
|
| 680 |
+
// 🔒 XSS防护:链接改为 DOM 事件绑定(内联 onclick 里的引号即使转义,
|
| 681 |
+
// 也会被浏览器先还原再交给 JS 解析,依然能被注入;本文件其他分支均用此写法)
|
| 682 |
+
if (isLinkError) {
|
| 683 |
+
const btnOpenSource = inlineStatusBox.querySelector('#btn-open-source-link');
|
| 684 |
+
if (btnOpenSource) {
|
| 685 |
+
btnOpenSource.onclick = () => {
|
| 686 |
+
if (!isCurrent()) return;
|
| 687 |
+
const safeUrl = _getSafeExternalUrl(itemData.link);
|
| 688 |
+
if (safeUrl) window.open(safeUrl, '_blank', 'noopener,noreferrer');
|
| 689 |
+
};
|
| 690 |
+
}
|
| 691 |
+
}
|
| 692 |
+
showToast(t('install.failed_for_item', { title: itemData.title, message: safeMessage }), "error");
|
| 693 |
+
} else {
|
| 694 |
+
// 根据后端返回的 message 判断是否为自更新场景
|
| 695 |
+
const isSelfUpdate = data.message && data.message.includes("重启") && data.message.includes("生效");
|
| 696 |
+
if (isSelfUpdate) {
|
| 697 |
+
inlineStatusBox.innerHTML = `<div class="resource-install__message resource-install__message--success resource-install__complete">🔄 ${escapeHtml(t('install.stage_complete'))}</div><div class="resource-install__note">${escapeHtml(t('install.restart_to_apply'))}</div>`;
|
| 698 |
+
showToast(t('install.completed_update', { title: itemData.title }), "success");
|
| 699 |
+
} else {
|
| 700 |
+
inlineStatusBox.innerHTML = `<div class="resource-install__message resource-install__message--success resource-install__complete">🎉 ${escapeHtml(t('install.complete'))}</div><div class="resource-install__note">${escapeHtml(t('install.restart_to_load'))}</div>`;
|
| 701 |
+
showToast(t('install.completed_restart', { title: itemData.title }), "success");
|
| 702 |
+
}
|
| 703 |
+
|
| 704 |
+
if (!isSelfUpdate) _recordToolInstalledVersion(itemData.id, data);
|
| 705 |
+
|
| 706 |
+
try {
|
| 707 |
+
await api.recordItemUse(itemData.id);
|
| 708 |
+
if (!isCurrent()) return;
|
| 709 |
+
clearUsesCache();
|
| 710 |
+
} catch (_) {}
|
| 711 |
+
if (!isCurrent()) return;
|
| 712 |
+
|
| 713 |
+
saveAcquiredItem(itemData);
|
| 714 |
+
|
| 715 |
+
btnUse.innerHTML = `✅ ${escapeHtml(t('common.installed'))}`;
|
| 716 |
+
btnUse.setAttribute("data-install-state", "success");
|
| 717 |
+
}
|
| 718 |
+
} catch(err2) {
|
| 719 |
+
if (!isCurrent()) return;
|
| 720 |
+
phase = "unknown"; btnUse.removeAttribute("aria-busy");
|
| 721 |
+
inlineStatusBox.innerHTML = `<span class="resource-install__message resource-install__message--error">❌ ${escapeHtml(t('workflow.local_connection_failed', { message: t('common.network_error_retry') }))}</span>`;
|
| 722 |
+
}
|
| 723 |
+
}
|
| 724 |
+
} catch (_) {
|
| 725 |
+
if (isCurrent()) { phase = "unknown"; btnUse.removeAttribute("aria-busy"); inlineStatusBox.textContent = t('install.connection_uncertain'); }
|
| 726 |
+
} finally { if (phase === "install") phase = "idle"; releaseButton(); }
|
| 727 |
+
};
|
| 728 |
+
} else if (isApp) {
|
| 729 |
+
const progress = trackProgress();
|
| 730 |
+
|
| 731 |
+
// 附带 account 凭证,解决问题 2 (一键鉴权加载)
|
| 732 |
try {
|
|
|
|
| 733 |
const result = await requestSSE(
|
| 734 |
+
"/community_hub/download_app_stream",
|
| 735 |
+
{ url: itemData.link, id: itemData.id, account, token },
|
| 736 |
+
(event) => {
|
| 737 |
+
if (!isCurrent()) return;
|
| 738 |
+
const stageKey = typeof event.stage === "string" && Object.hasOwn(WORKFLOW_STAGE_KEYS, event.stage)
|
| 739 |
+
? WORKFLOW_STAGE_KEYS[event.stage]
|
| 740 |
+
: "";
|
| 741 |
+
const message = event.status === "error"
|
| 742 |
+
? _getWorkflowErrorMessage(event)
|
| 743 |
+
: t(stageKey || 'workflow.stage_downloading');
|
| 744 |
+
progress.update(event.progress, message);
|
| 745 |
+
},
|
| 746 |
+
{ timeout: API.WORKFLOW_DOWNLOAD_TIMEOUT }
|
| 747 |
);
|
| 748 |
|
| 749 |
+
if (!isCurrent()) return;
|
| 750 |
if (result.status === "success") {
|
| 751 |
+
const graphLoaded = await _loadGraphToCanvas(result.data, isCurrent);
|
| 752 |
+
if (!isCurrent()) return;
|
| 753 |
+
if (!graphLoaded) {
|
| 754 |
+
const message = t('workflow.canvas_load_failed');
|
| 755 |
+
progress.error(message);
|
| 756 |
+
showToast(message, "error");
|
| 757 |
+
return;
|
| 758 |
}
|
| 759 |
+
progress.complete(t('workflow.loaded_for_item', { title: itemData.title }));
|
| 760 |
+
showToast(t('workflow.loaded_for_item', { title: itemData.title }), "success");
|
| 761 |
|
| 762 |
// 🚀 安装成功后,盖上本地版本戳
|
| 763 |
+
if (itemData.latest_version) {
|
| 764 |
+
if (!saveInstalledVersion(itemData.id, itemData.latest_version)) {
|
| 765 |
+
showToast(t('install.version_record_unavailable'), 'warning');
|
| 766 |
+
}
|
|
|
|
| 767 |
}
|
| 768 |
|
| 769 |
// 记录使用量(后端自动去重)
|
| 770 |
+
api.recordItemUse(itemData.id).then(() => {
|
| 771 |
+
if (!isCurrent()) return;
|
| 772 |
clearUsesCache();
|
| 773 |
+
}).catch(() => {});
|
| 774 |
|
| 775 |
// 🚀 保存到已获取记录
|
| 776 |
saveAcquiredItem(itemData);
|
| 777 |
|
| 778 |
// 🚀 更新按钮状态
|
| 779 |
+
btnUse.innerHTML = `✅ ${escapeHtml(t('common.downloaded'))}`;
|
| 780 |
+
btnUse.setAttribute("data-install-state", "success");
|
| 781 |
} else {
|
| 782 |
+
if (result?.outcomeUnknown) { phase = "unknown"; btnUse.removeAttribute("aria-busy"); }
|
| 783 |
+
const message = _getWorkflowErrorMessage(result);
|
| 784 |
+
const localizedMessage = result.error_code
|
| 785 |
+
? message
|
| 786 |
+
: t('workflow.failed', { message });
|
| 787 |
+
progress.error(message);
|
| 788 |
+
inlineStatusBox.innerHTML = `<span class="resource-install__message resource-install__message--error">❌ ${escapeHtml(localizedMessage)}</span>`;
|
| 789 |
+
showToast(localizedMessage, "error");
|
| 790 |
}
|
| 791 |
} catch(err) {
|
| 792 |
+
if (!isCurrent()) return;
|
| 793 |
+
if (!err?.fallbackSafe) {
|
| 794 |
+
phase = "unknown"; btnUse.removeAttribute("aria-busy");
|
| 795 |
+
const message = t('workflow.connection_uncertain');
|
| 796 |
+
progress.error(message);
|
| 797 |
+
showToast(message, "error");
|
| 798 |
+
return;
|
| 799 |
+
}
|
| 800 |
// 降级到原接口
|
| 801 |
progress.destroy();
|
| 802 |
+
inlineStatusBox.innerHTML = `<span class="resource-install__message resource-install__message--info">⏳ ${escapeHtml(t('workflow.fallback_loading'))}</span>`;
|
| 803 |
|
| 804 |
try {
|
| 805 |
+
const res = await fetch("/community_hub/download_app", {
|
| 806 |
+
method: "POST",
|
| 807 |
+
headers: { "Content-Type": "application/json" },
|
| 808 |
+
body: JSON.stringify({ url: itemData.link, id: itemData.id, account, token })
|
| 809 |
});
|
| 810 |
+
if (!isCurrent()) return;
|
| 811 |
+
if (!res.ok) {
|
| 812 |
+
const messageKey = [401, 403].includes(res.status)
|
| 813 |
+
? 'workflow.error_auth'
|
| 814 |
+
: ([502, 503, 504].includes(res.status) ? 'workflow.error_network' : 'workflow.error_generic');
|
| 815 |
+
inlineStatusBox.innerHTML = `<span class="resource-install__message resource-install__message--error">❌ ${escapeHtml(t('workflow.http_failed', { status: res.status, message: t(messageKey) }))}</span>`;
|
| 816 |
+
return;
|
| 817 |
+
}
|
| 818 |
+
let data;
|
| 819 |
+
try {
|
| 820 |
+
data = await res.json();
|
| 821 |
+
if (!isCurrent()) return;
|
| 822 |
+
} catch (parseErr) {
|
| 823 |
+
if (!isCurrent()) return;
|
| 824 |
+
inlineStatusBox.innerHTML = `<span class="resource-install__message resource-install__message--error">❌ ${escapeHtml(t('workflow.parse_failed'))}</span>`;
|
| 825 |
+
showToast(t('common.parse_error'), "error");
|
| 826 |
+
return;
|
| 827 |
+
}
|
| 828 |
+
if (data?.status !== "success") {
|
| 829 |
+
// 旧式响应的服务端原文可能未本地化,也可能包含上游诊断/链接信息;
|
| 830 |
+
// 使用稳定双语类别提示,不把远端正文直接展示或写入诊断日志。
|
| 831 |
+
const message = t('workflow.error_remote');
|
| 832 |
+
inlineStatusBox.innerHTML = `<span class="resource-install__message resource-install__message--error">❌ ${escapeHtml(message)}</span>`;
|
| 833 |
+
showToast(message, "error");
|
| 834 |
} else {
|
| 835 |
+
const graphLoaded = await _loadGraphToCanvas(data.data, isCurrent);
|
| 836 |
+
if (!isCurrent()) return;
|
| 837 |
+
if (!graphLoaded) {
|
| 838 |
+
const message = t('workflow.canvas_load_failed');
|
| 839 |
+
inlineStatusBox.textContent = message;
|
| 840 |
+
showToast(message, "error");
|
| 841 |
+
return;
|
|
|
|
| 842 |
}
|
| 843 |
+
inlineStatusBox.innerHTML = `<div class="resource-install__message resource-install__message--success resource-install__complete">✅ ${escapeHtml(t('workflow.loaded_for_item', { title: itemData.title }))}</div><div class="resource-install__note">${escapeHtml(t('workflow.loaded_warning'))}</div>`;
|
| 844 |
+
showToast(t('workflow.loaded_for_item', { title: itemData.title }), "success");
|
| 845 |
|
| 846 |
+
if (itemData.latest_version) {
|
| 847 |
+
if (!saveInstalledVersion(itemData.id, itemData.latest_version)) {
|
| 848 |
+
showToast(t('install.version_record_unavailable'), 'warning');
|
| 849 |
+
}
|
| 850 |
}
|
| 851 |
|
| 852 |
+
api.recordItemUse(itemData.id).then(() => {
|
| 853 |
+
if (!isCurrent()) return;
|
| 854 |
clearUsesCache();
|
| 855 |
+
}).catch(() => {});
|
| 856 |
|
| 857 |
saveAcquiredItem(itemData);
|
| 858 |
|
| 859 |
+
btnUse.innerHTML = `✅ ${escapeHtml(t('common.downloaded'))}`;
|
| 860 |
+
btnUse.setAttribute("data-install-state", "success");
|
| 861 |
}
|
| 862 |
} catch(err2) {
|
| 863 |
+
if (!isCurrent()) return;
|
| 864 |
+
phase = "unknown"; btnUse.removeAttribute("aria-busy");
|
| 865 |
+
inlineStatusBox.innerHTML = `<span class="resource-install__message resource-install__message--error">❌ ${escapeHtml(t('workflow.local_connection_failed', { message: t('common.network_error_retry') }))}</span>`;
|
| 866 |
}
|
| 867 |
}
|
| 868 |
+
} else {
|
| 869 |
+
// ☁️ 网盘链接或纯链接模式(recordItemUse 延迟到用户点击按钮后)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 870 |
|
| 871 |
+
// ☁️ 从购买响应中获取网盘信息(安全:只有购买成功后才返回)
|
| 872 |
+
const isNetdisk = purchaseRes.is_netdisk;
|
| 873 |
+
const netdiskPassword = purchaseRes.netdisk_password;
|
| 874 |
|
| 875 |
+
// ☁️ 网盘资源:购买后显示密码
|
| 876 |
+
if (isNetdisk) {
|
| 877 |
+
_renderNetdiskUI(inlineStatusBox, itemData, netdiskPassword, isCurrent);
|
| 878 |
} else {
|
| 879 |
+
inlineStatusBox.replaceChildren();
|
| 880 |
+
const card = document.createElement('div');
|
| 881 |
+
card.className = 'netdisk-card';
|
| 882 |
+
const summary = document.createElement('div');
|
| 883 |
+
summary.className = 'netdisk-card__summary';
|
| 884 |
+
summary.textContent = t('acquire.pure_link_authorized');
|
| 885 |
+
const openButton = document.createElement('button');
|
| 886 |
+
openButton.type = 'button';
|
| 887 |
+
openButton.className = 'netdisk-card__open';
|
| 888 |
+
openButton.textContent = t('netdisk.open');
|
| 889 |
+
openButton.addEventListener('click', () => _openExternalResource(itemData.link, itemData.id, isCurrent));
|
| 890 |
+
const panel = document.createElement('div');
|
| 891 |
+
panel.className = 'netdisk-card__panel';
|
| 892 |
+
panel.appendChild(openButton);
|
| 893 |
+
card.append(summary, panel);
|
| 894 |
+
inlineStatusBox.appendChild(card);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 895 |
}
|
| 896 |
}
|
| 897 |
+
} catch (_) {
|
| 898 |
+
if (isCurrent()) { inlineStatusBox.style.display = "block"; inlineStatusBox.textContent = t('common.error'); }
|
| 899 |
+
} finally { if (phase === "preflight") phase = "idle"; releaseButton(); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 900 |
};
|
| 901 |
+
return scope;
|
| 902 |
+
}
|
前端页面/market/资源操作生命周期.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 安装与交付入口共用原账号/节点范围;已经提交的购买和安装不自动重放。
|
| 2 |
+
import { captureRequestSession, requestCancelManager } from "../core/网络请求API.js";
|
| 3 |
+
import { API, CACHE } from "../core/全局配置.js";
|
| 4 |
+
import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
|
| 5 |
+
import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
|
| 6 |
+
|
| 7 |
+
const scopes = new WeakMap();
|
| 8 |
+
let sequence = 0;
|
| 9 |
+
export function createResourceOperationScope(button, statusBox, account, options = {}) {
|
| 10 |
+
scopes.get(button)?.dispose();
|
| 11 |
+
const matches = captureRequestSession(), componentId = `resource-operation-${++sequence}`;
|
| 12 |
+
const confirmation = new AbortController(), cleanups = new Set();
|
| 13 |
+
let inner, observer, disposed = false;
|
| 14 |
+
const cleanup = () => {
|
| 15 |
+
if (disposed) return;
|
| 16 |
+
disposed = true;
|
| 17 |
+
observer?.disconnect(); confirmation.abort();
|
| 18 |
+
button.disabled = true; button.removeAttribute("aria-busy");
|
| 19 |
+
statusBox.replaceChildren(); statusBox.style.display = "none";
|
| 20 |
+
for (const callback of cleanups) { try { callback(); } catch (_) {} }
|
| 21 |
+
cleanups.clear();
|
| 22 |
+
};
|
| 23 |
+
const scope = {
|
| 24 |
+
readOptions: { noCache: true, componentId, timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES },
|
| 25 |
+
confirmationSignal: confirmation.signal,
|
| 26 |
+
dispose() { if (inner) inner.dispose(); else cleanup(); },
|
| 27 |
+
addCleanup(callback) {
|
| 28 |
+
if (disposed) { callback(); return () => {}; }
|
| 29 |
+
cleanups.add(callback); return () => cleanups.delete(callback);
|
| 30 |
+
},
|
| 31 |
+
isCurrent() {
|
| 32 |
+
if (disposed) return false;
|
| 33 |
+
// 只有实际操作过的卡片订阅身份/DOM变化,避免整个榜单逐卡安装全局监听。
|
| 34 |
+
if (!inner) {
|
| 35 |
+
inner = createTaskViewScope({
|
| 36 |
+
sessionMatches: () => matches() && getUser()?.account === account,
|
| 37 |
+
connected: () => button.isConnected && statusBox.isConnected
|
| 38 |
+
&& (!options.isCurrent || options.isCurrent()),
|
| 39 |
+
subscribe: notify => subscribeTaskSessionChanges(notify, { target: window, eventBus, events: EVENTS,
|
| 40 |
+
userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN }),
|
| 41 |
+
cancelReads: () => requestCancelManager.cancelAll(componentId), invalidate: () => {}
|
| 42 |
+
});
|
| 43 |
+
inner.addCleanup(cleanup);
|
| 44 |
+
observer = new MutationObserver(() => { scope.isCurrent(); });
|
| 45 |
+
observer.observe(document.body, { childList: true, subtree: true });
|
| 46 |
+
}
|
| 47 |
+
return inner.isCurrent();
|
| 48 |
+
}
|
| 49 |
+
};
|
| 50 |
+
button._cleanup = () => scope.dispose(); scopes.set(button, scope);
|
| 51 |
+
return scope;
|
| 52 |
+
}
|
前端页面/market/资源获取记录.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 本地交付记录仅用于离线展示,不作为购买权限或安装结果的依据。
|
| 2 |
+
import { CACHE } from "../core/全局配置.js";
|
| 3 |
+
|
| 4 |
+
function validId(id) {
|
| 5 |
+
return (typeof id === "string" && id.trim().length > 0) ||
|
| 6 |
+
(typeof id === "number" && Number.isFinite(id));
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
function readRecords() {
|
| 10 |
+
let raw;
|
| 11 |
+
try { raw = localStorage.getItem(CACHE.LOCAL_KEYS.ACQUIRED_ITEMS); }
|
| 12 |
+
catch { return { available: false, records: [] }; }
|
| 13 |
+
let entries;
|
| 14 |
+
try { entries = JSON.parse(raw || "[]"); }
|
| 15 |
+
catch { entries = []; }
|
| 16 |
+
if (!Array.isArray(entries)) entries = [];
|
| 17 |
+
return { available: true, records: entries.filter(entry =>
|
| 18 |
+
entry && typeof entry === "object" && !Array.isArray(entry) && validId(entry.id)) };
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
function writeRecords(records) {
|
| 22 |
+
try {
|
| 23 |
+
localStorage.setItem(CACHE.LOCAL_KEYS.ACQUIRED_ITEMS, JSON.stringify(records));
|
| 24 |
+
return true;
|
| 25 |
+
} catch {
|
| 26 |
+
console.warn("本地资源记录无法持久化");
|
| 27 |
+
return false;
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export function getAcquiredItems() {
|
| 32 |
+
return readRecords().records;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export function saveAcquiredItem(itemData) {
|
| 36 |
+
if (!itemData || typeof itemData !== "object" || Array.isArray(itemData) || !validId(itemData.id)) return false;
|
| 37 |
+
const snapshot = readRecords();
|
| 38 |
+
// 无法读取时不能用空数组覆盖可能仍存在的历史记录。
|
| 39 |
+
if (!snapshot.available) return false;
|
| 40 |
+
const record = {
|
| 41 |
+
id: itemData.id,
|
| 42 |
+
title: itemData.title,
|
| 43 |
+
type: itemData.type,
|
| 44 |
+
author: itemData.author,
|
| 45 |
+
shortDesc: itemData.shortDesc || itemData.short_desc || '',
|
| 46 |
+
coverUrl: itemData.coverUrl || itemData.cover_url || '',
|
| 47 |
+
coverBase64: itemData.coverBase64,
|
| 48 |
+
imageUrls: itemData.imageUrls || itemData.image_urls || [],
|
| 49 |
+
link: itemData.link || itemData.github_url || '',
|
| 50 |
+
price: itemData.price || 0,
|
| 51 |
+
likes: itemData.likes || 0,
|
| 52 |
+
uses: itemData.uses || 0,
|
| 53 |
+
local_version: itemData.latest_version || itemData.local_version || itemData.version || '',
|
| 54 |
+
acquired_at: Date.now()
|
| 55 |
+
};
|
| 56 |
+
const index = snapshot.records.findIndex(entry => String(entry.id) === String(itemData.id));
|
| 57 |
+
if (index >= 0) snapshot.records[index] = record;
|
| 58 |
+
else snapshot.records.unshift(record);
|
| 59 |
+
return writeRecords(snapshot.records);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
export function removeAcquiredItem(itemId) {
|
| 63 |
+
if (!validId(itemId)) return false;
|
| 64 |
+
const snapshot = readRecords();
|
| 65 |
+
if (!snapshot.available) return false;
|
| 66 |
+
return writeRecords(snapshot.records.filter(entry => String(entry.id) !== String(itemId)));
|
| 67 |
+
}
|
前端页面/market/资源详情页面组件.js
CHANGED
|
@@ -10,23 +10,26 @@
|
|
| 10 |
// ==========================================
|
| 11 |
|
| 12 |
import { api, proxyImages } from "../core/网络请求API.js";
|
| 13 |
-
import { IS_WEB_MODE } from "../core/全局配置.js";
|
|
|
|
| 14 |
import { openTipModal } from "../profile/个人中心_赞赏组件.js";
|
| 15 |
-
import { setupResourceInstall } from "./资源安装引擎.js";
|
| 16 |
import { renderTipBoardHTML } from "../components/打赏等级工具.js";
|
| 17 |
import { t } from "../components/用户体验增强.js";
|
| 18 |
-
import {
|
| 19 |
-
import {
|
|
|
|
| 20 |
import { showToast } from "../components/UI交互提示组件.js";
|
| 21 |
// 🧹 P2归一:escapeHtml 局部副本已移除,改用统一版(转义更严格,防XSS语义不变)
|
| 22 |
import { escapeHtml } from "../components/互动工具函数.js";
|
| 23 |
|
| 24 |
// 🔄 P7后悔模式:渲染退款按钮
|
| 25 |
-
async function renderRefundButton(container, itemData, currentUser) {
|
| 26 |
if (!currentUser) return;
|
| 27 |
|
| 28 |
const refundArea = container.querySelector("#refund-area");
|
| 29 |
if (!refundArea) return;
|
|
|
|
| 30 |
|
| 31 |
// 💸 检查商品是否支持退款
|
| 32 |
if (!itemData.allow_refund && itemData.allow_refund !== undefined) {
|
|
@@ -34,15 +37,19 @@ async function renderRefundButton(container, itemData, currentUser) {
|
|
| 34 |
return;
|
| 35 |
}
|
| 36 |
|
|
|
|
| 37 |
try {
|
| 38 |
-
const statusRes = await
|
|
|
|
|
|
|
| 39 |
if (!statusRes.owned) {
|
| 40 |
refundArea.innerHTML = '';
|
| 41 |
return;
|
| 42 |
}
|
| 43 |
|
| 44 |
-
if (statusRes.can_refund) {
|
| 45 |
-
const hoursLeft = statusRes.refund_hours_left;
|
|
|
|
| 46 |
refundArea.innerHTML = `
|
| 47 |
<div style="margin-top: 12px; padding: 12px; background: rgba(255,152,0,0.1); border: 1px dashed #FF9800; border-radius: 6px;">
|
| 48 |
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
|
|
@@ -59,7 +66,9 @@ async function renderRefundButton(container, itemData, currentUser) {
|
|
| 59 |
</div>
|
| 60 |
`;
|
| 61 |
|
| 62 |
-
refundArea.querySelector("#btn-refund").onclick = () =>
|
|
|
|
|
|
|
| 63 |
} else {
|
| 64 |
refundArea.innerHTML = `
|
| 65 |
<div style="margin-top: 12px; padding: 10px; background: rgba(76,175,80,0.1); border: 1px solid rgba(76,175,80,0.3); border-radius: 6px; font-size: 12px; color: #4CAF50;">
|
|
@@ -68,169 +77,19 @@ async function renderRefundButton(container, itemData, currentUser) {
|
|
| 68 |
`;
|
| 69 |
}
|
| 70 |
} catch (e) {
|
| 71 |
-
|
|
|
|
| 72 |
}
|
| 73 |
}
|
| 74 |
|
| 75 |
-
/
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
* @param {string} config.infoHtml - 商品信息区域HTML
|
| 81 |
-
* @param {string} config.warningHtml - 警告区域HTML
|
| 82 |
-
* @param {string} config.warningBgColor - 警告区域背景色
|
| 83 |
-
* @param {string} config.warningBorderColor - 警告区域边框色
|
| 84 |
-
* @param {string} config.confirmText - 确认按钮文本
|
| 85 |
-
* @param {string} config.confirmColor - 确认按钮颜色
|
| 86 |
-
* @param {string} config.confirmHoverColor - 确认按钮悬停颜色
|
| 87 |
-
* @param {string} config.processingText - 处理中文本
|
| 88 |
-
* @param {Function} config.onConfirm - 确认回调 (overlay: HTMLElement) => Promise<void>
|
| 89 |
-
*/
|
| 90 |
-
function _showConfirmOverlay({ title, titleColor, infoHtml, warningHtml, warningBgColor, warningBorderColor, confirmText, confirmColor, confirmHoverColor, processingText, onConfirm }) {
|
| 91 |
-
// 两个按钮的公共基础样式
|
| 92 |
-
const _btnBaseStyle = "color: #fff; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-size: 14px; transition: 0.2s;";
|
| 93 |
-
|
| 94 |
-
const overlay = document.createElement("div");
|
| 95 |
-
Object.assign(overlay.style, {
|
| 96 |
-
position: "fixed", top: "0", left: "0", right: "0", bottom: "0",
|
| 97 |
-
background: "rgba(0,0,0,0.7)", display: "flex", alignItems: "center", justifyContent: "center",
|
| 98 |
-
zIndex: "10000"
|
| 99 |
-
});
|
| 100 |
-
|
| 101 |
-
overlay.innerHTML = `
|
| 102 |
-
<div style="background: #1e2233; border-radius: 12px; padding: 25px; max-width: 420px; width: 90%; color: #fff; box-shadow: 0 8px 32px rgba(0,0,0,0.5);">
|
| 103 |
-
<div style="font-size: 18px; font-weight: bold; margin-bottom: 20px; color: ${titleColor}; display: flex; align-items: center; gap: 10px;">
|
| 104 |
-
⚠️ ${escapeHtml(title)}
|
| 105 |
-
</div>
|
| 106 |
-
|
| 107 |
-
<div style="background: #2a2d3e; border-radius: 8px; padding: 15px; margin-bottom: 15px;">
|
| 108 |
-
${infoHtml}
|
| 109 |
-
</div>
|
| 110 |
-
|
| 111 |
-
<div style="background: ${warningBgColor}; border: 1px solid ${warningBorderColor}; border-radius: 8px; padding: 15px; margin-bottom: 20px;">
|
| 112 |
-
<div style="font-size: 13px; color: #FF9800; line-height: 1.8;">
|
| 113 |
-
${warningHtml}
|
| 114 |
-
</div>
|
| 115 |
-
</div>
|
| 116 |
-
|
| 117 |
-
<div style="display: flex; gap: 12px; justify-content: flex-end;">
|
| 118 |
-
<button id="btn-cancel-confirm" style="background: #555; ${_btnBaseStyle}">
|
| 119 |
-
${t('common.cancel')}
|
| 120 |
-
</button>
|
| 121 |
-
<button id="btn-confirm-action" style="background: ${confirmColor}; font-weight: bold; ${_btnBaseStyle}">
|
| 122 |
-
${escapeHtml(confirmText)}
|
| 123 |
-
</button>
|
| 124 |
-
</div>
|
| 125 |
-
</div>
|
| 126 |
-
`;
|
| 127 |
-
|
| 128 |
-
document.body.appendChild(overlay);
|
| 129 |
-
|
| 130 |
-
const cancelBtn = overlay.querySelector("#btn-cancel-confirm");
|
| 131 |
-
cancelBtn.onmouseover = () => { cancelBtn.style.background = "#666"; };
|
| 132 |
-
cancelBtn.onmouseout = () => { cancelBtn.style.background = "#555"; };
|
| 133 |
-
cancelBtn.onclick = () => overlay.remove();
|
| 134 |
-
|
| 135 |
-
const confirmBtn = overlay.querySelector("#btn-confirm-action");
|
| 136 |
-
confirmBtn.onmouseover = () => { confirmBtn.style.background = confirmHoverColor; };
|
| 137 |
-
confirmBtn.onmouseout = () => { confirmBtn.style.background = confirmColor; };
|
| 138 |
-
confirmBtn.onclick = async () => {
|
| 139 |
-
confirmBtn.disabled = true;
|
| 140 |
-
confirmBtn.innerText = processingText;
|
| 141 |
-
await onConfirm(overlay);
|
| 142 |
-
};
|
| 143 |
-
|
| 144 |
-
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
|
| 145 |
-
}
|
| 146 |
-
|
| 147 |
-
// 🔄 P7后悔模式:退款确认弹窗
|
| 148 |
-
function showRefundConfirm(itemData, currentUser, pricePaid) {
|
| 149 |
-
_showConfirmOverlay({
|
| 150 |
-
title: t('item.confirm_refund_title'),
|
| 151 |
-
titleColor: "#FF5722",
|
| 152 |
-
infoHtml: `
|
| 153 |
-
<div style="font-size: 14px; margin-bottom: 10px;">${t('item.product')}${escapeHtml(itemData.title || t('item.untitled'))}</div>
|
| 154 |
-
<div style="font-size: 14px; color: #4CAF50;">${t('item.refund_amount')}<strong>${pricePaid}</strong> ${t('common.credits')}</div>
|
| 155 |
-
`,
|
| 156 |
-
warningHtml: `
|
| 157 |
-
<div>🚨 <strong>${t('item.refund_consequence')}:</strong></div>
|
| 158 |
-
<div style="margin-left: 20px; margin-top: 5px;">
|
| 159 |
-
• ${t('item.refund_consequence_1')}<br>
|
| 160 |
-
• ${t('item.refund_consequence_2')}<br>
|
| 161 |
-
• <strong style="color: #F44336;">${t('item.refund_consequence_3')}</strong>
|
| 162 |
-
</div>
|
| 163 |
-
`,
|
| 164 |
-
warningBgColor: "rgba(255,87,34,0.1)",
|
| 165 |
-
warningBorderColor: "rgba(255,87,34,0.3)",
|
| 166 |
-
confirmText: t('item.confirm_refund'),
|
| 167 |
-
confirmColor: "#FF5722",
|
| 168 |
-
confirmHoverColor: "#E64A19",
|
| 169 |
-
processingText: t('common.processing'),
|
| 170 |
-
onConfirm: async (overlay) => {
|
| 171 |
-
try {
|
| 172 |
-
const res = await api.requestRefund(currentUser.account, itemData.id);
|
| 173 |
-
if (res.status === "success") {
|
| 174 |
-
// 🔄 P7后悔模式:清除本地缓存和文件
|
| 175 |
-
burnLocalFiles(itemData.id);
|
| 176 |
-
|
| 177 |
-
overlay.innerHTML = `
|
| 178 |
-
<div style="background: #1e2233; border-radius: 12px; padding: 25px; max-width: 380px; width: 90%; color: #fff; text-align: center;">
|
| 179 |
-
<div style="font-size: 48px; margin-bottom: 15px;">✅</div>
|
| 180 |
-
<div style="font-size: 18px; font-weight: bold; margin-bottom: 10px; color: #4CAF50;">${t('item.refund_success')}</div>
|
| 181 |
-
<div style="font-size: 14px; color: #aaa; margin-bottom: 20px;">
|
| 182 |
-
${res.refund_amount} ${t('item.credits_refunded')}<br>
|
| 183 |
-
<span style="color: #FF9800;">${t('item.ban_days_notice', { days: res.ban_days })}</span>
|
| 184 |
-
</div>
|
| 185 |
-
<button id="btn-close-refund" style="background: #4CAF50; color: #fff; border: none; padding: 10px 30px; border-radius: 6px; cursor: pointer; font-size: 14px;">
|
| 186 |
-
${t('common.got_it')}
|
| 187 |
-
</button>
|
| 188 |
-
</div>
|
| 189 |
-
`;
|
| 190 |
-
overlay.querySelector("#btn-close-refund").onclick = () => {
|
| 191 |
-
overlay.remove();
|
| 192 |
-
window.dispatchEvent(new CustomEvent("comfy-route-back"));
|
| 193 |
-
};
|
| 194 |
-
} else {
|
| 195 |
-
showToast(t('item.refund_failed_retry'), "error");
|
| 196 |
-
overlay.remove();
|
| 197 |
-
}
|
| 198 |
-
} catch (e) {
|
| 199 |
-
showToast(t('item.refund_request_failed'), "error");
|
| 200 |
-
overlay.remove();
|
| 201 |
-
}
|
| 202 |
-
}
|
| 203 |
});
|
| 204 |
}
|
| 205 |
|
| 206 |
-
// 🔄 P7后悔模式:焚毁本地文件和缓存
|
| 207 |
-
function burnLocalFiles(itemId) {
|
| 208 |
-
// 清除本地版本标记
|
| 209 |
-
localStorage.removeItem(`ComfyCommunity_LocalVer_${itemId}`);
|
| 210 |
-
|
| 211 |
-
// 清除购买缓存
|
| 212 |
-
removeCache(`ItemOwnership_${itemId}`);
|
| 213 |
-
|
| 214 |
-
// 清除列表缓存(强制下次刷新)
|
| 215 |
-
// 🔧 修复:API 层缓存键带完整查询串,removeCache 精确删除命中不到(no-op),
|
| 216 |
-
// 购买/下载后返回列表仍会看到旧数据,改用前缀删除
|
| 217 |
-
removeCacheByPrefix('api_/api/items');
|
| 218 |
-
removeCacheByPrefix('api_/api/creators');
|
| 219 |
-
|
| 220 |
-
// 清除工具/Skill/应用/推荐列表缓存(使用标准 ListCache 格式)
|
| 221 |
-
const tabs = ['tools', 'skills', 'apps', 'recommends'];
|
| 222 |
-
const sorts = ['time', 'downloads', 'likes', 'favorites', 'tips', 'views', 'daily_views', 'rating'];
|
| 223 |
-
for (const tab of tabs) {
|
| 224 |
-
for (const sort of sorts) {
|
| 225 |
-
removeCache(`ListCache_${tab}_${sort}`);
|
| 226 |
-
}
|
| 227 |
-
}
|
| 228 |
-
|
| 229 |
-
console.log(`🔥 已焚毁资源 ${itemId} 的本地数据`);
|
| 230 |
-
}
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
// ==========================================
|
| 235 |
// 🗑️ 删除内容功能
|
| 236 |
// ==========================================
|
|
@@ -243,61 +102,18 @@ function canDeleteItem(itemData, currentUser) {
|
|
| 243 |
// 作者本人可以删除
|
| 244 |
if (itemData.author === currentUser.account) return true;
|
| 245 |
// 管理员可以删除
|
| 246 |
-
if (currentUser.is_admin) return true;
|
| 247 |
return false;
|
| 248 |
}
|
| 249 |
|
| 250 |
/**
|
| 251 |
* 显示删除确认弹窗
|
| 252 |
*/
|
| 253 |
-
function showDeleteConfirm(itemData, currentUser,
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
<div style="font-size: 14px; margin-bottom: 10px;">${t('item.content_label')}${escapeHtml(itemData.title || t('item.untitled'))}</div>
|
| 259 |
-
<div style="font-size: 13px; color: #888;">${t('item.type_label')}${escapeHtml(itemData.type || t('item.type_unknown'))}</div>
|
| 260 |
-
`,
|
| 261 |
-
warningHtml: `
|
| 262 |
-
<div>🚨 <strong>${t('item.delete_consequence')}:</strong></div>
|
| 263 |
-
<div style="margin-left: 20px; margin-top: 5px;">
|
| 264 |
-
• ${t('item.delete_consequence_1')}<br>
|
| 265 |
-
• ${t('item.delete_consequence_2')}<br>
|
| 266 |
-
• ${t('item.delete_consequence_3')}
|
| 267 |
-
</div>
|
| 268 |
-
`,
|
| 269 |
-
warningBgColor: "rgba(244,67,54,0.1)",
|
| 270 |
-
warningBorderColor: "rgba(244,67,54,0.3)",
|
| 271 |
-
confirmText: t('item.confirm_delete'),
|
| 272 |
-
confirmColor: "#F44336",
|
| 273 |
-
confirmHoverColor: "#D32F2F",
|
| 274 |
-
processingText: t('item.deleting'),
|
| 275 |
-
onConfirm: async (overlay) => {
|
| 276 |
-
try {
|
| 277 |
-
const res = await api.deleteItem(itemData.id);
|
| 278 |
-
if (res.status === "success") {
|
| 279 |
-
// 清除相关缓存
|
| 280 |
-
invalidateRelatedCache(`/api/items/${itemData.id}`, "DELETE");
|
| 281 |
-
|
| 282 |
-
overlay.remove();
|
| 283 |
-
showToast(t('item.delete_success'), "success");
|
| 284 |
-
|
| 285 |
-
// 返回列表页
|
| 286 |
-
window.dispatchEvent(new CustomEvent("comfy-route-view", {
|
| 287 |
-
detail: { view: "market", type: itemData.type || "tool" }
|
| 288 |
-
}));
|
| 289 |
-
|
| 290 |
-
if (onSuccess) onSuccess();
|
| 291 |
-
} else {
|
| 292 |
-
showToast(t('item.delete_failed_retry'), "error");
|
| 293 |
-
overlay.remove();
|
| 294 |
-
}
|
| 295 |
-
} catch (e) {
|
| 296 |
-
showToast(t('item.delete_request_failed'), "error");
|
| 297 |
-
overlay.remove();
|
| 298 |
-
}
|
| 299 |
-
}
|
| 300 |
-
});
|
| 301 |
}
|
| 302 |
|
| 303 |
export function createItemDetailView(itemData, currentUser) {
|
|
@@ -312,13 +128,11 @@ export function createItemDetailView(itemData, currentUser) {
|
|
| 312 |
backgroundColor: "#13151c"
|
| 313 |
});
|
| 314 |
|
| 315 |
-
const
|
| 316 |
-
const
|
| 317 |
-
let authorName = itemData.author;
|
| 318 |
-
if (cachedAuthorStr) { try { authorName = JSON.parse(cachedAuthorStr).name || itemData.author; } catch(e) {} }
|
| 319 |
|
| 320 |
// 🚀 使用统一工具渲染单品赞赏榜单(带星星/月亮/太阳等级)
|
| 321 |
-
const boardData = itemData.tip_board
|
| 322 |
const boardHtml = renderTipBoardHTML(boardData, 10, t('item.no_tips_yet'), "normal");
|
| 323 |
|
| 324 |
// 🗑️ 判断是否有权限删除
|
|
@@ -345,7 +159,7 @@ export function createItemDetailView(itemData, currentUser) {
|
|
| 345 |
${deleteBtnHtml}
|
| 346 |
</div>
|
| 347 |
</div>
|
| 348 |
-
<div style="margin-top: 10px; color: #888;">
|
| 349 |
|
| 350 |
<!-- 🔄 P7后悔模式:退款区域 -->
|
| 351 |
<div id="refund-area"></div>
|
|
@@ -357,7 +171,8 @@ export function createItemDetailView(itemData, currentUser) {
|
|
| 357 |
`;
|
| 358 |
|
| 359 |
// 🚀 核心逻辑:比对云端探测版本与本地安装版本
|
| 360 |
-
const
|
|
|
|
| 361 |
const cloudVersionHash = itemData.latest_version;
|
| 362 |
const isFree = !itemData.price || itemData.price <= 0 || (currentUser && currentUser.account === itemData.author);
|
| 363 |
|
|
@@ -369,13 +184,13 @@ export function createItemDetailView(itemData, currentUser) {
|
|
| 369 |
let actionBtnHtml = '';
|
| 370 |
if (IS_WEB_MODE) {
|
| 371 |
// 📱 Web 模式:保留「立即获取」按钮但行为受限(未购买仅触发购买、已购买提示电脑端安装,见资源安装引擎);不渲染重装/更新按钮
|
| 372 |
-
actionBtnHtml = `<button id="btn-use-item"
|
| 373 |
-
} else if (!isUpdateAvailable &&
|
| 374 |
-
actionBtnHtml = `<button id="btn-use-item"
|
| 375 |
} else if (isUpdateAvailable) {
|
| 376 |
-
actionBtnHtml = `<button id="btn-use-item"
|
| 377 |
} else {
|
| 378 |
-
actionBtnHtml = `<button id="btn-use-item"
|
| 379 |
}
|
| 380 |
|
| 381 |
// 🚀 返回按钮位置可调整参数:margin-left 控制右移,margin-top 控制下移
|
|
@@ -401,27 +216,48 @@ export function createItemDetailView(itemData, currentUser) {
|
|
| 401 |
</div>
|
| 402 |
`;
|
| 403 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
// 🚀 创建安装状态容器
|
| 405 |
const inlineStatusBox = document.createElement("div");
|
| 406 |
inlineStatusBox.id = "inline-status-box";
|
| 407 |
Object.assign(inlineStatusBox.style, { display: "none", marginTop: "15px", padding: "12px", background: "var(--comfy-menu-bg)", borderRadius: "6px", border: "1px solid var(--border-color, #333)", fontSize: "13px" });
|
| 408 |
container.appendChild(inlineStatusBox);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
|
| 410 |
-
container.querySelector("#btn-back-detail").onclick = () =>
|
|
|
|
|
|
|
|
|
|
| 411 |
|
| 412 |
// 🚀 绑定安装/更新按钮事件
|
| 413 |
const btnUseItem = container.querySelector("#btn-use-item");
|
| 414 |
if (btnUseItem) {
|
| 415 |
-
setupResourceInstall(btnUseItem, itemData, currentUser, inlineStatusBox
|
|
|
|
|
|
|
| 416 |
}
|
| 417 |
|
| 418 |
// 🚀 绑定作品打赏按钮事件
|
| 419 |
container.querySelector("#btn-tip-item").onclick = async () => {
|
|
|
|
| 420 |
if (!currentUser) return showToast(t('feedback.login_required'), "warning");
|
| 421 |
-
await openTipModal(currentUser, { account: itemData.author },
|
| 422 |
-
|
| 423 |
-
// 打赏成功后刷新局部或整体详情 (由框架重新渲染)
|
| 424 |
-
}, itemData.id);
|
| 425 |
};
|
| 426 |
|
| 427 |
// 🗑️ 绑定删除按钮事件
|
|
@@ -429,42 +265,41 @@ export function createItemDetailView(itemData, currentUser) {
|
|
| 429 |
if (btnDeleteItem) {
|
| 430 |
btnDeleteItem.onclick = () => {
|
| 431 |
if (!currentUser) return showToast(t('feedback.login_required'), "warning");
|
| 432 |
-
showDeleteConfirm(itemData, currentUser);
|
| 433 |
};
|
| 434 |
}
|
| 435 |
|
| 436 |
// 🐛 Bug修复:跳过系统页面的作者信息查询(如关于页面)
|
| 437 |
-
if (!itemData.isSystemPage) {
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
if (
|
| 442 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 443 |
}).catch(() => {});
|
| 444 |
}
|
| 445 |
|
| 446 |
-
//
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
});
|
| 462 |
-
}
|
| 463 |
-
// 未购买时保持默认的「立即获取使用」按钮
|
| 464 |
-
}).catch(err => {
|
| 465 |
-
console.warn("云端购买状态查询失败,fallback到本地逻辑:", err);
|
| 466 |
});
|
| 467 |
}
|
| 468 |
|
| 469 |
return container;
|
| 470 |
-
}
|
|
|
|
| 10 |
// ==========================================
|
| 11 |
|
| 12 |
import { api, proxyImages } from "../core/网络请求API.js";
|
| 13 |
+
import { IS_WEB_MODE, getCachedProfile, setCachedProfile } from "../core/全局配置.js";
|
| 14 |
+
import { normalizeProfileData } from "../profile/个人资料工具.js";
|
| 15 |
import { openTipModal } from "../profile/个人中心_赞赏组件.js";
|
| 16 |
+
import { getInstalledVersionStatus, setupResourceInstall } from "./资源安装引擎.js";
|
| 17 |
import { renderTipBoardHTML } from "../components/打赏等级工具.js";
|
| 18 |
import { t } from "../components/用户体验增强.js";
|
| 19 |
+
import { createPurchaseStatusScope, isValidPurchaseStatus } from "./购买状态生命周期.js";
|
| 20 |
+
import { showResourceDeleteDialog } from "./资源删除组件.js";
|
| 21 |
+
import { showResourceRefundDialog } from "./资源退款组件.js";
|
| 22 |
import { showToast } from "../components/UI交互提示组件.js";
|
| 23 |
// 🧹 P2归一:escapeHtml 局部副本已移除,改用统一版(转义更严格,防XSS语义不变)
|
| 24 |
import { escapeHtml } from "../components/互动工具函数.js";
|
| 25 |
|
| 26 |
// 🔄 P7后悔模式:渲染退款按钮
|
| 27 |
+
async function renderRefundButton(container, itemData, currentUser, scope, purchaseStatus) {
|
| 28 |
if (!currentUser) return;
|
| 29 |
|
| 30 |
const refundArea = container.querySelector("#refund-area");
|
| 31 |
if (!refundArea) return;
|
| 32 |
+
if (!scope.isCurrent()) return;
|
| 33 |
|
| 34 |
// 💸 检查商品是否支持退款
|
| 35 |
if (!itemData.allow_refund && itemData.allow_refund !== undefined) {
|
|
|
|
| 37 |
return;
|
| 38 |
}
|
| 39 |
|
| 40 |
+
refundArea.style.display = "block";
|
| 41 |
try {
|
| 42 |
+
const statusRes = await purchaseStatus;
|
| 43 |
+
if (!scope.isCurrent()) return;
|
| 44 |
+
if (!isValidPurchaseStatus(statusRes)) throw new Error();
|
| 45 |
if (!statusRes.owned) {
|
| 46 |
refundArea.innerHTML = '';
|
| 47 |
return;
|
| 48 |
}
|
| 49 |
|
| 50 |
+
if (statusRes.can_refund && statusRes.allow_refund) {
|
| 51 |
+
const hoursLeft = Number(statusRes.refund_hours_left);
|
| 52 |
+
if (!Number.isFinite(hoursLeft) || hoursLeft < 0) throw new Error("Invalid refund hours");
|
| 53 |
refundArea.innerHTML = `
|
| 54 |
<div style="margin-top: 12px; padding: 12px; background: rgba(255,152,0,0.1); border: 1px dashed #FF9800; border-radius: 6px;">
|
| 55 |
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
|
|
|
|
| 66 |
</div>
|
| 67 |
`;
|
| 68 |
|
| 69 |
+
refundArea.querySelector("#btn-refund").onclick = () => {
|
| 70 |
+
if (scope.isCurrent()) showRefundConfirm(itemData, currentUser, statusRes.price_paid, () => scope.isCurrent());
|
| 71 |
+
};
|
| 72 |
} else {
|
| 73 |
refundArea.innerHTML = `
|
| 74 |
<div style="margin-top: 12px; padding: 10px; background: rgba(76,175,80,0.1); border: 1px solid rgba(76,175,80,0.3); border-radius: 6px; font-size: 12px; color: #4CAF50;">
|
|
|
|
| 77 |
`;
|
| 78 |
}
|
| 79 |
} catch (e) {
|
| 80 |
+
if (!scope.isCurrent()) return;
|
| 81 |
+
refundArea.replaceChildren();
|
| 82 |
}
|
| 83 |
}
|
| 84 |
|
| 85 |
+
// 卡片与详情页共用退款流程,成功关闭后返回列表。
|
| 86 |
+
function showRefundConfirm(itemData, currentUser, pricePaid, isCurrent) {
|
| 87 |
+
return showResourceRefundDialog({
|
| 88 |
+
itemData, currentUser, pricePaid, isCurrent,
|
| 89 |
+
onClosed: () => window.dispatchEvent(new CustomEvent("comfy-route-back"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
});
|
| 91 |
}
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
// ==========================================
|
| 94 |
// 🗑️ 删除内容功能
|
| 95 |
// ==========================================
|
|
|
|
| 102 |
// 作者本人可以删除
|
| 103 |
if (itemData.author === currentUser.account) return true;
|
| 104 |
// 管理员可以删除
|
| 105 |
+
if (currentUser.is_admin === true) return true;
|
| 106 |
return false;
|
| 107 |
}
|
| 108 |
|
| 109 |
/**
|
| 110 |
* 显示删除确认弹窗
|
| 111 |
*/
|
| 112 |
+
function showDeleteConfirm(itemData, currentUser, isCurrent) {
|
| 113 |
+
return showResourceDeleteDialog({ itemData, currentUser, isCurrent,
|
| 114 |
+
onDeleted: () => window.dispatchEvent(new CustomEvent('comfy-route-view', {
|
| 115 |
+
detail: { view: 'market', type: itemData.type || 'tool' }
|
| 116 |
+
})) });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
}
|
| 118 |
|
| 119 |
export function createItemDetailView(itemData, currentUser) {
|
|
|
|
| 128 |
backgroundColor: "#13151c"
|
| 129 |
});
|
| 130 |
|
| 131 |
+
const cachedAuthor = getCachedProfile(itemData.author);
|
| 132 |
+
const authorName = cachedAuthor?.name || itemData.author || t('common.unknown_user');
|
|
|
|
|
|
|
| 133 |
|
| 134 |
// 🚀 使用统一工具渲染单品赞赏榜单(带星星/月亮/太阳等级)
|
| 135 |
+
const boardData = Array.isArray(itemData.tip_board) ? itemData.tip_board.filter(entry => entry && typeof entry === "object" && !Array.isArray(entry)) : [];
|
| 136 |
const boardHtml = renderTipBoardHTML(boardData, 10, t('item.no_tips_yet'), "normal");
|
| 137 |
|
| 138 |
// 🗑️ 判断是否有权限删除
|
|
|
|
| 159 |
${deleteBtnHtml}
|
| 160 |
</div>
|
| 161 |
</div>
|
| 162 |
+
<div style="margin-top: 10px; color: #888;">${escapeHtml(t('item.sponsor_thanks'))}</div>
|
| 163 |
|
| 164 |
<!-- 🔄 P7后悔模式:退款区域 -->
|
| 165 |
<div id="refund-area"></div>
|
|
|
|
| 171 |
`;
|
| 172 |
|
| 173 |
// 🚀 核心逻辑:比对云端探测版本与本地安装版本
|
| 174 |
+
const localVersionStatus = getInstalledVersionStatus(itemData.id);
|
| 175 |
+
const localVersionHash = localVersionStatus.version;
|
| 176 |
const cloudVersionHash = itemData.latest_version;
|
| 177 |
const isFree = !itemData.price || itemData.price <= 0 || (currentUser && currentUser.account === itemData.author);
|
| 178 |
|
|
|
|
| 184 |
let actionBtnHtml = '';
|
| 185 |
if (IS_WEB_MODE) {
|
| 186 |
// 📱 Web 模式:保留「立即获取」按钮但行为受限(未购买仅触发购买、已购买提示电脑端安装,见资源安装引擎);不渲染重装/更新按钮
|
| 187 |
+
actionBtnHtml = `<button type="button" id="btn-use-item" class="resource-install__button resource-install__entry" data-install-state="ready">⬇️ ${t('item.get_now')} <span class="resource-install__price">${isFree ? t('item.free') : escapeHtml(itemData.price) + ' ' + t('common.credits')}</span></button>`;
|
| 188 |
+
} else if (!isUpdateAvailable && localVersionStatus.installed) {
|
| 189 |
+
actionBtnHtml = `<button type="button" id="btn-use-item" class="resource-install__button resource-install__entry" data-install-state="success">✅ ${t('item.owned_reinstall')}</button>`;
|
| 190 |
} else if (isUpdateAvailable) {
|
| 191 |
+
actionBtnHtml = `<button type="button" id="btn-use-item" class="resource-install__button resource-install__entry" data-install-state="update">♻️ ${t('item.update_available')}</button>`;
|
| 192 |
} else {
|
| 193 |
+
actionBtnHtml = `<button type="button" id="btn-use-item" class="resource-install__button resource-install__entry" data-install-state="ready">⬇️ ${t('item.get_now')} <span class="resource-install__price">${isFree ? t('item.free') : escapeHtml(itemData.price) + ' ' + t('common.credits')}</span></button>`;
|
| 194 |
}
|
| 195 |
|
| 196 |
// 🚀 返回按钮位置可调整参数:margin-left 控制右移,margin-top 控制下移
|
|
|
|
| 216 |
</div>
|
| 217 |
`;
|
| 218 |
|
| 219 |
+
// 按既有状态生成的安装入口必须实际挂载到详情页面。
|
| 220 |
+
container.insertAdjacentHTML("beforeend", actionBtnHtml);
|
| 221 |
+
const scope = createPurchaseStatusScope(container.querySelector("#refund-area"), currentUser?.account);
|
| 222 |
+
container._cleanup = () => scope.dispose();
|
| 223 |
+
scope.addCleanup(() => {
|
| 224 |
+
for (const id of ["btn-use-item", "btn-tip-item", "btn-delete-item"]) {
|
| 225 |
+
const button = container.querySelector(`#${id}`);
|
| 226 |
+
if (button) { button.onclick = null; button.remove(); }
|
| 227 |
+
}
|
| 228 |
+
container.querySelector("#inline-status-box")?.replaceChildren();
|
| 229 |
+
});
|
| 230 |
+
|
| 231 |
// 🚀 创建安装状态容器
|
| 232 |
const inlineStatusBox = document.createElement("div");
|
| 233 |
inlineStatusBox.id = "inline-status-box";
|
| 234 |
Object.assign(inlineStatusBox.style, { display: "none", marginTop: "15px", padding: "12px", background: "var(--comfy-menu-bg)", borderRadius: "6px", border: "1px solid var(--border-color, #333)", fontSize: "13px" });
|
| 235 |
container.appendChild(inlineStatusBox);
|
| 236 |
+
if (!localVersionStatus.available) {
|
| 237 |
+
inlineStatusBox.style.display = "block";
|
| 238 |
+
inlineStatusBox.setAttribute("role", "status");
|
| 239 |
+
inlineStatusBox.textContent = t('install.version_status_unavailable');
|
| 240 |
+
}
|
| 241 |
|
| 242 |
+
container.querySelector("#btn-back-detail").onclick = () => {
|
| 243 |
+
scope.dispose();
|
| 244 |
+
window.dispatchEvent(new CustomEvent("comfy-route-back"));
|
| 245 |
+
};
|
| 246 |
|
| 247 |
// 🚀 绑定安装/更新按钮事件
|
| 248 |
const btnUseItem = container.querySelector("#btn-use-item");
|
| 249 |
if (btnUseItem) {
|
| 250 |
+
const installation = setupResourceInstall(btnUseItem, itemData, currentUser, inlineStatusBox,
|
| 251 |
+
{ isCurrent: () => scope.isCurrent() });
|
| 252 |
+
scope.addCleanup(() => installation?.dispose());
|
| 253 |
}
|
| 254 |
|
| 255 |
// 🚀 绑定作品打赏按钮事件
|
| 256 |
container.querySelector("#btn-tip-item").onclick = async () => {
|
| 257 |
+
if (!scope.isCurrent()) return;
|
| 258 |
if (!currentUser) return showToast(t('feedback.login_required'), "warning");
|
| 259 |
+
await openTipModal(currentUser, { account: itemData.author }, undefined, itemData.id,
|
| 260 |
+
{ isCurrent: () => scope.isCurrent() });
|
|
|
|
|
|
|
| 261 |
};
|
| 262 |
|
| 263 |
// 🗑️ 绑定删除按钮事件
|
|
|
|
| 265 |
if (btnDeleteItem) {
|
| 266 |
btnDeleteItem.onclick = () => {
|
| 267 |
if (!currentUser) return showToast(t('feedback.login_required'), "warning");
|
| 268 |
+
if (scope.isCurrent()) showDeleteConfirm(itemData, currentUser, () => scope.isCurrent());
|
| 269 |
};
|
| 270 |
}
|
| 271 |
|
| 272 |
// 🐛 Bug修复:跳过系统页面的作者信息查询(如关于页面)
|
| 273 |
+
if (!itemData.isSystemPage && itemData.author && scope.isCurrent()) {
|
| 274 |
+
const originalNameEl = container.querySelector("#detail-author-name");
|
| 275 |
+
api.getUserProfile(itemData.author, scope.readOptions).then(res => {
|
| 276 |
+
if (!scope.isCurrent() || !originalNameEl?.isConnected || container.querySelector("#detail-author-name") !== originalNameEl) return;
|
| 277 |
+
if (res?.status !== 'success') return;
|
| 278 |
+
const profile = normalizeProfileData(res.data, itemData.author);
|
| 279 |
+
setCachedProfile(itemData.author, profile);
|
| 280 |
+
if (originalNameEl?.isConnected && container.querySelector("#detail-author-name") === originalNameEl) {
|
| 281 |
+
originalNameEl.textContent = profile.name || itemData.author;
|
| 282 |
+
}
|
| 283 |
}).catch(() => {});
|
| 284 |
}
|
| 285 |
|
| 286 |
+
// 拥有文案和退款入口使用同一次、同账号的购买快照。
|
| 287 |
+
const needsOwnership = !localVersionHash && !isFree;
|
| 288 |
+
const needsRefund = itemData.allow_refund !== false;
|
| 289 |
+
if (currentUser && scope.isCurrent() && (needsOwnership || needsRefund)) {
|
| 290 |
+
const purchaseStatus = api.getPurchaseStatus(currentUser.account, itemData.id, scope.readOptions)
|
| 291 |
+
.then(response => {
|
| 292 |
+
if (!scope.isCurrent() || !isValidPurchaseStatus(response)) return null;
|
| 293 |
+
return response;
|
| 294 |
+
}).catch(() => null);
|
| 295 |
+
if (needsRefund) renderRefundButton(container, itemData, currentUser, scope, purchaseStatus);
|
| 296 |
+
if (needsOwnership) purchaseStatus.then(response => {
|
| 297 |
+
if (!scope.isCurrent() || !response?.owned || !btnUseItem?.isConnected
|
| 298 |
+
|| container.querySelector("#btn-use-item") !== btnUseItem) return;
|
| 299 |
+
btnUseItem.textContent = `✅ ${t(IS_WEB_MODE ? 'item.owned_purchased' : 'item.owned_reinstall')}`;
|
| 300 |
+
btnUseItem.setAttribute("data-install-state", "success");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
});
|
| 302 |
}
|
| 303 |
|
| 304 |
return container;
|
| 305 |
+
}
|