// ========================================== // ⚙️ Config: 参数配置 // ========================================== // -------------------------------------------------- // 🌟 动画与物理参数配置区 // -------------------------------------------------- const ANIM_CONFIG = { startY: 24, stiffness: 280, damping: 0.7, staggerDelay: 30, opacitySpeed: 0.1, // 🌟 悬停缩放配置 zoomScale: 1.05, zoomDuration: "0.6s", zoomEasing: "cubic-bezier(0.25, 0.1, 0.25, 1)" // 🌟 新增:平滑减速曲线 }; // -------------------------------------------------- // 基础仓库配置 // -------------------------------------------------- const REPO_ID = "waveshaker/loras"; const CONFIG_URL = "./models.json"; const RESOLVE_BASE = `https://huggingface.co/${REPO_ID}/resolve/main/`; const HF_BASE = "https://huggingface.co"; const DATASET_BASE = `${HF_BASE}/datasets/waveshaker/dataset_collections/tree/main/`; const SHOWCASE_BASE = `${HF_BASE}/waveshaker/loras/tree/main/comfyui_showcase/`; const GUIDE_BASE = `${HF_BASE}/waveshaker/loras/tree/main/usage_guide/`; // 全局动态配置 let siteConfig = { models: [], categories: [] }; // -------------------------------------------------- // 配置全局状态与分页配置 // -------------------------------------------------- const ITEMS_PER_PAGE = 30; let currentPage = 1; let allItems = []; let filteredItems = []; let isLoading = false; let isFiltering = false; // 🌟 核心状态:只有这里声明一次 let currentFilter = { model: [], category: [] }; let currentSort = "newest"; // 排序状态 let searchQuery = ""; // 用于保存搜索关键词; // ========================================== // 🚀 Core: 核心入口 // ========================================== async function main() { const container = document.getElementById("app"); const navContainer = document.getElementById("nav-container"); if (!container || !navContainer) return; renderFullSkeletons(container, navContainer); // 🌟 核心:必须把变量声明在 try 外面 let itemsConfig = {}; try { const siteConfigRes = await fetchWithRetry("./site_config.json", { cache: "no-store" }); if (siteConfigRes.ok) siteConfig = await siteConfigRes.json(); } catch (e) { console.warn("site_config.json 未找到"); } if (!container) return; async function fetchWithRetry(url, options, retries = 3, delay = 1000) { for (let i = 0; i < retries; i++) { try { const res = await fetch(url, options); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res; } catch (err) { if (i === retries - 1) throw err; await new Promise(r => setTimeout(r, delay)); } } } try { let tree = []; let nextUrl = `https://huggingface.co/api/models/${REPO_ID}/tree/main?recursive=true`; while (nextUrl) { const res = await fetchWithRetry(nextUrl, { cache: "no-store" }); if (!res.ok) throw new Error(`HF API: ${res.status}`); const data = await res.json(); tree = tree.concat(data); const link = res.headers.get("Link"); if (link && link.includes('rel="next"')) { const match = link.match(/<([^>]+)>;\s*rel="next"/); nextUrl = match ? match[1] : null; } else { nextUrl = null; } } // 1. 缓存获取 (避免 JSON.stringify 的巨大性能开销) try { const cachedData = localStorage.getItem("loras_config_cache"); if (cachedData) itemsConfig = JSON.parse(cachedData); const configRes = await fetchWithRetry(`${CONFIG_URL}?t=${Date.now()}`, { cache: "no-store" }); if (configRes.ok) { const newItems = (await configRes.json()).items || {}; // 🌟 修复:直接覆盖,不再做深比对 itemsConfig = newItems; localStorage.setItem("loras_config_cache", JSON.stringify(itemsConfig)); console.log("✅ 已加载最新 models.json"); } } catch (e) { console.warn("models.json 加载失败,使用旧缓存数据"); } // 2. 获取下载量 try { const statRes = await fetchWithRetry(`https://huggingface.co/api/models/${REPO_ID}`); if (statRes.ok) { const statData = await statRes.json(); const totalEl = document.getElementById("total-downloads"); if (totalEl) { totalEl.textContent = `${(statData.downloads || 0).toLocaleString()} Total Downloads`; totalEl.style.display = "inline-block"; } } } catch (e) {} // 4. 解析模型数据 const safetensors = tree.filter(i => i.type === "file" && i.path.endsWith(".safetensors")); if (safetensors.length === 0) throw new Error("No safetensors files found."); const allImagePaths = new Set(tree.filter(i => i.type === "file" && /\.(jpg|jpeg|webp)$/i.test(i.path)).map(i => i.path)); allItems = safetensors.map(file => { const parts = file.path.split("/"); const filename = parts[parts.length - 1]; const dirs = parts.slice(0, -1); const modelRaw = dirs.length >= 1 ? dirs[0].toLowerCase() : "other"; const categoryRaw = dirs.length >= 2 ? dirs[1] : "unknown"; const fileData = itemsConfig[file.path] || {}; const title = (typeof fileData.title === "string" && fileData.title.trim() !== "") ? fileData.title : filename.replace(/\.safetensors$/i, ""); const baseCoverPath = file.path.replace(/\.safetensors$/i, ""); let finalCoverExt = ".jpg"; if (allImagePaths.has(baseCoverPath + ".webp")) finalCoverExt = ".webp"; else if (allImagePaths.has(baseCoverPath + ".jpeg")) finalCoverExt = ".jpeg"; const rawUrl = RESOLVE_BASE + encodePath(baseCoverPath + finalCoverExt); let finalPreviewUrl = rawUrl; const cleanTitle = (str) => { if (!str) return ""; const match = str.match(/^.*?[::]\s*(.*)/); return match ? match[1].trim() : str.trim(); }; return { path: file.path, filename, title, cleanTitle: cleanTitle(title), // 🌟 核心:这里的 commitTimes 终于能正确读取到了 lastModified: fileData.update_time || 0, modelRaw, modelLabel: formatModelName(modelRaw), categoryRaw, categoryLabel: formatCategory(categoryRaw), // 分流后的 URL previewUrl: finalPreviewUrl, downloadUrl: RESOLVE_BASE + encodePath(file.path) + "?download=true", trigger: fileData.trigger || "-", dataset: fileData.dataset ? DATASET_BASE + fileData.dataset : "", showcase: fileData.showcase ? SHOWCASE_BASE + fileData.showcase : "", guide: fileData.guide ? GUIDE_BASE + fileData.guide : "" }; }); // 5. 排序、渲染等后续逻辑 allItems.sort((a, b) => { const idxA = siteConfig.models.findIndex(m => m.key.toLowerCase() === a.modelRaw.toLowerCase()); const idxB = siteConfig.models.findIndex(m => m.key.toLowerCase() === b.modelRaw.toLowerCase()); if (idxA !== idxB) return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB); const catA = siteConfig.categories.findIndex(c => c.key.toLowerCase() === a.categoryRaw.toLowerCase()); const catB = siteConfig.categories.findIndex(c => c.key.toLowerCase() === b.categoryRaw.toLowerCase()); if (catA !== catB) return (catA === -1 ? 999 : catA) - (catB === -1 ? 999 : catB); return a.title.localeCompare(b.title); }); const uniqueModels = [...new Set(allItems.map(i => i.modelRaw))]; const uniqueCategories = [...new Set(allItems.map(i => i.categoryRaw))]; // 🌟 加上这一句,强行按 JSON 配置排序 uniqueCategories.sort((a, b) => { const idxA = siteConfig.categories.findIndex(c => c.key.toLowerCase() === a.toLowerCase()); const idxB = siteConfig.categories.findIndex(c => c.key.toLowerCase() === b.toLowerCase()); return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB); }); // if (currentFilter.model.length === 0) currentFilter.model = [...uniqueModels]; // if (currentFilter.category.length === 0) currentFilter.category = [...uniqueCategories ]; // ----------------------------------------- // 🌟 默认选中的配置 (首次加载时生效) // ----------------------------------------- if (currentFilter.model.length === 0) { // 默认选中:Krea 2, ANIMA, Flux 1 Dev currentFilter.model = ["moss", "krea2", "anima", "flux1_dev"]; } if (currentFilter.category.length === 0) { currentFilter.category = ["CN", "KR", "JP", "WE", "Fictional"]; } renderNav(navContainer, uniqueModels, uniqueCategories); document.querySelectorAll('.skeleton-hide').forEach(el => { el.classList.remove('skeleton-hide'); }); const searchInput = document.getElementById("search-input"); if (searchInput) { searchInput.disabled = false; searchInput.style.opacity = "1"; searchInput.style.animation = "none"; searchInput.style.pointerEvents = "auto"; searchInput.style.background = "#1e1e1e"; searchInput.style.borderColor = "#333"; searchInput.style.color = "#fff"; searchInput.placeholder = "Search title or LoRA..."; searchInput.addEventListener("input", (e) => { searchQuery = e.target.value.toLowerCase().trim(); applyFilter(); }); } const sortSelect = document.getElementById("sort-select"); if (sortSelect) { sortSelect.addEventListener("change", (e) => { currentSort = e.target.value; applyFilter(); }); } isFiltering = false; applyFilter(); } catch (err) { console.error(err); container.innerHTML = `

