Files
ComiPy/templates/view.html.j2
2025-09-02 00:56:54 +08:00

773 lines
30 KiB
Django/Jinja
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ComiPy-漫画详情页</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: 'Google Sans', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f8f9fa;
min-height: 100vh;
color: #202124;
line-height: 1.6;
}
.header {
background: #ffffff;
padding: 12px 20px;
text-align: center;
box-shadow: 0 1px 3px rgba(60, 64, 67, 0.3);
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
border-bottom: 1px solid #dadce0;
transform: translateY(0);
transition: transform 0.3s ease-in-out;
}
.header.hidden {
transform: translateY(-100%);
}
.header h1 {
display: none;
}
.page-info {
font-size: 0.8rem;
color: #5f6368;
margin-bottom: 8px;
font-weight: 400;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 24px 16px;
background-color: #ffffff;
border-radius: 8px;
margin-top: 80px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(60, 64, 67, 0.3);
}
#comic-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
margin-bottom: 32px;
}
.comic-image {
max-width: 100%;
width: auto;
height: auto;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(60, 64, 67, 0.3);
transition: box-shadow 0.3s ease;
background: #fff;
overflow: hidden;
}
.comic-image:hover {
box-shadow: 0 4px 8px rgba(60, 64, 67, 0.3);
}
img {
display: block;
width: 100%;
min-height: 200px;
border-radius: 4px;
background: linear-gradient(45deg, #f1f3f4 25%, transparent 25%, transparent 75%, #f1f3f4 75%),
linear-gradient(45deg, #f1f3f4 25%, transparent 25%, transparent 75%, #f1f3f4 75%);
background-size: 20px 20px;
background-position: 0 0, 10px 10px;
}
.pagination-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 8px;
margin: 16px 0;
padding: 0 8px; /* 给两侧留白,避免被屏幕边缘贴边或裁剪 */
box-sizing: border-box;
scroll-padding-inline: 12px; /* 滚动对齐时保留左右内边距 */
scrollbar-gutter: stable both-edges; /* 预留滚动条空间,避免内容被遮挡 */
}
.page-button {
padding: 8px 16px;
background: #ffffff;
color: #1a73e8;
border: 1px solid #dadce0;
cursor: pointer;
border-radius: 4px;
font-weight: 500;
font-size: 14px;
transition: all 0.2s ease;
min-width: 36px;
font-family: inherit;
white-space: nowrap; /* 防止中文自动换行成竖排 */
flex: 0 0 auto; /* 在可横向滚动容器中保持宽度,不被压缩 */
}
.page-button:hover:not(:disabled) {
box-shadow: 0 1px 3px rgba(60, 64, 67, 0.3);
border-color: #1a73e8;
}
.page-button:active:not(:disabled) {
background: #f8f9fa;
}
.page-button:disabled {
background: #1a73e8;
color: #ffffff;
border-color: #1a73e8;
cursor: default;
}
.page-button.nav-button {
background: #1a73e8;
color: #ffffff;
border-color: #1a73e8;
padding: 8px 24px;
font-size: 14px;
min-width: 88px; /* 桌面端保证能容纳“上一页/下一页” */
}
.page-button.nav-button:hover:not(:disabled) {
background: #1557b0;
box-shadow: 0 1px 3px rgba(60, 64, 67, 0.3);
}
.page-button:disabled.nav-button {
background: #dadce0;
color: #80868b;
border-color: #dadce0;
}
.ellipsis {
padding: 8px 16px;
margin: 0;
background: transparent;
border: none;
cursor: default;
color: #5f6368;
font-weight: 400;
}
.page-jump {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 8px;
flex: 0 0 auto; /* 避免被压缩导致输入框过小 */
}
.page-input {
width: 84px;
padding: 7px 8px;
border: 1px solid #dadce0;
border-radius: 4px;
font-size: 14px;
font-family: inherit;
outline: none;
}
.page-input:focus {
border-color: #1a73e8;
box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.15);
}
.loading-placeholder {
background: linear-gradient(90deg, #f1f3f4 25%, #e8eaed 50%, #f1f3f4 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
border-radius: 4px;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
#global-blur {
background-color: rgba(255, 255, 255, 0.95);
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
transition: opacity 0.3s ease;
z-index: 1000;
pointer-events: none;
opacity: 0;
}
@media (max-width: 768px) {
.header {
padding: 16px;
}
.header h1 {
font-size: 1.5rem;
}
.container {
margin: 8px;
padding: 12px 10px;
}
.page-button {
padding: 8px 12px; /* 提高触摸目标尺寸 */
font-size: 13px;
min-width: 32px;
}
.page-button.nav-button {
padding: 8px 16px;
font-size: 13px;
min-width: 72px; /* 移动端最小宽度,避免断行 */
}
.page-input {
width: 72px;
padding: 6px 8px;
font-size: 13px;
}
/* 小屏分页支持横向滚动,减少换行高度 */
.pagination-container {
flex-wrap: nowrap;
justify-content: flex-start; /* 小屏靠左对齐,避免左侧被裁剪 */
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
gap: 6px;
padding: 0 12px; /* 左右更充足的可视留白 */
scroll-padding-inline: 12px; /* 小屏滚动对齐保留内边距 */
overscroll-behavior-x: contain; /* 避免横向滚动影响页面整体 */
}
.pagination-container::-webkit-scrollbar { height: 6px; }
.pagination-container::-webkit-scrollbar-thumb { background: #dadce0; border-radius: 3px; }
}
/* 移动端悬浮操作按钮FAB */
.mobile-fabs {
position: fixed;
right: 12px;
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
display: none; /* 桌面端隐藏 */
flex-direction: column;
gap: 10px;
z-index: 1100;
}
.mobile-fabs .fab {
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid #dadce0;
background: #ffffff;
color: #1a73e8;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(60,64,67,.3);
cursor: pointer;
user-select: none;
font-size: 18px;
}
.mobile-fabs .fab:disabled {
color: #9aa0a6;
border-color: #e0e0e0;
}
@media (max-width: 768px) {
.mobile-fabs { display: inline-flex; }
}
</style>
</head>
<body>
<div class="header" id="header">
<div class="page-info" id="page-info">第 <span id="current-page-display">1</span> 页,共 <span id="total-pages-display">1</span> 页</div>
<div class="pagination-container" id="top-pagination"></div>
</div>
<div class="container">
<div id="comic-container"></div>
<div class="pagination-container" id="bottom-pagination"></div>
</div>
<!-- 移动端悬浮操作按钮:上一页、下一页、回到顶部 -->
<div class="mobile-fabs" aria-label="阅读操作" id="mobile-fabs" role="toolbar">
<button class="fab" id="fab-prev" title="上一页" aria-label="上一页">⟨</button>
<button class="fab" id="fab-next" title="下一页" aria-label="下一页">⟩</button>
<button class="fab" id="fab-top" title="回到顶部" aria-label="回到顶部">↑</button>
</div>
<div id="global-blur" onclick="unshowGlobalBlur()"></div>
<script>
document.addEventListener('DOMContentLoaded', function () {
const imgsData = [
{% for i in index %}
{ src: "/api/img/{{ id }}/{{ i }}", alt: "漫画页面 {{ i }}" },
{% endfor %}
];
const itemsPerPage = 25; // 每页显示的图片数量
let currentPage = 1; // 逻辑分页每25张图为一页
const totalPages = Math.ceil(imgsData.length / itemsPerPage);
const contentId = "{{ id }}";
const storageKey = `ComiPy:reader:${contentId}`;
let ignoreHashChange = false; // 避免程序更新 hash 触发重复处理
let pendingScrollSave = null; // 节流保存滚动位置
let lastVisibleImageIndex = 0; // 记录当前页内大致位置(第几张图)
function renderPage(page) {
const comicContainer = document.getElementById('comic-container');
comicContainer.innerHTML = ''; // 清空当前内容
// 显示加载动画
showLoadingPlaceholders();
const start = (page - 1) * itemsPerPage;
const end = start + itemsPerPage;
const pageItems = imgsData.slice(start, end);
// 清空加载动画并加载图片
setTimeout(() => {
comicContainer.innerHTML = '';
pageItems.forEach((item, index) => {
const img = document.createElement('img');
img.className = 'imgs comic-image loading-placeholder';
img.setAttribute('data-src', item.src);
img.setAttribute('alt', item.alt);
img.setAttribute('loading', 'lazy');
img.setAttribute('decoding', 'async');
img.setAttribute('fetchpriority', 'low');
img.onload = () => img.classList.remove('loading-placeholder');
comicContainer.appendChild(img);
});
// 优先恢复页内滚动位置,否则回到顶部
const saved = loadReadingState();
if (saved && saved.page === currentPage && typeof saved.inPageIndex === 'number') {
// 等待下一帧,确保元素渲染后再滚动
requestAnimationFrame(() => {
scrollToImage(saved.inPageIndex);
lazyLoad(); // 确保惰性加载生效
});
} else {
window.scrollTo({ top: 0, behavior: 'smooth' }); // 平滑滚动到页面顶部
lazyLoad(); // 确保惰性加载生效
}
}, 300);
}
function showLoadingPlaceholders() {
const comicContainer = document.getElementById('comic-container');
comicContainer.innerHTML = '';
for (let i = 0; i < Math.min(itemsPerPage, 5); i++) {
const placeholder = document.createElement('div');
placeholder.className = 'comic-image loading-placeholder';
placeholder.style.height = '400px';
comicContainer.appendChild(placeholder);
}
}
function updatePageInfo() {
document.getElementById('current-page-display').textContent = currentPage;
document.getElementById('total-pages-display').textContent = totalPages;
}
function renderPagination() {
const topPagination = document.getElementById('top-pagination');
const bottomPagination = document.getElementById('bottom-pagination');
// 清空当前内容
topPagination.innerHTML = '';
bottomPagination.innerHTML = '';
if (totalPages <= 1) return;
// 创建分页按钮
function createPaginationButtons(container) {
const isMobile = window.innerWidth <= 768;
const pageWindow = isMobile ? 1 : 2; // 手机端收窄页码窗口
// 上一页按钮
const prevButton = document.createElement('button');
prevButton.className = 'page-button nav-button';
prevButton.innerHTML = '上一页';
prevButton.disabled = (currentPage === 1);
prevButton.addEventListener('click', () => {
if (currentPage > 1) {
currentPage--;
changePage();
}
});
container.appendChild(prevButton);
// 页码按钮
const startPage = Math.max(1, currentPage - pageWindow);
const endPage = Math.min(totalPages, currentPage + pageWindow);
// 第一页
if (startPage > 1) {
createPageButton(container, 1);
if (startPage > 2) {
const ellipsis = document.createElement('span');
ellipsis.className = 'ellipsis';
ellipsis.textContent = '...';
container.appendChild(ellipsis);
}
}
// 中间页码
for (let i = startPage; i <= endPage; i++) {
createPageButton(container, i);
}
// 最后一页
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
const ellipsis = document.createElement('span');
ellipsis.className = 'ellipsis';
ellipsis.textContent = '...';
container.appendChild(ellipsis);
}
createPageButton(container, totalPages);
}
// 下一页按钮
const nextButton = document.createElement('button');
nextButton.className = 'page-button nav-button';
nextButton.innerHTML = '下一页';
nextButton.disabled = (currentPage === totalPages);
nextButton.addEventListener('click', () => {
if (currentPage < totalPages) {
currentPage++;
changePage();
}
});
container.appendChild(nextButton);
// 跳转控件
const jumpWrap = document.createElement('span');
jumpWrap.className = 'page-jump';
const input = document.createElement('input');
input.type = 'number';
input.min = '1';
input.max = String(totalPages);
input.placeholder = `跳转 1-${totalPages}`;
input.className = 'page-input';
input.value = String(currentPage);
const goBtn = document.createElement('button');
goBtn.className = 'page-button';
goBtn.textContent = '跳转';
function doJump() {
let val = parseInt(input.value, 10);
if (isNaN(val)) return;
val = Math.max(1, Math.min(totalPages, val));
if (val !== currentPage) {
currentPage = val;
changePage();
}
}
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') doJump();
});
goBtn.addEventListener('click', doJump);
jumpWrap.appendChild(input);
jumpWrap.appendChild(goBtn);
container.appendChild(jumpWrap);
}
function createPageButton(container, pageNum) {
const button = document.createElement('button');
button.className = 'page-button';
button.innerText = pageNum;
button.disabled = (pageNum === currentPage);
button.addEventListener('click', () => {
currentPage = pageNum;
changePage();
});
container.appendChild(button);
}
createPaginationButtons(topPagination);
createPaginationButtons(bottomPagination);
centerActivePageButtons();
}
function changePage() {
renderPage(currentPage);
renderPagination();
updatePageInfo();
saveReadingState();
updateURLHash(currentPage);
centerActivePageButtons();
}
function lazyLoad() {
const imgs = document.querySelectorAll('.imgs');
const windowHeight = window.innerHeight;
const scrollY = window.scrollY || window.pageYOffset;
imgs.forEach(img => {
if (img.src) return; // 如果已经加载过了就跳过
const imgTop = img.getBoundingClientRect().top + scrollY;
if (windowHeight + scrollY + 200 > imgTop) { // 提前200px开始加载
img.src = img.getAttribute('data-src');
}
});
}
// 键盘快捷键支持
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft' && currentPage > 1) {
currentPage--;
changePage();
} else if (e.key === 'ArrowRight' && currentPage < totalPages) {
currentPage++;
changePage();
}
});
// 滚动隐藏顶栏功能
let lastScrollTop = 0;
const header = document.getElementById('header');
window.addEventListener('scroll', () => {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
if (scrollTop > lastScrollTop && scrollTop > 100) {
// 向下滚动且滚动距离超过100px时隐藏
header.classList.add('hidden');
} else {
// 向上滚动时显示
header.classList.remove('hidden');
}
lastScrollTop = scrollTop <= 0 ? 0 : scrollTop; // 防止负数
// 记录页内可见图片索引并惰性加载
updateVisibleImageIndex();
lazyLoad();
});
window.addEventListener('resize', lazyLoad);
function centerActivePageButtons() {
const containers = [
document.getElementById('top-pagination'),
document.getElementById('bottom-pagination')
];
containers.forEach((c) => {
if (!c) return;
const active = c.querySelector('.page-button[disabled]');
if (!active) return;
// 仅在容器可横向滚动时执行
if (c.scrollWidth <= c.clientWidth) return;
// 计算按钮相对容器的中心偏移,使用 scrollLeft 精确定位
const containerCenter = c.clientWidth / 2;
const activeCenter = active.offsetLeft + active.offsetWidth / 2;
let targetScrollLeft = activeCenter - containerCenter;
const maxScroll = c.scrollWidth - c.clientWidth;
const padding = 8; // 与容器左右 padding 保持一致
targetScrollLeft = Math.max(0, Math.min(maxScroll, targetScrollLeft));
// 保证左侧留有可视 padding不至于看起来被切掉
if (targetScrollLeft < padding) targetScrollLeft = 0;
c.scrollTo({ left: targetScrollLeft, behavior: 'smooth' });
});
}
// 移动端悬浮按钮事件
const fabPrev = document.getElementById('fab-prev');
const fabNext = document.getElementById('fab-next');
const fabTop = document.getElementById('fab-top');
if (fabPrev && fabNext && fabTop) {
fabPrev.addEventListener('click', () => {
if (currentPage > 1) { currentPage--; changePage(); }
});
fabNext.addEventListener('click', () => {
if (currentPage < totalPages) { currentPage++; changePage(); }
});
fabTop.addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
}
function updateFabs() {
if (!fabPrev || !fabNext) return;
fabPrev.disabled = (currentPage === 1);
fabNext.disabled = (currentPage === totalPages);
}
// 简单左右滑动切页(阈值:水平>60px 且 垂直<30px
let touchStartX = 0, touchStartY = 0, touchStartTime = 0;
const swipeEl = document.getElementById('comic-container');
if (swipeEl) {
swipeEl.addEventListener('touchstart', (e) => {
const t = e.changedTouches[0];
touchStartX = t.clientX;
touchStartY = t.clientY;
touchStartTime = Date.now();
}, { passive: true });
swipeEl.addEventListener('touchend', (e) => {
const t = e.changedTouches[0];
const dx = t.clientX - touchStartX;
const dy = Math.abs(t.clientY - touchStartY);
const dt = Date.now() - touchStartTime;
if (dt < 600 && Math.abs(dx) > 60 && dy < 30) {
if (dx < 0 && currentPage < totalPages) { // 向左滑 -> 下一页
currentPage++;
changePage();
} else if (dx > 0 && currentPage > 1) { // 向右滑 -> 上一页
currentPage--;
changePage();
}
}
}, { passive: true });
}
const globalBlur = document.getElementById('global-blur');
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
globalBlur.style.opacity = 1;
globalBlur.style.pointerEvents = 'auto';
} else if (document.visibilityState === 'visible') {
globalBlur.style.opacity = 0;
globalBlur.style.pointerEvents = 'none';
}
});
// --- 位置记忆 & URL 同步 ---
function saveReadingState() {
const state = {
page: currentPage,
inPageIndex: lastVisibleImageIndex
};
try {
localStorage.setItem(storageKey, JSON.stringify(state));
} catch (_) {}
}
function loadReadingState() {
try {
const raw = localStorage.getItem(storageKey);
return raw ? JSON.parse(raw) : null;
} catch (_) { return null; }
}
function parsePageFromURL() {
// 1) hash: #p=3 或 #page=3
const hash = (location.hash || '').replace(/^#/, '');
const hashParams = new URLSearchParams(hash.includes('=') ? hash : `p=${hash}`);
let p = parseInt(hashParams.get('p') || hashParams.get('page') || '', 10);
if (!isNaN(p)) return p;
// 2) search: ?p=3 或 ?page=3
const sp = new URLSearchParams(location.search);
p = parseInt(sp.get('p') || sp.get('page') || '', 10);
return isNaN(p) ? null : p;
}
function updateURLHash(page) {
// 使用 hash 避免后端路由干扰
const newHash = `p=${page}`;
if (location.hash !== `#${newHash}`) {
ignoreHashChange = true;
location.hash = newHash;
// 短暂忽略这次 hashchange 事件
setTimeout(() => { ignoreHashChange = false; }, 50);
}
}
window.addEventListener('hashchange', () => {
if (ignoreHashChange) return;
const p = parsePageFromURL();
if (typeof p === 'number' && p >= 1 && p <= totalPages && p !== currentPage) {
currentPage = p;
changePage();
}
});
function scrollToImage(inPageIdx) {
const imgs = document.querySelectorAll('#comic-container .imgs');
if (!imgs.length) return;
const idx = Math.max(0, Math.min(imgs.length - 1, inPageIdx | 0));
const el = imgs[idx];
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
function updateVisibleImageIndex() {
const imgs = document.querySelectorAll('#comic-container .imgs');
if (!imgs.length) return;
const scrollY = window.scrollY || window.pageYOffset;
let idx = 0;
for (let i = 0; i < imgs.length; i++) {
const rect = imgs[i].getBoundingClientRect();
const top = rect.top + scrollY;
if (top - scrollY >= -50) { // 视口附近的第一张
idx = i;
break;
}
}
lastVisibleImageIndex = idx;
// 节流保存
if (pendingScrollSave) clearTimeout(pendingScrollSave);
pendingScrollSave = setTimeout(saveReadingState, 400);
}
window.addEventListener('scroll', () => {
updateVisibleImageIndex();
}, { passive: true });
// 初始化页面:从 URL 或本地存储恢复
(function init() {
let initialPage = parsePageFromURL();
const saved = loadReadingState();
if (!initialPage && saved && typeof saved.page === 'number') {
initialPage = saved.page;
}
if (typeof initialPage === 'number') {
currentPage = Math.max(1, Math.min(totalPages, initialPage));
}
updatePageInfo();
renderPage(currentPage);
renderPagination();
updateURLHash(currentPage);
updateFabs();
})();
});
function unshowGlobalBlur() {
const globalBlur = document.getElementById('global-blur');
globalBlur.style.opacity = 0;
globalBlur.style.pointerEvents = 'none';
}
</script>
</body>
</html>