Files
FoodClassifier/data_management/app.py
T

536 lines
18 KiB
Python

"""
物联网图片采集管理系统 - 主应用程序
"""
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()