This commit is contained in:
2025-11-02 19:17:20 +08:00
parent ebf784146e
commit e71b69db5f
2575 changed files with 1242294 additions and 95 deletions

View File

@@ -1,5 +1,6 @@
# coding: utf-8
import re
from loguru import logger
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QPixmap
@@ -8,7 +9,7 @@ from qfluentwidgets import Action, BodyLabel, CardWidget, ImageLabel, InfoBar, I
MessageBox, PushButton, RoundMenu, StrongBodyLabel
from qfluentwidgets import FluentIcon as FIF
from app.core import (DeleteFileThread, formatDate, formatSize, getFileIcon, lang, signalBus)
from app.core import (DeleteFileThread, RenameFileThread, formatDate, formatSize, getFileIcon, lang, signalBus)
from app.view.widgets.share_file_messageBox import ShareFileMessageBox
@@ -132,6 +133,9 @@ class FileCard(CardWidget):
Action(
FIF.PROJECTOR, lang("预览"), triggered=lambda: self.selfPreview()
),
Action(
FIF.EDIT, lang("重命名"), triggered=lambda: self.renameSelf()
),
]
)
@@ -151,6 +155,9 @@ class FileCard(CardWidget):
menu.addActions(
[
Action(FIF.DOWNLOAD, lang("进入"), triggered=lambda: self.dirClicked()),
Action(
FIF.EDIT, lang("重命名"), triggered=lambda: self.renameSelf()
),
]
)
@@ -171,31 +178,47 @@ class FileCard(CardWidget):
)
if w.exec():
# 构建Cloudreve V4 API所需的正确路径格式
if self.filePath == "/":
# 确保路径格式规范避免Path not exist错误
# 只使用路径中的目录部分,不包含文件名,确保不会重复
dir_path = self.filePath
if dir_path == "/":
# 根目录情况
full_path = f"cloudreve://my/{self.fileName}"
else:
# 子目录情况,确保正确拼接路径
# 清理路径,避免重复的斜杠和前缀
clean_path = self.filePath.lstrip("/")
# 确保路径中不包含cloudreve://my前缀
clean_path = clean_path.replace("cloudreve://my/", "")
full_path = f"cloudreve://my/{clean_path}/{self.fileName}"
# 子目录情况,确保只使用目录路径
# 清理路径,移除协议前缀
clean_dir = dir_path
if clean_dir.startswith("cloudreve://my/"):
clean_dir = clean_dir[15:] # 移除 "cloudreve://my/"
elif clean_dir.startswith("/"):
clean_dir = clean_dir[1:] # 移除前导斜杠
# 确保路径格式正确,移除重复的前缀
full_path = full_path.replace("cloudreve://my/cloudreve://my", "cloudreve://my")
# 确保路径中间没有多余的斜杠
clean_dir = '/'.join([part for part in clean_dir.split('/') if part])
# 更健壮地处理重复文件名的情况
# 分割路径并去重
# 构建完整URI只包含目录路径和文件名一次
full_path = f"cloudreve://my/{clean_dir}/{self.fileName}"
# 检查并移除可能的重复文件名部分
# 避免同时包含编码形式和原始形式的文件名
import re
# 查找可能的编码形式+原始形式的重复模式
path_parts = full_path.split('/')
if len(path_parts) > 1:
# 检查最后个部分是否是文件名
if path_parts[-1] == self.fileName:
# 检查倒数第二个部分是否也是文件名
if len(path_parts) > 2 and path_parts[-2] == self.fileName:
# 移除重复的文件名部分
path_parts.pop(-2)
full_path = '/'.join(path_parts)
if len(path_parts) > 3:
# 检查最后个部分是否可能是编码和原始形式的关系
potential_encoding = path_parts[-2]
original = path_parts[-1]
# 如果倒数第二个部分看起来像URL编码且最后一个部分是中文
if re.match(r'^%[0-9A-Fa-f]{2}%[0-9A-Fa-f]{2}%[0-9A-Fa-f]{2}%[0-9A-Fa-f]{2}$', potential_encoding) and any('\u4e00'-'\u9fff' in c for c in original):
# 移除倒数第二个部分,避免重复
path_parts.pop(-2)
full_path = '/'.join(path_parts)
# 最终清理
full_path = full_path.replace("//", "/") # 移除多余的斜杠
# 记录构建的路径用于调试
print(f"[DEBUG] 重命名路径: {full_path}")
self.deleteThread = DeleteFileThread(full_path, self.fileType)
self.deleteThread.successDelete.connect(self.deleteSuccess)
@@ -235,6 +258,162 @@ class FileCard(CardWidget):
self.window(),
)
logger.error(f"删除文件失败:{error_msg}")
def renameSelf(self):
"""重命名文件或文件夹"""
# 使用输入对话框获取新名称
from qfluentwidgets import LineEdit, MessageBoxBase, SubtitleLabel
from PyQt6.QtCore import Qt
import re
# 创建自定义对话框
class RenameMessageBox(MessageBoxBase):
"""自定义重命名对话框"""
def __init__(self, parent=None, initial_name=""):
super().__init__(parent)
self.titleLabel = SubtitleLabel(f"{lang("请输入新名称")}:")
self.lineEdit = LineEdit()
self.lineEdit.setText(initial_name)
# 选择文件名部分(不包括扩展名)
if "." in initial_name:
name_part = initial_name.rsplit(".", 1)[0]
self.lineEdit.setSelection(0, len(name_part))
else:
self.lineEdit.selectAll()
# 将组件添加到布局中
self.viewLayout.addWidget(self.titleLabel)
self.viewLayout.addWidget(self.lineEdit)
# 设置对话框的最小宽度
self.widget.setMinimumWidth(400)
# 设置窗口标题
self.setWindowTitle(lang("重命名"))
# 创建并显示对话框
dlg = RenameMessageBox(self.window(), self.fileName)
if dlg.exec():
new_name = dlg.lineEdit.text().strip()
# 验证文件名
if not new_name:
InfoBar.warning(
lang("警告"),
lang("文件名不能为空"),
Qt.Orientation.Horizontal,
True,
1000,
InfoBarPosition.TOP_RIGHT,
self.window(),
)
return
# 检查文件名是否包含无效字符
if re.search(r'[<>"/\\|?*]', new_name):
InfoBar.warning(
lang("警告"),
lang("文件名包含无效字符"),
Qt.Orientation.Horizontal,
True,
1000,
InfoBarPosition.TOP_RIGHT,
self.window(),
)
return
# 检查文件名是否与当前目录中的其他文件重复
# 获取当前目录下的所有文件名称
current_folder = self.filePath
# 从父窗口获取文件列表假设父窗口有fileList属性
parent_window = self.window()
if hasattr(parent_window, 'fileList'):
# 遍历文件列表,检查是否有相同名称的文件(排除当前正在重命名的文件)
for file_item in parent_window.fileList:
# 跳过当前正在重命名的文件
if hasattr(file_item, 'fileName') and file_item.fileName == self.fileName:
continue
# 检查是否有同名文件
if hasattr(file_item, 'fileName') and file_item.fileName == new_name:
InfoBar.warning(
lang("警告"),
lang("文件名已存在"),
Qt.Orientation.Horizontal,
True,
1000,
InfoBarPosition.TOP_RIGHT,
self.window(),
)
return
# 构建Cloudreve V4 API所需的正确路径格式
# 确保路径格式规范避免Path not exist错误
# 只使用路径中的目录部分,不包含文件名
if self.filePath == "/":
# 根目录情况URI只包含根目录路径
full_path = "cloudreve://my" # 直接使用正确的双斜杠格式
else:
# 子目录情况,确保只使用目录路径,不包含文件名
# 清理路径,移除协议前缀
clean_path = self.filePath
if clean_path.startswith("cloudreve://my/"):
clean_path = clean_path[15:] # 移除 "cloudreve://my/"
elif clean_path.startswith("/"):
clean_path = clean_path[1:] # 移除前导斜杠
# 构建完整URI只包含目录路径
full_path = f"cloudreve://my/{clean_path}" # 直接使用正确的双斜杠格式
# 强制确保协议前缀格式正确,使用双斜杠
# 直接替换所有可能的单斜杠格式
full_path = full_path.replace("cloudreve:/my", "cloudreve://my")
full_path = full_path.replace("cloudreve:/", "cloudreve://")
# 再次确认格式完全正确
if not full_path.startswith("cloudreve://"):
# 如果格式仍然不正确重新构建整个URI
path_part = full_path.replace("cloudreve:", "").lstrip("/")
full_path = f"cloudreve://{path_part}"
# 不再需要处理文件名重复因为URI中只包含目录路径
# 最终清理
full_path = full_path.replace("//", "/") # 移除多余的斜杠
# 记录构建的路径用于调试
print(f"[DEBUG] 重命名路径: {full_path}")
# 调用重命名线程
self.renameThread = RenameFileThread(full_path, new_name, self.fileType)
self.renameThread.successRename.connect(self.renameSuccess)
self.renameThread.errorRename.connect(self.renameError)
self.renameThread.start()
def renameSuccess(self):
InfoBar.success(
lang("成功"),
lang("重命名成功"),
Qt.Orientation.Horizontal,
True,
1000,
InfoBarPosition.TOP_RIGHT,
self.window(),
)
# 刷新文件夹列表
signalBus.refreshFolderListSignal.emit()
def renameError(self, error_msg):
InfoBar.error(
lang("失败"),
f"{lang("重命名失败")}: {error_msg}",
Qt.Orientation.Horizontal,
True,
1000,
InfoBarPosition.TOP_RIGHT,
self.window(),
)
logger.error(f"重命名文件失败:{error_msg}")
def contextMenuEvent(self, e):
"""重写上下文菜单事件,确保只有右键点击才会触发"""

