""" 物联网图片采集管理系统 - 主应用程序 """ import os import sys import customtkinter as ctk from tkinter import filedialog, messagebox import threading import json from datetime import datetime # 导入配置 from config import ( APPEARANCE_MODE, COLOR_THEME, WINDOW_WIDTH, WINDOW_HEIGHT, LEFT_PANEL_WIDTH, RIGHT_PANEL_WIDTH, ITEMS_PER_PAGE ) # 导入数据库管理器 from database import MySQLManager, SQLiteManager # 导入UI组件 from ui import FilterPanel, PreviewPanel, StatisticsPanel # 导入工具 from utils import ImageDownloader # 设置customtkinter外观 ctk.set_appearance_mode(APPEARANCE_MODE) ctk.set_default_color_theme(COLOR_THEME) class DataManagementApp: """物联网图片采集管理系统主应用""" def __init__(self, root): self.root = root self.root.title("数字味道 - 物联网图片采集管理系统") self.root.geometry(f"{WINDOW_WIDTH}x{WINDOW_HEIGHT}") # 数据库管理器 try: self.mysql_manager = MySQLManager() self.sqlite_manager = SQLiteManager() except Exception as e: messagebox.showerror("错误", f"数据库连接失败: {e}") sys.exit(1) # 图片下载器 self.image_downloader = ImageDownloader(self.sqlite_manager) # 当前筛选条件 self.current_filters = {} # 创建UI self._create_widgets() # 窗口关闭事件 self.root.protocol("WM_DELETE_WINDOW", self._on_closing) def _create_widgets(self): """创建UI组件""" # 主容器 main_container = ctk.CTkFrame(self.root) main_container.pack(fill="both", expand=True, padx=10, pady=10) # ==================== 左侧筛选面板 ==================== self.filter_panel = FilterPanel( main_container, on_query_callback=self._on_query, on_datasource_change_callback=self._on_datasource_changed, width=LEFT_PANEL_WIDTH ) self.filter_panel.pack(side="left", fill="y", padx=(0, 5)) # ==================== 中间预览面板 ==================== self.preview_panel = PreviewPanel( main_container, sqlite_manager=self.sqlite_manager, mysql_manager=self.mysql_manager, on_download_callback=self._on_download, on_delete_callback=self._on_refresh ) self.preview_panel.pack(side="left", fill="both", expand=True, padx=5) # 绑定分页事件 self.preview_panel.first_page_btn.configure(command=self._on_first_page) self.preview_panel.prev_btn.configure(command=self._on_prev_page) self.preview_panel.next_btn.configure(command=self._on_next_page) # ==================== 右侧统计面板 ==================== self.statistics_panel = StatisticsPanel( main_container, on_refresh_callback=self._on_refresh, on_download_history_callback=self._on_download_history, on_export_callback=self._on_export, on_settings_callback=self._on_settings, width=RIGHT_PANEL_WIDTH ) self.statistics_panel.pack(side="right", fill="y", padx=(5, 0)) def _on_query(self, filters: dict): """查询按钮回调""" self.current_filters = filters self._load_data(page=1) def _on_datasource_changed(self, datasource: str): """数据源切换回调""" self.statistics_panel.update_datasource(datasource) def _load_data(self, page: int = 1): """加载数据""" # 设置加载状态 self.filter_panel.set_loading_state(True) # 在后台线程中查询数据 def query_thread(): try: # 查询图片数据 data_list, total_count = self.mysql_manager.query_images( datasource=self.current_filters.get('datasource', '菜品'), start_date=self.current_filters.get('start_date'), end_date=self.current_filters.get('end_date'), goods_name=self.current_filters.get('goods_name'), equipment_code=self.current_filters.get('equipment_code'), page=page, page_size=ITEMS_PER_PAGE ) # 查询统计信息 stats = self.mysql_manager.get_statistics( datasource=self.current_filters.get('datasource', '菜品'), start_date=self.current_filters.get('start_date'), end_date=self.current_filters.get('end_date') ) # 在主线程中更新UI self.root.after(0, lambda: self._update_ui(data_list, total_count, stats, page)) except Exception as e: self.root.after(0, lambda: messagebox.showerror("错误", f"查询数据失败: {e}")) finally: self.root.after(0, lambda: self.filter_panel.set_loading_state(False)) threading.Thread(target=query_thread, daemon=True).start() def _update_ui(self, data_list: list, total_count: int, stats: dict, page: int): """更新UI""" # 更新预览面板 self.preview_panel.load_data(data_list, total_count, page) # 更新统计面板 self.statistics_panel.update_statistics(stats) def _on_first_page(self): """首页""" current_page = self.preview_panel.get_current_page() if current_page > 1: self._load_data(page=1) def _on_prev_page(self): """上一页""" current_page = self.preview_panel.get_current_page() if current_page > 1: self._load_data(page=current_page - 1) def _on_next_page(self): """下一页""" current_page = self.preview_panel.get_current_page() total_pages = (self.preview_panel.total_count + ITEMS_PER_PAGE - 1) // ITEMS_PER_PAGE if current_page < total_pages: self._load_data(page=current_page + 1) def _on_download(self, selected_data: list): """批量下载回调""" # 选择保存目录 save_directory = filedialog.askdirectory(title="选择保存目录") if not save_directory: return # 创建下载进度窗口 progress_window = ctk.CTkToplevel(self.root) progress_window.title("下载进度") progress_window.geometry("500x250") progress_window.transient(self.root) progress_window.grab_set() # 居中显示 progress_window.update_idletasks() x = (progress_window.winfo_screenwidth() // 2) - 250 y = (progress_window.winfo_screenheight() // 2) - 125 progress_window.geometry(f"500x250+{x}+{y}") # 标题 title_label = ctk.CTkLabel( progress_window, text="正在下载图片...", font=("Arial", 14, "bold") ) title_label.pack(pady=(20, 10)) # 进度标签 progress_label = ctk.CTkLabel( progress_window, text="准备下载...", font=("Arial", 12) ) progress_label.pack(pady=10) # 进度条 progress_bar = ctk.CTkProgressBar(progress_window, width=400) progress_bar.pack(pady=10) progress_bar.set(0) # 当前文件名 current_file_label = ctk.CTkLabel( progress_window, text="", font=("Arial", 10), text_color="gray" ) current_file_label.pack(pady=5) # 取消按钮 cancel_btn = ctk.CTkButton( progress_window, text="取消", command=lambda: self._cancel_download(progress_window), fg_color="red", hover_color="darkred" ) cancel_btn.pack(pady=10) # 进度回调 def progress_callback(current, total, filename): progress = current / total progress_bar.set(progress) progress_label.configure(text=f"进度: {current}/{total} ({int(progress * 100)}%)") current_file_label.configure(text=f"当前: {filename}") # 完成回调 def complete_callback(success_count, failed_count, error_messages): progress_window.destroy() # 显示结果 if failed_count == 0: messagebox.showinfo( "下载完成", f"成功下载 {success_count} 张图片!\n保存位置: {save_directory}" ) else: error_text = "\n".join(error_messages[:5]) # 只显示前5条错误 if len(error_messages) > 5: error_text += f"\n... 还有 {len(error_messages) - 5} 条错误" messagebox.showwarning( "下载完成", f"成功: {success_count} 张\n失败: {failed_count} 张\n\n错误信息:\n{error_text}" ) # 刷新预览面板(更新下载状态) self._on_refresh() # 开始下载 def start_download(): self.image_downloader.download_images( selected_data, save_directory, progress_callback=progress_callback, complete_callback=complete_callback ) threading.Thread(target=start_download, daemon=True).start() def _cancel_download(self, progress_window): """取消下载""" result = messagebox.askyesno("确认", "确定要取消下载吗?") if result: self.image_downloader.cancel() progress_window.destroy() def _on_refresh(self): """刷新数据""" if self.current_filters: current_page = self.preview_panel.get_current_page() self._load_data(page=current_page) else: messagebox.showinfo("提示", "请先执行查询操作") def _on_download_history(self): """查看下载历史""" # 创建下载历史窗口 history_window = ctk.CTkToplevel(self.root) history_window.title("下载历史") history_window.geometry("900x600") history_window.transient(self.root) # 居中显示 history_window.update_idletasks() x = (history_window.winfo_screenwidth() // 2) - 450 y = (history_window.winfo_screenheight() // 2) - 300 history_window.geometry(f"900x600+{x}+{y}") # 标题 title_label = ctk.CTkLabel( history_window, text="下载历史记录", font=("Arial", 16, "bold") ) title_label.pack(pady=(20, 10)) # 统计信息 download_stats = self.sqlite_manager.get_download_statistics() stats_text = f"总下载: {download_stats.get('total_count', 0)} 张 | 今日: {download_stats.get('today_count', 0)} 张" stats_label = ctk.CTkLabel( history_window, text=stats_text, font=("Arial", 12), text_color="gray" ) stats_label.pack(pady=5) # 历史记录列表 history_scroll = ctk.CTkScrollableFrame(history_window) history_scroll.pack(fill="both", expand=True, padx=20, pady=10) # 加载历史记录 history_records = self.sqlite_manager.get_download_history(limit=100) if not history_records: no_data_label = ctk.CTkLabel( history_scroll, text="暂无下载历史", font=("Arial", 12), text_color="gray" ) no_data_label.pack(pady=50) else: for record in history_records: record_frame = ctk.CTkFrame(history_scroll) record_frame.pack(fill="x", padx=10, pady=5) info_text = f""" 数据源: {record['datasource']} | 物品: {record['goods_name']} 下载时间: {record['download_time']} 本地路径: {record['local_path']} """.strip() info_label = ctk.CTkLabel( record_frame, text=info_text, font=("Arial", 10), justify="left", anchor="w" ) info_label.pack(fill="x", padx=10, pady=5) # 底部按钮 bottom_frame = ctk.CTkFrame(history_window) bottom_frame.pack(fill="x", padx=20, pady=(0, 20)) # 清理无效记录按钮 clean_btn = ctk.CTkButton( bottom_frame, text="清理无效记录", command=lambda: self._clean_invalid_records(history_window), fg_color="orange", hover_color="darkorange" ) clean_btn.pack(side="left", padx=10, pady=10) # 关闭按钮 close_btn = ctk.CTkButton( bottom_frame, text="关闭", command=history_window.destroy, fg_color="gray", hover_color="darkgray" ) close_btn.pack(side="right", padx=10, pady=10) def _clean_invalid_records(self, window): """清理无效记录""" result = messagebox.askyesno( "确认", "将清理本地文件已被删除的下载记录,确定继续吗?" ) if result: deleted_count = self.sqlite_manager.clear_invalid_records() messagebox.showinfo("完成", f"已清理 {deleted_count} 条无效记录") window.destroy() self._on_download_history() # 重新打开窗口 def _on_export(self): """导出数据""" if not self.current_filters: messagebox.showinfo("提示", "请先执行查询操作") return # 选择保存文件 file_path = filedialog.asksaveasfilename( title="导出数据", defaultextension=".json", filetypes=[ ("JSON文件", "*.json"), ("CSV文件", "*.csv"), ("所有文件", "*.*") ] ) if not file_path: return try: # 查询所有数据(不分页) all_data, total_count = self.mysql_manager.query_images( datasource=self.current_filters.get('datasource', '菜品'), start_date=self.current_filters.get('start_date'), end_date=self.current_filters.get('end_date'), goods_name=self.current_filters.get('goods_name'), equipment_code=self.current_filters.get('equipment_code'), page=1, page_size=10000 # 大数量 ) if file_path.endswith('.json'): # 导出为JSON with open(file_path, 'w', encoding='utf-8') as f: json.dump(all_data, f, ensure_ascii=False, indent=2) elif file_path.endswith('.csv'): # 导出为CSV import csv with open(file_path, 'w', newline='', encoding='utf-8-sig') as f: if all_data: writer = csv.DictWriter(f, fieldnames=all_data[0].keys()) writer.writeheader() writer.writerows(all_data) messagebox.showinfo("成功", f"已导出 {len(all_data)} 条数据到:\n{file_path}") except Exception as e: messagebox.showerror("错误", f"导出失败: {e}") def _on_settings(self): """设置""" # 创建设置窗口 settings_window = ctk.CTkToplevel(self.root) settings_window.title("设置") settings_window.geometry("500x400") settings_window.transient(self.root) settings_window.grab_set() # 居中显示 settings_window.update_idletasks() x = (settings_window.winfo_screenwidth() // 2) - 250 y = (settings_window.winfo_screenheight() // 2) - 200 settings_window.geometry(f"500x400+{x}+{y}") # 标题 title_label = ctk.CTkLabel( settings_window, text="系统设置", font=("Arial", 16, "bold") ) title_label.pack(pady=(20, 10)) # 外观模式 appearance_frame = ctk.CTkFrame(settings_window) appearance_frame.pack(fill="x", padx=20, pady=10) appearance_label = ctk.CTkLabel( appearance_frame, text="外观模式:", font=("Arial", 12) ) appearance_label.pack(side="left", padx=10, pady=10) appearance_menu = ctk.CTkOptionMenu( appearance_frame, values=["System", "Light", "Dark"], command=lambda mode: ctk.set_appearance_mode(mode) ) appearance_menu.pack(side="left", padx=10, pady=10) appearance_menu.set(APPEARANCE_MODE) # 说明文本 info_text = """ 系统信息: - 数据库: MySQL (生产环境,只读) - 下载历史: SQLite (本地存储) - 图片URL前缀: http://dev.yixiong-tech.com:8081 - 并发下载数: 5 """.strip() info_label = ctk.CTkLabel( settings_window, text=info_text, font=("Arial", 11), justify="left", anchor="w" ) info_label.pack(fill="x", padx=30, pady=20) # 关闭按钮 close_btn = ctk.CTkButton( settings_window, text="关闭", command=settings_window.destroy ) close_btn.pack(pady=20) def _on_closing(self): """窗口关闭事件""" result = messagebox.askyesno("确认", "确定要退出系统吗?") if result: # 清理图片加载器资源 from utils.image_loader import cleanup_resources cleanup_resources() # 关闭数据库连接 self.mysql_manager.close() self.root.destroy() def main(): """主函数""" root = ctk.CTk() app = DataManagementApp(root) root.mainloop() if __name__ == "__main__": main()