# coding: utf-8 from loguru import logger import os from PyQt6.QtCore import Qt, QTimer, QUrl, QDir, QFileInfo from PyQt6.QtGui import QPixmap from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtWebEngineCore import QWebEnginePage, QWebEngineContextMenuRequest from qfluentwidgets import ( ImageLabel, InfoBar, InfoBarPosition, IndeterminateProgressBar, MessageBoxBase, PushButton, ) from app.core import (ImageLoaderThread, TextLoaderThread, UpdateFileContentThread) class MonacoWebEnginePage(QWebEnginePage): """自定义WebEnginePage以处理右键菜单""" def __init__(self, parent=None): super().__init__(parent) def contextMenuEvent(self, event): """自定义右键菜单事件,允许基本的编辑操作""" request = event.request() # 可以在这里根据需要自定义右键菜单的行为 # 例如,只允许复制、粘贴等特定操作 # 目前我们让Monaco Editor自己处理右键菜单 pass from app.core.api import miaoStarsBasicApi 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 # 设置编辑器HTML文件路径 - 修正为项目根目录的_internal文件夹 project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) self.editor_index = os.path.join(project_root, "_internal/editor_main/index.html") logger.info(f"编辑器HTML路径: {self.editor_index}") # 创建占位符标签替代WebEngineView from PyQt6.QtWidgets import QLabel self.placeholderLabel = QLabel('''

文本编辑将在外部浏览器中打开

请在浏览器中完成编辑后,点击"保存并返回"按钮

应用将自动检测并更新内容

''') self.placeholderLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) self.placeholderLabel.setWordWrap(True) self.viewLayout.addWidget(self.placeholderLabel) # 初始化变量 self.editorContent = "" self.tempFilePath = None # 轮询定时器,用于检查浏览器是否保存了内容 self.pollingTimer = QTimer(self) self.pollingTimer.setInterval(1000) # 每秒检查一次 self.pollingTimer.timeout.connect(self._checkBrowserContent) # 加载状态显示 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) # 直接使用editorContent变量,因为我们已经切换到外部浏览器编辑模式 self._saveContent(self.editorContent) 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 _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): """设置文本内容并在外部浏览器中打开""" # 检查内容是否已保存,如果已保存则不允许再次编辑 if hasattr(self, 'isContentSaved') and self.isContentSaved: logger.warning("内容已保存,不允许再次编辑") self.placeholderLabel.setText('''

已经保存

该内容已经保存,不再允许编辑

''') return logger.info(f"文本文件加载成功,原始内容长度: {len(content)}字符") self.loadingCard.hide() self.saveButton.setEnabled(True) # 保存原始内容 self.editorContent = content # 检测语言类型 language = self._detect_language(content) # 创建临时文件用于保存内容和语言信息 import tempfile import base64 # 创建临时文件 fd, self.tempFilePath = tempfile.mkstemp(suffix='.json', text=True) os.close(fd) # 对内容进行Base64编码 encoded_content = base64.b64encode(content.encode('utf-8')).decode('ascii') # 保存到临时文件 import json with open(self.tempFilePath, 'w', encoding='utf-8') as f: json.dump({ 'content': encoded_content, 'language': language, 'file_id': self._id, 'saved': False # 初始状态为未保存 }, f) # 获取编辑器HTML文件的绝对路径 editor_abs_path = os.path.abspath(self.editor_index) # 构建URL参数 params = { 'temp_file': self.tempFilePath, 'content': encoded_content, 'language': language } # 导入必要的模块来创建本地Web服务器 import http.server import socketserver import threading import sys # 创建本地Web服务器类 class SimpleHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): def log_message(self, format, *args): # 禁用服务器日志输出 pass # 获取_internal目录的路径 # 当前文件路径是app/view/widgets/preview_box.py,需要找到项目根目录 current_file = os.path.abspath(__file__) # 从当前文件路径向上找项目根目录(跳过app目录) project_root = os.path.dirname(os.path.dirname(os.path.dirname(current_file))) internal_dir = os.path.join(project_root, "_internal") logger.info(f"项目根目录: {project_root}") logger.info(f"_internal目录: {internal_dir}") # 启动本地Web服务器 port = 36852 handler = SimpleHTTPRequestHandler # 创建一个允许地址重用的TCPServer类 class ReuseTCPServer(socketserver.TCPServer): allow_reuse_address = True # 自定义HTTP请求处理器,用于处理POST保存请求 class EditorHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): # 保存对PreviewTextBox实例的引用 preview_box_instance = None def do_POST(self): """处理POST请求,特别是保存请求""" if self.path == '/save': # 获取请求体长度 content_length = int(self.headers['Content-Length']) # 读取请求体数据 post_data = self.rfile.read(content_length) try: # 解析JSON数据 import json save_data = json.loads(post_data) # 记录保存请求 logger.info("接收到来自浏览器的保存请求") # 使用preview_box_instance处理保存的数据 if EditorHTTPRequestHandler.preview_box_instance: EditorHTTPRequestHandler.preview_box_instance._processSavedContent(save_data) # 返回成功响应 self.send_response(200) self.send_header('Content-Type', 'application/json') self.end_headers() self.wfile.write(json.dumps({'status': 'success'}).encode('utf-8')) except Exception as e: logger.error(f"处理保存请求失败: {e}") # 返回错误响应 self.send_response(500) self.send_header('Content-Type', 'application/json') self.end_headers() self.wfile.write(json.dumps({'status': 'error', 'message': str(e)}).encode('utf-8')) else: # 对于其他POST请求,返回404 self.send_response(404) self.end_headers() def log_message(self, format, *args): """禁用日志消息,保持安静""" return # 尝试使用多个备用端口,避免端口被占用 max_attempts = 5 attempts = 0 while attempts < max_attempts: try: # 设置处理器的实例引用 EditorHTTPRequestHandler.preview_box_instance = self # 使用自定义处理器创建服务器 self.httpd = ReuseTCPServer(("127.0.0.1", port), EditorHTTPRequestHandler) logger.info(f"Web服务器成功在端口 {port} 启动") break except OSError as e: attempts += 1 logger.warning(f"端口 {port} 被占用,尝试使用备用端口") port += 1 # 使用下一个端口 if attempts >= max_attempts: logger.error(f"无法找到可用端口,最后一个错误: {e}") raise # 切换工作目录到_internal目录 if os.path.exists(internal_dir): os.chdir(internal_dir) logger.info(f"切换Web服务器工作目录到: {internal_dir}") else: logger.error(f"_internal目录不存在: {internal_dir}") # 如果目录不存在,保持在当前目录运行服务器 # 在后台线程中运行服务器 self.server_thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) self.server_thread.start() # 构建基于HTTP的URL from urllib.parse import urlencode from PyQt6.QtGui import QDesktopServices # 根据目录是否存在来调整HTML路径 if os.path.exists(internal_dir): # 如果在_internal目录下运行服务器,使用相对路径 html_path = "editor_main/index.html" else: # 如果不在_internal目录下,使用完整路径 html_path = "_internal/editor_main/index.html" url = f'http://127.0.0.1:{port}/{html_path}?{urlencode(params)}' # 使用系统默认浏览器打开URL logger.info(f"在外部浏览器中打开编辑器(HTTP): {url}") QDesktopServices.openUrl(QUrl(url)) # 显示保存提醒 self.placeholderLabel.setText('''

