/
O.S.Prog
/
DiScan
Обзор
Документация
Войти
/
O.S.Prog
/
DiScan
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
gui/main_window.py
1 484 строки
67 KB
O.S.Prog
Initial commit: DiScan v1.1.0
18 июл 2026, 15:51
18 июл 2026, 15:51
238a6ec
Код
Авторство
О чём код?
# gui/main_window.py """ Главное окно программы """ import tkinter as tk from tkinter import ttk, filedialog, messagebox from pathlib import Path from threading import Thread import os from export.patient_exporter import PatientExporter from cache_manager import CacheManager from core.scanner import DICOMScanner from core.models import PatientInfo from gui.table_view import PatientTableView from export.excel_exporter import ExcelExporter from export.csv_exporter import CSVExporter from export.txt_exporter import TxtExporter from utils.clipboard import Clipboard from utils.file_ops import FileOps from typing import List from gui.help_window import HelpWindow from config import APP_NAME, VERSION, ENCODINGS, DEFAULT_ENCODING class MainWindow: """Главное окно программы""" def __init__(self, root): self.root = root self.root.title(f"{APP_NAME} v{VERSION}") self.root.geometry("1200x700") self.root.minsize(900, 500) # Настройка иконки (если есть) try: self.root.iconbitmap('icon.ico') except: pass self.scan_result = None self.scanner = None self.current_encoding = DEFAULT_ENCODING self.raw_patients = [] self.last_folder = self._load_last_folder() self.is_scanning = False self.scan_thread = None self._setup_ui() self._setup_menu() self._setup_hotkeys() # Если есть последняя папка - подставляем if self.last_folder and Path(self.last_folder).exists(): self.path_var.set(self.last_folder) self.root.protocol("WM_DELETE_WINDOW", self._on_close) def _setup_ui(self): """Настройка интерфейса""" # Стиль style = ttk.Style() style.configure('Title.TLabel', font=('Segoe UI', 10, 'bold')) style.configure('Status.TLabel', font=('Segoe UI', 9)) # ===== Верхняя панель ===== self.top_frame = ttk.Frame(self.root, padding="8") self.top_frame.pack(fill=tk.X) # Заголовок title_label = ttk.Label(self.top_frame, text="📂 Папка с исследованиями:", style='Title.TLabel') title_label.pack(anchor=tk.W) # Строка с путём и кнопками path_row = ttk.Frame(self.top_frame) path_row.pack(fill=tk.X, pady=(5, 0)) self.path_var = tk.StringVar() self.path_entry = ttk.Entry(path_row, textvariable=self.path_var, font=('Segoe UI', 10)) self.path_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 5)) self.btn_browse = ttk.Button(path_row, text="📂 Обзор", command=self._browse_folder, width=10) self.btn_browse.pack(side=tk.LEFT, padx=2) self.btn_scan = ttk.Button(path_row, text="🔍 Сканировать", command=self._start_scan, width=12) self.btn_scan.pack(side=tk.LEFT, padx=2) # ← НОВАЯ КНОПКА СТОП (в той же строке) self.btn_stop = ttk.Button( path_row, text="⏹️ Стоп", command=self._stop_scan, state=tk.DISABLED, width=10 ) self.btn_stop.pack(side=tk.LEFT, padx=2) # ===== Панель инструментов ===== toolbar = ttk.Frame(self.root, padding="5") toolbar.pack(fill=tk.X) # Кнопки действий self.btn_open = ttk.Button( toolbar, text="📁 Открыть", command=self._open_folder, state=tk.DISABLED, width=10 ) self.btn_open.pack(side=tk.LEFT, padx=2) self.btn_copy = ttk.Button( toolbar, text="📋 Копировать путь", command=self._copy_path, state=tk.DISABLED, width=14 ) self.btn_copy.pack(side=tk.LEFT, padx=2) self.btn_export = ttk.Button( toolbar, text="📊 Экспорт", command=self._export_data, state=tk.DISABLED, width=10 ) self.btn_export.pack(side=tk.LEFT, padx=2) # Разделитель ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, padx=8, fill=tk.Y) # Кодировка encoding_frame = ttk.Frame(toolbar) encoding_frame.pack(side=tk.LEFT) ttk.Label(encoding_frame, text="🔤 Кодировка:", font=('Segoe UI', 9)).pack(side=tk.LEFT, padx=(0, 5)) self.encoding_var = tk.StringVar(value=self.current_encoding) self.encoding_combo = ttk.Combobox( encoding_frame, textvariable=self.encoding_var, values=list(ENCODINGS.keys()), state="readonly", width=20 ) self.encoding_combo.pack(side=tk.LEFT) self.encoding_combo.bind('<<ComboboxSelected>>', self._on_encoding_change) # Отображение текущей кодировки self.encoding_label = ttk.Label( encoding_frame, text=f"({ENCODINGS.get(self.current_encoding, self.current_encoding)})", font=('Segoe UI', 8), foreground='gray' ) self.encoding_label.pack(side=tk.LEFT, padx=5) # Разделитель ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, padx=8, fill=tk.Y) # Поиск search_frame = ttk.Frame(toolbar) search_frame.pack(side=tk.LEFT, fill=tk.X, expand=True) ttk.Label(search_frame, text="🔍 Поиск:", font=('Segoe UI', 9)).pack(side=tk.LEFT, padx=(0, 5)) self.search_var = tk.StringVar() self.search_var.trace('w', self._filter_results) self.search_entry = ttk.Entry(search_frame, textvariable=self.search_var, width=25) self.search_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 5)) self.btn_clear = ttk.Button(search_frame, text="✖", command=self._clear_search, width=3) self.btn_clear.pack(side=tk.LEFT) # Счетчик (справа) self.counter_label = ttk.Label(toolbar, text="", font=('Segoe UI', 9)) self.counter_label.pack(side=tk.RIGHT, padx=10) # ===== Таблица ===== self.table = PatientTableView(self.root) self.table.pack(fill=tk.BOTH, expand=True, padx=8, pady=5) # ===== Контекстное меню ===== self.context_menu = tk.Menu(self.root, tearoff=0) self.context_menu.add_command( label="📂 Открыть папку", command=self._open_folder ) self.context_menu.add_command( label="📋 Копировать путь", command=self._copy_path ) self.context_menu.add_separator() self.context_menu.add_command( label="📤 Экспортировать пациента", command=self._export_selected_patient ) self.context_menu.add_separator() self.context_menu.add_command( label="ℹ️ Информация", command=self._show_selected_info ) self.context_menu.add_separator() # Привязываем меню к таблице self.table.set_context_menu(self.context_menu) # Привязываем события self.table.bind_selection(self._on_selection_change) self.table.bind_double_click(self._open_folder) # ===== Статусная строка ===== status_frame = ttk.Frame(self.root, padding="5") status_frame.pack(fill=tk.X) # Статус (слева) — здесь вся информация о сканировании self.status_var = tk.StringVar() self.status_var.set("✅ Готов к работе") self.status_label = ttk.Label(status_frame, textvariable=self.status_var, style='Status.TLabel') self.status_label.pack(side=tk.LEFT) # Прогресс экспорта (справа) — появляется только при экспорте self.progress_var = tk.StringVar() self.progress_var.set("") self.progress_label = ttk.Label(status_frame, textvariable=self.progress_var, style='Status.TLabel') self.progress_label.pack(side=tk.RIGHT) self.progress_label.pack_forget() # ← СКРЫВАЕМ ПО УМОЛЧАНИЮ # Строка для кнопки остановки экспорта self.export_control_row = ttk.Frame(status_frame) self.btn_stop_export = ttk.Button( self.export_control_row, text="⏹️ Остановить экспорт", command=self._stop_export, state=tk.DISABLED, width=30 ) self.btn_stop_export.pack(side=tk.LEFT, padx=5, pady=5) def _setup_menu(self): """Настройка меню""" menubar = tk.Menu(self.root) self.root.config(menu=menubar) # ===== Файл ===== file_menu = tk.Menu(menubar, tearoff=0) menubar.add_cascade(label="📁 Файл", menu=file_menu) file_menu.add_command(label="Открыть папку...", command=self._browse_folder, accelerator="Ctrl+O") file_menu.add_command(label="Сканировать", command=self._start_scan, accelerator="Ctrl+S") file_menu.add_separator() file_menu.add_command(label="Экспорт в Excel", command=lambda: self._export_data('excel')) file_menu.add_command(label="Экспорт в CSV", command=lambda: self._export_data('csv')) file_menu.add_command(label="Экспорт в TXT", command=lambda: self._export_data('txt')) file_menu.add_separator() file_menu.add_command(label="Выход", command=self._on_close, accelerator="Ctrl+Q") # ===== Действия ===== action_menu = tk.Menu(menubar, tearoff=0) menubar.add_cascade(label="⚡ Действия", menu=action_menu) action_menu.add_command(label="Открыть папку пациента", command=self._open_folder, accelerator="Enter") action_menu.add_command(label="Копировать путь к папке", command=self._copy_path, accelerator="Ctrl+C") action_menu.add_command(label="Копировать ФИО", command=self._copy_patient_name, accelerator="Ctrl+Shift+C") action_menu.add_separator() action_menu.add_command(label="Очистить поиск", command=self._clear_search, accelerator="Esc") action_menu.add_separator() action_menu.add_command( label="📤 Экспорт выбранного пациента", command=self._export_selected_patient, accelerator="Ctrl+E" ) action_menu.add_separator() action_menu.add_command( label="📦 Пакетный экспорт всех пациентов", command=self._export_all_patients, accelerator="Ctrl+Shift+E" ) # ===== Кодировка ===== encoding_menu = tk.Menu(menubar, tearoff=0) menubar.add_cascade(label="🔤 Кодировка", menu=encoding_menu) for enc, name in ENCODINGS.items(): encoding_menu.add_command( label=f"{name}", command=lambda e=enc: self._set_encoding_from_menu(e) ) # ===== Вид ===== view_menu = tk.Menu(menubar, tearoff=0) menubar.add_cascade(label="👁 Вид", menu=view_menu) view_menu.add_command(label="Обновить", command=self._refresh_view, accelerator="F5") # ===== Помощь ===== help_menu = tk.Menu(menubar, tearoff=0) menubar.add_cascade(label="❓ Помощь", menu=help_menu) help_menu.add_command(label="📖 Инструкция", command=self._show_help, accelerator="F1") help_menu.add_command(label="📜 Лицензионное соглашение", command=self._show_license) help_menu.add_separator() help_menu.add_command(label="ℹ️ О программе", command=self._show_about) # ===== Кэш ===== cache_menu = tk.Menu(menubar, tearoff=0) menubar.add_cascade(label="💾 Кэш", menu=cache_menu) cache_menu.add_command(label="Очистить кэш для текущей папки", command=self._clear_cache) cache_menu.add_command(label="Информация о кэше", command=self._show_cache_info) def _setup_hotkeys(self): """Настройка горячих клавиш""" self.root.bind('<Control-o>', lambda e: self._browse_folder()) self.root.bind('<Control-s>', lambda e: self._start_scan()) self.root.bind('<Control-q>', lambda e: self._on_close()) self.root.bind('<Control-c>', lambda e: self._copy_path()) self.root.bind('<Control-Shift-c>', lambda e: self._copy_patient_name()) self.root.bind('<Escape>', lambda e: self._clear_search()) self.root.bind('<Return>', lambda e: self._open_folder()) self.root.bind('<F5>', lambda e: self._refresh_view()) self.root.bind('<F1>', lambda e: self._show_help()) self.root.bind('<Control-e>', lambda e: self._export_selected_patient()) self.root.bind('<Control-Shift-e>', lambda e: self._export_all_patients()) def _load_last_folder(self) -> str: """Загружает последнюю открытую папку""" try: config_file = Path('last_folder.txt') if config_file.exists(): with open(config_file, 'r', encoding='utf-8') as f: return f.read().strip() except: pass return '' def _save_last_folder(self, folder: str): """Сохраняет последнюю открытую папку""" try: with open('last_folder.txt', 'w', encoding='utf-8') as f: f.write(folder) except: pass def _get_folder_size(self, folder_path: Path) -> int: """ Быстро оценивает размер папки в байтах (с ограничением по времени) Возвращает 0, если не удалось подсчитать """ try: total_size = 0 file_count = 0 max_files_to_check = 10000 # Проверяем не больше 10000 файлов для скорости for file_path in folder_path.rglob('*'): if file_path.is_file(): try: total_size += file_path.stat().st_size file_count += 1 except: pass # Ограничиваем проверку, чтобы не зависнуть if file_count >= max_files_to_check: # Если уже проверили 10000 файлов, экстраполируем # Но для простоты просто возвращаем текущий размер break return total_size except Exception as e: print(f"⚠️ Ошибка подсчёта размера: {e}") return 0 def _format_size(self, size_bytes: int) -> str: """Форматирует размер в удобный вид""" if size_bytes == 0: return "0 Б" units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ'] unit_index = 0 while size_bytes >= 1024 and unit_index < len(units) - 1: size_bytes /= 1024 unit_index += 1 return f"{size_bytes:.1f} {units[unit_index]}" def _set_encoding_from_menu(self, encoding: str): """Устанавливает кодировку из меню""" self.encoding_var.set(encoding) self.encoding_label.config(text=f"({ENCODINGS.get(encoding, encoding)})") self._reencode_patients() def _browse_folder(self): """Выбор папки""" folder = filedialog.askdirectory(title="Выберите папку с DICOM-файлами") if folder: self.path_var.set(folder) self._save_last_folder(folder) self._start_scan() def _start_scan(self): """Запуск сканирования в отдельном потоке с проверкой размера папки""" folder_path = self.path_var.get().strip() if not folder_path: messagebox.showerror("Ошибка", "Выберите папку для сканирования") return folder_path_obj = Path(folder_path) if not folder_path_obj.exists(): messagebox.showerror("Ошибка", "Указанная папка не существует") return # ===== ПРОВЕРКА РАЗМЕРА ПАПКИ ===== # Сначала проверяем, есть ли кэш для этой папки encoding = self.encoding_var.get() if not CacheManager.exists(folder_path_obj, encoding): # Если кэша нет — проверяем размер self.status_var.set("⏳ Оценка размера папки...") self.root.update() # Быстрая оценка размера (не блокирует интерфейс) size_bytes = self._get_folder_size(folder_path_obj) size_str = self._format_size(size_bytes) # Если папка больше 1 ГБ — предупреждаем if size_bytes > 1 * 1024**3: # 1 ГБ if not messagebox.askyesno( "⚠️ Большая папка", f"Папка содержит примерно {size_str} данных.\n" f"Сканирование может занять несколько минут.\n\n" f"Вы можете:\n" f"• Нажать 'Да' — продолжить сканирование\n" f"• Нажать 'Нет' — выбрать другую папку\n\n" f"💡 После первого сканирования результаты будут сохранены в кэш,\n" f"и повторное открытие папки будет мгновенным." ): self.status_var.set("✅ Сканирование отменено") return # Если папка больше 10 ГБ — дополнительное предупреждение if size_bytes > 10 * 1024**3: # 10 ГБ if not messagebox.askyesno( "⚠️ Очень большая папка", f"Папка содержит примерно {size_str} данных.\n" f"Сканирование может занять 10+ минут.\n\n" f"Рекомендуется выбрать более узкую папку (например, по месяцам).\n\n" f"Продолжить сканирование?" ): self.status_var.set("✅ Сканирование отменено") return # ===== ЗАПУСК СКАНИРОВАНИЯ ===== # Отключаем кнопки self.btn_scan.config(state=tk.DISABLED) self.btn_stop.config(state=tk.NORMAL) self.is_scanning = True self.status_var.set("⏳ Сканирование...") self.table.clear() self.counter_label.config(text="⏳ Поиск...") # Запускаем сканирование в потоке self.scan_thread = Thread(target=self._scan_worker, args=(folder_path,), daemon=True) self.scan_thread.start() def _update_status(self, message: str): """Простое обновление статуса""" self.status_var.set(f"⏳ {message}") def _scan_worker(self, folder_path: str): """Рабочий процесс сканирования с поддержкой кэша""" try: encoding = self.encoding_var.get() folder_path_obj = Path(folder_path) # ===== ПРОВЕРЯЕМ КЭШ ===== if CacheManager.exists(folder_path_obj, encoding): # Проверяем актуальность кэша if CacheManager.is_valid(folder_path_obj, encoding): # Кэш актуален — загружаем cached_result = CacheManager.load(folder_path_obj, encoding) if cached_result: self.scan_result = cached_result self.raw_patients = self.scan_result.patients.copy() self.root.after(0, self._update_results) count = len(self.scan_result.patients) self.root.after(0, lambda c=count: self.status_var.set(f"✅ Загружено из кэша: {c} пациентов")) self.root.after(0, lambda: self.btn_scan.config(state=tk.NORMAL)) self.root.after(0, lambda: self.btn_stop.config(state=tk.DISABLED)) self.root.after(0, lambda: self.btn_open.config(state=tk.NORMAL)) self.root.after(0, lambda: self.btn_copy.config(state=tk.NORMAL)) self.root.after(0, lambda: self.btn_export.config(state=tk.NORMAL)) self.root.after(0, self._scan_finished) return else: # Кэш есть, но не актуален self.root.after(0, lambda: self.status_var.set("🔄 Кэш устарел, выполняется сканирование...")) CacheManager.clear(folder_path_obj) # ===== КЭША НЕТ ИЛИ ОН УСТАРЕЛ ===== self.scanner = DICOMScanner(folder_path, encoding, max_files=500000) def progress_callback(message, percent): self.root.after(0, lambda: self._update_progress(message, percent)) def patient_callback(patient_info): self.root.after(0, lambda: self._add_patient_to_table(patient_info)) self.scan_result = self.scanner.scan( progress_callback=progress_callback, patient_callback=patient_callback ) if self.scanner._stop_scanning: self.root.after(0, lambda: self._show_stopped_message()) return if self.scan_result.errors and any("прервано" in e for e in self.scan_result.errors): self.root.after(0, lambda: self._show_stopped_message()) return # ===== СОХРАНЯЕМ В КЭШ ===== if self.scan_result and not self.scanner._stop_scanning: try: CacheManager.save(folder_path_obj, self.scan_result, encoding) self.root.after(0, lambda: self.status_var.set("💾 Результат сохранён в кэш")) except Exception as cache_error: print(f"⚠️ Ошибка сохранения кэша: {cache_error}") self.raw_patients = self.scan_result.patients.copy() self.root.after(0, self._update_results) except Exception as err: error_message = str(err) self.root.after(0, lambda: self._show_error(f"Ошибка сканирования: {error_message}")) finally: self.root.after(0, self._scan_finished) def _stop_scan(self): """Останавливает сканирование""" if self.scanner: self.scanner.stop() self.btn_stop.config(state=tk.DISABLED) self.status_var.set("⏹️ Остановка...") def _update_results(self): """Обновление результатов в UI""" if not self.scan_result: return # Отображаем всех пациентов сразу self.table.set_data(self.scan_result.patients) # Включаем кнопки self.btn_scan.config(state=tk.NORMAL) self.btn_stop.config(state=tk.DISABLED) self.btn_open.config(state=tk.NORMAL) self.btn_copy.config(state=tk.NORMAL) self.btn_export.config(state=tk.NORMAL) self.is_scanning = False # Обновляем счётчик count = len(self.scan_result.patients) self.counter_label.config(text=f"👤 {count} пациентов") # Обновляем статус status = f"✅ Найдено {count} пациентов" if self.scan_result.errors: status += f" (⚠️ {len(self.scan_result.errors)} ошибок)" self.status_var.set(status) def _set_scan_state(self, enabled: bool): """Включает/выключает кнопки в зависимости от состояния сканирования""" state = tk.NORMAL if enabled else tk.DISABLED self.btn_scan.config(state=state) self.btn_open.config(state=state) self.btn_copy.config(state=state) self.btn_export.config(state=state) if not enabled: self.btn_open.config(state=tk.DISABLED) self.btn_copy.config(state=tk.DISABLED) self.btn_export.config(state=tk.DISABLED) def _show_error(self, message: str): """Показывает ошибку""" # Если ошибка связана с экспортом — сбрасываем прогресс if "экспорт" in message.lower() or "папка" in message.lower(): self.progress_var.set("❌ Ошибка") self.btn_stop_export.pack_forget() self.btn_stop_export.config(state=tk.DISABLED) messagebox.showerror("Ошибка", message) self.status_var.set("❌ " + message[:50] + ("..." if len(message) > 50 else "")) self.btn_scan.config(state=tk.NORMAL) self.btn_stop.config(state=tk.DISABLED) self.is_scanning = False def _reencode_patients(self): """Перекодирует имена пациентов с новой кодировкой""" if not self.raw_patients or not self.scanner: return new_encoding = self.encoding_var.get() self.status_var.set(f"🔄 Перекодировка...") try: # Перекодируем reencoded = self.scanner.reencode_patients(self.raw_patients, new_encoding) # Обновляем результат self.scan_result.patients = reencoded # Обновляем таблицу self.table.set_data(reencoded) # Обновляем статус status = f"✅ Перекодировано: {ENCODINGS.get(new_encoding, new_encoding)}" status += f" | Пациентов: {len(reencoded)}" self.status_var.set(status) # Обновляем отображение кодировки self.encoding_label.config(text=f"({ENCODINGS.get(new_encoding, new_encoding)})") except Exception as e: messagebox.showerror("Ошибка", f"Ошибка перекодировки: {e}") def _on_encoding_change(self, event): """Обработка смены кодировки в комбобоксе""" self._reencode_patients() def _refresh_view(self): """Обновляет вид (перерисовывает таблицу)""" if self.scan_result: self.table.set_data(self.scan_result.patients) self.status_var.set("🔄 Вид обновлён") self.root.after(1500, lambda: self.status_var.set(f"✅ Найдено {len(self.scan_result.patients)} пациентов")) def _filter_results(self, *args): """Фильтрация результатов по поиску""" if not self.scan_result: return search_text = self.search_var.get().strip() if search_text: filtered = [p for p in self.scan_result.patients if search_text.lower() in p.patient_name.lower() or search_text.lower() in p.patient_id.lower()] self.table.set_data(filtered) self.status_var.set(f"🔍 Найдено {len(filtered)} совпадений") self.counter_label.config(text=f"👤 {len(filtered)} / {len(self.scan_result.patients)}") else: self.table.set_data(self.scan_result.patients) self.status_var.set(f"✅ Найдено {len(self.scan_result.patients)} пациентов") self.counter_label.config(text=f"👤 {len(self.scan_result.patients)} пациентов") def _clear_search(self): """Очищает поиск""" self.search_var.set("") self.search_entry.focus() def _on_selection_change(self, event): """Обработка изменения выделения""" selected = self.table.get_selected() if selected: self.status_var.set(f"Выбран: {selected.patient_name}") def _open_folder(self, event=None): """Открывает папку пациента в проводнике""" selected = self.table.get_selected() if selected: FileOps.open_folder(selected.folder_path) self.status_var.set(f"📂 Открыта папка: {selected.patient_name}") def _copy_path(self): """Копирует путь к папке""" selected = self.table.get_selected() if selected: Clipboard.copy(str(selected.folder_path)) self.status_var.set(f"📋 Путь скопирован: {selected.folder_path}") def _copy_patient_name(self): """Копирует ФИО пациента""" selected = self.table.get_selected() if selected: Clipboard.copy(selected.patient_name) self.status_var.set(f"📋 ФИО скопировано: {selected.patient_name}") def _export_data(self, format_type=None): """Экспорт данных""" if not self.scan_result or not self.scan_result.patients: messagebox.showinfo("Информация", "Нет данных для экспорта") return if format_type: self._do_export(format_type) else: self._show_export_dialog() def _show_export_dialog(self): """Показывает диалог выбора формата экспорта""" dialog = tk.Toplevel(self.root) dialog.title("Экспорт данных") dialog.geometry("400x280") dialog.transient(self.root) dialog.grab_set() # Центрируем окно dialog.update_idletasks() x = (dialog.winfo_screenwidth() // 2) - (400 // 2) y = (dialog.winfo_screenheight() // 2) - (280 // 2) dialog.geometry(f"+{x}+{y}") # Заголовок ttk.Label(dialog, text="📊 Экспорт данных", font=('Segoe UI', 12, 'bold')).pack(pady=20) ttk.Label(dialog, text=f"Количество пациентов: {len(self.scan_result.patients)}", font=('Segoe UI', 10)).pack(pady=(0, 20)) # Кнопки форматов formats = [ ('excel', '📊 Excel (.xlsx)', '#4CAF50'), ('csv', '📄 CSV (.csv)', '#2196F3'), ('txt', '📝 TXT (.txt)', '#FF9800') ] for fmt, label, color in formats: btn = ttk.Button( dialog, text=label, command=lambda f=fmt: self._on_export_format(f, dialog), width=30 ) btn.pack(pady=4) ttk.Button(dialog, text="❌ Отмена", command=dialog.destroy, width=30).pack(pady=15) def _on_export_format(self, format_type, dialog): """Обработка выбора формата""" dialog.destroy() self._do_export(format_type) def _do_export(self, format_type: str): """Выполняет экспорт в выбранном формате""" if format_type == 'excel': exporter = ExcelExporter() elif format_type == 'csv': exporter = CSVExporter() elif format_type == 'txt': exporter = TxtExporter() else: return # Предлагаем имя файла по умолчанию default_name = f"patients_{Path(self.path_var.get()).name}{exporter.extension}" file_path = filedialog.asksaveasfilename( defaultextension=exporter.extension, filetypes=[(exporter.description, f"*{exporter.extension}")], initialfile=default_name ) if file_path: try: exporter.export(self.scan_result.patients, Path(file_path)) self.status_var.set(f"✅ Экспортировано в {Path(file_path).name}") messagebox.showinfo("Успех", f"✅ Данные экспортированы в:\n{file_path}") except Exception as e: messagebox.showerror("Ошибка", f"❌ Ошибка экспорта: {e}") def _show_about(self): """Показывает информацию о программе""" about_window = tk.Toplevel(self.root) about_window.title("ℹ️ О программе") about_window.geometry("420x380") about_window.resizable(False, False) about_window.transient(self.root) about_window.grab_set() # Центрируем about_window.update_idletasks() x = (about_window.winfo_screenwidth() // 2) - (420 // 2) y = (about_window.winfo_screenheight() // 2) - (380 // 2) about_window.geometry(f"+{x}+{y}") # Основной фрейм main_frame = ttk.Frame(about_window, padding="30") main_frame.pack(fill=tk.BOTH, expand=True) # Заголовок ttk.Label( main_frame, text="DiSc — DICOM Scanner", font=('Segoe UI', 16, 'bold'), foreground='#0a2463' ).pack(anchor=tk.CENTER, pady=(0, 5)) ttk.Label( main_frame, text="Программа для поиска пациентов в DICOM-архивах", font=('Segoe UI', 10), foreground='#475569' ).pack(anchor=tk.CENTER, pady=(0, 20)) # Разделитель ttk.Separator(main_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=5) # Информация info_frame = ttk.Frame(main_frame) info_frame.pack(fill=tk.X, pady=10) info = [ ("Версия:", "1.1.0"), ("Автор:", "Шваб Олег Станиславович"), ("Год выпуска:", "2026"), ("Лицензия:", "Бесплатное программное обеспечение"), ] for label, value in info: row = ttk.Frame(info_frame) row.pack(fill=tk.X, pady=2) ttk.Label(row, text=label, font=('Segoe UI', 10), width=14, anchor=tk.W).pack(side=tk.LEFT) ttk.Label(row, text=value, font=('Segoe UI', 10, 'bold'), anchor=tk.W).pack(side=tk.LEFT) # Разделитель ttk.Separator(main_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=10) ttk.Label( main_frame, text="© 2026, Шваб Олег Станиславович", font=('Segoe UI', 9), foreground='#94a3b8' ).pack(anchor=tk.CENTER, pady=(0, 15)) # Кнопки btn_frame = ttk.Frame(main_frame) btn_frame.pack(fill=tk.X) ttk.Button( btn_frame, text="OK", command=about_window.destroy, width=10 ).pack(side=tk.RIGHT, padx=2) ttk.Button( btn_frame, text="💝 Поддержать автора", command=self._donate, width=18 ).pack(side=tk.RIGHT, padx=2) def _update_progress(self, message: str, percent: int): """Обновляет информацию о прогрессе""" # Если пришёл специальный сигнал паузы if percent == -1: self.status_var.set(f"⏸️ {message}") return # Для экспорта — обновляем прогресс справа if "Экспорт" in message or "пациентов" in message: if percent >= 0 and percent <= 100: self.progress_var.set(f"{percent}%") else: self.progress_var.set("⏳ ...") # Для сканирования — только статус слева if message: self.status_var.set(f"⏳ {message}") def _scan_finished(self): """Вызывается после завершения сканирования""" self.is_scanning = False self.scan_thread = None self.btn_stop.config(state=tk.DISABLED) self.btn_scan.config(state=tk.NORMAL) # Не меняем статус, если уже есть сообщение if not self.status_var.get().startswith("✅"): self.status_var.set("✅ Готово") def _add_patient_to_table(self, patient_info): """ Добавляет пациента в таблицу в реальном времени """ if not hasattr(self, 'table'): return # Получаем текущие данные таблицы current_items = self.table.tree.get_children() # Обрезаем длинные имена name = patient_info.patient_name if len(name) > 40: name = name[:37] + "..." # Добавляем в таблицу item = self.table.tree.insert( '', 'end', values=( name, patient_info.patient_id, patient_info.study_date, patient_info.image_count, patient_info.total_files ) ) self.table._items[item] = patient_info # Обновляем счётчик count = len(self.table._items) self.counter_label.config(text=f"👤 {count} пациентов") # Прокручиваем к последнему добавленному self.table.tree.see(item) def _show_stopped_message(self): """Показывает сообщение о принудительной остановке""" # Проверяем, есть ли уже найденные пациенты if self.scan_result and self.scan_result.patients: count = len(self.scan_result.patients) self.table.set_data(self.scan_result.patients) self.counter_label.config(text=f"👤 {count} пациентов (остановлено)") self.status_var.set(f"⏹️ Сканирование остановлено. Найдено {count} пациентов") # Включаем кнопки self.btn_open.config(state=tk.NORMAL) self.btn_copy.config(state=tk.NORMAL) self.btn_export.config(state=tk.NORMAL) else: self.status_var.set("⏹️ Сканирование остановлено. Пациенты не найдены") if hasattr(self, 'progress_info'): self.progress_info.config(text="⏹️ Остановлено") self.btn_scan.config(state=tk.NORMAL) self.btn_stop.config(state=tk.DISABLED) self.is_scanning = False # Добавляем сброс прогресса self.progress_var.set("⏹️ Остановлено") def _on_close(self): """Обработка закрытия окна""" if messagebox.askokcancel("Выход", "Закрыть программу?"): self.root.destroy() def _clear_cache(self): """Очищает кэш для текущей папки""" folder_path = self.path_var.get().strip() if not folder_path: messagebox.showinfo("Информация", "Сначала выберите папку") return if messagebox.askyesno("Очистка кэша", "Удалить кэш для текущей папки?"): CacheManager.clear(Path(folder_path)) self.status_var.set("🗑️ Кэш очищен") def _show_cache_info(self): """Показывает информацию о кэше""" folder_path = self.path_var.get().strip() if not folder_path: messagebox.showinfo("Информация", "Сначала выберите папку") return folder_obj = Path(folder_path) if CacheManager.exists(folder_obj, self.encoding_var.get()): size = CacheManager.get_cache_size(folder_obj) cache_path = CacheManager.get_cache_path(folder_obj) messagebox.showinfo("Информация о кэше", f"📁 Папка: {folder_path}\n" f"📦 Кэш существует: Да\n" f"💾 Размер: {size}\n" f"📂 Путь: {cache_path}" ) else: messagebox.showinfo("Информация о кэше", f"📁 Папка: {folder_path}\n" f"📦 Кэш: Не найден\n" f"ℹ️ При следующем сканировании будет создан" ) def _export_selected_patient(self): """Экспорт выбранного пациента в выбранную папку""" selected = self.table.get_selected() if not selected: messagebox.showinfo("Информация", "Сначала выберите пациента в таблице") return if not selected.folder_path.exists(): messagebox.showerror("Ошибка", f"Папка пациента не найдена:\n{selected.folder_path}") return target_root = filedialog.askdirectory( title="Выберите папку для экспорта", initialdir=Path.home() / "Desktop" ) if not target_root: return target_root = Path(target_root) # Генерируем имя папки folder_name = PatientExporter.generate_folder_name(selected) target_folder = target_root / folder_name # ===== ПРОВЕРКА: существует ли уже папка ===== if target_folder.exists(): # Создаём кастомный диалог с 4 кнопками dialog = tk.Toplevel(self.root) dialog.title("Папка уже существует") dialog.geometry("550x300") # ← БОЛЬШЕ dialog.transient(self.root) dialog.grab_set() # Центрируем dialog.update_idletasks() x = (dialog.winfo_screenwidth() // 2) - (550 // 2) y = (dialog.winfo_screenheight() // 2) - (300 // 2) dialog.geometry(f"+{x}+{y}") # Текст ttk.Label( dialog, text=f"Папка '{folder_name}' уже существует.", font=('Segoe UI', 12, 'bold') # ← ЖИРНЫЙ ).pack(pady=(25, 5)) ttk.Label( dialog, text="Выберите действие:", font=('Segoe UI', 10) ).pack(pady=(0, 20)) # Контейнер для кнопок btn_frame = ttk.Frame(dialog) btn_frame.pack(pady=10, padx=20, fill=tk.X) # Переменная для результата result = {"action": "cancel"} def set_action(action): result["action"] = action dialog.destroy() # Кнопки (широкие) btn_width = 55 ttk.Button( btn_frame, text="✅ Да — перезаписать (старая папка будет удалена)", command=lambda: set_action("overwrite"), width=btn_width ).pack(pady=4) ttk.Button( btn_frame, text="❌ Нет — отменить экспорт", command=lambda: set_action("cancel"), width=btn_width ).pack(pady=4) ttk.Button( btn_frame, text="📂 Создать копию (будет добавлен суффикс _1, _2...)", command=lambda: set_action("copy"), width=btn_width ).pack(pady=4) ttk.Button( btn_frame, text="✖ Отмена — закрыть диалог", command=lambda: set_action("cancel"), width=btn_width ).pack(pady=4) # Ждём закрытия диалога self.root.wait_window(dialog) # Обрабатываем результат if result["action"] == "cancel": self.status_var.set("✅ Экспорт отменён") return elif result["action"] == "overwrite": # Перезаписываем import shutil shutil.rmtree(target_folder) target_folder.mkdir(parents=True, exist_ok=True) elif result["action"] == "copy": # Создаём с суффиксом counter = 1 while target_folder.exists(): target_folder = target_root / f"{folder_name}_{counter}" counter += 1 # ===== ПОДТВЕРЖДЕНИЕ ===== if not messagebox.askyesno( "Экспорт пациента", f"Экспортировать:\n" f"👤 {selected.patient_name}\n" f"📁 {selected.folder_path}\n" f"📂 Файлов: {selected.total_files}\n" f"🖼️ Снимков: {selected.image_count}\n\n" f"📂 Будет создана папка:\n" f"{target_folder}\n\n" f"Продолжить?" ): self.status_var.set("✅ Экспорт отменён") return # ===== ЗАПУСК ЭКСПОРТА ===== self.status_var.set(f"⏳ Экспорт пациента: {selected.patient_name}...") self.progress_var.set("Подготовка...") self.btn_scan.config(state=tk.DISABLED) thread = Thread( target=self._export_worker, args=(selected, target_root), daemon=True ) thread.start() def _export_worker(self, patient: PatientInfo, target_root: Path): """Рабочий процесс экспорта""" from export.patient_exporter import PatientExporter PatientExporter.reset_stop_flag() try: # Показываем прогресс и кнопку self.root.after(0, lambda: self.progress_label.pack(side=tk.RIGHT)) self.root.after(0, lambda: self.export_control_row.pack(fill=tk.X, pady=(2, 0))) self.root.after(0, lambda: self.btn_stop_export.config(state=tk.NORMAL)) self.root.after(0, lambda: self.status_var.set(f"⏳ Экспорт: {patient.patient_name}...")) self.root.after(0, lambda: self.progress_var.set("Подготовка...")) def progress_callback(message, percent): self.root.after(0, lambda: self._update_progress(message, percent)) success, message = PatientExporter.export_patient( patient, target_root, progress_callback ) if success: self.root.after(0, lambda: self._show_export_success(message)) else: self.root.after(0, lambda: self._show_error(message)) except Exception as e: self.root.after(0, lambda: self._show_error(f"Ошибка экспорта: {e}")) finally: self.root.after(0, self._export_finished) def _show_export_success(self, message: str): """Показывает сообщение об успешном экспорте""" self.status_var.set("✅ " + message.split('\n')[0]) self.progress_var.set("✅ Готово") # Кнопка скроется в _export_finished messagebox.showinfo("Экспорт завершён", message) def _export_finished(self): """Завершение экспорта""" self.btn_scan.config(state=tk.NORMAL) self.status_var.set("✅ Готов к работе") def _show_selected_info(self): """Показывает информацию о выбранном пациенте""" selected = self.table.get_selected() if not selected: return info = f""" 👤 Пациент: {selected.patient_name} 🆔 ID: {selected.patient_id} 📅 Дата: {selected.study_date} 🖼️ Снимков: {selected.image_count} 📁 Файлов: {selected.total_files} 📂 Путь: {selected.folder_path} """ messagebox.showinfo("Информация о пациенте", info) def _export_all_patients(self): """Пакетный экспорт всех пациентов с проверкой размера""" if not self.scan_result or not self.scan_result.patients: messagebox.showinfo("Информация", "Нет пациентов для экспорта.\nСначала выполните сканирование.") return patients = self.scan_result.patients total = len(patients) # ===== ОЦЕНКА РАЗМЕРА ===== self.status_var.set("⏳ Оценка размера данных...") self.root.update() total_size_bytes = 0 total_files = 0 # Быстрая оценка размера всех пациентов for patient in patients[:100]: # Ограничиваем для скорости try: if patient.folder_path.exists(): for file_path in patient.folder_path.rglob('*'): if file_path.is_file(): total_size_bytes += file_path.stat().st_size total_files += 1 except: pass # Экстраполируем на всех пациентов (грубо) if len(patients) > 100: factor = len(patients) / 100 total_size_bytes = int(total_size_bytes * factor) total_files = int(total_files * factor) size_str = self._format_size(total_size_bytes) # ===== ПРЕДУПРЕЖДЕНИЯ ===== # Если больше 1 ГБ if total_size_bytes > 1 * 1024**3: # 1 ГБ if not messagebox.askyesno( "⚠️ Большой объём данных", f"Пакетный экспорт {total} пациентов займёт примерно {size_str}.\n" f"Количество файлов: ~{total_files}\n\n" f"Время копирования может быть значительным.\n\n" f"Продолжить?" ): self.status_var.set("✅ Экспорт отменён") return # Если больше 10 ГБ if total_size_bytes > 10 * 1024**3: # 10 ГБ if not messagebox.askyesno( "⚠️ Очень большой объём данных", f"Пакетный экспорт {total} пациентов займёт примерно {size_str}.\n" f"Количество файлов: ~{total_files}\n\n" f"Рекомендуется экспортировать выборочных пациентов.\n\n" f"Продолжить?" ): self.status_var.set("✅ Экспорт отменён") return # Если больше 100 ГБ — особое предупреждение if total_size_bytes > 100 * 1024**3: # 100 ГБ if not messagebox.askyesno( "🚨 КРИТИЧЕСКИ БОЛЬШОЙ ОБЪЁМ", f"Пакетный экспорт {total} пациентов займёт примерно {size_str}.\n" f"Количество файлов: ~{total_files}\n\n" f"Это может занять часы!\n" f"Рекомендуется выбрать более узкий диапазон или экспортировать выборочных пациентов.\n\n" f"Продолжить на свой страх и риск?" ): self.status_var.set("✅ Экспорт отменён") return # ===== ВЫБОР ПАПКИ ===== target_folder = filedialog.askdirectory( title="Выберите папку для пакетного экспорта", initialdir=Path.home() / "Desktop" ) if not target_folder: self.status_var.set("✅ Экспорт отменён") return # ===== ФИНАЛЬНОЕ ПОДТВЕРЖДЕНИЕ ===== if not messagebox.askyesno( "Пакетный экспорт", f"📦 Будет экспортировано {total} пациентов\n" f"💾 Примерный размер: {size_str}\n" f"📁 Количество файлов: ~{total_files}\n\n" f"📂 Целевая папка:\n{target_folder}\n\n" f"Каждый пациент будет сохранён в отдельную папку.\n" f"Папки будут созданы по шаблону: ФИО_дата\n\n" f"Продолжить?" ): self.status_var.set("✅ Экспорт отменён") return # ===== ЗАПУСК ЭКСПОРТА ===== self.status_var.set(f"⏳ Пакетный экспорт {total} пациентов...") self.progress_var.set("Подготовка...") self.btn_scan.config(state=tk.DISABLED) thread = Thread( target=self._export_all_worker, args=(patients, Path(target_folder)), daemon=True ) thread.start() def _export_all_worker(self, patients: List[PatientInfo], target_root: Path): """Рабочий процесс пакетного экспорта""" from export.patient_exporter import PatientExporter PatientExporter.reset_stop_flag() try: # Показываем прогресс и кнопку self.root.after(0, lambda: self.progress_label.pack(side=tk.RIGHT)) self.root.after(0, lambda: self.export_control_row.pack(fill=tk.X, pady=(2, 0))) self.root.after(0, lambda: self.btn_stop_export.config(state=tk.NORMAL)) self.root.after(0, lambda: self.status_var.set(f"⏳ Пакетный экспорт {len(patients)} пациентов...")) self.root.after(0, lambda: self.progress_var.set("Подготовка...")) def progress_callback(message, percent): self.root.after(0, lambda: self._update_progress(message, percent)) success, message = PatientExporter.export_all_patients( patients, target_root, progress_callback ) if success: self.root.after(0, lambda: self._show_export_success(message)) else: self.root.after(0, lambda: self._show_error(message)) except Exception as e: self.root.after(0, lambda: self._show_error(f"Ошибка пакетного экспорта: {e}")) finally: self.root.after(0, self._export_finished) def _stop_export(self): """Останавливает текущий экспорт с подтверждением""" from export.patient_exporter import PatientExporter if PatientExporter.is_paused(): return PatientExporter.stop() self.btn_stop_export.config(state=tk.DISABLED) self.status_var.set("⏸️ Остановка экспорта... (подтвердите)") result = messagebox.askyesno( "Остановка экспорта", "Остановить экспорт?\n\n" "Текущий пациент НЕ будет сохранён (папка будет удалена).\n" "Уже экспортированные пациенты останутся.\n\n" "Нажмите 'Да' для остановки, 'Нет' для продолжения." ) if result: PatientExporter.confirm_stop() self.status_var.set("⏹️ Экспорт остановлен") self.btn_stop_export.config(state=tk.DISABLED) else: PatientExporter.cancel_stop() self.status_var.set("▶️ Экспорт продолжен") self.btn_stop_export.config(state=tk.NORMAL) def _export_finished(self): """Завершение экспорта""" self.btn_scan.config(state=tk.NORMAL) # Скрываем прогресс и кнопку self.progress_label.pack_forget() self.export_control_row.pack_forget() self.btn_stop_export.config(state=tk.DISABLED) self.progress_var.set("") current_status = self.status_var.get() if not current_status.startswith("✅") and not current_status.startswith("⏹️"): self.status_var.set("✅ Готов к работе") def _show_help(self): """Открывает окно инструкции""" HelpWindow(self.root) def _show_license(self): """Показывает лицензионное соглашение""" license_text = """ ЛИЦЕНЗИОННОЕ СОГЛАШЕНИЕ Настоящее соглашение является юридическим документом между Вами (физическим или юридическим лицом, далее — «Пользователь») и разработчиком программы «DiSc — DICOM Scanner» (далее — «Программа»). 1. Принятие условий Устанавливая, копируя или используя Программу, Вы подтверждаете, что ознакомились с условиями настоящего соглашения и принимаете их в полном объёме. Если Вы не согласны с условиями, Вы обязаны немедленно прекратить использование Программы и удалить все её компоненты. 2. Отказ от гарантий Программа предоставляется и распространяется на условиях «как есть» (AS IS) без какой-либо гарантии, явной или подразумеваемой. 3. Отказ от ответственности Разработчик ни при каких обстоятельствах не несёт ответственности за: — любые прямые или косвенные убытки, включая упущенную выгоду, потерю данных или ущерб деловой репутации; — последствия использования Программы в медицинской практике, включая ошибочные диагнозы, неправильные назначения или иные клинические решения; — несовместимость с конкретным программным или аппаратным обеспечением; — сбои, ошибки, задержки или прерывания в работе Программы; — утрату или повреждение данных. Пользователь самостоятельно несёт полную ответственность за проверку корректности всех полученных данных перед их использованием в клинической практике. 4. Изменения и обновления Разработчик оставляет за собой право изменять функциональность Программы или прекращать её поддержку без предварительного уведомления. 5. Интеллектуальная собственность Все исключительные права на Программу принадлежат разработчику. Разрешается свободное копирование и распространение неизменённой Программы в некоммерческих целях. Любое коммерческое использование или модификация Программы запрещены без письменного согласия разработчика. 6. Персональные данные Программа работает локально и не осуществляет сбор или передачу персональных данных на серверы разработчика. Все данные хранятся локально на компьютере пользователя. 7. Применимое право Настоящее соглашение регулируется законодательством Российской Федерации. Все споры подлежат рассмотрению в суде по месту нахождения разработчика. 8. Заключительные положения Настоящее соглашение вступает в силу с момента его принятия Пользователем и действует в течение всего срока использования Программы. © 2026, Шваб Олег Станиславович """ # Создаём окно (увеличиваем высоту) license_window = tk.Toplevel(self.root) license_window.title("📜 Лицензионное соглашение") license_window.geometry("700x650") license_window.minsize(600, 450) license_window.transient(self.root) license_window.grab_set() # Центрируем license_window.update_idletasks() x = (license_window.winfo_screenwidth() // 2) - (700 // 2) y = (license_window.winfo_screenheight() // 2) - (650 // 2) license_window.geometry(f"+{x}+{y}") # Основной фрейм main_frame = ttk.Frame(license_window, padding="20") main_frame.pack(fill=tk.BOTH, expand=True) # Заголовок ttk.Label( main_frame, text="📜 Лицензионное соглашение", font=('Segoe UI', 14, 'bold'), foreground='#0a2463' ).pack(anchor=tk.W, pady=(0, 5)) ttk.Label( main_frame, text="DiSc — DICOM Scanner", font=('Segoe UI', 10), foreground='#475569' ).pack(anchor=tk.W, pady=(0, 15)) # Разделитель ttk.Separator(main_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=5) # Текст лицензии с прокруткой text_frame = ttk.Frame(main_frame) text_frame.pack(fill=tk.BOTH, expand=True, pady=10) text_widget = tk.Text( text_frame, wrap=tk.WORD, font=('Segoe UI', 10), bg='#f8fafc', relief=tk.FLAT, padx=15, pady=15 ) text_widget.insert('1.0', license_text) text_widget.config(state=tk.DISABLED) scrollbar = ttk.Scrollbar(text_frame, orient=tk.VERTICAL, command=text_widget.yview) text_widget.configure(yscrollcommand=scrollbar.set) text_widget.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) # ===== КНОПКА (с большими отступами) ===== btn_frame = ttk.Frame(main_frame) btn_frame.pack(fill=tk.X, pady=(15, 30)) ttk.Button( btn_frame, text="OK", command=license_window.destroy, width=10 ).pack(side=tk.RIGHT, padx=5) def _donate(self): """Открывает страницу для поддержки автора""" import webbrowser webbrowser.open("https://pay.cloudtips.ru/p/3ae2fae8")