View File

@@ -23,6 +23,7 @@ from qframelesswindow import FramelessWindow as Window
from app.core import LoginThread, RegisterThread
from app.view.widgets.login_widget import LoginWidget
from app.view.widgets.register_widget import RegisterWidget
from welcome_video import WelcomeVideoPlayer
class RegisterWindow(Window):
@@ -50,6 +51,7 @@ class RegisterWindow(Window):
self.loginWidget = LoginWidget(self)
self.registerWidget = RegisterWidget(self)
self.videoPlayer = WelcomeVideoPlayer(self)
self.__initWidgets()
logger.info("注册窗口初始化完成")
@@ -65,6 +67,11 @@ class RegisterWindow(Window):
self.setStyleSheet(f"RegisterWindow{{background: {color.name()}}}")
self.setWindowIcon(QIcon(":app/images/logo.png"))
self.setFixedSize(690, 470)
# 设置视频播放器为全窗口大小
self.videoPlayer.setGeometry(0, 0, 690, 470)
# 初始时隐藏视频播放器
self.videoPlayer.hide()
# self.promotionalImageLabel.setImage(":app/images/background.png")
# self.promotionalImageLabel.scaledToWidth(300)
@@ -92,6 +99,25 @@ class RegisterWindow(Window):
self.stackedWidget.setMaximumWidth(300)
self.stackedWidget.addWidget(self.loginWidget)
self.stackedWidget.addWidget(self.registerWidget)
def show_welcome_video(self):
"""显示欢迎视频"""
# 确保视频播放器显示在最前面
self.videoPlayer.raise_()
# 开始播放视频
self.videoPlayer.start_playback()
# 再次确保跳过按钮在最顶层
self.videoPlayer.skip_button.raise_()
# 连接视频完成信号
self.videoPlayer.videoFinished.connect(self.hide_welcome_video)
def hide_welcome_video(self):
"""隐藏欢迎视频,显示登录界面"""
self.videoPlayer.stop_playback()
# 确保登录界面元素可见
self.pivot.show()
self.stackedWidget.show()
self.logoImage.show()
self.titleBar.titleLabel.setStyleSheet(
"""

View File

@@ -5,6 +5,7 @@ from PyQt6.QtCore import QSize
from PyQt6.QtGui import QColor, QIcon
from PyQt6.QtWidgets import QApplication, QWidget
from qfluentwidgets import NavigationAvatarWidget, NavigationItemPosition, SplashScreen
from qfluentwidgets import FluentIcon
from app.core import cfg, qconfig, userConfig, GetUserAvatarThread,lang,signalBus
from app.view.app_info_interface import AppInfoInterface
@@ -68,26 +69,26 @@ class MainWindow(CustomFluentWindow):
# 注意MSFluentWindow的addSubInterface方法支持selectedIcon参数
self.addSubInterface(
self.ownFiledInterface,
QIcon(":app/icons/Myfile.svg"),
FluentIcon.FOLDER,
lang("我的文件"),
position=NavigationItemPosition.TOP,
)
self.addSubInterface(
self.storagespaceInterface,
QIcon(":app/icons/Storage.svg"),
FluentIcon.APPLICATION,
lang("存储配额"),
position=NavigationItemPosition.TOP,
)
self.addSubInterface(
self.taskInterface,
QIcon(":app/icons/Task.svg"),
FluentIcon.PASTE,
lang("任务管理"),
position=NavigationItemPosition.TOP,
)
self.addSubInterface(
self.appInfoInterface,
QIcon(":app/icons/Application.svg"),
FluentIcon.INFO,
lang("应用信息"),
position=NavigationItemPosition.BOTTOM,
)

View File

@@ -15,13 +15,13 @@ class NumInformationWidget(QWidget):
self.hBoxLayout = QHBoxLayout(self)
self.basicSizeCard = GbInformationCard(0, lang("用户组基础容量"), self)
self.packSizeCard = GbInformationCard(0, lang("有效容量包附加附加容量"), self)
# self.packSizeCard = GbInformationCard(0, lang("有效容量包附加附加容量"), self)
self.usedSizeCard = GbInformationCard(0, lang("已使用容量"), self)
self.totalSizeCard = GbInformationCard(0, lang("总容量"), self)
self.hBoxLayout.setSpacing(10)
self.hBoxLayout.addWidget(self.basicSizeCard)
self.hBoxLayout.addWidget(self.packSizeCard)
# self.hBoxLayout.addWidget(self.packSizeCard)
self.hBoxLayout.addWidget(self.usedSizeCard)
self.hBoxLayout.addWidget(self.totalSizeCard)
@@ -69,7 +69,7 @@ class StoragespaceInterface(ScrollArea):
def _successGetPack(self, datas):
self.packData = datas["data"]
self.firstLoad = False
self.numInformationWidget.packSizeCard.updateValue(self.packData["pack"])
# self.numInformationWidget.packSizeCard.updateValue(self.packData["pack"])
self.numInformationWidget.basicSizeCard.updateValue(self.packData["base"])
self.numInformationWidget.usedSizeCard.updateValue(self.packData["used"])
self.numInformationWidget.totalSizeCard.updateValue(self.packData["total"])

View File

@@ -1,21 +1,37 @@
# coding: utf-8
from loguru import logger
from PyQt6.QtCore import Qt, QTimer
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,
PlainTextEdit,
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.core.services.text_speech import LocalSpeechController
from app.view.components.empty_card import EmptyCard
@@ -251,30 +267,31 @@ class PreviewTextBox(MessageBoxBase):
self.widget.setMinimumSize(600, 400)
self._id = _id
self.isChanged = False
self.speech_controller = LocalSpeechController(self)
# 设置编辑器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}")
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)
# 创建占位符标签替代WebEngineView
from PyQt6.QtWidgets import QLabel
self.placeholderLabel = QLabel('''<div style='text-align:center; padding:20px;'>
<h3>文本编辑将在外部浏览器中打开</h3>
<p>请在浏览器中完成编辑后,点击"保存并返回"按钮</p>
<p>应用将自动检测并更新内容</p>
</div>''')
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)
@@ -315,13 +332,8 @@ class PreviewTextBox(MessageBoxBase):
# 显示进度条并禁用按钮
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()
# 直接使用editorContent变量因为我们已经切换到外部浏览器编辑模式
self._saveContent(self.editorContent)
def _successSave(self):
logger.info(f"文本文件保存成功文件ID: {self._id}")
@@ -353,20 +365,7 @@ class PreviewTextBox(MessageBoxBase):
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如果缺失"""
@@ -432,29 +431,453 @@ class PreviewTextBox(MessageBoxBase):
self.loadingCard.setText(f"正在加载文本... {progress}%")
def setTextContent(self, content):
"""设置文本内容"""
"""设置文本内容并在外部浏览器中打开"""
# 检查内容是否已保存,如果已保存则不允许再次编辑
if hasattr(self, 'isContentSaved') and self.isContentSaved:
logger.warning("内容已保存,不允许再次编辑")
self.placeholderLabel.setText('''<div style='text-align:center; padding:20px;'>
<h3>已经保存</h3>
<p>该内容已经保存,不再允许编辑</p>
</div>''')
return
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.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('''<div style='text-align:center; padding:20px;'>
<h3>文本编辑已在外部浏览器中打开</h3>
<p><strong>重要提示:</strong>完成编辑后,请点击浏览器中的"保存并返回"按钮</p>
<p>应用正在自动检测...</p>
</div>''')
# 启动轮询检查
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('''<div style='text-align:center; padding:20px;'>
<h3>已经保存</h3>
<p>内容已从浏览器同步到应用并已保存</p>
<p>该内容将不再允许编辑</p>
</div>''')
# 禁用保存按钮
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}")
self.textEdit.setPlainText(content)
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'''
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body, html {{
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
font-family: 'Microsoft YaHei', Arial, sans-serif;
}}
#container {{
width: 100%;
height: 100%;
}}
</style>
<script src="vs/loader.js"></script>
<script>
let editor;
require.config({{ paths: {{ 'vs': './vs' }} }});
require(['vs/editor/editor.main'], function() {{
editor = monaco.editor.create(document.getElementById('container'), {{
value: `{content.replace('`', '\\`')}`,
}});
// 重新获取编辑器实例以确保正确初始化
setTimeout(() => {{
editor = monaco.editor.getModels()[0] ? monaco.editor.getModels()[0].getContainerInfo().domNode.monacoEditor : null;
}}, 100);'\\`'}}`,
language: '{language}',
theme: 'vs-dark',
automaticLayout: true,
minimap: {{ enabled: true }},
scrollBeyondLastLine: false,
fontSize: 14,
wordWrap: 'on',
lineNumbers: 'on',
readOnly: false,
fontFamily: 'Consolas, "Microsoft YaHei", monospace'
}});
// 添加内容变化监听器
editor.onDidChangeModelContent(function() {{
// 可以在这里添加内容变化的处理逻辑
}});
}});
// 暴露获取内容的方法给QWebEngineView调用
function getEditorContent() {{
return editor ? editor.getValue() : '';
}}
// 暴露设置内容的方法
function setEditorContent(content) {{
if (editor) {{
editor.setValue(content);
}}
}}
</script>
</head>
<body>
<div id="container"></div>
</body>
</html>
'''
# 由于我们已经切换到外部浏览器编辑模式这里不再需要加载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 '<!doctype html>' in content_lower or '<html>' 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 '<?xml' in content_lower:
return 'xml'
elif '<?php' in content_lower:
return 'php'
elif 'class ' in content_lower and '{' in content_lower and '}' in content_lower and ';' in content_lower:
return 'cpp'
elif 'select ' in content_lower and 'from ' in content_lower:
return 'sql'
elif '#include' in content_lower:
return 'cpp'
elif '<script' in content_lower:
return 'html'
elif 'function' in content_lower and '{' in content_lower and '}' in content_lower:
return 'javascript'
elif 'namespace' in content_lower and '{' in content_lower and '}' in content_lower:
return 'cpp'
elif 'var ' in content_lower and '=' in content_lower:
return 'javascript'
elif 'using namespace' in content_lower:
return 'cpp'
elif 'struct ' in content_lower and '{' in content_lower:
return 'cpp'
elif '#' in content_lower and 'define' in content_lower:
return 'cpp'
else:
return 'text'
# JavaScript执行完成的处理已移除
def _saveContent(self, content):
"""保存编辑器内容并提交修改"""
logger.info(f"保存文本文件修改文件ID: {self._id}")
self.saveTextThread = UpdateFileContentThread(
self._id,
content,
)
self.saveTextThread.successUpdated.connect(self._successSave)
self.saveTextThread.errorUpdated.connect(self._errorSave)
self.saveTextThread.start()
def getTextContent(self):
"""获取编辑器内容"""
return self.editorContent
def resizeEvent(self, event):
"""重写窗口大小改变事件"""
super().resizeEvent(event)
# 文本预览框会自动适应大小,无需特殊处理
def closeEvent(self, event):
"""关闭事件清理临时文件、定时器和Web服务器"""
# 停止轮询定时器
if hasattr(self, 'pollingTimer'):
self.pollingTimer.stop()
logger.info("轮询定时器已停止")
# 停止Web服务器
if hasattr(self, 'httpd'):
try:
logger.info("正在停止本地Web服务器...")
self.httpd.shutdown()
self.httpd.server_close()
logger.info("本地Web服务器已成功停止")
except Exception as e:
logger.error(f"停止Web服务器失败: {e}")
# 清理临时文件
if hasattr(self, 'tempFilePath') and os.path.exists(self.tempFilePath):
try:
os.unlink(self.tempFilePath)
logger.info(f"临时文件已清理: {self.tempFilePath}")
except Exception as e:
logger.error(f"清理临时文件失败: {e}")
# 调用父类方法并接受事件
super().closeEvent(event)
event.accept()

