上线MIKUPARA:初音插画收藏展示站

前段时间,我突发奇想想到了“MIKUPARA”这个极佳的名字,然后惊喜地发现MIKUPARA.COM没有人注册过,看了看美元最近的汇率走向在低位,直接梭哈拿下域名!
拿下域名后,我就在想要不要拿这个域名干些什么有趣的事情,因为MIKUPARA对应过来名称就是初音乐园,那肯定是要和初音未来相关的东西。思来想去,我感觉如果做成一个社区,感觉大概率就是死水一潭,而且也不好管理。其他的话目前也不知道有什么可持续的,因为我感觉就现在互联网的氛围,从零开始搞一个独立的社区真的很难了,已经不是2010年前后的互联网了,不会再有mikufans了(笑)。
正好我有一些收藏的初音插画,不如就搞个网站专门展示我的收藏吧,我感觉这个想法比较可行,毕竟如果是纯静态展示,定期更新收藏的话,Cloudflare Pages就可以搞定了,没有其他成本,只是域名的钱。不过这个域名也算是我的收藏之一了。闲着也是闲着,不如用起来。

技术路线构思

因为是图片展示站,为了丝滑的浏览体验,有个瀑布流加载肯定是最好的。但是,纯静态页面,怎么做到瀑布流下拉刷新呢?我想起来了我之前做的小玩具——MediaPages。为了实现在线媒体库,我的做法是把相关的文件信息保存到json文件作为索引,然后通过js获取相关的信息来更新页面。这个思路应该是可以应用到本次的开发上的。
我打算让json文件包含以下信息:
对于每一张图片:

  • 预览文件名称:用于瀑布流预览的缩略图,缩小图片尺寸并转码为webp节省加载时间。
  • 全尺寸文件名称:用于点击图片展示,转码为webp节省加载时间。
  • 原文件名称:原始的插画文件,用于下载。
  • 图片尺寸信息:包含宽度和高度的数值,单位为像素
  • 图片来源信息:存放图片来源的URL,注明来源,尊重插画作者

此外,还有一个图片文件的文件目录URL,用于拼接图片完整地址,节省数据文件大小。

网站界面构思

关于网站的界面,我打算采用封面图+内容的简单组合。也就是网站加载是显示一个整页的图片封面+文字,下滑展示内容,这样子可以让网站更有层次感。然后在内容页面,顶部有两个按钮,可以切换横图和竖图,方便筛选,且为后续自然过度到下部图片瀑布流提供便利。
在交互逻辑上,我希望做到点开图片打开一个大窗口,契合图片尺寸进行全屏展示,对内容进行一些遮罩来突出重点。此外,再制作一个信息框用于展示网站的信息。
构思完成后,接下来就是压力Gemini开发前端了。

前端开发

我使用的是Gemini的canvas功能进行网页前端的快速迭代,但是因为Gemini的网页开发训练集都是大众化的项目,开发出来的第一版方案,虽然好看,但是总感觉没有二次元内味。
01.png
02.png
虽然说味不太对,但是它能Get到我想要的点,第一次就能做出来这样的页面,确实已经相当不错了。
后续就是需要进行风格化打磨,我翻了我之前收集的B站各种二次元风格活动页面的参考图,并且与Gemini讨论后,意识到了问题出在哪里——页面运用了大量模糊,柔和阴影,发光边缘,虽然视觉效果好看,但是二次元的风格是边缘硬朗,贴纸风格,配色艳丽柔和,这一版设计稿很明显不符合这样的审美。
于是,我就开始一步一步让Gemini把页面二次元风格化:

  • 为首页的标题文字添加粗重的描边和硬朗的投射贴纸风阴影。
  • 对于展示页面,让UI不是完全扁平化,而是加上了硬朗的贴纸风阴影和边框。
  • 对于画面的细节,我让他在展示页面的背景加上了点阵纹理,选择图片时遮罩一层斜向纹理,点开图片的背景遮罩层加上方格纹理,这些都是二次元常用的补充画面细节的纹理,可以提升浓度和画面观感。
  • 此外,因为当时收集参考图时,收集到了一些可爱的素材图片,不少还是透明背景的png图片文件,我就在页面的适当位置增加了这些图案,进一步拉高二次元浓度。

处理完成后,页面就长这样了:
03.png
04.png
05.png
06.png
确实是,浓度大了一大截!
当然了,这部分UI的迭代也是顺着功能的完善一起进行的,最终我是把单页面的html内嵌的css和js拆出来放到独立的文件了,虽然说东西并不多。
不过,我发现Gemini是真的很喜欢用Tailwind CSS来做前端开发,毕竟用框架可以节省代码量。

遇到的问题和解决方案

开发过程确实也遇到了一些小问题,基本上问一次Gemini就能解决,我就拿我遇到的比较典型的问题来说一下吧。

封面翻页时下部内容滚动

之前的代码,因为没有处理好逻辑,导致在首页快速滚动鼠标滚轮,翻页动画没结束下面的内容就开始滚动了,让人感觉不太舒服。为了修复这个问题,需要设置一个滚动锁定状态,等动画播放完毕后,再解锁,就可以解决问题:

// ================= 封面与弹窗交互逻辑 =================
let isCoverUp = false;
let isModalOpen = false; 
let isAnimating = false; // 【新增】动画状态锁,防止过渡期间误触
const coverSection = document.getElementById('coverSection');

function slideCoverUp() {
    if (isCoverUp || isAnimating) return; // 如果正在播放动画,直接拦截
    isCoverUp = true;
    isAnimating = true; // 开启锁
    coverSection.classList.add('-translate-y-full');
    
    // 【核心修复】等待 800ms(与 CSS 动画时间一致)后,再允许页面滚动并解锁
    setTimeout(() => {
        document.body.style.overflowY = 'auto';
        isAnimating = false;
    }, 800);
}

function slideCoverDown() {
    if (!isCoverUp || isAnimating) return; // 如果正在播放动画,直接拦截
    isCoverUp = false;
    isAnimating = true; // 开启锁
    coverSection.classList.remove('-translate-y-full');
    
    // 往下盖的时候,需要瞬间锁定滚动条,防止内容抖动
    document.body.style.overflowY = 'hidden';
    
    // 等待 800ms 后解锁交互
    setTimeout(() => {
        isAnimating = false;
    }, 800);
}

// 监听电脑端滚轮事件
window.addEventListener('wheel', (e) => {
    // 只要有弹窗或者正在播动画,所有滚轮操作全部无效!
    if (isModalOpen || isAnimating) return; 
    if (!isCoverUp && e.deltaY > 0) slideCoverUp();
    else if (isCoverUp && window.scrollY <= 0 && e.deltaY < 0) slideCoverDown();
});

// 监听手机端触摸事件
let touchStartY = 0;
window.addEventListener('touchstart', e => {
    if (isModalOpen || isAnimating) return;
    touchStartY = e.touches[0].clientY;
});
window.addEventListener('touchend', e => {
    if (isModalOpen || isAnimating) return;
    let touchEndY = e.changedTouches[0].clientY;
    if (!isCoverUp && touchStartY > touchEndY + 30) slideCoverUp();
    else if (isCoverUp && window.scrollY <= 0 && touchStartY < touchEndY - 30) slideCoverDown();
});

增加图片加载动画

因为网络速度原因,全尺寸的图加载有时候会比较久,这时打开的图片窗口是完全空白的,只有等到图片完全加载完成后才会显示。有点影响用户体验,于是我决定优化一下,加上个简单的加载动画提示。
首先要修改html文件,找到:

<div class="relative w-full flex-1 flex items-center justify-center min-h-0">
    <img id="modalImage" src="" alt="预览大图"
        class="max-w-full max-h-full object-contain bg-white p-2 md:p-3 rounded-xl border-2 border-miku-teal shadow-[8px_8px_0px_#FF69B4]">
</div>

修改为:

<div class="relative w-full flex-1 flex items-center justify-center min-h-0">
    
    <div id="modalLoading" class="absolute z-20 flex flex-col items-center justify-center gap-3 transition-opacity duration-300 pointer-events-none opacity-0 hidden">
        <i data-lucide="loader-2" class="w-10 h-10 md:w-12 md:h-12 text-miku-teal animate-spin stroke-[3] drop-shadow-[2px_2px_0px_#FF69B4]"></i>
        <span class="text-white font-black text-xs md:text-sm tracking-widest px-4 py-1.5 bg-miku-pink rounded-full border-2 border-white shadow-[3px_3px_0px_#39C5BB] animate-pulse">
            原图加载中...
        </span>
    </div>

    <img id="modalImage" src="" alt="预览大图"
        class="max-w-full max-h-full object-contain bg-white p-2 md:p-3 rounded-xl border-2 border-miku-teal shadow-[8px_8px_0px_#FF69B4] transition-opacity duration-300">