文本编辑已在外部浏览器中打开

重要提示:完成编辑后,请点击浏览器中的"保存并返回"按钮

应用正在自动检测...

''') # 启动轮询检查 self.pollingTimer.start() def _checkBrowserContent(self): """检查浏览器是否已保存内容""" # 首先尝试从localStorage读取 try: import json import os import base64 # 尝试获取localStorage数据 # 在Windows上,localStorage通常存储在用户的AppData目录中 # 由于直接访问localStorage有困难,我们使用一个更可靠的方法: # 1. 首先检查临时文件 if self.tempFilePath and os.path.exists(self.tempFilePath): try: with open(self.tempFilePath, 'r', encoding='utf-8') as f: data = json.load(f) if data.get('saved', False): logger.info("检测到浏览器已保存内容(通过临时文件)") self._processSavedContent(data) return except Exception as e: logger.error(f"读取临时文件失败: {e}") # 2. 作为备用方案,检查是否有特定的保存文件 # 这个文件可以由浏览器通过特定的方法创建 import tempfile app_data_dir = os.path.join(tempfile.gettempdir(), 'LeonPan') os.makedirs(app_data_dir, exist_ok=True) save_file_path = os.path.join(app_data_dir, 'editor_content.json') if os.path.exists(save_file_path): try: with open(save_file_path, 'r', encoding='utf-8') as f: data = json.load(f) if data.get('saved', False): logger.info("检测到浏览器已保存内容(通过备用文件)") self._processSavedContent(data) # 删除备用文件 os.unlink(save_file_path) except Exception as e: logger.error(f"读取备用保存文件失败: {e}") except Exception as e: logger.error(f"检查浏览器内容时出错: {e}") def _processSavedContent(self, data): """处理已保存的内容""" # 停止轮询 self.pollingTimer.stop() # 更新内容 import base64 encoded_content = data.get('content', '') try: self.editorContent = base64.b64decode(encoded_content).decode('utf-8') except Exception as e: logger.error(f"解码内容失败: {e}") return # 标记内容已保存 self.isContentSaved = True # 更新占位符文本,显示已保存信息 self.placeholderLabel.setText('''

已经保存

内容已从浏览器同步到应用并已保存

该内容将不再允许编辑

''') # 禁用保存按钮 self.saveButton.setEnabled(False) # 清理临时文件 if self.tempFilePath and os.path.exists(self.tempFilePath): try: os.unlink(self.tempFilePath) self.tempFilePath = None except Exception as e: logger.error(f"删除临时文件失败: {e}") def handleError(self, error_msg): """处理加载错误""" logger.error(f"文本预览失败,URL: {self.url}, 错误: {error_msg}") self.loadingCard.error() def _initMonacoEditor(self, content): """初始化Monaco Editor并加载内容""" # 获取编辑器文件的绝对路径 editor_dir = QDir("_internal/editor") editor_path = editor_dir.absoluteFilePath("min/vs/editor/editor.main.js") editor_file = QFileInfo(editor_path) base_url = QUrl.fromLocalFile(editor_file.absolutePath()) # 检测文本语言 language = self._detect_language(content) logger.info(f"检测到文本语言类型: {language}") # 构建HTML内容,使用ES模块方式加载Monaco Editor html_content = f'''
''' # 由于我们已经切换到外部浏览器编辑模式,这里不再需要加载HTML内容到textEdit # self.textEdit.setHtml(html_content, base_url) - 已注释,因为textEdit不再存在 def _detect_language(self, content): """检测文本语言类型""" content_lower = content.lower() if 'public class' in content_lower and ('import ' in content_lower or 'package ' in content_lower): return 'java' elif 'def ' in content_lower and ('import ' in content_lower or 'print(' in content_lower): return 'python' elif '' in content_lower or '' in content_lower: return 'html' elif '{' in content_lower and '}' in content_lower and ':' in content_lower: return 'json' elif '{' in content_lower and '}' in content_lower and ('function' in content_lower or 'const ' in content_lower): return 'javascript' elif '