initialˆ
This commit is contained in:
894
reijm-read/src/views/Manga.vue
Normal file
894
reijm-read/src/views/Manga.vue
Normal file
@@ -0,0 +1,894 @@
|
||||
<!-- Manga.vue -->
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// 漫画专辑数据
|
||||
interface AlbumData {
|
||||
album_id: string
|
||||
name: string
|
||||
image_urls: string[]
|
||||
nums: number[]
|
||||
authors?: string[]
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
// 漫画图片列表(从 jm.vue 中获取)
|
||||
const mangaImages = ref<string[]>([])
|
||||
const mangaNums = ref<number[]>([])
|
||||
const albumInfo = ref<Omit<AlbumData, 'image_urls' | 'nums'> | null>(null)
|
||||
|
||||
// 阅读器状态
|
||||
const showMenu = ref(false)
|
||||
const isFullscreen = ref(false)
|
||||
const showDrawer = ref(false)
|
||||
const currentImageIndex = ref(0) // 当前显示的图片索引
|
||||
const imageStates = ref<Array<{ scale: number; translateX: number; translateY: number }>>([])
|
||||
const loading = ref(true)
|
||||
const canvasImages = ref<string[]>([]) // 解码后的图片
|
||||
const abortLoading = ref(false) // 取消加载标志
|
||||
|
||||
// 计算是否应该隐藏头部(用于样式调整)
|
||||
const shouldHideHeader = computed(() => {
|
||||
return route.meta?.hideHeader === true
|
||||
})
|
||||
|
||||
// 计算当前页码信息
|
||||
const currentPageInfo = computed(() => {
|
||||
return `第 ${currentImageIndex.value + 1} / ${mangaImages.value.length} 页`
|
||||
})
|
||||
|
||||
// 获取专辑数据
|
||||
const fetchAlbum = async (id: string) => {
|
||||
console.log('尝试获取专辑数据,ID:', id)
|
||||
if (!id) {
|
||||
console.log('ID为空,无法获取专辑数据')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
console.log('发送请求到:', `/api/manga/read?mangaId=${id}`)
|
||||
const response = await axios.get(`/api/manga/read?mangaId=${id}`, {
|
||||
maxRedirects: 5,
|
||||
validateStatus: (status) => status < 500,
|
||||
})
|
||||
console.log('收到响应:', response)
|
||||
|
||||
let data: AlbumData
|
||||
|
||||
if (response.status === 307 || response.status === 302) {
|
||||
const newUrl = new URL(response.headers.location, response.config.url).href
|
||||
const redirectResponse = await axios.get(newUrl)
|
||||
data = redirectResponse.data
|
||||
} else {
|
||||
data = response.data
|
||||
}
|
||||
|
||||
// 设置专辑信息
|
||||
albumInfo.value = {
|
||||
album_id: data.album_id,
|
||||
name: data.name,
|
||||
authors: data.authors || [],
|
||||
tags: data.tags || []
|
||||
}
|
||||
|
||||
// 设置图片 URL 和解码参数
|
||||
mangaImages.value = data.image_urls || []
|
||||
mangaNums.value = data.nums || []
|
||||
|
||||
// 初始化图片状态
|
||||
imageStates.value = (data.image_urls || []).map(() => ({
|
||||
scale: 1,
|
||||
translateX: 0,
|
||||
translateY: 0
|
||||
}))
|
||||
|
||||
// 初始化画布图片数组
|
||||
canvasImages.value = new Array((data.image_urls || []).length)
|
||||
|
||||
// 开始加载图片
|
||||
if (data.image_urls && data.nums) {
|
||||
loadImagesInBatches(data.image_urls, data.nums)
|
||||
} else {
|
||||
loading.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载专辑失败', error)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 分批加载图片(每次加载4张)
|
||||
const loadImagesInBatches = async (urls: string[], nums: number[]) => {
|
||||
const batchSize = 4 // 每批加载4张图片
|
||||
let currentIndex = 0
|
||||
abortLoading.value = false // 开始新加载任务前重置标志
|
||||
|
||||
while (currentIndex < urls.length && !abortLoading.value) {
|
||||
const batchUrls = urls.slice(currentIndex, currentIndex + batchSize)
|
||||
const batchNums = nums.slice(currentIndex, currentIndex + batchSize)
|
||||
|
||||
const loadPromises = batchUrls.map((url, index) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (abortLoading.value) return resolve()
|
||||
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.src = url
|
||||
|
||||
img.onload = () => {
|
||||
if (abortLoading.value) return resolve()
|
||||
|
||||
if (batchNums[index] !== 0) {
|
||||
const decodedCanvas = decodeImage(img, batchNums[index])
|
||||
const base64 = decodedCanvas.toDataURL()
|
||||
canvasImages.value[currentIndex + index] = base64
|
||||
} else {
|
||||
canvasImages.value[currentIndex + index] = url
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
if (abortLoading.value) return resolve()
|
||||
console.error(`图片加载失败: ${url}`)
|
||||
// 加载失败时使用原始 URL
|
||||
canvasImages.value[currentIndex + index] = url
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await Promise.all(loadPromises)
|
||||
currentIndex += batchSize
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// JS 版 decodeImage(等效 Java 方法)
|
||||
const decodeImage = (imgSrc: HTMLImageElement, num: number): HTMLCanvasElement => {
|
||||
if (num === 0) {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = imgSrc.width
|
||||
canvas.height = imgSrc.height
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx?.drawImage(imgSrc, 0, 0)
|
||||
return canvas
|
||||
}
|
||||
|
||||
const w = imgSrc.width
|
||||
const h = imgSrc.height
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
const ctx = canvas.getContext('2d')!
|
||||
|
||||
const over = h % num
|
||||
|
||||
for (let i = 0; i < num; i++) {
|
||||
let move = Math.floor(h / num)
|
||||
let ySrc = h - move * (i + 1) - over
|
||||
let yDst = move * i
|
||||
|
||||
if (i === 0) {
|
||||
move += over
|
||||
} else {
|
||||
yDst += over
|
||||
}
|
||||
|
||||
const srcRect = { x: 0, y: ySrc, width: w, height: move }
|
||||
const dstRect = { x: 0, y: yDst, width: w, height: move }
|
||||
|
||||
const tempCanvas = document.createElement('canvas')
|
||||
tempCanvas.width = w
|
||||
tempCanvas.height = move
|
||||
const tempCtx = tempCanvas.getContext('2d')!
|
||||
tempCtx.drawImage(
|
||||
imgSrc,
|
||||
srcRect.x,
|
||||
srcRect.y,
|
||||
srcRect.width,
|
||||
srcRect.height,
|
||||
0,
|
||||
0,
|
||||
srcRect.width,
|
||||
srcRect.height
|
||||
)
|
||||
|
||||
ctx.drawImage(
|
||||
tempCanvas,
|
||||
0,
|
||||
0,
|
||||
tempCanvas.width,
|
||||
tempCanvas.height,
|
||||
dstRect.x,
|
||||
dstRect.y,
|
||||
dstRect.width,
|
||||
dstRect.height
|
||||
)
|
||||
}
|
||||
|
||||
return canvas
|
||||
}
|
||||
|
||||
// 切换抽屉菜单显示
|
||||
const toggleDrawer = () => {
|
||||
showDrawer.value = !showDrawer.value
|
||||
}
|
||||
|
||||
// 切换顶部菜单显示
|
||||
const toggleMenu = () => {
|
||||
showMenu.value = !showMenu.value
|
||||
}
|
||||
|
||||
// 进入全屏模式
|
||||
const enterFullscreen = () => {
|
||||
const element = document.documentElement
|
||||
if (element.requestFullscreen) {
|
||||
element.requestFullscreen()
|
||||
isFullscreen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 退出全屏模式
|
||||
const exitFullscreen = () => {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen()
|
||||
isFullscreen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置所有图片的缩放
|
||||
const resetAllImages = () => {
|
||||
imageStates.value = imageStates.value.map(() => ({
|
||||
scale: 1,
|
||||
translateX: 0,
|
||||
translateY: 0
|
||||
}))
|
||||
}
|
||||
|
||||
// 处理双指缩放(只响应真正的触摸事件)
|
||||
let initialDistance = 0
|
||||
let initialScale = 1
|
||||
|
||||
const getDistance = (touch1: Touch, touch2: Touch) => {
|
||||
const dx = touch1.clientX - touch2.clientX
|
||||
const dy = touch1.clientY - touch2.clientY
|
||||
return Math.sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
|
||||
const handleTouchStart = (index: number, event: TouchEvent) => {
|
||||
if (event.touches.length === 2) {
|
||||
// 双指触摸开始
|
||||
initialDistance = getDistance(event.touches[0], event.touches[1])
|
||||
initialScale = imageStates.value[index].scale
|
||||
} else if (event.touches.length === 1) {
|
||||
// 单指触摸,可能是滚动,不阻止默认行为
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchMove = (index: number, event: TouchEvent) => {
|
||||
if (event.touches.length === 2) {
|
||||
// 双指缩放
|
||||
event.preventDefault() // 只在双指操作时阻止默认行为
|
||||
const currentDistance = getDistance(event.touches[0], event.touches[1])
|
||||
const scale = initialScale * (currentDistance / initialDistance)
|
||||
|
||||
// 限制缩放范围
|
||||
const clampedScale = Math.min(Math.max(scale, 1), 3)
|
||||
imageStates.value[index].scale = clampedScale
|
||||
}
|
||||
}
|
||||
|
||||
// 处理滚轮缩放(仅鼠标滚轮)
|
||||
const handleWheel = (index: number, event: WheelEvent) => {
|
||||
// 确保是鼠标滚轮事件而不是触摸板手势
|
||||
if (Math.abs(event.deltaX) > Math.abs(event.deltaY) * 2) {
|
||||
// 可能是水平滚动,不处理缩放
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const currentState = imageStates.value[index]
|
||||
|
||||
if (event.deltaY < 0) {
|
||||
// 向上滚动放大
|
||||
if (currentState.scale < 3) {
|
||||
currentState.scale *= 1.1
|
||||
}
|
||||
} else {
|
||||
// 向下滚动缩小
|
||||
if (currentState.scale > 1) {
|
||||
currentState.scale /= 1.1
|
||||
if (currentState.scale <= 1) {
|
||||
currentState.scale = 1
|
||||
currentState.translateX = 0
|
||||
currentState.translateY = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到指定图片
|
||||
const scrollToImage = (index: number) => {
|
||||
if (index < 0 || index >= mangaImages.value.length) return
|
||||
|
||||
const element = document.getElementById(`image-${index}`)
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
currentImageIndex.value = index
|
||||
// 关闭抽屉菜单
|
||||
showDrawer.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到下一张图片
|
||||
const nextImage = () => {
|
||||
if (currentImageIndex.value < mangaImages.value.length - 1) {
|
||||
scrollToImage(currentImageIndex.value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到上一张图片
|
||||
const prevImage = () => {
|
||||
if (currentImageIndex.value > 0) {
|
||||
scrollToImage(currentImageIndex.value - 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 节流函数
|
||||
const throttle = (func: Function, limit: number) => {
|
||||
let inThrottle: boolean
|
||||
return function() {
|
||||
const args = arguments
|
||||
const context = this
|
||||
if (!inThrottle) {
|
||||
func.apply(context, args)
|
||||
inThrottle = true
|
||||
setTimeout(() => inThrottle = false, limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监听滚动事件,更新当前图片索引
|
||||
const handleScroll = () => {
|
||||
const containers = document.querySelectorAll('.image-container')
|
||||
let currentIndex = 0
|
||||
|
||||
// 使用更精确的计算方式确定当前视口中的图片
|
||||
for (let i = 0; i < containers.length; i++) {
|
||||
const rect = containers[i].getBoundingClientRect()
|
||||
// 当图片的任意部分在视口中时就认为是当前图片
|
||||
if (rect.bottom > 0 && rect.top < window.innerHeight) {
|
||||
// 优先选择图片中心点在视口中央的图片
|
||||
if (Math.abs(rect.top + rect.height/2 - window.innerHeight/2) <
|
||||
Math.abs(containers[currentIndex].getBoundingClientRect().top +
|
||||
containers[currentIndex].getBoundingClientRect().height/2 - window.innerHeight/2)) {
|
||||
currentIndex = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 只有当索引真正改变时才更新
|
||||
if (currentImageIndex.value !== currentIndex) {
|
||||
currentImageIndex.value = currentIndex
|
||||
}
|
||||
}
|
||||
|
||||
// 使用节流优化滚动处理
|
||||
const throttledHandleScroll = throttle(handleScroll, 100)
|
||||
|
||||
// 键盘事件处理
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
prevImage()
|
||||
event.preventDefault()
|
||||
break
|
||||
case 'ArrowRight':
|
||||
nextImage()
|
||||
event.preventDefault()
|
||||
break
|
||||
case 'Escape':
|
||||
if (isFullscreen.value) {
|
||||
exitFullscreen()
|
||||
}
|
||||
showDrawer.value = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 监听路由参数变化
|
||||
watch(
|
||||
() => route.params.id,
|
||||
(newId) => {
|
||||
console.log('路由参数变化:', newId)
|
||||
if (newId) {
|
||||
abortLoading.value = true // 取消之前的加载
|
||||
fetchAlbum(newId as string)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 组件挂载时添加事件监听器
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
// 直接监听 manga-reader 元素的滚动事件,而不是 manga-content
|
||||
const mangaReader = document.querySelector('.manga-reader')
|
||||
if (mangaReader) {
|
||||
mangaReader.addEventListener('scroll', throttledHandleScroll)
|
||||
}
|
||||
|
||||
// 初始化当前图片索引
|
||||
handleScroll()
|
||||
})
|
||||
|
||||
// 组件卸载时移除事件监听器
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
const mangaReader = document.querySelector('.manga-content')
|
||||
if (mangaReader) {
|
||||
mangaReader.removeEventListener('scroll', throttledHandleScroll)
|
||||
}
|
||||
abortLoading.value = true // 取消加载
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="manga-reader"
|
||||
@click="toggleMenu"
|
||||
:class="{ 'no-header': shouldHideHeader }">
|
||||
|
||||
<!-- <!– 始终可见的页码显示 –>-->
|
||||
<!-- <div class="page-indicator">-->
|
||||
<!-- {{ currentPageInfo }}-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- 顶部菜单栏 -->
|
||||
<div
|
||||
class="menu-bar"
|
||||
:class="{ visible: showMenu }"
|
||||
@click.stop
|
||||
>
|
||||
<div class="menu-content">
|
||||
<span class="page-info">{{ currentPageInfo }}</span>
|
||||
<button @click="prevImage" :disabled="currentImageIndex === 0 || loading">上一张</button>
|
||||
<button @click="nextImage" :disabled="currentImageIndex === mangaImages.length - 1 || loading">下一张</button>
|
||||
<button @click="isFullscreen ? exitFullscreen() : enterFullscreen()">
|
||||
{{ isFullscreen ? '退出全屏' : '全屏' }}
|
||||
</button>
|
||||
<button @click="toggleDrawer">菜单</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 左侧抽屉菜单 -->
|
||||
<div
|
||||
class="drawer-overlay"
|
||||
:class="{ 'drawer-open': showDrawer }"
|
||||
@click="toggleDrawer"
|
||||
>
|
||||
<div
|
||||
class="drawer"
|
||||
:class="{ 'drawer-open': showDrawer }"
|
||||
@click.stop
|
||||
>
|
||||
<div class="drawer-content">
|
||||
<div class="drawer-header">
|
||||
<h3>ReiJM</h3>
|
||||
<button @click="toggleDrawer" class="close-btn">×</button>
|
||||
</div>
|
||||
<div class="drawer-body">
|
||||
<button @click="isFullscreen ? exitFullscreen() : enterFullscreen()" class="drawer-item">
|
||||
{{ isFullscreen ? '退出全屏' : '进入全屏' }}
|
||||
</button>
|
||||
<div class="page-list">
|
||||
<h4>页面列表</h4>
|
||||
<div
|
||||
v-for="(image, index) in mangaImages"
|
||||
:key="index"
|
||||
class="page-item"
|
||||
:class="{ 'active': index === currentImageIndex }"
|
||||
@click="scrollToImage(index)"
|
||||
>
|
||||
第 {{ index + 1 }} 页
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 漫画内容 -->
|
||||
<div class="manga-content">
|
||||
<!-- 专辑信息 -->
|
||||
<div v-if="albumInfo" class="comic-header">
|
||||
<h1 class="comic-title">{{ albumInfo.name }}</h1>
|
||||
<div class="comic-meta">
|
||||
<div v-if="albumInfo.authors && albumInfo.authors.length" class="meta-item">
|
||||
<span class="label">作者:</span>
|
||||
<span class="value">{{ albumInfo.authors.join(' / ') }}</span>
|
||||
</div>
|
||||
<div v-if="albumInfo.tags && albumInfo.tags.length" class="meta-item">
|
||||
<span class="label">标签:</span>
|
||||
<span class="value tags">
|
||||
<span v-for="(tag, idx) in albumInfo.tags" :key="idx" class="tag">{{ tag }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading">加载中...</div>
|
||||
|
||||
<!-- 漫画图片 -->
|
||||
<div
|
||||
v-for="(image, index) in canvasImages"
|
||||
:key="index"
|
||||
class="image-container"
|
||||
:id="`image-${index}`"
|
||||
>
|
||||
<img
|
||||
v-if="image"
|
||||
:src="image"
|
||||
:alt="`漫画第 ${index + 1} 页`"
|
||||
class="manga-image"
|
||||
:style="{
|
||||
transform: `scale(${imageStates[index].scale}) translate(${imageStates[index].translateX}px, ${imageStates[index].translateY}px)`
|
||||
}"
|
||||
@wheel="handleWheel(index, $event)"
|
||||
@touchstart="handleTouchStart(index, $event)"
|
||||
@touchmove="handleTouchMove(index, $event)"
|
||||
/>
|
||||
<div v-else class="image-placeholder">图片加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 点击屏幕显示菜单的提示区域 -->
|
||||
<div
|
||||
class="screen-click-area"
|
||||
@click="toggleMenu"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.manga-reader {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: #000;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
user-select: none;
|
||||
padding-top: 0; /* 移除顶部内边距 */
|
||||
padding-bottom: 60px; /* 添加底部内边距 */
|
||||
}
|
||||
|
||||
.manga-reader.no-header {
|
||||
padding-bottom: 0; /* 无头部时也无底部内边距 */
|
||||
}
|
||||
|
||||
/* 添加页码指示器样式 */
|
||||
.page-indicator {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 5px 15px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
z-index: 90;
|
||||
pointer-events: none; /* 不拦截点击事件 */
|
||||
}
|
||||
|
||||
.menu-bar {
|
||||
position: fixed;
|
||||
bottom: 0; /* 改为底部定位 */
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
z-index: 100;
|
||||
transform: translateY(100%); /* 改为向上偏移 */
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.menu-bar.visible {
|
||||
transform: translateY(0); /* 回到正常位置 */
|
||||
}
|
||||
|
||||
.menu-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.menu-content button {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.menu-content button:hover:not(:disabled) {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.menu-content button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 左侧抽屉菜单样式 */
|
||||
.drawer-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 199;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||
}
|
||||
|
||||
.drawer-overlay.drawer-open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 300px;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
z-index: 200;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.drawer.drawer-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #333;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.drawer-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drawer-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.drawer-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drawer-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.page-list h4 {
|
||||
margin: 20px 0 10px 0;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.page-item {
|
||||
padding: 8px;
|
||||
margin: 5px 0;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.page-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.page-item.active {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.manga-content {
|
||||
padding: 20px 0 0 0; /* 只保留顶部内边距 */
|
||||
}
|
||||
|
||||
.image-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 0; /* 移除图片间的间距 */
|
||||
padding: 0; /* 移除内边距 */
|
||||
}
|
||||
|
||||
.manga-image {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
cursor: zoom-in;
|
||||
transform-origin: center center;
|
||||
transition: transform 0.2s ease;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
display: block; /* 避免 inline 元素带来的空白间隙 */
|
||||
}
|
||||
|
||||
.manga-image:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
color: #999;
|
||||
font-size: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 顶部信息 */
|
||||
.comic-header {
|
||||
margin-bottom: 24px;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.comic-title {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.comic-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: bold;
|
||||
color: #aaa;
|
||||
width: 50px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* 屏幕点击区域 */
|
||||
.screen-click-area {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 768px) {
|
||||
.menu-content {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.menu-content > * {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.manga-image {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.comic-header {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.comic-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.page-indicator {
|
||||
top: 5px;
|
||||
font-size: 12px;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user