Failed to load

${escapeHtml(err.message)}

`; } } // ========================================== // 🧩 Component: 骨架屏 (彻底防位移版) // ========================================== function renderFullSkeletons(container, navContainer) { if (navContainer && navContainer.innerHTML === "") { let fakeNavHtml = ` `; // 4. Category 骨架 fakeNavHtml += ` `; // 5. Footer 骨架 (完全对齐真实的 margin-top 1rem 及内部 gap) fakeNavHtml += `
`; navContainer.innerHTML = fakeNavHtml; } // 主内容区卡片骨架 let html = `
`; for (let i = 0; i < 15; i++) { html += `
`; } html += `
`; container.innerHTML = html; } // ========================================== // 📊 Data: 筛选与渲染主流程 // ========================================== function applyFilter() { // 1. 核心过滤 const itemsUnderCurrentNav = allItems.filter(c => { const matchModel = currentFilter.model.length > 0 && currentFilter.model.includes(c.modelRaw); const matchCat = currentFilter.category.length > 0 && currentFilter.category.includes(c.categoryRaw); return matchModel && matchCat; }); // 2. 搜索词过滤 filteredItems = itemsUnderCurrentNav.filter(c => { if (searchQuery === "") return true; const matchTitle = c.title.toLowerCase().includes(searchQuery); const loraName = c.filename.replace(/\.safetensors$/i, "").toLowerCase(); const matchLora = loraName.includes(searchQuery); return matchTitle || matchLora; }); // 🌟 3. 排序逻辑 (支持 Default, A-Z, Newest) // 🌟 3. 排序逻辑 (完善版:支持 Default, A-Z, Newest) filteredItems.sort((a, b) => { // 1. 默认排序 (Default) if (currentSort === "default") { // 返回 0 意味着保留数组原本的顺序(即来自 siteConfig 配置或 HF 仓库默认拉取的自然顺序) return 0; } // 2. 按名字 A-Z 排序 else if (currentSort === "a-z") { const strA = a.cleanTitle; const strB = b.cleanTitle; // 提取首字符 const charA = strA.charAt(0); const charB = strB.charAt(0); // 定义正则,用于判断字符类型 const isNum = /[0-9]/; const isEn = /[a-zA-Z]/; const isZh = /[\u4e00-\u9fa5]/; // 辅助函数:获取权重 (数字: 1, 英文: 2, 中文: 3, 其他: 4) const getWeight = (char) => { if (isNum.test(char)) return 1; if (isEn.test(char)) return 2; if (isZh.test(char)) return 3; return 4; }; const weightA = getWeight(charA); const weightB = getWeight(charB); // 优先按权重排序(数字排最前,英文其次,中文最后) if (weightA !== weightB) { return weightA - weightB; } // 如果权重相同,说明处于同一类别,再调用 localeCompare 进行内部排序 if (weightA === 1 || weightA === 2) { // 都是数字或都是英文:忽略大小写进行严格 A-Z 排序 return strA.localeCompare(strB, 'en', { numeric: true, sensitivity: 'base' }); } else if (weightA === 3) { // 都是中文:按拼音 A-Z 排序 return strA.localeCompare(strB, 'zh-CN'); } else { // 都是其他符号 return strA.localeCompare(strB); } } // 3. 按最新上传时间排序 else if (currentSort === "newest") { // 这里根据 HF 文件最后修改时间(时间戳)降序排列,最新的在最前面 const timeA = a.lastModified || 0; const timeB = b.lastModified || 0; return timeB - timeA; } // 兜底:如果 currentSort 出现未知的值,保持原样 return 0; }); // 4. 渲染卡片 currentPage = 1; isFiltering = true; // 🌟 更新底部动态筛选结果 const countText = document.getElementById("selected-count-text"); if (countText) countText.textContent = filteredItems.length; const container = document.getElementById("app"); if (!container) return; if (filteredItems.length === 0) { container.innerHTML = '
No models found for this query.
'; isFiltering = false; } else { container.style.transition = 'opacity 0.2s ease-in-out'; container.style.opacity = '0'; setTimeout(() => { renderGrid(); container.style.opacity = '1'; }, 200); } } // ========================================== // 🧩 Component: 渲染卡片网格 // ========================================== function renderGrid() { const container = document.getElementById("app"); if (!container) return; container.innerHTML = `
`; const grid = document.getElementById("card-grid"); const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; const itemsToRender = filteredItems.slice(startIndex, startIndex + ITEMS_PER_PAGE); let html = ""; itemsToRender.forEach((item, index) => { // 计算是否是首屏(前 8 个) const isFirstScreen = currentPage === 1 && index < 8; // 直接调用函数,把需要的参数传进去 html += getCardHTML(item, index, isFirstScreen, isFiltering); }); grid.innerHTML = html; observeImages(grid); if (isFiltering) { const cards = grid.querySelectorAll('.card[data-spring="true"]'); requestAnimationFrame(() => { cards.forEach(card => { setTimeout(() => { applySpringAnimation(card); }, parseInt(card.getAttribute('data-index')) * ANIM_CONFIG.staggerDelay); }); }); isFiltering = false; } } // 🌟 新增:全局图片懒加载监听器 let imgObserver = null; function observeImages(container) { if (!('IntersectionObserver' in window)) { // 老浏览器兜底:直接加载 container.querySelectorAll('img[data-src]').forEach(img => { if (img.dataset.src) img.src = img.dataset.src; }); return; } // 只创建一次 Observer 实例,复用性能更好 if (!imgObserver) { imgObserver = new IntersectionObserver((entries, observer) => { entries.forEach(entry => { if (entry.isIntersecting) { const img = entry.target; if (img.dataset.src) { img.src = img.dataset.src; // 真正开始加载图片 } imgObserver.unobserve(img); // 加载完就不再监听 } }); }, { rootMargin: "600px" }); // 提前 200px 开始加载 } // 把新渲染出来的图片加入监听 container.querySelectorAll('img[data-src]').forEach(img => imgObserver.observe(img)); } // ========================================== // 🪄 Animation: 物理弹簧动画 // ========================================== function applySpringAnimation(element) { if (!element || element.dataset.animating === 'true') return; element.dataset.animating = 'true'; const startY = ANIM_CONFIG.startY; const omega0 = Math.sqrt(ANIM_CONFIG.stiffness); const zeta = ANIM_CONFIG.damping; const omegaD = omega0 * Math.sqrt(1 - zeta * zeta); const startTime = performance.now(); element.style.transition = ''; element.style.opacity = '0'; element.style.transform = `translate3d(0, ${startY}px, 0)`; element.style.willChange = 'transform, opacity'; element.style.backfaceVisibility = 'hidden'; void element.offsetWidth; function step(currentTime) { const t = (currentTime - startTime) / 1000; const envelope = Math.exp(-zeta * omega0 * t); if (Math.abs(envelope * startY) < 0.1) { // 🌟 核心修复:不要用 translate3d,直接清空 transform,让父级彻底“干干净净” element.style.transform = ''; element.style.opacity = 1; element.style.willChange = 'auto'; element.style.transition = ''; element.dataset.animating = 'false'; return; } const positionY = startY * envelope * Math.cos(omegaD * t); const opacity = Math.min(t / 0.25, 1); element.style.transform = `translate3d(0, ${positionY}px, 0)`; element.style.opacity = opacity; requestAnimationFrame(step); } requestAnimationFrame(step); } // 🌟 新增:滑块的专用动画 function animateIndicator(indicator, targetY, targetHeight) { if (!indicator) return; // 取消正在进行的旧动画 if (indicator._anim) { indicator._anim.cancel(); } const startY = indicator.dataset.currentY ? parseFloat(indicator.dataset.currentY) : targetY; // 高度直接瞬间改变,保证边框顺滑 indicator.style.height = `${targetHeight}px`; indicator.style.opacity = "1"; // 🌟 使用原生 WAAPI,配合 ease-in-out 曲线 indicator._anim = indicator.animate( [ { transform: `translate3d(0, ${startY}px, 0)` }, { transform: `translate3d(0, ${targetY}px, 0)` } ], { duration: 200, // 🌟 改这里:0.2s,干脆利落 easing: "cubic-bezier(0.25, 0.1, 0.25, 1)", // 🌟 偏向 ease-out 的平滑减速,比 pure ease-in-out 更敏捷 fill: "forwards" } ); // 记录最新的 Y 坐标 indicator.dataset.currentY = targetY; } // ========================================== // 🧩 Component: 渲染导航 // ========================================== function renderNav(container, models, categories) { if (!container) return; // 🌟 强制排序,确保按 JSON 顺序渲染 categories.sort((a, b) => { const idxA = siteConfig.categories.findIndex(c => c.key.toLowerCase() === a.toLowerCase()); const idxB = siteConfig.categories.findIndex(c => c.key.toLowerCase() === b.toLowerCase()); return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB); }); // Base Model 区域 let modelHtml = ``; // ========================================== // Category 区域 // ========================================== // 算出当前勾选的 Base Model 下有多少数据,用于动态更新 Category 的数字 const itemsUnderCurrentModel = allItems.filter(c => currentFilter.model.length === 0 || currentFilter.model.includes(c.modelRaw)); let catHtml = ``; // 🌟 核心:必须把生成的 HTML 塞进容器里,不然页面什么都不显示! container.innerHTML = modelHtml + catHtml; // 绑定点击事件 (智能兜底) container.querySelectorAll('.nav-btn').forEach(btn => { btn.onclick = (e) => { const targetBtn = e.target.closest('.nav-btn'); if (!targetBtn) return; const type = targetBtn.getAttribute('data-type'); const value = targetBtn.getAttribute('data-value'); // 切换选中状态 if (currentFilter[type].includes(value)) { currentFilter[type] = currentFilter[type].filter(v => v !== value); } else { currentFilter[type].push(value); } // 🌟 核心智能兜底逻辑 (互相联动) if (type === 'model') { // 选了底座,如果分类为空,自动全选分类 if (currentFilter.model.length > 0 && currentFilter.category.length === 0) { currentFilter.category = [...categories]; } // 底座全空,分类也跟着清空 if (currentFilter.model.length === 0) { currentFilter.category = []; } } if (type === 'category') { // 选了分类,如果底座为空,自动全选底座 if (currentFilter.category.length > 0 && currentFilter.model.length === 0) { currentFilter.model = [...models]; } // 分类全空,底座也跟着清空 if (currentFilter.category.length === 0) { currentFilter.model = []; } } // 重新渲染界面(刷新高亮) renderNav(container, models, categories); // 触发过滤 applyFilter(); }; }); } // ========================================== // 📍 Event: 滚动无限加载 // ========================================== let lastScrollY = window.scrollY; window.addEventListener('scroll', () => { // 🌟 2. 处理拦截逻辑(注意:这里 return 只拦截“无限加载”,不影响上面的游标) if (isLoading || isFiltering) return; const currentScrollY = window.scrollY; if (currentScrollY <= lastScrollY) { lastScrollY = currentScrollY; return; // 向上滚动,不触发无限加载 } lastScrollY = currentScrollY; if (filteredItems.length === 0 || allItems.length === 0) return; // 🌟 3. 触底判定与无限加载 const scrollPosition = window.innerHeight + currentScrollY; const threshold = document.body.offsetHeight - 500; if (scrollPosition >= threshold) { const endIndex = currentPage * ITEMS_PER_PAGE; if (endIndex < filteredItems.length) { isLoading = true; currentPage++; const grid = document.getElementById("card-grid"); if (grid) { const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; const itemsToRender = filteredItems.slice(startIndex, startIndex + ITEMS_PER_PAGE); let html = ""; itemsToRender.forEach((item, index) => { // 滚动加载不用首屏高优先级,且通常不处于过滤动画状态(false) // 顺便这里加上 index 参数,修复了之前滚动加载里没传 index 的问题 html += getCardHTML(item, index, false, false); }); grid.insertAdjacentHTML('beforeend', html); observeImages(grid); } setTimeout(() => { isLoading = false; }, 500); } } }); // ========================================== // 🧩 抽离的公共卡片生成函数 // ========================================== function getCardHTML(item, index, isFirstScreen = false, isFiltering = false) { const baseBadge = `
${escapeHtml(item.modelLabel)}
`; const datasetHtml = item.dataset ? `` : ""; const usageHtml = item.guide ? `` : ""; const showcaseRow = item.showcase ? `` : ""; let triggerHtml = ""; const formatTrigger = (text) => { if (!text) return text; let t = text.trim(); if (!t.endsWith(",")) { t += ","; } return t; }; if (item.trigger && item.trigger !== "-") { if (Array.isArray(item.trigger)) { item.trigger.forEach(t => { const formattedT = formatTrigger(t); triggerHtml += `
${escapeHtml(formattedT)}
`; }); } else { const formattedTrigger = formatTrigger(item.trigger); triggerHtml = `
${escapeHtml(formattedTrigger)}
`; } } else { triggerHtml = `-`; } const loraName = item.filename.replace(/\.safetensors$/i, ""); const loraHtml = `
${escapeHtml(loraName)}
`; const loadingAttr = isFirstScreen ? 'fetchpriority="high" loading="eager"' : ''; const springStyle = isFiltering ? `opacity: 0; transform: translateY(${ANIM_CONFIG.startY}px); will-change: transform, opacity;` : ''; const springAttr = isFiltering ? `data-spring="true" data-index="${index}"` : ''; // 注意:这里整合了你首屏的 correct HTML 结构 return `
${baseBadge} ${escapeAttr(item.title)}
${escapeHtml(item.title)}
${escapeHtml(item.categoryLabel)}
LoRA:
${loraHtml}
Trigger:
${triggerHtml}
${showcaseRow} ${datasetHtml} ${usageHtml} ⬇️ Download
`; } // ========================================== // 🪟 Modal: Dataset/Usage/Showcase 弹窗逻辑 (彻底解决缩略图卡顿 + 滚动锁定) // ========================================== let currentModalData = { repoId: "", branch: "", images: [], currentIndex: 0 }; let currentGridPage = 1; let GRID_ITEMS_PER_PAGE = 30; // 🌟 动态计算列数和每页数量 (监听容器真实宽度) function calculateGridLayout() { const gridView = document.getElementById("grid-view"); if (!gridView) return; // 使用 getBoundingClientRect 获取精确宽度 const containerWidth = gridView.getBoundingClientRect().width || 1200; const minImgWidth = 120; const gap = 12.8; const columns = Math.max(1, Math.floor((containerWidth + gap) / (minImgWidth + gap))); // 每页显示 3 行,正好填满,不会留空 GRID_ITEMS_PER_PAGE = columns * 3; } window.openShowcaseModal = async function(showcaseUrl) { // 🌟 顶部动态 UI 更新 document.querySelector(".modal-header h3").textContent = "ComfyUI Showcase Viewer"; const infoLink = document.getElementById("modal-info-link"); const compareLink = document.getElementById("modal-compare-link"); // 设置右侧外链(真实的 Showcase 目录链接) infoLink.href = showcaseUrl; infoLink.target = "_blank"; infoLink.innerHTML = "🔗 View Showcase Folder ↗"; // 智能反查对应的 Dataset 链接 let inferredDatasetUrl = ""; try { const currentItem = allItems.find(item => item.showcase === showcaseUrl); if (currentItem && currentItem.dataset) { inferredDatasetUrl = currentItem.dataset; } } catch (e) { console.warn("查找 Dataset 失败", e); } if (inferredDatasetUrl) { compareLink.href = `javascript:openDatasetModal('${inferredDatasetUrl}')`; compareLink.innerHTML = "📊 Compare with Training Dataset"; compareLink.style.display = "inline-block"; } else { compareLink.style.display = "none"; } const modal = document.getElementById("dataset-modal"); //const repoLink = document.getElementById("modal-repo-link"); if (!modal) return; modal.style.display = "flex"; document.body.style.overflow = "hidden"; // UI 切换:强制显示网格,隐藏详情,隐藏子文件夹选择器 document.getElementById("grid-view-container").style.display = "flex"; document.getElementById("detail-view").style.display = "none"; document.getElementById("back-to-grid").style.display = "none"; document.getElementById("select-container").style.display = "none"; // 🌟 核心修复:重新严谨解析 URL let repoId = "waveshaker/loras"; // 默认兜底 let branch = "main"; let basePath = ""; try { // 把 URL 去掉域名,变成 "waveshaker/loras/tree/main/comfyui_showcase/..." const cleanPath = showcaseUrl.replace("https://huggingface.co/", "").replace("http://huggingface.co/", ""); const pathParts = cleanPath.split("/"); // pathParts 应为: ["waveshaker", "loras", "tree", "main", "comfyui_showcase", "krea2", "LeeJian_KR2"] const treeIndex = pathParts.indexOf("tree"); if (treeIndex !== -1) { // repoId 是 tree 前面的部分(例如 waveshaker/loras) repoId = pathParts.slice(0, treeIndex).join("/"); // branch 是 tree 后面的第一个元素(例如 main) branch = pathParts[treeIndex + 1] || "main"; // basePath 是 branch 后面的所有部分 basePath = pathParts.slice(treeIndex + 2).join("/"); } } catch (e) { console.error("URL 解析失败:", e); } //repoLink.href = showcaseUrl; console.log("解析结果 -> Repo:", repoId, "| Branch:", branch, "| BasePath:", basePath); // 初始化当前弹窗的状态 currentModalData = { repoId: repoId, branch: branch, images: [], currentIndex: 0, mode: 'showcase', jsonData: [] }; const gridView = document.getElementById("grid-view"); gridView.innerHTML = `
Loading Showcase...
`; try { // 1. 获取 gallery.json // 🌟 这里必须加上 encodeURIComponent 处理,防止中文或空格导致 404 // 拼出的 URL 示例:https://huggingface.co/waveshaker/loras/resolve/main/comfyui_showcase/krea2/LeeJian_KR2/gallery.json const jsonUrl = `https://huggingface.co/${repoId}/resolve/${branch}/${basePath}/gallery.json`; console.log("正在请求:", jsonUrl); // 你在 F12 控制台里应该能看到这个 URL 是否正确 const res = await fetch(jsonUrl); if (!res.ok) throw new Error(`Failed to load gallery.json (HTTP ${res.status})`); const galleryData = await res.json(); if (!Array.isArray(galleryData) || galleryData.length === 0) { throw new Error("Gallery is empty or invalid format."); } // 2. 映射图片信息 (拼凑完整路径) currentModalData.jsonData = galleryData; currentModalData.images = galleryData.map(item => ({ path: `${basePath}/${item.file_name}`, // 组合成 相对路径,供大图展示 jsonInfo: item // 把该图片的专属参数存起来 })); // 3. 调用现有的渲染函数 currentGridPage = 1; document.getElementById("grid-pagination").style.display = "flex"; // 显示分页器 calculateGridLayout(); renderGridPage(); } catch (err) { console.error(err); gridView.innerHTML = `
Failed: ${err.message}
`; document.getElementById("grid-pagination").style.display = "none"; } }; window.openDatasetModal = async function(datasetUrl) { // 🌟 顶部动态 UI 更新 document.querySelector(".modal-header h3").textContent = "Dataset Viewer"; const infoLink = document.getElementById("modal-info-link"); const compareLink = document.getElementById("modal-compare-link"); // 设置右侧外链(真实的 Dataset 仓库链接) infoLink.href = datasetUrl; infoLink.target = "_blank"; infoLink.innerHTML = "🔗 View Dataset Repo ↗"; // 智能反查对应的 Showcase 链接 let inferredShowcaseUrl = ""; try { const currentItem = allItems.find(item => item.dataset === datasetUrl); if (currentItem && currentItem.showcase) { inferredShowcaseUrl = currentItem.showcase; } } catch (e) { console.warn("查找 Showcase 失败", e); } if (inferredShowcaseUrl) { compareLink.href = `javascript:openShowcaseModal('${inferredShowcaseUrl}')`; compareLink.innerHTML = "✨ Compare with ComfyUI Showcase"; compareLink.style.display = "inline-block"; } else { compareLink.style.display = "none"; } const modal = document.getElementById("dataset-modal"); const select = document.getElementById("folder-select"); //const repoLink = document.getElementById("modal-repo-link"); if (!modal) return; modal.style.display = "flex"; document.body.style.overflow = "hidden"; document.getElementById("grid-view-container").style.display = "none"; document.getElementById("detail-view").style.display = "none"; document.getElementById("back-to-grid").style.display = "none"; document.getElementById("select-container").style.display = "flex"; select.innerHTML = ``; let parts; if (datasetUrl.startsWith("http")) { const pathParts = new URL(datasetUrl).pathname.split("/").filter(Boolean); parts = [pathParts[1], pathParts[2], pathParts[4], ...pathParts.slice(5)]; } else { parts = datasetUrl.split("/"); } const repoId = `${parts[0]}/${parts[1]}`, branch = parts[2] || "main", basePath = parts.slice(3).join("/"); //repoLink.href = `https://huggingface.co/datasets/${repoId}/tree/${branch}/${basePath}`; currentModalData = { repoId, branch, images: [], currentIndex: 0 }; try { const res = await fetch(`https://huggingface.co/api/datasets/${repoId}/tree/${branch}/${basePath}`); if (!res.ok) throw new Error(`API: ${res.status}`); const files = await res.json(); const subfolders = files.filter(f => f.type === "directory").map(f => f.path); // 🌟 修复 1:智能排序,处理 NaN 的情况 subfolders.sort((a, b) => { const nameA = a.split("/").pop(); const nameB = b.split("/").pop(); // 提取开头的数字,如果没有数字则视为 0 const matchA = nameA.match(/^(\d+)/); const matchB = nameB.match(/^(\d+)/); const numA = matchA ? parseInt(matchA[1], 10) : 0; const numB = matchB ? parseInt(matchB[1], 10) : 0; // 1. 优先按数字降序排列 (核心:让最新的 5_Portrait 排在最上面) if (numA !== numB) { return numB - numA; } // 2. 如果数字相同,或者都没有数字,再按名字字母升序排列 (A-Z) return nameA.localeCompare(nameB); }); if (subfolders.length === 0) { select.innerHTML = ``; loadFolderImages(basePath); } else { // 🌟 修复 2:给渲染出来的 option 绑定是否默认选中的状态 select.innerHTML = subfolders.map((f, index) => { const isSelected = index === 0 ? "selected" : ""; // 默认选中第一个(也就是最大的) return ``; }).join(''); select.onchange = (e) => loadFolderImages(e.target.value); // 触发加载第一个文件夹 loadFolderImages(subfolders[0]); } } catch (err) { alert(`Failed to load dataset: ${err.message}`); closeModal(); } }; async function loadFolderImages(targetFolder) { const { repoId, branch } = currentModalData; const gridContainer = document.getElementById("grid-view-container"); const detailView = document.getElementById("detail-view"); const gridView = document.getElementById("grid-view"); gridContainer.style.display = "flex"; detailView.style.display = "none"; gridView.innerHTML = `
Loading Dataset...
`; currentGridPage = 1; try { const res = await fetch(`https://huggingface.co/api/datasets/${repoId}/tree/${branch}/${targetFolder}`); if (!res.ok) throw new Error(`Access failed (${res.status})`); const files = await res.json(); const images = files.filter(f => f.type === "file" && /\.(jpg|jpeg)$/i.test(f.path)); if (images.length === 0) { gridView.innerHTML = `
No .jpg images found.
`; document.getElementById("grid-pagination").style.display = "none"; return; } currentModalData.images = images; document.getElementById("grid-pagination").style.display = "flex"; // 🌟 关键:等待一帧,确保 DOM 已经渲染出真实的宽度,再执行计算和渲染 requestAnimationFrame(() => { calculateGridLayout(); renderGridPage(); }); } catch (err) { gridView.innerHTML = `
Failed: ${err.message}
`; } } window.openUsageModal = async function(guideUrl) { const modal = document.getElementById("usage-modal"); const body = document.getElementById("usage-modal-body"); const title = document.getElementById("usage-modal-title"); const repoLink = document.getElementById("usage-repo-link"); if (!modal) return; modal.style.display = "flex"; document.body.style.overflow = "hidden"; body.innerHTML = `
Loading guide...
`; // 🌟 核心修复:解析完整 URL,提取 repoId, branch 和 path let repoId = "waveshaker/loras"; let branch = "main"; let basePath = ""; try { // 变成 "waveshaker/loras/tree/main/usage_guide/10inPH_KR2" const cleanPath = guideUrl.replace("https://huggingface.co/", "").replace("http://huggingface.co/", ""); const pathParts = cleanPath.split("/"); const treeIndex = pathParts.indexOf("tree"); if (treeIndex !== -1) { repoId = pathParts.slice(0, treeIndex).join("/"); branch = pathParts[treeIndex + 1] || "main"; basePath = pathParts.slice(treeIndex + 2).join("/"); } } catch (e) { console.error("URL 解析失败:", e); } // 正确拼凑出 Resolve 下载链接 const mdUrl = `https://huggingface.co/${repoId}/resolve/${branch}/${basePath}/guide.md`; repoLink.href = guideUrl; // 打开新窗口用的还是原网页链接 try { const res = await fetch(mdUrl); if (!res.ok) throw new Error("Guide not found"); const mdText = await res.text(); // 使用 marked.js 解析 Markdown 为 HTML const htmlContent = marked.parse(mdText, { breaks: true }); // 提取路径的最后一部分作为标题 const guideName = basePath.split("/").pop(); title.textContent = `Usage Guide: ${guideName}`; // 渲染 HTML body.innerHTML = `
${htmlContent}
`; // 🌟 核心修复:处理 Usage 里的图片,加骨架和防抖动 const mdImages = body.querySelectorAll('.markdown-body img'); mdImages.forEach(img => { // 1. 获取原始相对路径,并拼接成完整的绝对路径 const rawSrc = img.getAttribute('src'); if (!rawSrc) return; // 如果是相对路径,根据你的 mdUrl 来拼凑 // 假设 mdUrl 是 https://huggingface.co/.../guide.md // 那么图片应该去 https://huggingface.co/.../ 目录下找 const baseDir = mdUrl.substring(0, mdUrl.lastIndexOf('/') + 1); const realSrc = rawSrc.startsWith('http') ? rawSrc : baseDir + rawSrc; // 2. 移除原来的 src img.removeAttribute('src'); // 3. 给图片包裹一个外层容器 const wrapper = document.createElement('div'); wrapper.className = 'md-img-wrapper img-skeleton'; // 给个默认的占位高度,或者你可以根据图片原始宽高比例来算 wrapper.style.minHeight = '200px'; // 4. 替换 DOM 结构 img.parentNode.insertBefore(wrapper, img); wrapper.appendChild(img); // 5. 定义加载逻辑 const loadImg = () => { img.src = realSrc; img.onload = () => { wrapper.classList.remove('img-skeleton'); img.classList.add('loaded'); }; img.onerror = () => { wrapper.classList.remove('img-skeleton'); img.classList.add('preview-missing'); console.warn("Usage image failed to load:", realSrc); }; }; // 6. 监听 wrapper(因为 img 现在没有高度,监听 img 没用) const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { loadImg(); observer.unobserve(wrapper); } }); }, { rootMargin: "400px" }); observer.observe(wrapper); // 监听包裹容器 }); } catch (err) { body.innerHTML = `
❌ Failed to load guide: ${err.message}
`; } }; // 关闭弹窗 document.getElementById("close-usage-modal").onclick = () => { document.getElementById("usage-modal").style.display = "none"; document.body.style.overflow = ""; }; document.getElementById("usage-modal").onclick = (e) => { if (e.target.id === "usage-modal") { document.getElementById("usage-modal").style.display = "none"; document.body.style.overflow = ""; } }; function renderGridPage() { const { repoId, branch, images } = currentModalData; const gridView = document.getElementById("grid-view"); const paginationContainer = document.getElementById("grid-pagination"); calculateGridLayout(); const totalPages = Math.ceil(images.length / GRID_ITEMS_PER_PAGE); const startIndex = (currentGridPage - 1) * GRID_ITEMS_PER_PAGE; const endIndex = startIndex + GRID_ITEMS_PER_PAGE; const itemsToRender = images.slice(startIndex, endIndex); let html = ""; itemsToRender.forEach((img, idx) => { const globalIndex = startIndex + idx; // 🌟 修改点 1:套上 wsrv.nl 代理,限制宽度,转 WebP // 强制把空格转义,防止 Frame 412.jpg 这种文件名导致 403/404 // const safePath = img.path.replace(/ /g, "%20"); // const rawUrl = `https://huggingface.co/datasets/${repoId}/resolve/${branch}/${safePath}`; // const proxiedUrl = `https://wsrv.nl/?url=${encodeURIComponent(rawUrl)}&w=200&output=webp&q=80`; const safePath = img.path.replace(/ /g, "%20"); //const proxiedUrl = `https://huggingface.co/datasets/${repoId}/resolve/${branch}/${safePath}`; const repoType = currentModalData.mode === 'showcase' ? '' : 'datasets/'; const proxiedUrl = `https://huggingface.co/${repoType}${repoId}/resolve/${branch}/${safePath}`; // 🌟 修改点 2:把 src 换成 data-src html += `
thumb
`; }); gridView.innerHTML = html; // 🌟 修改点 3:调用懒加载监听器 observeImages(gridView); // 渲染高级分页器(保持原样) renderPagination(paginationContainer, currentGridPage, totalPages, (newPage) => { currentGridPage = newPage; renderGridPage(); gridView.scrollTop = 0; }); } // 🌟 核心:智能分页器生成逻辑 function renderPagination(container, currentPage, totalPages, onPageChange) { let html = ""; // 上一页按钮 html += ``; // 生成页码逻辑(1 ... 4 5 6 ... 20) let startPage = Math.max(1, currentPage - 2); let endPage = Math.min(totalPages, currentPage + 2); if (startPage > 1) { html += ``; if (startPage > 2) html += `...`; } for (let i = startPage; i <= endPage; i++) { html += ``; } if (endPage < totalPages) { if (endPage < totalPages - 1) html += `...`; html += ``; } // 下一页按钮 html += ``; // 底部小字显示总页数 html += `Total: ${totalPages} pages`; container.innerHTML = html; // 绑定点击事件 container.querySelectorAll('.page-btn').forEach(btn => { btn.onclick = (e) => { const page = parseInt(e.target.getAttribute('data-page'), 10); if (page && page !== currentPage && !e.target.disabled) { onPageChange(page); } }; }); } window.openDetailView = function(index) { // 🌟 核心:必须彻底隐藏网格容器! document.getElementById("grid-view-container").style.display = "none"; // 显示详情页,变为 Flex 布局 document.getElementById("detail-view").style.display = "flex"; // 显示返回按钮,隐藏下拉框 document.getElementById("back-to-grid").style.display = "inline-block"; document.getElementById("select-container").style.display = "none"; currentModalData.currentIndex = index; if (currentModalData.images.length > 1) { document.getElementById("btn-prev").style.display = "flex"; document.getElementById("btn-next").style.display = "flex"; } renderCurrentImage(); }; document.getElementById("back-to-grid").onclick = () => { document.getElementById("grid-view-container").style.display = "flex"; document.getElementById("detail-view").style.display = "none"; document.getElementById("back-to-grid").style.display = "none"; // 🌟 关键:如果是 Dataset 模式才显示下拉框,Showcase 模式保持隐藏 if (currentModalData.mode !== 'showcase') { document.getElementById("select-container").style.display = "flex"; } }; // 在全局声明一个变量,记录当前正在预加载的临时图片对象 let currentPreloadImg = null; function renderCurrentImage() { const { repoId, branch, images, currentIndex, mode } = currentModalData; const img = document.getElementById("viewer-img"); const txt = document.getElementById("viewer-txt"); const loading = document.getElementById("viewer-loading"); const copyBtn = document.getElementById("copy-caption-btn"); const charCountEl = document.getElementById("char-count"); const currentImage = images[currentIndex]; document.getElementById("image-counter").textContent = `${currentIndex + 1} / ${images.length}`; // 1. 重置 UI 状态,显示扫光 loading.classList.add("viewer-loading-skeleton"); loading.style.display = "flex"; loading.innerHTML = "Loading..."; txt.textContent = "Loading..."; charCountEl.textContent = "0 Tokens"; copyBtn.textContent = "Copy"; copyBtn.classList.remove("copied"); // 2. 彻底隐藏旧图 img.style.display = "none"; // 3. 准备 URL (区分 Model Repo 和 Dataset Repo) const safePath = currentImage.path.replace(/ /g, "%20"); // 🌟 核心修复:Showcase 在 Model Repo,Dataset 在 Dataset Repo const repoType = mode === 'showcase' ? '' : 'datasets/'; const rawUrl = `https://huggingface.co/${repoType}${repoId}/resolve/${branch}/${safePath}`; let imgUrl = rawUrl; const requestedPath = currentImage.path; // 🌟 核心保护:中断上一次未加载完的图片请求 if (currentPreloadImg) { currentPreloadImg.onload = null; // 🌟 关键:移除加载完成的事件监听 currentPreloadImg.onerror = null; // 🌟 关键:移除加载失败的事件监听 currentPreloadImg.src = ""; // 清空 src 中断网络请求 } currentPreloadImg = new Image(); currentPreloadImg.src = imgUrl; currentPreloadImg.onload = () => { // 如果加载完时,用户已经切到了别的图,则放弃这次渲染 if (currentModalData.images[currentModalData.currentIndex].path !== requestedPath) return; // 🌟 第一步:把 src 赋给真实的 img 标签,但先别显示它 img.src = imgUrl; // 🌟 第二步:等待浏览器把这个图彻底解码完毕 img.decode().then(() => { // 🌟 第三步:解码完毕,立刻瞬间显示,并隐藏 loading 扫光 img.style.display = "block"; loading.style.display = "none"; currentPreloadImg = null; }).catch((err) => { // 如果解码失败(极少情况),也让它显示出来 img.style.display = "block"; loading.style.display = "none"; currentPreloadImg = null; console.warn("图片解码失败", err); }); }; // 4. 获取文本 (区分 Showcase JSON 和 Dataset TXT) const paramsPanel = document.getElementById("params-panel"); const dragTip = document.getElementById("drag-tip"); const promptLabel = document.querySelector(".caption-container label"); if (mode === 'showcase') { txt.style.fontSize = "0.7rem"; txt.style.lineHeight = "1.4"; if (promptLabel) promptLabel.textContent = "PROMPT:"; if (dragTip) dragTip.style.display = "flex"; const info = currentImage.jsonInfo; if (info) { const promptText = info.pos || info.prompt || "No prompt provided."; // txt.textContent = promptText; txt.innerText = promptText; const estimatedTokens = Math.ceil(promptText.trim().length / 4); charCountEl.textContent = `~${estimatedTokens} Tokens`; // 🌟 渲染参数面板 if (paramsPanel) { let html = ""; // 辅助函数:生成统一的一行 const addRow = (key, value) => { if (!value) return; html += `
${key}:
${value}
`; }; addRow("Size", info.size); addRow("Model", info.model); addRow("VAE", info.vae); addRow("CLIP", info.clip); addRow("Seed", info.seed); addRow("Steps", info.steps); addRow("CFG", info.cfg); addRow("Sampler", info.sampler); addRow("Scheduler", info.scheduler); addRow("Denoise", info.denoise); // LoRAs 特殊处理 if (info.loras && info.loras.length > 0) { const loraList = Array.isArray(info.loras) ? info.loras : info.loras.split(',').map(s => s.trim()); let loraHtml = `
LoRAs:
`; loraList.forEach(lora => { loraHtml += `
${lora}
`; }); loraHtml += `
`; html += loraHtml; } paramsPanel.innerHTML = html; paramsPanel.style.display = "flex"; } } else { txt.textContent = "No JSON data for this image."; } } else { // 🌟 Dataset 模式:彻底隐藏专属组件,恢复默认状态 txt.style.fontSize = "0.75rem"; txt.style.lineHeight = "1.5"; if (promptLabel) promptLabel.textContent = "CAPTION:"; if (paramsPanel) paramsPanel.style.display = "none"; if (dragTip) dragTip.style.display = "none"; // 原有的 Dataset fetch .txt 逻辑 const txtFileName = currentImage.path.replace(/\.[^/.]+$/, ".txt"); fetch(`https://huggingface.co/${repoType}${repoId}/resolve/${branch}/${txtFileName}`) .then(res => { if (!res.ok) return "Caption not found."; return res.text(); }) .then(text => { if (currentModalData.images[currentModalData.currentIndex].path !== requestedPath) return; txt.textContent = text; if (text !== "Caption not found.") { const estimatedTokens = Math.ceil(text.trim().length / 4); charCountEl.textContent = `~${estimatedTokens} Tokens`; } }) .catch(() => { if (currentModalData.images[currentModalData.currentIndex].path === requestedPath) { txt.textContent = "Failed to read text."; } }); } // 5. 静默预加载前后张图片 // 🌟 清理上一次的全部缓存 if (!window._preloadImgs) window._preloadImgs = []; window._preloadImgs.forEach(img => { img.src = ""; }); window._preloadImgs = []; const nextIndex = (currentIndex + 1) % images.length; const prevIndex = (currentIndex - 1 + images.length) % images.length; const preloadImage = (path) => { if (!path) return; const safeP = path.replace(/ /g, "%20"); const raw = `https://huggingface.co/${repoType}${repoId}/resolve/${branch}/${safeP}`; const img = new Image(); img.src = raw; window._preloadImgs.push(img); // 🌟 存入数组等待下一次清理 }; if (images[nextIndex]) preloadImage(images[nextIndex].path); if (images[prevIndex]) preloadImage(images[prevIndex].path); } document.getElementById("copy-caption-btn").onclick = function() { const txt = document.getElementById("viewer-txt").textContent; if (txt === "Loading..." || txt === "Caption not found.") return; navigator.clipboard.writeText(txt).then(() => { this.textContent = "Copied!"; this.classList.add("copied"); setTimeout(() => { this.textContent = "Copy"; this.classList.remove("copied"); }, 1500); }); }; function changeImage(dir) { const { images, currentIndex } = currentModalData; let n = currentIndex + dir; if (n < 0) n = images.length - 1; if (n >= images.length) n = 0; currentModalData.currentIndex = n; renderCurrentImage(); } // 🌟 统一的关闭弹窗函数 function closeModal() { document.getElementById("dataset-modal").style.display = "none"; document.getElementById("viewer-img").src = ""; document.body.style.overflow = ""; // 🌟 恢复主屏滚动 } document.getElementById("btn-prev").onclick = () => changeImage(-1); document.getElementById("btn-next").onclick = () => changeImage(1); document.getElementById("close-modal").onclick = closeModal; document.getElementById("dataset-modal").onclick = (e) => { if (e.target.id === "dataset-modal") closeModal(); }; // 🌟 记录上一次的窗口宽度 let lastWindowWidth = window.innerWidth; // 监听容器尺寸变化,自动重新排版(防抖处理) let resizeTimer; const resizeObserver = new ResizeObserver(() => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { const gridContainer = document.getElementById("grid-view-container"); // 🌟 核心:只有窗口宽度真正变了才重绘,避免 display 切换引发闪烁 const currentWindowWidth = window.innerWidth; if (currentWindowWidth === lastWindowWidth) return; if (gridContainer && gridContainer.style.display !== "none" && currentModalData.images.length > 0) { calculateGridLayout(); renderGridPage(); } }, 150); }); window.addEventListener('resize', () => { lastWindowWidth = window.innerWidth; }); window.addEventListener('DOMContentLoaded', () => { const gridView = document.getElementById("grid-view"); if (gridView) resizeObserver.observe(gridView); }); // ========================================== // 🪟 弹窗: Disclaimer 弹窗逻辑 // ========================================== const discModal = document.getElementById("disclaimer-modal"); const openDiscBtn = document.getElementById("open-disclaimer-btn"); const closeDiscBtn = document.getElementById("close-disclaimer-modal"); if (openDiscBtn && discModal) { openDiscBtn.onclick = () => { discModal.style.display = "flex"; document.body.style.overflow = "hidden"; // 🌟 锁定主屏滚动 }; } if (closeDiscBtn && discModal) { closeDiscBtn.onclick = () => { discModal.style.display = "none"; document.body.style.overflow = ""; // 🌟 恢复主屏滚动 }; } if (discModal) { discModal.onclick = (e) => { if (e.target.id === "disclaimer-modal") { discModal.style.display = "none"; document.body.style.overflow = ""; // 🌟 恢复主屏滚动 } }; } // ========================================== // 📍 Event:键盘左右键切换图片支持 // ========================================== // 🌟 新增一个全局变量记录上次按键时间 let lastKeyTime = 0; // 🌟 核心:定义一个全局标志位,记录是否在拖拽 window.isDragging = false; // 监听全局拖拽状态(兜底) window.addEventListener('dragstart', () => { window.isDragging = true; }); window.addEventListener('dragend', () => { // 延迟 100ms 解除状态,防止拖拽释放瞬间按下按键 setTimeout(() => { window.isDragging = false; }, 100); }); window.addEventListener('keydown', (e) => { // 1. 如果弹窗没开,或者没进详情页,直接退出 const modal = document.getElementById("dataset-modal"); if (!modal || modal.style.display === "none") return; const detailView = document.getElementById("detail-view"); if (!detailView || detailView.style.display === "none") return; // 2. 如果是输入框,退出 const activeTag = document.activeElement.tagName; if (activeTag === 'INPUT' || activeTag === 'TEXTAREA') return; // 🌟 3. 核心修复:如果正在拖拽图片,绝对不响应键盘! if (window.isDragging) { console.warn("正在拖拽中,忽略键盘事件防止卡死"); return; } // 4. 300ms 防连点 const now = Date.now(); if (now - lastKeyTime < 300) return; // 5. 执行切图 if (e.key === "ArrowLeft") { e.preventDefault(); lastKeyTime = now; changeImage(-1); } else if (e.key === "ArrowRight") { e.preventDefault(); lastKeyTime = now; changeImage(1); } else if (e.key === "Escape") { closeModal(); } }); // ========================================== // 📍 Event: 移动端侧边栏抽屉交互 // ========================================== const mobileMenuBtn = document.getElementById("mobile-menu-btn"); const mainSidebar = document.getElementById("main-sidebar"); if (mobileMenuBtn && mainSidebar) { // 1. 点击按钮切换 mobileMenuBtn.onclick = (e) => { e.stopPropagation(); // 阻止事件冒泡到 document mainSidebar.classList.toggle("open"); }; // 2. 点击页面其他地方(比如卡片),自动关闭侧边栏 document.addEventListener("click", (e) => { // 如果侧边栏是打开的,且用户点击的不是侧边栏内部,也不是菜单按钮 if (mainSidebar.classList.contains("open") && !mainSidebar.contains(e.target) && e.target !== mobileMenuBtn) { mainSidebar.classList.remove("open"); } }); } // ========================================== // 🛠️ Utils:核心工具函数 // ========================================== async function fetchWithRetry(url, options, retries = 3, delay = 1000) { for (let i = 0; i < retries; i++) { try { const res = await fetch(url, options); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res; } catch (err) { if (i === retries - 1) throw err; console.warn(`Fetch failed for ${url}, retrying in ${delay}ms... (Attempt ${i + 1}/${retries})`); await new Promise(r => setTimeout(r, delay)); } } } function formatModelName(dir) { if (!dir) return "Other"; // 去动态配置里找对应的 label const match = siteConfig.models.find(m => m.key.toLowerCase() === dir.toLowerCase()); if (match) return match.label; return dir.replace(/[_-]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); } function formatCategory(dir) { if (!dir) return "Unknown"; const match = siteConfig.categories.find(c => c.key.toLowerCase() === dir.toLowerCase()); if (match) return match.label; return dir; } function encodePath(path) { // 🌟 核心修复:先把原路径里的空格全部替换成 %20,然后再进行 encodeURIComponent return path.split("/").map(seg => encodeURIComponent(seg.replace(/ /g, "%20"))).join("/"); } function escapeHtml(str) { if (str === null || str === undefined) return ""; return String(str).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function escapeAttr(str) { return escapeHtml(str); } // ========================================== // 🚀 Launch: 启动 // ========================================== main();