View File

@@ -0,0 +1,133 @@
# coding:utf-8
import os
import sys
from PyQt6.QtCore import Qt, QUrl, QTimer
from PyQt6.QtGui import QIcon
from PyQt6.QtWidgets import QApplication, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from PyQt6.QtMultimedia import QMediaPlayer, QVideoSink, QAudioOutput
from PyQt6.QtMultimediaWidgets import QVideoWidget
from loguru import logger
from app.core.utils.config import cfg
class WelcomeVideoPlayer(QWidget):
"""欢迎视频播放器,用于首次运行时播放介绍视频"""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
# 设置视频播放器组件
self.media_player = QMediaPlayer()
# 创建视频窗口
self.video_widget = QVideoWidget()
# 使用正确的属性赋值方式
self.media_player.setVideoOutput(self.video_widget)
# 创建跳过按钮
self.skip_button = QPushButton("跳过")
self.skip_button.setFixedSize(100, 40)
self.skip_button.setObjectName("skipButton")
self.skip_button.setStyleSheet("""
QPushButton {
background-color: rgba(255, 255, 255, 0.3);
color: white;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
}
QPushButton:hover {
background-color: rgba(255, 255, 255, 0.5);
}
QPushButton:pressed {
background-color: rgba(255, 255, 255, 0.7);
}
""")
self.skip_button.clicked.connect(self.skip_video)
# 设置布局
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.addWidget(self.video_widget)
# 创建跳过按钮布局
self.skip_layout = QHBoxLayout()
self.skip_layout.addStretch()
self.skip_layout.addWidget(self.skip_button)
self.skip_layout.setContentsMargins(0, 0, 20, 20)
# 将跳过按钮布局添加到主布局
self.main_layout.addLayout(self.skip_layout)
self.main_layout.setAlignment(self.skip_layout, Qt.AlignmentFlag.AlignBottom)
# 连接信号
self.media_player.playbackStateChanged.connect(self.on_playback_state_changed)
self.media_player.errorOccurred.connect(self.on_error)
# 设置视频文件路径
self.video_path = os.path.join("_internal", "start.mp4")
def play(self):
"""播放视频"""
if os.path.exists(self.video_path):
logger.info(f"开始播放欢迎视频: {self.video_path}")
# 设置视频源
self.media_player.setSource(QUrl.fromLocalFile(self.video_path))
# 调整窗口大小以适应屏幕
self.adjust_window_size()
# 显示窗口
self.show()
# 开始播放
self.media_player.play()
else:
logger.warning(f"欢迎视频文件不存在: {self.video_path}")
self.close()
def skip_video(self):
"""跳过视频"""
logger.info("用户点击跳过按钮,停止播放欢迎视频")
self.stop_and_close()
def stop_and_close(self):
"""停止播放并关闭窗口"""
self.media_player.stop()
# 标记为非首次运行
cfg.firstRun.value = False
# 延迟关闭,确保配置保存
QTimer.singleShot(100, self.close)
def on_playback_state_changed(self, state):
"""处理播放状态变化"""
from PyQt6.QtMultimedia import QMediaPlayer
if state == QMediaPlayer.PlaybackState.StoppedState:
logger.info("欢迎视频播放完成")
self.stop_and_close()
def on_error(self, error, error_string):
"""处理播放器错误"""
logger.error(f"视频播放错误: {error_string}")
self.stop_and_close()
def adjust_window_size(self):
"""调整窗口大小以适应屏幕"""
# 获取屏幕大小
screen = QApplication.primaryScreen().availableGeometry()
# 设置窗口大小为屏幕的90%
width = int(screen.width() * 0.9)
height = int(screen.height() * 0.9)
self.resize(width, height)
# 居中显示
self.move(screen.center().x() - width // 2, screen.center().y() - height // 2)
def closeEvent(self, event):
"""关闭事件"""
self.media_player.stop()
event.accept()