上线MIKUPARA:初音插画收藏展示站
前段时间,我突发奇想想到了“MIKUPARA”这个极佳的名字,然后惊喜地发现MIKUPARA.COM没有人注册过,看了看美元最近的汇率走向在低位,直接梭哈拿下域名!
拿下域名后,我就在想要不要拿这个域名干些什么有趣的事情,因为MIKUPARA对应过来名称就是初音乐园,那肯定是要和初音未来相关的东西。思来想去,我感觉如果做成一个社区,感觉大概率就是死水一潭,而且也不好管理。其他的话目前也不知道有什么可持续的,因为我感觉就现在互联网的氛围,从零开始搞一个独立的社区真的很难了,已经不是2010年前后的互联网了,不会再有mikufans了(笑)。
正好我有一些收藏的初音插画,不如就搞个网站专门展示我的收藏吧,我感觉这个想法比较可行,毕竟如果是纯静态展示,定期更新收藏的话,Cloudflare Pages就可以搞定了,没有其他成本,只是域名的钱。不过这个域名也算是我的收藏之一了。闲着也是闲着,不如用起来。
技术路线构思
因为是图片展示站,为了丝滑的浏览体验,有个瀑布流加载肯定是最好的。但是,纯静态页面,怎么做到瀑布流下拉刷新呢?我想起来了我之前做的小玩具——MediaPages。为了实现在线媒体库,我的做法是把相关的文件信息保存到json文件作为索引,然后通过js获取相关的信息来更新页面。这个思路应该是可以应用到本次的开发上的。
我打算让json文件包含以下信息:
对于每一张图片:
- 预览文件名称:用于瀑布流预览的缩略图,缩小图片尺寸并转码为webp节省加载时间。
- 全尺寸文件名称:用于点击图片展示,转码为webp节省加载时间。
- 原文件名称:原始的插画文件,用于下载。
- 图片尺寸信息:包含宽度和高度的数值,单位为像素
- 图片来源信息:存放图片来源的URL,注明来源,尊重插画作者
此外,还有一个图片文件的文件目录URL,用于拼接图片完整地址,节省数据文件大小。
网站界面构思
关于网站的界面,我打算采用封面图+内容的简单组合。也就是网站加载是显示一个整页的图片封面+文字,下滑展示内容,这样子可以让网站更有层次感。然后在内容页面,顶部有两个按钮,可以切换横图和竖图,方便筛选,且为后续自然过度到下部图片瀑布流提供便利。
在交互逻辑上,我希望做到点开图片打开一个大窗口,契合图片尺寸进行全屏展示,对内容进行一些遮罩来突出重点。此外,再制作一个信息框用于展示网站的信息。
构思完成后,接下来就是压力Gemini开发前端了。
前端开发
我使用的是Gemini的canvas功能进行网页前端的快速迭代,但是因为Gemini的网页开发训练集都是大众化的项目,开发出来的第一版方案,虽然好看,但是总感觉没有二次元内味。

虽然说味不太对,但是它能Get到我想要的点,第一次就能做出来这样的页面,确实已经相当不错了。
后续就是需要进行风格化打磨,我翻了我之前收集的B站各种二次元风格活动页面的参考图,并且与Gemini讨论后,意识到了问题出在哪里——页面运用了大量模糊,柔和阴影,发光边缘,虽然视觉效果好看,但是二次元的风格是边缘硬朗,贴纸风格,配色艳丽柔和,这一版设计稿很明显不符合这样的审美。
于是,我就开始一步一步让Gemini把页面二次元风格化:
- 为首页的标题文字添加粗重的描边和硬朗的投射贴纸风阴影。
- 对于展示页面,让UI不是完全扁平化,而是加上了硬朗的贴纸风阴影和边框。
- 对于画面的细节,我让他在展示页面的背景加上了点阵纹理,选择图片时遮罩一层斜向纹理,点开图片的背景遮罩层加上方格纹理,这些都是二次元常用的补充画面细节的纹理,可以提升浓度和画面观感。
- 此外,因为当时收集参考图时,收集到了一些可爱的素材图片,不少还是透明背景的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就上线啦!









































































































































































