</div>

对应更新js文件,先增加一个变量:

const modalLoading = document.getElementById('modalLoading');

然后完善函数:

// 【优化版】接收预览图(full)、原图(raw)和来源(sourceUrl)
function openModal(fullSrc, rawSrc, sourceUrl, thumbSrc, width, height) {
    isModalOpen = true; // 标记弹窗已打开
    currentModalImage = fullSrc;

    // --- 【新增】打开弹窗时,先显示 Loading 动画,并将底图稍微变暗 ---
    modalLoading.classList.remove('hidden');
    // 强制重绘,确保 transition 动画生效
    void modalLoading.offsetWidth; 
    modalLoading.classList.remove('opacity-0');
    // 给占位的 SVG 底图加上半透明,让加载提示更凸显
    modalImg.classList.add('opacity-40'); 
    // -------------------------------------------------------------

    // 1. 提取宽高(如果没有传,给个默认值兜底防报错)
    const w = width || 1000;
    const h = height || 1000;

    const svgStr = thumbSrc
    ? `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}"><image href="${thumbSrc}" width="${w}" height="${h}" preserveAspectRatio="none" /></svg>`
    : `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}"></svg>`;

    // 3. 将 SVG 字符串转码为安全的 Data URL(防止特殊字符报错)
    const svgPlaceholder = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgStr)}`;

    // 4. 将内嵌了缩略图的 SVG 直接作为源文件赋值
    modalImg.src = svgPlaceholder;
    modalImg.style.backgroundImage = 'none';

    // 5. 后台静悄悄地加载真正的高清大图
    const imgLoader = new Image();
    imgLoader.src = fullSrc;
    imgLoader.onload = () => {
        // 确保没有切图(比如用户在加载期间关掉又点开了另一张)
        if (currentModalImage === fullSrc) {
            modalImg.src = fullSrc;
            
            // --- 【新增】图片加载完成后,隐藏 Loading,恢复图片透明度 ---
            modalImg.classList.remove('opacity-40');
            modalLoading.classList.add('opacity-0');
            // 等淡出动画结束后再彻底隐藏 DOM
            setTimeout(() => {
                if(currentModalImage === fullSrc) {
                    modalLoading.classList.add('hidden');
                }
            }, 300);
            // --------------------------------------------------------
        }
    };
    
    // 【可选】加上图片加载失败的兜底逻辑,避免 Loading 转个不停
    imgLoader.onerror = () => {
        if (currentModalImage === fullSrc) {
            modalImg.classList.remove('opacity-40');
            modalLoading.querySelector('span').textContent = "加载失败 T_T";
            modalLoading.querySelector('span').classList.replace('bg-miku-pink', 'bg-gray-400');
            modalLoading.querySelector('i').classList.remove('animate-spin', 'text-miku-teal');
        }
    };

    // 动态处理来源链接的展示逻辑
    if (sourceUrl && sourceUrl.trim() !== "") {
        modalUrl.href = sourceUrl;
        modalUrl.textContent = sourceUrl;
        modalUrl.classList.remove('pointer-events-none', 'text-gray-400', 'decoration-transparent');
        modalUrl.classList.add('text-miku-darkTeal', 'hover:text-miku-pink', 'underline');
    } else {
        modalUrl.href = 'javascript:void(0);';
        modalUrl.textContent = '暂未收录来源信息';
        modalUrl.classList.remove('text-miku-darkTeal', 'hover:text-miku-pink', 'underline');
        modalUrl.classList.add('pointer-events-none', 'text-gray-400', 'decoration-transparent');
    }

    // 设置下载链接
    downloadBtn.href = rawSrc;

    // ================= 【新增逻辑:提取原文件名称】 =================
    let fileName = rawSrc.split('/').pop().split('?')[0];
    if (!fileName) {
        fileName = 'mikupara_kawaii_miku.jpg';
    }
    downloadBtn.download = fileName;
    // ==============================================================

    modal.classList.remove('hidden'); void modal.offsetWidth;
    modal.classList.remove('opacity-0'); modalContent.classList.replace('scale-95', 'scale-100');
    document.body.style.overflowY = 'hidden'; 
}

封面图未加载导致透视

这个问题出现在首页封面图没能完全加载时,因为封面遮罩是半透明的,可以直接看穿到下面的图片列表内容。之前没有发现是因为在本地测试,图片加载很快,根本没机会看到图片加载中的情况,而正式部署后才发现这个问题。
解决方法也很简单,因为网页使用了Tailwind CSS,只需要在封面背景图的最底层容器上加上一个主题色的背景类名bg-miku-bg就可以了。
修改前:

<div class="absolute inset-0 overflow-hidden z-0 pointer-events-none">
    <div
        class="absolute inset-0 bg-[url('https://teachermate.oss-cn-qingdao.aliyuncs.com/6eqnD-1775882960705-background2.jpg?x-oss-process=image/format,webp')] md:bg-[url('https://teachermate.oss-cn-qingdao.aliyuncs.com/Gzcb8-1775882957232-background.jpg?x-oss-process=image/format,webp')] bg-cover bg-center">
    </div>
    <div class="absolute inset-0 bg-miku-darkTeal/10"></div>
</div>

修改后:

<div class="absolute inset-0 overflow-hidden z-0 pointer-events-none bg-miku-bg">
    <div
        class="absolute inset-0 bg-[url('https://teachermate.oss-cn-qingdao.aliyuncs.com/6eqnD-1775882960705-background2.jpg?x-oss-process=image/format,webp')] md:bg-[url('https://teachermate.oss-cn-qingdao.aliyuncs.com/Gzcb8-1775882957232-background.jpg?x-oss-process=image/format,webp')] bg-cover bg-center">
    </div>
    <div class="absolute inset-0 bg-miku-darkTeal/10"></div>
</div>

除了上面说的三个问题,其实在迭代测试中进行了打磨和优化的地方还有很多,我就不一一罗列了。

网页代码展示

如果你希望做一个像MIKUPARA这样可以静态托管图片库的网站,我写的代码可以给你作为参考。

index.html

<!DOCTYPE html>
<html lang="zh-CN">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>MIKUPARA - 初音未来优质插画收录站</title>
    <link rel="icon" href="./favicon.png" type="image/png">
    <link rel="apple-touch-icon" href="./favicon.png">
    <meta name="keywords" content="初音未来, Hatsune Miku, Miku, 初音插画, 初音壁纸, Pixiv, 二次元美图, 动漫壁纸, 初音同人图, MIKUPARA">
    <meta name="description"
        content="欢迎光临 MIKUPARA (初音乐园) ✨!这里是专为初音未来爱好者打造的可爱插画收录小站~ 精选超多 Pixiv 上的神仙 Miku 美图与壁纸,瀑布流看图超爽快!持续不定期掉落更新,快来寻找让你疯狂心动的公主殿下吧!">
    <!-- 引入 Tailwind CSS -->
    <script src="https://cdn.tailwindcss.com"></script>
    <!-- 引入 Lucide 图标 -->
    <script src="https://unpkg.com/lucide@latest"></script>
    <script>
        // 配置 Tailwind 主题色
        tailwind.config = {
            theme: {
                extend: {
                    colors: {
                        miku: {
                            teal: '#39C5BB',
                            darkTeal: '#2b9e95',
                            pink: '#FF69B4',
                            lightPink: '#ff9bc8',
                            bg: '#f0fbfb'
                        }
                    },
                    fontFamily: {
                        sans: ['"Nunito"', '"ZCOOL KuaiLe"', '"STYuanti-SC"', '"YouYuan"', '"PingFang SC"', 'sans-serif'],
                    }
                }
            }
        }
    </script>
    <link rel="stylesheet" href="./assets/css/style.css">
</head>

<body>

    <!-- 主容器 -->
    <div id="mainContainer" class="w-full">

        <!-- ================= 1. 封面部分 (绝对定位覆盖层) ================= -->
        <section id="coverSection"
            class="fixed inset-0 z-50 w-full flex items-center justify-center transition-transform duration-[800ms] ease-in-out">

            <div class="absolute inset-0 overflow-hidden z-0 pointer-events-none bg-miku-bg">
                <div
                    class="absolute inset-0 bg-[url('https://teachermate.oss-cn-qingdao.aliyuncs.com/6eqnD-1775882960705-background2.jpg?x-oss-process=image/format,webp')] md:bg-[url('https://teachermate.oss-cn-qingdao.aliyuncs.com/Gzcb8-1775882957232-background.jpg?x-oss-process=image/format,webp')] bg-cover bg-center">
                </div>
                <div class="absolute inset-0 bg-miku-darkTeal/10"></div>
            </div>

            <img src="./assets/img/band1.webp"
                class="absolute top-0 left-1/2 -translate-x-1/2 w-[max(100vw,1920px)] max-w-none pointer-events-none z-10 opacity-90"
                style="margin-top: calc(max(100vw, 1920px) * -450 / 1920);" alt="">

            <img src="./assets/img/band2.webp"
                class="absolute top-full left-1/2 -translate-x-1/2 w-[max(100vw,1920px)] max-w-none pointer-events-none z-10 opacity-95"
                style="margin-top: calc(max(100vw, 1920px) * -500 / 1920);" alt="">

            <div class="relative z-20 text-center px-4 flex flex-col items-center">

                <div class="relative flex flex-col items-center">

                    <div class="absolute -top-6 -left-8 md:-top-10 md:-left-16 -rotate-12 pointer-events-none z-20">
                        <img src="./assets/img/float_1.webp" class="animate-[float_3s_ease-in-out_infinite]"
                            style="animation-delay: 0.1s;" alt="">
                    </div>
                    <div class="absolute top-0 -right-6 md:-top-4 md:-right-12 rotate-12 pointer-events-none z-20">
                        <img src="./assets/img/float_2.webp" class="animate-[float_4s_ease-in-out_infinite]"
                            style="animation-delay: 0.5s;" alt="">
                    </div>
                    <div class="absolute top-1/3 -left-12 md:-left-28 -rotate-6 pointer-events-none z-0">
                        <img src="./assets/img/float_3.webp" class="animate-[float_3.5s_ease-in-out_infinite]"
                            style="animation-delay: 0.8s;" alt="">
                    </div>
                    <div class="absolute top-1/4 -right-10 md:-right-24 rotate-6 pointer-events-none z-0">
                        <img src="./assets/img/float_4.webp" class="animate-[float_3s_ease-in-out_infinite]"
                            style="animation-delay: 1.2s;" alt="">
                    </div>
                    <div class="absolute bottom-2 -left-6 md:bottom-0 md:-left-12 -rotate-12 pointer-events-none z-20">
                        <img src="./assets/img/float_5.webp" class="animate-[float_4.5s_ease-in-out_infinite]"
                            style="animation-delay: 0.3s;" alt="">
                    </div>
                    <div class="absolute bottom-6 -right-8 md:bottom-4 md:-right-16 rotate-12 pointer-events-none z-20">
                        <img src="./assets/img/float_6.webp" class="animate-[float_3s_ease-in-out_infinite]"
                            style="animation-delay: 0.7s;" alt="">
                    </div>

                    <div
                        class="hidden md:block absolute -top-16 -left-36 -rotate-45 pointer-events-none z-0 opacity-90">
                        <img src="./assets/img/float_7.webp" class="animate-[float_5s_ease-in-out_infinite]"
                            style="animation-delay: 1.5s;" alt="">
                    </div>
                    <div
                        class="hidden md:block absolute -top-12 -right-32 rotate-45 pointer-events-none z-0 opacity-90">
                        <img src="./assets/img/float_8.webp" class="animate-[float_4.2s_ease-in-out_infinite]"
                            style="animation-delay: 0.2s;" alt="">
                    </div>
                    <div
                        class="hidden md:block absolute -bottom-6 -left-32 -rotate-12 pointer-events-none z-0 opacity-90">
                        <img src="./assets/img/float_9.webp" class="animate-[float_3.8s_ease-in-out_infinite]"
                            style="animation-delay: 0.9s;" alt="">
                    </div>
                    <div
                        class="hidden md:block absolute -bottom-2 -right-32 rotate-12 pointer-events-none z-0 opacity-90">
                        <img src="./assets/img/float_10.webp" class="animate-[float_3.4s_ease-in-out_infinite]"
                            style="animation-delay: 1.1s;" alt="">
                    </div>

                    <h1 class="art-text mb-1 relative z-10">MIKUPARA</h1>
                    <p class="text-white text-4xl md:text-5xl font-black tracking-widest mb-4 transform -skew-x-6 relative z-10"
                        style="text-shadow: 3px 3px 0px #39C5BB, 7px 7px 0px #FF69B4;">
                        初 音 乐 园
                    </p>
                </div>

                <p class="text-white text-sm md:text-base font-black tracking-widest mt-2 relative z-10"
                    style="text-shadow: 2px 2px 0px #FF69B4, 0px 0px 10px rgba(0,0,0,0.5);">
                    ✨ 一个个人兴趣向的插画收录站,专注收集好看的初音未来插画 ✨
                </p>
            </div>

            <!-- 底部下拉提示:移除外框,简洁风 -->
            <div class="absolute bottom-10 left-1/2 -translate-x-1/2 z-10 cursor-pointer" onclick="slideCoverUp()">
                <div class="flex flex-col items-center bounce-arrow">
                    <span class="text-white font-black mb-1 tracking-widest text-sm"
                        style="text-shadow: 2px 2px 0px #FF69B4, 0px 0px 5px rgba(0,0,0,0.5);">向下滚动进入</span>
                    <i data-lucide="chevrons-down" class="text-white w-10 h-10 drop-shadow-[2px_2px_0px_#39C5BB]"></i>
                </div>
            </div>
        </section>

        <!-- ================= 2. 内容部分 ================= -->
        <section id="contentSection" class="relative w-full min-h-screen pt-20 md:pt-10 overflow-hidden"
            style="padding-bottom: calc(max(100vw, 1920px) * (151 / 1920) / 2);">

            <button onclick="openInfoModal()"
                class="absolute top-4 right-4 md:top-6 md:right-6 z-20 p-2.5 md:p-3 bg-white text-miku-pink border-2 border-miku-pink rounded-full shadow-[4px_4px_0px_#39C5BB] transition-transform hover:-translate-y-1 hover:shadow-[6px_6px_0px_#39C5BB]">
                <i data-lucide="info" class="w-5 h-5 md:w-6 md:h-6 stroke-[3]"></i>
            </button>

            <div
                class="absolute inset-0 bg-[url('https://teachermate.oss-cn-qingdao.aliyuncs.com/OrkoN-1775882963311-background4.png?x-oss-process=image/format,webp')] md:bg-[url('https://teachermate.oss-cn-qingdao.aliyuncs.com/1wuUG-1775882966129-background3.jpg?x-oss-process=image/format,webp')] bg-cover bg-fixed bg-center">
            </div>
            <div class="absolute inset-0 bg-white/70"></div>
            <div class="absolute inset-0 pattern-dots-dark pointer-events-none"></div>

            <div class="relative z-10 max-w-7xl mx-auto px-4 md:px-10 flex flex-col h-full mt-4 md:mt-4">

                <div class="flex justify-center mb-8 md:mb-10 gap-4 md:gap-8">
                    <button id="btn-horizontal" onclick="switchTab('horizontal')"
                        class="px-4 sm:px-8 py-2 sm:py-3 rounded-full font-black text-sm sm:text-lg whitespace-nowrap flex items-center gap-1 sm:gap-2 border-2 border-miku-teal bg-miku-teal text-white shadow-[4px_4px_0px_#FF69B4] transform transition-transform hover:-translate-y-1 hover:shadow-[6px_6px_0px_#FF69B4]">
                        <i data-lucide="monitor" class="w-4 h-4 sm:w-5 sm:h-5 stroke-[2.5]"></i>
                        横图展示
                    </button>
                    <button id="btn-vertical" onclick="switchTab('vertical')"
                        class="px-4 sm:px-8 py-2 sm:py-3 rounded-full font-black text-sm sm:text-lg whitespace-nowrap flex items-center gap-1 sm:gap-2 border-2 border-miku-teal bg-white text-miku-teal shadow-[4px_4px_0px_#39C5BB] transform transition-transform hover:-translate-y-1 hover:shadow-[6px_6px_0px_#39C5BB]">
                        <i data-lucide="smartphone" class="w-4 h-4 sm:w-5 sm:h-5 stroke-[2.5]"></i>
                        竖图展示
                    </button>
                </div>

                <div id="imageGrid" class="masonry-grid flex-grow pb-10"></div>

                <div id="loadMoreAnchor" class="w-full py-8 flex justify-center items-center">
                    <div id="loadingState"
                        class="flex items-center gap-2 text-miku-pink font-black animate-pulse text-lg transition-opacity duration-300"
                        style="text-shadow: 2px 2px 0px rgba(255,105,180,0.2);">
                        <i data-lucide="sparkles" class="w-6 h-6"></i>
                        <span>正在寻找更多可爱的图片...</span>
                    </div>
                    <div id="finishedState"
                        class="hidden text-miku-darkTeal font-bold text-sm bg-white px-4 py-2 rounded-full border-2 border-miku-teal shadow-[3px_3px_0px_#39C5BB] transition-opacity duration-300">
                        没有更多图片啦 ~
                    </div>
                </div>

            </div>

            <div
                class="absolute bottom-0 left-0 w-full overflow-hidden flex justify-center pointer-events-none z-10 opacity-80">
                <img src="./assets/img/button.webp" class="w-full min-w-[1920px] max-w-none block" alt="底部装饰">
            </div>

        </section>

    </div>

    <!-- ================= 网页信息弹窗 (纯平贴纸风) ================= -->
    <div id="infoModal" class="fixed inset-0 z-50 hidden opacity-0 transition-opacity duration-300">
        <!-- 半透明纯色遮罩层 + 二次元方格网纹理 -->
        <div class="absolute inset-0 bg-miku-darkTeal/85 pattern-grid cursor-pointer" onclick="closeInfoModal()"></div>
        <div class="absolute inset-0 flex items-center justify-center p-4 pointer-events-none">
            <!-- 弹窗本体:粗边框、硬阴影、纯白背景 -->
            <div id="infoModalContent"
                class="relative bg-white w-full max-w-[90vw] lg:max-w-5xl max-h-[90vh] flex flex-col p-8 md:p-12 rounded-3xl border-4 border-miku-teal shadow-[10px_10px_0px_#FF69B4] pointer-events-auto transform transition-transform duration-300 scale-95">

                <button onclick="closeInfoModal()"
                    class="absolute -top-4 -right-4 p-2 bg-white border-2 border-miku-teal text-miku-pink rounded-full shadow-[3px_3px_0px_#39C5BB] hover:-translate-y-1 hover:shadow-[4px_4px_0px_#39C5BB] transition-transform">
                    <i data-lucide="x" class="w-6 h-6 stroke-[3]"></i>
                </button>

                <div class="text-center mb-6 flex-shrink-0">
                    <h2 class="text-3xl font-black text-white bg-miku-teal inline-block px-4 py-1 rounded-lg transform -skew-x-6 shadow-[3px_3px_0px_#FF69B4] mb-2">MIKUPARA</h2>
                    <p class="text-miku-pink font-black tracking-widest mt-2 border-b-2 border-dashed border-miku-lightPink pb-2">初音乐园</p>
                </div>

                <div class="text-miku-darkTeal font-bold space-y-4 leading-relaxed text-sm bg-miku-bg p-4 md:p-5 rounded-xl border-2 border-miku-lightPink shadow-[inset_0_0_10px_rgba(255,105,180,0.1)] overflow-y-auto flex-1 min-h-0 modal-scrollbar">
                    <p class="text-base text-miku-pink flex items-center gap-1">
                        <i data-lucide="music" class="w-4 h-4"></i>
                        欢迎来到 MIKUPARA ✨!这里是一个专为喜欢初音未来的小伙伴打造的插画小天地~
                    </p>

                    <p class="bg-white/60 p-3 rounded-lg border border-miku-teal/30">
                        说到这个名字的由来,其实是因为我超级喜欢《Nekopara》(巧克力与香子兰)!有一天我突然灵光一闪,发现“Miku”加上“para”读音和“Nekopara”神似,读起来不仅非常押韵,而且特别可爱!最让我意外的是,<strong>mikupara.com</strong>
                        这个绝赞的域名居然还可以注册!于是我果断拿下,就有了现在这个充满爱意的“初音乐园”啦 🎵!
                    </p>

                    <p>这里收录的插画主要来源于 Pixiv,都是根据我个人的审美喜好精挑细选出来的神仙美图,希望你也能在这里遇到心动的公主殿下!</p>

                    <p>因为是个人爱好,所以当我平时刷图、积累到一定数量后,就会为大家放送一波更新(也就是随缘不定期掉落惊喜啦)~ 🎁</p>

                    <p>
                        如果你也有私藏的绝美 Miku 插画想要分享,非常欢迎把 P站的链接砸向我的邮箱:
                        <a href="mailto:chocola@nekopara.uk" class="text-miku-teal font-black hover:text-miku-pink transition-colors underline decoration-miku-teal hover:decoration-miku-pink decoration-2 underline-offset-4">chocola@nekopara.uk</a>
                        ,我会超级超级感谢你的!(๑•̀ㅂ•́)و✧
                    </p>

                    <div class="pt-2 flex flex-col sm:flex-row sm:items-center justify-between border-t-2 border-dashed border-miku-lightPink/50 mt-4">
                        <p class="mb-2 sm:mb-0">
                            我的个人小站:
                            <a href="https://www.nekopara.uk" target="_blank" class="text-miku-pink font-black hover:text-miku-teal transition-colors break-all text-xs sm:text-sm underline decoration-miku-pink decoration-2 underline-offset-4">www.nekopara.uk</a>
                        (域名也是非常的可爱呢!)</p>
                        <p class="text-xs text-miku-teal/80 flex items-center gap-1">
                            <i data-lucide="alert-circle" class="w-3 h-3"></i>
                            本站仅作展示用途,图片版权归原作者所有哦!
                        </p>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <!-- ================= 3. 图片弹窗 (Modal) ================= -->
    <div id="imageModal" class="fixed inset-0 z-50 hidden opacity-0 transition-opacity duration-300">
        <!-- 弹窗半透明遮罩层 + 二次元方格网纹理 -->
        <div class="absolute inset-0 bg-miku-darkTeal/85 pattern-grid cursor-pointer" onclick="closeModal()"></div>

        <!-- 弹窗内容容器 -->
        <div class="absolute inset-0 flex flex-col items-center justify-center p-4 md:p-10 pointer-events-none">

            <!-- 右上角关闭按钮 -->
            <button onclick="closeModal()"
                class="absolute top-4 right-4 md:top-8 md:right-8 p-2 bg-miku-pink text-white border-2 border-white rounded-full shadow-[4px_4px_0px_rgba(0,0,0,0.3)] transition-transform hover:-translate-y-1 pointer-events-auto z-10">
                <i data-lucide="x" class="w-6 h-6 md:w-8 md:h-8 stroke-[3]"></i>
            </button>

            <!-- 弹窗主体结构 -->
            <div class="relative w-full max-w-[90vw] 2xl:max-w-[1600px] h-full flex flex-col items-center justify-center pointer-events-auto transform transition-transform duration-300 scale-95 py-4"
                id="modalContent">

                <!-- 主图片展示:粗白边 + 亮色硬阴影,像冲洗出来的相片/拍立得 -->
                <div class="relative w-full flex-1 flex items-center justify-center min-h-0">

                    <div id="modalLoading" class="absolute z-20 flex flex-col items-center justify-center gap-3 transition-opacity duration-300 pointer-events-none opacity-0 hidden">
                        <i data-lucide="loader-2" class="w-10 h-10 md:w-12 md:h-12 text-miku-teal animate-spin stroke-[3] drop-shadow-[2px_2px_0px_#FF69B4]"></i>
                        <span class="text-white font-black text-xs md:text-sm tracking-widest px-4 py-1.5 bg-miku-pink rounded-full border-2 border-white shadow-[3px_3px_0px_#39C5BB] animate-pulse">
                            原图加载中...
                        </span>
                    </div>

                    <img id="modalImage" src="" alt="预览大图"
                        class="max-w-full max-h-full object-contain bg-white p-2 md:p-3 rounded-xl border-2 border-miku-teal shadow-[8px_8px_0px_#FF69B4] transition-opacity duration-300">
                </div>

                <!-- 底部信息展示区 (无模糊,纯白底,硬阴影) -->
                <div
                    class="mt-6 w-full max-w-5xl flex-shrink-0 bg-white rounded-2xl p-4 md:p-5 border-4 border-miku-teal shadow-[8px_8px_0px_#39C5BB] flex flex-col items-center">
                    <h3
                        class="text-miku-pink font-black text-lg md:text-xl mb-1 flex items-center justify-center gap-2 bg-miku-pink/10 px-4 py-1 rounded-full">
                        <i data-lucide="link" class="w-5 h-5 stroke-[3]"></i> 图片来源信息
                    </h3>
                    <a id="modalUrl" href="#" target="_blank"
                        class="text-miku-darkTeal font-bold hover:text-miku-pink transition-colors break-all text-xs sm:text-sm underline decoration-miku-pink decoration-2 underline-offset-4 mb-4 mt-2">
                        https://www.mikupara.com
                    </a>

                    <!-- 下载按钮:纯正二次元交互按钮 -->
                    <a id="downloadBtn" href="#" download="mikupara_image.jpg" target="_blank"
                        class="px-8 py-2.5 bg-miku-pink text-white border-2 border-miku-pink rounded-full font-black text-sm md:text-base transition-transform shadow-[4px_4px_0px_#39C5BB] hover:shadow-[6px_6px_0px_#39C5BB] hover:-translate-y-1 flex items-center gap-2 cursor-pointer">
                        <i data-lucide="download" class="w-5 h-5 stroke-[2.5]"></i>
                        立即保存
                    </a>
                </div>
            </div>

        </div>
    </div>
    <script src="./assets/js/main.js"></script>
</body>

</html>

style.css

/* 引入一些可爱的圆润字体 */
@import url('https://fonts.googleapis.com/css2?family=M+PLUS+Rounded+1c:wght@500;700;800&family=Nunito:wght@600;800&family=ZCOOL+KuaiLe&display=swap');

body {
    margin: 0;
    overflow: hidden;
    /* 将圆体放在普通黑体的前面 */
    font-family: 'Nunito', 'ZCOOL KuaiLe', 'STYuanti-SC', 'YouYuan', 'PingFang SC', 'Microsoft YaHei', sans-serif;
    background-color: #f0fbfb;
}

/* 隐藏原来的 snap-container 样式,改为普通容器 */
#mainContainer {
width: 100%;
}

/* 自定义滚动条 (应用到 body) */
body::-webkit-scrollbar {
    width: 10px;
}
body::-webkit-scrollbar-track {
    background: #f0fbfb;
    border-left: 2px solid #39C5BB;
}
body::-webkit-scrollbar-thumb {
    background: #FF69B4;
    border: 2px solid #f0fbfb;
    border-radius: 10px;
}

/* 二次元背景叠加效果集合 */
/* 1. 波点 (用于内容页背景底纹) */
.pattern-dots-dark {
    background-image: radial-gradient(rgba(57, 197, 187, 0.15) 2px, transparent 2px);
    background-size: 16px 16px;
}

/* 2. 方格网 (用于弹窗遮罩) */
.pattern-grid {
    background-image:
    linear-gradient(rgba(255, 255, 255, 0.15) 2px, transparent 2px),
    linear-gradient(90deg, rgba(255, 255, 255, 0.15) 2px, transparent 2px);
    background-size: 30px 30px;
    background-position: -1px -1px;
}

/* 3. 斜纹线条 (用于图片悬浮遮罩) */
.pattern-stripes {
    background-image: repeating-linear-gradient(
        45deg,
        rgba(255, 255, 255, 0.25),
        rgba(255, 255, 255, 0.25) 10px,
        transparent 10px,
        transparent 20px
    );
}

/* 封面艺术字特效 - 增强二次元浓度 */
.art-text {
    font-size: 5.5rem;
    font-weight: 900;
    color: #ffffff;
    font-style: italic; /* 倾斜增加动感 */
    /* 贴纸风:使用多重 text-shadow 替代 -webkit-text-stroke 避免锐角产生色块/尖刺 */
    text-shadow:
    -3px -3px 0 #39C5BB, 0 -3px 0 #39C5BB, 3px -3px 0 #39C5BB,
    3px 0 0 #39C5BB, 3px 3px 0 #39C5BB, 0 3px 0 #39C5BB,
    -3px 3px 0 #39C5BB, -3px 0 0 #39C5BB,
    8px 8px 0px #FF69B4; /* 去除模糊阴影,改为纯硬阴影 */
    letter-spacing: 0.05em;
    animation: float 3s ease-in-out infinite;
}

@media (max-width: 768px) {
    .art-text {
        font-size: 3.5rem;
        text-shadow:
        -2px -2px 0 #39C5BB, 0 -2px 0 #39C5BB, 2px -2px 0 #39C5BB,
        2px 0 0 #39C5BB, 2px 2px 0 #39C5BB, 0 2px 0 #39C5BB,
        -2px 2px 0 #39C5BB, -2px 0 0 #39C5BB,
        6px 6px 0px #FF69B4;
    }
}

/* 可爱的浮动动画 */
@keyframes float {
    0% { transform: translateY(0px); }
    50% { transform: translateY(-15px); }
    100% { transform: translateY(0px); }
}

/* 底部跳动的箭头 */
.bounce-arrow {
    animation: bounce 2s infinite;
}
@keyframes bounce {
    0%, 20%, 50%, 80%, 100% { transform: translateY(0); }
    40% { transform: translateY(-20px); }
    60% { transform: translateY(-10px); }
}

/* 照片墙瀑布流布局 (已交由JS管理列,这里只保留硬件加速和交互类) */
.masonry-item {
    transform: translateZ(0); /* 开启硬件加速防闪烁 */
}

/* 二次元贴纸感悬浮效果:位移并加深硬阴影 */
.sticker-hover {
    transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.2s;
}
.sticker-hover:hover {
    transform: translateY(-6px) rotate(-1deg);
    box-shadow: 8px 12px 0px #FF69B4;
}

/* 弹窗内部专属可爱滚动条 */
.modal-scrollbar::-webkit-scrollbar {
    width: 6px; /* 比外部主滚动条稍微细一点,更精致 */
}
.modal-scrollbar::-webkit-scrollbar-track {
    background: rgba(57, 197, 187, 0.1); /* 极淡的初音绿轨道 */
    border-radius: 10px;
    margin: 4px; /* 让滚动条上下留一点空隙 */
}
.modal-scrollbar::-webkit-scrollbar-thumb {
    background: #ff9bc8; /* 初音粉滑块 */
    border-radius: 10px;
}
.modal-scrollbar::-webkit-scrollbar-thumb:hover {
    background: #FF69B4; /* 悬停时加深 */
}

main.js

// 初始化图标
lucide.createIcons();

// ================= 封面与弹窗交互逻辑 =================
let isCoverUp = false;
let isModalOpen = false;
let isAnimating = false; // 【新增】动画状态锁,防止过渡期间误触
const coverSection = document.getElementById('coverSection');

function slideCoverUp() {
    if (isCoverUp || isAnimating) return; // 如果正在播放动画,直接拦截
    isCoverUp = true;
    isAnimating = true; // 开启锁
    coverSection.classList.add('-translate-y-full');

    // 【核心修复】等待 800ms(与 CSS 动画时间一致)后,再允许页面滚动并解锁
    setTimeout(() => {
        document.body.style.overflowY = 'auto';
        isAnimating = false;
    }, 800);
}

function slideCoverDown() {
    if (!isCoverUp || isAnimating) return; // 如果正在播放动画,直接拦截
    isCoverUp = false;
    isAnimating = true; // 开启锁
    coverSection.classList.remove('-translate-y-full');

    // 往下盖的时候,需要瞬间锁定滚动条,防止内容抖动
    document.body.style.overflowY = 'hidden';

    // 等待 800ms 后解锁交互
    setTimeout(() => {
        isAnimating = false;
    }, 800);
}

// 监听电脑端滚轮事件
window.addEventListener('wheel', (e) => {
    // 只要有弹窗或者正在播动画,所有滚轮操作全部无效!
    if (isModalOpen || isAnimating) return;
    if (!isCoverUp && e.deltaY > 0) slideCoverUp();
    else if (isCoverUp && window.scrollY <= 0 && e.deltaY < 0) slideCoverDown();
});

// 监听手机端触摸事件
let touchStartY = 0;
window.addEventListener('touchstart', e => {
    if (isModalOpen || isAnimating) return;
    touchStartY = e.touches[0].clientY;
});
window.addEventListener('touchend', e => {
    if (isModalOpen || isAnimating) return;
    let touchEndY = e.changedTouches[0].clientY;
    if (!isCoverUp && touchStartY > touchEndY + 30) slideCoverUp();
    else if (isCoverUp && window.scrollY <= 0 && touchStartY < touchEndY - 30) slideCoverDown();
});

const infoModal = document.getElementById('infoModal');
const infoModalContent = document.getElementById('infoModalContent');
function openInfoModal() {
    isModalOpen = true; // 标记弹窗已打开
    infoModal.classList.remove('hidden'); void infoModal.offsetWidth;
    infoModal.classList.remove('opacity-0'); infoModalContent.classList.replace('scale-95', 'scale-100');
    document.body.style.overflowY = 'hidden'; // 统一使用 overflowY
}
function closeInfoModal() {
    infoModal.classList.add('opacity-0'); infoModalContent.classList.replace('scale-100', 'scale-95');
    setTimeout(() => {
        infoModal.classList.add('hidden');
        document.body.style.overflowY = 'auto';
        isModalOpen = false; // 标记弹窗已关闭
    }, 300);
}

const modal = document.getElementById('imageModal');
const modalContent = document.getElementById('modalContent');
const modalImg = document.getElementById('modalImage');
const modalUrl = document.getElementById('modalUrl');
const downloadBtn = document.getElementById('downloadBtn');
const modalLoading = document.getElementById('modalLoading');

let currentModalImage = '';

// 【优化版】接收预览图(full)、原图(raw)和来源(sourceUrl)
function openModal(fullSrc, rawSrc, sourceUrl, thumbSrc, width, height) {
    isModalOpen = true; // 标记弹窗已打开
    currentModalImage = fullSrc;

    // --- 【新增】打开弹窗时,先显示 Loading 动画,并将底图稍微变暗 ---
    modalLoading.classList.remove('hidden');
    // 强制重绘,确保 transition 动画生效
    void modalLoading.offsetWidth;
    modalLoading.classList.remove('opacity-0');
    // 给占位的 SVG 底图加上半透明,让加载提示更凸显
    modalImg.classList.add('opacity-40');
    // -------------------------------------------------------------

    // 1. 提取宽高(如果没有传,给个默认值兜底防报错)
    const w = width || 1000;
    const h = height || 1000;

    const svgStr = thumbSrc
    ? `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}"><image href="${thumbSrc}" width="${w}" height="${h}" preserveAspectRatio="none" /></svg>`
    : `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}"></svg>`;

    // 3. 将 SVG 字符串转码为安全的 Data URL(防止特殊字符报错)
    const svgPlaceholder = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgStr)}`;

    // 4. 将内嵌了缩略图的 SVG 直接作为源文件赋值
    modalImg.src = svgPlaceholder;
    modalImg.style.backgroundImage = 'none';

    // 5. 后台静悄悄地加载真正的高清大图
    const imgLoader = new Image();
    imgLoader.src = fullSrc;
    imgLoader.onload = () => {
        // 确保没有切图(比如用户在加载期间关掉又点开了另一张)
        if (currentModalImage === fullSrc) {
            modalImg.src = fullSrc;

            // --- 【新增】图片加载完成后,隐藏 Loading,恢复图片透明度 ---
            modalImg.classList.remove('opacity-40');
            modalLoading.classList.add('opacity-0');
            // 等淡出动画结束后再彻底隐藏 DOM
            setTimeout(() => {
                if(currentModalImage === fullSrc) {
                    modalLoading.classList.add('hidden');
                }
            }, 300);
            // --------------------------------------------------------
        }
    };

    // 【可选】加上图片加载失败的兜底逻辑,避免 Loading 转个不停
    imgLoader.onerror = () => {
        if (currentModalImage === fullSrc) {
            modalImg.classList.remove('opacity-40');
            modalLoading.querySelector('span').textContent = "加载失败 T_T";
            modalLoading.querySelector('span').classList.replace('bg-miku-pink', 'bg-gray-400');
            modalLoading.querySelector('i').classList.remove('animate-spin', 'text-miku-teal');
        }
    };

    // 动态处理来源链接的展示逻辑
    if (sourceUrl && sourceUrl.trim() !== "") {
        modalUrl.href = sourceUrl;
        modalUrl.textContent = sourceUrl;
        modalUrl.classList.remove('pointer-events-none', 'text-gray-400', 'decoration-transparent');
        modalUrl.classList.add('text-miku-darkTeal', 'hover:text-miku-pink', 'underline');
    } else {
        modalUrl.href = 'javascript:void(0);';
        modalUrl.textContent = '暂未收录来源信息';
        modalUrl.classList.remove('text-miku-darkTeal', 'hover:text-miku-pink', 'underline');
        modalUrl.classList.add('pointer-events-none', 'text-gray-400', 'decoration-transparent');
    }

    // 设置下载链接
    downloadBtn.href = rawSrc;

    // ================= 【新增逻辑:提取原文件名称】 =================
    let fileName = rawSrc.split('/').pop().split('?')[0];
    if (!fileName) {
        fileName = 'mikupara_kawaii_miku.jpg';
    }
    downloadBtn.download = fileName;
    // ==============================================================

    modal.classList.remove('hidden'); void modal.offsetWidth;
    modal.classList.remove('opacity-0'); modalContent.classList.replace('scale-95', 'scale-100');
    document.body.style.overflowY = 'hidden';
}

function closeModal() {
    modal.classList.add('opacity-0'); modalContent.classList.replace('scale-100', 'scale-95');
    setTimeout(() => {
        modal.classList.add('hidden');
        document.body.style.overflowY = 'auto';
        isModalOpen = false; // 标记弹窗已关闭
    }, 300);
}

document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') {
        if (!modal.classList.contains('hidden')) closeModal();
        if (!infoModal.classList.contains('hidden')) closeInfoModal();
    }
});

// ================= 数据加载与瀑布流核心逻辑 =================

let currentTab = 'horizontal';
let currentData = { baseUrl: '', images: [] };
let currentIndex = 0;
const BATCH_SIZE = 16;
let isLoading = false;

const grid = document.getElementById('imageGrid');
const loadMoreAnchor = document.getElementById('loadMoreAnchor');

// 瀑布流列管理
let columns = [];
let columnHeights = [];

// 根据屏幕宽度获取列数
function getColumnCount() {
    if (window.innerWidth >= 1280) return 4;
    if (window.innerWidth >= 1024) return 3;
    if (window.innerWidth >= 640) return 2;
    return 1;
}

// 初始化/重置列容器
function initColumns() {
    grid.innerHTML = '';
    // 使用 Tailwind Flexbox 横向排列列容器
    grid.className = "flex justify-center gap-4 md:gap-6 w-full";
    columns = [];
    columnHeights = [];
    const colCount = getColumnCount();

    for (let i = 0; i < colCount; i++) {
        const col = document.createElement('div');
        // 每列内部元素垂直排列,平分宽度
        col.className = "flex flex-col gap-4 md:gap-6 flex-1 min-w-0";
        grid.appendChild(col);
        columns.push(col);
        columnHeights.push(0); // 初始高度为0
    }
}

// 创建单张图片卡片的 DOM 元素
function createImageCard(img) {
    const div = document.createElement('div');
    div.className = "masonry-item sticker-hover bg-white p-1.5 md:p-2 rounded-2xl border-2 border-miku-teal shadow-[5px_5px_0px_#39C5BB] cursor-pointer w-full h-fit";

    const thumbUrl = currentData.baseUrl + img.thumb;
    const fullUrl = currentData.baseUrl + img.full;
    const rawUrl = currentData.baseUrl + img.raw;

    div.onclick = () => openModal(fullUrl, rawUrl, img.source, thumbUrl, img.width, img.height);

    const aspectRatio = img.width && img.height ? `${img.width} / ${img.height}` : 'auto';

    div.innerHTML = `
    <div class="overflow-hidden rounded-xl relative group bg-miku-bg" style="aspect-ratio: ${aspectRatio};">
    <img src="${thumbUrl}" alt="作品展示" class="w-full h-full object-cover transform transition-transform duration-500 group-hover:scale-105" loading="lazy">
    <div class="absolute inset-0 bg-miku-pink/40 pattern-stripes opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center mix-blend-hard-light">
    <div class="bg-white border-2 border-miku-pink p-3 rounded-full text-miku-pink shadow-[3px_3px_0px_#FF69B4] transform translate-y-4 group-hover:translate-y-0 transition-transform duration-300">
    <i data-lucide="heart" class="w-6 h-6 fill-miku-pink"></i>
    </div>
    </div>
    </div>
    `;
    return div;
}

// 切换 Tab 函数
async function switchTab(tabType) {
    if (currentTab === tabType && currentData.images.length > 0) return;
    currentTab = tabType;

    const btnH = document.getElementById('btn-horizontal');
    const btnV = document.getElementById('btn-vertical');
    const baseClass = "px-4 sm:px-8 py-2 sm:py-3 rounded-full font-black text-sm sm:text-lg whitespace-nowrap flex items-center gap-1 sm:gap-2 border-2 border-miku-teal transform transition-transform hover:-translate-y-1 ";
    const activeFull = baseClass + "bg-miku-teal text-white shadow-[4px_4px_0px_#FF69B4] hover:shadow-[6px_6px_0px_#FF69B4]";
    const inactiveFull = baseClass + "bg-white text-miku-teal shadow-[4px_4px_0px_#39C5BB] hover:shadow-[6px_6px_0px_#39C5BB]";

    if (tabType === 'horizontal') {
        btnH.className = activeFull; btnV.className = inactiveFull;
    } else {
        btnV.className = activeFull; btnH.className = inactiveFull;
    }

    grid.style.opacity = '0';
    currentIndex = 0;
    currentData = { baseUrl: '', images: [] };
    isLoading = true;
    loadMoreAnchor.style.display = 'flex';

    setTimeout(async () => {
        initColumns(); // 【修改点】初始化新的列布局
        grid.style.opacity = '1';

        try {
            const response = await fetch(`${tabType}.json`);
            if (!response.ok) throw new Error('网络请求失败');
            const jsonData = await response.json();

            currentData.baseUrl = jsonData.baseUrl || '';
            currentData.images = jsonData.images || [];

            isLoading = false;
            renderNextBatch();
        } catch (error) {
            console.error('获取图片数据失败:', error);
            grid.innerHTML = `<div class="w-full text-center text-miku-pink font-bold mt-10">阿勒?加载数据失败了,请确保 ${tabType}.json 文件存在且格式正确~</div>`;
            loadMoreAnchor.style.display = 'none';
        }
    }, 300);
}

// 渲染下一批图片
function renderNextBatch() {
    if (isLoading || currentIndex >= currentData.images.length) return;
    isLoading = true;

    const endIndex = Math.min(currentIndex + BATCH_SIZE, currentData.images.length);

    for (let i = currentIndex; i < endIndex; i++) {
        const img = currentData.images[i];
        const card = createImageCard(img);

        // 【核心算法】寻找当前最矮的列
        let minIndex = 0;
        let minHeight = columnHeights[0];
        for (let j = 1; j < columns.length; j++) {
            if (columnHeights[j] < minHeight) {
                minHeight = columnHeights[j];
                minIndex = j;
            }
        }

        // 将卡片加入最矮的列
        columns[minIndex].appendChild(card);

        // 利用 JSON 中记录的分辨率,准确预判图片高度比例并累加,无需等待图片加载完成!
        const ratio = (img.height && img.width) ? (img.height / img.width) : 1;
        columnHeights[minIndex] += ratio;
    }

    lucide.createIcons();
    currentIndex = endIndex;
    isLoading = false;

    if (currentIndex >= currentData.images.length) {
        loadMoreAnchor.innerHTML = `<span class="text-miku-darkTeal font-bold text-sm bg-white px-4 py-2 rounded-full border-2 border-miku-teal shadow-[3px_3px_0px_#39C5BB]">没有更多图片啦 ~</span>`;
    } else {
        loadMoreAnchor.innerHTML = `
        <div class="flex items-center gap-2 text-miku-pink font-black animate-pulse text-lg" style="text-shadow: 2px 2px 0px rgba(255,105,180,0.2);">
        <i data-lucide="sparkles" class="w-6 h-6"></i>
        <span>正在寻找更多可爱的图片...</span>
        </div>
        `;

        const rect = loadMoreAnchor.getBoundingClientRect();
        if (rect.top <= (window.innerHeight || document.documentElement.clientHeight) + 150) {
            setTimeout(() => {
                renderNextBatch();
            }, 50);
        }
    }
}

// 监听窗口大小改变,防抖重新排版
let resizeTimer;
window.addEventListener('resize', () => {
    clearTimeout(resizeTimer);
    resizeTimer = setTimeout(() => {
        const newColCount = getColumnCount();
        // 如果跨越了屏幕断点导致列数变化,则重新渲染当前已加载的所有图片
        if (columns.length !== newColCount && currentData.images.length > 0) {
            const loadedEndIndex = currentIndex;
            initColumns();

            // 将之前加载的图片重新分配到新的列数中
            for (let i = 0; i < loadedEndIndex; i++) {
                const img = currentData.images[i];
                const card = createImageCard(img);

                let minIndex = 0;
                let minHeight = columnHeights[0];
                for (let j = 1; j < columns.length; j++) {
                    if (columnHeights[j] < minHeight) {
                        minHeight = columnHeights[j];
                        minIndex = j;
                    }
                }
                columns[minIndex].appendChild(card);
                const ratio = (img.height && img.width) ? (img.height / img.width) : 1;
                columnHeights[minIndex] += ratio;
            }
            lucide.createIcons();
        }
    }, 300);
});

const loadMoreObserver = new IntersectionObserver((entries) => {
    if (entries[0].isIntersecting && !isLoading && currentIndex < currentData.images.length) {
        setTimeout(() => {
            renderNextBatch();
        }, 300);
    }
}, {
    root: null,
    rootMargin: '150px',
    threshold: 0.1
});

loadMoreObserver.observe(loadMoreAnchor);

// 页面初始化
switchTab('horizontal');

使用Python批量处理插画

因为在开发网页的时候,我定义了一个用于存放插画信息的json文件,内容是这样的:

{
  "baseUrl": "./horizontal_pics/",
  "images": [
    {
      "thumb": "143070590_p0_thumb.webp",
      "full": "143070590_p0.webp",
      "raw": "143070590_p0.jpg",
      "width": 3543,
      "height": 1772,
      "source": "https://www.pixiv.net/artworks/143070590"
    },
    {
      "thumb": "109053034_p0_thumb.webp",
      "full": "109053034_p0.webp",
      "raw": "109053034_p0.png",
      "width": 4391,
      "height": 2371,
      "source": "https://www.pixiv.net/artworks/109053034"
    },
    {
      "thumb": "113518068_p0_thumb.webp",
      "full": "113518068_p0.webp",
      "raw": "113518068_p0.jpg",
      "width": 8757,
      "height": 4334,
      "source": "https://www.pixiv.net/artworks/113518068"
    }
  ]
}

这个文件用于给js在网页生成图片瀑布流视图,总不能手搓这个文件吧,自然我们就需要一个Python程序来完成这个工作。
于是就有了process.py

import os
import json
import shutil
from pathlib import Path
from PIL import Image
from concurrent.futures import ThreadPoolExecutor, as_completed

# ================= 配置区 =================
INPUT_DIR = "/run/media/chocola/MIKU/tmp2/"  # 放入你搜集到的所有原图的文件夹
OUTPUT_DIR = "/run/media/chocola/3T-DATA02/wwwroot/mikupara/pages/"       # 处理完成后存放最终文件和 JSON 的文件夹

# 你的网站部署好之后,存放图片的相对网络路径
BASE_URL_HORIZONTAL = "./horizontal_pics/"
BASE_URL_VERTICAL = "./vertical_pics/"

# 你的网站真实域名(用于生成 Sitemap,必须是绝对路径,结尾不要加斜杠)
SITE_URL = "https://www.mikupara.com"
# ==========================================

# 读取已有的 JSON 文件,用于获取历史数据并查重
def load_existing_json(json_path, default_base_url):
    if json_path.exists():
        try:
            with open(json_path, 'r', encoding='utf-8') as f:
                return json.load(f)
        except Exception:
            pass
    return {"baseUrl": default_base_url, "images": []}

# 单个图片处理任务(为了多线程安全,剥离为独立函数)
def process_single_image(img_path, horiz_dir, vert_dir):
    try:
        with Image.open(img_path) as img:
            if img.mode in ("RGBA", "P"):
                img = img.convert("RGB")

            width, height = img.size
            is_horizontal = width >= height
            target_dir = horiz_dir if is_horizontal else vert_dir

            base_name = img_path.stem
            ext = img_path.suffix.lower()

            thumb_name = f"{base_name}_thumb.webp"
            full_name = f"{base_name}.webp"
            raw_name = f"{base_name}{ext}" # 这个名字其实就是源文件名

            # 1. 拷贝原始文件
            shutil.copy2(img_path, target_dir / raw_name)

            # 2. 生成全尺寸预览图
            img.save(target_dir / full_name, "WEBP", quality=90)

            # 3. 生成缩略图
            target_width = 600
            if width > target_width:
                target_height = int(height * (target_width / width))
                thumb_img = img.resize((target_width, target_height), Image.Resampling.LANCZOS)
            else:
                thumb_img = img

            thumb_img.save(target_dir / thumb_name, "WEBP", quality=80)

            # 4. 提取 Pixiv ID
            pixiv_id = base_name.split('_')[0]
            source_url = f"https://www.pixiv.net/artworks/{pixiv_id}" if pixiv_id.isdigit() else ""

            # 组装这单张图片的数据
            data = {
                "thumb": thumb_name,
                "full": full_name,
                "raw": raw_name,
                "width": width,
                "height": height,
                "source": source_url
            }

            return {
                "success": True,
                "name": img_path.name,
                "orientation": "horizontal" if is_horizontal else "vertical",
                "data": data
            }

    except Exception as e:
        return {
            "success": False,
            "name": img_path.name,
            "error": str(e)
        }


def process_images():
    horiz_dir = Path(OUTPUT_DIR) / "horizontal_pics"
    vert_dir = Path(OUTPUT_DIR) / "vertical_pics"
    horiz_dir.mkdir(parents=True, exist_ok=True)
    vert_dir.mkdir(parents=True, exist_ok=True)

    json_horiz_path = Path(OUTPUT_DIR) / "horizontal.json"
    json_vert_path = Path(OUTPUT_DIR) / "vertical.json"

    # ================= 1. 增量更新:读取现有数据并查重 =================
    horiz_data = load_existing_json(json_horiz_path, BASE_URL_HORIZONTAL)
    vert_data = load_existing_json(json_vert_path, BASE_URL_VERTICAL)

    # 建立一个集合,存储所有已经处理过的原图文件名
    processed_files = set()
    for img in horiz_data["images"] + vert_data["images"]:
        processed_files.add(img["raw"])

    valid_exts = {".jpg", ".jpeg", ".png", ".webp"}
    input_path = Path(INPUT_DIR)

    if not input_path.exists():
        print(f"❌ 找不到输入目录 '{INPUT_DIR}'")
        return

    # 过滤出全新的图片(未被处理过的)
    all_files = [f for f in input_path.iterdir() if f.is_file() and f.suffix.lower() in valid_exts]
    new_files = [f for f in all_files if f.name not in processed_files]

    if not new_files:
        print("⚡ 没有发现新图片,所有图片均已处理过,已跳过。")
        return

    print(f"🔍 扫描到 {len(all_files)} 张图片,其中 {len(new_files)} 张是全新的!")
    print(f"🚀 开始多线程处理这 {len(new_files)} 张新图片...\n")

    new_horiz_images = []
    new_vert_images = []

    # ================= 2. 多线程并行处理 =================
    # 获取电脑的 CPU 核心数作为最大线程数,最大程度压榨性能
    max_workers = os.cpu_count() or 4

    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        # 提交所有新图片的处理任务
        future_to_img = {executor.submit(process_single_image, f, horiz_dir, vert_dir): f for f in new_files}

        # 实时获取处理结果
        for future in as_completed(future_to_img):
            result = future.result()
            if result["success"]:
                orientation = "横图" if result["orientation"] == "horizontal" else "竖图"
                print(f"✅ 处理成功: {result['name']} -> [{orientation}]")

                # 将新图数据暂时存在新列表中
                if result["orientation"] == "horizontal":
                    new_horiz_images.append(result["data"])
                else:
                    new_vert_images.append(result["data"])
            else:
                print(f"❌ 处理失败 {result['name']}: {result['error']}")

    # ================= 3. 倒序拼接 JSON (新图在最前) =================
    # 新处理的图片 + 历史已有的图片 = 最新的优先展示
    horiz_data["images"] = new_horiz_images + horiz_data["images"]
    vert_data["images"] = new_vert_images + vert_data["images"]

    with open(json_horiz_path, "w", encoding="utf-8") as f:
        json.dump(horiz_data, f, ensure_ascii=False, indent=2)

    with open(json_vert_path, "w", encoding="utf-8") as f:
        json.dump(vert_data, f, ensure_ascii=False, indent=2)

    # ================= 4. 生成最新的 Sitemap =================
    print("\n🗺️ 正在更新 sitemap.xml...")
    image_urls = []

    # 此时 horiz_data 和 vert_data 已经包含了新老所有图片
    for img in horiz_data["images"]:
        rel_path = BASE_URL_HORIZONTAL.lstrip('.')
        image_urls.append(f"{SITE_URL}{rel_path}{img['full']}")

    for img in vert_data["images"]:
        rel_path = BASE_URL_VERTICAL.lstrip('.')
        image_urls.append(f"{SITE_URL}{rel_path}{img['full']}")

    xml_content = [
        '<?xml version="1.0" encoding="UTF-8"?>',
        '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"',
        '        xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">'
    ]

    chunk_size = 1000
    for i in range(0, len(image_urls), chunk_size):
        xml_content.append('  <url>')
        xml_content.append(f'    <loc>{SITE_URL}/</loc>')
        chunk = image_urls[i:i + chunk_size]
        for img_url in chunk:
            xml_content.append('    <image:image>')
            xml_content.append(f'      <image:loc>{img_url}</image:loc>')
            xml_content.append('      <image:title>初音未来插画 Hatsune Miku Illustration</image:title>')
            xml_content.append('    </image:image>')
        xml_content.append('  </url>')
    xml_content.append('</urlset>')

    with open(Path(OUTPUT_DIR) / "sitemap.xml", "w", encoding="utf-8") as f:
        f.write("\n".join(xml_content))

    print("\n🎉 全部增量更新并处理完成!")
    print("你可以直接将 'output' 文件夹覆盖上传到 Cloudflare Pages 了。")

if __name__ == "__main__":
    process_images()

这个Python程序可以根据文件名拼凑出P站的作品URL(前提是你的图片从P站下载回来没有改过文件名),并且可以分类出横图和竖图分别放置,还会获取图片的尺寸信息,并且进行格式的转化和压缩,确保得到的图片文件夹符合网页展示要求。有了这个程序,只需要整理好图片放在一个文件夹,Python启动,三百多张图片在几分钟内就能处理好啦~
如果没有这个程序,在GIMP手工一张一张图转换,整理,再手搓json和SiteMap真的会死人的喵!

设置_headers文件优化SEO

因为网页的图片众多,且会有三张内容重复的图片,此外还有不希望搜索引擎展示的json数据文件,所以我们需要指导搜索引擎爬虫避开这些文件的检索。
不过,因为这些文件是网页渲染必须的,如果用robots.txt直接屏蔽,搜索引擎无法抓取,势必会影响SEO。所以我们需要使用Cloudflare Pages的一个进阶玩法——设置_headers文件。
_headers文件可以让Cloudflare Pages在响应某些特定文件时,附上自定义的响应头。对于搜索引擎爬虫,只需要附带上X-Robots-Tag: noindex的相应头,就可以让搜索引擎只抓取文件用于网页的渲染,但是这个文件不进入搜索引擎的索引,从而防止出现无意义的内容和重复的内容。
下面就是我的_headers文件的内容,可以作为参考:

/*.json
  X-Robots-Tag: noindex

/assets/img/*
  X-Robots-Tag: noindex

/horizontal_pics/*_thumb.webp
  X-Robots-Tag: noindex
/vertical_pics/*_thumb.webp
  X-Robots-Tag: noindex

这个文件设置了特定的目录和文件,例如/目录下的所有json文件,附带上X-Robots-Tag: noindex相应头。
玩过Linux的应该对这个路径和通配符的意义很熟悉了。

使用Wrangler部署页面

最后一步,就是把静态页面部署到Cloudflare Pages啦!
因为插画比较多,很明显已经超过了网页端上传的限制,这时我们只能使用Wrangler了。
因为wrangler需要nodejs环境,我们先安装nodejs:

pacman -S nodejs npm

然后再安装wrangler,系统级安装命令如下:

npm install -g wrangler

当然,也可以在当前目录进行安装,这个更推荐:

npx wrangler

安装完成后,先通过环境变量导入Cloudflare账户ID和API令牌:

export CLOUDFLARE_API_TOKEN="www.nekopara.uk"
export CLOUDFLARE_ACCOUNT_ID="www.nekopara.uk"

记得先在Cloudflare网页创建一个Pages实例,记住项目名字,然后,部署:

npx wrangler pages deploy ./pages --project-name mikupara

等命令行跑完,大功告成,MIKUPARA.COM就上线啦!