724 lines
29 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
圖片上傳元件
============
支援文件選擇剪貼板貼上拖拽上傳等多種方式的圖片上傳元件
"""
import os
import uuid
import time
from typing import Dict, List
from pathlib import Path
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QScrollArea, QGridLayout, QFileDialog, QMessageBox, QApplication,
QComboBox, QCheckBox, QGroupBox, QFrame
)
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QFont, QDragEnterEvent, QDropEvent
from PySide6.QtWidgets import QSizePolicy
# 導入多語系支援
from ...i18n import t
from ...debug import gui_debug_log as debug_log
from .image_preview import ImagePreviewWidget
class ImageUploadWidget(QWidget):
"""圖片上傳元件"""
images_changed = Signal()
def __init__(self, parent=None, config_manager=None):
super().__init__(parent)
self.images: Dict[str, Dict[str, str]] = {}
self.config_manager = config_manager
self._setup_ui()
self.setAcceptDrops(True)
# 啟動時清理舊的臨時文件
self._cleanup_old_temp_files()
def _setup_ui(self) -> None:
"""設置用戶介面"""
layout = QVBoxLayout(self)
layout.setSpacing(6)
layout.setContentsMargins(0, 8, 0, 8) # 調整邊距使其與其他區域一致
# 標題
self.title = QLabel(t('images.title'))
self.title.setFont(QFont("", 10, QFont.Bold))
self.title.setStyleSheet("color: #007acc; margin: 1px 0;")
layout.addWidget(self.title)
# 圖片設定區域
self._create_settings_area(layout)
# 狀態標籤
self.status_label = QLabel(t('images.status', count=0))
self.status_label.setStyleSheet("color: #9e9e9e; font-size: 10px; margin: 5px 0;")
layout.addWidget(self.status_label)
# 統一的圖片區域(整合按鈕、拖拽、預覽)
self._create_unified_image_area(layout)
def _create_settings_area(self, layout: QVBoxLayout) -> None:
"""創建圖片設定區域"""
if not self.config_manager:
return # 如果沒有 config_manager跳過設定區域
# 設定群組框
settings_group = QGroupBox(t('images.settings.title'))
settings_group.setStyleSheet("""
QGroupBox {
font-weight: bold;
font-size: 9px;
color: #9e9e9e;
border: 1px solid #464647;
border-radius: 4px;
margin-top: 6px;
padding-top: 4px;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 8px;
padding: 0 4px 0 4px;
}
""")
settings_layout = QHBoxLayout(settings_group)
settings_layout.setSpacing(12)
settings_layout.setContentsMargins(8, 8, 8, 8)
# 圖片大小限制設定
size_label = QLabel(t('images.settings.sizeLimit') + ":")
size_label.setStyleSheet("color: #cccccc; font-size: 9px;")
self.size_limit_combo = QComboBox()
self.size_limit_combo.addItem(t('images.settings.sizeLimitOptions.unlimited'), 0)
self.size_limit_combo.addItem(t('images.settings.sizeLimitOptions.1mb'), 1024*1024)
self.size_limit_combo.addItem(t('images.settings.sizeLimitOptions.3mb'), 3*1024*1024)
self.size_limit_combo.addItem(t('images.settings.sizeLimitOptions.5mb'), 5*1024*1024)
# 載入當前設定
current_limit = self.config_manager.get_image_size_limit()
for i in range(self.size_limit_combo.count()):
if self.size_limit_combo.itemData(i) == current_limit:
self.size_limit_combo.setCurrentIndex(i)
break
self.size_limit_combo.currentIndexChanged.connect(self._on_size_limit_changed)
# Base64 詳細模式設定
self.base64_checkbox = QCheckBox(t('images.settings.base64Detail'))
self.base64_checkbox.setChecked(self.config_manager.get_enable_base64_detail())
self.base64_checkbox.stateChanged.connect(self._on_base64_detail_changed)
self.base64_checkbox.setToolTip(t('images.settings.base64DetailHelp'))
# Base64 警告標籤
base64_warning = QLabel(t('images.settings.base64Warning'))
base64_warning.setStyleSheet("color: #ff9800; font-size: 8px;")
# 添加到佈局
settings_layout.addWidget(size_label)
settings_layout.addWidget(self.size_limit_combo)
settings_layout.addWidget(self.base64_checkbox)
settings_layout.addWidget(base64_warning)
settings_layout.addStretch()
layout.addWidget(settings_group)
def _on_size_limit_changed(self, index: int) -> None:
"""圖片大小限制變更處理"""
if self.config_manager and index >= 0:
size_bytes = self.size_limit_combo.itemData(index)
# 處理 None 值
if size_bytes is not None:
self.config_manager.set_image_size_limit(size_bytes)
debug_log(f"圖片大小限制已更新: {size_bytes} bytes")
def _on_base64_detail_changed(self, state: int) -> None:
"""Base64 詳細模式變更處理"""
if self.config_manager:
enabled = state == Qt.Checked
self.config_manager.set_enable_base64_detail(enabled)
debug_log(f"Base64 詳細模式已更新: {enabled}")
def _create_unified_image_area(self, layout: QVBoxLayout) -> None:
"""創建統一的圖片區域"""
# 創建滾動區域
self.preview_scroll = QScrollArea()
self.preview_widget = QWidget()
self.preview_widget.setMinimumHeight(140) # 設置預覽部件的最小高度
# 設置尺寸策略,允許垂直擴展
self.preview_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
self.preview_layout = QVBoxLayout(self.preview_widget)
self.preview_layout.setSpacing(6)
self.preview_layout.setContentsMargins(8, 8, 8, 8)
# 創建操作按鈕區域
self._create_buttons_in_area()
# 創建拖拽提示標籤(初始顯示)
self.drop_hint_label = QLabel(t('images.dragHint'))
self.drop_hint_label.setAlignment(Qt.AlignCenter)
self.drop_hint_label.setMinimumHeight(80) # 增加最小高度
self.drop_hint_label.setMaximumHeight(120) # 設置最大高度
self.drop_hint_label.setStyleSheet("""
QLabel {
border: 2px dashed #464647;
border-radius: 6px;
background-color: #2d2d30;
color: #9e9e9e;
font-size: 11px;
margin: 4px 0;
padding: 16px 8px;
}
""")
# 創建圖片網格容器
self.images_grid_widget = QWidget()
self.images_grid_layout = QGridLayout(self.images_grid_widget)
self.images_grid_layout.setSpacing(4)
self.images_grid_layout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
# 將部分添加到主布局
self.preview_layout.addWidget(self.button_widget) # 按鈕始終顯示
self.preview_layout.addWidget(self.drop_hint_label)
self.preview_layout.addWidget(self.images_grid_widget)
# 初始時隱藏圖片網格
self.images_grid_widget.hide()
# 設置滾動區域
self.preview_scroll.setWidget(self.preview_widget)
self.preview_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) # 改回按需顯示滾動條
self.preview_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.preview_scroll.setMinimumHeight(160) # 增加最小高度,確保有足夠空間
self.preview_scroll.setMaximumHeight(300) # 增加最大高度
self.preview_scroll.setWidgetResizable(True)
# 增強的滾動區域樣式,改善 macOS 兼容性
self.preview_scroll.setStyleSheet("""
QScrollArea {
border: 1px solid #464647;
border-radius: 4px;
background-color: #1e1e1e;
}
QScrollArea QScrollBar:vertical {
background-color: #2a2a2a;
width: 14px;
border-radius: 7px;
margin: 0;
}
QScrollArea QScrollBar::handle:vertical {
background-color: #555;
border-radius: 7px;
min-height: 30px;
margin: 2px;
}
QScrollArea QScrollBar::handle:vertical:hover {
background-color: #777;
}
QScrollArea QScrollBar::add-line:vertical,
QScrollArea QScrollBar::sub-line:vertical {
border: none;
background: none;
height: 0px;
}
QScrollArea QScrollBar:horizontal {
background-color: #2a2a2a;
height: 14px;
border-radius: 7px;
margin: 0;
}
QScrollArea QScrollBar::handle:horizontal {
background-color: #555;
border-radius: 7px;
min-width: 30px;
margin: 2px;
}
QScrollArea QScrollBar::handle:horizontal:hover {
background-color: #777;
}
QScrollArea QScrollBar::add-line:horizontal,
QScrollArea QScrollBar::sub-line:horizontal {
border: none;
background: none;
width: 0px;
}
""")
layout.addWidget(self.preview_scroll)
def _create_buttons_in_area(self) -> None:
"""在統一區域內創建操作按鈕"""
self.button_widget = QWidget()
button_layout = QHBoxLayout(self.button_widget)
button_layout.setContentsMargins(0, 0, 0, 4)
button_layout.setSpacing(6)
# 選擇文件按鈕
self.file_button = QPushButton(t('buttons.selectFiles'))
self.file_button.clicked.connect(self.select_files)
# 剪貼板按鈕
self.paste_button = QPushButton(t('buttons.pasteClipboard'))
self.paste_button.clicked.connect(self.paste_from_clipboard)
# 清除按鈕
self.clear_button = QPushButton(t('buttons.clearAll'))
self.clear_button.clicked.connect(self.clear_all_images)
# 設置按鈕樣式(更緊湊)
button_style = """
QPushButton {
color: white;
border: none;
padding: 4px 8px;
border-radius: 3px;
font-weight: bold;
font-size: 10px;
min-height: 24px;
}
QPushButton:hover {
opacity: 0.8;
}
"""
self.file_button.setStyleSheet(button_style + """
QPushButton {
background-color: #0e639c;
}
QPushButton:hover {
background-color: #005a9e;
}
""")
self.paste_button.setStyleSheet(button_style + """
QPushButton {
background-color: #4caf50;
}
QPushButton:hover {
background-color: #45a049;
}
""")
self.clear_button.setStyleSheet(button_style + """
QPushButton {
background-color: #f44336;
color: #ffffff;
}
QPushButton:hover {
background-color: #d32f2f;
color: #ffffff;
}
""")
button_layout.addWidget(self.file_button)
button_layout.addWidget(self.paste_button)
button_layout.addWidget(self.clear_button)
button_layout.addStretch() # 左對齊按鈕
def select_files(self) -> None:
"""選擇文件對話框"""
files, _ = QFileDialog.getOpenFileNames(
self,
t('images.select'),
"",
"Image files (*.png *.jpg *.jpeg *.gif *.bmp *.webp);;All files (*)"
)
if files:
self._add_images(files)
def paste_from_clipboard(self) -> None:
"""從剪貼板粘貼圖片"""
clipboard = QApplication.clipboard()
mimeData = clipboard.mimeData()
if mimeData.hasImage():
image = clipboard.image()
if not image.isNull():
# 創建一個唯一的臨時文件名
temp_dir = Path.home() / ".cache" / "mcp-feedback-enhanced"
temp_dir.mkdir(parents=True, exist_ok=True)
timestamp = int(time.time() * 1000)
temp_file = temp_dir / f"clipboard_{timestamp}_{uuid.uuid4().hex[:8]}.png"
# 保存剪貼板圖片
if image.save(str(temp_file), "PNG"):
if os.path.getsize(temp_file) > 0:
self._add_images([str(temp_file)])
debug_log(f"從剪貼板成功粘貼圖片: {temp_file}")
else:
QMessageBox.warning(self, t('errors.warning'), t('errors.imageSaveEmpty', path=str(temp_file)))
else:
QMessageBox.warning(self, t('errors.warning'), t('errors.imageSaveFailed'))
else:
QMessageBox.warning(self, t('errors.warning'), t('errors.clipboardSaveFailed'))
elif mimeData.hasText():
# 檢查是否為圖片數據
text = mimeData.text()
if text.startswith('data:image/') or any(ext in text.lower() for ext in ['.png', '.jpg', '.jpeg', '.gif']):
QMessageBox.information(self, t('errors.info'), t('errors.noValidImage'))
else:
QMessageBox.information(self, t('errors.info'), t('errors.noImageContent'))
def clear_all_images(self) -> None:
"""清除所有圖片"""
if self.images:
reply = QMessageBox.question(
self, t('errors.confirmClearTitle'),
t('errors.confirmClearAll', count=len(self.images)),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.Yes:
# 清理臨時文件
temp_files_cleaned = 0
for image_info in self.images.values():
file_path = image_info["path"]
if "clipboard_" in os.path.basename(file_path) and ".cache" in file_path:
try:
if os.path.exists(file_path):
os.remove(file_path)
temp_files_cleaned += 1
debug_log(f"已刪除臨時文件: {file_path}")
except Exception as e:
debug_log(f"刪除臨時文件失敗: {e}")
# 清除內存中的圖片數據
self.images.clear()
self._refresh_preview()
self._update_status()
self.images_changed.emit()
debug_log(f"已清除所有圖片,包括 {temp_files_cleaned} 個臨時文件")
def _add_images(self, file_paths: List[str]) -> None:
"""添加圖片"""
added_count = 0
for file_path in file_paths:
try:
debug_log(f"嘗試添加圖片: {file_path}")
if not os.path.exists(file_path):
debug_log(f"文件不存在: {file_path}")
continue
if not self._is_image_file(file_path):
debug_log(f"不是圖片文件: {file_path}")
continue
file_size = os.path.getsize(file_path)
debug_log(f"文件大小: {file_size} bytes")
# 動態圖片大小限制檢查
size_limit = self.config_manager.get_image_size_limit() if self.config_manager else 1024*1024
if size_limit > 0 and file_size > size_limit:
# 格式化限制大小顯示
if size_limit >= 1024*1024:
limit_str = f"{size_limit/(1024*1024):.0f}MB"
else:
limit_str = f"{size_limit/1024:.0f}KB"
# 格式化文件大小顯示
if file_size >= 1024*1024:
size_str = f"{file_size/(1024*1024):.1f}MB"
else:
size_str = f"{file_size/1024:.1f}KB"
QMessageBox.warning(
self, t('errors.warning'),
t('images.sizeLimitExceeded', filename=os.path.basename(file_path), size=size_str, limit=limit_str) +
"\n\n" + t('images.sizeLimitExceededAdvice')
)
continue
if file_size == 0:
QMessageBox.warning(self, t('errors.warning'), t('errors.emptyFile', filename=os.path.basename(file_path)))
continue
# 讀取圖片原始二進制數據
with open(file_path, 'rb') as f:
raw_data = f.read()
debug_log(f"讀取原始數據大小: {len(raw_data)} bytes")
if len(raw_data) == 0:
debug_log(f"讀取的數據為空!")
continue
# 再次檢查內存中的數據大小(使用配置的限制)
size_limit = self.config_manager.get_image_size_limit() if self.config_manager else 1024*1024
if size_limit > 0 and len(raw_data) > size_limit:
# 格式化限制大小顯示
if size_limit >= 1024*1024:
limit_str = f"{size_limit/(1024*1024):.0f}MB"
else:
limit_str = f"{size_limit/1024:.0f}KB"
# 格式化文件大小顯示
if len(raw_data) >= 1024*1024:
size_str = f"{len(raw_data)/(1024*1024):.1f}MB"
else:
size_str = f"{len(raw_data)/1024:.1f}KB"
QMessageBox.warning(
self, t('errors.warning'),
t('images.sizeLimitExceeded', filename=os.path.basename(file_path), size=size_str, limit=limit_str) +
"\n\n" + t('images.sizeLimitExceededAdvice')
)
continue
image_id = str(uuid.uuid4())
self.images[image_id] = {
"path": file_path,
"data": raw_data, # 直接保存原始二進制數據
"name": os.path.basename(file_path),
"size": file_size
}
added_count += 1
debug_log(f"圖片添加成功: {os.path.basename(file_path)}")
except Exception as e:
debug_log(f"添加圖片失敗: {e}")
QMessageBox.warning(self, t('errors.title'), t('errors.loadImageFailed', filename=os.path.basename(file_path), error=str(e)))
if added_count > 0:
debug_log(f"共添加 {added_count} 張圖片,當前總數: {len(self.images)}")
self._refresh_preview()
self._update_status()
self.images_changed.emit()
def _is_image_file(self, file_path: str) -> bool:
"""檢查是否為支援的圖片格式"""
extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'}
return Path(file_path).suffix.lower() in extensions
def _refresh_preview(self) -> None:
"""刷新預覽布局"""
# 清除現有預覽
while self.images_grid_layout.count():
child = self.images_grid_layout.takeAt(0)
if child.widget():
child.widget().deleteLater()
# 根據圖片數量決定顯示內容
if len(self.images) == 0:
# 沒有圖片時,顯示拖拽提示
self.drop_hint_label.show()
self.images_grid_widget.hide()
else:
# 有圖片時,隱藏拖拽提示,顯示圖片網格
self.drop_hint_label.hide()
self.images_grid_widget.show()
# 重新添加圖片預覽
for i, (image_id, image_info) in enumerate(self.images.items()):
preview = ImagePreviewWidget(image_info["path"], image_id, self)
preview.remove_clicked.connect(self._remove_image)
row = i // 5
col = i % 5
self.images_grid_layout.addWidget(preview, row, col)
# 強制更新佈局和滾動區域
self.preview_widget.updateGeometry()
self.preview_scroll.updateGeometry()
# 確保滾動區域能正確計算內容大小
from PySide6.QtWidgets import QApplication
QApplication.processEvents()
def _remove_image(self, image_id: str) -> None:
"""移除圖片"""
if image_id in self.images:
image_info = self.images[image_id]
# 如果是臨時文件(剪貼板圖片),則物理刪除文件
file_path = image_info["path"]
if "clipboard_" in os.path.basename(file_path) and ".cache" in file_path:
try:
if os.path.exists(file_path):
os.remove(file_path)
debug_log(f"已刪除臨時文件: {file_path}")
except Exception as e:
debug_log(f"刪除臨時文件失敗: {e}")
# 從內存中移除圖片數據
del self.images[image_id]
self._refresh_preview()
self._update_status()
self.images_changed.emit()
debug_log(f"已移除圖片: {image_info['name']}")
def _update_status(self) -> None:
"""更新狀態標籤"""
count = len(self.images)
if count == 0:
self.status_label.setText(t('images.status', count=0))
else:
total_size = sum(img["size"] for img in self.images.values())
# 格式化文件大小
if total_size > 1024 * 1024: # MB
size_mb = total_size / (1024 * 1024)
size_str = f"{size_mb:.1f} MB"
else: # KB
size_kb = total_size / 1024
size_str = f"{size_kb:.1f} KB"
self.status_label.setText(t('images.statusWithSize', count=count, size=size_str))
# 基本調試信息
debug_log(f"圖片狀態: {count} 張圖片,總大小: {size_str}")
def get_images_data(self) -> List[dict]:
"""獲取所有圖片的數據列表"""
images_data = []
for image_info in self.images.values():
images_data.append(image_info)
return images_data
def add_image_data(self, image_data: dict) -> None:
"""添加圖片數據(用於恢復界面時的圖片)"""
try:
# 檢查必要的字段
if not all(key in image_data for key in ['filename', 'data', 'size']):
debug_log("圖片數據格式不正確,缺少必要字段")
return
# 生成新的圖片ID
image_id = str(uuid.uuid4())
# 復制圖片數據
self.images[image_id] = image_data.copy()
# 刷新預覽
self._refresh_preview()
self._update_status()
self.images_changed.emit()
debug_log(f"成功恢復圖片: {image_data['filename']}")
except Exception as e:
debug_log(f"添加圖片數據失敗: {e}")
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
"""拖拽進入事件"""
if event.mimeData().hasUrls():
for url in event.mimeData().urls():
if url.isLocalFile() and self._is_image_file(url.toLocalFile()):
event.acceptProposedAction()
self.drop_hint_label.setStyleSheet("""
QLabel {
border: 2px dashed #007acc;
border-radius: 6px;
background-color: #383838;
color: #007acc;
font-size: 11px;
}
""")
return
event.ignore()
def dragLeaveEvent(self, event) -> None:
"""拖拽離開事件"""
self.drop_hint_label.setStyleSheet("""
QLabel {
border: 2px dashed #464647;
border-radius: 6px;
background-color: #2d2d30;
color: #9e9e9e;
font-size: 11px;
}
""")
def dropEvent(self, event: QDropEvent) -> None:
"""拖拽放下事件"""
self.dragLeaveEvent(event)
files = []
for url in event.mimeData().urls():
if url.isLocalFile():
file_path = url.toLocalFile()
if self._is_image_file(file_path):
files.append(file_path)
if files:
self._add_images(files)
event.acceptProposedAction()
else:
QMessageBox.warning(self, t('errors.warning'), t('errors.dragInvalidFiles'))
def _cleanup_old_temp_files(self) -> None:
"""清理舊的臨時文件"""
try:
temp_dir = Path.home() / ".cache" / "interactive-feedback-mcp"
if temp_dir.exists():
cleaned_count = 0
for temp_file in temp_dir.glob("clipboard_*.png"):
try:
# 清理超過1小時的臨時文件
if temp_file.exists():
file_age = time.time() - temp_file.stat().st_mtime
if file_age > 3600: # 1小時 = 3600秒
temp_file.unlink()
cleaned_count += 1
except Exception as e:
debug_log(f"清理舊臨時文件失敗: {e}")
if cleaned_count > 0:
debug_log(f"清理了 {cleaned_count} 個舊的臨時文件")
except Exception as e:
debug_log(f"臨時文件清理過程出錯: {e}")
def update_texts(self) -> None:
"""更新界面文字(用於語言切換)"""
# 更新標題
if hasattr(self, 'title'):
self.title.setText(t('images.title'))
# 更新設定區域文字
if hasattr(self, 'size_limit_combo'):
# 保存當前選擇
current_data = self.size_limit_combo.currentData()
# 暫時斷開信號連接以避免觸發變更事件
self.size_limit_combo.blockSignals(True)
# 清除並重新添加選項
self.size_limit_combo.clear()
self.size_limit_combo.addItem(t('images.settings.sizeLimitOptions.unlimited'), 0)
self.size_limit_combo.addItem(t('images.settings.sizeLimitOptions.1mb'), 1024*1024)
self.size_limit_combo.addItem(t('images.settings.sizeLimitOptions.3mb'), 3*1024*1024)
self.size_limit_combo.addItem(t('images.settings.sizeLimitOptions.5mb'), 5*1024*1024)
# 恢復選擇
for i in range(self.size_limit_combo.count()):
if self.size_limit_combo.itemData(i) == current_data:
self.size_limit_combo.setCurrentIndex(i)
break
# 重新連接信號
self.size_limit_combo.blockSignals(False)
if hasattr(self, 'base64_checkbox'):
self.base64_checkbox.setText(t('images.settings.base64Detail'))
self.base64_checkbox.setToolTip(t('images.settings.base64DetailHelp'))
# 更新按鈕文字
if hasattr(self, 'file_button'):
self.file_button.setText(t('buttons.selectFiles'))
if hasattr(self, 'paste_button'):
self.paste_button.setText(t('buttons.pasteClipboard'))
if hasattr(self, 'clear_button'):
self.clear_button.setText(t('buttons.clearAll'))
# 更新拖拽區域文字
if hasattr(self, 'drop_hint_label'):
self.drop_hint_label.setText(t('images.dragHint'))
# 更新狀態文字
self._update_status()