commit b2060c5aecf2ed1ca90bc3dc37ff0bd9b0a0547a Author: ElmGates <396832647@qq.com> Date: Tue Dec 16 14:10:19 2025 +0800 Initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..413558a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +build/ +builder/ +dist/ +dmgcreator/ +website/ +CardCopyer.spec +.DS_Store \ No newline at end of file diff --git a/appicon.png b/appicon.png new file mode 100644 index 0000000..cd61e48 Binary files /dev/null and b/appicon.png differ diff --git a/config.json b/config.json new file mode 100644 index 0000000..74e8a99 --- /dev/null +++ b/config.json @@ -0,0 +1,82 @@ +{ + "app": { + "name": "CardCopyer-拷贝乐", + "version": "1.1.2", + "author": "SuperJia", + "description": "现代化的DIT拷卡软件" + }, + "ui": { + "theme": "darkly", + "window_size": [1400, 900], + "font_family": "Segoe UI", + "font_size": 11, + "colors": { + "primary": "#007acc", + "secondary": "#6c757d", + "success": "#28a745", + "info": "#17a2b8", + "warning": "#ffc107", + "danger": "#dc3545", + "light": "#f8f9fa", + "dark": "#343a40" + } + }, + "copy": { + "buffer_size": 8192, + "max_threads": 4, + "retry_count": 3, + "retry_delay": 1, + "verify_after_copy": true, + "create_checksum_file": true, + "auto_rename_duplicates": true + }, + "verification": { + "enable_md5": true, + "verify_chunk_size": 8192, + "parallel_verification": true, + "verification_threads": 2, + "skip_verification_for_small_files": false, + "small_file_threshold": 1024 + }, + "logging": { + "level": "INFO", + "log_to_file": true, + "log_file": "dit_copy_tool.log", + "max_log_size": 10485760, + "backup_count": 5, + "log_format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + }, + "performance": { + "update_interval": 100, + "progress_refresh_rate": 50, + "memory_optimization": true, + "cache_size": 1000 + }, + "safety": { + "confirm_before_overwrite": true, + "backup_before_copy": false, + "check_disk_space": true, + "min_free_space_gb": 1, + "exclude_system_files": true, + "exclude_patterns": [ + "*.tmp", + "*.temp", + "Thumbs.db", + ".DS_Store", + "*.log" + ] + }, + "notifications": { + "show_completion_notification": true, + "play_sound": true, + "sound_file": "completion.wav", + "celebration_animation": true, + "auto_close_celebration": 3000 + }, + "advanced": { + "enable_experimental_features": false, + "debug_mode": false, + "profile_performance": false, + "enable_telemetry": false + } +} \ No newline at end of file diff --git a/icon/32.ico b/icon/32.ico new file mode 100644 index 0000000..6b79a44 Binary files /dev/null and b/icon/32.ico differ diff --git a/icon/appicon.png b/icon/appicon.png new file mode 100644 index 0000000..cd61e48 Binary files /dev/null and b/icon/appicon.png differ diff --git a/icon/appicon512.png b/icon/appicon512.png new file mode 100644 index 0000000..42063e2 Binary files /dev/null and b/icon/appicon512.png differ diff --git a/icon/dtw1j-qypsc-001.ico b/icon/dtw1j-qypsc-001.ico new file mode 100644 index 0000000..43ff7af Binary files /dev/null and b/icon/dtw1j-qypsc-001.ico differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..93b510b --- /dev/null +++ b/main.py @@ -0,0 +1,2500 @@ +#!/usr/bin/env python3 + +import sys +import os +import threading +import time +from datetime import datetime +from pathlib import Path +from typing import List, Dict, Optional + +# 全局图标管理器 +_global_icon_image = None # 存储PIL Image对象 +_icon_preloaded = False + +def preload_icon(): + """预加载图标,确保在窗口创建前就可用了""" + global _icon_preloaded, _global_icon_image + + if not _icon_preloaded: + try: + # 确保PIL模块可用 + if 'PIL_Image' not in globals() or 'PIL_ImageTk' not in globals(): + from PIL import Image as PIL_Image + from PIL import ImageTk as PIL_ImageTk + # 将其注入全局命名空间以便其他函数使用 + globals()['PIL_Image'] = PIL_Image + globals()['PIL_ImageTk'] = PIL_ImageTk + + icon_path = get_icon_path() + if icon_path and 'PIL_Image' in globals(): + # 只加载Image对象,不创建PhotoImage,因为PhotoImage依赖于特定的Tk实例 + _global_icon_image = PIL_Image.open(icon_path) + _icon_preloaded = True + print(f"图标图像预加载成功: {icon_path}") + return True + except Exception as e: + print(f"图标预加载失败: {e}") + _icon_preloaded = False + + return _icon_preloaded is True + +def get_global_icon_image(): + """获取全局图标Image对象""" + global _global_icon_image + + if _global_icon_image is None: + preload_icon() + + return _global_icon_image + +def get_global_icon_photo(): + """已废弃:为了兼容性保留,但返回None以强制重新创建""" + return None + +def get_icon_path(): + """获取图标文件的完整路径""" + # 检查当前目录(源码运行) + if os.path.exists('appicon.png'): + return 'appicon.png' + + # 检查可执行文件所在目录(打包后运行) + if hasattr(sys, '_MEIPASS'): + # PyInstaller 打包后的路径 + icon_path = os.path.join(sys._MEIPASS, 'appicon.png') + if os.path.exists(icon_path): + return icon_path + + # 检查应用包内的 Resources 目录 + app_path = os.path.dirname(os.path.abspath(__file__)) + resources_path = os.path.join(app_path, 'appicon.png') + if os.path.exists(resources_path): + return resources_path + + return None + +def get_log_directory(): + """获取跨平台的日志目录路径""" + system = sys.platform + + if system == "darwin": # macOS + # macOS: ~/Documents/CardCopyer/logs + log_dir = os.path.expanduser("~/Documents/CardCopyer/logs") + elif system == "win32": # Windows + # Windows: ~/Documents/CardCopyer/logs + log_dir = os.path.expanduser("~/Documents/CardCopyer/logs") + else: # Linux 和其他系统 + # Linux: ~/.local/share/CardCopyer/logs + log_dir = os.path.expanduser("~/.local/share/CardCopyer/logs") + + # 确保目录存在 + try: + os.makedirs(log_dir, exist_ok=True) + except Exception as e: + # 如果无法创建目录,回退到应用目录 + log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs") + os.makedirs(log_dir, exist_ok=True) + + return log_dir + +# 延迟导入 - 提高启动速度 +def import_heavy_modules(): + """延迟导入重量级的模块""" + global tk, ttk, tb, filedialog, messagebox, shutil, hashlib, psutil, json, subprocess, PIL_Image, PIL_ImageTk + + import tkinter as tk + from tkinter import ttk, filedialog, messagebox + from tkinter.constants import BOTH, YES, NO, X, Y, LEFT, RIGHT, TOP, BOTTOM, W, E, N, S, CENTER, HORIZONTAL, VERTICAL + import shutil + import hashlib + import json + import subprocess + + # 这些模块较重,延迟导入 + try: + import ttkbootstrap as tb + from ttkbootstrap.constants import PRIMARY, SUCCESS, INFO, WARNING, DANGER + except ImportError: + tb = None + + try: + import psutil + except ImportError: + psutil = None + + try: + from PIL import Image as PIL_Image, ImageTk as PIL_ImageTk + except ImportError: + PIL_Image = None + PIL_ImageTk = None + +# 快速依赖检查(只检查关键模块) +def quick_check_dependencies(): + """快速依赖检查,只检查最基本的模块""" + required_modules = ["tkinter"] + missing_modules = [] + + for module_name in required_modules: + try: + __import__(module_name) + except ImportError: + missing_modules.append(module_name) + + if missing_modules: + if getattr(sys, 'frozen', False): + error_msg = f"缺少必要的依赖模块: {', '.join(missing_modules)}\n" + error_msg += "请重新打包程序或联系开发者。" + print(error_msg) + return False + else: + print(f"缺少依赖模块: {', '.join(missing_modules)}") + return False + + return True + +# 完整依赖检查(在后台线程中执行) +def full_check_dependencies(): + """完整的依赖检查""" + required_modules = ["ttkbootstrap", "psutil", "PIL"] + missing_modules = [] + + for module_name in required_modules: + try: + __import__(module_name) + except ImportError: + missing_modules.append(module_name) + + if missing_modules: + if getattr(sys, 'frozen', False): + return False, f"缺少必要的依赖模块: {', '.join(missing_modules)}" + else: + return False, f"缺少依赖模块: {', '.join(missing_modules)}\n请在命令行中运行: pip install ttkbootstrap psutil Pillow" + + return True, None + +class CopyManager: + """拷贝管理器 - 优化性能和资源管理""" + def __init__(self): + self.copying = False + self.verifying = False + self.total_files = 0 + self.copied_files = 0 + self.verified_files = 0 + self.errors = [] + + # 日志文件相关 + self.log_file = None + self.log_buffer = [] # 日志缓冲区 + + # 拷贝进度相关 + self.total_size = 0 # 总大小(字节) + self.copied_size = 0 # 已拷贝大小(字节) + self.copy_start_time = 0 # 拷贝开始时间 + self.copy_speed = 0 # 拷贝速度(字节/秒) + self.copy_eta = 0 # 预计剩余时间(秒) + + # 验证进度相关 + self.verified_size = 0 # 已验证大小(字节) + self.verify_start_time = 0 # 验证开始时间 + self.verify_speed = 0 # 验证速度(字节/秒) + self.verify_eta = 0 # 预计剩余时间(秒) + + # 日期文件夹名称(用于保持拷贝和验证使用相同的时间戳) + self.date_folder = None + + # MD5验证统计 + self.total_md5_files = 0 # 需要验证MD5的文件总数 + self.md5_verified_files = 0 # 已完成MD5验证的文件数 + self.md5_calc_size = 0 # 已计算MD5的数据量(字节) + self.md5_calc_speed = 0 # MD5计算速度(字节/秒) + self.md5_start_time = 0 # MD5验证开始时间 + + # 性能优化:缓存文件大小计算结果 + self._size_cache = {} + self._size_cache_timeout = 5 # 缓存超时时间(秒) + + def get_folder_size(self, folder_path: str) -> int: + """计算文件夹总大小(字节)- 使用缓存优化""" + # 检查缓存 + current_time = time.time() + cache_key = folder_path + if cache_key in self._size_cache: + cached_size, cache_time = self._size_cache[cache_key] + if current_time - cache_time < self._size_cache_timeout: + return cached_size + + total_size = 0 + try: + # 使用更快的文件遍历方法 + for root, dirs, files in os.walk(folder_path, followlinks=False): + # 限制遍历深度,避免深层嵌套目录 + if root.count(os.sep) - folder_path.count(os.sep) > 10: + dirs[:] = [] # 不继续深入 + continue + + for file in files: + file_path = os.path.join(root, file) + try: + stat = os.lstat(file_path) # 使用lstat避免符号链接 + total_size += stat.st_size + except (OSError, PermissionError, FileNotFoundError): + # 跳过无法访问的文件 + continue + except (OSError, PermissionError): + # 跳过无法访问的文件夹 + pass + + # 缓存结果 + self._size_cache[cache_key] = (total_size, current_time) + return total_size + + def format_size(self, size_bytes: int) -> str: + """格式化文件大小显示""" + if size_bytes == 0: + return "0 B" + + size_names = ["B", "KB", "MB", "GB", "TB"] + i = 0 + size = float(size_bytes) + + while size >= 1024 and i < len(size_names) - 1: + size /= 1024 + i += 1 + + return f"{size:.1f} {size_names[i]}" + + def format_time(self, seconds) -> str: + """格式化时间显示""" + if seconds < 0: + seconds = 0 + + seconds = int(seconds) # 转换为整数 + + if seconds < 60: + return f"{seconds}秒" + elif seconds < 3600: + minutes = seconds // 60 + secs = seconds % 60 + return f"{minutes}分{secs}秒" + else: + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + secs = seconds % 60 + return f"{hours}小时{minutes}分{secs}秒" + + def init_log_file(self, log_dir: str, session_name: str): + """初始化日志文件""" + try: + # 确保日志目录存在 + os.makedirs(log_dir, exist_ok=True) + + # 生成日志文件名(使用时间戳和会话名称) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + log_filename = f"copy_log_{timestamp}_{session_name}.log" + log_path = os.path.join(log_dir, log_filename) + + self.log_file = open(log_path, 'w', encoding='utf-8') + + # 写入日志头 + self.log_file.write("="*80 + "\n") + self.log_file.write(f"CardCopyer-拷贝乐 - 拷贝日志\n") + self.log_file.write(f"开始时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") + self.log_file.write(f"会话名称: {session_name}\n") + self.log_file.write("="*80 + "\n\n") + self.log_file.flush() + + return log_path + except Exception as e: + print(f"初始化日志文件失败: {e}") + return None + + def write_log(self, message: str): + """写入日志到文件""" + if self.log_file: + try: + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + self.log_file.write(f"[{timestamp}] {message}\n") + self.log_file.flush() + except Exception as e: + print(f"写入日志失败: {e}") + + def close_log_file(self): + """关闭日志文件 - 优化清理过程""" + if self.log_file: + try: + # 确保所有缓冲的日志都被写入 + if self.log_buffer: + for message in self.log_buffer: + self.log_file.write(message + "\n") + self.log_buffer.clear() + + self.log_file.write("\n" + "="*80 + "\n") + self.log_file.write(f"结束时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") + self.log_file.write("="*80 + "\n") + self.log_file.flush() # 确保数据写入磁盘 + self.log_file.close() + self.log_file = None + except Exception as e: + print(f"关闭日志文件失败: {e}") + + # 清理缓存 + self._size_cache.clear() + +class StartupWindow: + """启动窗口 - 显示加载进度""" + def __init__(self): + # 确保tkinter已导入 + if 'tk' not in globals(): + import tkinter as tk + from tkinter import ttk + + # 确保PIL已导入 + if 'PIL_Image' not in globals() or 'PIL_ImageTk' not in globals(): + try: + from PIL import Image as PIL_Image, ImageTk as PIL_ImageTk + except ImportError: + pass + + self.root = tk.Tk() + self.root.title("CardCopyer-拷贝乐 - 启动中") + self.root.geometry("300x150") + self.root.resizable(False, False) + + # 设置窗口图标 + try: + icon_path = get_icon_path() + if icon_path and 'PIL_Image' in globals() and 'PIL_ImageTk' in globals(): + icon_image = PIL_Image.open(icon_path) + icon_photo = PIL_ImageTk.PhotoImage(icon_image) + self.root.iconphoto(True, icon_photo) + except Exception: + pass # 如果图标设置失败,继续使用默认图标 + + # 居中显示 + self.root.update_idletasks() + x = (self.root.winfo_screenwidth() - 300) // 2 + y = (self.root.winfo_screenheight() - 150) // 2 + self.root.geometry(f"+{x}+{y}") + + # 设置样式 + self.root.configure(bg='#2b3e50') + + # 标题 + title_label = tk.Label( + self.root, + text="CardCopyer-拷贝乐", + font=("Arial", 16, "bold"), + bg='#2b3e50', + fg='white' + ) + title_label.pack(pady=20) + + # 进度标签 + self.progress_label = tk.Label( + self.root, + text="正在初始化...", + font=("Arial", 10), + bg='#2b3e50', + fg='white' + ) + self.progress_label.pack() + + # 进度条 + self.progress = ttk.Progressbar( + self.root, + mode='indeterminate', + length=250 + ) + self.progress.pack(pady=10) + self.progress.start() + + def update_progress(self, message): + """更新进度信息""" + self.progress_label.config(text=message) + self.root.update() + + def close(self): + """关闭启动窗口""" + self.progress.stop() + self.root.destroy() + + +class DITCopyTool: + """CardCopyer主窗口""" + + def __init__(self): + # 导入重量级模块 + import_heavy_modules() + + # 检查ttkbootstrap是否可用 + if tb is None: + self.show_error_and_exit("ttkbootstrap模块不可用", "请安装ttkbootstrap: pip install ttkbootstrap") + return + + # 预加载图标,确保在窗口创建前准备好 + preload_icon() + self.icon_photo = None + + # 创建主窗口但先隐藏,避免显示默认图标 + self.window = tb.Window( + title="CardCopyer-拷贝乐", + themename="darkly", + size=(1400, 900), + resizable=(True, True) + ) + + # 立即隐藏窗口,防止显示默认图标 + self.window.withdraw() + + # 尝试立即设置图标(窗口隐藏状态下) + try: + icon_image = get_global_icon_image() + if icon_image and 'PIL_ImageTk' in globals(): + self.icon_photo = PIL_ImageTk.PhotoImage(icon_image, master=self.window) + self.window.wm_iconphoto(True, self.icon_photo) + if hasattr(self.window, 'iconphoto'): + self.window.iconphoto(True, self.icon_photo) + print("主窗口图标在隐藏状态下设置成功") + except Exception as e: + print(f"隐藏状态下设置主窗口图标失败: {e}") + + # 设置窗口关闭事件处理 + self.window.protocol("WM_DELETE_WINDOW", self.on_closing) + + self.copy_manager = CopyManager() + self.source_items = [] # 源项目列表 + self.destination_path = "" + self.copy_thread = None + + # 延迟UI初始化(窗口仍在隐藏状态) + self.window.after(100, self._show_main_window_with_icon) + + # 启动图标监控定时器 + if self.icon_photo: + self._start_icon_monitor() + + def _show_main_window_with_icon(self): + """显示主窗口并确保图标正确设置""" + try: + # 先设置UI + self.setup_ui() + + # 尝试设置图标 + self._try_set_icon() + + # 显示窗口 + self.window.deiconify() + print("主窗口已显示") + + except Exception as e: + print(f"显示主窗口时发生异常: {e}") + # 确保窗口显示,但不重复设置UI + self.window.deiconify() + + def _try_set_icon(self): + """尝试设置窗口图标,处理跨实例问题""" + try: + # 获取PIL Image对象而不是预先创建的PhotoImage + icon_image = get_global_icon_image() + if icon_image: + # 为当前窗口创建专用的PhotoImage + if 'PIL_ImageTk' in globals(): + self.icon_photo = PIL_ImageTk.PhotoImage(icon_image, master=self.window) + self.window.wm_iconphoto(True, self.icon_photo) + if hasattr(self.window, 'iconphoto'): + self.window.iconphoto(True, self.icon_photo) + print("图标设置成功") + except Exception as e: + print(f"设置图标失败: {e}") + + def _set_window_icon(self): + """延迟设置主窗口图标,使用全局图标确保一致性""" + try: + # 使用全局图标管理器获取Image对象 + icon_image = get_global_icon_image() + + if icon_image and self.window: + # 为当前窗口创建专用的PhotoImage + if 'PIL_ImageTk' in globals(): + self.icon_photo = PIL_ImageTk.PhotoImage(icon_image, master=self.window) + + # 使用多种方法设置图标,确保兼容性 + self.window.wm_iconphoto(True, self.icon_photo) + if hasattr(self.window, 'iconphoto'): + self.window.iconphoto(True, self.icon_photo) + + # 启动图标监控定时器 + self._start_icon_monitor() + elif not icon_image: + # 如果全局图标不可用,尝试本地创建 + icon_path = get_icon_path() + if icon_path and 'PIL_Image' in globals() and 'PIL_ImageTk' in globals(): + icon_image = PIL_Image.open(icon_path) + self.icon_photo = PIL_ImageTk.PhotoImage(icon_image, master=self.window) + if self.window and self.icon_photo: + self.window.wm_iconphoto(True, self.icon_photo) + if hasattr(self.window, 'iconphoto'): + self.window.iconphoto(True, self.icon_photo) + + # 启动图标监控定时器 + self._start_icon_monitor() + except Exception as e: + print(f"设置主窗口图标失败: {e}") + pass + + def _start_icon_monitor(self): + """启动图标监控定时器,防止图标被系统重置""" + def check_and_restore_icon(): + try: + # 检查图标是否仍然有效 + if self.window and self.icon_photo: + # 重新应用图标 + self.window.wm_iconphoto(True, self.icon_photo) + if hasattr(self.window, 'iconphoto'): + self.window.iconphoto(True, self.icon_photo) + + # 每5秒检查一次 + self.window.after(5000, check_and_restore_icon) + except Exception: + pass + + # 启动第一次检查 + self.window.after(5000, check_and_restore_icon) + + def show_error_and_exit(self, title, message): + """显示错误并退出""" + messagebox.showerror(title, message) + sys.exit(1) + + def on_closing(self): + """窗口关闭事件处理 - 优化退出性能""" + try: + # 如果有正在进行的拷贝线程,先停止它 + if self.copy_thread and self.copy_thread.is_alive(): + if messagebox.askyesno("确认", "有拷贝任务正在进行中,确定要退出吗?"): + # 设置停止标志 + self.copy_manager.copying = False + self.copy_manager.verifying = False + # 等待线程结束(最多3秒) + self.copy_thread.join(timeout=3) + else: + return + + # 清理资源 + self.cleanup_resources() + + # 销毁窗口 + self.window.quit() + self.window.destroy() + + except Exception as e: + print(f"退出时出错: {e}") + # 强制退出 + try: + self.window.quit() + self.window.destroy() + except: + pass + + def cleanup_resources(self): + """清理资源 - 优化退出性能""" + try: + # 关闭日志文件 + if hasattr(self, 'copy_manager'): + self.copy_manager.close_log_file() + + # 清理UI组件引用(帮助垃圾回收) + if hasattr(self, 'source_tree'): + self.source_tree.delete(*self.source_tree.get_children()) + + # 清理线程引用 + if hasattr(self, 'copy_thread'): + self.copy_thread = None + + # 清理大对象引用 + if hasattr(self, 'copy_manager'): + self.copy_manager = None + + except Exception as e: + print(f"清理资源时出错: {e}") + + def setup_ui(self): + """设置UI界面""" + # 主框架 + main_frame = tb.Frame(self.window, padding=20) + main_frame.pack(fill="both", expand=True) + + # 标题 + title_label = tb.Label( + main_frame, + text="CardCopyer-拷贝乐", + font=("Arial", 24, "bold"), + bootstyle="primary" + ) + title_label.pack(pady=(0, 20)) + + # 主要内容区域 + content_frame = tb.Frame(main_frame) + content_frame.pack(fill="both", expand=True) + + # 左侧 - 源选择 + self.setup_source_frame(content_frame) + + # 中间 - 目的地选择 + self.setup_destination_frame(content_frame) + + # 右侧 - 进度显示 + self.setup_progress_frame(content_frame) + + # 底部按钮区域 + self.setup_bottom_frame(main_frame) + + def setup_source_frame(self, parent): + """设置源选择框架""" + source_frame = ttk.LabelFrame( + parent, + text="源文件夹选择", + bootstyle="primary", + padding=15 + ) + source_frame.pack(side="left", fill="both", expand=True, padx=(0, 10)) + + # 文件夹选择区域 + folder_select_frame = tb.Frame(source_frame) + folder_select_frame.pack(fill="x", pady=(0, 10)) + + # 添加文件夹按钮 + add_folder_btn = tb.Button( + folder_select_frame, + text="添加文件夹", + bootstyle="success-outline", + command=self.add_source_folder + ) + add_folder_btn.pack(side="left", padx=(0, 10)) + + # 文件夹大小统计 + self.folder_size_label = tb.Label( + folder_select_frame, + text="总大小: 0 GB", + font=("Arial", 10), + bootstyle="info" + ) + self.folder_size_label.pack(side="left") + + # 源项目列表 + tb.Label(source_frame, text="已选择的源文件夹:", font=("Arial", 12, "bold")).pack(pady=(10, 5)) + + source_items_frame = tb.Frame(source_frame) + source_items_frame.pack(fill="both", expand=True) + + source_scroll = tb.Scrollbar(source_items_frame) + source_scroll.pack(side="right", fill="y") + + self.source_items_listbox = tk.Listbox( + source_items_frame, + yscrollcommand=source_scroll.set, + font=("Arial", 10), + bg="#1e1e1e", + fg="white", + selectbackground="#005a9e" + ) + self.source_items_listbox.pack(side="left", fill="both", expand=True) + source_scroll.config(command=self.source_items_listbox.yview) + + # 操作按钮区域 + button_frame = tb.Frame(source_frame) + button_frame.pack(fill="x", pady=(10, 0)) + + # 修改名称按钮 + rename_folder_btn = tb.Button( + button_frame, + text="修改名称", + bootstyle="warning-outline", + command=self.rename_selected_folder + ) + rename_folder_btn.pack(side="left", padx=(0, 10)) + + # 移除选中文件夹按钮 + remove_folder_btn = tb.Button( + button_frame, + text="移除选中", + bootstyle="danger-outline", + command=self.remove_selected_folder + ) + remove_folder_btn.pack(side="left", padx=(0, 10)) + + # 清空所有按钮 + clear_all_btn = tb.Button( + button_frame, + text="清空所有", + bootstyle="secondary-outline", + command=self.clear_all_folders + ) + clear_all_btn.pack(side="left") + + def setup_destination_frame(self, parent): + """设置目的地选择框架""" + dest_frame = ttk.LabelFrame( + parent, + text="目的地选择", + bootstyle="info", + padding=15 + ) + dest_frame.pack(side="left", fill="both", expand=True, padx=(0, 10)) + + # 目的地路径显示 + self.dest_path_label = tb.Label( + dest_frame, + text="未选择目的地", + font=("Arial", 11), + bootstyle="secondary", + wraplength=300 + ) + self.dest_path_label.pack(pady=(0, 15)) + + # 选择目的地按钮 + select_dest_btn = tb.Button( + dest_frame, + text="选择目的地文件夹", + bootstyle="info", + command=self.select_destination + ) + select_dest_btn.pack(pady=(0, 20)) + + # 自动创建文件夹(默认启用,不再显示选框) + self.auto_folder_var = tk.BooleanVar(value=True) + + # 项目名称输入区域 + project_frame = tb.Frame(dest_frame) + project_frame.pack(fill="x", pady=(0, 15)) + + tb.Label( + project_frame, + text="项目名称:", + bootstyle="info" + ).pack(side="left", padx=(0, 10)) + + self.project_name_var = tk.StringVar() + self.project_name_entry = tb.Entry( + project_frame, + textvariable=self.project_name_var, + width=20, + bootstyle="info" + ) + self.project_name_entry.pack(side="left", fill="x", expand=True) + + # 项目名称提示 + tb.Label( + dest_frame, + text="留空则只使用日期命名", + font=("Arial", 9), + bootstyle="secondary" + ).pack(pady=(0, 5)) + + # 文件夹名称预览 + self.folder_preview_label = tb.Label( + dest_frame, + text="", + font=("Arial", 9, "italic"), + bootstyle="info" + ) + self.folder_preview_label.pack(pady=(0, 15)) + + # 目的地信息 + self.dest_info_label = tb.Label( + dest_frame, + text="", + font=("Arial", 10), + bootstyle="secondary" + ) + self.dest_info_label.pack() + + # 绑定项目名称变化事件,实时更新预览 + self.project_name_var.trace_add("write", lambda *args: self.update_folder_preview()) + + def setup_progress_frame(self, parent): + """设置进度显示框架""" + progress_frame = ttk.LabelFrame( + parent, + text="拷贝进度", + bootstyle="success", + padding=15 + ) + progress_frame.pack(side="left", fill="both", expand=True) + + # 拷贝进度 + tb.Label(progress_frame, text="拷贝进度:", font=("Arial", 12, "bold")).pack(anchor="w") + + self.copy_progress = tb.Progressbar( + progress_frame, + bootstyle="success-striped", + length=300, + mode='determinate' + ) + self.copy_progress.pack(fill="x", pady=(5, 15)) + + self.copy_status_label = tb.Label( + progress_frame, + text="等待开始拷贝...", + font=("Arial", 10) + ) + self.copy_status_label.pack(anchor="w") + + # 拷贝速度和进度信息 + self.copy_speed_label = tb.Label( + progress_frame, + text="速度: 0 MB/s | 已用: 00:00 | 剩余: 00:00", + font=("Arial", 9) + ) + self.copy_speed_label.pack(anchor="w", pady=(2, 0)) + + # 验证进度 + tb.Label(progress_frame, text="验证进度:", font=("Arial", 12, "bold")).pack(anchor="w", pady=(20, 5)) + + self.verify_progress = tb.Progressbar( + progress_frame, + bootstyle="warning-striped", + length=300, + mode='determinate' + ) + self.verify_progress.pack(fill="x", pady=(5, 15)) + + self.verify_status_label = tb.Label( + progress_frame, + text="等待拷贝完成...", + font=("Arial", 10) + ) + self.verify_status_label.pack(anchor="w") + + # 验证速度信息 + self.verify_speed_label = tb.Label( + progress_frame, + text="速度: 0 MB/s | 已用: 00:00 | 剩余: 00:00", + font=("Arial", 9) + ) + self.verify_speed_label.pack(anchor="w", pady=(2, 0)) + + # 统计信息 + stats_frame = tb.Frame(progress_frame) + stats_frame.pack(fill="x", pady=(20, 0)) + + self.total_files_label = tb.Label(stats_frame, text="总文件数: 0", font=("Arial", 10)) + self.total_files_label.pack(side="left") + + self.copied_files_label = tb.Label(stats_frame, text="已拷贝: 0", font=("Arial", 10)) + self.copied_files_label.pack(side="left", padx=(20, 0)) + + self.verified_files_label = tb.Label(stats_frame, text="已验证: 0", font=("Arial", 10)) + self.verified_files_label.pack(side="left", padx=(20, 0)) + + # 日志区域 + tb.Label(progress_frame, text="操作日志:", font=("Arial", 12, "bold")).pack(anchor="w", pady=(20, 5)) + + log_frame = tb.Frame(progress_frame) + log_frame.pack(fill="both", expand=True) + + log_scroll = tb.Scrollbar(log_frame) + log_scroll.pack(side="right", fill="y") + + self.log_text = tk.Text( + log_frame, + height=10, + yscrollcommand=log_scroll.set, + font=("Courier", 9), + bg="#1e1e1e", + fg="white" + ) + self.log_text.pack(side="left", fill="both", expand=True) + log_scroll.config(command=self.log_text.yview) + + def setup_bottom_frame(self, parent): + """设置底部按钮框架""" + bottom_frame = tb.Frame(parent) + bottom_frame.pack(fill="x", pady=(20, 0)) + + # 开始按钮 + self.start_btn = tb.Button( + bottom_frame, + text="开始拷贝", + bootstyle="success", + command=self.start_copy, + width=20 + ) + self.start_btn.pack(side="left", padx=(0, 10)) + + # 停止按钮 + self.stop_btn = tb.Button( + bottom_frame, + text="停止拷贝", + bootstyle="danger", + command=self.stop_copy, + width=20, + state="disabled" + ) + self.stop_btn.pack(side="left", padx=(0, 10)) + + # 清空按钮 + clear_btn = tb.Button( + bottom_frame, + text="清空列表", + bootstyle="warning", + command=self.clear_all, + width=15 + ) + clear_btn.pack(side="left", padx=(0, 10)) + + # 查看日志按钮 + view_logs_btn = tb.Button( + bottom_frame, + text="查看日志", + bootstyle="info", + command=self.open_log_viewer, + width=15 + ) + view_logs_btn.pack(side="left", padx=(0, 10)) + + # 版权信息标签(可点击) + copyright_label = tb.Label( + bottom_frame, + text="Copyright ©️ 2025-Now SuperJia 保留所有权利,CardCopyer-拷贝乐 v1.1.2(beta) 点击前往官网", + font=("Arial", 9), + bootstyle="secondary", + cursor="hand2" # 鼠标悬停时显示手型光标 + ) + copyright_label.pack(side="left", padx=(10, 0)) + + # 绑定点击事件 + copyright_label.bind("", self.open_official_website) + + # 退出按钮 + exit_btn = tb.Button( + bottom_frame, + text="退出", + bootstyle="secondary", + command=self.window.destroy, + width=15 + ) + exit_btn.pack(side="right") + + def format_time(self, seconds): + """格式化时间显示""" + if seconds <= 0: + return "00:00" + + minutes = int(seconds // 60) + secs = int(seconds % 60) + + if minutes >= 60: + hours = minutes // 60 + minutes = minutes % 60 + return f"{hours:02d}:{minutes:02d}:{secs:02d}" + else: + return f"{minutes:02d}:{secs:02d}" + + def update_total_size(self): + """更新总大小显示""" + total_size = sum(item['size'] for item in self.source_items) + size_str = self.copy_manager.format_size(total_size) + self.folder_size_label.config(text=f"总大小: {size_str}") + + # 如果总大小超过1GB,显示更详细的信息 + if total_size > 1024**3: + gb_size = total_size / (1024**3) + self.folder_size_label.config(text=f"总大小: {size_str} ({gb_size:.2f} GB)") + + def add_source_folder(self): + """添加源文件夹""" + folder = filedialog.askdirectory(title="选择源文件夹") + if folder: + # 获取文件夹名称和大小 + folder_name = os.path.basename(folder) + try: + # 使用复制管理器的文件夹大小计算功能 + total_size = self.copy_manager.get_folder_size(folder) + size_str = self.copy_manager.format_size(total_size) + + # 弹出对话框让用户自定义命名 + custom_name = self.ask_custom_folder_name(folder_name) + if custom_name is None: # 用户取消 + return + + source_item = { + 'path': folder, + 'name': folder_name, + 'custom_name': custom_name, + 'size': total_size, + 'display': f"{folder_name} - {size_str}" + } + + # 如果自定义名称与原始名称不同,在显示中添加提示 + if custom_name != folder_name: + source_item['display'] = f"{folder_name} - {size_str} (→ {custom_name})" + + self.source_items.append(source_item) + self.source_items_listbox.insert(tk.END, source_item['display']) + self.log_message(f"添加源文件夹: {folder} ({size_str})") + if custom_name != folder_name: + self.log_message(f"自定义命名为: {custom_name}") + + # 更新总大小显示 + self.update_total_size() + + except Exception as e: + messagebox.showerror("错误", f"无法读取文件夹信息: {str(e)}") + + def ask_custom_folder_name(self, original_name): + """询问用户自定义文件夹名称""" + dialog = tk.Toplevel(self.window) + dialog.title("自定义文件夹名称") + dialog.geometry("400x200") + dialog.transient(self.window) + dialog.grab_set() + + # 居中显示 + dialog.update_idletasks() + x = (dialog.winfo_screenwidth() - dialog.winfo_width()) // 2 + y = (dialog.winfo_screenheight() - dialog.winfo_height()) // 2 + dialog.geometry(f"+{x}+{y}") + + # 提示信息 + info_frame = tb.Frame(dialog, padding=20) + info_frame.pack(fill="both", expand=True) + + tb.Label(info_frame, text="为源文件夹设置目标名称", font=("Arial", 12, "bold")).pack(pady=(0, 10)) + tb.Label(info_frame, text=f"原始名称: {original_name}", font=("Arial", 10)).pack(pady=(0, 15)) + + # 输入框 + name_var = tk.StringVar(value=original_name) + entry = tb.Entry(info_frame, textvariable=name_var, width=40, font=("Arial", 11)) + entry.pack(pady=(0, 20)) + entry.focus() + entry.select_range(0, tk.END) + + # 按钮区域 + button_frame = tb.Frame(info_frame) + button_frame.pack(fill="x") + + def on_ok(): + dialog.result = name_var.get().strip() + dialog.destroy() + + def on_cancel(): + dialog.result = None + dialog.destroy() + + tb.Button(button_frame, text="确定", bootstyle="success", command=on_ok).pack(side="right", padx=(10, 0)) + tb.Button(button_frame, text="取消", bootstyle="secondary", command=on_cancel).pack(side="right") + + # 绑定回车键 + entry.bind("", lambda e: on_ok()) + entry.bind("", lambda e: on_cancel()) + + # 等待对话框关闭 + dialog.wait_window(dialog) + return getattr(dialog, 'result', None) + + def rename_selected_folder(self): + """修改选中文件夹的自定义名称""" + selection = self.source_items_listbox.curselection() + if not selection: + messagebox.showinfo("提示", "请先选择要修改名称的文件夹") + return + + if len(selection) > 1: + messagebox.showinfo("提示", "一次只能修改一个文件夹的名称") + return + + index = selection[0] + source_item = self.source_items[index] + + # 询问新的自定义名称 + new_custom_name = self.ask_custom_folder_name(source_item['name']) + if new_custom_name is None: # 用户取消 + return + + # 更新自定义名称 + old_custom_name = source_item.get('custom_name', source_item['name']) + source_item['custom_name'] = new_custom_name + + # 更新显示 + size_str = self.copy_manager.format_size(source_item['size']) + if new_custom_name != source_item['name']: + source_item['display'] = f"{source_item['name']} - {size_str} (→ {new_custom_name})" + else: + source_item['display'] = f"{source_item['name']} - {size_str}" + + # 更新列表框 + self.source_items_listbox.delete(index) + self.source_items_listbox.insert(index, source_item['display']) + self.source_items_listbox.selection_set(index) + + # 记录日志 + if old_custom_name != new_custom_name: + self.log_message(f"修改文件夹名称: {source_item['path']}") + self.log_message(f" 从 '{old_custom_name}' 改为 '{new_custom_name}'") + + def remove_selected_folder(self): + """移除选中的文件夹""" + selection = self.source_items_listbox.curselection() + if selection: + # 从后往前删除,避免索引问题 + for index in reversed(selection): + removed_item = self.source_items.pop(index) + self.source_items_listbox.delete(index) + self.log_message(f"移除源文件夹: {removed_item['path']}") + + # 更新总大小显示 + self.update_total_size() + else: + messagebox.showinfo("提示", "请先选择要移除的文件夹") + + def clear_all_folders(self): + """清空所有文件夹""" + if not self.source_items: + messagebox.showinfo("提示", "当前没有选择任何文件夹") + return + + # 确认对话框 + result = messagebox.askyesno( + "确认清空", + f"确定要清空所有 {len(self.source_items)} 个文件夹吗?\n\n此操作不会删除实际文件,只是取消选择。" + ) + + if result: + self.source_items.clear() + self.source_items_listbox.delete(0, tk.END) + self.update_total_size() + self.log_message("清空所有源文件夹") + + def select_destination(self): + """选择目的地""" + folder = filedialog.askdirectory(title="选择目的地文件夹") + if folder: + self.destination_path = folder + self.dest_path_label.config(text=folder) + + # 更新目的地信息 + try: + usage = psutil.disk_usage(folder) + free_gb = usage.free / (1024**3) + self.dest_info_label.config(text=f"可用空间: {free_gb:.2f}GB") + except: + self.dest_info_label.config(text="无法获取空间信息") + + self.log_message(f"选择目的地: {folder}") + + # 更新文件夹预览 + self.update_folder_preview() + + def update_folder_preview(self): + """更新文件夹名称预览""" + if not hasattr(self, 'destination_path') or not self.destination_path: + self.folder_preview_label.config(text="") + return + + # 生成预览文件夹名称(自动创建文件夹始终启用) + from datetime import datetime + date_str = datetime.now().strftime("%Y%m%d_%H%M%S") + project_name = self.project_name_var.get().strip() + + if project_name: + # 清理项目名称中的特殊字符 + safe_project_name = "".join(c for c in project_name if c.isalnum() or c in "-_ ") + safe_project_name = safe_project_name.strip().replace(" ", "_") + folder_name = f"{date_str}_{safe_project_name}" + else: + folder_name = date_str + + self.folder_preview_label.config(text=f"📁 将创建文件夹: {folder_name}") + + def start_copy(self): + """开始拷贝""" + if not self.source_items: + messagebox.showwarning("警告", "请先选择源文件夹") + return + + if not self.destination_path: + messagebox.showwarning("警告", "请先选择目的地") + return + + # 禁用开始按钮,启用停止按钮 + self.start_btn.config(state="disabled") + self.stop_btn.config(state="normal") + + # 开始拷贝线程 + self.copy_thread = threading.Thread(target=self.copy_process) + self.copy_thread.daemon = True + self.copy_thread.start() + + def stop_copy(self): + """停止拷贝""" + self.copy_manager.copying = False + self.log_message("用户停止拷贝操作") + + def copy_process(self): + """拷贝过程""" + import time + try: + self.copy_manager.copying = True + self.copy_manager.total_files = 0 + self.copy_manager.copied_files = 0 + self.copy_manager.verified_files = 0 + self.copy_manager.total_size = 0 + self.copy_manager.copied_size = 0 + + # 重置日期文件夹,确保每次拷贝都使用新的时间戳 + self.copy_manager.date_folder = None + + # 初始化日志文件 + log_dir = get_log_directory() + session_name = self.project_name_var.get().strip() or "untitled" + log_path = self.copy_manager.init_log_file(log_dir, session_name) + + if log_path: + self.log_message(f"📋 日志文件已创建: {log_path}") + else: + self.log_message("⚠️ 日志文件创建失败,继续拷贝...") + + # 创建目标文件夹 + if self.auto_folder_var.get(): + # 生成日期文件夹并保存,确保拷贝和验证使用相同的时间戳 + if self.copy_manager.date_folder is None: + date_str = datetime.now().strftime("%Y%m%d_%H%M%S") + project_name = self.project_name_var.get().strip() + + # 如果有项目名称,格式为:日期_项目名称 + if project_name: + # 清理项目名称中的特殊字符,确保文件夹名安全 + safe_project_name = "".join(c for c in project_name if c.isalnum() or c in "-_ ") + safe_project_name = safe_project_name.strip().replace(" ", "_") + self.copy_manager.date_folder = f"{date_str}_{safe_project_name}" + else: + # 没有项目名称,只使用日期 + self.copy_manager.date_folder = date_str + + final_dest = os.path.join(self.destination_path, self.copy_manager.date_folder) + else: + final_dest = self.destination_path + + os.makedirs(final_dest, exist_ok=True) + self.log_message(f"创建目标文件夹: {final_dest}") + if self.auto_folder_var.get() and self.copy_manager.date_folder: + self.log_message(f"使用日期文件夹: {self.copy_manager.date_folder}") + + # 统计总文件数和总大小 + self.log_message("正在统计文件...") + for source_item in self.source_items: + for root, dirs, files in os.walk(source_item['path']): + self.copy_manager.total_files += len(files) + for file in files: + file_path = os.path.join(root, file) + try: + self.copy_manager.total_size += os.path.getsize(file_path) + except: + pass + + self.update_stats() + self.log_message(f"总计 {self.copy_manager.total_files} 个文件 ({self.copy_manager.format_size(self.copy_manager.total_size)})") + + # 记录开始时间 + self.copy_manager.copy_start_time = time.time() + + # 开始拷贝 + for source_item in self.source_items: + if not self.copy_manager.copying: + break + + # 使用自定义名称进行拷贝 + folder_name = source_item.get('custom_name', source_item['name']) + self.copy_folder(source_item['path'], final_dest, folder_name) + + # 开始验证 + if self.copy_manager.copying and self.copy_manager.copied_files > 0: + self.verify_files() + + # 完成 + if self.copy_manager.copying: + self.copy_complete() + else: + self.copy_stopped() + + except Exception as e: + self.log_message(f"拷贝过程出错: {str(e)}") + messagebox.showerror("错误", f"拷贝过程出错: {str(e)}") + + finally: + # 恢复按钮状态 + self.start_btn.config(state="normal") + self.stop_btn.config(state="disabled") + + def copy_file_with_progress(self, source_file, dest_file, file_size): + """优化的分块拷贝文件,支持实时进度更新""" + import time + + # 根据文件大小调整块大小 - 优化大文件处理 + if file_size > 500 * 1024 * 1024: # 大于500MB + chunk_size = 16 * 1024 * 1024 # 16MB块 + elif file_size > 100 * 1024 * 1024: # 大于100MB + chunk_size = 8 * 1024 * 1024 # 8MB块 + elif file_size > 10 * 1024 * 1024: # 大于10MB + chunk_size = 4 * 1024 * 1024 # 4MB块 + else: + chunk_size = 1024 * 1024 # 1MB块 + + copied_size = 0 + last_update_time = time.time() + last_progress_log = 0 # 上次记录进度的时间 + update_interval = 0.2 # 减少更新频率到200ms + + # 对于大文件,显示开始拷贝信息 + if file_size > 50 * 1024 * 1024: + self.log_message(f"📁 开始拷贝大文件: {os.path.basename(source_file)} ({self.copy_manager.format_size(file_size)})") + + try: + # 使用缓冲IO提高性能 + with open(source_file, 'rb', buffering=chunk_size) as src: + with open(dest_file, 'wb', buffering=chunk_size) as dst: + while True: + if not self.copy_manager.copying: + break + + # 读取数据块 + chunk = src.read(chunk_size) + if not chunk: + break + + # 写入数据块 + dst.write(chunk) + copied_size += len(chunk) + + # 按间隔更新进度,避免过于频繁 + current_time = time.time() + if current_time - last_update_time >= update_interval: + # 对于大文件,记录进度百分比(减少日志频率) + if file_size > 100 * 1024 * 1024 and current_time - last_progress_log >= 10: # 每10秒记录一次 + progress_percent = (copied_size / file_size) * 100 + self.log_message(f"⏳ 拷贝进度: {progress_percent:.1f}% ({self.copy_manager.format_size(copied_size)}/{self.copy_manager.format_size(file_size)})") + last_progress_log = current_time + + # 批量更新进度(减少UI更新频率) + if self.copy_manager.total_size > 0: + file_progress_ratio = copied_size / file_size if file_size > 0 else 0 + temp_copied_size = self.copy_manager.copied_size + (file_size * file_progress_ratio) + temp_copied_size = min(temp_copied_size, self.copy_manager.total_size) + + # 只在有显著变化时更新UI + if abs(temp_copied_size - self.copy_manager.copied_size) > (self.copy_manager.total_size * 0.01): # 变化超过1% + original_copied_size = self.copy_manager.copied_size + self.copy_manager.copied_size = temp_copied_size + self.update_progress() + self.copy_manager.copied_size = original_copied_size + + self.window.update() # 刷新界面 + last_update_time = current_time + + except Exception as e: + # 如果分块拷贝失败,回退到标准拷贝 + self.log_message(f"分块拷贝失败,回退到标准拷贝: {str(e)}") + shutil.copy2(source_file, dest_file) + + def copy_folder(self, source_path, dest_path, folder_name): + """拷贝文件夹""" + import time + target_path = os.path.join(dest_path, folder_name) + + # 调试信息:路径构建 + self.log_message(f"📁 拷贝文件夹信息:") + self.log_message(f" 源路径: {source_path}") + self.log_message(f" 目标基础路径: {dest_path}") + self.log_message(f" 文件夹名称: {folder_name}") + self.log_message(f" 完整目标路径: {target_path}") + + os.makedirs(target_path, exist_ok=True) + + for root, dirs, files in os.walk(source_path): + if not self.copy_manager.copying: + break + + # 创建子目录 + rel_path = os.path.relpath(root, source_path) + if rel_path == '.': + current_dest = target_path + else: + current_dest = os.path.join(target_path, rel_path) + + # 调试信息:相对路径处理 + self.log_message(f"📂 相对路径处理:") + self.log_message(f" 源根目录: {source_path}") + self.log_message(f" 当前源目录: {root}") + self.log_message(f" 相对路径: {rel_path}") + self.log_message(f" 目标根目录: {target_path}") + self.log_message(f" 当前目标目录: {current_dest}") + + # 调试信息:目录创建 + self.log_message(f"📂 创建目录: {current_dest}") + try: + os.makedirs(current_dest, exist_ok=True) + self.log_message(f" ✅ 目录创建成功: {current_dest}") + self.log_message(f" 📍 目录存在: {os.path.exists(current_dest)}") + except Exception as e: + self.log_message(f" ❌ 目录创建失败: {current_dest} - {str(e)}") + + # 拷贝文件 + for file in files: + if not self.copy_manager.copying: + break + + source_file = os.path.join(root, file) + dest_file = os.path.join(current_dest, file) + + try: + # 获取文件大小 + file_size = os.path.getsize(source_file) + copy_start = time.time() + + # 调试信息 + self.log_message(f"📁 拷贝文件: {file}") + self.log_message(f" 从: {source_file}") + self.log_message(f" 到: {dest_file}") + + # 分块拷贝文件,支持实时进度更新 + self.copy_file_with_progress(source_file, dest_file, file_size) + + # 验证拷贝结果 + if os.path.exists(dest_file): + self.log_message(f" ✅ 拷贝成功: {file}") + # 验证文件大小 + source_size = os.path.getsize(source_file) + dest_size = os.path.getsize(dest_file) + if source_size == dest_size: + self.log_message(f" ✅ 文件大小匹配: {source_size} bytes") + else: + self.log_message(f" ⚠️ 文件大小不匹配: 源={source_size}, 目标={dest_size}") + else: + self.log_message(f" ❌ 拷贝后文件不存在: {file}") + # 检查父目录 + parent_dir = os.path.dirname(dest_file) + self.log_message(f" 📍 父目录: {parent_dir}") + self.log_message(f" 📂 父目录存在: {os.path.exists(parent_dir)}") + if os.path.exists(parent_dir): + files = os.listdir(parent_dir) + self.log_message(f" 📄 父目录内容: {files}") + + # 文件拷贝完成,更新进度 + copy_time = time.time() - copy_start + self.copy_manager.copied_files += 1 + + # 确保总大小正确(在文件拷贝完成后更新总大小) + self.copy_manager.copied_size += file_size + + # 计算速度 + if copy_time > 0: + file_speed = file_size / copy_time # 字节/秒 + self.copy_manager.copy_speed = file_speed + + self.update_progress() + self.log_message(f"已拷贝: {file} ({self.copy_manager.format_size(file_size)})") + except Exception as e: + self.log_message(f"拷贝失败 {file}: {str(e)}") + + def verify_files(self): + """验证文件""" + import time + self.copy_manager.verifying = True + self.copy_manager.verify_start_time = time.time() + self.copy_manager.md5_start_time = time.time() # MD5验证开始时间 + self.copy_manager.md5_verified_files = 0 + self.copy_manager.md5_calc_size = 0 + + # 统计需要验证的文件总数 + total_files = 0 + for source_item in self.source_items: + for root, dirs, files in os.walk(source_item['path']): + total_files += len(files) + self.copy_manager.total_md5_files = total_files + + self.log_message(f"开始MD5验证,共 {total_files} 个文件...") + + # 构建与拷贝时相同的目标路径 + if self.auto_folder_var.get(): + # 使用拷贝时保存的日期文件夹,确保时间戳一致 + if self.copy_manager.date_folder is not None: + final_dest = os.path.join(self.destination_path, self.copy_manager.date_folder) + else: + # 如果date_folder不存在,生成新的(这种情况不应该发生) + final_dest = self.destination_path + else: + final_dest = self.destination_path + + # 这里应该实现MD5验证逻辑 + # 简化版本:只检查文件是否存在 + for source_item in self.source_items: + if not self.copy_manager.copying: + break + + source_path = source_item['path'] + # 使用与拷贝时相同的路径构建逻辑,包括自定义名称 + folder_name = source_item.get('custom_name', source_item['name']) + dest_path = os.path.join(final_dest, folder_name) + + # 调试信息:验证路径 + self.log_message(f"🔍 验证路径构建:") + self.log_message(f" 源项目路径: {source_item['path']}") + self.log_message(f" 源项目名称: {source_item['name']}") + self.log_message(f" 最终目标路径: {final_dest}") + self.log_message(f" 验证目标路径: {dest_path}") + if self.auto_folder_var.get() and self.copy_manager.date_folder: + self.log_message(f" 使用日期文件夹: {self.copy_manager.date_folder}") + + self.verify_folder(source_path, dest_path) + + # 验证完成,显示总结 + import time + elapsed_time = time.time() - self.copy_manager.md5_start_time + if elapsed_time > 0: + self.copy_manager.md5_calc_speed = self.copy_manager.md5_calc_size / elapsed_time + + self.log_message("\n" + "="*60) + self.log_message("🎉 MD5验证完成!") + self.log_message(f"📊 验证统计:") + self.log_message(f" 总文件数: {self.copy_manager.total_md5_files}") + self.log_message(f" 验证成功: {self.copy_manager.verified_files}") + self.log_message(f" 验证失败: {self.copy_manager.total_md5_files - self.copy_manager.verified_files}") + self.log_message(f" 总数据量: {self.copy_manager.format_size(self.copy_manager.md5_calc_size)}") + self.log_message(f" 用时: {self.copy_manager.format_time(int(elapsed_time))}") + self.log_message(f" 平均速度: {self.copy_manager.format_size(int(self.copy_manager.md5_calc_speed))}/s") + self.log_message("="*60) + + self.copy_manager.verifying = False + + def verify_folder(self, source_path, dest_path): + """验证文件夹""" + from md5_verifier import MD5Verifier + verifier = MD5Verifier() + + for root, dirs, files in os.walk(source_path): + if not self.copy_manager.copying: + break + + rel_path = os.path.relpath(root, source_path) + if rel_path == '.': + current_dest = dest_path + else: + current_dest = os.path.join(dest_path, rel_path) + + for file in files: + if not self.copy_manager.copying: + break + + source_file = os.path.join(root, file) + dest_file = os.path.join(current_dest, file) + + # 更新MD5验证进度 + self.copy_manager.md5_verified_files += 1 + + # 计算文件大小用于速度统计 + try: + file_size = os.path.getsize(source_file) + self.copy_manager.md5_calc_size += file_size + except: + file_size = 0 + + # 调试信息 + self.log_message(f"🔍 检查文件: {file}") + self.log_message(f" 源路径: {source_file}") + self.log_message(f" 目标路径: {dest_file}") + self.log_message(f" 目标存在: {os.path.exists(dest_file)}") + + if os.path.exists(dest_file): + try: + # 计算MD5验证进度和速度 + import time + elapsed_time = time.time() - self.copy_manager.md5_start_time + # 确保时间不为负数(防止系统时间被修改) + if elapsed_time < 0: + self.log_message(f"⚠️ 检测到负MD5时间: {elapsed_time:.2f}s,重置为0") + elapsed_time = 0 + if elapsed_time > 0: + self.copy_manager.md5_calc_speed = self.copy_manager.md5_calc_size / elapsed_time + + md5_progress = (self.copy_manager.md5_verified_files / self.copy_manager.total_md5_files) * 100 + + # 使用MD5验证文件 - 显示详细进度 + self.log_message(f"🔍 [{md5_progress:.1f}%] 开始MD5验证: {file}") + self.log_message(f" 进度: {self.copy_manager.md5_verified_files}/{self.copy_manager.total_md5_files} 文件") + self.log_message(f" 速度: {self.copy_manager.format_size(int(self.copy_manager.md5_calc_speed))}/s") + + # 计算源文件MD5 + self.log_message(f" 计算源文件MD5...") + source_md5 = verifier.calculate_md5(source_file) + self.log_message(f" 源MD5: {source_md5}") + + # 计算目标文件MD5 + self.log_message(f" 计算目标文件MD5...") + dest_md5 = verifier.calculate_md5(dest_file) + self.log_message(f" 目标MD5: {dest_md5}") + + # 对比MD5值 + if source_md5 == dest_md5: + self.copy_manager.verified_files += 1 + self.update_verify_progress() + self.log_message(f" ✅ MD5匹配: {file}") + self.log_message(f" 哈希值: {source_md5}") + else: + self.log_message(f" ❌ MD5不匹配: {file}") + self.log_message(f" 源哈希: {source_md5}") + self.log_message(f" 目标哈希: {dest_md5}") + + except Exception as e: + self.log_message(f" ❌ MD5验证错误: {file} - {str(e)}") + self.log_message(f" 错误详情: {str(e)}") + else: + self.log_message(f"⚠️ 文件不存在: {file}") + # 检查父目录是否存在 + parent_dir = os.path.dirname(dest_file) + self.log_message(f" 父目录存在: {os.path.exists(parent_dir)}") + if os.path.exists(parent_dir): + # 列出父目录中的文件 + try: + files_in_dir = os.listdir(parent_dir) + self.log_message(f" 父目录中的文件: {files_in_dir}") + except: + self.log_message(f" 无法读取父目录") + + def update_progress(self): + """更新拷贝进度""" + import time + if self.copy_manager.total_files > 0: + # 文件进度 + file_progress = (self.copy_manager.copied_files / self.copy_manager.total_files) * 100 + self.copy_progress.config(value=file_progress) + + # 大小进度 + size_progress = 0 + if self.copy_manager.total_size > 0: + size_progress = (self.copy_manager.copied_size / self.copy_manager.total_size) * 100 + + # 时间计算 + elapsed_time = 0 + if self.copy_manager.copy_start_time > 0: + elapsed_time = time.time() - self.copy_manager.copy_start_time + # 确保时间不为负数(防止系统时间被修改) + if elapsed_time < 0: + self.log_message(f"⚠️ 检测到负时间: {elapsed_time:.2f}s,重置为0") + elapsed_time = 0 + + # 速度计算 + speed_mb_s = 0 + if elapsed_time > 0 and self.copy_manager.copied_size >= 0: + speed_mb_s = (self.copy_manager.copied_size / (1024 * 1024)) / elapsed_time + # 确保速度不为负数 + speed_mb_s = max(0, speed_mb_s) + + # 调试信息:如果速度异常,记录详细信息 + if speed_mb_s < 0 or speed_mb_s > 10000: # 异常速度(负数或超过10GB/s) + self.log_message(f"⚠️ 速度异常: {speed_mb_s:.2f} MB/s") + self.log_message(f" 已拷贝大小: {self.copy_manager.copied_size} bytes ({self.copy_manager.format_size(self.copy_manager.copied_size)})") + self.log_message(f" 总大小: {self.copy_manager.total_size} bytes ({self.copy_manager.format_size(self.copy_manager.total_size)})") + self.log_message(f" 已用时间: {elapsed_time:.2f} seconds") + self.log_message(f" 开始时间: {self.copy_manager.copy_start_time}") + self.log_message(f" 当前时间: {time.time()}") + + # 预估剩余时间 + eta_seconds = 0 + if speed_mb_s > 0 and self.copy_manager.total_size > self.copy_manager.copied_size: + remaining_mb = (self.copy_manager.total_size - self.copy_manager.copied_size) / (1024 * 1024) + eta_seconds = remaining_mb / speed_mb_s + + # 格式化时间显示 + elapsed_str = self.format_time(elapsed_time) + eta_str = self.format_time(eta_seconds) + + # 更新状态标签 + self.copy_status_label.config( + text=f"已拷贝 {self.copy_manager.copied_files}/{self.copy_manager.total_files} 个文件 ({file_progress:.1f}%)" + ) + self.copy_speed_label.config( + text=f"速度: {speed_mb_s:.1f} MB/s | 已用: {elapsed_str} | 剩余: {eta_str}" + ) + + self.update_stats() + + def update_verify_progress(self): + """更新验证进度""" + import time + if self.copy_manager.total_files > 0: + # 文件进度 + file_progress = (self.copy_manager.verified_files / self.copy_manager.total_files) * 100 + self.verify_progress.config(value=file_progress) + + # 时间计算 + elapsed_time = 0 + if self.copy_manager.verify_start_time > 0: + elapsed_time = time.time() - self.copy_manager.verify_start_time + # 确保时间不为负数(防止系统时间被修改) + if elapsed_time < 0: + self.log_message(f"⚠️ 检测到负验证时间: {elapsed_time:.2f}s,重置为0") + elapsed_time = 0 + + # 速度计算(基于文件数量估算) + verify_speed = 0 + if elapsed_time > 0: + verify_speed = self.copy_manager.verified_files / elapsed_time # 文件/秒 + + # 预估剩余时间 + eta_seconds = 0 + if verify_speed > 0 and self.copy_manager.total_files > self.copy_manager.verified_files: + remaining_files = self.copy_manager.total_files - self.copy_manager.verified_files + eta_seconds = remaining_files / verify_speed + + # 格式化时间显示 + elapsed_str = self.format_time(elapsed_time) + eta_str = self.format_time(eta_seconds) + + # 更新状态标签 + self.verify_status_label.config( + text=f"已验证 {self.copy_manager.verified_files}/{self.copy_manager.total_files} 个文件 ({file_progress:.1f}%)" + ) + self.verify_speed_label.config( + text=f"速度: {verify_speed:.1f} 文件/秒 | 已用: {elapsed_str} | 剩余: {eta_str}" + ) + + self.update_stats() + + def update_stats(self): + """更新统计信息""" + self.total_files_label.config(text=f"总文件数: {self.copy_manager.total_files}") + self.copied_files_label.config(text=f"已拷贝: {self.copy_manager.copied_files}") + self.verified_files_label.config(text=f"已验证: {self.copy_manager.verified_files}") + + def copy_complete(self): + """拷贝完成""" + self.log_message("拷贝和验证完成!") + self.copy_progress.config(value=100) + self.verify_progress.config(value=100) + self.copy_status_label.config(text="拷贝完成!") + self.verify_status_label.config(text="验证完成!") + + # 关闭日志文件 + self.copy_manager.close_log_file() + + # 计算统计信息 + total_time = 0 + avg_speed = 0 + if self.copy_manager.copy_start_time > 0: + total_time = time.time() - self.copy_manager.copy_start_time + if total_time > 0 and self.copy_manager.copied_size > 0: + avg_speed = (self.copy_manager.copied_size / (1024 * 1024)) / total_time + + # 验证状态 + verify_status = "已完成" if self.copy_manager.verified_files > 0 else "未验证" + if self.copy_manager.verified_files > 0 and self.copy_manager.total_files > 0: + verify_status = f"已完成 ({self.copy_manager.verified_files}/{self.copy_manager.total_files})" + + # 显示增强版庆祝动画 + self.celebrate_completion_with_stats( + total_files=self.copy_manager.total_files, + total_size=self.copy_manager.copied_size, + avg_speed=avg_speed, + total_time=total_time, + verify_status=verify_status + ) + + def copy_stopped(self): + """拷贝被停止""" + self.log_message("拷贝已停止") + self.copy_status_label.config(text="拷贝已停止") + self.verify_status_label.config(text="验证已停止") + + # 关闭日志文件 + self.copy_manager.close_log_file() + + def open_log_viewer(self): + """打开日志查看器""" + try: + # 在打开日志查看器之前,确保主窗口图标已设置 + self.restore_main_window_icon() + + log_viewer = LogViewerWindow() + log_viewer.mainloop() + + # 日志查看器关闭后,重新设置主窗口图标以确保一致性 + self.restore_main_window_icon() + except Exception as e: + messagebox.showerror("错误", f"无法打开日志查看器: {str(e)}") + + def restore_main_window_icon(self): + """恢复主窗口图标 - 增强版本""" + try: + icon_path = get_icon_path() + if icon_path and 'PIL_Image' in globals() and 'PIL_ImageTk' in globals(): + icon_image = PIL_Image.open(icon_path) + self.icon_photo = PIL_ImageTk.PhotoImage(icon_image) # 重新创建图标引用 + + # 使用多种方法设置图标,确保兼容性 + if self.window: + self.window.wm_iconphoto(True, self.icon_photo) + if hasattr(self.window, 'iconphoto'): + self.window.iconphoto(True, self.icon_photo) + + # 额外:强制刷新窗口属性 + self.window.update_idletasks() + except Exception as e: + print(f"恢复主窗口图标失败: {e}") + pass # 如果恢复失败,保持当前状态 + + def open_official_website(self, event=None): + """打开官方网站""" + try: + import webbrowser + webbrowser.open("https://dit.superjia.com.cn") + except Exception as e: + messagebox.showerror("错误", f"无法打开官网: {str(e)}") + + def celebrate_completion_with_stats(self, total_files, total_size, avg_speed, total_time, verify_status): + """增强版庆祝完成动画,包含统计信息""" + # 创建庆祝窗口 + celebrate_window = tk.Toplevel(self.window) + celebrate_window.title("🎉 拷贝完成!") + celebrate_window.geometry("500x400") + celebrate_window.transient(self.window) + celebrate_window.configure(bg="#1e1e1e") + + # 居中显示 + celebrate_window.update_idletasks() + x = (celebrate_window.winfo_screenwidth() // 2) - (celebrate_window.winfo_width() // 2) + y = (celebrate_window.winfo_screenheight() // 2) - (celebrate_window.winfo_height() // 2) + celebrate_window.geometry(f"+{x}+{y}") + + # 阻止窗口关闭按钮(用户必须通过按钮操作) + celebrate_window.protocol("WM_DELETE_WINDOW", lambda: None) + + # 主框架 + main_frame = ttk.Frame(celebrate_window, padding="20") + main_frame.pack(fill="both", expand=True) + + # 庆祝标题 + title_label = ttk.Label( + main_frame, + text="🎉 拷贝完成! 🎉", + font=("Arial", 28, "bold"), + bootstyle="success", + anchor="center" + ) + title_label.pack(pady=(0, 20)) + + # 统计信息框架 + stats_frame = ttk.Frame(main_frame) + stats_frame.pack(fill="both", expand=True, pady=10) + + # 文件数量 + files_label = ttk.Label( + stats_frame, + text=f"📁 文件数量: {total_files} 个", + font=("Arial", 12), + bootstyle="info" + ) + files_label.pack(anchor="w", pady=5) + + # 总大小 + size_label = ttk.Label( + stats_frame, + text=f"💾 总大小: {self.copy_manager.format_size(total_size)}", + font=("Arial", 12), + bootstyle="info" + ) + size_label.pack(anchor="w", pady=5) + + # 平均速度 + speed_label = ttk.Label( + stats_frame, + text=f"⚡ 平均速度: {avg_speed:.1f} MB/s", + font=("Arial", 12), + bootstyle="info" + ) + speed_label.pack(anchor="w", pady=5) + + # 总用时 + time_label = ttk.Label( + stats_frame, + text=f"⏱️ 总用时: {self.format_time(total_time)}", + font=("Arial", 12), + bootstyle="info" + ) + time_label.pack(anchor="w", pady=5) + + # 验证状态 + verify_label = ttk.Label( + stats_frame, + text=f"✅ 验证状态: {verify_status}", + font=("Arial", 12), + bootstyle="success" + ) + verify_label.pack(anchor="w", pady=5) + + # 按钮框架 + button_frame = ttk.Frame(main_frame) + button_frame.pack(fill="x", pady=(20, 0)) + + # 打开目标文件夹按钮 + open_folder_btn = ttk.Button( + button_frame, + text="📂 打开目标文件夹", + bootstyle="info-outline", + command=lambda: self.open_destination_folder(celebrate_window) + ) + open_folder_btn.pack(side="left", padx=(0, 10)) + + # 确定按钮 + ok_btn = ttk.Button( + button_frame, + text="确定", + bootstyle="success", + command=celebrate_window.destroy + ) + ok_btn.pack(side="right") + + # 设置按钮焦点 + ok_btn.focus() + + # 绑定回车键关闭窗口 + celebrate_window.bind("", lambda e: celebrate_window.destroy()) + + # 确保窗口在最前面 + celebrate_window.lift() + celebrate_window.attributes('-topmost', True) + celebrate_window.after(100, lambda: celebrate_window.attributes('-topmost', False)) + + def open_destination_folder(self, parent_window): + """打开目标文件夹""" + try: + if self.destination_path and os.path.exists(self.destination_path): + if os.name == 'nt': # Windows + os.startfile(self.destination_path) + elif os.name == 'posix': # macOS and Linux + subprocess.run(['open', self.destination_path]) + parent_window.destroy() # 打开文件夹后关闭庆祝窗口 + else: + messagebox.showwarning("警告", "目标文件夹不存在!") + except Exception as e: + messagebox.showerror("错误", f"无法打开目标文件夹: {str(e)}") + + def clear_all(self): + """清空所有选择""" + self.source_items.clear() + self.source_items_listbox.delete(0, tk.END) + self.destination_path = "" + self.dest_path_label.config(text="未选择目的地") + self.dest_info_label.config(text="") + self.log_text.delete(1.0, tk.END) + + # 重置进度 + self.copy_progress.config(value=0) + self.verify_progress.config(value=0) + self.copy_status_label.config(text="等待开始拷贝...") + self.verify_status_label.config(text="等待拷贝完成...") + + self.copy_manager.total_files = 0 + self.copy_manager.copied_files = 0 + self.copy_manager.verified_files = 0 + self.update_stats() + + def log_message(self, message): + """记录日志消息""" + timestamp = datetime.now().strftime("%H:%M:%S") + log_entry = f"[{timestamp}] {message}\n" + self.log_text.insert(tk.END, log_entry) + self.log_text.see(tk.END) + self.window.update() + + # 同时写入日志文件 + if self.copy_manager.log_file: + self.copy_manager.write_log(message) + +class LogViewerWindow: + """日志查看器窗口""" + + def __init__(self): + """初始化日志查看器 - 完全避免默认图标闪烁""" + + # 确保重量级模块已导入 + if 'tb' not in globals() or tb is None: + import_heavy_modules() + + # 预加载图标 + preload_icon() + self.icon_photo = None + + # 创建窗口但先隐藏,避免显示默认图标 + self.window = tb.Window( + title="日志查看器 - CardCopyer-拷贝乐", + themename="darkly", + size=(1200, 800), + resizable=(True, True) + ) + + # 立即隐藏窗口,防止显示默认图标 + self.window.withdraw() + + # 尝试立即设置图标(窗口隐藏状态下) + try: + icon_image = get_global_icon_image() + if icon_image and 'PIL_ImageTk' in globals(): + self.icon_photo = PIL_ImageTk.PhotoImage(icon_image, master=self.window) + self.window.wm_iconphoto(True, self.icon_photo) + if hasattr(self.window, 'iconphoto'): + self.window.iconphoto(True, self.icon_photo) + print("日志查看器图标在隐藏状态下设置成功") + except Exception as e: + print(f"隐藏状态下设置图标失败: {e}") + + self.log_dir = get_log_directory() + self.current_log_file = None + self.current_log_content = "" + + # 设置UI(窗口仍在隐藏状态) + self.setup_ui() + self.load_log_files() + + # 延迟显示窗口,确保图标已完全设置 + self.window.after(100, self._show_window_with_icon) + + def _show_window_with_icon(self): + """显示窗口并确保图标正确设置""" + try: + # 尝试设置图标 + if not self.icon_photo: + icon_image = get_global_icon_image() + if icon_image and 'PIL_ImageTk' in globals(): + self.icon_photo = PIL_ImageTk.PhotoImage(icon_image, master=self.window) + self.window.wm_iconphoto(True, self.icon_photo) + if hasattr(self.window, 'iconphoto'): + self.window.iconphoto(True, self.icon_photo) + + # 显示窗口 + self.window.deiconify() + print("日志查看器窗口已显示") + + # 启动图标监控定时器 + if self.icon_photo: + self._start_icon_monitor() + + except Exception as e: + print(f"显示窗口时设置图标失败: {e}") + # 即使图标设置失败,也要显示窗口 + self.window.deiconify() + + def _set_window_icon(self): + """延迟设置日志查看器窗口图标,使用全局图标确保一致性""" + try: + # 使用全局图标管理器获取Image对象 + icon_image = get_global_icon_image() + + if icon_image and self.window: + # 为当前窗口创建专用的PhotoImage + if 'PIL_ImageTk' in globals(): + self.icon_photo = PIL_ImageTk.PhotoImage(icon_image, master=self.window) + + # 使用多种方法设置图标,确保兼容性 + self.window.wm_iconphoto(True, self.icon_photo) + if hasattr(self.window, 'iconphoto'): + self.window.iconphoto(True, self.icon_photo) + + # 启动图标监控定时器 + self._start_icon_monitor() + elif not icon_image: + # 如果全局图标不可用,尝试本地创建 + icon_path = get_icon_path() + if icon_path and 'PIL_Image' in globals() and 'PIL_ImageTk' in globals(): + icon_image = PIL_Image.open(icon_path) + self.icon_photo = PIL_ImageTk.PhotoImage(icon_image, master=self.window) + if self.window and self.icon_photo: + self.window.wm_iconphoto(True, self.icon_photo) + if hasattr(self.window, 'iconphoto'): + self.window.iconphoto(True, self.icon_photo) + + # 启动图标监控定时器 + self._start_icon_monitor() + except Exception as e: + print(f"设置日志查看器图标失败: {e}") + pass + + def setup_ui(self): + """设置UI界面""" + # 主框架 + main_frame = tb.Frame(self.window, padding=20) + main_frame.pack(fill="both", expand=True) + + # 标题 + title_label = tb.Label( + main_frame, + text="日志查看器", + font=("Arial", 20, "bold"), + bootstyle="primary" + ) + title_label.pack(pady=(0, 20)) + + # 主要内容区域 - 左右分栏 + content_frame = tb.Frame(main_frame) + content_frame.pack(fill="both", expand=True) + + # 左侧 - 日志文件列表 + left_frame = tb.Frame(content_frame) + left_frame.pack(side="left", fill="both", expand=True, padx=(0, 10)) + + tb.Label(left_frame, text="历史日志文件:", font=("Arial", 12, "bold")).pack(pady=(0, 10)) + + # 日志文件列表框架 + list_frame = tb.Frame(left_frame) + list_frame.pack(fill="both", expand=True) + + # 滚动条 + list_scroll = tb.Scrollbar(list_frame) + list_scroll.pack(side="right", fill="y") + + self.log_listbox = tk.Listbox( + list_frame, + yscrollcommand=list_scroll.set, + font=("Arial", 10), + bg="#1e1e1e", + fg="white", + selectbackground="#005a9e", + height=20 + ) + self.log_listbox.pack(side="left", fill="both", expand=True) + list_scroll.config(command=self.log_listbox.yview) + + # 绑定选择事件 + self.log_listbox.bind('<>', self.on_log_selected) + + # 右侧 - 日志内容显示 + right_frame = tb.Frame(content_frame) + right_frame.pack(side="left", fill="both", expand=True) + + tb.Label(right_frame, text="日志内容:", font=("Arial", 12, "bold")).pack(pady=(0, 10)) + + # 日志信息显示框架 + info_frame = tb.Frame(right_frame) + info_frame.pack(fill="x", pady=(0, 10)) + + self.log_info_label = tb.Label( + info_frame, + text="请选择一个日志文件", + font=("Arial", 10), + bootstyle="secondary" + ) + self.log_info_label.pack(side="left") + + # 日志内容显示 + content_frame = tb.Frame(right_frame) + content_frame.pack(fill="both", expand=True) + + content_scroll = tb.Scrollbar(content_frame) + content_scroll.pack(side="right", fill="y") + + self.log_content_text = tk.Text( + content_frame, + yscrollcommand=content_scroll.set, + font=("Courier", 9), + bg="#1e1e1e", + fg="white", + wrap=tk.WORD, + height=25, + width=60 + ) + self.log_content_text.pack(side="left", fill="both", expand=True) + content_scroll.config(command=self.log_content_text.yview) + + # 底部按钮区域 + bottom_frame = tb.Frame(main_frame) + bottom_frame.pack(fill="x", pady=(20, 0)) + + # 刷新按钮 + refresh_btn = tb.Button( + bottom_frame, + text="刷新日志列表", + bootstyle="info-outline", + command=self.load_log_files + ) + refresh_btn.pack(side="left", padx=(0, 10)) + + # 导出按钮 + export_btn = tb.Button( + bottom_frame, + text="导出日志", + bootstyle="success-outline", + command=self.export_log + ) + export_btn.pack(side="left", padx=(0, 10)) + + # 删除按钮 + delete_btn = tb.Button( + bottom_frame, + text="删除日志", + bootstyle="danger-outline", + command=self.delete_log + ) + delete_btn.pack(side="left") + + # 关闭按钮 + close_btn = tb.Button( + bottom_frame, + text="关闭", + bootstyle="secondary", + command=self.window.destroy + ) + close_btn.pack(side="right") + + def load_log_files(self): + """加载日志文件列表""" + self.log_listbox.delete(0, tk.END) + + try: + if os.path.exists(self.log_dir): + log_files = [f for f in os.listdir(self.log_dir) if f.endswith('.log')] + log_files.sort(reverse=True) # 最新的在前 + + for log_file in log_files: + # 获取文件信息 + file_path = os.path.join(self.log_dir, log_file) + file_size = os.path.getsize(file_path) + file_time = os.path.getmtime(file_path) + file_date = datetime.fromtimestamp(file_time).strftime('%Y-%m-%d %H:%M') + + # 显示格式:文件名 (大小, 日期) + size_str = self.format_size(file_size) + display_text = f"{log_file} ({size_str}, {file_date})" + self.log_listbox.insert(tk.END, display_text) + + if log_files: + self.log_info_label.config(text=f"找到 {len(log_files)} 个日志文件") + else: + self.log_info_label.config(text="暂无日志文件") + else: + self.log_info_label.config(text="日志目录不存在") + + except Exception as e: + self.log_info_label.config(text=f"加载日志文件失败: {str(e)}") + + def format_size(self, size_bytes: int) -> str: + """格式化文件大小显示""" + if size_bytes == 0: + return "0 B" + + size_names = ["B", "KB", "MB", "GB", "TB"] + i = 0 + size = float(size_bytes) + + while size >= 1024 and i < len(size_names) - 1: + size /= 1024 + i += 1 + + return f"{size:.1f} {size_names[i]}" + + def on_log_selected(self, event): + """选择日志文件时的处理""" + selection = self.log_listbox.curselection() + if not selection: + return + + try: + # 获取选中的日志文件名 + selected_text = self.log_listbox.get(selection[0]) + log_filename = selected_text.split(' (')[0] # 提取文件名 + + # 读取日志内容 + log_path = os.path.join(self.log_dir, log_filename) + with open(log_path, 'r', encoding='utf-8') as f: + content = f.read() + + self.current_log_file = log_filename + self.current_log_content = content + + # 显示日志内容 + self.log_content_text.delete(1.0, tk.END) + self.log_content_text.insert(1.0, content) + + # 更新信息标签 + file_size = os.path.getsize(log_path) + file_time = os.path.getmtime(log_path) + file_date = datetime.fromtimestamp(file_time).strftime('%Y-%m-%d %H:%M:%S') + + info_text = f"文件名: {log_filename} | 大小: {self.format_size(file_size)} | 修改时间: {file_date}" + self.log_info_label.config(text=info_text) + + except Exception as e: + self.log_info_label.config(text=f"读取日志文件失败: {str(e)}") + self.log_content_text.delete(1.0, tk.END) + self.log_content_text.insert(1.0, f"错误: 无法读取日志文件\n\n{str(e)}") + + def export_log(self): + """导出当前选中的日志""" + if not self.current_log_file or not self.current_log_content: + messagebox.showwarning("提示", "请先选择一个日志文件") + return + + # 选择导出位置 + export_path = filedialog.asksaveasfilename( + title="导出日志", + initialfile=self.current_log_file, + defaultextension=".log", + filetypes=[("日志文件", "*.log"), ("文本文件", "*.txt"), ("所有文件", "*.*")] + ) + + if export_path: + try: + with open(export_path, 'w', encoding='utf-8') as f: + f.write(self.current_log_content) + messagebox.showinfo("成功", f"日志已导出到:\n{export_path}") + except Exception as e: + messagebox.showerror("错误", f"导出日志失败:\n{str(e)}") + + def delete_log(self): + """删除当前选中的日志""" + if not self.current_log_file: + messagebox.showwarning("提示", "请先选择一个日志文件") + return + + result = messagebox.askyesno( + "确认删除", + f"确定要删除日志文件:\n{self.current_log_file}?\n\n此操作不可恢复。" + ) + + if result: + try: + log_path = os.path.join(self.log_dir, self.current_log_file) + os.remove(log_path) + messagebox.showinfo("成功", "日志文件已删除") + + # 清空显示 + self.log_content_text.delete(1.0, tk.END) + self.current_log_file = None + self.current_log_content = "" + + # 重新加载列表 + self.load_log_files() + + except Exception as e: + messagebox.showerror("错误", f"删除日志文件失败:\n{str(e)}") + + def mainloop(self): + """运行日志查看器主循环 - 增强图标一致性""" + try: + # 确保窗口图标已正确设置 - 使用全局图标确保一致性 + global_icon = get_global_icon_photo() + if global_icon and self.window: + self.icon_photo = global_icon + # 延迟设置以避免初始化问题 + self.window.after(100, lambda: self._apply_window_icon()) + + # 运行主循环 + self.window.mainloop() + except Exception as e: + print(f"日志查看器主循环错误: {e}") + raise + + def _apply_window_icon(self): + """应用窗口图标 - 分离方法以确保可靠性""" + try: + if self.window and self.icon_photo: + self.window.wm_iconphoto(True, self.icon_photo) + if hasattr(self.window, 'iconphoto'): + self.window.iconphoto(True, self.icon_photo) + except Exception as e: + print(f"应用窗口图标失败: {e}") + + def _start_icon_monitor(self): + """启动图标监控定时器,防止图标被系统重置""" + def check_and_restore_icon(): + try: + # 检查图标是否仍然有效 + if self.window and self.icon_photo: + # 重新应用图标 + self.window.wm_iconphoto(True, self.icon_photo) + if hasattr(self.window, 'iconphoto'): + self.window.iconphoto(True, self.icon_photo) + + # 每5秒检查一次 + self.window.after(5000, check_and_restore_icon) + except Exception: + pass + + # 启动第一次检查 + self.window.after(5000, check_and_restore_icon) + + +def show_startup_error(message, detail=""): + """显示启动错误对话框""" + import tkinter as tk + from tkinter import messagebox + + root = tk.Tk() + root.withdraw() # 隐藏主窗口 + messagebox.showerror("启动错误", f"{message}\n\n{detail}") + root.destroy() + +def main(): + """主函数 - 优化启动流程""" + startup_window = None + + try: + # 预加载图标,确保在窗口创建前就可用了 + startup_window = StartupWindow() + startup_window.update_progress("正在预加载图标...") + + # 在后台线程中预加载图标 + def preload_icon_in_background(): + try: + preload_success = preload_icon() + if preload_success: + startup_window.update_progress("图标预加载成功...") + else: + startup_window.update_progress("图标预加载失败,将使用默认设置...") + except Exception as e: + print(f"图标预加载异常: {e}") + startup_window.update_progress("图标预加载异常,继续启动...") + + # 立即开始图标预加载 + threading.Thread(target=preload_icon_in_background, daemon=True).start() + + # 快速依赖检查 + startup_window.update_progress("正在检查依赖...") + if not quick_check_dependencies(): + startup_window.close() + return + + # 在后台线程中进行完整依赖检查 + def check_deps_in_background(): + success, error_msg = full_check_dependencies() + if not success: + startup_window.close() + show_startup_error("依赖检查失败", error_msg) + return + + # 依赖检查通过后,启动主应用 + startup_window.update_progress("正在加载界面...") + + # 关闭启动窗口 + if startup_window: + startup_window.close() + + # 创建主应用 + app = DITCopyTool() + app.window.mainloop() + + # 延迟启动依赖检查,让图标预加载先完成 + startup_window.root.after(800, check_deps_in_background) + + # 运行启动窗口的主循环 + startup_window.root.mainloop() + + except KeyboardInterrupt: + if startup_window: + startup_window.close() + except Exception as e: + if startup_window: + startup_window.close() + show_startup_error("程序启动失败", str(e)) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/md5_verifier.py b/md5_verifier.py new file mode 100644 index 0000000..f98a496 --- /dev/null +++ b/md5_verifier.py @@ -0,0 +1,227 @@ +""" +MD5验证模块 +用于文件完整性验证 +""" + +import hashlib +import os +from pathlib import Path +from typing import Dict, Optional, Tuple +import json + +class MD5Verifier: + """MD5验证器""" + + def __init__(self): + self.checksums = {} # 存储文件校验和 + + def calculate_md5(self, file_path: str, chunk_size: int = 8192) -> str: + """ + 计算文件的MD5值 + + Args: + file_path: 文件路径 + chunk_size: 分块大小 + + Returns: + MD5哈希值 + """ + md5_hash = hashlib.md5() + try: + with open(file_path, "rb") as f: + while chunk := f.read(chunk_size): + md5_hash.update(chunk) + return md5_hash.hexdigest() + except Exception as e: + raise Exception(f"计算MD5失败 {file_path}: {str(e)}") + + def verify_file(self, source_path: str, dest_path: str) -> Tuple[bool, str]: + """ + 验证两个文件是否相同 + + Args: + source_path: 源文件路径 + dest_path: 目标文件路径 + + Returns: + (是否相同, 错误信息) + """ + try: + source_md5 = self.calculate_md5(source_path) + dest_md5 = self.calculate_md5(dest_path) + + if source_md5 == dest_md5: + return True, "" + else: + return False, f"MD5不匹配: 源={source_md5}, 目标={dest_md5}" + + except Exception as e: + return False, f"验证失败: {str(e)}" + + def create_checksum_file(self, folder_path: str, output_file: str = None) -> str: + """ + 为文件夹创建校验和文件 + + Args: + folder_path: 文件夹路径 + output_file: 输出文件路径(可选) + + Returns: + 校验和文件路径 + """ + if output_file is None: + output_file = os.path.join(folder_path, "checksums.md5") + + checksums = {} + + for root, dirs, files in os.walk(folder_path): + for file in files: + if file == "checksums.md5": # 跳过已有的校验和文件 + continue + + file_path = os.path.join(root, file) + rel_path = os.path.relpath(file_path, folder_path) + + try: + md5_hash = self.calculate_md5(file_path) + checksums[rel_path] = md5_hash + except Exception as e: + print(f"计算MD5失败 {file_path}: {str(e)}") + + # 保存校验和文件 + try: + with open(output_file, 'w', encoding='utf-8') as f: + for rel_path, md5_hash in checksums.items(): + f.write(f"{md5_hash} {rel_path}\n") + return output_file + except Exception as e: + raise Exception(f"保存校验和文件失败: {str(e)}") + + def load_checksum_file(self, checksum_file: str) -> Dict[str, str]: + """ + 加载校验和文件 + + Args: + checksum_file: 校验和文件路径 + + Returns: + 文件路径到MD5的映射字典 + """ + checksums = {} + + try: + with open(checksum_file, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#'): + parts = line.split(' ', 1) # 使用两个空格分隔 + if len(parts) == 2: + md5_hash, file_path = parts + checksums[file_path] = md5_hash + + except Exception as e: + raise Exception(f"加载校验和文件失败: {str(e)}") + + return checksums + + def verify_folder(self, source_folder: str, dest_folder: str, create_checksums: bool = True) -> Dict[str, Tuple[bool, str]]: + """ + 验证两个文件夹是否相同 + + Args: + source_folder: 源文件夹 + dest_folder: 目标文件夹 + create_checksums: 是否创建校验和文件 + + Returns: + 验证结果字典 + """ + results = {} + + # 为源文件夹创建校验和文件 + if create_checksums: + try: + source_checksum_file = self.create_checksum_file(source_folder) + print(f"创建源文件夹校验和文件: {source_checksum_file}") + except Exception as e: + print(f"创建源文件夹校验和文件失败: {str(e)}") + + # 获取源文件夹的所有文件 + source_files = {} + for root, dirs, files in os.walk(source_folder): + for file in files: + if file == "checksums.md5": + continue + + file_path = os.path.join(root, file) + rel_path = os.path.relpath(file_path, source_folder) + source_files[rel_path] = file_path + + # 验证每个文件 + for rel_path, source_file in source_files.items(): + dest_file = os.path.join(dest_folder, rel_path) + + if not os.path.exists(dest_file): + results[rel_path] = (False, "目标文件不存在") + continue + + is_valid, error_msg = self.verify_file(source_file, dest_file) + results[rel_path] = (is_valid, error_msg) + + return results + + def get_file_info(self, file_path: str) -> Dict[str, any]: + """ + 获取文件信息 + + Args: + file_path: 文件路径 + + Returns: + 文件信息字典 + """ + try: + stat = os.stat(file_path) + return { + 'size': stat.st_size, + 'modified': stat.st_mtime, + 'md5': self.calculate_md5(file_path) + } + except Exception as e: + raise Exception(f"获取文件信息失败: {str(e)}") + +def test_md5_verifier(): + """测试MD5验证器""" + verifier = MD5Verifier() + + # 创建测试文件 + test_file = "test_file.txt" + with open(test_file, 'w') as f: + f.write("这是一个测试文件\n") + + try: + # 计算MD5 + md5_hash = verifier.calculate_md5(test_file) + print(f"文件MD5: {md5_hash}") + + # 验证文件 + is_valid, error_msg = verifier.verify_file(test_file, test_file) + print(f"文件验证结果: {is_valid}, {error_msg}") + + # 创建校验和文件 + checksum_file = verifier.create_checksum_file(".") + print(f"校验和文件: {checksum_file}") + + # 加载校验和文件 + checksums = verifier.load_checksum_file(checksum_file) + print(f"加载的校验和: {checksums}") + + finally: + # 清理测试文件 + if os.path.exists(test_file): + os.remove(test_file) + if os.path.exists("checksums.md5"): + os.remove("checksums.md5") + +if __name__ == "__main__": + test_md5_verifier() \ No newline at end of file diff --git a/readme/PACKAGING.md b/readme/PACKAGING.md new file mode 100644 index 0000000..8d2ab86 --- /dev/null +++ b/readme/PACKAGING.md @@ -0,0 +1,64 @@ +# CardCopyer-拷贝乐 打包说明 + +## 已完成的打包 + +### macOS 版本 +- ✅ **CardCopyer-拷贝乐.app** - macOS 应用程序包 +- ✅ **CardCopyer** - macOS 可执行文件 (内部二进制名称) + +打包文件位置:`/Users/peterjia/Documents/项目工程/cardcopy/dist/` + +## Windows 版本打包方法 + +### 方法一:使用打包脚本 +1. 在 Windows 系统中安装 Python 3.7+ +2. 将项目文件复制到 Windows 电脑 +3. 双击运行 `build_windows.bat` + +### 方法二:手动打包 +1. 安装依赖: + ```cmd + pip install pyinstaller ttkbootstrap psutil Pillow + ``` + +2. 执行打包: + ```cmd + pyinstaller CardCopyer_windows.spec + ``` + +3. 打包完成后,可执行文件在 `dist\CardCopyer-拷贝乐.exe` + +## 打包配置说明 + +### 包含的文件 +- `styles.css` - 程序样式文件 +- `config.json` - 配置文件 +- `launcher.py` - 程序启动器 +- `main.py` - 主程序文件 +- `md5_verifier.py` - MD5 验证模块 + +### 隐藏导入的模块 +- ttkbootstrap 及其子模块 +- psutil +- PIL (Pillow) + +### 打包选项 +- 不显示控制台窗口(windowed 模式) +- 启用 UPX 压缩 +- 包含所有依赖项 + +## 注意事项 + +1. **图标文件**:可以在 spec 文件中设置 `icon` 参数来添加程序图标 +2. **代码签名**:macOS 版本可以添加代码签名以避免安全警告 +3. **权限**:确保程序有访问文件系统的权限 + +## 文件结构 +``` +dist/ +├── CardCopyer-拷贝乐.app/ # macOS 应用程序包 +│ └── Contents/ +├── CardCopyer # macOS 可执行文件 +└── [Windows 打包后] + └── CardCopyer-拷贝乐.exe # Windows 可执行文件 +``` diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..cdc95cc --- /dev/null +++ b/styles.css @@ -0,0 +1,301 @@ +/* +DIT拷卡软件 - 自定义样式 +现代化界面美化样式 +*/ + +/* 全局样式 */ +* { + font-family: 'Segoe UI', 'Microsoft YaHei', Arial, sans-serif; +} + +/* 主窗口样式 */ +.main-window { + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); +} + +/* 标题样式 */ +.title-label { + background: linear-gradient(45deg, #007acc, #00d4ff); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + text-shadow: 0 0 20px rgba(0, 122, 204, 0.5); +} + +/* 框架样式 */ +.labelframe { + border-radius: 15px; + border: 2px solid #007acc; + background: rgba(0, 26, 51, 0.8); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + backdrop-filter: blur(10px); +} + +/* 列表框样式 */ +.listbox { + border-radius: 10px; + border: 1px solid #007acc; + background: rgba(30, 30, 30, 0.9); + color: #ffffff; + selection-background-color: #007acc; + selection-color: #ffffff; +} + +.listbox:hover { + border-color: #00d4ff; + box-shadow: 0 0 15px rgba(0, 212, 255, 0.3); +} + +/* 按钮样式 */ +.button { + border-radius: 8px; + border: none; + padding: 10px 20px; + font-weight: bold; + transition: all 0.3s ease; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); +} + +.button:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); +} + +.button:pressed { + transform: translateY(0); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); +} + +/* 进度条样式 */ +.progressbar { + border-radius: 10px; + height: 25px; + border: 1px solid #007acc; + background: rgba(30, 30, 30, 0.9); +} + +.progressbar::chunk { + border-radius: 8px; + background: linear-gradient(45deg, #007acc, #00d4ff); +} + +/* 文本样式 */ +.text { + border-radius: 10px; + border: 1px solid #007acc; + background: rgba(30, 30, 30, 0.9); + color: #ffffff; + font-family: 'Courier New', monospace; +} + +/* 标签样式 */ +.label { + color: #ffffff; + font-weight: 500; +} + +.label:disabled { + color: #888888; +} + +/* 单选按钮样式 */ +.radiobutton { + color: #ffffff; + spacing: 10px; +} + +.radiobutton::indicator { + width: 16px; + height: 16px; + border-radius: 8px; + border: 2px solid #007acc; + background: rgba(30, 30, 30, 0.9); +} + +.radiobutton::indicator:checked { + background: #007acc; + border-color: #00d4ff; +} + +/* 复选框样式 */ +.checkbutton { + color: #ffffff; + spacing: 10px; +} + +.checkbutton::indicator { + width: 16px; + height: 16px; + border-radius: 4px; + border: 2px solid #007acc; + background: rgba(30, 30, 30, 0.9); +} + +.checkbutton::indicator:checked { + background: #007acc; + border-color: #00d4ff; +} + +/* 滚动条样式 */ +.scrollbar { + border-radius: 8px; + background: rgba(0, 122, 204, 0.3); + border: none; + width: 12px; +} + +.scrollbar::handle { + border-radius: 6px; + background: #007acc; + min-height: 30px; +} + +.scrollbar::handle:hover { + background: #00d4ff; +} + +.scrollbar::add-line, .scrollbar::sub-line { + border: none; + background: none; +} + +/* 输入框样式 */ +.entry { + border-radius: 8px; + border: 1px solid #007acc; + background: rgba(30, 30, 30, 0.9); + color: #ffffff; + padding: 8px 12px; +} + +.entry:focus { + border-color: #00d4ff; + box-shadow: 0 0 10px rgba(0, 212, 255, 0.3); +} + +/* 框架标题样式 */ +.labelframe-title { + color: #00d4ff; + font-weight: bold; + font-size: 14px; + background: rgba(0, 26, 51, 0.8); + padding: 4px 12px; + border-radius: 8px; +} + +/* 成功状态颜色 */ +.success { + color: #28a745; +} + +/* 警告状态颜色 */ +.warning { + color: #ffc107; +} + +/* 错误状态颜色 */ +.danger { + color: #dc3545; +} + +/* 信息状态颜色 */ +.info { + color: #17a2b8; +} + +/* 动画效果 */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes pulse { + 0% { transform: scale(1); } + 50% { transform: scale(1.05); } + 100% { transform: scale(1); } +} + +@keyframes glow { + 0% { box-shadow: 0 0 5px rgba(0, 212, 255, 0.5); } + 50% { box-shadow: 0 0 20px rgba(0, 212, 255, 0.8); } + 100% { box-shadow: 0 0 5px rgba(0, 212, 255, 0.5); } +} + +/* 庆祝动画 */ +.celebration { + animation: pulse 0.5s ease-in-out 3, glow 1s ease-in-out 3; +} + +/* 按钮特殊效果 */ +.button-success { + background: linear-gradient(45deg, #28a745, #20c997); + animation: fadeIn 0.5s ease-out; +} + +.button-info { + background: linear-gradient(45deg, #17a2b8, #20c997); +} + +.button-warning { + background: linear-gradient(45deg, #ffc107, #fd7e14); +} + +.button-danger { + background: linear-gradient(45deg, #dc3545, #e83e8c); +} + +/* 进度条动画 */ +.progressbar-animated { + animation: progress-pulse 2s ease-in-out infinite; +} + +@keyframes progress-pulse { + 0% { opacity: 1; } + 50% { opacity: 0.8; } + 100% { opacity: 1; } +} + +/* 玻璃拟态效果 */ +.glass-effect { + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 15px; +} + +/* 霓虹灯效果 */ +.neon-text { + text-shadow: + 0 0 10px #00d4ff, + 0 0 20px #00d4ff, + 0 0 30px #00d4ff, + 0 0 40px #007acc; +} + +/* 响应式设计 */ +@media (max-width: 1200px) { + .main-container { + flex-direction: column; + } + + .section { + width: 100%; + margin: 10px 0; + } +} + +/* 深色模式优化 */ +.dark-mode { + background: #0a0a0a; + color: #ffffff; +} + +.dark-mode .card { + background: rgba(30, 30, 30, 0.9); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.dark-mode .hover-effect:hover { + background: rgba(255, 255, 255, 0.05); + transform: translateY(-1px); +} \ No newline at end of file