initialˆ

This commit is contained in:
2025-09-30 12:54:29 +08:00
commit acdf544b08
117 changed files with 20260 additions and 0 deletions

View 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 }">
<!-- &lt;!&ndash; 始终可见的页码显示 &ndash;&gt;-->
<!-- <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>