diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/auth/\347\224\250\346\210\267\346\263\250\345\206\214\347\231\273\345\275\225\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/auth/\347\224\250\346\210\267\346\263\250\345\206\214\347\231\273\345\275\225\347\273\204\344\273\266.js" index 9c523e9f2262b609fe6fc2ecfc984bd2041a0b8e..1b3d96c3b5239e44eba4f0097339b3f638fcddf8 100644 --- "a/\345\211\215\347\253\257\351\241\265\351\235\242/auth/\347\224\250\346\210\267\346\263\250\345\206\214\347\231\273\345\275\225\347\273\204\344\273\266.js" +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/auth/\347\224\250\346\210\267\346\263\250\345\206\214\347\231\273\345\275\225\347\273\204\344\273\266.js" @@ -2,10 +2,13 @@ import { renderLoginForm } from "./登录表单组件.js"; import { renderRegisterForm } from "./注册表单组件.js"; import { renderResetForm } from "./重置密码表单组件.js"; import { renderRecoverForm } from "./找回账号表单组件.js"; +import { showToast } from "../components/UI交互提示组件.js"; import { t } from "../components/用户体验增强.js"; export function createAuthView(onSuccessCallback) { const container = document.createElement("div"); + let disposed = false, generation = 0, mounted = false, busy = false; + let observer; // 🔧 自适应高度:flex 填充剩余空间,内容超出时在容器内部滚动(与主界面保持一致) Object.assign(container.style, { display: "flex", flexDirection: "column", gap: "15px", color: "#ccc", @@ -22,30 +25,59 @@ export function createAuthView(onSuccessCallback) { `; container.querySelector("#btn-back-auth").onclick = () => { + container._cleanup(); window.dispatchEvent(new CustomEvent("comfy-route-back")); }; const formContainer = container.querySelector("#auth-form-container"); + const releaseForm = () => { + formContainer.querySelectorAll('input, textarea').forEach(input => { input.value = ''; }); + const cleanup = formContainer._onCleanup; formContainer._onCleanup = null; + try { cleanup?.(); } catch (_) {} + }; + container._cleanup = () => { + if (disposed) return; + disposed = true; generation++; busy = false; + observer?.disconnect(); releaseForm(); + }; const switchView = (viewState, options = {}) => { - // 🔧 定时器清理:切换视图前执行上一个表单注册的清理函数(如验证码倒计时),防止内存泄漏 - if (typeof formContainer._onCleanup === "function") { - formContainer._onCleanup(); - formContainer._onCleanup = null; - } + if (disposed) return; + releaseForm(); generation++; busy = false; + const currentGeneration = generation; + const current = () => !disposed && generation === currentGeneration && container.isConnected; + const submit = async formData => { + if (!current() || busy) return; + busy = true; + const button = formContainer.querySelector('button[id^="btn-submit"]'); + if (button) { button.disabled = true; button.setAttribute('aria-busy', 'true'); } + try { await onSuccessCallback?.(formData, { isCurrent: current }); } + catch (_) { if (current()) showToast(t('common.error'), 'error'); } + finally { + if (current()) { + busy = false; + if (button) { button.disabled = false; button.setAttribute('aria-busy', 'false'); } + } + } + }; formContainer.innerHTML = ""; if (viewState === "login") { - renderLoginForm(formContainer, switchView, onSuccessCallback); + renderLoginForm(formContainer, switchView, submit); } else if (viewState === "register") { - renderRegisterForm(formContainer, switchView, onSuccessCallback); + renderRegisterForm(formContainer, switchView, submit); } else if (viewState === "reset") { - renderResetForm(formContainer, switchView, onSuccessCallback, options.prefill); + renderResetForm(formContainer, switchView, submit, options.prefill); } else if (viewState === "recover") { renderRecoverForm(formContainer, switchView); } }; - switchView("login"); + switchView("login"); + observer = new MutationObserver(() => { + if (container.isConnected) mounted = true; + else if (mounted) container._cleanup(); + }); + observer.observe(document.body, { childList: true, subtree: true }); return container; } \ No newline at end of file diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/UI\344\272\244\344\272\222\346\217\220\347\244\272\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/UI\344\272\244\344\272\222\346\217\220\347\244\272\347\273\204\344\273\266.js" index bf8c998baf83536ced3c2f07cffeaf4fc69bde35..5779472cfccb02cc03972ea5f5b2f8edb1b6665d 100644 --- "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/UI\344\272\244\344\272\222\346\217\220\347\244\272\347\273\204\344\273\266.js" +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/UI\344\272\244\344\272\222\346\217\220\347\244\272\347\273\204\344\273\266.js" @@ -25,6 +25,14 @@ import { escapeHtml } from "../core/全局配置.js"; // Toast 队列管理(避免重叠) let toastQueue = []; let toastOffset = 50; +let dialogInstanceId = 0; +const dialogStack = []; + +function repositionToasts() { + toastQueue.forEach((toast, index) => { + toast.style.top = `${toastOffset + index * 60}px`; + }); +} /** * 显示 Toast 消息提示 @@ -34,63 +42,35 @@ let toastOffset = 50; */ export function showToast(message, type = "info", duration = 3000) { const toast = document.createElement("div"); - const colors = { - success: "#4CAF50", - error: "#F44336", - info: "#2196F3", - warning: "#FF9800" - }; - const icons = { - success: "✅", - error: "❌", - info: "ℹ️", - warning: "⚠️" - }; - - // 计算位置(避免重叠) - const topPosition = toastOffset + toastQueue.length * 60; - - Object.assign(toast.style, { - position: "fixed", - top: `${topPosition - 30}px`, - left: "50%", - transform: "translateX(-50%)", - background: colors[type] || colors.info, - color: "#fff", - padding: "12px 24px", - borderRadius: "8px", - fontSize: "14px", - fontWeight: "500", - zIndex: "10000", - boxShadow: "0 4px 20px rgba(0,0,0,0.3)", - opacity: "0", - transition: "all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)", - pointerEvents: "auto", - display: "flex", - alignItems: "center", - gap: "10px", - maxWidth: "400px", - wordBreak: "break-word" - }); + const safeType = ["success", "error", "warning", "info"].includes(type) ? type : "info"; + toast.className = `comfy-ranking-toast comfy-ranking-toast--${safeType}`; + toast.setAttribute("role", type === "error" ? "alert" : "status"); + toast.setAttribute("aria-live", safeType === "error" ? "assertive" : "polite"); + toast.setAttribute("aria-atomic", "true"); + const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; + if (reduceMotion) toast.classList.add("comfy-ranking-toast--reduced-motion"); + // 新提示从目标位置上方滑入;关闭后再按当前队列位置重排。 + toast.style.top = `${toastOffset + toastQueue.length * 60 - 30}px`; // 提示文本统一转义:message 常携带昵称/标题/后端报错等用户可控内容, // 全库无任何调用方故意向 showToast 传 HTML 标签,故无条件转义不会改变现有显示效果。 + const icons = { success: "✅", error: "❌", info: "ℹ️", warning: "⚠️" }; toast.innerHTML = ` - ${icons[type] || icons.info} - ${escapeHtml(message)} - × + + ${escapeHtml(message)} + `; document.body.appendChild(toast); toastQueue.push(toast); // 点击关闭 - toast.querySelector(".toast-close").onclick = () => closeToast(toast); + toast.querySelector(".toast-close").addEventListener("click", () => closeToast(toast)); // 入场动画 requestAnimationFrame(() => { toast.style.opacity = "1"; - toast.style.top = `${topPosition}px`; + repositionToasts(); }); // 自动关闭 @@ -99,16 +79,17 @@ export function showToast(message, type = "info", duration = 3000) { } function closeToast(toast) { + if (!toast?.isConnected || toast.classList.contains("comfy-ranking-toast--closing")) return; if (toast._autoCloseTimer) clearTimeout(toast._autoCloseTimer); - toast.style.opacity = "0"; - toast.style.transform = "translateX(-50%) translateY(-20px)"; + toast.classList.add("comfy-ranking-toast--closing"); setTimeout(() => { toast.remove(); const idx = toastQueue.indexOf(toast); if (idx > -1) toastQueue.splice(idx, 1); - }, 300); + repositionToasts(); + }, window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ? 0 : 300); } @@ -124,12 +105,9 @@ function closeToast(toast) { */ function _createDialogOverlay(bgOpacity = 0.6, blurPx = 3) { const overlay = document.createElement("div"); - Object.assign(overlay.style, { - position: "fixed", top: "0", left: "0", width: "100vw", height: "100vh", - background: `rgba(0,0,0,${bgOpacity})`, zIndex: "10001", display: "flex", - alignItems: "center", justifyContent: "center", backdropFilter: `blur(${blurPx}px)`, - opacity: "0", transition: "opacity 0.2s" - }); + overlay.className = "comfy-ranking-dialog-overlay"; + overlay.style.setProperty("--dialog-overlay-opacity", String(bgOpacity)); + overlay.style.setProperty("--dialog-blur", `${blurPx}px`); return overlay; } @@ -144,16 +122,16 @@ function _createDialogOverlay(bgOpacity = 0.6, blurPx = 3) { * @param {string} [transformOrigin] - 变换基点(影响初始 transform) * @returns {HTMLElement} */ -function _createDialogBox({ width = "360px", background = "#222", borderRadius = "12px", padding = "24px", boxShadow = "0 10px 40px rgba(0,0,0,0.5)", gap = "16px", hasYTranslate = true } = {}) { +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 } = {}) { const box = document.createElement("div"); - const initTransform = hasYTranslate ? "scale(0.9) translateY(-20px)" : "scale(0.9)"; - Object.assign(box.style, { - background, border: "1px solid #444", borderRadius, - padding, width, maxWidth: "90vw", color: "#fff", - boxShadow, - display: "flex", flexDirection: "column", gap, - transform: initTransform, transition: "transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)" - }); + box.className = "comfy-ranking-dialog"; + box.dataset.motion = hasYTranslate ? "translate" : "scale"; + box.style.setProperty("--dialog-width", width); + box.style.setProperty("--dialog-background", background); + box.style.setProperty("--dialog-radius", borderRadius); + box.style.setProperty("--dialog-padding", padding); + box.style.setProperty("--dialog-shadow", boxShadow); + box.style.setProperty("--dialog-gap", gap); return box; } @@ -164,6 +142,11 @@ function _createDialogBox({ width = "360px", background = "#222", borderRadius = * @param {boolean} hasYTranslate - 是否包含 Y 轴位移 */ function _showDialogAnimation(overlay, box, hasYTranslate = true) { + if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) { + overlay.style.opacity = "1"; + box.style.transform = hasYTranslate ? "scale(1) translateY(0)" : "scale(1)"; + return; + } requestAnimationFrame(() => { overlay.style.opacity = "1"; box.style.transform = hasYTranslate ? "scale(1) translateY(0)" : "scale(1)"; @@ -178,11 +161,75 @@ function _showDialogAnimation(overlay, box, hasYTranslate = true) { * @param {Function} [callback] - 关闭完成后的回调 */ function _closeDialog(overlay, box, hasYTranslate = true, callback) { + const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; + if (reduceMotion) { + overlay.style.transition = "none"; + box.style.transition = "none"; + } overlay.style.opacity = "0"; box.style.transform = hasYTranslate ? "scale(0.9) translateY(-20px)" : "scale(0.9)"; - setTimeout(() => { overlay.remove(); if (callback) callback(); }, 200); + setTimeout(() => { overlay.remove(); if (callback) callback(); }, reduceMotion ? 0 : 200); +} + +/** 为共用弹窗提供模态语义、焦点约束与关闭后的焦点恢复。 */ +function _setupDialogAccessibility(box, titleId, descriptionId, initialFocusSelector, onEscape) { + const previousFocus = document.activeElement; + box.setAttribute("role", "dialog"); + box.setAttribute("aria-modal", "true"); + box.setAttribute("aria-labelledby", titleId); + if (descriptionId) box.setAttribute("aria-describedby", descriptionId); + else box.removeAttribute("aria-describedby"); + box.setAttribute("tabindex", "-1"); + + const getFocusable = () => Array.from(box.querySelectorAll( + 'button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])' + )).filter(element => !element.closest('[inert], [hidden], [aria-hidden="true"]') + && element.getClientRects().length > 0 && getComputedStyle(element).visibility !== "hidden"); + + const handleKeydown = event => { + if (dialogStack[dialogStack.length - 1] !== box) return; + if (event.key === "Escape") { + event.preventDefault(); + onEscape(); + return; + } + if (event.key !== "Tab") return; + const focusable = getFocusable(); + if (!focusable.length) { + event.preventDefault(); + box.focus(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && (document.activeElement === first || !box.contains(document.activeElement))) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && (document.activeElement === last || !box.contains(document.activeElement))) { + event.preventDefault(); + first.focus(); + } + }; + dialogStack.push(box); + document.addEventListener("keydown", handleKeydown); + const initialFocus = box.querySelector(initialFocusSelector) || getFocusable()[0] || box; + initialFocus.focus(); + + return { + cleanup() { + document.removeEventListener("keydown", handleKeydown); + const stackIndex = dialogStack.lastIndexOf(box); + if (stackIndex >= 0) dialogStack.splice(stackIndex, 1); + }, + restoreFocus() { + if (previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus(); + } + }; } +// 业务弹窗复用相同的模态栈、焦点循环与恢复行为;调用方负责在关闭时 cleanup。 +export { _setupDialogAccessibility as setupDialogAccessibility }; + // ========================================== // ⚠️ 确认对话框 // ========================================== @@ -196,37 +243,49 @@ function _closeDialog(overlay, box, hasYTranslate = true, callback) { * @param {string} options.cancelText - 取消按钮文字 * @param {string} options.type - 类型: 'warning', 'danger', 'info' * @param {boolean} options.html - 是否把 message 当作可信 HTML 渲染(默认 false,即按纯文本转义) + * @param {Function} options.onConfirm - 可选异步操作,返回 false 时按未成功关闭;调用方负责业务错误提示 + * @param {string} options.processingText - 异步操作期间的本地化状态文案 + * @param {AbortSignal} [options.signal] - 强制释放原范围弹窗,不取消已提交业务操作 * @returns {Promise} */ export function showConfirm(message, options = {}) { const { - title = "操作确认", - confirmText = "确认执行", - cancelText = "取消", + title = t('common.confirm_action'), + confirmText = t('common.confirm_execute'), + cancelText = t('common.cancel'), type = "warning", - html = false + html = false, + onConfirm, + processingText = t('common.processing'), + signal } = options; const typeConfig = { - warning: { icon: "⚠️", color: "#FF9800", btnColor: "#FF9800" }, - danger: { icon: "🚨", color: "#F44336", btnColor: "#F44336" }, - info: { icon: "ℹ️", color: "#2196F3", btnColor: "#2196F3" } + warning: { icon: "⚠️" }, + danger: { icon: "🚨" }, + info: { icon: "ℹ️" } }; - const config = typeConfig[type] || typeConfig.warning; + const safeType = Object.hasOwn(typeConfig, type) ? type : "warning"; + const config = typeConfig[safeType]; + if (signal?.aborted) return Promise.resolve(false); return new Promise((resolve) => { const overlay = _createDialogOverlay(0.6, 3); const box = _createDialogBox({ width: "360px", hasYTranslate: true }); + box.dataset.type = safeType; + const dialogId = ++dialogInstanceId; + const titleId = `ui-dialog-title-${dialogId}`; + const descriptionId = `ui-dialog-description-${dialogId}`; box.innerHTML = ` -
- ${config.icon} +
+ ${escapeHtml(title)}
-
${html ? message : escapeHtml(message)}
-
- - +
${html ? message : escapeHtml(message)}
+
+ +
`; @@ -236,26 +295,65 @@ export function showConfirm(message, options = {}) { // 入场动画 _showDialogAnimation(overlay, box, true); - const close = (result) => { - _closeDialog(overlay, box, true, () => resolve(result)); + let settled = false, aborted = false; + let pending = false; + let dialogAccessibility, observer; + const abort = () => { + if (aborted) return; + aborted = true; settled = true; pending = false; + observer?.disconnect(); dialogAccessibility?.cleanup(); overlay.remove(); + signal?.removeEventListener("abort", abort); + resolve(false); }; - - // ESC 键关闭 - const handleKeydown = (e) => { - if (e.key === "Escape") { - document.removeEventListener("keydown", handleKeydown); - close(false); - } + const close = result => { + if (settled || pending) return; + settled = true; + observer?.disconnect(); + dialogAccessibility?.cleanup(); + _closeDialog(overlay, box, true, () => { + signal?.removeEventListener("abort", abort); + if (!aborted) dialogAccessibility?.restoreFocus(); + resolve(aborted ? false : result); + }); }; - document.addEventListener("keydown", handleKeydown); + + dialogAccessibility = _setupDialogAccessibility( + box, titleId, descriptionId, "#ui-btn-cancel", () => close(false) + ); + + observer = new MutationObserver(() => { if (!overlay.isConnected) abort(); }); + observer.observe(document.body, { childList: true, subtree: true }); + signal?.addEventListener("abort", abort, { once: true }); + if (signal?.aborted) { abort(); return; } box.querySelector("#ui-btn-cancel").onclick = () => { - document.removeEventListener("keydown", handleKeydown); close(false); }; - box.querySelector("#ui-btn-confirm").onclick = () => { - document.removeEventListener("keydown", handleKeydown); - close(true); + box.querySelector("#ui-btn-confirm").onclick = async () => { + if (settled || pending) return; + if (typeof onConfirm !== "function") { close(true); return; } + pending = true; + box.setAttribute("aria-busy", "true"); + box.querySelector("#ui-btn-cancel").disabled = true; + const confirmButton = box.querySelector("#ui-btn-confirm"); + confirmButton.disabled = true; + confirmButton.textContent = processingText; + const progress = document.createElement("p"); + progress.textContent = processingText; + progress.setAttribute("role", "status"); + progress.setAttribute("aria-live", "polite"); + box.querySelector(".comfy-ranking-dialog__message").appendChild(progress); + box.focus(); + let succeeded = false; + try { succeeded = (await onConfirm()) !== false; } + catch { + if (!settled) showToast(t('common.operation_failed'), "error"); + } finally { + if (settled) return; + pending = false; + box.removeAttribute("aria-busy"); + close(succeeded); + } }; }); } @@ -275,14 +373,17 @@ export function showConfirm(message, options = {}) { */ export function showRetryDialog(message, retryFn, options = {}) { const { - title = "操作失败", - retryText = "🔄 重试", - cancelText = "关闭", + title = t('common.error'), + retryText = `🔄 ${t('common.retry')}`, + cancelText = t('common.close'), html = false } = options; return new Promise((resolve) => { const overlay = _createDialogOverlay(0.7, 4); + const dialogId = ++dialogInstanceId; + const titleId = `ui-dialog-title-${dialogId}`; + const descriptionId = `ui-dialog-description-${dialogId}`; const box = _createDialogBox({ width: "400px", background: "linear-gradient(145deg, #2a2a2a, #1f1f1f)", @@ -292,20 +393,19 @@ export function showRetryDialog(message, retryFn, options = {}) { gap: "20px", hasYTranslate: false }); - // RetryDialog 的过渡时间略长 - box.style.transition = "transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)"; + box.dataset.type = "retry"; box.innerHTML = ` -
-
😕
-
${escapeHtml(title)}
+
+ +
${escapeHtml(title)}
-
+
${html ? message : escapeHtml(message)}
-
- - +
+ +
`; @@ -315,35 +415,49 @@ export function showRetryDialog(message, retryFn, options = {}) { // 入场动画 _showDialogAnimation(overlay, box, false); - const close = () => { - _closeDialog(overlay, box, false); + let settled = false; + let retryInProgress = false; + let dialogAccessibility; + const settle = result => { + if (settled) return; + settled = true; + dialogAccessibility?.cleanup(); + _closeDialog(overlay, box, false, () => { + dialogAccessibility?.restoreFocus(); + resolve(result); + }); }; + dialogAccessibility = _setupDialogAccessibility( + box, titleId, descriptionId, "#ui-btn-cancel", () => { + if (!retryInProgress) settle(false); + } + ); box.querySelector("#ui-btn-cancel").onclick = () => { - close(); - resolve(false); + settle(false); }; box.querySelector("#ui-btn-retry").onclick = async () => { const retryBtn = box.querySelector("#ui-btn-retry"); retryBtn.innerHTML = ` ${t('common.retrying')}`; retryBtn.disabled = true; + retryInProgress = true; try { await retryFn(); - close(); - resolve(true); + settle(true); } catch (error) { - // 重试失败,更新错误信息(与初次渲染保持一致:默认纯文本,仅 html=true 时按 HTML 渲染) + // 重试错误可能来自远端或扩展,即使初始提示允许富文本也必须作为纯文本处理 const errText = error.message || t('common.retry_failed'); - const errBox = box.querySelector("div[style*='background: #1a1a1a']"); + const errBox = box.querySelector(".comfy-ranking-dialog__message"); if (html) { - errBox.innerHTML = errText; + errBox.innerHTML = escapeHtml(errText); } else { errBox.textContent = errText; } retryBtn.innerHTML = escapeHtml(retryText); retryBtn.disabled = false; + retryInProgress = false; } }; }); @@ -373,7 +487,7 @@ let loadingCount = 0; * 显示全局加载遮罩 * @param {string} message - 加载提示文字 */ -export function showLoading(message = "加载中...") { +export function showLoading(message = t('feedback.processing')) { loadingCount++; if (loadingOverlay) { @@ -461,7 +575,7 @@ export function hideLoading() { * @param {boolean} loading - 是否加载中 * @param {string} loadingText - 加载时显示的文字 */ -export function setButtonLoading(button, loading, loadingText = "处理中...") { +export function setButtonLoading(button, loading, loadingText = t('feedback.processing')) { if (!button) return; if (loading) { @@ -494,7 +608,7 @@ export function setButtonLoading(button, loading, loadingText = "处理中...") * @param {string} text - 要复制的文本 * @param {string} successMsg - 成功提示 */ -export async function copyToClipboard(text, successMsg = "已复制到剪贴板") { +export async function copyToClipboard(text, successMsg = t('feedback.copied')) { try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); @@ -510,7 +624,7 @@ export async function copyToClipboard(text, successMsg = "已复制到剪贴板" } showToast(successMsg, "success"); } catch (error) { - showToast("复制失败", "error"); + showToast(t('feedback.copy_failed'), "error"); } } @@ -527,9 +641,9 @@ export async function copyToClipboard(text, successMsg = "已复制到剪贴板" */ export async function withFeedback(asyncFn, options = {}) { const { - loadingMsg = "处理中...", - successMsg = "操作成功", - errorMsg = "操作失败", + loadingMsg = t('feedback.processing'), + successMsg = t('feedback.operation_success'), + errorMsg = t('feedback.operation_failed'), showLoadingOverlay = false, button = null } = options; @@ -551,8 +665,7 @@ export async function withFeedback(asyncFn, options = {}) { return result; } catch (error) { - const msg = error.message || errorMsg; - showToast(msg, "error"); + showToast(errorMsg, "error"); throw error; } finally { if (showLoadingOverlay) { @@ -572,58 +685,113 @@ export async function withFeedback(asyncFn, options = {}) { /** * 创建安装进度条 * @param {HTMLElement} container - 进度条容器元素 - * @returns {{update: Function, complete: Function, error: Function, destroy: Function}} + * @returns {{update: Function, complete: Function, error: Function, showDiagnostics: Function, destroy: Function}} */ export function createInstallProgress(container) { // 创建进度条DOM + const preparingLabel = t('install.preparing'); const wrapper = document.createElement("div"); wrapper.className = "install-progress-wrapper"; wrapper.innerHTML = ` -
-
- 准备中... - 0% +
+
+ ${escapeHtml(preparingLabel)} + + 0%
-
-
+
+
`; container.innerHTML = ""; container.appendChild(wrapper); - const fill = wrapper.querySelector(".progress-fill"); - const stage = wrapper.querySelector(".progress-stage"); - const percent = wrapper.querySelector(".progress-percent"); + const fill = wrapper.querySelector(".install-progress__fill"); + const stage = wrapper.querySelector(".install-progress__stage"); + const elapsed = wrapper.querySelector(".install-progress__elapsed"); + const percent = wrapper.querySelector(".install-progress__percent"); + const progressTrack = wrapper.querySelector(".install-progress__track"); + const startedAt = Date.now(); + let cancelButton = null; + const updateElapsed = () => { + const totalSeconds = Math.floor((Date.now() - startedAt) / 1000); + const time = `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, "0")}`; + const elapsedLabel = t('install.elapsed', { time }); + elapsed.textContent = elapsedLabel; + elapsed.setAttribute("aria-label", elapsedLabel); + }; + const elapsedTimer = setInterval(updateElapsed, 1000); + const stopElapsedTimer = () => clearInterval(elapsedTimer); + updateElapsed(); return { update(progress, message) { const p = Math.max(0, Math.min(100, progress)); fill.style.width = `${p}%`; percent.textContent = `${p}%`; - if (message) stage.textContent = message; + progressTrack.setAttribute("aria-valuenow", String(p)); + if (message) { + stage.textContent = message; + progressTrack.setAttribute("aria-valuetext", message); + } }, complete(message) { + stopElapsedTimer(); + cancelButton?.remove(); fill.style.width = "100%"; - fill.style.background = "linear-gradient(90deg, #4CAF50, #8BC34A)"; percent.textContent = "100%"; - percent.style.color = "#4CAF50"; - stage.textContent = message || "✅ 安装完成!"; - stage.style.color = "#4CAF50"; + progressTrack.setAttribute("aria-valuenow", "100"); + wrapper.classList.add("is-complete"); + stage.textContent = message || `✅ ${t('install.complete')}`; + progressTrack.setAttribute("aria-valuetext", stage.textContent); }, error(message) { - fill.style.background = "#F44336"; - percent.style.color = "#F44336"; - stage.textContent = message || "❌ 安装失败"; - stage.style.color = "#F44336"; + stopElapsedTimer(); + cancelButton?.remove(); + wrapper.classList.add("is-error"); + stage.textContent = message || `❌ ${t('install.failed')}`; + progressTrack.setAttribute("aria-valuetext", stage.textContent); + }, + showDiagnostics(entries) { + if (!Array.isArray(entries) || entries.length === 0) return; + const details = document.createElement("details"); + details.className = "install-progress__diagnostics"; + const summary = document.createElement("summary"); + summary.textContent = t('install.diagnostics'); + details.appendChild(summary); + + const list = document.createElement("ol"); + for (const entry of entries.slice(-50)) { + const item = document.createElement("li"); + const stageKey = entry.stageKey; + const stageLabel = stageKey ? t(stageKey) : (entry.stage || t('install.stage_unknown')); + item.textContent = t('install.diagnostic_entry', { + time: entry.time, + stage: stageLabel, + progress: Number.isFinite(entry.progress) ? `${entry.progress}%` : "—" + }); + list.appendChild(item); + } + details.appendChild(list); + wrapper.appendChild(details); + }, + addCancelButton(label, onCancel) { + if (cancelButton || typeof onCancel !== "function") return; + cancelButton = document.createElement("button"); + cancelButton.type = "button"; + cancelButton.className = "install-progress__cancel"; + cancelButton.textContent = label; + cancelButton.addEventListener("click", () => { + if (cancelButton.disabled) return; + cancelButton.disabled = true; + cancelButton.textContent = t('install.cancelling'); + onCancel(); + }); + wrapper.appendChild(cancelButton); }, destroy() { + stopElapsedTimer(); wrapper.remove(); } }; diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\344\272\222\345\212\250\345\267\245\345\205\267\345\207\275\346\225\260.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\344\272\222\345\212\250\345\267\245\345\205\267\345\207\275\346\225\260.js" index f7c47a83879d68037c1b58a12c2008554683f5d2..b4e09fc3ae38bd3ea3afed711ff6f9e955e9ebaa 100644 --- "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\344\272\222\345\212\250\345\267\245\345\205\267\345\207\275\346\225\260.js" +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\344\272\222\345\212\250\345\267\245\345\205\267\345\207\275\346\225\260.js" @@ -8,6 +8,8 @@ import { showToast } from "./UI交互提示组件.js"; import { t } from "./用户体验增强.js"; +import { captureRequestSession } from "../core/网络请求_基础设施.js"; +import { getUser } from "../core/状态管理.js"; // 本模块内部也需要转义(下方 renderInteractionButtonsHTML);re-export 不会绑定本地标识符,故单独 import import { escapeHtml } from "../core/全局配置.js"; @@ -23,7 +25,7 @@ const VIEW_DEBOUNCE_MS = 300000; // 5分钟成功冷却 const VIEW_ERROR_COOLDOWN_MS = 30000; // 30秒错误冷却 // 防重复提交状态跟踪 -const _pendingActions = new Set(); +const _pendingActions = new WeakMap(); /** * 记录内容浏览量(带5分钟防抖) @@ -131,7 +133,8 @@ export function renderInteractionButtonsHTML(data, currentAccount, options = {}) * @param {string} options.activeToastKey - 激活时 toast 的 i18n key * @param {string} [options.activeTextColor] - 激活时的按钮文字色(可选) * @param {string} [options.inactiveTextColor] - 取消激活时的按钮文字色(可选) - * @param {string} [options.warnLabel] - 日志警告用标签 + * @param {string} options.inactiveAction - 服务端取消动作值 + * @param {Function} [options.isCurrent] - 原视图仍有效的检查 * @returns {Promise} */ async function _handleToggleAction(apiMethod, contentId, buttonEl, countEl, currentUser, options) { @@ -143,29 +146,37 @@ async function _handleToggleAction(apiMethod, contentId, buttonEl, countEl, curr activeToastKey, activeTextColor = null, inactiveTextColor = null, - warnLabel = 'toggle' + inactiveAction, + isCurrent: viewCurrent = () => true } = options; - if (!currentUser) { + const sessionMatches = captureRequestSession(); + const originalHandler = buttonEl?.onclick; + const current = () => sessionMatches() && viewCurrent() + && (!buttonEl || (buttonEl.isConnected && buttonEl.onclick === originalHandler)) + && (!countEl || countEl.isConnected) + && (getUser()?.account || null) === (currentUser?.account || null); + if (!current()) return null; + if (!currentUser?.account) { showToast(t('auth.login_required'), "warning"); return null; } - const actionKey = `${actionPrefix}_${contentId}`; - if (_pendingActions.has(actionKey)) return null; - _pendingActions.add(actionKey); + // 同类内容ID可在不同业务存在;按实际API方法和账号隔离在途动作。 + const actionKey = JSON.stringify([currentUser.account, actionPrefix, contentId]); + let pending = _pendingActions.get(apiMethod); + if (!pending) { pending = new Set(); _pendingActions.set(apiMethod, pending); } + if (pending.has(actionKey)) return null; + pending.add(actionKey); if (buttonEl) buttonEl.disabled = true; try { const res = await apiMethod(contentId); - - if (!res || res.status !== "success") { - console.warn(`${warnLabel}响应异常:`, res); - return null; - } - - const count = typeof res[countField] === 'number' ? res[countField] : 0; + if (!current()) return null; + if (res?.status !== "success" || !Number.isSafeInteger(res[countField]) || res[countField] < 0 + || (res.action !== activeAction && res.action !== inactiveAction)) throw new Error(); + const count = res[countField]; if (countEl) countEl.textContent = count; if (res.action === activeAction) { @@ -178,14 +189,14 @@ async function _handleToggleAction(apiMethod, contentId, buttonEl, countEl, curr if (buttonEl) buttonEl.style.borderColor = "#555"; if (buttonEl && inactiveTextColor) buttonEl.style.color = inactiveTextColor; } + if (buttonEl) buttonEl.setAttribute("aria-pressed", String(res.action === activeAction)); return res; - } catch (err) { - console.error(`${warnLabel}操作失败:`, err); - showToast(t('task.operation_failed'), "error"); + } catch (_) { + if (current()) showToast(t('task.operation_failed'), "error"); return null; } finally { - _pendingActions.delete(actionKey); - if (buttonEl) buttonEl.disabled = false; + pending.delete(actionKey); + if (current() && buttonEl) buttonEl.disabled = false; } } @@ -196,16 +207,18 @@ async function _handleToggleAction(apiMethod, contentId, buttonEl, countEl, curr * @param {HTMLElement} buttonEl - 按钮元素 * @param {HTMLElement} countEl - 计数元素 * @param {Object} currentUser - 当前用户 + * @param {Object} [options] - 可选视图检查,原五参数调用保持 * @returns {Promise} */ -export async function handleToggleLike(apiMethod, contentId, buttonEl, countEl, currentUser) { +export async function handleToggleLike(apiMethod, contentId, buttonEl, countEl, currentUser, options = {}) { return _handleToggleAction(apiMethod, contentId, buttonEl, countEl, currentUser, { actionPrefix: 'like', countField: 'likes', activeAction: 'liked', activeColor: '#FF5722', activeToastKey: 'post.liked', - warnLabel: '点赞' + inactiveAction: 'unliked', + isCurrent: options.isCurrent }); } @@ -216,9 +229,10 @@ export async function handleToggleLike(apiMethod, contentId, buttonEl, countEl, * @param {HTMLElement} buttonEl - 按钮元素 * @param {HTMLElement} countEl - 计数元素 * @param {Object} currentUser - 当前用户 + * @param {Object} [options] - 可选视图检查,原五参数调用保持 * @returns {Promise} */ -export async function handleToggleFavorite(apiMethod, contentId, buttonEl, countEl, currentUser) { +export async function handleToggleFavorite(apiMethod, contentId, buttonEl, countEl, currentUser, options = {}) { return _handleToggleAction(apiMethod, contentId, buttonEl, countEl, currentUser, { actionPrefix: 'favorite', countField: 'favorites', @@ -227,7 +241,8 @@ export async function handleToggleFavorite(apiMethod, contentId, buttonEl, count activeToastKey: 'post.favorited', activeTextColor: '#000', inactiveTextColor: '#fff', - warnLabel: '收藏' + inactiveAction: 'unfavorited', + isCurrent: options.isCurrent }); } diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\205\250\345\261\200\345\274\271\347\252\227\347\256\241\347\220\206\345\231\250.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\205\250\345\261\200\345\274\271\347\252\227\347\256\241\347\220\206\345\231\250.js" index fbb3720d7215367ed58365e9031b543930543c91..29f32bb6b6cc7a1a2f302c01e4d561c5ffc04a26 100644 --- "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\205\250\345\261\200\345\274\271\347\252\227\347\256\241\347\220\206\345\231\250.js" +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\205\250\345\261\200\345\274\271\347\252\227\347\256\241\347\220\206\345\231\250.js" @@ -1,138 +1,92 @@ -// 前端页面/components/全局弹窗管理器.js +// 共用业务弹窗:与确认/重试提示复用同一个可访问性栈。 +import { setupDialogAccessibility } from "./UI交互提示组件.js"; +import { t } from "./用户体验增强.js"; +let modalSequence = 0; export class ModalManager { constructor() { - this.activeModals = []; // 用于存储当前打开的所有弹窗,实现层级管理 + this.activeModals = []; + this.entries = new Map(); this.overlay = null; + this.observer = null; this.initOverlay(); } - // 初始化全局半透明遮罩层 initOverlay() { - if (document.getElementById("community-hub-overlay")) return; - - this.overlay = document.createElement("div"); - this.overlay.id = "community-hub-overlay"; - Object.assign(this.overlay.style, { - position: "fixed", - top: "0", - left: "0", - width: "100vw", - height: "100vh", - backgroundColor: "rgba(0, 0, 0, 0.7)", - zIndex: "9998", // 位于 ComfyUI 画布之上,但低于侧边栏和弹窗 - display: "none", - backdropFilter: "blur(3px)" // 添加背景模糊效果 - }); - - // 点击遮罩层时,关闭最顶层的一个弹窗 - this.overlay.onclick = () => { - try { this.closeTopModal(); } catch(e) { console.warn('[ModalManager] closeTopModal error:', e); } - }; - - document.body.appendChild(this.overlay); + if (!document.getElementById('hub-theme-style')) { + const link = document.createElement('link'); + link.id = 'hub-theme-style'; link.rel = 'stylesheet'; + link.href = new URL('../styles/主题基础.css', import.meta.url).href; + document.head.appendChild(link); + } + this.overlay = document.getElementById('community-hub-overlay'); + if (!this.overlay) { + this.overlay = document.createElement('div'); + this.overlay.id = 'community-hub-overlay'; + this.overlay.className = 'ranking-modal-overlay'; + this.overlay.hidden = true; + document.body.appendChild(this.overlay); + } + this.overlay.onclick = () => this.closeTopModal(); } - /** - * 打开一个新的弹窗 - * @param {string} title 弹窗标题 - * @param {HTMLElement} contentElement 包含实际内容的 DOM 元素 - * @param {object} options 配置项 (如宽度) - */ - openModal(title, contentElement, options = { width: "500px" }) { - const modalContainer = document.createElement("div"); - Object.assign(modalContainer.style, { - position: "fixed", - top: "50%", - left: "50%", - transform: "translate(-50%, -50%)", - width: options.width, - backgroundColor: "var(--comfy-menu-bg)", - border: "1px solid #555", - borderRadius: "12px", - boxShadow: "0 10px 30px rgba(0,0,0,0.8)", - zIndex: (9999 + this.activeModals.length).toString(), // 动态计算 z-index,实现层级覆盖 - display: "flex", - flexDirection: "column", - overflow: "hidden" - }); - - // 弹窗头部 (包含标题和关闭按钮) - const header = document.createElement("div"); - Object.assign(header.style, { - display: "flex", - justifyContent: "space-between", - alignItems: "center", - padding: "15px 20px", - borderBottom: "1px solid #444", - backgroundColor: "var(--comfy-input-bg)", - }); - - const titleSpan = document.createElement("span"); - titleSpan.innerText = title; - Object.assign(titleSpan.style, { - fontWeight: "bold", - fontSize: "16px", - color: "#fff" - }); - - const closeBtn = document.createElement("button"); - closeBtn.innerHTML = "✖"; - Object.assign(closeBtn.style, { - background: "transparent", - border: "none", - color: "#aaa", - cursor: "pointer", - fontSize: "14px" + syncLayers() { + const top = this.activeModals.at(-1); + this.activeModals.forEach((modal, index) => { + modal.style.zIndex = String(9999 + index); + modal.inert = modal !== top; + if (modal === top) { modal.removeAttribute('aria-hidden'); modal.setAttribute('aria-modal', 'true'); } + else { modal.setAttribute('aria-hidden', 'true'); modal.removeAttribute('aria-modal'); } }); - // 绑定关闭当前弹窗事件,防止事件冒泡触发底层点击 - closeBtn.onclick = (e) => { - e.stopPropagation(); - this.closeModal(modalContainer); - }; - - header.appendChild(titleSpan); - header.appendChild(closeBtn); - - // 弹窗内容区 - const contentWrapper = document.createElement("div"); - Object.assign(contentWrapper.style, { - padding: "20px", - maxHeight: "70vh", // 防止弹窗过高,限制最大高度 - overflowY: "auto", - color: "#eee" - }); - contentWrapper.appendChild(contentElement); - - modalContainer.appendChild(header); - modalContainer.appendChild(contentWrapper); - document.body.appendChild(modalContainer); - - // 记录状态并显示遮罩 - this.activeModals.push(modalContainer); - this.overlay.style.display = "block"; + this.overlay.hidden = !top; } - // 关闭指定的弹窗 - closeModal(modalElement) { - if (!modalElement) return; - modalElement.remove(); - this.activeModals = this.activeModals.filter(m => m !== modalElement); - - // 如果没有打开的弹窗了,隐藏遮罩层 - if (this.activeModals.length === 0) { - this.overlay.style.display = "none"; + openModal(title, contentElement, options = { width: '500px' }) { + const modal = document.createElement('div'); + modal.className = 'ranking-modal'; + modal.style.setProperty('--ranking-modal-width', options.width || '500px'); + const header = document.createElement('div'); header.className = 'ranking-modal__header'; + const titleNode = document.createElement('span'); titleNode.className = 'ranking-modal__title'; + titleNode.id = `ranking-modal-title-${++modalSequence}`; titleNode.textContent = title; + const close = document.createElement('button'); close.type = 'button'; + close.className = 'ranking-modal__close'; close.textContent = '✖'; + close.setAttribute('aria-label', t('common.close')); + close.onclick = event => { event.stopPropagation(); this.closeModal(modal); }; + header.append(titleNode, close); + const body = document.createElement('div'); body.className = 'ranking-modal__body'; body.appendChild(contentElement); + modal.append(header, body); document.body.appendChild(modal); + this.activeModals.push(modal); this.syncLayers(); + const accessibility = setupDialogAccessibility(modal, titleNode.id, null, '.ranking-modal__close', () => this.closeModal(modal)); + this.entries.set(modal, { content: contentElement, accessibility }); + if (!this.observer) { + this.observer = new MutationObserver(() => { + for (const current of [...this.activeModals]) if (!current.isConnected) this.closeModal(current); + }); + this.observer.observe(document.body, { childList: true, subtree: true }); } + return modal; } - // 关闭位于最顶层的弹窗 (通常用于点击遮罩层或按 ESC 键时触发) - closeTopModal() { - if (this.activeModals.length > 0) { - const topModal = this.activeModals[this.activeModals.length - 1]; - this.closeModal(topModal); + closeModal(modal) { + const entry = this.entries.get(modal); + if (!entry) return; + const wasTop = this.activeModals.at(-1) === modal; + const restore = wasTop && (modal.contains(document.activeElement) || document.activeElement === document.body); + this.entries.delete(modal); + entry.accessibility.cleanup(); + try { entry.content._cleanup?.(); } catch (_) {} + modal.remove(); + this.activeModals = this.activeModals.filter(current => current !== modal); + this.syncLayers(); + if (!this.activeModals.length) { this.observer?.disconnect(); this.observer = null; } + if (restore) { + entry.accessibility.restoreFocus(); + const top = this.activeModals.at(-1); + if (top && !top.contains(document.activeElement)) top.querySelector('.ranking-modal__close').focus(); } } + + closeTopModal() { this.closeModal(this.activeModals.at(-1)); } } -// 导出一个全局单例,确保整个应用只实例化一次 -export const globalModal = new ModalManager(); \ No newline at end of file +export const globalModal = new ModalManager(); diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\205\263\344\272\216\346\217\222\344\273\266\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\205\263\344\272\216\346\217\222\344\273\266\347\273\204\344\273\266.js" index 7f39594c2c858a06c86be2e6df24d7c2a684020f..0b76bf01370a4820dc586c7d31d9fcefeb6e1883 100644 --- "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\205\263\344\272\216\346\217\222\344\273\266\347\273\204\344\273\266.js" +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\205\263\344\272\216\346\217\222\344\273\266\347\273\204\344\273\266.js" @@ -1,352 +1,180 @@ -// 前端页面/components/关于插件组件.js -import { api, request } from "../core/网络请求API.js"; -import { t } from "./用户体验增强.js"; - -// 🏷️ 版本配置缓存 -let cachedVersionConfig = null; - -// 清除版本配置缓存(管理员修改版本后调用) -export function clearVersionCache() { - cachedVersionConfig = null; -} - -// 🎨 卡片基础样式常量(authorSection 与 createCardSection 共享) -const CARD_BASE_STYLE = { - background: "rgba(255,255,255,0.03)", - border: "1px solid rgba(255,255,255,0.06)", - borderRadius: "10px", - padding: "16px" -}; - -// 获取版本配置 -async function getVersionConfig() { - if (cachedVersionConfig) return cachedVersionConfig; - - try { - const res = await request('/api/public/project-version'); - if (res?.data) { - cachedVersionConfig = res.data; - return cachedVersionConfig; - } - } catch (e) { - console.warn('获取版本配置失败:', e); - } - - // 默认值 - return { stage: 'beta', major: 2, minor: 0, patch: 0 }; -} - -// 生成版本字符串 -export function formatVersionString(config) { - const stageLabels = { - 'alpha': t('about.stage_alpha'), - 'beta': t('about.stage_beta'), - 'rc': t('about.stage_rc'), - 'stable': t('about.stage_stable') - }; - const version = `V${config.major}.${config.minor}.${config.patch}`; - const stageLabel = stageLabels[config.stage] || config.stage; - return `${version} ${stageLabel}`; -} - -// 获取阶段本地化名称 -export function getStageLabel(stage) { - const stageLabels = { - 'alpha': t('about.stage_alpha'), - 'beta': t('about.stage_beta'), - 'rc': t('about.stage_rc'), - 'stable': t('about.stage_stable') - }; - return stageLabels[stage] || stage; -} - -// ========================================== -// 🎨 创建关于页面独立视图 -// ========================================== -export function createAboutView(versionString, stageLabel) { - const container = document.createElement("div"); - Object.assign(container.style, { - display: "flex", - flexDirection: "column", - flex: "1", - overflowY: "auto", - color: "#ccc", - fontSize: "14px", - padding: "16px", - boxSizing: "border-box", - gap: "16px", - background: "transparent" - }); - - // ===== 1. Hero 品牌区 ===== - const heroSection = document.createElement("div"); - Object.assign(heroSection.style, { - background: "linear-gradient(180deg, rgba(0, 212, 170, 0.15) 0%, transparent 100%)", - borderRadius: "10px", - padding: "28px 20px", - textAlign: "center", - border: "1px solid rgba(255,255,255,0.06)" - }); - heroSection.innerHTML = ` -
${t('about.title')}
-
Community Hub
-
${versionString}
-
${t('about.subtitle')}
- `; - - // ===== 2. 核心优势区 ===== - const advantageSection = createCardSection("✨ " + t('about.advantages_title'), ` -
-
-
🔗
-
${t('about.adv_native_title')}
-
${t('about.adv_native_desc')}
-
-
-
⚡
-
${t('about.adv_speed_title')}
-
${t('about.adv_speed_desc')}
-
-
-
💬
-
${t('about.adv_im_title')}
-
${t('about.adv_im_desc')}
-
-
-
🛡️
-
${t('about.adv_sandbox_title')}
-
${t('about.adv_sandbox_desc')}
-
-
- `); - - // ===== 3. 主要功能区 ===== - const featureSection = createCardSection("🎯 " + t('about.features_title'), ` -
-
-
1.${t('about.feat_publish_title')}
-
${t('about.feat_publish_desc')}
-
-
-
2.${t('about.feat_social_title')}
-
${t('about.feat_social_desc')}
-
-
-
3.${t('about.feat_install_title')}
-
${t('about.feat_install_desc')}
-
-
-
4.${t('about.feat_download_title')}
-
${t('about.feat_download_desc')}
-
-
-
5.${t('about.feat_chat_title')}
-
${t('about.feat_chat_desc')}
-
-
-
6.${t('about.feat_account_title')}
-
${t('about.feat_account_desc')}
-
-
-
7.${t('about.feat_payment_title')}
-
${t('about.feat_payment_desc')}
-
-
-
8.${t('about.feat_protection_title')}
-
${t('about.feat_protection_desc')}
-
-
- `); - - // ===== 4. 使用指南区 ===== - const guideSection = createCardSection("📖 " + t('about.guide_title'), ` -
-
- 1. -
-
${t('about.guide_install_title')}
-
${t('about.guide_install_desc')}
-
-
-
- 2. -
-
${t('about.guide_path_title')}
-
${t('about.guide_path_desc')}
-
-
-
- 3. -
-
${t('about.guide_update_title')}
-
${t('about.guide_update_desc')}
-
-
-
- 4. -
-
${t('about.guide_publish_title')}
-
${t('about.guide_publish_desc')}
-
-
-
- 5. -
-
${t('about.guide_buy_title')}
-
${t('about.guide_buy_desc')}
-
-
-
- 6. -
-
${t('about.guide_refund_title')}
-
${t('about.guide_refund_desc')}
-
-
-
- 7. -
-
${t('about.guide_withdraw_title')}
-
${t('about.guide_withdraw_desc')}
-
-
-
- `); - - // ===== 5. 用户价值区 ===== - const userValueSection = createCardSection("👤 " + t('about.benefits_title'), ` -
-
- 1. - ${t('about.benefit_1')} -
-
- 2. - ${t('about.benefit_2')} -
-
- 3. - ${t('about.benefit_3')} -
-
- 4. - ${t('about.benefit_4')} -
-
- 5. - ${t('about.benefit_5')} -
-
- `); - - // ===== 6. 注意事项区 ===== - const warningSection = document.createElement("div"); - Object.assign(warningSection.style, { - background: "rgba(240, 160, 48, 0.05)", - borderRadius: "10px", - padding: "16px", - borderTop: "1px solid rgba(240, 160, 48, 0.15)", - borderRight: "1px solid rgba(240, 160, 48, 0.15)", - borderBottom: "1px solid rgba(240, 160, 48, 0.15)", - borderLeft: "3px solid #f0a030" - }); - warningSection.innerHTML = ` -
- ⚠️ ${t('about.warning_title')} -
-
-
- ${t('about.warning_login_title')}${t('about.warning_login_desc')} -
-
- ${t('about.warning_account_title')}${t('about.warning_account_desc')} -
-
- `; - - // ===== 7. 关于作者区 ===== - const authorSection = document.createElement("div"); - Object.assign(authorSection.style, { ...CARD_BASE_STYLE, textAlign: "center" }); - authorSection.innerHTML = ` -
${t('about.author_title')}
- - Bilibili - -
- ${t('about.author_thanks')} -
- `; - - // ===== 返回按钮 ===== - const backBtn = document.createElement("button"); - Object.assign(backBtn.style, { - background: "rgba(51,51,51,0.8)", - border: "1px solid rgba(85,85,85,0.8)", - color: "#fff", - padding: "6px 14px", - borderRadius: "6px", - cursor: "pointer", - fontSize: "13px", - fontWeight: "bold", - display: "flex", - alignItems: "center", - gap: "6px", - boxShadow: "0 2px 4px rgba(0,0,0,0.3)", - width: "fit-content", - transition: "0.2s", - marginLeft: "14px", - marginTop: "14px", - marginBottom: "4px" - }); - backBtn.innerHTML = "⬅ " + t('common.back'); - backBtn.onmouseover = () => { - backBtn.style.background = "#4CAF50"; - backBtn.style.borderColor = "#4CAF50"; - }; - backBtn.onmouseout = () => { - backBtn.style.background = "rgba(51,51,51,0.8)"; - backBtn.style.borderColor = "rgba(85,85,85,0.8)"; - }; - backBtn.onclick = () => { - window.dispatchEvent(new CustomEvent("comfy-route-back")); - }; - - // 组装 - container.appendChild(backBtn); - container.appendChild(heroSection); - container.appendChild(advantageSection); - container.appendChild(featureSection); - container.appendChild(guideSection); - container.appendChild(userValueSection); - container.appendChild(warningSection); - container.appendChild(authorSection); - - return container; -} - -// 辅助函数:创建带标题的卡片分区(titleHtml 支持纯文本或 HTML) -function createCardSection(titleHtml, contentHtml) { - const section = document.createElement("div"); - Object.assign(section.style, CARD_BASE_STYLE); - section.innerHTML = ` -
${titleHtml}
- ${contentHtml} - `; - return section; -} - -// ========================================== -// 🚀 显示关于信息(通过路由系统) -// ========================================== -export async function showAboutInfo(currentUser) { - // 动态获取版本配置 - const versionConfig = await getVersionConfig(); - const versionString = formatVersionString(versionConfig); - const stageLabel = getStageLabel(versionConfig.stage); - - const view = createAboutView(versionString, stageLabel); - window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } })); -} - -// 导出版本获取函数供其他组件使用 -export { getVersionConfig }; +// 关于页面:纯文本内容、共享主题与受控的公开版本刷新。 +import { request } from "../core/网络请求API.js"; +import { UI } from "../core/全局配置.js"; +import { removeCacheByPrefix } from "../core/网络请求_缓存管理.js"; +import { t } from "./用户体验增强.js"; + +let cachedVersionConfig = null; +let pendingVersionRequest = null; +let versionGeneration = 0; +let nextViewId = 0; + +function normalizeVersion(config) { + if (!config || typeof config !== "object" || Array.isArray(config)) return null; + if (!["alpha", "beta", "rc", "stable"].includes(config.stage)) return null; + if (![config.major, config.minor, config.patch].every(value => Number.isSafeInteger(value) && value >= 0)) return null; + return { stage: config.stage, major: config.major, minor: config.minor, patch: config.patch }; +} + +export function clearVersionCache() { + versionGeneration++; + cachedVersionConfig = null; + pendingVersionRequest = null; + removeCacheByPrefix('api_/api/public/project-version'); +} + +export async function getVersionConfig() { + if (cachedVersionConfig) return { ...cachedVersionConfig }; + if (!pendingVersionRequest) { + const generation = versionGeneration; + const pending = Promise.resolve().then(() => request('/api/public/project-version', + generation > 0 ? { noCache: true } : undefined)).then(res => { + const config = res?.status === "success" ? normalizeVersion(res.data) : null; + if (!config) throw new Error("Invalid project version"); + if (generation !== versionGeneration) return cachedVersionConfig || UI.DEFAULT_PROJECT_VERSION; + cachedVersionConfig = Object.freeze(config); + return config; + }).catch(() => { + console.warn('公开版本信息无法刷新'); + return { ...UI.DEFAULT_PROJECT_VERSION }; + }).finally(() => { + if (pendingVersionRequest === pending) pendingVersionRequest = null; + }); + pendingVersionRequest = pending; + } + return { ...await pendingVersionRequest }; +} + +export function getStageLabel(stage) { + switch (stage) { + case "alpha": return t('about.stage_alpha'); + case "beta": return t('about.stage_beta'); + case "rc": return t('about.stage_rc'); + case "stable": return t('about.stage_stable'); + default: return t('about.stage_unknown'); + } +} + +export function formatVersionString(config) { + const version = normalizeVersion(config); + if (!version) return t('about.version_unknown'); + return `V${version.major}.${version.minor}.${version.patch} ${getStageLabel(version.stage)}`; +} + +function element(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +function section(title, children) { + const node = element('section', 'about-section'); + const heading = element('h2', 'about-section__title', title); + heading.id = `ranking-about-heading-${++nextViewId}`; + node.setAttribute('aria-labelledby', heading.id); + node.append(heading, ...children); + return node; +} + +function grid(entries, ordered = false) { + const node = element(ordered ? 'ol' : 'ul', 'about-grid'); + for (const [title, description, icon] of entries) { + const item = element('li', 'about-grid__item'); + if (icon) { + const decoration = element('span', 'about-grid__icon', icon); + decoration.setAttribute('aria-hidden', 'true'); + item.appendChild(decoration); + } + item.append(element('h3', 'about-item__title', title), element('p', 'about-description', description)); + node.appendChild(item); + } + return node; +} + +export function createAboutView(versionString, stageLabel) { + const container = element('div', 'about-view'); + const back = element('button', 'about-back', '⬅ ' + t('common.back')); + back.type = 'button'; + back.addEventListener('click', () => window.dispatchEvent(new CustomEvent('comfy-route-back'))); + const hero = element('header', 'about-hero'); + const title = element('h1', 'about-hero__title', t('about.title')); + title.id = `ranking-about-title-${++nextViewId}`; + container.setAttribute('aria-labelledby', title.id); + hero.append(title, element('p', 'about-hero__brand', t('about.brand')), + element('p', 'about-version', typeof versionString === 'string' ? versionString : t('about.version_unknown')), + element('p', 'about-description', t('about.subtitle'))); + const advantages = section(t('about.advantages_title'), [grid([ + [t('about.adv_native_title'), t('about.adv_native_desc'), '🔗'], + [t('about.adv_speed_title'), t('about.adv_speed_desc'), '⚡'], + [t('about.adv_im_title'), t('about.adv_im_desc'), '💬'], + [t('about.adv_sandbox_title'), t('about.adv_sandbox_desc'), '🛡️'] + ])]); + const features = section(t('about.features_title'), [grid([ + [t('about.feat_publish_title'), t('about.feat_publish_desc')], + [t('about.feat_social_title'), t('about.feat_social_desc')], + [t('about.feat_install_title'), t('about.feat_install_desc')], + [t('about.feat_download_title'), t('about.feat_download_desc')], + [t('about.feat_chat_title'), t('about.feat_chat_desc')], + [t('about.feat_account_title'), t('about.feat_account_desc')], + [t('about.feat_payment_title'), t('about.feat_payment_desc')], + [t('about.feat_protection_title'), t('about.feat_protection_desc')] + ], true)]); + const guideList = element('ol', 'about-list'); + for (const [heading, description] of [ + [t('about.guide_install_title'), t('about.guide_install_desc')], + [t('about.guide_path_title'), t('about.guide_path_desc')], + [t('about.guide_update_title'), t('about.guide_update_desc')], + [t('about.guide_publish_title'), t('about.guide_publish_desc')], + [t('about.guide_buy_title'), t('about.guide_buy_desc')], + [t('about.guide_refund_title'), t('about.guide_refund_desc')], + [t('about.guide_withdraw_title'), t('about.guide_withdraw_desc')] + ]) { + const item = element('li'); + item.append(element('h3', 'about-item__title', heading), element('p', 'about-description', description)); + guideList.appendChild(item); + } + const benefits = element('ol', 'about-list'); + for (const text of [t('about.benefit_1'), t('about.benefit_2'), t('about.benefit_3'), t('about.benefit_4'), t('about.benefit_5')]) { + benefits.appendChild(element('li', 'about-benefit', text)); + } + const warningCopy = []; + for (const [heading, description] of [ + [t('about.warning_login_title'), t('about.warning_login_desc')], + [t('about.warning_account_title'), t('about.warning_account_desc')] + ]) { + const paragraph = element('p', 'about-warning__copy'); + paragraph.append(element('strong', '', heading), document.createTextNode(description)); + warningCopy.push(paragraph); + } + const warning = section(t('about.warning_title'), warningCopy); + warning.classList.add('about-section--warning'); + const authorLink = element('a', 'about-author__link'); + authorLink.href = UI.ABOUT.AUTHOR_URL; + authorLink.target = '_blank'; + authorLink.rel = 'noopener noreferrer'; + const badge = element('img', 'about-author__badge'); + badge.src = UI.ABOUT.AUTHOR_BADGE_URL; + badge.alt = ''; + badge.loading = 'lazy'; + authorLink.appendChild(badge); + // 保留文字入口,即使远端徽章图片无法加载也可操作。 + authorLink.appendChild(element('span', '', t('about.author_link'))); + const author = section(t('about.author_title'), [authorLink, element('p', 'about-description', t('about.author_thanks'))]); + author.classList.add('about-section--author'); + container.append(back, hero, advantages, features, section(t('about.guide_title'), [guideList]), + section(t('about.benefits_title'), [benefits]), warning, author); + return container; +} + +export async function showAboutInfo(currentUser) { + const generation = versionGeneration; + const view = createAboutView(formatVersionString(cachedVersionConfig || UI.DEFAULT_PROJECT_VERSION)); + window.dispatchEvent(new CustomEvent('comfy-route-view', { detail: { view } })); + // 先显示完整页面,网络只补新原节点;不在请求完成后重新导航。 + getVersionConfig().then(config => { + if (view.isConnected && generation === versionGeneration) { + view.querySelector('.about-version').textContent = formatVersionString(config); + } + }); +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\212\250\347\224\273\351\237\263\346\225\210\345\274\225\346\223\216.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\212\250\347\224\273\351\237\263\346\225\210\345\274\225\346\223\216.js" index e417510c88d1bda848dc5464d4212696a70d430d..6b1d2fe28c68ac66d775848e232469a1eb77d511 100644 --- "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\212\250\347\224\273\351\237\263\346\225\210\345\274\225\346\223\216.js" +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\212\250\347\224\273\351\237\263\346\225\210\345\274\225\346\223\216.js" @@ -13,6 +13,16 @@ import { getSettings } from "./全局设置组件.js"; +function prefersReducedMotion() { + try { return window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true; } + catch { return false; } +} + +export function scrollCardIntoView(card) { + const instant = !getSettings().enableAnimations || prefersReducedMotion(); + card.scrollIntoView({ behavior: instant ? 'instant' : 'smooth', block: 'center' }); +} + // ========================================== // 🔊 音效管理 // ========================================== @@ -360,7 +370,7 @@ function injectAnimationStyles() { */ export function applyCardAnimation(card, animationType, index, totalVisible = 10, silent = false) { const settings = getSettings(); - if (!settings.enableAnimations) return; + if (!settings.enableAnimations || prefersReducedMotion()) return; // 确保样式已注入 injectAnimationStyles(); @@ -436,6 +446,12 @@ export function applyCardAnimation(card, animationType, index, totalVisible = 10 // 容器 -> 观察器映射(同一视图的首次渲染与分页追加复用同一观察器) const _viewportObservers = new WeakMap(); +export function stopViewportAnimations(container) { + const observer = _viewportObservers.get(container); + _viewportObservers.delete(container); + observer?.disconnect(); +} + /** * ✨ 视口感知卡片动画: * - 已在视口内的卡片立即依次错开播放载入动画 @@ -448,23 +464,23 @@ const _viewportObservers = new WeakMap(); */ export function applyViewportAnimations(container, cards, animationType, isFirstBatch = false) { const settings = getSettings(); - if (!settings.enableAnimations) return; + if (!settings.enableAnimations || prefersReducedMotion()) { + stopViewportAnimations(container); + return; + } // 确保样式已注入 injectAnimationStyles(); // 视图重建:停止上一批残留卡片的补播观察 if (isFirstBatch) { - const oldObserver = _viewportObservers.get(container); - if (oldObserver) { - oldObserver.disconnect(); - _viewportObservers.delete(container); - } + stopViewportAnimations(container); } let observer = _viewportObservers.get(container); if (!observer) { observer = new IntersectionObserver((entries) => { + if (_viewportObservers.get(container) !== observer) return; // 本批进入视口的卡片,按文档顺序(从上到下)排列后错开播放 const entering = entries .filter(e => e.isIntersecting) diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\215\241\347\211\207\345\257\274\350\210\252\347\224\237\345\221\275\345\221\250\346\234\237.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\215\241\347\211\207\345\257\274\350\210\252\347\224\237\345\221\275\345\221\250\346\234\237.js" new file mode 100644 index 0000000000000000000000000000000000000000..4ffb432ce18f3e4673ebeb18dcd92406118ea2b9 --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\215\241\347\211\207\345\257\274\350\210\252\347\224\237\345\221\275\345\221\250\346\234\237.js" @@ -0,0 +1,32 @@ +// 按视图管理导航代次,不清理榜单数据或滚动位置。 +export function createCardNavigationScope(target, isCurrent, { captureSession = () => () => true, storageKeys = [] } = {}) { + let generation = 0; + let disposed = false; + let identityRevoked = false; + const isViewSessionCurrent = captureSession(); + const events = ['comfy-route-view', 'comfy-route-back']; + const identityEvents = ['comfy-user-login', 'comfy-user-logout', 'comfy-ranking-auth-expired']; + const invalidate = () => { generation++; }; + const revokeIdentity = () => { identityRevoked = true; invalidate(); }; + const onStorage = event => { + if (event.key === null || storageKeys.includes(event.key)) revokeIdentity(); + }; + for (const event of events) target.addEventListener(event, invalidate); + for (const event of identityEvents) target.addEventListener(event, revokeIdentity); + target.addEventListener('storage', onStorage); + return { + capture() { + const captured = generation; + const isSessionCurrent = captureSession(); + return () => !disposed && !identityRevoked && captured === generation && isCurrent() && isViewSessionCurrent() && isSessionCurrent(); + }, + dispose() { + if (disposed) return; + disposed = true; + generation++; + for (const event of events) target.removeEventListener(event, invalidate); + for (const event of identityEvents) target.removeEventListener(event, revokeIdentity); + target.removeEventListener('storage', onStorage); + }, + }; +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\233\276\347\211\207\345\261\225\347\244\272\346\265\213\350\257\225.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\233\276\347\211\207\345\261\225\347\244\272\346\265\213\350\257\225.js" index 7b2cf8af198eccae792e5c2bfe433f6265bb6744..bea3a5cb961f77a030ffaedd1b0c836dd85ef36b 100644 --- "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\233\276\347\211\207\345\261\225\347\244\272\346\265\213\350\257\225.js" +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\233\276\347\211\207\345\261\225\347\244\272\346\265\213\350\257\225.js" @@ -7,6 +7,7 @@ // ========================================== import { escapeHtml } from "../core/全局配置.js"; +import { t } from "./用户体验增强.js"; // ========================================== // 🎨 CSS样式定义(CSS-in-JS模式) @@ -176,12 +177,24 @@ const VIEWER_STYLES = ` opacity: 0; transition: opacity 0.3s ease; z-index: 15; + border: 0; + padding: 0; + color: inherit; + font: inherit; } .cyber-nav-zone:hover { opacity: 1; } +.cyber-nav-zone:focus-visible, +.cyber-close-btn:focus-visible, +.cyber-autoplay-btn:focus-visible { + opacity: 1; + outline: 3px solid #00ff96; + outline-offset: -4px; +} + .cyber-nav-zone.left { left: 0; background: linear-gradient(90deg, rgba(255, 255, 255, 0.05) 0%, transparent 100%); @@ -256,6 +269,9 @@ const VIEWER_STYLES = ` border-radius: 4px; overflow: hidden; cursor: pointer; + border: 0; + padding: 0; + background: transparent; border: 2px solid transparent; transition: all 0.3s ease; opacity: 0.5; @@ -277,6 +293,11 @@ const VIEWER_STYLES = ` opacity: 1; } +.cyber-thumb:focus-visible { + outline: 3px solid #00ff96; + outline-offset: 2px; +} + /* 键盘提示 */ .cyber-keyboard-hint { font-size: 12px; @@ -393,6 +414,7 @@ class CyberImageViewer { this._autoPlayTimerId = null; this._autoPlayInterval = 1000; // 默认1秒展示时间 this._autoPlayStateChangeCallbacks = []; // 状态变化回调 + this._returnFocus = document.activeElement; } /** @@ -436,11 +458,15 @@ class CyberImageViewer { // 创建主容器 this.container = document.createElement('div'); this.container.className = 'cyber-image-viewer'; + this.container.setAttribute('role', 'dialog'); + this.container.setAttribute('aria-modal', 'true'); + this.container.setAttribute('aria-label', t('image.viewer.dialog')); + this.container.tabIndex = -1; this.container.style.background = background; this.container.innerHTML = ` - +
@@ -451,23 +477,23 @@ class CyberImageViewer {
-
+
-
+ +
+
-
+
1 / ${this.imageUrls.length}
-
- ← 上一张 - → 下一张 - ESC 关闭 + ← ${escapeHtml(t('image.viewer.previous_short'))} + → ${escapeHtml(t('image.viewer.next_short'))} + ESC ${escapeHtml(t('image.viewer.close_short'))}
@@ -587,12 +613,12 @@ class CyberImageViewer { if (this._autoPlayEnabled) { btn.classList.add('active'); - btn.title = '暂停自动播放 (空格)'; + btn.title = t('image.viewer.pause'); playIcon.style.display = 'none'; pauseIcon.style.display = 'block'; } else { btn.classList.remove('active'); - btn.title = '自动播放 (空格)'; + btn.title = t('image.viewer.autoplay'); playIcon.style.display = 'block'; pauseIcon.style.display = 'none'; } @@ -736,6 +762,18 @@ class CyberImageViewer { _bindEvents() { // 键盘导航 this._keydownHandler = (e) => { + if (e.key === 'Tab') { + const controls = [...this.container.querySelectorAll('button:not([disabled]), [tabindex="0"]')]; + if (!controls.length) { e.preventDefault(); this.container.focus(); return; } + const first = controls[0]; + const last = controls[controls.length - 1]; + if (e.shiftKey && (document.activeElement === first || !this.container.contains(document.activeElement))) { + e.preventDefault(); last.focus(); + } else if (!e.shiftKey && (document.activeElement === last || !this.container.contains(document.activeElement))) { + e.preventDefault(); first.focus(); + } + return; + } if (e.key === 'Escape') { this.close(); } else if (e.key === 'ArrowLeft') { @@ -785,8 +823,11 @@ class CyberImageViewer { const maxThumbs = Math.min(this.imageUrls.length, 7); for (let i = 0; i < maxThumbs; i++) { - const thumb = document.createElement('div'); + const thumb = document.createElement('button'); + thumb.type = 'button'; thumb.className = 'cyber-thumb' + (i === this.currentIndex ? ' active' : ''); + thumb.setAttribute('aria-label', t('image.viewer.image_position', { current: i + 1, total: this.imageUrls.length })); + thumb.setAttribute('aria-current', i === this.currentIndex ? 'true' : 'false'); // 🔒 XSS防护:图片 URL 来自其他用户发布的内容,写入 innerHTML 前必须转义 thumb.innerHTML = ``; thumb.addEventListener('click', (e) => { @@ -812,6 +853,7 @@ class CyberImageViewer { const thumbs = this.elements.thumbnails.querySelectorAll('.cyber-thumb'); thumbs.forEach((thumb, index) => { thumb.classList.toggle('active', index === this.currentIndex); + thumb.setAttribute('aria-current', index === this.currentIndex ? 'true' : 'false'); }); } @@ -851,6 +893,7 @@ class CyberImageViewer { // 更新计数器 this.elements.counterCurrent.textContent = index + 1; + this.elements.mainImage.alt = t('image.viewer.image_position', { current: index + 1, total: this.imageUrls.length }); this._updateThumbnails(); // 预加载相邻图片 @@ -863,13 +906,13 @@ class CyberImageViewer { _loadImageAsync(url) { return new Promise((resolve, reject) => { const img = new Image(); - console.log('📥 开始加载图片:', url?.substring(0, 100) + '...'); + console.log('📥 开始加载图片'); img.onload = () => { - console.log('✅ 图片加载成功:', url?.substring(0, 100) + '...'); + console.log('✅ 图片加载成功'); resolve(img); }; img.onerror = (err) => { - console.error('❌ 图片加载失败:', url?.substring(0, 100) + '...', err); + console.error('❌ 图片加载失败:', err?.name || 'Error'); reject(new Error('图片加载失败')); }; img.src = url; @@ -911,7 +954,7 @@ class CyberImageViewer { const imgSrc = img.src; // 调试日志 - console.log('🎬 方块聚合动画开始 - 图片URL:', imgSrc?.substring(0, 100) + '...'); + console.log('🎬 方块聚合动画开始'); // 获取wrapper的实际尺寸 const rect = wrapper.getBoundingClientRect(); @@ -1530,6 +1573,8 @@ class CyberImageViewer { * 关闭查看器 */ close() { + if (this._isClosing) return; + this._isClosing = true; // 停止自动播放并清除定时器 this._stopAutoPlay(); @@ -1546,6 +1591,7 @@ class CyberImageViewer { if (this.container.parentNode) { this.container.parentNode.removeChild(this.container); } + if (this._returnFocus?.isConnected && typeof this._returnFocus.focus === 'function') this._returnFocus.focus(); }, 300); }); } @@ -1571,13 +1617,13 @@ export function openImageViewer(imageUrls, startIndex = 0, options = {}) { } console.log('🎬 openImageViewer 被调用:', 'URL数量:', imageUrls.length, '起始索引:', startIndex); - console.log('🖼️ 图片URLs:', imageUrls.map(u => u?.substring(0, 80) + '...')); const viewer = new CyberImageViewer(imageUrls, startIndex); const container = viewer.create(options); // 添加到body document.body.appendChild(container); + viewer.elements.closeBtn.focus(); // 添加入场动画 requestAnimationFrame(() => { diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\233\276\347\211\207\346\262\231\347\233\222\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\233\276\347\211\207\346\262\231\347\233\222\347\273\204\344\273\266.js" index f57b5306033ae204f7107fd8355c84beff3e6adb..158c40cf2666a59b13ca644c76760072239b49f8 100644 --- "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\233\276\347\211\207\346\262\231\347\233\222\347\273\204\344\273\266.js" +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\233\276\347\211\207\346\262\231\347\233\222\347\273\204\344\273\266.js" @@ -111,7 +111,7 @@ export function getCoverSandboxHTML(imageSource, lazyLoad = true) { - +
@@ -262,7 +262,7 @@ export function setupImageSandboxEvents(detailView) { const currentIndex = parseInt(viewport.getAttribute('data-current') || '0', 10); // 🔍 调试日志:输出收集到的图片URL - console.log('🖼️ 全屏播放图片:', imageUrls, '当前索引:', currentIndex); + console.log('🖼️ 全屏播放图片:', '图片数量:', imageUrls.length, '当前索引:', currentIndex); if (imageUrls.length > 0) { // 打开全屏图像播放器(使用静态import,避免动态import路径问题) @@ -272,11 +272,11 @@ export function setupImageSandboxEvents(detailView) { console.error('无法打开图像播放器:', err); // 显示用户可见的错误提示 import('./UI交互提示组件.js').then(module => { - module.showToast('图像播放器加载失败,请刷新页面重试', 'error'); + module.showToast(t('image.viewer.load_failed'), 'error'); }).catch(() => { // 动态import也失败时,创建简单的内联提示 const fallback = document.createElement('div'); - fallback.textContent = '图像播放器加载失败,请刷新页面重试'; + fallback.textContent = t('image.viewer.load_failed'); 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' }); document.body.appendChild(fallback); setTimeout(() => fallback.remove(), 3000); diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\233\276\347\211\207\350\243\201\345\211\252\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\233\276\347\211\207\350\243\201\345\211\252\347\273\204\344\273\266.js" index f5d7665be67c86ea8a25084b8338e765603bff59..ef1ec4f5f15677978537492bb28cecb1cc637bcd 100644 --- "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\233\276\347\211\207\350\243\201\345\211\252\347\273\204\344\273\266.js" +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\233\276\347\211\207\350\243\201\345\211\252\347\273\204\344\273\266.js" @@ -35,8 +35,10 @@ function _isTouchDevice() { * @param {number} maxSizeMB - 最大输出大小(MB) * @returns {Promise} - 裁剪后的文件或 null(取消) */ -export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片", maxSizeMB = 3) { +export function openImageCropper(file, aspectRatio = 16/9, title = t('crop.title'), maxSizeMB = 3) { return new Promise((resolve) => { + const previousFocus = document.activeElement; + const dialogId = `image-crop-dialog-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; // 创建遮罩层 const overlay = document.createElement("div"); Object.assign(overlay.style, { @@ -47,6 +49,11 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片 // 创建弹窗容器 const modal = document.createElement("div"); + modal.setAttribute("role", "dialog"); + modal.setAttribute("aria-modal", "true"); + modal.setAttribute("aria-labelledby", `${dialogId}-title`); + modal.setAttribute("aria-describedby", `${dialogId}-hint`); + modal.setAttribute("tabindex", "-1"); Object.assign(modal.style, { background: "var(--comfy-input-bg)", borderRadius: "12px", padding: "20px", maxWidth: "90vw", maxHeight: "90vh", display: "flex", flexDirection: "column", @@ -66,8 +73,8 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片 // 📱 触控设备与鼠标设备的操作提示区分(移动端无滚轮/拖动概念);文案走词典,随界面语言切换 const gestureHint = _isTouchDevice() ? t('crop.gesture_hint_touch') : t('crop.gesture_hint_mouse'); header.innerHTML = ` - ✂️ ${escapeHtml(title)} - ${escapeHtml(t('crop.ratio_label'))} ${ratioText} | ${escapeHtml(gestureHint)} + ✂️ ${escapeHtml(title)} + ${escapeHtml(t('crop.ratio_label'))} ${ratioText} | ${escapeHtml(gestureHint)} `; // 裁剪区域容器 @@ -166,8 +173,28 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片 // 🔧 P3优化:ESC 键关闭 function handleKeyDown(e) { if (e.key === 'Escape') { + e.preventDefault(); cleanup(); resolve(null); + return; + } + if (e.key !== 'Tab') return; + const focusable = Array.from(modal.querySelectorAll( + 'button:not(:disabled), input:not(:disabled), [tabindex]:not([tabindex="-1"])' + )); + if (!focusable.length) { + e.preventDefault(); + modal.focus(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey && (document.activeElement === first || !modal.contains(document.activeElement))) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && (document.activeElement === last || !modal.contains(document.activeElement))) { + e.preventDefault(); + first.focus(); } } document.addEventListener("keydown", handleKeyDown); @@ -308,8 +335,8 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片 display: "flex", alignItems: "center", gap: "10px", marginTop: "15px", padding: "0 10px" }); sliderContainer.innerHTML = ` - 🔍 缩放 - 🔍 ${escapeHtml(t('crop.zoom_label'))} + 100% `; @@ -320,8 +347,8 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片 display: "flex", gap: "10px", marginTop: "15px", justifyContent: "flex-end" }); buttonContainer.innerHTML = ` - - + + `; // 组装DOM @@ -332,6 +359,7 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片 modal.appendChild(buttonContainer); overlay.appendChild(modal); document.body.appendChild(overlay); + modal.querySelector("#btn-crop-cancel").focus(); // 绑定缩放滑块事件 setTimeout(() => { @@ -345,6 +373,7 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片 scale = minScale * (parseInt(slider.value) / 100); scaleValue.textContent = `${slider.value}%`; + slider.setAttribute("aria-valuetext", `${slider.value}%`); updateImagePosition(); }; @@ -357,7 +386,7 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片 // 确认裁剪按钮 modal.querySelector("#btn-crop-confirm").onclick = async () => { const btn = modal.querySelector("#btn-crop-confirm"); - btn.textContent = "⏳ 处理中..."; + btn.textContent = `⏳ ${t('crop.processing')}`; btn.disabled = true; try { @@ -365,7 +394,7 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片 cleanup(); resolve(croppedFile); } catch (err) { - btn.textContent = "❌ 处理失败,重试"; + btn.textContent = t('crop.process_failed'); btn.disabled = false; console.error("裁剪失败:", err); } @@ -385,6 +414,7 @@ export function openImageCropper(file, aspectRatio = 16/9, title = "裁剪图片 cropContainer.removeEventListener("touchend", handleTouchEnd); cropContainer.removeEventListener("touchcancel", handleTouchEnd); overlay.remove(); + if (previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus(); } // 点击遮罩关闭 diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\275\251\350\233\213\345\212\250\347\224\273\345\274\225\346\223\216.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\275\251\350\233\213\345\212\250\347\224\273\345\274\225\346\223\216.js" index 5a58c4e175967cd0c97259e33b8ba5fa775eff2b..55d762df773d2b9ff1d525394f3f6289b314ed07 100644 --- "a/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\275\251\350\233\213\345\212\250\347\224\273\345\274\225\346\223\216.js" +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/components/\345\275\251\350\233\213\345\212\250\347\224\273\345\274\225\346\223\216.js" @@ -8,9 +8,8 @@ /** * 在新标签页打开彩蛋动画页面 */ -export function openEasterEggPage() { - const html = generateFullHTML(); - const newWindow = window.open('', '_blank'); +export function openEasterEggPage(newWindow, { language, title, hint, speedLabel } = {}) { + const html = generateFullHTML({ language, title, hint, speedLabel }); if (newWindow) { newWindow.document.write(html); newWindow.document.close(); @@ -20,13 +19,15 @@ export function openEasterEggPage() { /** * 生成完整的彩蛋动画HTML页面 */ -function generateFullHTML() { +function generateFullHTML({ language = 'zh-CN', title = '猪的飞行梦', hint = '鼠标移动改变视角 | PgUp加速 PgDn减速 | ESC关闭', speedLabel = '速度' } = {}) { + const safeLanguage = language === 'en-US' ? 'en-US' : 'zh-CN'; + const speedLabelLiteral = JSON.stringify(speedLabel); return ` - + - 🐷 猪的飞行梦 + 🐷 ${title} -
- - - ${escapeHtml(viewTitle)} -
+
+
+ +

${escapeHtml(viewTitle)}

+
-
-
- - + + + +
- -
- - +
+ +
-
- - +
+ +
-
-
- - + + +
-
`; -} \ No newline at end of file +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/market/\345\217\221\345\270\203\345\206\205\345\256\271_\346\217\220\344\272\244\345\274\225\346\223\216.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\345\217\221\345\270\203\345\206\205\345\256\271_\346\217\220\344\272\244\345\274\225\346\223\216.js" index 2a725741c3da270a8b1a3725fbeb591f134ead66..48abbf1420b69f87be6683bf2d37e14f06b6b8dc 100644 --- "a/\345\211\215\347\253\257\351\241\265\351\235\242/market/\345\217\221\345\270\203\345\206\205\345\256\271_\346\217\220\344\272\244\345\274\225\346\223\216.js" +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\345\217\221\345\270\203\345\206\205\345\256\271_\346\217\220\344\272\244\345\274\225\346\223\216.js" @@ -1,4 +1,8 @@ +import { processImageCore as _processImageCore } from "./有界图片处理.js"; +import { validateImageFile } from "./图片文件检查.js"; +import { IMAGE_PROCESS } from "../core/全局配置.js"; // 前端页面/market/发布内容_提交引擎.js +import { createPublishSubmissionScope } from "./发布提交生命周期.js"; import { api } from "../core/网络请求API.js"; import { showToast } from "../components/UI交互提示组件.js"; import { t } from "../components/用户体验增强.js"; @@ -46,112 +50,6 @@ function clearItemCacheByType(type) { console.log(`🗑️ 已清除 [${tab}] 类型的列表缓存`); } -/** - * 通用图片处理核心函数:加载图片 → Canvas绘制 → 转换为JPG文件 - * @param {File} file - 原始图片文件 - * @param {Object} options - 配置选项 - * @param {boolean} options.cropToSquare - 是否中心裁剪为正方形 - * @param {number|null} options.targetSize - 目标尺寸(正方形边长),null则保持原始尺寸 - * @param {number} options.quality - 初始JPG质量 (0-1) - * @param {string|null} options.fillBackground - 背景填充颜色,null则不填充 - * @param {number|null} options.maxFileSize - 最大文件大小(字节),超限则迭代降低质量 - * @param {boolean} options.skipIfSmallJpeg - 是否跳过已是小体积JPEG的文件 - * @param {string} options.suffix - 输出文件名后缀 - * @returns {Promise} 处理后的文件 - */ -function _processImageCore(file, options = {}) { - const { - cropToSquare = false, - targetSize = null, - quality = 0.85, - fillBackground = null, - maxFileSize = null, - skipIfSmallJpeg = false, - suffix = '.jpg' - } = options; - - return new Promise((resolve, reject) => { - const img = new Image(); - const url = URL.createObjectURL(file); - - img.onload = () => { - URL.revokeObjectURL(url); - - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - - let sourceX = 0, sourceY = 0; - let sourceW = img.naturalWidth, sourceH = img.naturalHeight; - let destW = img.naturalWidth, destH = img.naturalHeight; - - // 中心裁剪为正方形 - if (cropToSquare) { - const sourceMin = Math.min(sourceW, sourceH); - sourceX = (sourceW - sourceMin) / 2; - sourceY = (sourceH - sourceMin) / 2; - sourceW = sourceMin; - sourceH = sourceMin; - destW = sourceMin; - destH = sourceMin; - } - - // 指定目标尺寸时缩放 - if (targetSize) { - destW = targetSize; - destH = targetSize; - } - - canvas.width = destW; - canvas.height = destH; - - // 填充背景色 - if (fillBackground) { - ctx.fillStyle = fillBackground; - ctx.fillRect(0, 0, canvas.width, canvas.height); - } - - ctx.drawImage(img, sourceX, sourceY, sourceW, sourceH, 0, 0, destW, destH); - - // 跳过已是小体积JPEG的文件 - if (skipIfSmallJpeg && maxFileSize && - (file.type === 'image/jpeg' || file.type === 'image/jpg') && - file.size <= maxFileSize) { - resolve(file); - return; - } - - // 转换为JPG Blob,超限时迭代降低质量 - let currentQuality = quality; - const tryCompress = () => { - canvas.toBlob((blob) => { - if (!blob) { - reject(new Error('Canvas 转换 Blob 失败')); - return; - } - - if (maxFileSize && blob.size > maxFileSize && currentQuality > 0.3) { - currentQuality -= 0.1; - tryCompress(); - return; - } - - const newFileName = file.name.replace(/\.[^/.]+$/, '') + suffix; - resolve(new File([blob], newFileName, { type: 'image/jpeg' })); - }, 'image/jpeg', currentQuality); - }; - - tryCompress(); - }; - - img.onerror = () => { - URL.revokeObjectURL(url); - reject(new Error('图片加载失败')); - }; - - img.src = url; - }); -} - /** * 🟢 利用 HTML5 Canvas 纯前端裁剪并压缩头像 * 功能:强制 1:1 比例中心裁剪,缩放到 512x512,转换为 JPG 格式。 @@ -159,7 +57,8 @@ function _processImageCore(file, options = {}) { function processAvatar(file) { return _processImageCore(file, { cropToSquare: true, - targetSize: 512, + maxFileSize: IMAGE_PROCESS.AVATAR_MAX_BYTES, + targetSize: IMAGE_PROCESS.AVATAR_SIZE, quality: 0.85, suffix: '_cropped.jpg' }).then(processedFile => { @@ -171,7 +70,7 @@ function processAvatar(file) { /** * 上传前图片压缩处理 * - 转为 JPG 格式 - * - 超过 5MB 时进行质量压缩,不改变宽高比例 + * - 最长边至多4096并保持比例;质量压缩后仍超过5MB则拒绝 * @param {File} file - 原始图片文件 * @returns {Promise} - 处理后的文件 */ @@ -181,65 +80,54 @@ export async function compressImageForUpload(file) { return file; } - // GIF 动图保留原始格式,不转换为 JPG - if (file.type === 'image/gif') { - const GIF_MAX_SIZE = 50 * 1024 * 1024; // 50MB - if (file.size > GIF_MAX_SIZE) { - throw new Error(`GIF文件过大(${(file.size/1024/1024).toFixed(1)}MB),最大允许50MB`); - } - console.log(`🎞️ GIF动图保留原始格式: ${(file.size/1024/1024).toFixed(2)}MB`); - return file; - } - - try { - const processedFile = await _processImageCore(file, { - quality: 0.92, - fillBackground: '#FFFFFF', - maxFileSize: 5 * 1024 * 1024, - skipIfSmallJpeg: true, - suffix: '.jpg' - }); - console.log(`📦 图片压缩: ${(file.size/1024/1024).toFixed(2)}MB → ${(processedFile.size/1024/1024).toFixed(2)}MB`); - return processedFile; - } catch (e) { - // 加载失败,返回原文件 - return file; + if (!Number.isFinite(file.size) || file.size < 0 || file.size > IMAGE_PROCESS.MAX_INPUT_BYTES) { + throw new Error('Image exceeds upload budget'); } + // 动图保持原格式;不由Canvas变为静态图片。 + if (file.type === 'image/gif') { await validateImageFile(file); return file; } + return _processImageCore(file, { + quality: 0.92, fillBackground: '#FFFFFF', maxFileSize: IMAGE_PROCESS.MAX_OUTPUT_BYTES, + skipIfSmallJpeg: true, suffix: '.jpg' + }); } /** * 🟢 新增:全局统一的文件上传包装器(供个人设置等其它组件调用) * 会在上传前自动拦截并处理头像图片 */ -export async function uploadFile(file, file_type) { +export async function uploadFile(file, file_type, options = {}) { + const current = options.isCurrent || (() => true); + const feedback = (message, level) => { if (!options.silent && current()) showToast(message, level); }; + if (!current()) return null; try { let fileToUpload = file; // 拦截点:如果是头像,就在纯内存中先进行裁剪、缩放、压缩 if (file_type === "avatar" && file.type && file.type.startsWith("image/")) { - showToast(`🖼️ ${t('publish.processing_avatar')}`); + feedback(`🖼️ ${t('publish.processing_avatar')}`); fileToUpload = await processAvatar(file); } - showToast(`🌐 ${t('publish.uploading_file', { size: (fileToUpload.size/1024).toFixed(1) })}`); + feedback(`🌐 ${t('publish.uploading_file', { size: (fileToUpload.size/1024).toFixed(1) })}`); + if (!current()) return null; const res = await api.uploadFile(fileToUpload, file_type); + if (!current()) return null; if (res.status === "success") { - showToast(`✅ ${t('publish.upload_success')}`); + feedback(`✅ ${t('publish.upload_success')}`); return res.url; } else { - showToast(`❌ ${t('publish.upload_failed')}: ${res.error || res.message}`, "error"); + feedback(t('publish.upload_failed'), 'error'); return null; } } catch (error) { - console.error("Upload Error:", error); - showToast(`❌ ${t('publish.upload_error')}: ${error.message}`, "error"); + feedback(t('publish.upload_error'), 'error'); return null; } } // ========================================== -// 原有的发布表单提交逻辑 (完美保留,无任何删减) +// 发布表单提交:原会话范围与保存结果核实 // ========================================== export async function handlePublishSubmit(params) { const { @@ -247,6 +135,13 @@ export async function handlePublishSubmit(params) { imageFiles, jsonFile, onSuccessCallback, submitBtn, submitBtnText // 🖼️ imageFiles 替换 coverFile } = params; + const scope = params.submissionScope || createPublishSubmissionScope(container, submitBtn, currentUser?.account); + if (!scope.isCurrent()) return; + const expectedRevision = isEditMode ? (editItemData?.edit_revision === undefined ? 0 : editItemData.edit_revision) : null; + if (isEditMode && (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0)) { + showToast(t('common.operation_failed'), 'error'); return; + } + const selectedImages = Array.from(imageFiles || []); const typeSelect = container.querySelector("#pub-type"); const recommendTypeSelect = container.querySelector("#pub-recommend-type"); const resTypeSelect = container.querySelector("#pub-resource-type"); @@ -264,7 +159,8 @@ export async function handlePublishSubmit(params) { const title = container.querySelector("#pub-title").value.trim(); const shortDesc = container.querySelector("#pub-short").value.trim(); const fullDesc = container.querySelector("#pub-full").value.trim(); - const price = rules.price ? (parseFloat(container.querySelector("#pub-price").value) || 0) : 0; + const price = rules.price ? Number(container.querySelector("#pub-price").value) : 0; + if (!Number.isSafeInteger(price) || price < 0) return showToast(t('common.operation_failed'), "warning"); let finalLink = inputLink.value.trim(); let isJsonUpload = (resTypeSelect.value === "json") || (type === "recommend_app"); @@ -315,15 +211,18 @@ export async function handlePublishSubmit(params) { const allowRefundCheckbox = container.querySelector("#allow-refund-checkbox"); const allowRefund = rules.refund ? (allowRefundCheckbox?.checked ?? true) : null; + if (!scope.begin()) return; + let committed = false, saveAttempted = false, outcomeUnknown = false; submitBtn.innerHTML = `⏳ ${t('publish.connecting')}`; submitBtn.disabled = true; - submitBtn.style.background = "#555"; try { if (isJsonUpload && jsonFile) { submitBtn.innerHTML = `⏳ ${t('publish.uploading_secure')}`; const uploadType = type.includes("app") ? "app" : (type.includes("tool") || type === "skill" ? "tool" : "recommend"); const jsonUploadRes = await api.uploadFile(jsonFile, uploadType); + if (!scope.isCurrent()) return; + if (jsonUploadRes?.status !== "success" || typeof jsonUploadRes.url !== "string" || !jsonUploadRes.url.trim()) throw new Error("Invalid upload result"); finalLink = jsonUploadRes.url; } @@ -331,19 +230,21 @@ export async function handlePublishSubmit(params) { let coverUrl = isEditMode ? editItemData.coverUrl : null; let imageUrls = isEditMode ? (editItemData.imageUrls || []) : []; - if (imageFiles && imageFiles.length > 0) { - submitBtn.innerHTML = `⏳ ${t('publish.uploading_images', { current: 0, total: imageFiles.length })}`; + if (selectedImages.length > 0) { + submitBtn.innerHTML = `⏳ ${t('publish.uploading_images', { current: 0, total: selectedImages.length })}`; const uploadedUrls = []; - for (let i = 0; i < imageFiles.length; i++) { - submitBtn.innerHTML = `⏳ ${t('publish.uploading_images', { current: i + 1, total: imageFiles.length })}`; + for (let i = 0; i < selectedImages.length; i++) { + submitBtn.innerHTML = `⏳ ${t('publish.uploading_images', { current: i + 1, total: selectedImages.length })}`; // 上传前压缩:转JPG,超5MB压缩 - const processedFile = await compressImageForUpload(imageFiles[i]); + const processedFile = await compressImageForUpload(selectedImages[i]); + if (!scope.isCurrent()) return; const uploadRes = await api.uploadFile(processedFile, "cover"); - if (uploadRes && uploadRes.url) { + if (!scope.isCurrent()) return; + if (uploadRes?.status === "success" && typeof uploadRes.url === "string" && uploadRes.url.trim()) { uploadedUrls.push(uploadRes.url); } else { - throw new Error(`图片上传失败: ${uploadRes?.error || '未知错误'}`); + throw new Error("Invalid image upload result"); } } @@ -351,12 +252,15 @@ export async function handlePublishSubmit(params) { imageUrls = uploadedUrls; // 全部图片URL } + if (!scope.isCurrent()) return; submitBtn.innerHTML = `⏳ ${t('publish.syncing')}`; // 🖼️ 统一封面逻辑:第一张图片始终作为封面(兑容拖拽排序后的顺序变化) if (imageUrls && imageUrls.length > 0) { coverUrl = imageUrls[0]; } const submitData = { + ...(!isEditMode && scope.publicationId ? { request_id: scope.publicationId } : {}), + ...(isEditMode ? { expected_revision: expectedRevision } : {}), type, title, shortDesc, fullDesc, price, link: finalLink, coverUrl, imageUrls, // 🖼️ 添加 imageUrls author: currentUser.account, github_token, netdisk_password, // ☁️ 网盘密码(后端加密存储) @@ -369,22 +273,40 @@ export async function handlePublishSubmit(params) { submitData.allow_refund = allowRefund; } - if (isEditMode) { - await api.updateItem(editItemData.id, currentUser.account, submitData); - showToast(`✅ ${t('publish.save_success')}`, "success"); - } else { - await api.publishItem(submitData); - showToast(`🎉 ${t('publish.publish_success')}`, "success"); + saveAttempted = true; + const result = isEditMode + ? await api.updateItem(editItemData.id, currentUser.account, submitData) + : await api.publishItem(submitData); + if (!scope.isCurrent()) return; + if (result?.status !== "success") throw new Error("Invalid save result"); + committed = true; + showToast(isEditMode ? `✅ ${t('publish.save_success')}` : `🎉 ${t('publish.publish_success')}`, "success"); + // 主保存已确认;派生缓存或界面回调异常不重新开放提交。 + try { clearItemCacheByType(type); } catch (_) {} + if (scope.isCurrent() && onSuccessCallback) { + try { await onSuccessCallback(); } catch (_) {} } - - // 🚀 精确清除:只清除当前发布类型的缓存 - clearItemCacheByType(type); - - if (onSuccessCallback) onSuccessCallback(); } catch (err) { - showToast(`${t('common.operation_failed')}: ` + err.message, "error"); + if (!scope.isCurrent() || committed) return; + if (isEditMode && err?.status === 409 && err?.code === 'resource_revision_conflict') { + outcomeUnknown = true; // 保持入口锁,要求重开取得新版本;不自动覆盖。 + showToast(t('publish.edit_conflict'), 'warning'); + submitBtn.disabled = true; submitBtn.innerHTML = t('publish.edit_reopen'); + return; + } + // 已发出的保存可能在服务端提交;仅已知前置拒绝允许原表单重试。 + const rejected = [400, 401, 403, 404, 409, 422, 429].includes(err?.status); + outcomeUnknown = saveAttempted && !rejected; + if (outcomeUnknown) { + showToast(t('publish.save_unconfirmed'), "warning"); + submitBtn.disabled = true; + submitBtn.innerHTML = t('publish.result_pending'); + return; + } + showToast(t('common.operation_failed'), "error"); submitBtn.disabled = false; submitBtn.innerHTML = submitBtnText; - submitBtn.style.background = "#2196F3"; + } finally { + scope.finish(committed, outcomeUnknown); } -} \ No newline at end of file +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/market/\345\217\221\345\270\203\345\206\205\345\256\271\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\345\217\221\345\270\203\345\206\205\345\256\271\347\273\204\344\273\266.js" index 54b0a012ec4e22f078bb7ffba8857c6c2aa69fcd..f3c622d4c19cba782b702ff9f24880127a7384a9 100644 --- "a/\345\211\215\347\253\257\351\241\265\351\235\242/market/\345\217\221\345\270\203\345\206\205\345\256\271\347\273\204\344\273\266.js" +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\345\217\221\345\270\203\345\206\205\345\256\271\347\273\204\344\273\266.js" @@ -1,5 +1,7 @@ +import { validateImageFile } from "./图片文件检查.js"; // 前端页面/market/发布内容组件.js import { generatePublishHTML } from "./发布内容_UI模板.js"; +import { createPublishSubmissionScope } from "./发布提交生命周期.js"; import { handlePublishSubmit } from "./发布内容_提交引擎.js"; import { t } from "../components/用户体验增强.js"; import { showConfirm, showToast } from "../components/UI交互提示组件.js"; @@ -206,7 +208,9 @@ export function createPublishView(currentUser, onBackCallback, onSuccessCallback // 1. 渲染分离出去的视图模板 container.innerHTML = generatePublishHTML(isEditMode, viewTitle, submitBtnText, hasExistingToken, editItemData, t); + let submissionScope; container.querySelector("#btn-back").onclick = async () => { + if (!submissionScope?.isCurrent()) return; // 检查是否有已编辑内容 const hasChanges = !!( container.querySelector("#pub-title")?.value?.trim() || @@ -222,12 +226,13 @@ export function createPublishView(currentUser, onBackCallback, onSuccessCallback title: isEditMode ? t('publish.unsaved_changes_title') : t('publish.leave_confirm_title'), confirmText: t('common.leave'), cancelText: t('common.stay'), - type: 'warning' + type: 'warning', signal: submissionScope.confirmationSignal } ); - if (!confirmed) return; + if (!confirmed || !submissionScope.isCurrent()) return; } + submissionScope.dispose(); if (onBackCallback) onBackCallback(); }; @@ -449,36 +454,49 @@ export function createPublishView(currentUser, onBackCallback, onSuccessCallback updateFormView(); - // 🖼️ 多图上传处理 - imagesInput.onchange = (e) => { - const rawFiles = Array.from(e.target.files).slice(0, 6); // 最多6张 - if (rawFiles.length === 0) return; - - // GIF 文件大小校验:超过 50MB 的 GIF 立即排除 - const GIF_MAX_SIZE = 50 * 1024 * 1024; - const files = rawFiles.filter(file => { - if (file.type === 'image/gif' && file.size > GIF_MAX_SIZE) { - const sizeMB = (file.size / (1024 * 1024)).toFixed(1); - const msg = `"${file.name}" 文件过大(${sizeMB}MB),GIF最大允许50MB,已自动跳过`; - console.warn('[发布内容组件]', msg); - showToast(msg, "warning"); - return false; + // 文件头确认后才创建预览,迟到选择不覆盖后一次选择。 + let previewGeneration = 0, previewPending = false, resumeDisabled = false; + const previewUrls = new Set(); + const releasePreviews = () => { + for (const url of previewUrls) URL.revokeObjectURL(url); + previewUrls.clear(); + }; + imagesInput.onchange = async (e) => { + if (!submissionScope?.isCurrent()) return; + const rawFiles = Array.from(e.target.files).slice(0, 6); + if (!rawFiles.length) return; + const generation = ++previewGeneration; + if (!previewPending) resumeDisabled = submitBtn.disabled; + previewPending = true; submitBtn.disabled = true; + try { + const files = []; + for (const file of rawFiles) { + try { await validateImageFile(file); files.push(file); } + catch (_) { + if (generation === previewGeneration && submissionScope.isCurrent()) showToast(t('common.operation_failed'), 'warning'); + } + if (generation !== previewGeneration || !submissionScope.isCurrent()) return; + } + if (!files.length) return; + releasePreviews(); imageFiles = files; imagesPreview.replaceChildren(); + for (const [index, file] of files.entries()) { + const url = URL.createObjectURL(file); previewUrls.add(url); + imagesPreview.appendChild(_createImagePreviewWrapper(url, index)); + } + updateCoverMark(imagesPreview); setupImageDragSort(imagesPreview, imageFiles); + } catch (_) { + if (generation === previewGeneration && submissionScope.isCurrent()) { + releasePreviews(); imageFiles = []; imagesPreview.replaceChildren(); + showToast(t('common.operation_failed'), 'warning'); } - return true; - }); - if (files.length === 0) return; - - imageFiles = files; - imagesPreview.innerHTML = ''; - - files.forEach((file, idx) => { - const objectUrl = URL.createObjectURL(file); - imagesPreview.appendChild(_createImagePreviewWrapper(objectUrl, idx)); - }); - updateCoverMark(imagesPreview); - setupImageDragSort(imagesPreview, imageFiles); + } finally { + if (generation === previewGeneration) { + previewPending = false; + if (submissionScope.isCurrent()) submitBtn.disabled = resumeDisabled; + } + } }; - + inputJson.onchange = (e) => { jsonFile = e.target.files[0]; }; // 🎨 原创勾选框逻辑:工具/应用类型强制确认弹窗 @@ -490,15 +508,18 @@ export function createPublishView(currentUser, onBackCallback, onSuccessCallback const rules = TYPE_RULES[typeSelect.value]; if (originalHintText) { if (rules?.originalRequired) { - originalHintText.innerHTML = `⚠️ ${t('publish.original_required_hint')}`; + originalHintText.textContent = `⚠️ ${t('publish.original_required_hint')}`; + originalHintText.classList.add('ranking-publish__original-required'); } else { originalHintText.textContent = t('publish.original_default_hint'); + originalHintText.classList.remove('ranking-publish__original-required'); } } }; // 显示原创确认弹窗 const showOriginalConfirmModal = (onConfirm, onCancel) => { + if (!submissionScope?.isCurrent()) return; const contentDiv = document.createElement("div"); contentDiv.innerHTML = `
@@ -519,20 +540,25 @@ export function createPublishView(currentUser, onBackCallback, onSuccessCallback
`; - globalModal.openModal(t('publish.original_confirm_title'), contentDiv, { width: "450px" }); + const modal = globalModal.openModal(t('publish.original_confirm_title'), contentDiv, { width: "450px" }); + const release = submissionScope.addCleanup(() => globalModal.closeModal(modal)); + contentDiv._cleanup = release; contentDiv.querySelector("#btn-original-confirm").onclick = () => { - globalModal.closeTopModal(); + if (!submissionScope.isCurrent()) return; + globalModal.closeModal(modal); onConfirm(); }; contentDiv.querySelector("#btn-original-cancel").onclick = () => { - globalModal.closeTopModal(); + if (!submissionScope.isCurrent()) return; + globalModal.closeModal(modal); onCancel(); }; }; // 监听原创勾选框变化 isOriginalCheckbox.onchange = (e) => { + if (!submissionScope?.isCurrent()) return; const rules = TYPE_RULES[typeSelect.value]; // 只有规则要求原创强制的类型才需要确认弹窗 if (rules?.originalRequired && e.target.checked) { @@ -552,7 +578,10 @@ export function createPublishView(currentUser, onBackCallback, onSuccessCallback // 4. 将提取的参数交接给分离出去的提交引擎 const submitBtn = container.querySelector("#btn-submit-publish"); - submitBtn.onclick = () => handlePublishSubmit({ + submissionScope = createPublishSubmissionScope(container, submitBtn, currentUser?.account); + submissionScope.addCleanup(() => { ++previewGeneration; releasePreviews(); imageFiles = []; jsonFile = null; imagesPreview.replaceChildren(); }); + submitBtn.onclick = () => previewPending ? undefined : handlePublishSubmit({ + submissionScope, container, currentUser, isEditMode, editItemData, imageFiles, jsonFile, onSuccessCallback, submitBtn, submitBtnText // 🖼️ imageFiles 替换 coverFile }); diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/market/\345\217\221\345\270\203\345\255\227\346\256\265\351\224\231\350\257\257.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\345\217\221\345\270\203\345\255\227\346\256\265\351\224\231\350\257\257.js" new file mode 100644 index 0000000000000000000000000000000000000000..e2d1087bf85ec1273eeb27ebbc396a73c21f297c --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\345\217\221\345\270\203\345\255\227\346\256\265\351\224\231\350\257\257.js" @@ -0,0 +1,41 @@ +/** 发布表单本地校验提示;不保存草稿,也不展示远端异常正文。 */ +export function createPublishFieldErrors(inputs, signal) { + const entries = new Map(); let disposed = false; + const clear = input => { + const entry = entries.get(input); if (!entry) return; + entry.error.hidden = true; entry.error.textContent = ''; + const tokens = (input.getAttribute('aria-describedby') || '').split(/\s+/).filter(token => token && token !== entry.error.id); + if (tokens.length) input.setAttribute('aria-describedby', tokens.join(' ')); else input.removeAttribute('aria-describedby'); + if (entry.active && input.getAttribute('aria-invalid') === 'true') { + if (entry.previousInvalid === null) input.removeAttribute('aria-invalid'); + else input.setAttribute('aria-invalid', entry.previousInvalid); + } + entry.active = false; + }; + const clearAll = () => { for (const input of entries.keys()) clear(input); }; + const dispose = () => { + if (disposed) return; disposed = true; + clearAll(); + for (const [input, entry] of entries) { + input.removeEventListener('input', entry.onChange); input.removeEventListener('change', entry.onChange); entry.error.remove(); + } + entries.clear(); signal?.removeEventListener('abort', dispose); + }; + const show = (input, message) => { + if (disposed || signal?.aborted) return; + const entry = entries.get(input); if (!entry) return; + if (!entry.active) entry.previousInvalid = input.getAttribute('aria-invalid'); + entry.active = true; entry.error.textContent = message; entry.error.hidden = false; + const tokens = new Set((input.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean)); tokens.add(entry.error.id); + input.setAttribute('aria-describedby', [...tokens].join(' ')); input.setAttribute('aria-invalid', 'true'); input.focus(); + }; + if (signal?.aborted) { disposed = true; return { show, clearAll, dispose }; } + for (const input of inputs) { + const error = document.createElement('div'); error.id = `${input.id}-error`; error.className = 'ranking-publish-field-error'; + error.hidden = true; error.setAttribute('role', 'alert'); input.insertAdjacentElement('afterend', error); + const entry = { error, active: false, previousInvalid: null, onChange: () => clear(input) }; + entries.set(input, entry); input.addEventListener('input', entry.onChange); input.addEventListener('change', entry.onChange); + } + signal?.addEventListener('abort', dispose, { once: true }); + return { show, clearAll, dispose }; +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/market/\345\217\221\345\270\203\346\217\220\344\272\244\347\224\237\345\221\275\345\221\250\346\234\237.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\345\217\221\345\270\203\346\217\220\344\272\244\347\224\237\345\221\275\345\221\250\346\234\237.js" new file mode 100644 index 0000000000000000000000000000000000000000..a29295deee7bc5a355112c496b9a76a10acbeb42 --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\345\217\221\345\270\203\346\217\220\344\272\244\347\224\237\345\221\275\345\221\250\346\234\237.js" @@ -0,0 +1,55 @@ +/** 发布表单绑定原账号与凭证,已经发送的写操作不取消或重放。 */ +import { captureRequestSession } from "../core/网络请求API.js"; +import { getUser, eventBus, EVENTS } from "../core/状态管理.js"; +import { CACHE } from "../core/全局配置.js"; +import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js"; + +const bindings = new WeakMap(); + +export function createPublishSubmissionScope(container, button, account) { + const previous = bindings.get(container); + if (previous?.button === button && previous.account === account) return previous.binding; + previous?.binding.dispose(); + const matches = captureRequestSession(), confirmation = new AbortController(); + let phase = 'idle', observer; + const scope = createTaskViewScope({ + sessionMatches: () => !!account && matches() && getUser()?.account === account, + connected: () => container.isConnected && button.isConnected, + subscribe: notify => subscribeTaskSessionChanges(notify, { target: window, eventBus, events: EVENTS, + userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN }), + cancelReads: () => {}, + invalidate: () => {} + }); + scope.addCleanup(() => { + phase = 'disposed'; observer?.disconnect(); confirmation.abort(); + button.disabled = true; button.removeAttribute('aria-busy'); + for (const selector of ['#pub-github-token', '#pub-netdisk-password']) { + const input = container.querySelector(selector); if (input) input.value = ''; + } + }); + let wasConnected = container.isConnected; + observer = new MutationObserver(() => { + if (container.isConnected) wasConnected = true; + if (wasConnected) scope.isCurrent(); + }); + observer.observe(document.body, { childList: true, subtree: true }); + container._cleanup = () => scope.dispose(); + const publicationId = Array.from(crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, '0')).join(''); + const binding = { + publicationId, + confirmationSignal: confirmation.signal, + addCleanup: cleanup => scope.addCleanup(cleanup), + isCurrent: () => scope.isCurrent(), + dispose: () => scope.dispose(), + begin() { + if (phase !== 'idle' || !scope.isCurrent()) return false; + phase = 'submitting'; button.setAttribute('aria-busy', 'true'); return true; + }, + finish(committed, outcomeUnknown = false) { + if (!scope.isCurrent()) return; + phase = committed ? 'committed' : (outcomeUnknown ? 'unconfirmed' : 'idle'); button.removeAttribute('aria-busy'); + } + }; + bindings.set(container, { button, account, binding }); + return binding; +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/market/\345\217\221\345\270\203\346\226\207\344\273\266\351\200\211\346\213\251\345\217\257\350\256\277\351\227\256\346\200\247.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\345\217\221\345\270\203\346\226\207\344\273\266\351\200\211\346\213\251\345\217\257\350\256\277\351\227\256\346\200\247.js" new file mode 100644 index 0000000000000000000000000000000000000000..5c90e01124c6f5460c2adcf5599095f1af5249a7 --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\345\217\221\345\270\203\346\226\207\344\273\266\351\200\211\346\213\251\345\217\257\350\256\277\351\227\256\346\200\247.js" @@ -0,0 +1,44 @@ +const bindings = new WeakMap(); + +/** onclick属性变化不触发MutationObserver,渲染时显式同步空态语义。 */ +export function setPublishFilePickerAction(area, action) { + area.onclick = action; + bindings.get(area)?.(); +} + +/** 上传区域只在空态作为一个按钮;已有媒体中的原生控件保留独立语义。 */ +export function bindPublishFilePicker(area, input, label, signal) { + let disposed = false; + input.setAttribute('aria-label', label); + const sync = () => { + if (disposed) return; + const active = typeof area.onclick === 'function'; + if (active) { + area.setAttribute('role', 'button'); area.tabIndex = 0; + area.setAttribute('aria-label', label); + area.setAttribute('aria-disabled', String(input.disabled)); + if (input.getAttribute('aria-busy') === 'true') area.setAttribute('aria-busy', 'true'); + else area.removeAttribute('aria-busy'); + } else { + for (const name of ['role', 'tabindex', 'aria-label', 'aria-disabled', 'aria-busy']) area.removeAttribute(name); + } + for (const button of area.querySelectorAll('[data-file-picker-button]')) button.disabled = input.disabled; + }; + const onKey = event => { + if (disposed || signal?.aborted || input.disabled || event.target !== area || typeof area.onclick !== 'function' + || event.isComposing || event.repeat || !['Enter', ' '].includes(event.key)) return; + event.preventDefault(); event.stopPropagation(); area.click(); + }; + const observer = new MutationObserver(sync); + const dispose = () => { + if (disposed) return; + disposed = true; observer.disconnect(); + if (bindings.get(area) === sync) bindings.delete(area); area.removeEventListener('keydown', onKey); + signal?.removeEventListener('abort', dispose); + }; + if (signal?.aborted) { dispose(); return dispose; } + observer.observe(area, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] }); + observer.observe(input, { attributes: true, attributeFilter: ['disabled', 'aria-busy'] }); + area.addEventListener('keydown', onKey); signal?.addEventListener('abort', dispose, { once: true }); + bindings.set(area, sync); sync(); return dispose; +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/market/\345\217\221\345\270\203\347\274\226\350\276\221\351\224\201.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\345\217\221\345\270\203\347\274\226\350\276\221\351\224\201.js" new file mode 100644 index 0000000000000000000000000000000000000000..4e10d9107804c40a0a67bab92c8703903aec8e81 --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\345\217\221\345\270\203\347\274\226\350\276\221\351\224\201.js" @@ -0,0 +1,43 @@ +/** 锁住本次发布的编辑入口;返回、提交按钮由原生命周期管理。 */ +export function createPublishEditingLock(container, excluded = []) { + let locked = false, disposed = false; + const controls = new Map(); + const except = target => excluded.some(node => node === target || node?.contains(target)); + const captureControls = () => { + if (!locked || disposed) return; + for (const control of container.querySelectorAll('input, textarea, select, button')) { + if (except(control)) continue; + if (!controls.has(control)) controls.set(control, control.disabled); + control.disabled = true; + } + }; + const preventEditing = event => { + if (!locked || disposed || except(event.target)) return; + // 媒体播放不改变待发布内容,仍可预览;其它点击/键盘/拖拽入口锁定。 + if (event.target?.closest?.('video, audio')) return; + event.preventDefault(); event.stopImmediatePropagation(); + }; + const events = ['click', 'keydown', 'dragstart', 'drop', 'input', 'change']; + for (const name of events) container.addEventListener(name, preventEditing, true); + const observer = new MutationObserver(captureControls); + return { + lock() { + if (disposed || locked) return; + locked = true; captureControls(); + observer.observe(container, { childList: true, subtree: true }); + }, + unlock() { + if (disposed || !locked) return; + locked = false; observer.disconnect(); + for (const [control, disabled] of controls) { + if (container.contains(control)) control.disabled = disabled; + } + controls.clear(); + }, + dispose() { + if (disposed) return; + disposed = true; observer.disconnect(); controls.clear(); + for (const name of events) container.removeEventListener(name, preventEditing, true); + } + }; +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/market/\345\233\276\347\211\207\346\226\207\344\273\266\346\243\200\346\237\245.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\345\233\276\347\211\207\346\226\207\344\273\266\346\243\200\346\237\245.js" new file mode 100644 index 0000000000000000000000000000000000000000..2357b6b88e4345d836148701a26934dc644547a5 --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\345\233\276\347\211\207\346\226\207\344\273\266\346\243\200\346\237\245.js" @@ -0,0 +1,77 @@ +/** 有界文件头检查;仅确认格式/尺寸,不代替浏览器解码和服务端审核。 */ +import { IMAGE_PROCESS } from "../core/全局配置.js"; + +export function readImageDimensions(buffer) { + const b = new Uint8Array(buffer), v = new DataView(buffer); + const text = (offset, count) => String.fromCharCode(...b.subarray(offset, offset + count)); + const requireBytes = end => { if (end > b.length) throw new Error('Truncated image header'); }; + let width, height, type; + if (b.length >= 8 && b[0] === 137 && text(1, 3) === 'PNG' && text(4, 4) === '\r\n\x1a\n') { + requireBytes(33); + if (v.getUint32(8) !== 13 || text(12, 4) !== 'IHDR') throw new Error('Invalid PNG header'); + width = v.getUint32(16); height = v.getUint32(20); type = 'image/png'; + } else if (text(0, 6) === 'GIF87a' || text(0, 6) === 'GIF89a') { + requireBytes(13); width = v.getUint16(6, true); height = v.getUint16(8, true); type = 'image/gif'; + } else if (text(0, 4) === 'RIFF' && text(8, 4) === 'WEBP') { + requireBytes(20); const chunk = text(12, 4), size = v.getUint32(16, true); + if (v.getUint32(4, true) < size + 12) throw new Error('Invalid WebP container'); + if (chunk === 'VP8X' && size === 10) { + requireBytes(30); width = 1 + b[24] + (b[25] << 8) + (b[26] << 16); + height = 1 + b[27] + (b[28] << 8) + (b[29] << 16); + } else if (chunk === 'VP8L' && size >= 5) { + requireBytes(25); if (b[20] !== 47 || (b[24] >> 5) !== 0) throw new Error('Invalid lossless header'); + const bits = v.getUint32(21, true); width = 1 + (bits & 16383); height = 1 + ((bits >>> 14) & 16383); + } else if (chunk === 'VP8 ' && size >= 10) { + requireBytes(30); + if ((b[20] & 1) || b[23] !== 157 || b[24] !== 1 || b[25] !== 42) throw new Error('Invalid VP8 frame'); + width = v.getUint16(26, true) & 16383; height = v.getUint16(28, true) & 16383; + } else throw new Error('Unsupported WebP header'); + type = 'image/webp'; + } else if (b[0] === 255 && b[1] === 216) { + let offset = 2; + while (offset < b.length) { + if (b[offset++] !== 255) throw new Error('Invalid JPEG marker'); + while (b[offset] === 255) offset++; + requireBytes(offset + 1); const marker = b[offset++]; + if (marker === 1) continue; + if (marker === 0 || marker === 216 || marker === 217 || marker === 218 || (marker >= 208 && marker <= 215)) break; + requireBytes(offset + 2); const length = v.getUint16(offset); + if (length < 2) throw new Error('Invalid JPEG segment'); + requireBytes(offset + length); + if (marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker)) { + if (length < 8 || b[offset + 7] === 0 || length !== 8 + 3 * b[offset + 7]) throw new Error('Invalid JPEG frame'); + height = v.getUint16(offset + 3); width = v.getUint16(offset + 5); type = 'image/jpeg'; break; + } + offset += length; + } + } + if (!type || !Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0 + || Math.max(width, height) > IMAGE_PROCESS.MAX_SOURCE_EDGE || width * height > IMAGE_PROCESS.MAX_PIXELS) { + throw new Error('Unsupported image or dimensions exceed budget'); + } + return { width, height, type }; +} + +export async function validateImageFile(file, { signal } = {}) { + if (!Number.isSafeInteger(file?.size) || file.size <= 0 || file.size > IMAGE_PROCESS.MAX_INPUT_BYTES + || typeof file.slice !== 'function') throw new Error('Invalid image file'); + let timer, abort; + const aborted = () => Object.assign(new Error('Image header read aborted'), { name: 'AbortError' }); + if (signal?.aborted) throw aborted(); + try { + const cancellation = new Promise((_, reject) => { + abort = () => reject(aborted()); + signal?.addEventListener('abort', abort, { once: true }); + }); + const buffer = await Promise.race([ + cancellation, + file.slice(0, IMAGE_PROCESS.HEADER_BYTES).arrayBuffer(), + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Image header read timed out')), IMAGE_PROCESS.TIMEOUT); }) + ]); + const dimensions = readImageDimensions(buffer); + if (file.type && file.type !== dimensions.type && !(file.type === 'image/jpg' && dimensions.type === 'image/jpeg')) { + throw new Error('Image MIME does not match header'); + } + return dimensions; + } finally { clearTimeout(timer); signal?.removeEventListener('abort', abort); } +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/market/\346\224\257\344\273\230\344\272\214\347\273\264\347\240\201\345\267\245\345\205\267.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\346\224\257\344\273\230\344\272\214\347\273\264\347\240\201\345\267\245\345\205\267.js" new file mode 100644 index 0000000000000000000000000000000000000000..d039604b7ebce8463226b5ebec86d0c331303b12 --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\346\224\257\344\273\230\344\272\214\347\273\264\347\240\201\345\267\245\345\205\267.js" @@ -0,0 +1,22 @@ +import { QrCode } from '../vendor/qrcodegen.js'; + +/** 支付链接仅用于本地二维码编码,SVG 中不插入链接或文本。 */ +export function createPaymentQRCode(value, maxBytes) { + if (typeof value !== 'string' || !Number.isSafeInteger(maxBytes) || maxBytes < 1 + || new TextEncoder().encode(value).length > maxBytes) throw new Error('invalid payment data'); + const url = new URL(value); + if (url.protocol !== 'https:' || url.username || url.password || url.port + || !(url.hostname === 'alipay.com' || url.hostname.endsWith('.alipay.com'))) { + throw new Error('invalid payment address'); + } + const qr = QrCode.encodeText(value, QrCode.Ecc.MEDIUM); + const border = 4, size = qr.size + border * 2; + const paths = []; + for (let y = 0; y < qr.size; y++) { + for (let x = 0; x < qr.size; x++) { + if (qr.getModule(x, y)) paths.push(`M${x + border},${y + border}h1v1h-1z`); + } + } + const svg = ``; + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/market/\346\234\211\347\225\214\345\233\276\347\211\207\345\244\204\347\220\206.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\346\234\211\347\225\214\345\233\276\347\211\207\345\244\204\347\220\206.js" new file mode 100644 index 0000000000000000000000000000000000000000..18a64fd35e4be74bc0b04195a7130104c9a5cee0 --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\346\234\211\347\225\214\345\233\276\347\211\207\345\244\204\347\220\206.js" @@ -0,0 +1,152 @@ +import { validateImageFile } from "./图片文件检查.js"; +import { IMAGE_PROCESS, PUBLISH_MEDIA_LIMITS } from "../core/全局配置.js"; + +/** + * 通用图片处理核心函数:加载图片 → Canvas绘制 → 转换为JPG文件 + * @param {File} file - 原始图片文件 + * @param {Object} options - 配置选项 + * @param {boolean} options.cropToSquare - 是否中心裁剪为正方形 + * @param {number|null} options.targetSize - 目标尺寸(正方形边长),null则按输出边长预算等比缩小 + * @param {number} options.quality - 初始JPG质量 (0-1) + * @param {string|null} options.fillBackground - 背景填充颜色,null则不填充 + * @param {number|null} options.maxFileSize - 最大文件大小(字节),超限则迭代降低质量 + * @param {boolean} options.skipIfSmallJpeg - 是否跳过已是小体积JPEG的文件 + * @param {number} options.maxEdge - 可选输出最长边(不超过全局输出预算) + * @param {AbortSignal} options.signal - 所属会话的取消信号 + * @param {string} options.suffix - 输出文件名后缀 + * @returns {Promise} 处理后的文件 + */ +export function processImageCore(file, options = {}) { + if (!Number.isFinite(file?.size) || file.size < 0 || file.size > IMAGE_PROCESS.MAX_INPUT_BYTES) { + return Promise.reject(new Error('Invalid image size')); + } + return validateImageFile(file, { signal: options.signal }).then(() => new Promise((resolve, reject) => { + let img, url, canvas, timer, settled = false; + const signal = options.signal; + const abort = () => finish(Object.assign(new Error('Image processing aborted'), { name: 'AbortError' })); + const finish = (error, result) => { + if (settled) return; + settled = true; clearTimeout(timer); signal?.removeEventListener('abort', abort); + if (img) { img.onload = null; img.onerror = null; img.src = ''; } + if (url) URL.revokeObjectURL(url); + if (canvas) { canvas.width = 0; canvas.height = 0; } + if (error) reject(error); else resolve(result); + }; + if (signal?.aborted) { abort(); return; } + signal?.addEventListener('abort', abort, { once: true }); + timer = setTimeout(() => finish(new Error('Image processing timed out')), IMAGE_PROCESS.TIMEOUT); + try { + img = new Image(); url = URL.createObjectURL(file); + img.onerror = () => finish(new Error('Image decode failed')); + img.onload = () => { + if (settled) return; + try { + const width = img.naturalWidth, height = img.naturalHeight; + if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0 + || Math.max(width, height) > IMAGE_PROCESS.MAX_SOURCE_EDGE || width * height > IMAGE_PROCESS.MAX_PIXELS) { + throw new Error('Image dimensions exceed budget'); + } + const cap = options.maxFileSize || IMAGE_PROCESS.MAX_OUTPUT_BYTES; + if (options.skipIfSmallJpeg && file.type === 'image/jpeg' && file.size <= cap + && Math.max(width, height) <= IMAGE_PROCESS.MAX_OUTPUT_EDGE) { finish(null, file); return; } + let sourceX = 0, sourceY = 0, sourceW = width, sourceH = height; + if (options.cropToSquare) { + sourceW = sourceH = Math.min(width, height); + sourceX = (width - sourceW) / 2; sourceY = (height - sourceH) / 2; + } + const outputEdge = Math.min(options.maxEdge || IMAGE_PROCESS.MAX_OUTPUT_EDGE, IMAGE_PROCESS.MAX_OUTPUT_EDGE); + const scale = Math.min(1, outputEdge / Math.max(sourceW, sourceH)); + const destW = options.targetSize || Math.max(1, Math.round(sourceW * scale)); + const destH = options.targetSize || Math.max(1, Math.round(sourceH * scale)); + canvas = document.createElement('canvas'); canvas.width = destW; canvas.height = destH; + const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('Canvas unavailable'); + if (options.fillBackground) { ctx.fillStyle = options.fillBackground; ctx.fillRect(0, 0, destW, destH); } + ctx.drawImage(img, sourceX, sourceY, sourceW, sourceH, 0, 0, destW, destH); + let quality = options.quality || 0.85; + const encode = () => { + if (settled) return; + try { + canvas.toBlob(blob => { + if (settled) return; + try { + if (!blob || !Number.isSafeInteger(blob.size) || blob.size <= 0) throw new Error('Image encoding failed'); + if (blob.size > cap) { + if (quality <= IMAGE_PROCESS.MIN_QUALITY) throw new Error('Compressed image exceeds budget'); + quality = Math.max(IMAGE_PROCESS.MIN_QUALITY, quality - IMAGE_PROCESS.QUALITY_STEP); + encode(); return; + } + finish(null, new File([blob], file.name.replace(/\.[^/.]+$/, '') + (options.suffix || '.jpg'), { type: 'image/jpeg' })); + } catch (error) { finish(error); } + }, 'image/jpeg', quality); + } catch (error) { finish(error); } + }; + encode(); + } catch (error) { finish(error); } + }; + img.src = url; + } catch (error) { finish(error); } + })); +} + +// 只记住本模块成功生成的不可变File;弱引用不会延长表单文件生命周期。 +const preparedPublishImages = new WeakSet(); + +/** 帖子/提示词保持原1920最长边与JPEG转换策略,不保留动画。 */ +export function compressPublishImage(file, { signal } = {}) { + if (signal?.aborted) return Promise.reject(Object.assign(new Error('Image processing aborted'), { name: 'AbortError' })); + if (preparedPublishImages.has(file)) return Promise.resolve(file); + return processImageCore(file, { + maxEdge: PUBLISH_MEDIA_LIMITS.IMAGE_MAX_EDGE, + quality: PUBLISH_MEDIA_LIMITS.IMAGE_QUALITY, + maxFileSize: IMAGE_PROCESS.MAX_OUTPUT_BYTES, + suffix: '.jpg', signal + }).then(result => { preparedPublishImages.add(result); return result; }); +} + +/** 按发布输出预算截取当前视频帧,终态释放画布。 */ +export function capturePublishVideoFrame(video, { signal } = {}) { + return new Promise((resolve, reject) => { + let canvas, timer, settled = false; + const abort = () => finish(Object.assign(new Error('Video frame capture aborted'), { name: 'AbortError' })); + const finish = (error, result) => { + if (settled) return; + settled = true; clearTimeout(timer); signal?.removeEventListener('abort', abort); + if (canvas) { canvas.width = 0; canvas.height = 0; } + if (error) reject(error); else resolve(result); + }; + if (signal?.aborted) { abort(); return; } + signal?.addEventListener('abort', abort, { once: true }); + timer = setTimeout(() => finish(new Error('Video frame capture timed out')), IMAGE_PROCESS.TIMEOUT); + try { + const width = video.videoWidth, height = video.videoHeight; + if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0 || + Math.max(width, height) > IMAGE_PROCESS.MAX_SOURCE_EDGE || width * height > IMAGE_PROCESS.MAX_PIXELS) { + throw new Error('Video frame dimensions exceed budget'); + } + const scale = Math.min(1, PUBLISH_MEDIA_LIMITS.IMAGE_MAX_EDGE / Math.max(width, height)); + canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round(width * scale)); canvas.height = Math.max(1, Math.round(height * scale)); + const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('Canvas unavailable'); + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + let quality = PUBLISH_MEDIA_LIMITS.IMAGE_QUALITY; + const encode = () => { + if (settled) return; + try { + canvas.toBlob(blob => { + if (settled) return; + try { + if (!blob || !Number.isSafeInteger(blob.size) || blob.size <= 0) throw new Error('Invalid video frame output'); + if (blob.size > IMAGE_PROCESS.MAX_OUTPUT_BYTES) { + if (quality <= IMAGE_PROCESS.MIN_QUALITY) throw new Error('Video frame output exceeds budget'); + quality = Math.max(IMAGE_PROCESS.MIN_QUALITY, quality - IMAGE_PROCESS.QUALITY_STEP); + encode(); return; + } + finish(null, new File([blob], 'cover.jpg', { type: 'image/jpeg' })); + } catch (error) { finish(error); } + }, 'image/jpeg', quality); + } catch (error) { finish(error); } + }; + encode(); + } catch (error) { finish(error); } + }); +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\264\255\344\271\260\347\212\266\346\200\201\347\224\237\345\221\275\345\221\250\346\234\237.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\264\255\344\271\260\347\212\266\346\200\201\347\224\237\345\221\275\345\221\250\346\234\237.js" new file mode 100644 index 0000000000000000000000000000000000000000..ef0bd6da3557f3452c7016601ed0ceb52bbf0a93 --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\264\255\344\271\260\347\212\266\346\200\201\347\224\237\345\221\275\345\221\250\346\234\237.js" @@ -0,0 +1,37 @@ +// 购买/退款状态DOM属于原账号;两种资源入口共用取消和身份释放。 +import { captureRequestSession, requestCancelManager } from "../core/网络请求API.js"; +import { API, CACHE } from "../core/全局配置.js"; +import { getUser, eventBus, EVENTS } from "../core/状态管理.js"; +import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js"; + +const scopes = new WeakMap(); +let sequence = 0; +export function createPurchaseStatusScope(node, account) { + scopes.get(node)?.dispose(); + const matches = captureRequestSession(), componentId = `purchase-status-${++sequence}`; + let constructing = true, observer; + const scope = createTaskViewScope({ + sessionMatches: () => matches() && getUser()?.account === account, + connected: () => constructing || node.isConnected, + subscribe: notify => subscribeTaskSessionChanges(notify, { target: window, eventBus, events: EVENTS, + userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN }), + cancelReads: () => requestCancelManager.cancelAll(componentId), invalidate: () => {} + }); + scope.readOptions = { noCache: true, componentId, timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES }; + scope.addCleanup(() => { observer?.disconnect(); node.replaceChildren(); node.style.display = "none"; }); + node._cleanup = () => scope.dispose(); scopes.set(node, scope); + queueMicrotask(() => { + constructing = false; + if (!scope.isCurrent()) return; + observer = new MutationObserver(() => { scope.isCurrent(); }); + observer.observe(document.body, { childList: true, subtree: true }); + }); + return scope; +} + +export function isValidPurchaseStatus(response) { + if (response?.status !== "success" || typeof response.owned !== "boolean") return false; + if (!response.owned) return true; + return typeof response.allow_refund === "boolean" && typeof response.can_refund === "boolean" && Number.isSafeInteger(response.price_paid) && response.price_paid >= 0 + && typeof response.refund_hours_left === "number" && Number.isFinite(response.refund_hours_left) && response.refund_hours_left >= 0; +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\346\272\220\345\210\240\351\231\244\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\346\272\220\345\210\240\351\231\244\347\273\204\344\273\266.js" new file mode 100644 index 0000000000000000000000000000000000000000..debfc4b221e6441c6938167a76d43690d55705cd --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\346\272\220\345\210\240\351\231\244\347\273\204\344\273\266.js" @@ -0,0 +1,59 @@ +// 资源卡片与详情共用删除确认;取消页面不取消或重放已经提交的DELETE。 +import { api, captureRequestSession, invalidateRelatedCache } from "../core/网络请求API.js"; +import { CACHE } from "../core/全局配置.js"; +import { getUser, eventBus, EVENTS } from "../core/状态管理.js"; +import { showConfirm, showToast } from "../components/UI交互提示组件.js"; +import { t } from "../components/用户体验增强.js"; +import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js"; + +const active = new Set(); +export async function showResourceDeleteDialog({ itemData, currentUser, isCurrent = () => true, onDeleted }) { + const account = currentUser?.account, itemId = itemData?.id; + const matches = captureRequestSession(); + const sessionMatches = () => matches() && getUser()?.account === account; + if (!account || !itemId || !sessionMatches() || !isCurrent() + || (itemData.author !== account && currentUser.is_admin !== true)) return false; + const key = JSON.stringify([account, itemId]); + if (active.has(key)) return false; + active.add(key); + const controller = new AbortController(); + let observer; + const scope = createTaskViewScope({ + sessionMatches, connected: isCurrent, + subscribe: notify => subscribeTaskSessionChanges(notify, { target: window, eventBus, events: EVENTS, + userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN }), + cancelReads: () => {}, invalidate: () => {} + }); + scope.addCleanup(() => { observer?.disconnect(); controller.abort(); active.delete(key); }); + observer = new MutationObserver(() => { scope.isCurrent(); }); + observer.observe(document.body, { childList: true, subtree: true }); + const message = [t('item.content_label') + (itemData.title || t('item.untitled')), + t('item.type_label') + (itemData.type || t('item.type_unknown')), '', t('item.delete_consequence'), + t('item.delete_consequence_1'), t('item.delete_consequence_2'), t('item.delete_consequence_3')].join('\n'); + try { + const succeeded = await showConfirm(message, { + title: t('item.confirm_delete_title'), type: 'danger', confirmText: t('item.confirm_delete'), + processingText: t('item.deleting'), signal: controller.signal, + onConfirm: async () => { + if (!scope.isCurrent()) return false; + try { + const result = await api.deleteItem(itemId); + if (!scope.isCurrent()) return false; + if (result?.status !== 'success') { + showToast(t('item.delete_failed_retry'), 'error'); return false; + } + return true; + } catch (_) { + if (scope.isCurrent()) showToast(t('item.delete_request_failed'), 'error'); + return false; + } + } + }); + if (!succeeded || !scope.isCurrent()) return false; + try { invalidateRelatedCache(`/api/items/${itemId}`, 'DELETE'); } + catch (_) { console.warn('删除成功,部分本地缓存无法清理'); } + showToast(t('item.delete_success'), 'success'); + try { onDeleted?.(); } catch (_) { console.warn('删除成功,视图更新失败'); } + return true; + } finally { scope.dispose(); } +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\346\272\220\345\256\211\350\243\205\345\274\225\346\223\216.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\346\272\220\345\256\211\350\243\205\345\274\225\346\223\216.js" index 8657d849d426cc8fe3886c3a3c9aa2687f935e8f..b2d533d1a3b87dc1ced63eb68105e9b408a7183e 100644 --- "a/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\346\272\220\345\256\211\350\243\205\345\274\225\346\223\216.js" +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\346\272\220\345\256\211\350\243\205\345\274\225\346\223\216.js" @@ -5,16 +5,22 @@ import { requestSSE, request } from "../core/网络请求_基础设施.js"; import { openUserProfileModal } from "../profile/个人中心视图.js"; import { API, CACHE, IS_WEB_MODE, escapeHtml } from "../core/全局配置.js"; import { t } from "../components/用户体验增强.js"; +import { createResourceOperationScope } from "./资源操作生命周期.js"; +import { isValidPurchaseStatus } from "./购买状态生命周期.js"; +import { saveAcquiredItem } from "./资源获取记录.js"; +export { getAcquiredItems } from "./资源获取记录.js"; // 📱 Web 模式兼容:ComfyUI 的 app 对象仅存在于本地环境,改为受保护动态导入 // (Web 模式不暴露安装/下载入口,不会走到此路径;万一走到也不抛错) -async function _loadGraphToCanvas(graphData) { +async function _loadGraphToCanvas(graphData, isCurrent) { + if (!isCurrent() || !graphData || typeof graphData !== "object" || Array.isArray(graphData) || Object.hasOwn(graphData, "error")) return false; try { const { app } = await import("../../../scripts/app.js"); - app.loadGraphData(graphData); + if (!isCurrent()) return false; + await app.loadGraphData(graphData); return true; } catch (e) { - console.warn("当前环境不支持加载工作流到画布:", e); + console.warn("当前环境无法加载工作流到画布"); return false; } } @@ -47,7 +53,87 @@ function clearUsesCache() { * @returns {string} JWT Token(未登录时为空字符串) */ function _getAuthToken() { - return localStorage.getItem("ComfyCommunity_Token") || sessionStorage.getItem("ComfyCommunity_Token") || ""; + try { + const localToken = localStorage.getItem("ComfyCommunity_Token"); + if (localToken) return localToken; + } catch (error) { + console.warn('无法读取本地登录凭证存储'); + } + try { + return sessionStorage.getItem("ComfyCommunity_Token") || ""; + } catch (error) { + console.warn('无法读取会话登录凭证存储'); + return ""; + } +} + +function _getInstallErrorMessage(result) { + if (result?.outcomeUnknown) return t('install.connection_uncertain'); + const errorKeys = { + missing_params: 'install.error_missing_params', + invalid_repository: 'install.error_invalid_repository', + invalid_target: 'install.error_invalid_target', + git_missing: 'install.error_git_missing', + install_timeout: 'install.error_timeout', + install_queue_timeout: 'install.error_queue_timeout', + install_queue_full: 'install.error_queue_full', + git_clone_failed: 'install.error_git_clone', + insufficient_disk_space: 'install.error_disk_space', + remote_rejected: 'install.error_remote_rejected', + package_too_large: 'install.error_package_too_large', + repository_too_large: 'install.error_repository_too_large', + process_guard_unavailable: 'install.error_process_guard', + download_failed: 'install.error_download', + target_occupied: 'install.error_target_occupied', + invalid_archive: 'install.error_invalid_archive', + install_failed: 'install.error_generic' + }; + const errorKey = Object.hasOwn(errorKeys, result?.error_code) ? errorKeys[result.error_code] : ""; + return errorKey ? t(errorKey) : t('install.error_generic'); +} + +const INSTALL_STAGE_KEYS = { + queued: 'install.stage_queued', + validate: 'install.stage_validate', + auth: 'install.stage_auth', + cleanup: 'install.stage_cleanup', + git_mirror: 'install.stage_git_mirror', + git_cloning: 'install.stage_git_clone', + git_fallback: 'install.stage_git_fallback', + git_direct: 'install.stage_git_direct', + downloading: 'install.stage_downloading', + download_done: 'install.stage_download_done', + extracting: 'install.stage_extracting', + installing: 'install.stage_installing', + complete: 'install.stage_complete', + error: 'install.stage_failed' +}; + +const WORKFLOW_STAGE_KEYS = { + validate: 'workflow.stage_validate', + cache_check: 'workflow.stage_cache_check', + cache_hit: 'workflow.stage_cache_hit', + downloading: 'workflow.stage_downloading', + saving: 'workflow.stage_saving', + complete: 'workflow.stage_download_complete', + error: 'workflow.stage_failed' +}; + +function _getWorkflowErrorMessage(result) { + if (result?.outcomeUnknown) return t('workflow.connection_uncertain'); + const errorKeys = { + invalid_request: 'workflow.error_invalid_request', + missing_params: 'workflow.error_missing_params', + invalid_app_id: 'workflow.error_invalid_id', + invalid_workflow_data: 'workflow.error_invalid_data', + auth_failed: 'workflow.error_auth', + remote_rejected: 'workflow.error_remote', + network_failed: 'workflow.error_network', + cloud_cooldown: 'workflow.error_cloud_cooldown', + download_failed: 'workflow.error_generic' + }; + const errorKey = Object.hasOwn(errorKeys, result?.error_code) ? errorKeys[result.error_code] : ""; + return errorKey ? t(errorKey) : t('workflow.error_generic'); } /** @@ -56,563 +142,761 @@ function _getAuthToken() { * @param {Object} itemData - 资源数据 * @param {string} netdiskPassword - 网盘密码(为空则显示无密码版本) */ -function _renderNetdiskUI(inlineStatusBox, itemData, netdiskPassword) { - inlineStatusBox.innerHTML = ''; +function _getSafeExternalUrl(link) { + let parsedUrl; + try { + if (typeof link !== 'string' || !link.trim()) throw new Error('Missing resource link'); + parsedUrl = new URL(link.trim()); + } catch (_) { + showToast(t('netdisk.invalid_link'), 'error'); + return null; + } + if (!['http:', 'https:'].includes(parsedUrl.protocol) || parsedUrl.username || parsedUrl.password) { + showToast(t('netdisk.invalid_link'), 'error'); + return null; + } + return parsedUrl.href; +} + +function _openExternalResource(link, itemId, isCurrent) { + if (!isCurrent()) return; + const safeUrl = _getSafeExternalUrl(link); + if (!safeUrl) return; + window.open(safeUrl, '_blank', 'noopener,noreferrer'); + api.recordItemUse(itemId).then(() => { + if (!isCurrent()) return; + clearUsesCache(); + window.dispatchEvent(new CustomEvent('comfy-trigger-sidebar-reload', { detail: { force: true } })); + }).catch(() => {}); +} + +function _renderNetdiskUI(inlineStatusBox, itemData, netdiskPassword, isCurrent) { + if (!isCurrent()) return; + inlineStatusBox.replaceChildren(); inlineStatusBox.style.display = 'block'; + const container = document.createElement('div'); + container.className = 'netdisk-card'; + const summary = document.createElement('div'); + summary.className = 'netdisk-card__summary'; + summary.textContent = t(netdiskPassword ? 'netdisk.authorized_with_password' : 'netdisk.authorized_no_password'); + container.appendChild(summary); + + const panel = document.createElement('div'); + panel.className = 'netdisk-card__panel'; + const openButton = document.createElement('button'); + openButton.type = 'button'; + openButton.className = 'netdisk-card__open'; + openButton.textContent = t('netdisk.open'); + openButton.addEventListener('click', () => _openExternalResource(itemData.link, itemData.id, isCurrent)); + + let hint = null; if (netdiskPassword) { - // 🔒 XSS防护:使用DOM API安全构建网盘密码显示区域 - const container = document.createElement('div'); - container.innerHTML = ` -
✅ 授权通过,网盘资源信息如下:
-
-
- 🔗 网盘链接: - -
-
- 🔐 提取码: - - -
-
-
💡 提示:请复制提取码后前往网盘下载资源
- `; - inlineStatusBox.appendChild(container); - - // 安全设置密码文本(防止XSS) - const pwdCodeEl = container.querySelector(`#netdisk-pwd-code-${itemData.id}`); - if (pwdCodeEl) pwdCodeEl.textContent = netdiskPassword; - - // 绑定复制按钮事件 - const copyBtn = container.querySelector(`#btn-copy-pwd-${itemData.id}`); - if (copyBtn) { - copyBtn.onclick = () => { - navigator.clipboard.writeText(netdiskPassword); - copyBtn.textContent = '✅ 已复制'; - }; - } + const row = document.createElement('div'); + row.className = 'netdisk-card__row'; + const codeLabel = document.createElement('span'); + codeLabel.className = 'netdisk-card__label'; + codeLabel.textContent = t('netdisk.code_label'); + const code = document.createElement('code'); + code.className = 'netdisk-card__code'; + code.textContent = netdiskPassword; + const copyButton = document.createElement('button'); + copyButton.type = 'button'; + copyButton.className = 'netdisk-card__copy'; + copyButton.textContent = t('netdisk.copy'); + copyButton.addEventListener('click', async () => { + if (!isCurrent()) return; + try { + await navigator.clipboard.writeText(netdiskPassword); + if (isCurrent()) copyButton.textContent = t('common.copied'); + } catch (error) { + if (isCurrent()) showToast(t('netdisk.copy_failed'), 'error'); + } + }); + row.append(codeLabel, code, copyButton); + panel.append(row); - // 绑定打开网盘按钮事件 - const btnOpen = inlineStatusBox.querySelector(`#btn-netdisk-open-pwd-${itemData.id}`); - if (btnOpen) { - btnOpen.onclick = () => { - window.open(itemData.link, '_blank'); - api.recordItemUse(itemData.id).then(() => { - clearUsesCache(); - window.dispatchEvent(new CustomEvent("comfy-trigger-sidebar-reload", { detail: { force: true } })); - }).catch(err => console.warn('📊 使用量记录失败:', err)); - }; - } - } else { - // 无密码网盘资源 - inlineStatusBox.innerHTML = ` -
✅ 授权通过,请前往网盘下载:
- - `; - // 绑定打开网盘按钮事件 - const btnOpen = inlineStatusBox.querySelector(`#btn-netdisk-open-${itemData.id}`); - if (btnOpen) { - btnOpen.onclick = () => { - window.open(itemData.link, '_blank'); - api.recordItemUse(itemData.id).then(() => { - clearUsesCache(); - window.dispatchEvent(new CustomEvent("comfy-trigger-sidebar-reload", { detail: { force: true } })); - }).catch(err => console.warn('📊 使用量记录失败:', err)); - }; - } + hint = document.createElement('div'); + hint.className = 'netdisk-card__hint'; + hint.textContent = t('netdisk.download_hint'); } + + panel.prepend(openButton); + container.appendChild(panel); + if (hint) container.appendChild(hint); + inlineStatusBox.appendChild(container); } /** - * 保存已获取的资源记录到本地 - * @param {Object} itemData - 资源数据 + * 检查资源是否已安装 + * @param {string} itemId - 资源ID + * @returns {{installed: boolean, hasUpdate: boolean, localVersion: string|null, storageAvailable: boolean}} */ -function saveAcquiredItem(itemData) { +export function checkItemStatus(itemId, cloudVersion) { + const versionStatus = getInstalledVersionStatus(itemId); + const localVersion = versionStatus.version; + if (!localVersion) { + return { installed: versionStatus.installed === true, hasUpdate: false, localVersion: null, storageAvailable: versionStatus.available }; + } + const hasUpdate = cloudVersion && localVersion !== cloudVersion; + return { installed: true, hasUpdate, localVersion, storageAvailable: versionStatus.available }; +} + +const failedInstalledVersionWrites = new Map(); + +export function getInstalledVersionStatus(itemId) { try { - const key = CACHE.LOCAL_KEYS.ACQUIRED_ITEMS; - const existing = JSON.parse(localStorage.getItem(key) || "[]"); - - // 检查是否已存在,如果存在则更新 - const index = existing.findIndex(item => item.id === itemData.id); - const record = { - id: itemData.id, - title: itemData.title, - type: itemData.type, - author: itemData.author, - shortDesc: itemData.shortDesc || itemData.short_desc || '', - coverUrl: itemData.coverUrl || itemData.cover_url || '', // 新增 - coverBase64: itemData.coverBase64, - imageUrls: itemData.imageUrls || itemData.image_urls || [], // 新增 - link: itemData.link || itemData.github_url || '', - price: itemData.price || 0, - likes: itemData.likes || 0, // 新增 - uses: itemData.uses || 0, // 新增 - local_version: itemData.latest_version || itemData.local_version || itemData.version || '', - acquired_at: Date.now() - }; - - if (index >= 0) { - existing[index] = record; - } else { - existing.unshift(record); // 新记录放在最前面 - } - - localStorage.setItem(key, JSON.stringify(existing)); - } catch (e) { - console.warn("保存已获取资源记录失败:", e); + const failedWrite = failedInstalledVersionWrites.has(itemId); + const recorded = failedWrite ? failedInstalledVersionWrites.get(itemId) + : localStorage.getItem(CACHE.LOCAL_KEYS.INSTALLED_VERSION_PREFIX + itemId); + const unconfirmed = recorded === CACHE.INSTALLED_VERSION_UNCONFIRMED; + return { version: unconfirmed ? null : recorded, installed: !!recorded, versionConfirmed: !!recorded && !unconfirmed, available: !failedWrite }; + } catch (error) { + console.warn('无法读取本地安装版本标记'); + return { version: null, installed: false, versionConfirmed: false, available: false }; } } -/** - * 获取所有已获取的资源记录 - * @returns {Array} - */ -export function getAcquiredItems() { +export function saveInstalledVersion(itemId, version) { + if (!version) return true; try { - const key = CACHE.LOCAL_KEYS.ACQUIRED_ITEMS; - return JSON.parse(localStorage.getItem(key) || "[]"); - } catch (e) { - return []; + localStorage.setItem(CACHE.LOCAL_KEYS.INSTALLED_VERSION_PREFIX + itemId, version); + failedInstalledVersionWrites.delete(itemId); + return true; + } catch (error) { + failedInstalledVersionWrites.set(itemId, version); + console.warn('无法保存本地安装版本标记'); + return false; } } -/** - * 检查资源是否已安装 - * @param {string} itemId - 资源ID - * @returns {Object|null} { installed: boolean, hasUpdate: boolean, localVersion: string|null } - */ -export function checkItemStatus(itemId, cloudVersion) { - const localVersion = localStorage.getItem(`ComfyCommunity_LocalVer_${itemId}`); - if (!localVersion) { - return { installed: false, hasUpdate: false, localVersion: null }; +function _recordToolInstalledVersion(itemId, result) { + const value = result?.installed_version; + const confirmed = typeof value === "string" && /^(?:[a-f0-9]{7}|[a-f0-9]{40})$/i.test(value.trim()); + const version = confirmed ? value.trim().slice(0, 7).toLowerCase() : CACHE.INSTALLED_VERSION_UNCONFIRMED; + if (!saveInstalledVersion(itemId, version)) { + showToast(t('install.version_record_unavailable'), 'warning'); } - const hasUpdate = cloudVersion && localVersion !== cloudVersion; - return { installed: true, hasUpdate, localVersion }; + if (!confirmed) showToast(t('install.version_unconfirmed'), 'warning'); } -export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatusBox) { +function _guessInstallTargetName(itemUrl) { + try { + const lastSegment = String(itemUrl).replace(/\/+$/, '').split('/').pop() || ''; + const name = lastSegment.replace(/\.git$/i, ''); + return /^[A-Za-z0-9_.-]+$/.test(name) && name !== '.' && name !== '..' && !name.includes('..') + ? name + : null; + } catch (error) { + return null; + } +} + +const activeOperations = new Map(); +let latestWalletRefresh = 0; + +export function setupResourceInstall(btnUse, itemData, currentUser, inlineStatusBox, options = {}) { + btnUse.type = "button"; + btnUse.classList.add("resource-install__button"); + for (const property of ["background", "color", "border", "border-color"]) btnUse.style.removeProperty(property); + inlineStatusBox.classList.add("resource-install__status"); + const account = currentUser?.account, token = _getAuthToken(); + const scope = createResourceOperationScope(btnUse, inlineStatusBox, account, options); + const isCurrent = () => scope.isCurrent(); + const confirm = (message, options = {}) => showConfirm(message, { ...options, signal: scope.confirmationSignal }); + let phase = "idle", previousProgress, removeProgressCleanup = () => {}; + const operationKey = JSON.stringify([account, String(itemData.id)]); + scope.addCleanup(() => { if (activeOperations.get(operationKey) === scope) activeOperations.delete(operationKey); }); + const trackProgress = () => { + removeProgressCleanup(); previousProgress?.destroy(); + previousProgress = createInstallProgress(inlineStatusBox); + const progress = previousProgress; + removeProgressCleanup = scope.addCleanup(() => progress.destroy()); + return progress; + }; + const releaseButton = () => { + if (phase !== "idle") return; + if (activeOperations.get(operationKey) === scope) activeOperations.delete(operationKey); + if (isCurrent()) { btnUse.disabled = false; btnUse.removeAttribute("aria-busy"); } + }; btnUse.onclick = async (e) => { e.stopPropagation(); - if (!currentUser) return showToast("⚠️ 请先登录您的社区账号后再获取!", "warning"); - - const isFree = !itemData.price || itemData.price <= 0 || currentUser.account === itemData.author; - const isTool = itemData.type === 'tool' || itemData.type === 'recommend_tool' || itemData.type === 'skill' || itemData.type === 'recommend_skill'; - const isApp = itemData.type === 'app' || itemData.type === 'recommend_app'; - - // 📱 Web 模式:不支持下载/安装。免费(含作者本人)或已购买 → 仅提醒电脑端安装;未购买 → 放行到下方纯购买流程 - if (IS_WEB_MODE) { - if (isFree) return showToast(t('web.install_on_desktop'), "warning"); - let webOwned = false; - try { - const st = await api.getPurchaseStatus(currentUser.account, itemData.id); - webOwned = !!(st && st.owned); - } catch (e) { - console.warn('购买状态查询失败,继续购买流程', e); + if (!isCurrent() || phase !== "idle" || activeOperations.has(operationKey)) return; + if (!account) return showToast(t('auth.login_required'), "warning"); + activeOperations.set(operationKey, scope); + ++latestWalletRefresh; // 新操作使先前余额补新失效。 + phase = "preflight"; btnUse.disabled = true; btnUse.setAttribute("aria-busy", "true"); + try { + const price = itemData.price ?? 0; + if (!Number.isSafeInteger(price) || price < 0 || !itemData.id) { + showToast(t('acquire.invalid_id'), "error"); return; } - if (webOwned) return showToast(t('item.owned_purchased'), "warning"); - } - - // 记录是否已经拥有,决定是否涨销量 - let alreadyOwned = false; + const isFree = price === 0 || account === itemData.author; + const isTool = itemData.type === 'tool' || itemData.type === 'recommend_tool' || itemData.type === 'skill' || itemData.type === 'recommend_skill'; + const isApp = itemData.type === 'app' || itemData.type === 'recommend_app'; + if (IS_WEB_MODE && isFree) return showToast(t('web.install_on_desktop'), "warning"); - // 已购买用户跳过余额检查 - if (!isFree && currentUser && currentUser.account) { + // 同一次新读取决定是否拥有;查询失败不能继续到付款。 + let alreadyOwned = false; try { - const purchaseStatus = await api.getPurchaseStatus(currentUser.account, itemData.id); - if (purchaseStatus && purchaseStatus.owned) { - alreadyOwned = true; + const status = await api.getPurchaseStatus(account, itemData.id, scope.readOptions); + if (!isCurrent()) return; + if (!isValidPurchaseStatus(status)) throw new Error(); + if (status.price_confirmation_supported !== true) { + showToast(t('acquire.purchase_verification_unavailable'), "warning"); + return; } - } catch (e) { - console.warn('购买状态查询失败,继续正常流程', e); + alreadyOwned = status.owned; + } catch (_) { + if (isCurrent()) showToast(t('common.network_error_retry'), "error"); + return; } - } + if (IS_WEB_MODE && alreadyOwned) return showToast(t('item.owned_purchased'), "warning"); - // 【新增】:本地前置余额校验(购买前动态获取最新余额) - if (!isFree && !alreadyOwned) { - try { - const walletRes = await api.getWallet(currentUser.account); - if (walletRes && walletRes.status === "success") { - currentUser.balance = walletRes.balance || 0; + if (!isFree && !alreadyOwned) { + let balance; + try { + const wallet = await api.getWallet(account, scope.readOptions); + if (!isCurrent()) return; + if (wallet?.status !== "success" || !Number.isSafeInteger(wallet.balance) || wallet.balance < 0) throw new Error(); + balance = wallet.balance; + } catch (_) { + if (isCurrent()) showToast(t('common.network_error_retry'), "error"); + return; } - } catch (e) { - console.warn("获取钱包余额失败:", e); - } - - if ((currentUser.balance || 0) < itemData.price) { - if (await showConfirm(`您的积分余额不足(当前:${currentUser.balance || 0},需要:${itemData.price})。是否前往个人中心充值?`)) { - if (typeof globalModal !== 'undefined') globalModal.closeTopModal(); - // 【修复点】:直接调用函数唤起个人中心,解决白屏 Bug - openUserProfileModal(currentUser); + if (balance < price) { + const recharge = await confirm(`${t('acquire.balance_short', { balance, price })} ${t('acquire.balance_recharge_prompt')}`); + if (recharge && isCurrent()) await openUserProfileModal(currentUser); + return; + } + if (itemData.is_netdisk === true) { + if (!_getSafeExternalUrl(itemData.link)) return; + } else { + inlineStatusBox.style.display = "block"; + inlineStatusBox.textContent = t('acquire.validation_running'); + const validationController = new AbortController(); + const removeValidationCleanup = scope.addCleanup(() => validationController.abort()); + const timeout = setTimeout(() => validationController.abort(), API.BACKGROUND_TIMEOUT); + try { + const response = await fetch(`${API.BASE_URL}/api/validate_resource`, { + method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` }, + body: JSON.stringify({ url: itemData.link, item_id: itemData.id, account }), + signal: validationController.signal + }); + if (!isCurrent()) return; + const data = await response.json(); + if (!isCurrent()) return; + if (!response.ok || data?.status !== "success" || data.error) throw new Error(); + } catch (_) { + if (isCurrent()) inlineStatusBox.textContent = t('acquire.validation_blocked', { message: t('common.network_error_retry') }); + return; + } finally { clearTimeout(timeout); removeValidationCleanup(); } } - return; // 余额不足,直接终止后续所有云端请求 + const confirmPay = await confirm(t('acquire.purchase_confirmation_html', { price }), { html: true }); + if (!isCurrent()) return; + if (!confirmPay) { inlineStatusBox.style.display = "none"; return; } } - } - - // 防线 1:扣费前进行死链探测 (解决问题 1) - if (!isFree) { + if (!isCurrent()) return; inlineStatusBox.style.display = "block"; - inlineStatusBox.innerHTML = `⏳ 正在检测云端资源有效性...`; - + inlineStatusBox.textContent = t('acquire.purchase_running'); + let purchaseRes; try { - // 直接向云端发起验证(含10秒超时控制) - const valController = new AbortController(); - const valTimeout = setTimeout(() => valController.abort(), 10000); - const valRes = await fetch(`${API.BASE_URL}/api/validate_resource`, { - method: "POST", headers: {"Content-Type": "application/json"}, - body: JSON.stringify({ - url: itemData.link, - item_id: itemData.id, - account: currentUser.account - }), - signal: valController.signal - }); - clearTimeout(valTimeout); - const valData = await valRes.json(); - if (!valRes.ok || valData.error) { - inlineStatusBox.innerHTML = `❌ 拦截提示: ${escapeHtml(valData.error || '无法访问源地址')}`; - return; // 发现死链,强行阻断用户付款 - } - } catch(e) { - if (e.name === 'AbortError') { - console.warn("[资源验证] 超时,跳过验证"); + purchaseRes = await api.purchaseItem(account, itemData.id, isFree ? 0 : price); + if (!isCurrent()) return; + if (purchaseRes?.status !== "success" || typeof purchaseRes.already_owned !== "boolean" + || typeof purchaseRes.is_netdisk !== "boolean" + || (purchaseRes.netdisk_password != null && typeof purchaseRes.netdisk_password !== "string")) throw new Error(); + } catch (error) { + if (!isCurrent()) return; + inlineStatusBox.setAttribute("role", "alert"); + if (["purchase_price_changed", "purchase_price_confirmation_required"].includes(error?.code)) { + phase = "unknown"; btnUse.removeAttribute("aria-busy"); + inlineStatusBox.textContent = t('acquire.price_changed'); + } else if (error?.status >= 400 && error.status < 500) { + inlineStatusBox.textContent = t(error.status === 402 ? 'acquire.purchase_balance_short' : 'common.error'); } else { - console.warn("[资源验证] 网络异常,跳过验证:", e.message || e); + phase = "unknown"; btnUse.removeAttribute("aria-busy"); + inlineStatusBox.textContent = t('acquire.purchase_unknown'); } + return; } - - // 已购买用户跳过购买确认弹窗,直接进入后续流程 - if (!alreadyOwned) { - const confirmPay = await showConfirm(`该资源标价为 ${itemData.price} 积分。

确认获取吗?
(注:一次购买永久免费,如果已购买过系统将直接放行,不会重复扣款)`, { html: true }); - if (!confirmPay) { - inlineStatusBox.style.display = "none"; - return; - } + alreadyOwned = purchaseRes.already_owned; + if (!isFree) showToast(t(alreadyOwned ? 'acquire.already_owned_no_charge' : 'acquire.purchase_success', { price }), alreadyOwned ? "info" : "success"); + // 权威余额来自 SQL 钱包;补新不阻塞已经确认的资源交付。 + if (!isFree) { + const walletVersion = ++latestWalletRefresh; + api.getWallet(account, scope.readOptions).then(wallet => { + if (walletVersion !== latestWalletRefresh || !isCurrent() || wallet?.status !== "success" || !Number.isSafeInteger(wallet.balance) || wallet.balance < 0) return; + currentUser.balance = wallet.balance; + window.dispatchEvent(new CustomEvent("comfy-wallet-update", { detail: { account, balance: wallet.balance } })); + }).catch(() => {}); } - } else { - inlineStatusBox.style.display = "block"; - } - - inlineStatusBox.innerHTML = `⏳ 正在校验购买状态与扣款安全...`; - - // 防线 2:事务级扣费 (天然解决问题 3,意外中断不重扣) - let purchaseRes = null; // ☁️ 保存购买响应(包含网盘密码) - // 🐛 Bug修复:防御性校验,防止 itemId 为空 - if (!itemData.id) { - console.warn('⚠️ 资源安装: itemData.id 为空,已拦截无效请求'); - inlineStatusBox.innerHTML = `❌ 获取失败:资源ID无效`; - return; - } - try { - purchaseRes = await api.purchaseItem(currentUser.account, itemData.id); - - // 【需求2修改核心】:如果返回 already_owned,改变提示 - if (purchaseRes && purchaseRes.already_owned) { - alreadyOwned = true; - if (!isFree) showToast("您已购买过此商品,此次使用不会收费!", "info"); - } else if (!isFree) { - showToast(`支付成功!已扣除 ${itemData.price} 积分。`, "success"); - } - - // 更新用户本地显示的余额缓存 - try { - const profileRes = await api.getUserProfile(currentUser.account); - if (profileRes.data && profileRes.data.balance !== undefined) { - currentUser.balance = profileRes.data.balance; - } - } catch(e) {} - - } catch (err) { - // 【需求3修改核心】:捕获余额不足异常,阻断流程并引导充值 - if (err.message && err.message.includes("余额不足")) { - inlineStatusBox.innerHTML = `❌ 获取失败:积分余额不足。`; - if (await showConfirm("您的积分余额不足,是否立刻前往个人中心充值?")) { - // 【修复点】:直接调用函数唤起个人中心,解决白屏 Bug - openUserProfileModal(currentUser); - } - return; // 直接退出函数,不会走到下方的安装流程 - } else { - inlineStatusBox.innerHTML = `❌ 获取失败: ${escapeHtml(err.message)}`; - showToast("获取失败:" + err.message, "error"); + if (!isCurrent()) return; + + // 📱 Web 模式:购买完成即止,不进入任何下载/安装/网盘交付分支 + if (IS_WEB_MODE) { + inlineStatusBox.style.display = "block"; + inlineStatusBox.innerHTML = alreadyOwned + ? `✅ ${escapeHtml(t('item.owned_purchased'))}` + : `✅ ${escapeHtml(t('web.purchase_success_desktop'))}`; + showToast(t('web.install_on_desktop'), "info"); return; } - } - // 📱 Web 模式:购买完成即止,不进入任何下载/安装/网盘交付分支 - if (IS_WEB_MODE) { - inlineStatusBox.style.display = "block"; - inlineStatusBox.innerHTML = alreadyOwned - ? `✅ ${escapeHtml(t('item.owned_purchased'))}` - : `✅ ${escapeHtml(t('web.purchase_success_desktop'))}`; - showToast(t('web.install_on_desktop'), "info"); - return; - } + // ☁️ 优先处理网盘资源(不论是工具还是应用) + const isNetdisk = purchaseRes.is_netdisk; + const netdiskPassword = purchaseRes.netdisk_password; - // ☁️ 优先处理网盘资源(不论是工具还是应用) - const isNetdisk = purchaseRes?.is_netdisk || itemData.is_netdisk; - const netdiskPassword = purchaseRes?.netdisk_password || itemData.netdisk_password; - - if (isNetdisk) { - // 网盘资源:显示链接和密码(recordItemUse 延迟到用户点击按钮后) - saveAcquiredItem(itemData); - _renderNetdiskUI(inlineStatusBox, itemData, netdiskPassword); - btnUse.innerHTML = `✅ 已获取`; - btnUse.style.background = "#4CAF50"; - return; // ← 提前返回,不进入 Git 安装流程 - } + if (isNetdisk) { + // 网盘资源:显示链接和密码(recordItemUse 延迟到用户点击按钮后) + saveAcquiredItem(itemData); + _renderNetdiskUI(inlineStatusBox, itemData, netdiskPassword, isCurrent); + btnUse.innerHTML = `✅ ${escapeHtml(t('common.acquired'))}`; + btnUse.setAttribute("data-install-state", "success"); + return; // ← 提前返回,不进入 Git 安装流程 + } - if (isTool) { - const installPromptText = isFree - ? "✅ 授权通过!是否通过 Git 自动将此工具克隆安装到本地 custom_nodes 文件夹?" - : "✅ 授权通过!是否开始静默下载并热覆盖到本地 custom_nodes 文件夹?"; - - inlineStatusBox.innerHTML = ` -
${installPromptText}
-
- - -
- `; - inlineStatusBox.querySelector('#btn-cancel-install').onclick = () => inlineStatusBox.style.display = "none"; - inlineStatusBox.querySelector('#btn-confirm-install').onclick = async () => { - // 获取最新的 item 数据以确保路由决策准确 - // noCache: true 绕过前端 GET 请求缓存(5min TTL),确保读取服务器最新 has_private_token 值 - let freshHasPrivateToken = !!itemData.has_private_token; - let apiFetchFailed = false; + if (isTool) { + inlineStatusBox.style.display = "block"; + inlineStatusBox.textContent = t('install.preview_loading'); + let installPreview = null; + const previewController = new AbortController(); + const removePreviewCleanup = scope.addCleanup(() => previewController.abort()); + const previewTimeout = setTimeout(() => previewController.abort(), API.BACKGROUND_TIMEOUT); try { - const freshRes = await request(`/api/items/${itemData.id}`, { noCache: true }); - if (freshRes.status === "success" && freshRes.data) { - freshHasPrivateToken = !!freshRes.data.has_private_token; + const previewResponse = await fetch("/community_hub/install_preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: itemData.link }), signal: previewController.signal + }); + if (!isCurrent()) return; + if (previewResponse.ok) { + const previewData = await previewResponse.json(); + if (!isCurrent()) return; + if (previewData.status === "success") installPreview = previewData; + } + } catch (error) { + if (!isCurrent()) return; + } finally { clearTimeout(previewTimeout); removePreviewCleanup(); } + if (!isCurrent()) return; + const guessedTargetName = _guessInstallTargetName(itemData.link); + const targetDisplay = installPreview?.target_display + || (guessedTargetName ? `custom_nodes/${guessedTargetName}` : null); + const installPromptText = t( + isFree ? 'install.confirm_git' : 'install.confirm_zip', + { folder: targetDisplay || t('install.target_unknown') } + ); + const targetStateText = installPreview + ? (installPreview.self_update + ? t('install.target_self_update') + : (installPreview.already_exists + ? t('install.target_exists') + : t('install.target_new'))) + : t('install.preview_unavailable'); + let localVersionText = ''; + if (installPreview?.already_exists) { + if (installPreview.current_git_version) { + localVersionText = t('install.current_git_version', { + version: installPreview.current_git_version + }); + } else if (!isFree) { + try { + const recordedVersion = getInstalledVersionStatus(itemData.id).version; + if (recordedVersion) { + localVersionText = t('install.recorded_local_version', { version: recordedVersion }); + } + } catch (error) { + console.warn('无法读取本地已安装版本记录'); + } } - } catch (e) { - apiFetchFailed = true; - console.warn("获取最新 item 数据失败,使用缓存值:", e); } - const hasPrivateToken = freshHasPrivateToken; - // 路由决策:仅当确认是免费+无私有Token时才走 git clone - // 如果 API 获取失败且缓存中无 has_private_token 信息,保守地走 ZIP 代理路径 - const useGitClone = isFree && !hasPrivateToken && !apiFetchFailed; - const localApiEndpoint = useGitClone - ? "/community_hub/install_tool" - : "/community_hub/install_private_tool"; - const progress = createInstallProgress(inlineStatusBox); + phase = "confirm"; + inlineStatusBox.innerHTML = ` +
+
${escapeHtml(installPromptText)}
+
${escapeHtml(targetStateText)}
+ ${localVersionText ? `
${escapeHtml(localVersionText)}
` : ''} +
+ + +
+
+ `; + const confirmInstallButton = inlineStatusBox.querySelector('#btn-confirm-install'); + const cancelInstallButton = inlineStatusBox.querySelector('#btn-cancel-install'); + let removeEscapeCleanup = () => {}; + const removeInstallEscapeHandler = () => { + inlineStatusBox.removeEventListener("keydown", handleInstallEscape); removeEscapeCleanup(); + }; + const cancelInstallPrompt = () => { + if (!isCurrent() || phase !== "confirm") return; + phase = "idle"; releaseButton(); + removeInstallEscapeHandler(); + inlineStatusBox.style.display = "none"; + btnUse.focus(); + }; + const handleInstallEscape = (event) => { + if (event.key === "Escape") { + event.preventDefault(); + cancelInstallPrompt(); + } + }; + cancelInstallButton.onclick = cancelInstallPrompt; + inlineStatusBox.addEventListener("keydown", handleInstallEscape); + removeEscapeCleanup = scope.addCleanup(removeInstallEscapeHandler); + confirmInstallButton.focus(); + confirmInstallButton.onclick = async () => { + if (!isCurrent() || phase !== "confirm") return; + phase = "install"; + try { + removeInstallEscapeHandler(); + confirmInstallButton.disabled = true; + cancelInstallButton.disabled = true; + // 获取最新的 item 数据以确保路由决策准确 + // noCache: true 绕过前端 GET 请求缓存(5min TTL),确保读取服务器最新 has_private_token 值 + let freshHasPrivateToken = !!itemData.has_private_token; + let apiFetchFailed = false; + try { + const freshRes = await request(`/api/items/${itemData.id}`, scope.readOptions); + if (!isCurrent()) return; + if (freshRes.status === "success" && freshRes.data) { + freshHasPrivateToken = !!freshRes.data.has_private_token; + } + } catch (e) { + apiFetchFailed = true; + if (!isCurrent()) return; + } + if (!isCurrent()) return; + const hasPrivateToken = freshHasPrivateToken; + // 路由决策:仅当确认是免费+无私有Token时才走 git clone + // 如果 API 获取失败且缓存中无 has_private_token 信息,保守地走 ZIP 代理路径 + const useGitClone = isFree && !hasPrivateToken && !apiFetchFailed; + const localApiEndpoint = useGitClone + ? "/community_hub/install_tool" + : "/community_hub/install_private_tool"; + + const progress = trackProgress(); + const cancelController = new AbortController(); + progress.addCancelButton(t('install.cancel_in_progress'), () => cancelController.abort()); + const diagnosticEvents = []; + const installStartedAt = Date.now(); + try { + // 发起 SSE 流式安装请求 + const result = await requestSSE( + `${localApiEndpoint}_stream`, + { url: itemData.link, id: itemData.id, account, token }, + (event) => { + if (!isCurrent()) return; + const elapsedSeconds = Math.floor((Date.now() - installStartedAt) / 1000); + const rawStage = typeof event.stage === "string" ? event.stage : ""; + diagnosticEvents.push({ + stageKey: Object.hasOwn(INSTALL_STAGE_KEYS, rawStage) ? INSTALL_STAGE_KEYS[rawStage] : "", + stage: /^[a-z0-9_]{1,40}$/i.test(rawStage) ? rawStage : "", + progress: Number.isFinite(event.progress) ? Math.max(0, Math.min(100, event.progress)) : null, + time: `${Math.floor(elapsedSeconds / 60)}:${String(elapsedSeconds % 60).padStart(2, "0")}` + }); + if (diagnosticEvents.length > 50) diagnosticEvents.shift(); + const stageKey = Object.hasOwn(INSTALL_STAGE_KEYS, rawStage) ? INSTALL_STAGE_KEYS[rawStage] : ""; + progress.update(event.progress, event.status === "error" + ? _getInstallErrorMessage(event) + : t(stageKey || 'install.stage_installing')); + }, + { signal: cancelController.signal } + ); + + if (!isCurrent()) return; + if (result.cancelRequested) { + phase = "unknown"; btnUse.removeAttribute("aria-busy"); + const message = t('install.cancel_outcome_uncertain'); + progress.error(message); + showToast(message, "warning"); + return; + } + + if (result.status === "success") { + progress.complete(t('install.complete')); + // 根据后端返回的 message 判断是否为自更新场景 + const isSelfUpdate = result.message && result.message.includes("重启") && result.message.includes("生效"); + if (isSelfUpdate) { + showToast(t('install.completed_update', { title: itemData.title }), "success"); + } else { + showToast(t('install.completed_restart', { title: itemData.title }), "success"); + } + + // 仅记录本次引擎证明的commit;榜单版本不能证明已经安装。 + if (!isSelfUpdate) _recordToolInstalledVersion(itemData.id, result); + + // 记录使用量(后端自动去重) + try { + await api.recordItemUse(itemData.id); + if (!isCurrent()) return; + clearUsesCache(); + } catch (_) {} + if (!isCurrent()) return; + + // 🚀 保存到已获取记录 + saveAcquiredItem(itemData); + + // 🚀 更新按钮状态 + btnUse.innerHTML = `✅ ${escapeHtml(t('common.installed'))}`; + btnUse.setAttribute("data-install-state", "success"); + } else { + if (result?.outcomeUnknown) { phase = "unknown"; btnUse.removeAttribute("aria-busy"); } + const message = _getInstallErrorMessage(result); + progress.error(message); + progress.showDiagnostics(diagnosticEvents); + showToast(t('install.failed_for_item', { title: itemData.title, message }), "error"); + } + } catch(err) { + if (!isCurrent()) return; + if (!err?.fallbackSafe) { + phase = "unknown"; btnUse.removeAttribute("aria-busy"); + const message = t('install.connection_uncertain'); + progress.error(message); + progress.showDiagnostics(diagnosticEvents); + showToast(message, "error"); + return; + } + // 降级到原接口 + progress.destroy(); + inlineStatusBox.innerHTML = `⏳ ${escapeHtml(t('install.background_started'))}`; + + try { + const res = await fetch(localApiEndpoint, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: itemData.link, id: itemData.id, account, token }) + }); + if (!isCurrent()) return; + const data = await res.json(); + if (!isCurrent()) return; + if (!res.ok || data?.status !== "success") { + const isLinkError = typeof data?.error === "string" && data.error.includes('不是有效的 Git'); + const safeMessage = isLinkError ? t('install.error_invalid_repository') : t('install.error_generic'); + inlineStatusBox.innerHTML = isLinkError + ? `❌ ${escapeHtml(safeMessage)}
` + : `❌ ${escapeHtml(t('install.failed_for_item', { title: itemData.title, message: safeMessage }))}`; + // 🔒 XSS防护:链接改为 DOM 事件绑定(内联 onclick 里的引号即使转义, + // 也会被浏览器先还原再交给 JS 解析,依然能被注入;本文件其他分支均用此写法) + if (isLinkError) { + const btnOpenSource = inlineStatusBox.querySelector('#btn-open-source-link'); + if (btnOpenSource) { + btnOpenSource.onclick = () => { + if (!isCurrent()) return; + const safeUrl = _getSafeExternalUrl(itemData.link); + if (safeUrl) window.open(safeUrl, '_blank', 'noopener,noreferrer'); + }; + } + } + showToast(t('install.failed_for_item', { title: itemData.title, message: safeMessage }), "error"); + } else { + // 根据后端返回的 message 判断是否为自更新场景 + const isSelfUpdate = data.message && data.message.includes("重启") && data.message.includes("生效"); + if (isSelfUpdate) { + inlineStatusBox.innerHTML = `
🔄 ${escapeHtml(t('install.stage_complete'))}
${escapeHtml(t('install.restart_to_apply'))}
`; + showToast(t('install.completed_update', { title: itemData.title }), "success"); + } else { + inlineStatusBox.innerHTML = `
🎉 ${escapeHtml(t('install.complete'))}
${escapeHtml(t('install.restart_to_load'))}
`; + showToast(t('install.completed_restart', { title: itemData.title }), "success"); + } + + if (!isSelfUpdate) _recordToolInstalledVersion(itemData.id, data); + + try { + await api.recordItemUse(itemData.id); + if (!isCurrent()) return; + clearUsesCache(); + } catch (_) {} + if (!isCurrent()) return; + + saveAcquiredItem(itemData); + + btnUse.innerHTML = `✅ ${escapeHtml(t('common.installed'))}`; + btnUse.setAttribute("data-install-state", "success"); + } + } catch(err2) { + if (!isCurrent()) return; + phase = "unknown"; btnUse.removeAttribute("aria-busy"); + inlineStatusBox.innerHTML = `❌ ${escapeHtml(t('workflow.local_connection_failed', { message: t('common.network_error_retry') }))}`; + } + } + } catch (_) { + if (isCurrent()) { phase = "unknown"; btnUse.removeAttribute("aria-busy"); inlineStatusBox.textContent = t('install.connection_uncertain'); } + } finally { if (phase === "install") phase = "idle"; releaseButton(); } + }; + } else if (isApp) { + const progress = trackProgress(); + + // 附带 account 凭证,解决问题 2 (一键鉴权加载) try { - // 发起 SSE 流式安装请求 const result = await requestSSE( - `${localApiEndpoint}_stream`, - { url: itemData.link, id: itemData.id, account: currentUser.account, token: _getAuthToken() }, - (event) => progress.update(event.progress, event.message) + "/community_hub/download_app_stream", + { url: itemData.link, id: itemData.id, account, token }, + (event) => { + if (!isCurrent()) return; + const stageKey = typeof event.stage === "string" && Object.hasOwn(WORKFLOW_STAGE_KEYS, event.stage) + ? WORKFLOW_STAGE_KEYS[event.stage] + : ""; + const message = event.status === "error" + ? _getWorkflowErrorMessage(event) + : t(stageKey || 'workflow.stage_downloading'); + progress.update(event.progress, message); + }, + { timeout: API.WORKFLOW_DOWNLOAD_TIMEOUT } ); + if (!isCurrent()) return; if (result.status === "success") { - progress.complete(result.message); - // 根据后端返回的 message 判断是否为自更新场景 - const isSelfUpdate = result.message && result.message.includes("重启") && result.message.includes("生效"); - if (isSelfUpdate) { - showToast(`🔄 插件 [${itemData.title}] 新版本已就绪,重启 ComfyUI 即可生效!`, "success"); - } else { - showToast(`🎉 插件 [${itemData.title}] 安装成功!请重启 ComfyUI。`, "success"); + const graphLoaded = await _loadGraphToCanvas(result.data, isCurrent); + if (!isCurrent()) return; + if (!graphLoaded) { + const message = t('workflow.canvas_load_failed'); + progress.error(message); + showToast(message, "error"); + return; } + progress.complete(t('workflow.loaded_for_item', { title: itemData.title })); + showToast(t('workflow.loaded_for_item', { title: itemData.title }), "success"); // 🚀 安装成功后,盖上本地版本戳 - // 优先用本地引擎回传的真实安装 commit(installed_version),兜底云端 latest_version; - // 二者皆空才跳过——避免版本戳缺失导致卡片更新徽章永不触发 - const _installedVer = result.installed_version || itemData.latest_version; - if (_installedVer) { - localStorage.setItem(`ComfyCommunity_LocalVer_${itemData.id}`, _installedVer); + if (itemData.latest_version) { + if (!saveInstalledVersion(itemData.id, itemData.latest_version)) { + showToast(t('install.version_record_unavailable'), 'warning'); + } } // 记录使用量(后端自动去重) - try { - await api.recordItemUse(itemData.id); + api.recordItemUse(itemData.id).then(() => { + if (!isCurrent()) return; clearUsesCache(); - } catch(err) { console.warn('📊 使用量记录失败:', err); } + }).catch(() => {}); // 🚀 保存到已获取记录 saveAcquiredItem(itemData); // 🚀 更新按钮状态 - btnUse.innerHTML = `✅ 已安装`; - btnUse.style.background = "#4CAF50"; + btnUse.innerHTML = `✅ ${escapeHtml(t('common.downloaded'))}`; + btnUse.setAttribute("data-install-state", "success"); } else { - progress.error(result.message); - showToast(`插件 [${itemData.title}] 安装失败: ${result.message}`, "error"); + if (result?.outcomeUnknown) { phase = "unknown"; btnUse.removeAttribute("aria-busy"); } + const message = _getWorkflowErrorMessage(result); + const localizedMessage = result.error_code + ? message + : t('workflow.failed', { message }); + progress.error(message); + inlineStatusBox.innerHTML = `❌ ${escapeHtml(localizedMessage)}`; + showToast(localizedMessage, "error"); } } catch(err) { + if (!isCurrent()) return; + if (!err?.fallbackSafe) { + phase = "unknown"; btnUse.removeAttribute("aria-busy"); + const message = t('workflow.connection_uncertain'); + progress.error(message); + showToast(message, "error"); + return; + } // 降级到原接口 progress.destroy(); - inlineStatusBox.innerHTML = `⏳ 正在后台静默安装 (关闭侧边栏不影响进度)...`; + inlineStatusBox.innerHTML = `⏳ ${escapeHtml(t('workflow.fallback_loading'))}`; try { - const res = await fetch(localApiEndpoint, { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url: itemData.link, id: itemData.id, account: currentUser.account, token: _getAuthToken() }) + const res = await fetch("/community_hub/download_app", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: itemData.link, id: itemData.id, account, token }) }); - const data = await res.json(); - - if (data.error) { - const isLinkError = data.error.includes('不是有效的 Git'); - inlineStatusBox.innerHTML = isLinkError - ? `❌ ${escapeHtml(data.error)}
` - : `❌ 安装失败: ${escapeHtml(data.error)}`; - // 🔒 XSS防护:链接改为 DOM 事件绑定(内联 onclick 里的引号即使转义, - // 也会被浏览器先还原再交给 JS 解析,依然能被注入;本文件其他分支均用此写法) - if (isLinkError) { - const btnOpenSource = inlineStatusBox.querySelector('#btn-open-source-link'); - if (btnOpenSource) { - btnOpenSource.onclick = () => window.open(itemData.link, '_blank'); - } - } - showToast(`插件 [${itemData.title}] 安装失败: ${data.error}`, "error"); + if (!isCurrent()) return; + if (!res.ok) { + const messageKey = [401, 403].includes(res.status) + ? 'workflow.error_auth' + : ([502, 503, 504].includes(res.status) ? 'workflow.error_network' : 'workflow.error_generic'); + inlineStatusBox.innerHTML = `❌ ${escapeHtml(t('workflow.http_failed', { status: res.status, message: t(messageKey) }))}`; + return; + } + let data; + try { + data = await res.json(); + if (!isCurrent()) return; + } catch (parseErr) { + if (!isCurrent()) return; + inlineStatusBox.innerHTML = `❌ ${escapeHtml(t('workflow.parse_failed'))}`; + showToast(t('common.parse_error'), "error"); + return; + } + if (data?.status !== "success") { + // 旧式响应的服务端原文可能未本地化,也可能包含上游诊断/链接信息; + // 使用稳定双语类别提示,不把远端正文直接展示或写入诊断日志。 + const message = t('workflow.error_remote'); + inlineStatusBox.innerHTML = `❌ ${escapeHtml(message)}`; + showToast(message, "error"); } else { - // 根据后端返回的 message 判断是否为自更新场景 - const isSelfUpdate = data.message && data.message.includes("重启") && data.message.includes("生效"); - if (isSelfUpdate) { - inlineStatusBox.innerHTML = `
🔄 新版本已就绪!
重启 ComfyUI 即可生效。
`; - showToast(`🔄 插件 [${itemData.title}] 新版本已就绪,重启 ComfyUI 即可生效!`, "success"); - } else { - inlineStatusBox.innerHTML = `
🎉 插件安装成功!
请重启 ComfyUI 以加载新节点。
`; - showToast(`🎉 插件 [${itemData.title}] 安装成功!请重启 ComfyUI。`, "success"); + const graphLoaded = await _loadGraphToCanvas(data.data, isCurrent); + if (!isCurrent()) return; + if (!graphLoaded) { + const message = t('workflow.canvas_load_failed'); + inlineStatusBox.textContent = message; + showToast(message, "error"); + return; } + inlineStatusBox.innerHTML = `
✅ ${escapeHtml(t('workflow.loaded_for_item', { title: itemData.title }))}
${escapeHtml(t('workflow.loaded_warning'))}
`; + showToast(t('workflow.loaded_for_item', { title: itemData.title }), "success"); - const _installedVer = data.installed_version || itemData.latest_version; - if (_installedVer) { - localStorage.setItem(`ComfyCommunity_LocalVer_${itemData.id}`, _installedVer); + if (itemData.latest_version) { + if (!saveInstalledVersion(itemData.id, itemData.latest_version)) { + showToast(t('install.version_record_unavailable'), 'warning'); + } } - try { - await api.recordItemUse(itemData.id); + api.recordItemUse(itemData.id).then(() => { + if (!isCurrent()) return; clearUsesCache(); - } catch(err2) { console.warn('📊 使用量记录失败:', err2); } + }).catch(() => {}); saveAcquiredItem(itemData); - btnUse.innerHTML = `✅ 已安装`; - btnUse.style.background = "#4CAF50"; + btnUse.innerHTML = `✅ ${escapeHtml(t('common.downloaded'))}`; + btnUse.setAttribute("data-install-state", "success"); } } catch(err2) { - inlineStatusBox.innerHTML = `❌ 无法连接到本地服务。`; + if (!isCurrent()) return; + phase = "unknown"; btnUse.removeAttribute("aria-busy"); + inlineStatusBox.innerHTML = `❌ ${escapeHtml(t('workflow.local_connection_failed', { message: t('common.network_error_retry') }))}`; } } - }; - } else if (isApp) { - const progress = createInstallProgress(inlineStatusBox); - - // 附带 account 凭证,解决问题 2 (一键鉴权加载) - try { - const result = await requestSSE( - "/community_hub/download_app_stream", - { url: itemData.link, id: itemData.id, account: currentUser.account, token: _getAuthToken() }, - (event) => progress.update(event.progress, event.message), - { timeout: 120000 } // 应用JSON下载2分钟足够 - ); - - if (result.status === "success") { - progress.complete(result.message); - await _loadGraphToCanvas(result.data); - showToast(`✅ 工作流 [${itemData.title}] 已成功加载到画布!`, "success"); - - // 🚀 安装成功后,盖上本地版本戳 - if (itemData.latest_version) { - localStorage.setItem(`ComfyCommunity_LocalVer_${itemData.id}`, itemData.latest_version); - } - - // 记录使用量(后端自动去重) - api.recordItemUse(itemData.id).then(() => { - clearUsesCache(); - }).catch(err => console.warn('📊 使用量记录失败:', err)); + } else { + // ☁️ 网盘链接或纯链接模式(recordItemUse 延迟到用户点击按钮后) - // 🚀 保存到已获取记录 - saveAcquiredItem(itemData); + // ☁️ 从购买响应中获取网盘信息(安全:只有购买成功后才返回) + const isNetdisk = purchaseRes.is_netdisk; + const netdiskPassword = purchaseRes.netdisk_password; - // 🚀 更新按钮状态 - btnUse.innerHTML = `✅ 已下载`; - btnUse.style.background = "#4CAF50"; + // ☁️ 网盘资源:购买后显示密码 + if (isNetdisk) { + _renderNetdiskUI(inlineStatusBox, itemData, netdiskPassword, isCurrent); } else { - progress.error(result.message); - inlineStatusBox.innerHTML = `❌ 加载失败:${escapeHtml(result.message)}`; - showToast(`工作流加载失败:${result.message}`, "error"); - } - } catch(err) { - // 降级到原接口 - progress.destroy(); - inlineStatusBox.innerHTML = `⏳ 授权通过,正在安全鉴权并热加载入工作区...`; - - try { - const res = await fetch("/community_hub/download_app", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url: itemData.link, id: itemData.id, account: currentUser.account, token: _getAuthToken() }) - }); - if (!res.ok) { - const errText = await res.text().catch(() => '未知服务端错误'); - inlineStatusBox.innerHTML = `❌ 加载失败 (${res.status}):${escapeHtml(errText)}`; - return; - } - let data; - try { - data = await res.json(); - } catch (parseErr) { - inlineStatusBox.innerHTML = `❌ 响应解析失败,请重试`; - showToast(t('common.parse_error'), "error"); - return; - } - if (data.error) { - inlineStatusBox.innerHTML = `❌ 加载失败:${escapeHtml(data.error)}`; - showToast(`工作流加载失败:${data.error}`, "error"); - } else { - await _loadGraphToCanvas(data.data); - inlineStatusBox.innerHTML = `
✅ 工作流已加载到画布!
由于本地节点差异可能出现飘红,请使用管理器补全节点。
`; - showToast(`✅ 工作流 [${itemData.title}] 已成功加载到画布!`, "success"); - - if (itemData.latest_version) { - localStorage.setItem(`ComfyCommunity_LocalVer_${itemData.id}`, itemData.latest_version); - } - - api.recordItemUse(itemData.id).then(() => { - clearUsesCache(); - }).catch(err => console.warn('📊 使用量记录失败:', err)); - - saveAcquiredItem(itemData); - - btnUse.innerHTML = `✅ 已下载`; - btnUse.style.background = "#4CAF50"; - } - } catch(err2) { - console.error('❌ 应用下载失败:', err2); - inlineStatusBox.innerHTML = `❌ 无法连接到本地服务:${escapeHtml(err2.message || '网络错误')}`; + inlineStatusBox.replaceChildren(); + const card = document.createElement('div'); + card.className = 'netdisk-card'; + const summary = document.createElement('div'); + summary.className = 'netdisk-card__summary'; + summary.textContent = t('acquire.pure_link_authorized'); + const openButton = document.createElement('button'); + openButton.type = 'button'; + openButton.className = 'netdisk-card__open'; + openButton.textContent = t('netdisk.open'); + openButton.addEventListener('click', () => _openExternalResource(itemData.link, itemData.id, isCurrent)); + const panel = document.createElement('div'); + panel.className = 'netdisk-card__panel'; + panel.appendChild(openButton); + card.append(summary, panel); + inlineStatusBox.appendChild(card); } } - } else { - // ☁️ 网盘链接或纯链接模式(recordItemUse 延迟到用户点击按钮后) - - // ☁️ 从购买响应中获取网盘信息(安全:只有购买成功后才返回) - const isNetdisk = purchaseRes?.is_netdisk || itemData.is_netdisk; - const netdiskPassword = purchaseRes?.netdisk_password || itemData.netdisk_password; - - // ☁️ 网盘资源:购买后显示密码 - if (isNetdisk) { - _renderNetdiskUI(inlineStatusBox, itemData, netdiskPassword); - } else { - // 纯链接模式 - inlineStatusBox.innerHTML = ` -
✅ 授权通过,该资源需手动访问源地址获取:
- - `; - // 绑定按钮点击事件 - const btnOpen = inlineStatusBox.querySelector(`#btn-pure-link-${itemData.id}`); - if (btnOpen) { - btnOpen.onclick = () => { - window.open(itemData.link, '_blank'); - api.recordItemUse(itemData.id).then(() => { - clearUsesCache(); - window.dispatchEvent(new CustomEvent("comfy-trigger-sidebar-reload", { detail: { force: true } })); - }).catch(err => console.warn('📊 使用量记录失败:', err)); - }; - } - } - } + } catch (_) { + if (isCurrent()) { inlineStatusBox.style.display = "block"; inlineStatusBox.textContent = t('common.error'); } + } finally { if (phase === "preflight") phase = "idle"; releaseButton(); } }; -} \ No newline at end of file + return scope; +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\346\272\220\346\223\215\344\275\234\347\224\237\345\221\275\345\221\250\346\234\237.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\346\272\220\346\223\215\344\275\234\347\224\237\345\221\275\345\221\250\346\234\237.js" new file mode 100644 index 0000000000000000000000000000000000000000..5c99a57d0f7cd953cc10d18576159c5ac577f7bc --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\346\272\220\346\223\215\344\275\234\347\224\237\345\221\275\345\221\250\346\234\237.js" @@ -0,0 +1,52 @@ +// 安装与交付入口共用原账号/节点范围;已经提交的购买和安装不自动重放。 +import { captureRequestSession, requestCancelManager } from "../core/网络请求API.js"; +import { API, CACHE } from "../core/全局配置.js"; +import { getUser, eventBus, EVENTS } from "../core/状态管理.js"; +import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js"; + +const scopes = new WeakMap(); +let sequence = 0; +export function createResourceOperationScope(button, statusBox, account, options = {}) { + scopes.get(button)?.dispose(); + const matches = captureRequestSession(), componentId = `resource-operation-${++sequence}`; + const confirmation = new AbortController(), cleanups = new Set(); + let inner, observer, disposed = false; + const cleanup = () => { + if (disposed) return; + disposed = true; + observer?.disconnect(); confirmation.abort(); + button.disabled = true; button.removeAttribute("aria-busy"); + statusBox.replaceChildren(); statusBox.style.display = "none"; + for (const callback of cleanups) { try { callback(); } catch (_) {} } + cleanups.clear(); + }; + const scope = { + readOptions: { noCache: true, componentId, timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES }, + confirmationSignal: confirmation.signal, + dispose() { if (inner) inner.dispose(); else cleanup(); }, + addCleanup(callback) { + if (disposed) { callback(); return () => {}; } + cleanups.add(callback); return () => cleanups.delete(callback); + }, + isCurrent() { + if (disposed) return false; + // 只有实际操作过的卡片订阅身份/DOM变化,避免整个榜单逐卡安装全局监听。 + if (!inner) { + inner = createTaskViewScope({ + sessionMatches: () => matches() && getUser()?.account === account, + connected: () => button.isConnected && statusBox.isConnected + && (!options.isCurrent || options.isCurrent()), + subscribe: notify => subscribeTaskSessionChanges(notify, { target: window, eventBus, events: EVENTS, + userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN }), + cancelReads: () => requestCancelManager.cancelAll(componentId), invalidate: () => {} + }); + inner.addCleanup(cleanup); + observer = new MutationObserver(() => { scope.isCurrent(); }); + observer.observe(document.body, { childList: true, subtree: true }); + } + return inner.isCurrent(); + } + }; + button._cleanup = () => scope.dispose(); scopes.set(button, scope); + return scope; +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\346\272\220\350\216\267\345\217\226\350\256\260\345\275\225.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\346\272\220\350\216\267\345\217\226\350\256\260\345\275\225.js" new file mode 100644 index 0000000000000000000000000000000000000000..2ce321ac878434ef7f8dde6c6628ed18e94d6eed --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\346\272\220\350\216\267\345\217\226\350\256\260\345\275\225.js" @@ -0,0 +1,67 @@ +// 本地交付记录仅用于离线展示,不作为购买权限或安装结果的依据。 +import { CACHE } from "../core/全局配置.js"; + +function validId(id) { + return (typeof id === "string" && id.trim().length > 0) || + (typeof id === "number" && Number.isFinite(id)); +} + +function readRecords() { + let raw; + try { raw = localStorage.getItem(CACHE.LOCAL_KEYS.ACQUIRED_ITEMS); } + catch { return { available: false, records: [] }; } + let entries; + try { entries = JSON.parse(raw || "[]"); } + catch { entries = []; } + if (!Array.isArray(entries)) entries = []; + return { available: true, records: entries.filter(entry => + entry && typeof entry === "object" && !Array.isArray(entry) && validId(entry.id)) }; +} + +function writeRecords(records) { + try { + localStorage.setItem(CACHE.LOCAL_KEYS.ACQUIRED_ITEMS, JSON.stringify(records)); + return true; + } catch { + console.warn("本地资源记录无法持久化"); + return false; + } +} + +export function getAcquiredItems() { + return readRecords().records; +} + +export function saveAcquiredItem(itemData) { + if (!itemData || typeof itemData !== "object" || Array.isArray(itemData) || !validId(itemData.id)) return false; + const snapshot = readRecords(); + // 无法读取时不能用空数组覆盖可能仍存在的历史记录。 + if (!snapshot.available) return false; + const record = { + id: itemData.id, + title: itemData.title, + type: itemData.type, + author: itemData.author, + shortDesc: itemData.shortDesc || itemData.short_desc || '', + coverUrl: itemData.coverUrl || itemData.cover_url || '', + coverBase64: itemData.coverBase64, + imageUrls: itemData.imageUrls || itemData.image_urls || [], + link: itemData.link || itemData.github_url || '', + price: itemData.price || 0, + likes: itemData.likes || 0, + uses: itemData.uses || 0, + local_version: itemData.latest_version || itemData.local_version || itemData.version || '', + acquired_at: Date.now() + }; + const index = snapshot.records.findIndex(entry => String(entry.id) === String(itemData.id)); + if (index >= 0) snapshot.records[index] = record; + else snapshot.records.unshift(record); + return writeRecords(snapshot.records); +} + +export function removeAcquiredItem(itemId) { + if (!validId(itemId)) return false; + const snapshot = readRecords(); + if (!snapshot.available) return false; + return writeRecords(snapshot.records.filter(entry => String(entry.id) !== String(itemId))); +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\346\272\220\350\257\246\346\203\205\351\241\265\351\235\242\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\346\272\220\350\257\246\346\203\205\351\241\265\351\235\242\347\273\204\344\273\266.js" index df17a284bbdb75978a915501d4b59b6bd4ace042..958698b2fb40627ed967d4b752dbd7a81f9d7e29 100644 --- "a/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\346\272\220\350\257\246\346\203\205\351\241\265\351\235\242\347\273\204\344\273\266.js" +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\346\272\220\350\257\246\346\203\205\351\241\265\351\235\242\347\273\204\344\273\266.js" @@ -10,23 +10,26 @@ // ========================================== import { api, proxyImages } from "../core/网络请求API.js"; -import { IS_WEB_MODE } from "../core/全局配置.js"; +import { IS_WEB_MODE, getCachedProfile, setCachedProfile } from "../core/全局配置.js"; +import { normalizeProfileData } from "../profile/个人资料工具.js"; import { openTipModal } from "../profile/个人中心_赞赏组件.js"; -import { setupResourceInstall } from "./资源安装引擎.js"; +import { getInstalledVersionStatus, setupResourceInstall } from "./资源安装引擎.js"; import { renderTipBoardHTML } from "../components/打赏等级工具.js"; import { t } from "../components/用户体验增强.js"; -import { removeCache, removeCacheByPrefix } from "../components/性能优化工具.js"; -import { invalidateRelatedCache } from "../core/网络请求API.js"; +import { createPurchaseStatusScope, isValidPurchaseStatus } from "./购买状态生命周期.js"; +import { showResourceDeleteDialog } from "./资源删除组件.js"; +import { showResourceRefundDialog } from "./资源退款组件.js"; import { showToast } from "../components/UI交互提示组件.js"; // 🧹 P2归一:escapeHtml 局部副本已移除,改用统一版(转义更严格,防XSS语义不变) import { escapeHtml } from "../components/互动工具函数.js"; // 🔄 P7后悔模式:渲染退款按钮 -async function renderRefundButton(container, itemData, currentUser) { +async function renderRefundButton(container, itemData, currentUser, scope, purchaseStatus) { if (!currentUser) return; const refundArea = container.querySelector("#refund-area"); if (!refundArea) return; + if (!scope.isCurrent()) return; // 💸 检查商品是否支持退款 if (!itemData.allow_refund && itemData.allow_refund !== undefined) { @@ -34,15 +37,19 @@ async function renderRefundButton(container, itemData, currentUser) { return; } + refundArea.style.display = "block"; try { - const statusRes = await api.getPurchaseStatus(currentUser.account, itemData.id); + const statusRes = await purchaseStatus; + if (!scope.isCurrent()) return; + if (!isValidPurchaseStatus(statusRes)) throw new Error(); if (!statusRes.owned) { refundArea.innerHTML = ''; return; } - if (statusRes.can_refund) { - const hoursLeft = statusRes.refund_hours_left; + if (statusRes.can_refund && statusRes.allow_refund) { + const hoursLeft = Number(statusRes.refund_hours_left); + if (!Number.isFinite(hoursLeft) || hoursLeft < 0) throw new Error("Invalid refund hours"); refundArea.innerHTML = `
@@ -59,7 +66,9 @@ async function renderRefundButton(container, itemData, currentUser) {
`; - refundArea.querySelector("#btn-refund").onclick = () => showRefundConfirm(itemData, currentUser, statusRes.price_paid); + refundArea.querySelector("#btn-refund").onclick = () => { + if (scope.isCurrent()) showRefundConfirm(itemData, currentUser, statusRes.price_paid, () => scope.isCurrent()); + }; } else { refundArea.innerHTML = `
@@ -68,169 +77,19 @@ async function renderRefundButton(container, itemData, currentUser) { `; } } catch (e) { - console.warn("获取购买状态失败:", e); + if (!scope.isCurrent()) return; + refundArea.replaceChildren(); } } -/** - * 通用确认弹窗 - * @param {Object} config - 弹窗配置 - * @param {string} config.title - 弹窗标题 - * @param {string} config.titleColor - 标题颜色 - * @param {string} config.infoHtml - 商品信息区域HTML - * @param {string} config.warningHtml - 警告区域HTML - * @param {string} config.warningBgColor - 警告区域背景色 - * @param {string} config.warningBorderColor - 警告区域边框色 - * @param {string} config.confirmText - 确认按钮文本 - * @param {string} config.confirmColor - 确认按钮颜色 - * @param {string} config.confirmHoverColor - 确认按钮悬停颜色 - * @param {string} config.processingText - 处理中文本 - * @param {Function} config.onConfirm - 确认回调 (overlay: HTMLElement) => Promise - */ -function _showConfirmOverlay({ title, titleColor, infoHtml, warningHtml, warningBgColor, warningBorderColor, confirmText, confirmColor, confirmHoverColor, processingText, onConfirm }) { - // 两个按钮的公共基础样式 - const _btnBaseStyle = "color: #fff; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-size: 14px; transition: 0.2s;"; - - const overlay = document.createElement("div"); - Object.assign(overlay.style, { - position: "fixed", top: "0", left: "0", right: "0", bottom: "0", - background: "rgba(0,0,0,0.7)", display: "flex", alignItems: "center", justifyContent: "center", - zIndex: "10000" - }); - - overlay.innerHTML = ` -
-
- ⚠️ ${escapeHtml(title)} -
- -
- ${infoHtml} -
- -
-
- ${warningHtml} -
-
- -
- - -
-
- `; - - document.body.appendChild(overlay); - - const cancelBtn = overlay.querySelector("#btn-cancel-confirm"); - cancelBtn.onmouseover = () => { cancelBtn.style.background = "#666"; }; - cancelBtn.onmouseout = () => { cancelBtn.style.background = "#555"; }; - cancelBtn.onclick = () => overlay.remove(); - - const confirmBtn = overlay.querySelector("#btn-confirm-action"); - confirmBtn.onmouseover = () => { confirmBtn.style.background = confirmHoverColor; }; - confirmBtn.onmouseout = () => { confirmBtn.style.background = confirmColor; }; - confirmBtn.onclick = async () => { - confirmBtn.disabled = true; - confirmBtn.innerText = processingText; - await onConfirm(overlay); - }; - - overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; -} - -// 🔄 P7后悔模式:退款确认弹窗 -function showRefundConfirm(itemData, currentUser, pricePaid) { - _showConfirmOverlay({ - title: t('item.confirm_refund_title'), - titleColor: "#FF5722", - infoHtml: ` -
${t('item.product')}${escapeHtml(itemData.title || t('item.untitled'))}
-
${t('item.refund_amount')}${pricePaid} ${t('common.credits')}
- `, - warningHtml: ` -
🚨 ${t('item.refund_consequence')}:
-
- • ${t('item.refund_consequence_1')}
- • ${t('item.refund_consequence_2')}
- • ${t('item.refund_consequence_3')} -
- `, - warningBgColor: "rgba(255,87,34,0.1)", - warningBorderColor: "rgba(255,87,34,0.3)", - confirmText: t('item.confirm_refund'), - confirmColor: "#FF5722", - confirmHoverColor: "#E64A19", - processingText: t('common.processing'), - onConfirm: async (overlay) => { - try { - const res = await api.requestRefund(currentUser.account, itemData.id); - if (res.status === "success") { - // 🔄 P7后悔模式:清除本地缓存和文件 - burnLocalFiles(itemData.id); - - overlay.innerHTML = ` -
-
✅
-
${t('item.refund_success')}
-
- ${res.refund_amount} ${t('item.credits_refunded')}
- ${t('item.ban_days_notice', { days: res.ban_days })} -
- -
- `; - overlay.querySelector("#btn-close-refund").onclick = () => { - overlay.remove(); - window.dispatchEvent(new CustomEvent("comfy-route-back")); - }; - } else { - showToast(t('item.refund_failed_retry'), "error"); - overlay.remove(); - } - } catch (e) { - showToast(t('item.refund_request_failed'), "error"); - overlay.remove(); - } - } +// 卡片与详情页共用退款流程,成功关闭后返回列表。 +function showRefundConfirm(itemData, currentUser, pricePaid, isCurrent) { + return showResourceRefundDialog({ + itemData, currentUser, pricePaid, isCurrent, + onClosed: () => window.dispatchEvent(new CustomEvent("comfy-route-back")) }); } -// 🔄 P7后悔模式:焚毁本地文件和缓存 -function burnLocalFiles(itemId) { - // 清除本地版本标记 - localStorage.removeItem(`ComfyCommunity_LocalVer_${itemId}`); - - // 清除购买缓存 - removeCache(`ItemOwnership_${itemId}`); - - // 清除列表缓存(强制下次刷新) - // 🔧 修复:API 层缓存键带完整查询串,removeCache 精确删除命中不到(no-op), - // 购买/下载后返回列表仍会看到旧数据,改用前缀删除 - removeCacheByPrefix('api_/api/items'); - removeCacheByPrefix('api_/api/creators'); - - // 清除工具/Skill/应用/推荐列表缓存(使用标准 ListCache 格式) - const tabs = ['tools', 'skills', 'apps', 'recommends']; - const sorts = ['time', 'downloads', 'likes', 'favorites', 'tips', 'views', 'daily_views', 'rating']; - for (const tab of tabs) { - for (const sort of sorts) { - removeCache(`ListCache_${tab}_${sort}`); - } - } - - console.log(`🔥 已焚毁资源 ${itemId} 的本地数据`); -} - - - // ========================================== // 🗑️ 删除内容功能 // ========================================== @@ -243,61 +102,18 @@ function canDeleteItem(itemData, currentUser) { // 作者本人可以删除 if (itemData.author === currentUser.account) return true; // 管理员可以删除 - if (currentUser.is_admin) return true; + if (currentUser.is_admin === true) return true; return false; } /** * 显示删除确认弹窗 */ -function showDeleteConfirm(itemData, currentUser, onSuccess) { - _showConfirmOverlay({ - title: t('item.confirm_delete_title'), - titleColor: "#F44336", - infoHtml: ` -
${t('item.content_label')}${escapeHtml(itemData.title || t('item.untitled'))}
-
${t('item.type_label')}${escapeHtml(itemData.type || t('item.type_unknown'))}
- `, - warningHtml: ` -
🚨 ${t('item.delete_consequence')}:
-
- • ${t('item.delete_consequence_1')}
- • ${t('item.delete_consequence_2')}
- • ${t('item.delete_consequence_3')} -
- `, - warningBgColor: "rgba(244,67,54,0.1)", - warningBorderColor: "rgba(244,67,54,0.3)", - confirmText: t('item.confirm_delete'), - confirmColor: "#F44336", - confirmHoverColor: "#D32F2F", - processingText: t('item.deleting'), - onConfirm: async (overlay) => { - try { - const res = await api.deleteItem(itemData.id); - if (res.status === "success") { - // 清除相关缓存 - invalidateRelatedCache(`/api/items/${itemData.id}`, "DELETE"); - - overlay.remove(); - showToast(t('item.delete_success'), "success"); - - // 返回列表页 - window.dispatchEvent(new CustomEvent("comfy-route-view", { - detail: { view: "market", type: itemData.type || "tool" } - })); - - if (onSuccess) onSuccess(); - } else { - showToast(t('item.delete_failed_retry'), "error"); - overlay.remove(); - } - } catch (e) { - showToast(t('item.delete_request_failed'), "error"); - overlay.remove(); - } - } - }); +function showDeleteConfirm(itemData, currentUser, isCurrent) { + return showResourceDeleteDialog({ itemData, currentUser, isCurrent, + onDeleted: () => window.dispatchEvent(new CustomEvent('comfy-route-view', { + detail: { view: 'market', type: itemData.type || 'tool' } + })) }); } export function createItemDetailView(itemData, currentUser) { @@ -312,13 +128,11 @@ export function createItemDetailView(itemData, currentUser) { backgroundColor: "#13151c" }); - const authorCacheKey = `ComfyCommunity_ProfileCache_${itemData.author}`; - const cachedAuthorStr = localStorage.getItem(authorCacheKey); - let authorName = itemData.author; - if (cachedAuthorStr) { try { authorName = JSON.parse(cachedAuthorStr).name || itemData.author; } catch(e) {} } + const cachedAuthor = getCachedProfile(itemData.author); + const authorName = cachedAuthor?.name || itemData.author || t('common.unknown_user'); // 🚀 使用统一工具渲染单品赞赏榜单(带星星/月亮/太阳等级) - const boardData = itemData.tip_board || []; + const boardData = Array.isArray(itemData.tip_board) ? itemData.tip_board.filter(entry => entry && typeof entry === "object" && !Array.isArray(entry)) : []; const boardHtml = renderTipBoardHTML(boardData, 10, t('item.no_tips_yet'), "normal"); // 🗑️ 判断是否有权限删除 @@ -345,7 +159,7 @@ export function createItemDetailView(itemData, currentUser) { ${deleteBtnHtml}
-
感谢 砚影科技 的支持!
+
${escapeHtml(t('item.sponsor_thanks'))}
@@ -357,7 +171,8 @@ export function createItemDetailView(itemData, currentUser) { `; // 🚀 核心逻辑:比对云端探测版本与本地安装版本 - const localVersionHash = localStorage.getItem(`ComfyCommunity_LocalVer_${itemData.id}`); + const localVersionStatus = getInstalledVersionStatus(itemData.id); + const localVersionHash = localVersionStatus.version; const cloudVersionHash = itemData.latest_version; const isFree = !itemData.price || itemData.price <= 0 || (currentUser && currentUser.account === itemData.author); @@ -369,13 +184,13 @@ export function createItemDetailView(itemData, currentUser) { let actionBtnHtml = ''; if (IS_WEB_MODE) { // 📱 Web 模式:保留「立即获取」按钮但行为受限(未购买仅触发购买、已购买提示电脑端安装,见资源安装引擎);不渲染重装/更新按钮 - actionBtnHtml = ``; - } else if (!isUpdateAvailable && localVersionHash) { - actionBtnHtml = ``; + actionBtnHtml = ``; + } else if (!isUpdateAvailable && localVersionStatus.installed) { + actionBtnHtml = ``; } else if (isUpdateAvailable) { - actionBtnHtml = ``; + actionBtnHtml = ``; } else { - actionBtnHtml = ``; + actionBtnHtml = ``; } // 🚀 返回按钮位置可调整参数:margin-left 控制右移,margin-top 控制下移 @@ -401,27 +216,48 @@ export function createItemDetailView(itemData, currentUser) {
`; + // 按既有状态生成的安装入口必须实际挂载到详情页面。 + container.insertAdjacentHTML("beforeend", actionBtnHtml); + const scope = createPurchaseStatusScope(container.querySelector("#refund-area"), currentUser?.account); + container._cleanup = () => scope.dispose(); + scope.addCleanup(() => { + for (const id of ["btn-use-item", "btn-tip-item", "btn-delete-item"]) { + const button = container.querySelector(`#${id}`); + if (button) { button.onclick = null; button.remove(); } + } + container.querySelector("#inline-status-box")?.replaceChildren(); + }); + // 🚀 创建安装状态容器 const inlineStatusBox = document.createElement("div"); inlineStatusBox.id = "inline-status-box"; 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" }); container.appendChild(inlineStatusBox); + if (!localVersionStatus.available) { + inlineStatusBox.style.display = "block"; + inlineStatusBox.setAttribute("role", "status"); + inlineStatusBox.textContent = t('install.version_status_unavailable'); + } - container.querySelector("#btn-back-detail").onclick = () => window.dispatchEvent(new CustomEvent("comfy-route-back")); + container.querySelector("#btn-back-detail").onclick = () => { + scope.dispose(); + window.dispatchEvent(new CustomEvent("comfy-route-back")); + }; // 🚀 绑定安装/更新按钮事件 const btnUseItem = container.querySelector("#btn-use-item"); if (btnUseItem) { - setupResourceInstall(btnUseItem, itemData, currentUser, inlineStatusBox); + const installation = setupResourceInstall(btnUseItem, itemData, currentUser, inlineStatusBox, + { isCurrent: () => scope.isCurrent() }); + scope.addCleanup(() => installation?.dispose()); } // 🚀 绑定作品打赏按钮事件 container.querySelector("#btn-tip-item").onclick = async () => { + if (!scope.isCurrent()) return; if (!currentUser) return showToast(t('feedback.login_required'), "warning"); - await openTipModal(currentUser, { account: itemData.author }, (newBalance) => { - currentUser.balance = newBalance; - // 打赏成功后刷新局部或整体详情 (由框架重新渲染) - }, itemData.id); + await openTipModal(currentUser, { account: itemData.author }, undefined, itemData.id, + { isCurrent: () => scope.isCurrent() }); }; // 🗑️ 绑定删除按钮事件 @@ -429,42 +265,41 @@ export function createItemDetailView(itemData, currentUser) { if (btnDeleteItem) { btnDeleteItem.onclick = () => { if (!currentUser) return showToast(t('feedback.login_required'), "warning"); - showDeleteConfirm(itemData, currentUser); + if (scope.isCurrent()) showDeleteConfirm(itemData, currentUser, () => scope.isCurrent()); }; } // 🐛 Bug修复:跳过系统页面的作者信息查询(如关于页面) - if (!itemData.isSystemPage) { - api.getUserProfile(itemData.author).then(res => { - const freshName = res.data.name || itemData.author; - const nameEl = container.querySelector("#detail-author-name"); - if (nameEl) nameEl.innerText = freshName; - localStorage.setItem(authorCacheKey, JSON.stringify(res.data)); + if (!itemData.isSystemPage && itemData.author && scope.isCurrent()) { + const originalNameEl = container.querySelector("#detail-author-name"); + api.getUserProfile(itemData.author, scope.readOptions).then(res => { + if (!scope.isCurrent() || !originalNameEl?.isConnected || container.querySelector("#detail-author-name") !== originalNameEl) return; + if (res?.status !== 'success') return; + const profile = normalizeProfileData(res.data, itemData.author); + setCachedProfile(itemData.author, profile); + if (originalNameEl?.isConnected && container.querySelector("#detail-author-name") === originalNameEl) { + originalNameEl.textContent = profile.name || itemData.author; + } }).catch(() => {}); } - // 🔄 P7后悔模式:异步加载退款按钮状态 - renderRefundButton(container, itemData, currentUser); - - // 🔄 云端购买状态兜底:本地无版本戳时,查询云端是否已购买 - if (!localVersionHash && !isFree && currentUser) { - api.getPurchaseStatus(currentUser.account, itemData.id).then(res => { - const btnUseItem = container.querySelector("#btn-use-item"); - if (!btnUseItem) return; - if (res.owned) { - // 📱 Web 模式无本地重装概念,文案改为「已购买」 - btnUseItem.innerHTML = `✅ ${t(IS_WEB_MODE ? 'item.owned_purchased' : 'item.owned_reinstall')}`; - Object.assign(btnUseItem.style, { - background: "#4CAF50", - color: "#fff", - border: "none" - }); - } - // 未购买时保持默认的「立即获取使用」按钮 - }).catch(err => { - console.warn("云端购买状态查询失败,fallback到本地逻辑:", err); + // 拥有文案和退款入口使用同一次、同账号的购买快照。 + const needsOwnership = !localVersionHash && !isFree; + const needsRefund = itemData.allow_refund !== false; + if (currentUser && scope.isCurrent() && (needsOwnership || needsRefund)) { + const purchaseStatus = api.getPurchaseStatus(currentUser.account, itemData.id, scope.readOptions) + .then(response => { + if (!scope.isCurrent() || !isValidPurchaseStatus(response)) return null; + return response; + }).catch(() => null); + if (needsRefund) renderRefundButton(container, itemData, currentUser, scope, purchaseStatus); + if (needsOwnership) purchaseStatus.then(response => { + if (!scope.isCurrent() || !response?.owned || !btnUseItem?.isConnected + || container.querySelector("#btn-use-item") !== btnUseItem) return; + btnUseItem.textContent = `✅ ${t(IS_WEB_MODE ? 'item.owned_purchased' : 'item.owned_reinstall')}`; + btnUseItem.setAttribute("data-install-state", "success"); }); } return container; -} \ No newline at end of file +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\346\272\220\351\200\200\346\254\276\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\346\272\220\351\200\200\346\254\276\347\273\204\344\273\266.js" new file mode 100644 index 0000000000000000000000000000000000000000..ad728753c1f5930402cd87f957fdd244f15a48df --- /dev/null +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\346\272\220\351\200\200\346\254\276\347\273\204\344\273\266.js" @@ -0,0 +1,167 @@ +// 卡片与资源详情共用的退款确认、状态展示和客户端失效流程。 +import { api, captureRequestSession } from "../core/网络请求API.js"; +import { CACHE } from "../core/全局配置.js"; +import { removeCache, removeCacheByPrefix } from "../core/网络请求_缓存管理.js"; +import { clearSensitiveCache } from "../components/性能优化工具.js"; +import { showToast, setupDialogAccessibility } from "../components/UI交互提示组件.js"; +import { t } from "../components/用户体验_国际化.js"; + +import { getUser, eventBus, EVENTS } from "../core/状态管理.js"; +import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js"; +import { removeAcquiredItem } from "./资源获取记录.js"; + +const openDialogs = new Map(); +let nextDialogId = 0; + +function textElement(tag, text, className) { + const element = document.createElement(tag); + element.textContent = text; + if (className) element.className = className; + return element; +} + +/** 云端已提交退款;各项本地清理独立容错,不能把成功改成失败。 */ +function clearRefundClientState(itemId) { + const safely = action => { + try { action(); } catch { console.warn("退款成功,部分本地状态无法清理"); } + }; + safely(() => localStorage.removeItem(CACHE.LOCAL_KEYS.INSTALLED_VERSION_PREFIX + itemId)); + safely(() => removeAcquiredItem(itemId)); + safely(() => removeCache(CACHE.LOCAL_KEYS.ITEM_OWNERSHIP_PREFIX + itemId)); + for (const prefix of CACHE.REFUND_CACHE_PREFIXES) safely(() => removeCacheByPrefix(prefix)); + safely(() => clearSensitiveCache()); +} + +/** @param {{itemData: Object, currentUser: Object, pricePaid: number, onRefunded?: Function, onClosed?: Function, isCurrent?: Function}} options */ +export function showResourceRefundDialog({ itemData, currentUser, pricePaid, onRefunded, onClosed, isCurrent = () => true }) { + if (!currentUser?.account) { showToast(t("feedback.login_required"), "warning"); return null; } + if (!itemData?.id) { showToast(t("item.invalid_id_tip"), "error"); return null; } + const account = currentUser.account; + const itemId = itemData.id; + const matches = captureRequestSession(); + const sessionMatches = () => matches() && getUser()?.account === account; + if (!sessionMatches() || !isCurrent()) return null; + const key = JSON.stringify([account, itemId]); + const existing = openDialogs.get(key); + if (existing?.focus()) return existing; + + const id = `ranking-refund-${++nextDialogId}`; + const overlay = document.createElement("div"); + overlay.className = "comfy-ranking-dialog-overlay refund-dialog-overlay"; + const box = document.createElement("div"); + box.className = "comfy-ranking-dialog refund-dialog"; + box.dataset.type = "danger"; + const title = textElement("h2", t("item.confirm_refund_title"), "comfy-ranking-dialog__title"); + title.id = id + "-title"; + const description = document.createElement("div"); + description.id = id + "-description"; + description.className = "comfy-ranking-dialog__message"; + description.append( + textElement("p", t("item.product") + (itemData.title || t("item.untitled"))), + textElement("p", t("item.refund_amount") + String(pricePaid ?? "") + " " + t("common.credits")), + textElement("strong", t("item.refund_consequence")) + ); + const warnings = document.createElement("ul"); + for (const warning of [t("item.refund_consequence_1"), t("item.refund_consequence_2"), t("item.refund_consequence_3")]) { + warnings.appendChild(textElement("li", warning)); + } + description.appendChild(warnings); + const actions = document.createElement("div"); + actions.className = "comfy-ranking-dialog__actions"; + const cancel = textElement("button", t("common.cancel"), "comfy-ranking-dialog__button comfy-ranking-dialog__button--cancel"); + cancel.type = "button"; + cancel.dataset.action = "cancel"; + const confirm = textElement("button", t("item.confirm_refund"), "comfy-ranking-dialog__button comfy-ranking-dialog__button--confirm"); + confirm.type = "button"; + confirm.dataset.action = "confirm"; + actions.append(cancel, confirm); + box.append(title, description, actions); + overlay.appendChild(box); + document.body.appendChild(overlay); + + let phase = "confirm", observer; + let accessibility; + const scope = createTaskViewScope({ + sessionMatches, + connected: () => overlay.isConnected && (phase === "success" || isCurrent()), + subscribe: notify => subscribeTaskSessionChanges(notify, { target: window, eventBus, events: EVENTS, + userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN }), + cancelReads: () => {}, invalidate: () => {} + }); + scope.addCleanup(() => { + phase = "closed"; observer?.disconnect(); accessibility?.cleanup(); + description.replaceChildren(); actions.replaceChildren(); overlay.remove(); + if (openDialogs.get(key) === handle) openDialogs.delete(key); + }); + const close = () => { + if (phase === "pending" || phase === "closed") return; + const succeeded = phase === "success"; + const restore = sessionMatches(); + scope.dispose(); + if (restore) accessibility?.restoreFocus(); + if (succeeded && restore) { + try { onClosed?.(); } catch { console.warn("退款成功,返回视图处理失败"); } + } + }; + const handle = { close, dispose: () => scope.dispose(), focus: () => { + if (!scope.isCurrent()) return false; + (box.querySelector("button:not(:disabled)") || box).focus(); + return true; + } }; + overlay._cleanup = handle.dispose; + openDialogs.set(key, handle); + observer = new MutationObserver(() => { scope.isCurrent(); }); + observer.observe(document.body, { childList: true, subtree: true }); + accessibility = setupDialogAccessibility(box, title.id, description.id, '[data-action="cancel"]', close); + cancel.onclick = close; + overlay.onclick = event => { if (event.target === overlay) close(); }; + confirm.onclick = async () => { + if (phase !== "confirm" || !scope.isCurrent()) return; + phase = "pending"; + cancel.disabled = confirm.disabled = true; + confirm.textContent = t("common.processing"); + box.setAttribute("aria-busy", "true"); + const progress = textElement("p", t("common.processing")); + progress.setAttribute("role", "status"); + progress.setAttribute("aria-live", "polite"); + description.appendChild(progress); + box.focus(); + let result; + try { + result = await api.requestRefund(account, itemId); + if (!scope.isCurrent()) return; + } catch { + if (!scope.isCurrent()) return; + phase = "confirm"; + showToast(t("item.refund_request_failed"), "error"); + close(); + return; + } + if (result?.status !== "success") { + phase = "confirm"; + showToast(t("item.refund_failed_retry"), "error"); + close(); + return; + } + clearRefundClientState(itemId); + phase = "success"; + try { onRefunded?.(result); } catch { console.warn("退款成功,视图更新失败"); } + if (!scope.isCurrent()) return; + box.removeAttribute("aria-busy"); + title.textContent = t("item.refund_success"); + description.setAttribute("role", "status"); + description.setAttribute("aria-live", "polite"); + description.replaceChildren( + textElement("p", t("item.refund_amount_returned", { amount: result.refund_amount })), + textElement("p", t("item.ban_days_notice", { days: result.ban_days })) + ); + const done = textElement("button", t("common.got_it"), "comfy-ranking-dialog__button comfy-ranking-dialog__button--confirm"); + done.type = "button"; + done.dataset.action = "close"; + done.onclick = close; + actions.replaceChildren(done); + done.focus(); + showToast(t("item.refund_success_toast"), "success"); + }; + return handle; +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\351\207\221\344\270\216\351\222\261\345\214\205_\345\205\205\345\200\274\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\351\207\221\344\270\216\351\222\261\345\214\205_\345\205\205\345\200\274\347\273\204\344\273\266.js" index 06e76fab9bc1e433d2c58396b2fc58b68edb52db..98f4e9f9e944123bfa27613eb305b25af786afa9 100644 --- "a/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\351\207\221\344\270\216\351\222\261\345\214\205_\345\205\205\345\200\274\347\273\204\344\273\266.js" +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\351\207\221\344\270\216\351\222\261\345\214\205_\345\205\205\345\200\274\347\273\204\344\273\266.js" @@ -1,9 +1,14 @@ // 前端页面/market/资金与钱包_充值组件.js import { showToast, showConfirm } from "../components/UI交互提示组件.js"; import { globalModal } from "../components/全局弹窗管理器.js"; -import { api } from "../core/网络请求_业务API.js"; +import { api, request, captureRequestSession, requestCancelManager } from "../core/网络请求API.js"; import { t } from "../components/用户体验增强.js"; -import { API } from "../core/全局配置.js"; +import { API, UI, CACHE } from "../core/全局配置.js"; + +import { eventBus, EVENTS } from "../core/状态管理.js"; +import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js"; +import { normalizeWalletData } from "../profile/个人资料工具.js"; +let rechargeSequence = 0; /** * 充值弹窗组件 (真实拉起支付宝网关) @@ -19,23 +24,39 @@ import { API } from "../core/全局配置.js"; */ function _setButtonLoading(btn, isLoading, loadingText, normalText) { btn.disabled = isLoading; + btn.setAttribute('aria-busy', String(isLoading)); btn.innerText = isLoading ? loadingText : normalText; btn.style.background = isLoading ? "#888" : "#4CAF50"; btn.style.cursor = isLoading ? "not-allowed" : "pointer"; } export async function openRechargeModal(currentUser, onBalanceChange) { - // 🔄 打开充值弹窗时主动拉取最新余额,避免显示过期数据 + if (!currentUser?.account) return; + const container = document.createElement("div"); + const account = currentUser.account; + const componentId = `recharge-${++rechargeSequence}`; + let constructing = true, modalElement = null, pollingTimer = null; + const scope = createTaskViewScope({ + sessionMatches: captureRequestSession(), connected: () => constructing || container.isConnected, + subscribe: notify => subscribeTaskSessionChanges(notify, { target: window, eventBus, events: EVENTS, + userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN }), + cancelReads: () => requestCancelManager.cancelAll(componentId), + invalidate: () => { container.replaceChildren(); if (modalElement) globalModal.closeModal(modalElement); } + }); + const readOptions = { noCache: true, componentId, timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES }; + scope.addCleanup(() => { + clearTimeout(pollingTimer); + container.querySelectorAll('input').forEach(input => { input.value = ''; }); + container.querySelector('#qr-image')?.removeAttribute('src'); + }); + container._cleanup = () => scope.dispose(); + let balanceText = t('wallet.recharge.balance_unavailable'); try { - const freshWallet = await api.getWallet(currentUser.account, { noCache: true }); - if (freshWallet && freshWallet.balance !== undefined) { - currentUser.balance = freshWallet.balance; - } - } catch (e) { - // 获取失败则使用本地值,不阻塞弹窗打开 - } + const wallet = normalizeWalletData(await api.getWallet(account, readOptions)); + if (!scope.isCurrent()) return; + if (Number.isSafeInteger(wallet.balance)) balanceText = String(wallet.balance); + } catch (_) { if (!scope.isCurrent()) return; } - const container = document.createElement("div"); container.style.color = "#ccc"; const rechargeOptions = [ @@ -49,7 +70,7 @@ export async function openRechargeModal(currentUser, onBalanceChange) { let paymentMethod = "alipay"; function _rechargeOptionWrapper(isSelected, index, innerHtml) { - return `
${innerHtml}
`; + return ``; } const renderOptions = () => rechargeOptions.map((opt, index) => { @@ -66,7 +87,7 @@ export async function openRechargeModal(currentUser, onBalanceChange) { container.innerHTML = `
${t('wallet.recharge.current_balance')}
-
${currentUser.balance || 0} ${t('wallet.recharge.points_suffix')}
+
${balanceText} ${t('wallet.recharge.points_suffix')}
@@ -109,6 +130,7 @@ export async function openRechargeModal(currentUser, onBalanceChange) { const bindGridEvents = () => { container.querySelectorAll('.recharge-opt').forEach(el => { el.onclick = () => { + if (!scope.isCurrent() || submitting) return; selectedOption = rechargeOptions[parseInt(el.getAttribute('data-index'))]; container.querySelector('#recharge-grid').innerHTML = renderOptions(); @@ -133,128 +155,92 @@ export async function openRechargeModal(currentUser, onBalanceChange) { const qrContainer = container.querySelector("#qr-container"); const qrImage = container.querySelector("#qr-image"); const qrLoading = container.querySelector("#qr-loading"); - let pollingInterval = null; - let pollCount = 0; - let paymentConfirmed = false; // 🔒 源头防重:确保成功逻辑只执行一次 - const MAX_POLL_COUNT = 100; // 最多轮询 100 次(约 5 分钟) + let submitting = false, pollCount = 0, paymentConfirmed = false; + qrImage.alt = t('wallet.recharge.scan_to_pay'); + customAmountInput.setAttribute('aria-label', t('wallet.recharge.select_amount')); + qrLoading.setAttribute('role', 'status'); btnCreateOrder.onclick = async () => { - if (btnCreateOrder.disabled) return; - let finalPoints = 0; - let finalPrice = 0; - - if (selectedOption.isCustom) { - const customVal = Number(customAmountInput.value); - if (!Number.isInteger(customVal) || customVal <= 0) { - return showToast(t('wallet.recharge.invalid_amount'), "warning"); - } - if (customVal > 10000) { - return showToast(t('wallet.recharge.max_amount_exceeded'), "warning"); - } - finalPoints = customVal; - finalPrice = customVal; - } else { - finalPoints = selectedOption.points; - finalPrice = selectedOption.price; - } - - // 💳 充值二次确认 - const confirmMsg = `确认充值 ${finalPoints} 积分(¥${finalPrice.toFixed(2)})?`; - const confirmed = await showConfirm(confirmMsg, { - title: t('wallet.recharge.title'), - confirmText: t('wallet.recharge.get_qr'), - type: 'warning' - }); - if (!confirmed) return; - + if (!scope.isCurrent() || submitting) return; + const points = selectedOption.isCustom ? Number(customAmountInput.value) : selectedOption.points; + if (!Number.isSafeInteger(points) || points < 1) return showToast(t('wallet.recharge.invalid_amount'), 'warning'); + if (points > 10000) return showToast(t('wallet.recharge.max_amount_exceeded'), 'warning'); + submitting = true; _setButtonLoading(btnCreateOrder, true, t('wallet.recharge.processing'), t('wallet.recharge.get_qr')); - qrContainer.style.display = "block"; - qrLoading.style.display = "block"; - qrImage.style.display = "none"; - + let confirmed; + try { confirmed = await showConfirm(t('wallet.recharge.confirm_amount', { points, price: points.toFixed(2) }), { + title: t('wallet.recharge.title'), confirmText: t('wallet.recharge.get_qr'), type: 'warning' + }); } catch (_) { + if (!scope.isCurrent()) return; + submitting = false; + _setButtonLoading(btnCreateOrder, false, t('wallet.recharge.processing'), t('wallet.recharge.get_qr')); + return; + } + if (!scope.isCurrent()) return; + if (!confirmed) { + submitting = false; + _setButtonLoading(btnCreateOrder, false, t('wallet.recharge.processing'), t('wallet.recharge.get_qr')); + return; + } + qrContainer.style.display = 'block'; + qrLoading.style.display = 'block'; + qrImage.style.display = 'none'; try { - // 封装带有 Token 的请求头 - const token = localStorage.getItem("ComfyCommunity_Token") || sessionStorage.getItem("ComfyCommunity_Token"); - const headers = { "Content-Type": "application/json" }; - if (token) headers["Authorization"] = `Bearer ${token}`; - - // 1. 发起真实创建订单请求 - const res = await fetch(`${API.BASE_URL}/api/wallet/create_recharge_order`, { - method: "POST", headers: headers, - body: JSON.stringify({ account: currentUser.account, amount: finalPoints }) - }).then(async r => { - const data = await r.json(); - if (!r.ok) throw new Error(data.detail || data.error || "请求被拒绝"); - return data; + const order = await request('/api/wallet/create_recharge_order', { + method: 'POST', body: { account, amount: points }, retries: 0 }); - - qrLoading.style.display = "none"; - - // 2. 利用免费接口将返回的支付字符串转成真实二维码图片 -qrImage.src = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(res.qr_code)}`; - qrImage.style.display = "block"; - container.querySelector("#pay-price-text").innerText = finalPrice.toFixed(2); - - // 3. 开启真实轮询查单,监听回调 - pollingInterval = setInterval(async () => { - pollCount++; - if (pollCount >= MAX_POLL_COUNT) { - clearInterval(pollingInterval); - pollingInterval = null; - showToast(t('wallet.recharge.timeout'), "warning"); - return; + if (!scope.isCurrent()) return; + if (order?.status !== 'success' || typeof order.order_id !== 'string' || !/^PAY_[a-zA-Z0-9_-]{1,128}$/.test(order.order_id) + || typeof order.qr_code !== 'string' || order.qr_code.length > 4096) throw new Error('invalid order'); + const { createPaymentQRCode } = await import("./支付二维码工具.js"); + if (!scope.isCurrent()) return; + qrImage.src = createPaymentQRCode(order.qr_code, UI.RECHARGE_QR_MAX_BYTES); + qrLoading.style.display = 'none'; + qrImage.style.display = 'block'; + container.querySelector('#pay-price-text').textContent = points.toFixed(2); + // 一次查询结束后再安排下一次,避免慢网络下并行查单。 + const pollUntil = Date.now() + UI.RECHARGE_POLL_INTERVAL * UI.RECHARGE_POLL_LIMIT; + const poll = async () => { + if (!scope.isCurrent() || paymentConfirmed) return; + if (++pollCount > UI.RECHARGE_POLL_LIMIT || Date.now() >= pollUntil) { + showToast(t('wallet.recharge.timeout'), 'warning'); return; } try { - const checkRes = await fetch(`${API.BASE_URL}/api/wallet/check_order/${res.order_id}?account=${currentUser.account}`, { headers }) - .then(r => r.json()); - - if (checkRes.status === "SUCCESS") { - if (paymentConfirmed) return; // 🔒 已确认过,跳过(防止多个在途请求重复触发) + const checked = await request(`/api/wallet/check_order/${encodeURIComponent(order.order_id)}`, readOptions); + if (!scope.isCurrent()) return; + if (checked?.status === 'SUCCESS') { paymentConfirmed = true; - clearInterval(pollingInterval); - - // 先获取真实余额 + let balance; try { - const walletRes = await api.getWallet(currentUser.account); - if (walletRes && walletRes.balance !== undefined) { - currentUser.balance = walletRes.balance; - } else { - currentUser.balance = (currentUser.balance || 0) + finalPoints; - } - } catch(e) { - currentUser.balance = (currentUser.balance || 0) + finalPoints; - } - - // 再触发余额变更回调 - if (onBalanceChange) { - onBalanceChange(currentUser.balance); - } - - // 最后关闭弹窗和提示 - showToast(t('wallet.recharge.success', { points: finalPoints }), "success"); - globalModal.closeTopModal(); + const wallet = normalizeWalletData(await api.getWallet(account, readOptions)); + if (Number.isSafeInteger(wallet.balance)) balance = wallet.balance; + } catch (_) {} + if (!scope.isCurrent()) return; + showToast(t('wallet.recharge.success', { points }), 'success'); + if (balance === undefined) showToast(t('wallet.recharge.balance_unavailable'), 'warning'); + scope.dispose(); globalModal.closeModal(modalElement); + if (balance !== undefined && onBalanceChange) onBalanceChange(balance); + return; } - } catch (e) { - // 查单接口网络波动不中断主流程 - console.warn("轮询波动...", e); - } - }, 3000); - + } catch (_) { if (!scope.isCurrent()) return; } + pollingTimer = setTimeout(poll, UI.RECHARGE_POLL_INTERVAL); + }; + pollingTimer = setTimeout(poll, UI.RECHARGE_POLL_INTERVAL); } catch (error) { - qrLoading.innerText = t('wallet.recharge.order_failed') + error.message; - qrLoading.style.color = "#F44336"; - } finally { - _setButtonLoading(btnCreateOrder, false, t('wallet.recharge.processing'), t('wallet.recharge.get_qr')); + if (!scope.isCurrent()) return; + const rejected = [400, 401, 403, 404, 422, 429].includes(error.status); + qrLoading.textContent = t(rejected ? 'wallet.recharge.order_failed' : 'wallet.recharge.order_uncertain'); + if (rejected) { + submitting = false; + _setButtonLoading(btnCreateOrder, false, t('wallet.recharge.processing'), t('wallet.recharge.get_qr')); + } } }; - - // 5分钟兆底清理轮询 - setTimeout(() => { - if (pollingInterval) { - clearInterval(pollingInterval); - pollingInterval = null; - } - }, 300000); - - globalModal.openModal(t('wallet.recharge.title'), container, { width: "400px" }); -} \ No newline at end of file + if (!scope.isCurrent()) return; + const observer = new MutationObserver(() => { if (!container.isConnected) scope.dispose(); }); + scope.addCleanup(() => observer.disconnect()); + globalModal.openModal(t('wallet.recharge.title'), container, { width: '400px' }); + modalElement = globalModal.activeModals.find(modal => modal.contains(container)); + constructing = false; + observer.observe(document.body, { childList: true, subtree: true }); +} diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\351\207\221\344\270\216\351\222\261\345\214\205_\346\217\220\347\216\260\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\351\207\221\344\270\216\351\222\261\345\214\205_\346\217\220\347\216\260\347\273\204\344\273\266.js" index adab99d4d9c5819064ba538e86d4a3f2be04c711..8d3276ebcdcd01065522e6926807339bda15ae22 100644 --- "a/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\351\207\221\344\270\216\351\222\261\345\214\205_\346\217\220\347\216\260\347\273\204\344\273\266.js" +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/market/\350\265\204\351\207\221\344\270\216\351\222\261\345\214\205_\346\217\220\347\216\260\347\273\204\344\273\266.js" @@ -1,11 +1,14 @@ // 前端页面/market/资金与钱包_提现组件.js -import { api } from "../core/网络请求API.js"; +import { api, captureRequestSession, requestCancelManager } from "../core/网络请求API.js"; import { showToast } from "../components/UI交互提示组件.js"; import { globalModal } from "../components/全局弹窗管理器.js"; import { t } from "../components/用户体验增强.js"; -// 模块级防重复提交标志 -let isSubmitting = false; +import { API, CACHE } from "../core/全局配置.js"; +import { eventBus, EVENTS } from "../core/状态管理.js"; +import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js"; +import { normalizeWalletData } from "../profile/个人资料工具.js"; +let withdrawalSequence = 0; /** * 统一管理提现相关按钮的禁用/恢复状态 @@ -16,6 +19,7 @@ let isSubmitting = false; */ function _setButtonState(btn, disabled, bgColor, text) { btn.disabled = disabled; + btn.setAttribute('aria-busy', String(disabled)); btn.style.background = bgColor; btn.innerText = text; } @@ -23,7 +27,7 @@ function _setButtonState(btn, disabled, bgColor, text) { /** 提现表单验证规则 */ const _WITHDRAW_RULES = [ { check: (f) => !f.amount || f.amount < 1 || !Number.isInteger(f.amount), msgKey: 'wallet.withdraw.min_amount', level: 'warning' }, - { check: (f) => f.amount > 5000, msgKey: 'wallet.withdraw.max_amount_exceeded', fallback: '单次提现金额不得超过5000元', level: 'warning' }, + { check: (f) => f.amount > 5000, msgKey: 'wallet.withdraw.max_amount_exceeded', level: 'warning' }, { check: (f) => !f.alipayAccount || !f.realName, msgKey: 'wallet.withdraw.fill_account', level: 'warning' }, { check: (f) => f.code.length !== 6, msgKey: 'wallet.withdraw.invalid_code', level: 'warning' }, ]; @@ -31,7 +35,7 @@ const _WITHDRAW_RULES = [ function _validateWithdrawForm(fields) { for (const rule of _WITHDRAW_RULES) { if (rule.check(fields)) { - showToast(t(rule.msgKey) || rule.fallback || '', rule.level); + showToast(t(rule.msgKey), rule.level); return false; } } @@ -44,26 +48,43 @@ function _validateWithdrawForm(fields) { * @param {Function} onSuccess 提现成功后的回调函数 */ export async function openWithdrawModal(currentUser, onSuccess) { - // 在函数开头添加:从后端获取实时钱包数据 - let walletData = currentUser; + if (!currentUser?.account) return; + const container = document.createElement("div"); + const account = currentUser.account; + const componentId = `withdrawal-${++withdrawalSequence}`; + let constructing = true, isSubmitting = false, countdown = null, deadline = null; + let modalElement = null; + const scope = createTaskViewScope({ + sessionMatches: captureRequestSession(), connected: () => constructing || container.isConnected, + subscribe: notify => subscribeTaskSessionChanges(notify, { target: window, eventBus, events: EVENTS, + userKey: CACHE.LEGACY_KEYS.USER, tokenKey: CACHE.LEGACY_KEYS.TOKEN }), + cancelReads: () => requestCancelManager.cancelAll(componentId), + invalidate: () => { + container.replaceChildren(); + if (modalElement) globalModal.closeModal(modalElement); + } + }); + scope.addCleanup(() => { + clearInterval(countdown); clearTimeout(deadline); + container.querySelectorAll('input').forEach(input => { input.value = ''; }); + }); + container._cleanup = () => scope.dispose(); + let walletData; try { - const freshData = await api.getWallet(currentUser.account); - walletData = { ...currentUser, ...freshData }; - } catch (err) { - console.warn("获取钱包数据失败,使用本地数据:", err); + walletData = normalizeWalletData(await api.getWallet(account, { noCache: true, componentId, + timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES })); + if (!scope.isCurrent()) return; + if (!Number.isSafeInteger(walletData.balance) || !Number.isSafeInteger(walletData.total_withdrawn)) throw new Error('wallet unavailable'); + } catch (_) { + if (scope.isCurrent()) showToast(t('wallet.withdraw.balance_unavailable'), 'warning'); + scope.dispose(); return; } - - // 使用统一可用余额作为可提现额度 - const maxWithdraw = walletData.balance || 0; - - // 🚀 获取历史累计提现金额用于计算免责额度 - const totalWithdrawn = walletData.total_withdrawn || 0; - + const maxWithdraw = walletData.balance; + const totalWithdrawn = walletData.total_withdrawn; if (maxWithdraw <= 0) { - return showToast(t('wallet.withdraw.no_balance'), "warning"); + showToast(t('wallet.withdraw.no_balance'), "warning"); scope.dispose(); return; } - const container = document.createElement("div"); container.style.color = "#ccc"; container.innerHTML = ` @@ -105,18 +126,18 @@ export async function openWithdrawModal(currentUser, onSuccess) {
- + `; // 🚀 核心逻辑:动态计算提现手续费 const inputAmount = container.querySelector("#withdraw-amount"); inputAmount.oninput = () => { - let val = parseInt(inputAmount.value); + let val = Number(inputAmount.value); if (val > maxWithdraw) { val = maxWithdraw; inputAmount.value = val; } const feeBox = container.querySelector("#fee-calc-box"); const feeText = container.querySelector("#fee-text"); - if (!val || val <= 0) { + if (!Number.isSafeInteger(val) || val <= 0) { feeBox.style.display = "none"; return; } @@ -138,21 +159,25 @@ export async function openWithdrawModal(currentUser, onSuccess) { const btnSendCode = container.querySelector("#btn-send-code"); btnSendCode.onclick = async () => { + if (!scope.isCurrent() || btnSendCode.disabled) return; _setButtonState(btnSendCode, true, "#555", t('wallet.withdraw.sending')); try { await api.sendVerifyCode(currentUser.email, "email", "withdraw", currentUser.account); + if (!scope.isCurrent()) return; showToast(t('wallet.withdraw.code_sent'), "success"); let count = 60; - const timer = setInterval(() => { + countdown = setInterval(() => { + if (!scope.isCurrent()) return; count--; btnSendCode.innerText = t('wallet.withdraw.resend_in', { seconds: count }); if (count <= 0) { - clearInterval(timer); + clearInterval(countdown); _setButtonState(btnSendCode, false, "#2196F3", t('wallet.withdraw.get_code')); } }, 1000); } catch (error) { - showToast(t('wallet.withdraw.code_failed') + error.message, "error"); + if (!scope.isCurrent()) return; + showToast(t('wallet.withdraw.code_failed'), "error"); _setButtonState(btnSendCode, false, "#2196F3", t('wallet.withdraw.get_code')); } }; @@ -160,9 +185,9 @@ export async function openWithdrawModal(currentUser, onSuccess) { const btnSubmit = container.querySelector("#btn-submit-withdraw"); btnSubmit.onclick = async () => { // 防重复提交检查 - if (isSubmitting) return; + if (!scope.isCurrent() || isSubmitting) return; - const amount = parseInt(inputAmount.value); + const amount = Number(inputAmount.value); const alipayAccount = container.querySelector("#withdraw-account").value.trim(); const realName = container.querySelector("#withdraw-name").value.trim(); const code = container.querySelector("#withdraw-code").value.trim(); @@ -175,29 +200,43 @@ export async function openWithdrawModal(currentUser, onSuccess) { _setButtonState(btnSubmit, true, "#888", t('wallet.withdraw.submitting')); // 30秒超时保护 - const timeoutId = setTimeout(() => { - isSubmitting = false; - _setButtonState(btnSubmit, false, "#4CAF50", t('wallet.withdraw.confirm')); + deadline = setTimeout(() => { + if (!scope.isCurrent()) return; showToast(t('wallet.withdraw.timeout'), "warning"); }, 30000); try { - await api.submitWithdraw({ amount, alipayAccount, real_name: realName, code, account: currentUser.account }); - clearTimeout(timeoutId); + const result = await api.submitWithdraw({ amount, alipayAccount, real_name: realName, code, account: currentUser.account }); + clearTimeout(deadline); + if (!scope.isCurrent()) return; + if (result?.status !== 'success') throw new Error('unconfirmed withdrawal'); showToast(t('wallet.withdraw.success'), "success"); - globalModal.closeTopModal(); + scope.dispose(); + globalModal.closeModal(modalElement); if (onSuccess) onSuccess(); } catch (error) { - clearTimeout(timeoutId); - showToast(t('wallet.withdraw.submit_failed') + error.message, "error"); - _setButtonState(btnSubmit, false, "#4CAF50", t('wallet.withdraw.confirm')); - } finally { - // 无论成功失败,重置提交标志 - isSubmitting = false; + clearTimeout(deadline); + if (!scope.isCurrent()) return; + const rejected = [400, 401, 403, 404, 422, 429].includes(error.status); + showToast(t(rejected ? 'wallet.withdraw.submit_failed' : 'wallet.withdraw.timeout'), 'warning'); + if (rejected) { + isSubmitting = false; + _setButtonState(btnSubmit, false, '#4CAF50', t('wallet.withdraw.confirm')); + } } }; + if (!scope.isCurrent()) return; + container.querySelectorAll('label').forEach(label => { + const input = label.parentElement.querySelector('input'); + if (input) input.setAttribute('aria-label', label.textContent.trim()); + }); + const observer = new MutationObserver(() => { if (!container.isConnected) scope.dispose(); }); + scope.addCleanup(() => observer.disconnect()); globalModal.openModal(t('wallet.withdraw.title'), container, { width: "420px" }); + modalElement = globalModal.activeModals.find(modal => modal.contains(container)); + constructing = false; + observer.observe(document.body, { childList: true, subtree: true }); } \ No newline at end of file diff --git "a/\345\211\215\347\253\257\351\241\265\351\235\242/post/\345\217\221\345\270\203\345\270\226\345\255\220\347\273\204\344\273\266.js" "b/\345\211\215\347\253\257\351\241\265\351\235\242/post/\345\217\221\345\270\203\345\270\226\345\255\220\347\273\204\344\273\266.js" index a02193845580fd9fef17c9d15e400e08294fa9b0..7197805a5196f62f9dbc1f92935ab95c64733f45 100644 --- "a/\345\211\215\347\253\257\351\241\265\351\235\242/post/\345\217\221\345\270\203\345\270\226\345\255\220\347\273\204\344\273\266.js" +++ "b/\345\211\215\347\253\257\351\241\265\351\235\242/post/\345\217\221\345\270\203\345\270\226\345\255\220\347\273\204\344\273\266.js" @@ -8,11 +8,30 @@ // - UI交互提示组件.js (提示信息) // ========================================== +import { createPublishFieldErrors } from "../market/发布字段错误.js"; +import { bindPublishFilePicker, setPublishFilePickerAction } from "../market/发布文件选择可访问性.js"; +import { compressPublishImage as compressImage, capturePublishVideoFrame } from "../market/有界图片处理.js"; import { api } from "../core/网络请求API.js"; import { showToast, showConfirm } from "../components/UI交互提示组件.js"; import { t } from "../components/用户体验增强.js"; import { removeCache, removeCacheByPrefix } from "../components/性能优化工具.js"; -import { API, escapeHtml } from "../core/全局配置.js"; +import { createPublishEditingLock } from "../market/发布编辑锁.js"; +import { createPublishSubmissionScope } from "../market/发布提交生命周期.js"; +import { API, CACHE, PUBLISH_MEDIA_LIMITS, IMAGE_PROCESS, escapeHtml } from "../core/全局配置.js"; + +function requirePostUpload(result) { + if (result?.status !== 'success' || typeof result.url !== 'string' || !result.url.trim()) { + throw new Error('Unconfirmed post upload'); + } + return result.url; +} + +function confirmPostSave(result, isEditMode) { + if (result?.status !== 'success' || (!isEditMode && + (typeof result.data?.id !== 'string' || !result.data.id.trim()))) { + throw new Error('Unconfirmed post save'); + } +} // 📦 清除帖子列表缓存 function clearPostListCache() { @@ -36,14 +55,26 @@ function updateCoverMark(previewContainer) { wrappers.forEach((wrapper, idx) => { const img = wrapper.querySelector('img'); const coverLabel = wrapper.querySelector('.cover-label'); + wrapper.tabIndex = 0; + wrapper.setAttribute('role', 'group'); + wrapper.setAttribute('aria-label', t('post.image_position', { index: idx + 1, total: wrappers.length })); + wrapper.setAttribute('aria-keyshortcuts', 'Alt+ArrowLeft Alt+ArrowRight'); + if (!wrapper.title) wrapper.title = t('post.image_move_hint'); + const remove = wrapper.querySelector('button[data-action="remove"]'); + if (remove) { + remove.type = 'button'; + remove.setAttribute('aria-label', t('post.remove_image', { index: idx + 1 })); + } + if (img && !img.hasAttribute('alt')) img.alt = ''; + if (img) { - img.style.borderColor = idx === 0 ? '#4CAF50' : '#444'; + img.style.borderColor = idx === 0 ? '#4CAF50' : 'var(--rk-border, var(--border-color, #444))'; } if (idx === 0) { if (!coverLabel) { const label = document.createElement('span'); label.className = 'cover-label'; - label.style.cssText = 'position: absolute; top: 4px; left: 4px; background: #4CAF50; color: #fff; font-size: 10px; padding: 2px 6px; border-radius: 3px; pointer-events: none;'; + label.style.cssText = 'position: absolute; top: 4px; left: 4px; background: #4CAF50; color: var(--rk-text, var(--fg-color, #ddd)); font-size: 10px; padding: 2px 6px; border-radius: 3px; pointer-events: none;'; label.textContent = t('post.cover'); wrapper.appendChild(label); } @@ -55,7 +86,7 @@ function updateCoverMark(previewContainer) { // ✅ 为图片预览容器设置拖拽排序 function setupImageDragSort(previewContainer, fileArray, options = {}) { - const { onRemove, onDrop } = options; + const { onRemove, onDrop, canInteract = () => true, focusEmpty } = options; const getWrappers = () => Array.from(previewContainer.children).filter( child => child.tagName === 'DIV' && child.querySelector('img') ); @@ -63,6 +94,7 @@ function setupImageDragSort(previewContainer, fileArray, options = {}) { const wrappers = getWrappers(); if (wrappers.length === 0) return; + updateCoverMark(previewContainer); let dragSrcEl = null; let dragSrcIndex = -1; @@ -95,10 +127,32 @@ function setupImageDragSort(previewContainer, fileArray, options = {}) { updateCoverMark(previewContainer); setupImageDragSort(previewContainer, fileArray, options); } - }); + }, { previewContainer, wrapper, canInteract, focusEmpty }); } + wrapper.onkeydown = (e) => { + if (e.target !== wrapper || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || + e.repeat || e.isComposing || !canInteract() || + !['ArrowLeft', 'ArrowRight'].includes(e.key)) return; + const current = getWrappers(); + const from = current.indexOf(wrapper); + const to = from + (e.key === 'ArrowLeft' ? -1 : 1); + e.preventDefault(); e.stopPropagation(); + // 编辑保存按已有URL、新文件分组;不能移动到另一组而仅改变视觉顺序。 + if (from < 0 || to < 0 || to >= current.length || + current[to]._imageType !== wrapper._imageType) return; + previewContainer.insertBefore(wrapper, to > from ? current[to].nextSibling : current[to]); + if (onDrop) onDrop(); + else if (fileArray) { + const [moved] = fileArray.splice(from, 1); + fileArray.splice(to, 0, moved); + } + updateCoverMark(previewContainer); + wrapper.focus(); + }; + wrapper.ondragstart = (e) => { + if (!canInteract()) { e.preventDefault(); return; } e.stopPropagation(); dragSrcEl = wrapper; dragSrcIndex = getWrappers().indexOf(wrapper); @@ -137,7 +191,8 @@ function setupImageDragSort(previewContainer, fileArray, options = {}) { e.stopPropagation(); const currentWrappers = getWrappers(); const targetIndex = currentWrappers.indexOf(wrapper); - if (targetIndex === dragSrcIndex || dragSrcIndex === -1) return; + if (!canInteract() || targetIndex === dragSrcIndex || dragSrcIndex === -1 || + !currentWrappers.includes(dragSrcEl) || wrapper._imageType !== dragSrcEl._imageType) return; // 移动 DOM 元素 if (targetIndex > dragSrcIndex) { @@ -168,60 +223,6 @@ function setupImageDragSort(previewContainer, fileArray, options = {}) { }); } -// 🖼️ 图片压缩配置 -const IMAGE_MAX_SIZE = 1920; // 最大宽/高 -const IMAGE_QUALITY = 0.85; // JPG压缩质量 - -/** - * 🖼️ 压缩图片:转为JPG格式并限制尺寸 - * @param {File} file - 原始图片文件 - * @returns {Promise} - 压缩后的图片文件 - */ -function compressImage(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.onload = (e) => { - const img = new Image(); - img.src = e.target.result; - img.onload = () => { - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - - // 计算缩放尺寸(保持比例,限制最大尺寸) - let { width, height } = img; - if (width > IMAGE_MAX_SIZE || height > IMAGE_MAX_SIZE) { - if (width > height) { - height = Math.round(height * IMAGE_MAX_SIZE / width); - width = IMAGE_MAX_SIZE; - } else { - width = Math.round(width * IMAGE_MAX_SIZE / height); - height = IMAGE_MAX_SIZE; - } - } - - canvas.width = width; - canvas.height = height; - ctx.drawImage(img, 0, 0, width, height); - - // 转为 JPG 并压缩 - canvas.toBlob((blob) => { - if (blob) { - const newFileName = file.name.replace(/\.[^/.]+$/, "") + ".jpg"; - const compressedFile = new File([blob], newFileName, { type: "image/jpeg" }); - console.log(`🖼️ 图片压缩: ${(file.size/1024).toFixed(1)}KB → ${(compressedFile.size/1024).toFixed(1)}KB`); - resolve(compressedFile); - } else { - reject(new Error("图片压缩失败")); - } - }, "image/jpeg", IMAGE_QUALITY); - }; - img.onerror = () => reject(new Error("图片加载失败")); - }; - reader.onerror = () => reject(new Error("文件读取失败")); - }); -} - /** * 📐 格式化文件大小 */ @@ -246,22 +247,27 @@ function _clearDragIndicators(wrappers) { } // P2-1: 创建图片区域「+」添加按钮 -function _createAddButton(imagesInput) { - const addBtn = document.createElement("div"); +function _createAddButton(imagesInput, scope) { + const addBtn = document.createElement("button"); + addBtn.type = "button"; + addBtn.setAttribute("aria-label", t("post.upload_images")); + addBtn.setAttribute("data-file-picker-button", ""); Object.assign(addBtn.style, { width: "80px", height: "80px", - border: "2px dashed #444", + background: "transparent", + border: "2px dashed var(--rk-border, var(--border-color, #444))", borderRadius: "6px", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", - color: "#666", + color: "var(--rk-muted, var(--descrip-text, #a4a4a4))", fontSize: "24px" }); addBtn.textContent = "+"; addBtn.onclick = (e) => { + if (!scope.isCurrent() || imagesInput.disabled) return; e.stopPropagation(); imagesInput.click(); }; @@ -275,56 +281,82 @@ function _showVideoErrorAndReset(msg, videoInput) { } // P1-1: 创建删除按钮点击处理器(停止冒泡 + 执行回调) -function _createRemoveButtonHandler(removeCallback) { +function _createRemoveButtonHandler(removeCallback, options = {}) { + const { previewContainer, wrapper, canInteract = () => true, focusEmpty } = options; + const images = () => Array.from(previewContainer.children).filter(child => + child.tagName === 'DIV' && child.querySelector('img')); return (e) => { e.stopPropagation(); + if (!canInteract() || e.currentTarget?.disabled) return; + const index = previewContainer ? images().indexOf(wrapper) : -1; + if (previewContainer && index < 0) return; + const restoreFocus = wrapper?.contains(document.activeElement); removeCallback(); + if (!restoreFocus || !canInteract()) return; + const remaining = images(); + const next = remaining[Math.min(index, remaining.length - 1)]; + if (next) next.querySelector('button[data-action="remove"]').focus(); + else focusEmpty?.focus(); }; } /** * 🎬 使用 XMLHttpRequest 上传视频并支持进度回调 */ -function uploadVideoWithProgress(file, onProgress) { +function uploadVideoWithProgress(file, onProgress, { signal } = {}) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); - const formData = new FormData(); - formData.append("file", file); - formData.append("file_type", "post_video"); - - const baseUrl = API?.BASE_URL || ""; - xhr.open("POST", `${baseUrl}/api/upload`); - - const token = localStorage.getItem("ComfyCommunity_Token") || sessionStorage.getItem("ComfyCommunity_Token"); - if (token) xhr.setRequestHeader("Authorization", `Bearer ${token}`); - - xhr.upload.onprogress = (e) => { - if (e.lengthComputable && onProgress) { - onProgress(Math.round((e.loaded / e.total) * 100)); - } + let settled = false; + const abort = () => { + finish(Object.assign(new Error('Video upload aborted'), { name: 'AbortError' })); + try { xhr.abort(); } catch {} }; - - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - try { - resolve(JSON.parse(xhr.responseText)); - } catch { - resolve({ url: xhr.responseText }); + const finish = (error, result) => { + if (settled) return; + settled = true; + signal?.removeEventListener('abort', abort); + xhr.onload = xhr.onerror = xhr.ontimeout = xhr.onabort = null; + xhr.upload.onprogress = null; + if (error) reject(error); else resolve(result); + }; + if (signal?.aborted) { abort(); return; } + signal?.addEventListener('abort', abort, { once: true }); + const tokenFrom = readStorage => { + try { return readStorage().getItem(CACHE.LEGACY_KEYS.TOKEN); } catch { return null; } + }; + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('file_type', 'post_video'); + xhr.open('POST', `${API.BASE_URL}/api/upload`); + const token = tokenFrom(() => localStorage) || tokenFrom(() => sessionStorage); + if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`); + xhr.upload.onprogress = event => { + if (settled || !event.lengthComputable || !Number.isFinite(event.loaded) || + !Number.isFinite(event.total) || event.loaded < 0 || event.total <= 0) return; + const percent = Math.max(0, Math.min(100, Math.round(event.loaded / event.total * 100))); + try { onProgress?.(percent); } catch {} + }; + xhr.onload = () => { + if (settled) return; + if (xhr.status < 200 || xhr.status >= 300) { + finish(Object.assign(new Error('Video upload rejected'), { status: xhr.status })); return; } - } else { - let msg = `上传失败 (${xhr.status})`; try { - const data = JSON.parse(xhr.responseText); - if (data.detail || data.message || data.error) msg = data.detail || data.message || data.error; - } catch {} - reject(new Error(msg)); - } - }; - - xhr.onerror = () => reject(new Error('网络错误,上传失败')); - xhr.ontimeout = () => reject(new Error('上传超时')); - xhr.timeout = 300000; - xhr.send(formData); + if (typeof xhr.responseText !== 'string' || xhr.responseText.length > API.VIDEO_UPLOAD_RESPONSE_MAX_CHARS) { + throw new Error('Invalid video upload response'); + } + const result = JSON.parse(xhr.responseText); + requirePostUpload(result); + finish(null, result); + } catch { finish(new Error('Unconfirmed video upload')); } + }; + xhr.onerror = () => finish(new Error('Video upload network failure')); + xhr.ontimeout = () => finish(new Error('Video upload timed out')); + xhr.onabort = () => finish(Object.assign(new Error('Video upload aborted'), { name: 'AbortError' })); + xhr.timeout = API.VIDEO_UPLOAD_TIMEOUT; + xhr.send(formData); + } catch { finish(new Error('Video upload could not start')); } }); } @@ -346,41 +378,31 @@ export function createPublishPostView(currentUser, editPostData = null) { const editCoverUrl = isEditMode ? (editPostData.cover_image || '') : ''; const container = document.createElement("div"); - Object.assign(container.style, { - display: "flex", - flexDirection: "column", - gap: "0", - color: "#ccc", - fontSize: "14px", - padding: "0", - overflowY: "auto", - flex: "1", - boxSizing: "border-box" - }); - + + container.classList.add("ranking-publish-form"); container.innerHTML = ` -
-
- - ✏️ ${isEditMode ? '编辑帖子' : t('post.publish')} + ✏️ ${isEditMode ? t('post.edit_title') : t('post.publish')}
-
+
-