|
|
|
|
@ -0,0 +1,673 @@
|
|
|
|
|
# P2P Network Communication - File Transfer Widget
|
|
|
|
|
"""
|
|
|
|
|
文件传输界面组件
|
|
|
|
|
实现文件选择、传输进度显示和图片预览
|
|
|
|
|
|
|
|
|
|
需求: 4.1, 4.3, 4.6, 5.2, 5.3, 5.4, 5.6
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
import os
|
|
|
|
|
from typing import Optional, Dict
|
|
|
|
|
|
|
|
|
|
from PyQt6.QtWidgets import (
|
|
|
|
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
|
|
|
|
QProgressBar, QFileDialog, QScrollArea, QFrame, QMessageBox,
|
|
|
|
|
QDialog, QSlider, QSizePolicy
|
|
|
|
|
)
|
|
|
|
|
from PyQt6.QtCore import Qt, pyqtSignal, QSize
|
|
|
|
|
from PyQt6.QtGui import QPixmap, QTransform
|
|
|
|
|
|
|
|
|
|
from shared.models import TransferProgress, TransferStatus
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TransferItem(QFrame):
|
|
|
|
|
"""传输项组件"""
|
|
|
|
|
|
|
|
|
|
cancel_requested = pyqtSignal(str) # file_id
|
|
|
|
|
|
|
|
|
|
def __init__(self, file_id: str, file_name: str, total_size: int,
|
|
|
|
|
is_upload: bool = True, parent=None):
|
|
|
|
|
super().__init__(parent)
|
|
|
|
|
self.file_id = file_id
|
|
|
|
|
self.file_name = file_name
|
|
|
|
|
self.total_size = total_size
|
|
|
|
|
self.is_upload = is_upload
|
|
|
|
|
self._setup_ui()
|
|
|
|
|
|
|
|
|
|
def _setup_ui(self) -> None:
|
|
|
|
|
"""设置UI"""
|
|
|
|
|
self.setFrameShape(QFrame.Shape.StyledPanel)
|
|
|
|
|
self.setStyleSheet("""
|
|
|
|
|
QFrame {
|
|
|
|
|
background-color: white;
|
|
|
|
|
border: 1px solid #ddd;
|
|
|
|
|
border-radius: 5px;
|
|
|
|
|
margin: 2px;
|
|
|
|
|
}
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
layout = QVBoxLayout(self)
|
|
|
|
|
layout.setContentsMargins(10, 10, 10, 10)
|
|
|
|
|
layout.setSpacing(5)
|
|
|
|
|
|
|
|
|
|
# 文件信息行
|
|
|
|
|
info_layout = QHBoxLayout()
|
|
|
|
|
|
|
|
|
|
icon = "⬆️" if self.is_upload else "⬇️"
|
|
|
|
|
self._name_label = QLabel(f"{icon} {self.file_name}")
|
|
|
|
|
self._name_label.setStyleSheet("font-weight: bold;")
|
|
|
|
|
info_layout.addWidget(self._name_label, 1)
|
|
|
|
|
|
|
|
|
|
self._size_label = QLabel(self._format_size(self.total_size))
|
|
|
|
|
self._size_label.setStyleSheet("color: #666;")
|
|
|
|
|
info_layout.addWidget(self._size_label)
|
|
|
|
|
|
|
|
|
|
self._cancel_btn = QPushButton("✕")
|
|
|
|
|
self._cancel_btn.setFixedSize(20, 20)
|
|
|
|
|
self._cancel_btn.setStyleSheet("""
|
|
|
|
|
QPushButton {
|
|
|
|
|
border: none;
|
|
|
|
|
color: #999;
|
|
|
|
|
}
|
|
|
|
|
QPushButton:hover {
|
|
|
|
|
color: red;
|
|
|
|
|
}
|
|
|
|
|
""")
|
|
|
|
|
self._cancel_btn.clicked.connect(lambda: self.cancel_requested.emit(self.file_id))
|
|
|
|
|
info_layout.addWidget(self._cancel_btn)
|
|
|
|
|
|
|
|
|
|
layout.addLayout(info_layout)
|
|
|
|
|
|
|
|
|
|
# 进度条
|
|
|
|
|
self._progress_bar = QProgressBar()
|
|
|
|
|
self._progress_bar.setRange(0, 100)
|
|
|
|
|
self._progress_bar.setValue(0)
|
|
|
|
|
self._progress_bar.setTextVisible(True)
|
|
|
|
|
self._progress_bar.setStyleSheet("""
|
|
|
|
|
QProgressBar {
|
|
|
|
|
border: 1px solid #ddd;
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
text-align: center;
|
|
|
|
|
height: 20px;
|
|
|
|
|
}
|
|
|
|
|
QProgressBar::chunk {
|
|
|
|
|
background-color: #4a90d9;
|
|
|
|
|
border-radius: 2px;
|
|
|
|
|
}
|
|
|
|
|
""")
|
|
|
|
|
layout.addWidget(self._progress_bar)
|
|
|
|
|
|
|
|
|
|
# 状态行
|
|
|
|
|
status_layout = QHBoxLayout()
|
|
|
|
|
|
|
|
|
|
self._status_label = QLabel("等待中...")
|
|
|
|
|
self._status_label.setStyleSheet("color: #666; font-size: 12px;")
|
|
|
|
|
status_layout.addWidget(self._status_label)
|
|
|
|
|
|
|
|
|
|
status_layout.addStretch()
|
|
|
|
|
|
|
|
|
|
self._speed_label = QLabel("")
|
|
|
|
|
self._speed_label.setStyleSheet("color: #666; font-size: 12px;")
|
|
|
|
|
status_layout.addWidget(self._speed_label)
|
|
|
|
|
|
|
|
|
|
layout.addLayout(status_layout)
|
|
|
|
|
|
|
|
|
|
def _format_size(self, size: int) -> str:
|
|
|
|
|
"""格式化文件大小"""
|
|
|
|
|
if size < 1024:
|
|
|
|
|
return f"{size} B"
|
|
|
|
|
elif size < 1024 * 1024:
|
|
|
|
|
return f"{size / 1024:.1f} KB"
|
|
|
|
|
elif size < 1024 * 1024 * 1024:
|
|
|
|
|
return f"{size / (1024 * 1024):.1f} MB"
|
|
|
|
|
else:
|
|
|
|
|
return f"{size / (1024 * 1024 * 1024):.2f} GB"
|
|
|
|
|
|
|
|
|
|
def _format_speed(self, speed: float) -> str:
|
|
|
|
|
"""格式化传输速度"""
|
|
|
|
|
return f"{self._format_size(int(speed))}/s"
|
|
|
|
|
|
|
|
|
|
def update_progress(self, progress: TransferProgress) -> None:
|
|
|
|
|
"""更新进度"""
|
|
|
|
|
percent = int(progress.progress_percent)
|
|
|
|
|
self._progress_bar.setValue(percent)
|
|
|
|
|
self._speed_label.setText(self._format_speed(progress.speed))
|
|
|
|
|
|
|
|
|
|
if progress.eta > 0:
|
|
|
|
|
eta_str = f"剩余 {int(progress.eta)}秒"
|
|
|
|
|
else:
|
|
|
|
|
eta_str = ""
|
|
|
|
|
self._status_label.setText(f"传输中... {eta_str}")
|
|
|
|
|
|
|
|
|
|
def set_status(self, status: TransferStatus) -> None:
|
|
|
|
|
"""设置状态"""
|
|
|
|
|
status_texts = {
|
|
|
|
|
TransferStatus.PENDING: "等待中...",
|
|
|
|
|
TransferStatus.IN_PROGRESS: "传输中...",
|
|
|
|
|
TransferStatus.COMPLETED: "✓ 完成",
|
|
|
|
|
TransferStatus.FAILED: "✕ 失败",
|
|
|
|
|
TransferStatus.CANCELLED: "已取消",
|
|
|
|
|
TransferStatus.PAUSED: "已暂停",
|
|
|
|
|
}
|
|
|
|
|
self._status_label.setText(status_texts.get(status, "未知"))
|
|
|
|
|
|
|
|
|
|
if status == TransferStatus.COMPLETED:
|
|
|
|
|
self._progress_bar.setValue(100)
|
|
|
|
|
self._cancel_btn.hide()
|
|
|
|
|
self._progress_bar.setStyleSheet("""
|
|
|
|
|
QProgressBar {
|
|
|
|
|
border: 1px solid #ddd;
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
text-align: center;
|
|
|
|
|
height: 20px;
|
|
|
|
|
}
|
|
|
|
|
QProgressBar::chunk {
|
|
|
|
|
background-color: #4caf50;
|
|
|
|
|
border-radius: 2px;
|
|
|
|
|
}
|
|
|
|
|
""")
|
|
|
|
|
elif status == TransferStatus.FAILED:
|
|
|
|
|
self._cancel_btn.hide()
|
|
|
|
|
self._progress_bar.setStyleSheet("""
|
|
|
|
|
QProgressBar {
|
|
|
|
|
border: 1px solid #ddd;
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
text-align: center;
|
|
|
|
|
height: 20px;
|
|
|
|
|
}
|
|
|
|
|
QProgressBar::chunk {
|
|
|
|
|
background-color: #f44336;
|
|
|
|
|
border-radius: 2px;
|
|
|
|
|
}
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FileTransferWidget(QWidget):
|
|
|
|
|
"""
|
|
|
|
|
文件传输界面组件
|
|
|
|
|
|
|
|
|
|
实现文件选择对话框 (需求 4.1)
|
|
|
|
|
实现传输进度显示 (需求 4.3)
|
|
|
|
|
实现图片预览和查看 (需求 5.2, 5.3, 5.4, 5.6)
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
# 信号定义
|
|
|
|
|
send_file_requested = pyqtSignal(str, str) # peer_id, file_path
|
|
|
|
|
cancel_transfer_requested = pyqtSignal(str) # file_id
|
|
|
|
|
|
|
|
|
|
def __init__(self, parent=None):
|
|
|
|
|
super().__init__(parent)
|
|
|
|
|
|
|
|
|
|
self._transfers: Dict[str, TransferItem] = {}
|
|
|
|
|
|
|
|
|
|
self._setup_ui()
|
|
|
|
|
|
|
|
|
|
logger.info("FileTransferWidget initialized")
|
|
|
|
|
|
|
|
|
|
def _setup_ui(self) -> None:
|
|
|
|
|
"""设置UI"""
|
|
|
|
|
layout = QVBoxLayout(self)
|
|
|
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
|
layout.setSpacing(0)
|
|
|
|
|
|
|
|
|
|
# 标题栏
|
|
|
|
|
header = QFrame()
|
|
|
|
|
header.setFixedHeight(40)
|
|
|
|
|
header.setStyleSheet("""
|
|
|
|
|
QFrame {
|
|
|
|
|
background-color: #f5f5f5;
|
|
|
|
|
border-bottom: 1px solid #ddd;
|
|
|
|
|
}
|
|
|
|
|
""")
|
|
|
|
|
header_layout = QHBoxLayout(header)
|
|
|
|
|
header_layout.setContentsMargins(10, 0, 10, 0)
|
|
|
|
|
|
|
|
|
|
title_label = QLabel("文件传输")
|
|
|
|
|
title_label.setStyleSheet("font-weight: bold;")
|
|
|
|
|
header_layout.addWidget(title_label)
|
|
|
|
|
|
|
|
|
|
header_layout.addStretch()
|
|
|
|
|
|
|
|
|
|
self._clear_btn = QPushButton("清除已完成")
|
|
|
|
|
self._clear_btn.clicked.connect(self._clear_completed)
|
|
|
|
|
header_layout.addWidget(self._clear_btn)
|
|
|
|
|
|
|
|
|
|
layout.addWidget(header)
|
|
|
|
|
|
|
|
|
|
# 传输列表
|
|
|
|
|
scroll_area = QScrollArea()
|
|
|
|
|
scroll_area.setWidgetResizable(True)
|
|
|
|
|
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
|
|
|
|
|
|
|
|
self._transfer_container = QWidget()
|
|
|
|
|
self._transfer_layout = QVBoxLayout(self._transfer_container)
|
|
|
|
|
self._transfer_layout.setContentsMargins(5, 5, 5, 5)
|
|
|
|
|
self._transfer_layout.setSpacing(5)
|
|
|
|
|
self._transfer_layout.addStretch()
|
|
|
|
|
|
|
|
|
|
scroll_area.setWidget(self._transfer_container)
|
|
|
|
|
layout.addWidget(scroll_area, 1)
|
|
|
|
|
|
|
|
|
|
# 空状态提示
|
|
|
|
|
self._empty_label = QLabel("暂无传输任务")
|
|
|
|
|
self._empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
|
self._empty_label.setStyleSheet("color: #999;")
|
|
|
|
|
self._transfer_layout.insertWidget(0, self._empty_label)
|
|
|
|
|
|
|
|
|
|
def _clear_completed(self) -> None:
|
|
|
|
|
"""清除已完成的传输"""
|
|
|
|
|
to_remove = []
|
|
|
|
|
for file_id, item in self._transfers.items():
|
|
|
|
|
# 检查是否已完成(简化判断)
|
|
|
|
|
if item._progress_bar.value() == 100:
|
|
|
|
|
to_remove.append(file_id)
|
|
|
|
|
|
|
|
|
|
for file_id in to_remove:
|
|
|
|
|
self.remove_transfer(file_id)
|
|
|
|
|
|
|
|
|
|
def _update_empty_state(self) -> None:
|
|
|
|
|
"""更新空状态显示"""
|
|
|
|
|
self._empty_label.setVisible(len(self._transfers) == 0)
|
|
|
|
|
|
|
|
|
|
# ==================== 公共方法 ====================
|
|
|
|
|
|
|
|
|
|
def add_transfer(self, file_id: str, file_name: str, total_size: int,
|
|
|
|
|
is_upload: bool = True) -> None:
|
|
|
|
|
"""
|
|
|
|
|
添加传输任务
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
file_id: 文件ID
|
|
|
|
|
file_name: 文件名
|
|
|
|
|
total_size: 文件大小
|
|
|
|
|
is_upload: 是否是上传
|
|
|
|
|
"""
|
|
|
|
|
if file_id in self._transfers:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
item = TransferItem(file_id, file_name, total_size, is_upload)
|
|
|
|
|
item.cancel_requested.connect(self._on_cancel_requested)
|
|
|
|
|
|
|
|
|
|
self._transfer_layout.insertWidget(self._transfer_layout.count() - 1, item)
|
|
|
|
|
self._transfers[file_id] = item
|
|
|
|
|
self._update_empty_state()
|
|
|
|
|
|
|
|
|
|
logger.debug(f"Transfer added: {file_name}")
|
|
|
|
|
|
|
|
|
|
def update_transfer_progress(self, file_id: str, progress: TransferProgress) -> None:
|
|
|
|
|
"""
|
|
|
|
|
更新传输进度
|
|
|
|
|
|
|
|
|
|
实现传输进度显示 (需求 4.3)
|
|
|
|
|
WHILE 文件传输进行中 THEN P2P_Client SHALL 显示传输进度百分比和传输速度
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
file_id: 文件ID
|
|
|
|
|
progress: 进度信息
|
|
|
|
|
"""
|
|
|
|
|
if file_id in self._transfers:
|
|
|
|
|
self._transfers[file_id].update_progress(progress)
|
|
|
|
|
|
|
|
|
|
def update_transfer_status(self, file_id: str, status: TransferStatus) -> None:
|
|
|
|
|
"""
|
|
|
|
|
更新传输状态
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
file_id: 文件ID
|
|
|
|
|
status: 状态
|
|
|
|
|
"""
|
|
|
|
|
if file_id in self._transfers:
|
|
|
|
|
self._transfers[file_id].set_status(status)
|
|
|
|
|
|
|
|
|
|
def remove_transfer(self, file_id: str) -> None:
|
|
|
|
|
"""
|
|
|
|
|
移除传输任务
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
file_id: 文件ID
|
|
|
|
|
"""
|
|
|
|
|
if file_id not in self._transfers:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
item = self._transfers.pop(file_id)
|
|
|
|
|
self._transfer_layout.removeWidget(item)
|
|
|
|
|
item.deleteLater()
|
|
|
|
|
self._update_empty_state()
|
|
|
|
|
|
|
|
|
|
def _on_cancel_requested(self, file_id: str) -> None:
|
|
|
|
|
"""处理取消请求"""
|
|
|
|
|
self.cancel_transfer_requested.emit(file_id)
|
|
|
|
|
|
|
|
|
|
def show_file_dialog(self, peer_id: str) -> Optional[str]:
|
|
|
|
|
"""
|
|
|
|
|
显示文件选择对话框
|
|
|
|
|
|
|
|
|
|
实现文件选择对话框 (需求 4.1)
|
|
|
|
|
WHEN 用户选择发送文件 THEN File_Transfer_Module SHALL 允许用户从本地选择任意类型的文件
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
peer_id: 目标用户ID
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
选择的文件路径,如果取消则返回None
|
|
|
|
|
"""
|
|
|
|
|
file_path, _ = QFileDialog.getOpenFileName(
|
|
|
|
|
self,
|
|
|
|
|
"选择文件",
|
|
|
|
|
"",
|
|
|
|
|
"所有文件 (*.*)"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if file_path:
|
|
|
|
|
self.send_file_requested.emit(peer_id, file_path)
|
|
|
|
|
return file_path
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def show_image_dialog(self, peer_id: str) -> Optional[str]:
|
|
|
|
|
"""
|
|
|
|
|
显示图片选择对话框
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
peer_id: 目标用户ID
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
选择的图片路径
|
|
|
|
|
"""
|
|
|
|
|
file_path, _ = QFileDialog.getOpenFileName(
|
|
|
|
|
self,
|
|
|
|
|
"选择图片",
|
|
|
|
|
"",
|
|
|
|
|
"图片文件 (*.jpg *.jpeg *.png *.gif *.bmp);;所有文件 (*.*)"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if file_path:
|
|
|
|
|
self.send_file_requested.emit(peer_id, file_path)
|
|
|
|
|
return file_path
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def show_save_dialog(self, file_name: str) -> Optional[str]:
|
|
|
|
|
"""
|
|
|
|
|
显示保存文件对话框
|
|
|
|
|
|
|
|
|
|
实现用户选择保存位置 (需求 4.6)
|
|
|
|
|
WHEN 接收到文件 THEN P2P_Client SHALL 允许用户选择保存位置
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
file_name: 默认文件名
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
保存路径
|
|
|
|
|
"""
|
|
|
|
|
file_path, _ = QFileDialog.getSaveFileName(
|
|
|
|
|
self,
|
|
|
|
|
"保存文件",
|
|
|
|
|
file_name,
|
|
|
|
|
"所有文件 (*.*)"
|
|
|
|
|
)
|
|
|
|
|
return file_path if file_path else None
|
|
|
|
|
|
|
|
|
|
def show_image_preview(self, image_path: str) -> None:
|
|
|
|
|
"""
|
|
|
|
|
显示图片预览
|
|
|
|
|
|
|
|
|
|
实现图片预览和查看 (需求 5.4)
|
|
|
|
|
WHEN 用户点击图片缩略图 THEN P2P_Client SHALL 打开图片查看器显示完整图片
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
image_path: 图片路径
|
|
|
|
|
"""
|
|
|
|
|
dialog = ImagePreviewDialog(image_path, self)
|
|
|
|
|
dialog.exec()
|
|
|
|
|
|
|
|
|
|
def create_image_thumbnail(self, image_path: str) -> Optional[ImageThumbnail]:
|
|
|
|
|
"""
|
|
|
|
|
创建图片缩略图组件
|
|
|
|
|
|
|
|
|
|
实现图片缩略图预览 (需求 5.2, 5.3)
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
image_path: 图片路径
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
缩略图组件
|
|
|
|
|
"""
|
|
|
|
|
if not os.path.exists(image_path):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
thumbnail = ImageThumbnail(image_path)
|
|
|
|
|
thumbnail.clicked.connect(self.show_image_preview)
|
|
|
|
|
return thumbnail
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ImagePreviewDialog(QDialog):
|
|
|
|
|
"""
|
|
|
|
|
图片预览对话框
|
|
|
|
|
|
|
|
|
|
实现图片预览和查看 (需求 5.3, 5.4)
|
|
|
|
|
WHEN 图片传输完成 THEN P2P_Client SHALL 在聊天窗口内直接显示图片缩略图
|
|
|
|
|
WHEN 用户点击图片缩略图 THEN P2P_Client SHALL 打开图片查看器显示完整图片
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, image_path: str, parent=None):
|
|
|
|
|
super().__init__(parent)
|
|
|
|
|
self.image_path = image_path
|
|
|
|
|
self._rotation = 0
|
|
|
|
|
self._zoom_level = 100
|
|
|
|
|
self._setup_ui()
|
|
|
|
|
self._load_image()
|
|
|
|
|
|
|
|
|
|
def _setup_ui(self) -> None:
|
|
|
|
|
"""设置UI"""
|
|
|
|
|
self.setWindowTitle("图片预览")
|
|
|
|
|
self.setMinimumSize(600, 500)
|
|
|
|
|
self.resize(800, 600)
|
|
|
|
|
|
|
|
|
|
layout = QVBoxLayout(self)
|
|
|
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
|
layout.setSpacing(0)
|
|
|
|
|
|
|
|
|
|
# 图片显示区域
|
|
|
|
|
scroll_area = QScrollArea()
|
|
|
|
|
scroll_area.setWidgetResizable(True)
|
|
|
|
|
scroll_area.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
|
scroll_area.setStyleSheet("background-color: #2d2d2d;")
|
|
|
|
|
|
|
|
|
|
self._image_label = QLabel()
|
|
|
|
|
self._image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
|
scroll_area.setWidget(self._image_label)
|
|
|
|
|
|
|
|
|
|
layout.addWidget(scroll_area, 1)
|
|
|
|
|
|
|
|
|
|
# 工具栏
|
|
|
|
|
toolbar = QFrame()
|
|
|
|
|
toolbar.setFixedHeight(50)
|
|
|
|
|
toolbar.setStyleSheet("""
|
|
|
|
|
QFrame {
|
|
|
|
|
background-color: #f5f5f5;
|
|
|
|
|
border-top: 1px solid #ddd;
|
|
|
|
|
}
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
toolbar_layout = QHBoxLayout(toolbar)
|
|
|
|
|
toolbar_layout.setContentsMargins(10, 5, 10, 5)
|
|
|
|
|
toolbar_layout.setSpacing(10)
|
|
|
|
|
|
|
|
|
|
# 缩放控制
|
|
|
|
|
zoom_out_btn = QPushButton("➖")
|
|
|
|
|
zoom_out_btn.setFixedSize(30, 30)
|
|
|
|
|
zoom_out_btn.clicked.connect(self._zoom_out)
|
|
|
|
|
toolbar_layout.addWidget(zoom_out_btn)
|
|
|
|
|
|
|
|
|
|
self._zoom_slider = QSlider(Qt.Orientation.Horizontal)
|
|
|
|
|
self._zoom_slider.setRange(25, 400)
|
|
|
|
|
self._zoom_slider.setValue(100)
|
|
|
|
|
self._zoom_slider.setFixedWidth(150)
|
|
|
|
|
self._zoom_slider.valueChanged.connect(self._on_zoom_changed)
|
|
|
|
|
toolbar_layout.addWidget(self._zoom_slider)
|
|
|
|
|
|
|
|
|
|
zoom_in_btn = QPushButton("➕")
|
|
|
|
|
zoom_in_btn.setFixedSize(30, 30)
|
|
|
|
|
zoom_in_btn.clicked.connect(self._zoom_in)
|
|
|
|
|
toolbar_layout.addWidget(zoom_in_btn)
|
|
|
|
|
|
|
|
|
|
self._zoom_label = QLabel("100%")
|
|
|
|
|
self._zoom_label.setFixedWidth(50)
|
|
|
|
|
toolbar_layout.addWidget(self._zoom_label)
|
|
|
|
|
|
|
|
|
|
toolbar_layout.addStretch()
|
|
|
|
|
|
|
|
|
|
# 旋转按钮
|
|
|
|
|
rotate_left_btn = QPushButton("↺")
|
|
|
|
|
rotate_left_btn.setFixedSize(30, 30)
|
|
|
|
|
rotate_left_btn.setToolTip("向左旋转")
|
|
|
|
|
rotate_left_btn.clicked.connect(self._rotate_left)
|
|
|
|
|
toolbar_layout.addWidget(rotate_left_btn)
|
|
|
|
|
|
|
|
|
|
rotate_right_btn = QPushButton("↻")
|
|
|
|
|
rotate_right_btn.setFixedSize(30, 30)
|
|
|
|
|
rotate_right_btn.setToolTip("向右旋转")
|
|
|
|
|
rotate_right_btn.clicked.connect(self._rotate_right)
|
|
|
|
|
toolbar_layout.addWidget(rotate_right_btn)
|
|
|
|
|
|
|
|
|
|
toolbar_layout.addStretch()
|
|
|
|
|
|
|
|
|
|
# 关闭按钮
|
|
|
|
|
close_btn = QPushButton("关闭")
|
|
|
|
|
close_btn.clicked.connect(self.close)
|
|
|
|
|
toolbar_layout.addWidget(close_btn)
|
|
|
|
|
|
|
|
|
|
layout.addWidget(toolbar)
|
|
|
|
|
|
|
|
|
|
def _load_image(self) -> None:
|
|
|
|
|
"""加载图片"""
|
|
|
|
|
if not os.path.exists(self.image_path):
|
|
|
|
|
self._image_label.setText("图片不存在")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
self._original_pixmap = QPixmap(self.image_path)
|
|
|
|
|
if self._original_pixmap.isNull():
|
|
|
|
|
self._image_label.setText("无法加载图片")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
self._update_display()
|
|
|
|
|
|
|
|
|
|
def _update_display(self) -> None:
|
|
|
|
|
"""更新图片显示"""
|
|
|
|
|
if not hasattr(self, '_original_pixmap') or self._original_pixmap.isNull():
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# 应用旋转
|
|
|
|
|
transform = QTransform()
|
|
|
|
|
transform.rotate(self._rotation)
|
|
|
|
|
rotated = self._original_pixmap.transformed(transform, Qt.TransformationMode.SmoothTransformation)
|
|
|
|
|
|
|
|
|
|
# 应用缩放
|
|
|
|
|
scale = self._zoom_level / 100.0
|
|
|
|
|
scaled = rotated.scaled(
|
|
|
|
|
int(rotated.width() * scale),
|
|
|
|
|
int(rotated.height() * scale),
|
|
|
|
|
Qt.AspectRatioMode.KeepAspectRatio,
|
|
|
|
|
Qt.TransformationMode.SmoothTransformation
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self._image_label.setPixmap(scaled)
|
|
|
|
|
|
|
|
|
|
def _zoom_in(self) -> None:
|
|
|
|
|
"""放大"""
|
|
|
|
|
self._zoom_slider.setValue(min(400, self._zoom_level + 25))
|
|
|
|
|
|
|
|
|
|
def _zoom_out(self) -> None:
|
|
|
|
|
"""缩小"""
|
|
|
|
|
self._zoom_slider.setValue(max(25, self._zoom_level - 25))
|
|
|
|
|
|
|
|
|
|
def _on_zoom_changed(self, value: int) -> None:
|
|
|
|
|
"""缩放变化"""
|
|
|
|
|
self._zoom_level = value
|
|
|
|
|
self._zoom_label.setText(f"{value}%")
|
|
|
|
|
self._update_display()
|
|
|
|
|
|
|
|
|
|
def _rotate_left(self) -> None:
|
|
|
|
|
"""向左旋转"""
|
|
|
|
|
self._rotation = (self._rotation - 90) % 360
|
|
|
|
|
self._update_display()
|
|
|
|
|
|
|
|
|
|
def _rotate_right(self) -> None:
|
|
|
|
|
"""向右旋转"""
|
|
|
|
|
self._rotation = (self._rotation + 90) % 360
|
|
|
|
|
self._update_display()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ImageThumbnail(QFrame):
|
|
|
|
|
"""
|
|
|
|
|
图片缩略图组件
|
|
|
|
|
|
|
|
|
|
实现图片缩略图预览 (需求 5.2)
|
|
|
|
|
WHEN 图片发送前 THEN P2P_Client SHALL 显示图片缩略图预览
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
clicked = pyqtSignal(str) # image_path
|
|
|
|
|
|
|
|
|
|
def __init__(self, image_path: str, thumbnail_size: tuple = (150, 150), parent=None):
|
|
|
|
|
super().__init__(parent)
|
|
|
|
|
self.image_path = image_path
|
|
|
|
|
self.thumbnail_size = thumbnail_size
|
|
|
|
|
self._setup_ui()
|
|
|
|
|
|
|
|
|
|
def _setup_ui(self) -> None:
|
|
|
|
|
"""设置UI"""
|
|
|
|
|
self.setFrameShape(QFrame.Shape.StyledPanel)
|
|
|
|
|
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
|
|
|
self.setStyleSheet("""
|
|
|
|
|
QFrame {
|
|
|
|
|
background-color: white;
|
|
|
|
|
border: 1px solid #ddd;
|
|
|
|
|
border-radius: 5px;
|
|
|
|
|
}
|
|
|
|
|
QFrame:hover {
|
|
|
|
|
border-color: #4a90d9;
|
|
|
|
|
}
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
layout = QVBoxLayout(self)
|
|
|
|
|
layout.setContentsMargins(5, 5, 5, 5)
|
|
|
|
|
layout.setSpacing(5)
|
|
|
|
|
|
|
|
|
|
# 缩略图
|
|
|
|
|
self._image_label = QLabel()
|
|
|
|
|
self._image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
|
self._image_label.setFixedSize(*self.thumbnail_size)
|
|
|
|
|
|
|
|
|
|
if os.path.exists(self.image_path):
|
|
|
|
|
pixmap = QPixmap(self.image_path)
|
|
|
|
|
if not pixmap.isNull():
|
|
|
|
|
scaled = pixmap.scaled(
|
|
|
|
|
*self.thumbnail_size,
|
|
|
|
|
Qt.AspectRatioMode.KeepAspectRatio,
|
|
|
|
|
Qt.TransformationMode.SmoothTransformation
|
|
|
|
|
)
|
|
|
|
|
self._image_label.setPixmap(scaled)
|
|
|
|
|
else:
|
|
|
|
|
self._image_label.setText("无法加载")
|
|
|
|
|
else:
|
|
|
|
|
self._image_label.setText("图片不存在")
|
|
|
|
|
|
|
|
|
|
layout.addWidget(self._image_label)
|
|
|
|
|
|
|
|
|
|
# 文件名
|
|
|
|
|
file_name = os.path.basename(self.image_path)
|
|
|
|
|
if len(file_name) > 20:
|
|
|
|
|
file_name = file_name[:17] + "..."
|
|
|
|
|
name_label = QLabel(file_name)
|
|
|
|
|
name_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
|
name_label.setStyleSheet("font-size: 11px; color: #666;")
|
|
|
|
|
layout.addWidget(name_label)
|
|
|
|
|
|
|
|
|
|
def mousePressEvent(self, event) -> None:
|
|
|
|
|
"""鼠标点击事件"""
|
|
|
|
|
if event.button() == Qt.MouseButton.LeftButton:
|
|
|
|
|
self.clicked.emit(self.image_path)
|
|
|
|
|
super().mousePressEvent(event)
|