Files
leonpan-pc/app/view/widgets/preview_box.py
2025-11-01 20:14:35 +08:00

461 lines
19 KiB
Python
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.

# coding: utf-8
from loguru import logger
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QPixmap
from qfluentwidgets import (
ImageLabel,
InfoBar,
InfoBarPosition,
IndeterminateProgressBar,
MessageBoxBase,
PlainTextEdit,
PushButton,
)
from app.core import (ImageLoaderThread, TextLoaderThread, UpdateFileContentThread)
from app.core.api import miaoStarsBasicApi
from app.core.services.text_speech import LocalSpeechController
from app.view.components.empty_card import EmptyCard
# 图片预览类
def createThumbnail(pixmap, max_size=200):
"""创建快速缩略图"""
if pixmap.isNull():
return pixmap
# 使用快速缩放算法
return pixmap.scaled(
max_size,
max_size,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.FastTransformation,
)
class OptimizedPreviewBox(MessageBoxBase):
def __init__(self, parent=None, url=None, fileId=None):
super().__init__(parent=parent)
self.fileId = fileId # 保存文件ID
self.url = url # 暂存原始URL可能需要作为fallback
logger.info(f"初始化图片预览框文件ID: {self.fileId}, 原始URL: {self.url}")
self.widget.setMinimumSize(500, 500)
self.original_pixmap = None
self.current_scale = 1.0
# 加载状态显示
self.loadingCard = EmptyCard(self)
self.loadingCard.load()
self.viewLayout.addWidget(self.loadingCard, 0, Qt.AlignmentFlag.AlignCenter)
# 图片显示标签
self.previewLabel = ImageLabel(self)
self.previewLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.previewLabel.setScaledContents(False) # 重要:禁用自动缩放
self.viewLayout.addWidget(self.previewLabel, 0, Qt.AlignmentFlag.AlignCenter)
# 延迟启动加载避免阻塞UI初始化
from PyQt6.QtCore import QTimer
QTimer.singleShot(100, self._initImageLoading)
def _ensure_full_url(self, url):
"""确保URL是完整的添加scheme和base URL如果缺失"""
if not url:
return url
# 检查URL是否已经包含scheme
if url.startswith(('http://', 'https://')):
return url
# 对于相对路径使用API的base URL构建完整URL
# 移除可能的前导斜杠,避免重复
path = url.lstrip('/')
# 从MiaoStarsBasicApi获取base URL但只使用到域名部分
base_url = miaoStarsBasicApi.basicApi.split('/api/v4')[0]
full_url = f"{base_url}/{path}"
logger.debug(f"将相对路径转换为完整URL: {url} -> {full_url}")
return full_url
def _initImageLoading(self):
"""初始化图片加载优先使用getFileUrl方法获取临时URL"""
if self.fileId:
logger.info(f"使用文件URI {self.fileId} 获取临时预览URL")
try:
# 使用getFileUrl方法获取临时URL
from app.core.api import miaoStarsBasicApi
response = miaoStarsBasicApi.getFileUrl(self.fileId, redirect=False)
# 详细记录响应内容
logger.debug(f"getFileUrl响应: {response}")
# 处理getFileUrl的返回值支持多种格式
if isinstance(response, str):
# 直接返回URL字符串的情况
preview_url = response.strip('` ')
logger.info(f"成功获取临时预览URL: {preview_url}")
self.url = preview_url
elif isinstance(response, dict):
# 检查是否有code字段表示错误
if response.get('code') == -1:
error_msg = response.get('msg', '获取临时URL失败')
logger.warning(f"获取临时URL失败: {error_msg}将使用原始URL作为fallback")
# 检查是否有urls字段Cloudreve API返回格式
elif 'urls' in response and isinstance(response['urls'], list) and len(response['urls']) > 0:
preview_url = response['urls'][0].get('url', '').strip('` ')
logger.info(f"成功获取临时预览URL: {preview_url}")
self.url = preview_url
# 检查是否有data字段Cloudreve V4 API标准格式
elif 'data' in response:
data = response['data']
if isinstance(data, dict):
# 如果data是字典检查是否包含urls数组
if 'urls' in data and isinstance(data['urls'], list) and len(data['urls']) > 0:
preview_url = data['urls'][0].get('url', '').strip('` ')
logger.info(f"成功从data中获取临时预览URL: {preview_url}")
self.url = preview_url
else:
# 尝试将data直接转为字符串作为fallback
preview_url = str(data).strip('` ')
logger.warning(f"data字段不包含urls数组使用原始data字符串: {preview_url}")
self.url = preview_url
else:
# data不是字典直接转为字符串
preview_url = str(data).strip('` ')
logger.info(f"成功获取临时预览URL: {preview_url}")
self.url = preview_url
else:
error_msg = response.get('msg', '获取临时URL失败')
logger.warning(f"获取临时URL失败: {error_msg}将使用原始URL作为fallback")
if self.url:
self.url = self._ensure_full_url(self.url)
logger.info(f"使用处理后的fallback URL: {self.url}")
else:
logger.error("没有可用的URL无法加载图片")
self.handleError("没有可用的图片URL")
return
except Exception as e:
logger.error(f"获取预览URL时发生异常: {str(e)}")
# 尝试使用备用方式处理
if self.url:
self.url = self._ensure_full_url(self.url)
logger.info(f"异常后使用处理后的fallback URL: {self.url}")
else:
logger.error("异常后没有可用的URL无法加载图片")
self.handleError(f"获取URL异常: {str(e)}")
return
else:
# 如果没有fileId尝试处理原始URL
if self.url:
self.url = self._ensure_full_url(self.url)
logger.info(f"使用处理后的原始URL: {self.url}")
else:
logger.error("没有提供文件ID或URL无法加载图片")
self.handleError("没有提供图片URL")
return
# 创建图片加载线程
self.imageLoaderThread = ImageLoaderThread(self.url)
self.imageLoaderThread.imageLoaded.connect(self.setPreviewImg)
self.imageLoaderThread.errorOccurred.connect(self.handleError)
self.imageLoaderThread.progressUpdated.connect(self.updateProgress)
# 开始加载
self.imageLoaderThread.start()
logger.info(f"开始加载图片: {self.url}")
# startLoading方法已被_initImageLoading替代
def updateProgress(self, progress):
"""更新加载进度"""
logger.debug(f"图片加载进度: {progress}%")
self.loadingCard.setText(f"正在加载图片... {progress}%")
def setPreviewImg(self, img: QPixmap):
"""设置预览图片"""
logger.info(f"图片加载成功,尺寸: {img.width()}x{img.height()}px")
self.loadingCard.hide()
self.original_pixmap = img
# 立即显示缩略图
thumbnail = createThumbnail(img)
self.previewLabel.setPixmap(thumbnail)
# 然后异步加载高质量版本
self.adjustImageSize()
def resizeEvent(self, event):
"""重写窗口大小改变事件"""
super().resizeEvent(event)
if self.original_pixmap and not self.original_pixmap.isNull():
# 使用定时器延迟调整,避免频繁调整
from PyQt6.QtCore import QTimer
QTimer.singleShot(50, self.adjustImageSize)
def adjustImageSize(self):
"""根据窗口大小动态调整图片尺寸"""
if not self.original_pixmap or self.original_pixmap.isNull():
return
# 获取可用显示区域大小
margin = 80
available_width = self.width() - margin * 2
available_height = self.height() - margin * 2
# 获取原始图片尺寸
original_width = self.original_pixmap.width()
original_height = self.original_pixmap.height()
# 计算缩放比例
width_ratio = available_width / original_width
height_ratio = available_height / original_height
scale_ratio = min(width_ratio, height_ratio, 1.0)
# 只在需要时重新缩放
if abs(scale_ratio - self.current_scale) > 0.05: # 变化超过5%才重新缩放
self.current_scale = scale_ratio
new_width = int(original_width * scale_ratio)
new_height = int(original_height * scale_ratio)
# 使用平滑缩放
scaled_pixmap = self.original_pixmap.scaled(
new_width,
new_height,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
self.previewLabel.setPixmap(scaled_pixmap)
def handleError(self, msg):
"""处理加载错误"""
logger.error(f"图片预览失败: {msg}")
self.loadingCard.error()
self.previewLabel.hide()
# 文本文档预览类
class PreviewTextBox(MessageBoxBase):
"""文本预览对话框"""
def __init__(self, parent=None, url=None, _id=None):
super().__init__(parent=parent)
# 处理URL确保它是完整的URL
self.url = self._ensure_full_url(url)
logger.info(f"初始化文本预览框URL: {self.url}, 文件ID: {_id}")
self.updateTxtThread = None
self.widget.setMinimumSize(600, 400)
self._id = _id
self.isChanged = False
self.speech_controller = LocalSpeechController(self)
self.textSpeakButton = PushButton("朗读文本", self)
self.textSpeakButton.hide()
self.isSpeaking = False
self.textSpeakButton.clicked.connect(self.playTextSpeech)
self.viewLayout.addWidget(
self.textSpeakButton,
0,
Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
)
# 创建文本编辑框
self.textEdit = PlainTextEdit(self)
self.textEdit.hide()
self.textEdit.setLineWrapMode(PlainTextEdit.LineWrapMode.NoWrap) # 不自动换行
# 设置等宽字体,便于阅读代码或日志
from PyQt6.QtGui import QFont
font = QFont("微软雅黑", 10) # 等宽字体
self.textEdit.setFont(font)
self.viewLayout.addWidget(self.textEdit)
# 加载状态显示
self.loadingCard = EmptyCard(self)
self.loadingCard.load()
self.viewLayout.addWidget(self.loadingCard, 0, Qt.AlignmentFlag.AlignCenter)
# 确保在创建线程前使用处理后的URL
self.textLoaderThread = TextLoaderThread(self.url, fileId=_id)
self.textLoaderThread.textLoaded.connect(self.setTextContent)
self.textLoaderThread.errorOccurred.connect(self.handleError)
self.textLoaderThread.progressUpdated.connect(self.updateProgress)
self.yesButton.hide()
# 创建保存按钮
self.saveButton = PushButton("保存修改", self)
# 创建进度条
self.saveProgressBar = IndeterminateProgressBar(self)
self.saveProgressBar.setFixedHeight(4)
self.saveProgressBar.hide()
# 添加按钮和进度条到布局
self.buttonLayout.insertWidget(
0, self.saveButton, 1, Qt.AlignmentFlag.AlignVCenter
)
self.buttonLayout.insertWidget(
1, self.saveProgressBar, 1, Qt.AlignmentFlag.AlignVCenter
)
self.saveButton.setEnabled(False)
self.saveButton.clicked.connect(self.saveText)
self.cancelButton.setText("返回")
# 延迟启动加载避免阻塞UI初始化
QTimer.singleShot(100, self.startLoading)
def saveText(self):
logger.info(f"保存文本文件修改文件ID: {self._id}")
# 显示进度条并禁用按钮
self.saveProgressBar.show()
self.saveButton.setEnabled(False)
self.saveTextThread = UpdateFileContentThread(
self._id,
self.textEdit.toPlainText(),
)
self.saveTextThread.successUpdated.connect(self._successSave)
self.saveTextThread.errorUpdated.connect(self._errorSave)
self.saveTextThread.start()
def _successSave(self):
logger.info(f"文本文件保存成功文件ID: {self._id}")
InfoBar.success(
"成功",
"修改保存成功",
Qt.Orientation.Horizontal,
True,
1000,
InfoBarPosition.TOP_RIGHT,
self.window(),
)
# 隐藏进度条
self.saveProgressBar.hide()
QTimer.singleShot(700, self.accept)
def _errorSave(self, msg):
logger.error(f"文本文件保存失败文件ID: {self._id}, 错误: {msg}")
InfoBar.error(
"失败",
msg,
Qt.Orientation.Horizontal,
True,
1000,
InfoBarPosition.TOP_RIGHT,
self.window(),
)
# 隐藏进度条并重新启用按钮
self.saveProgressBar.hide()
self.saveButton.setEnabled(True)
def playTextSpeech(self):
"""播放文本语音"""
if not self.isSpeaking:
text = self.textEdit.toPlainText()
if text and len(text.strip()) > 0:
logger.info(f"开始文本朗读文件ID: {self._id}")
self.speech_controller.play_text(text)
self.isSpeaking = True
self.textSpeakButton.setText("暂停朗读")
else:
logger.info(f"暂停文本朗读文件ID: {self._id}")
self.speech_controller.stop_playback()
self.isSpeaking = False
self.textSpeakButton.setText("朗读文本")
def _ensure_full_url(self, url):
"""确保URL是完整的添加scheme和base URL如果缺失"""
if not url:
return url
# 检查URL是否已经包含scheme
if url.startswith(('http://', 'https://')):
return url
# 对于相对路径使用API的base URL构建完整URL
# 移除可能的前导斜杠,避免重复
path = url.lstrip('/')
# 从MiaoStarsBasicApi获取base URL但只使用到域名部分
base_url = miaoStarsBasicApi.basicApi.split('/api/v4')[0]
full_url = f"{base_url}/{path}"
logger.debug(f"将相对路径转换为完整URL: {url} -> {full_url}")
return full_url
def startLoading(self):
"""开始加载文本"""
# 如果有fileId尝试获取临时URL
if self._id:
logger.info(f"使用文件URI {self._id} 获取临时文本URL")
response = miaoStarsBasicApi.getFileUrl(self._id, redirect=False)
# 处理getFileUrl的返回值支持多种格式
preview_url = None
if isinstance(response, dict):
# 检查是否是Cloudreve V4 API格式 (data.urls)
if response.get('code') == 0 and 'data' in response:
data = response['data']
# 格式1: data.urls数组
if isinstance(data, dict) and 'urls' in data and isinstance(data['urls'], list) and len(data['urls']) > 0:
preview_url = data['urls'][0].get('url', '').strip('` ')
# 格式2: data直接包含URL
elif isinstance(data, str):
preview_url = data.strip('` ')
# 检查是否直接包含urls字段
elif 'urls' in response and isinstance(response['urls'], list) and len(response['urls']) > 0:
preview_url = response['urls'][0].get('url', '').strip('` ')
# 如果成功获取到URL
if preview_url:
self.url = preview_url
logger.info(f"成功获取临时文本URL: {self.url}")
else:
error_msg = response.get('msg', '获取临时URL失败')
logger.warning(f"获取临时URL失败: {error_msg}将使用处理后的原始URL")
# 更新线程的URL和fileId
self.textLoaderThread = TextLoaderThread(self.url, fileId=self._id)
self.textLoaderThread.textLoaded.connect(self.setTextContent)
self.textLoaderThread.errorOccurred.connect(self.handleError)
self.textLoaderThread.progressUpdated.connect(self.updateProgress)
logger.info(f"开始加载文本文件: {self.url}")
self.textLoaderThread.start()
def updateProgress(self, progress):
"""更新加载进度"""
logger.debug(f"文本加载进度: {progress}%")
self.loadingCard.setText(f"正在加载文本... {progress}%")
def setTextContent(self, content):
"""设置文本内容"""
logger.info(f"文本文件加载成功,原始内容长度: {len(content)}字符")
self.loadingCard.hide()
self.textEdit.show()
self.textSpeakButton.show()
self.saveButton.setEnabled(True)
# 限制显示的内容长度,避免性能问题
max_display_length = 100000 # 最多显示10万个字符
if len(content) > max_display_length:
logger.warning(f"文本内容过长,已截断显示,原始长度: {len(content)}字符")
content = (
content[:max_display_length]
+ f"\n\n... (内容过长,已截断前{max_display_length}个字符,完整内容请下载文件查看)"
)
self.textEdit.setPlainText(content)
def handleError(self, error_msg):
"""处理加载错误"""
logger.error(f"文本预览失败URL: {self.url}, 错误: {error_msg}")
self.loadingCard.error()
def resizeEvent(self, event):
"""重写窗口大小改变事件"""
super().resizeEvent(event)
# 文本预览框会自动适应大小,无需特殊处理