Spaces:
Running
Running
Upload 98 files
Browse files- 前端页面/components/UI交互提示组件.js +81 -16
- 前端页面/components/全局设置组件.js +512 -643
- 前端页面/components/卡片导航生命周期.js +43 -4
- 前端页面/components/图片展示测试.js +317 -130
- 前端页面/components/图片沙盒组件.js +217 -232
- 前端页面/components/图片裁剪组件.js +68 -78
- 前端页面/components/性能优化工具.js +135 -108
- 前端页面/components/用户体验_国际化.js +44 -2
- 前端页面/components/用户体验增强.js +3 -1
- 前端页面/core/侧边栏主程序.js +12 -16
- 前端页面/core/全局配置.js +19 -4
- 前端页面/core/网络请求_缓存管理.js +22 -12
- 前端页面/market/列表卡片组件.js +6 -3
- 前端页面/market/创作者卡片组件.js +2 -5
- 前端页面/market/资金与钱包_充值组件.js +34 -28
- 前端页面/market/资金与钱包_提现组件.js +38 -27
- 前端页面/post/发布帖子组件.js +85 -92
- 前端页面/post/帖子详情组件.js +120 -96
- 前端页面/post/讨论区组件.js +35 -14
- 前端页面/profile/个人中心_UI模板.js +45 -70
- 前端页面/profile/个人列表组件.js +84 -125
- 前端页面/profile/个人设置表单组件.js +75 -91
- 前端页面/prompt/价格校验.js +7 -0
- 前端页面/prompt/发布提示词组件.js +121 -131
- 前端页面/prompt/提示词组件.js +76 -57
- 前端页面/prompt/提示词详情组件.js +167 -132
- 前端页面/social/私信聊天组件.js +4 -1
- 前端页面/social/评论数据工具.js +9 -0
- 前端页面/styles/主题基础.css +0 -0
- 前端页面/task/任务榜组件.js +61 -24
- 前端页面/task/任务详情组件.js +160 -148
- 前端页面/task/发布任务组件.js +43 -54
前端页面/components/UI交互提示组件.js
CHANGED
|
@@ -26,6 +26,39 @@ import { escapeHtml, UI } from "../core/全局配置.js";
|
|
| 26 |
let toastQueue = [];
|
| 27 |
let toastObserver = null;
|
| 28 |
let toastSizeObserver = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
let dialogInstanceId = 0;
|
| 30 |
const dialogStack = [];
|
| 31 |
|
|
@@ -36,6 +69,7 @@ function repositionToasts() {
|
|
| 36 |
toast.style.top = `${top}px`;
|
| 37 |
top += heights[index] + UI.TOAST.GAP;
|
| 38 |
});
|
|
|
|
| 39 |
}
|
| 40 |
|
| 41 |
function observeToastSize(toast) {
|
|
@@ -57,6 +91,7 @@ function releaseToast(toast) {
|
|
| 57 |
toast._previousFocus = null;
|
| 58 |
toast._cleanupToastEvents?.();
|
| 59 |
toast._cleanupToastEvents = null;
|
|
|
|
| 60 |
const index = toastQueue.indexOf(toast);
|
| 61 |
if (index > -1) toastQueue.splice(index, 1);
|
| 62 |
repositionToasts();
|
|
@@ -65,6 +100,11 @@ function releaseToast(toast) {
|
|
| 65 |
toastObserver = null;
|
| 66 |
toastSizeObserver?.disconnect();
|
| 67 |
toastSizeObserver = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
}
|
| 69 |
}
|
| 70 |
|
|
@@ -76,6 +116,17 @@ function observeToastRemoval() {
|
|
| 76 |
toastObserver.observe(document.body, { childList: true });
|
| 77 |
}
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
/**
|
| 80 |
* 显示 Toast 消息提示
|
| 81 |
* @param {string} message - 提示内容
|
|
@@ -83,6 +134,7 @@ function observeToastRemoval() {
|
|
| 83 |
* @param {number} duration - 显示时长(毫秒),默认3000
|
| 84 |
*/
|
| 85 |
export function showToast(message, type = "info", duration = 3000) {
|
|
|
|
| 86 |
const previousFocus = document.activeElement;
|
| 87 |
const toast = document.createElement("div");
|
| 88 |
const safeType = ["success", "error", "warning", "info"].includes(type) ? type : "info";
|
|
@@ -93,7 +145,7 @@ export function showToast(message, type = "info", duration = 3000) {
|
|
| 93 |
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
|
| 94 |
if (reduceMotion) toast.classList.add("comfy-ranking-toast--reduced-motion");
|
| 95 |
// 新提示从目标位置上方滑入;关闭后再按当前队列位置重排。
|
| 96 |
-
toast.style.top = `${UI.TOAST.OFFSET - 30}px`;
|
| 97 |
|
| 98 |
// 提示文本统一转义:message 常携带昵称/标题/后端报错等用户可控内容,
|
| 99 |
// 全库无任何调用方故意向 showToast 传 HTML 标签,故无条件转义不会改变现有显示效果。
|
|
@@ -138,22 +190,24 @@ export function showToast(message, type = "info", duration = 3000) {
|
|
| 138 |
deadline = performance.now() + remaining;
|
| 139 |
toast._autoCloseTimer = setTimeout(() => closeToast(toast), remaining);
|
| 140 |
};
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
const handleFocusOut = event => {
|
| 144 |
if (!toast.contains(event.relatedTarget)) queueMicrotask(resumeTimer);
|
| 145 |
};
|
| 146 |
-
toast.addEventListener("mouseenter", handleEnter);
|
| 147 |
-
toast.addEventListener("mouseleave", handleLeave);
|
| 148 |
toast.addEventListener("focusin", pauseTimer);
|
| 149 |
toast.addEventListener("focusout", handleFocusOut);
|
| 150 |
toast._cleanupToastEvents = () => {
|
| 151 |
closeButton.removeEventListener("click", handleClose);
|
| 152 |
-
toast.removeEventListener("mouseenter", handleEnter);
|
| 153 |
-
toast.removeEventListener("mouseleave", handleLeave);
|
| 154 |
toast.removeEventListener("focusin", pauseTimer);
|
| 155 |
toast.removeEventListener("focusout", handleFocusOut);
|
| 156 |
};
|
|
|
|
|
|
|
| 157 |
resumeTimer();
|
| 158 |
}
|
| 159 |
|
|
@@ -502,10 +556,11 @@ export function showRetryDialog(message, retryFn, options = {}) {
|
|
| 502 |
|
| 503 |
let settled = false;
|
| 504 |
let retryInProgress = false;
|
| 505 |
-
let dialogAccessibility;
|
| 506 |
const settle = result => {
|
| 507 |
if (settled) return;
|
| 508 |
settled = true;
|
|
|
|
| 509 |
dialogAccessibility?.cleanup();
|
| 510 |
_closeDialog(overlay, box, false, () => {
|
| 511 |
dialogAccessibility?.restoreFocus();
|
|
@@ -517,32 +572,42 @@ export function showRetryDialog(message, retryFn, options = {}) {
|
|
| 517 |
if (!retryInProgress) settle(false);
|
| 518 |
}
|
| 519 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 520 |
|
| 521 |
box.querySelector("#ui-btn-cancel").onclick = () => {
|
| 522 |
settle(false);
|
| 523 |
};
|
| 524 |
|
| 525 |
box.querySelector("#ui-btn-retry").onclick = async () => {
|
|
|
|
| 526 |
const retryBtn = box.querySelector("#ui-btn-retry");
|
| 527 |
retryBtn.innerHTML = `<span class="btn-spinner"></span> ${t('common.retrying')}`;
|
| 528 |
retryBtn.disabled = true;
|
| 529 |
retryInProgress = true;
|
|
|
|
| 530 |
|
| 531 |
try {
|
| 532 |
await retryFn();
|
|
|
|
| 533 |
settle(true);
|
| 534 |
-
} catch
|
| 535 |
-
|
| 536 |
-
const errText = error.message || t('common.retry_failed');
|
| 537 |
const errBox = box.querySelector(".comfy-ranking-dialog__message");
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
}
|
| 543 |
retryBtn.innerHTML = escapeHtml(retryText);
|
| 544 |
retryBtn.disabled = false;
|
| 545 |
retryInProgress = false;
|
|
|
|
| 546 |
}
|
| 547 |
};
|
| 548 |
});
|
|
|
|
| 26 |
let toastQueue = [];
|
| 27 |
let toastObserver = null;
|
| 28 |
let toastSizeObserver = null;
|
| 29 |
+
let toastPointer = null;
|
| 30 |
+
let toastPointerListening = false;
|
| 31 |
+
|
| 32 |
+
function isToastHovered(toast) {
|
| 33 |
+
if (!toastPointer) return false;
|
| 34 |
+
const bounds = toast.getBoundingClientRect();
|
| 35 |
+
return toastPointer.x >= bounds.left && toastPointer.x < bounds.right &&
|
| 36 |
+
toastPointer.y >= bounds.top && toastPointer.y < bounds.bottom;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
function updateToastHover() {
|
| 40 |
+
const states = toastQueue.map(isToastHovered);
|
| 41 |
+
toastQueue.forEach((toast, index) => toast._setToastHovered?.(states[index]));
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
function handleToastPointer(event) {
|
| 45 |
+
if (event.pointerType === "touch") return;
|
| 46 |
+
toastPointer = { x: event.clientX, y: event.clientY };
|
| 47 |
+
updateToastHover();
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function clearToastPointer() {
|
| 51 |
+
toastPointer = null;
|
| 52 |
+
updateToastHover();
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function observeToastPointer() {
|
| 56 |
+
if (toastPointerListening) return;
|
| 57 |
+
toastPointerListening = true;
|
| 58 |
+
document.addEventListener("pointermove", handleToastPointer, { passive: true });
|
| 59 |
+
document.addEventListener("pointerleave", clearToastPointer);
|
| 60 |
+
window.addEventListener("blur", clearToastPointer);
|
| 61 |
+
}
|
| 62 |
let dialogInstanceId = 0;
|
| 63 |
const dialogStack = [];
|
| 64 |
|
|
|
|
| 69 |
toast.style.top = `${top}px`;
|
| 70 |
top += heights[index] + UI.TOAST.GAP;
|
| 71 |
});
|
| 72 |
+
updateToastHover();
|
| 73 |
}
|
| 74 |
|
| 75 |
function observeToastSize(toast) {
|
|
|
|
| 91 |
toast._previousFocus = null;
|
| 92 |
toast._cleanupToastEvents?.();
|
| 93 |
toast._cleanupToastEvents = null;
|
| 94 |
+
toast._setToastHovered = null;
|
| 95 |
const index = toastQueue.indexOf(toast);
|
| 96 |
if (index > -1) toastQueue.splice(index, 1);
|
| 97 |
repositionToasts();
|
|
|
|
| 100 |
toastObserver = null;
|
| 101 |
toastSizeObserver?.disconnect();
|
| 102 |
toastSizeObserver = null;
|
| 103 |
+
document.removeEventListener("pointermove", handleToastPointer);
|
| 104 |
+
document.removeEventListener("pointerleave", clearToastPointer);
|
| 105 |
+
window.removeEventListener("blur", clearToastPointer);
|
| 106 |
+
toastPointerListening = false;
|
| 107 |
+
toastPointer = null;
|
| 108 |
}
|
| 109 |
}
|
| 110 |
|
|
|
|
| 116 |
toastObserver.observe(document.body, { childList: true });
|
| 117 |
}
|
| 118 |
|
| 119 |
+
function reserveToastSlot() {
|
| 120 |
+
while (toastQueue.length >= UI.TOAST.MAX_VISIBLE) {
|
| 121 |
+
const candidate = toastQueue.find(toast => !toast.contains(document.activeElement) && !isToastHovered(toast))
|
| 122 |
+
|| toastQueue.find(toast => !toast.contains(document.activeElement));
|
| 123 |
+
if (!candidate) return false;
|
| 124 |
+
candidate.remove();
|
| 125 |
+
releaseToast(candidate);
|
| 126 |
+
}
|
| 127 |
+
return true;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
/**
|
| 131 |
* 显示 Toast 消息提示
|
| 132 |
* @param {string} message - 提示内容
|
|
|
|
| 134 |
* @param {number} duration - 显示时长(毫秒),默认3000
|
| 135 |
*/
|
| 136 |
export function showToast(message, type = "info", duration = 3000) {
|
| 137 |
+
if (!reserveToastSlot()) return;
|
| 138 |
const previousFocus = document.activeElement;
|
| 139 |
const toast = document.createElement("div");
|
| 140 |
const safeType = ["success", "error", "warning", "info"].includes(type) ? type : "info";
|
|
|
|
| 145 |
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
|
| 146 |
if (reduceMotion) toast.classList.add("comfy-ranking-toast--reduced-motion");
|
| 147 |
// 新提示从目标位置上方滑入;关闭后再按当前队列位置重排。
|
| 148 |
+
toast.style.top = `${UI.TOAST.OFFSET + toastQueue.reduce((height, item) => height + item.offsetHeight + UI.TOAST.GAP, 0) - 30}px`;
|
| 149 |
|
| 150 |
// 提示文本统一转义:message 常携带昵称/标题/后端报错等用户可控内容,
|
| 151 |
// 全库无任何调用方故意向 showToast 传 HTML 标签,故无条件转义不会改变现有显示效果。
|
|
|
|
| 190 |
deadline = performance.now() + remaining;
|
| 191 |
toast._autoCloseTimer = setTimeout(() => closeToast(toast), remaining);
|
| 192 |
};
|
| 193 |
+
toast._setToastHovered = value => {
|
| 194 |
+
if (hovered === value) return;
|
| 195 |
+
hovered = value;
|
| 196 |
+
if (hovered) pauseTimer();
|
| 197 |
+
else resumeTimer();
|
| 198 |
+
};
|
| 199 |
const handleFocusOut = event => {
|
| 200 |
if (!toast.contains(event.relatedTarget)) queueMicrotask(resumeTimer);
|
| 201 |
};
|
|
|
|
|
|
|
| 202 |
toast.addEventListener("focusin", pauseTimer);
|
| 203 |
toast.addEventListener("focusout", handleFocusOut);
|
| 204 |
toast._cleanupToastEvents = () => {
|
| 205 |
closeButton.removeEventListener("click", handleClose);
|
|
|
|
|
|
|
| 206 |
toast.removeEventListener("focusin", pauseTimer);
|
| 207 |
toast.removeEventListener("focusout", handleFocusOut);
|
| 208 |
};
|
| 209 |
+
observeToastPointer();
|
| 210 |
+
updateToastHover();
|
| 211 |
resumeTimer();
|
| 212 |
}
|
| 213 |
|
|
|
|
| 556 |
|
| 557 |
let settled = false;
|
| 558 |
let retryInProgress = false;
|
| 559 |
+
let dialogAccessibility, observer;
|
| 560 |
const settle = result => {
|
| 561 |
if (settled) return;
|
| 562 |
settled = true;
|
| 563 |
+
observer?.disconnect();
|
| 564 |
dialogAccessibility?.cleanup();
|
| 565 |
_closeDialog(overlay, box, false, () => {
|
| 566 |
dialogAccessibility?.restoreFocus();
|
|
|
|
| 572 |
if (!retryInProgress) settle(false);
|
| 573 |
}
|
| 574 |
);
|
| 575 |
+
observer = new MutationObserver(() => {
|
| 576 |
+
if (settled || overlay.isConnected) return;
|
| 577 |
+
settled = true;
|
| 578 |
+
observer.disconnect();
|
| 579 |
+
dialogAccessibility.cleanup();
|
| 580 |
+
resolve(false);
|
| 581 |
+
});
|
| 582 |
+
observer.observe(document.body, { childList: true, subtree: true });
|
| 583 |
|
| 584 |
box.querySelector("#ui-btn-cancel").onclick = () => {
|
| 585 |
settle(false);
|
| 586 |
};
|
| 587 |
|
| 588 |
box.querySelector("#ui-btn-retry").onclick = async () => {
|
| 589 |
+
if (settled || retryInProgress || !overlay.isConnected) return;
|
| 590 |
const retryBtn = box.querySelector("#ui-btn-retry");
|
| 591 |
retryBtn.innerHTML = `<span class="btn-spinner"></span> ${t('common.retrying')}`;
|
| 592 |
retryBtn.disabled = true;
|
| 593 |
retryInProgress = true;
|
| 594 |
+
box.setAttribute("aria-busy", "true");
|
| 595 |
|
| 596 |
try {
|
| 597 |
await retryFn();
|
| 598 |
+
if (settled || !overlay.isConnected) return;
|
| 599 |
settle(true);
|
| 600 |
+
} catch {
|
| 601 |
+
if (settled || !overlay.isConnected) return;
|
|
|
|
| 602 |
const errBox = box.querySelector(".comfy-ranking-dialog__message");
|
| 603 |
+
errBox.textContent = t('common.retry_failed');
|
| 604 |
+
errBox.setAttribute("role", "alert");
|
| 605 |
+
} finally {
|
| 606 |
+
if (settled || !overlay.isConnected) return;
|
|
|
|
| 607 |
retryBtn.innerHTML = escapeHtml(retryText);
|
| 608 |
retryBtn.disabled = false;
|
| 609 |
retryInProgress = false;
|
| 610 |
+
box.removeAttribute("aria-busy");
|
| 611 |
}
|
| 612 |
};
|
| 613 |
});
|
前端页面/components/全局设置组件.js
CHANGED
|
@@ -1,643 +1,512 @@
|
|
| 1 |
-
// 前端页面/components/全局设置组件.js
|
| 2 |
-
// ==========================================
|
| 3 |
-
// ⚙️ 全局设置组件(界面偏好设置)
|
| 4 |
-
// ==========================================
|
| 5 |
-
// 作用:提供全局界面设置入口,控制显示偏好
|
| 6 |
-
// 关联文件:
|
| 7 |
-
// - 顶部导航组件.js (设置按钮入口)
|
| 8 |
-
// - 创作者卡片组件.js (背景图显示控制)
|
| 9 |
-
// ==========================================
|
| 10 |
-
|
| 11 |
-
import { showToast } from "./UI交互提示组件.js";
|
| 12 |
-
import { setLanguage, getLanguage, t } from "./用户体验增强.js";
|
| 13 |
-
import { clearAllCache } from "./性能优化工具.js";
|
| 14 |
-
import { IS_WEB_MODE, getSettings, saveSettings } from "../core/全局配置.js";
|
| 15 |
-
export { getSettings, saveSettings };
|
| 16 |
-
import { getDiskCacheStats, clearDiskCache } from './缓存管理请求.js';
|
| 17 |
-
|
| 18 |
-
// 🌐 语言选项(双语显示)
|
| 19 |
-
const LANGUAGE_OPTIONS = [
|
| 20 |
-
{ value: 'zh-CN', label: '简体中文 / Simplified Chinese' },
|
| 21 |
-
{ value: 'en-US', label: 'English / 英语' }
|
| 22 |
-
];
|
| 23 |
-
|
| 24 |
-
/**
|
| 25 |
-
* 📊 格式化文件大小
|
| 26 |
-
*/
|
| 27 |
-
function formatSize(bytes) {
|
| 28 |
-
if (bytes === 0) return '0 B';
|
| 29 |
-
const units = ['B', 'KB', 'MB', 'GB'];
|
| 30 |
-
const i = Math.floor(Math.log2(bytes) / 10);
|
| 31 |
-
const unitIndex = Math.min(i, units.length - 1);
|
| 32 |
-
const size = bytes / Math.pow(1024, unitIndex);
|
| 33 |
-
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
/**
|
| 37 |
-
* 📊 获取浏览器缓存统计
|
| 38 |
-
*/
|
| 39 |
-
function getBrowserCacheStats() {
|
| 40 |
-
let totalSize = 0;
|
| 41 |
-
let count = 0;
|
| 42 |
-
let available = true;
|
| 43 |
-
try {
|
| 44 |
-
for (let i = 0; i < localStorage.length; i++) {
|
| 45 |
-
const key = localStorage.key(i);
|
| 46 |
-
if (key && key.startsWith('ComfyRanking_')) {
|
| 47 |
-
const value = localStorage.getItem(key);
|
| 48 |
-
if (value) {
|
| 49 |
-
totalSize += new Blob([value]).size;
|
| 50 |
-
count++;
|
| 51 |
-
}
|
| 52 |
-
}
|
| 53 |
-
}
|
| 54 |
-
} catch (e) { available = false; }
|
| 55 |
-
return { count, totalSize, available };
|
| 56 |
-
}
|
| 57 |
-
|
| 58 |
-
/**
|
| 59 |
-
* ⚙️ 创建设置页面视图
|
| 60 |
-
*/
|
| 61 |
-
export function createSettingsView() {
|
| 62 |
-
const settings = getSettings();
|
| 63 |
-
|
| 64 |
-
const container = document.createElement("div");
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
${
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
};
|
| 367 |
-
|
| 368 |
-
/
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
const
|
| 421 |
-
const
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
if (!
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
//
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
languageTimer = null;
|
| 514 |
-
statsGeneration++;
|
| 515 |
-
statsController?.abort();
|
| 516 |
-
statsController = null;
|
| 517 |
-
};
|
| 518 |
-
|
| 519 |
-
async function loadCacheStats() {
|
| 520 |
-
const generation = ++statsGeneration;
|
| 521 |
-
statsController?.abort();
|
| 522 |
-
const controller = new AbortController();
|
| 523 |
-
statsController = controller;
|
| 524 |
-
// 📱 Web 模式:无本地磁盘缓存,仅统计浏览器缓存
|
| 525 |
-
if (IS_WEB_MODE) {
|
| 526 |
-
statsController = null;
|
| 527 |
-
const browserStats = getBrowserCacheStats();
|
| 528 |
-
if (!browserStats.available) {
|
| 529 |
-
const status = lastStatsHTML === null ? t('settings.cache_stats_unavailable') : t('settings.cache_stats_stale');
|
| 530 |
-
cacheStatsPanel.innerHTML = `<div>${status}</div>` + (lastStatsHTML || '');
|
| 531 |
-
return;
|
| 532 |
-
}
|
| 533 |
-
cacheStatsPanel.innerHTML = `
|
| 534 |
-
<div style="padding: 10px 12px; background: rgba(0,0,0,0.2); border-radius: 4px; border-left: 3px solid #9C27B0; color: #aaa; font-size: 13px;">
|
| 535 |
-
<span style="color: #ccc;">${t('settings.cache_browser')}:</span>${browserStats.count} ${t('settings.files')} · ${formatSize(browserStats.totalSize)}
|
| 536 |
-
</div>
|
| 537 |
-
`;
|
| 538 |
-
lastStatsHTML = cacheStatsPanel.innerHTML;
|
| 539 |
-
return;
|
| 540 |
-
}
|
| 541 |
-
try {
|
| 542 |
-
const [diskRes, browserStats] = await Promise.all([
|
| 543 |
-
getDiskCacheStats({ signal: controller.signal }),
|
| 544 |
-
Promise.resolve(getBrowserCacheStats())
|
| 545 |
-
]);
|
| 546 |
-
if (generation !== statsGeneration || cacheViewDisposed || !container.isConnected) return;
|
| 547 |
-
if (!browserStats.available) throw new Error('cache_stats_unavailable');
|
| 548 |
-
|
| 549 |
-
const imageCount = diskRes.image_count;
|
| 550 |
-
const imageSize = diskRes.image_size;
|
| 551 |
-
const videoCount = diskRes.video_count;
|
| 552 |
-
const videoSize = diskRes.video_size;
|
| 553 |
-
const browserCount = browserStats.count;
|
| 554 |
-
const browserSize = browserStats.totalSize;
|
| 555 |
-
|
| 556 |
-
const hasAnyCache = imageCount > 0 || videoCount > 0 || browserCount > 0;
|
| 557 |
-
|
| 558 |
-
if (!hasAnyCache) {
|
| 559 |
-
cacheStatsPanel.innerHTML = `
|
| 560 |
-
<div style="padding: 10px 12px; background: rgba(0,0,0,0.2); border-radius: 4px; border-left: 3px solid #9C27B0; color: #888; font-size: 13px;">
|
| 561 |
-
${t('settings.no_cache')}
|
| 562 |
-
</div>
|
| 563 |
-
`;
|
| 564 |
-
lastStatsHTML = cacheStatsPanel.innerHTML;
|
| 565 |
-
return;
|
| 566 |
-
}
|
| 567 |
-
|
| 568 |
-
cacheStatsPanel.innerHTML = `
|
| 569 |
-
<div style="padding: 10px 12px; background: rgba(0,0,0,0.2); border-radius: 4px; border-left: 3px solid #9C27B0; color: #aaa; font-size: 13px;">
|
| 570 |
-
<span style="color: #ccc;">${t('settings.cache_disk_images')}:</span>${imageCount} ${t('settings.files')} · ${formatSize(imageSize)}
|
| 571 |
-
</div>
|
| 572 |
-
<div style="padding: 10px 12px; background: rgba(0,0,0,0.2); border-radius: 4px; border-left: 3px solid #9C27B0; color: #aaa; font-size: 13px;">
|
| 573 |
-
<span style="color: #ccc;">${t('settings.cache_disk_videos')}:</span>${videoCount} ${t('settings.files')} · ${formatSize(videoSize)}
|
| 574 |
-
</div>
|
| 575 |
-
<div style="padding: 10px 12px; background: rgba(0,0,0,0.2); border-radius: 4px; border-left: 3px solid #9C27B0; color: #aaa; font-size: 13px;">
|
| 576 |
-
<span style="color: #ccc;">${t('settings.cache_browser')}:</span>${browserCount} ${t('settings.files')} · ${formatSize(browserSize)}
|
| 577 |
-
</div>
|
| 578 |
-
`;
|
| 579 |
-
lastStatsHTML = cacheStatsPanel.innerHTML;
|
| 580 |
-
} catch (e) {
|
| 581 |
-
if (generation !== statsGeneration || cacheViewDisposed || !container.isConnected) return;
|
| 582 |
-
cacheStatsPanel.innerHTML = `
|
| 583 |
-
<div style="padding: 10px 12px; background: rgba(0,0,0,0.2); border-radius: 4px; border-left: 3px solid #9C27B0; color: #888; font-size: 13px;">
|
| 584 |
-
${lastStatsHTML === null ? t('settings.cache_stats_unavailable') : t('settings.cache_stats_stale')}
|
| 585 |
-
</div>
|
| 586 |
-
` + (lastStatsHTML || '');
|
| 587 |
-
} finally {
|
| 588 |
-
if (statsController === controller) statsController = null;
|
| 589 |
-
}
|
| 590 |
-
}
|
| 591 |
-
|
| 592 |
-
// 🧹 缓存清理按钮
|
| 593 |
-
const btnClearBrowser = container.querySelector("#btn-clear-browser-cache");
|
| 594 |
-
const btnClearDisk = container.querySelector("#btn-clear-disk-cache");
|
| 595 |
-
const btnClearAll = container.querySelector("#btn-clear-all-cache");
|
| 596 |
-
const clearButtons = [btnClearBrowser, btnClearDisk, btnClearAll].filter(Boolean);
|
| 597 |
-
let clearingCache = false;
|
| 598 |
-
|
| 599 |
-
async function handleClearCache(btn, action) {
|
| 600 |
-
if (clearingCache || cacheViewDisposed || !container.isConnected) return;
|
| 601 |
-
clearingCache = true;
|
| 602 |
-
const originalText = btn.textContent;
|
| 603 |
-
btn.textContent = t('settings.cache_clearing');
|
| 604 |
-
clearButtons.forEach(button => { button.disabled = true; button.setAttribute('aria-busy', 'true'); });
|
| 605 |
-
|
| 606 |
-
try {
|
| 607 |
-
if (action === 'browser' || action === 'all') {
|
| 608 |
-
if (!clearAllCache()) throw new Error('cache_operation_unconfirmed');
|
| 609 |
-
}
|
| 610 |
-
if (action === 'disk' || action === 'all') {
|
| 611 |
-
await clearDiskCache();
|
| 612 |
-
}
|
| 613 |
-
if (cacheViewDisposed || !container.isConnected) return;
|
| 614 |
-
await loadCacheStats();
|
| 615 |
-
if (!cacheViewDisposed && container.isConnected) showToast(t('settings.cache_cleared'), "success");
|
| 616 |
-
} catch (e) {
|
| 617 |
-
if (cacheViewDisposed || !container.isConnected) return;
|
| 618 |
-
showToast(t('settings.cache_clear_unconfirmed'), "error");
|
| 619 |
-
await loadCacheStats();
|
| 620 |
-
} finally {
|
| 621 |
-
btn.textContent = originalText;
|
| 622 |
-
clearingCache = false;
|
| 623 |
-
clearButtons.forEach(button => { button.disabled = false; button.removeAttribute('aria-busy'); });
|
| 624 |
-
}
|
| 625 |
-
}
|
| 626 |
-
|
| 627 |
-
btnClearBrowser.onclick = () => handleClearCache(btnClearBrowser, 'browser');
|
| 628 |
-
if (btnClearDisk) btnClearDisk.onclick = () => handleClearCache(btnClearDisk, 'disk');
|
| 629 |
-
if (btnClearAll) btnClearAll.onclick = () => handleClearCache(btnClearAll, 'all');
|
| 630 |
-
|
| 631 |
-
// 进入设置页时自动加载缓存统计
|
| 632 |
-
loadCacheStats();
|
| 633 |
-
|
| 634 |
-
return container;
|
| 635 |
-
}
|
| 636 |
-
|
| 637 |
-
/**
|
| 638 |
-
* ⚙️ 打开设置页面(通过路由)
|
| 639 |
-
*/
|
| 640 |
-
export function openSettingsPage() {
|
| 641 |
-
const view = createSettingsView();
|
| 642 |
-
window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } }));
|
| 643 |
-
}
|
|
|
|
| 1 |
+
// 前端页面/components/全局设置组件.js
|
| 2 |
+
// ==========================================
|
| 3 |
+
// ⚙️ 全局设置组件(界面偏好设置)
|
| 4 |
+
// ==========================================
|
| 5 |
+
// 作用:提供全局界面设置入口,控制显示偏好
|
| 6 |
+
// 关联文件:
|
| 7 |
+
// - 顶部导航组件.js (设置按钮入口)
|
| 8 |
+
// - 创作者卡片组件.js (背景图显示控制)
|
| 9 |
+
// ==========================================
|
| 10 |
+
|
| 11 |
+
import { showToast } from "./UI交互提示组件.js";
|
| 12 |
+
import { setLanguage, getLanguage, t } from "./用户体验增强.js";
|
| 13 |
+
import { clearAllCache } from "./性能优化工具.js";
|
| 14 |
+
import { IS_WEB_MODE, getSettings, saveSettings } from "../core/全局配置.js";
|
| 15 |
+
export { getSettings, saveSettings };
|
| 16 |
+
import { getDiskCacheStats, clearDiskCache } from './缓存管理请求.js';
|
| 17 |
+
|
| 18 |
+
// 🌐 语言选项(双语显示)
|
| 19 |
+
const LANGUAGE_OPTIONS = [
|
| 20 |
+
{ value: 'zh-CN', label: '简体中文 / Simplified Chinese' },
|
| 21 |
+
{ value: 'en-US', label: 'English / 英语' }
|
| 22 |
+
];
|
| 23 |
+
|
| 24 |
+
/**
|
| 25 |
+
* 📊 格式化文件大小
|
| 26 |
+
*/
|
| 27 |
+
function formatSize(bytes) {
|
| 28 |
+
if (bytes === 0) return '0 B';
|
| 29 |
+
const units = ['B', 'KB', 'MB', 'GB'];
|
| 30 |
+
const i = Math.floor(Math.log2(bytes) / 10);
|
| 31 |
+
const unitIndex = Math.min(i, units.length - 1);
|
| 32 |
+
const size = bytes / Math.pow(1024, unitIndex);
|
| 33 |
+
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/**
|
| 37 |
+
* 📊 获取浏览器缓存统计
|
| 38 |
+
*/
|
| 39 |
+
function getBrowserCacheStats() {
|
| 40 |
+
let totalSize = 0;
|
| 41 |
+
let count = 0;
|
| 42 |
+
let available = true;
|
| 43 |
+
try {
|
| 44 |
+
for (let i = 0; i < localStorage.length; i++) {
|
| 45 |
+
const key = localStorage.key(i);
|
| 46 |
+
if (key && key.startsWith('ComfyRanking_')) {
|
| 47 |
+
const value = localStorage.getItem(key);
|
| 48 |
+
if (value) {
|
| 49 |
+
totalSize += new Blob([value]).size;
|
| 50 |
+
count++;
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
} catch (e) { available = false; }
|
| 55 |
+
return { count, totalSize, available };
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/**
|
| 59 |
+
* ⚙️ 创建设置页面视图
|
| 60 |
+
*/
|
| 61 |
+
export function createSettingsView() {
|
| 62 |
+
const settings = getSettings();
|
| 63 |
+
|
| 64 |
+
const container = document.createElement("div");
|
| 65 |
+
container.className = "ranking-settings";
|
| 66 |
+
|
| 67 |
+
container.innerHTML = `
|
| 68 |
+
<!-- 顶部标题栏 -->
|
| 69 |
+
<header class="ranking-settings__header">
|
| 70 |
+
<button type="button" class="ranking-settings__back" id="btn-back-settings" >
|
| 71 |
+
⬅ ${t('common.back')}
|
| 72 |
+
</button>
|
| 73 |
+
<h1 class="ranking-settings__title">⚙️ ${t('settings.title')}</h1>
|
| 74 |
+
</header>
|
| 75 |
+
|
| 76 |
+
<!-- 设置内容区域 -->
|
| 77 |
+
<div class="ranking-settings__body" >
|
| 78 |
+
|
| 79 |
+
<!-- 分类:通用设置 -->
|
| 80 |
+
<h2 class="ranking-settings__heading-general">
|
| 81 |
+
🌐 ${t('settings.general')}
|
| 82 |
+
</h2>
|
| 83 |
+
|
| 84 |
+
<!-- 设置项:界面语言 -->
|
| 85 |
+
<div class="setting-item">
|
| 86 |
+
<div class="ranking-settings__copy" >
|
| 87 |
+
<div class="ranking-settings__label" id="label-language" >${t('settings.language')} / Language</div>
|
| 88 |
+
<div class="ranking-settings__description" >${t('settings.language_desc')} / Select display language</div>
|
| 89 |
+
</div>
|
| 90 |
+
<select aria-labelledby="label-language" id="select-language" class="setting-select">
|
| 91 |
+
${LANGUAGE_OPTIONS.map(opt =>
|
| 92 |
+
`<option value="${opt.value}" ${(settings.language || 'zh-CN') === opt.value ? 'selected' : ''}>${opt.label}</option>`
|
| 93 |
+
).join('')}
|
| 94 |
+
</select>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
<section class="setting-shortcuts" aria-labelledby="heading-shortcuts">
|
| 98 |
+
<h2 id="heading-shortcuts" class="setting-shortcuts__heading">⌨️ ${t('settings.shortcuts')}</h2>
|
| 99 |
+
<div class="setting-item">
|
| 100 |
+
<div class="setting-shortcuts__copy">
|
| 101 |
+
<div id="label-keyboard-shortcuts" class="setting-shortcuts__label">${t('settings.keyboard_combinations')}</div>
|
| 102 |
+
<p id="desc-keyboard-shortcuts" class="setting-shortcuts__desc">${t('settings.keyboard_combinations_desc')}</p>
|
| 103 |
+
</div>
|
| 104 |
+
<button type="button" role="switch" id="switch-keyboard-shortcuts" aria-labelledby="label-keyboard-shortcuts" aria-describedby="desc-keyboard-shortcuts" class="setting-switch ${settings.enableKeyboardShortcuts === true ? 'active' : ''}"></button>
|
| 105 |
+
</div>
|
| 106 |
+
<dl class="setting-shortcuts__list">
|
| 107 |
+
${[[t('settings.shortcuts_tab_keys'), t('settings.shortcuts_controls')], [t('settings.shortcuts_activate_keys'), t('settings.shortcuts_activate')], [t('settings.shortcuts_arrows'), t('settings.shortcuts_tabs')], [t('settings.shortcuts_image_keys'), t('settings.shortcuts_images')], [t('settings.shortcuts_escape_key'), t('settings.shortcuts_escape')]].map(([keys, description]) => `<div class="setting-shortcuts__entry"><dt><kbd>${keys}</kbd></dt><dd>${description}</dd></div>`).join('')}
|
| 108 |
+
</dl>
|
| 109 |
+
<p class="setting-shortcuts__note">${t('settings.shortcuts_scope')}</p>
|
| 110 |
+
</section>
|
| 111 |
+
|
| 112 |
+
<!-- 分类:显示偏好 -->
|
| 113 |
+
<h2 class="ranking-settings__heading-display">
|
| 114 |
+
📐 ${t('settings.display')}
|
| 115 |
+
</h2>
|
| 116 |
+
|
| 117 |
+
<!-- 设置项:创作者卡片背景图 -->
|
| 118 |
+
<div class="setting-item">
|
| 119 |
+
<div class="ranking-settings__copy" >
|
| 120 |
+
<div class="ranking-settings__label" id="label-creator-banner" >${t('settings.creator_banner')}</div>
|
| 121 |
+
<div class="ranking-settings__description" id="desc-creator-banner" >${t('settings.creator_banner_desc')}</div>
|
| 122 |
+
</div>
|
| 123 |
+
<button type="button" role="switch" id="switch-creator-banner" aria-labelledby="label-creator-banner" aria-describedby="desc-creator-banner" class="setting-switch ${settings.showCreatorBanner ? 'active' : ''}"></button>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
<!-- 分类:更多功能 -->
|
| 127 |
+
<h2 class="ranking-settings__heading-animation">
|
| 128 |
+
✨ ${t('settings.animation')}
|
| 129 |
+
</h2>
|
| 130 |
+
|
| 131 |
+
<!-- 设置项:榜单切换动画 -->
|
| 132 |
+
<div class="setting-item">
|
| 133 |
+
<div class="ranking-settings__copy" >
|
| 134 |
+
<div class="ranking-settings__label" id="label-animations" >${t('settings.animations')}</div>
|
| 135 |
+
<div class="ranking-settings__description" id="desc-animations" >${t('settings.animations_desc')}</div>
|
| 136 |
+
</div>
|
| 137 |
+
<button type="button" role="switch" id="switch-animations" aria-labelledby="label-animations" aria-describedby="desc-animations" class="setting-switch ${settings.enableAnimations ? 'active' : ''}"></button>
|
| 138 |
+
</div>
|
| 139 |
+
|
| 140 |
+
<!-- 设置项:动画音效 -->
|
| 141 |
+
<div class="setting-item">
|
| 142 |
+
<div class="ranking-settings__copy" >
|
| 143 |
+
<div class="ranking-settings__label" id="label-sound-effects" >${t('settings.sound')}</div>
|
| 144 |
+
<div class="ranking-settings__description" id="desc-sound-effects" >${t('settings.sound_desc')}</div>
|
| 145 |
+
</div>
|
| 146 |
+
<button type="button" role="switch" id="switch-sound-effects" aria-labelledby="label-sound-effects" aria-describedby="desc-sound-effects" class="setting-switch ${settings.enableSoundEffects ? 'active' : ''}"></button>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
<!-- 分类:数据与缓存 -->
|
| 150 |
+
<h2 class="ranking-settings__heading-cache">
|
| 151 |
+
${t('settings.data_cache')}
|
| 152 |
+
</h2>
|
| 153 |
+
|
| 154 |
+
<!-- 设置项:缓存刷新间隔(滑条) -->
|
| 155 |
+
<div class="ranking-settings__stack setting-item" >
|
| 156 |
+
<div class="ranking-settings__copy-wide" >
|
| 157 |
+
<div class="ranking-settings__label" id="label-cache-expire" >${t('settings.cache_expire')}</div>
|
| 158 |
+
<div class="ranking-settings__description" >${t('settings.cache_expire_desc')}</div>
|
| 159 |
+
</div>
|
| 160 |
+
<div class="ranking-settings__controls" >
|
| 161 |
+
<div class="ranking-settings__range-row" >
|
| 162 |
+
<input aria-labelledby="label-cache-expire" type="range" id="slider-cache-expire" class="ranking-settings__copy setting-range-slider"
|
| 163 |
+
value="${settings.cacheExpireSeconds || 7200}"
|
| 164 |
+
min="60" max="86400" step="60"
|
| 165 |
+
>
|
| 166 |
+
<input aria-labelledby="label-cache-expire" type="number" id="input-cache-expire" class="setting-number-input"
|
| 167 |
+
value="${settings.cacheExpireSeconds || 7200}"
|
| 168 |
+
min="60" max="86400" step="60">
|
| 169 |
+
<span class="ranking-settings__time" id="cache-expire-label" ></span>
|
| 170 |
+
</div>
|
| 171 |
+
<div class="ranking-settings__hint" >
|
| 172 |
+
💡 ${t('settings.cache_trigger_desc')}
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
|
| 177 |
+
<!-- 设置项:缓存统计与清理 -->
|
| 178 |
+
<div class="ranking-settings__stack setting-item" >
|
| 179 |
+
<div class="ranking-settings__copy-wide" >
|
| 180 |
+
<div class="ranking-settings__stats-label" >${t('settings.cache_stats')}</div>
|
| 181 |
+
<div class="ranking-settings__stats-hint" >${t('settings.cache_stats_hint')}</div>
|
| 182 |
+
</div>
|
| 183 |
+
<div class="ranking-settings__stats" id="cache-stats-panel" >
|
| 184 |
+
<div class="ranking-settings__stat-muted" >
|
| 185 |
+
${t('settings.no_cache')}
|
| 186 |
+
</div>
|
| 187 |
+
</div>
|
| 188 |
+
<div class="ranking-settings__actions" >
|
| 189 |
+
<button type="button" id="btn-clear-browser-cache" class="cache-clear-btn">${t('settings.clear_browser_cache')}</button>
|
| 190 |
+
${IS_WEB_MODE ? '' : `<button type="button" id="btn-clear-disk-cache" class="cache-clear-btn">${t('settings.clear_disk_cache')}</button>
|
| 191 |
+
<button type="button" id="btn-clear-all-cache" class="cache-clear-btn">${t('settings.clear_all_cache')}</button>`}
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
|
| 195 |
+
<!-- 动画预览提示 -->
|
| 196 |
+
<div class="ranking-settings__preview" >
|
| 197 |
+
<div class="ranking-settings__preview-title" >🎨 ${t('settings.animation_preview')}</div>
|
| 198 |
+
<div class="ranking-settings__preview-copy" >
|
| 199 |
+
• ${t('settings.anim_cascade')}<br>
|
| 200 |
+
• ${t('settings.anim_fan')}<br>
|
| 201 |
+
• ${t('settings.anim_abyss')}<br>
|
| 202 |
+
• ${t('settings.anim_dataflow')}
|
| 203 |
+
</div>
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
+
|
| 207 |
+
<!-- 底部信息 -->
|
| 208 |
+
<div class="ranking-settings__footer" >
|
| 209 |
+
<div class="ranking-settings__footer-copy" >${t('settings.auto_save')}</div>
|
| 210 |
+
</div>
|
| 211 |
+
`;
|
| 212 |
+
|
| 213 |
+
// 🔌 绑定事件
|
| 214 |
+
|
| 215 |
+
// 返回按钮
|
| 216 |
+
container.querySelector("#btn-back-settings").onclick = () => {
|
| 217 |
+
window.dispatchEvent(new CustomEvent("comfy-route-back"));
|
| 218 |
+
};
|
| 219 |
+
|
| 220 |
+
/**
|
| 221 |
+
* 通用开关绑定辅助函数
|
| 222 |
+
* @param {string} selector - querySelector 选择器
|
| 223 |
+
* @param {string} settingKey - settings 对象的字段名
|
| 224 |
+
* @param {string} labelKey - i18n label key
|
| 225 |
+
* @param {string} [iconOn] - 开启时的图标前缀,默认 '✅'
|
| 226 |
+
*/
|
| 227 |
+
function _bindSwitchHandler(selector, settingKey, labelKey, iconOn = '✅') {
|
| 228 |
+
const switchEl = container.querySelector(selector);
|
| 229 |
+
switchEl.setAttribute("aria-checked", String(switchEl.classList.contains("active")));
|
| 230 |
+
switchEl.onclick = () => {
|
| 231 |
+
if (settingKey === 'enableKeyboardShortcuts' && (cacheViewDisposed || !container.isConnected)) return;
|
| 232 |
+
let currentSettings;
|
| 233 |
+
try { currentSettings = getSettings(settingKey === 'enableKeyboardShortcuts' ? { strict: true } : undefined); }
|
| 234 |
+
catch { showToast(t('common.error'), 'error'); return; }
|
| 235 |
+
const previousValue = switchEl.classList.contains("active");
|
| 236 |
+
const newValue = !previousValue;
|
| 237 |
+
switchEl.classList.toggle("active", newValue);
|
| 238 |
+
switchEl.setAttribute("aria-checked", String(newValue));
|
| 239 |
+
currentSettings[settingKey] = newValue;
|
| 240 |
+
const saved = saveSettings(currentSettings);
|
| 241 |
+
let confirmed = saved === true;
|
| 242 |
+
if (settingKey === 'enableKeyboardShortcuts') {
|
| 243 |
+
try { confirmed = confirmed && getSettings({ strict: true }).enableKeyboardShortcuts === newValue; }
|
| 244 |
+
catch { confirmed = false; }
|
| 245 |
+
}
|
| 246 |
+
if (settingKey === 'enableKeyboardShortcuts' && !confirmed) {
|
| 247 |
+
switchEl.classList.toggle('active', previousValue);
|
| 248 |
+
switchEl.setAttribute('aria-checked', String(previousValue));
|
| 249 |
+
showToast(t('common.error'), 'error');
|
| 250 |
+
return;
|
| 251 |
+
}
|
| 252 |
+
showToast(
|
| 253 |
+
newValue
|
| 254 |
+
? `${iconOn} ${t(labelKey)} ${t('settings.enabled')}`
|
| 255 |
+
: `${t(labelKey)} ${t('settings.disabled')}`,
|
| 256 |
+
"success"
|
| 257 |
+
);
|
| 258 |
+
};
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
// 🌐 下拉控制:界面语言
|
| 262 |
+
const selectLanguage = container.querySelector("#select-language");
|
| 263 |
+
let languageTimer = null;
|
| 264 |
+
let languageGeneration = 0;
|
| 265 |
+
selectLanguage.onchange = () => {
|
| 266 |
+
if (cacheViewDisposed || !container.isConnected) return;
|
| 267 |
+
const newLang = selectLanguage.value;
|
| 268 |
+
if (!LANGUAGE_OPTIONS.some(option => option.value === newLang)) return;
|
| 269 |
+
const generation = ++languageGeneration;
|
| 270 |
+
clearTimeout(languageTimer);
|
| 271 |
+
languageTimer = null;
|
| 272 |
+
|
| 273 |
+
const currentSettings = getSettings();
|
| 274 |
+
currentSettings.language = newLang;
|
| 275 |
+
saveSettings(currentSettings);
|
| 276 |
+
|
| 277 |
+
// 同步到 i18n 模块
|
| 278 |
+
setLanguage(newLang);
|
| 279 |
+
if (cacheViewDisposed || generation !== languageGeneration || !container.isConnected) return;
|
| 280 |
+
|
| 281 |
+
const langName = LANGUAGE_OPTIONS.find(opt => opt.value === newLang)?.label || newLang;
|
| 282 |
+
showToast(`🌐 ${t('settings.language_changed')} ${langName.split(' / ')[0]}`, "success");
|
| 283 |
+
|
| 284 |
+
languageTimer = setTimeout(() => {
|
| 285 |
+
languageTimer = null;
|
| 286 |
+
if (cacheViewDisposed || generation !== languageGeneration || !container.isConnected) return;
|
| 287 |
+
const restoreFocus = document.activeElement === selectLanguage;
|
| 288 |
+
const newSettingsView = createSettingsView();
|
| 289 |
+
if (cacheViewDisposed || generation !== languageGeneration || !container.isConnected) {
|
| 290 |
+
newSettingsView._cleanup?.();
|
| 291 |
+
return;
|
| 292 |
+
}
|
| 293 |
+
window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view: newSettingsView } }));
|
| 294 |
+
if (!newSettingsView.isConnected) {
|
| 295 |
+
newSettingsView._cleanup?.();
|
| 296 |
+
return;
|
| 297 |
+
}
|
| 298 |
+
if (restoreFocus && (document.activeElement === document.body || document.activeElement === selectLanguage)) {
|
| 299 |
+
newSettingsView.querySelector("#select-language")?.focus({ preventScroll: true });
|
| 300 |
+
}
|
| 301 |
+
}, 100);
|
| 302 |
+
};
|
| 303 |
+
|
| 304 |
+
// 🎛️ 开关控制:创作者背景图 / 榜单切换动画 / 动画音效
|
| 305 |
+
_bindSwitchHandler("#switch-keyboard-shortcuts", "enableKeyboardShortcuts", "settings.keyboard_combinations");
|
| 306 |
+
_bindSwitchHandler("#switch-creator-banner", "showCreatorBanner", "settings.creator_banner", "✅");
|
| 307 |
+
_bindSwitchHandler("#switch-animations", "enableAnimations", "settings.animations", "✨");
|
| 308 |
+
_bindSwitchHandler("#switch-sound-effects", "enableSoundEffects", "settings.sound", "🔊");
|
| 309 |
+
|
| 310 |
+
// ⏱️ 滑条+输入框联动:缓存刷新间隔
|
| 311 |
+
const inputCacheExpire = container.querySelector("#input-cache-expire");
|
| 312 |
+
const sliderCacheExpire = container.querySelector("#slider-cache-expire");
|
| 313 |
+
const cacheExpireLabel = container.querySelector("#cache-expire-label");
|
| 314 |
+
|
| 315 |
+
// 格式化时间显示
|
| 316 |
+
function formatCacheTime(seconds) {
|
| 317 |
+
if (seconds >= 3600) {
|
| 318 |
+
const hours = Math.floor(seconds / 3600);
|
| 319 |
+
const mins = Math.floor((seconds % 3600) / 60);
|
| 320 |
+
return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
|
| 321 |
+
}
|
| 322 |
+
return `${Math.floor(seconds / 60)}min`;
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
// 更新滑条渐变背景
|
| 326 |
+
function updateSliderTrack() {
|
| 327 |
+
const val = sliderCacheExpire.value;
|
| 328 |
+
const pct = ((val - 60) / (86400 - 60)) * 100;
|
| 329 |
+
sliderCacheExpire.style.setProperty("--settings-range-progress", `${pct}%`);
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
// 保存设置
|
| 333 |
+
function saveCacheExpire(newValue) {
|
| 334 |
+
const currentSettings = getSettings();
|
| 335 |
+
currentSettings.cacheExpireSeconds = newValue;
|
| 336 |
+
saveSettings(currentSettings);
|
| 337 |
+
cacheExpireLabel.textContent = formatCacheTime(newValue);
|
| 338 |
+
updateSliderTrack();
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
// 初始化显示
|
| 342 |
+
cacheExpireLabel.textContent = formatCacheTime(parseInt(inputCacheExpire.value));
|
| 343 |
+
updateSliderTrack();
|
| 344 |
+
|
| 345 |
+
// 滑条拖动 → 同步输入框 + 保存
|
| 346 |
+
sliderCacheExpire.oninput = () => {
|
| 347 |
+
const val = parseInt(sliderCacheExpire.value);
|
| 348 |
+
inputCacheExpire.value = val;
|
| 349 |
+
cacheExpireLabel.textContent = formatCacheTime(val);
|
| 350 |
+
updateSliderTrack();
|
| 351 |
+
};
|
| 352 |
+
sliderCacheExpire.onchange = () => {
|
| 353 |
+
saveCacheExpire(parseInt(sliderCacheExpire.value));
|
| 354 |
+
showToast(`⏱️ ${t('settings.cache_expire')}: ${formatCacheTime(parseInt(sliderCacheExpire.value))}`, "success");
|
| 355 |
+
};
|
| 356 |
+
|
| 357 |
+
// 输入框修改 → 同步滑条 + 保存
|
| 358 |
+
inputCacheExpire.onchange = () => {
|
| 359 |
+
let newValue = parseInt(inputCacheExpire.value);
|
| 360 |
+
if (isNaN(newValue) || newValue < 60) { newValue = 60; }
|
| 361 |
+
else if (newValue > 86400) { newValue = 86400; }
|
| 362 |
+
inputCacheExpire.value = newValue;
|
| 363 |
+
sliderCacheExpire.value = newValue;
|
| 364 |
+
saveCacheExpire(newValue);
|
| 365 |
+
showToast(`⏱️ ${t('settings.cache_expire')}: ${formatCacheTime(newValue)}`, "success");
|
| 366 |
+
};
|
| 367 |
+
|
| 368 |
+
// 📊 缓存统计面板
|
| 369 |
+
const cacheStatsPanel = container.querySelector("#cache-stats-panel");
|
| 370 |
+
cacheStatsPanel.setAttribute('role', 'status');
|
| 371 |
+
cacheStatsPanel.setAttribute('aria-live', 'polite');
|
| 372 |
+
let statsGeneration = 0;
|
| 373 |
+
let statsController = null;
|
| 374 |
+
let lastStatsHTML = null;
|
| 375 |
+
let cacheViewDisposed = false;
|
| 376 |
+
container._cleanup = () => {
|
| 377 |
+
if (cacheViewDisposed) return;
|
| 378 |
+
cacheViewDisposed = true;
|
| 379 |
+
container.querySelector("#switch-keyboard-shortcuts").onclick = null;
|
| 380 |
+
languageGeneration++;
|
| 381 |
+
clearTimeout(languageTimer);
|
| 382 |
+
languageTimer = null;
|
| 383 |
+
statsGeneration++;
|
| 384 |
+
statsController?.abort();
|
| 385 |
+
statsController = null;
|
| 386 |
+
};
|
| 387 |
+
|
| 388 |
+
async function loadCacheStats() {
|
| 389 |
+
const generation = ++statsGeneration;
|
| 390 |
+
statsController?.abort();
|
| 391 |
+
const controller = new AbortController();
|
| 392 |
+
statsController = controller;
|
| 393 |
+
// 📱 Web 模式:无本地磁盘缓存,仅统计浏览器缓存
|
| 394 |
+
if (IS_WEB_MODE) {
|
| 395 |
+
statsController = null;
|
| 396 |
+
const browserStats = getBrowserCacheStats();
|
| 397 |
+
if (!browserStats.available) {
|
| 398 |
+
const status = lastStatsHTML === null ? t('settings.cache_stats_unavailable') : t('settings.cache_stats_stale');
|
| 399 |
+
cacheStatsPanel.innerHTML = `<div>${status}</div>` + (lastStatsHTML || '');
|
| 400 |
+
return;
|
| 401 |
+
}
|
| 402 |
+
cacheStatsPanel.innerHTML = `
|
| 403 |
+
<div class="ranking-settings__stat" >
|
| 404 |
+
<span class="ranking-settings__stat-title" >${t('settings.cache_browser')}:</span>${browserStats.count} ${t('settings.files')} · ${formatSize(browserStats.totalSize)}
|
| 405 |
+
</div>
|
| 406 |
+
`;
|
| 407 |
+
lastStatsHTML = cacheStatsPanel.innerHTML;
|
| 408 |
+
return;
|
| 409 |
+
}
|
| 410 |
+
try {
|
| 411 |
+
const [diskRes, browserStats] = await Promise.all([
|
| 412 |
+
getDiskCacheStats({ signal: controller.signal }),
|
| 413 |
+
Promise.resolve(getBrowserCacheStats())
|
| 414 |
+
]);
|
| 415 |
+
if (generation !== statsGeneration || cacheViewDisposed || !container.isConnected) return;
|
| 416 |
+
if (!browserStats.available) throw new Error('cache_stats_unavailable');
|
| 417 |
+
|
| 418 |
+
const imageCount = diskRes.image_count;
|
| 419 |
+
const imageSize = diskRes.image_size;
|
| 420 |
+
const videoCount = diskRes.video_count;
|
| 421 |
+
const videoSize = diskRes.video_size;
|
| 422 |
+
const browserCount = browserStats.count;
|
| 423 |
+
const browserSize = browserStats.totalSize;
|
| 424 |
+
|
| 425 |
+
const hasAnyCache = imageCount > 0 || videoCount > 0 || browserCount > 0;
|
| 426 |
+
|
| 427 |
+
if (!hasAnyCache) {
|
| 428 |
+
cacheStatsPanel.innerHTML = `
|
| 429 |
+
<div class="ranking-settings__stat-muted" >
|
| 430 |
+
${t('settings.no_cache')}
|
| 431 |
+
</div>
|
| 432 |
+
`;
|
| 433 |
+
lastStatsHTML = cacheStatsPanel.innerHTML;
|
| 434 |
+
return;
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
cacheStatsPanel.innerHTML = `
|
| 438 |
+
<div class="ranking-settings__stat" >
|
| 439 |
+
<span class="ranking-settings__stat-title" >${t('settings.cache_disk_images')}:</span>${imageCount} ${t('settings.files')} · ${formatSize(imageSize)}
|
| 440 |
+
</div>
|
| 441 |
+
<div class="ranking-settings__stat" >
|
| 442 |
+
<span class="ranking-settings__stat-title" >${t('settings.cache_disk_videos')}:</span>${videoCount} ${t('settings.files')} · ${formatSize(videoSize)}
|
| 443 |
+
</div>
|
| 444 |
+
<div class="ranking-settings__stat" >
|
| 445 |
+
<span class="ranking-settings__stat-title" >${t('settings.cache_browser')}:</span>${browserCount} ${t('settings.files')} · ${formatSize(browserSize)}
|
| 446 |
+
</div>
|
| 447 |
+
`;
|
| 448 |
+
lastStatsHTML = cacheStatsPanel.innerHTML;
|
| 449 |
+
} catch (e) {
|
| 450 |
+
if (generation !== statsGeneration || cacheViewDisposed || !container.isConnected) return;
|
| 451 |
+
cacheStatsPanel.innerHTML = `
|
| 452 |
+
<div class="ranking-settings__stat-muted" >
|
| 453 |
+
${lastStatsHTML === null ? t('settings.cache_stats_unavailable') : t('settings.cache_stats_stale')}
|
| 454 |
+
</div>
|
| 455 |
+
` + (lastStatsHTML || '');
|
| 456 |
+
} finally {
|
| 457 |
+
if (statsController === controller) statsController = null;
|
| 458 |
+
}
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
// 🧹 缓存清理按钮
|
| 462 |
+
const btnClearBrowser = container.querySelector("#btn-clear-browser-cache");
|
| 463 |
+
const btnClearDisk = container.querySelector("#btn-clear-disk-cache");
|
| 464 |
+
const btnClearAll = container.querySelector("#btn-clear-all-cache");
|
| 465 |
+
const clearButtons = [btnClearBrowser, btnClearDisk, btnClearAll].filter(Boolean);
|
| 466 |
+
let clearingCache = false;
|
| 467 |
+
|
| 468 |
+
async function handleClearCache(btn, action) {
|
| 469 |
+
if (clearingCache || cacheViewDisposed || !container.isConnected) return;
|
| 470 |
+
clearingCache = true;
|
| 471 |
+
const originalText = btn.textContent;
|
| 472 |
+
btn.textContent = t('settings.cache_clearing');
|
| 473 |
+
clearButtons.forEach(button => { button.disabled = true; button.setAttribute('aria-busy', 'true'); });
|
| 474 |
+
|
| 475 |
+
try {
|
| 476 |
+
if (action === 'browser' || action === 'all') {
|
| 477 |
+
if (!clearAllCache()) throw new Error('cache_operation_unconfirmed');
|
| 478 |
+
}
|
| 479 |
+
if (action === 'disk' || action === 'all') {
|
| 480 |
+
await clearDiskCache();
|
| 481 |
+
}
|
| 482 |
+
if (cacheViewDisposed || !container.isConnected) return;
|
| 483 |
+
await loadCacheStats();
|
| 484 |
+
if (!cacheViewDisposed && container.isConnected) showToast(t('settings.cache_cleared'), "success");
|
| 485 |
+
} catch (e) {
|
| 486 |
+
if (cacheViewDisposed || !container.isConnected) return;
|
| 487 |
+
showToast(t('settings.cache_clear_unconfirmed'), "error");
|
| 488 |
+
await loadCacheStats();
|
| 489 |
+
} finally {
|
| 490 |
+
btn.textContent = originalText;
|
| 491 |
+
clearingCache = false;
|
| 492 |
+
clearButtons.forEach(button => { button.disabled = false; button.removeAttribute('aria-busy'); });
|
| 493 |
+
}
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
btnClearBrowser.onclick = () => handleClearCache(btnClearBrowser, 'browser');
|
| 497 |
+
if (btnClearDisk) btnClearDisk.onclick = () => handleClearCache(btnClearDisk, 'disk');
|
| 498 |
+
if (btnClearAll) btnClearAll.onclick = () => handleClearCache(btnClearAll, 'all');
|
| 499 |
+
|
| 500 |
+
// 进入设置���时自动加载缓存统计
|
| 501 |
+
loadCacheStats();
|
| 502 |
+
|
| 503 |
+
return container;
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
/**
|
| 507 |
+
* ⚙️ 打开设置页面(通过路由)
|
| 508 |
+
*/
|
| 509 |
+
export function openSettingsPage() {
|
| 510 |
+
const view = createSettingsView();
|
| 511 |
+
window.dispatchEvent(new CustomEvent("comfy-route-view", { detail: { view } }));
|
| 512 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
前端页面/components/卡片导航生命周期.js
CHANGED
|
@@ -14,11 +14,50 @@ export function createCardNavigationScope(target, isCurrent, { captureSession =
|
|
| 14 |
for (const event of events) target.addEventListener(event, invalidate);
|
| 15 |
for (const event of identityEvents) target.addEventListener(event, revokeIdentity);
|
| 16 |
target.addEventListener('storage', onStorage);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
return {
|
| 18 |
-
capture
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
},
|
| 23 |
dispose() {
|
| 24 |
if (disposed) return;
|
|
|
|
| 14 |
for (const event of events) target.addEventListener(event, invalidate);
|
| 15 |
for (const event of identityEvents) target.addEventListener(event, revokeIdentity);
|
| 16 |
target.addEventListener('storage', onStorage);
|
| 17 |
+
const capture = () => {
|
| 18 |
+
const captured = generation;
|
| 19 |
+
const isSessionCurrent = captureSession();
|
| 20 |
+
return () => !disposed && !identityRevoked && captured === generation && isCurrent() && isViewSessionCurrent() && isSessionCurrent();
|
| 21 |
+
};
|
| 22 |
return {
|
| 23 |
+
capture,
|
| 24 |
+
captureListFocus(container) {
|
| 25 |
+
let doc = container?.ownerDocument;
|
| 26 |
+
let active = doc?.activeElement;
|
| 27 |
+
if (!active || !container.contains(active)) return () => {};
|
| 28 |
+
let oldCards = Array.from(container.children).filter(card => card._rankingListId != null);
|
| 29 |
+
let oldCard = oldCards.find(card => card === active || card.contains(active));
|
| 30 |
+
if (!oldCard) return () => {};
|
| 31 |
+
const id = oldCard._rankingListId;
|
| 32 |
+
const index = oldCards.indexOf(oldCard);
|
| 33 |
+
const rootFocused = oldCard === active;
|
| 34 |
+
const tag = active.tagName;
|
| 35 |
+
const className = typeof active.className === 'string' ? active.className : '';
|
| 36 |
+
const current = capture();
|
| 37 |
+
oldCard = oldCards = null;
|
| 38 |
+
let consumed = false;
|
| 39 |
+
return () => {
|
| 40 |
+
if (consumed) return;
|
| 41 |
+
consumed = true;
|
| 42 |
+
try {
|
| 43 |
+
if (!current() || !container.isConnected || (active.isConnected && container.contains(active))) return;
|
| 44 |
+
const focused = doc.activeElement;
|
| 45 |
+
if (focused !== doc.body && focused !== doc.documentElement && focused !== active) return;
|
| 46 |
+
const cards = Array.from(container.children).filter(card => card._rankingListId != null);
|
| 47 |
+
const card = cards.find(item => item._rankingListId === id) || cards[Math.min(index, cards.length - 1)];
|
| 48 |
+
if (!card) {
|
| 49 |
+
container.setAttribute('tabindex', '-1');
|
| 50 |
+
container.focus({ preventScroll: true });
|
| 51 |
+
return;
|
| 52 |
+
}
|
| 53 |
+
const sameAction = rootFocused ? card : [...card.querySelectorAll('button, a[href], [tabindex]')]
|
| 54 |
+
.find(item => item.tagName === tag && item.className === className);
|
| 55 |
+
const replacement = sameAction || card.querySelector('.business-list-card__title') || card;
|
| 56 |
+
replacement.focus({ preventScroll: true });
|
| 57 |
+
} finally {
|
| 58 |
+
container = doc = active = null;
|
| 59 |
+
}
|
| 60 |
+
};
|
| 61 |
},
|
| 62 |
dispose() {
|
| 63 |
if (disposed) return;
|
前端页面/components/图片展示测试.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
| 6 |
// 接口:openImageViewer(imageUrls, startIndex, options)
|
| 7 |
// ==========================================
|
| 8 |
|
| 9 |
-
import { escapeHtml } from "../core/全局配置.js";
|
| 10 |
import { t } from "./用户体验增强.js";
|
| 11 |
|
| 12 |
// ==========================================
|
|
@@ -400,10 +400,14 @@ function _calcGridLayout(displayWidth, displayHeight, maxGridSize) {
|
|
| 400 |
*/
|
| 401 |
class CyberImageViewer {
|
| 402 |
constructor(imageUrls, startIndex = 0) {
|
| 403 |
-
// 处理
|
| 404 |
this.imageUrls = imageUrls.map(url => this._proxyImageUrl(url));
|
| 405 |
this.currentIndex = Math.max(0, Math.min(startIndex, this.imageUrls.length - 1));
|
| 406 |
this.preloadCache = new Map();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
this.isAnimating = false;
|
| 408 |
this.container = null;
|
| 409 |
this.elements = {};
|
|
@@ -412,30 +416,163 @@ class CyberImageViewer {
|
|
| 412 |
// 自动播放状态管理
|
| 413 |
this._autoPlayEnabled = false;
|
| 414 |
this._autoPlayTimerId = null;
|
|
|
|
| 415 |
this._autoPlayInterval = 1000; // 默认1秒展示时间
|
| 416 |
this._autoPlayStateChangeCallbacks = []; // 状态变化回调
|
| 417 |
this._returnFocus = document.activeElement;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
}
|
| 419 |
|
| 420 |
/**
|
| 421 |
-
*
|
| 422 |
* 自动识别代理URL、data: URL、blob: URL和普通HTTP(S) URL
|
| 423 |
*/
|
| 424 |
_proxyImageUrl(url) {
|
| 425 |
-
if (!url) return '';
|
| 426 |
-
|
| 427 |
-
if (
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
return
|
| 437 |
}
|
| 438 |
-
return url;
|
|
|
|
| 439 |
}
|
| 440 |
|
| 441 |
/**
|
|
@@ -534,6 +671,7 @@ class CyberImageViewer {
|
|
| 534 |
// 渲染缩略图
|
| 535 |
this._renderThumbnails();
|
| 536 |
|
|
|
|
| 537 |
// 加载初始图片
|
| 538 |
this._loadImage(this.currentIndex);
|
| 539 |
|
|
@@ -571,7 +709,7 @@ class CyberImageViewer {
|
|
| 571 |
* 开始自动播放
|
| 572 |
*/
|
| 573 |
_startAutoPlay() {
|
| 574 |
-
if (this._autoPlayEnabled) return;
|
| 575 |
|
| 576 |
console.log('▶️ 自动播放开始');
|
| 577 |
this._autoPlayEnabled = true;
|
|
@@ -580,6 +718,8 @@ class CyberImageViewer {
|
|
| 580 |
// 如果当前没有动画,立即开始第一张图片的计时
|
| 581 |
if (!this.isAnimating) {
|
| 582 |
this._scheduleNextAutoPlay();
|
|
|
|
|
|
|
| 583 |
}
|
| 584 |
}
|
| 585 |
|
|
@@ -591,10 +731,12 @@ class CyberImageViewer {
|
|
| 591 |
|
| 592 |
console.log('⏸️ 自动播放停止');
|
| 593 |
this._autoPlayEnabled = false;
|
|
|
|
|
|
|
| 594 |
|
| 595 |
// 清除定时器
|
| 596 |
if (this._autoPlayTimerId) {
|
| 597 |
-
|
| 598 |
this._autoPlayTimerId = null;
|
| 599 |
}
|
| 600 |
|
|
@@ -632,6 +774,7 @@ class CyberImageViewer {
|
|
| 632 |
* @param {Function} callback - 回调函数,参数为新的自动播放状态
|
| 633 |
*/
|
| 634 |
onAutoPlayStateChange(callback) {
|
|
|
|
| 635 |
this._autoPlayStateChangeCallbacks.push(callback);
|
| 636 |
}
|
| 637 |
|
|
@@ -642,7 +785,7 @@ class CyberImageViewer {
|
|
| 642 |
_scheduleNextAutoPlay() {
|
| 643 |
// 清除之前的定时器
|
| 644 |
if (this._autoPlayTimerId) {
|
| 645 |
-
|
| 646 |
this._autoPlayTimerId = null;
|
| 647 |
}
|
| 648 |
|
|
@@ -652,7 +795,7 @@ class CyberImageViewer {
|
|
| 652 |
|
| 653 |
console.log(`⏱️ 自动播放:等待 ${this._autoPlayInterval}ms 后切换`);
|
| 654 |
|
| 655 |
-
this._autoPlayTimerId =
|
| 656 |
// 检查动画ID守卫
|
| 657 |
if (currentAnimId !== this._animationId) {
|
| 658 |
console.log('⛔ 自动播放定时器:动画已被中断,跳过本次切换');
|
|
@@ -692,7 +835,10 @@ class CyberImageViewer {
|
|
| 692 |
if (!this._autoPlayEnabled) return;
|
| 693 |
|
| 694 |
// 递增动画ID,用于中断检测
|
| 695 |
-
this.
|
|
|
|
|
|
|
|
|
|
| 696 |
const currentAnimId = this._animationId;
|
| 697 |
|
| 698 |
// 计算下一张索引(循环播放)
|
|
@@ -728,7 +874,10 @@ class CyberImageViewer {
|
|
| 728 |
* @param {number} expectedAnimId - 期望的动画ID
|
| 729 |
*/
|
| 730 |
_waitForBlockAnimationAndSchedule(expectedAnimId) {
|
|
|
|
|
|
|
| 731 |
const checkAndSchedule = () => {
|
|
|
|
| 732 |
// 检查动画ID守卫
|
| 733 |
if (expectedAnimId !== this._animationId) {
|
| 734 |
console.log('⛔ 自动播放等待:动画已被中断');
|
|
@@ -743,7 +892,7 @@ class CyberImageViewer {
|
|
| 743 |
|
| 744 |
// 如果动画还在进行,继续等待
|
| 745 |
if (this.isAnimating) {
|
| 746 |
-
|
| 747 |
return;
|
| 748 |
}
|
| 749 |
|
|
@@ -762,6 +911,7 @@ class CyberImageViewer {
|
|
| 762 |
_bindEvents() {
|
| 763 |
// 键盘导航
|
| 764 |
this._keydownHandler = (e) => {
|
|
|
|
| 765 |
if (e.defaultPrevented || e.isComposing || e.keyCode === 229
|
| 766 |
|| e.ctrlKey || e.metaKey || e.altKey || (e.shiftKey && e.key !== 'Tab')) return;
|
| 767 |
if (e.key === 'Tab') {
|
|
@@ -783,29 +933,29 @@ class CyberImageViewer {
|
|
| 783 |
|
| 784 |
}
|
| 785 |
};
|
| 786 |
-
this.
|
| 787 |
|
| 788 |
// 点击导航区域 - 传递点击位置用于波浪效果
|
| 789 |
-
this.elements.navLeft
|
| 790 |
this._stopAutoPlay(); // 手动切换时停止自动播放
|
| 791 |
this.prev(e.clientX, e.clientY);
|
| 792 |
});
|
| 793 |
-
this.elements.navRight
|
| 794 |
this._stopAutoPlay(); // 手动切换时停止自动播放
|
| 795 |
this.next(e.clientX, e.clientY);
|
| 796 |
});
|
| 797 |
|
| 798 |
// 关闭按钮
|
| 799 |
-
this.elements.closeBtn
|
| 800 |
|
| 801 |
// 自动播放按钮
|
| 802 |
-
this.elements.autoPlayBtn
|
| 803 |
|
| 804 |
// 阻止右键菜单
|
| 805 |
-
this.
|
| 806 |
|
| 807 |
// 阻止拖拽
|
| 808 |
-
this.elements.mainImage
|
| 809 |
}
|
| 810 |
|
| 811 |
/**
|
|
@@ -823,7 +973,7 @@ class CyberImageViewer {
|
|
| 823 |
thumb.setAttribute('aria-current', i === this.currentIndex ? 'true' : 'false');
|
| 824 |
// 🔒 XSS防护:图片 URL 来自其他用户发布的内容,写入 innerHTML 前必须转义
|
| 825 |
thumb.innerHTML = `<img src="${escapeHtml(this.imageUrls[i])}" alt="">`;
|
| 826 |
-
|
| 827 |
this._stopAutoPlay(); // 手动切换时停止自动播放
|
| 828 |
this.goTo(i, e.clientX, e.clientY);
|
| 829 |
});
|
|
@@ -854,69 +1004,89 @@ class CyberImageViewer {
|
|
| 854 |
* 加载指定索引的图片
|
| 855 |
*/
|
| 856 |
async _loadImage(index) {
|
| 857 |
-
if (index < 0 || index >= this.imageUrls.length) return;
|
| 858 |
-
|
| 859 |
-
const
|
|
|
|
|
|
|
|
|
|
| 860 |
const img = this.elements.mainImage;
|
| 861 |
-
const
|
| 862 |
-
|
| 863 |
-
|
| 864 |
img.style.transition = 'none';
|
| 865 |
img.style.opacity = '0';
|
| 866 |
img.style.visibility = 'hidden';
|
| 867 |
img.classList.remove('visible');
|
| 868 |
-
loader.style.display = 'flex';
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
img.src = url;
|
| 880 |
-
this._onImageLoaded();
|
| 881 |
-
} catch (err) {
|
| 882 |
-
console.error('图片加载失败');
|
| 883 |
-
this._onImageError();
|
| 884 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 885 |
}
|
| 886 |
-
|
| 887 |
-
// 更新计数器
|
| 888 |
this.elements.counterCurrent.textContent = index + 1;
|
| 889 |
-
|
| 890 |
this._updateThumbnails();
|
| 891 |
-
|
| 892 |
-
// 预加载相邻图片
|
| 893 |
-
this._preloadAdjacent(index);
|
| 894 |
}
|
| 895 |
|
| 896 |
-
|
| 897 |
-
* 异步加载图片
|
| 898 |
-
*/
|
| 899 |
-
_loadImageAsync(url) {
|
| 900 |
return new Promise((resolve, reject) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 901 |
const img = new Image();
|
| 902 |
-
|
| 903 |
-
|
| 904 |
-
|
| 905 |
-
|
| 906 |
-
|
| 907 |
-
|
| 908 |
-
|
| 909 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 910 |
};
|
| 911 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 912 |
});
|
| 913 |
}
|
| 914 |
|
| 915 |
/**
|
| 916 |
* 图片加载完成回调 - 触发方块合并动画
|
| 917 |
*/
|
| 918 |
-
_onImageLoaded() {
|
| 919 |
-
|
|
|
|
|
|
|
|
|
|
| 920 |
this.elements.loader.style.display = 'none';
|
| 921 |
// 确保图片初始隐藏,不要在这里显示图片(同步立即隐藏,无过渡)
|
| 922 |
const img = this.elements.mainImage;
|
|
@@ -941,10 +1111,13 @@ class CyberImageViewer {
|
|
| 941 |
* 使用与粒子消散相同的网格参数和背景图裁切方式
|
| 942 |
*/
|
| 943 |
_playBlockAssemblyAnimation() {
|
|
|
|
|
|
|
| 944 |
const currentAnimId = this._animationId; // 记录当前动画ID
|
| 945 |
const img = this.elements.mainImage;
|
| 946 |
const wrapper = this.elements.wrapper;
|
| 947 |
const imgSrc = img.src;
|
|
|
|
| 948 |
|
| 949 |
// 调试日志
|
| 950 |
console.log('🎬 方块聚合动画开始');
|
|
@@ -1009,8 +1182,7 @@ class CyberImageViewer {
|
|
| 1009 |
block.style.top = `${r * blockSide}px`;
|
| 1010 |
|
| 1011 |
// 使用与粒子消散相同的背景图设置方式
|
| 1012 |
-
|
| 1013 |
-
block.style.backgroundImage = `url("${imgSrc}")`;
|
| 1014 |
block.style.backgroundSize = `${displayWidth}px ${displayHeight}px`;
|
| 1015 |
// 背景定位:根据正方形方块的实际位置计算
|
| 1016 |
const bgPosX = c * blockSide;
|
|
@@ -1056,8 +1228,8 @@ class CyberImageViewer {
|
|
| 1056 |
blocksContainer.getBoundingClientRect();
|
| 1057 |
|
| 1058 |
// 触发方块聚合动画 - 使用双重requestAnimationFrame确保浏览器已渲染初始状态
|
| 1059 |
-
|
| 1060 |
-
|
| 1061 |
// 检查动画是否已被中断
|
| 1062 |
if (currentAnimId !== this._animationId) {
|
| 1063 |
console.log('⛔ 方块聚合动画被中断,跳过动画触发');
|
|
@@ -1078,7 +1250,7 @@ class CyberImageViewer {
|
|
| 1078 |
console.log('⏱️ 动画持续时间:', animationDuration, 'ms');
|
| 1079 |
|
| 1080 |
// 动画完成后显示完整图片并清理方块
|
| 1081 |
-
|
| 1082 |
// 检查动画是否已被中断
|
| 1083 |
if (currentAnimId !== this._animationId) {
|
| 1084 |
// 动画已被中断,只做清理
|
|
@@ -1109,7 +1281,7 @@ class CyberImageViewer {
|
|
| 1109 |
blocksContainer.style.transition = 'opacity 0.15s ease';
|
| 1110 |
|
| 1111 |
// 等待方块淡出完成后移除容器,然后恢复transition
|
| 1112 |
-
|
| 1113 |
// 内层timeout也要检查
|
| 1114 |
if (currentAnimId !== this._animationId) {
|
| 1115 |
if (blocksContainer.parentNode) {
|
|
@@ -1137,10 +1309,12 @@ class CyberImageViewer {
|
|
| 1137 |
* 支持多种路径类型:弧线(90%)、S形(5%)、直线(5%)
|
| 1138 |
*/
|
| 1139 |
async _playParticleDisperseAnimation(clickX, clickY) {
|
|
|
|
| 1140 |
const currentAnimId = this._animationId; // 记录当前动画ID
|
| 1141 |
const img = this.elements.mainImage;
|
| 1142 |
const wrapper = this.elements.wrapper;
|
| 1143 |
const imgSrc = img.src;
|
|
|
|
| 1144 |
|
| 1145 |
console.log('💨 粒子消散动画开始');
|
| 1146 |
|
|
@@ -1257,7 +1431,7 @@ class CyberImageViewer {
|
|
| 1257 |
particleInner = document.createElement('div');
|
| 1258 |
particleInner.style.width = '100%';
|
| 1259 |
particleInner.style.height = '100%';
|
| 1260 |
-
particleInner.style.backgroundImage =
|
| 1261 |
particleInner.style.backgroundSize = `${displayWidth}px ${displayHeight}px`;
|
| 1262 |
particleInner.style.backgroundPosition = `-${c * particleSide}px -${r * particleSide}px`;
|
| 1263 |
particleInner.style.backgroundRepeat = 'no-repeat';
|
|
@@ -1342,7 +1516,7 @@ class CyberImageViewer {
|
|
| 1342 |
particle.style.left = `${c * particleSide}px`;
|
| 1343 |
particle.style.top = `${r * particleSide}px`;
|
| 1344 |
|
| 1345 |
-
particle.style.backgroundImage =
|
| 1346 |
particle.style.backgroundSize = `${displayWidth}px ${displayHeight}px`;
|
| 1347 |
particle.style.backgroundPosition = `-${c * particleSide}px -${r * particleSide}px`;
|
| 1348 |
particle.style.backgroundRepeat = 'no-repeat';
|
|
@@ -1388,8 +1562,8 @@ class CyberImageViewer {
|
|
| 1388 |
particlesContainer.getBoundingClientRect();
|
| 1389 |
|
| 1390 |
// 触发消散动画 - 使用双重requestAnimationFrame
|
| 1391 |
-
|
| 1392 |
-
|
| 1393 |
console.log('🎭 开始粒子消散动画');
|
| 1394 |
particles.forEach(({ element, innerElement, disperseX, disperseY, rotate, pathType }) => {
|
| 1395 |
if (pathType === 'arc' || pathType === 's-curve') {
|
|
@@ -1416,7 +1590,7 @@ class CyberImageViewer {
|
|
| 1416 |
|
| 1417 |
// 等待动画完成
|
| 1418 |
return new Promise(resolve => {
|
| 1419 |
-
|
| 1420 |
console.log('✨ 粒子消散动画完成');
|
| 1421 |
// 无论是否被中断,都要清理粒子容器
|
| 1422 |
if (particlesContainer.parentNode) {
|
|
@@ -1430,7 +1604,7 @@ class CyberImageViewer {
|
|
| 1430 |
img.classList.remove('visible');
|
| 1431 |
}
|
| 1432 |
resolve();
|
| 1433 |
-
}, animationDuration);
|
| 1434 |
});
|
| 1435 |
}
|
| 1436 |
|
|
@@ -1441,35 +1615,51 @@ class CyberImageViewer {
|
|
| 1441 |
this.elements.loader.style.display = 'none';
|
| 1442 |
this.elements.mainImage.src = this._createErrorPlaceholder();
|
| 1443 |
// 错误图片直接显示,不使用方块动画
|
|
|
|
|
|
|
| 1444 |
this.elements.mainImage.classList.add('visible');
|
|
|
|
| 1445 |
}
|
| 1446 |
|
| 1447 |
/**
|
| 1448 |
* 创建错误占位图
|
| 1449 |
*/
|
| 1450 |
_createErrorPlaceholder() {
|
| 1451 |
-
return
|
| 1452 |
}
|
| 1453 |
|
| 1454 |
/**
|
| 1455 |
* 预加载相邻图片
|
| 1456 |
*/
|
| 1457 |
_preloadAdjacent(currentIndex) {
|
| 1458 |
-
|
| 1459 |
-
|
| 1460 |
-
|
| 1461 |
-
|
| 1462 |
-
|
| 1463 |
-
|
| 1464 |
-
|
| 1465 |
-
|
| 1466 |
-
|
| 1467 |
-
img.onload = () => {
|
| 1468 |
-
this.preloadCache.set(url, img);
|
| 1469 |
-
};
|
| 1470 |
-
img.src = url;
|
| 1471 |
}
|
| 1472 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1473 |
}
|
| 1474 |
|
| 1475 |
/**
|
|
@@ -1480,23 +1670,23 @@ class CyberImageViewer {
|
|
| 1480 |
* @param {number} clickY - 点击位置的Y坐标(用于波浪效果)
|
| 1481 |
*/
|
| 1482 |
async goTo(index, clickX, clickY) {
|
|
|
|
| 1483 |
if (index === this.currentIndex) return;
|
| 1484 |
if (index < 0 || index >= this.imageUrls.length) return;
|
| 1485 |
|
| 1486 |
// 递增动画ID,用于中断检测
|
| 1487 |
-
this.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1488 |
const currentAnimId = this._animationId;
|
| 1489 |
|
| 1490 |
// 如果正在动画中,立��清理并跳过载出动画,直接切换到新图片
|
| 1491 |
-
if (
|
|
|
|
| 1492 |
console.log('⚡ 快速切换:跳过载出动画,直接切换到图片索引:', index);
|
| 1493 |
|
| 1494 |
-
// 清理所有动画容器
|
| 1495 |
-
const blocks = this.container.querySelector('.cyber-blocks-container');
|
| 1496 |
-
if (blocks) blocks.remove();
|
| 1497 |
-
const particles = this.container.querySelector('.cyber-particles-container');
|
| 1498 |
-
if (particles) particles.remove();
|
| 1499 |
-
|
| 1500 |
// 立即隐藏当前图片(同步操作,防止旧图闪现)
|
| 1501 |
const img = this.elements.mainImage;
|
| 1502 |
img.style.transition = 'none';
|
|
@@ -1525,7 +1715,7 @@ class CyberImageViewer {
|
|
| 1525 |
if (currentAnimId !== this._animationId) return;
|
| 1526 |
|
| 1527 |
// 2. 载出动画完全结束后,等待0.5秒间隔
|
| 1528 |
-
await new Promise(resolve =>
|
| 1529 |
|
| 1530 |
// 检查是否被新的切换中断
|
| 1531 |
if (currentAnimId !== this._animationId) return;
|
|
@@ -1566,27 +1756,24 @@ class CyberImageViewer {
|
|
| 1566 |
* 关闭查看器
|
| 1567 |
*/
|
| 1568 |
close() {
|
| 1569 |
-
if (this._isClosing) return;
|
| 1570 |
this._isClosing = true;
|
| 1571 |
-
|
| 1572 |
-
this.
|
| 1573 |
-
|
| 1574 |
-
|
| 1575 |
-
|
| 1576 |
-
|
| 1577 |
-
|
| 1578 |
-
|
| 1579 |
-
|
| 1580 |
-
|
| 1581 |
-
|
| 1582 |
-
|
| 1583 |
-
|
| 1584 |
-
|
| 1585 |
-
|
| 1586 |
-
|
| 1587 |
-
if (this._returnFocus?.isConnected && typeof this._returnFocus.focus === 'function') this._returnFocus.focus();
|
| 1588 |
-
}, 300);
|
| 1589 |
-
});
|
| 1590 |
}
|
| 1591 |
}
|
| 1592 |
|
|
@@ -1619,10 +1806,10 @@ export function openImageViewer(imageUrls, startIndex = 0, options = {}) {
|
|
| 1619 |
viewer.elements.closeBtn.focus();
|
| 1620 |
|
| 1621 |
// 添加入场动画
|
| 1622 |
-
|
| 1623 |
container.style.opacity = '0';
|
| 1624 |
container.style.transition = 'opacity 0.3s ease';
|
| 1625 |
-
|
| 1626 |
container.style.opacity = '1';
|
| 1627 |
});
|
| 1628 |
});
|
|
|
|
| 6 |
// 接口:openImageViewer(imageUrls, startIndex, options)
|
| 7 |
// ==========================================
|
| 8 |
|
| 9 |
+
import { escapeHtml, API, IS_WEB_MODE } from "../core/全局配置.js";
|
| 10 |
import { t } from "./用户体验增强.js";
|
| 11 |
|
| 12 |
// ==========================================
|
|
|
|
| 400 |
*/
|
| 401 |
class CyberImageViewer {
|
| 402 |
constructor(imageUrls, startIndex = 0) {
|
| 403 |
+
// 按运行模式处理图片地址
|
| 404 |
this.imageUrls = imageUrls.map(url => this._proxyImageUrl(url));
|
| 405 |
this.currentIndex = Math.max(0, Math.min(startIndex, this.imageUrls.length - 1));
|
| 406 |
this.preloadCache = new Map();
|
| 407 |
+
this._preloadRequests = new Map();
|
| 408 |
+
this._imageReads = new Set();
|
| 409 |
+
this._loadGeneration = 0;
|
| 410 |
+
this._mainLoadController = null;
|
| 411 |
this.isAnimating = false;
|
| 412 |
this.container = null;
|
| 413 |
this.elements = {};
|
|
|
|
| 416 |
// 自动播放状态管理
|
| 417 |
this._autoPlayEnabled = false;
|
| 418 |
this._autoPlayTimerId = null;
|
| 419 |
+
this._autoWaitTimerId = null;
|
| 420 |
this._autoPlayInterval = 1000; // 默认1秒展示时间
|
| 421 |
this._autoPlayStateChangeCallbacks = []; // 状态变化回调
|
| 422 |
this._returnFocus = document.activeElement;
|
| 423 |
+
this._frames = new Map();
|
| 424 |
+
this._timers = new Map();
|
| 425 |
+
this._eventCleanups = [];
|
| 426 |
+
this._removalObserver = null;
|
| 427 |
+
this._wasMounted = false;
|
| 428 |
+
this._disposed = false;
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
_isActive(allowClosing = false) {
|
| 432 |
+
return !this._disposed && (!this._isClosing || allowClosing) && !!this.container?.isConnected;
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
_scheduleFrame(callback, allowClosing = false) {
|
| 436 |
+
if (!this._isActive(allowClosing)) return null;
|
| 437 |
+
const record = {};
|
| 438 |
+
const id = requestAnimationFrame(time => {
|
| 439 |
+
if (this._frames.get(id) !== record) return;
|
| 440 |
+
this._frames.delete(id);
|
| 441 |
+
if (!this._isActive(allowClosing)) {
|
| 442 |
+
if (!this.container?.isConnected) this._dispose();
|
| 443 |
+
return;
|
| 444 |
+
}
|
| 445 |
+
callback(time);
|
| 446 |
+
});
|
| 447 |
+
this._frames.set(id, record);
|
| 448 |
+
return id;
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
_scheduleTimer(callback, delay, onCancel = null, allowClosing = false) {
|
| 452 |
+
if (!this._isActive(allowClosing)) { onCancel?.(); return null; }
|
| 453 |
+
const record = { onCancel };
|
| 454 |
+
const id = setTimeout(() => {
|
| 455 |
+
if (this._timers.get(id) !== record) return;
|
| 456 |
+
this._timers.delete(id);
|
| 457 |
+
if (!this._isActive(allowClosing)) {
|
| 458 |
+
onCancel?.();
|
| 459 |
+
if (!this.container?.isConnected) this._dispose();
|
| 460 |
+
return;
|
| 461 |
+
}
|
| 462 |
+
callback();
|
| 463 |
+
}, delay);
|
| 464 |
+
this._timers.set(id, record);
|
| 465 |
+
return id;
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
_cancelTimer(id) {
|
| 469 |
+
if (id == null) return;
|
| 470 |
+
const record = this._timers.get(id);
|
| 471 |
+
if (!record) return;
|
| 472 |
+
this._timers.delete(id);
|
| 473 |
+
clearTimeout(id);
|
| 474 |
+
record.onCancel?.();
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
_listen(target, type, callback, options) {
|
| 478 |
+
const owned = event => {
|
| 479 |
+
if (!this._isActive()) return;
|
| 480 |
+
this._watchRemoval();
|
| 481 |
+
callback(event);
|
| 482 |
+
};
|
| 483 |
+
target.addEventListener(type, owned, options);
|
| 484 |
+
this._eventCleanups.push(() => target.removeEventListener(type, owned, options));
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
_watchRemoval() {
|
| 488 |
+
if (this._disposed || this._removalObserver) return;
|
| 489 |
+
const container = this.container;
|
| 490 |
+
const inspect = records => {
|
| 491 |
+
if (this._disposed || this._removalObserver !== observer) return;
|
| 492 |
+
if (container.isConnected) { this._wasMounted = true; return; }
|
| 493 |
+
const appeared = records.some(record => [...record.addedNodes, ...record.removedNodes]
|
| 494 |
+
.some(node => node === container || node.contains?.(container)));
|
| 495 |
+
if (this._wasMounted || appeared) this._dispose();
|
| 496 |
+
};
|
| 497 |
+
const observer = new MutationObserver(inspect);
|
| 498 |
+
this._removalObserver = observer;
|
| 499 |
+
observer.observe(document.body, { childList: true, subtree: true });
|
| 500 |
+
queueMicrotask(() => {
|
| 501 |
+
if (this._disposed || this._removalObserver !== observer) return;
|
| 502 |
+
inspect(observer.takeRecords());
|
| 503 |
+
if (!container.isConnected && !this._wasMounted && !this._disposed) {
|
| 504 |
+
observer.disconnect();
|
| 505 |
+
this._removalObserver = null;
|
| 506 |
+
}
|
| 507 |
+
});
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
_cancelAnimationWork() {
|
| 511 |
+
this._animationId++;
|
| 512 |
+
for (const id of this._frames.keys()) cancelAnimationFrame(id);
|
| 513 |
+
this._frames.clear();
|
| 514 |
+
for (const id of [...this._timers.keys()]) this._cancelTimer(id);
|
| 515 |
+
this.container?.querySelectorAll('.cyber-blocks-container, .cyber-particles-container').forEach(node => node.remove());
|
| 516 |
+
this.isAnimating = false;
|
| 517 |
+
this._autoPlayTimerId = null;
|
| 518 |
+
this._autoWaitTimerId = null;
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
_stopOwnedWork() {
|
| 522 |
+
this._loadGeneration++;
|
| 523 |
+
this._mainLoadController?.abort();
|
| 524 |
+
this._mainLoadController = null;
|
| 525 |
+
for (const cancel of [...this._imageReads]) cancel();
|
| 526 |
+
for (const controller of this._preloadRequests.values()) controller.abort();
|
| 527 |
+
this._preloadRequests.clear();
|
| 528 |
+
for (const image of this.preloadCache.values()) image.src = '';
|
| 529 |
+
this.preloadCache.clear();
|
| 530 |
+
const wasPlaying = this._autoPlayEnabled;
|
| 531 |
+
this._autoPlayEnabled = false;
|
| 532 |
+
this._cancelTimer(this._autoPlayTimerId);
|
| 533 |
+
this._autoPlayTimerId = null;
|
| 534 |
+
if (wasPlaying && this.container?.isConnected) {
|
| 535 |
+
try { this._updateAutoPlayUI(); } catch (_) { console.warn('Image viewer playback cleanup failed'); }
|
| 536 |
+
}
|
| 537 |
+
this._cancelAnimationWork();
|
| 538 |
+
for (const cleanup of this._eventCleanups) cleanup();
|
| 539 |
+
this._eventCleanups = [];
|
| 540 |
+
this._keydownHandler = null;
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
_dispose() {
|
| 544 |
+
if (this._disposed) return;
|
| 545 |
+
this._disposed = true;
|
| 546 |
+
this._isClosing = true;
|
| 547 |
+
this._stopOwnedWork();
|
| 548 |
+
this._removalObserver?.disconnect();
|
| 549 |
+
this._removalObserver = null;
|
| 550 |
+
this._returnFocus = null;
|
| 551 |
+
this._autoPlayStateChangeCallbacks = [];
|
| 552 |
+
this.container?.querySelectorAll('img').forEach(image => image.removeAttribute('src'));
|
| 553 |
+
this.imageUrls = [];
|
| 554 |
}
|
| 555 |
|
| 556 |
/**
|
| 557 |
+
* 按本地或网页模式处理图片地址
|
| 558 |
* 自动识别代理URL、data: URL、blob: URL和普通HTTP(S) URL
|
| 559 |
*/
|
| 560 |
_proxyImageUrl(url) {
|
| 561 |
+
if (typeof url !== 'string' || !url) return '';
|
| 562 |
+
const prefix = API.LOCAL_IMAGE_PROXY_PREFIX;
|
| 563 |
+
if (IS_WEB_MODE) {
|
| 564 |
+
let original = url;
|
| 565 |
+
for (let depth = 0; depth < 10 && original.startsWith(prefix); depth++) {
|
| 566 |
+
let encoded = original.slice(prefix.length);
|
| 567 |
+
if (encoded.endsWith(API.COMPLETE_IMAGE_QUERY_SUFFIX)) {
|
| 568 |
+
encoded = encoded.slice(0, -API.COMPLETE_IMAGE_QUERY_SUFFIX.length);
|
| 569 |
+
}
|
| 570 |
+
try { original = decodeURIComponent(encoded); } catch (_) { break; }
|
| 571 |
+
}
|
| 572 |
+
return original;
|
| 573 |
}
|
| 574 |
+
if (url.startsWith('data:') || url.startsWith('blob:') || url.startsWith(prefix)) return url;
|
| 575 |
+
return url.startsWith('http') ? prefix + encodeURIComponent(url) : url;
|
| 576 |
}
|
| 577 |
|
| 578 |
/**
|
|
|
|
| 671 |
// 渲染缩略图
|
| 672 |
this._renderThumbnails();
|
| 673 |
|
| 674 |
+
this._watchRemoval();
|
| 675 |
// 加载初始图片
|
| 676 |
this._loadImage(this.currentIndex);
|
| 677 |
|
|
|
|
| 709 |
* 开始自动播放
|
| 710 |
*/
|
| 711 |
_startAutoPlay() {
|
| 712 |
+
if (!this._isActive() || this._autoPlayEnabled) return;
|
| 713 |
|
| 714 |
console.log('▶️ 自动播放开始');
|
| 715 |
this._autoPlayEnabled = true;
|
|
|
|
| 718 |
// 如果当前没有动画,立即开始第一张图片的计时
|
| 719 |
if (!this.isAnimating) {
|
| 720 |
this._scheduleNextAutoPlay();
|
| 721 |
+
} else {
|
| 722 |
+
this._waitForBlockAnimationAndSchedule(this._animationId);
|
| 723 |
}
|
| 724 |
}
|
| 725 |
|
|
|
|
| 731 |
|
| 732 |
console.log('⏸️ 自动播放停止');
|
| 733 |
this._autoPlayEnabled = false;
|
| 734 |
+
this._cancelTimer(this._autoWaitTimerId);
|
| 735 |
+
this._autoWaitTimerId = null;
|
| 736 |
|
| 737 |
// 清除定时器
|
| 738 |
if (this._autoPlayTimerId) {
|
| 739 |
+
this._cancelTimer(this._autoPlayTimerId);
|
| 740 |
this._autoPlayTimerId = null;
|
| 741 |
}
|
| 742 |
|
|
|
|
| 774 |
* @param {Function} callback - 回调函数,参数为新的自动播放状态
|
| 775 |
*/
|
| 776 |
onAutoPlayStateChange(callback) {
|
| 777 |
+
if (this._isClosing || this._disposed) return;
|
| 778 |
this._autoPlayStateChangeCallbacks.push(callback);
|
| 779 |
}
|
| 780 |
|
|
|
|
| 785 |
_scheduleNextAutoPlay() {
|
| 786 |
// 清除之前的定时器
|
| 787 |
if (this._autoPlayTimerId) {
|
| 788 |
+
this._cancelTimer(this._autoPlayTimerId);
|
| 789 |
this._autoPlayTimerId = null;
|
| 790 |
}
|
| 791 |
|
|
|
|
| 795 |
|
| 796 |
console.log(`⏱️ 自动播放:等待 ${this._autoPlayInterval}ms 后切换`);
|
| 797 |
|
| 798 |
+
this._autoPlayTimerId = this._scheduleTimer(() => {
|
| 799 |
// 检查动画ID守卫
|
| 800 |
if (currentAnimId !== this._animationId) {
|
| 801 |
console.log('⛔ 自动播放定时器:动画已被中断,跳过本次切换');
|
|
|
|
| 835 |
if (!this._autoPlayEnabled) return;
|
| 836 |
|
| 837 |
// 递增动画ID,用于中断检测
|
| 838 |
+
this._cancelAnimationWork();
|
| 839 |
+
this._loadGeneration++;
|
| 840 |
+
this._mainLoadController?.abort();
|
| 841 |
+
this._mainLoadController = null;
|
| 842 |
const currentAnimId = this._animationId;
|
| 843 |
|
| 844 |
// 计算下一张索引(循环播放)
|
|
|
|
| 874 |
* @param {number} expectedAnimId - 期望的动画ID
|
| 875 |
*/
|
| 876 |
_waitForBlockAnimationAndSchedule(expectedAnimId) {
|
| 877 |
+
this._cancelTimer(this._autoWaitTimerId);
|
| 878 |
+
this._autoWaitTimerId = null;
|
| 879 |
const checkAndSchedule = () => {
|
| 880 |
+
this._autoWaitTimerId = null;
|
| 881 |
// 检查动画ID守卫
|
| 882 |
if (expectedAnimId !== this._animationId) {
|
| 883 |
console.log('⛔ 自动播放等待:动画已被中断');
|
|
|
|
| 892 |
|
| 893 |
// 如果动画还在进行,继续等待
|
| 894 |
if (this.isAnimating) {
|
| 895 |
+
this._autoWaitTimerId = this._scheduleTimer(checkAndSchedule, 100);
|
| 896 |
return;
|
| 897 |
}
|
| 898 |
|
|
|
|
| 911 |
_bindEvents() {
|
| 912 |
// 键盘导航
|
| 913 |
this._keydownHandler = (e) => {
|
| 914 |
+
if (!this._isActive()) return;
|
| 915 |
if (e.defaultPrevented || e.isComposing || e.keyCode === 229
|
| 916 |
|| e.ctrlKey || e.metaKey || e.altKey || (e.shiftKey && e.key !== 'Tab')) return;
|
| 917 |
if (e.key === 'Tab') {
|
|
|
|
| 933 |
|
| 934 |
}
|
| 935 |
};
|
| 936 |
+
this._listen(this.container, 'keydown', this._keydownHandler);
|
| 937 |
|
| 938 |
// 点击导航区域 - 传递点击位置用于波浪效果
|
| 939 |
+
this._listen(this.elements.navLeft, 'click', (e) => {
|
| 940 |
this._stopAutoPlay(); // 手动切换时停止自动播放
|
| 941 |
this.prev(e.clientX, e.clientY);
|
| 942 |
});
|
| 943 |
+
this._listen(this.elements.navRight, 'click', (e) => {
|
| 944 |
this._stopAutoPlay(); // 手动切换时停止自动播放
|
| 945 |
this.next(e.clientX, e.clientY);
|
| 946 |
});
|
| 947 |
|
| 948 |
// 关闭按钮
|
| 949 |
+
this._listen(this.elements.closeBtn, 'click', () => this.close());
|
| 950 |
|
| 951 |
// 自动播放按钮
|
| 952 |
+
this._listen(this.elements.autoPlayBtn, 'click', () => this.toggleAutoPlay());
|
| 953 |
|
| 954 |
// 阻止右键菜单
|
| 955 |
+
this._listen(this.container, 'contextmenu', (e) => e.preventDefault());
|
| 956 |
|
| 957 |
// 阻止拖拽
|
| 958 |
+
this._listen(this.elements.mainImage, 'dragstart', (e) => e.preventDefault());
|
| 959 |
}
|
| 960 |
|
| 961 |
/**
|
|
|
|
| 973 |
thumb.setAttribute('aria-current', i === this.currentIndex ? 'true' : 'false');
|
| 974 |
// 🔒 XSS防护:图片 URL 来自其他用户发布的内容,写入 innerHTML 前必须转义
|
| 975 |
thumb.innerHTML = `<img src="${escapeHtml(this.imageUrls[i])}" alt="">`;
|
| 976 |
+
this._listen(thumb, 'click', (e) => {
|
| 977 |
this._stopAutoPlay(); // 手动切换时停止自动播放
|
| 978 |
this.goTo(i, e.clientX, e.clientY);
|
| 979 |
});
|
|
|
|
| 1004 |
* 加载指定索引的图片
|
| 1005 |
*/
|
| 1006 |
async _loadImage(index) {
|
| 1007 |
+
if (this._isClosing || index < 0 || index >= this.imageUrls.length) return;
|
| 1008 |
+
this._mainLoadController?.abort();
|
| 1009 |
+
const controller = new AbortController();
|
| 1010 |
+
this._mainLoadController = controller;
|
| 1011 |
+
const generation = ++this._loadGeneration;
|
| 1012 |
+
const container = this.container;
|
| 1013 |
const img = this.elements.mainImage;
|
| 1014 |
+
const url = this.imageUrls[index];
|
| 1015 |
+
const current = () => !this._isClosing && generation === this._loadGeneration &&
|
| 1016 |
+
this.container === container && this.elements.mainImage === img;
|
| 1017 |
img.style.transition = 'none';
|
| 1018 |
img.style.opacity = '0';
|
| 1019 |
img.style.visibility = 'hidden';
|
| 1020 |
img.classList.remove('visible');
|
| 1021 |
+
this.elements.loader.style.display = 'flex';
|
| 1022 |
+
let loaded = false;
|
| 1023 |
+
try {
|
| 1024 |
+
if (this.preloadCache.has(url)) {
|
| 1025 |
+
if (!container.isConnected) return;
|
| 1026 |
+
img.src = this.preloadCache.get(url).src;
|
| 1027 |
+
this._onImageLoaded(generation);
|
| 1028 |
+
} else {
|
| 1029 |
+
await this._loadImageAsync(url, { signal: controller.signal });
|
| 1030 |
+
if (!current() || !container.isConnected) return;
|
| 1031 |
+
this._watchRemoval();
|
| 1032 |
img.src = url;
|
| 1033 |
+
this._onImageLoaded(generation);
|
|
|
|
|
|
|
|
|
|
| 1034 |
}
|
| 1035 |
+
loaded = true;
|
| 1036 |
+
} catch (error) {
|
| 1037 |
+
if (!current() || !container.isConnected || controller.signal.aborted) return;
|
| 1038 |
+
this._watchRemoval();
|
| 1039 |
+
console.error('图片加载失败');
|
| 1040 |
+
this._onImageError();
|
| 1041 |
+
} finally {
|
| 1042 |
+
if (this._mainLoadController === controller) this._mainLoadController = null;
|
| 1043 |
}
|
| 1044 |
+
if (!current()) return;
|
|
|
|
| 1045 |
this.elements.counterCurrent.textContent = index + 1;
|
| 1046 |
+
img.alt = t('image.viewer.image_position', { current: index + 1, total: this.imageUrls.length });
|
| 1047 |
this._updateThumbnails();
|
| 1048 |
+
if (loaded) this._preloadAdjacent(index);
|
|
|
|
|
|
|
| 1049 |
}
|
| 1050 |
|
| 1051 |
+
_loadImageAsync(url, { signal } = {}) {
|
|
|
|
|
|
|
|
|
|
| 1052 |
return new Promise((resolve, reject) => {
|
| 1053 |
+
if (this._isClosing || signal?.aborted) {
|
| 1054 |
+
reject(new DOMException('Image read cancelled', 'AbortError'));
|
| 1055 |
+
return;
|
| 1056 |
+
}
|
| 1057 |
const img = new Image();
|
| 1058 |
+
let settled = false;
|
| 1059 |
+
let timer = null;
|
| 1060 |
+
const finish = error => {
|
| 1061 |
+
if (settled) return;
|
| 1062 |
+
settled = true;
|
| 1063 |
+
if (timer !== null) clearTimeout(timer);
|
| 1064 |
+
img.onload = img.onerror = null;
|
| 1065 |
+
signal?.removeEventListener('abort', cancel);
|
| 1066 |
+
this._imageReads.delete(cancel);
|
| 1067 |
+
if (error) {
|
| 1068 |
+
img.src = '';
|
| 1069 |
+
reject(error);
|
| 1070 |
+
} else resolve(img);
|
| 1071 |
};
|
| 1072 |
+
const cancel = () => finish(new DOMException('Image read cancelled', 'AbortError'));
|
| 1073 |
+
this._imageReads.add(cancel);
|
| 1074 |
+
signal?.addEventListener('abort', cancel, { once: true });
|
| 1075 |
+
img.onload = () => finish(null);
|
| 1076 |
+
img.onerror = () => finish(new Error('Image read failed'));
|
| 1077 |
+
timer = setTimeout(() => finish(new Error('Image read timed out')), API.TIMEOUT);
|
| 1078 |
+
try { img.src = url; } catch (error) { finish(error); }
|
| 1079 |
});
|
| 1080 |
}
|
| 1081 |
|
| 1082 |
/**
|
| 1083 |
* 图片加载完成回调 - 触发方块合并动画
|
| 1084 |
*/
|
| 1085 |
+
_onImageLoaded(generation = this._loadGeneration) {
|
| 1086 |
+
const container = this.container;
|
| 1087 |
+
this._scheduleFrame(() => {
|
| 1088 |
+
if (this._isClosing || generation !== this._loadGeneration ||
|
| 1089 |
+
this.container !== container || !container.isConnected) return;
|
| 1090 |
this.elements.loader.style.display = 'none';
|
| 1091 |
// 确保图片初始隐藏,不要在这里显示图片(同步立即隐藏,无过渡)
|
| 1092 |
const img = this.elements.mainImage;
|
|
|
|
| 1111 |
* 使用与粒子消散相同的网格参数和背景图裁切方式
|
| 1112 |
*/
|
| 1113 |
_playBlockAssemblyAnimation() {
|
| 1114 |
+
if (!this._isActive()) return;
|
| 1115 |
+
this.isAnimating = true;
|
| 1116 |
const currentAnimId = this._animationId; // 记录当前动画ID
|
| 1117 |
const img = this.elements.mainImage;
|
| 1118 |
const wrapper = this.elements.wrapper;
|
| 1119 |
const imgSrc = img.src;
|
| 1120 |
+
const imageBackground = `url(${JSON.stringify(imgSrc)})`;
|
| 1121 |
|
| 1122 |
// 调试日志
|
| 1123 |
console.log('🎬 方块聚合动画开始');
|
|
|
|
| 1182 |
block.style.top = `${r * blockSide}px`;
|
| 1183 |
|
| 1184 |
// 使用与粒子消散相同的背景图设置方式
|
| 1185 |
+
block.style.backgroundImage = imageBackground;
|
|
|
|
| 1186 |
block.style.backgroundSize = `${displayWidth}px ${displayHeight}px`;
|
| 1187 |
// 背景定位:根据正方形方块的实际位置计算
|
| 1188 |
const bgPosX = c * blockSide;
|
|
|
|
| 1228 |
blocksContainer.getBoundingClientRect();
|
| 1229 |
|
| 1230 |
// 触发方块聚合动画 - 使用双重requestAnimationFrame确保浏览器已渲染初始状态
|
| 1231 |
+
this._scheduleFrame(() => {
|
| 1232 |
+
this._scheduleFrame(() => {
|
| 1233 |
// 检查动画是否已被中断
|
| 1234 |
if (currentAnimId !== this._animationId) {
|
| 1235 |
console.log('⛔ 方块聚合动画被中断,跳过动画触发');
|
|
|
|
| 1250 |
console.log('⏱️ 动画持续时间:', animationDuration, 'ms');
|
| 1251 |
|
| 1252 |
// 动画完成后显示完整图片并清理方块
|
| 1253 |
+
this._scheduleTimer(() => {
|
| 1254 |
// 检查动画是否已被中断
|
| 1255 |
if (currentAnimId !== this._animationId) {
|
| 1256 |
// 动画已被中断,只做清理
|
|
|
|
| 1281 |
blocksContainer.style.transition = 'opacity 0.15s ease';
|
| 1282 |
|
| 1283 |
// 等待方块淡出完成后移除容器,然后恢复transition
|
| 1284 |
+
this._scheduleTimer(() => {
|
| 1285 |
// 内层timeout也要检查
|
| 1286 |
if (currentAnimId !== this._animationId) {
|
| 1287 |
if (blocksContainer.parentNode) {
|
|
|
|
| 1309 |
* 支持多种路径类型:弧线(90%)、S形(5%)、直线(5%)
|
| 1310 |
*/
|
| 1311 |
async _playParticleDisperseAnimation(clickX, clickY) {
|
| 1312 |
+
if (!this._isActive()) return;
|
| 1313 |
const currentAnimId = this._animationId; // 记录当前动画ID
|
| 1314 |
const img = this.elements.mainImage;
|
| 1315 |
const wrapper = this.elements.wrapper;
|
| 1316 |
const imgSrc = img.src;
|
| 1317 |
+
const imageBackground = `url(${JSON.stringify(imgSrc)})`;
|
| 1318 |
|
| 1319 |
console.log('💨 粒子消散动画开始');
|
| 1320 |
|
|
|
|
| 1431 |
particleInner = document.createElement('div');
|
| 1432 |
particleInner.style.width = '100%';
|
| 1433 |
particleInner.style.height = '100%';
|
| 1434 |
+
particleInner.style.backgroundImage = imageBackground;
|
| 1435 |
particleInner.style.backgroundSize = `${displayWidth}px ${displayHeight}px`;
|
| 1436 |
particleInner.style.backgroundPosition = `-${c * particleSide}px -${r * particleSide}px`;
|
| 1437 |
particleInner.style.backgroundRepeat = 'no-repeat';
|
|
|
|
| 1516 |
particle.style.left = `${c * particleSide}px`;
|
| 1517 |
particle.style.top = `${r * particleSide}px`;
|
| 1518 |
|
| 1519 |
+
particle.style.backgroundImage = imageBackground;
|
| 1520 |
particle.style.backgroundSize = `${displayWidth}px ${displayHeight}px`;
|
| 1521 |
particle.style.backgroundPosition = `-${c * particleSide}px -${r * particleSide}px`;
|
| 1522 |
particle.style.backgroundRepeat = 'no-repeat';
|
|
|
|
| 1562 |
particlesContainer.getBoundingClientRect();
|
| 1563 |
|
| 1564 |
// 触发消散动画 - 使用双重requestAnimationFrame
|
| 1565 |
+
this._scheduleFrame(() => {
|
| 1566 |
+
this._scheduleFrame(() => {
|
| 1567 |
console.log('🎭 开始粒子消散动画');
|
| 1568 |
particles.forEach(({ element, innerElement, disperseX, disperseY, rotate, pathType }) => {
|
| 1569 |
if (pathType === 'arc' || pathType === 's-curve') {
|
|
|
|
| 1590 |
|
| 1591 |
// 等待动画完成
|
| 1592 |
return new Promise(resolve => {
|
| 1593 |
+
this._scheduleTimer(() => {
|
| 1594 |
console.log('✨ 粒子消散动画完成');
|
| 1595 |
// 无论是否被中断,都要清理粒子容器
|
| 1596 |
if (particlesContainer.parentNode) {
|
|
|
|
| 1604 |
img.classList.remove('visible');
|
| 1605 |
}
|
| 1606 |
resolve();
|
| 1607 |
+
}, animationDuration, resolve);
|
| 1608 |
});
|
| 1609 |
}
|
| 1610 |
|
|
|
|
| 1615 |
this.elements.loader.style.display = 'none';
|
| 1616 |
this.elements.mainImage.src = this._createErrorPlaceholder();
|
| 1617 |
// 错误图片直接显示,不使用方块动画
|
| 1618 |
+
this.elements.mainImage.style.visibility = 'visible';
|
| 1619 |
+
this.elements.mainImage.style.opacity = '1';
|
| 1620 |
this.elements.mainImage.classList.add('visible');
|
| 1621 |
+
this.isAnimating = false;
|
| 1622 |
}
|
| 1623 |
|
| 1624 |
/**
|
| 1625 |
* 创建错误占位图
|
| 1626 |
*/
|
| 1627 |
_createErrorPlaceholder() {
|
| 1628 |
+
return 'data:image/svg+xml,' + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300"><rect fill="#1a1a1a" width="400" height="300"/><text x="200" y="140" text-anchor="middle" fill="#ff6b6b" font-size="48">⚠️</text><text x="200" y="180" text-anchor="middle" fill="#888" font-size="14">${escapeHtml(t('image.load_failed'))}</text></svg>`);
|
| 1629 |
}
|
| 1630 |
|
| 1631 |
/**
|
| 1632 |
* 预加载相邻图片
|
| 1633 |
*/
|
| 1634 |
_preloadAdjacent(currentIndex) {
|
| 1635 |
+
if (this._isClosing || !this.container?.isConnected) return;
|
| 1636 |
+
const wanted = new Set([currentIndex - 1, currentIndex + 1]
|
| 1637 |
+
.filter(index => index >= 0 && index < this.imageUrls.length)
|
| 1638 |
+
.map(index => this.imageUrls[index]));
|
| 1639 |
+
const retained = new Set([...wanted, this.imageUrls[currentIndex]]);
|
| 1640 |
+
for (const [url, image] of this.preloadCache) {
|
| 1641 |
+
if (!retained.has(url)) {
|
| 1642 |
+
image.src = '';
|
| 1643 |
+
this.preloadCache.delete(url);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1644 |
}
|
| 1645 |
+
}
|
| 1646 |
+
for (const [url, controller] of this._preloadRequests) {
|
| 1647 |
+
if (!wanted.has(url)) {
|
| 1648 |
+
controller.abort();
|
| 1649 |
+
this._preloadRequests.delete(url);
|
| 1650 |
+
}
|
| 1651 |
+
}
|
| 1652 |
+
for (const url of wanted) {
|
| 1653 |
+
if (this.preloadCache.has(url) || this._preloadRequests.has(url)) continue;
|
| 1654 |
+
const controller = new AbortController();
|
| 1655 |
+
this._preloadRequests.set(url, controller);
|
| 1656 |
+
this._loadImageAsync(url, { signal: controller.signal }).then(img => {
|
| 1657 |
+
if (!this._isClosing && this.container?.isConnected && !controller.signal.aborted &&
|
| 1658 |
+
this._preloadRequests.get(url) === controller) this.preloadCache.set(url, img);
|
| 1659 |
+
}).catch(() => {}).finally(() => {
|
| 1660 |
+
if (this._preloadRequests.get(url) === controller) this._preloadRequests.delete(url);
|
| 1661 |
+
});
|
| 1662 |
+
}
|
| 1663 |
}
|
| 1664 |
|
| 1665 |
/**
|
|
|
|
| 1670 |
* @param {number} clickY - 点击位置的Y坐标(用于波浪效果)
|
| 1671 |
*/
|
| 1672 |
async goTo(index, clickX, clickY) {
|
| 1673 |
+
if (this._isClosing || !this.container?.isConnected) return;
|
| 1674 |
if (index === this.currentIndex) return;
|
| 1675 |
if (index < 0 || index >= this.imageUrls.length) return;
|
| 1676 |
|
| 1677 |
// 递增动画ID,用于中断检测
|
| 1678 |
+
const interrupted = this.isAnimating;
|
| 1679 |
+
this._cancelAnimationWork();
|
| 1680 |
+
this._loadGeneration++;
|
| 1681 |
+
this._mainLoadController?.abort();
|
| 1682 |
+
this._mainLoadController = null;
|
| 1683 |
const currentAnimId = this._animationId;
|
| 1684 |
|
| 1685 |
// 如果正在动画中,立��清理并跳过载出动画,直接切换到新图片
|
| 1686 |
+
if (interrupted) {
|
| 1687 |
+
this.isAnimating = true;
|
| 1688 |
console.log('⚡ 快速切换:跳过载出动画,直接切换到图片索引:', index);
|
| 1689 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1690 |
// 立即隐藏当前图片(同步操作,防止旧图闪现)
|
| 1691 |
const img = this.elements.mainImage;
|
| 1692 |
img.style.transition = 'none';
|
|
|
|
| 1715 |
if (currentAnimId !== this._animationId) return;
|
| 1716 |
|
| 1717 |
// 2. 载出动画完全结束后,等待0.5秒间隔
|
| 1718 |
+
await new Promise(resolve => this._scheduleTimer(resolve, 250, resolve));
|
| 1719 |
|
| 1720 |
// 检查是否被新的切换中断
|
| 1721 |
if (currentAnimId !== this._animationId) return;
|
|
|
|
| 1756 |
* 关闭查看器
|
| 1757 |
*/
|
| 1758 |
close() {
|
| 1759 |
+
if (this._isClosing || this._disposed) return;
|
| 1760 |
this._isClosing = true;
|
| 1761 |
+
this._stopOwnedWork();
|
| 1762 |
+
if (!this.container?.isConnected) { this._dispose(); return; }
|
| 1763 |
+
const container = this.container;
|
| 1764 |
+
container.style.opacity = '1';
|
| 1765 |
+
container.style.transition = 'opacity 0.3s ease';
|
| 1766 |
+
this._scheduleFrame(() => {
|
| 1767 |
+
container.style.opacity = '0';
|
| 1768 |
+
this._scheduleTimer(() => {
|
| 1769 |
+
const restore = container.contains(document.activeElement);
|
| 1770 |
+
const previousFocus = this._returnFocus;
|
| 1771 |
+
container.remove();
|
| 1772 |
+
this._dispose();
|
| 1773 |
+
if (restore && document.activeElement === document.body && previousFocus?.isConnected &&
|
| 1774 |
+
typeof previousFocus.focus === 'function') previousFocus.focus();
|
| 1775 |
+
}, 300, null, true);
|
| 1776 |
+
}, true);
|
|
|
|
|
|
|
|
|
|
| 1777 |
}
|
| 1778 |
}
|
| 1779 |
|
|
|
|
| 1806 |
viewer.elements.closeBtn.focus();
|
| 1807 |
|
| 1808 |
// 添加入场动画
|
| 1809 |
+
viewer._scheduleFrame(() => {
|
| 1810 |
container.style.opacity = '0';
|
| 1811 |
container.style.transition = 'opacity 0.3s ease';
|
| 1812 |
+
viewer._scheduleFrame(() => {
|
| 1813 |
container.style.opacity = '1';
|
| 1814 |
});
|
| 1815 |
});
|
前端页面/components/图片沙盒组件.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
| 12 |
// ==========================================
|
| 13 |
|
| 14 |
import { t } from "./用户体验增强.js";
|
| 15 |
-
import { escapeHtml } from "../core/全局配置.js";
|
| 16 |
import { openImageViewer } from "./图片展示测试.js";
|
| 17 |
|
| 18 |
// 🔧 通用SVG占位图生成函数
|
|
@@ -34,25 +34,31 @@ function getErrorSVG() { return createErrorSVG(t('image.load_failed')); }
|
|
| 34 |
function getTimeoutSVG() { return createTimeoutSVG(t('image.load_timeout')); }
|
| 35 |
|
| 36 |
// 🔧 P3优化:带超时机制的图片加载器
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
function loadImageWithTimeout(url, timeout = IMAGE_LOAD_TIMEOUT) {
|
| 40 |
return new Promise((resolve, reject) => {
|
| 41 |
-
|
| 42 |
-
const
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
clearTimeout(timer);
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
reject(new Error('图片加载失败'));
|
| 54 |
};
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
});
|
| 57 |
}
|
| 58 |
|
|
@@ -65,8 +71,8 @@ export function getCoverSandboxHTML(imageSource, lazyLoad = true) {
|
|
| 65 |
// 兼容:接受单个URL或数组
|
| 66 |
let imageUrls = [];
|
| 67 |
if (Array.isArray(imageSource)) {
|
| 68 |
-
imageUrls = imageSource.filter(url => url && url.trim());
|
| 69 |
-
} else if (imageSource && imageSource.trim()) {
|
| 70 |
imageUrls = [imageSource];
|
| 71 |
}
|
| 72 |
|
|
@@ -75,7 +81,7 @@ export function getCoverSandboxHTML(imageSource, lazyLoad = true) {
|
|
| 75 |
const firstUrl = imageUrls[0];
|
| 76 |
|
| 77 |
// 🚀 本地代理URL已缓存,无需懒加载(从磁盘读取几乎瞬时)
|
| 78 |
-
const isLocalProxy = firstUrl && firstUrl.startsWith(
|
| 79 |
const shouldLazy = lazyLoad && !isLocalProxy;
|
| 80 |
|
| 81 |
// 🔒 XSS防护:图片 URL 来自其他用户发布的内容,写入 HTML 属性前必须转义
|
|
@@ -86,36 +92,26 @@ export function getCoverSandboxHTML(imageSource, lazyLoad = true) {
|
|
| 86 |
|
| 87 |
// 🖼️ 多图模式:显示左右切换按钮和计数器
|
| 88 |
const hasMultiple = imageUrls.length > 1;
|
|
|
|
| 89 |
const navButtons = hasMultiple ? `
|
| 90 |
-
<button
|
| 91 |
-
<button
|
| 92 |
-
<div class="img-counter"
|
| 93 |
` : '';
|
| 94 |
-
|
| 95 |
-
// 🔒 图片保护:禁用右键菜单、拖拽
|
| 96 |
-
const protectionStyles = `
|
| 97 |
-
-webkit-user-select: none;
|
| 98 |
-
-moz-user-select: none;
|
| 99 |
-
-ms-user-select: none;
|
| 100 |
-
user-select: none;
|
| 101 |
-
pointer-events: none;
|
| 102 |
-
-webkit-user-drag: none;
|
| 103 |
-
`;
|
| 104 |
-
|
| 105 |
return `
|
| 106 |
-
<div
|
| 107 |
-
<div class="img-viewport" data-images='${escapeHtml(JSON.stringify(imageUrls))}' data-current="0"
|
| 108 |
-
<img class="target-img ${lazyClass}" src="${escapeHtml(imgSrc)}" ${dataAttr} draggable="false"
|
| 109 |
${navButtons}
|
| 110 |
-
<div
|
| 111 |
-
<button class="btn-zoom-out"
|
| 112 |
-
<button class="btn-zoom-reset"
|
| 113 |
-
<button class="btn-zoom-in"
|
| 114 |
-
<button
|
| 115 |
</div>
|
| 116 |
-
<div class="sliders-wrapper"
|
| 117 |
-
<input type="range" class="slider-x" min="-400" max="400" value="0"
|
| 118 |
-
<input type="range" class="slider-y" min="-400" max="400" value="0"
|
| 119 |
</div>
|
| 120 |
</div>
|
| 121 |
`;
|
|
@@ -125,201 +121,192 @@ export function getCoverSandboxHTML(imageSource, lazyLoad = true) {
|
|
| 125 |
* 🖼️ 设置图片沙盒事件(轮播、缩放、拖动)
|
| 126 |
*/
|
| 127 |
export function setupImageSandboxEvents(detailView) {
|
| 128 |
-
const viewport = detailView.querySelector('.img-viewport');
|
| 129 |
if (!viewport) return;
|
| 130 |
-
|
| 131 |
-
const targetImg =
|
| 132 |
-
const btnIn =
|
| 133 |
-
const btnOut =
|
| 134 |
-
const btnReset =
|
| 135 |
-
const sliderX =
|
| 136 |
-
const sliderY =
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
let
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
currentIndex = index;
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
targetImg.classList.add('lazy-loading');
|
| 162 |
targetImg.src = getPlaceholderSVG();
|
| 163 |
-
|
| 164 |
try {
|
| 165 |
-
await loadImageWithTimeout(url);
|
|
|
|
|
|
|
| 166 |
targetImg.src = url;
|
| 167 |
-
targetImg.classList.remove('lazy-loading');
|
| 168 |
targetImg.classList.add('lazy-loaded');
|
| 169 |
targetImg.style.opacity = '1';
|
| 170 |
-
} catch (
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
targetImg.
|
|
|
|
| 174 |
targetImg.classList.add('lazy-error');
|
|
|
|
|
|
|
| 175 |
}
|
| 176 |
-
|
| 177 |
-
// 更新计数器
|
| 178 |
-
if (imgCounter) {
|
| 179 |
-
imgCounter.textContent = `${index + 1} / ${imageUrls.length}`;
|
| 180 |
-
}
|
| 181 |
-
|
| 182 |
-
// 重置缩放和位置
|
| 183 |
-
scale = 1;
|
| 184 |
-
sliderX.value = 0;
|
| 185 |
-
sliderY.value = 0;
|
| 186 |
-
syncTransform();
|
| 187 |
};
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
let scale = 1.0;
|
| 220 |
-
const syncTransform = () => {
|
| 221 |
-
targetImg.style.transform = `scale(${scale}) translate(${sliderX.value}px, ${sliderY.value}px)`;
|
| 222 |
-
};
|
| 223 |
-
|
| 224 |
-
const zoom = (delta) => {
|
| 225 |
-
scale = Math.max(0.5, Math.min(5.0, scale + delta));
|
| 226 |
-
syncTransform();
|
| 227 |
-
};
|
| 228 |
-
|
| 229 |
-
btnIn.onclick = () => zoom(0.2);
|
| 230 |
-
btnOut.onclick = () => zoom(-0.2);
|
| 231 |
-
btnReset.onclick = () => { scale = 1; sliderX.value = 0; sliderY.value = 0; syncTransform(); };
|
| 232 |
-
|
| 233 |
-
// ⛶ 全屏播放按钮事件
|
| 234 |
-
const btnFullscreen = viewport.querySelector('.btn-fullscreen');
|
| 235 |
-
if (btnFullscreen) {
|
| 236 |
-
btnFullscreen.onclick = (e) => {
|
| 237 |
-
e.stopPropagation();
|
| 238 |
-
|
| 239 |
-
// 收集当前viewport的所有图片URL
|
| 240 |
-
const dataImages = viewport.getAttribute('data-images');
|
| 241 |
-
let imageUrls = [];
|
| 242 |
-
|
| 243 |
-
if (dataImages) {
|
| 244 |
-
try {
|
| 245 |
-
imageUrls = JSON.parse(dataImages);
|
| 246 |
-
} catch (e) {
|
| 247 |
-
// 单图模式
|
| 248 |
-
const img = viewport.querySelector('.target-img');
|
| 249 |
-
if (img && img.src) {
|
| 250 |
-
imageUrls = [img.src];
|
| 251 |
-
}
|
| 252 |
-
}
|
| 253 |
-
} else {
|
| 254 |
-
// 无data-images属性,取当前img的src
|
| 255 |
-
const img = viewport.querySelector('.target-img');
|
| 256 |
-
if (img && img.src) {
|
| 257 |
-
imageUrls = [img.src];
|
| 258 |
-
}
|
| 259 |
-
}
|
| 260 |
-
|
| 261 |
-
// 获取当前图片索引
|
| 262 |
-
const currentIndex = parseInt(viewport.getAttribute('data-current') || '0', 10);
|
| 263 |
-
|
| 264 |
-
// 🔍 调试日志:输出收集到的图片URL
|
| 265 |
-
console.log('🖼️ 全屏播放图片:', '图片数量:', imageUrls.length, '当前索引:', currentIndex);
|
| 266 |
-
|
| 267 |
-
if (imageUrls.length > 0) {
|
| 268 |
-
// 打开全屏图像播放器(使用静态import,避免动态import路径问题)
|
| 269 |
-
try {
|
| 270 |
-
openImageViewer(imageUrls, currentIndex, { opaque: true });
|
| 271 |
-
} catch (err) {
|
| 272 |
-
console.error('无法打开图像播放器');
|
| 273 |
-
// 显示用户可见的错误提示
|
| 274 |
-
import('./UI交互提示组件.js').then(module => {
|
| 275 |
-
module.showToast(t('image.viewer.load_failed'), 'error');
|
| 276 |
-
}).catch(() => {
|
| 277 |
-
// 动态import也失败时,创建简单的内联提示
|
| 278 |
-
const fallback = document.createElement('div');
|
| 279 |
-
fallback.textContent = t('image.viewer.load_failed');
|
| 280 |
-
Object.assign(fallback.style, { position: 'fixed', top: '50px', left: '50%', transform: 'translateX(-50%)', background: '#F44336', color: '#fff', padding: '10px 20px', borderRadius: '8px', zIndex: '10000', fontSize: '14px' });
|
| 281 |
-
document.body.appendChild(fallback);
|
| 282 |
-
setTimeout(() => fallback.remove(), 3000);
|
| 283 |
-
});
|
| 284 |
-
}
|
| 285 |
-
} else {
|
| 286 |
-
console.warn('⚠️ 没有可播放的图片URL');
|
| 287 |
-
}
|
| 288 |
-
};
|
| 289 |
-
}
|
| 290 |
-
|
| 291 |
-
viewport.addEventListener('wheel', (e) => { e.preventDefault(); zoom(e.deltaY > 0 ? -0.1 : 0.1); });
|
| 292 |
-
sliderX.addEventListener('input', syncTransform);
|
| 293 |
-
sliderY.addEventListener('input', syncTransform);
|
| 294 |
-
|
| 295 |
-
// ✨ 拖动功能(统一管理事件监听器)
|
| 296 |
-
let isDragging = false; let startX, startY, initValX, initValY;
|
| 297 |
-
|
| 298 |
-
// 🔧 命名事件处理函数,便于清理
|
| 299 |
-
const handleMouseMove = (e) => {
|
| 300 |
-
if (!isDragging) return;
|
| 301 |
-
const dx = (e.clientX - startX) / scale; const dy = (e.clientY - startY) / scale;
|
| 302 |
-
sliderX.value = initValX + dx; sliderY.value = initValY + dy; syncTransform();
|
| 303 |
};
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
if(e.target.tagName.toLowerCase() === 'input' || e.target.tagName.toLowerCase() === 'button') return;
|
| 309 |
-
e.preventDefault(); // 阻止浏览器默认的文本/图片选中行为
|
| 310 |
-
isDragging = true; viewport.style.cursor = 'grabbing';
|
| 311 |
-
startX = e.clientX; startY = e.clientY; initValX = parseInt(sliderX.value); initValY = parseInt(sliderY.value);
|
| 312 |
});
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
window.removeEventListener('mousemove', handleMouseMove);
|
| 320 |
-
window.removeEventListener('mouseup', handleMouseUp);
|
| 321 |
};
|
| 322 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
}
|
| 324 |
|
| 325 |
/**
|
|
@@ -327,9 +314,7 @@ export function setupImageSandboxEvents(detailView) {
|
|
| 327 |
* @param {HTMLElement} detailView - 详情视图容器
|
| 328 |
*/
|
| 329 |
export function cleanupImageSandbox(detailView) {
|
| 330 |
-
const
|
| 331 |
-
if (viewport
|
| 332 |
-
|
| 333 |
-
viewport._sandboxCleanup = null;
|
| 334 |
-
}
|
| 335 |
}
|
|
|
|
| 12 |
// ==========================================
|
| 13 |
|
| 14 |
import { t } from "./用户体验增强.js";
|
| 15 |
+
import { escapeHtml, API, UI } from "../core/全局配置.js";
|
| 16 |
import { openImageViewer } from "./图片展示测试.js";
|
| 17 |
|
| 18 |
// 🔧 通用SVG占位图生成函数
|
|
|
|
| 34 |
function getTimeoutSVG() { return createTimeoutSVG(t('image.load_timeout')); }
|
| 35 |
|
| 36 |
// 🔧 P3优化:带超时机制的图片加载器
|
| 37 |
+
function loadImageWithTimeout(url, signal) {
|
|
|
|
|
|
|
| 38 |
return new Promise((resolve, reject) => {
|
| 39 |
+
if (signal.aborted) { reject(new DOMException('Image read cancelled', 'AbortError')); return; }
|
| 40 |
+
const image = new Image();
|
| 41 |
+
let settled = false;
|
| 42 |
+
let timer = null;
|
| 43 |
+
const finish = error => {
|
| 44 |
+
if (settled) return;
|
| 45 |
+
settled = true;
|
| 46 |
+
if (timer !== null) clearTimeout(timer);
|
| 47 |
+
image.onload = image.onerror = null;
|
| 48 |
+
signal.removeEventListener('abort', cancel);
|
| 49 |
+
if (error) { image.src = ''; reject(error); }
|
| 50 |
+
else resolve(image);
|
|
|
|
| 51 |
};
|
| 52 |
+
const cancel = () => finish(new DOMException('Image read cancelled', 'AbortError'));
|
| 53 |
+
signal.addEventListener('abort', cancel, { once: true });
|
| 54 |
+
image.onload = () => finish(null);
|
| 55 |
+
image.onerror = () => finish(new Error('Image read failed'));
|
| 56 |
+
timer = setTimeout(() => {
|
| 57 |
+
const error = new Error('Image read timed out');
|
| 58 |
+
error.name = 'TimeoutError';
|
| 59 |
+
finish(error);
|
| 60 |
+
}, API.TIMEOUT);
|
| 61 |
+
try { image.src = url; } catch (error) { finish(error); }
|
| 62 |
});
|
| 63 |
}
|
| 64 |
|
|
|
|
| 71 |
// 兼容:接受单个URL或数组
|
| 72 |
let imageUrls = [];
|
| 73 |
if (Array.isArray(imageSource)) {
|
| 74 |
+
imageUrls = imageSource.filter(url => typeof url === 'string' && url.trim());
|
| 75 |
+
} else if (typeof imageSource === 'string' && imageSource.trim()) {
|
| 76 |
imageUrls = [imageSource];
|
| 77 |
}
|
| 78 |
|
|
|
|
| 81 |
const firstUrl = imageUrls[0];
|
| 82 |
|
| 83 |
// 🚀 本地代理URL已缓存,无需懒加载(从磁盘读取几乎瞬时)
|
| 84 |
+
const isLocalProxy = firstUrl && firstUrl.startsWith(API.LOCAL_IMAGE_PROXY_PREFIX);
|
| 85 |
const shouldLazy = lazyLoad && !isLocalProxy;
|
| 86 |
|
| 87 |
// 🔒 XSS防护:图片 URL 来自其他用户发布的内容,写入 HTML 属性前必须转义
|
|
|
|
| 92 |
|
| 93 |
// 🖼️ 多图模式:显示左右切换按钮和计数器
|
| 94 |
const hasMultiple = imageUrls.length > 1;
|
| 95 |
+
const positionLabel = escapeHtml(t('image.viewer.image_position', { current: 1, total: imageUrls.length }));
|
| 96 |
const navButtons = hasMultiple ? `
|
| 97 |
+
<button type="button" class="sandbox-control btn-prev" aria-label="${escapeHtml(t('image.viewer.previous_short'))}" title="${escapeHtml(t('image.viewer.previous_short'))}">❮</button>
|
| 98 |
+
<button type="button" class="sandbox-control btn-next" aria-label="${escapeHtml(t('image.viewer.next_short'))}" title="${escapeHtml(t('image.viewer.next_short'))}">❯</button>
|
| 99 |
+
<div class="img-counter" role="status" aria-label="${positionLabel}">1 / ${imageUrls.length}</div>
|
| 100 |
` : '';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
return `
|
| 102 |
+
<div class="sandbox-heading">🖼️ ${t('image.gallery')}${hasMultiple ? ` (${imageUrls.length})` : ''}</div>
|
| 103 |
+
<div class="img-viewport image-sandbox" role="group" aria-label="${escapeHtml(t('image.gallery'))}" data-images='${escapeHtml(JSON.stringify(imageUrls))}' data-current="0">
|
| 104 |
+
<img class="target-img ${lazyClass}" src="${escapeHtml(imgSrc)}" ${dataAttr} draggable="false" alt="${positionLabel}">
|
| 105 |
${navButtons}
|
| 106 |
+
<div class="sandbox-tools">
|
| 107 |
+
<button type="button" class="sandbox-control btn-zoom-out" aria-label="${escapeHtml(t('image.zoom_out'))}" title="${escapeHtml(t('image.zoom_out'))}">−</button>
|
| 108 |
+
<button type="button" class="sandbox-control btn-zoom-reset" title="${escapeHtml(t('common.reset'))}">${t('common.reset')}</button>
|
| 109 |
+
<button type="button" class="sandbox-control btn-zoom-in" aria-label="${escapeHtml(t('image.zoom_in'))}" title="${escapeHtml(t('image.zoom_in'))}">+</button>
|
| 110 |
+
<button type="button" class="sandbox-control btn-fullscreen" aria-label="${escapeHtml(t('image.viewer.fullscreen'))}" title="${escapeHtml(t('image.viewer.fullscreen'))}">⛶</button>
|
| 111 |
</div>
|
| 112 |
+
<div class="sliders-wrapper">
|
| 113 |
+
<input type="range" class="slider-x" min="-400" max="400" value="0" aria-label="${escapeHtml(t('image.pan_horizontal'))}">
|
| 114 |
+
<input type="range" class="slider-y" min="-400" max="400" value="0" aria-label="${escapeHtml(t('image.pan_vertical'))}">
|
| 115 |
</div>
|
| 116 |
</div>
|
| 117 |
`;
|
|
|
|
| 121 |
* 🖼️ 设置图片沙盒事件(轮播、缩放、拖动)
|
| 122 |
*/
|
| 123 |
export function setupImageSandboxEvents(detailView) {
|
| 124 |
+
const viewport = detailView?.matches?.('.img-viewport') ? detailView : detailView?.querySelector('.img-viewport');
|
| 125 |
if (!viewport) return;
|
| 126 |
+
viewport._sandboxCleanup?.();
|
| 127 |
+
const targetImg = viewport.querySelector('.target-img');
|
| 128 |
+
const btnIn = viewport.querySelector('.btn-zoom-in');
|
| 129 |
+
const btnOut = viewport.querySelector('.btn-zoom-out');
|
| 130 |
+
const btnReset = viewport.querySelector('.btn-zoom-reset');
|
| 131 |
+
const sliderX = viewport.querySelector('.slider-x');
|
| 132 |
+
const sliderY = viewport.querySelector('.slider-y');
|
| 133 |
+
if (![targetImg, btnIn, btnOut, btnReset, sliderX, sliderY].every(Boolean)) return;
|
| 134 |
+
let imageUrls;
|
| 135 |
+
try { imageUrls = JSON.parse(viewport.dataset.images || '[]'); } catch (_) { imageUrls = []; }
|
| 136 |
+
if (!Array.isArray(imageUrls)) imageUrls = [];
|
| 137 |
+
imageUrls = imageUrls.filter(url => typeof url === 'string' && url.trim());
|
| 138 |
+
if (!imageUrls.length) return;
|
| 139 |
+
let currentIndex = Number.parseInt(viewport.dataset.current || '0', 10);
|
| 140 |
+
if (!Number.isInteger(currentIndex) || currentIndex < 0 || currentIndex >= imageUrls.length) currentIndex = 0;
|
| 141 |
+
let disposed = false, generation = 0, readController = null, closeViewer = null;
|
| 142 |
+
let observer = null, wasMounted = false, fallbackNode = null, fallbackTimer = null;
|
| 143 |
+
const stops = [];
|
| 144 |
+
const owns = () => !disposed && viewport._sandboxCleanup === cleanup;
|
| 145 |
+
const connected = () => viewport.isConnected && (detailView === viewport || detailView.contains(viewport));
|
| 146 |
+
const current = () => owns() && connected();
|
| 147 |
+
const cleanup = () => {
|
| 148 |
+
if (disposed) return;
|
| 149 |
+
disposed = true;
|
| 150 |
+
generation++;
|
| 151 |
+
readController?.abort();
|
| 152 |
+
readController = null;
|
| 153 |
+
for (const stop of stops) stop();
|
| 154 |
+
stops.length = 0;
|
| 155 |
+
observer?.disconnect();
|
| 156 |
+
observer = null;
|
| 157 |
+
if (fallbackTimer !== null) clearTimeout(fallbackTimer);
|
| 158 |
+
fallbackTimer = null;
|
| 159 |
+
fallbackNode?.remove();
|
| 160 |
+
fallbackNode = null;
|
| 161 |
+
const close = closeViewer;
|
| 162 |
+
closeViewer = null;
|
| 163 |
+
try { close?.(); } catch (_) { console.warn('Image sandbox viewer cleanup failed'); }
|
| 164 |
+
if (viewport._sandboxCleanup === cleanup) viewport._sandboxCleanup = null;
|
| 165 |
+
};
|
| 166 |
+
viewport._sandboxCleanup = cleanup;
|
| 167 |
+
const watchRemoval = () => {
|
| 168 |
+
if (!owns() || observer) return;
|
| 169 |
+
const inspect = records => {
|
| 170 |
+
if (!owns() || observer !== ownedObserver) return;
|
| 171 |
+
if (connected()) { wasMounted = true; return; }
|
| 172 |
+
const appeared = records.some(record => [...record.addedNodes, ...record.removedNodes]
|
| 173 |
+
.some(node => node === viewport || node.contains?.(viewport)));
|
| 174 |
+
if (wasMounted || appeared) cleanup();
|
| 175 |
+
};
|
| 176 |
+
const ownedObserver = new MutationObserver(inspect);
|
| 177 |
+
observer = ownedObserver;
|
| 178 |
+
observer.observe(document.body, { childList: true, subtree: true });
|
| 179 |
+
queueMicrotask(() => {
|
| 180 |
+
if (!owns() || observer !== ownedObserver) return;
|
| 181 |
+
inspect(ownedObserver.takeRecords());
|
| 182 |
+
if (!connected() && !wasMounted && owns()) { ownedObserver.disconnect(); observer = null; }
|
| 183 |
+
});
|
| 184 |
+
};
|
| 185 |
+
const guard = callback => event => {
|
| 186 |
+
if (!current()) return;
|
| 187 |
+
watchRemoval();
|
| 188 |
+
callback(event);
|
| 189 |
+
};
|
| 190 |
+
const listen = (target, type, callback, options) => {
|
| 191 |
+
const owned = guard(callback);
|
| 192 |
+
target.addEventListener(type, owned, options);
|
| 193 |
+
stops.push(() => target.removeEventListener(type, owned, options));
|
| 194 |
+
};
|
| 195 |
+
const click = (target, callback) => {
|
| 196 |
+
if (!target) return;
|
| 197 |
+
const owned = guard(callback);
|
| 198 |
+
target.onclick = owned;
|
| 199 |
+
stops.push(() => { if (target.onclick === owned) target.onclick = null; });
|
| 200 |
+
};
|
| 201 |
+
let scale = Number(viewport.dataset.sandboxScale);
|
| 202 |
+
if (!Number.isFinite(scale) || scale < 0.5 || scale > 5) scale = 1;
|
| 203 |
+
const syncTransform = () => {
|
| 204 |
+
viewport.dataset.sandboxScale = String(scale);
|
| 205 |
+
targetImg.style.transform = `scale(${scale}) translate(${sliderX.value}px, ${sliderY.value}px)`;
|
| 206 |
+
};
|
| 207 |
+
const counter = viewport.querySelector('.img-counter');
|
| 208 |
+
const updateCounter = () => {
|
| 209 |
+
viewport.dataset.current = String(currentIndex);
|
| 210 |
+
const label = t('image.viewer.image_position', { current: currentIndex + 1, total: imageUrls.length });
|
| 211 |
+
targetImg.alt = label;
|
| 212 |
+
if (counter) {
|
| 213 |
+
counter.textContent = `${currentIndex + 1} / ${imageUrls.length}`;
|
| 214 |
+
counter.setAttribute('aria-label', label);
|
| 215 |
+
}
|
| 216 |
+
};
|
| 217 |
+
const loadImage = async index => {
|
| 218 |
+
if (!owns() || index < 0 || index >= imageUrls.length) return;
|
| 219 |
+
readController?.abort();
|
| 220 |
+
const controller = new AbortController();
|
| 221 |
+
readController = controller;
|
| 222 |
+
const expected = ++generation;
|
| 223 |
currentIndex = index;
|
| 224 |
+
updateCounter();
|
| 225 |
+
scale = 1; sliderX.value = 0; sliderY.value = 0; syncTransform();
|
| 226 |
+
targetImg.classList.remove('lazy-loaded', 'lazy-error');
|
| 227 |
targetImg.classList.add('lazy-loading');
|
| 228 |
targetImg.src = getPlaceholderSVG();
|
| 229 |
+
const url = imageUrls[index];
|
| 230 |
try {
|
| 231 |
+
await loadImageWithTimeout(url, controller.signal);
|
| 232 |
+
if (!current() || expected !== generation) return;
|
| 233 |
+
watchRemoval();
|
| 234 |
targetImg.src = url;
|
| 235 |
+
targetImg.classList.remove('lazy-loading', 'lazy-error');
|
| 236 |
targetImg.classList.add('lazy-loaded');
|
| 237 |
targetImg.style.opacity = '1';
|
| 238 |
+
} catch (error) {
|
| 239 |
+
if (!current() || expected !== generation || controller.signal.aborted) return;
|
| 240 |
+
watchRemoval();
|
| 241 |
+
targetImg.src = error.name === 'TimeoutError' ? getTimeoutSVG() : getErrorSVG();
|
| 242 |
+
targetImg.classList.remove('lazy-loading', 'lazy-loaded');
|
| 243 |
targetImg.classList.add('lazy-error');
|
| 244 |
+
} finally {
|
| 245 |
+
if (readController === controller) readController = null;
|
| 246 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
};
|
| 248 |
+
delete targetImg.dataset.src;
|
| 249 |
+
watchRemoval();
|
| 250 |
+
updateCounter();
|
| 251 |
+
syncTransform();
|
| 252 |
+
const ready = targetImg.getAttribute('src') === imageUrls[currentIndex] &&
|
| 253 |
+
(targetImg.classList.contains('lazy-loaded') || (targetImg.complete && targetImg.naturalWidth > 0));
|
| 254 |
+
if (!ready) loadImage(currentIndex);
|
| 255 |
+
else { targetImg.classList.remove('lazy-loading', 'lazy-error'); targetImg.classList.add('lazy-loaded'); }
|
| 256 |
+
listen(viewport, 'contextmenu', event => event.preventDefault());
|
| 257 |
+
listen(targetImg, 'dragstart', event => event.preventDefault());
|
| 258 |
+
click(viewport.querySelector('.btn-prev'), event => { event.stopPropagation(); loadImage(currentIndex > 0 ? currentIndex - 1 : imageUrls.length - 1); });
|
| 259 |
+
click(viewport.querySelector('.btn-next'), event => { event.stopPropagation(); loadImage(currentIndex < imageUrls.length - 1 ? currentIndex + 1 : 0); });
|
| 260 |
+
const zoom = delta => { scale = Math.max(0.5, Math.min(5, scale + delta)); syncTransform(); };
|
| 261 |
+
click(btnIn, () => zoom(0.2));
|
| 262 |
+
click(btnOut, () => zoom(-0.2));
|
| 263 |
+
click(btnReset, () => { scale = 1; sliderX.value = 0; sliderY.value = 0; syncTransform(); });
|
| 264 |
+
const notifyFailure = async () => {
|
| 265 |
+
try {
|
| 266 |
+
const module = await import('./UI交互提示组件.js');
|
| 267 |
+
if (current()) module.showToast(t('image.viewer.load_failed'), 'error');
|
| 268 |
+
} catch (_) {
|
| 269 |
+
if (!current()) return;
|
| 270 |
+
fallbackNode?.remove();
|
| 271 |
+
if (fallbackTimer !== null) clearTimeout(fallbackTimer);
|
| 272 |
+
const node = document.createElement('div');
|
| 273 |
+
node.textContent = t('image.viewer.load_failed');
|
| 274 |
+
Object.assign(node.style, { position: 'fixed', top: '50px', left: '50%', transform: 'translateX(-50%)', background: '#F44336', color: '#fff', padding: '10px 20px', borderRadius: '8px', zIndex: '10000', fontSize: '14px' });
|
| 275 |
+
fallbackNode = node;
|
| 276 |
+
document.body.appendChild(node);
|
| 277 |
+
fallbackTimer = setTimeout(() => { node.remove(); if (fallbackNode === node) { fallbackNode = null; fallbackTimer = null; } }, UI.TOAST.DURATION);
|
| 278 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
};
|
| 280 |
+
click(viewport.querySelector('.btn-fullscreen'), event => {
|
| 281 |
+
event.stopPropagation();
|
| 282 |
+
try { closeViewer?.(); closeViewer = openImageViewer(imageUrls, currentIndex, { opaque: true }); }
|
| 283 |
+
catch (_) { console.error('无法打开图像播放器'); notifyFailure(); }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
});
|
| 285 |
+
listen(viewport, 'wheel', event => { event.preventDefault(); zoom(event.deltaY > 0 ? -0.1 : 0.1); }, { passive: false });
|
| 286 |
+
const rangeKeys = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown']);
|
| 287 |
+
const keepNativeRangeKey = event => {
|
| 288 |
+
if (event.defaultPrevented || event.isComposing || event.keyCode === 229 ||
|
| 289 |
+
event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) return;
|
| 290 |
+
if (rangeKeys.has(event.key)) event.stopPropagation();
|
|
|
|
|
|
|
| 291 |
};
|
| 292 |
+
listen(sliderX, 'keydown', keepNativeRangeKey);
|
| 293 |
+
listen(sliderY, 'keydown', keepNativeRangeKey);
|
| 294 |
+
listen(sliderX, 'input', syncTransform);
|
| 295 |
+
listen(sliderY, 'input', syncTransform);
|
| 296 |
+
let dragging = false, startX, startY, initialX, initialY;
|
| 297 |
+
listen(viewport, 'mousedown', event => {
|
| 298 |
+
if (event.target.closest('input, button')) return;
|
| 299 |
+
event.preventDefault(); dragging = true; viewport.style.cursor = 'grabbing';
|
| 300 |
+
startX = event.clientX; startY = event.clientY; initialX = Number(sliderX.value); initialY = Number(sliderY.value);
|
| 301 |
+
});
|
| 302 |
+
listen(window, 'mousemove', event => {
|
| 303 |
+
if (!dragging) return;
|
| 304 |
+
sliderX.value = initialX + (event.clientX - startX) / scale;
|
| 305 |
+
sliderY.value = initialY + (event.clientY - startY) / scale;
|
| 306 |
+
syncTransform();
|
| 307 |
+
});
|
| 308 |
+
listen(window, 'mouseup', () => { dragging = false; viewport.style.cursor = 'grab'; });
|
| 309 |
+
listen(viewport, 'mouseleave', () => { dragging = false; viewport.style.cursor = 'grab'; });
|
| 310 |
}
|
| 311 |
|
| 312 |
/**
|
|
|
|
| 314 |
* @param {HTMLElement} detailView - 详情视图容器
|
| 315 |
*/
|
| 316 |
export function cleanupImageSandbox(detailView) {
|
| 317 |
+
const viewports = [...(detailView?.querySelectorAll?.('.img-viewport') || [])];
|
| 318 |
+
if (detailView?.matches?.('.img-viewport')) viewports.unshift(detailView);
|
| 319 |
+
for (const viewport of viewports) viewport._sandboxCleanup?.();
|
|
|
|
|
|
|
| 320 |
}
|
前端页面/components/图片裁剪组件.js
CHANGED
|
@@ -53,11 +53,7 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
|
|
| 53 |
const dialogId = `image-crop-dialog-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
| 54 |
// 创建遮罩层
|
| 55 |
const overlay = document.createElement("div");
|
| 56 |
-
|
| 57 |
-
position: "fixed", top: "0", left: "0", width: "100%", height: "100%",
|
| 58 |
-
background: "rgba(0,0,0,0.85)", zIndex: "99999",
|
| 59 |
-
display: "flex", justifyContent: "center", alignItems: "center"
|
| 60 |
-
});
|
| 61 |
|
| 62 |
// 创建弹窗容器
|
| 63 |
const modal = document.createElement("div");
|
|
@@ -66,18 +62,11 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
|
|
| 66 |
modal.setAttribute("aria-labelledby", `${dialogId}-title`);
|
| 67 |
modal.setAttribute("aria-describedby", `${dialogId}-hint`);
|
| 68 |
modal.setAttribute("tabindex", "-1");
|
| 69 |
-
|
| 70 |
-
background: "var(--comfy-input-bg)", borderRadius: "12px", padding: "20px",
|
| 71 |
-
maxWidth: "90vw", maxHeight: "90vh", display: "flex", flexDirection: "column",
|
| 72 |
-
boxShadow: "0 8px 32px rgba(0,0,0,0.5)"
|
| 73 |
-
});
|
| 74 |
|
| 75 |
// 标题栏
|
| 76 |
const header = document.createElement("div");
|
| 77 |
-
|
| 78 |
-
display: "flex", justifyContent: "space-between", alignItems: "center",
|
| 79 |
-
marginBottom: "15px", paddingBottom: "10px", borderBottom: "1px solid #444"
|
| 80 |
-
});
|
| 81 |
// 比例显示文字
|
| 82 |
let ratioText = '16:9';
|
| 83 |
if (aspectRatio === 1) ratioText = '1:1';
|
|
@@ -85,40 +74,23 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
|
|
| 85 |
// 📱 触控设备与鼠标设备的操作提示区分(移动端无滚轮/拖动概念);文案走词典,随界面语言切换
|
| 86 |
const gestureHint = _isTouchDevice() ? t('crop.gesture_hint_touch') : t('crop.gesture_hint_mouse');
|
| 87 |
header.innerHTML = `
|
| 88 |
-
<span id="${dialogId}-title" role="heading" aria-level="2"
|
| 89 |
-
<span id="${dialogId}-hint"
|
| 90 |
`;
|
| 91 |
|
| 92 |
// 裁剪区域容器
|
| 93 |
const cropContainer = document.createElement("div");
|
| 94 |
let containerWidth, containerHeight;
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
containerHeight = containerWidth / aspectRatio;
|
| 102 |
-
} else {
|
| 103 |
-
// 9:16 纵向
|
| 104 |
-
containerWidth = Math.min(300, window.innerWidth * 0.5);
|
| 105 |
-
containerHeight = Math.min(450, window.innerHeight * 0.5);
|
| 106 |
-
}
|
| 107 |
-
|
| 108 |
-
Object.assign(cropContainer.style, {
|
| 109 |
-
width: `${containerWidth}px`, height: `${containerHeight}px`,
|
| 110 |
-
position: "relative", overflow: "hidden", borderRadius: "8px",
|
| 111 |
-
background: "#1a1a1a", border: "2px solid #4CAF50", cursor: "move",
|
| 112 |
-
// 📱 禁止浏览器接管触摸手势(页面滚动/双指缩放页面),交由裁剪组件自行处理
|
| 113 |
-
touchAction: "none"
|
| 114 |
-
});
|
| 115 |
|
| 116 |
// 图片元素
|
| 117 |
const img = document.createElement("img");
|
| 118 |
-
|
| 119 |
-
position: "absolute", maxWidth: "none", maxHeight: "none",
|
| 120 |
-
userSelect: "none", pointerEvents: "none"
|
| 121 |
-
});
|
| 122 |
|
| 123 |
// 裁剪状态
|
| 124 |
let scale = 1;
|
|
@@ -177,13 +149,30 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
|
|
| 177 |
img.style.height = `${scaledHeight}px`;
|
| 178 |
img.style.left = `${left}px`;
|
| 179 |
img.style.top = `${top}px`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
}
|
| 181 |
|
| 182 |
// 鼠标拖动
|
| 183 |
cropContainer.onmousedown = (e) => {
|
|
|
|
| 184 |
isDragging = true;
|
| 185 |
-
|
| 186 |
-
|
|
|
|
| 187 |
cropContainer.style.cursor = "grabbing";
|
| 188 |
};
|
| 189 |
|
|
@@ -192,6 +181,7 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
|
|
| 192 |
|
| 193 |
// 🔧 P3优化:ESC 键关闭
|
| 194 |
function handleKeyDown(e) {
|
|
|
|
| 195 |
if (e.key === 'Escape') {
|
| 196 |
e.preventDefault();
|
| 197 |
cleanup();
|
|
@@ -221,8 +211,9 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
|
|
| 221 |
|
| 222 |
function handleMouseMove(e) {
|
| 223 |
if (!isDragging) return;
|
| 224 |
-
|
| 225 |
-
|
|
|
|
| 226 |
updateImagePosition();
|
| 227 |
}
|
| 228 |
|
|
@@ -234,6 +225,7 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
|
|
| 234 |
// 滚轮缩放
|
| 235 |
cropContainer.onwheel = (e) => {
|
| 236 |
e.preventDefault();
|
|
|
|
| 237 |
const delta = e.deltaY > 0 ? 0.95 : 1.05;
|
| 238 |
const newScale = scale * delta;
|
| 239 |
|
|
@@ -254,9 +246,6 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
|
|
| 254 |
let pinchCenterY = 0;
|
| 255 |
let pinchStartOffsetX = 0;
|
| 256 |
let pinchStartOffsetY = 0;
|
| 257 |
-
// 🔧 手势起始时容器的视口原点(含 border),用于把 clientX/Y 换算成与 img.style.left 同源的容器内坐标
|
| 258 |
-
let pinchOriginX = 0;
|
| 259 |
-
let pinchOriginY = 0;
|
| 260 |
|
| 261 |
// 获取缩放边界(与滚轮缩放共用同一规则)
|
| 262 |
function _getScaleLimits() {
|
|
@@ -267,10 +256,8 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
|
|
| 267 |
}
|
| 268 |
|
| 269 |
function _getTouchMidPoint(touches) {
|
| 270 |
-
return
|
| 271 |
-
|
| 272 |
-
y: (touches[0].clientY + touches[1].clientY) / 2
|
| 273 |
-
};
|
| 274 |
}
|
| 275 |
|
| 276 |
function _getTouchDistance(touches) {
|
|
@@ -280,22 +267,19 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
|
|
| 280 |
}
|
| 281 |
|
| 282 |
function handleTouchStart(e) {
|
|
|
|
| 283 |
if (e.touches.length === 1) {
|
| 284 |
isDragging = true;
|
| 285 |
-
|
| 286 |
-
|
|
|
|
| 287 |
} else if (e.touches.length === 2) {
|
| 288 |
isDragging = false;
|
| 289 |
pinchStartDist = _getTouchDistance(e.touches);
|
| 290 |
pinchStartScale = scale;
|
| 291 |
-
// 🔧 clientX 是视口坐标,offsetX/containerWidth 是容器内坐标,两者必须同原点才能参与锚点计算;
|
| 292 |
-
// clientLeft/clientTop 为 2px 边框宽度(img 的 absolute 定位原点在 padding box 左上角)
|
| 293 |
-
const rect = cropContainer.getBoundingClientRect();
|
| 294 |
-
pinchOriginX = rect.left + cropContainer.clientLeft;
|
| 295 |
-
pinchOriginY = rect.top + cropContainer.clientTop;
|
| 296 |
const mid = _getTouchMidPoint(e.touches);
|
| 297 |
-
pinchCenterX = mid.x
|
| 298 |
-
pinchCenterY = mid.y
|
| 299 |
pinchStartOffsetX = offsetX;
|
| 300 |
pinchStartOffsetY = offsetY;
|
| 301 |
}
|
|
@@ -305,8 +289,9 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
|
|
| 305 |
function handleTouchMove(e) {
|
| 306 |
if (e.touches.length === 1 && isDragging) {
|
| 307 |
// 单指拖动
|
| 308 |
-
|
| 309 |
-
|
|
|
|
| 310 |
updateImagePosition();
|
| 311 |
} else if (e.touches.length === 2 && pinchStartDist > 0) {
|
| 312 |
// 双指捏合缩放(以两指中点为锚点,图片跟随手指中心移动)
|
|
@@ -314,9 +299,8 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
|
|
| 314 |
const newScale = _clamp(pinchStartScale * (_getTouchDistance(e.touches) / pinchStartDist), minScale, maxScale);
|
| 315 |
const ratio = newScale / pinchStartScale;
|
| 316 |
const mid = _getTouchMidPoint(e.touches);
|
| 317 |
-
|
| 318 |
-
const
|
| 319 |
-
const relY = mid.y - pinchOriginY;
|
| 320 |
// 图片 left = (W - s·imgW)/2 + o,故锚点不动的条件解出:
|
| 321 |
// o1 = (p1 - p0) + ratio·(o0 - d) + d,其中 d = p0 - W/2 为起始手指相对容器中心的偏移
|
| 322 |
const centerX = containerWidth / 2;
|
|
@@ -339,8 +323,9 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
|
|
| 339 |
// 双指抬起剩单指:切换为拖动模式,避免图片跳变
|
| 340 |
pinchStartDist = 0;
|
| 341 |
isDragging = true;
|
| 342 |
-
|
| 343 |
-
|
|
|
|
| 344 |
}
|
| 345 |
}
|
| 346 |
|
|
@@ -351,24 +336,20 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
|
|
| 351 |
|
| 352 |
// 缩放滑块
|
| 353 |
const sliderContainer = document.createElement("div");
|
| 354 |
-
|
| 355 |
-
display: "flex", alignItems: "center", gap: "10px", marginTop: "15px", padding: "0 10px"
|
| 356 |
-
});
|
| 357 |
sliderContainer.innerHTML = `
|
| 358 |
-
<
|
| 359 |
<input type="range" id="crop-scale-slider" min="100" max="500" value="100" aria-label="${escapeHtml(t('crop.zoom_label'))}" aria-valuetext="100%"
|
| 360 |
-
|
| 361 |
-
<span id="crop-scale-value"
|
| 362 |
`;
|
| 363 |
|
| 364 |
// 按钮区域
|
| 365 |
const buttonContainer = document.createElement("div");
|
| 366 |
-
|
| 367 |
-
display: "flex", gap: "10px", marginTop: "15px", justifyContent: "flex-end"
|
| 368 |
-
});
|
| 369 |
buttonContainer.innerHTML = `
|
| 370 |
-
<button id="btn-crop-cancel" type="button"
|
| 371 |
-
<button id="btn-crop-confirm" type="button" disabled
|
| 372 |
`;
|
| 373 |
|
| 374 |
// 组装DOM
|
|
@@ -379,6 +360,13 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
|
|
| 379 |
modal.appendChild(buttonContainer);
|
| 380 |
overlay.appendChild(modal);
|
| 381 |
document.body.appendChild(overlay);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
modal.querySelector("#btn-crop-cancel").focus();
|
| 383 |
|
| 384 |
// 绑定缩放滑块事件
|
|
@@ -389,6 +377,7 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
|
|
| 389 |
const scaleValue = modal.querySelector("#crop-scale-value");
|
| 390 |
|
| 391 |
slider.oninput = () => {
|
|
|
|
| 392 |
const minScaleX = containerWidth / imgNaturalWidth;
|
| 393 |
const minScaleY = containerHeight / imgNaturalHeight;
|
| 394 |
const minScale = Math.max(minScaleX, minScaleY);
|
|
@@ -411,6 +400,7 @@ export async function openImageCropper(file, aspectRatio = 16/9, title = t('crop
|
|
| 411 |
const btn = modal.querySelector("#btn-crop-confirm");
|
| 412 |
if (btn.disabled) return;
|
| 413 |
btn.textContent = `⏳ ${t('crop.processing')}`;
|
|
|
|
| 414 |
btn.disabled = true;
|
| 415 |
|
| 416 |
encodeTimer = setTimeout(fail, IMAGE_PROCESS.TIMEOUT);
|
|
|
|
| 53 |
const dialogId = `image-crop-dialog-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
| 54 |
// 创建遮罩层
|
| 55 |
const overlay = document.createElement("div");
|
| 56 |
+
overlay.className = "image-crop-overlay";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
// 创建弹窗容器
|
| 59 |
const modal = document.createElement("div");
|
|
|
|
| 62 |
modal.setAttribute("aria-labelledby", `${dialogId}-title`);
|
| 63 |
modal.setAttribute("aria-describedby", `${dialogId}-hint`);
|
| 64 |
modal.setAttribute("tabindex", "-1");
|
| 65 |
+
modal.className = "image-crop-dialog";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
// 标题栏
|
| 68 |
const header = document.createElement("div");
|
| 69 |
+
header.className = "image-crop-header";
|
|
|
|
|
|
|
|
|
|
| 70 |
// 比例显示文字
|
| 71 |
let ratioText = '16:9';
|
| 72 |
if (aspectRatio === 1) ratioText = '1:1';
|
|
|
|
| 74 |
// 📱 触控设备与鼠标设备的操作提示区分(移动端无滚轮/拖动概念);文案走词典,随界面语言切换
|
| 75 |
const gestureHint = _isTouchDevice() ? t('crop.gesture_hint_touch') : t('crop.gesture_hint_mouse');
|
| 76 |
header.innerHTML = `
|
| 77 |
+
<span id="${dialogId}-title" role="heading" aria-level="2" class="image-crop-title">✂️ ${escapeHtml(title)}</span>
|
| 78 |
+
<span id="${dialogId}-hint" class="image-crop-hint">${escapeHtml(t('crop.ratio_label'))} ${ratioText} | ${escapeHtml(gestureHint)}</span>
|
| 79 |
`;
|
| 80 |
|
| 81 |
// 裁剪区域容器
|
| 82 |
const cropContainer = document.createElement("div");
|
| 83 |
let containerWidth, containerHeight;
|
| 84 |
+
const preferredWidth = aspectRatio === 1
|
| 85 |
+
? Math.min(400, window.innerWidth * 0.7)
|
| 86 |
+
: aspectRatio > 1 ? Math.min(600, window.innerWidth * 0.8)
|
| 87 |
+
: Math.min(300, window.innerWidth * 0.5, Math.min(450, window.innerHeight * 0.5) * aspectRatio);
|
| 88 |
+
cropContainer.className = "image-crop-viewport";
|
| 89 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
// 图片元素
|
| 92 |
const img = document.createElement("img");
|
| 93 |
+
img.className = "image-crop-image";
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
// 裁剪状态
|
| 96 |
let scale = 1;
|
|
|
|
| 149 |
img.style.height = `${scaledHeight}px`;
|
| 150 |
img.style.left = `${left}px`;
|
| 151 |
img.style.top = `${top}px`;
|
| 152 |
+
const slider = modal.querySelector('#crop-scale-slider');
|
| 153 |
+
if (slider && imgNaturalWidth) {
|
| 154 |
+
const percent = Math.round(scale / _getScaleLimits().minScale * 100);
|
| 155 |
+
slider.value = String(percent);
|
| 156 |
+
slider.setAttribute('aria-valuetext', `${percent}%`);
|
| 157 |
+
modal.querySelector('#crop-scale-value').textContent = `${percent}%`;
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
function localPoint(clientX, clientY) {
|
| 162 |
+
const rect = cropContainer.getBoundingClientRect();
|
| 163 |
+
return {
|
| 164 |
+
x: (clientX - rect.left) * (containerWidth + cropContainer.clientLeft * 2) / rect.width - cropContainer.clientLeft,
|
| 165 |
+
y: (clientY - rect.top) * (containerHeight + cropContainer.clientTop * 2) / rect.height - cropContainer.clientTop
|
| 166 |
+
};
|
| 167 |
}
|
| 168 |
|
| 169 |
// 鼠标拖动
|
| 170 |
cropContainer.onmousedown = (e) => {
|
| 171 |
+
if (!imgNaturalWidth || e.button !== 0) return;
|
| 172 |
isDragging = true;
|
| 173 |
+
const point = localPoint(e.clientX, e.clientY);
|
| 174 |
+
dragStartX = point.x - offsetX;
|
| 175 |
+
dragStartY = point.y - offsetY;
|
| 176 |
cropContainer.style.cursor = "grabbing";
|
| 177 |
};
|
| 178 |
|
|
|
|
| 181 |
|
| 182 |
// 🔧 P3优化:ESC 键关闭
|
| 183 |
function handleKeyDown(e) {
|
| 184 |
+
if (e.isComposing || e.ctrlKey || e.altKey || e.metaKey) return;
|
| 185 |
if (e.key === 'Escape') {
|
| 186 |
e.preventDefault();
|
| 187 |
cleanup();
|
|
|
|
| 211 |
|
| 212 |
function handleMouseMove(e) {
|
| 213 |
if (!isDragging) return;
|
| 214 |
+
const point = localPoint(e.clientX, e.clientY);
|
| 215 |
+
offsetX = point.x - dragStartX;
|
| 216 |
+
offsetY = point.y - dragStartY;
|
| 217 |
updateImagePosition();
|
| 218 |
}
|
| 219 |
|
|
|
|
| 225 |
// 滚轮缩放
|
| 226 |
cropContainer.onwheel = (e) => {
|
| 227 |
e.preventDefault();
|
| 228 |
+
if (!imgNaturalWidth) return;
|
| 229 |
const delta = e.deltaY > 0 ? 0.95 : 1.05;
|
| 230 |
const newScale = scale * delta;
|
| 231 |
|
|
|
|
| 246 |
let pinchCenterY = 0;
|
| 247 |
let pinchStartOffsetX = 0;
|
| 248 |
let pinchStartOffsetY = 0;
|
|
|
|
|
|
|
|
|
|
| 249 |
|
| 250 |
// 获取缩放边界(与滚轮缩放共用同一规则)
|
| 251 |
function _getScaleLimits() {
|
|
|
|
| 256 |
}
|
| 257 |
|
| 258 |
function _getTouchMidPoint(touches) {
|
| 259 |
+
return localPoint((touches[0].clientX + touches[1].clientX) / 2,
|
| 260 |
+
(touches[0].clientY + touches[1].clientY) / 2);
|
|
|
|
|
|
|
| 261 |
}
|
| 262 |
|
| 263 |
function _getTouchDistance(touches) {
|
|
|
|
| 267 |
}
|
| 268 |
|
| 269 |
function handleTouchStart(e) {
|
| 270 |
+
if (!imgNaturalWidth) return;
|
| 271 |
if (e.touches.length === 1) {
|
| 272 |
isDragging = true;
|
| 273 |
+
const point = localPoint(e.touches[0].clientX, e.touches[0].clientY);
|
| 274 |
+
dragStartX = point.x - offsetX;
|
| 275 |
+
dragStartY = point.y - offsetY;
|
| 276 |
} else if (e.touches.length === 2) {
|
| 277 |
isDragging = false;
|
| 278 |
pinchStartDist = _getTouchDistance(e.touches);
|
| 279 |
pinchStartScale = scale;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
const mid = _getTouchMidPoint(e.touches);
|
| 281 |
+
pinchCenterX = mid.x;
|
| 282 |
+
pinchCenterY = mid.y;
|
| 283 |
pinchStartOffsetX = offsetX;
|
| 284 |
pinchStartOffsetY = offsetY;
|
| 285 |
}
|
|
|
|
| 289 |
function handleTouchMove(e) {
|
| 290 |
if (e.touches.length === 1 && isDragging) {
|
| 291 |
// 单指拖动
|
| 292 |
+
const point = localPoint(e.touches[0].clientX, e.touches[0].clientY);
|
| 293 |
+
offsetX = point.x - dragStartX;
|
| 294 |
+
offsetY = point.y - dragStartY;
|
| 295 |
updateImagePosition();
|
| 296 |
} else if (e.touches.length === 2 && pinchStartDist > 0) {
|
| 297 |
// 双指捏合缩放(以两指中点为锚点,图片跟随手指中心移动)
|
|
|
|
| 299 |
const newScale = _clamp(pinchStartScale * (_getTouchDistance(e.touches) / pinchStartDist), minScale, maxScale);
|
| 300 |
const ratio = newScale / pinchStartScale;
|
| 301 |
const mid = _getTouchMidPoint(e.touches);
|
| 302 |
+
const relX = mid.x;
|
| 303 |
+
const relY = mid.y;
|
|
|
|
| 304 |
// 图片 left = (W - s·imgW)/2 + o,故锚点不动的条件解出:
|
| 305 |
// o1 = (p1 - p0) + ratio·(o0 - d) + d,其中 d = p0 - W/2 为起始手指相对容器中心的偏移
|
| 306 |
const centerX = containerWidth / 2;
|
|
|
|
| 323 |
// 双指抬起剩单指:切换为拖动模式,避免图片跳变
|
| 324 |
pinchStartDist = 0;
|
| 325 |
isDragging = true;
|
| 326 |
+
const point = localPoint(e.touches[0].clientX, e.touches[0].clientY);
|
| 327 |
+
dragStartX = point.x - offsetX;
|
| 328 |
+
dragStartY = point.y - offsetY;
|
| 329 |
}
|
| 330 |
}
|
| 331 |
|
|
|
|
| 336 |
|
| 337 |
// 缩放滑块
|
| 338 |
const sliderContainer = document.createElement("div");
|
| 339 |
+
sliderContainer.className = "image-crop-zoom";
|
|
|
|
|
|
|
| 340 |
sliderContainer.innerHTML = `
|
| 341 |
+
<label for="crop-scale-slider" class="image-crop-zoom-label">🔍 ${escapeHtml(t('crop.zoom_label'))}</label>
|
| 342 |
<input type="range" id="crop-scale-slider" min="100" max="500" value="100" aria-label="${escapeHtml(t('crop.zoom_label'))}" aria-valuetext="100%"
|
| 343 |
+
class="image-crop-range">
|
| 344 |
+
<span id="crop-scale-value" class="image-crop-zoom-value">100%</span>
|
| 345 |
`;
|
| 346 |
|
| 347 |
// 按钮区域
|
| 348 |
const buttonContainer = document.createElement("div");
|
| 349 |
+
buttonContainer.className = "image-crop-actions";
|
|
|
|
|
|
|
| 350 |
buttonContainer.innerHTML = `
|
| 351 |
+
<button id="btn-crop-cancel" type="button" class="image-crop-button image-crop-cancel">${escapeHtml(t('common.cancel'))}</button>
|
| 352 |
+
<button id="btn-crop-confirm" type="button" disabled class="image-crop-button image-crop-confirm">✂️ ${escapeHtml(t('crop.confirm'))}</button>
|
| 353 |
`;
|
| 354 |
|
| 355 |
// 组装DOM
|
|
|
|
| 360 |
modal.appendChild(buttonContainer);
|
| 361 |
overlay.appendChild(modal);
|
| 362 |
document.body.appendChild(overlay);
|
| 363 |
+
const modalStyle = getComputedStyle(modal);
|
| 364 |
+
const availableWidth = modal.clientWidth - parseFloat(modalStyle.paddingLeft) - parseFloat(modalStyle.paddingRight);
|
| 365 |
+
const viewportStyle = getComputedStyle(cropContainer);
|
| 366 |
+
containerWidth = Math.min(preferredWidth, Math.max(1, availableWidth - parseFloat(viewportStyle.borderLeftWidth) - parseFloat(viewportStyle.borderRightWidth)));
|
| 367 |
+
containerHeight = containerWidth / aspectRatio;
|
| 368 |
+
cropContainer.style.width = `${containerWidth}px`;
|
| 369 |
+
cropContainer.style.height = `${containerHeight}px`;
|
| 370 |
modal.querySelector("#btn-crop-cancel").focus();
|
| 371 |
|
| 372 |
// 绑定缩放滑块事件
|
|
|
|
| 377 |
const scaleValue = modal.querySelector("#crop-scale-value");
|
| 378 |
|
| 379 |
slider.oninput = () => {
|
| 380 |
+
if (!imgNaturalWidth) return;
|
| 381 |
const minScaleX = containerWidth / imgNaturalWidth;
|
| 382 |
const minScaleY = containerHeight / imgNaturalHeight;
|
| 383 |
const minScale = Math.max(minScaleX, minScaleY);
|
|
|
|
| 400 |
const btn = modal.querySelector("#btn-crop-confirm");
|
| 401 |
if (btn.disabled) return;
|
| 402 |
btn.textContent = `⏳ ${t('crop.processing')}`;
|
| 403 |
+
if (modal.contains(document.activeElement)) modal.focus({ preventScroll: true });
|
| 404 |
btn.disabled = true;
|
| 405 |
|
| 406 |
encodeTimer = setTimeout(fail, IMAGE_PROCESS.TIMEOUT);
|
前端页面/components/性能优化工具.js
CHANGED
|
@@ -15,7 +15,8 @@
|
|
| 15 |
// 🔒 P0安全优化:敏感数据脱敏
|
| 16 |
// ==========================================
|
| 17 |
|
| 18 |
-
import { API, PAGINATION } from "../core/全局配置.js";
|
|
|
|
| 19 |
// 📦 列表缓存工具需要本地可用的缓存实现;下方「向后兼容重导出」那行是纯转发、
|
| 20 |
// 不会生成本地绑定,故此处必须单独 import(勿误删,否则 readListCache 运行时报错)
|
| 21 |
import { setCache, getCacheWithMeta, removeCache, getCacheKeysByPrefix } from "../core/网络请求_缓存管理.js";
|
|
@@ -429,136 +430,162 @@ export function renderListSearchMessage(container, message) {
|
|
| 429 |
// 特点:
|
| 430 |
// - 使用 IntersectionObserver API
|
| 431 |
// - 支持占位图和加载动画
|
| 432 |
-
// -
|
| 433 |
|
| 434 |
-
|
| 435 |
-
|
|
|
|
| 436 |
|
| 437 |
-
|
| 438 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 439 |
|
| 440 |
-
|
| 441 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
|
| 443 |
-
/**
|
| 444 |
-
* 初始化图片懒加载(全局单例)
|
| 445 |
-
*/
|
| 446 |
function _initLazyLoadObserver() {
|
| 447 |
-
if (lazyLoadObserver) return;
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
}
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
}
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
});
|
|
|
|
|
|
|
| 467 |
}
|
| 468 |
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
// 创建临时图片测试加载
|
| 477 |
-
const tempImg = new Image();
|
| 478 |
-
|
| 479 |
-
tempImg.onload = () => {
|
| 480 |
img.src = src;
|
| 481 |
-
img.classList.remove("lazy-loading");
|
| 482 |
-
img.classList.add("lazy-loaded");
|
| 483 |
delete img.dataset.src;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
};
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
img.classList.remove("lazy-loading");
|
| 490 |
-
img.classList.add("lazy-error");
|
| 491 |
-
|
| 492 |
-
// 记录失败,后续可重试
|
| 493 |
-
img.dataset.retries = (parseInt(img.dataset.retries) || 0) + 1;
|
| 494 |
-
};
|
| 495 |
-
|
| 496 |
-
tempImg.src = src;
|
| 497 |
}
|
| 498 |
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
* @param {string} src - 图片真实地址
|
| 502 |
-
* @param {string} alt - 图片描述
|
| 503 |
-
* @param {Object} style - 样式对象
|
| 504 |
-
* @returns {HTMLImageElement}
|
| 505 |
-
*/
|
| 506 |
-
export function createLazyImage(src, alt = "", style = {}) {
|
| 507 |
-
_initLazyLoadObserver();
|
| 508 |
-
|
| 509 |
-
const img = document.createElement("img");
|
| 510 |
img.alt = alt;
|
| 511 |
-
img.
|
| 512 |
-
img
|
| 513 |
-
img.className = "lazy-image lazy-loading";
|
| 514 |
-
|
| 515 |
-
// 应用样式
|
| 516 |
-
Object.assign(img.style, {
|
| 517 |
-
transition: "opacity 0.3s ease",
|
| 518 |
-
...style
|
| 519 |
-
});
|
| 520 |
-
|
| 521 |
-
// 添加到观察队列
|
| 522 |
-
if (lazyLoadObserver) {
|
| 523 |
-
lazyLoadObserver.observe(img);
|
| 524 |
-
} else {
|
| 525 |
-
// 降级:直接加载
|
| 526 |
-
img.src = src;
|
| 527 |
-
}
|
| 528 |
-
|
| 529 |
return img;
|
| 530 |
}
|
| 531 |
|
| 532 |
-
/**
|
| 533 |
-
* 将现有图片转���为懒加载
|
| 534 |
-
* @param {HTMLImageElement} img - 已存在的图片元素
|
| 535 |
-
*/
|
| 536 |
export function makeLazy(img) {
|
| 537 |
if (!img.src || img.dataset.src) return;
|
| 538 |
-
|
| 539 |
-
_initLazyLoadObserver();
|
| 540 |
-
|
| 541 |
-
img.dataset.src = img.src;
|
| 542 |
-
img.src = PLACEHOLDER_IMAGE;
|
| 543 |
-
img.classList.add("lazy-image", "lazy-loading");
|
| 544 |
-
|
| 545 |
-
if (lazyLoadObserver) {
|
| 546 |
-
lazyLoadObserver.observe(img);
|
| 547 |
-
}
|
| 548 |
}
|
| 549 |
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
*/
|
| 555 |
-
export function lazyLoadImages(container, selector = "img") {
|
| 556 |
-
const images = container.querySelectorAll(selector);
|
| 557 |
-
images.forEach(img => {
|
| 558 |
-
// 跳过已处理的和 data URL
|
| 559 |
-
if (img.dataset.src || img.src.startsWith("data:")) return;
|
| 560 |
makeLazy(img);
|
| 561 |
-
}
|
| 562 |
}
|
| 563 |
|
| 564 |
|
|
|
|
| 15 |
// 🔒 P0安全优化:敏感数据脱敏
|
| 16 |
// ==========================================
|
| 17 |
|
| 18 |
+
import { API, PAGINATION, LAZY_IMAGES } from "../core/全局配置.js";
|
| 19 |
+
import { t } from "./用户体验_国际化.js";
|
| 20 |
// 📦 列表缓存工具需要本地可用的缓存实现;下方「向后兼容重导出」那行是纯转发、
|
| 21 |
// 不会生成本地绑定,故此处必须单独 import(勿误删,否则 readListCache 运行时报错)
|
| 22 |
import { setCache, getCacheWithMeta, removeCache, getCacheKeysByPrefix } from "../core/网络请求_缓存管理.js";
|
|
|
|
| 430 |
// 特点:
|
| 431 |
// - 使用 IntersectionObserver API
|
| 432 |
// - 支持占位图和加载动画
|
| 433 |
+
// - 节点移除、读取超时与迟到结果清理
|
| 434 |
|
| 435 |
+
const lazyImages = new Map();
|
| 436 |
+
let lazyLoadObserver = null;
|
| 437 |
+
let lazyRemovalObserver = null;
|
| 438 |
|
| 439 |
+
function lazyPlaceholder(failed = false) {
|
| 440 |
+
const text = t(failed ? 'image.load_failed' : 'image.loading');
|
| 441 |
+
const fill = failed ? '%23ffeaea' : '%23f0f0f0';
|
| 442 |
+
const color = failed ? '%23c44' : '%23999';
|
| 443 |
+
return `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'%3E%3Crect fill='${fill}' width='100' height='100'/%3E%3Ctext x='50' y='55' text-anchor='middle' fill='${color}' font-size='12'%3E${encodeURIComponent(text)}%3C/text%3E%3C/svg%3E`;
|
| 444 |
+
}
|
| 445 |
|
| 446 |
+
function releaseLazyImage(record, restore = false) {
|
| 447 |
+
const img = record.img;
|
| 448 |
+
if (!img || lazyImages.get(img) !== record) return;
|
| 449 |
+
if (restore && img.dataset.src === record.src && img.getAttribute('src') === record.placeholder) {
|
| 450 |
+
img.loading = 'lazy';
|
| 451 |
+
img.src = record.src;
|
| 452 |
+
delete img.dataset.src;
|
| 453 |
+
img.classList.remove('lazy-loading');
|
| 454 |
+
}
|
| 455 |
+
lazyImages.delete(img);
|
| 456 |
+
lazyLoadObserver?.unobserve(img);
|
| 457 |
+
if (record.timer !== null) clearTimeout(record.timer);
|
| 458 |
+
record.timer = null;
|
| 459 |
+
if (record.reader) {
|
| 460 |
+
record.reader.onload = record.reader.onerror = null;
|
| 461 |
+
record.reader.src = '';
|
| 462 |
+
record.reader = null;
|
| 463 |
+
}
|
| 464 |
+
if (img._lazyCleanup === record.cleanup) delete img._lazyCleanup;
|
| 465 |
+
record.img = null;
|
| 466 |
+
record.cleanup = null;
|
| 467 |
+
if (!lazyImages.size) {
|
| 468 |
+
lazyLoadObserver?.disconnect(); lazyLoadObserver = null;
|
| 469 |
+
lazyRemovalObserver?.disconnect(); lazyRemovalObserver = null;
|
| 470 |
+
}
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
function ownsLazyImage(record) {
|
| 474 |
+
const img = record.img;
|
| 475 |
+
return !!img && lazyImages.get(img) === record && img.isConnected &&
|
| 476 |
+
img.dataset.src === record.src && img.getAttribute('src') === record.placeholder;
|
| 477 |
+
}
|
| 478 |
|
|
|
|
|
|
|
|
|
|
| 479 |
function _initLazyLoadObserver() {
|
| 480 |
+
if (lazyLoadObserver || !('IntersectionObserver' in window)) return;
|
| 481 |
+
const observer = new IntersectionObserver(entries => {
|
| 482 |
+
if (lazyLoadObserver !== observer) return;
|
| 483 |
+
for (const entry of entries) {
|
| 484 |
+
if (entry.isIntersecting) _loadImage(entry.target);
|
| 485 |
+
}
|
| 486 |
+
}, { rootMargin: LAZY_IMAGES.ROOT_MARGIN, threshold: LAZY_IMAGES.THRESHOLD });
|
| 487 |
+
lazyLoadObserver = observer;
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
function watchLazyImages() {
|
| 491 |
+
if (lazyRemovalObserver) return;
|
| 492 |
+
const observer = new MutationObserver(mutations => {
|
| 493 |
+
if (lazyRemovalObserver !== observer) return;
|
| 494 |
+
for (const record of lazyImages.values()) {
|
| 495 |
+
const img = record.img;
|
| 496 |
+
if (mutations.some(mutation => [...mutation.addedNodes].some(node => node === img || node.contains?.(img)))) record.seen = true;
|
| 497 |
+
if (img.isConnected) {
|
| 498 |
+
record.seen = true;
|
| 499 |
+
if (!record.reader && record.timer !== null) { clearTimeout(record.timer); record.timer = null; }
|
| 500 |
}
|
| 501 |
+
if ((!img.isConnected && record.seen) || img.dataset.src !== record.src || img.getAttribute('src') !== record.placeholder) {
|
| 502 |
+
releaseLazyImage(record, !img.isConnected);
|
| 503 |
+
}
|
| 504 |
+
}
|
| 505 |
});
|
| 506 |
+
lazyRemovalObserver = observer;
|
| 507 |
+
observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['src', 'data-src'] });
|
| 508 |
}
|
| 509 |
|
| 510 |
+
function registerLazyImage(img, src) {
|
| 511 |
+
const previous = lazyImages.get(img);
|
| 512 |
+
if (previous) releaseLazyImage(previous);
|
| 513 |
+
_initLazyLoadObserver();
|
| 514 |
+
if (!lazyLoadObserver) {
|
| 515 |
+
img.classList.add('lazy-image');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
img.src = src;
|
|
|
|
|
|
|
| 517 |
delete img.dataset.src;
|
| 518 |
+
img.classList.remove('lazy-loading', 'lazy-error');
|
| 519 |
+
return;
|
| 520 |
+
}
|
| 521 |
+
const placeholder = lazyPlaceholder();
|
| 522 |
+
img.dataset.src = src;
|
| 523 |
+
img.src = placeholder;
|
| 524 |
+
img.classList.remove('lazy-loaded', 'lazy-error');
|
| 525 |
+
img.classList.add('lazy-image', 'lazy-loading');
|
| 526 |
+
const record = { img, src, placeholder, seen: img.isConnected, reader: null, timer: null, cleanup: null };
|
| 527 |
+
record.cleanup = () => releaseLazyImage(record, true);
|
| 528 |
+
img._lazyCleanup = record.cleanup;
|
| 529 |
+
lazyImages.set(img, record);
|
| 530 |
+
watchLazyImages();
|
| 531 |
+
if (!record.seen) record.timer = setTimeout(() => {
|
| 532 |
+
if (ownsLazyImage(record)) { record.seen = true; record.timer = null; }
|
| 533 |
+
else {
|
| 534 |
+
const unmounted = !record.seen && img.dataset.src === src && img.getAttribute('src') === placeholder;
|
| 535 |
+
releaseLazyImage(record);
|
| 536 |
+
if (unmounted) { img.src = src; delete img.dataset.src; img.classList.remove('lazy-loading'); }
|
| 537 |
+
}
|
| 538 |
+
}, LAZY_IMAGES.MOUNT_TIMEOUT);
|
| 539 |
+
lazyLoadObserver.observe(img);
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
function _loadImage(img) {
|
| 543 |
+
const record = lazyImages.get(img);
|
| 544 |
+
if (!record || record.reader) return;
|
| 545 |
+
if (!ownsLazyImage(record)) {
|
| 546 |
+
if (record.seen || img.dataset.src !== record.src || img.getAttribute('src') !== record.placeholder) releaseLazyImage(record, !img.isConnected);
|
| 547 |
+
return;
|
| 548 |
+
}
|
| 549 |
+
record.seen = true;
|
| 550 |
+
lazyLoadObserver?.unobserve(img);
|
| 551 |
+
if (record.timer !== null) clearTimeout(record.timer);
|
| 552 |
+
const reader = new Image();
|
| 553 |
+
record.reader = reader;
|
| 554 |
+
const finish = failed => {
|
| 555 |
+
if (!ownsLazyImage(record)) { releaseLazyImage(record, !img.isConnected); return; }
|
| 556 |
+
const src = record.src;
|
| 557 |
+
releaseLazyImage(record);
|
| 558 |
+
img.src = failed ? lazyPlaceholder(true) : src;
|
| 559 |
+
img.classList.remove('lazy-loading');
|
| 560 |
+
img.classList.add(failed ? 'lazy-error' : 'lazy-loaded');
|
| 561 |
+
if (failed) img.dataset.retries = (parseInt(img.dataset.retries) || 0) + 1;
|
| 562 |
+
else delete img.dataset.src;
|
| 563 |
};
|
| 564 |
+
reader.onload = () => finish(false);
|
| 565 |
+
reader.onerror = () => finish(true);
|
| 566 |
+
record.timer = setTimeout(() => finish(true), LAZY_IMAGES.READ_TIMEOUT);
|
| 567 |
+
reader.src = record.src;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 568 |
}
|
| 569 |
|
| 570 |
+
export function createLazyImage(src, alt = '', style = {}) {
|
| 571 |
+
const img = document.createElement('img');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 572 |
img.alt = alt;
|
| 573 |
+
Object.assign(img.style, { transition: 'opacity 0.3s ease', ...style });
|
| 574 |
+
registerLazyImage(img, src);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 575 |
return img;
|
| 576 |
}
|
| 577 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 578 |
export function makeLazy(img) {
|
| 579 |
if (!img.src || img.dataset.src) return;
|
| 580 |
+
registerLazyImage(img, img.src);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
}
|
| 582 |
|
| 583 |
+
export function lazyLoadImages(container, selector = 'img') {
|
| 584 |
+
for (const img of container.querySelectorAll(selector)) {
|
| 585 |
+
if (img.classList.contains('target-img') && img.closest('.img-viewport')?._sandboxCleanup) continue;
|
| 586 |
+
if (img.dataset.src || img.src.startsWith('data:')) continue;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 587 |
makeLazy(img);
|
| 588 |
+
}
|
| 589 |
}
|
| 590 |
|
| 591 |
|
前端页面/components/用户体验_国际化.js
CHANGED
|
@@ -206,6 +206,21 @@ const translations = {
|
|
| 206 |
// 设置页面
|
| 207 |
'settings.title': '界面设置',
|
| 208 |
'settings.general': '通用设置',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
'settings.display': '显示偏好',
|
| 210 |
'settings.animation': '动画与音效',
|
| 211 |
'settings.language': '界面语言',
|
|
@@ -478,7 +493,7 @@ const translations = {
|
|
| 478 |
'wallet.withdraw.fill_account': '请完整填写收款人支付宝账号与真实姓名!',
|
| 479 |
'wallet.withdraw.invalid_code': '请输入 6 位有效验证码!',
|
| 480 |
'wallet.withdraw.code_sent': '验证码已发送,请查收邮箱 (注意检查垃圾箱)',
|
| 481 |
-
'wallet.withdraw.code_failed': '发送
|
| 482 |
'wallet.withdraw.success': '🎉 提现申请提交成功!预计 1-3 个工作日内打款至您的支付宝。',
|
| 483 |
'wallet.withdraw.submit_failed': '提交失败: ',
|
| 484 |
'wallet.withdraw.fee_free_quota': '🎉 您目前享有平台 100元 内免手续费优惠福利!',
|
|
@@ -562,6 +577,7 @@ const translations = {
|
|
| 562 |
'social.tip_author': '赞赏作者',
|
| 563 |
'social.comment_refresh_required': '评论操作已完成,请重新打开当前页面更新显示后再操作。',
|
| 564 |
'social.comment_unconfirmed': '评论操作结果尚未确认,请重新打开当前页面核对后再操作。',
|
|
|
|
| 565 |
'social.say_something': '说点什么...',
|
| 566 |
'social.reply': '回复',
|
| 567 |
'social.reply_to': '回复',
|
|
@@ -1156,6 +1172,7 @@ const translations = {
|
|
| 1156 |
'prompt.text_placeholder': '输入完整提示词,发布后仅购买者可查看...',
|
| 1157 |
'prompt.price_label': '定价',
|
| 1158 |
'prompt.price_invalid': '请输入 0–{max} 的整数价格。',
|
|
|
|
| 1159 |
'prompt.video_label': '示例视频',
|
| 1160 |
'prompt.title_required': '请输入标题',
|
| 1161 |
'prompt.content_required': '请输入介绍文案',
|
|
@@ -1277,6 +1294,10 @@ const translations = {
|
|
| 1277 |
'image.load_failed': '图片加载失败',
|
| 1278 |
'image.load_timeout': '加载超时',
|
| 1279 |
'image.gallery': '效果展示图',
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1280 |
'image.viewer.fullscreen': '全屏播放',
|
| 1281 |
'image.viewer.load_failed': '图像播放器加载失败,请刷新页面重试',
|
| 1282 |
'image.viewer.close': '关闭 (ESC)',
|
|
@@ -1940,6 +1961,21 @@ const translations = {
|
|
| 1940 |
// Settings
|
| 1941 |
'settings.title': 'Settings',
|
| 1942 |
'settings.general': 'General',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1943 |
'settings.display': 'Display',
|
| 1944 |
'settings.animation': 'Animation & Sound',
|
| 1945 |
'settings.language': 'Language',
|
|
@@ -2212,7 +2248,7 @@ const translations = {
|
|
| 2212 |
'wallet.withdraw.fill_account': 'Please fill in both Alipay account and real name!',
|
| 2213 |
'wallet.withdraw.invalid_code': 'Please enter a valid 6-digit code!',
|
| 2214 |
'wallet.withdraw.code_sent': 'Code sent! Please check your email (including spam folder)',
|
| 2215 |
-
'wallet.withdraw.code_failed': '
|
| 2216 |
'wallet.withdraw.success': '🎉 Withdrawal submitted! Funds will be transferred within 1-3 business days.',
|
| 2217 |
'wallet.withdraw.submit_failed': 'Submit failed: ',
|
| 2218 |
'wallet.withdraw.fee_free_quota': '🎉 You enjoy fee-free withdrawal up to 100 CNY!',
|
|
@@ -2297,6 +2333,7 @@ const translations = {
|
|
| 2297 |
'social.tip_author': 'Tip Author',
|
| 2298 |
'social.comment_refresh_required': 'The comment action completed. Reopen this page to update the display before another action.',
|
| 2299 |
'social.comment_unconfirmed': 'The comment result is unconfirmed. Reopen this page to check before another action.',
|
|
|
|
| 2300 |
'social.say_something': 'Say something...',
|
| 2301 |
'social.reply_to': 'Reply to',
|
| 2302 |
'social.comment_deleted': 'This comment has been deleted',
|
|
@@ -2967,6 +3004,7 @@ const translations = {
|
|
| 2967 |
'prompt.text_placeholder': 'Enter the full prompt, only visible to buyers after publishing...',
|
| 2968 |
'prompt.price_label': 'Price',
|
| 2969 |
'prompt.price_invalid': 'Enter a whole-number price from 0 to {max}.',
|
|
|
|
| 2970 |
'prompt.video_label': 'Sample Video',
|
| 2971 |
'prompt.title_required': 'Please enter a title',
|
| 2972 |
'prompt.content_required': 'Please enter the intro',
|
|
@@ -3103,6 +3141,10 @@ const translations = {
|
|
| 3103 |
'image.load_failed': 'Image load failed',
|
| 3104 |
'image.load_timeout': 'Load timeout',
|
| 3105 |
'image.gallery': 'Gallery',
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3106 |
'image.viewer.fullscreen': 'Open fullscreen viewer',
|
| 3107 |
'image.viewer.load_failed': 'Could not load the image viewer. Refresh and try again.',
|
| 3108 |
'image.viewer.close': 'Close (Esc)',
|
|
|
|
| 206 |
// 设置页面
|
| 207 |
'settings.title': '界面设置',
|
| 208 |
'settings.general': '通用设置',
|
| 209 |
+
'settings.shortcuts': '快捷键',
|
| 210 |
+
'settings.keyboard_combinations': '启用插件组合键',
|
| 211 |
+
'settings.keyboard_combinations_desc': '默认关闭,避免与 ComfyUI 冲突。开启后,仅在对应控件聚焦时生效。',
|
| 212 |
+
'settings.shortcuts_controls': '控件之间移动',
|
| 213 |
+
'settings.shortcuts_activate': '激活已聚焦的按钮',
|
| 214 |
+
'settings.shortcuts_tabs': '聚焦榜单标签后切换标签',
|
| 215 |
+
'settings.shortcuts_images': '聚焦发布图片后,在已有图片或新图片组内排序;需开启组合键',
|
| 216 |
+
'settings.shortcuts_escape': '关闭当前媒体或弹窗,仅在其内部生效',
|
| 217 |
+
'settings.shortcuts_scope': '基础键盘操作始终保留。插件不提供全局保存快捷键,不接管 ComfyUI 画布按键。',
|
| 218 |
+
'settings.shortcuts_tab_keys': 'Tab / Shift+Tab',
|
| 219 |
+
'settings.shortcuts_activate_keys': 'Enter / Space',
|
| 220 |
+
'settings.shortcuts_image_keys': 'Alt+← / Alt+→',
|
| 221 |
+
'settings.shortcuts_escape_key': 'Escape',
|
| 222 |
+
'settings.shortcuts_arrows': '← / → / Home / End',
|
| 223 |
+
'post.image_drag_hint': '可拖拽排序,首张图片为封面。',
|
| 224 |
'settings.display': '显示偏好',
|
| 225 |
'settings.animation': '动画与音效',
|
| 226 |
'settings.language': '界面语言',
|
|
|
|
| 493 |
'wallet.withdraw.fill_account': '请完整填写收款人支付宝账号与真实姓名!',
|
| 494 |
'wallet.withdraw.invalid_code': '请输入 6 位有效验证码!',
|
| 495 |
'wallet.withdraw.code_sent': '验证码已发送,请查收邮箱 (注意检查垃圾箱)',
|
| 496 |
+
'wallet.withdraw.code_failed': '暂时无法确认验证码发送,请稍后重试。',
|
| 497 |
'wallet.withdraw.success': '🎉 提现申请提交成功!预计 1-3 个工作日内打款至您的支付宝。',
|
| 498 |
'wallet.withdraw.submit_failed': '提交失败: ',
|
| 499 |
'wallet.withdraw.fee_free_quota': '🎉 您目前享有平台 100元 内免手续费优惠福利!',
|
|
|
|
| 577 |
'social.tip_author': '赞赏作者',
|
| 578 |
'social.comment_refresh_required': '评论操作已完成,请重新打开当前页面更新显示后再操作。',
|
| 579 |
'social.comment_unconfirmed': '评论操作结果尚未确认,请重新打开当前页面核对后再操作。',
|
| 580 |
+
'social.comment_pending': '评论结果待确认',
|
| 581 |
'social.say_something': '说点什么...',
|
| 582 |
'social.reply': '回复',
|
| 583 |
'social.reply_to': '回复',
|
|
|
|
| 1172 |
'prompt.text_placeholder': '输入完整提示词,发布后仅购买者可查看...',
|
| 1173 |
'prompt.price_label': '定价',
|
| 1174 |
'prompt.price_invalid': '请输入 0–{max} 的整数价格。',
|
| 1175 |
+
'prompt.price_unknown': '价格暂时无法确认,请重新打开详情后重试。',
|
| 1176 |
'prompt.video_label': '示例视频',
|
| 1177 |
'prompt.title_required': '请输入标题',
|
| 1178 |
'prompt.content_required': '请输入介绍文案',
|
|
|
|
| 1294 |
'image.load_failed': '图片加载失败',
|
| 1295 |
'image.load_timeout': '加载超时',
|
| 1296 |
'image.gallery': '效果展示图',
|
| 1297 |
+
'image.zoom_in': '放大图片',
|
| 1298 |
+
'image.zoom_out': '缩小图片',
|
| 1299 |
+
'image.pan_horizontal': '水平移动图片',
|
| 1300 |
+
'image.pan_vertical': '垂直移动图片',
|
| 1301 |
'image.viewer.fullscreen': '全屏播放',
|
| 1302 |
'image.viewer.load_failed': '图像播放器加载失败,请刷新页面重试',
|
| 1303 |
'image.viewer.close': '关闭 (ESC)',
|
|
|
|
| 1961 |
// Settings
|
| 1962 |
'settings.title': 'Settings',
|
| 1963 |
'settings.general': 'General',
|
| 1964 |
+
'settings.shortcuts': 'Keyboard shortcuts',
|
| 1965 |
+
'settings.keyboard_combinations': 'Enable plugin key combinations',
|
| 1966 |
+
'settings.keyboard_combinations_desc': 'Off by default to avoid ComfyUI conflicts. When enabled, combinations work only in the focused plugin control.',
|
| 1967 |
+
'settings.shortcuts_controls': 'Move between controls',
|
| 1968 |
+
'settings.shortcuts_activate': 'Activate the focused button',
|
| 1969 |
+
'settings.shortcuts_tabs': 'Navigate focused ranking tabs',
|
| 1970 |
+
'settings.shortcuts_images': 'Reorder focused publishing images within the existing or new image group; requires key combinations',
|
| 1971 |
+
'settings.shortcuts_escape': 'Close the current media viewer or dialog, only while focus is inside it',
|
| 1972 |
+
'settings.shortcuts_scope': 'Standard keyboard controls stay available. The plugin has no global save shortcut and does not capture ComfyUI canvas keys.',
|
| 1973 |
+
'settings.shortcuts_tab_keys': 'Tab / Shift+Tab',
|
| 1974 |
+
'settings.shortcuts_activate_keys': 'Enter / Space',
|
| 1975 |
+
'settings.shortcuts_image_keys': 'Alt+← / Alt+→',
|
| 1976 |
+
'settings.shortcuts_escape_key': 'Escape',
|
| 1977 |
+
'settings.shortcuts_arrows': '← / → / Home / End',
|
| 1978 |
+
'post.image_drag_hint': 'Drag to reorder. The first image is the cover.',
|
| 1979 |
'settings.display': 'Display',
|
| 1980 |
'settings.animation': 'Animation & Sound',
|
| 1981 |
'settings.language': 'Language',
|
|
|
|
| 2248 |
'wallet.withdraw.fill_account': 'Please fill in both Alipay account and real name!',
|
| 2249 |
'wallet.withdraw.invalid_code': 'Please enter a valid 6-digit code!',
|
| 2250 |
'wallet.withdraw.code_sent': 'Code sent! Please check your email (including spam folder)',
|
| 2251 |
+
'wallet.withdraw.code_failed': 'Code delivery could not be confirmed. Try again later.',
|
| 2252 |
'wallet.withdraw.success': '🎉 Withdrawal submitted! Funds will be transferred within 1-3 business days.',
|
| 2253 |
'wallet.withdraw.submit_failed': 'Submit failed: ',
|
| 2254 |
'wallet.withdraw.fee_free_quota': '🎉 You enjoy fee-free withdrawal up to 100 CNY!',
|
|
|
|
| 2333 |
'social.tip_author': 'Tip Author',
|
| 2334 |
'social.comment_refresh_required': 'The comment action completed. Reopen this page to update the display before another action.',
|
| 2335 |
'social.comment_unconfirmed': 'The comment result is unconfirmed. Reopen this page to check before another action.',
|
| 2336 |
+
'social.comment_pending': 'Comment result unconfirmed',
|
| 2337 |
'social.say_something': 'Say something...',
|
| 2338 |
'social.reply_to': 'Reply to',
|
| 2339 |
'social.comment_deleted': 'This comment has been deleted',
|
|
|
|
| 3004 |
'prompt.text_placeholder': 'Enter the full prompt, only visible to buyers after publishing...',
|
| 3005 |
'prompt.price_label': 'Price',
|
| 3006 |
'prompt.price_invalid': 'Enter a whole-number price from 0 to {max}.',
|
| 3007 |
+
'prompt.price_unknown': 'The price is unavailable. Reopen the details and try again.',
|
| 3008 |
'prompt.video_label': 'Sample Video',
|
| 3009 |
'prompt.title_required': 'Please enter a title',
|
| 3010 |
'prompt.content_required': 'Please enter the intro',
|
|
|
|
| 3141 |
'image.load_failed': 'Image load failed',
|
| 3142 |
'image.load_timeout': 'Load timeout',
|
| 3143 |
'image.gallery': 'Gallery',
|
| 3144 |
+
'image.zoom_in': 'Zoom in',
|
| 3145 |
+
'image.zoom_out': 'Zoom out',
|
| 3146 |
+
'image.pan_horizontal': 'Move image horizontally',
|
| 3147 |
+
'image.pan_vertical': 'Move image vertically',
|
| 3148 |
'image.viewer.fullscreen': 'Open fullscreen viewer',
|
| 3149 |
'image.viewer.load_failed': 'Could not load the image viewer. Refresh and try again.',
|
| 3150 |
'image.viewer.close': 'Close (Esc)',
|
前端页面/components/用户体验增强.js
CHANGED
|
@@ -10,6 +10,8 @@
|
|
| 10 |
// - 用户体验_国际化.js (i18n 翻译字典)
|
| 11 |
// ==========================================
|
| 12 |
|
|
|
|
|
|
|
| 13 |
import { t, initI18n } from './用户体验_国际化.js';
|
| 14 |
|
| 15 |
|
|
@@ -231,7 +233,7 @@ export function registerShortcut(key, handler, options = {}) {
|
|
| 231 |
if (event.target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])')
|
| 232 |
&& event.key !== 'Escape') return;
|
| 233 |
const combo = `${event.ctrlKey || event.metaKey ? 'Ctrl+' : ''}${event.altKey ? 'Alt+' : ''}${event.shiftKey ? 'Shift+' : ''}${event.key}`;
|
| 234 |
-
if (combo !== key) return;
|
| 235 |
if (preventDefault) event.preventDefault();
|
| 236 |
event.stopPropagation();
|
| 237 |
handler(event);
|
|
|
|
| 10 |
// - 用户体验_国际化.js (i18n 翻译字典)
|
| 11 |
// ==========================================
|
| 12 |
|
| 13 |
+
import { areKeyboardShortcutsEnabled } from '../core/全局配置.js';
|
| 14 |
+
|
| 15 |
import { t, initI18n } from './用户体验_国际化.js';
|
| 16 |
|
| 17 |
|
|
|
|
| 233 |
if (event.target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])')
|
| 234 |
&& event.key !== 'Escape') return;
|
| 235 |
const combo = `${event.ctrlKey || event.metaKey ? 'Ctrl+' : ''}${event.altKey ? 'Alt+' : ''}${event.shiftKey ? 'Shift+' : ''}${event.key}`;
|
| 236 |
+
if (combo !== key || ((event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) && !areKeyboardShortcutsEnabled())) return;
|
| 237 |
if (preventDefault) event.preventDefault();
|
| 238 |
event.stopPropagation();
|
| 239 |
handler(event);
|
前端页面/core/侧边栏主程序.js
CHANGED
|
@@ -124,6 +124,16 @@ async function loadPublishFactory(kind) {
|
|
| 124 |
throw new Error('Invalid publish view');
|
| 125 |
}
|
| 126 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
export function buildSidebarDOM() {
|
| 128 |
// 🔧 P1修复:重建前先清理上一轮的 window 监听,避免重复注册
|
| 129 |
_cleanupSidebarWindowListeners();
|
|
@@ -132,13 +142,8 @@ export function buildSidebarDOM() {
|
|
| 132 |
container.className = "comfy-ranking-root";
|
| 133 |
|
| 134 |
// 加载本地背景图
|
| 135 |
-
|
| 136 |
-
const bgStyle = savedBg
|
| 137 |
-
? `background-image: url(${savedBg}); background-size: cover; background-position: center;`
|
| 138 |
-
: `background-color: var(--comfy-menu-bg);`;
|
| 139 |
|
| 140 |
-
container.style.cssText += bgStyle;
|
| 141 |
-
|
| 142 |
const topNav = createTopNav();
|
| 143 |
const sidebarInstanceId = `comfy-ranking-${++_sidebarInstanceSequence}`;
|
| 144 |
|
|
@@ -293,16 +298,7 @@ export function buildSidebarDOM() {
|
|
| 293 |
|
| 294 |
// 监听从设置页面触发的背景更新事件
|
| 295 |
_onSidebarWindow("comfy-sidebar-bg-update", () => {
|
| 296 |
-
|
| 297 |
-
if (newBg) {
|
| 298 |
-
container.style.backgroundImage = `url(${newBg})`;
|
| 299 |
-
container.style.backgroundSize = "cover";
|
| 300 |
-
container.style.backgroundPosition = "center";
|
| 301 |
-
container.style.backgroundColor = "transparent";
|
| 302 |
-
} else {
|
| 303 |
-
container.style.backgroundImage = "none";
|
| 304 |
-
container.style.backgroundColor = "var(--comfy-menu-bg)";
|
| 305 |
-
}
|
| 306 |
});
|
| 307 |
// =========================================================================
|
| 308 |
|
|
|
|
| 124 |
throw new Error('Invalid publish view');
|
| 125 |
}
|
| 126 |
|
| 127 |
+
function _applySidebarBackground(container, value) {
|
| 128 |
+
const valid = typeof value === "string" && value.trim() && !/[\u0000-\u001f\u007f]/.test(value);
|
| 129 |
+
container.style.backgroundImage = valid ? `url(${JSON.stringify(value)})` : "none";
|
| 130 |
+
container.style.backgroundColor = valid ? "transparent" : "var(--comfy-menu-bg)";
|
| 131 |
+
if (valid) {
|
| 132 |
+
container.style.backgroundSize = "cover";
|
| 133 |
+
container.style.backgroundPosition = "center";
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
export function buildSidebarDOM() {
|
| 138 |
// 🔧 P1修复:重建前先清理上一轮的 window 监听,避免重复注册
|
| 139 |
_cleanupSidebarWindowListeners();
|
|
|
|
| 142 |
container.className = "comfy-ranking-root";
|
| 143 |
|
| 144 |
// 加载本地背景图
|
| 145 |
+
_applySidebarBackground(container, BackgroundStore.load());
|
|
|
|
|
|
|
|
|
|
| 146 |
|
|
|
|
|
|
|
| 147 |
const topNav = createTopNav();
|
| 148 |
const sidebarInstanceId = `comfy-ranking-${++_sidebarInstanceSequence}`;
|
| 149 |
|
|
|
|
| 298 |
|
| 299 |
// 监听从设置页面触发的背景更新事件
|
| 300 |
_onSidebarWindow("comfy-sidebar-bg-update", () => {
|
| 301 |
+
_applySidebarBackground(container, BackgroundStore.load());
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
});
|
| 303 |
// =========================================================================
|
| 304 |
|
前端页面/core/全局配置.js
CHANGED
|
@@ -104,6 +104,13 @@ export const API = {
|
|
| 104 |
|
| 105 |
|
| 106 |
// 发布视频的本地读取预算,不改变云端上传额度。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
export const PUBLISH_MEDIA_LIMITS = Object.freeze({
|
| 108 |
MAX_IMAGES: 9,
|
| 109 |
IMAGE_MAX_EDGE: 1920,
|
|
@@ -809,6 +816,7 @@ const SETTINGS_KEY = "ComfyCommunity_Settings";
|
|
| 809 |
|
| 810 |
// 🔧 默认设置值
|
| 811 |
const DEFAULT_SETTINGS = {
|
|
|
|
| 812 |
showCreatorBanner: true, // 创作者卡片显示背景图
|
| 813 |
enableAnimations: true, // ✨ 榜单切换动画
|
| 814 |
enableSoundEffects: true, // 🔊 动画音效
|
|
@@ -822,23 +830,30 @@ const DEFAULT_SETTINGS = {
|
|
| 822 |
/**
|
| 823 |
* 📦 获取当前设置
|
| 824 |
*/
|
| 825 |
-
export function getSettings() {
|
| 826 |
try {
|
| 827 |
const saved = localStorage.getItem(SETTINGS_KEY);
|
| 828 |
if (saved) {
|
| 829 |
-
|
|
|
|
|
|
|
| 830 |
}
|
| 831 |
-
} catch (e) {}
|
| 832 |
return { ...DEFAULT_SETTINGS };
|
| 833 |
}
|
| 834 |
|
| 835 |
/**
|
| 836 |
* 💾 保存设置
|
| 837 |
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
| 838 |
export function saveSettings(settings) {
|
| 839 |
try {
|
| 840 |
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
| 841 |
// 🔔 广播设置变更事件,让其他组件响应
|
| 842 |
window.dispatchEvent(new CustomEvent("comfy-settings-changed", { detail: settings }));
|
| 843 |
-
|
|
|
|
| 844 |
}
|
|
|
|
| 104 |
|
| 105 |
|
| 106 |
// 发布视频的本地读取预算,不改变云端上传额度。
|
| 107 |
+
export const LAZY_IMAGES = Object.freeze({
|
| 108 |
+
ROOT_MARGIN: "200px 0px",
|
| 109 |
+
THRESHOLD: 0.01,
|
| 110 |
+
MOUNT_TIMEOUT: 30000,
|
| 111 |
+
READ_TIMEOUT: 30000
|
| 112 |
+
});
|
| 113 |
+
|
| 114 |
export const PUBLISH_MEDIA_LIMITS = Object.freeze({
|
| 115 |
MAX_IMAGES: 9,
|
| 116 |
IMAGE_MAX_EDGE: 1920,
|
|
|
|
| 816 |
|
| 817 |
// 🔧 默认设置值
|
| 818 |
const DEFAULT_SETTINGS = {
|
| 819 |
+
enableKeyboardShortcuts: false,
|
| 820 |
showCreatorBanner: true, // 创作者卡片显示背景图
|
| 821 |
enableAnimations: true, // ✨ 榜单切换动画
|
| 822 |
enableSoundEffects: true, // 🔊 动画音效
|
|
|
|
| 830 |
/**
|
| 831 |
* 📦 获取当前设置
|
| 832 |
*/
|
| 833 |
+
export function getSettings(options = {}) {
|
| 834 |
try {
|
| 835 |
const saved = localStorage.getItem(SETTINGS_KEY);
|
| 836 |
if (saved) {
|
| 837 |
+
const value = JSON.parse(saved);
|
| 838 |
+
if (options.strict && (!value || typeof value !== 'object' || Array.isArray(value))) throw new Error('Invalid settings');
|
| 839 |
+
return { ...DEFAULT_SETTINGS, ...value };
|
| 840 |
}
|
| 841 |
+
} catch (e) { if (options.strict) throw e; }
|
| 842 |
return { ...DEFAULT_SETTINGS };
|
| 843 |
}
|
| 844 |
|
| 845 |
/**
|
| 846 |
* 💾 保存设置
|
| 847 |
*/
|
| 848 |
+
export function areKeyboardShortcutsEnabled() {
|
| 849 |
+
return getSettings().enableKeyboardShortcuts === true;
|
| 850 |
+
}
|
| 851 |
+
|
| 852 |
export function saveSettings(settings) {
|
| 853 |
try {
|
| 854 |
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
| 855 |
// 🔔 广播设置变更事件,让其他组件响应
|
| 856 |
window.dispatchEvent(new CustomEvent("comfy-settings-changed", { detail: settings }));
|
| 857 |
+
return true;
|
| 858 |
+
} catch (e) { return false; }
|
| 859 |
}
|
前端页面/core/网络请求_缓存管理.js
CHANGED
|
@@ -300,9 +300,9 @@ export function getCacheKeysByPrefix(prefix) {
|
|
| 300 |
function _touchStorageEntry(key, cached) {
|
| 301 |
if (memoryOnlyEntries.has(cached)) return;
|
| 302 |
try {
|
| 303 |
-
if (localStorage.getItem(key) === null) return;
|
| 304 |
const now = Date.now();
|
| 305 |
if (Number.isFinite(cached.lastAccessAt) && now - cached.lastAccessAt < 60_000) return;
|
|
|
|
| 306 |
cached.lastAccessAt = now;
|
| 307 |
localStorage.setItem(key, JSON.stringify(cached));
|
| 308 |
} catch {}
|
|
@@ -394,7 +394,12 @@ export function removeCache(key) {
|
|
| 394 |
*/
|
| 395 |
export function removeCacheByPrefix(prefix) {
|
| 396 |
if (!prefix) return 0;
|
| 397 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
const isMatch = (key) => candidates.some(p => key.startsWith(p));
|
| 399 |
const removed = new Set();
|
| 400 |
|
|
@@ -474,8 +479,11 @@ function _cleanExpiredStorage(aggressive = false) {
|
|
| 474 |
// 激进模式:清除所有缓存项
|
| 475 |
keysToRemove.push(key);
|
| 476 |
} else {
|
|
|
|
|
|
|
|
|
|
| 477 |
try {
|
| 478 |
-
const cached = JSON.parse(
|
| 479 |
if (cached.expireAt < now) {
|
| 480 |
keysToRemove.push(key);
|
| 481 |
}
|
|
@@ -496,24 +504,29 @@ function _cleanExpiredStorage(aggressive = false) {
|
|
| 496 |
function _evictOldestStorage() {
|
| 497 |
try {
|
| 498 |
const entries = [];
|
|
|
|
| 499 |
for (let i = 0; i < localStorage.length; i++) {
|
| 500 |
const key = localStorage.key(i);
|
| 501 |
if (_isManagedStorageKey(key)) {
|
|
|
|
|
|
|
|
|
|
| 502 |
try {
|
| 503 |
-
const cached = JSON.parse(
|
| 504 |
if (!_isValidCacheEntry(cached)) {
|
| 505 |
-
|
| 506 |
continue;
|
| 507 |
}
|
| 508 |
const writtenAt = Number.isFinite(cached.expireAt) ? cached.expireAt - DEFAULT_TTL : 0;
|
| 509 |
entries.push({ key, lastAccessAt: Number.isFinite(cached.lastAccessAt) ? cached.lastAccessAt : writtenAt });
|
| 510 |
} catch {
|
| 511 |
-
|
| 512 |
}
|
| 513 |
}
|
| 514 |
}
|
| 515 |
|
| 516 |
-
|
|
|
|
| 517 |
|
| 518 |
entries.sort((a, b) => a.lastAccessAt - b.lastAccessAt);
|
| 519 |
|
|
@@ -702,11 +715,8 @@ export function invalidateRelatedCache(endpoint, method = null) {
|
|
| 702 |
// 🔧 同时修复:原实现无条件给 pattern 补 CACHE.PREFIX,使上方映射表中的裸键前缀
|
| 703 |
// (ComfyCommunity_ProfileCache_,SWR 头像缓存直接写 localStorage、不带前缀)被拼成
|
| 704 |
// ComfyRanking_ComfyCommunity_ProfileCache_,永远匹配不到,改头像/昵称后该缓存清不掉。
|
| 705 |
-
//
|
| 706 |
-
|
| 707 |
-
for (const pattern of patterns) {
|
| 708 |
-
cleared += removeCacheByPrefix(pattern);
|
| 709 |
-
}
|
| 710 |
|
| 711 |
if (cleared > 0) {
|
| 712 |
console.log(`🗑️ 精确清除缓存: ${cleared} 个项`);
|
|
|
|
| 300 |
function _touchStorageEntry(key, cached) {
|
| 301 |
if (memoryOnlyEntries.has(cached)) return;
|
| 302 |
try {
|
|
|
|
| 303 |
const now = Date.now();
|
| 304 |
if (Number.isFinite(cached.lastAccessAt) && now - cached.lastAccessAt < 60_000) return;
|
| 305 |
+
if (localStorage.getItem(key) === null) return;
|
| 306 |
cached.lastAccessAt = now;
|
| 307 |
localStorage.setItem(key, JSON.stringify(cached));
|
| 308 |
} catch {}
|
|
|
|
| 394 |
*/
|
| 395 |
export function removeCacheByPrefix(prefix) {
|
| 396 |
if (!prefix) return 0;
|
| 397 |
+
return _removeCacheByPrefixes([prefix]);
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
function _removeCacheByPrefixes(prefixes) {
|
| 401 |
+
const candidates = [...new Set(prefixes.flatMap(prefix =>
|
| 402 |
+
prefix.startsWith(CACHE_PREFIX) ? [prefix] : [_fullKey(prefix), prefix]))];
|
| 403 |
const isMatch = (key) => candidates.some(p => key.startsWith(p));
|
| 404 |
const removed = new Set();
|
| 405 |
|
|
|
|
| 479 |
// 激进模式:清除所有缓存项
|
| 480 |
keysToRemove.push(key);
|
| 481 |
} else {
|
| 482 |
+
let stored;
|
| 483 |
+
try { stored = localStorage.getItem(key); } catch { continue; }
|
| 484 |
+
if (stored === null) continue;
|
| 485 |
try {
|
| 486 |
+
const cached = JSON.parse(stored);
|
| 487 |
if (cached.expireAt < now) {
|
| 488 |
keysToRemove.push(key);
|
| 489 |
}
|
|
|
|
| 504 |
function _evictOldestStorage() {
|
| 505 |
try {
|
| 506 |
const entries = [];
|
| 507 |
+
const invalidKeys = [];
|
| 508 |
for (let i = 0; i < localStorage.length; i++) {
|
| 509 |
const key = localStorage.key(i);
|
| 510 |
if (_isManagedStorageKey(key)) {
|
| 511 |
+
let stored;
|
| 512 |
+
try { stored = localStorage.getItem(key); } catch { continue; }
|
| 513 |
+
if (stored === null) continue;
|
| 514 |
try {
|
| 515 |
+
const cached = JSON.parse(stored);
|
| 516 |
if (!_isValidCacheEntry(cached)) {
|
| 517 |
+
invalidKeys.push(key);
|
| 518 |
continue;
|
| 519 |
}
|
| 520 |
const writtenAt = Number.isFinite(cached.expireAt) ? cached.expireAt - DEFAULT_TTL : 0;
|
| 521 |
entries.push({ key, lastAccessAt: Number.isFinite(cached.lastAccessAt) ? cached.lastAccessAt : writtenAt });
|
| 522 |
} catch {
|
| 523 |
+
invalidKeys.push(key);
|
| 524 |
}
|
| 525 |
}
|
| 526 |
}
|
| 527 |
|
| 528 |
+
invalidKeys.forEach(key => localStorage.removeItem(key));
|
| 529 |
+
if (entries.length === 0) return invalidKeys.length > 0;
|
| 530 |
|
| 531 |
entries.sort((a, b) => a.lastAccessAt - b.lastAccessAt);
|
| 532 |
|
|
|
|
| 715 |
// 🔧 同时修复:原实现无条件给 pattern 补 CACHE.PREFIX,使上方映射表中的裸键前缀
|
| 716 |
// (ComfyCommunity_ProfileCache_,SWR 头像缓存直接写 localStorage、不带前缀)被拼成
|
| 717 |
// ComfyRanking_ComfyCommunity_ProfileCache_,永远匹配不到,改头像/昵称后该缓存清不掉。
|
| 718 |
+
// 复用两级前缀删除逻辑;同次失效合并为一次存储遍历,兼容带前缀与裸键。
|
| 719 |
+
const cleared = _removeCacheByPrefixes(patterns);
|
|
|
|
|
|
|
|
|
|
| 720 |
|
| 721 |
if (cleared > 0) {
|
| 722 |
console.log(`🗑️ 精确清除缓存: ${cleared} 个项`);
|
前端页面/market/列表卡片组件.js
CHANGED
|
@@ -4,7 +4,7 @@ import { api, captureRequestSession } from "../core/网络请求API.js";
|
|
| 4 |
import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
|
| 5 |
import { recordView, escapeHtml } from "../components/互动工具函数.js"; // 🧹 P2归一:escapeHtml 局部副本已移除
|
| 6 |
import { renderItemTrendChart } from "../components/图表渲染组件.js";
|
| 7 |
-
import { getCoverSandboxHTML, setupImageSandboxEvents } from "../components/图片沙盒组件.js";
|
| 8 |
import { setupResourceInstall, checkItemStatus } from "./资源安装引擎.js";
|
| 9 |
import { showToast } from "../components/UI交互提示组件.js";
|
| 10 |
import { openTipModal } from "../profile/个人中心_赞赏组件.js";
|
|
@@ -368,7 +368,10 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
|
|
| 368 |
let commentUI = null;
|
| 369 |
const toggleButton = summaryView.querySelector('.card-summary__toggle');
|
| 370 |
const setExpanded = (expanded) => {
|
| 371 |
-
if (expanded)
|
|
|
|
|
|
|
|
|
|
| 372 |
commentUI?._setActive?.(expanded);
|
| 373 |
if (!expanded && detailView.contains(document.activeElement)) toggleButton.focus();
|
| 374 |
detailView.hidden = !expanded;
|
|
@@ -455,7 +458,7 @@ export function createItemCard(itemData, currentUser = null, contextType = null)
|
|
| 455 |
// 🖼️ 启用图片懒加载(跳过 data URL 和已处理的)
|
| 456 |
lazyLoadImages(card);
|
| 457 |
|
| 458 |
-
card._cleanup = () => commentUI?._cleanup?.();
|
| 459 |
card.appendChild(summaryView);
|
| 460 |
card.appendChild(detailView);
|
| 461 |
return card;
|
|
|
|
| 4 |
import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
|
| 5 |
import { recordView, escapeHtml } from "../components/互动工具函数.js"; // 🧹 P2归一:escapeHtml 局部副本已移除
|
| 6 |
import { renderItemTrendChart } from "../components/图表渲染组件.js";
|
| 7 |
+
import { getCoverSandboxHTML, setupImageSandboxEvents, cleanupImageSandbox } from "../components/图片沙盒组件.js";
|
| 8 |
import { setupResourceInstall, checkItemStatus } from "./资源安装引擎.js";
|
| 9 |
import { showToast } from "../components/UI交互提示组件.js";
|
| 10 |
import { openTipModal } from "../profile/个人中心_赞赏组件.js";
|
|
|
|
| 368 |
let commentUI = null;
|
| 369 |
const toggleButton = summaryView.querySelector('.card-summary__toggle');
|
| 370 |
const setExpanded = (expanded) => {
|
| 371 |
+
if (expanded) {
|
| 372 |
+
mountComments();
|
| 373 |
+
if (isRendered) setupImageSandboxEvents(detailView);
|
| 374 |
+
} else cleanupImageSandbox(detailView);
|
| 375 |
commentUI?._setActive?.(expanded);
|
| 376 |
if (!expanded && detailView.contains(document.activeElement)) toggleButton.focus();
|
| 377 |
detailView.hidden = !expanded;
|
|
|
|
| 458 |
// 🖼️ 启用图片懒加载(跳过 data URL 和已处理的)
|
| 459 |
lazyLoadImages(card);
|
| 460 |
|
| 461 |
+
card._cleanup = () => { cleanupImageSandbox(detailView); commentUI?._cleanup?.(); };
|
| 462 |
card.appendChild(summaryView);
|
| 463 |
card.appendChild(detailView);
|
| 464 |
return card;
|
前端页面/market/创作者卡片组件.js
CHANGED
|
@@ -300,15 +300,12 @@ export function createCreatorCard(creatorData, currentUser = null) {
|
|
| 300 |
const cacheKey = getBannerCacheKey(creatorData.account);
|
| 301 |
let cachedBanner = null;
|
| 302 |
try { cachedBanner = localStorage.getItem(cacheKey); } catch (_) { /* 可选装饰缓存不可用时继续构建卡片。 */ }
|
| 303 |
-
const validBanner = value => typeof value === 'string' && value.trim() ? value : '';
|
| 304 |
const bannerUrl = validBanner(cachedBanner) || validBanner(creatorData.bannerUrl);
|
| 305 |
const hasBanner = !!(showBanner && bannerUrl); // 有效本地缓存优先,受显示设置控制
|
| 306 |
|
| 307 |
-
// 🖼️ 全覆盖背景图样式
|
| 308 |
-
// 在使用 bannerUrl 之前添加安全过滤
|
| 309 |
-
const safeBannerUrl = (bannerUrl || '').replace(/['"()\\]/g, '');
|
| 310 |
summaryView.classList.toggle('creator-card__summary--banner', !!hasBanner);
|
| 311 |
-
if (hasBanner) summaryView.style.backgroundImage = `url(
|
| 312 |
const profileName = typeof creatorData.name === 'string' && creatorData.name.trim() ? creatorData.name : creatorData.account;
|
| 313 |
const profileLabel = `${t('creator.visit_profile')}: ${profileName}`;
|
| 314 |
const stat = (field, label, value, unit = '') => `<div class="creator-card__stat" data-stat="${field}"><span>${escapeHtml(label)}</span><strong class="creator-card__stat-value"><span class="${/^\d+$/.test(String(value)) ? 'creator-card__number' : ''}">${escapeHtml(value)}</span>${unit ? ` <small>${escapeHtml(unit)}</small>` : ''}</strong></div>`;
|
|
|
|
| 300 |
const cacheKey = getBannerCacheKey(creatorData.account);
|
| 301 |
let cachedBanner = null;
|
| 302 |
try { cachedBanner = localStorage.getItem(cacheKey); } catch (_) { /* 可选装饰缓存不可用时继续构建卡片。 */ }
|
| 303 |
+
const validBanner = value => typeof value === 'string' && value.trim() && !/[\u0000-\u001f\u007f]/.test(value) ? value : '';
|
| 304 |
const bannerUrl = validBanner(cachedBanner) || validBanner(creatorData.bannerUrl);
|
| 305 |
const hasBanner = !!(showBanner && bannerUrl); // 有效本地缓存优先,受显示设置控制
|
| 306 |
|
|
|
|
|
|
|
|
|
|
| 307 |
summaryView.classList.toggle('creator-card__summary--banner', !!hasBanner);
|
| 308 |
+
if (hasBanner) summaryView.style.backgroundImage = `url(${JSON.stringify(bannerUrl)})`;
|
| 309 |
const profileName = typeof creatorData.name === 'string' && creatorData.name.trim() ? creatorData.name : creatorData.account;
|
| 310 |
const profileLabel = `${t('creator.visit_profile')}: ${profileName}`;
|
| 311 |
const stat = (field, label, value, unit = '') => `<div class="creator-card__stat" data-stat="${field}"><span>${escapeHtml(label)}</span><strong class="creator-card__stat-value"><span class="${/^\d+$/.test(String(value)) ? 'creator-card__number' : ''}">${escapeHtml(value)}</span>${unit ? ` <small>${escapeHtml(unit)}</small>` : ''}</strong></div>`;
|
前端页面/market/资金与钱包_充值组件.js
CHANGED
|
@@ -26,8 +26,6 @@ function _setButtonLoading(btn, isLoading, loadingText, normalText) {
|
|
| 26 |
btn.disabled = isLoading;
|
| 27 |
btn.setAttribute('aria-busy', String(isLoading));
|
| 28 |
btn.innerText = isLoading ? loadingText : normalText;
|
| 29 |
-
btn.style.background = isLoading ? "#888" : "#4CAF50";
|
| 30 |
-
btn.style.cursor = isLoading ? "not-allowed" : "pointer";
|
| 31 |
}
|
| 32 |
|
| 33 |
export async function openRechargeModal(currentUser, onBalanceChange) {
|
|
@@ -57,7 +55,7 @@ export async function openRechargeModal(currentUser, onBalanceChange) {
|
|
| 57 |
if (Number.isSafeInteger(wallet.balance)) balanceText = String(wallet.balance);
|
| 58 |
} catch (_) { if (!scope.isCurrent()) return; }
|
| 59 |
|
| 60 |
-
container.
|
| 61 |
|
| 62 |
const rechargeOptions = [
|
| 63 |
{ points: 10, price: 10, isCustom: false },
|
|
@@ -70,58 +68,58 @@ export async function openRechargeModal(currentUser, onBalanceChange) {
|
|
| 70 |
let paymentMethod = "alipay";
|
| 71 |
|
| 72 |
function _rechargeOptionWrapper(isSelected, index, innerHtml) {
|
| 73 |
-
return `<button type="button" aria-pressed="${isSelected}" class="recharge-opt ${isSelected ? 'selected' : ''}" data-index="${index}"
|
| 74 |
}
|
| 75 |
|
| 76 |
const renderOptions = () => rechargeOptions.map((opt, index) => {
|
| 77 |
const isSelected = selectedOption === opt;
|
| 78 |
if (opt.isCustom) {
|
| 79 |
-
const inner = `<div
|
| 80 |
return _rechargeOptionWrapper(isSelected, index, inner);
|
| 81 |
} else {
|
| 82 |
-
const inner = `<div
|
| 83 |
return _rechargeOptionWrapper(isSelected, index, inner);
|
| 84 |
}
|
| 85 |
}).join("");
|
| 86 |
|
| 87 |
container.innerHTML = `
|
| 88 |
-
<div
|
| 89 |
-
<div
|
| 90 |
-
<div
|
| 91 |
</div>
|
| 92 |
|
| 93 |
-
<div
|
| 94 |
-
<label
|
| 95 |
-
<div id="recharge-grid"
|
| 96 |
${renderOptions()}
|
| 97 |
</div>
|
| 98 |
-
<div id="custom-input-container" style="display: none;
|
| 99 |
-
<input type="number" id="custom-amount" placeholder="${t('wallet.recharge.custom_placeholder')}" min="1" max="10000" step="1"
|
| 100 |
</div>
|
| 101 |
</div>
|
| 102 |
|
| 103 |
-
<div
|
| 104 |
-
<
|
| 105 |
-
<div
|
| 106 |
-
<label
|
| 107 |
<input type="radio" name="pay_method" value="alipay" checked>
|
| 108 |
-
<span
|
| 109 |
</label>
|
| 110 |
-
<label
|
| 111 |
<input type="radio" name="pay_method" value="wechat" disabled>
|
| 112 |
-
<span
|
| 113 |
</label>
|
| 114 |
</div>
|
| 115 |
</div>
|
| 116 |
|
| 117 |
-
<div id="qr-container" style="display: none;
|
| 118 |
-
<div id="qr-loading"
|
| 119 |
-
<img id="qr-image"
|
| 120 |
-
<div
|
| 121 |
-
<div
|
| 122 |
</div>
|
| 123 |
|
| 124 |
-
<button id="btn-create-order"
|
| 125 |
`;
|
| 126 |
|
| 127 |
const customInputContainer = container.querySelector("#custom-input-container");
|
|
@@ -131,6 +129,8 @@ export async function openRechargeModal(currentUser, onBalanceChange) {
|
|
| 131 |
container.querySelectorAll('.recharge-opt').forEach(el => {
|
| 132 |
el.onclick = () => {
|
| 133 |
if (!scope.isCurrent() || submitting) return;
|
|
|
|
|
|
|
| 134 |
selectedOption = rechargeOptions[parseInt(el.getAttribute('data-index'))];
|
| 135 |
container.querySelector('#recharge-grid').innerHTML = renderOptions();
|
| 136 |
|
|
@@ -141,7 +141,13 @@ export async function openRechargeModal(currentUser, onBalanceChange) {
|
|
| 141 |
customInputContainer.style.display = "none";
|
| 142 |
customAmountInput.value = "";
|
| 143 |
}
|
| 144 |
-
bindGridEvents();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
};
|
| 146 |
});
|
| 147 |
};
|
|
|
|
| 26 |
btn.disabled = isLoading;
|
| 27 |
btn.setAttribute('aria-busy', String(isLoading));
|
| 28 |
btn.innerText = isLoading ? loadingText : normalText;
|
|
|
|
|
|
|
| 29 |
}
|
| 30 |
|
| 31 |
export async function openRechargeModal(currentUser, onBalanceChange) {
|
|
|
|
| 55 |
if (Number.isSafeInteger(wallet.balance)) balanceText = String(wallet.balance);
|
| 56 |
} catch (_) { if (!scope.isCurrent()) return; }
|
| 57 |
|
| 58 |
+
container.className = "wallet-modal wallet-modal--recharge";
|
| 59 |
|
| 60 |
const rechargeOptions = [
|
| 61 |
{ points: 10, price: 10, isCustom: false },
|
|
|
|
| 68 |
let paymentMethod = "alipay";
|
| 69 |
|
| 70 |
function _rechargeOptionWrapper(isSelected, index, innerHtml) {
|
| 71 |
+
return `<button type="button" aria-pressed="${isSelected}" class="recharge-opt ${isSelected ? 'selected' : ''}" data-index="${index}">${innerHtml}</button>`;
|
| 72 |
}
|
| 73 |
|
| 74 |
const renderOptions = () => rechargeOptions.map((opt, index) => {
|
| 75 |
const isSelected = selectedOption === opt;
|
| 76 |
if (opt.isCustom) {
|
| 77 |
+
const inner = `<div class="wallet-recharge__option-title">${opt.label}</div><div class="wallet-modal__hint">${t('wallet.recharge.any_amount')}</div>`;
|
| 78 |
return _rechargeOptionWrapper(isSelected, index, inner);
|
| 79 |
} else {
|
| 80 |
+
const inner = `<div class="wallet-recharge__option-title">${opt.points} ${t('wallet.recharge.points_suffix')}</div><div class="wallet-modal__hint">${t('wallet.recharge.price_prefix')}${opt.price.toFixed(2)}</div>`;
|
| 81 |
return _rechargeOptionWrapper(isSelected, index, inner);
|
| 82 |
}
|
| 83 |
}).join("");
|
| 84 |
|
| 85 |
container.innerHTML = `
|
| 86 |
+
<div class="wallet-recharge__balance">
|
| 87 |
+
<div class="wallet-modal__muted">${t('wallet.recharge.current_balance')}</div>
|
| 88 |
+
<div class="wallet-recharge__balance-value">${balanceText} <span class="wallet-recharge__balance-unit">${t('wallet.recharge.points_suffix')}</span></div>
|
| 89 |
</div>
|
| 90 |
|
| 91 |
+
<div class="wallet-modal__field">
|
| 92 |
+
<label for="custom-amount" class="wallet-modal__label">${t('wallet.recharge.select_amount')} <span class="wallet-modal__muted">${t('wallet.recharge.exchange_rate')}</span></label>
|
| 93 |
+
<div id="recharge-grid" class="wallet-recharge__grid">
|
| 94 |
${renderOptions()}
|
| 95 |
</div>
|
| 96 |
+
<div id="custom-input-container" class="wallet-recharge__custom" style="display: none;">
|
| 97 |
+
<input type="number" id="custom-amount" placeholder="${t('wallet.recharge.custom_placeholder')}" min="1" max="10000" step="1" class="wallet-modal__input wallet-recharge__custom-input">
|
| 98 |
</div>
|
| 99 |
</div>
|
| 100 |
|
| 101 |
+
<div class="wallet-modal__field wallet-modal__field--last">
|
| 102 |
+
<div class="wallet-modal__label">${t('wallet.recharge.payment_method')}</div>
|
| 103 |
+
<div class="wallet-recharge__methods">
|
| 104 |
+
<label class="wallet-recharge__method">
|
| 105 |
<input type="radio" name="pay_method" value="alipay" checked>
|
| 106 |
+
<span class="wallet-recharge__method-name">${t('wallet.recharge.alipay')}</span>
|
| 107 |
</label>
|
| 108 |
+
<label class="wallet-recharge__method">
|
| 109 |
<input type="radio" name="pay_method" value="wechat" disabled>
|
| 110 |
+
<span class="wallet-modal__muted">${t('wallet.recharge.wechat')}</span>
|
| 111 |
</label>
|
| 112 |
</div>
|
| 113 |
</div>
|
| 114 |
|
| 115 |
+
<div id="qr-container" class="wallet-recharge__qr" style="display: none;">
|
| 116 |
+
<div id="qr-loading" class="wallet-recharge__qr-status">${t('wallet.recharge.connecting')}</div>
|
| 117 |
+
<img id="qr-image" class="wallet-recharge__qr-image" style="display: none;">
|
| 118 |
+
<div class="wallet-recharge__pay-label">${t('wallet.recharge.scan_to_pay')} <span id="pay-price-text" class="wallet-modal__danger"></span> ${t('wallet.recharge.yuan')}</div>
|
| 119 |
+
<div class="wallet-recharge__qr-hint">${t('wallet.recharge.auto_redirect')}</div>
|
| 120 |
</div>
|
| 121 |
|
| 122 |
+
<button type="button" id="btn-create-order" class="wallet-modal__button wallet-modal__button--primary">${t('wallet.recharge.get_qr')}</button>
|
| 123 |
`;
|
| 124 |
|
| 125 |
const customInputContainer = container.querySelector("#custom-input-container");
|
|
|
|
| 129 |
container.querySelectorAll('.recharge-opt').forEach(el => {
|
| 130 |
el.onclick = () => {
|
| 131 |
if (!scope.isCurrent() || submitting) return;
|
| 132 |
+
const restoreOptionFocus = document.activeElement === el;
|
| 133 |
+
const optionIndex = el.getAttribute('data-index');
|
| 134 |
selectedOption = rechargeOptions[parseInt(el.getAttribute('data-index'))];
|
| 135 |
container.querySelector('#recharge-grid').innerHTML = renderOptions();
|
| 136 |
|
|
|
|
| 141 |
customInputContainer.style.display = "none";
|
| 142 |
customAmountInput.value = "";
|
| 143 |
}
|
| 144 |
+
bindGridEvents();
|
| 145 |
+
if (restoreOptionFocus && !selectedOption.isCustom && scope.isCurrent() && container.isConnected &&
|
| 146 |
+
document.activeElement === document.body) {
|
| 147 |
+
const replacement = Array.from(container.querySelectorAll('.recharge-opt'))
|
| 148 |
+
.find(button => button.getAttribute('data-index') === optionIndex);
|
| 149 |
+
replacement?.focus({ preventScroll: true });
|
| 150 |
+
}
|
| 151 |
};
|
| 152 |
});
|
| 153 |
};
|
前端页面/market/资金与钱包_提现组件.js
CHANGED
|
@@ -20,7 +20,6 @@ let withdrawalSequence = 0;
|
|
| 20 |
function _setButtonState(btn, disabled, bgColor, text) {
|
| 21 |
btn.disabled = disabled;
|
| 22 |
btn.setAttribute('aria-busy', String(disabled));
|
| 23 |
-
btn.style.background = bgColor;
|
| 24 |
btn.innerText = text;
|
| 25 |
}
|
| 26 |
|
|
@@ -85,48 +84,48 @@ export async function openWithdrawModal(currentUser, onSuccess) {
|
|
| 85 |
showToast(t('wallet.withdraw.no_balance'), "warning"); scope.dispose(); return;
|
| 86 |
}
|
| 87 |
|
| 88 |
-
container.
|
| 89 |
|
| 90 |
container.innerHTML = `
|
| 91 |
-
<div
|
| 92 |
<div>
|
| 93 |
-
<div
|
| 94 |
-
<div
|
| 95 |
</div>
|
| 96 |
-
<div
|
| 97 |
-
<div
|
| 98 |
-
<div
|
| 99 |
</div>
|
| 100 |
</div>
|
| 101 |
|
| 102 |
-
<div
|
| 103 |
-
<label
|
| 104 |
-
<input type="number" id="withdraw-amount" placeholder="${t('wallet.withdraw.amount_placeholder')}" max="${maxWithdraw}" min="1" step="1"
|
| 105 |
|
| 106 |
-
<div id="fee-calc-box"
|
| 107 |
<div id="fee-text"></div>
|
| 108 |
</div>
|
| 109 |
</div>
|
| 110 |
|
| 111 |
-
<div
|
| 112 |
-
<label
|
| 113 |
-
<input type="text" id="withdraw-account" placeholder="${t('wallet.withdraw.alipay_placeholder')}"
|
| 114 |
</div>
|
| 115 |
|
| 116 |
-
<div
|
| 117 |
-
<label
|
| 118 |
-
<input type="text" id="withdraw-name" placeholder="${t('wallet.withdraw.real_name_placeholder')}"
|
| 119 |
</div>
|
| 120 |
|
| 121 |
-
<div
|
| 122 |
-
<label
|
| 123 |
-
<div
|
| 124 |
-
<input type="text" id="withdraw-code" placeholder="${t('wallet.withdraw.code_placeholder')}" maxlength="6"
|
| 125 |
-
<button id="btn-send-code"
|
| 126 |
</div>
|
| 127 |
</div>
|
| 128 |
|
| 129 |
-
<button id="btn-submit-withdraw"
|
| 130 |
`;
|
| 131 |
|
| 132 |
// 🚀 核心逻辑:动态计算提现手续费
|
|
@@ -151,9 +150,9 @@ export async function openWithdrawModal(currentUser, onSuccess) {
|
|
| 151 |
|
| 152 |
feeBox.style.display = "block";
|
| 153 |
if (fee > 0) {
|
| 154 |
-
feeText.innerHTML = `${t('wallet.withdraw.fee_quota_exhausted')}<br>${t('wallet.withdraw.fee_deducted')}<strong
|
| 155 |
} else {
|
| 156 |
-
feeText.innerHTML = `${t('wallet.withdraw.fee_free_quota')}<br>${t('wallet.withdraw.fee_full_amount')}<strong
|
| 157 |
}
|
| 158 |
};
|
| 159 |
|
|
@@ -162,8 +161,20 @@ export async function openWithdrawModal(currentUser, onSuccess) {
|
|
| 162 |
if (!scope.isCurrent() || btnSendCode.disabled) return;
|
| 163 |
_setButtonState(btnSendCode, true, "#555", t('wallet.withdraw.sending'));
|
| 164 |
try {
|
| 165 |
-
await api.
|
|
|
|
| 166 |
if (!scope.isCurrent()) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
showToast(t('wallet.withdraw.code_sent'), "success");
|
| 168 |
let count = 60;
|
| 169 |
countdown = setInterval(() => {
|
|
|
|
| 20 |
function _setButtonState(btn, disabled, bgColor, text) {
|
| 21 |
btn.disabled = disabled;
|
| 22 |
btn.setAttribute('aria-busy', String(disabled));
|
|
|
|
| 23 |
btn.innerText = text;
|
| 24 |
}
|
| 25 |
|
|
|
|
| 84 |
showToast(t('wallet.withdraw.no_balance'), "warning"); scope.dispose(); return;
|
| 85 |
}
|
| 86 |
|
| 87 |
+
container.className = "wallet-modal wallet-modal--withdraw";
|
| 88 |
|
| 89 |
container.innerHTML = `
|
| 90 |
+
<div class="wallet-withdraw__summary">
|
| 91 |
<div>
|
| 92 |
+
<div class="wallet-withdraw__summary-label">${t('wallet.withdraw.total_withdrawable')}</div>
|
| 93 |
+
<div class="wallet-withdraw__balance">${maxWithdraw}</div>
|
| 94 |
</div>
|
| 95 |
+
<div class="wallet-withdraw__equivalent">
|
| 96 |
+
<div class="wallet-withdraw__summary-label">${t('wallet.withdraw.rmb_equivalent')}</div>
|
| 97 |
+
<div class="wallet-withdraw__equivalent-value">≈ ${t('wallet.recharge.price_prefix')} ${maxWithdraw.toFixed(2)}</div>
|
| 98 |
</div>
|
| 99 |
</div>
|
| 100 |
|
| 101 |
+
<div class="wallet-modal__field">
|
| 102 |
+
<label for="withdraw-amount" class="wallet-modal__label">${t('wallet.withdraw.amount_label')} <span class="wallet-modal__danger">*</span></label>
|
| 103 |
+
<input type="number" id="withdraw-amount" placeholder="${t('wallet.withdraw.amount_placeholder')}" max="${maxWithdraw}" min="1" step="1" class="wallet-modal__input">
|
| 104 |
|
| 105 |
+
<div id="fee-calc-box" class="wallet-withdraw__fee" style="display:none;">
|
| 106 |
<div id="fee-text"></div>
|
| 107 |
</div>
|
| 108 |
</div>
|
| 109 |
|
| 110 |
+
<div class="wallet-modal__field">
|
| 111 |
+
<label for="withdraw-account" class="wallet-modal__label">${t('wallet.withdraw.alipay_account')} <span class="wallet-modal__danger">*</span></label>
|
| 112 |
+
<input type="text" id="withdraw-account" placeholder="${t('wallet.withdraw.alipay_placeholder')}" class="wallet-modal__input">
|
| 113 |
</div>
|
| 114 |
|
| 115 |
+
<div class="wallet-modal__field">
|
| 116 |
+
<label for="withdraw-name" class="wallet-modal__label">${t('wallet.withdraw.real_name')} <span class="wallet-modal__danger">*</span></label>
|
| 117 |
+
<input type="text" id="withdraw-name" placeholder="${t('wallet.withdraw.real_name_placeholder')}" class="wallet-modal__input">
|
| 118 |
</div>
|
| 119 |
|
| 120 |
+
<div class="wallet-modal__field wallet-modal__field--last">
|
| 121 |
+
<label for="withdraw-code" class="wallet-modal__label">${t('wallet.withdraw.security_code')} <span class="wallet-modal__danger">*</span></label>
|
| 122 |
+
<div class="wallet-withdraw__code-row">
|
| 123 |
+
<input type="text" id="withdraw-code" placeholder="${t('wallet.withdraw.code_placeholder')}" maxlength="6" class="wallet-modal__input wallet-withdraw__code-input">
|
| 124 |
+
<button type="button" id="btn-send-code" class="wallet-modal__button wallet-modal__button--code">${t('wallet.withdraw.get_code')}</button>
|
| 125 |
</div>
|
| 126 |
</div>
|
| 127 |
|
| 128 |
+
<button type="button" id="btn-submit-withdraw" class="wallet-modal__button wallet-modal__button--primary" >${t('wallet.withdraw.confirm')}</button>
|
| 129 |
`;
|
| 130 |
|
| 131 |
// 🚀 核心逻辑:动态计算提现手续费
|
|
|
|
| 150 |
|
| 151 |
feeBox.style.display = "block";
|
| 152 |
if (fee > 0) {
|
| 153 |
+
feeText.innerHTML = `${t('wallet.withdraw.fee_quota_exhausted')}<br>${t('wallet.withdraw.fee_deducted')}<strong class="wallet-modal__danger">${fee} ${t('wallet.recharge.points_suffix')}</strong><br>${t('wallet.withdraw.fee_actual')}<strong class="wallet-modal__success">${(val - fee).toFixed(2)} ${t('wallet.withdraw.yuan')}</strong>`;
|
| 154 |
} else {
|
| 155 |
+
feeText.innerHTML = `${t('wallet.withdraw.fee_free_quota')}<br>${t('wallet.withdraw.fee_full_amount')}<strong class="wallet-modal__success">${val.toFixed(2)} ${t('wallet.withdraw.yuan')}</strong>`;
|
| 156 |
}
|
| 157 |
};
|
| 158 |
|
|
|
|
| 161 |
if (!scope.isCurrent() || btnSendCode.disabled) return;
|
| 162 |
_setButtonState(btnSendCode, true, "#555", t('wallet.withdraw.sending'));
|
| 163 |
try {
|
| 164 |
+
const profile = await api.getUserProfile(account, { noCache: true, noStore: true, componentId,
|
| 165 |
+
timeout: API.BACKGROUND_TIMEOUT, retries: API.BACKGROUND_RETRIES });
|
| 166 |
if (!scope.isCurrent()) return;
|
| 167 |
+
if (!profile || typeof profile !== "object" || Array.isArray(profile) || profile.status !== "success" ||
|
| 168 |
+
!profile.data || typeof profile.data !== "object" ||
|
| 169 |
+
Array.isArray(profile.data) || profile.data.account !== account ||
|
| 170 |
+
typeof profile.data.email !== "string" || !profile.data.email.trim()) {
|
| 171 |
+
throw new Error("unconfirmed withdrawal contact");
|
| 172 |
+
}
|
| 173 |
+
const result = await api.sendVerifyCode(profile.data.email, "email", "withdraw", account);
|
| 174 |
+
if (!scope.isCurrent()) return;
|
| 175 |
+
if (!result || typeof result !== "object" || Array.isArray(result) || result.status !== "success") {
|
| 176 |
+
throw new Error("unconfirmed verification code");
|
| 177 |
+
}
|
| 178 |
showToast(t('wallet.withdraw.code_sent'), "success");
|
| 179 |
let count = 60;
|
| 180 |
countdown = setInterval(() => {
|
前端页面/post/发布帖子组件.js
CHANGED
|
@@ -17,7 +17,7 @@ import { t } from "../components/用户体验增强.js";
|
|
| 17 |
import { removeCache, removeCacheByPrefix } from "../components/性能优化工具.js";
|
| 18 |
import { createPublishEditingLock } from "../market/发布编辑锁.js";
|
| 19 |
import { createPublishSubmissionScope } from "../market/发布提交生命周期.js";
|
| 20 |
-
import { API, CACHE, PUBLISH_MEDIA_LIMITS, IMAGE_PROCESS, escapeHtml } from "../core/全局配置.js";
|
| 21 |
|
| 22 |
function requirePostUpload(result) {
|
| 23 |
if (result?.status !== 'success' || typeof result.url !== 'string' || !result.url.trim()) {
|
|
@@ -58,8 +58,9 @@ function updateCoverMark(previewContainer) {
|
|
| 58 |
wrapper.tabIndex = 0;
|
| 59 |
wrapper.setAttribute('role', 'group');
|
| 60 |
wrapper.setAttribute('aria-label', t('post.image_position', { index: idx + 1, total: wrappers.length }));
|
| 61 |
-
wrapper.setAttribute('aria-keyshortcuts', 'Alt+ArrowLeft Alt+ArrowRight');
|
| 62 |
-
|
|
|
|
| 63 |
const remove = wrapper.querySelector('button[data-action="remove"]');
|
| 64 |
if (remove) {
|
| 65 |
remove.type = 'button';
|
|
@@ -68,13 +69,13 @@ function updateCoverMark(previewContainer) {
|
|
| 68 |
if (img && !img.hasAttribute('alt')) img.alt = '';
|
| 69 |
|
| 70 |
if (img) {
|
| 71 |
-
img.style.borderColor = idx === 0 ? '#
|
| 72 |
}
|
| 73 |
if (idx === 0) {
|
| 74 |
if (!coverLabel) {
|
| 75 |
const label = document.createElement('span');
|
| 76 |
label.className = 'cover-label';
|
| 77 |
-
|
| 78 |
label.textContent = t('post.cover');
|
| 79 |
wrapper.appendChild(label);
|
| 80 |
}
|
|
@@ -109,7 +110,7 @@ function setupImageDragSort(previewContainer, fileArray, options = {}) {
|
|
| 109 |
|
| 110 |
wrappers.forEach(wrapper => {
|
| 111 |
wrapper.draggable = true;
|
| 112 |
-
|
| 113 |
|
| 114 |
// 绑定删除按钮(如果尚未绑定)
|
| 115 |
const removeBtn = wrapper.querySelector('button[data-action="remove"]');
|
|
@@ -131,7 +132,7 @@ function setupImageDragSort(previewContainer, fileArray, options = {}) {
|
|
| 131 |
}
|
| 132 |
|
| 133 |
wrapper.onkeydown = (e) => {
|
| 134 |
-
if (e.target !== wrapper || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey ||
|
| 135 |
e.repeat || e.isComposing || !canInteract() ||
|
| 136 |
!['ArrowLeft', 'ArrowRight'].includes(e.key)) return;
|
| 137 |
const current = getWrappers();
|
|
@@ -177,13 +178,15 @@ function setupImageDragSort(previewContainer, fileArray, options = {}) {
|
|
| 177 |
_clearDragIndicators(currentWrappers);
|
| 178 |
|
| 179 |
// 在当前目标左侧显示绿色竖线指示插入位置
|
| 180 |
-
wrapper.style.borderLeft = '3px solid #
|
| 181 |
wrapper.style.paddingLeft = '5px';
|
|
|
|
| 182 |
};
|
| 183 |
|
| 184 |
wrapper.ondragleave = () => {
|
| 185 |
wrapper.style.borderLeft = '';
|
| 186 |
wrapper.style.paddingLeft = '';
|
|
|
|
| 187 |
};
|
| 188 |
|
| 189 |
wrapper.ondrop = (e) => {
|
|
@@ -234,7 +237,7 @@ function formatFileSize(bytes) {
|
|
| 234 |
|
| 235 |
// ===== 共用样式常量 =====
|
| 236 |
// P3-1: 图片预览包装器通用尺寸样式
|
| 237 |
-
|
| 238 |
|
| 239 |
// ===== 共用辅助函数 =====
|
| 240 |
|
|
@@ -243,6 +246,7 @@ function _clearDragIndicators(wrappers) {
|
|
| 243 |
wrappers.forEach(w => {
|
| 244 |
w.style.borderLeft = '';
|
| 245 |
w.style.paddingLeft = '';
|
|
|
|
| 246 |
});
|
| 247 |
}
|
| 248 |
|
|
@@ -252,19 +256,7 @@ function _createAddButton(imagesInput, scope) {
|
|
| 252 |
addBtn.type = "button";
|
| 253 |
addBtn.setAttribute("aria-label", t("post.upload_images"));
|
| 254 |
addBtn.setAttribute("data-file-picker-button", "");
|
| 255 |
-
|
| 256 |
-
width: "80px",
|
| 257 |
-
height: "80px",
|
| 258 |
-
background: "transparent",
|
| 259 |
-
border: "2px dashed var(--rk-border, var(--border-color, #444))",
|
| 260 |
-
borderRadius: "6px",
|
| 261 |
-
display: "flex",
|
| 262 |
-
alignItems: "center",
|
| 263 |
-
justifyContent: "center",
|
| 264 |
-
cursor: "pointer",
|
| 265 |
-
color: "var(--rk-muted, var(--descrip-text, #a4a4a4))",
|
| 266 |
-
fontSize: "24px"
|
| 267 |
-
});
|
| 268 |
addBtn.textContent = "+";
|
| 269 |
addBtn.onclick = (e) => {
|
| 270 |
if (!scope.isCurrent() || imagesInput.disabled) return;
|
|
@@ -379,7 +371,7 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 379 |
|
| 380 |
const container = document.createElement("div");
|
| 381 |
|
| 382 |
-
container.classList.add("ranking-publish-form");
|
| 383 |
container.innerHTML = `
|
| 384 |
<!-- 顶部标题栏 -->
|
| 385 |
<div class="ranking-publish-header">
|
|
@@ -394,65 +386,65 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 394 |
<!-- 表单内容 -->
|
| 395 |
<div class="ranking-publish-body">
|
| 396 |
<!-- 模式切换 -->
|
| 397 |
-
<div
|
| 398 |
-
<label
|
| 399 |
${t('post.mode_label')}
|
| 400 |
</label>
|
| 401 |
-
<div role="group" aria-label="${t('post.mode_label')}"
|
| 402 |
-
<button id="mode-image"
|
| 403 |
📷 ${t('post.mode_image')}
|
| 404 |
</button>
|
| 405 |
-
<button id="mode-video"
|
| 406 |
🎬 ${t('post.mode_video')}
|
| 407 |
</button>
|
| 408 |
</div>
|
| 409 |
</div>
|
| 410 |
|
| 411 |
<!-- 图片上传区 -->
|
| 412 |
-
<div id="image-upload-section"
|
| 413 |
-
<label
|
| 414 |
-
🖼️ ${t('post.upload_images')} <span
|
| 415 |
-
<span
|
| 416 |
</label>
|
| 417 |
<input type="file" id="images-input" accept="image/*" multiple style="display: none;">
|
| 418 |
-
<div id="images-preview"
|
| 419 |
-
<div
|
| 420 |
-
<div
|
| 421 |
${t('post.click_upload')}
|
| 422 |
</div>
|
| 423 |
</div>
|
| 424 |
</div>
|
| 425 |
|
| 426 |
<!-- 视频上传区 -->
|
| 427 |
-
<div id="video-upload-section" style="
|
| 428 |
-
<label
|
| 429 |
-
🎬 ${t('post.upload_video')} <span
|
| 430 |
-
<span
|
| 431 |
</label>
|
| 432 |
<input type="file" id="video-input" accept="video/mp4,video/webm,video/quicktime" style="display: none;">
|
| 433 |
-
<div id="video-preview-area"
|
| 434 |
-
<div id="video-empty-state"
|
| 435 |
-
<div
|
| 436 |
${t('post.click_upload_video')}
|
| 437 |
</div>
|
| 438 |
<div id="video-player-wrap" style="display: none;">
|
| 439 |
-
<video id="video-player" controls
|
| 440 |
-
<div
|
| 441 |
-
<div
|
| 442 |
<div id="video-name"></div>
|
| 443 |
<div id="video-meta"></div>
|
| 444 |
</div>
|
| 445 |
-
<button id="btn-remove-video"
|
| 446 |
</div>
|
| 447 |
<!-- 封面区域 -->
|
| 448 |
-
<div id="video-cover-wrap" style="display: none;
|
| 449 |
-
<div
|
| 450 |
<div class="ranking-publish-cover-controls">
|
| 451 |
-
<div
|
| 452 |
-
<img id="cover-thumb" src="" style="
|
| 453 |
-
<span id="cover-label" style="
|
| 454 |
</div>
|
| 455 |
-
<div
|
| 456 |
<button type="button" id="btn-capture-frame" class="ranking-publish-secondary">
|
| 457 |
📸 ${t('post.capture_frame')}
|
| 458 |
</button>
|
|
@@ -468,40 +460,40 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 468 |
</div>
|
| 469 |
|
| 470 |
<!-- 标题输入 -->
|
| 471 |
-
<div
|
| 472 |
-
<label for="title-input"
|
| 473 |
-
📝 ${t('post.title_label')} <span
|
| 474 |
</label>
|
| 475 |
<input type="text" id="title-input" class="ranking-publish-input" value="${escapeHtml(editTitle)}" placeholder="${t('post.title_placeholder')}" maxlength="50">
|
| 476 |
-
<div
|
| 477 |
<span id="title-count">${editTitle.length}</span>/50
|
| 478 |
</div>
|
| 479 |
</div>
|
| 480 |
|
| 481 |
<!-- 正文输入 -->
|
| 482 |
-
<div
|
| 483 |
-
<label for="content-input"
|
| 484 |
✍️ ${t('post.content_label')}
|
| 485 |
</label>
|
| 486 |
-
<textarea id="content-input" class="ranking-publish-input" placeholder="${t('post.content_placeholder')}" maxlength="2000"
|
| 487 |
-
<div
|
| 488 |
<span id="content-count">${editContent.length}</span>/2000
|
| 489 |
</div>
|
| 490 |
</div>
|
| 491 |
|
| 492 |
<!-- 原创作品勾选框 -->
|
| 493 |
-
<div
|
| 494 |
-
<label
|
| 495 |
-
<input type="checkbox" id="is-original-checkbox" ${editIsOriginal ? 'checked' : ''}
|
| 496 |
-
<span
|
| 497 |
</label>
|
| 498 |
-
<div
|
| 499 |
${t('publish.original_default_hint')}
|
| 500 |
</div>
|
| 501 |
</div>
|
| 502 |
|
| 503 |
<!-- 提示信息 -->
|
| 504 |
-
<div
|
| 505 |
💡 <strong>${t('post.publish_notice')}:</strong><br>
|
| 506 |
• ${t('post.notice_original')}<br>
|
| 507 |
• ${t('post.notice_compress')}<br>
|
|
@@ -509,8 +501,8 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 509 |
</div>
|
| 510 |
|
| 511 |
<!-- 确认发布按钮 -->
|
| 512 |
-
<button id="btn-submit-post"
|
| 513 |
-
🚀 ${isEditMode ? '
|
| 514 |
</button>
|
| 515 |
</div>
|
| 516 |
`;
|
|
@@ -560,6 +552,15 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 560 |
const isOriginalCheckbox = container.querySelector("#is-original-checkbox");
|
| 561 |
const account = currentUser?.account;
|
| 562 |
const scope = createPublishSubmissionScope(container, submitBtn, account);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 563 |
const editingLock = createPublishEditingLock(container, [submitBtn, container.querySelector("#btn-back-publish")]);
|
| 564 |
scope.addCleanup(() => editingLock.dispose());
|
| 565 |
const previewReaders = new Set(), temporaryVideos = new Map();
|
|
@@ -620,17 +621,9 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 620 |
modeImageBtn.setAttribute('aria-pressed', String(postMode === 'image'));
|
| 621 |
modeVideoBtn.setAttribute('aria-pressed', String(postMode === 'video'));
|
| 622 |
if (postMode === 'image') {
|
| 623 |
-
modeImageBtn.style.background = '#4CAF50';
|
| 624 |
-
modeImageBtn.style.color = 'var(--rk-on-accent, #fff)';
|
| 625 |
-
modeVideoBtn.style.background = 'transparent';
|
| 626 |
-
modeVideoBtn.style.color = 'var(--rk-muted, var(--descrip-text, #a4a4a4))';
|
| 627 |
imageSection.style.display = 'block';
|
| 628 |
videoSection.style.display = 'none';
|
| 629 |
} else {
|
| 630 |
-
modeImageBtn.style.background = 'transparent';
|
| 631 |
-
modeImageBtn.style.color = 'var(--rk-muted, var(--descrip-text, #a4a4a4))';
|
| 632 |
-
modeVideoBtn.style.background = '#4CAF50';
|
| 633 |
-
modeVideoBtn.style.color = 'var(--rk-on-accent, #fff)';
|
| 634 |
imageSection.style.display = 'none';
|
| 635 |
videoSection.style.display = 'block';
|
| 636 |
}
|
|
@@ -774,8 +767,8 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 774 |
|
| 775 |
if (totalImages === 0) {
|
| 776 |
imagesPreview.innerHTML = `
|
| 777 |
-
<div
|
| 778 |
-
<div
|
| 779 |
${t('post.click_upload')}
|
| 780 |
</div>
|
| 781 |
`;
|
|
@@ -801,12 +794,12 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 801 |
|
| 802 |
existingImageUrls.forEach((url, idx) => {
|
| 803 |
const wrapper = document.createElement("div");
|
| 804 |
-
|
| 805 |
|
| 806 |
wrapper.innerHTML = `
|
| 807 |
-
<img src="${escapeHtml(url)}"
|
| 808 |
-
${idx === 0 ? `<span class="
|
| 809 |
-
<button data-action="remove" data-existing-idx="${idx}"
|
| 810 |
`;
|
| 811 |
|
| 812 |
wrapper._imageType = 'existing';
|
|
@@ -830,11 +823,11 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 830 |
previewReaders.add(reader);
|
| 831 |
reader.onabort = () => finishPreviewReader(reader);
|
| 832 |
const wrapper = document.createElement("div");
|
| 833 |
-
|
| 834 |
wrapper.innerHTML = `
|
| 835 |
-
<img src=""
|
| 836 |
-
${existingImageUrls.length === 0 && idx === 0 ? `<span class="
|
| 837 |
-
<button data-action="remove" data-idx="${idx}"
|
| 838 |
`;
|
| 839 |
|
| 840 |
wrapper.querySelector('img').removeAttribute('src');
|
|
@@ -881,8 +874,8 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 881 |
const generation = beginImagePreviews();
|
| 882 |
if (imageFiles.length === 0) {
|
| 883 |
imagesPreview.innerHTML = `
|
| 884 |
-
<div
|
| 885 |
-
<div
|
| 886 |
${t('post.click_upload')}
|
| 887 |
</div>
|
| 888 |
`;
|
|
@@ -898,12 +891,12 @@ export function createPublishPostView(currentUser, editPostData = null) {
|
|
| 898 |
previewReaders.add(reader);
|
| 899 |
reader.onabort = () => finishPreviewReader(reader);
|
| 900 |
const wrapper = document.createElement("div");
|
| 901 |
-
|
| 902 |
|
| 903 |
wrapper.innerHTML = `
|
| 904 |
-
<img src=""
|
| 905 |
-
${idx === 0 ? `<span class="
|
| 906 |
-
<button data-action="remove" data-idx="${idx}"
|
| 907 |
`;
|
| 908 |
|
| 909 |
wrapper.querySelector('img').removeAttribute('src');
|
|
|
|
| 17 |
import { removeCache, removeCacheByPrefix } from "../components/性能优化工具.js";
|
| 18 |
import { createPublishEditingLock } from "../market/发布编辑锁.js";
|
| 19 |
import { createPublishSubmissionScope } from "../market/发布提交生命周期.js";
|
| 20 |
+
import { areKeyboardShortcutsEnabled, API, CACHE, PUBLISH_MEDIA_LIMITS, IMAGE_PROCESS, escapeHtml } from "../core/全局配置.js";
|
| 21 |
|
| 22 |
function requirePostUpload(result) {
|
| 23 |
if (result?.status !== 'success' || typeof result.url !== 'string' || !result.url.trim()) {
|
|
|
|
| 58 |
wrapper.tabIndex = 0;
|
| 59 |
wrapper.setAttribute('role', 'group');
|
| 60 |
wrapper.setAttribute('aria-label', t('post.image_position', { index: idx + 1, total: wrappers.length }));
|
| 61 |
+
if (areKeyboardShortcutsEnabled()) wrapper.setAttribute('aria-keyshortcuts', 'Alt+ArrowLeft Alt+ArrowRight');
|
| 62 |
+
else wrapper.removeAttribute('aria-keyshortcuts');
|
| 63 |
+
wrapper.title = t(areKeyboardShortcutsEnabled() ? 'post.image_move_hint' : 'post.image_drag_hint');
|
| 64 |
const remove = wrapper.querySelector('button[data-action="remove"]');
|
| 65 |
if (remove) {
|
| 66 |
remove.type = 'button';
|
|
|
|
| 69 |
if (img && !img.hasAttribute('alt')) img.alt = '';
|
| 70 |
|
| 71 |
if (img) {
|
| 72 |
+
img.style.borderColor = idx === 0 ? 'var(--rk-success, #4caf50)' : 'var(--rk-border, var(--border-color, #444))';
|
| 73 |
}
|
| 74 |
if (idx === 0) {
|
| 75 |
if (!coverLabel) {
|
| 76 |
const label = document.createElement('span');
|
| 77 |
label.className = 'cover-label';
|
| 78 |
+
|
| 79 |
label.textContent = t('post.cover');
|
| 80 |
wrapper.appendChild(label);
|
| 81 |
}
|
|
|
|
| 110 |
|
| 111 |
wrappers.forEach(wrapper => {
|
| 112 |
wrapper.draggable = true;
|
| 113 |
+
|
| 114 |
|
| 115 |
// 绑定删除按钮(如果尚未绑定)
|
| 116 |
const removeBtn = wrapper.querySelector('button[data-action="remove"]');
|
|
|
|
| 132 |
}
|
| 133 |
|
| 134 |
wrapper.onkeydown = (e) => {
|
| 135 |
+
if (!areKeyboardShortcutsEnabled() || e.defaultPrevented || e.keyCode === 229 || e.target !== wrapper || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey ||
|
| 136 |
e.repeat || e.isComposing || !canInteract() ||
|
| 137 |
!['ArrowLeft', 'ArrowRight'].includes(e.key)) return;
|
| 138 |
const current = getWrappers();
|
|
|
|
| 178 |
_clearDragIndicators(currentWrappers);
|
| 179 |
|
| 180 |
// 在当前目标左侧显示绿色竖线指示插入位置
|
| 181 |
+
wrapper.style.borderLeft = '3px solid var(--rk-success, #4caf50)';
|
| 182 |
wrapper.style.paddingLeft = '5px';
|
| 183 |
+
|
| 184 |
};
|
| 185 |
|
| 186 |
wrapper.ondragleave = () => {
|
| 187 |
wrapper.style.borderLeft = '';
|
| 188 |
wrapper.style.paddingLeft = '';
|
| 189 |
+
|
| 190 |
};
|
| 191 |
|
| 192 |
wrapper.ondrop = (e) => {
|
|
|
|
| 237 |
|
| 238 |
// ===== 共用样式常量 =====
|
| 239 |
// P3-1: 图片预览包装器通用尺寸样式
|
| 240 |
+
|
| 241 |
|
| 242 |
// ===== 共用辅助函数 =====
|
| 243 |
|
|
|
|
| 246 |
wrappers.forEach(w => {
|
| 247 |
w.style.borderLeft = '';
|
| 248 |
w.style.paddingLeft = '';
|
| 249 |
+
|
| 250 |
});
|
| 251 |
}
|
| 252 |
|
|
|
|
| 256 |
addBtn.type = "button";
|
| 257 |
addBtn.setAttribute("aria-label", t("post.upload_images"));
|
| 258 |
addBtn.setAttribute("data-file-picker-button", "");
|
| 259 |
+
addBtn.className = "post-publish-add";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
addBtn.textContent = "+";
|
| 261 |
addBtn.onclick = (e) => {
|
| 262 |
if (!scope.isCurrent() || imagesInput.disabled) return;
|
|
|
|
| 371 |
|
| 372 |
const container = document.createElement("div");
|
| 373 |
|
| 374 |
+
container.classList.add("ranking-publish-form", "discussion-publish");
|
| 375 |
container.innerHTML = `
|
| 376 |
<!-- 顶部标题栏 -->
|
| 377 |
<div class="ranking-publish-header">
|
|
|
|
| 386 |
<!-- 表单内容 -->
|
| 387 |
<div class="ranking-publish-body">
|
| 388 |
<!-- 模式切换 -->
|
| 389 |
+
<div class="post-publish__field-group">
|
| 390 |
+
<label class="post-publish__field-label">
|
| 391 |
${t('post.mode_label')}
|
| 392 |
</label>
|
| 393 |
+
<div role="group" aria-label="${t('post.mode_label')}" class="post-publish__modes">
|
| 394 |
+
<button type="button" id="mode-image" class="post-publish__mode-image">
|
| 395 |
📷 ${t('post.mode_image')}
|
| 396 |
</button>
|
| 397 |
+
<button type="button" id="mode-video" class="post-publish__mode-video">
|
| 398 |
🎬 ${t('post.mode_video')}
|
| 399 |
</button>
|
| 400 |
</div>
|
| 401 |
</div>
|
| 402 |
|
| 403 |
<!-- 图片上传区 -->
|
| 404 |
+
<div id="image-upload-section" class="post-publish__upload-section">
|
| 405 |
+
<label for="images-input" class="post-publish__field-label">
|
| 406 |
+
🖼️ ${t('post.upload_images')} <span class="post-publish__required">*</span>
|
| 407 |
+
<span class="post-publish__hint">(${t('post.max_9_images')})</span>
|
| 408 |
</label>
|
| 409 |
<input type="file" id="images-input" accept="image/*" multiple style="display: none;">
|
| 410 |
+
<div id="images-preview" class="post-publish__images-preview">
|
| 411 |
+
<div class="post-publish__upload-empty">
|
| 412 |
+
<div class="post-publish__upload-icon">📷</div>
|
| 413 |
${t('post.click_upload')}
|
| 414 |
</div>
|
| 415 |
</div>
|
| 416 |
</div>
|
| 417 |
|
| 418 |
<!-- 视频上传区 -->
|
| 419 |
+
<div id="video-upload-section" style="display: none;" class="post-publish__upload-section">
|
| 420 |
+
<label for="video-input" class="post-publish__field-label">
|
| 421 |
+
🎬 ${t('post.upload_video')} <span class="post-publish__required">*</span>
|
| 422 |
+
<span class="post-publish__hint">${t('post.video_limits_hint', { megabytes: PUBLISH_MEDIA_LIMITS.VIDEO_MAX_BYTES / (1024 * 1024), minutes: PUBLISH_MEDIA_LIMITS.VIDEO_MAX_SECONDS / 60 })}</span>
|
| 423 |
</label>
|
| 424 |
<input type="file" id="video-input" accept="video/mp4,video/webm,video/quicktime" style="display: none;">
|
| 425 |
+
<div id="video-preview-area" class="post-publish__video-preview">
|
| 426 |
+
<div id="video-empty-state" class="post-publish__upload-empty">
|
| 427 |
+
<div class="post-publish__upload-icon">🎬</div>
|
| 428 |
${t('post.click_upload_video')}
|
| 429 |
</div>
|
| 430 |
<div id="video-player-wrap" style="display: none;">
|
| 431 |
+
<video id="video-player" controls class="post-publish__video"></video>
|
| 432 |
+
<div class="post-publish__video-info">
|
| 433 |
+
<div class="post-publish__video-meta">
|
| 434 |
<div id="video-name"></div>
|
| 435 |
<div id="video-meta"></div>
|
| 436 |
</div>
|
| 437 |
+
<button type="button" id="btn-remove-video" aria-label="${t('common.delete')} ${t('post.mode_video')}" class="post-publish__remove-video">×</button>
|
| 438 |
</div>
|
| 439 |
<!-- 封面区域 -->
|
| 440 |
+
<div id="video-cover-wrap" style="display: none;" class="post-publish__cover-section">
|
| 441 |
+
<div class="post-publish__cover-heading">${t('post.video_cover')}</div>
|
| 442 |
<div class="ranking-publish-cover-controls">
|
| 443 |
+
<div class="post-publish__cover-slot">
|
| 444 |
+
<img id="cover-thumb" src="" style="display: none;" class="post-publish__cover-image">
|
| 445 |
+
<span id="cover-label" style="display: none;" class="post-publish__cover-label">${t('post.cover')}</span>
|
| 446 |
</div>
|
| 447 |
+
<div class="post-publish__cover-actions">
|
| 448 |
<button type="button" id="btn-capture-frame" class="ranking-publish-secondary">
|
| 449 |
📸 ${t('post.capture_frame')}
|
| 450 |
</button>
|
|
|
|
| 460 |
</div>
|
| 461 |
|
| 462 |
<!-- 标题输入 -->
|
| 463 |
+
<div class="post-publish__field-group">
|
| 464 |
+
<label for="title-input" class="post-publish__field-label">
|
| 465 |
+
📝 ${t('post.title_label')} <span class="post-publish__required">*</span>
|
| 466 |
</label>
|
| 467 |
<input type="text" id="title-input" class="ranking-publish-input" value="${escapeHtml(editTitle)}" placeholder="${t('post.title_placeholder')}" maxlength="50">
|
| 468 |
+
<div class="post-publish__counter">
|
| 469 |
<span id="title-count">${editTitle.length}</span>/50
|
| 470 |
</div>
|
| 471 |
</div>
|
| 472 |
|
| 473 |
<!-- 正文输入 -->
|
| 474 |
+
<div class="post-publish__field-group">
|
| 475 |
+
<label for="content-input" class="post-publish__field-label">
|
| 476 |
✍️ ${t('post.content_label')}
|
| 477 |
</label>
|
| 478 |
+
<textarea id="content-input" class="post-publish__content ranking-publish-input" placeholder="${t('post.content_placeholder')}" maxlength="2000">${escapeHtml(editContent)}</textarea>
|
| 479 |
+
<div class="post-publish__counter">
|
| 480 |
<span id="content-count">${editContent.length}</span>/2000
|
| 481 |
</div>
|
| 482 |
</div>
|
| 483 |
|
| 484 |
<!-- 原创作品勾选框 -->
|
| 485 |
+
<div class="post-publish__original">
|
| 486 |
+
<label class="post-publish__original-label">
|
| 487 |
+
<input type="checkbox" id="is-original-checkbox" ${editIsOriginal ? 'checked' : ''} class="post-publish__original-checkbox" />
|
| 488 |
+
<span class="post-publish__original-text">🎨 ${t('publish.mark_as_original')}</span>
|
| 489 |
</label>
|
| 490 |
+
<div class="post-publish__original-hint">
|
| 491 |
${t('publish.original_default_hint')}
|
| 492 |
</div>
|
| 493 |
</div>
|
| 494 |
|
| 495 |
<!-- 提示信息 -->
|
| 496 |
+
<div class="post-publish__notice">
|
| 497 |
💡 <strong>${t('post.publish_notice')}:</strong><br>
|
| 498 |
• ${t('post.notice_original')}<br>
|
| 499 |
• ${t('post.notice_compress')}<br>
|
|
|
|
| 501 |
</div>
|
| 502 |
|
| 503 |
<!-- 确认发布按钮 -->
|
| 504 |
+
<button type="button" id="btn-submit-post" class="post-publish__submit">
|
| 505 |
+
🚀 ${isEditMode ? t('task.save_changes') : t('common.publish')}
|
| 506 |
</button>
|
| 507 |
</div>
|
| 508 |
`;
|
|
|
|
| 552 |
const isOriginalCheckbox = container.querySelector("#is-original-checkbox");
|
| 553 |
const account = currentUser?.account;
|
| 554 |
const scope = createPublishSubmissionScope(container, submitBtn, account);
|
| 555 |
+
const refreshImageShortcuts = () => {
|
| 556 |
+
if (scope.isCurrent()) updateCoverMark(imagesPreview);
|
| 557 |
+
};
|
| 558 |
+
window.addEventListener('comfy-settings-changed', refreshImageShortcuts);
|
| 559 |
+
window.addEventListener('storage', refreshImageShortcuts);
|
| 560 |
+
scope.addCleanup(() => {
|
| 561 |
+
window.removeEventListener('comfy-settings-changed', refreshImageShortcuts);
|
| 562 |
+
window.removeEventListener('storage', refreshImageShortcuts);
|
| 563 |
+
});
|
| 564 |
const editingLock = createPublishEditingLock(container, [submitBtn, container.querySelector("#btn-back-publish")]);
|
| 565 |
scope.addCleanup(() => editingLock.dispose());
|
| 566 |
const previewReaders = new Set(), temporaryVideos = new Map();
|
|
|
|
| 621 |
modeImageBtn.setAttribute('aria-pressed', String(postMode === 'image'));
|
| 622 |
modeVideoBtn.setAttribute('aria-pressed', String(postMode === 'video'));
|
| 623 |
if (postMode === 'image') {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 624 |
imageSection.style.display = 'block';
|
| 625 |
videoSection.style.display = 'none';
|
| 626 |
} else {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 627 |
imageSection.style.display = 'none';
|
| 628 |
videoSection.style.display = 'block';
|
| 629 |
}
|
|
|
|
| 767 |
|
| 768 |
if (totalImages === 0) {
|
| 769 |
imagesPreview.innerHTML = `
|
| 770 |
+
<div class="post-publish__upload-empty">
|
| 771 |
+
<div class="post-publish__upload-icon">📷</div>
|
| 772 |
${t('post.click_upload')}
|
| 773 |
</div>
|
| 774 |
`;
|
|
|
|
| 794 |
|
| 795 |
existingImageUrls.forEach((url, idx) => {
|
| 796 |
const wrapper = document.createElement("div");
|
| 797 |
+
wrapper.className = "post-publish-preview";
|
| 798 |
|
| 799 |
wrapper.innerHTML = `
|
| 800 |
+
<img src="${escapeHtml(url)}" class="post-publish__preview-image">
|
| 801 |
+
${idx === 0 ? `<span class="post-publish__cover-label cover-label">${t('post.cover')}</span>` : ''}
|
| 802 |
+
<button type="button" data-action="remove" data-existing-idx="${idx}" class="post-publish__remove-image">×</button>
|
| 803 |
`;
|
| 804 |
|
| 805 |
wrapper._imageType = 'existing';
|
|
|
|
| 823 |
previewReaders.add(reader);
|
| 824 |
reader.onabort = () => finishPreviewReader(reader);
|
| 825 |
const wrapper = document.createElement("div");
|
| 826 |
+
wrapper.className = "post-publish-preview";
|
| 827 |
wrapper.innerHTML = `
|
| 828 |
+
<img src="" class="post-publish__preview-image">
|
| 829 |
+
${existingImageUrls.length === 0 && idx === 0 ? `<span class="post-publish__cover-label cover-label">${t('post.cover')}</span>` : ''}
|
| 830 |
+
<button type="button" data-action="remove" data-idx="${idx}" class="post-publish__remove-image">×</button>
|
| 831 |
`;
|
| 832 |
|
| 833 |
wrapper.querySelector('img').removeAttribute('src');
|
|
|
|
| 874 |
const generation = beginImagePreviews();
|
| 875 |
if (imageFiles.length === 0) {
|
| 876 |
imagesPreview.innerHTML = `
|
| 877 |
+
<div class="post-publish__upload-empty">
|
| 878 |
+
<div class="post-publish__upload-icon">📷</div>
|
| 879 |
${t('post.click_upload')}
|
| 880 |
</div>
|
| 881 |
`;
|
|
|
|
| 891 |
previewReaders.add(reader);
|
| 892 |
reader.onabort = () => finishPreviewReader(reader);
|
| 893 |
const wrapper = document.createElement("div");
|
| 894 |
+
wrapper.className = "post-publish-preview";
|
| 895 |
|
| 896 |
wrapper.innerHTML = `
|
| 897 |
+
<img src="" class="post-publish__preview-image">
|
| 898 |
+
${idx === 0 ? `<span class="post-publish__cover-label cover-label">${t('post.cover')}</span>` : ''}
|
| 899 |
+
<button type="button" data-action="remove" data-idx="${idx}" class="post-publish__remove-image">×</button>
|
| 900 |
`;
|
| 901 |
|
| 902 |
wrapper.querySelector('img').removeAttribute('src');
|
前端页面/post/帖子详情组件.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
| 11 |
|
| 12 |
import { api, proxyImages, captureRequestSession, requestCancelManager } from "../core/网络请求API.js";
|
| 13 |
import { showToast } from "../components/UI交互提示组件.js";
|
| 14 |
-
import { getCoverSandboxHTML, setupImageSandboxEvents } from "../components/图片沙盒组件.js";
|
| 15 |
import { getVideoPlayerHTML, setupVideoPlayerEvents, cleanupVideoPlayer } from "../components/视频播放器组件.js";
|
| 16 |
import { renderTipLevelHTML } from "../components/打赏等级工具.js";
|
| 17 |
import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
|
|
@@ -25,6 +25,8 @@ import { createRatingStars, isValidRatingResponse } from "../social/评论与互
|
|
| 25 |
import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
|
| 26 |
import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
|
| 27 |
|
|
|
|
|
|
|
| 28 |
const viewContexts = new WeakMap();
|
| 29 |
let postViewSequence = 0;
|
| 30 |
const isCurrent = node => !!viewContexts.get(node)?.scope.isCurrent();
|
|
@@ -48,10 +50,10 @@ function _generateAvatarHtml(avatar, name, size, extraStyle = '') {
|
|
| 48 |
const initial = (name || 'U')[0].toUpperCase();
|
| 49 |
// 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
|
| 50 |
if (avatar) {
|
| 51 |
-
return `<img class="swr-avatar" src="${escapeHtml(avatar)}" style="width: ${size}px; height: ${size}px; border
|
| 52 |
}
|
| 53 |
const fontSize = Math.max(9, Math.round(size * 0.4));
|
| 54 |
-
return `<div class="swr-avatar
|
| 55 |
}
|
| 56 |
|
| 57 |
/**
|
|
@@ -66,7 +68,7 @@ function _updateAvatarElement(avatarEl, profile, size, extraStyle = '') {
|
|
| 66 |
if (avatarEl.tagName === 'IMG') {
|
| 67 |
avatarEl.src = profile.avatar;
|
| 68 |
} else {
|
| 69 |
-
avatarEl.outerHTML = `<img class="swr-avatar" src="${escapeHtml(profile.avatar)}" style="width: ${size}px; height: ${size}px; border
|
| 70 |
}
|
| 71 |
}
|
| 72 |
|
|
@@ -75,31 +77,21 @@ function _updateAvatarElement(avatarEl, profile, size, extraStyle = '') {
|
|
| 75 |
*/
|
| 76 |
export function createPostDetailView(postId, currentUser) {
|
| 77 |
const container = document.createElement("div");
|
| 78 |
-
|
| 79 |
-
display: "flex",
|
| 80 |
-
flexDirection: "column",
|
| 81 |
-
gap: "0",
|
| 82 |
-
color: "#ccc",
|
| 83 |
-
fontSize: "14px",
|
| 84 |
-
padding: "0",
|
| 85 |
-
overflowY: "auto",
|
| 86 |
-
flex: "1",
|
| 87 |
-
boxSizing: "border-box"
|
| 88 |
-
});
|
| 89 |
|
| 90 |
container.innerHTML = `
|
| 91 |
<!-- 顶部标题栏 -->
|
| 92 |
-
<div
|
| 93 |
-
<button id="btn-back-detail"
|
| 94 |
⬅ ${t('common.back')}
|
| 95 |
</button>
|
| 96 |
-
<span
|
| 97 |
</div>
|
| 98 |
|
| 99 |
<!-- 内容区域 -->
|
| 100 |
-
<div id="post-content"
|
| 101 |
-
<div
|
| 102 |
-
<div
|
| 103 |
${t('post.loading')}
|
| 104 |
</div>
|
| 105 |
</div>
|
|
@@ -123,6 +115,7 @@ export function createPostDetailView(postId, currentUser) {
|
|
| 123 |
viewContexts.set(container, context); viewContexts.set(contentArea, context);
|
| 124 |
const handleRouteBack = () => scope.dispose();
|
| 125 |
scope.addCleanup(() => {
|
|
|
|
| 126 |
cleanupVideoPlayer(contentArea);
|
| 127 |
contentArea.querySelectorAll('audio').forEach(audio => { audio.pause(); audio.removeAttribute('src'); audio.load(); });
|
| 128 |
contentArea.replaceChildren(); observer?.disconnect();
|
|
@@ -158,6 +151,7 @@ async function loadPostDetail(container, postId, currentUser) {
|
|
| 158 |
|
| 159 |
// 先清理旧的视频播放器全局事件(如果存在)
|
| 160 |
if (contentArea) {
|
|
|
|
| 161 |
cleanupVideoPlayer(contentArea);
|
| 162 |
}
|
| 163 |
|
|
@@ -182,8 +176,8 @@ async function loadPostDetail(container, postId, currentUser) {
|
|
| 182 |
|
| 183 |
if (!post) {
|
| 184 |
contentArea.innerHTML = `
|
| 185 |
-
<div
|
| 186 |
-
<div
|
| 187 |
${t('post.not_exist')}
|
| 188 |
</div>
|
| 189 |
`;
|
|
@@ -209,92 +203,89 @@ async function loadPostDetail(container, postId, currentUser) {
|
|
| 209 |
|
| 210 |
contentArea.innerHTML = `
|
| 211 |
<!-- 图片展示区 -->
|
| 212 |
-
<div id="images-area"
|
| 213 |
${mediaHTML}
|
| 214 |
</div>
|
| 215 |
|
| 216 |
<!-- 标题 -->
|
| 217 |
-
<div
|
| 218 |
${escapeHtml(post.title)}
|
| 219 |
</div>
|
| 220 |
|
| 221 |
<!-- 原创标识 -->
|
| 222 |
${post.is_original ? `
|
| 223 |
-
<div
|
| 224 |
-
background: linear-gradient(135deg, #FF6B35, #FF8F00); border-radius: 12px;
|
| 225 |
-
font-size: 11px; color: #fff; font-weight: 500; margin-bottom: 12px;">
|
| 226 |
🎨 ${t('publish.original_no_commercial')}
|
| 227 |
</div>
|
| 228 |
` : ''}
|
| 229 |
|
| 230 |
<!-- 作者信息(SWR 缓存头像) -->
|
| 231 |
-
<div
|
| 232 |
-
<
|
| 233 |
-
<div
|
| 234 |
-
<div class="swr-name"
|
| 235 |
-
<div
|
| 236 |
-
</div>
|
| 237 |
${currentUser && currentUser.account === post.author ? `
|
| 238 |
-
<div
|
| 239 |
-
${IS_WEB_MODE ? '' : `<button id="btn-edit-post"
|
| 240 |
-
<button id="btn-delete-post"
|
| 241 |
</div>
|
| 242 |
` : ''}
|
| 243 |
</div>
|
| 244 |
|
| 245 |
<!-- 正文内容 -->
|
| 246 |
-
<div
|
| 247 |
-
${escapeHtml(post.content)}
|
| 248 |
-
</div>
|
| 249 |
|
| 250 |
<!-- 互动按钮栏 -->
|
| 251 |
-
<div id="interaction-bar"
|
| 252 |
-
<button id="btn-like"
|
| 253 |
❤️ <span id="like-count">${post.likes || 0}</span>
|
| 254 |
</button>
|
| 255 |
-
<button id="btn-favorite"
|
| 256 |
🔖 <span id="favorite-count">${post.favorites || 0}</span>
|
| 257 |
</button>
|
| 258 |
-
<button id="btn-tip"
|
| 259 |
${t('post.tip_author')}
|
| 260 |
</button>
|
| 261 |
<!-- 👀 浏览量统计(纯展示) -->
|
| 262 |
-
<div
|
| 263 |
-
<span
|
| 264 |
-
<span
|
| 265 |
</div>
|
| 266 |
</div>
|
| 267 |
|
| 268 |
<!-- 评分区域 -->
|
| 269 |
-
<div id="rating-section"
|
| 270 |
|
| 271 |
<!-- 打赏榜单 -->
|
| 272 |
-
<div id="tip-board-area"
|
| 273 |
${renderTipBoard(post.tip_board || [])}
|
| 274 |
</div>
|
| 275 |
|
| 276 |
<!-- 评论区 -->
|
| 277 |
-
<div
|
| 278 |
-
${t('post.comments_title')} <span
|
| 279 |
</div>
|
| 280 |
|
| 281 |
<!-- 评论输入框 -->
|
| 282 |
-
<div
|
| 283 |
-
<input type="text" id="comment-input"
|
| 284 |
-
<button id="btn-send-comment"
|
| 285 |
${t('common.send')}
|
| 286 |
</button>
|
| 287 |
</div>
|
| 288 |
|
| 289 |
<!-- 评论列表 -->
|
| 290 |
-
<div id="comments-list"
|
| 291 |
-
<div
|
| 292 |
${t('post.comment_loading')}
|
| 293 |
</div>
|
| 294 |
</div>
|
| 295 |
`;
|
| 296 |
|
| 297 |
// 设置图片沙盒事件(视频帖无沙盒元素,函数会安全返回)
|
|
|
|
| 298 |
setupImageSandboxEvents(contentArea);
|
| 299 |
|
| 300 |
// 视频播放器事件绑定
|
|
@@ -489,19 +480,19 @@ async function showTipDialog(post, currentUser, container) {
|
|
| 489 |
}
|
| 490 |
|
| 491 |
const content = document.createElement("div");
|
| 492 |
-
content.
|
| 493 |
content.innerHTML = `
|
| 494 |
-
<div
|
| 495 |
-
${t('wallet.current_balance')}: <strong
|
| 496 |
</div>
|
| 497 |
-
<div
|
| 498 |
-
<button class="tip-amount" data-amount="10"
|
| 499 |
-
<button class="tip-amount" data-amount="50"
|
| 500 |
-
<button class="tip-amount" data-amount="100"
|
| 501 |
-
<button class="tip-amount" data-amount="500"
|
| 502 |
</div>
|
| 503 |
-
<div
|
| 504 |
-
<button id="tip-cancel"
|
| 505 |
</div>
|
| 506 |
`;
|
| 507 |
|
|
@@ -550,6 +541,13 @@ async function loadComments(container, postId, currentUser) {
|
|
| 550 |
const sendBtn = container.querySelector("#btn-send-comment");
|
| 551 |
if (!isCurrent(container)) return;
|
| 552 |
const context = viewContexts.get(container);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 553 |
const generation = context.commentGeneration = (context.commentGeneration || 0) + 1;
|
| 554 |
const current = () => isCurrent(container) && context.commentGeneration === generation && commentsList.isConnected;
|
| 555 |
|
|
@@ -563,7 +561,7 @@ async function loadComments(container, postId, currentUser) {
|
|
| 563 |
|
| 564 |
if (comments.length === 0) {
|
| 565 |
commentsList.innerHTML = `
|
| 566 |
-
<div
|
| 567 |
${t('post.no_comments')}
|
| 568 |
</div>
|
| 569 |
`;
|
|
@@ -577,13 +575,13 @@ async function loadComments(container, postId, currentUser) {
|
|
| 577 |
const avatarHtml = _generateAvatarHtml(avatar, name, 24, 'background: var(--comfy-input-bg);');
|
| 578 |
|
| 579 |
return `
|
| 580 |
-
<div id="comment-${idx}" data-account="${escapeHtml(c.author)}"
|
| 581 |
-
<div
|
| 582 |
<div class="swr-avatar-container">${avatarHtml}</div>
|
| 583 |
-
<span class="
|
| 584 |
-
<span
|
| 585 |
</div>
|
| 586 |
-
<div
|
| 587 |
${escapeHtml(c.content)}
|
| 588 |
</div>
|
| 589 |
</div>
|
|
@@ -607,45 +605,71 @@ async function loadComments(container, postId, currentUser) {
|
|
| 607 |
} catch (err) {
|
| 608 |
if (!current()) return;
|
| 609 |
commentsList.innerHTML = `
|
| 610 |
-
<div
|
| 611 |
${t('post.comment_load_failed')}
|
| 612 |
</div>
|
| 613 |
`;
|
| 614 |
}
|
| 615 |
|
| 616 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 617 |
sendBtn.onclick = async () => {
|
| 618 |
-
if (!
|
| 619 |
-
if (!
|
| 620 |
showToast(t('auth.login_required'), "warning");
|
| 621 |
return;
|
| 622 |
}
|
| 623 |
-
const
|
| 624 |
if (!content) {
|
|
|
|
|
|
|
| 625 |
showToast(t('post.comment_required'), "warning");
|
| 626 |
return;
|
| 627 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 628 |
try {
|
| 629 |
-
sendBtn.disabled = true;
|
| 630 |
-
sendBtn.textContent = t('common.sending');
|
| 631 |
const result = await api.addPostComment(postId, content);
|
| 632 |
-
if (!
|
| 633 |
-
if (result
|
| 634 |
-
|
|
|
|
| 635 |
showToast(t('post.comment_success'), "success");
|
| 636 |
-
loadComments(container, postId, currentUser);
|
|
|
|
| 637 |
} catch (err) {
|
| 638 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 639 |
} finally {
|
| 640 |
-
if (!
|
| 641 |
-
sendBtn.disabled =
|
| 642 |
-
|
|
|
|
|
|
|
| 643 |
}
|
| 644 |
};
|
| 645 |
|
| 646 |
// 回车发送
|
| 647 |
commentInput.onkeydown = (e) => {
|
| 648 |
-
if (
|
|
|
|
|
|
|
|
|
|
| 649 |
e.preventDefault();
|
| 650 |
sendBtn.click();
|
| 651 |
}
|
|
@@ -665,15 +689,15 @@ function renderTipBoard(tipBoard) {
|
|
| 665 |
function showDeleteConfirmDialog(post, container) {
|
| 666 |
if (!isCurrent(container)) return;
|
| 667 |
const content = document.createElement("div");
|
| 668 |
-
content.
|
| 669 |
content.innerHTML = `
|
| 670 |
-
<div
|
| 671 |
-
<div
|
| 672 |
-
<div
|
| 673 |
-
<div
|
| 674 |
-
<div
|
| 675 |
-
<button id="delete-cancel"
|
| 676 |
-
<button id="delete-confirm"
|
| 677 |
</div>
|
| 678 |
</div>
|
| 679 |
`;
|
|
|
|
| 11 |
|
| 12 |
import { api, proxyImages, captureRequestSession, requestCancelManager } from "../core/网络请求API.js";
|
| 13 |
import { showToast } from "../components/UI交互提示组件.js";
|
| 14 |
+
import { getCoverSandboxHTML, setupImageSandboxEvents, cleanupImageSandbox } from "../components/图片沙盒组件.js";
|
| 15 |
import { getVideoPlayerHTML, setupVideoPlayerEvents, cleanupVideoPlayer } from "../components/视频播放器组件.js";
|
| 16 |
import { renderTipLevelHTML } from "../components/打赏等级工具.js";
|
| 17 |
import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
|
|
|
|
| 25 |
import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
|
| 26 |
import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
|
| 27 |
|
| 28 |
+
import { isConfirmedDedicatedCommentResponse, isDefiniteCommentRejection } from "../social/评论数据工具.js";
|
| 29 |
+
|
| 30 |
const viewContexts = new WeakMap();
|
| 31 |
let postViewSequence = 0;
|
| 32 |
const isCurrent = node => !!viewContexts.get(node)?.scope.isCurrent();
|
|
|
|
| 50 |
const initial = (name || 'U')[0].toUpperCase();
|
| 51 |
// 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
|
| 52 |
if (avatar) {
|
| 53 |
+
return `<img alt="" class="swr-avatar post-detail-avatar ${extraStyle === 'border: 2px solid #444;' ? 'post-detail-avatar--border' : extraStyle === 'background: var(--comfy-input-bg);' ? 'post-detail-avatar--panel' : ''}" src="${escapeHtml(avatar)}" style="width: ${size}px; height: ${size}px; ${['border: 2px solid #444;', 'background: var(--comfy-input-bg);'].includes(extraStyle) ? '' : extraStyle}">`;
|
| 54 |
}
|
| 55 |
const fontSize = Math.max(9, Math.round(size * 0.4));
|
| 56 |
+
return `<div class="swr-avatar post-detail-avatar ${extraStyle === 'border: 2px solid #444;' ? 'post-detail-avatar--border' : extraStyle === 'background: var(--comfy-input-bg);' ? 'post-detail-avatar--panel' : ''}" style="width: ${size}px; height: ${size}px; font-size: ${fontSize}px; font-weight: bold;">${escapeHtml(initial)}</div>`;
|
| 57 |
}
|
| 58 |
|
| 59 |
/**
|
|
|
|
| 68 |
if (avatarEl.tagName === 'IMG') {
|
| 69 |
avatarEl.src = profile.avatar;
|
| 70 |
} else {
|
| 71 |
+
avatarEl.outerHTML = `<img alt="" class="swr-avatar post-detail-avatar ${extraStyle === 'border: 2px solid #444;' ? 'post-detail-avatar--border' : extraStyle === 'background: var(--comfy-input-bg);' ? 'post-detail-avatar--panel' : ''}" src="${escapeHtml(profile.avatar)}" style="width: ${size}px; height: ${size}px; ${['border: 2px solid #444;', 'background: var(--comfy-input-bg);'].includes(extraStyle) ? '' : extraStyle}">`;
|
| 72 |
}
|
| 73 |
}
|
| 74 |
|
|
|
|
| 77 |
*/
|
| 78 |
export function createPostDetailView(postId, currentUser) {
|
| 79 |
const container = document.createElement("div");
|
| 80 |
+
container.className = "discussion-detail";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
container.innerHTML = `
|
| 83 |
<!-- 顶部标题栏 -->
|
| 84 |
+
<div class="post-detail__header">
|
| 85 |
+
<button type="button" id="btn-back-detail" class="post-detail__back">
|
| 86 |
⬅ ${t('common.back')}
|
| 87 |
</button>
|
| 88 |
+
<span class="post-detail__heading">${t('post.detail_title')}</span>
|
| 89 |
</div>
|
| 90 |
|
| 91 |
<!-- 内容区域 -->
|
| 92 |
+
<div id="post-content" class="post-detail__body">
|
| 93 |
+
<div class="post-detail__loading" role="status">
|
| 94 |
+
<div class="post-detail__icon">⏳</div>
|
| 95 |
${t('post.loading')}
|
| 96 |
</div>
|
| 97 |
</div>
|
|
|
|
| 115 |
viewContexts.set(container, context); viewContexts.set(contentArea, context);
|
| 116 |
const handleRouteBack = () => scope.dispose();
|
| 117 |
scope.addCleanup(() => {
|
| 118 |
+
cleanupImageSandbox(contentArea);
|
| 119 |
cleanupVideoPlayer(contentArea);
|
| 120 |
contentArea.querySelectorAll('audio').forEach(audio => { audio.pause(); audio.removeAttribute('src'); audio.load(); });
|
| 121 |
contentArea.replaceChildren(); observer?.disconnect();
|
|
|
|
| 151 |
|
| 152 |
// 先清理旧的视频播放器全局事件(如果存在)
|
| 153 |
if (contentArea) {
|
| 154 |
+
cleanupImageSandbox(contentArea);
|
| 155 |
cleanupVideoPlayer(contentArea);
|
| 156 |
}
|
| 157 |
|
|
|
|
| 176 |
|
| 177 |
if (!post) {
|
| 178 |
contentArea.innerHTML = `
|
| 179 |
+
<div class="post-detail__error" role="alert">
|
| 180 |
+
<div class="post-detail__icon">⚠️</div>
|
| 181 |
${t('post.not_exist')}
|
| 182 |
</div>
|
| 183 |
`;
|
|
|
|
| 203 |
|
| 204 |
contentArea.innerHTML = `
|
| 205 |
<!-- 图片展示区 -->
|
| 206 |
+
<div id="images-area" class="post-detail__media">
|
| 207 |
${mediaHTML}
|
| 208 |
</div>
|
| 209 |
|
| 210 |
<!-- 标题 -->
|
| 211 |
+
<div class="post-detail__title">
|
| 212 |
${escapeHtml(post.title)}
|
| 213 |
</div>
|
| 214 |
|
| 215 |
<!-- 原创标识 -->
|
| 216 |
${post.is_original ? `
|
| 217 |
+
<div class="post-detail__original">
|
|
|
|
|
|
|
| 218 |
🎨 ${t('publish.original_no_commercial')}
|
| 219 |
</div>
|
| 220 |
` : ''}
|
| 221 |
|
| 222 |
<!-- 作者信息(SWR 缓存头像) -->
|
| 223 |
+
<div class="post-detail__author post-detail-author-row">
|
| 224 |
+
<button type="button" id="author-row" class="post-detail-author-button"><div class="post-detail__avatar-slot swr-avatar-container"></div>
|
| 225 |
+
<div class="post-detail__identity">
|
| 226 |
+
<div class="post-detail__name swr-name">${escapeHtml(post.author_name || post.author)}</div>
|
| 227 |
+
<div class="post-detail__timestamp">${formatTime(post.created_at)}</div>
|
| 228 |
+
</div></button>
|
| 229 |
${currentUser && currentUser.account === post.author ? `
|
| 230 |
+
<div onclick="event.stopPropagation()" class="post-detail__owner-actions">
|
| 231 |
+
${IS_WEB_MODE ? '' : `<button type="button" id="btn-edit-post" aria-label="${t('common.edit')}" title="${t('common.edit')}" class="post-detail__edit">✏️</button>`}
|
| 232 |
+
<button type="button" id="btn-delete-post" aria-label="${t('common.delete')}" title="${t('common.delete')}" class="post-detail__delete">🗑️</button>
|
| 233 |
</div>
|
| 234 |
` : ''}
|
| 235 |
</div>
|
| 236 |
|
| 237 |
<!-- 正文内容 -->
|
| 238 |
+
<div class="post-detail__content">${escapeHtml(post.content)}</div>
|
|
|
|
|
|
|
| 239 |
|
| 240 |
<!-- 互动按钮栏 -->
|
| 241 |
+
<div id="interaction-bar" class="post-detail__interactions">
|
| 242 |
+
<button type="button" id="btn-like" aria-label="${t('market.like')}" aria-pressed="${isLiked}" class="post-detail-toggle">
|
| 243 |
❤️ <span id="like-count">${post.likes || 0}</span>
|
| 244 |
</button>
|
| 245 |
+
<button type="button" id="btn-favorite" aria-label="${t('market.favorite')}" aria-pressed="${isFavorited}" class="post-detail-toggle">
|
| 246 |
🔖 <span id="favorite-count">${post.favorites || 0}</span>
|
| 247 |
</button>
|
| 248 |
+
<button type="button" id="btn-tip" class="post-detail__tip">
|
| 249 |
${t('post.tip_author')}
|
| 250 |
</button>
|
| 251 |
<!-- 👀 浏览量统计(纯展示) -->
|
| 252 |
+
<div class="post-detail__stats">
|
| 253 |
+
<span class="post-detail__stat">🔥 <span id="post-view-total">${post.views || 0}</span></span>
|
| 254 |
+
<span class="post-detail__stat">📅 <span id="post-view-daily">${post.daily_views || 0}</span></span>
|
| 255 |
</div>
|
| 256 |
</div>
|
| 257 |
|
| 258 |
<!-- 评分区域 -->
|
| 259 |
+
<div id="rating-section" class="post-detail__rating"></div>
|
| 260 |
|
| 261 |
<!-- 打赏榜单 -->
|
| 262 |
+
<div id="tip-board-area" class="post-detail__media">
|
| 263 |
${renderTipBoard(post.tip_board || [])}
|
| 264 |
</div>
|
| 265 |
|
| 266 |
<!-- 评论区 -->
|
| 267 |
+
<div class="post-detail__comments-heading">
|
| 268 |
+
${t('post.comments_title')} <span class="post-detail__comments-count">(${post.comments || 0})</span>
|
| 269 |
</div>
|
| 270 |
|
| 271 |
<!-- 评论输入框 -->
|
| 272 |
+
<div class="post-detail__comment-composer">
|
| 273 |
+
<input type="text" id="comment-input" required aria-label="${escapeHtml(t('post.comment_placeholder'))}" placeholder="${t('post.comment_placeholder')}" class="post-detail__comment-input">
|
| 274 |
+
<button type="button" id="btn-send-comment" class="post-detail__comment-send">
|
| 275 |
${t('common.send')}
|
| 276 |
</button>
|
| 277 |
</div>
|
| 278 |
|
| 279 |
<!-- 评论列表 -->
|
| 280 |
+
<div id="comments-list" class="post-detail__comments-list">
|
| 281 |
+
<div class="post-detail__comments-loading" role="status">
|
| 282 |
${t('post.comment_loading')}
|
| 283 |
</div>
|
| 284 |
</div>
|
| 285 |
`;
|
| 286 |
|
| 287 |
// 设置图片沙盒事件(视频帖无沙盒元素,函数会安全返回)
|
| 288 |
+
contentArea.querySelectorAll("#images-area button:not([type])").forEach(button => { button.type = "button"; });
|
| 289 |
setupImageSandboxEvents(contentArea);
|
| 290 |
|
| 291 |
// 视频播放器事件绑定
|
|
|
|
| 480 |
}
|
| 481 |
|
| 482 |
const content = document.createElement("div");
|
| 483 |
+
content.className = "discussion-modal";
|
| 484 |
content.innerHTML = `
|
| 485 |
+
<div class="post-detail__balance">
|
| 486 |
+
${t('wallet.current_balance')}: <strong class="post-detail__balance-value">${balance}</strong> ${t('wallet.credits')}
|
| 487 |
</div>
|
| 488 |
+
<div class="post-detail__tip-options">
|
| 489 |
+
<button type="button" class="post-detail__tip-amount tip-amount" data-amount="10">10 ${t('task.points')}</button>
|
| 490 |
+
<button type="button" class="post-detail__tip-amount tip-amount" data-amount="50">50 ${t('task.points')}</button>
|
| 491 |
+
<button type="button" class="post-detail__tip-amount tip-amount" data-amount="100">100 ${t('task.points')}</button>
|
| 492 |
+
<button type="button" class="post-detail__tip-amount tip-amount" data-amount="500">500 ${t('task.points')}</button>
|
| 493 |
</div>
|
| 494 |
+
<div class="post-detail__modal-actions">
|
| 495 |
+
<button type="button" id="tip-cancel" class="post-detail__modal-cancel">${t('common.cancel')}</button>
|
| 496 |
</div>
|
| 497 |
`;
|
| 498 |
|
|
|
|
| 541 |
const sendBtn = container.querySelector("#btn-send-comment");
|
| 542 |
if (!isCurrent(container)) return;
|
| 543 |
const context = viewContexts.get(container);
|
| 544 |
+
const account = typeof currentUser === "string" ? currentUser : currentUser?.account;
|
| 545 |
+
let submission = context.commentSubmission;
|
| 546 |
+
if (!submission || submission.input !== commentInput || submission.button !== sendBtn || submission.account !== account) {
|
| 547 |
+
submission = context.commentSubmission = { input: commentInput, button: sendBtn, account, phase: "idle", bound: false };
|
| 548 |
+
}
|
| 549 |
+
const submissionCurrent = () => isCurrent(container) && context.commentSubmission === submission
|
| 550 |
+
&& commentInput.isConnected && sendBtn.isConnected;
|
| 551 |
const generation = context.commentGeneration = (context.commentGeneration || 0) + 1;
|
| 552 |
const current = () => isCurrent(container) && context.commentGeneration === generation && commentsList.isConnected;
|
| 553 |
|
|
|
|
| 561 |
|
| 562 |
if (comments.length === 0) {
|
| 563 |
commentsList.innerHTML = `
|
| 564 |
+
<div class="post-detail__comments-empty">
|
| 565 |
${t('post.no_comments')}
|
| 566 |
</div>
|
| 567 |
`;
|
|
|
|
| 575 |
const avatarHtml = _generateAvatarHtml(avatar, name, 24, 'background: var(--comfy-input-bg);');
|
| 576 |
|
| 577 |
return `
|
| 578 |
+
<div id="comment-${idx}" data-account="${escapeHtml(c.author)}" class="post-detail__comment">
|
| 579 |
+
<div class="post-detail__comment-author">
|
| 580 |
<div class="swr-avatar-container">${avatarHtml}</div>
|
| 581 |
+
<span class="post-detail__comment-name swr-name">${escapeHtml(name)}</span>
|
| 582 |
+
<span class="post-detail__comment-time">${formatTime(c.created_at)}</span>
|
| 583 |
</div>
|
| 584 |
+
<div class="post-detail__comment-content">
|
| 585 |
${escapeHtml(c.content)}
|
| 586 |
</div>
|
| 587 |
</div>
|
|
|
|
| 605 |
} catch (err) {
|
| 606 |
if (!current()) return;
|
| 607 |
commentsList.innerHTML = `
|
| 608 |
+
<div class="post-detail__comments-error" role="alert">
|
| 609 |
${t('post.comment_load_failed')}
|
| 610 |
</div>
|
| 611 |
`;
|
| 612 |
}
|
| 613 |
|
| 614 |
+
if (submission.bound || !submissionCurrent()) return;
|
| 615 |
+
submission.bound = true;
|
| 616 |
+
sendBtn.disabled = false;
|
| 617 |
+
commentInput.readOnly = false;
|
| 618 |
+
sendBtn.removeAttribute("aria-busy");
|
| 619 |
+
commentInput.oninput = () => {
|
| 620 |
+
if (submissionCurrent()) commentInput.removeAttribute("aria-invalid");
|
| 621 |
+
};
|
| 622 |
sendBtn.onclick = async () => {
|
| 623 |
+
if (!submissionCurrent() || submission.phase !== "idle") return;
|
| 624 |
+
if (typeof account !== "string" || !account.trim()) {
|
| 625 |
showToast(t('auth.login_required'), "warning");
|
| 626 |
return;
|
| 627 |
}
|
| 628 |
+
const originalInput = commentInput.value, content = originalInput.trim();
|
| 629 |
if (!content) {
|
| 630 |
+
commentInput.setAttribute("aria-invalid", "true");
|
| 631 |
+
commentInput.focus();
|
| 632 |
showToast(t('post.comment_required'), "warning");
|
| 633 |
return;
|
| 634 |
}
|
| 635 |
+
commentInput.removeAttribute("aria-invalid");
|
| 636 |
+
submission.phase = "submitting";
|
| 637 |
+
sendBtn.disabled = true;
|
| 638 |
+
sendBtn.setAttribute("aria-busy", "true");
|
| 639 |
+
sendBtn.textContent = t('common.sending');
|
| 640 |
try {
|
|
|
|
|
|
|
| 641 |
const result = await api.addPostComment(postId, content);
|
| 642 |
+
if (!submissionCurrent()) return;
|
| 643 |
+
if (!isConfirmedDedicatedCommentResponse(result, account, content)) throw new Error("Unconfirmed comment");
|
| 644 |
+
submission.phase = "committed";
|
| 645 |
+
if (commentInput.value === originalInput) commentInput.value = "";
|
| 646 |
showToast(t('post.comment_success'), "success");
|
| 647 |
+
await loadComments(container, postId, currentUser);
|
| 648 |
+
if (submissionCurrent()) submission.phase = "idle";
|
| 649 |
} catch (err) {
|
| 650 |
+
if (!submissionCurrent()) return;
|
| 651 |
+
if (submission.phase === "committed") {
|
| 652 |
+
try { showToast(t('social.comment_refresh_required'), "warning"); } catch (_) {}
|
| 653 |
+
return;
|
| 654 |
+
}
|
| 655 |
+
const rejected = isDefiniteCommentRejection(err);
|
| 656 |
+
submission.phase = rejected ? "idle" : "unconfirmed";
|
| 657 |
+
showToast(t(rejected ? 'post.comment_failed' : 'social.comment_unconfirmed'), rejected ? "error" : "warning");
|
| 658 |
} finally {
|
| 659 |
+
if (!submissionCurrent()) return;
|
| 660 |
+
sendBtn.disabled = submission.phase !== "idle";
|
| 661 |
+
commentInput.readOnly = submission.phase !== "idle";
|
| 662 |
+
sendBtn.removeAttribute("aria-busy");
|
| 663 |
+
sendBtn.textContent = t(submission.phase === "unconfirmed" ? 'social.comment_pending' : 'common.send');
|
| 664 |
}
|
| 665 |
};
|
| 666 |
|
| 667 |
// 回车发送
|
| 668 |
commentInput.onkeydown = (e) => {
|
| 669 |
+
if (isCurrent(container) && !e.defaultPrevented && e.key === "Enter"
|
| 670 |
+
&& !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey
|
| 671 |
+
&& !e.isComposing && e.keyCode !== 229 && !e.repeat
|
| 672 |
+
&& !commentInput.disabled && !commentInput.readOnly) {
|
| 673 |
e.preventDefault();
|
| 674 |
sendBtn.click();
|
| 675 |
}
|
|
|
|
| 689 |
function showDeleteConfirmDialog(post, container) {
|
| 690 |
if (!isCurrent(container)) return;
|
| 691 |
const content = document.createElement("div");
|
| 692 |
+
content.className = "discussion-modal";
|
| 693 |
content.innerHTML = `
|
| 694 |
+
<div class="post-detail__modal-center">
|
| 695 |
+
<div class="post-detail__warning-icon">⚠️</div>
|
| 696 |
+
<div class="post-detail__modal-title">${t('post.delete_confirm_title')}</div>
|
| 697 |
+
<div class="post-detail__modal-description">${t('post.delete_confirm_desc')}</div>
|
| 698 |
+
<div class="post-detail__modal-actions">
|
| 699 |
+
<button type="button" id="delete-cancel" class="post-detail__modal-cancel">${t('common.cancel')}</button>
|
| 700 |
+
<button type="button" id="delete-confirm" class="post-detail__modal-danger">${t('common.delete')}</button>
|
| 701 |
</div>
|
| 702 |
</div>
|
| 703 |
`;
|
前端页面/post/讨论区组件.js
CHANGED
|
@@ -259,7 +259,7 @@ export function createPostsView(currentUser, keyword = "") {
|
|
| 259 |
// 🚀 本地排序优先:已有数据时直接本地排序渲染
|
| 260 |
if (allPostsData.length > 0) {
|
| 261 |
const sorted = sortPostsLocally(allPostsData, currentSort);
|
| 262 |
-
renderPostsFromCache(sorted);
|
| 263 |
// 后台静默刷新最新数据
|
| 264 |
silentRefresh();
|
| 265 |
} else {
|
|
@@ -282,7 +282,7 @@ export function createPostsView(currentUser, keyword = "") {
|
|
| 282 |
listViewGeneration++;
|
| 283 |
if (allPostsData.length > 0) {
|
| 284 |
const sorted = sortPostsLocally(allPostsData, currentSort);
|
| 285 |
-
renderPostsFromCache(sorted);
|
| 286 |
}
|
| 287 |
silentRefresh();
|
| 288 |
};
|
|
@@ -445,6 +445,7 @@ export function createPostsView(currentUser, keyword = "") {
|
|
| 445 |
// 从缓存渲染帖子列表
|
| 446 |
const renderPostsFromCache = (posts, deferSearchScan = false) => {
|
| 447 |
firstPageReady = true;
|
|
|
|
| 448 |
postsGrid.innerHTML = "";
|
| 449 |
|
| 450 |
// 🔍 搜索过滤
|
|
@@ -460,6 +461,7 @@ export function createPostsView(currentUser, keyword = "") {
|
|
| 460 |
postsGrid.innerHTML = _renderEmptyState(t);
|
| 461 |
loadMoreWrapper.style.display = "none";
|
| 462 |
}
|
|
|
|
| 463 |
return;
|
| 464 |
}
|
| 465 |
|
|
@@ -487,6 +489,7 @@ export function createPostsView(currentUser, keyword = "") {
|
|
| 487 |
} else {
|
| 488 |
loadMoreWrapper.style.display = "none";
|
| 489 |
}
|
|
|
|
| 490 |
};
|
| 491 |
|
| 492 |
// 后台静默更新
|
|
@@ -548,14 +551,25 @@ export function createPostsView(currentUser, keyword = "") {
|
|
| 548 |
const _postsDataChanged = (oldData, newData) => {
|
| 549 |
if (!oldData || !newData) return true;
|
| 550 |
if (oldData.length !== newData.length) return true;
|
| 551 |
-
const checkCount =
|
| 552 |
const COMPARE_FIELDS = ['likes', 'favorites', 'views', 'daily_views', 'comments_count'];
|
|
|
|
|
|
|
|
|
|
|
|
|
| 553 |
for (let i = 0; i < checkCount; i++) {
|
| 554 |
if ((oldData[i].id) !== (newData[i].id)) return true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 555 |
for (const f of COMPARE_FIELDS) {
|
| 556 |
if ((oldData[i][f] || 0) !== (newData[i][f] || 0)) return true;
|
| 557 |
}
|
| 558 |
-
|
|
|
|
| 559 |
}
|
| 560 |
return false;
|
| 561 |
};
|
|
@@ -601,12 +615,17 @@ export function createPostsView(currentUser, keyword = "") {
|
|
| 601 |
function createPostCard(post, currentUser = currentUserCache, isCurrent = () => true, captureNavigation = () => isCurrent) {
|
| 602 |
const card = document.createElement("div");
|
| 603 |
card.className = "business-list-card posts-card";
|
|
|
|
|
|
|
|
|
|
| 604 |
|
| 605 |
// 格式化时间
|
| 606 |
const timeStr = formatTime(post.created_at);
|
| 607 |
|
| 608 |
// 判断是否为视频帖子
|
| 609 |
const isVideo = post.post_type === "video";
|
|
|
|
|
|
|
| 610 |
|
| 611 |
card.innerHTML = `
|
| 612 |
<!-- 封面图 -->
|
|
@@ -630,7 +649,7 @@ function createPostCard(post, currentUser = currentUserCache, isCurrent = () =>
|
|
| 630 |
</button>
|
| 631 |
|
| 632 |
<!-- 作者信息(SWR 缓存头像) -->
|
| 633 |
-
<div class="business-list-card__author posts-card__author"></div>
|
| 634 |
|
| 635 |
<!-- 互动数据 -->
|
| 636 |
<div class="posts-card__stats">
|
|
@@ -650,9 +669,9 @@ function createPostCard(post, currentUser = currentUserCache, isCurrent = () =>
|
|
| 650 |
if (!authorContainer) return;
|
| 651 |
|
| 652 |
const account = post.author;
|
| 653 |
-
const cached =
|
| 654 |
-
const avatar = cached?.avatar
|
| 655 |
-
const name =
|
| 656 |
const initial = (name || 'U')[0].toUpperCase();
|
| 657 |
|
| 658 |
// 渲染初始头像
|
|
@@ -661,21 +680,23 @@ function createPostCard(post, currentUser = currentUserCache, isCurrent = () =>
|
|
| 661 |
authorContainer.innerHTML = `${avatarHtml}<span class="swr-name posts-card__name">${escapeHtml(name)}</span>`;
|
| 662 |
|
| 663 |
// 后台静默校对
|
| 664 |
-
|
| 665 |
if (!isCurrent() || !card.isConnected) return;
|
| 666 |
const avatarEl = authorContainer.querySelector('.swr-avatar');
|
| 667 |
const nameEl = authorContainer.querySelector('.swr-name');
|
| 668 |
-
if (avatarEl && profile.avatar) {
|
| 669 |
if (avatarEl.tagName === 'IMG') {
|
| 670 |
-
avatarEl.src = profile.avatar;
|
| 671 |
} else {
|
| 672 |
-
avatarEl.outerHTML = _generateAvatarHtml(profile.avatar, profile.name
|
| 673 |
}
|
| 674 |
}
|
| 675 |
-
if (nameEl && profile.name) {
|
| 676 |
nameEl.textContent = profile.name;
|
| 677 |
}
|
| 678 |
-
}
|
|
|
|
|
|
|
| 679 |
}, 0);
|
| 680 |
|
| 681 |
const cover = card.querySelector('img');
|
|
|
|
| 259 |
// 🚀 本地排序优先:已有数据时直接本地排序渲染
|
| 260 |
if (allPostsData.length > 0) {
|
| 261 |
const sorted = sortPostsLocally(allPostsData, currentSort);
|
| 262 |
+
renderPostsFromCache(sorted, true);
|
| 263 |
// 后台静默刷新最新数据
|
| 264 |
silentRefresh();
|
| 265 |
} else {
|
|
|
|
| 282 |
listViewGeneration++;
|
| 283 |
if (allPostsData.length > 0) {
|
| 284 |
const sorted = sortPostsLocally(allPostsData, currentSort);
|
| 285 |
+
renderPostsFromCache(sorted, true);
|
| 286 |
}
|
| 287 |
silentRefresh();
|
| 288 |
};
|
|
|
|
| 445 |
// 从缓存渲染帖子列表
|
| 446 |
const renderPostsFromCache = (posts, deferSearchScan = false) => {
|
| 447 |
firstPageReady = true;
|
| 448 |
+
const restoreFocus = cardNavigation.captureListFocus(postsGrid);
|
| 449 |
postsGrid.innerHTML = "";
|
| 450 |
|
| 451 |
// 🔍 搜索过滤
|
|
|
|
| 461 |
postsGrid.innerHTML = _renderEmptyState(t);
|
| 462 |
loadMoreWrapper.style.display = "none";
|
| 463 |
}
|
| 464 |
+
restoreFocus();
|
| 465 |
return;
|
| 466 |
}
|
| 467 |
|
|
|
|
| 489 |
} else {
|
| 490 |
loadMoreWrapper.style.display = "none";
|
| 491 |
}
|
| 492 |
+
restoreFocus();
|
| 493 |
};
|
| 494 |
|
| 495 |
// 后台静默更新
|
|
|
|
| 551 |
const _postsDataChanged = (oldData, newData) => {
|
| 552 |
if (!oldData || !newData) return true;
|
| 553 |
if (oldData.length !== newData.length) return true;
|
| 554 |
+
const checkCount = oldData.length;
|
| 555 |
const COMPARE_FIELDS = ['likes', 'favorites', 'views', 'daily_views', 'comments_count'];
|
| 556 |
+
const contentFields = ["title", "content", "post_type", "created_at", "author", "author_name"];
|
| 557 |
+
const mediaFields = ["cover_image", "author_avatar"];
|
| 558 |
+
const normalizedImage = value => typeof value === 'string'
|
| 559 |
+
? proxyImages({ cover_image: value }).cover_image : value;
|
| 560 |
for (let i = 0; i < checkCount; i++) {
|
| 561 |
if ((oldData[i].id) !== (newData[i].id)) return true;
|
| 562 |
+
for (const field of contentFields) {
|
| 563 |
+
if (!Object.is(oldData[i][field], newData[i][field])) return true;
|
| 564 |
+
}
|
| 565 |
+
for (const field of mediaFields) {
|
| 566 |
+
if (!Object.is(normalizedImage(oldData[i][field]), normalizedImage(newData[i][field]))) return true;
|
| 567 |
+
}
|
| 568 |
for (const f of COMPARE_FIELDS) {
|
| 569 |
if ((oldData[i][f] || 0) !== (newData[i][f] || 0)) return true;
|
| 570 |
}
|
| 571 |
+
const ratingText = value => (typeof value === 'number' && Number.isFinite(value) ? value : 0).toFixed(1);
|
| 572 |
+
if (ratingText(oldData[i].rating_avg) !== ratingText(newData[i].rating_avg)) return true;
|
| 573 |
}
|
| 574 |
return false;
|
| 575 |
};
|
|
|
|
| 615 |
function createPostCard(post, currentUser = currentUserCache, isCurrent = () => true, captureNavigation = () => isCurrent) {
|
| 616 |
const card = document.createElement("div");
|
| 617 |
card.className = "business-list-card posts-card";
|
| 618 |
+
if (typeof post.id === 'string' || (typeof post.id === 'number' && Number.isFinite(post.id))) {
|
| 619 |
+
card._rankingListId = String(post.id);
|
| 620 |
+
}
|
| 621 |
|
| 622 |
// 格式化时间
|
| 623 |
const timeStr = formatTime(post.created_at);
|
| 624 |
|
| 625 |
// 判断是否为视频帖子
|
| 626 |
const isVideo = post.post_type === "video";
|
| 627 |
+
const initialAuthor = isCurrent() && typeof post.author === 'string' ? getCachedProfile(post.author) : null;
|
| 628 |
+
const initialAuthorName = [initialAuthor?.name, post.author_name, post.author].find(value => typeof value === 'string' && value) || '';
|
| 629 |
|
| 630 |
card.innerHTML = `
|
| 631 |
<!-- 封面图 -->
|
|
|
|
| 649 |
</button>
|
| 650 |
|
| 651 |
<!-- 作者信息(SWR 缓存头像) -->
|
| 652 |
+
<div class="business-list-card__author posts-card__author">${_generateAvatarHtml('', initialAuthorName)}<span class="swr-name posts-card__name">${escapeHtml(initialAuthorName)}</span></div>
|
| 653 |
|
| 654 |
<!-- 互动数据 -->
|
| 655 |
<div class="posts-card__stats">
|
|
|
|
| 669 |
if (!authorContainer) return;
|
| 670 |
|
| 671 |
const account = post.author;
|
| 672 |
+
const cached = initialAuthor;
|
| 673 |
+
const avatar = [cached?.avatar, post.author_avatar].find(value => typeof value === 'string' && value) || '';
|
| 674 |
+
const name = initialAuthorName;
|
| 675 |
const initial = (name || 'U')[0].toUpperCase();
|
| 676 |
|
| 677 |
// 渲染初始头像
|
|
|
|
| 680 |
authorContainer.innerHTML = `${avatarHtml}<span class="swr-name posts-card__name">${escapeHtml(name)}</span>`;
|
| 681 |
|
| 682 |
// 后台静默校对
|
| 683 |
+
const updateAuthor = (profile) => {
|
| 684 |
if (!isCurrent() || !card.isConnected) return;
|
| 685 |
const avatarEl = authorContainer.querySelector('.swr-avatar');
|
| 686 |
const nameEl = authorContainer.querySelector('.swr-name');
|
| 687 |
+
if (avatarEl && typeof profile.avatar === 'string' && profile.avatar) {
|
| 688 |
if (avatarEl.tagName === 'IMG') {
|
| 689 |
+
if (avatarEl.getAttribute('src') !== profile.avatar) avatarEl.src = profile.avatar;
|
| 690 |
} else {
|
| 691 |
+
avatarEl.outerHTML = _generateAvatarHtml(profile.avatar, typeof profile.name === 'string' && profile.name ? profile.name : name);
|
| 692 |
}
|
| 693 |
}
|
| 694 |
+
if (nameEl && typeof profile.name === 'string' && profile.name && nameEl.textContent !== profile.name) {
|
| 695 |
nameEl.textContent = profile.name;
|
| 696 |
}
|
| 697 |
+
};
|
| 698 |
+
const latestCached = getProfileWithSWR(account, api.getUserProfile, updateAuthor);
|
| 699 |
+
if (latestCached) updateAuthor(latestCached);
|
| 700 |
}, 0);
|
| 701 |
|
| 702 |
const cover = card.querySelector('img');
|
前端页面/profile/个人中心_UI模板.js
CHANGED
|
@@ -12,34 +12,9 @@ import { getBannerCacheKey, PLACEHOLDERS } from "../core/全局配置.js";
|
|
| 12 |
import { t } from "../components/用户体验增强.js";
|
| 13 |
import { escapeHtml } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
|
| 14 |
|
| 15 |
-
// 一次性注入组件样式(CSS类替代内联hover事件+减少重复样式)
|
| 16 |
-
if (!document.getElementById('profile-template-styles')) {
|
| 17 |
-
const style = document.createElement('style');
|
| 18 |
-
style.id = 'profile-template-styles';
|
| 19 |
-
style.textContent = `
|
| 20 |
-
.profile-nav-btn{background:rgba(51,51,51,0.8);border:1px solid rgba(85,85,85,0.8);color:#fff;padding:6px 14px;border-radius:6px;cursor:pointer;font-size:13px;font-weight:bold;display:flex;align-items:center;gap:6px;box-shadow:0 2px 4px rgba(0,0,0,0.3);transition:0.2s}
|
| 21 |
-
.profile-nav-btn:hover{background:#4CAF50;border-color:#4CAF50}
|
| 22 |
-
.profile-header-btn{padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px;transition:0.2s;backdrop-filter:blur(4px)}
|
| 23 |
-
.profile-header-btn--settings{background:rgba(51,51,51,0.8);border:1px solid rgba(85,85,85,0.8);color:#ccc}
|
| 24 |
-
.profile-header-btn--settings:hover{color:#fff;border-color:#888}
|
| 25 |
-
.profile-header-btn--logout{background:rgba(244,67,54,0.2);border:1px solid #F44336;color:#F44336}
|
| 26 |
-
.profile-header-btn--logout:hover{background:#F44336;color:#fff}
|
| 27 |
-
.profile-action-btn{border-radius:4px;cursor:pointer;font-weight:bold}
|
| 28 |
-
.profile-action-btn--primary{background:#FF9800;border:none;color:white;box-shadow:0 2px 4px rgba(0,0,0,0.2);transition:0.2s}
|
| 29 |
-
.profile-action-btn--primary:hover{opacity:0.8}
|
| 30 |
-
.profile-action-btn--withdraw{background:rgba(76,175,80,0.2);border:1px solid #4CAF50;color:#4CAF50;transition:0.2s}
|
| 31 |
-
.profile-action-btn--withdraw:hover{background:rgba(76,175,80,0.3)}
|
| 32 |
-
.profile-action-btn--send{background:#2196F3;border:none;color:white;box-shadow:0 2px 4px rgba(0,0,0,0.2)}
|
| 33 |
-
.profile-action-btn--follow{background:#4CAF50;border:none;color:white;box-shadow:0 2px 4px rgba(0,0,0,0.2)}
|
| 34 |
-
.profile-action-btn--me{padding:8px 12px;font-size:13px}
|
| 35 |
-
.profile-action-btn--other{padding:6px 12px;font-size:12px;width:100%}
|
| 36 |
-
`;
|
| 37 |
-
document.head.appendChild(style);
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
/** 生成返回按钮HTML */
|
| 41 |
-
function _backButtonHtml(
|
| 42 |
-
return `<button id="btn-back-profile" class="profile-nav-btn
|
| 43 |
}
|
| 44 |
|
| 45 |
export function buildProfileHTML(userData, isMe, isSettingsView, isFollowing, followingCount, activeTab, tabs) {
|
|
@@ -55,78 +30,78 @@ export function buildProfileHTML(userData, isMe, isSettingsView, isFollowing, fo
|
|
| 55 |
}
|
| 56 |
}
|
| 57 |
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
const bannerStyle =
|
| 61 |
-
? `background-image: url(${
|
| 62 |
-
: `
|
| 63 |
|
| 64 |
// 设置视图时返回简化的返回按钮(与个人资料页按钮样式一致,但不显示背景图)
|
| 65 |
if (isSettingsView && isMe) {
|
| 66 |
-
return _backButtonHtml('
|
| 67 |
}
|
| 68 |
|
| 69 |
const actionButtons = isMe
|
| 70 |
-
? `<div
|
| 71 |
-
<button id="btn-wallet" class="profile-action-btn profile-action-btn--primary profile-action-btn--me">💰 ${t('profile.recharge')}</button>
|
| 72 |
-
<button id="btn-withdraw" class="profile-action-btn profile-action-btn--withdraw profile-action-btn--me">💸 ${t('profile.withdraw')}</button>
|
| 73 |
</div>`
|
| 74 |
-
: `<button id="btn-tip-user" class="profile-action-btn profile-action-btn--primary profile-action-btn--other"
|
| 75 |
-
<button id="btn-send-msg" class="profile-action-btn profile-action-btn--send profile-action-btn--other">✉️ ${t('profile.send_message')}</button>
|
| 76 |
-
<button id="btn-follow-user" class="profile-action-btn profile-action-btn--follow profile-action-btn--other"
|
| 77 |
|
| 78 |
// 统计数据区域(现在在背景图内)
|
| 79 |
const statsHtml = `
|
| 80 |
-
<div
|
| 81 |
${isMe ? `
|
| 82 |
-
<div
|
| 83 |
-
<div
|
| 84 |
-
<span>💰 ${t('profile.balance')}: <strong
|
| 85 |
</div>
|
| 86 |
-
<div
|
| 87 |
<span>🛍️ ${t('profile.sales')}: ${escapeHtml(userData.earn_balance || 0)}</span>
|
| 88 |
-
<span
|
| 89 |
-
<span
|
| 90 |
</div>
|
| 91 |
</div>
|
| 92 |
` : ''}
|
| 93 |
-
<div
|
| 94 |
-
<span>👍 ${t('profile.received_likes')}: <strong
|
| 95 |
-
<span>🔖 ${t('profile.received_favorites')}: <strong
|
| 96 |
-
<
|
| 97 |
-
<
|
| 98 |
</div>
|
| 99 |
</div>
|
| 100 |
`;
|
| 101 |
|
| 102 |
// 整个头部区域(包括返回按钮、用户信息、统计数据)都在背景图内
|
| 103 |
const headerHtml = `
|
| 104 |
-
<div
|
| 105 |
-
<div style="${bannerStyle}
|
| 106 |
<!-- 顶部导航栏 -->
|
| 107 |
-
<div
|
| 108 |
-
<div
|
| 109 |
-
${_backButtonHtml(
|
| 110 |
</div>
|
| 111 |
${isMe ? `
|
| 112 |
-
<div
|
| 113 |
-
<button id="btn-open-settings" class="profile-header-btn profile-header-btn--settings">⚙️ ${t('profile.settings')}</button>
|
| 114 |
-
<button id="btn-logout" class="profile-header-btn profile-header-btn--logout">🚪 ${t('profile.logout')}</button>
|
| 115 |
</div>` : ''}
|
| 116 |
</div>
|
| 117 |
|
| 118 |
<!-- 用户信息区域 -->
|
| 119 |
-
<div
|
| 120 |
-
<img src="${escapeHtml(userData.avatarDataUrl || userData.avatar || PLACEHOLDERS.AVATAR)}"
|
| 121 |
-
<div
|
| 122 |
-
<div
|
| 123 |
${escapeHtml(userData.name)}
|
| 124 |
-
<span
|
| 125 |
</div>
|
| 126 |
-
<div
|
| 127 |
-
<div
|
| 128 |
</div>
|
| 129 |
-
<div
|
| 130 |
</div>
|
| 131 |
|
| 132 |
<!-- 统计数据区域 -->
|
|
@@ -135,12 +110,12 @@ export function buildProfileHTML(userData, isMe, isSettingsView, isFollowing, fo
|
|
| 135 |
</div>
|
| 136 |
`;
|
| 137 |
|
| 138 |
-
let tabsHtml = `<div
|
| 139 |
tabs.forEach(tab => {
|
| 140 |
const isActive = activeTab === tab.id;
|
| 141 |
-
tabsHtml += `<button class="profile-tab-btn" data-id="${tab.id}"
|
| 142 |
});
|
| 143 |
-
tabsHtml += `</div><div
|
| 144 |
|
| 145 |
return headerHtml + tabsHtml;
|
| 146 |
}
|
|
|
|
| 12 |
import { t } from "../components/用户体验增强.js";
|
| 13 |
import { escapeHtml } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
/** 生成返回按钮HTML */
|
| 16 |
+
function _backButtonHtml(extraClass = '') {
|
| 17 |
+
return `<button type="button" id="btn-back-profile" class="profile-nav-btn ${extraClass}">⬅ ${t('common.back')}</button>`;
|
| 18 |
}
|
| 19 |
|
| 20 |
export function buildProfileHTML(userData, isMe, isSettingsView, isFollowing, followingCount, activeTab, tabs) {
|
|
|
|
| 30 |
}
|
| 31 |
}
|
| 32 |
|
| 33 |
+
const hasBannerImage = typeof bannerImageUrl === "string" && bannerImageUrl.trim()
|
| 34 |
+
&& !/[\u0000-\u001f\u007f]/.test(bannerImageUrl);
|
| 35 |
+
const bannerStyle = hasBannerImage
|
| 36 |
+
? `background-image: ${escapeHtml(`url(${JSON.stringify(bannerImageUrl)})`)}; background-size: cover; background-position: center;`
|
| 37 |
+
: ``;
|
| 38 |
|
| 39 |
// 设置视图时返回简化的返回按钮(与个人资料页按钮样式一致,但不显示背景图)
|
| 40 |
if (isSettingsView && isMe) {
|
| 41 |
+
return _backButtonHtml('profile-nav-btn--settings-return');
|
| 42 |
}
|
| 43 |
|
| 44 |
const actionButtons = isMe
|
| 45 |
+
? `<div class="profile-wallet-actions">
|
| 46 |
+
<button type="button" id="btn-wallet" class="profile-action-btn profile-action-btn--primary profile-action-btn--me">💰 ${t('profile.recharge')}</button>
|
| 47 |
+
<button type="button" id="btn-withdraw" class="profile-action-btn profile-action-btn--withdraw profile-action-btn--me">💸 ${t('profile.withdraw')}</button>
|
| 48 |
</div>`
|
| 49 |
+
: `<button type="button" id="btn-tip-user" class="profile-tip-action profile-action-btn profile-action-btn--primary profile-action-btn--other">🎁 ${t('profile.tip_support')}</button>
|
| 50 |
+
<button type="button" id="btn-send-msg" class="profile-action-btn profile-action-btn--send profile-action-btn--other">✉️ ${t('profile.send_message')}</button>
|
| 51 |
+
<button type="button" id="btn-follow-user" class="profile-follow-action profile-action-btn profile-action-btn--follow profile-action-btn--other">${isFollowing ? `✔️ ${t('profile.followed')}` : `➕ ${t('profile.follow_author')}`}</button>`;
|
| 52 |
|
| 53 |
// 统计数据区域(现在在背景图内)
|
| 54 |
const statsHtml = `
|
| 55 |
+
<div class="profile-stats">
|
| 56 |
${isMe ? `
|
| 57 |
+
<div class="profile-wallet-stats">
|
| 58 |
+
<div class="profile-balance">
|
| 59 |
+
<span>💰 ${t('profile.balance')}: <strong class="profile-balance-amount">${escapeHtml(userData.balance || 0)}</strong></span>
|
| 60 |
</div>
|
| 61 |
+
<div class="profile-earnings">
|
| 62 |
<span>🛍️ ${t('profile.sales')}: ${escapeHtml(userData.earn_balance || 0)}</span>
|
| 63 |
+
<span class="profile-earning-item">🎁 ${t('profile.tips')}: ${escapeHtml(userData.tip_balance || 0)}</span>
|
| 64 |
+
<span class="profile-earning-item">📋 ${t('profile.task_earnings')}: ${escapeHtml(userData.task_balance || 0)}</span>
|
| 65 |
</div>
|
| 66 |
</div>
|
| 67 |
` : ''}
|
| 68 |
+
<div class="profile-counts">
|
| 69 |
+
<span>👍 ${t('profile.received_likes')}: <strong class="profile-count">${escapeHtml(userData.receivedLikes || 0)}</strong></span>
|
| 70 |
+
<span>🔖 ${t('profile.received_favorites')}: <strong class="profile-count">${escapeHtml(userData.receivedFavorites || 0)}</strong></span>
|
| 71 |
+
<button type="button" class="profile-count-link" id="btn-followers">👥 ${t('profile.followers_count')}: <strong class="profile-count">${userData.followers ? userData.followers.length : 0}</strong></button>
|
| 72 |
+
<button type="button" class="profile-count-link" id="btn-following">🏃 ${t('profile.following_count')}: <strong class="profile-count">${escapeHtml(followingCount)}</strong></button>
|
| 73 |
</div>
|
| 74 |
</div>
|
| 75 |
`;
|
| 76 |
|
| 77 |
// 整个头部区域(包括返回按钮、用户信息、统计数据)都在背景图内
|
| 78 |
const headerHtml = `
|
| 79 |
+
<div class="profile-header">
|
| 80 |
+
<div class="profile-banner" style="${bannerStyle}">
|
| 81 |
<!-- 顶部导航栏 -->
|
| 82 |
+
<div class="profile-navigation">
|
| 83 |
+
<div class="profile-back-group">
|
| 84 |
+
${_backButtonHtml()}
|
| 85 |
</div>
|
| 86 |
${isMe ? `
|
| 87 |
+
<div class="profile-account-actions">
|
| 88 |
+
<button type="button" id="btn-open-settings" class="profile-header-btn profile-header-btn--settings">⚙️ ${t('profile.settings')}</button>
|
| 89 |
+
<button type="button" id="btn-logout" class="profile-header-btn profile-header-btn--logout">🚪 ${t('profile.logout')}</button>
|
| 90 |
</div>` : ''}
|
| 91 |
</div>
|
| 92 |
|
| 93 |
<!-- 用户信息区域 -->
|
| 94 |
+
<div class="profile-identity">
|
| 95 |
+
<img alt="" class="profile-avatar" src="${escapeHtml(userData.avatarDataUrl || userData.avatar || PLACEHOLDERS.AVATAR)}">
|
| 96 |
+
<div class="profile-bio">
|
| 97 |
+
<div class="profile-name">
|
| 98 |
${escapeHtml(userData.name)}
|
| 99 |
+
<span class="profile-age">${userData.gender === 'male' ? '♂️' : (userData.gender === 'female' ? '♀️' : '🔒')} ${userData.age !== undefined && userData.age !== null ? escapeHtml(userData.age) : t('profile.unknown_age')}${t('profile.age_years')}</span>
|
| 100 |
</div>
|
| 101 |
+
<div class="profile-location">📍 ${escapeHtml(userData.country) || t('profile.location_secret')} - ${escapeHtml(userData.region || '')}</div>
|
| 102 |
+
<div class="profile-intro">${escapeHtml(userData.intro) || t('profile.no_intro')}</div>
|
| 103 |
</div>
|
| 104 |
+
<div class="profile-actions">${actionButtons}</div>
|
| 105 |
</div>
|
| 106 |
|
| 107 |
<!-- 统计数据区域 -->
|
|
|
|
| 110 |
</div>
|
| 111 |
`;
|
| 112 |
|
| 113 |
+
let tabsHtml = `<div class="profile-tabs">`;
|
| 114 |
tabs.forEach(tab => {
|
| 115 |
const isActive = activeTab === tab.id;
|
| 116 |
+
tabsHtml += `<button type="button" class="profile-tab-btn" data-id="${tab.id}" aria-pressed="${isActive}">${tab.label}</button>`;
|
| 117 |
});
|
| 118 |
+
tabsHtml += `</div><div class="profile-list" id="profile-list-container"></div>`;
|
| 119 |
|
| 120 |
return headerHtml + tabsHtml;
|
| 121 |
}
|
前端页面/profile/个人列表组件.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
// 前端页面/profile/个人列表组件.js
|
| 2 |
import { api } from "../core/网络请求API.js";
|
|
|
|
| 3 |
import { createItemCard } from "../market/列表卡片组件.js";
|
| 4 |
import { getAcquiredItems } from "../market/资源安装引擎.js";
|
| 5 |
import { showToast } from "../components/UI交互提示组件.js";
|
|
@@ -243,14 +244,12 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
|
|
| 243 |
};
|
| 244 |
|
| 245 |
const listDiv = document.createElement("div");
|
| 246 |
-
listDiv.
|
| 247 |
-
listDiv.style.flexDirection = "column";
|
| 248 |
-
listDiv.style.gap = "10px";
|
| 249 |
|
| 250 |
// 渲染我发布的任务
|
| 251 |
if (publishedTasks.length > 0) {
|
| 252 |
const sectionTitle = document.createElement("div");
|
| 253 |
-
sectionTitle.
|
| 254 |
sectionTitle.textContent = `📤 ${t('profile.published_tasks')} (${publishedTasks.length})`;
|
| 255 |
listDiv.appendChild(sectionTitle);
|
| 256 |
|
|
@@ -263,7 +262,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
|
|
| 263 |
// 渲染我接的任务
|
| 264 |
if (assignedTasks.length > 0) {
|
| 265 |
const sectionTitle = document.createElement("div");
|
| 266 |
-
sectionTitle.
|
| 267 |
sectionTitle.textContent = `📥 ${t('profile.assigned_tasks')} (${assignedTasks.length})`;
|
| 268 |
listDiv.appendChild(sectionTitle);
|
| 269 |
|
|
@@ -304,7 +303,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
|
|
| 304 |
const salesStats = salesStatsRes.data || {};
|
| 305 |
|
| 306 |
const containerDiv = document.createElement("div");
|
| 307 |
-
containerDiv.
|
| 308 |
|
| 309 |
// 💰 销售统计卡片(放在最上方,与打赏统计样式一致)
|
| 310 |
// 🔧 下方销售/打赏两处的 profile.transactions_unit 不再加 || '笔' 兜底:
|
|
@@ -342,7 +341,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
|
|
| 342 |
|
| 343 |
// 📝 交易明细列表
|
| 344 |
const txListDiv = document.createElement("div");
|
| 345 |
-
txListDiv.
|
| 346 |
|
| 347 |
if (transactions.length === 0) {
|
| 348 |
txListDiv.innerHTML = _renderEmptyState('💰', t('profile.no_transactions'));
|
|
@@ -384,9 +383,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
|
|
| 384 |
}
|
| 385 |
|
| 386 |
const listDiv = document.createElement("div");
|
| 387 |
-
listDiv.
|
| 388 |
-
listDiv.style.flexDirection = "column";
|
| 389 |
-
listDiv.style.gap = "10px";
|
| 390 |
|
| 391 |
posts.forEach(post => {
|
| 392 |
const postDiv = createPostItem(post, currentUser);
|
|
@@ -419,9 +416,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
|
|
| 419 |
}
|
| 420 |
|
| 421 |
const listDiv = document.createElement("div");
|
| 422 |
-
listDiv.
|
| 423 |
-
listDiv.style.flexDirection = "column";
|
| 424 |
-
listDiv.style.gap = "10px";
|
| 425 |
|
| 426 |
prompts.forEach(prompt => {
|
| 427 |
const promptDiv = createPromptItem(prompt, currentUser);
|
|
@@ -453,11 +448,11 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
|
|
| 453 |
return;
|
| 454 |
}
|
| 455 |
const listDiv = document.createElement("div");
|
| 456 |
-
listDiv.
|
| 457 |
|
| 458 |
if (items && items.length > 0) {
|
| 459 |
const sectionTitle = document.createElement("div");
|
| 460 |
-
sectionTitle.
|
| 461 |
sectionTitle.textContent = `📦 ${t('profile.collected_items')} (${items.length})`;
|
| 462 |
listDiv.appendChild(sectionTitle);
|
| 463 |
items.forEach(item => { listDiv.appendChild(createItemCard(item, currentUser)); });
|
|
@@ -465,7 +460,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
|
|
| 465 |
|
| 466 |
if (posts && posts.length > 0) {
|
| 467 |
const sectionTitle = document.createElement("div");
|
| 468 |
-
sectionTitle.
|
| 469 |
sectionTitle.textContent = `💬 ${t('profile.collected_posts')} (${posts.length})`;
|
| 470 |
listDiv.appendChild(sectionTitle);
|
| 471 |
posts.forEach(post => { listDiv.appendChild(createPostItem(post, currentUser)); });
|
|
@@ -473,7 +468,7 @@ export async function renderProfileListContent(tabId, domElement, userData, curr
|
|
| 473 |
|
| 474 |
if (prompts && prompts.length > 0) {
|
| 475 |
const sectionTitle = document.createElement("div");
|
| 476 |
-
sectionTitle.
|
| 477 |
sectionTitle.textContent = `🧩 ${t('profile.collected_prompts')} (${prompts.length})`;
|
| 478 |
listDiv.appendChild(sectionTitle);
|
| 479 |
prompts.forEach(prompt => { listDiv.appendChild(createPromptItem(prompt, currentUser)); });
|
|
@@ -582,15 +577,16 @@ function _renderEmptyState(icon, title, subtitle = '') {
|
|
| 582 |
*/
|
| 583 |
function _createStatsCard(title, stats, extraStyle = '') {
|
| 584 |
const card = document.createElement('div');
|
| 585 |
-
card.
|
|
|
|
| 586 |
const colsHtml = stats.map(s => `
|
| 587 |
-
<div
|
| 588 |
-
<div
|
| 589 |
-
<div
|
| 590 |
</div>`).join('');
|
| 591 |
card.innerHTML = `
|
| 592 |
-
<div
|
| 593 |
-
<div
|
| 594 |
`;
|
| 595 |
return card;
|
| 596 |
}
|
|
@@ -599,35 +595,26 @@ function _createStatsCard(title, stats, extraStyle = '') {
|
|
| 599 |
* 📝 创建任务卡片项
|
| 600 |
*/
|
| 601 |
function createTaskItem(task, statusColors, statusLabels, currentUser) {
|
| 602 |
-
const taskDiv = document.createElement("
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
background: "var(--comfy-input-bg)",
|
| 606 |
-
borderRadius: "8px",
|
| 607 |
-
border: "1px solid #444",
|
| 608 |
-
cursor: "pointer",
|
| 609 |
-
transition: "0.2s"
|
| 610 |
-
});
|
| 611 |
|
| 612 |
-
const
|
|
|
|
| 613 |
const statusLabel = statusLabels[task.status] || t('task.status_unknown');
|
| 614 |
|
| 615 |
taskDiv.innerHTML = `
|
| 616 |
-
<div
|
| 617 |
-
<span
|
| 618 |
-
<span
|
| 619 |
</div>
|
| 620 |
-
<div
|
| 621 |
-
<div
|
| 622 |
-
<span>✈️ ${formatDeadline(task.deadline)}</span>
|
| 623 |
-
${task.status === "open" ? `<span>👥 ${(task.applicants || []).length} ${t('task.applicants')}</span>` : ""}
|
| 624 |
</div>
|
| 625 |
`;
|
| 626 |
|
| 627 |
-
// 悬停效果
|
| 628 |
-
taskDiv.onmouseover = () => { taskDiv.style.borderColor = "#666"; taskDiv.style.transform = "translateX(3px)"; };
|
| 629 |
-
taskDiv.onmouseout = () => { taskDiv.style.borderColor = "#444"; taskDiv.style.transform = "translateX(0)"; };
|
| 630 |
-
|
| 631 |
// 点击进入详情
|
| 632 |
taskDiv.onclick = () => {
|
| 633 |
import("../task/任务详情组件.js").then(module => {
|
|
@@ -661,15 +648,9 @@ function formatDeadline(deadline) {
|
|
| 661 |
* 💬 创建帖子卡片项
|
| 662 |
*/
|
| 663 |
function createPostItem(post, currentUser) {
|
| 664 |
-
const postDiv = document.createElement("
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
background: "var(--comfy-input-bg)",
|
| 668 |
-
borderRadius: "8px",
|
| 669 |
-
border: "1px solid #444",
|
| 670 |
-
cursor: "pointer",
|
| 671 |
-
transition: "all 0.2s"
|
| 672 |
-
});
|
| 673 |
|
| 674 |
// 截取内容摘要
|
| 675 |
const contentPreview = (post.content || "").slice(0, 80) + ((post.content || "").length > 80 ? "..." : "");
|
|
@@ -688,39 +669,31 @@ function createPostItem(post, currentUser) {
|
|
| 688 |
}
|
| 689 |
|
| 690 |
postDiv.innerHTML = `
|
| 691 |
-
<div
|
| 692 |
${coverImageUrl ? `
|
| 693 |
-
<div
|
| 694 |
-
<img src="${escapeHtml(coverImageUrl)}"
|
| 695 |
${isVideo ? `
|
| 696 |
-
<div
|
| 697 |
-
|
| 698 |
-
display: flex; align-items: center; justify-content: center; pointer-events: none;">
|
| 699 |
-
<div style="width: 0; height: 0; border-left: 10px solid rgba(255,255,255,0.9);
|
| 700 |
-
border-top: 6px solid transparent; border-bottom: 6px solid transparent;
|
| 701 |
-
margin-left: 2px;"></div>
|
| 702 |
</div>
|
| 703 |
` : ''}
|
| 704 |
</div>
|
| 705 |
` : ""}
|
| 706 |
-
<div
|
| 707 |
-
<div
|
| 708 |
${escapeHtml(contentPreview)}
|
| 709 |
</div>
|
| 710 |
-
<div
|
| 711 |
-
<span>❤️ ${post.like_count || 0}</span>
|
| 712 |
-
<span>💬 ${post.comment_count || 0}</span>
|
| 713 |
${isVideo ? `<span>🎬 ${t('post.video_tag')}</span>` : ((post.images || []).length > 1 ? `<span>🖼️ ${(post.images || []).length}${t('post.images_unit')}</span>` : "")}
|
| 714 |
-
<span
|
| 715 |
</div>
|
| 716 |
</div>
|
| 717 |
</div>
|
| 718 |
`;
|
| 719 |
|
| 720 |
-
// 悬停效果(使用 mouseenter/mouseleave 避免子元素边界闪烁)
|
| 721 |
-
postDiv.onmouseenter = () => { postDiv.style.borderColor = "#9C27B0"; postDiv.style.transform = "translateX(2px)"; };
|
| 722 |
-
postDiv.onmouseleave = () => { postDiv.style.borderColor = "#444"; postDiv.style.transform = "translateX(0)"; };
|
| 723 |
-
|
| 724 |
// 点击进入详情
|
| 725 |
postDiv.onclick = () => {
|
| 726 |
import("../post/帖子详情组件.js").then(module => {
|
|
@@ -739,15 +712,9 @@ function createPostItem(post, currentUser) {
|
|
| 739 |
* 🧩 创建提示词卡片项(复刻 createPostItem 模式)
|
| 740 |
*/
|
| 741 |
function createPromptItem(prompt, currentUser) {
|
| 742 |
-
const promptDiv = document.createElement("
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
background: "var(--comfy-input-bg)",
|
| 746 |
-
borderRadius: "8px",
|
| 747 |
-
border: "1px solid #444",
|
| 748 |
-
cursor: "pointer",
|
| 749 |
-
transition: "all 0.2s"
|
| 750 |
-
});
|
| 751 |
|
| 752 |
// 时间格式化(复用帖子时间格式函数)
|
| 753 |
const timeStr = formatPostTime(prompt.created_at);
|
|
@@ -757,46 +724,39 @@ function createPromptItem(prompt, currentUser) {
|
|
| 757 |
const coverImageUrl = prompt.cover_image || null;
|
| 758 |
|
| 759 |
// 价格徽标
|
| 760 |
-
const
|
| 761 |
-
|
| 762 |
-
|
|
|
|
| 763 |
|
| 764 |
promptDiv.innerHTML = `
|
| 765 |
-
<div
|
| 766 |
${coverImageUrl ? `
|
| 767 |
-
<div
|
| 768 |
-
<img src="${escapeHtml(coverImageUrl)}"
|
| 769 |
${isVideo ? `
|
| 770 |
-
<div
|
| 771 |
-
|
| 772 |
-
display: flex; align-items: center; justify-content: center; pointer-events: none;">
|
| 773 |
-
<div style="width: 0; height: 0; border-left: 10px solid rgba(255,255,255,0.9);
|
| 774 |
-
border-top: 6px solid transparent; border-bottom: 6px solid transparent;
|
| 775 |
-
margin-left: 2px;"></div>
|
| 776 |
</div>
|
| 777 |
` : ''}
|
| 778 |
</div>
|
| 779 |
` : ""}
|
| 780 |
-
<div
|
| 781 |
-
<div
|
| 782 |
${escapeHtml(prompt.title || '')}
|
| 783 |
</div>
|
| 784 |
-
<div
|
| 785 |
${priceBadge}
|
| 786 |
-
${prompt.category ? `<span
|
| 787 |
-
<span>❤️ ${prompt.likes || 0}</span>
|
| 788 |
-
<span>⭐ ${prompt.favorites || 0}</span>
|
| 789 |
-
<span>👁️ ${prompt.views || 0}</span>
|
| 790 |
-
<span
|
| 791 |
</div>
|
| 792 |
</div>
|
| 793 |
</div>
|
| 794 |
`;
|
| 795 |
|
| 796 |
-
// 悬停效果(青色主题,与提示词 Tab 配色一致)
|
| 797 |
-
promptDiv.onmouseenter = () => { promptDiv.style.borderColor = "#00BCD4"; promptDiv.style.transform = "translateX(2px)"; };
|
| 798 |
-
promptDiv.onmouseleave = () => { promptDiv.style.borderColor = "#444"; promptDiv.style.transform = "translateX(0)"; };
|
| 799 |
-
|
| 800 |
// 点击进入提示词详情
|
| 801 |
promptDiv.onclick = () => {
|
| 802 |
import("../prompt/提示词详情组件.js").then(module => {
|
|
@@ -833,10 +793,9 @@ function formatPostTime(timestamp) {
|
|
| 833 |
*/
|
| 834 |
function createTransactionCard(tx) {
|
| 835 |
const txDiv = document.createElement("div");
|
| 836 |
-
txDiv.
|
| 837 |
|
| 838 |
const isPositive = tx.amount > 0;
|
| 839 |
-
const amountColor = isPositive ? '#4CAF50' : '#F44336';
|
| 840 |
const timeStr = tx.created_at ? new Date(tx.created_at).toLocaleString(getLanguage(), { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '';
|
| 841 |
|
| 842 |
// 获取交易类型配置
|
|
@@ -945,31 +904,31 @@ function createTransactionCard(tx) {
|
|
| 945 |
|
| 946 |
// 资源标题(如果有)
|
| 947 |
const itemTitle = tx.item_title && tx.tx_type !== 'TIP_IN' && tx.tx_type !== 'TIP_OUT'
|
| 948 |
-
? `<span
|
| 949 |
: '';
|
| 950 |
|
| 951 |
// 实到金额(仅提现类型)
|
| 952 |
const netAmountHtml = (tx.tx_type === 'WITHDRAW' && tx.net_amount !== null && tx.net_amount !== undefined)
|
| 953 |
-
? `<div
|
| 954 |
: '';
|
| 955 |
|
| 956 |
if (hasDetailedInfo) {
|
| 957 |
// 详细展示模式
|
| 958 |
txDiv.innerHTML = `
|
| 959 |
-
<div
|
| 960 |
-
<span
|
| 961 |
-
<div
|
| 962 |
-
<div
|
| 963 |
-
<span
|
| 964 |
${itemTitle}
|
| 965 |
</div>
|
| 966 |
-
${description ? `<div
|
| 967 |
-
${detailText ? `<div
|
| 968 |
</div>
|
| 969 |
</div>
|
| 970 |
-
<div
|
| 971 |
-
<div
|
| 972 |
-
${isPositive ? '+' : ''}${tx.amount}
|
| 973 |
</div>
|
| 974 |
${netAmountHtml}
|
| 975 |
</div>
|
|
@@ -977,15 +936,15 @@ function createTransactionCard(tx) {
|
|
| 977 |
} else {
|
| 978 |
// 简洁展示模式(向后兼容旧数据)
|
| 979 |
txDiv.innerHTML = `
|
| 980 |
-
<div
|
| 981 |
-
<span
|
| 982 |
<div>
|
| 983 |
-
<div
|
| 984 |
-
<div
|
| 985 |
</div>
|
| 986 |
</div>
|
| 987 |
-
<div
|
| 988 |
-
${isPositive ? '+' : ''}${tx.amount}
|
| 989 |
</div>
|
| 990 |
`;
|
| 991 |
}
|
|
|
|
| 1 |
// 前端页面/profile/个人列表组件.js
|
| 2 |
import { api } from "../core/网络请求API.js";
|
| 3 |
+
import { getPromptPriceState } from "../prompt/价格校验.js";
|
| 4 |
import { createItemCard } from "../market/列表卡片组件.js";
|
| 5 |
import { getAcquiredItems } from "../market/资源安装引擎.js";
|
| 6 |
import { showToast } from "../components/UI交互提示组件.js";
|
|
|
|
| 244 |
};
|
| 245 |
|
| 246 |
const listDiv = document.createElement("div");
|
| 247 |
+
listDiv.className = 'profile-content-list';
|
|
|
|
|
|
|
| 248 |
|
| 249 |
// 渲染我发布的任务
|
| 250 |
if (publishedTasks.length > 0) {
|
| 251 |
const sectionTitle = document.createElement("div");
|
| 252 |
+
sectionTitle.className = 'profile-list-section-title';
|
| 253 |
sectionTitle.textContent = `📤 ${t('profile.published_tasks')} (${publishedTasks.length})`;
|
| 254 |
listDiv.appendChild(sectionTitle);
|
| 255 |
|
|
|
|
| 262 |
// 渲染我接的任务
|
| 263 |
if (assignedTasks.length > 0) {
|
| 264 |
const sectionTitle = document.createElement("div");
|
| 265 |
+
sectionTitle.className = 'profile-list-section-title';
|
| 266 |
sectionTitle.textContent = `📥 ${t('profile.assigned_tasks')} (${assignedTasks.length})`;
|
| 267 |
listDiv.appendChild(sectionTitle);
|
| 268 |
|
|
|
|
| 303 |
const salesStats = salesStatsRes.data || {};
|
| 304 |
|
| 305 |
const containerDiv = document.createElement("div");
|
| 306 |
+
containerDiv.className = 'profile-transactions';
|
| 307 |
|
| 308 |
// 💰 销售统计卡片(放在最上方,与打赏统计样式一致)
|
| 309 |
// 🔧 下方销售/打赏两处的 profile.transactions_unit 不再加 || '笔' 兜底:
|
|
|
|
| 341 |
|
| 342 |
// 📝 交易明细列表
|
| 343 |
const txListDiv = document.createElement("div");
|
| 344 |
+
txListDiv.className = 'profile-transaction-list';
|
| 345 |
|
| 346 |
if (transactions.length === 0) {
|
| 347 |
txListDiv.innerHTML = _renderEmptyState('💰', t('profile.no_transactions'));
|
|
|
|
| 383 |
}
|
| 384 |
|
| 385 |
const listDiv = document.createElement("div");
|
| 386 |
+
listDiv.className = 'profile-content-list';
|
|
|
|
|
|
|
| 387 |
|
| 388 |
posts.forEach(post => {
|
| 389 |
const postDiv = createPostItem(post, currentUser);
|
|
|
|
| 416 |
}
|
| 417 |
|
| 418 |
const listDiv = document.createElement("div");
|
| 419 |
+
listDiv.className = 'profile-content-list';
|
|
|
|
|
|
|
| 420 |
|
| 421 |
prompts.forEach(prompt => {
|
| 422 |
const promptDiv = createPromptItem(prompt, currentUser);
|
|
|
|
| 448 |
return;
|
| 449 |
}
|
| 450 |
const listDiv = document.createElement("div");
|
| 451 |
+
listDiv.className = 'profile-content-list';
|
| 452 |
|
| 453 |
if (items && items.length > 0) {
|
| 454 |
const sectionTitle = document.createElement("div");
|
| 455 |
+
sectionTitle.className = 'profile-list-section-title';
|
| 456 |
sectionTitle.textContent = `📦 ${t('profile.collected_items')} (${items.length})`;
|
| 457 |
listDiv.appendChild(sectionTitle);
|
| 458 |
items.forEach(item => { listDiv.appendChild(createItemCard(item, currentUser)); });
|
|
|
|
| 460 |
|
| 461 |
if (posts && posts.length > 0) {
|
| 462 |
const sectionTitle = document.createElement("div");
|
| 463 |
+
sectionTitle.className = 'profile-list-section-title';
|
| 464 |
sectionTitle.textContent = `💬 ${t('profile.collected_posts')} (${posts.length})`;
|
| 465 |
listDiv.appendChild(sectionTitle);
|
| 466 |
posts.forEach(post => { listDiv.appendChild(createPostItem(post, currentUser)); });
|
|
|
|
| 468 |
|
| 469 |
if (prompts && prompts.length > 0) {
|
| 470 |
const sectionTitle = document.createElement("div");
|
| 471 |
+
sectionTitle.className = 'profile-list-section-title';
|
| 472 |
sectionTitle.textContent = `🧩 ${t('profile.collected_prompts')} (${prompts.length})`;
|
| 473 |
listDiv.appendChild(sectionTitle);
|
| 474 |
prompts.forEach(prompt => { listDiv.appendChild(createPromptItem(prompt, currentUser)); });
|
|
|
|
| 577 |
*/
|
| 578 |
function _createStatsCard(title, stats, extraStyle = '') {
|
| 579 |
const card = document.createElement('div');
|
| 580 |
+
card.className = extraStyle === ' margin-bottom: 15px;' ? 'profile-stats-card profile-stats-card--spaced' : 'profile-stats-card';
|
| 581 |
+
const tones = { '#4CAF50': 'income', '#F44336': 'expense', '#2196F3': 'net' };
|
| 582 |
const colsHtml = stats.map(s => `
|
| 583 |
+
<div class="profile-stats-card__cell profile-stats-card__cell--${Object.hasOwn(tones, s.color) ? tones[s.color] : 'net'}">
|
| 584 |
+
<div class="profile-stats-card__value">${escapeHtml(String(s.value))}</div>
|
| 585 |
+
<div class="profile-stats-card__label">${escapeHtml(String(s.label))}</div>
|
| 586 |
</div>`).join('');
|
| 587 |
card.innerHTML = `
|
| 588 |
+
<div class="profile-stats-card__title">${escapeHtml(String(title))}</div>
|
| 589 |
+
<div class="profile-stats-card__grid">${colsHtml}</div>
|
| 590 |
`;
|
| 591 |
return card;
|
| 592 |
}
|
|
|
|
| 595 |
* 📝 创建任务卡片项
|
| 596 |
*/
|
| 597 |
function createTaskItem(task, statusColors, statusLabels, currentUser) {
|
| 598 |
+
const taskDiv = document.createElement("button");
|
| 599 |
+
taskDiv.type = 'button';
|
| 600 |
+
taskDiv.className = 'profile-task-card';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 601 |
|
| 602 |
+
const statusTones = { '#4CAF50': 'open', '#2196F3': 'active', '#FF9800': 'pending', '#F44336': 'disputed' };
|
| 603 |
+
const statusTone = Object.hasOwn(statusTones, statusColors[task.status]) ? statusTones[statusColors[task.status]] : 'neutral';
|
| 604 |
const statusLabel = statusLabels[task.status] || t('task.status_unknown');
|
| 605 |
|
| 606 |
taskDiv.innerHTML = `
|
| 607 |
+
<div class="profile-task-card__summary">
|
| 608 |
+
<span class="profile-task-card__status profile-task-card__status--${statusTone}">${escapeHtml(String(statusLabel))}</span>
|
| 609 |
+
<span class="profile-task-card__price">💰 ${escapeHtml(String(task.total_price))} ${t('task.points')}</span>
|
| 610 |
</div>
|
| 611 |
+
<div class="profile-list-card__title">${escapeHtml(task.title)}</div>
|
| 612 |
+
<div class="profile-list-card__meta">
|
| 613 |
+
<span>✈️ ${escapeHtml(String(formatDeadline(task.deadline)))}</span>
|
| 614 |
+
${task.status === "open" ? `<span>👥 ${escapeHtml(String((task.applicants || []).length))} ${t('task.applicants')}</span>` : ""}
|
| 615 |
</div>
|
| 616 |
`;
|
| 617 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 618 |
// 点击进入详情
|
| 619 |
taskDiv.onclick = () => {
|
| 620 |
import("../task/任务详情组件.js").then(module => {
|
|
|
|
| 648 |
* 💬 创建帖子卡片项
|
| 649 |
*/
|
| 650 |
function createPostItem(post, currentUser) {
|
| 651 |
+
const postDiv = document.createElement("button");
|
| 652 |
+
postDiv.type = 'button';
|
| 653 |
+
postDiv.className = 'profile-entry-card';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 654 |
|
| 655 |
// 截取内容摘要
|
| 656 |
const contentPreview = (post.content || "").slice(0, 80) + ((post.content || "").length > 80 ? "..." : "");
|
|
|
|
| 669 |
}
|
| 670 |
|
| 671 |
postDiv.innerHTML = `
|
| 672 |
+
<div class="profile-entry-card__row">
|
| 673 |
${coverImageUrl ? `
|
| 674 |
+
<div class="profile-entry-card__media">
|
| 675 |
+
<img src="${escapeHtml(coverImageUrl)}" class="profile-entry-card__image">
|
| 676 |
${isVideo ? `
|
| 677 |
+
<div class="profile-entry-card__play">
|
| 678 |
+
<div class="profile-entry-card__play-triangle"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 679 |
</div>
|
| 680 |
` : ''}
|
| 681 |
</div>
|
| 682 |
` : ""}
|
| 683 |
+
<div class="profile-list-card__body">
|
| 684 |
+
<div class="profile-list-card__title">
|
| 685 |
${escapeHtml(contentPreview)}
|
| 686 |
</div>
|
| 687 |
+
<div class="profile-list-card__meta">
|
| 688 |
+
<span>❤️ ${escapeHtml(String(post.like_count || 0))}</span>
|
| 689 |
+
<span>💬 ${escapeHtml(String(post.comment_count || 0))}</span>
|
| 690 |
${isVideo ? `<span>🎬 ${t('post.video_tag')}</span>` : ((post.images || []).length > 1 ? `<span>🖼️ ${(post.images || []).length}${t('post.images_unit')}</span>` : "")}
|
| 691 |
+
<span class="profile-list-card__time">${escapeHtml(String(timeStr))}</span>
|
| 692 |
</div>
|
| 693 |
</div>
|
| 694 |
</div>
|
| 695 |
`;
|
| 696 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 697 |
// 点击进入详情
|
| 698 |
postDiv.onclick = () => {
|
| 699 |
import("../post/帖子详情组件.js").then(module => {
|
|
|
|
| 712 |
* 🧩 创建提示词卡片项(复刻 createPostItem 模式)
|
| 713 |
*/
|
| 714 |
function createPromptItem(prompt, currentUser) {
|
| 715 |
+
const promptDiv = document.createElement("button");
|
| 716 |
+
promptDiv.type = 'button';
|
| 717 |
+
promptDiv.className = 'profile-entry-card';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 718 |
|
| 719 |
// 时间格式化(复用帖子时间格式函数)
|
| 720 |
const timeStr = formatPostTime(prompt.created_at);
|
|
|
|
| 724 |
const coverImageUrl = prompt.cover_image || null;
|
| 725 |
|
| 726 |
// 价格徽标
|
| 727 |
+
const priceState = getPromptPriceState(prompt.price);
|
| 728 |
+
const priceBadge = priceState !== 'free'
|
| 729 |
+
? `<span class="profile-list-badge profile-list-badge--price">💎 ${escapeHtml(priceState === 'paid' ? String(prompt.price) : t('common.unknown'))}</span>`
|
| 730 |
+
: `<span class="profile-list-badge profile-list-badge--free">${t('prompt.free')}</span>`;
|
| 731 |
|
| 732 |
promptDiv.innerHTML = `
|
| 733 |
+
<div class="profile-entry-card__row">
|
| 734 |
${coverImageUrl ? `
|
| 735 |
+
<div class="profile-entry-card__media">
|
| 736 |
+
<img src="${escapeHtml(coverImageUrl)}" class="profile-entry-card__image">
|
| 737 |
${isVideo ? `
|
| 738 |
+
<div class="profile-entry-card__play">
|
| 739 |
+
<div class="profile-entry-card__play-triangle"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 740 |
</div>
|
| 741 |
` : ''}
|
| 742 |
</div>
|
| 743 |
` : ""}
|
| 744 |
+
<div class="profile-list-card__body">
|
| 745 |
+
<div class="profile-list-card__title">
|
| 746 |
${escapeHtml(prompt.title || '')}
|
| 747 |
</div>
|
| 748 |
+
<div class="profile-list-card__meta">
|
| 749 |
${priceBadge}
|
| 750 |
+
${prompt.category ? `<span class="profile-list-badge">${escapeHtml(tIfExists('promptcat.' + prompt.category, prompt.category))}</span>` : ''}
|
| 751 |
+
<span>❤️ ${escapeHtml(String(prompt.likes || 0))}</span>
|
| 752 |
+
<span>⭐ ${escapeHtml(String(prompt.favorites || 0))}</span>
|
| 753 |
+
<span>👁️ ${escapeHtml(String(prompt.views || 0))}</span>
|
| 754 |
+
<span class="profile-list-card__time">${escapeHtml(String(timeStr))}</span>
|
| 755 |
</div>
|
| 756 |
</div>
|
| 757 |
</div>
|
| 758 |
`;
|
| 759 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 760 |
// 点击进入提示词详情
|
| 761 |
promptDiv.onclick = () => {
|
| 762 |
import("../prompt/提示词详情组件.js").then(module => {
|
|
|
|
| 793 |
*/
|
| 794 |
function createTransactionCard(tx) {
|
| 795 |
const txDiv = document.createElement("div");
|
| 796 |
+
txDiv.className = 'profile-transaction-card';
|
| 797 |
|
| 798 |
const isPositive = tx.amount > 0;
|
|
|
|
| 799 |
const timeStr = tx.created_at ? new Date(tx.created_at).toLocaleString(getLanguage(), { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '';
|
| 800 |
|
| 801 |
// 获取交易类型配置
|
|
|
|
| 904 |
|
| 905 |
// 资源标题(如果有)
|
| 906 |
const itemTitle = tx.item_title && tx.tx_type !== 'TIP_IN' && tx.tx_type !== 'TIP_OUT'
|
| 907 |
+
? `<span class="profile-transaction-card__item">- ${escapeHtml(tx.item_title)}</span>`
|
| 908 |
: '';
|
| 909 |
|
| 910 |
// 实到金额(仅提现类型)
|
| 911 |
const netAmountHtml = (tx.tx_type === 'WITHDRAW' && tx.net_amount !== null && tx.net_amount !== undefined)
|
| 912 |
+
? `<div class="profile-transaction-card__net">${t('tx.net_amount')}: ${escapeHtml(String(tx.net_amount))}</div>`
|
| 913 |
: '';
|
| 914 |
|
| 915 |
if (hasDetailedInfo) {
|
| 916 |
// 详细展示模式
|
| 917 |
txDiv.innerHTML = `
|
| 918 |
+
<div class="profile-transaction-card__summary">
|
| 919 |
+
<span class="profile-transaction-card__icon">${typeConfig.icon}</span>
|
| 920 |
+
<div class="profile-list-card__body">
|
| 921 |
+
<div class="profile-transaction-card__heading">
|
| 922 |
+
<span class="profile-transaction-card__title">${escapeHtml(String(typeConfig.title))}</span>
|
| 923 |
${itemTitle}
|
| 924 |
</div>
|
| 925 |
+
${description ? `<div class="profile-transaction-card__description">${escapeHtml(description)}</div>` : ''}
|
| 926 |
+
${detailText ? `<div class="profile-transaction-card__detail">${escapeHtml(detailText)}</div>` : ''}
|
| 927 |
</div>
|
| 928 |
</div>
|
| 929 |
+
<div class="profile-transaction-card__money">
|
| 930 |
+
<div class="profile-transaction-card__amount ${isPositive ? 'profile-transaction-card__amount--income' : 'profile-transaction-card__amount--expense'}">
|
| 931 |
+
${isPositive ? '+' : ''}${escapeHtml(String(tx.amount))}
|
| 932 |
</div>
|
| 933 |
${netAmountHtml}
|
| 934 |
</div>
|
|
|
|
| 936 |
} else {
|
| 937 |
// 简洁展示模式(向后兼容旧数据)
|
| 938 |
txDiv.innerHTML = `
|
| 939 |
+
<div class="profile-transaction-card__summary">
|
| 940 |
+
<span class="profile-transaction-card__icon">${typeConfig.icon}</span>
|
| 941 |
<div>
|
| 942 |
+
<div class="profile-transaction-card__title">${escapeHtml(String(typeConfig.title))}</div>
|
| 943 |
+
<div class="profile-transaction-card__detail">${escapeHtml(String(timeStr))}${tx.related_account ? ' · ' + escapeHtml(tx.related_account.substring(0, 8)) + '...' : ''}</div>
|
| 944 |
</div>
|
| 945 |
</div>
|
| 946 |
+
<div class="profile-transaction-card__amount ${isPositive ? 'profile-transaction-card__amount--income' : 'profile-transaction-card__amount--expense'}">
|
| 947 |
+
${isPositive ? '+' : ''}${escapeHtml(String(tx.amount))}
|
| 948 |
</div>
|
| 949 |
`;
|
| 950 |
}
|
前端页面/profile/个人设置表单组件.js
CHANGED
|
@@ -16,6 +16,11 @@ function _safeLocalStorageRemove(key) {
|
|
| 16 |
try { window.localStorage.removeItem(key); return true; } catch { return false; }
|
| 17 |
}
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
// 计算年龄工具函数
|
| 20 |
function calculateAge(birthDate) {
|
| 21 |
if (!birthDate) return null;
|
|
@@ -101,7 +106,7 @@ function _readAndPreviewImage(file, previewElement, cacheKey, current, readers,
|
|
| 101 |
}
|
| 102 |
}
|
| 103 |
}
|
| 104 |
-
previewElement.style.backgroundImage =
|
| 105 |
previewElement.style.backgroundSize = "cover";
|
| 106 |
previewElement.style.backgroundPosition = "center";
|
| 107 |
finish(base64);
|
|
@@ -147,85 +152,85 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
|
|
| 147 |
const countryOptions = sortedCountries.map(c => `<option value="${c}" ${c === userData.country ? 'selected' : ''}>${c}</option>`).join("");
|
| 148 |
|
| 149 |
container.innerHTML = `
|
| 150 |
-
<div
|
| 151 |
-
<div
|
| 152 |
-
<div
|
| 153 |
-
<img id="setting-avatar-preview" src="${escapeHtml(userData.avatarDataUrl || PLACEHOLDERS.AVATAR)}"
|
| 154 |
<div>
|
| 155 |
-
<button
|
| 156 |
-
<div
|
| 157 |
</div>
|
| 158 |
-
<input type="file" id="setting-avatar" accept="image/*"
|
| 159 |
</div>
|
| 160 |
|
| 161 |
-
<div
|
| 162 |
-
<div
|
| 163 |
-
<div
|
| 164 |
-
<div
|
| 165 |
-
<button
|
| 166 |
-
<button
|
| 167 |
</div>
|
| 168 |
-
<div
|
| 169 |
-
<input type="file" id="setting-banner" accept="image/*"
|
| 170 |
</div>
|
| 171 |
|
| 172 |
-
<div
|
| 173 |
-
<div
|
| 174 |
-
<div
|
| 175 |
-
<div
|
| 176 |
-
<button
|
| 177 |
-
<button id="btn-clear-ui-bg"
|
| 178 |
</div>
|
| 179 |
-
<div
|
| 180 |
-
<input type="file" id="setting-ui-bg" accept="image/*"
|
| 181 |
</div>
|
| 182 |
|
| 183 |
-
<div><label
|
| 184 |
|
| 185 |
-
<div
|
| 186 |
-
<div
|
| 187 |
-
<select
|
| 188 |
<option value="male" ${userData.gender === 'male' ? 'selected' : ''}>${t('settings_form.gender_male')}</option>
|
| 189 |
<option value="female" ${userData.gender === 'female' ? 'selected' : ''}>${t('settings_form.gender_female')}</option>
|
| 190 |
<option value="other" ${userData.gender === 'other' ? 'selected' : ''}>${t('settings_form.gender_secret')}</option>
|
| 191 |
</select>
|
| 192 |
</div>
|
| 193 |
-
<div
|
| 194 |
-
<label
|
| 195 |
-
<div
|
| 196 |
-
<input type="date" id="setting-birthday" value="${escapeHtml(userData.birthday || '')}"
|
| 197 |
-
<span
|
| 198 |
</div>
|
| 199 |
</div>
|
| 200 |
</div>
|
| 201 |
|
| 202 |
-
<div><label
|
| 203 |
-
<select
|
| 204 |
<option value="">${t('settings_form.select_country')}</option>
|
| 205 |
${countryOptions}
|
| 206 |
</select>
|
| 207 |
-
<select id="setting-region"
|
| 208 |
<option value="${escapeHtml(userData.region || '')}">${escapeHtml(userData.region || t('settings_form.select_country_first'))}</option>
|
| 209 |
</select>
|
| 210 |
</div>
|
| 211 |
|
| 212 |
-
<div><label
|
| 213 |
|
| 214 |
-
<div
|
| 215 |
-
<div
|
| 216 |
-
<div
|
| 217 |
|
| 218 |
-
<div
|
| 219 |
-
<label
|
| 220 |
<input type="checkbox" id="hide-follows" ${getPrivacy('follows') ? 'checked' : ''}> ${t('settings_form.hide_follows')}
|
| 221 |
</label>
|
| 222 |
-
<label
|
| 223 |
<input type="checkbox" id="hide-likes" ${getPrivacy('likes') ? 'checked' : ''}> ${t('settings_form.hide_likes')}
|
| 224 |
</label>
|
| 225 |
-
<label
|
| 226 |
<input type="checkbox" id="hide-favorites" ${getPrivacy('favorites') ? 'checked' : ''}> ${t('settings_form.hide_favorites')}
|
| 227 |
</label>
|
| 228 |
-
<label
|
| 229 |
<input type="checkbox" id="hide-downloads" ${getPrivacy('downloads') ? 'checked' : ''}> ${t('settings_form.hide_downloads')}
|
| 230 |
</label>
|
| 231 |
</div>
|
|
@@ -233,36 +238,36 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
|
|
| 233 |
|
| 234 |
${isAdmin ? `
|
| 235 |
<!-- 🔒 管理员设置区域 -->
|
| 236 |
-
<div
|
| 237 |
-
<div
|
| 238 |
<span>🔒</span> ${t('settings_form.admin_title')}
|
| 239 |
-
<span
|
| 240 |
</div>
|
| 241 |
-
<div
|
| 242 |
|
| 243 |
-
<div
|
| 244 |
<!-- 图像审核开关 -->
|
| 245 |
-
<div
|
| 246 |
-
<div
|
| 247 |
<div>
|
| 248 |
-
<div
|
| 249 |
-
<div
|
| 250 |
</div>
|
| 251 |
-
<label class="
|
| 252 |
-
<input type="checkbox" id="admin-image-moderation" disabled aria-label="${escapeHtml(t('settings_form.image_moderation'))}" aria-describedby="moderation-hint moderation-status"
|
| 253 |
-
|
| 254 |
</label>
|
| 255 |
</div>
|
| 256 |
-
<div id="moderation-status" role="status" aria-live="polite"
|
| 257 |
<button id="moderation-retry" type="button" hidden>${escapeHtml(t('common.retry'))}</button>
|
| 258 |
</div>
|
| 259 |
</div>
|
| 260 |
</div>
|
| 261 |
` : ''}
|
| 262 |
</div>
|
| 263 |
-
<div
|
| 264 |
-
<button
|
| 265 |
-
<button
|
| 266 |
</div>
|
| 267 |
`;
|
| 268 |
|
|
@@ -273,6 +278,11 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
|
|
| 273 |
const settingUiBgInput = container.querySelector("#setting-ui-bg");
|
| 274 |
const bannerPreview = container.querySelector("#setting-banner-preview");
|
| 275 |
const uiBgPreview = container.querySelector("#setting-ui-bg-preview");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
const birthdayInput = container.querySelector("#setting-birthday");
|
| 277 |
const ageDisplay = container.querySelector("#setting-age-display");
|
| 278 |
let avatarDataUrl = userData.avatarDataUrl;
|
|
@@ -293,32 +303,6 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
|
|
| 293 |
let pendingBannerFile = null; // 待上传的背景图文件
|
| 294 |
let pendingBannerDataUrl = null; // 用于预览和本地缓存
|
| 295 |
|
| 296 |
-
// 🔒 管理员开关样式注入
|
| 297 |
-
if (isAdmin && !document.getElementById('admin-toggle-styles')) {
|
| 298 |
-
const toggleStyles = document.createElement('style');
|
| 299 |
-
toggleStyles.id = 'admin-toggle-styles';
|
| 300 |
-
toggleStyles.textContent = `
|
| 301 |
-
.toggle-switch input:checked + .toggle-slider {
|
| 302 |
-
background-color: #4CAF50;
|
| 303 |
-
}
|
| 304 |
-
.toggle-slider:before {
|
| 305 |
-
position: absolute;
|
| 306 |
-
content: "";
|
| 307 |
-
height: 20px;
|
| 308 |
-
width: 20px;
|
| 309 |
-
left: 3px;
|
| 310 |
-
bottom: 3px;
|
| 311 |
-
background-color: white;
|
| 312 |
-
transition: .3s;
|
| 313 |
-
border-radius: 50%;
|
| 314 |
-
}
|
| 315 |
-
.toggle-switch input:checked + .toggle-slider:before {
|
| 316 |
-
transform: translateX(24px);
|
| 317 |
-
}
|
| 318 |
-
`;
|
| 319 |
-
document.head.appendChild(toggleStyles);
|
| 320 |
-
}
|
| 321 |
-
|
| 322 |
if (isAdmin) {
|
| 323 |
const moderationToggle = container.querySelector('#admin-image-moderation');
|
| 324 |
const statusDiv = container.querySelector('#moderation-status');
|
|
@@ -405,7 +389,7 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
|
|
| 405 |
// 加载本地界面背景预览(使用账号区分键)
|
| 406 |
const savedUiBg = _safeStorageGet('localStorage', getBackgroundKey(userData.account));
|
| 407 |
if (typeof savedUiBg === "string" && /^data:image\/(?:png|jpeg|webp);base64,/i.test(savedUiBg)) {
|
| 408 |
-
uiBgPreview.style.backgroundImage =
|
| 409 |
uiBgPreview.style.backgroundSize = "cover";
|
| 410 |
uiBgPreview.style.backgroundPosition = "center";
|
| 411 |
}
|
|
@@ -415,7 +399,7 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
|
|
| 415 |
const age = calculateAge(birthdayInput.value);
|
| 416 |
if (age !== null) {
|
| 417 |
ageDisplay.textContent = `${age} ${t('profile.age_years')}`;
|
| 418 |
-
ageDisplay.
|
| 419 |
} else {
|
| 420 |
ageDisplay.textContent = "";
|
| 421 |
}
|
|
@@ -488,7 +472,7 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
|
|
| 488 |
pendingBannerFile = null;
|
| 489 |
pendingBannerDataUrl = null;
|
| 490 |
bannerPreview.style.backgroundImage = "none";
|
| 491 |
-
bannerPreview.style.
|
| 492 |
// 同时清除本地缓存(使用账号区分键)
|
| 493 |
if (!_safeLocalStorageRemove(getBannerCacheKey(userData.account))) {
|
| 494 |
showToast(t('settings_form.local_storage_unavailable'), "warning");
|
|
@@ -518,7 +502,7 @@ export function createSettingsForm(initialUserData, onCancelCallback, onSaveSucc
|
|
| 518 |
return;
|
| 519 |
}
|
| 520 |
uiBgPreview.style.backgroundImage = "none";
|
| 521 |
-
uiBgPreview.style.
|
| 522 |
// 立即应用到侧边栏
|
| 523 |
window.dispatchEvent(new CustomEvent("comfy-sidebar-bg-update"));
|
| 524 |
showToast(t('settings_form.ui_bg_cleared'), "success");
|
|
|
|
| 16 |
try { window.localStorage.removeItem(key); return true; } catch { return false; }
|
| 17 |
}
|
| 18 |
|
| 19 |
+
function _cssBackgroundImage(value) {
|
| 20 |
+
if (typeof value !== "string" || !value.trim() || /[\u0000-\u001f\u007f]/.test(value)) return "none";
|
| 21 |
+
return `url(${JSON.stringify(value)})`;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
// 计算年龄工具函数
|
| 25 |
function calculateAge(birthDate) {
|
| 26 |
if (!birthDate) return null;
|
|
|
|
| 106 |
}
|
| 107 |
}
|
| 108 |
}
|
| 109 |
+
previewElement.style.backgroundImage = _cssBackgroundImage(base64);
|
| 110 |
previewElement.style.backgroundSize = "cover";
|
| 111 |
previewElement.style.backgroundPosition = "center";
|
| 112 |
finish(base64);
|
|
|
|
| 152 |
const countryOptions = sortedCountries.map(c => `<option value="${c}" ${c === userData.country ? 'selected' : ''}>${c}</option>`).join("");
|
| 153 |
|
| 154 |
container.innerHTML = `
|
| 155 |
+
<div class="settings-heading">⚙️ ${t('settings_form.title')}</div>
|
| 156 |
+
<div class="settings-fields">
|
| 157 |
+
<div class="settings-avatar-panel">
|
| 158 |
+
<img alt="" class="settings-avatar" id="setting-avatar-preview" src="${escapeHtml(userData.avatarDataUrl || PLACEHOLDERS.AVATAR)}">
|
| 159 |
<div>
|
| 160 |
+
<button type="button" class="settings-avatar-action" id="btn-trigger-avatar">${t('settings_form.change_avatar')}</button>
|
| 161 |
+
<div class="settings-hint">${t('settings_form.avatar_hint')}</div>
|
| 162 |
</div>
|
| 163 |
+
<input class="settings-file-input" type="file" id="setting-avatar" accept="image/*">
|
| 164 |
</div>
|
| 165 |
|
| 166 |
+
<div class="settings-media-panel">
|
| 167 |
+
<div class="settings-media-title">🇺️ ${t('settings_form.banner_title')}</div>
|
| 168 |
+
<div class="settings-banner-preview" id="setting-banner-preview"></div>
|
| 169 |
+
<div class="settings-media-actions">
|
| 170 |
+
<button type="button" class="settings-upload" id="btn-trigger-banner">📷 ${t('settings_form.banner_upload')}</button>
|
| 171 |
+
<button type="button" class="settings-clear" id="btn-clear-banner">🗑️ ${t('settings_form.banner_clear')}</button>
|
| 172 |
</div>
|
| 173 |
+
<div class="settings-media-hint">${t('settings_form.banner_hint')}</div>
|
| 174 |
+
<input class="settings-file-input" type="file" id="setting-banner" accept="image/*">
|
| 175 |
</div>
|
| 176 |
|
| 177 |
+
<div class="settings-media-panel">
|
| 178 |
+
<div class="settings-media-title">🎨 ${t('settings_form.ui_bg_title')}</div>
|
| 179 |
+
<div class="settings-background-preview" id="setting-ui-bg-preview"></div>
|
| 180 |
+
<div class="settings-media-actions">
|
| 181 |
+
<button type="button" class="settings-upload" id="btn-trigger-ui-bg">📷 ${t('settings_form.banner_upload')}</button>
|
| 182 |
+
<button type="button" class="settings-clear" id="btn-clear-ui-bg">🗑️ ${t('settings_form.banner_clear')}</button>
|
| 183 |
</div>
|
| 184 |
+
<div class="settings-media-hint">${t('settings_form.ui_bg_hint')}</div>
|
| 185 |
+
<input class="settings-file-input" type="file" id="setting-ui-bg" accept="image/*">
|
| 186 |
</div>
|
| 187 |
|
| 188 |
+
<div><label class="settings-label" for="setting-name">${t('settings_form.display_name')}</label><input class="settings-input" type="text" id="setting-name" value="${escapeHtml(userData.name || '')}"></div>
|
| 189 |
|
| 190 |
+
<div class="settings-row">
|
| 191 |
+
<div class="settings-field"><label class="settings-label" for="setting-gender">${t('settings_form.gender')}</label>
|
| 192 |
+
<select class="settings-select" id="setting-gender">
|
| 193 |
<option value="male" ${userData.gender === 'male' ? 'selected' : ''}>${t('settings_form.gender_male')}</option>
|
| 194 |
<option value="female" ${userData.gender === 'female' ? 'selected' : ''}>${t('settings_form.gender_female')}</option>
|
| 195 |
<option value="other" ${userData.gender === 'other' ? 'selected' : ''}>${t('settings_form.gender_secret')}</option>
|
| 196 |
</select>
|
| 197 |
</div>
|
| 198 |
+
<div class="settings-field">
|
| 199 |
+
<label class="settings-label" for="setting-birthday">${t('settings_form.birthday')}</label>
|
| 200 |
+
<div class="settings-birthday-row">
|
| 201 |
+
<input class="settings-birthday" type="date" id="setting-birthday" value="${escapeHtml(userData.birthday || '')}">
|
| 202 |
+
<span class="settings-age" id="setting-age-display">${userData.age ? userData.age + ' ' + t('profile.age_years') : ''}</span>
|
| 203 |
</div>
|
| 204 |
</div>
|
| 205 |
</div>
|
| 206 |
|
| 207 |
+
<div><label class="settings-label" for="setting-country">${t('settings_form.country')}</label>
|
| 208 |
+
<select class="settings-country" id="setting-country">
|
| 209 |
<option value="">${t('settings_form.select_country')}</option>
|
| 210 |
${countryOptions}
|
| 211 |
</select>
|
| 212 |
+
<select class="settings-select" id="setting-region" aria-label="${escapeHtml(t('auth.region'))}">
|
| 213 |
<option value="${escapeHtml(userData.region || '')}">${escapeHtml(userData.region || t('settings_form.select_country_first'))}</option>
|
| 214 |
</select>
|
| 215 |
</div>
|
| 216 |
|
| 217 |
+
<div><label class="settings-label" for="setting-intro">${t('settings_form.intro')}</label><textarea class="settings-intro" id="setting-intro" rows="3" placeholder="${t('settings_form.intro_placeholder')}">${escapeHtml(userData.intro || '')}</textarea></div>
|
| 218 |
|
| 219 |
+
<div class="settings-privacy">
|
| 220 |
+
<div class="settings-section-heading">🛡️ ${t('settings_form.privacy_title')}</div>
|
| 221 |
+
<div class="settings-privacy-hint">${t('settings_form.privacy_hint')}</div>
|
| 222 |
|
| 223 |
+
<div class="settings-privacy-options">
|
| 224 |
+
<label class="settings-check">
|
| 225 |
<input type="checkbox" id="hide-follows" ${getPrivacy('follows') ? 'checked' : ''}> ${t('settings_form.hide_follows')}
|
| 226 |
</label>
|
| 227 |
+
<label class="settings-check">
|
| 228 |
<input type="checkbox" id="hide-likes" ${getPrivacy('likes') ? 'checked' : ''}> ${t('settings_form.hide_likes')}
|
| 229 |
</label>
|
| 230 |
+
<label class="settings-check">
|
| 231 |
<input type="checkbox" id="hide-favorites" ${getPrivacy('favorites') ? 'checked' : ''}> ${t('settings_form.hide_favorites')}
|
| 232 |
</label>
|
| 233 |
+
<label class="settings-check">
|
| 234 |
<input type="checkbox" id="hide-downloads" ${getPrivacy('downloads') ? 'checked' : ''}> ${t('settings_form.hide_downloads')}
|
| 235 |
</label>
|
| 236 |
</div>
|
|
|
|
| 238 |
|
| 239 |
${isAdmin ? `
|
| 240 |
<!-- 🔒 管理员设置区域 -->
|
| 241 |
+
<div class="settings-admin" id="admin-settings-section">
|
| 242 |
+
<div class="settings-admin-heading">
|
| 243 |
<span>🔒</span> ${t('settings_form.admin_title')}
|
| 244 |
+
<span class="settings-admin-badge">Admin</span>
|
| 245 |
</div>
|
| 246 |
+
<div class="settings-admin-hint">${t('settings_form.admin_hint')}</div>
|
| 247 |
|
| 248 |
+
<div class="settings-admin-fields">
|
| 249 |
<!-- 图像审核开关 -->
|
| 250 |
+
<div class="settings-moderation">
|
| 251 |
+
<div class="settings-moderation-row">
|
| 252 |
<div>
|
| 253 |
+
<div class="settings-moderation-title">🖼️ ${escapeHtml(t('settings_form.image_moderation'))}</div>
|
| 254 |
+
<div class="settings-hint" id="moderation-hint">${escapeHtml(t('settings_form.image_moderation_hint'))}</div>
|
| 255 |
</div>
|
| 256 |
+
<label class="settings-switch toggle-switch">
|
| 257 |
+
<input class="settings-switch-input" type="checkbox" id="admin-image-moderation" disabled aria-label="${escapeHtml(t('settings_form.image_moderation'))}" aria-describedby="moderation-hint moderation-status"><span class="toggle-slider" aria-hidden="true"></span>
|
| 258 |
+
|
| 259 |
</label>
|
| 260 |
</div>
|
| 261 |
+
<div class="settings-moderation-status" id="moderation-status" role="status" aria-live="polite">${escapeHtml(t('common.loading'))}</div>
|
| 262 |
<button id="moderation-retry" type="button" hidden>${escapeHtml(t('common.retry'))}</button>
|
| 263 |
</div>
|
| 264 |
</div>
|
| 265 |
</div>
|
| 266 |
` : ''}
|
| 267 |
</div>
|
| 268 |
+
<div class="settings-actions">
|
| 269 |
+
<button type="button" class="settings-cancel" id="btn-cancel-settings">${t('settings_form.cancel')}</button>
|
| 270 |
+
<button type="button" class="settings-save" id="btn-save-settings">${t('settings_form.save_all')}</button>
|
| 271 |
</div>
|
| 272 |
`;
|
| 273 |
|
|
|
|
| 278 |
const settingUiBgInput = container.querySelector("#setting-ui-bg");
|
| 279 |
const bannerPreview = container.querySelector("#setting-banner-preview");
|
| 280 |
const uiBgPreview = container.querySelector("#setting-ui-bg-preview");
|
| 281 |
+
const initialBannerBackground = _cssBackgroundImage(userData.bannerUrl);
|
| 282 |
+
bannerPreview.style.backgroundImage = initialBannerBackground;
|
| 283 |
+
if (initialBannerBackground !== "none") {
|
| 284 |
+
bannerPreview.style.backgroundColor = "transparent";
|
| 285 |
+
}
|
| 286 |
const birthdayInput = container.querySelector("#setting-birthday");
|
| 287 |
const ageDisplay = container.querySelector("#setting-age-display");
|
| 288 |
let avatarDataUrl = userData.avatarDataUrl;
|
|
|
|
| 303 |
let pendingBannerFile = null; // 待上传的背景图文件
|
| 304 |
let pendingBannerDataUrl = null; // 用于预览和本地缓存
|
| 305 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
if (isAdmin) {
|
| 307 |
const moderationToggle = container.querySelector('#admin-image-moderation');
|
| 308 |
const statusDiv = container.querySelector('#moderation-status');
|
|
|
|
| 389 |
// 加载本地界面背景预览(使用账号区分键)
|
| 390 |
const savedUiBg = _safeStorageGet('localStorage', getBackgroundKey(userData.account));
|
| 391 |
if (typeof savedUiBg === "string" && /^data:image\/(?:png|jpeg|webp);base64,/i.test(savedUiBg)) {
|
| 392 |
+
uiBgPreview.style.backgroundImage = _cssBackgroundImage(savedUiBg);
|
| 393 |
uiBgPreview.style.backgroundSize = "cover";
|
| 394 |
uiBgPreview.style.backgroundPosition = "center";
|
| 395 |
}
|
|
|
|
| 399 |
const age = calculateAge(birthdayInput.value);
|
| 400 |
if (age !== null) {
|
| 401 |
ageDisplay.textContent = `${age} ${t('profile.age_years')}`;
|
| 402 |
+
ageDisplay.classList.add("settings-age--known");
|
| 403 |
} else {
|
| 404 |
ageDisplay.textContent = "";
|
| 405 |
}
|
|
|
|
| 472 |
pendingBannerFile = null;
|
| 473 |
pendingBannerDataUrl = null;
|
| 474 |
bannerPreview.style.backgroundImage = "none";
|
| 475 |
+
bannerPreview.style.backgroundColor = "";
|
| 476 |
// 同时清除本地缓存(使用账号区分键)
|
| 477 |
if (!_safeLocalStorageRemove(getBannerCacheKey(userData.account))) {
|
| 478 |
showToast(t('settings_form.local_storage_unavailable'), "warning");
|
|
|
|
| 502 |
return;
|
| 503 |
}
|
| 504 |
uiBgPreview.style.backgroundImage = "none";
|
| 505 |
+
uiBgPreview.style.backgroundColor = "";
|
| 506 |
// 立即应用到侧边栏
|
| 507 |
window.dispatchEvent(new CustomEvent("comfy-sidebar-bg-update"));
|
| 508 |
showToast(t('settings_form.ui_bg_cleared'), "success");
|
前端页面/prompt/价格校验.js
CHANGED
|
@@ -20,3 +20,10 @@ export function validatePromptPriceInput(input, error, t) {
|
|
| 20 |
input.removeAttribute("aria-invalid");
|
| 21 |
return price;
|
| 22 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
input.removeAttribute("aria-invalid");
|
| 21 |
return price;
|
| 22 |
}
|
| 23 |
+
|
| 24 |
+
export function getPromptPriceState(value) {
|
| 25 |
+
const known = (typeof value === 'number' || (typeof value === 'string' && value.trim() !== ''))
|
| 26 |
+
&& Number.isFinite(Number(value)) && Number(value) >= 0;
|
| 27 |
+
if (!known) return 'unknown';
|
| 28 |
+
return Number(value) === 0 ? 'free' : 'paid';
|
| 29 |
+
}
|
前端页面/prompt/发布提示词组件.js
CHANGED
|
@@ -20,7 +20,7 @@ import { removeCacheByPrefix } from "../components/性能优化工具.js";
|
|
| 20 |
import { createPublishEditingLock } from "../market/发布编辑锁.js";
|
| 21 |
import { createPublishSubmissionScope } from "../market/发布提交生命周期.js";
|
| 22 |
import { parsePromptPrice, validatePromptPriceInput } from "./价格校验.js";
|
| 23 |
-
import { API, CACHE, PUBLISH_MEDIA_LIMITS, IMAGE_PROCESS, PROMPT_LIMITS } from "../core/全局配置.js";
|
| 24 |
import { escapeHtml } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
|
| 25 |
|
| 26 |
function requirePromptUpload(result) {
|
|
@@ -119,13 +119,11 @@ function formatFileSize(bytes) {
|
|
| 119 |
}
|
| 120 |
|
| 121 |
// ===== 多图预览与拖拽排序(参考讨论区发布界面) =====
|
| 122 |
-
const PREVIEW_WRAPPER_STYLE = { position: "relative", width: "80px", height: "80px" };
|
| 123 |
|
| 124 |
// 清除所有拖拽插入指示线
|
| 125 |
function _clearDragIndicators(wrappers) {
|
| 126 |
wrappers.forEach(w => {
|
| 127 |
-
w.
|
| 128 |
-
w.style.paddingLeft = '';
|
| 129 |
});
|
| 130 |
}
|
| 131 |
|
|
@@ -135,19 +133,7 @@ function _createAddButton(imagesInput, scope) {
|
|
| 135 |
addBtn.type = "button";
|
| 136 |
addBtn.setAttribute("aria-label", t("post.upload_images"));
|
| 137 |
addBtn.setAttribute("data-file-picker-button", "");
|
| 138 |
-
|
| 139 |
-
width: "80px",
|
| 140 |
-
height: "80px",
|
| 141 |
-
background: "transparent",
|
| 142 |
-
border: "2px dashed var(--rk-border, var(--border-color, #444))",
|
| 143 |
-
borderRadius: "6px",
|
| 144 |
-
display: "flex",
|
| 145 |
-
alignItems: "center",
|
| 146 |
-
justifyContent: "center",
|
| 147 |
-
cursor: "pointer",
|
| 148 |
-
color: "var(--rk-muted, var(--descrip-text, #a4a4a4))",
|
| 149 |
-
fontSize: "24px"
|
| 150 |
-
});
|
| 151 |
addBtn.textContent = "+";
|
| 152 |
addBtn.onclick = (e) => {
|
| 153 |
if (!scope.isCurrent() || imagesInput.disabled) return;
|
|
@@ -188,8 +174,9 @@ function updateCoverMark(previewContainer) {
|
|
| 188 |
wrapper.tabIndex = 0;
|
| 189 |
wrapper.setAttribute('role', 'group');
|
| 190 |
wrapper.setAttribute('aria-label', t('post.image_position', { index: idx + 1, total: wrappers.length }));
|
| 191 |
-
wrapper.setAttribute('aria-keyshortcuts', 'Alt+ArrowLeft Alt+ArrowRight');
|
| 192 |
-
|
|
|
|
| 193 |
const remove = wrapper.querySelector('button[data-action="remove"]');
|
| 194 |
if (remove) {
|
| 195 |
remove.type = 'button';
|
|
@@ -198,13 +185,13 @@ function updateCoverMark(previewContainer) {
|
|
| 198 |
if (img && !img.hasAttribute('alt')) img.alt = '';
|
| 199 |
|
| 200 |
if (img) {
|
| 201 |
-
|
| 202 |
}
|
| 203 |
if (idx === 0) {
|
| 204 |
if (!coverLabel) {
|
| 205 |
const label = document.createElement('span');
|
| 206 |
label.className = 'cover-label';
|
| 207 |
-
|
| 208 |
label.textContent = t('post.cover');
|
| 209 |
wrapper.appendChild(label);
|
| 210 |
}
|
|
@@ -239,7 +226,7 @@ function setupImageDragSort(previewContainer, fileArray, options = {}) {
|
|
| 239 |
|
| 240 |
wrappers.forEach(wrapper => {
|
| 241 |
wrapper.draggable = true;
|
| 242 |
-
wrapper.
|
| 243 |
|
| 244 |
// 绑定删除按钮(如果尚未绑定)
|
| 245 |
const removeBtn = wrapper.querySelector('button[data-action="remove"]');
|
|
@@ -261,7 +248,7 @@ function setupImageDragSort(previewContainer, fileArray, options = {}) {
|
|
| 261 |
}
|
| 262 |
|
| 263 |
wrapper.onkeydown = (e) => {
|
| 264 |
-
if (e.target !== wrapper || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey ||
|
| 265 |
e.repeat || e.isComposing || !canInteract() ||
|
| 266 |
!['ArrowLeft', 'ArrowRight'].includes(e.key)) return;
|
| 267 |
const current = getWrappers();
|
|
@@ -286,7 +273,7 @@ function setupImageDragSort(previewContainer, fileArray, options = {}) {
|
|
| 286 |
e.stopPropagation();
|
| 287 |
dragSrcEl = wrapper;
|
| 288 |
dragSrcIndex = getWrappers().indexOf(wrapper);
|
| 289 |
-
wrapper.
|
| 290 |
e.dataTransfer.effectAllowed = 'move';
|
| 291 |
e.dataTransfer.setData('text/plain', String(dragSrcIndex));
|
| 292 |
};
|
|
@@ -307,13 +294,11 @@ function setupImageDragSort(previewContainer, fileArray, options = {}) {
|
|
| 307 |
_clearDragIndicators(currentWrappers);
|
| 308 |
|
| 309 |
// 在当前目标左侧显示绿色竖线指示插入位置
|
| 310 |
-
wrapper.
|
| 311 |
-
wrapper.style.paddingLeft = '5px';
|
| 312 |
};
|
| 313 |
|
| 314 |
wrapper.ondragleave = () => {
|
| 315 |
-
wrapper.
|
| 316 |
-
wrapper.style.paddingLeft = '';
|
| 317 |
};
|
| 318 |
|
| 319 |
wrapper.ondrop = (e) => {
|
|
@@ -347,7 +332,7 @@ function setupImageDragSort(previewContainer, fileArray, options = {}) {
|
|
| 347 |
dragSrcEl = null;
|
| 348 |
dragSrcIndex = -1;
|
| 349 |
const endWrappers = getWrappers();
|
| 350 |
-
endWrappers.forEach(w => { w.
|
| 351 |
_clearDragIndicators(endWrappers);
|
| 352 |
};
|
| 353 |
});
|
|
@@ -377,10 +362,8 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
|
|
| 377 |
const editTags = isEditMode ? (Array.isArray(editPromptData.tags) ? editPromptData.tags : []) : [];
|
| 378 |
|
| 379 |
const container = document.createElement("div");
|
| 380 |
-
const fieldLabelStyle = "display: block; font-size: 13px; font-weight: bold; color: var(--rk-text, var(--fg-color, #ddd)); margin-bottom: 8px;";
|
| 381 |
-
const inputStyle = "";
|
| 382 |
|
| 383 |
-
container.classList.add("ranking-publish-form");
|
| 384 |
container.innerHTML = `
|
| 385 |
<!-- 顶部标题栏 -->
|
| 386 |
<div class="ranking-publish-header">
|
|
@@ -395,124 +378,124 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
|
|
| 395 |
<!-- 表单内容 -->
|
| 396 |
<div class="ranking-publish-body">
|
| 397 |
<!-- 类型切换(图像/视频提示词) -->
|
| 398 |
-
<div
|
| 399 |
-
<label
|
| 400 |
-
<div role="group" aria-label="${t('prompt.type_label')}"
|
| 401 |
-
<button id="ptype-image"
|
| 402 |
🎨 ${t('prompt.tab_image')}
|
| 403 |
</button>
|
| 404 |
-
<button id="ptype-video"
|
| 405 |
🎬 ${t('prompt.tab_video')}
|
| 406 |
</button>
|
| 407 |
-
<button id="ptype-music"
|
| 408 |
🎵 ${t('prompt.tab_music')}
|
| 409 |
</button>
|
| 410 |
</div>
|
| 411 |
</div>
|
| 412 |
|
| 413 |
<!-- 分类选择 -->
|
| 414 |
-
<div
|
| 415 |
-
<label for="category-select"
|
| 416 |
-
<select id="category-select" class="ranking-publish-input
|
| 417 |
<option value="">${t('prompt.category_select')}</option>
|
| 418 |
</select>
|
| 419 |
</div>
|
| 420 |
|
| 421 |
<!-- 🏷️ 标签(自定义 + 推荐快加 + 搜索过滤) -->
|
| 422 |
-
<div
|
| 423 |
-
<label for="tag-input"
|
| 424 |
-
<div id="tags-selected"
|
| 425 |
-
<input type="text" id="tag-input" class="ranking-publish-input" placeholder="${t('prompt.tag_placeholder')}" maxlength="12"
|
| 426 |
-
<div
|
| 427 |
-
<div id="tag-recommend-list"
|
| 428 |
</div>
|
| 429 |
|
| 430 |
<!-- 标题输入 -->
|
| 431 |
-
<div
|
| 432 |
-
<label for="title-input"
|
| 433 |
-
<input type="text" id="title-input" class="ranking-publish-input" value="${escapeAttr(editTitle)}" placeholder="${t('prompt.title_placeholder')}" maxlength="50"
|
| 434 |
-
<div
|
| 435 |
<span id="title-count">${editTitle.length}</span>/50
|
| 436 |
</div>
|
| 437 |
</div>
|
| 438 |
|
| 439 |
<!-- 文案/简介输入 -->
|
| 440 |
-
<div
|
| 441 |
-
<label for="content-input"
|
| 442 |
-
<textarea id="content-input" class="ranking-publish-input" placeholder="${t('prompt.content_placeholder')}" maxlength="2000"
|
| 443 |
-
<div
|
| 444 |
<span id="content-count">${editContent.length}</span>/2000
|
| 445 |
</div>
|
| 446 |
</div>
|
| 447 |
|
| 448 |
<!-- 提示词正文(付费内容) -->
|
| 449 |
-
<div
|
| 450 |
-
<label for="text-input"
|
| 451 |
-
<textarea id="text-input" class="ranking-publish-input" placeholder="${t('prompt.text_placeholder')}" maxlength="20000"
|
| 452 |
-
<div
|
| 453 |
<span id="text-count">${editText.length}</span>/20000
|
| 454 |
</div>
|
| 455 |
</div>
|
| 456 |
|
| 457 |
<!-- 价格设置 -->
|
| 458 |
-
<div
|
| 459 |
-
<label for="price-input"
|
| 460 |
-
<input type="number" id="price-input" class="ranking-publish-input" value="${escapeHtml(String(editPrice))}" min="0" max="${PROMPT_LIMITS.MAX_PRICE}" step="1" aria-describedby="price-error"
|
| 461 |
-
<div id="price-error" role="alert" hidden
|
| 462 |
</div>
|
| 463 |
|
| 464 |
<!-- 原创作品勾选(参考讨论区) -->
|
| 465 |
-
<div
|
| 466 |
-
<label
|
| 467 |
-
<input type="checkbox" id="is-original-checkbox" ${editIsOriginal ? 'checked' : ''}
|
| 468 |
-
<span
|
| 469 |
</label>
|
| 470 |
-
<div
|
| 471 |
${t('publish.original_default_hint')}
|
| 472 |
</div>
|
| 473 |
</div>
|
| 474 |
|
| 475 |
<!-- 图片上传区(图像提示词专用:多图 + 拖拽排序,首张为封面) -->
|
| 476 |
-
<div id="image-upload-section"
|
| 477 |
-
<label
|
| 478 |
-
<span
|
| 479 |
</label>
|
| 480 |
<input type="file" id="images-input" accept="image/*" multiple style="display: none;">
|
| 481 |
-
<div id="images-preview"
|
| 482 |
-
<div
|
| 483 |
-
<div
|
| 484 |
${t('post.click_upload')}
|
| 485 |
</div>
|
| 486 |
</div>
|
| 487 |
</div>
|
| 488 |
|
| 489 |
<!-- 视频上传区(视频提示词专用:截取当前帧或手动上传封面) -->
|
| 490 |
-
<div id="video-upload-section" style="
|
| 491 |
-
<label
|
| 492 |
<input type="file" id="video-input" accept="video/mp4,video/webm,video/quicktime" style="display: none;">
|
| 493 |
-
<div id="video-preview-area"
|
| 494 |
-
<div id="video-empty-state"
|
| 495 |
-
<div
|
| 496 |
${t('post.click_upload_video')}
|
| 497 |
</div>
|
| 498 |
<div id="video-player-wrap" style="display: none;">
|
| 499 |
-
<video id="video-player" controls
|
| 500 |
-
<div
|
| 501 |
-
<div
|
| 502 |
<div id="video-name"></div>
|
| 503 |
<div id="video-meta"></div>
|
| 504 |
</div>
|
| 505 |
-
<button
|
| 506 |
</div>
|
| 507 |
<!-- 封面区域(截取当前帧 / 手动上传) -->
|
| 508 |
-
<div id="video-cover-wrap" style="display: none;
|
| 509 |
-
<div
|
| 510 |
<div class="ranking-publish-cover-controls">
|
| 511 |
-
<div
|
| 512 |
-
<img id="cover-thumb" src="" style="
|
| 513 |
-
<span id="cover-label" style="
|
| 514 |
</div>
|
| 515 |
-
<div
|
| 516 |
<button type="button" id="btn-capture-frame" class="ranking-publish-secondary">
|
| 517 |
📸 ${t('post.capture_frame')}
|
| 518 |
</button>
|
|
@@ -528,30 +511,30 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
|
|
| 528 |
</div>
|
| 529 |
|
| 530 |
<!-- 🎵 音频上传区(音乐提示词专用:图片+音频方案,视频可选) -->
|
| 531 |
-
<div id="audio-upload-section" style="
|
| 532 |
-
<label
|
| 533 |
-
<div
|
| 534 |
<input type="file" id="audio-input" accept="audio/mpeg,audio/wav,audio/x-wav,audio/mp4,audio/x-m4a,audio/ogg,.mp3,.wav,.m4a,.ogg" style="display: none;">
|
| 535 |
-
<div id="audio-preview-area"
|
| 536 |
-
<div id="audio-empty-state"
|
| 537 |
-
<div
|
| 538 |
${t('post.click_upload_audio')}
|
| 539 |
</div>
|
| 540 |
<div id="audio-player-wrap" style="display: none;">
|
| 541 |
-
<audio id="audio-player" controls
|
| 542 |
-
<div
|
| 543 |
-
<div
|
| 544 |
<div id="audio-name"></div>
|
| 545 |
<div id="audio-meta"></div>
|
| 546 |
</div>
|
| 547 |
-
<button
|
| 548 |
</div>
|
| 549 |
</div>
|
| 550 |
</div>
|
| 551 |
</div>
|
| 552 |
|
| 553 |
<!-- 提示信息 -->
|
| 554 |
-
<div
|
| 555 |
💡 <strong>${t('prompt.notice_title')}:</strong><br>
|
| 556 |
• ${t('prompt.notice_1')}<br>
|
| 557 |
• ${t('prompt.notice_2')}<br>
|
|
@@ -559,7 +542,7 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
|
|
| 559 |
</div>
|
| 560 |
|
| 561 |
<!-- 确认发布按钮 -->
|
| 562 |
-
<button id="btn-submit-prompt"
|
| 563 |
${isEditMode ? '💾' : '🚀'} ${isEditMode ? t('prompt.save_submit') : t('prompt.publish_submit')}
|
| 564 |
</button>
|
| 565 |
</div>
|
|
@@ -647,6 +630,15 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
|
|
| 647 |
const submitBtn = container.querySelector("#btn-submit-prompt");
|
| 648 |
const account = currentUser?.account;
|
| 649 |
const scope = createPublishSubmissionScope(container, submitBtn, account);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 650 |
const editingLock = createPublishEditingLock(container, [submitBtn, container.querySelector("#btn-back-publish-prompt")]);
|
| 651 |
scope.addCleanup(() => editingLock.dispose());
|
| 652 |
const previewReaders = new Set(), temporaryVideos = new Map();
|
|
@@ -754,14 +746,6 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
|
|
| 754 |
// ========== 类型切换 ==========
|
| 755 |
function updateTypeUI() {
|
| 756 |
for (const [button, type] of [[ptypeImageBtn, 'image'], [ptypeVideoBtn, 'video'], [ptypeMusicBtn, 'music']]) button.setAttribute('aria-pressed', String(promptType === type));
|
| 757 |
-
// 先重置三个按钮样式,再高亮当前选中项
|
| 758 |
-
[ptypeImageBtn, ptypeVideoBtn, ptypeMusicBtn].forEach(btn => {
|
| 759 |
-
btn.style.background = 'transparent';
|
| 760 |
-
btn.style.color = 'var(--rk-muted, var(--descrip-text, #a4a4a4))';
|
| 761 |
-
});
|
| 762 |
-
const activeBtn = promptType === 'image' ? ptypeImageBtn : (promptType === 'music' ? ptypeMusicBtn : ptypeVideoBtn);
|
| 763 |
-
activeBtn.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
|
| 764 |
-
activeBtn.style.color = 'var(--rk-on-accent, #fff)';
|
| 765 |
// 图像区:图像提示词 + 音乐提示词(图片+音频方案);视频区:视频/音乐;音频区:仅音乐
|
| 766 |
imageSection.style.display = promptType === 'video' ? 'none' : 'block';
|
| 767 |
videoSection.style.display = promptType === 'image' ? 'none' : 'block';
|
|
@@ -879,11 +863,13 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
|
|
| 879 |
tagsSelectedEl.innerHTML = "";
|
| 880 |
selectedTags.forEach(tag => {
|
| 881 |
const chip = document.createElement("span");
|
| 882 |
-
chip.
|
| 883 |
chip.textContent = "#" + tag;
|
| 884 |
const rm = document.createElement("button");
|
| 885 |
rm.textContent = "×";
|
| 886 |
-
rm.
|
|
|
|
|
|
|
| 887 |
rm.onclick = () => {
|
| 888 |
if (!scope.isCurrent()) return; selectedTags = selectedTags.filter(x => x !== tag); renderSelectedTags(); renderRecommendTags(); };
|
| 889 |
chip.appendChild(rm);
|
|
@@ -900,12 +886,13 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
|
|
| 900 |
.slice(0, 20);
|
| 901 |
tagRecommendList.innerHTML = "";
|
| 902 |
if (!list.length) {
|
| 903 |
-
tagRecommendList.innerHTML = `<span
|
| 904 |
return;
|
| 905 |
}
|
| 906 |
list.forEach(name => {
|
| 907 |
const chip = document.createElement("button");
|
| 908 |
-
chip.
|
|
|
|
| 909 |
chip.textContent = "+ " + name;
|
| 910 |
chip.onclick = () => addTag(name);
|
| 911 |
tagRecommendList.appendChild(chip);
|
|
@@ -925,8 +912,11 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
|
|
| 925 |
}
|
| 926 |
|
| 927 |
tagInput.onkeydown = (e) => {
|
| 928 |
-
if (!scope.isCurrent()
|
| 929 |
-
|
|
|
|
|
|
|
|
|
|
| 930 |
};
|
| 931 |
tagInput.oninput = () => { if (scope.isCurrent()) renderRecommendTags(); };
|
| 932 |
|
|
@@ -1009,8 +999,8 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
|
|
| 1009 |
|
| 1010 |
if (totalImages === 0) {
|
| 1011 |
imagesPreview.innerHTML = `
|
| 1012 |
-
<div
|
| 1013 |
-
<div
|
| 1014 |
${t('post.click_upload')}
|
| 1015 |
</div>
|
| 1016 |
`;
|
|
@@ -1036,12 +1026,12 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
|
|
| 1036 |
|
| 1037 |
existingImageUrls.forEach((url, idx) => {
|
| 1038 |
const wrapper = document.createElement("div");
|
| 1039 |
-
|
| 1040 |
|
| 1041 |
wrapper.innerHTML = `
|
| 1042 |
-
<img src="${escapeAttr(url)}"
|
| 1043 |
-
${idx === 0 ? `<span class="
|
| 1044 |
-
<button data-action="remove" data-existing-idx="${idx}"
|
| 1045 |
`;
|
| 1046 |
|
| 1047 |
wrapper._imageType = 'existing';
|
|
@@ -1065,11 +1055,11 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
|
|
| 1065 |
previewReaders.add(reader);
|
| 1066 |
reader.onabort = () => finishPreviewReader(reader);
|
| 1067 |
const wrapper = document.createElement("div");
|
| 1068 |
-
|
| 1069 |
wrapper.innerHTML = `
|
| 1070 |
-
<img src=""
|
| 1071 |
-
${existingImageUrls.length === 0 && idx === 0 ? `<span class="
|
| 1072 |
-
<button data-action="remove" data-idx="${idx}"
|
| 1073 |
`;
|
| 1074 |
|
| 1075 |
wrapper.querySelector('img').removeAttribute('src');
|
|
@@ -1116,8 +1106,8 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
|
|
| 1116 |
const generation = beginImagePreviews();
|
| 1117 |
if (imageFiles.length === 0) {
|
| 1118 |
imagesPreview.innerHTML = `
|
| 1119 |
-
<div
|
| 1120 |
-
<div
|
| 1121 |
${t('post.click_upload')}
|
| 1122 |
</div>
|
| 1123 |
`;
|
|
@@ -1133,12 +1123,12 @@ export function createPublishPromptView(currentUser, editPromptData = null) {
|
|
| 1133 |
previewReaders.add(reader);
|
| 1134 |
reader.onabort = () => finishPreviewReader(reader);
|
| 1135 |
const wrapper = document.createElement("div");
|
| 1136 |
-
|
| 1137 |
|
| 1138 |
wrapper.innerHTML = `
|
| 1139 |
-
<img src=""
|
| 1140 |
-
${idx === 0 ? `<span class="
|
| 1141 |
-
<button data-action="remove" data-idx="${idx}"
|
| 1142 |
`;
|
| 1143 |
|
| 1144 |
wrapper.querySelector('img').removeAttribute('src');
|
|
|
|
| 20 |
import { createPublishEditingLock } from "../market/发布编辑锁.js";
|
| 21 |
import { createPublishSubmissionScope } from "../market/发布提交生命周期.js";
|
| 22 |
import { parsePromptPrice, validatePromptPriceInput } from "./价格校验.js";
|
| 23 |
+
import { areKeyboardShortcutsEnabled, API, CACHE, PUBLISH_MEDIA_LIMITS, IMAGE_PROCESS, PROMPT_LIMITS } from "../core/全局配置.js";
|
| 24 |
import { escapeHtml } from "../components/互动工具函数.js"; // 🧹 P2归一:局部副本已移除
|
| 25 |
|
| 26 |
function requirePromptUpload(result) {
|
|
|
|
| 119 |
}
|
| 120 |
|
| 121 |
// ===== 多图预览与拖拽排序(参考讨论区发布界面) =====
|
|
|
|
| 122 |
|
| 123 |
// 清除所有拖拽插入指示线
|
| 124 |
function _clearDragIndicators(wrappers) {
|
| 125 |
wrappers.forEach(w => {
|
| 126 |
+
w.removeAttribute('data-prompt-drop');
|
|
|
|
| 127 |
});
|
| 128 |
}
|
| 129 |
|
|
|
|
| 133 |
addBtn.type = "button";
|
| 134 |
addBtn.setAttribute("aria-label", t("post.upload_images"));
|
| 135 |
addBtn.setAttribute("data-file-picker-button", "");
|
| 136 |
+
addBtn.className = "prompt-publish__add";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
addBtn.textContent = "+";
|
| 138 |
addBtn.onclick = (e) => {
|
| 139 |
if (!scope.isCurrent() || imagesInput.disabled) return;
|
|
|
|
| 174 |
wrapper.tabIndex = 0;
|
| 175 |
wrapper.setAttribute('role', 'group');
|
| 176 |
wrapper.setAttribute('aria-label', t('post.image_position', { index: idx + 1, total: wrappers.length }));
|
| 177 |
+
if (areKeyboardShortcutsEnabled()) wrapper.setAttribute('aria-keyshortcuts', 'Alt+ArrowLeft Alt+ArrowRight');
|
| 178 |
+
else wrapper.removeAttribute('aria-keyshortcuts');
|
| 179 |
+
wrapper.title = t(areKeyboardShortcutsEnabled() ? 'post.image_move_hint' : 'post.image_drag_hint');
|
| 180 |
const remove = wrapper.querySelector('button[data-action="remove"]');
|
| 181 |
if (remove) {
|
| 182 |
remove.type = 'button';
|
|
|
|
| 185 |
if (img && !img.hasAttribute('alt')) img.alt = '';
|
| 186 |
|
| 187 |
if (img) {
|
| 188 |
+
wrapper.setAttribute('data-prompt-cover', String(idx === 0));
|
| 189 |
}
|
| 190 |
if (idx === 0) {
|
| 191 |
if (!coverLabel) {
|
| 192 |
const label = document.createElement('span');
|
| 193 |
label.className = 'cover-label';
|
| 194 |
+
|
| 195 |
label.textContent = t('post.cover');
|
| 196 |
wrapper.appendChild(label);
|
| 197 |
}
|
|
|
|
| 226 |
|
| 227 |
wrappers.forEach(wrapper => {
|
| 228 |
wrapper.draggable = true;
|
| 229 |
+
wrapper.className = "prompt-publish__image";
|
| 230 |
|
| 231 |
// 绑定删除按钮(如果尚未绑定)
|
| 232 |
const removeBtn = wrapper.querySelector('button[data-action="remove"]');
|
|
|
|
| 248 |
}
|
| 249 |
|
| 250 |
wrapper.onkeydown = (e) => {
|
| 251 |
+
if (!areKeyboardShortcutsEnabled() || e.defaultPrevented || e.keyCode === 229 || e.target !== wrapper || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey ||
|
| 252 |
e.repeat || e.isComposing || !canInteract() ||
|
| 253 |
!['ArrowLeft', 'ArrowRight'].includes(e.key)) return;
|
| 254 |
const current = getWrappers();
|
|
|
|
| 273 |
e.stopPropagation();
|
| 274 |
dragSrcEl = wrapper;
|
| 275 |
dragSrcIndex = getWrappers().indexOf(wrapper);
|
| 276 |
+
wrapper.setAttribute('data-prompt-dragging', 'true');
|
| 277 |
e.dataTransfer.effectAllowed = 'move';
|
| 278 |
e.dataTransfer.setData('text/plain', String(dragSrcIndex));
|
| 279 |
};
|
|
|
|
| 294 |
_clearDragIndicators(currentWrappers);
|
| 295 |
|
| 296 |
// 在当前目标左侧显示绿色竖线指示插入位置
|
| 297 |
+
wrapper.setAttribute('data-prompt-drop', 'true');
|
|
|
|
| 298 |
};
|
| 299 |
|
| 300 |
wrapper.ondragleave = () => {
|
| 301 |
+
wrapper.removeAttribute('data-prompt-drop');
|
|
|
|
| 302 |
};
|
| 303 |
|
| 304 |
wrapper.ondrop = (e) => {
|
|
|
|
| 332 |
dragSrcEl = null;
|
| 333 |
dragSrcIndex = -1;
|
| 334 |
const endWrappers = getWrappers();
|
| 335 |
+
endWrappers.forEach(w => { w.removeAttribute('data-prompt-dragging'); });
|
| 336 |
_clearDragIndicators(endWrappers);
|
| 337 |
};
|
| 338 |
});
|
|
|
|
| 362 |
const editTags = isEditMode ? (Array.isArray(editPromptData.tags) ? editPromptData.tags : []) : [];
|
| 363 |
|
| 364 |
const container = document.createElement("div");
|
|
|
|
|
|
|
| 365 |
|
| 366 |
+
container.classList.add("ranking-publish-form", "prompt-publish");
|
| 367 |
container.innerHTML = `
|
| 368 |
<!-- 顶部标题栏 -->
|
| 369 |
<div class="ranking-publish-header">
|
|
|
|
| 378 |
<!-- 表单内容 -->
|
| 379 |
<div class="ranking-publish-body">
|
| 380 |
<!-- 类型切换(图像/视频提示词) -->
|
| 381 |
+
<div class="prompt-publish__field">
|
| 382 |
+
<label class="prompt-publish__label">🎯 ${t('prompt.type_label')}</label>
|
| 383 |
+
<div role="group" aria-label="${t('prompt.type_label')}" class="prompt-publish__types">
|
| 384 |
+
<button type="button" id="ptype-image" class="prompt-publish__type">
|
| 385 |
🎨 ${t('prompt.tab_image')}
|
| 386 |
</button>
|
| 387 |
+
<button type="button" id="ptype-video" class="prompt-publish__type">
|
| 388 |
🎬 ${t('prompt.tab_video')}
|
| 389 |
</button>
|
| 390 |
+
<button type="button" id="ptype-music" class="prompt-publish__type">
|
| 391 |
🎵 ${t('prompt.tab_music')}
|
| 392 |
</button>
|
| 393 |
</div>
|
| 394 |
</div>
|
| 395 |
|
| 396 |
<!-- 分类选择 -->
|
| 397 |
+
<div class="prompt-publish__field">
|
| 398 |
+
<label for="category-select" class="prompt-publish__label">🏷️ ${t('prompt.category_label')} <span class="prompt-publish__required">*</span></label>
|
| 399 |
+
<select id="category-select" class="ranking-publish-input prompt-publish__select">
|
| 400 |
<option value="">${t('prompt.category_select')}</option>
|
| 401 |
</select>
|
| 402 |
</div>
|
| 403 |
|
| 404 |
<!-- 🏷️ 标签(自定义 + 推荐快加 + 搜索过滤) -->
|
| 405 |
+
<div class="prompt-publish__field">
|
| 406 |
+
<label for="tag-input" class="prompt-publish__label">🔖 ${t('prompt.tag_label')}</label>
|
| 407 |
+
<div id="tags-selected" class="prompt-publish__tags"></div>
|
| 408 |
+
<input type="text" id="tag-input" class="ranking-publish-input" placeholder="${t('prompt.tag_placeholder')}" maxlength="12">
|
| 409 |
+
<div class="prompt-publish__hint">${t('prompt.tag_recommend')}</div>
|
| 410 |
+
<div id="tag-recommend-list" class="prompt-publish__recommendations"></div>
|
| 411 |
</div>
|
| 412 |
|
| 413 |
<!-- 标题输入 -->
|
| 414 |
+
<div class="prompt-publish__field">
|
| 415 |
+
<label for="title-input" class="prompt-publish__label">📝 ${t('prompt.title_label')} <span class="prompt-publish__required">*</span></label>
|
| 416 |
+
<input type="text" id="title-input" class="ranking-publish-input" value="${escapeAttr(editTitle)}" placeholder="${t('prompt.title_placeholder')}" maxlength="50">
|
| 417 |
+
<div class="prompt-publish__count">
|
| 418 |
<span id="title-count">${editTitle.length}</span>/50
|
| 419 |
</div>
|
| 420 |
</div>
|
| 421 |
|
| 422 |
<!-- 文案/简介输入 -->
|
| 423 |
+
<div class="prompt-publish__field">
|
| 424 |
+
<label for="content-input" class="prompt-publish__label">✍️ ${t('prompt.content_label')} <span class="prompt-publish__required">*</span></label>
|
| 425 |
+
<textarea id="content-input" class="ranking-publish-input prompt-publish__description" placeholder="${t('prompt.content_placeholder')}" maxlength="2000">${escapeHtml(editContent)}</textarea>
|
| 426 |
+
<div class="prompt-publish__count">
|
| 427 |
<span id="content-count">${editContent.length}</span>/2000
|
| 428 |
</div>
|
| 429 |
</div>
|
| 430 |
|
| 431 |
<!-- 提示词正文(付费内容) -->
|
| 432 |
+
<div class="prompt-publish__field">
|
| 433 |
+
<label for="text-input" class="prompt-publish__label">🔑 ${t('prompt.text_label')} <span class="prompt-publish__required">*</span></label>
|
| 434 |
+
<textarea id="text-input" class="ranking-publish-input prompt-publish__text" placeholder="${t('prompt.text_placeholder')}" maxlength="20000">${escapeHtml(editText)}</textarea>
|
| 435 |
+
<div class="prompt-publish__count">
|
| 436 |
<span id="text-count">${editText.length}</span>/20000
|
| 437 |
</div>
|
| 438 |
</div>
|
| 439 |
|
| 440 |
<!-- 价格设置 -->
|
| 441 |
+
<div class="prompt-publish__field">
|
| 442 |
+
<label for="price-input" class="prompt-publish__label">💎 ${t('prompt.price_label')}</label>
|
| 443 |
+
<input type="number" id="price-input" class="ranking-publish-input" value="${escapeHtml(String(editPrice))}" min="0" max="${PROMPT_LIMITS.MAX_PRICE}" step="1" aria-describedby="price-error">
|
| 444 |
+
<div id="price-error" role="alert" hidden class="prompt-publish__error"></div>
|
| 445 |
</div>
|
| 446 |
|
| 447 |
<!-- 原创作品勾选(参考讨论区) -->
|
| 448 |
+
<div class="prompt-publish__original">
|
| 449 |
+
<label class="prompt-publish__checkbox">
|
| 450 |
+
<input type="checkbox" id="is-original-checkbox" ${editIsOriginal ? 'checked' : ''} class="prompt-publish__check" />
|
| 451 |
+
<span class="prompt-publish__copy">🎨 ${t('publish.mark_as_original')}</span>
|
| 452 |
</label>
|
| 453 |
+
<div class="prompt-publish__checkbox-hint">
|
| 454 |
${t('publish.original_default_hint')}
|
| 455 |
</div>
|
| 456 |
</div>
|
| 457 |
|
| 458 |
<!-- 图片上传区(图像提示词专用:多图 + 拖拽排序,首张为封面) -->
|
| 459 |
+
<div id="image-upload-section" class="prompt-publish__field">
|
| 460 |
+
<label class="prompt-publish__label">🖼️ ${t('post.upload_images')} <span class="prompt-publish__required">*</span>
|
| 461 |
+
<span class="prompt-publish__optional">(${t('post.max_9_images')})</span>
|
| 462 |
</label>
|
| 463 |
<input type="file" id="images-input" accept="image/*" multiple style="display: none;">
|
| 464 |
+
<div id="images-preview" class="prompt-publish__images">
|
| 465 |
+
<div class="prompt-publish__empty">
|
| 466 |
+
<div class="prompt-publish__empty-icon">📷</div>
|
| 467 |
${t('post.click_upload')}
|
| 468 |
</div>
|
| 469 |
</div>
|
| 470 |
</div>
|
| 471 |
|
| 472 |
<!-- 视频上传区(视频提示词专用:截取当前帧或手动上传封面) -->
|
| 473 |
+
<div id="video-upload-section" style="display: none;" class="prompt-publish__media-section">
|
| 474 |
+
<label class="prompt-publish__label">🎬 ${t('prompt.video_label')} <span id="video-required-mark" class="prompt-publish__required">*</span><span id="video-optional-mark" style="display: none;" class="prompt-publish__optional">${t('prompt.optional_mark')}</span></label>
|
| 475 |
<input type="file" id="video-input" accept="video/mp4,video/webm,video/quicktime" style="display: none;">
|
| 476 |
+
<div id="video-preview-area" class="prompt-publish__media">
|
| 477 |
+
<div id="video-empty-state" class="prompt-publish__empty">
|
| 478 |
+
<div class="prompt-publish__empty-icon">🎬</div>
|
| 479 |
${t('post.click_upload_video')}
|
| 480 |
</div>
|
| 481 |
<div id="video-player-wrap" style="display: none;">
|
| 482 |
+
<video id="video-player" controls class="prompt-publish__video"></video>
|
| 483 |
+
<div class="prompt-publish__media-meta">
|
| 484 |
+
<div class="prompt-publish__meta-copy">
|
| 485 |
<div id="video-name"></div>
|
| 486 |
<div id="video-meta"></div>
|
| 487 |
</div>
|
| 488 |
+
<button type="button" aria-label="${t('common.delete')} ${t('prompt.video_label')}" id="btn-remove-video" class="prompt-publish__remove-media">×</button>
|
| 489 |
</div>
|
| 490 |
<!-- 封面区域(截取当前帧 / 手动上传) -->
|
| 491 |
+
<div id="video-cover-wrap" style="display: none;" class="prompt-publish__cover-section">
|
| 492 |
+
<div class="prompt-publish__cover-title">${t('post.video_cover')}</div>
|
| 493 |
<div class="ranking-publish-cover-controls">
|
| 494 |
+
<div class="prompt-publish__cover-frame">
|
| 495 |
+
<img id="cover-thumb" src="" style="display: none;" class="prompt-publish__cover-image">
|
| 496 |
+
<span id="cover-label" style="display: none;" class="prompt-publish__cover-badge">${t('post.cover')}</span>
|
| 497 |
</div>
|
| 498 |
+
<div class="prompt-publish__cover-actions">
|
| 499 |
<button type="button" id="btn-capture-frame" class="ranking-publish-secondary">
|
| 500 |
📸 ${t('post.capture_frame')}
|
| 501 |
</button>
|
|
|
|
| 511 |
</div>
|
| 512 |
|
| 513 |
<!-- 🎵 音频上传区(音乐提示词专用:图片+音频方案,视频可选) -->
|
| 514 |
+
<div id="audio-upload-section" style="display: none;" class="prompt-publish__media-section">
|
| 515 |
+
<label class="prompt-publish__label">🎵 ${t('prompt.audio_label')} <span class="prompt-publish__required">*</span></label>
|
| 516 |
+
<div class="prompt-publish__media-hint">${t('prompt.music_media_hint')}</div>
|
| 517 |
<input type="file" id="audio-input" accept="audio/mpeg,audio/wav,audio/x-wav,audio/mp4,audio/x-m4a,audio/ogg,.mp3,.wav,.m4a,.ogg" style="display: none;">
|
| 518 |
+
<div id="audio-preview-area" class="prompt-publish__media">
|
| 519 |
+
<div id="audio-empty-state" class="prompt-publish__empty">
|
| 520 |
+
<div class="prompt-publish__empty-icon">🎵</div>
|
| 521 |
${t('post.click_upload_audio')}
|
| 522 |
</div>
|
| 523 |
<div id="audio-player-wrap" style="display: none;">
|
| 524 |
+
<audio id="audio-player" controls class="prompt-publish__audio"></audio>
|
| 525 |
+
<div class="prompt-publish__media-meta">
|
| 526 |
+
<div class="prompt-publish__meta-copy">
|
| 527 |
<div id="audio-name"></div>
|
| 528 |
<div id="audio-meta"></div>
|
| 529 |
</div>
|
| 530 |
+
<button type="button" aria-label="${t('common.delete')} ${t('prompt.audio_label')}" id="btn-remove-audio" class="prompt-publish__remove-media">×</button>
|
| 531 |
</div>
|
| 532 |
</div>
|
| 533 |
</div>
|
| 534 |
</div>
|
| 535 |
|
| 536 |
<!-- 提示信息 -->
|
| 537 |
+
<div class="prompt-publish__notice">
|
| 538 |
💡 <strong>${t('prompt.notice_title')}:</strong><br>
|
| 539 |
• ${t('prompt.notice_1')}<br>
|
| 540 |
• ${t('prompt.notice_2')}<br>
|
|
|
|
| 542 |
</div>
|
| 543 |
|
| 544 |
<!-- 确认发布按钮 -->
|
| 545 |
+
<button type="button" id="btn-submit-prompt" class="prompt-publish__submit">
|
| 546 |
${isEditMode ? '💾' : '🚀'} ${isEditMode ? t('prompt.save_submit') : t('prompt.publish_submit')}
|
| 547 |
</button>
|
| 548 |
</div>
|
|
|
|
| 630 |
const submitBtn = container.querySelector("#btn-submit-prompt");
|
| 631 |
const account = currentUser?.account;
|
| 632 |
const scope = createPublishSubmissionScope(container, submitBtn, account);
|
| 633 |
+
const refreshImageShortcuts = () => {
|
| 634 |
+
if (scope.isCurrent()) updateCoverMark(imagesPreview);
|
| 635 |
+
};
|
| 636 |
+
window.addEventListener('comfy-settings-changed', refreshImageShortcuts);
|
| 637 |
+
window.addEventListener('storage', refreshImageShortcuts);
|
| 638 |
+
scope.addCleanup(() => {
|
| 639 |
+
window.removeEventListener('comfy-settings-changed', refreshImageShortcuts);
|
| 640 |
+
window.removeEventListener('storage', refreshImageShortcuts);
|
| 641 |
+
});
|
| 642 |
const editingLock = createPublishEditingLock(container, [submitBtn, container.querySelector("#btn-back-publish-prompt")]);
|
| 643 |
scope.addCleanup(() => editingLock.dispose());
|
| 644 |
const previewReaders = new Set(), temporaryVideos = new Map();
|
|
|
|
| 746 |
// ========== 类型切换 ==========
|
| 747 |
function updateTypeUI() {
|
| 748 |
for (const [button, type] of [[ptypeImageBtn, 'image'], [ptypeVideoBtn, 'video'], [ptypeMusicBtn, 'music']]) button.setAttribute('aria-pressed', String(promptType === type));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 749 |
// 图像区:图像提示词 + 音乐提示词(图片+音频方案);视频区:视频/音乐;音频区:仅音乐
|
| 750 |
imageSection.style.display = promptType === 'video' ? 'none' : 'block';
|
| 751 |
videoSection.style.display = promptType === 'image' ? 'none' : 'block';
|
|
|
|
| 863 |
tagsSelectedEl.innerHTML = "";
|
| 864 |
selectedTags.forEach(tag => {
|
| 865 |
const chip = document.createElement("span");
|
| 866 |
+
chip.className = "prompt-publish__tag";
|
| 867 |
chip.textContent = "#" + tag;
|
| 868 |
const rm = document.createElement("button");
|
| 869 |
rm.textContent = "×";
|
| 870 |
+
rm.className = "prompt-publish__remove-tag";
|
| 871 |
+
rm.type = "button";
|
| 872 |
+
rm.setAttribute("aria-label", `${t("common.delete")} ${tag}`);
|
| 873 |
rm.onclick = () => {
|
| 874 |
if (!scope.isCurrent()) return; selectedTags = selectedTags.filter(x => x !== tag); renderSelectedTags(); renderRecommendTags(); };
|
| 875 |
chip.appendChild(rm);
|
|
|
|
| 886 |
.slice(0, 20);
|
| 887 |
tagRecommendList.innerHTML = "";
|
| 888 |
if (!list.length) {
|
| 889 |
+
tagRecommendList.innerHTML = `<span class="prompt-publish__muted">${t('prompt.tag_no_recommend')}</span>`;
|
| 890 |
return;
|
| 891 |
}
|
| 892 |
list.forEach(name => {
|
| 893 |
const chip = document.createElement("button");
|
| 894 |
+
chip.className = "prompt-publish__tag";
|
| 895 |
+
chip.type = "button";
|
| 896 |
chip.textContent = "+ " + name;
|
| 897 |
chip.onclick = () => addTag(name);
|
| 898 |
tagRecommendList.appendChild(chip);
|
|
|
|
| 912 |
}
|
| 913 |
|
| 914 |
tagInput.onkeydown = (e) => {
|
| 915 |
+
if (!scope.isCurrent() || e.defaultPrevented || e.key !== "Enter"
|
| 916 |
+
|| e.shiftKey || e.ctrlKey || e.altKey || e.metaKey
|
| 917 |
+
|| e.isComposing || e.keyCode === 229 || e.repeat
|
| 918 |
+
|| tagInput.disabled || tagInput.readOnly) return;
|
| 919 |
+
e.preventDefault(); addTag(tagInput.value);
|
| 920 |
};
|
| 921 |
tagInput.oninput = () => { if (scope.isCurrent()) renderRecommendTags(); };
|
| 922 |
|
|
|
|
| 999 |
|
| 1000 |
if (totalImages === 0) {
|
| 1001 |
imagesPreview.innerHTML = `
|
| 1002 |
+
<div class="prompt-publish__empty">
|
| 1003 |
+
<div class="prompt-publish__empty-icon">📷</div>
|
| 1004 |
${t('post.click_upload')}
|
| 1005 |
</div>
|
| 1006 |
`;
|
|
|
|
| 1026 |
|
| 1027 |
existingImageUrls.forEach((url, idx) => {
|
| 1028 |
const wrapper = document.createElement("div");
|
| 1029 |
+
wrapper.className = "prompt-publish__image";
|
| 1030 |
|
| 1031 |
wrapper.innerHTML = `
|
| 1032 |
+
<img src="${escapeAttr(url)}" class="prompt-publish__thumbnail">
|
| 1033 |
+
${idx === 0 ? `<span class="prompt-publish__cover-badge cover-label">${t('post.cover')}</span>` : ''}
|
| 1034 |
+
<button type="button" data-action="remove" data-existing-idx="${idx}" class="prompt-publish__remove-image">×</button>
|
| 1035 |
`;
|
| 1036 |
|
| 1037 |
wrapper._imageType = 'existing';
|
|
|
|
| 1055 |
previewReaders.add(reader);
|
| 1056 |
reader.onabort = () => finishPreviewReader(reader);
|
| 1057 |
const wrapper = document.createElement("div");
|
| 1058 |
+
wrapper.className = "prompt-publish__image";
|
| 1059 |
wrapper.innerHTML = `
|
| 1060 |
+
<img src="" class="prompt-publish__thumbnail">
|
| 1061 |
+
${existingImageUrls.length === 0 && idx === 0 ? `<span class="prompt-publish__cover-badge cover-label">${t('post.cover')}</span>` : ''}
|
| 1062 |
+
<button type="button" data-action="remove" data-idx="${idx}" class="prompt-publish__remove-image">×</button>
|
| 1063 |
`;
|
| 1064 |
|
| 1065 |
wrapper.querySelector('img').removeAttribute('src');
|
|
|
|
| 1106 |
const generation = beginImagePreviews();
|
| 1107 |
if (imageFiles.length === 0) {
|
| 1108 |
imagesPreview.innerHTML = `
|
| 1109 |
+
<div class="prompt-publish__empty">
|
| 1110 |
+
<div class="prompt-publish__empty-icon">📷</div>
|
| 1111 |
${t('post.click_upload')}
|
| 1112 |
</div>
|
| 1113 |
`;
|
|
|
|
| 1123 |
previewReaders.add(reader);
|
| 1124 |
reader.onabort = () => finishPreviewReader(reader);
|
| 1125 |
const wrapper = document.createElement("div");
|
| 1126 |
+
wrapper.className = "prompt-publish__image";
|
| 1127 |
|
| 1128 |
wrapper.innerHTML = `
|
| 1129 |
+
<img src="" class="prompt-publish__thumbnail">
|
| 1130 |
+
${idx === 0 ? `<span class="prompt-publish__cover-badge cover-label">${t('post.cover')}</span>` : ''}
|
| 1131 |
+
<button type="button" data-action="remove" data-idx="${idx}" class="prompt-publish__remove-image">×</button>
|
| 1132 |
`;
|
| 1133 |
|
| 1134 |
wrapper.querySelector('img').removeAttribute('src');
|
前端页面/prompt/提示词组件.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
| 15 |
// ==========================================
|
| 16 |
|
| 17 |
import { createPromptCategorySync, isPromptCategoryList } from "./提示词分类同步.js";
|
|
|
|
| 18 |
import { createCardNavigationScope } from "../components/卡片导航生命周期.js";
|
| 19 |
import { api, proxyImages, captureRequestSession } from "../core/网络请求API.js";
|
| 20 |
import { showToast } from "../components/UI交互提示组件.js";
|
|
@@ -31,6 +32,20 @@ const CACHE_KEY_PREFIX = "PromptsCache";
|
|
| 31 |
const CATEGORIES_CACHE_PREFIX = "PromptsCategoriesCache"; // 📴 分类缓存键前缀(离线容灾)
|
| 32 |
const PAGE_SIZE = 20;
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
// 缓存当前用户
|
| 35 |
let currentUserCache = null;
|
| 36 |
|
|
@@ -84,10 +99,10 @@ function _catLabel(name) {
|
|
| 84 |
*/
|
| 85 |
function _renderEmptyState() {
|
| 86 |
return `
|
| 87 |
-
<div
|
| 88 |
-
<div
|
| 89 |
-
<div
|
| 90 |
-
<div
|
| 91 |
</div>
|
| 92 |
`;
|
| 93 |
}
|
|
@@ -101,7 +116,7 @@ function _generateAvatarHtml(avatar, name) {
|
|
| 101 |
if (avatar) {
|
| 102 |
return `<img class="swr-avatar prompt-card__avatar" alt="" src="${escapeHtml(avatar)}">`;
|
| 103 |
}
|
| 104 |
-
return `<div class="swr-avatar prompt-card__avatar prompt-card__avatar--initial">${escapeHtml(initial)}</div>`;
|
| 105 |
}
|
| 106 |
|
| 107 |
/**
|
|
@@ -115,18 +130,6 @@ export function createPromptsView(currentUser, keyword = "") {
|
|
| 115 |
|
| 116 |
const container = document.createElement("div");
|
| 117 |
container.className = "prompt-board";
|
| 118 |
-
Object.assign(container.style, {
|
| 119 |
-
display: "flex",
|
| 120 |
-
flexDirection: "column",
|
| 121 |
-
gap: "0",
|
| 122 |
-
color: "var(--rk-text, var(--fg-color, #ddd))",
|
| 123 |
-
fontSize: "14px",
|
| 124 |
-
padding: "0",
|
| 125 |
-
overflowY: "auto",
|
| 126 |
-
flex: "1",
|
| 127 |
-
boxSizing: "border-box"
|
| 128 |
-
});
|
| 129 |
-
|
| 130 |
container.innerHTML = `
|
| 131 |
<!-- 🎨 模块切换(图像/视频/音乐) -->
|
| 132 |
<div id="prompt-type-tabs" class="prompt-board__types" role="group" aria-label="${escapeHtml(t('prompt.type_label'))}">
|
|
@@ -143,19 +146,19 @@ export function createPromptsView(currentUser, keyword = "") {
|
|
| 143 |
|
| 144 |
<!-- 🏷️ 分类选择条(换行自适应侧边栏宽度) -->
|
| 145 |
<div id="prompt-category-bar" class="prompt-board__categories" role="group" aria-label="${escapeHtml(t('prompt.category_label'))}">
|
| 146 |
-
<div
|
| 147 |
</div>
|
| 148 |
|
| 149 |
<!-- 🎯 瀑布流容器 -->
|
| 150 |
-
<div id="prompts-grid"
|
| 151 |
-
<div id="prompts-loading"
|
| 152 |
-
<div
|
| 153 |
${t('prompt.loading')}
|
| 154 |
</div>
|
| 155 |
</div>
|
| 156 |
|
| 157 |
<!-- 加载更多 -->
|
| 158 |
-
<div id="load-more-wrapper" style="display: none;
|
| 159 |
<button type="button" id="btn-load-more" class="prompt-board__more">
|
| 160 |
${t('post.load_more')}
|
| 161 |
</button>
|
|
@@ -384,7 +387,7 @@ export function createPromptsView(currentUser, keyword = "") {
|
|
| 384 |
// 🚀 本地��序优先:已有数据时直接本地排序渲染
|
| 385 |
if (allPromptsData.length > 0) {
|
| 386 |
const sorted = sortPromptsLocally(allPromptsData, currentSort);
|
| 387 |
-
renderPromptsFromCache(sorted);
|
| 388 |
// 后台静默刷新最新数据
|
| 389 |
silentRefresh();
|
| 390 |
} else {
|
|
@@ -407,7 +410,7 @@ export function createPromptsView(currentUser, keyword = "") {
|
|
| 407 |
listViewGeneration++;
|
| 408 |
if (allPromptsData.length > 0) {
|
| 409 |
const sorted = sortPromptsLocally(allPromptsData, currentSort);
|
| 410 |
-
renderPromptsFromCache(sorted);
|
| 411 |
}
|
| 412 |
silentRefresh();
|
| 413 |
};
|
|
@@ -415,28 +418,20 @@ export function createPromptsView(currentUser, keyword = "") {
|
|
| 415 |
|
| 416 |
// 显示骨架屏
|
| 417 |
const showSkeleton = () => {
|
| 418 |
-
promptsGrid.innerHTML = "";
|
| 419 |
for (let i = 0; i < 4; i++) {
|
| 420 |
const skeletonCard = document.createElement("div");
|
| 421 |
-
skeletonCard.
|
|
|
|
| 422 |
skeletonCard.innerHTML = `
|
| 423 |
-
<div
|
| 424 |
-
<div
|
| 425 |
-
<div
|
| 426 |
-
<div
|
| 427 |
</div>
|
| 428 |
`;
|
| 429 |
promptsGrid.appendChild(skeletonCard);
|
| 430 |
}
|
| 431 |
-
if (!document.getElementById("prompts-skeleton-style")) {
|
| 432 |
-
const style = document.createElement("style");
|
| 433 |
-
style.id = "prompts-skeleton-style";
|
| 434 |
-
style.textContent = `
|
| 435 |
-
@keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }
|
| 436 |
-
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } }
|
| 437 |
-
`;
|
| 438 |
-
document.head.appendChild(style);
|
| 439 |
-
}
|
| 440 |
};
|
| 441 |
|
| 442 |
const loadPrompts = async (page = 1, append = false) => {
|
|
@@ -446,11 +441,11 @@ export function createPromptsView(currentUser, keyword = "") {
|
|
| 446 |
const requestedType = currentType;
|
| 447 |
const requestedCategory = currentCategory;
|
| 448 |
const requestedSort = currentSort;
|
| 449 |
-
const cacheKey =
|
| 450 |
|
| 451 |
// ✅ 优先从本地缓存读取(含过期缓存:离线容灾,与插件榜/工作流/推荐榜策略一致)
|
| 452 |
if (!append && page === 1) {
|
| 453 |
-
const cachedData =
|
| 454 |
if (cachedData) {
|
| 455 |
allPromptsData = proxyImages(cachedData);
|
| 456 |
renderPromptsFromCache(allPromptsData, true);
|
|
@@ -557,7 +552,7 @@ export function createPromptsView(currentUser, keyword = "") {
|
|
| 557 |
console.error("加载提示词失败");
|
| 558 |
// 网络失败时尝试从缓存读取(包括过期缓存,离线容灾)
|
| 559 |
if (!append) {
|
| 560 |
-
const cachedData =
|
| 561 |
if (cachedData) {
|
| 562 |
allPromptsData = proxyImages(cachedData);
|
| 563 |
renderPromptsFromCache(allPromptsData);
|
|
@@ -572,6 +567,7 @@ export function createPromptsView(currentUser, keyword = "") {
|
|
| 572 |
// 从缓存渲染提示词列表
|
| 573 |
const renderPromptsFromCache = (prompts, deferSearchScan = false) => {
|
| 574 |
firstPageReady = true;
|
|
|
|
| 575 |
promptsGrid.innerHTML = "";
|
| 576 |
|
| 577 |
// 🔍 搜索过滤
|
|
@@ -587,6 +583,7 @@ export function createPromptsView(currentUser, keyword = "") {
|
|
| 587 |
promptsGrid.innerHTML = _renderEmptyState();
|
| 588 |
loadMoreWrapper.style.display = "none";
|
| 589 |
}
|
|
|
|
| 590 |
return;
|
| 591 |
}
|
| 592 |
|
|
@@ -614,6 +611,7 @@ export function createPromptsView(currentUser, keyword = "") {
|
|
| 614 |
} else {
|
| 615 |
loadMoreWrapper.style.display = "none";
|
| 616 |
}
|
|
|
|
| 617 |
};
|
| 618 |
|
| 619 |
// 后台静默更新
|
|
@@ -623,7 +621,7 @@ export function createPromptsView(currentUser, keyword = "") {
|
|
| 623 |
const requestedType = currentType;
|
| 624 |
const requestedCategory = currentCategory;
|
| 625 |
const requestedSort = currentSort;
|
| 626 |
-
const cacheKey =
|
| 627 |
const requestGeneration = ++networkRequestGeneration;
|
| 628 |
try {
|
| 629 |
isLoadingFromNetwork = true;
|
|
@@ -677,10 +675,23 @@ export function createPromptsView(currentUser, keyword = "") {
|
|
| 677 |
const _promptsDataChanged = (oldData, newData) => {
|
| 678 |
if (!oldData || !newData) return true;
|
| 679 |
if (oldData.length !== newData.length) return true;
|
| 680 |
-
const checkCount =
|
| 681 |
const COMPARE_FIELDS = ['likes', 'favorites', 'views', 'daily_views', 'comments'];
|
|
|
|
|
|
|
|
|
|
|
|
|
| 682 |
for (let i = 0; i < checkCount; i++) {
|
| 683 |
if ((oldData[i].id) !== (newData[i].id)) return true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 684 |
for (const f of COMPARE_FIELDS) {
|
| 685 |
if ((oldData[i][f] || 0) !== (newData[i][f] || 0)) return true;
|
| 686 |
}
|
|
@@ -741,16 +752,22 @@ export function createPromptsView(currentUser, keyword = "") {
|
|
| 741 |
function createPromptCard(prompt, currentUser = currentUserCache, isCurrent = () => true, captureNavigation = () => isCurrent) {
|
| 742 |
const card = document.createElement("div");
|
| 743 |
card.className = "business-list-card prompt-card";
|
|
|
|
|
|
|
|
|
|
| 744 |
|
| 745 |
// 格式化时间
|
| 746 |
const timeStr = formatTime(prompt.created_at);
|
| 747 |
|
| 748 |
// 判断是否为视频封面
|
| 749 |
const isVideo = prompt.media_type === "video";
|
|
|
|
|
|
|
| 750 |
|
| 751 |
// 价格徽标
|
| 752 |
-
const
|
| 753 |
-
|
|
|
|
| 754 |
: `<span class="prompt-card__badge prompt-card__badge--free">${t('prompt.free')}</span>`;
|
| 755 |
|
| 756 |
card.innerHTML = `
|
|
@@ -768,7 +785,7 @@ function createPromptCard(prompt, currentUser = currentUserCache, isCurrent = ()
|
|
| 768 |
${priceHtml}
|
| 769 |
</div>
|
| 770 |
<!-- 🔒 付费锁标识 -->
|
| 771 |
-
${
|
| 772 |
<div class="prompt-card__lock">🔒</div>
|
| 773 |
` : ''}
|
| 774 |
</div>
|
|
@@ -787,7 +804,7 @@ function createPromptCard(prompt, currentUser = currentUserCache, isCurrent = ()
|
|
| 787 |
</div>
|
| 788 |
|
| 789 |
<!-- 作者信息(SWR 缓存头像) -->
|
| 790 |
-
<div class="business-list-card__author prompt-card__author"></div>
|
| 791 |
|
| 792 |
<!-- 互动数据 -->
|
| 793 |
<div class="prompt-card__stats">
|
|
@@ -806,9 +823,9 @@ function createPromptCard(prompt, currentUser = currentUserCache, isCurrent = ()
|
|
| 806 |
if (!authorContainer) return;
|
| 807 |
|
| 808 |
const account = prompt.author;
|
| 809 |
-
const cached =
|
| 810 |
-
const avatar = cached?.avatar || '';
|
| 811 |
-
const name =
|
| 812 |
|
| 813 |
// 渲染初始头像
|
| 814 |
const avatarHtml = _generateAvatarHtml(avatar, name);
|
|
@@ -816,21 +833,23 @@ function createPromptCard(prompt, currentUser = currentUserCache, isCurrent = ()
|
|
| 816 |
authorContainer.innerHTML = `${avatarHtml}<span class="swr-name prompt-card__name">${escapeHtml(name)}</span>`;
|
| 817 |
|
| 818 |
// 后台静默校对
|
| 819 |
-
|
| 820 |
if (!isCurrent() || !card.isConnected) return;
|
| 821 |
const avatarEl = authorContainer.querySelector('.swr-avatar');
|
| 822 |
const nameEl = authorContainer.querySelector('.swr-name');
|
| 823 |
-
if (avatarEl && profile.avatar) {
|
| 824 |
if (avatarEl.tagName === 'IMG') {
|
| 825 |
-
avatarEl.src = profile.avatar;
|
| 826 |
} else {
|
| 827 |
-
avatarEl.outerHTML = _generateAvatarHtml(profile.avatar, profile.name
|
| 828 |
}
|
| 829 |
}
|
| 830 |
-
if (nameEl && profile.name) {
|
| 831 |
nameEl.textContent = profile.name;
|
| 832 |
}
|
| 833 |
-
}
|
|
|
|
|
|
|
| 834 |
}, 0);
|
| 835 |
|
| 836 |
const cover = card.querySelector('img');
|
|
|
|
| 15 |
// ==========================================
|
| 16 |
|
| 17 |
import { createPromptCategorySync, isPromptCategoryList } from "./提示词分类同步.js";
|
| 18 |
+
import { getPromptPriceState } from "./价格校验.js";
|
| 19 |
import { createCardNavigationScope } from "../components/卡片导航生命周期.js";
|
| 20 |
import { api, proxyImages, captureRequestSession } from "../core/网络请求API.js";
|
| 21 |
import { showToast } from "../components/UI交互提示组件.js";
|
|
|
|
| 32 |
const CATEGORIES_CACHE_PREFIX = "PromptsCategoriesCache"; // 📴 分类缓存键前缀(离线容灾)
|
| 33 |
const PAGE_SIZE = 20;
|
| 34 |
|
| 35 |
+
function promptListCacheKey(type, category, sort) {
|
| 36 |
+
const filter = category ? `category_${JSON.stringify([category, sort])}` : `all_${sort}`;
|
| 37 |
+
return `${CACHE_KEY_PREFIX}_${type}_${filter}`;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
function readPromptListCache(type, category, sort) {
|
| 41 |
+
const cached = readListCache(promptListCacheKey(type, category, sort));
|
| 42 |
+
if (cached || !category) return cached;
|
| 43 |
+
const legacy = readListCache(`${CACHE_KEY_PREFIX}_${type}_${category}_${sort}`);
|
| 44 |
+
if (!legacy || legacy.some(item => item.category !== category ||
|
| 45 |
+
(item.prompt_type != null && item.prompt_type !== type))) return null;
|
| 46 |
+
return legacy;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
// 缓存当前用户
|
| 50 |
let currentUserCache = null;
|
| 51 |
|
|
|
|
| 99 |
*/
|
| 100 |
function _renderEmptyState() {
|
| 101 |
return `
|
| 102 |
+
<div class="prompt-board__empty" role="status">
|
| 103 |
+
<div class="prompt-board__empty-icon" aria-hidden="true">🧩</div>
|
| 104 |
+
<div class="prompt-board__empty-title">${t('prompt.no_prompts')}</div>
|
| 105 |
+
<div class="prompt-board__empty-hint">${t('prompt.be_first')}</div>
|
| 106 |
</div>
|
| 107 |
`;
|
| 108 |
}
|
|
|
|
| 116 |
if (avatar) {
|
| 117 |
return `<img class="swr-avatar prompt-card__avatar" alt="" src="${escapeHtml(avatar)}">`;
|
| 118 |
}
|
| 119 |
+
return `<div class="swr-avatar prompt-card__avatar prompt-card__avatar--initial" aria-hidden="true">${escapeHtml(initial)}</div>`;
|
| 120 |
}
|
| 121 |
|
| 122 |
/**
|
|
|
|
| 130 |
|
| 131 |
const container = document.createElement("div");
|
| 132 |
container.className = "prompt-board";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
container.innerHTML = `
|
| 134 |
<!-- 🎨 模块切换(图像/视频/音乐) -->
|
| 135 |
<div id="prompt-type-tabs" class="prompt-board__types" role="group" aria-label="${escapeHtml(t('prompt.type_label'))}">
|
|
|
|
| 146 |
|
| 147 |
<!-- 🏷️ 分类选择条(换行自适应侧边栏宽度) -->
|
| 148 |
<div id="prompt-category-bar" class="prompt-board__categories" role="group" aria-label="${escapeHtml(t('prompt.category_label'))}">
|
| 149 |
+
<div class="prompt-board__category-loading" aria-hidden="true">⏳</div>
|
| 150 |
</div>
|
| 151 |
|
| 152 |
<!-- 🎯 瀑布流容器 -->
|
| 153 |
+
<div id="prompts-grid" class="prompt-board__grid">
|
| 154 |
+
<div id="prompts-loading" class="prompt-board__loading" role="status">
|
| 155 |
+
<div class="prompt-board__loading-icon" aria-hidden="true">⏳</div>
|
| 156 |
${t('prompt.loading')}
|
| 157 |
</div>
|
| 158 |
</div>
|
| 159 |
|
| 160 |
<!-- 加载更多 -->
|
| 161 |
+
<div id="load-more-wrapper" class="prompt-board__pagination" style="display: none;">
|
| 162 |
<button type="button" id="btn-load-more" class="prompt-board__more">
|
| 163 |
${t('post.load_more')}
|
| 164 |
</button>
|
|
|
|
| 387 |
// 🚀 本地��序优先:已有数据时直接本地排序渲染
|
| 388 |
if (allPromptsData.length > 0) {
|
| 389 |
const sorted = sortPromptsLocally(allPromptsData, currentSort);
|
| 390 |
+
renderPromptsFromCache(sorted, true);
|
| 391 |
// 后台静默刷新最新数据
|
| 392 |
silentRefresh();
|
| 393 |
} else {
|
|
|
|
| 410 |
listViewGeneration++;
|
| 411 |
if (allPromptsData.length > 0) {
|
| 412 |
const sorted = sortPromptsLocally(allPromptsData, currentSort);
|
| 413 |
+
renderPromptsFromCache(sorted, true);
|
| 414 |
}
|
| 415 |
silentRefresh();
|
| 416 |
};
|
|
|
|
| 418 |
|
| 419 |
// 显示骨架屏
|
| 420 |
const showSkeleton = () => {
|
| 421 |
+
promptsGrid.innerHTML = `<div class="prompt-board__loading prompt-board__loading--skeleton" role="status">${t('prompt.loading')}</div>`;
|
| 422 |
for (let i = 0; i < 4; i++) {
|
| 423 |
const skeletonCard = document.createElement("div");
|
| 424 |
+
skeletonCard.className = "prompt-board__skeleton";
|
| 425 |
+
skeletonCard.setAttribute("aria-hidden", "true");
|
| 426 |
skeletonCard.innerHTML = `
|
| 427 |
+
<div class="prompt-board__skeleton-cover"></div>
|
| 428 |
+
<div class="prompt-board__skeleton-body">
|
| 429 |
+
<div class="prompt-board__skeleton-line"></div>
|
| 430 |
+
<div class="prompt-board__skeleton-line prompt-board__skeleton-line--short"></div>
|
| 431 |
</div>
|
| 432 |
`;
|
| 433 |
promptsGrid.appendChild(skeletonCard);
|
| 434 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
};
|
| 436 |
|
| 437 |
const loadPrompts = async (page = 1, append = false) => {
|
|
|
|
| 441 |
const requestedType = currentType;
|
| 442 |
const requestedCategory = currentCategory;
|
| 443 |
const requestedSort = currentSort;
|
| 444 |
+
const cacheKey = promptListCacheKey(requestedType, requestedCategory, requestedSort);
|
| 445 |
|
| 446 |
// ✅ 优先从本地缓存读取(含过期缓存:离线容灾,与插件榜/工作流/推荐榜策略一致)
|
| 447 |
if (!append && page === 1) {
|
| 448 |
+
const cachedData = readPromptListCache(requestedType, requestedCategory, requestedSort);
|
| 449 |
if (cachedData) {
|
| 450 |
allPromptsData = proxyImages(cachedData);
|
| 451 |
renderPromptsFromCache(allPromptsData, true);
|
|
|
|
| 552 |
console.error("加载提示词失败");
|
| 553 |
// 网络失败时尝试从缓存读取(包括过期缓存,离线容灾)
|
| 554 |
if (!append) {
|
| 555 |
+
const cachedData = readPromptListCache(requestedType, requestedCategory, requestedSort);
|
| 556 |
if (cachedData) {
|
| 557 |
allPromptsData = proxyImages(cachedData);
|
| 558 |
renderPromptsFromCache(allPromptsData);
|
|
|
|
| 567 |
// 从缓存渲染提示词列表
|
| 568 |
const renderPromptsFromCache = (prompts, deferSearchScan = false) => {
|
| 569 |
firstPageReady = true;
|
| 570 |
+
const restoreFocus = cardNavigation.captureListFocus(promptsGrid);
|
| 571 |
promptsGrid.innerHTML = "";
|
| 572 |
|
| 573 |
// 🔍 搜索过滤
|
|
|
|
| 583 |
promptsGrid.innerHTML = _renderEmptyState();
|
| 584 |
loadMoreWrapper.style.display = "none";
|
| 585 |
}
|
| 586 |
+
restoreFocus();
|
| 587 |
return;
|
| 588 |
}
|
| 589 |
|
|
|
|
| 611 |
} else {
|
| 612 |
loadMoreWrapper.style.display = "none";
|
| 613 |
}
|
| 614 |
+
restoreFocus();
|
| 615 |
};
|
| 616 |
|
| 617 |
// 后台静默更新
|
|
|
|
| 621 |
const requestedType = currentType;
|
| 622 |
const requestedCategory = currentCategory;
|
| 623 |
const requestedSort = currentSort;
|
| 624 |
+
const cacheKey = promptListCacheKey(requestedType, requestedCategory, requestedSort);
|
| 625 |
const requestGeneration = ++networkRequestGeneration;
|
| 626 |
try {
|
| 627 |
isLoadingFromNetwork = true;
|
|
|
|
| 675 |
const _promptsDataChanged = (oldData, newData) => {
|
| 676 |
if (!oldData || !newData) return true;
|
| 677 |
if (oldData.length !== newData.length) return true;
|
| 678 |
+
const checkCount = oldData.length;
|
| 679 |
const COMPARE_FIELDS = ['likes', 'favorites', 'views', 'daily_views', 'comments'];
|
| 680 |
+
const contentFields = ["title", "content", "category", "prompt_type", "media_type", "price", "created_at", "author", "author_name"];
|
| 681 |
+
const mediaFields = ["cover_image", "author_avatar"];
|
| 682 |
+
const normalizedImage = value => typeof value === 'string'
|
| 683 |
+
? proxyImages({ cover_image: value }).cover_image : value;
|
| 684 |
for (let i = 0; i < checkCount; i++) {
|
| 685 |
if ((oldData[i].id) !== (newData[i].id)) return true;
|
| 686 |
+
for (const field of contentFields) {
|
| 687 |
+
if (!Object.is(oldData[i][field], newData[i][field])) return true;
|
| 688 |
+
}
|
| 689 |
+
for (const field of mediaFields) {
|
| 690 |
+
if (!Object.is(normalizedImage(oldData[i][field]), normalizedImage(newData[i][field]))) return true;
|
| 691 |
+
}
|
| 692 |
+
const oldTags = Array.isArray(oldData[i].tags) ? oldData[i].tags : [];
|
| 693 |
+
const newTags = Array.isArray(newData[i].tags) ? newData[i].tags : [];
|
| 694 |
+
if (oldTags.length !== newTags.length || oldTags.some((value, index) => !Object.is(value, newTags[index]))) return true;
|
| 695 |
for (const f of COMPARE_FIELDS) {
|
| 696 |
if ((oldData[i][f] || 0) !== (newData[i][f] || 0)) return true;
|
| 697 |
}
|
|
|
|
| 752 |
function createPromptCard(prompt, currentUser = currentUserCache, isCurrent = () => true, captureNavigation = () => isCurrent) {
|
| 753 |
const card = document.createElement("div");
|
| 754 |
card.className = "business-list-card prompt-card";
|
| 755 |
+
if (typeof prompt.id === 'string' || (typeof prompt.id === 'number' && Number.isFinite(prompt.id))) {
|
| 756 |
+
card._rankingListId = String(prompt.id);
|
| 757 |
+
}
|
| 758 |
|
| 759 |
// 格式化时间
|
| 760 |
const timeStr = formatTime(prompt.created_at);
|
| 761 |
|
| 762 |
// 判断是否为视频封面
|
| 763 |
const isVideo = prompt.media_type === "video";
|
| 764 |
+
const initialAuthor = isCurrent() && typeof prompt.author === 'string' ? getCachedProfile(prompt.author) : null;
|
| 765 |
+
const initialAuthorName = [initialAuthor?.name, prompt.author_name, prompt.author].find(value => typeof value === 'string' && value) || '';
|
| 766 |
|
| 767 |
// 价格徽标
|
| 768 |
+
const priceState = getPromptPriceState(prompt.price);
|
| 769 |
+
const priceHtml = priceState !== 'free'
|
| 770 |
+
? `<span class="prompt-card__badge prompt-card__badge--paid">💎 ${escapeHtml(priceState === 'paid' ? prompt.price : t('common.unknown'))}</span>`
|
| 771 |
: `<span class="prompt-card__badge prompt-card__badge--free">${t('prompt.free')}</span>`;
|
| 772 |
|
| 773 |
card.innerHTML = `
|
|
|
|
| 785 |
${priceHtml}
|
| 786 |
</div>
|
| 787 |
<!-- 🔒 付费锁标识 -->
|
| 788 |
+
${priceState !== 'free' ? `
|
| 789 |
<div class="prompt-card__lock">🔒</div>
|
| 790 |
` : ''}
|
| 791 |
</div>
|
|
|
|
| 804 |
</div>
|
| 805 |
|
| 806 |
<!-- 作者信息(SWR 缓存头像) -->
|
| 807 |
+
<div class="business-list-card__author prompt-card__author">${_generateAvatarHtml('', initialAuthorName)}<span class="swr-name prompt-card__name">${escapeHtml(initialAuthorName)}</span></div>
|
| 808 |
|
| 809 |
<!-- 互动数据 -->
|
| 810 |
<div class="prompt-card__stats">
|
|
|
|
| 823 |
if (!authorContainer) return;
|
| 824 |
|
| 825 |
const account = prompt.author;
|
| 826 |
+
const cached = initialAuthor;
|
| 827 |
+
const avatar = [cached?.avatar, prompt.author_avatar].find(value => typeof value === 'string' && value) || '';
|
| 828 |
+
const name = initialAuthorName;
|
| 829 |
|
| 830 |
// 渲染初始头像
|
| 831 |
const avatarHtml = _generateAvatarHtml(avatar, name);
|
|
|
|
| 833 |
authorContainer.innerHTML = `${avatarHtml}<span class="swr-name prompt-card__name">${escapeHtml(name)}</span>`;
|
| 834 |
|
| 835 |
// 后台静默校对
|
| 836 |
+
const updateAuthor = (profile) => {
|
| 837 |
if (!isCurrent() || !card.isConnected) return;
|
| 838 |
const avatarEl = authorContainer.querySelector('.swr-avatar');
|
| 839 |
const nameEl = authorContainer.querySelector('.swr-name');
|
| 840 |
+
if (avatarEl && typeof profile.avatar === 'string' && profile.avatar) {
|
| 841 |
if (avatarEl.tagName === 'IMG') {
|
| 842 |
+
if (avatarEl.getAttribute('src') !== profile.avatar) avatarEl.src = profile.avatar;
|
| 843 |
} else {
|
| 844 |
+
avatarEl.outerHTML = _generateAvatarHtml(profile.avatar, typeof profile.name === 'string' && profile.name ? profile.name : name);
|
| 845 |
}
|
| 846 |
}
|
| 847 |
+
if (nameEl && typeof profile.name === 'string' && profile.name && nameEl.textContent !== profile.name) {
|
| 848 |
nameEl.textContent = profile.name;
|
| 849 |
}
|
| 850 |
+
};
|
| 851 |
+
const latestCached = getProfileWithSWR(account, api.getUserProfile, updateAuthor);
|
| 852 |
+
if (latestCached) updateAuthor(latestCached);
|
| 853 |
}, 0);
|
| 854 |
|
| 855 |
const cover = card.querySelector('img');
|
前端页面/prompt/提示词详情组件.js
CHANGED
|
@@ -12,8 +12,9 @@
|
|
| 12 |
// ==========================================
|
| 13 |
|
| 14 |
import { api, proxyImages, captureRequestSession, requestCancelManager } from "../core/网络请求API.js";
|
|
|
|
| 15 |
import { showToast } from "../components/UI交互提示组件.js";
|
| 16 |
-
import { getCoverSandboxHTML, setupImageSandboxEvents } from "../components/图片沙盒组件.js";
|
| 17 |
import { getVideoPlayerHTML, setupVideoPlayerEvents, cleanupVideoPlayer } from "../components/视频播放器组件.js";
|
| 18 |
import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
|
| 19 |
import { t, tIfExists } from "../components/用户体验增强.js";
|
|
@@ -25,6 +26,8 @@ import { recordView, handleToggleLike, handleToggleFavorite, renderTipBoardHTML
|
|
| 25 |
import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
|
| 26 |
import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
|
| 27 |
|
|
|
|
|
|
|
| 28 |
const viewContexts = new WeakMap();
|
| 29 |
let promptViewSequence = 0;
|
| 30 |
const isCurrent = node => !!viewContexts.get(node)?.scope.isCurrent();
|
|
@@ -44,10 +47,9 @@ function _generateAvatarHtml(avatar, name, size, extraStyle = '') {
|
|
| 44 |
const initial = (name || 'U')[0].toUpperCase();
|
| 45 |
// 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
|
| 46 |
if (avatar) {
|
| 47 |
-
return `<img class="swr-avatar
|
| 48 |
}
|
| 49 |
-
|
| 50 |
-
return `<div class="swr-avatar" style="width: ${size}px; height: ${size}px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: ${fontSize}px; font-weight: bold;">${escapeHtml(initial)}</div>`;
|
| 51 |
}
|
| 52 |
|
| 53 |
/**
|
|
@@ -58,7 +60,7 @@ function _updateAvatarElement(avatarEl, profile, size, extraStyle = '') {
|
|
| 58 |
if (avatarEl.tagName === 'IMG') {
|
| 59 |
avatarEl.src = profile.avatar;
|
| 60 |
} else {
|
| 61 |
-
avatarEl.outerHTML = `<img class="swr-avatar
|
| 62 |
}
|
| 63 |
}
|
| 64 |
|
|
@@ -67,31 +69,21 @@ function _updateAvatarElement(avatarEl, profile, size, extraStyle = '') {
|
|
| 67 |
*/
|
| 68 |
export function createPromptDetailView(promptId, currentUser) {
|
| 69 |
const container = document.createElement("div");
|
| 70 |
-
|
| 71 |
-
display: "flex",
|
| 72 |
-
flexDirection: "column",
|
| 73 |
-
gap: "0",
|
| 74 |
-
color: "#ccc",
|
| 75 |
-
fontSize: "14px",
|
| 76 |
-
padding: "0",
|
| 77 |
-
overflowY: "auto",
|
| 78 |
-
flex: "1",
|
| 79 |
-
boxSizing: "border-box"
|
| 80 |
-
});
|
| 81 |
|
| 82 |
container.innerHTML = `
|
| 83 |
<!-- 顶部标题栏 -->
|
| 84 |
-
<div
|
| 85 |
-
<button id="btn-back-prompt-detail"
|
| 86 |
⬅ ${t('common.back')}
|
| 87 |
</button>
|
| 88 |
-
<span
|
| 89 |
</div>
|
| 90 |
|
| 91 |
<!-- 内容区域 -->
|
| 92 |
-
<div id="prompt-content"
|
| 93 |
-
<div
|
| 94 |
-
<div
|
| 95 |
${t('prompt.loading')}
|
| 96 |
</div>
|
| 97 |
</div>
|
|
@@ -115,6 +107,7 @@ export function createPromptDetailView(promptId, currentUser) {
|
|
| 115 |
viewContexts.set(container, context); viewContexts.set(contentArea, context);
|
| 116 |
const handleRouteBack = () => scope.dispose();
|
| 117 |
scope.addCleanup(() => {
|
|
|
|
| 118 |
cleanupVideoPlayer(contentArea);
|
| 119 |
contentArea.querySelectorAll('audio').forEach(audio => { audio.pause(); audio.removeAttribute('src'); audio.load(); });
|
| 120 |
contentArea.replaceChildren(); observer?.disconnect();
|
|
@@ -147,6 +140,7 @@ async function loadPromptDetail(container, promptId, currentUser) {
|
|
| 147 |
|
| 148 |
// 先清理旧的视频播放器全局事件(如果存在)
|
| 149 |
if (contentArea) {
|
|
|
|
| 150 |
cleanupVideoPlayer(contentArea);
|
| 151 |
}
|
| 152 |
|
|
@@ -171,8 +165,8 @@ async function loadPromptDetail(container, promptId, currentUser) {
|
|
| 171 |
|
| 172 |
if (!prompt) {
|
| 173 |
contentArea.innerHTML = `
|
| 174 |
-
<div
|
| 175 |
-
<div
|
| 176 |
${t('prompt.not_exist')}
|
| 177 |
</div>
|
| 178 |
`;
|
|
@@ -185,6 +179,7 @@ async function loadPromptDetail(container, promptId, currentUser) {
|
|
| 185 |
// 🔓 判断提示词是否可见(免费/作者/已购买 → prompt_text 已由服务端下发)
|
| 186 |
const isOwner = currentUser && currentUser.account === prompt.author;
|
| 187 |
const isUnlocked = !fromCache && typeof prompt.prompt_text === "string" && prompt.locked !== true;
|
|
|
|
| 188 |
|
| 189 |
// 判断是否为视频封面
|
| 190 |
const isVideo = prompt.media_type === "video" && prompt.video_url;
|
|
@@ -199,9 +194,9 @@ async function loadPromptDetail(container, promptId, currentUser) {
|
|
| 199 |
|
| 200 |
// 🎵 音乐提示词音频播放器(图片+音频方案,仅存在 audio_url 时渲染)
|
| 201 |
const audioHTML = prompt.audio_url ? `
|
| 202 |
-
<div
|
| 203 |
-
<div
|
| 204 |
-
<audio controls preload="metadata" src="${escapeHtml(prompt.audio_url)}"
|
| 205 |
</div>` : '';
|
| 206 |
|
| 207 |
// 检查当前用户是否已点赞/收藏
|
|
@@ -210,100 +205,100 @@ async function loadPromptDetail(container, promptId, currentUser) {
|
|
| 210 |
|
| 211 |
contentArea.innerHTML = `
|
| 212 |
<!-- 媒体展示区 -->
|
| 213 |
-
<div id="media-area"
|
| 214 |
${mediaHTML}
|
| 215 |
${audioHTML}
|
| 216 |
</div>
|
| 217 |
|
| 218 |
<!-- 标题 -->
|
| 219 |
-
<div
|
| 220 |
${escapeHtml(prompt.title)}
|
| 221 |
</div>
|
| 222 |
|
| 223 |
<!-- 原创标识 -->
|
| 224 |
${prompt.is_original ? `
|
| 225 |
-
<div
|
| 226 |
-
background: linear-gradient(135deg, #FF6B35, #FF8F00); border-radius: 12px;
|
| 227 |
-
font-size: 11px; color: #fff; font-weight: 500; margin-bottom: 12px;">
|
| 228 |
🎨 ${t('prompt.original_badge')}
|
| 229 |
</div>
|
| 230 |
` : ''}
|
| 231 |
|
| 232 |
<!-- 分类与价格标签 -->
|
| 233 |
-
<div
|
| 234 |
-
<span
|
| 235 |
-
${
|
| 236 |
-
? `<span
|
| 237 |
-
: `<span
|
| 238 |
-
${prompt.owned ? `<span
|
| 239 |
</div>
|
| 240 |
|
| 241 |
<!-- 🏷️ 标签展示 -->
|
| 242 |
${(Array.isArray(prompt.tags) && prompt.tags.length) ? `
|
| 243 |
-
<div
|
| 244 |
-
${prompt.tags.map(tag => `<span
|
| 245 |
</div>` : ''}
|
| 246 |
|
| 247 |
<!-- 作者信息(SWR 缓存头像) -->
|
| 248 |
-
<div id="author-row"
|
| 249 |
-
<
|
| 250 |
-
<
|
| 251 |
-
|
| 252 |
-
<
|
| 253 |
-
|
|
|
|
|
|
|
| 254 |
${isOwner ? `
|
| 255 |
-
<div
|
| 256 |
-
${IS_WEB_MODE ? '' : `<button id="btn-edit-prompt"
|
| 257 |
-
<button id="btn-delete-prompt"
|
| 258 |
</div>
|
| 259 |
` : ''}
|
| 260 |
</div>
|
| 261 |
|
| 262 |
<!-- 简介内容 -->
|
| 263 |
-
<div
|
| 264 |
|
| 265 |
<!-- 🔑 提示词正文区(付费遮罩/解锁/复制) -->
|
| 266 |
-
<div id="prompt-text-area"
|
| 267 |
|
| 268 |
<!-- 互动按钮栏 -->
|
| 269 |
-
<div id="interaction-bar"
|
| 270 |
-
<button id="btn-like"
|
| 271 |
-
❤️ <span id="like-count">${prompt.likes || 0}</span>
|
| 272 |
</button>
|
| 273 |
-
<button id="btn-favorite"
|
| 274 |
-
🔖 <span id="favorite-count">${prompt.favorites || 0}</span>
|
| 275 |
</button>
|
| 276 |
-
<button id="btn-tip"
|
| 277 |
${t('prompt.tip_author')}
|
| 278 |
</button>
|
| 279 |
<!-- 👀 浏览量统计(纯展示) -->
|
| 280 |
-
<div
|
| 281 |
-
<span
|
| 282 |
-
<span
|
| 283 |
</div>
|
| 284 |
</div>
|
| 285 |
|
| 286 |
<!-- 打赏榜单 -->
|
| 287 |
-
<div id="tip-board-area"
|
| 288 |
${renderCommonTipBoardHTML(prompt.tip_board || [], 5, t('prompt.no_tips'))}
|
| 289 |
</div>
|
| 290 |
|
| 291 |
<!-- 评论区 -->
|
| 292 |
-
<div
|
| 293 |
-
${t('prompt.comments_title')} <span
|
| 294 |
</div>
|
| 295 |
|
| 296 |
<!-- 评论输入框 -->
|
| 297 |
-
<div
|
| 298 |
-
<input type="text" id="comment-input"
|
| 299 |
-
<button id="btn-send-comment"
|
| 300 |
${t('common.send')}
|
| 301 |
</button>
|
| 302 |
</div>
|
| 303 |
|
| 304 |
<!-- 评论列表 -->
|
| 305 |
-
<div id="comments-list"
|
| 306 |
-
<div
|
| 307 |
</div>
|
| 308 |
`;
|
| 309 |
|
|
@@ -349,7 +344,7 @@ async function loadPromptDetail(container, promptId, currentUser) {
|
|
| 349 |
}
|
| 350 |
|
| 351 |
// 绑定作者点击
|
| 352 |
-
contentArea.querySelector("#author-
|
| 353 |
if (!isCurrent(contentArea)) return;
|
| 354 |
if (e.target.id === 'btn-edit-prompt' || e.target.id === 'btn-delete-prompt') return;
|
| 355 |
openOtherUserProfileModal(prompt.author, currentUser);
|
|
@@ -402,35 +397,36 @@ function renderPromptTextArea(contentArea, prompt, currentUser, isUnlocked) {
|
|
| 402 |
if (!isCurrent(contentArea)) return;
|
| 403 |
const textArea = contentArea.querySelector("#prompt-text-area");
|
| 404 |
if (!textArea) return;
|
|
|
|
| 405 |
|
| 406 |
if (isUnlocked && prompt.prompt_text) {
|
| 407 |
// ✅ 已解锁:展示正文 + 一键复制
|
| 408 |
textArea.innerHTML = `
|
| 409 |
-
<div
|
| 410 |
-
<div
|
| 411 |
-
<span
|
| 412 |
-
<button id="btn-copy-prompt"
|
| 413 |
📋 ${t('prompt.copy')}
|
| 414 |
</button>
|
| 415 |
</div>
|
| 416 |
-
<div
|
| 417 |
</div>
|
| 418 |
`;
|
| 419 |
textArea.querySelector("#btn-copy-prompt").onclick = () => {
|
| 420 |
if (isCurrent(contentArea)) copyToClipboard(prompt.prompt_text, textArea.querySelector("#btn-copy-prompt"), () => isCurrent(contentArea));
|
| 421 |
};
|
| 422 |
-
} else if (
|
| 423 |
// 🔒 付费遮罩:模糊预览 + 解锁按钮
|
| 424 |
textArea.innerHTML = `
|
| 425 |
-
<div
|
| 426 |
-
<div
|
| 427 |
${escapeHtml((prompt.content || '') + '\n' + (prompt.content || ''))}
|
| 428 |
</div>
|
| 429 |
-
<div
|
| 430 |
-
<div
|
| 431 |
-
<div
|
| 432 |
-
<div
|
| 433 |
-
<button id="btn-unlock-prompt"
|
| 434 |
💎 ${t('prompt.unlock', { price: prompt.price })}
|
| 435 |
</button>
|
| 436 |
</div>
|
|
@@ -439,6 +435,12 @@ function renderPromptTextArea(contentArea, prompt, currentUser, isUnlocked) {
|
|
| 439 |
textArea.querySelector("#btn-unlock-prompt").onclick = () => {
|
| 440 |
showPurchaseConfirmDialog(contentArea, prompt, currentUser);
|
| 441 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
}
|
| 443 |
}
|
| 444 |
|
|
@@ -526,18 +528,18 @@ async function showPurchaseConfirmDialog(contentArea, prompt, currentUser) {
|
|
| 526 |
}
|
| 527 |
|
| 528 |
const content = document.createElement("div");
|
| 529 |
-
content.
|
| 530 |
content.innerHTML = `
|
| 531 |
-
<div
|
| 532 |
-
<div
|
| 533 |
-
<div
|
| 534 |
-
<div
|
| 535 |
-
<div
|
| 536 |
-
${t('prompt.purchase_balance')}: <strong
|
| 537 |
</div>
|
| 538 |
-
<div
|
| 539 |
-
<button id="purchase-cancel"
|
| 540 |
-
<button id="purchase-confirm"
|
| 541 |
</div>
|
| 542 |
</div>
|
| 543 |
`;
|
|
@@ -644,19 +646,19 @@ async function showTipDialog(prompt, currentUser, container) {
|
|
| 644 |
}
|
| 645 |
|
| 646 |
const content = document.createElement("div");
|
| 647 |
-
content.
|
| 648 |
content.innerHTML = `
|
| 649 |
-
<div
|
| 650 |
-
${t('wallet.current_balance')}: <strong
|
| 651 |
</div>
|
| 652 |
-
<div
|
| 653 |
-
<button class="tip-amount" data-amount="10"
|
| 654 |
-
<button class="tip-amount" data-amount="50"
|
| 655 |
-
<button class="tip-amount" data-amount="100"
|
| 656 |
-
<button class="tip-amount" data-amount="500"
|
| 657 |
</div>
|
| 658 |
-
<div
|
| 659 |
-
<button id="tip-cancel"
|
| 660 |
</div>
|
| 661 |
`;
|
| 662 |
|
|
@@ -705,6 +707,13 @@ async function loadComments(container, promptId, currentUser) {
|
|
| 705 |
const sendBtn = container.querySelector("#btn-send-comment");
|
| 706 |
if (!isCurrent(container)) return;
|
| 707 |
const context = viewContexts.get(container);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 708 |
const generation = context.commentGeneration = (context.commentGeneration || 0) + 1;
|
| 709 |
const current = () => isCurrent(container) && context.commentGeneration === generation && commentsList.isConnected;
|
| 710 |
|
|
@@ -718,7 +727,7 @@ async function loadComments(container, promptId, currentUser) {
|
|
| 718 |
|
| 719 |
if (comments.length === 0) {
|
| 720 |
commentsList.innerHTML = `
|
| 721 |
-
<div
|
| 722 |
${t('prompt.no_comments')}
|
| 723 |
</div>
|
| 724 |
`;
|
|
@@ -732,13 +741,13 @@ async function loadComments(container, promptId, currentUser) {
|
|
| 732 |
const avatarHtml = _generateAvatarHtml(avatar, name, 24, 'background: var(--comfy-input-bg);');
|
| 733 |
|
| 734 |
return `
|
| 735 |
-
<div id="comment-${idx}" data-account="${escapeHtml(c.author)}"
|
| 736 |
-
<div
|
| 737 |
<div class="swr-avatar-container">${avatarHtml}</div>
|
| 738 |
-
<span class="swr-name
|
| 739 |
-
<span
|
| 740 |
</div>
|
| 741 |
-
<div
|
| 742 |
${escapeHtml(c.content)}
|
| 743 |
</div>
|
| 744 |
</div>
|
|
@@ -762,45 +771,71 @@ async function loadComments(container, promptId, currentUser) {
|
|
| 762 |
} catch (err) {
|
| 763 |
if (!current()) return;
|
| 764 |
commentsList.innerHTML = `
|
| 765 |
-
<div
|
| 766 |
${t('prompt.load_failed')}
|
| 767 |
</div>
|
| 768 |
`;
|
| 769 |
}
|
| 770 |
|
| 771 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 772 |
sendBtn.onclick = async () => {
|
| 773 |
-
if (!
|
| 774 |
-
if (!
|
| 775 |
showToast(t('auth.login_required'), "warning");
|
| 776 |
return;
|
| 777 |
}
|
| 778 |
-
const
|
| 779 |
if (!content) {
|
|
|
|
|
|
|
| 780 |
showToast(t('prompt.comment_required'), "warning");
|
| 781 |
return;
|
| 782 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 783 |
try {
|
| 784 |
-
sendBtn.disabled = true;
|
| 785 |
-
sendBtn.textContent = t('common.sending');
|
| 786 |
const result = await api.addPromptComment(promptId, content);
|
| 787 |
-
if (!
|
| 788 |
-
if (result
|
| 789 |
-
|
|
|
|
| 790 |
showToast(t('prompt.comment_success'), "success");
|
| 791 |
-
loadComments(container, promptId, currentUser);
|
|
|
|
| 792 |
} catch (err) {
|
| 793 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 794 |
} finally {
|
| 795 |
-
if (!
|
| 796 |
-
sendBtn.disabled =
|
| 797 |
-
|
|
|
|
|
|
|
| 798 |
}
|
| 799 |
};
|
| 800 |
|
| 801 |
// 回车发送
|
| 802 |
commentInput.onkeydown = (e) => {
|
| 803 |
-
if (
|
|
|
|
|
|
|
|
|
|
| 804 |
e.preventDefault();
|
| 805 |
sendBtn.click();
|
| 806 |
}
|
|
@@ -813,15 +848,15 @@ async function loadComments(container, promptId, currentUser) {
|
|
| 813 |
function showDeleteConfirmDialog(prompt, container) {
|
| 814 |
if (!isCurrent(container)) return;
|
| 815 |
const content = document.createElement("div");
|
| 816 |
-
content.
|
| 817 |
content.innerHTML = `
|
| 818 |
-
<div
|
| 819 |
-
<div
|
| 820 |
-
<div
|
| 821 |
-
<div
|
| 822 |
-
<div
|
| 823 |
-
<button id="delete-cancel"
|
| 824 |
-
<button id="delete-confirm"
|
| 825 |
</div>
|
| 826 |
</div>
|
| 827 |
`;
|
|
|
|
| 12 |
// ==========================================
|
| 13 |
|
| 14 |
import { api, proxyImages, captureRequestSession, requestCancelManager } from "../core/网络请求API.js";
|
| 15 |
+
import { getPromptPriceState } from "./价格校验.js";
|
| 16 |
import { showToast } from "../components/UI交互提示组件.js";
|
| 17 |
+
import { getCoverSandboxHTML, setupImageSandboxEvents, cleanupImageSandbox } from "../components/图片沙盒组件.js";
|
| 18 |
import { getVideoPlayerHTML, setupVideoPlayerEvents, cleanupVideoPlayer } from "../components/视频播放器组件.js";
|
| 19 |
import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
|
| 20 |
import { t, tIfExists } from "../components/用户体验增强.js";
|
|
|
|
| 26 |
import { getUser, eventBus, EVENTS } from "../core/状态管理.js";
|
| 27 |
import { createTaskViewScope, subscribeTaskSessionChanges } from "../task/任务视图生命周期.js";
|
| 28 |
|
| 29 |
+
import { isConfirmedDedicatedCommentResponse, isDefiniteCommentRejection } from "../social/评论数据工具.js";
|
| 30 |
+
|
| 31 |
const viewContexts = new WeakMap();
|
| 32 |
let promptViewSequence = 0;
|
| 33 |
const isCurrent = node => !!viewContexts.get(node)?.scope.isCurrent();
|
|
|
|
| 47 |
const initial = (name || 'U')[0].toUpperCase();
|
| 48 |
// 🔒 XSS防护:avatar / name 均来自他人可修改的资料(昵称首字母可能是 < ),写入 HTML 前必须转义
|
| 49 |
if (avatar) {
|
| 50 |
+
return `<img class="swr-avatar prompt-detail__avatar ${size === 24 ? 'prompt-detail__avatar--small' : 'prompt-detail__avatar--large'}" alt="" src="${escapeHtml(avatar)}">`;
|
| 51 |
}
|
| 52 |
+
return `<div class="swr-avatar prompt-detail__avatar prompt-detail__avatar--initial ${size === 24 ? 'prompt-detail__avatar--small' : 'prompt-detail__avatar--large'}" aria-hidden="true">${escapeHtml(initial)}</div>`;
|
|
|
|
| 53 |
}
|
| 54 |
|
| 55 |
/**
|
|
|
|
| 60 |
if (avatarEl.tagName === 'IMG') {
|
| 61 |
avatarEl.src = profile.avatar;
|
| 62 |
} else {
|
| 63 |
+
avatarEl.outerHTML = `<img class="swr-avatar prompt-detail__avatar ${size === 24 ? 'prompt-detail__avatar--small' : 'prompt-detail__avatar--large'}" alt="" src="${escapeHtml(profile.avatar)}">`;
|
| 64 |
}
|
| 65 |
}
|
| 66 |
|
|
|
|
| 69 |
*/
|
| 70 |
export function createPromptDetailView(promptId, currentUser) {
|
| 71 |
const container = document.createElement("div");
|
| 72 |
+
container.className = "prompt-detail";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
container.innerHTML = `
|
| 75 |
<!-- 顶部标题栏 -->
|
| 76 |
+
<div class="prompt-detail__header">
|
| 77 |
+
<button type="button" id="btn-back-prompt-detail" class="prompt-detail__button">
|
| 78 |
⬅ ${t('common.back')}
|
| 79 |
</button>
|
| 80 |
+
<span class="prompt-detail__heading">${t('prompt.detail_title')}</span>
|
| 81 |
</div>
|
| 82 |
|
| 83 |
<!-- 内容区域 -->
|
| 84 |
+
<div id="prompt-content" class="prompt-detail__content">
|
| 85 |
+
<div class="prompt-detail__state" role="status">
|
| 86 |
+
<div class="prompt-detail__state-icon" aria-hidden="true">⏳</div>
|
| 87 |
${t('prompt.loading')}
|
| 88 |
</div>
|
| 89 |
</div>
|
|
|
|
| 107 |
viewContexts.set(container, context); viewContexts.set(contentArea, context);
|
| 108 |
const handleRouteBack = () => scope.dispose();
|
| 109 |
scope.addCleanup(() => {
|
| 110 |
+
cleanupImageSandbox(contentArea);
|
| 111 |
cleanupVideoPlayer(contentArea);
|
| 112 |
contentArea.querySelectorAll('audio').forEach(audio => { audio.pause(); audio.removeAttribute('src'); audio.load(); });
|
| 113 |
contentArea.replaceChildren(); observer?.disconnect();
|
|
|
|
| 140 |
|
| 141 |
// 先清理旧的视频播放器全局事件(如果存在)
|
| 142 |
if (contentArea) {
|
| 143 |
+
cleanupImageSandbox(contentArea);
|
| 144 |
cleanupVideoPlayer(contentArea);
|
| 145 |
}
|
| 146 |
|
|
|
|
| 165 |
|
| 166 |
if (!prompt) {
|
| 167 |
contentArea.innerHTML = `
|
| 168 |
+
<div class="prompt-detail__state prompt-detail__state--error" role="alert">
|
| 169 |
+
<div class="prompt-detail__state-icon" aria-hidden="true">⚠️</div>
|
| 170 |
${t('prompt.not_exist')}
|
| 171 |
</div>
|
| 172 |
`;
|
|
|
|
| 179 |
// 🔓 判断提示词是否可见(免费/作者/已购买 → prompt_text 已由服务端下发)
|
| 180 |
const isOwner = currentUser && currentUser.account === prompt.author;
|
| 181 |
const isUnlocked = !fromCache && typeof prompt.prompt_text === "string" && prompt.locked !== true;
|
| 182 |
+
const priceState = getPromptPriceState(prompt.price);
|
| 183 |
|
| 184 |
// 判断是否为视频封面
|
| 185 |
const isVideo = prompt.media_type === "video" && prompt.video_url;
|
|
|
|
| 194 |
|
| 195 |
// 🎵 音乐提示词音频播放器(图片+音频方案,仅存在 audio_url 时渲染)
|
| 196 |
const audioHTML = prompt.audio_url ? `
|
| 197 |
+
<div class="prompt-detail__audio">
|
| 198 |
+
<div class="prompt-detail__muted prompt-detail__audio-label">🎵 ${t('prompt.audio_label')}</div>
|
| 199 |
+
<audio controls preload="metadata" src="${escapeHtml(prompt.audio_url)}" class="prompt-detail__audio-player"></audio>
|
| 200 |
</div>` : '';
|
| 201 |
|
| 202 |
// 检查当前用户是否已点赞/收藏
|
|
|
|
| 205 |
|
| 206 |
contentArea.innerHTML = `
|
| 207 |
<!-- 媒体展示区 -->
|
| 208 |
+
<div id="media-area" class="prompt-detail__media">
|
| 209 |
${mediaHTML}
|
| 210 |
${audioHTML}
|
| 211 |
</div>
|
| 212 |
|
| 213 |
<!-- 标题 -->
|
| 214 |
+
<div class="prompt-detail__title">
|
| 215 |
${escapeHtml(prompt.title)}
|
| 216 |
</div>
|
| 217 |
|
| 218 |
<!-- 原创标识 -->
|
| 219 |
${prompt.is_original ? `
|
| 220 |
+
<div class="prompt-detail__badge prompt-detail__badge--original">
|
|
|
|
|
|
|
| 221 |
🎨 ${t('prompt.original_badge')}
|
| 222 |
</div>
|
| 223 |
` : ''}
|
| 224 |
|
| 225 |
<!-- 分类与价格标签 -->
|
| 226 |
+
<div class="prompt-detail__badges">
|
| 227 |
+
<span class="prompt-detail__badge">${prompt.prompt_type === 'video' ? '🎬' : (prompt.prompt_type === 'music' ? '🎵' : '🎨')} ${escapeHtml(tIfExists('promptcat.' + (prompt.category || ''), prompt.category || ''))}</span>
|
| 228 |
+
${priceState !== 'free'
|
| 229 |
+
? `<span class="prompt-detail__badge prompt-detail__badge--price">💎 ${escapeHtml(priceState === 'paid' ? prompt.price : t('common.unknown'))}</span>`
|
| 230 |
+
: `<span class="prompt-detail__badge">${t('prompt.free')}</span>`}
|
| 231 |
+
${prompt.owned ? `<span class="prompt-detail__badge">${t('prompt.owned_mark')}</span>` : ''}
|
| 232 |
</div>
|
| 233 |
|
| 234 |
<!-- 🏷️ 标签展示 -->
|
| 235 |
${(Array.isArray(prompt.tags) && prompt.tags.length) ? `
|
| 236 |
+
<div class="prompt-detail__tags">
|
| 237 |
+
${prompt.tags.map(tag => `<span class="prompt-detail__badge">#${escapeHtml(tag)}</span>`).join('')}
|
| 238 |
</div>` : ''}
|
| 239 |
|
| 240 |
<!-- 作者信息(SWR 缓存头像) -->
|
| 241 |
+
<div id="author-row" class="prompt-detail__author">
|
| 242 |
+
<button type="button" id="btn-author-prompt" class="prompt-detail__author-link">
|
| 243 |
+
<span class="swr-avatar-container prompt-detail__avatar-slot"></span>
|
| 244 |
+
<span class="prompt-detail__author-info">
|
| 245 |
+
<span class="swr-name prompt-detail__author-name">${escapeHtml(prompt.author_name || prompt.author)}</span>
|
| 246 |
+
<span class="prompt-detail__muted prompt-detail__author-time">${escapeHtml(formatTime(prompt.created_at))}</span>
|
| 247 |
+
</span>
|
| 248 |
+
</button>
|
| 249 |
${isOwner ? `
|
| 250 |
+
<div class="prompt-detail__author-actions">
|
| 251 |
+
${IS_WEB_MODE ? '' : `<button type="button" id="btn-edit-prompt" class="prompt-detail__button" aria-label="${t('common.edit')}" title="${t('common.edit')}">✏️</button>`}
|
| 252 |
+
<button type="button" id="btn-delete-prompt" class="prompt-detail__button prompt-detail__button--danger" aria-label="${t('common.delete')}" title="${t('common.delete')}">🗑️</button>
|
| 253 |
</div>
|
| 254 |
` : ''}
|
| 255 |
</div>
|
| 256 |
|
| 257 |
<!-- 简介内容 -->
|
| 258 |
+
<div class="prompt-detail__description">${escapeHtml((prompt.content || '').trim())}</div>
|
| 259 |
|
| 260 |
<!-- 🔑 提示词正文区(付费遮罩/解锁/复制) -->
|
| 261 |
+
<div id="prompt-text-area" class="prompt-detail__text-area"></div>
|
| 262 |
|
| 263 |
<!-- 互动按钮栏 -->
|
| 264 |
+
<div id="interaction-bar" class="prompt-detail__interactions">
|
| 265 |
+
<button type="button" id="btn-like" aria-label="${t('market.like')}" aria-pressed="${isLiked}" class="prompt-detail__button prompt-detail__interaction">
|
| 266 |
+
❤️ <span id="like-count">${escapeHtml(prompt.likes || 0)}</span>
|
| 267 |
</button>
|
| 268 |
+
<button type="button" id="btn-favorite" aria-label="${t('market.favorite')}" aria-pressed="${isFavorited}" class="prompt-detail__button prompt-detail__interaction">
|
| 269 |
+
🔖 <span id="favorite-count">${escapeHtml(prompt.favorites || 0)}</span>
|
| 270 |
</button>
|
| 271 |
+
<button type="button" id="btn-tip" class="prompt-detail__button prompt-detail__interaction">
|
| 272 |
${t('prompt.tip_author')}
|
| 273 |
</button>
|
| 274 |
<!-- 👀 浏览量统计(纯展示) -->
|
| 275 |
+
<div class="prompt-detail__views">
|
| 276 |
+
<span class="prompt-detail__view-count">🔥 <span id="prompt-view-total">${escapeHtml(prompt.views || 0)}</span></span>
|
| 277 |
+
<span class="prompt-detail__view-count">📅 <span id="prompt-view-daily">${escapeHtml(prompt.daily_views || 0)}</span></span>
|
| 278 |
</div>
|
| 279 |
</div>
|
| 280 |
|
| 281 |
<!-- 打赏榜单 -->
|
| 282 |
+
<div id="tip-board-area" class="prompt-detail__tips">
|
| 283 |
${renderCommonTipBoardHTML(prompt.tip_board || [], 5, t('prompt.no_tips'))}
|
| 284 |
</div>
|
| 285 |
|
| 286 |
<!-- 评论区 -->
|
| 287 |
+
<div class="prompt-detail__comments-heading">
|
| 288 |
+
${t('prompt.comments_title')} <span class="prompt-detail__muted">(${escapeHtml(prompt.comments || 0)})</span>
|
| 289 |
</div>
|
| 290 |
|
| 291 |
<!-- 评论输入框 -->
|
| 292 |
+
<div class="prompt-detail__comment-form">
|
| 293 |
+
<input type="text" id="comment-input" required aria-label="${escapeHtml(t('prompt.comment_placeholder'))}" placeholder="${t('prompt.comment_placeholder')}" class="prompt-detail__comment-input">
|
| 294 |
+
<button type="button" id="btn-send-comment" class="prompt-detail__button prompt-detail__button--accent">
|
| 295 |
${t('common.send')}
|
| 296 |
</button>
|
| 297 |
</div>
|
| 298 |
|
| 299 |
<!-- 评论列表 -->
|
| 300 |
+
<div id="comments-list" class="prompt-detail__comments">
|
| 301 |
+
<div class="prompt-detail__state prompt-detail__state--compact" role="status" aria-label="${t('prompt.loading')}">⏳</div>
|
| 302 |
</div>
|
| 303 |
`;
|
| 304 |
|
|
|
|
| 344 |
}
|
| 345 |
|
| 346 |
// 绑定作者点击
|
| 347 |
+
contentArea.querySelector("#btn-author-prompt").onclick = (e) => {
|
| 348 |
if (!isCurrent(contentArea)) return;
|
| 349 |
if (e.target.id === 'btn-edit-prompt' || e.target.id === 'btn-delete-prompt') return;
|
| 350 |
openOtherUserProfileModal(prompt.author, currentUser);
|
|
|
|
| 397 |
if (!isCurrent(contentArea)) return;
|
| 398 |
const textArea = contentArea.querySelector("#prompt-text-area");
|
| 399 |
if (!textArea) return;
|
| 400 |
+
const priceState = getPromptPriceState(prompt.price);
|
| 401 |
|
| 402 |
if (isUnlocked && prompt.prompt_text) {
|
| 403 |
// ✅ 已解锁:展示正文 + 一键复制
|
| 404 |
textArea.innerHTML = `
|
| 405 |
+
<div class="prompt-detail__text-box">
|
| 406 |
+
<div class="prompt-detail__text-header">
|
| 407 |
+
<span class="prompt-detail__text-label">🔑 ${t('prompt.text_label')}</span>
|
| 408 |
+
<button type="button" id="btn-copy-prompt" class="prompt-detail__button prompt-detail__button--accent">
|
| 409 |
📋 ${t('prompt.copy')}
|
| 410 |
</button>
|
| 411 |
</div>
|
| 412 |
+
<div class="prompt-detail__text-body">${escapeHtml(prompt.prompt_text)}</div>
|
| 413 |
</div>
|
| 414 |
`;
|
| 415 |
textArea.querySelector("#btn-copy-prompt").onclick = () => {
|
| 416 |
if (isCurrent(contentArea)) copyToClipboard(prompt.prompt_text, textArea.querySelector("#btn-copy-prompt"), () => isCurrent(contentArea));
|
| 417 |
};
|
| 418 |
+
} else if (priceState === 'paid') {
|
| 419 |
// 🔒 付费遮罩:模糊预览 + 解锁按钮
|
| 420 |
textArea.innerHTML = `
|
| 421 |
+
<div class="prompt-detail__locked">
|
| 422 |
+
<div class="prompt-detail__locked-preview" aria-hidden="true">
|
| 423 |
${escapeHtml((prompt.content || '') + '\n' + (prompt.content || ''))}
|
| 424 |
</div>
|
| 425 |
+
<div class="prompt-detail__locked-overlay">
|
| 426 |
+
<div class="prompt-detail__lock-icon" aria-hidden="true">🔒</div>
|
| 427 |
+
<div class="prompt-detail__lock-title">${t('prompt.locked_title')}</div>
|
| 428 |
+
<div class="prompt-detail__muted prompt-detail__locked-description">${t('prompt.locked_desc')}</div>
|
| 429 |
+
<button type="button" id="btn-unlock-prompt" class="prompt-detail__button prompt-detail__button--gold">
|
| 430 |
💎 ${t('prompt.unlock', { price: prompt.price })}
|
| 431 |
</button>
|
| 432 |
</div>
|
|
|
|
| 435 |
textArea.querySelector("#btn-unlock-prompt").onclick = () => {
|
| 436 |
showPurchaseConfirmDialog(contentArea, prompt, currentUser);
|
| 437 |
};
|
| 438 |
+
} else if (priceState === 'unknown') {
|
| 439 |
+
const message = document.createElement('p');
|
| 440 |
+
message.className = 'prompt-detail__muted';
|
| 441 |
+
message.setAttribute('role', 'status');
|
| 442 |
+
message.textContent = t('prompt.price_unknown');
|
| 443 |
+
textArea.replaceChildren(message);
|
| 444 |
}
|
| 445 |
}
|
| 446 |
|
|
|
|
| 528 |
}
|
| 529 |
|
| 530 |
const content = document.createElement("div");
|
| 531 |
+
content.className = "prompt-detail-dialog";
|
| 532 |
content.innerHTML = `
|
| 533 |
+
<div class="prompt-detail-dialog__center">
|
| 534 |
+
<div class="prompt-detail-dialog__icon" aria-hidden="true">💎</div>
|
| 535 |
+
<div class="prompt-detail-dialog__title">${t('prompt.purchase_confirm_title')}</div>
|
| 536 |
+
<div class="prompt-detail-dialog__description">${t('prompt.purchase_confirm_desc', { price: expectedPrice, title: escapeHtml(purchaseTitle) })}</div>
|
| 537 |
+
<div class="prompt-detail-dialog__balance">
|
| 538 |
+
${t('prompt.purchase_balance')}: <strong class="prompt-detail-dialog__value">${balance}</strong>
|
| 539 |
</div>
|
| 540 |
+
<div class="prompt-detail-dialog__actions">
|
| 541 |
+
<button type="button" id="purchase-cancel" class="prompt-detail__button">${t('common.cancel')}</button>
|
| 542 |
+
<button type="button" id="purchase-confirm" class="prompt-detail__button prompt-detail__button--gold">💎 ${t('prompt.unlock', { price: expectedPrice })}</button>
|
| 543 |
</div>
|
| 544 |
</div>
|
| 545 |
`;
|
|
|
|
| 646 |
}
|
| 647 |
|
| 648 |
const content = document.createElement("div");
|
| 649 |
+
content.className = "prompt-detail-dialog";
|
| 650 |
content.innerHTML = `
|
| 651 |
+
<div class="prompt-detail-dialog__balance">
|
| 652 |
+
${t('wallet.current_balance')}: <strong class="prompt-detail-dialog__value">${balance}</strong> ${t('wallet.credits')}
|
| 653 |
</div>
|
| 654 |
+
<div class="prompt-detail__tip-amounts">
|
| 655 |
+
<button type="button" class="tip-amount prompt-detail__button" data-amount="10">10 ${t('task.points')}</button>
|
| 656 |
+
<button type="button" class="tip-amount prompt-detail__button" data-amount="50">50 ${t('task.points')}</button>
|
| 657 |
+
<button type="button" class="tip-amount prompt-detail__button" data-amount="100">100 ${t('task.points')}</button>
|
| 658 |
+
<button type="button" class="tip-amount prompt-detail__button" data-amount="500">500 ${t('task.points')}</button>
|
| 659 |
</div>
|
| 660 |
+
<div class="prompt-detail-dialog__actions">
|
| 661 |
+
<button type="button" id="tip-cancel" class="prompt-detail__button">${t('common.cancel')}</button>
|
| 662 |
</div>
|
| 663 |
`;
|
| 664 |
|
|
|
|
| 707 |
const sendBtn = container.querySelector("#btn-send-comment");
|
| 708 |
if (!isCurrent(container)) return;
|
| 709 |
const context = viewContexts.get(container);
|
| 710 |
+
const account = typeof currentUser === "string" ? currentUser : currentUser?.account;
|
| 711 |
+
let submission = context.commentSubmission;
|
| 712 |
+
if (!submission || submission.input !== commentInput || submission.button !== sendBtn || submission.account !== account) {
|
| 713 |
+
submission = context.commentSubmission = { input: commentInput, button: sendBtn, account, phase: "idle", bound: false };
|
| 714 |
+
}
|
| 715 |
+
const submissionCurrent = () => isCurrent(container) && context.commentSubmission === submission
|
| 716 |
+
&& commentInput.isConnected && sendBtn.isConnected;
|
| 717 |
const generation = context.commentGeneration = (context.commentGeneration || 0) + 1;
|
| 718 |
const current = () => isCurrent(container) && context.commentGeneration === generation && commentsList.isConnected;
|
| 719 |
|
|
|
|
| 727 |
|
| 728 |
if (comments.length === 0) {
|
| 729 |
commentsList.innerHTML = `
|
| 730 |
+
<div class="prompt-detail__state prompt-detail__state--compact" role="status">
|
| 731 |
${t('prompt.no_comments')}
|
| 732 |
</div>
|
| 733 |
`;
|
|
|
|
| 741 |
const avatarHtml = _generateAvatarHtml(avatar, name, 24, 'background: var(--comfy-input-bg);');
|
| 742 |
|
| 743 |
return `
|
| 744 |
+
<div id="comment-${idx}" data-account="${escapeHtml(c.author)}" class="prompt-detail__comment">
|
| 745 |
+
<div class="prompt-detail__comment-header">
|
| 746 |
<div class="swr-avatar-container">${avatarHtml}</div>
|
| 747 |
+
<span class="swr-name prompt-detail__comment-name">${escapeHtml(name)}</span>
|
| 748 |
+
<span class="prompt-detail__muted prompt-detail__comment-time">${escapeHtml(formatTime(c.created_at))}</span>
|
| 749 |
</div>
|
| 750 |
+
<div class="prompt-detail__comment-body">
|
| 751 |
${escapeHtml(c.content)}
|
| 752 |
</div>
|
| 753 |
</div>
|
|
|
|
| 771 |
} catch (err) {
|
| 772 |
if (!current()) return;
|
| 773 |
commentsList.innerHTML = `
|
| 774 |
+
<div class="prompt-detail__state prompt-detail__state--compact prompt-detail__state--error" role="alert">
|
| 775 |
${t('prompt.load_failed')}
|
| 776 |
</div>
|
| 777 |
`;
|
| 778 |
}
|
| 779 |
|
| 780 |
+
if (submission.bound || !submissionCurrent()) return;
|
| 781 |
+
submission.bound = true;
|
| 782 |
+
sendBtn.disabled = false;
|
| 783 |
+
commentInput.readOnly = false;
|
| 784 |
+
sendBtn.removeAttribute("aria-busy");
|
| 785 |
+
commentInput.oninput = () => {
|
| 786 |
+
if (submissionCurrent()) commentInput.removeAttribute("aria-invalid");
|
| 787 |
+
};
|
| 788 |
sendBtn.onclick = async () => {
|
| 789 |
+
if (!submissionCurrent() || submission.phase !== "idle") return;
|
| 790 |
+
if (typeof account !== "string" || !account.trim()) {
|
| 791 |
showToast(t('auth.login_required'), "warning");
|
| 792 |
return;
|
| 793 |
}
|
| 794 |
+
const originalInput = commentInput.value, content = originalInput.trim();
|
| 795 |
if (!content) {
|
| 796 |
+
commentInput.setAttribute("aria-invalid", "true");
|
| 797 |
+
commentInput.focus();
|
| 798 |
showToast(t('prompt.comment_required'), "warning");
|
| 799 |
return;
|
| 800 |
}
|
| 801 |
+
commentInput.removeAttribute("aria-invalid");
|
| 802 |
+
submission.phase = "submitting";
|
| 803 |
+
sendBtn.disabled = true;
|
| 804 |
+
sendBtn.setAttribute("aria-busy", "true");
|
| 805 |
+
sendBtn.textContent = t('common.sending');
|
| 806 |
try {
|
|
|
|
|
|
|
| 807 |
const result = await api.addPromptComment(promptId, content);
|
| 808 |
+
if (!submissionCurrent()) return;
|
| 809 |
+
if (!isConfirmedDedicatedCommentResponse(result, account, content)) throw new Error("Unconfirmed comment");
|
| 810 |
+
submission.phase = "committed";
|
| 811 |
+
if (commentInput.value === originalInput) commentInput.value = "";
|
| 812 |
showToast(t('prompt.comment_success'), "success");
|
| 813 |
+
await loadComments(container, promptId, currentUser);
|
| 814 |
+
if (submissionCurrent()) submission.phase = "idle";
|
| 815 |
} catch (err) {
|
| 816 |
+
if (!submissionCurrent()) return;
|
| 817 |
+
if (submission.phase === "committed") {
|
| 818 |
+
try { showToast(t('social.comment_refresh_required'), "warning"); } catch (_) {}
|
| 819 |
+
return;
|
| 820 |
+
}
|
| 821 |
+
const rejected = isDefiniteCommentRejection(err);
|
| 822 |
+
submission.phase = rejected ? "idle" : "unconfirmed";
|
| 823 |
+
showToast(t(rejected ? 'prompt.comment_failed' : 'social.comment_unconfirmed'), rejected ? "error" : "warning");
|
| 824 |
} finally {
|
| 825 |
+
if (!submissionCurrent()) return;
|
| 826 |
+
sendBtn.disabled = submission.phase !== "idle";
|
| 827 |
+
commentInput.readOnly = submission.phase !== "idle";
|
| 828 |
+
sendBtn.removeAttribute("aria-busy");
|
| 829 |
+
sendBtn.textContent = t(submission.phase === "unconfirmed" ? 'social.comment_pending' : 'common.send');
|
| 830 |
}
|
| 831 |
};
|
| 832 |
|
| 833 |
// 回车发送
|
| 834 |
commentInput.onkeydown = (e) => {
|
| 835 |
+
if (isCurrent(container) && !e.defaultPrevented && e.key === "Enter"
|
| 836 |
+
&& !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey
|
| 837 |
+
&& !e.isComposing && e.keyCode !== 229 && !e.repeat
|
| 838 |
+
&& !commentInput.disabled && !commentInput.readOnly) {
|
| 839 |
e.preventDefault();
|
| 840 |
sendBtn.click();
|
| 841 |
}
|
|
|
|
| 848 |
function showDeleteConfirmDialog(prompt, container) {
|
| 849 |
if (!isCurrent(container)) return;
|
| 850 |
const content = document.createElement("div");
|
| 851 |
+
content.className = "prompt-detail-dialog";
|
| 852 |
content.innerHTML = `
|
| 853 |
+
<div class="prompt-detail-dialog__center">
|
| 854 |
+
<div class="prompt-detail-dialog__icon" aria-hidden="true">⚠️</div>
|
| 855 |
+
<div class="prompt-detail-dialog__title">${t('prompt.delete_confirm')}</div>
|
| 856 |
+
<div class="prompt-detail-dialog__description">${escapeHtml(prompt.title)}</div>
|
| 857 |
+
<div class="prompt-detail-dialog__actions">
|
| 858 |
+
<button type="button" id="delete-cancel" class="prompt-detail__button">${t('common.cancel')}</button>
|
| 859 |
+
<button type="button" id="delete-confirm" class="prompt-detail__button prompt-detail__button--danger">${t('common.delete')}</button>
|
| 860 |
</div>
|
| 861 |
</div>
|
| 862 |
`;
|
前端页面/social/私信聊天组件.js
CHANGED
|
@@ -455,7 +455,10 @@ export function openChatModal(currentUser, targetAccount = null) {
|
|
| 455 |
|
| 456 |
// 🚀 P1-2: Enter 发送,Shift+Enter 换行
|
| 457 |
inputField.onkeydown = (e) => {
|
| 458 |
-
if (
|
|
|
|
|
|
|
|
|
|
| 459 |
e.preventDefault();
|
| 460 |
sendBtn.click();
|
| 461 |
}
|
|
|
|
| 455 |
|
| 456 |
// 🚀 P1-2: Enter 发送,Shift+Enter 换行
|
| 457 |
inputField.onkeydown = (e) => {
|
| 458 |
+
if (scope.isCurrent() && !e.defaultPrevented && e.key === "Enter"
|
| 459 |
+
&& !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey
|
| 460 |
+
&& !e.isComposing && e.keyCode !== 229 && !e.repeat
|
| 461 |
+
&& !inputField.disabled && !inputField.readOnly) {
|
| 462 |
e.preventDefault();
|
| 463 |
sendBtn.click();
|
| 464 |
}
|
前端页面/social/评论数据工具.js
CHANGED
|
@@ -18,3 +18,12 @@ export function isConfirmedCommentDeletion(response) {
|
|
| 18 |
export function isDefiniteCommentRejection(error) {
|
| 19 |
return Number.isInteger(error?.status) && error.status >= 400 && error.status < 500 && error.status !== 408;
|
| 20 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
export function isDefiniteCommentRejection(error) {
|
| 19 |
return Number.isInteger(error?.status) && error.status >= 400 && error.status < 500 && error.status !== 408;
|
| 20 |
}
|
| 21 |
+
|
| 22 |
+
export function isConfirmedDedicatedCommentResponse(response, account, content) {
|
| 23 |
+
const data = response?.data;
|
| 24 |
+
return typeof account === 'string' && !!account.trim()
|
| 25 |
+
&& typeof content === 'string' && !!content.trim()
|
| 26 |
+
&& response?.status === 'success' && !!data && typeof data === 'object' && !Array.isArray(data)
|
| 27 |
+
&& typeof data.id === 'string' && !!data.id.trim()
|
| 28 |
+
&& data.author === account && data.content === content;
|
| 29 |
+
}
|
前端页面/styles/主题基础.css
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
前端页面/task/任务榜组件.js
CHANGED
|
@@ -212,10 +212,11 @@ export function createTasksView(currentUser, keyword = "") {
|
|
| 212 |
sorted.sort((a, b) => (b.daily_views || 0) - (a.daily_views || 0));
|
| 213 |
break;
|
| 214 |
case "price":
|
| 215 |
-
sorted.sort((a, b) => (b.
|
| 216 |
break;
|
| 217 |
case "deadline":
|
| 218 |
-
|
|
|
|
| 219 |
break;
|
| 220 |
default: // latest
|
| 221 |
sorted.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
|
|
@@ -353,6 +354,7 @@ export function createTasksView(currentUser, keyword = "") {
|
|
| 353 |
// 从缓存渲染任务列表
|
| 354 |
const renderTasksFromCache = (tasks) => {
|
| 355 |
firstPageReady = true;
|
|
|
|
| 356 |
tasksList.innerHTML = "";
|
| 357 |
|
| 358 |
// 🔍 搜索过滤
|
|
@@ -366,6 +368,7 @@ export function createTasksView(currentUser, keyword = "") {
|
|
| 366 |
tasksList.innerHTML = _renderNoTasksView();
|
| 367 |
loadMoreWrapper.style.display = "none";
|
| 368 |
}
|
|
|
|
| 369 |
return;
|
| 370 |
}
|
| 371 |
|
|
@@ -390,6 +393,7 @@ export function createTasksView(currentUser, keyword = "") {
|
|
| 390 |
} else {
|
| 391 |
loadMoreWrapper.style.display = "none";
|
| 392 |
}
|
|
|
|
| 393 |
};
|
| 394 |
|
| 395 |
// 后台静默更新
|
|
@@ -450,10 +454,20 @@ export function createTasksView(currentUser, keyword = "") {
|
|
| 450 |
const _tasksDataChanged = (oldData, newData) => {
|
| 451 |
if (!oldData || !newData) return true;
|
| 452 |
if (oldData.length !== newData.length) return true;
|
| 453 |
-
const checkCount =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
for (let i = 0; i < checkCount; i++) {
|
| 455 |
if (oldData[i].id !== newData[i].id) return true;
|
| 456 |
if (oldData[i].status !== newData[i].status) return true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 457 |
for (const field of COMPARE_FIELDS) {
|
| 458 |
if ((oldData[i][field] || 0) !== (newData[i][field] || 0)) return true;
|
| 459 |
}
|
|
@@ -561,6 +575,9 @@ export function createTasksView(currentUser, keyword = "") {
|
|
| 561 |
function createTaskCard(task, currentUser = currentUserCache, isCurrent = () => true, captureNavigation = () => isCurrent) {
|
| 562 |
const card = document.createElement("div");
|
| 563 |
card.className = "task-card";
|
|
|
|
|
|
|
|
|
|
| 564 |
card.setAttribute("role", "button");
|
| 565 |
card.tabIndex = 0;
|
| 566 |
|
|
@@ -583,7 +600,7 @@ function createTaskCard(task, currentUser = currentUserCache, isCurrent = () =>
|
|
| 583 |
const deadline = formatDeadline(task.deadline);
|
| 584 |
|
| 585 |
// 检查是否逾期(进行中但超过截止日期)
|
| 586 |
-
const isOverdue = task.is_overdue || (task.status === "in_progress" && task.deadline && task.deadline < new Date().toISOString().slice(0, 10));
|
| 587 |
const overdueTag = isOverdue ? `<span class="task-card__overdue">${t('task.overdue')}</span>` : "";
|
| 588 |
|
| 589 |
// 过期卡片样式
|
|
@@ -591,6 +608,10 @@ function createTaskCard(task, currentUser = currentUserCache, isCurrent = () =>
|
|
| 591 |
card.classList.add("task-card--stale");
|
| 592 |
}
|
| 593 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 594 |
card.innerHTML = `
|
| 595 |
<!-- 顶部:状态 + 价格 -->
|
| 596 |
<div class="task-card__header">
|
|
@@ -618,7 +639,7 @@ function createTaskCard(task, currentUser = currentUserCache, isCurrent = () =>
|
|
| 618 |
<!-- 底部信息 -->
|
| 619 |
<div class="task-card__footer">
|
| 620 |
<!-- 发布者(SWR 缓存头像) -->
|
| 621 |
-
<div class="task-card__author"></div>
|
| 622 |
|
| 623 |
<!-- 截止日期 + 申请人数 + 访问量 + 点赞 + 收藏 -->
|
| 624 |
<div class="task-card__stats">
|
|
@@ -638,12 +659,10 @@ function createTaskCard(task, currentUser = currentUserCache, isCurrent = () =>
|
|
| 638 |
if (!authorContainer) return;
|
| 639 |
|
| 640 |
const account = task.publisher;
|
| 641 |
-
const cached =
|
| 642 |
-
const avatar = cached?.avatar
|
| 643 |
-
const name =
|
| 644 |
|
| 645 |
-
// 内联 SVG 默认头像(用户剪影图标)
|
| 646 |
-
const DEFAULT_AVATAR_SVG = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 36 36'%3E%3Ccircle cx='18' cy='18' r='18' fill='%23333'/%3E%3Ccircle cx='18' cy='14' r='6' fill='%23666'/%3E%3Cpath d='M6 32c0-6.6 5.4-12 12-12s12 5.4 12 12' fill='%23666'/%3E%3C/svg%3E";
|
| 647 |
|
| 648 |
// 渲染头像(始终使用 img 标签)
|
| 649 |
const avatarSrc = avatar || DEFAULT_AVATAR_SVG;
|
|
@@ -653,17 +672,19 @@ function createTaskCard(task, currentUser = currentUserCache, isCurrent = () =>
|
|
| 653 |
authorContainer.innerHTML = `${avatarHtml}<span class="swr-name">${escapeHtml(name)}</span>`;
|
| 654 |
|
| 655 |
// 后台静默校对
|
| 656 |
-
|
| 657 |
if (!isCurrent() || !card.isConnected) return;
|
| 658 |
const avatarEl = authorContainer.querySelector('.swr-avatar');
|
| 659 |
const nameEl = authorContainer.querySelector('.swr-name');
|
| 660 |
-
if (avatarEl && profile.avatar) {
|
| 661 |
-
avatarEl.src = profile.avatar;
|
| 662 |
}
|
| 663 |
-
if (nameEl && profile.name) {
|
| 664 |
nameEl.textContent = profile.name;
|
| 665 |
}
|
| 666 |
-
}
|
|
|
|
|
|
|
| 667 |
}, 0);
|
| 668 |
|
| 669 |
// 点击进入详情;捕获创建视图的用户和点击时的导航范围。
|
|
@@ -697,26 +718,42 @@ function createTaskCard(task, currentUser = currentUserCache, isCurrent = () =>
|
|
| 697 |
return card;
|
| 698 |
}
|
| 699 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 700 |
/**
|
| 701 |
* 📅 格式化截止日期
|
| 702 |
*/
|
| 703 |
function formatDeadline(deadline) {
|
| 704 |
-
if (
|
|
|
|
|
|
|
| 705 |
|
| 706 |
try {
|
| 707 |
-
const
|
| 708 |
const now = new Date();
|
| 709 |
-
const
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
if (days === 0) return t('time.deadline_today');
|
| 715 |
if (days === 1) return t('time.deadline_tomorrow');
|
| 716 |
if (days < 7) return t('time.deadline_days', { n: days });
|
| 717 |
|
| 718 |
-
return `${
|
| 719 |
} catch {
|
| 720 |
-
return
|
| 721 |
}
|
| 722 |
}
|
|
|
|
| 212 |
sorted.sort((a, b) => (b.daily_views || 0) - (a.daily_views || 0));
|
| 213 |
break;
|
| 214 |
case "price":
|
| 215 |
+
sorted.sort((a, b) => (b.total_price || 0) - (a.total_price || 0));
|
| 216 |
break;
|
| 217 |
case "deadline":
|
| 218 |
+
const deadlineTime = value => parseTaskDeadline(value) ?? Infinity;
|
| 219 |
+
sorted.sort((a, b) => deadlineTime(a.deadline) - deadlineTime(b.deadline));
|
| 220 |
break;
|
| 221 |
default: // latest
|
| 222 |
sorted.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
|
|
|
|
| 354 |
// 从缓存渲染任务列表
|
| 355 |
const renderTasksFromCache = (tasks) => {
|
| 356 |
firstPageReady = true;
|
| 357 |
+
const restoreFocus = cardNavigation.captureListFocus(tasksList);
|
| 358 |
tasksList.innerHTML = "";
|
| 359 |
|
| 360 |
// 🔍 搜索过滤
|
|
|
|
| 368 |
tasksList.innerHTML = _renderNoTasksView();
|
| 369 |
loadMoreWrapper.style.display = "none";
|
| 370 |
}
|
| 371 |
+
restoreFocus();
|
| 372 |
return;
|
| 373 |
}
|
| 374 |
|
|
|
|
| 393 |
} else {
|
| 394 |
loadMoreWrapper.style.display = "none";
|
| 395 |
}
|
| 396 |
+
restoreFocus();
|
| 397 |
};
|
| 398 |
|
| 399 |
// 后台静默更新
|
|
|
|
| 454 |
const _tasksDataChanged = (oldData, newData) => {
|
| 455 |
if (!oldData || !newData) return true;
|
| 456 |
if (oldData.length !== newData.length) return true;
|
| 457 |
+
const checkCount = oldData.length;
|
| 458 |
+
const contentFields = ["title", "description", "total_price", "deadline", "is_overdue", "publisher", "publisher_name", "created_at"];
|
| 459 |
+
const mediaFields = ["publisher_avatar"];
|
| 460 |
+
const normalizedImage = value => typeof value === 'string'
|
| 461 |
+
? proxyImages({ cover_image: value }).cover_image : value;
|
| 462 |
for (let i = 0; i < checkCount; i++) {
|
| 463 |
if (oldData[i].id !== newData[i].id) return true;
|
| 464 |
if (oldData[i].status !== newData[i].status) return true;
|
| 465 |
+
for (const field of contentFields) {
|
| 466 |
+
if (!Object.is(oldData[i][field], newData[i][field])) return true;
|
| 467 |
+
}
|
| 468 |
+
for (const field of mediaFields) {
|
| 469 |
+
if (!Object.is(normalizedImage(oldData[i][field]), normalizedImage(newData[i][field]))) return true;
|
| 470 |
+
}
|
| 471 |
for (const field of COMPARE_FIELDS) {
|
| 472 |
if ((oldData[i][field] || 0) !== (newData[i][field] || 0)) return true;
|
| 473 |
}
|
|
|
|
| 575 |
function createTaskCard(task, currentUser = currentUserCache, isCurrent = () => true, captureNavigation = () => isCurrent) {
|
| 576 |
const card = document.createElement("div");
|
| 577 |
card.className = "task-card";
|
| 578 |
+
if (typeof task.id === 'string' || (typeof task.id === 'number' && Number.isFinite(task.id))) {
|
| 579 |
+
card._rankingListId = String(task.id);
|
| 580 |
+
}
|
| 581 |
card.setAttribute("role", "button");
|
| 582 |
card.tabIndex = 0;
|
| 583 |
|
|
|
|
| 600 |
const deadline = formatDeadline(task.deadline);
|
| 601 |
|
| 602 |
// 检查是否逾期(进行中但超过截止日期)
|
| 603 |
+
const isOverdue = task.is_overdue === true || (task.status === "in_progress" && parseTaskDeadline(task.deadline) != null && task.deadline.slice(0, 10) < new Date().toISOString().slice(0, 10));
|
| 604 |
const overdueTag = isOverdue ? `<span class="task-card__overdue">${t('task.overdue')}</span>` : "";
|
| 605 |
|
| 606 |
// 过期卡片样式
|
|
|
|
| 608 |
card.classList.add("task-card--stale");
|
| 609 |
}
|
| 610 |
|
| 611 |
+
const initialAuthor = isCurrent() && typeof task.publisher === 'string' ? getCachedProfile(task.publisher) : null;
|
| 612 |
+
const initialAuthorName = [initialAuthor?.name, task.publisher_name, task.publisher].find(value => typeof value === 'string' && value) || '';
|
| 613 |
+
const DEFAULT_AVATAR_SVG = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 36 36'%3E%3Ccircle cx='18' cy='18' r='18' fill='%23333'/%3E%3Ccircle cx='18' cy='14' r='6' fill='%23666'/%3E%3Cpath d='M6 32c0-6.6 5.4-12 12-12s12 5.4 12 12' fill='%23666'/%3E%3C/svg%3E";
|
| 614 |
+
|
| 615 |
card.innerHTML = `
|
| 616 |
<!-- 顶部:状态 + 价格 -->
|
| 617 |
<div class="task-card__header">
|
|
|
|
| 639 |
<!-- 底部信息 -->
|
| 640 |
<div class="task-card__footer">
|
| 641 |
<!-- 发布者(SWR 缓存头像) -->
|
| 642 |
+
<div class="task-card__author"><img class="swr-avatar task-card__avatar" alt="" src="${escapeHtml(DEFAULT_AVATAR_SVG)}"><span class="swr-name">${escapeHtml(initialAuthorName)}</span></div>
|
| 643 |
|
| 644 |
<!-- 截止日期 + 申请人数 + 访问量 + 点赞 + 收藏 -->
|
| 645 |
<div class="task-card__stats">
|
|
|
|
| 659 |
if (!authorContainer) return;
|
| 660 |
|
| 661 |
const account = task.publisher;
|
| 662 |
+
const cached = initialAuthor;
|
| 663 |
+
const avatar = [cached?.avatar, task.publisher_avatar].find(value => typeof value === 'string' && value) || '';
|
| 664 |
+
const name = initialAuthorName;
|
| 665 |
|
|
|
|
|
|
|
| 666 |
|
| 667 |
// 渲染头像(始终使用 img 标签)
|
| 668 |
const avatarSrc = avatar || DEFAULT_AVATAR_SVG;
|
|
|
|
| 672 |
authorContainer.innerHTML = `${avatarHtml}<span class="swr-name">${escapeHtml(name)}</span>`;
|
| 673 |
|
| 674 |
// 后台静默校对
|
| 675 |
+
const updateAuthor = (profile) => {
|
| 676 |
if (!isCurrent() || !card.isConnected) return;
|
| 677 |
const avatarEl = authorContainer.querySelector('.swr-avatar');
|
| 678 |
const nameEl = authorContainer.querySelector('.swr-name');
|
| 679 |
+
if (avatarEl && typeof profile.avatar === 'string' && profile.avatar) {
|
| 680 |
+
if (avatarEl.getAttribute('src') !== profile.avatar) avatarEl.src = profile.avatar;
|
| 681 |
}
|
| 682 |
+
if (nameEl && typeof profile.name === 'string' && profile.name && nameEl.textContent !== profile.name) {
|
| 683 |
nameEl.textContent = profile.name;
|
| 684 |
}
|
| 685 |
+
};
|
| 686 |
+
const latestCached = getProfileWithSWR(account, api.getUserProfile, updateAuthor);
|
| 687 |
+
if (latestCached) updateAuthor(latestCached);
|
| 688 |
}, 0);
|
| 689 |
|
| 690 |
// 点击进入详情;捕获创建视图的用户和点击时的导航范围。
|
|
|
|
| 718 |
return card;
|
| 719 |
}
|
| 720 |
|
| 721 |
+
function parseTaskDeadline(value) {
|
| 722 |
+
if (typeof value !== 'string' || value.trim() !== value ||
|
| 723 |
+
!/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/.test(value)) return null;
|
| 724 |
+
const year = Number(value.slice(0, 4));
|
| 725 |
+
const month = Number(value.slice(5, 7));
|
| 726 |
+
const day = Number(value.slice(8, 10));
|
| 727 |
+
if (year < 1 || month < 1 || month > 12 || day < 1) return null;
|
| 728 |
+
const leap = year % 400 === 0 || (year % 4 === 0 && year % 100 !== 0);
|
| 729 |
+
const lastDay = month === 2 ? (leap ? 29 : 28) : ([4, 6, 9, 11].includes(month) ? 30 : 31);
|
| 730 |
+
if (day > lastDay) return null;
|
| 731 |
+
const time = Date.parse(value);
|
| 732 |
+
return Number.isFinite(time) ? time : null;
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
/**
|
| 736 |
* 📅 格式化截止日期
|
| 737 |
*/
|
| 738 |
function formatDeadline(deadline) {
|
| 739 |
+
if (deadline == null || deadline === '') return t('time.unlimited');
|
| 740 |
+
const timestamp = parseTaskDeadline(deadline);
|
| 741 |
+
if (timestamp == null) return t('common.unknown');
|
| 742 |
|
| 743 |
try {
|
| 744 |
+
const day = deadline.slice(0, 10);
|
| 745 |
const now = new Date();
|
| 746 |
+
const today = new Date(0);
|
| 747 |
+
today.setUTCFullYear(now.getFullYear(), now.getMonth(), now.getDate());
|
| 748 |
+
const days = Math.round((Date.parse(day) - today.getTime()) / (1000 * 60 * 60 * 24));
|
| 749 |
+
if (!Number.isFinite(days)) return t('common.unknown');
|
| 750 |
+
if (days < 0) return t('time.deadline_ended');
|
| 751 |
if (days === 0) return t('time.deadline_today');
|
| 752 |
if (days === 1) return t('time.deadline_tomorrow');
|
| 753 |
if (days < 7) return t('time.deadline_days', { n: days });
|
| 754 |
|
| 755 |
+
return `${Number(day.slice(5, 7))}/${Number(day.slice(8, 10))}`;
|
| 756 |
} catch {
|
| 757 |
+
return t('common.unknown');
|
| 758 |
}
|
| 759 |
}
|
前端页面/task/任务详情组件.js
CHANGED
|
@@ -16,7 +16,8 @@ import { API, CACHE, PLACEHOLDERS, TASK_LIMITS, getCachedProfile, getProfileWith
|
|
| 16 |
import { removeCache, findInListCache, requireListResponseData } from "../components/性能优化工具.js";
|
| 17 |
import { globalModal } from "../components/全局弹窗管理器.js";
|
| 18 |
import { compressImageForUpload } from "../market/发布内容_提交引擎.js";
|
| 19 |
-
import { getCoverSandboxHTML, setupImageSandboxEvents } from "../components/图片沙盒组件.js";
|
|
|
|
| 20 |
import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
|
| 21 |
import { recordView, handleToggleLike, handleToggleFavorite, renderTipBoardHTML as renderCommonTipBoardHTML, escapeHtml } from "../components/互动工具函数.js";
|
| 22 |
|
|
@@ -27,6 +28,7 @@ const taskCommentStates = new WeakMap();
|
|
| 27 |
|
| 28 |
function openTaskModal(scope, title, content, options) {
|
| 29 |
if (scope && !scope.isCurrent()) return;
|
|
|
|
| 30 |
globalModal.openModal(title, content, options);
|
| 31 |
const modal = globalModal.activeModals.at(-1);
|
| 32 |
const release = scope?.addCleanup(() => globalModal.closeModal(modal));
|
|
@@ -87,6 +89,7 @@ export function createTaskDetailView(taskId, currentUser) {
|
|
| 87 |
});
|
| 88 |
scope.componentId = componentId;
|
| 89 |
scope.root = container;
|
|
|
|
| 90 |
container._taskScope = scope;
|
| 91 |
container.querySelector("#task-content")._taskScope = scope;
|
| 92 |
container._cleanup = () => {
|
|
@@ -187,7 +190,8 @@ function renderTaskDetail(contentEl, task, currentUser, preview = false) {
|
|
| 187 |
submitted: { bg: "#FF9800", textKey: "task.status_submitted" },
|
| 188 |
completed: { bg: "#9E9E9E", textKey: "task.status_completed" },
|
| 189 |
disputed: { bg: "#F44336", textKey: "task.status_disputed" },
|
| 190 |
-
cancelled: { bg: "#757575", textKey: "task.status_cancelled" }
|
|
|
|
| 191 |
};
|
| 192 |
|
| 193 |
const status = Object.prototype.hasOwnProperty.call(statusStyles, task.status)
|
|
@@ -216,123 +220,124 @@ function renderTaskDetail(contentEl, task, currentUser, preview = false) {
|
|
| 216 |
const isLiked = Array.isArray(task.liked_by) && task.liked_by.includes(currentAccount);
|
| 217 |
const isFavorited = Array.isArray(task.favorited_by) && task.favorited_by.includes(currentAccount);
|
| 218 |
|
|
|
|
| 219 |
contentEl.innerHTML = `
|
| 220 |
<!-- 状态 + 价格 -->
|
| 221 |
-
<div
|
| 222 |
-
<span
|
| 223 |
${statusText}
|
| 224 |
</span>
|
| 225 |
-
<div
|
| 226 |
-
<div
|
| 227 |
-
<div
|
| 228 |
</div>
|
| 229 |
</div>
|
| 230 |
|
| 231 |
<!-- 标题 -->
|
| 232 |
-
<h2
|
| 233 |
${escapeHtml(task.title)}
|
| 234 |
</h2>
|
| 235 |
|
| 236 |
<!-- 发布者信息 -->
|
| 237 |
-
<div
|
| 238 |
<img src="${escapeHtml(task.publisher_avatar || PLACEHOLDERS.AVATAR_SMALL)}"
|
| 239 |
-
|
| 240 |
-
<div
|
| 241 |
-
<div
|
| 242 |
-
<div
|
| 243 |
</div>
|
| 244 |
${isPublisher && ["open", "in_progress", "submitted"].includes(task.status) ? `
|
| 245 |
-
<div
|
| 246 |
-
${IS_WEB_MODE ? '' : `<button id="btn-edit-task"
|
| 247 |
${task.status === "open" ? `
|
| 248 |
-
<button id="btn-delete-task"
|
| 249 |
` : ''}
|
| 250 |
</div>
|
| 251 |
` : ''}
|
| 252 |
${!isPublisher && currentUser ? `
|
| 253 |
-
<button id="btn-chat-publisher"
|
| 254 |
💬 ${t('common.message')}
|
| 255 |
</button>
|
| 256 |
` : ""}
|
| 257 |
</div>
|
| 258 |
|
| 259 |
<!-- 👀 浏览量统计 -->
|
| 260 |
-
<div
|
| 261 |
-
<span
|
| 262 |
-
<span
|
| 263 |
</div>
|
| 264 |
|
| 265 |
<!-- 截止日期 -->
|
| 266 |
-
<div
|
| 267 |
-
<span
|
| 268 |
<div>
|
| 269 |
-
<div
|
| 270 |
-
<div
|
| 271 |
</div>
|
| 272 |
</div>
|
| 273 |
|
| 274 |
<!-- 任务描述 -->
|
| 275 |
-
<div
|
| 276 |
-
<div
|
| 277 |
-
<div
|
| 278 |
${escapeHtml(task.description)}</div>
|
| 279 |
</div>
|
| 280 |
|
| 281 |
<!-- 参考图片 -->
|
| 282 |
${referenceImages.length > 0 ? `
|
| 283 |
-
<div
|
| 284 |
-
<div
|
| 285 |
-
<div
|
| 286 |
-
${getCoverSandboxHTML(referenceImages, true)
|
| 287 |
</div>
|
| 288 |
</div>
|
| 289 |
` : ""}
|
| 290 |
|
| 291 |
<!-- 参考链接 -->
|
| 292 |
${safeReferenceLink ? `
|
| 293 |
-
<div
|
| 294 |
-
<div
|
| 295 |
-
<a href="${escapeHtml(safeReferenceLink)}" target="_blank" rel="noopener noreferrer"
|
| 296 |
${escapeHtml(task.reference_link)}
|
| 297 |
</a>
|
| 298 |
</div>
|
| 299 |
` : ""}
|
| 300 |
|
| 301 |
<!-- 👍 互动按钮栏 -->
|
| 302 |
-
<div id="interaction-bar"
|
| 303 |
-
<button id="btn-like"
|
| 304 |
❤️ <span id="like-count">${escapeHtml(task.likes || 0)}</span>
|
| 305 |
</button>
|
| 306 |
-
<button id="btn-favorite"
|
| 307 |
🔖 <span id="favorite-count">${escapeHtml(task.favorites || 0)}</span>
|
| 308 |
</button>
|
| 309 |
-
<button id="btn-tip"
|
| 310 |
💰 ${t('post.tip_author')}
|
| 311 |
</button>
|
| 312 |
</div>
|
| 313 |
|
| 314 |
<!-- 💰 打赏榜单 -->
|
| 315 |
-
<div id="tip-board-area"
|
| 316 |
${renderTipBoard(tipBoard)}
|
| 317 |
</div>
|
| 318 |
|
| 319 |
<!-- 💬 评论区 -->
|
| 320 |
-
<div
|
| 321 |
-
<div
|
| 322 |
-
💬 ${t('post.comments_title')} <span
|
| 323 |
</div>
|
| 324 |
|
| 325 |
<!-- 评论输入框 -->
|
| 326 |
-
<div
|
| 327 |
-
<input type="text" id="comment-input" aria-label="${escapeHtml(t('post.comment_placeholder'))}" aria-required="true" required placeholder="${escapeHtml(t('post.comment_placeholder'))}"
|
| 328 |
-
<button type="button" id="btn-send-comment"
|
| 329 |
${t('common.send')}
|
| 330 |
</button>
|
| 331 |
</div>
|
| 332 |
|
| 333 |
<!-- 评论列表 -->
|
| 334 |
-
<div id="comments-list"
|
| 335 |
-
<div
|
| 336 |
${t('post.comment_loading')}
|
| 337 |
</div>
|
| 338 |
</div>
|
|
@@ -340,41 +345,41 @@ ${escapeHtml(task.description)}</div>
|
|
| 340 |
|
| 341 |
<!-- 接单者信息(如果有) -->
|
| 342 |
${task.assignee ? `
|
| 343 |
-
<div
|
| 344 |
-
<div
|
| 345 |
-
<div
|
| 346 |
-
<img src="${escapeHtml(task.assignee_avatar || PLACEHOLDERS.AVATAR_SMALL)}"
|
| 347 |
-
|
| 348 |
-
<span
|
| 349 |
</div>
|
| 350 |
</div>
|
| 351 |
` : ""}
|
| 352 |
|
| 353 |
<!-- 交付成果(如果有) -->
|
| 354 |
${deliverables.length > 0 ? `
|
| 355 |
-
<div
|
| 356 |
-
<div
|
| 357 |
-
<div
|
| 358 |
-
${getCoverSandboxHTML(deliverables, true)
|
| 359 |
</div>
|
| 360 |
-
${task.submit_note ? `<div
|
| 361 |
</div>
|
| 362 |
` : ""}
|
| 363 |
|
| 364 |
<!-- 申请者列表(仅发布者可见,且在 open 状态) -->
|
| 365 |
${isPublisher && task.status === "open" && applicants.length > 0 ? `
|
| 366 |
-
<div
|
| 367 |
-
<div
|
| 368 |
-
<div id="applicants-list"
|
| 369 |
${applicants.map(app => `
|
| 370 |
-
<div
|
| 371 |
-
<img src="${escapeHtml(app.avatar || PLACEHOLDERS.AVATAR_SMALL)}"
|
| 372 |
-
|
| 373 |
-
<div
|
| 374 |
-
<div
|
| 375 |
-
${app.message ? `<div
|
| 376 |
</div>
|
| 377 |
-
<button class="btn-assign" data-account="${escapeHtml(app.account)}"
|
| 378 |
${t('task.choose_assignee')}
|
| 379 |
</button>
|
| 380 |
</div>
|
|
@@ -384,7 +389,7 @@ ${escapeHtml(task.description)}</div>
|
|
| 384 |
` : ""}
|
| 385 |
|
| 386 |
<!-- 操作按钮区域 -->
|
| 387 |
-
<div id="action-buttons"
|
| 388 |
</div>
|
| 389 |
`;
|
| 390 |
|
|
@@ -396,7 +401,7 @@ ${escapeHtml(task.description)}</div>
|
|
| 396 |
contentEl.querySelectorAll("button, input, textarea, select").forEach(control => { control.disabled = true; });
|
| 397 |
contentEl.querySelector("#action-buttons").textContent = t('task.cached_readonly');
|
| 398 |
contentEl.querySelector("#comments-list").textContent = t('task.cached_readonly');
|
| 399 |
-
setupImageSandboxEvents(
|
| 400 |
return;
|
| 401 |
}
|
| 402 |
|
|
@@ -415,6 +420,12 @@ ${escapeHtml(task.description)}</div>
|
|
| 415 |
showDeleteTaskConfirm(task, contentEl);
|
| 416 |
});
|
| 417 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
// 绑定申请者列表的选择按钮
|
| 419 |
contentEl.querySelectorAll(".btn-assign").forEach(btn => {
|
| 420 |
btn.onclick = () => {
|
|
@@ -424,7 +435,7 @@ ${escapeHtml(task.description)}</div>
|
|
| 424 |
});
|
| 425 |
|
| 426 |
// 绑定图片沙盒交互(缩放、拖动、轮播等)
|
| 427 |
-
setupImageSandboxEvents(
|
| 428 |
|
| 429 |
// 👍 绑定互动事件(点赞/收藏/打赏)
|
| 430 |
bindTaskInteractionEvents(contentEl, task, currentUser);
|
|
@@ -440,18 +451,18 @@ function _showConfirmDialog({ scope, title, icon, message, description, confirmT
|
|
| 440 |
let phase = "idle";
|
| 441 |
const content = document.createElement("div");
|
| 442 |
content._cleanup = () => { phase = "closed"; };
|
| 443 |
-
content.
|
| 444 |
// 🔒 XSS防护:message/description 可能嵌入其他用户的账号或昵称
|
| 445 |
// (如 t('task.confirm_assign', { assignee })),写入 innerHTML 前必须转义。
|
| 446 |
// 本函数的 4 个调用方传的都是词典文本/emoji/数字,转义不改变现有显示效果。
|
| 447 |
content.innerHTML = `
|
| 448 |
-
<div
|
| 449 |
-
<div
|
| 450 |
-
<div
|
| 451 |
-
<div
|
| 452 |
-
<div
|
| 453 |
-
<button id="dialog-cancel"
|
| 454 |
-
<button id="dialog-confirm"
|
| 455 |
</div>
|
| 456 |
</div>
|
| 457 |
`;
|
|
@@ -566,15 +577,15 @@ function bindTaskTextSubmission({ content, taskId, input, button, confirmText, e
|
|
| 566 |
function showApplyDialog(task, currentUser) {
|
| 567 |
const taskId = task.id;
|
| 568 |
const content = document.createElement("div");
|
| 569 |
-
content.
|
| 570 |
content.innerHTML = `
|
| 571 |
-
<div
|
| 572 |
-
<label for="apply-message"
|
| 573 |
-
<textarea id="apply-message" rows="4" placeholder="${t('task.apply_message_placeholder')}"
|
| 574 |
</div>
|
| 575 |
-
<div
|
| 576 |
-
<button type="button" id="apply-cancel"
|
| 577 |
-
<button type="button" id="apply-confirm"
|
| 578 |
</div>
|
| 579 |
`;
|
| 580 |
|
|
@@ -680,15 +691,15 @@ function showAcceptConfirmDialog(task, currentUser) {
|
|
| 680 |
function showRejectDialog(task, currentUser) {
|
| 681 |
const taskId = task.id;
|
| 682 |
const content = document.createElement("div");
|
| 683 |
-
content.
|
| 684 |
content.innerHTML = `
|
| 685 |
-
<div
|
| 686 |
-
<label for="reject-feedback"
|
| 687 |
-
<textarea aria-required="true" id="reject-feedback" rows="4" placeholder="${t('task.reject_reason_placeholder')}"
|
| 688 |
</div>
|
| 689 |
-
<div
|
| 690 |
-
<button type="button" id="reject-cancel"
|
| 691 |
-
<button type="button" id="reject-confirm"
|
| 692 |
</div>
|
| 693 |
`;
|
| 694 |
|
|
@@ -719,15 +730,15 @@ function showDeleteCommentConfirmDialog(taskId, comment, container, currentUser)
|
|
| 719 |
let phase = "idle";
|
| 720 |
const content = document.createElement("div");
|
| 721 |
content._cleanup = () => { phase = "closed"; };
|
| 722 |
-
content.
|
| 723 |
content.innerHTML = `
|
| 724 |
-
<div
|
| 725 |
-
<div
|
| 726 |
-
<div
|
| 727 |
-
<div
|
| 728 |
-
<div
|
| 729 |
-
<button type="button" id="delete-comment-cancel"
|
| 730 |
-
<button type="button" id="delete-comment-confirm"
|
| 731 |
</div>
|
| 732 |
</div>
|
| 733 |
`;
|
|
@@ -773,23 +784,23 @@ function renderActionButtons(container, task, currentUser, isPublisher, isAssign
|
|
| 773 |
let buttons = [];
|
| 774 |
|
| 775 |
if (!currentUser) {
|
| 776 |
-
buttons.push(`<div
|
| 777 |
} else if (task.status === "open") {
|
| 778 |
if (isPublisher) {
|
| 779 |
buttons.push(`
|
| 780 |
-
<button id="btn-cancel-task"
|
| 781 |
❌ ${t('task.cancel_task')}
|
| 782 |
</button>
|
| 783 |
`);
|
| 784 |
} else if (hasApplied) {
|
| 785 |
buttons.push(`
|
| 786 |
-
<button id="btn-cancel-apply"
|
| 787 |
${t('task.cancel_apply')}
|
| 788 |
</button>
|
| 789 |
`);
|
| 790 |
} else {
|
| 791 |
buttons.push(`
|
| 792 |
-
<button id="btn-apply"
|
| 793 |
🙋 ${t('task.apply_task')}
|
| 794 |
</button>
|
| 795 |
`);
|
|
@@ -797,7 +808,7 @@ function renderActionButtons(container, task, currentUser, isPublisher, isAssign
|
|
| 797 |
} else if (task.status === "in_progress") {
|
| 798 |
if (isAssignee) {
|
| 799 |
buttons.push(`
|
| 800 |
-
<button id="btn-submit"
|
| 801 |
📤 ${t('task.submit_work')}
|
| 802 |
</button>
|
| 803 |
`);
|
|
@@ -805,10 +816,10 @@ function renderActionButtons(container, task, currentUser, isPublisher, isAssign
|
|
| 805 |
} else if (task.status === "submitted") {
|
| 806 |
if (isPublisher) {
|
| 807 |
buttons.push(`
|
| 808 |
-
<button id="btn-accept"
|
| 809 |
✅ ${t('task.accept_work')}
|
| 810 |
</button>
|
| 811 |
-
<button id="btn-reject"
|
| 812 |
❌ ${t('task.reject_work')}
|
| 813 |
</button>
|
| 814 |
`);
|
|
@@ -818,7 +829,7 @@ function renderActionButtons(container, task, currentUser, isPublisher, isAssign
|
|
| 818 |
// 申诉按钮(进行中或待验收时,双方都可以申诉)
|
| 819 |
if ((task.status === "in_progress" || task.status === "submitted") && (isPublisher || isAssignee)) {
|
| 820 |
buttons.push(`
|
| 821 |
-
<button id="btn-dispute"
|
| 822 |
⚖️ ${t('task.start_dispute')}
|
| 823 |
</button>
|
| 824 |
`);
|
|
@@ -827,7 +838,7 @@ function renderActionButtons(container, task, currentUser, isPublisher, isAssign
|
|
| 827 |
// 查看申诉详情按钮(争议中状态)
|
| 828 |
if (task.status === "disputed" && task.dispute_id && (isPublisher || isAssignee)) {
|
| 829 |
buttons.push(`
|
| 830 |
-
<button id="btn-view-dispute"
|
| 831 |
⚖️ ${t('task.view_dispute')}
|
| 832 |
</button>
|
| 833 |
`);
|
|
@@ -904,22 +915,22 @@ function showSubmitDialog(task, currentUser) {
|
|
| 904 |
let phase = "idle";
|
| 905 |
const content = document.createElement("div");
|
| 906 |
content._cleanup = () => { phase = "closed"; };
|
| 907 |
-
content.
|
| 908 |
content.innerHTML = `
|
| 909 |
-
<div
|
| 910 |
-
<label for="deliverable-files"
|
| 911 |
-
<input type="file" aria-required="true" aria-describedby="deliverable-hint" id="deliverable-files" multiple accept="image/*"
|
| 912 |
-
<div id="deliverable-hint"
|
| 913 |
</div>
|
| 914 |
|
| 915 |
-
<div
|
| 916 |
-
<label for="submit-note"
|
| 917 |
-
<textarea id="submit-note" rows="3" placeholder="${t('task.note_placeholder')}"
|
| 918 |
</div>
|
| 919 |
|
| 920 |
-
<div
|
| 921 |
-
<button type="button" id="btn-cancel-modal"
|
| 922 |
-
<button type="button" id="btn-confirm-submit"
|
| 923 |
</div>
|
| 924 |
`;
|
| 925 |
|
|
@@ -1043,13 +1054,13 @@ function showTaskTipDialog(task, currentUser, container) {
|
|
| 1043 |
let phase = "idle";
|
| 1044 |
const content = document.createElement("div");
|
| 1045 |
content._cleanup = () => { phase = "closed"; };
|
| 1046 |
-
content.
|
| 1047 |
content.innerHTML = `
|
| 1048 |
-
<div
|
| 1049 |
-
${TASK_LIMITS.TIP_AMOUNTS.map(amount => `<button type="button" class="tip-amount" data-amount="${amount}"
|
| 1050 |
</div>
|
| 1051 |
-
<div
|
| 1052 |
-
<button type="button" id="tip-cancel"
|
| 1053 |
</div>
|
| 1054 |
`;
|
| 1055 |
|
|
@@ -1110,8 +1121,8 @@ function _renderCommentAvatar(name, avatarUrl) {
|
|
| 1110 |
const initial = String(name || 'U')[0].toUpperCase();
|
| 1111 |
// 🔒 XSS防护:评论者昵称与头像均来自他人可修改的资料,写入 HTML 前必须转义
|
| 1112 |
return avatarUrl
|
| 1113 |
-
? `<img class="swr-avatar" src="${escapeHtml(avatarUrl)}"
|
| 1114 |
-
: `<div class="swr-avatar
|
| 1115 |
}
|
| 1116 |
|
| 1117 |
/**
|
|
@@ -1154,7 +1165,7 @@ async function loadTaskComments(container, taskId, currentUser) {
|
|
| 1154 |
|
| 1155 |
if (comments.length === 0) {
|
| 1156 |
commentsList.innerHTML = `
|
| 1157 |
-
<div
|
| 1158 |
${t('post.no_comments')}
|
| 1159 |
</div>
|
| 1160 |
`;
|
|
@@ -1175,16 +1186,14 @@ async function loadTaskComments(container, taskId, currentUser) {
|
|
| 1175 |
);
|
| 1176 |
|
| 1177 |
return `
|
| 1178 |
-
<div id="task-comment-${idx}" data-account="${escapeHtml(c.author)}" data-comment-id="${escapeHtml(c.id || idx)}"
|
| 1179 |
-
<div
|
| 1180 |
<div class="swr-avatar-container">${avatarHtml}</div>
|
| 1181 |
-
<span class="swr-name
|
| 1182 |
-
<span
|
| 1183 |
-
${canDelete ? `<button class="btn-delete-comment" data-idx="${idx}"
|
| 1184 |
-
</div>
|
| 1185 |
-
<div style="font-size: 13px; color: #ddd; line-height: 1.5; padding-left: 32px;">
|
| 1186 |
-
${escapeHtml(c.content)}
|
| 1187 |
</div>
|
|
|
|
| 1188 |
</div>
|
| 1189 |
`;
|
| 1190 |
}).join("");
|
|
@@ -1220,7 +1229,7 @@ async function loadTaskComments(container, taskId, currentUser) {
|
|
| 1220 |
} catch (err) {
|
| 1221 |
if (!readCurrent()) return;
|
| 1222 |
commentsList.innerHTML = `
|
| 1223 |
-
<div
|
| 1224 |
${t('post.comment_load_failed')}
|
| 1225 |
</div>
|
| 1226 |
`;
|
|
@@ -1277,7 +1286,10 @@ async function loadTaskComments(container, taskId, currentUser) {
|
|
| 1277 |
}
|
| 1278 |
};
|
| 1279 |
commentInput.onkeydown = event => {
|
| 1280 |
-
if (
|
|
|
|
|
|
|
|
|
|
| 1281 |
event.preventDefault();
|
| 1282 |
sendBtn.click();
|
| 1283 |
}
|
|
@@ -1317,15 +1329,15 @@ function showDeleteTaskConfirm(task, contentEl) {
|
|
| 1317 |
let phase = "idle";
|
| 1318 |
const content = document.createElement("div");
|
| 1319 |
content._cleanup = () => { phase = "closed"; };
|
| 1320 |
-
content.
|
| 1321 |
content.innerHTML = `
|
| 1322 |
-
<div
|
| 1323 |
-
<div
|
| 1324 |
-
<div
|
| 1325 |
-
<div
|
| 1326 |
-
<div
|
| 1327 |
-
<button id="delete-cancel"
|
| 1328 |
-
<button id="delete-confirm"
|
| 1329 |
</div>
|
| 1330 |
</div>
|
| 1331 |
`;
|
|
@@ -1403,7 +1415,7 @@ function showDisputeDialog(task, currentUser) {
|
|
| 1403 |
</div>
|
| 1404 |
</div>
|
| 1405 |
|
| 1406 |
-
<button id="btn-submit-dispute" class="task-dispute__submit">
|
| 1407 |
${t('dispute.submit')}
|
| 1408 |
</button>
|
| 1409 |
`;
|
|
|
|
| 16 |
import { removeCache, findInListCache, requireListResponseData } from "../components/性能优化工具.js";
|
| 17 |
import { globalModal } from "../components/全局弹窗管理器.js";
|
| 18 |
import { compressImageForUpload } from "../market/发布内容_提交引擎.js";
|
| 19 |
+
import { getCoverSandboxHTML, setupImageSandboxEvents, cleanupImageSandbox } from "../components/图片沙盒组件.js";
|
| 20 |
+
import { openChatModal } from "../social/私信聊天组件.js";
|
| 21 |
import { openOtherUserProfileModal } from "../profile/个人中心视图.js";
|
| 22 |
import { recordView, handleToggleLike, handleToggleFavorite, renderTipBoardHTML as renderCommonTipBoardHTML, escapeHtml } from "../components/互动工具函数.js";
|
| 23 |
|
|
|
|
| 28 |
|
| 29 |
function openTaskModal(scope, title, content, options) {
|
| 30 |
if (scope && !scope.isCurrent()) return;
|
| 31 |
+
content.classList.add("task-modal");
|
| 32 |
globalModal.openModal(title, content, options);
|
| 33 |
const modal = globalModal.activeModals.at(-1);
|
| 34 |
const release = scope?.addCleanup(() => globalModal.closeModal(modal));
|
|
|
|
| 89 |
});
|
| 90 |
scope.componentId = componentId;
|
| 91 |
scope.root = container;
|
| 92 |
+
scope.addCleanup(() => cleanupImageSandbox(container));
|
| 93 |
container._taskScope = scope;
|
| 94 |
container.querySelector("#task-content")._taskScope = scope;
|
| 95 |
container._cleanup = () => {
|
|
|
|
| 190 |
submitted: { bg: "#FF9800", textKey: "task.status_submitted" },
|
| 191 |
completed: { bg: "#9E9E9E", textKey: "task.status_completed" },
|
| 192 |
disputed: { bg: "#F44336", textKey: "task.status_disputed" },
|
| 193 |
+
cancelled: { bg: "#757575", textKey: "task.status_cancelled" },
|
| 194 |
+
expired: { bg: "#757575", textKey: "task.status_expired" }
|
| 195 |
};
|
| 196 |
|
| 197 |
const status = Object.prototype.hasOwnProperty.call(statusStyles, task.status)
|
|
|
|
| 220 |
const isLiked = Array.isArray(task.liked_by) && task.liked_by.includes(currentAccount);
|
| 221 |
const isFavorited = Array.isArray(task.favorited_by) && task.favorited_by.includes(currentAccount);
|
| 222 |
|
| 223 |
+
cleanupImageSandbox(contentEl);
|
| 224 |
contentEl.innerHTML = `
|
| 225 |
<!-- 状态 + 价格 -->
|
| 226 |
+
<div class="task-detail__summary">
|
| 227 |
+
<span class="task-detail__status" data-status="${escapeHtml(task.status)}">
|
| 228 |
${statusText}
|
| 229 |
</span>
|
| 230 |
+
<div class="task-detail__price-align">
|
| 231 |
+
<div class="task-detail__price">💰 ${escapeHtml(task.total_price)} ${t('common.credits')}</div>
|
| 232 |
+
<div class="task-detail__deposit">${t('task.deposit')} ${escapeHtml(task.deposit_ratio)}%(${escapeHtml(task.deposit_amount)} ${t('common.credits')})</div>
|
| 233 |
</div>
|
| 234 |
</div>
|
| 235 |
|
| 236 |
<!-- 标题 -->
|
| 237 |
+
<h2 class="task-detail__title">
|
| 238 |
${escapeHtml(task.title)}
|
| 239 |
</h2>
|
| 240 |
|
| 241 |
<!-- 发布者信息 -->
|
| 242 |
+
<div class="task-detail__publisher">
|
| 243 |
<img src="${escapeHtml(task.publisher_avatar || PLACEHOLDERS.AVATAR_SMALL)}"
|
| 244 |
+
class="task-detail__avatar-main" id="publisher-avatar" alt="">
|
| 245 |
+
<div class="task-detail__grow">
|
| 246 |
+
<div class="task-detail__publisher-name">${escapeHtml(task.publisher_name || task.publisher)}</div>
|
| 247 |
+
<div class="task-detail__timestamp">${t('task.published_at')} ${formatTime(task.created_at)}</div>
|
| 248 |
</div>
|
| 249 |
${isPublisher && ["open", "in_progress", "submitted"].includes(task.status) ? `
|
| 250 |
+
<div class="task-detail__compact-actions">
|
| 251 |
+
${IS_WEB_MODE ? '' : `<button type="button" id="btn-edit-task" class="task-detail__edit" title="${t('common.edit')}">✏️ ${t('common.edit')}</button>`}
|
| 252 |
${task.status === "open" ? `
|
| 253 |
+
<button type="button" id="btn-delete-task" class="task-detail__delete" title="${t('common.delete')}">🗑️ ${t('common.delete')}</button>
|
| 254 |
` : ''}
|
| 255 |
</div>
|
| 256 |
` : ''}
|
| 257 |
${!isPublisher && currentUser ? `
|
| 258 |
+
<button type="button" id="btn-chat-publisher" class="task-detail__chat">
|
| 259 |
💬 ${t('common.message')}
|
| 260 |
</button>
|
| 261 |
` : ""}
|
| 262 |
</div>
|
| 263 |
|
| 264 |
<!-- 👀 浏览量统计 -->
|
| 265 |
+
<div class="task-detail__stats">
|
| 266 |
+
<span class="task-detail__stat">${t('task.view_total')}: <span id="task-view-total" class="task-detail__value">${escapeHtml(task.views || 0)}</span></span>
|
| 267 |
+
<span class="task-detail__stat">${t('task.view_today')}: <span id="task-view-daily" class="task-detail__value">${escapeHtml(task.daily_views || 0)}</span></span>
|
| 268 |
</div>
|
| 269 |
|
| 270 |
<!-- 截止日期 -->
|
| 271 |
+
<div class="task-detail__deadline">
|
| 272 |
+
<span class="task-detail__icon">⏰</span>
|
| 273 |
<div>
|
| 274 |
+
<div class="task-detail__deadline-title">${t('task.deadline')}</div>
|
| 275 |
+
<div class="task-detail__deadline-value">${formatDeadline(task.deadline)}</div>
|
| 276 |
</div>
|
| 277 |
</div>
|
| 278 |
|
| 279 |
<!-- 任务描述 -->
|
| 280 |
+
<div class="task-detail__section">
|
| 281 |
+
<div class="task-detail__heading">📄 ${t('task.description')}</div>
|
| 282 |
+
<div class="task-detail__prose">
|
| 283 |
${escapeHtml(task.description)}</div>
|
| 284 |
</div>
|
| 285 |
|
| 286 |
<!-- 参考图片 -->
|
| 287 |
${referenceImages.length > 0 ? `
|
| 288 |
+
<div class="task-detail__section ref-images-sandbox" >
|
| 289 |
+
<div class="task-detail__heading">🖼️ ${t('task.reference_images')}</div>
|
| 290 |
+
<div class="task-detail__gallery-inner">
|
| 291 |
+
${getCoverSandboxHTML(referenceImages, true)}
|
| 292 |
</div>
|
| 293 |
</div>
|
| 294 |
` : ""}
|
| 295 |
|
| 296 |
<!-- 参考链接 -->
|
| 297 |
${safeReferenceLink ? `
|
| 298 |
+
<div class="task-detail__section">
|
| 299 |
+
<div class="task-detail__heading">🔗 ${t('task.reference_link')}</div>
|
| 300 |
+
<a href="${escapeHtml(safeReferenceLink)}" target="_blank" rel="noopener noreferrer" class="task-detail__reference-link">
|
| 301 |
${escapeHtml(task.reference_link)}
|
| 302 |
</a>
|
| 303 |
</div>
|
| 304 |
` : ""}
|
| 305 |
|
| 306 |
<!-- 👍 互动按钮栏 -->
|
| 307 |
+
<div id="interaction-bar" class="task-detail__interactions">
|
| 308 |
+
<button type="button" id="btn-like" aria-label="${escapeHtml(t('market.like'))}" class="task-detail__like" aria-pressed="${isLiked}">
|
| 309 |
❤️ <span id="like-count">${escapeHtml(task.likes || 0)}</span>
|
| 310 |
</button>
|
| 311 |
+
<button type="button" id="btn-favorite" aria-label="${escapeHtml(t('market.favorite'))}" class="task-detail__favorite" aria-pressed="${isFavorited}">
|
| 312 |
🔖 <span id="favorite-count">${escapeHtml(task.favorites || 0)}</span>
|
| 313 |
</button>
|
| 314 |
+
<button type="button" id="btn-tip" class="task-detail__tip">
|
| 315 |
💰 ${t('post.tip_author')}
|
| 316 |
</button>
|
| 317 |
</div>
|
| 318 |
|
| 319 |
<!-- 💰 打赏榜单 -->
|
| 320 |
+
<div id="tip-board-area" class="task-detail__spaced">
|
| 321 |
${renderTipBoard(tipBoard)}
|
| 322 |
</div>
|
| 323 |
|
| 324 |
<!-- 💬 评论区 -->
|
| 325 |
+
<div class="task-detail__spaced">
|
| 326 |
+
<div class="task-detail__comments-heading">
|
| 327 |
+
💬 ${t('post.comments_title')} <span class="task-detail__count">(${escapeHtml(task.comments || 0)})</span>
|
| 328 |
</div>
|
| 329 |
|
| 330 |
<!-- 评论输入框 -->
|
| 331 |
+
<div class="task-detail__comment-compose">
|
| 332 |
+
<input type="text" id="comment-input" aria-label="${escapeHtml(t('post.comment_placeholder'))}" aria-required="true" required placeholder="${escapeHtml(t('post.comment_placeholder'))}" class="task-detail__comment-input">
|
| 333 |
+
<button type="button" id="btn-send-comment" class="task-detail__comment-send">
|
| 334 |
${t('common.send')}
|
| 335 |
</button>
|
| 336 |
</div>
|
| 337 |
|
| 338 |
<!-- 评论列表 -->
|
| 339 |
+
<div id="comments-list" class="task-detail__stack">
|
| 340 |
+
<div class="task-detail__empty">
|
| 341 |
${t('post.comment_loading')}
|
| 342 |
</div>
|
| 343 |
</div>
|
|
|
|
| 345 |
|
| 346 |
<!-- 接单者信息(如果有) -->
|
| 347 |
${task.assignee ? `
|
| 348 |
+
<div class="task-detail__assignee">
|
| 349 |
+
<div class="task-detail__assignee-heading">👷 ${t('task.assignee')}</div>
|
| 350 |
+
<div class="task-detail__person">
|
| 351 |
+
<img alt="" src="${escapeHtml(task.assignee_avatar || PLACEHOLDERS.AVATAR_SMALL)}"
|
| 352 |
+
class="task-detail__avatar">
|
| 353 |
+
<span class="task-detail__person-name">${escapeHtml(task.assignee_name || task.assignee)}</span>
|
| 354 |
</div>
|
| 355 |
</div>
|
| 356 |
` : ""}
|
| 357 |
|
| 358 |
<!-- 交付成果(如果有) -->
|
| 359 |
${deliverables.length > 0 ? `
|
| 360 |
+
<div class="task-detail__delivery deliverables-sandbox" >
|
| 361 |
+
<div class="task-detail__delivery-heading">📦 ${t('task.deliverables')}</div>
|
| 362 |
+
<div class="task-detail__gallery-inner">
|
| 363 |
+
${getCoverSandboxHTML(deliverables, true)}
|
| 364 |
</div>
|
| 365 |
+
${task.submit_note ? `<div class="task-detail__note">${t('task.note')}: ${escapeHtml(task.submit_note)}</div>` : ""}
|
| 366 |
</div>
|
| 367 |
` : ""}
|
| 368 |
|
| 369 |
<!-- 申请者列表(仅发布者可见,且在 open 状态) -->
|
| 370 |
${isPublisher && task.status === "open" && applicants.length > 0 ? `
|
| 371 |
+
<div class="task-detail__section">
|
| 372 |
+
<div class="task-detail__heading">👥 ${t('task.applicants')}(${applicants.length}${t('task.applicants_count')})</div>
|
| 373 |
+
<div id="applicants-list" class="task-detail__applicants">
|
| 374 |
${applicants.map(app => `
|
| 375 |
+
<div class="task-detail__applicant">
|
| 376 |
+
<img alt="" src="${escapeHtml(app.avatar || PLACEHOLDERS.AVATAR_SMALL)}"
|
| 377 |
+
class="task-detail__avatar">
|
| 378 |
+
<div class="task-detail__grow">
|
| 379 |
+
<div class="task-detail__applicant-name">${escapeHtml(app.name || app.account)}</div>
|
| 380 |
+
${app.message ? `<div class="task-detail__applicant-message">${escapeHtml(app.message)}</div>` : ""}
|
| 381 |
</div>
|
| 382 |
+
<button type="button" class="btn-assign task-detail__assign" data-account="${escapeHtml(app.account)}" >
|
| 383 |
${t('task.choose_assignee')}
|
| 384 |
</button>
|
| 385 |
</div>
|
|
|
|
| 389 |
` : ""}
|
| 390 |
|
| 391 |
<!-- 操作按钮区域 -->
|
| 392 |
+
<div id="action-buttons" class="task-detail__actions">
|
| 393 |
</div>
|
| 394 |
`;
|
| 395 |
|
|
|
|
| 401 |
contentEl.querySelectorAll("button, input, textarea, select").forEach(control => { control.disabled = true; });
|
| 402 |
contentEl.querySelector("#action-buttons").textContent = t('task.cached_readonly');
|
| 403 |
contentEl.querySelector("#comments-list").textContent = t('task.cached_readonly');
|
| 404 |
+
contentEl.querySelectorAll(".img-viewport").forEach(viewport => setupImageSandboxEvents(viewport));
|
| 405 |
return;
|
| 406 |
}
|
| 407 |
|
|
|
|
| 420 |
showDeleteTaskConfirm(task, contentEl);
|
| 421 |
});
|
| 422 |
|
| 423 |
+
contentEl.querySelector("#btn-chat-publisher")?.addEventListener("click", () => {
|
| 424 |
+
const scope = taskScopes.get(task);
|
| 425 |
+
if (!scope?.isCurrent() || typeof task.publisher !== "string" || !task.publisher.trim()) return;
|
| 426 |
+
openChatModal(currentUser, task.publisher);
|
| 427 |
+
});
|
| 428 |
+
|
| 429 |
// 绑定申请者列表的选择按钮
|
| 430 |
contentEl.querySelectorAll(".btn-assign").forEach(btn => {
|
| 431 |
btn.onclick = () => {
|
|
|
|
| 435 |
});
|
| 436 |
|
| 437 |
// 绑定图片沙盒交互(缩放、拖动、轮播等)
|
| 438 |
+
contentEl.querySelectorAll(".img-viewport").forEach(viewport => setupImageSandboxEvents(viewport));
|
| 439 |
|
| 440 |
// 👍 绑定互动事件(点赞/收藏/打赏)
|
| 441 |
bindTaskInteractionEvents(contentEl, task, currentUser);
|
|
|
|
| 451 |
let phase = "idle";
|
| 452 |
const content = document.createElement("div");
|
| 453 |
content._cleanup = () => { phase = "closed"; };
|
| 454 |
+
content.classList.add("task-modal");
|
| 455 |
// 🔒 XSS防护:message/description 可能嵌入其他用户的账号或昵称
|
| 456 |
// (如 t('task.confirm_assign', { assignee })),写入 innerHTML 前必须转义。
|
| 457 |
// 本函数的 4 个调用方传的都是词典文本/emoji/数字,转义不改变现有显示效果。
|
| 458 |
content.innerHTML = `
|
| 459 |
+
<div class="task-detail__centered">
|
| 460 |
+
<div class="task-detail__dialog-icon">${icon}</div>
|
| 461 |
+
<div class="task-detail__dialog-message">${escapeHtml(message)}</div>
|
| 462 |
+
<div class="task-detail__dialog-description">${escapeHtml(description)}</div>
|
| 463 |
+
<div class="task-detail__buttons">
|
| 464 |
+
<button type="button" id="dialog-cancel" class="task-detail__cancel">${t('common.cancel')}</button>
|
| 465 |
+
<button type="button" id="dialog-confirm" class="task-detail__confirm" data-tone="${confirmStyle === 'background: #F44336' ? 'danger' : confirmStyle === 'background: #FF9800' ? 'warning' : 'success'}">${escapeHtml(confirmText)}</button>
|
| 466 |
</div>
|
| 467 |
</div>
|
| 468 |
`;
|
|
|
|
| 577 |
function showApplyDialog(task, currentUser) {
|
| 578 |
const taskId = task.id;
|
| 579 |
const content = document.createElement("div");
|
| 580 |
+
content.classList.add("task-modal");
|
| 581 |
content.innerHTML = `
|
| 582 |
+
<div class="task-detail__spaced">
|
| 583 |
+
<label for="apply-message" class="task-detail__label">${t('task.apply_message_prompt')}</label>
|
| 584 |
+
<textarea id="apply-message" rows="4" placeholder="${t('task.apply_message_placeholder')}" class="task-detail__textarea"></textarea>
|
| 585 |
</div>
|
| 586 |
+
<div class="task-detail__buttons">
|
| 587 |
+
<button type="button" id="apply-cancel" class="task-detail__cancel">${t('common.cancel')}</button>
|
| 588 |
+
<button type="button" id="apply-confirm" class="task-detail__submit-warning">${t('common.confirm_submit')}</button>
|
| 589 |
</div>
|
| 590 |
`;
|
| 591 |
|
|
|
|
| 691 |
function showRejectDialog(task, currentUser) {
|
| 692 |
const taskId = task.id;
|
| 693 |
const content = document.createElement("div");
|
| 694 |
+
content.classList.add("task-modal");
|
| 695 |
content.innerHTML = `
|
| 696 |
+
<div class="task-detail__spaced">
|
| 697 |
+
<label for="reject-feedback" class="task-detail__label">${t('task.reject_reason_prompt')}</label>
|
| 698 |
+
<textarea aria-required="true" id="reject-feedback" rows="4" placeholder="${t('task.reject_reason_placeholder')}" class="task-detail__textarea"></textarea>
|
| 699 |
</div>
|
| 700 |
+
<div class="task-detail__buttons">
|
| 701 |
+
<button type="button" id="reject-cancel" class="task-detail__cancel">${t('common.cancel')}</button>
|
| 702 |
+
<button type="button" id="reject-confirm" class="task-detail__submit-danger">${t('task.reject_work')}</button>
|
| 703 |
</div>
|
| 704 |
`;
|
| 705 |
|
|
|
|
| 730 |
let phase = "idle";
|
| 731 |
const content = document.createElement("div");
|
| 732 |
content._cleanup = () => { phase = "closed"; };
|
| 733 |
+
content.classList.add("task-modal");
|
| 734 |
content.innerHTML = `
|
| 735 |
+
<div class="task-detail__centered">
|
| 736 |
+
<div class="task-detail__dialog-icon">⚠️</div>
|
| 737 |
+
<div class="task-detail__dialog-message">${t('post.delete_comment_confirm')}</div>
|
| 738 |
+
<div class="task-detail__dialog-description">${t('post.delete_comment_warning')}</div>
|
| 739 |
+
<div class="task-detail__buttons">
|
| 740 |
+
<button type="button" id="delete-comment-cancel" class="task-detail__cancel">${t('common.cancel')}</button>
|
| 741 |
+
<button type="button" id="delete-comment-confirm" class="task-detail__submit-danger">${t('common.delete')}</button>
|
| 742 |
</div>
|
| 743 |
</div>
|
| 744 |
`;
|
|
|
|
| 784 |
let buttons = [];
|
| 785 |
|
| 786 |
if (!currentUser) {
|
| 787 |
+
buttons.push(`<div class="task-detail__login">${t('common.login_required')}</div>`);
|
| 788 |
} else if (task.status === "open") {
|
| 789 |
if (isPublisher) {
|
| 790 |
buttons.push(`
|
| 791 |
+
<button type="button" id="btn-cancel-task" class="task-detail__action-danger">
|
| 792 |
❌ ${t('task.cancel_task')}
|
| 793 |
</button>
|
| 794 |
`);
|
| 795 |
} else if (hasApplied) {
|
| 796 |
buttons.push(`
|
| 797 |
+
<button type="button" id="btn-cancel-apply" class="task-detail__action-neutral">
|
| 798 |
${t('task.cancel_apply')}
|
| 799 |
</button>
|
| 800 |
`);
|
| 801 |
} else {
|
| 802 |
buttons.push(`
|
| 803 |
+
<button type="button" id="btn-apply" class="task-detail__action-warning">
|
| 804 |
🙋 ${t('task.apply_task')}
|
| 805 |
</button>
|
| 806 |
`);
|
|
|
|
| 808 |
} else if (task.status === "in_progress") {
|
| 809 |
if (isAssignee) {
|
| 810 |
buttons.push(`
|
| 811 |
+
<button type="button" id="btn-submit" class="task-detail__action-success">
|
| 812 |
📤 ${t('task.submit_work')}
|
| 813 |
</button>
|
| 814 |
`);
|
|
|
|
| 816 |
} else if (task.status === "submitted") {
|
| 817 |
if (isPublisher) {
|
| 818 |
buttons.push(`
|
| 819 |
+
<button type="button" id="btn-accept" class="task-detail__action-accept">
|
| 820 |
✅ ${t('task.accept_work')}
|
| 821 |
</button>
|
| 822 |
+
<button type="button" id="btn-reject" class="task-detail__action-danger">
|
| 823 |
❌ ${t('task.reject_work')}
|
| 824 |
</button>
|
| 825 |
`);
|
|
|
|
| 829 |
// 申诉按钮(进行中或待验收时,双方都可以申诉)
|
| 830 |
if ((task.status === "in_progress" || task.status === "submitted") && (isPublisher || isAssignee)) {
|
| 831 |
buttons.push(`
|
| 832 |
+
<button type="button" id="btn-dispute" class="task-detail__action-dispute">
|
| 833 |
⚖️ ${t('task.start_dispute')}
|
| 834 |
</button>
|
| 835 |
`);
|
|
|
|
| 838 |
// 查看申诉详情按钮(争议中状态)
|
| 839 |
if (task.status === "disputed" && task.dispute_id && (isPublisher || isAssignee)) {
|
| 840 |
buttons.push(`
|
| 841 |
+
<button type="button" id="btn-view-dispute" class="task-detail__action-dispute-view">
|
| 842 |
⚖️ ${t('task.view_dispute')}
|
| 843 |
</button>
|
| 844 |
`);
|
|
|
|
| 915 |
let phase = "idle";
|
| 916 |
const content = document.createElement("div");
|
| 917 |
content._cleanup = () => { phase = "closed"; };
|
| 918 |
+
content.classList.add("task-modal");
|
| 919 |
content.innerHTML = `
|
| 920 |
+
<div class="task-detail__spaced">
|
| 921 |
+
<label for="deliverable-files" class="task-detail__file-label">${t('task.deliverable_images')} <span aria-hidden="true">*</span></label>
|
| 922 |
+
<input type="file" aria-required="true" aria-describedby="deliverable-hint" id="deliverable-files" multiple accept="image/*" class="task-detail__file-input">
|
| 923 |
+
<div id="deliverable-hint" class="task-detail__hint">${t('task.support_multiple_images')}</div>
|
| 924 |
</div>
|
| 925 |
|
| 926 |
+
<div class="task-detail__section">
|
| 927 |
+
<label for="submit-note" class="task-detail__file-label">${t('task.note_optional')}</label>
|
| 928 |
+
<textarea id="submit-note" rows="3" placeholder="${t('task.note_placeholder')}" class="task-detail__note-input"></textarea>
|
| 929 |
</div>
|
| 930 |
|
| 931 |
+
<div class="task-detail__buttons">
|
| 932 |
+
<button type="button" id="btn-cancel-modal" class="task-detail__cancel-padded">${t('common.cancel')}</button>
|
| 933 |
+
<button type="button" id="btn-confirm-submit" class="task-detail__submit-success">${t('common.confirm_submit')}</button>
|
| 934 |
</div>
|
| 935 |
`;
|
| 936 |
|
|
|
|
| 1054 |
let phase = "idle";
|
| 1055 |
const content = document.createElement("div");
|
| 1056 |
content._cleanup = () => { phase = "closed"; };
|
| 1057 |
+
content.classList.add("task-modal");
|
| 1058 |
content.innerHTML = `
|
| 1059 |
+
<div class="task-detail__tip-options">
|
| 1060 |
+
${TASK_LIMITS.TIP_AMOUNTS.map(amount => `<button type="button" class="tip-amount task-detail__tip-option" data-amount="${amount}" >${amount} ${t('task.points')}</button>`).join("")}
|
| 1061 |
</div>
|
| 1062 |
+
<div class="task-detail__buttons">
|
| 1063 |
+
<button type="button" id="tip-cancel" class="task-detail__cancel">${t('common.cancel')}</button>
|
| 1064 |
</div>
|
| 1065 |
`;
|
| 1066 |
|
|
|
|
| 1121 |
const initial = String(name || 'U')[0].toUpperCase();
|
| 1122 |
// 🔒 XSS防护:评论者昵称与头像均来自他人可修改的资料,写入 HTML 前必须转义
|
| 1123 |
return avatarUrl
|
| 1124 |
+
? `<img alt="" class="swr-avatar task-detail__comment-avatar" src="${escapeHtml(avatarUrl)}" >`
|
| 1125 |
+
: `<div class="swr-avatar task-detail__comment-avatar-fallback" >${escapeHtml(initial)}</div>`;
|
| 1126 |
}
|
| 1127 |
|
| 1128 |
/**
|
|
|
|
| 1165 |
|
| 1166 |
if (comments.length === 0) {
|
| 1167 |
commentsList.innerHTML = `
|
| 1168 |
+
<div class="task-detail__comments-empty">
|
| 1169 |
${t('post.no_comments')}
|
| 1170 |
</div>
|
| 1171 |
`;
|
|
|
|
| 1186 |
);
|
| 1187 |
|
| 1188 |
return `
|
| 1189 |
+
<div id="task-comment-${idx}" data-account="${escapeHtml(c.author)}" data-comment-id="${escapeHtml(c.id || idx)}" class="task-detail__comment">
|
| 1190 |
+
<div class="task-detail__comment-meta">
|
| 1191 |
<div class="swr-avatar-container">${avatarHtml}</div>
|
| 1192 |
+
<span class="swr-name task-detail__comment-name" >${escapeHtml(name)}</span>
|
| 1193 |
+
<span class="task-detail__comment-time">${formatTime(c.created_at)}</span>
|
| 1194 |
+
${canDelete ? `<button type="button" aria-label="${escapeHtml(t('social.delete_comment_title'))}" class="btn-delete-comment task-detail__comment-delete" data-idx="${idx}" >🗑️</button>` : ''}
|
|
|
|
|
|
|
|
|
|
| 1195 |
</div>
|
| 1196 |
+
<div class="task-detail__comment-body">${escapeHtml(c.content)}</div>
|
| 1197 |
</div>
|
| 1198 |
`;
|
| 1199 |
}).join("");
|
|
|
|
| 1229 |
} catch (err) {
|
| 1230 |
if (!readCurrent()) return;
|
| 1231 |
commentsList.innerHTML = `
|
| 1232 |
+
<div class="task-detail__comments-error">
|
| 1233 |
${t('post.comment_load_failed')}
|
| 1234 |
</div>
|
| 1235 |
`;
|
|
|
|
| 1286 |
}
|
| 1287 |
};
|
| 1288 |
commentInput.onkeydown = event => {
|
| 1289 |
+
if (isCurrent() && !event.defaultPrevented && event.key === "Enter"
|
| 1290 |
+
&& !event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey
|
| 1291 |
+
&& !event.isComposing && event.keyCode !== 229 && !event.repeat
|
| 1292 |
+
&& !commentInput.disabled && !commentInput.readOnly) {
|
| 1293 |
event.preventDefault();
|
| 1294 |
sendBtn.click();
|
| 1295 |
}
|
|
|
|
| 1329 |
let phase = "idle";
|
| 1330 |
const content = document.createElement("div");
|
| 1331 |
content._cleanup = () => { phase = "closed"; };
|
| 1332 |
+
content.classList.add("task-modal");
|
| 1333 |
content.innerHTML = `
|
| 1334 |
+
<div class="task-detail__centered">
|
| 1335 |
+
<div class="task-detail__dialog-icon">⚠️</div>
|
| 1336 |
+
<div class="task-detail__dialog-message">${t('task.delete_confirm_title')}</div>
|
| 1337 |
+
<div class="task-detail__dialog-description">${t('task.delete_confirm_desc')}</div>
|
| 1338 |
+
<div class="task-detail__buttons">
|
| 1339 |
+
<button type="button" id="delete-cancel" class="task-detail__cancel">${t('common.cancel')}</button>
|
| 1340 |
+
<button type="button" id="delete-confirm" class="task-detail__submit-danger">${t('common.delete')}</button>
|
| 1341 |
</div>
|
| 1342 |
</div>
|
| 1343 |
`;
|
|
|
|
| 1415 |
</div>
|
| 1416 |
</div>
|
| 1417 |
|
| 1418 |
+
<button type="button" id="btn-submit-dispute" class="task-dispute__submit">
|
| 1419 |
${t('dispute.submit')}
|
| 1420 |
</button>
|
| 1421 |
`;
|
前端页面/task/发布任务组件.js
CHANGED
|
@@ -97,18 +97,7 @@ export function createPublishTaskView(currentUser, editTaskData = null) {
|
|
| 97 |
const isLockedStatus = editTaskData && ["in_progress", "submitted"].includes(editTaskData.status);
|
| 98 |
|
| 99 |
const container = document.createElement("div");
|
| 100 |
-
|
| 101 |
-
display: "flex",
|
| 102 |
-
flexDirection: "column",
|
| 103 |
-
gap: "0",
|
| 104 |
-
color: "#ccc",
|
| 105 |
-
fontSize: "14px",
|
| 106 |
-
padding: "0",
|
| 107 |
-
overflowY: "auto",
|
| 108 |
-
flex: "1",
|
| 109 |
-
boxSizing: "border-box",
|
| 110 |
-
background: "#121212"
|
| 111 |
-
});
|
| 112 |
|
| 113 |
// 使用本地日历日期,不通过 UTC 转换选择器的日期。
|
| 114 |
const tomorrow = new Date();
|
|
@@ -127,114 +116,114 @@ export function createPublishTaskView(currentUser, editTaskData = null) {
|
|
| 127 |
|
| 128 |
container.innerHTML = `
|
| 129 |
<!-- 顶部导航 -->
|
| 130 |
-
<div
|
| 131 |
-
<button type="button" id="btn-back"
|
| 132 |
⬅ ${t('common.back')}
|
| 133 |
</button>
|
| 134 |
-
<span
|
| 135 |
-
<div
|
| 136 |
</div>
|
| 137 |
|
| 138 |
<!-- 表单内容 -->
|
| 139 |
-
<div
|
| 140 |
|
| 141 |
<!-- 任务标题 -->
|
| 142 |
<div>
|
| 143 |
-
<label for="task-title"
|
| 144 |
-
📌 ${t('task.task_title')} <span aria-hidden="true"
|
| 145 |
</label>
|
| 146 |
<input type="text" id="task-title" required maxlength="50" placeholder="${t('task.title_placeholder')}" value="${escapeHtml(editTitle)}"
|
| 147 |
-
|
| 148 |
</div>
|
| 149 |
|
| 150 |
<!-- 任务描述 -->
|
| 151 |
<div>
|
| 152 |
-
<label for="task-description"
|
| 153 |
-
📄 ${t('task.description')} <span aria-hidden="true"
|
| 154 |
</label>
|
| 155 |
<textarea id="task-description" required aria-describedby="desc-count" rows="6" maxlength="2000" placeholder="${t('task.description_placeholder')}"
|
| 156 |
-
|
| 157 |
-
<div
|
| 158 |
<span id="desc-count">${editDescription.length}</span>/2000
|
| 159 |
</div>
|
| 160 |
</div>
|
| 161 |
|
| 162 |
<!-- 参考图片 -->
|
| 163 |
<div>
|
| 164 |
-
<label for="ref-images"
|
| 165 |
🖼️ ${t('task.reference_images_optional')}
|
| 166 |
</label>
|
| 167 |
<input type="file" id="ref-images" multiple accept="image/*"
|
| 168 |
style="display: none;">
|
| 169 |
-
<div id="image-preview"
|
| 170 |
-
<button type="button" id="btn-add-image"
|
| 171 |
+ ${t('task.add_reference_image')}
|
| 172 |
</button>
|
| 173 |
</div>
|
| 174 |
|
| 175 |
<!-- 参考链接 -->
|
| 176 |
<div>
|
| 177 |
-
<label for="ref-link"
|
| 178 |
🔗 ${t('task.reference_link_optional')}
|
| 179 |
</label>
|
| 180 |
<input type="url" id="ref-link" placeholder="https://..." value="${escapeHtml(editRefLink)}"
|
| 181 |
-
|
| 182 |
</div>
|
| 183 |
|
| 184 |
<!-- 价格设置 -->
|
| 185 |
-
<div
|
| 186 |
<!-- 总价格 -->
|
| 187 |
<div>
|
| 188 |
-
<label for="total-price"
|
| 189 |
-
💰 ${t('task.total_price')} <span aria-hidden="true"
|
| 190 |
</label>
|
| 191 |
-
<div
|
| 192 |
<input type="number" id="total-price" required aria-describedby="task-price-hint" min="0" max="${TASK_LIMITS.MAX_PRICE}" step="1" placeholder="100" value="${escapeHtml(editTotalPrice)}" ${isLockedStatus ? 'disabled' : ''}
|
| 193 |
-
|
| 194 |
-
<span
|
| 195 |
</div>
|
| 196 |
-
<div id="task-price-hint"
|
| 197 |
</div>
|
| 198 |
|
| 199 |
<!-- 订金比例 -->
|
| 200 |
<div>
|
| 201 |
-
<label for="deposit-ratio"
|
| 202 |
-
📊 ${t('task.deposit_ratio')} <span aria-hidden="true"
|
| 203 |
</label>
|
| 204 |
-
<select id="deposit-ratio" required aria-describedby="task-deposit-hint" ${isLockedStatus ? 'disabled' : ''}
|
| 205 |
${TASK_LIMITS.DEPOSIT_RATIOS.map(ratio => `<option value="${ratio}" ${editDepositRatio == ratio ? 'selected' : ''}>${ratio}%</option>`).join('')}
|
| 206 |
</select>
|
| 207 |
-
<div id="task-deposit-hint"
|
| 208 |
</div>
|
| 209 |
</div>
|
| 210 |
|
| 211 |
<!-- 价格说明 -->
|
| 212 |
-
<div id="price-summary"
|
| 213 |
-
<div
|
| 214 |
-
<div
|
| 215 |
-
${t('task.total_price')}: <span id="summary-total"
|
| 216 |
-
${t('task.deposit_on_assign')}: <span id="summary-deposit"
|
| 217 |
${t('task.remaining_on_accept')}: <span id="summary-remaining">0</span> ${t('common.credits')}
|
| 218 |
</div>
|
| 219 |
</div>
|
| 220 |
|
| 221 |
<!-- 截止日期 -->
|
| 222 |
<div>
|
| 223 |
-
<label for="deadline"
|
| 224 |
-
⏰ ${t('task.deadline')} <span aria-hidden="true"
|
| 225 |
</label>
|
| 226 |
<input type="date" id="deadline" required ${isLockedStatus ? 'aria-describedby="task-deadline-hint"' : ''} min="${minDate}" value="${defaultDate}" ${isLockedStatus ? 'disabled' : ''}
|
| 227 |
-
|
| 228 |
-
${isLockedStatus ? `<div id="task-deadline-hint"
|
| 229 |
</div>
|
| 230 |
|
| 231 |
<!-- 提交按钮 -->
|
| 232 |
-
<button type="button" id="btn-publish"
|
| 233 |
🚀 ${isEditMode ? t('task.save_changes') : t('task.publish_task')}
|
| 234 |
</button>
|
| 235 |
|
| 236 |
<!-- 提示 -->
|
| 237 |
-
<div
|
| 238 |
${t('task.manage_in_task_list')}
|
| 239 |
</div>
|
| 240 |
</div>
|
|
@@ -396,9 +385,9 @@ export function createPublishTaskView(currentUser, editTaskData = null) {
|
|
| 396 |
|
| 397 |
const renderImagePreview = () => {
|
| 398 |
imagePreview.innerHTML = selectedImages.map((img, i) => `
|
| 399 |
-
<div
|
| 400 |
-
<img alt="${escapeHtml(t('task.reference_images'))} ${i + 1}" src="${escapeHtml(img.dataUrl)}"
|
| 401 |
-
<button type="button" aria-label="${escapeHtml(t('task.remove_reference_image', { index: i + 1 }))}" class="btn-remove-img" data-index="${i}"
|
| 402 |
</div>
|
| 403 |
`).join("");
|
| 404 |
|
|
|
|
| 97 |
const isLockedStatus = editTaskData && ["in_progress", "submitted"].includes(editTaskData.status);
|
| 98 |
|
| 99 |
const container = document.createElement("div");
|
| 100 |
+
container.className = "task-publish ranking-publish-form";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
// 使用本地日历日期,不通过 UTC 转换选择器的日期。
|
| 103 |
const tomorrow = new Date();
|
|
|
|
| 116 |
|
| 117 |
container.innerHTML = `
|
| 118 |
<!-- 顶部导航 -->
|
| 119 |
+
<div class="task-publish__header">
|
| 120 |
+
<button type="button" id="btn-back" class="task-publish__back">
|
| 121 |
⬅ ${t('common.back')}
|
| 122 |
</button>
|
| 123 |
+
<span class="task-publish__heading">📝 ${isEditMode ? t('task.edit_task') : t('task.publish')}</span>
|
| 124 |
+
<div class="task-publish__header-spacer"></div>
|
| 125 |
</div>
|
| 126 |
|
| 127 |
<!-- 表单内容 -->
|
| 128 |
+
<div class="task-publish__body">
|
| 129 |
|
| 130 |
<!-- 任务标题 -->
|
| 131 |
<div>
|
| 132 |
+
<label for="task-title" class="task-publish__label">
|
| 133 |
+
📌 ${t('task.task_title')} <span aria-hidden="true" class="task-publish__required">*</span>
|
| 134 |
</label>
|
| 135 |
<input type="text" id="task-title" required maxlength="50" placeholder="${t('task.title_placeholder')}" value="${escapeHtml(editTitle)}"
|
| 136 |
+
class="task-publish__input">
|
| 137 |
</div>
|
| 138 |
|
| 139 |
<!-- 任务描述 -->
|
| 140 |
<div>
|
| 141 |
+
<label for="task-description" class="task-publish__label">
|
| 142 |
+
📄 ${t('task.description')} <span aria-hidden="true" class="task-publish__required">*</span>
|
| 143 |
</label>
|
| 144 |
<textarea id="task-description" required aria-describedby="desc-count" rows="6" maxlength="2000" placeholder="${t('task.description_placeholder')}"
|
| 145 |
+
class="task-publish__textarea">${escapeHtml(editDescription)}</textarea>
|
| 146 |
+
<div class="task-publish__counter">
|
| 147 |
<span id="desc-count">${editDescription.length}</span>/2000
|
| 148 |
</div>
|
| 149 |
</div>
|
| 150 |
|
| 151 |
<!-- 参考图片 -->
|
| 152 |
<div>
|
| 153 |
+
<label for="ref-images" class="task-publish__label">
|
| 154 |
🖼️ ${t('task.reference_images_optional')}
|
| 155 |
</label>
|
| 156 |
<input type="file" id="ref-images" multiple accept="image/*"
|
| 157 |
style="display: none;">
|
| 158 |
+
<div id="image-preview" class="task-publish__images"></div>
|
| 159 |
+
<button type="button" id="btn-add-image" class="task-publish__add-image">
|
| 160 |
+ ${t('task.add_reference_image')}
|
| 161 |
</button>
|
| 162 |
</div>
|
| 163 |
|
| 164 |
<!-- 参考链接 -->
|
| 165 |
<div>
|
| 166 |
+
<label for="ref-link" class="task-publish__label">
|
| 167 |
🔗 ${t('task.reference_link_optional')}
|
| 168 |
</label>
|
| 169 |
<input type="url" id="ref-link" placeholder="https://..." value="${escapeHtml(editRefLink)}"
|
| 170 |
+
class="task-publish__input">
|
| 171 |
</div>
|
| 172 |
|
| 173 |
<!-- 价格设置 -->
|
| 174 |
+
<div class="task-publish__price-grid">
|
| 175 |
<!-- 总价格 -->
|
| 176 |
<div>
|
| 177 |
+
<label for="total-price" class="task-publish__label">
|
| 178 |
+
💰 ${t('task.total_price')} <span aria-hidden="true" class="task-publish__required">*</span>
|
| 179 |
</label>
|
| 180 |
+
<div class="task-publish__amount-wrap">
|
| 181 |
<input type="number" id="total-price" required aria-describedby="task-price-hint" min="0" max="${TASK_LIMITS.MAX_PRICE}" step="1" placeholder="100" value="${escapeHtml(editTotalPrice)}" ${isLockedStatus ? 'disabled' : ''}
|
| 182 |
+
class="task-publish__amount">
|
| 183 |
+
<span class="task-publish__credits">${t('common.credits')}</span>
|
| 184 |
</div>
|
| 185 |
+
<div id="task-price-hint" class="task-publish__hint">${isLockedStatus ? t('task.price_locked') : t('task.price_range', { max: TASK_LIMITS.MAX_PRICE })}</div>
|
| 186 |
</div>
|
| 187 |
|
| 188 |
<!-- 订金比例 -->
|
| 189 |
<div>
|
| 190 |
+
<label for="deposit-ratio" class="task-publish__label">
|
| 191 |
+
📊 ${t('task.deposit_ratio')} <span aria-hidden="true" class="task-publish__required">*</span>
|
| 192 |
</label>
|
| 193 |
+
<select id="deposit-ratio" required aria-describedby="task-deposit-hint" ${isLockedStatus ? 'disabled' : ''} class="task-publish__select">
|
| 194 |
${TASK_LIMITS.DEPOSIT_RATIOS.map(ratio => `<option value="${ratio}" ${editDepositRatio == ratio ? 'selected' : ''}>${ratio}%</option>`).join('')}
|
| 195 |
</select>
|
| 196 |
+
<div id="task-deposit-hint" class="task-publish__hint">${isLockedStatus ? t('task.deposit_locked') : t('task.deposit_deducted_on_assign')}</div>
|
| 197 |
</div>
|
| 198 |
</div>
|
| 199 |
|
| 200 |
<!-- 价格说明 -->
|
| 201 |
+
<div id="price-summary" class="task-publish__summary">
|
| 202 |
+
<div class="task-publish__summary-title">💡 ${t('task.price_description')}</div>
|
| 203 |
+
<div class="task-publish__summary-body">
|
| 204 |
+
${t('task.total_price')}: <span id="summary-total" class="task-publish__summary-total">0</span> ${t('common.credits')}<br>
|
| 205 |
+
${t('task.deposit_on_assign')}: <span id="summary-deposit" class="task-publish__summary-deposit">0</span> ${t('common.credits')}<br>
|
| 206 |
${t('task.remaining_on_accept')}: <span id="summary-remaining">0</span> ${t('common.credits')}
|
| 207 |
</div>
|
| 208 |
</div>
|
| 209 |
|
| 210 |
<!-- 截止日期 -->
|
| 211 |
<div>
|
| 212 |
+
<label for="deadline" class="task-publish__label">
|
| 213 |
+
⏰ ${t('task.deadline')} <span aria-hidden="true" class="task-publish__required">*</span>
|
| 214 |
</label>
|
| 215 |
<input type="date" id="deadline" required ${isLockedStatus ? 'aria-describedby="task-deadline-hint"' : ''} min="${minDate}" value="${defaultDate}" ${isLockedStatus ? 'disabled' : ''}
|
| 216 |
+
class="task-publish__date">
|
| 217 |
+
${isLockedStatus ? `<div id="task-deadline-hint" class="task-publish__hint">${t('task.deadline_locked')}</div>` : ''}
|
| 218 |
</div>
|
| 219 |
|
| 220 |
<!-- 提交按钮 -->
|
| 221 |
+
<button type="button" id="btn-publish" class="task-publish__submit">
|
| 222 |
🚀 ${isEditMode ? t('task.save_changes') : t('task.publish_task')}
|
| 223 |
</button>
|
| 224 |
|
| 225 |
<!-- 提示 -->
|
| 226 |
+
<div class="task-publish__footer">
|
| 227 |
${t('task.manage_in_task_list')}
|
| 228 |
</div>
|
| 229 |
</div>
|
|
|
|
| 385 |
|
| 386 |
const renderImagePreview = () => {
|
| 387 |
imagePreview.innerHTML = selectedImages.map((img, i) => `
|
| 388 |
+
<div class="task-publish__image">
|
| 389 |
+
<img alt="${escapeHtml(t('task.reference_images'))} ${i + 1}" src="${escapeHtml(img.dataUrl)}" class="task-publish__preview">
|
| 390 |
+
<button type="button" aria-label="${escapeHtml(t('task.remove_reference_image', { index: i + 1 }))}" class="btn-remove-img task-publish__remove" data-index="${i}">×</button>
|
| 391 |
</div>
|
| 392 |
`).join("");
|
| 393 |
|