From 1bedc16bd8e4ab034953d0e1f44e1bba7a1f6c69 Mon Sep 17 00:00:00 2001 From: zhangpu <1250681871@qq.com> Date: Thu, 27 Nov 2025 10:46:53 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BA=86=E6=9F=A5=E7=9C=8B?= =?UTF-8?q?=E5=85=A5=E5=BA=93=E7=A7=A4=E4=B8=8A=E4=BC=A0=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=9A=84GUI=E7=95=8C=E9=9D=A2=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + data_management/README.md | 212 +++++++++ data_management/__init__.py | 5 + data_management/app.py | 522 +++++++++++++++++++++ data_management/config.py | 73 +++ data_management/database/__init__.py | 7 + data_management/database/mysql_manager.py | 313 ++++++++++++ data_management/database/sqlite_manager.py | 250 ++++++++++ data_management/ui/__init__.py | 8 + data_management/ui/filter_panel.py | 278 +++++++++++ data_management/ui/preview_panel.py | 469 ++++++++++++++++++ data_management/ui/statistics_panel.py | 296 ++++++++++++ data_management/utils/__init__.py | 7 + data_management/utils/image_downloader.py | 182 +++++++ data_management/utils/image_loader.py | 78 +++ requirements.txt | 2 + 16 files changed, 2703 insertions(+) create mode 100644 data_management/README.md create mode 100644 data_management/__init__.py create mode 100644 data_management/app.py create mode 100644 data_management/config.py create mode 100644 data_management/database/__init__.py create mode 100644 data_management/database/mysql_manager.py create mode 100644 data_management/database/sqlite_manager.py create mode 100644 data_management/ui/__init__.py create mode 100644 data_management/ui/filter_panel.py create mode 100644 data_management/ui/preview_panel.py create mode 100644 data_management/ui/statistics_panel.py create mode 100644 data_management/utils/__init__.py create mode 100644 data_management/utils/image_downloader.py create mode 100644 data_management/utils/image_loader.py diff --git a/.gitignore b/.gitignore index 4169aaf..97c323a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ /faiss_vector_db/DishClassification/faiss_index101001/ /faiss_vector_db/faiss_index*/ /faiss_vector_db/WholeIngredientRecognition/faiss_index*/ +/data_management/download_history.db diff --git a/data_management/README.md b/data_management/README.md new file mode 100644 index 0000000..ed7b37b --- /dev/null +++ b/data_management/README.md @@ -0,0 +1,212 @@ +# 数字味道 - 物联网图片采集管理系统 + +## 项目简介 + +本系统用于管理物联网入库秤采集的图片数据,提供可视化查看、筛选和批量下载功能。 + +## 功能特性 + +### 1. 数据源管理 +- **菜品**:档口秤采集的菜品图片 +- **食材**:入库秤采集的食材图片 +- **半加工**:净材秤采集的半加工食材图片 + +### 2. 多维度筛选 +- 时间范围筛选(支持快捷选项:今日/最近3天/7天/30天) +- 物品名称搜索(模糊匹配) +- 设备编码筛选(精确匹配) + +### 3. 图片预览 +- 网格布局(5列)显示缩略图 +- 显示物品名称、采集时间、设备编码 +- 已下载图片显示✅标记 +- 支持点击查看大图 + +### 4. 批量下载 +- 多选图片批量下载 +- 自动按分类创建子文件夹 +- 文件命名规则:`{分类}_{时间戳}_{ID}.jpg` +- 多线程并发下载(5个线程) +- 实时进度显示 + +### 5. 统计分析 +- 查询结果统计 +- 时间分布统计(今日/本周/本月) +- TOP 10 物品排行榜 + +### 6. 下载历史 +- 本地SQLite数据库记录下载历史 +- 防止重复下载 +- 支持清理无效记录 + +### 7. 数据导出 +- 支持导出为JSON/CSV格式 +- 可导出当前筛选结果 + +## 技术架构 + +### 数据库 +- **MySQL**: 生产数据库(只读) + - 地址: 192.168.1.250:3308 + - 数据库: yx_terminal + - 表: + - `dish_collection_vector_data_info` (菜品) + - `inbound_weighing_scale_vector_data_info` (食材) + - `dish_xxx` (半加工,待替换) + +- **SQLite**: 本地下载历史数据库 + - 路径: `data_management/download_history.db` + - 表: `download_history` + +### 技术栈 +- **UI框架**: CustomTkinter +- **ORM**: SQLAlchemy +- **MySQL驱动**: PyMySQL +- **网络请求**: Requests +- **图片处理**: Pillow + +### 项目结构 +``` +data_management/ +├── __init__.py +├── config.py # 配置文件 +├── app.py # 主应用程序 +├── database/ # 数据库模块 +│ ├── __init__.py +│ ├── mysql_manager.py # MySQL管理器 +│ └── sqlite_manager.py # SQLite管理器 +├── ui/ # UI组件 +│ ├── __init__.py +│ ├── filter_panel.py # 左侧筛选面板 +│ ├── preview_panel.py # 中间预览面板 +│ └── statistics_panel.py # 右侧统计面板 +├── utils/ # 工具模块 +│ ├── __init__.py +│ ├── image_downloader.py # 图片下载器 +│ └── image_loader.py # 图片加载器 +├── download_history.db # SQLite数据库(自动生成) +├── run.bat # Windows启动脚本 +└── README.md # 本文件 +``` + +## 安装依赖 + +确保已安装以下Python包: + +```bash +pip install customtkinter +pip install sqlalchemy +pip install pymysql +pip install requests +pip install pillow +``` + +或者使用项目根目录的 `requirements.txt`(如果已包含相关依赖)。 + +## 运行方法 + +### Windows +双击运行 `run.bat` 脚本 + +### 命令行 +```bash +cd data_management +python app.py +``` + +## 使用说明 + +### 1. 查询图片 +1. 选择数据源(菜品/食材/半加工) +2. 设置筛选条件(时间、物品名称、设备编码) +3. 点击"查询"按钮 + +### 2. 下载图片 +1. 在预览区勾选需要下载的图片 +2. 点击"批量下载"按钮 +3. 选择保存目录 +4. 等待下载完成 + +### 3. 查看下载历史 +1. 点击右侧"📜 下载历史"按钮 +2. 查看历史记录 +3. 可清理无效记录(本地文件已删除的记录) + +### 4. 导出数据 +1. 执行查询操作 +2. 点击"📊 导出数据"按钮 +3. 选择保存格式(JSON/CSV) +4. 选择保存位置 + +## 配置说明 + +主要配置在 `config.py` 文件中: + +### MySQL配置 +```python +MYSQL_CONFIG = { + 'host': '192.168.1.250', + 'port': 3308, + 'user': 'root', + 'password': 'Aa135790123', + 'database': 'yx_terminal', +} +``` + +### 下载配置 +```python +DOWNLOAD_THREADS = 5 # 并发下载数 +DOWNLOAD_TIMEOUT = 30 # 下载超时时间(秒) +CREATE_SUBFOLDERS = True # 是否按分类创建子文件夹 +``` + +### UI配置 +```python +WINDOW_WIDTH = 1600 # 窗口宽度 +WINDOW_HEIGHT = 900 # 窗口高度 +GRID_COLUMNS = 5 # 网格列数 +THUMBNAIL_SIZE = 150 # 缩略图尺寸 +ITEMS_PER_PAGE = 20 # 每页显示数量 +``` + +## 注意事项 + +1. **网络连接**: 确保能连接到 192.168.1.250:3308 的MySQL服务器 +2. **图片URL**: 图片URL前缀为 `http://dev.yixiong-tech.com:8081` +3. **只读访问**: 系统不会修改生产MySQL数据库 +4. **下载状态**: 下载记录保存在本地SQLite数据库 +5. **文件命名**: 下载的文件会自动重命名,避免冲突 +6. **半加工表名**: 当前为 `dish_xxx`,待后续替换为实际表名 + +## 常见问题 + +### Q: 无法连接到MySQL数据库 +A: 检查网络连接和防火墙设置,确保能访问 192.168.1.250:3308 + +### Q: 图片加载失败 +A: 检查图片URL是否正确,确保能访问 `http://dev.yixiong-tech.com:8081` + +### Q: 下载速度慢 +A: 可在 `config.py` 中调整 `DOWNLOAD_THREADS` 参数增加并发数 + +### Q: 如何修改半加工表名 +A: 修改 `config.py` 中的 `TABLE_MAPPING` 字典,将 `'半加工': 'dish_xxx'` 改为实际表名 + +## 更新日志 + +### v1.0.0 (2025-11-27) +- ✅ 初始版本发布 +- ✅ 三栏式界面设计 +- ✅ 多维度筛选功能 +- ✅ 批量下载功能 +- ✅ 下载历史记录 +- ✅ 统计分析功能 +- ✅ 数据导出功能 + +## 作者 + +数字味道团队 + +## 许可证 + +内部使用项目 diff --git a/data_management/__init__.py b/data_management/__init__.py new file mode 100644 index 0000000..3bae832 --- /dev/null +++ b/data_management/__init__.py @@ -0,0 +1,5 @@ +""" +物联网图片采集管理系统 +用于从MySQL数据库读取图片URL并进行可视化管理和下载 +""" +__version__ = "1.0.0" diff --git a/data_management/app.py b/data_management/app.py new file mode 100644 index 0000000..252af20 --- /dev/null +++ b/data_management/app.py @@ -0,0 +1,522 @@ +""" +物联网图片采集管理系统 - 主应用程序 +""" +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, + on_download_callback=self._on_download + ) + self.preview_panel.pack(side="left", fill="both", expand=True, padx=5) + + # 绑定分页事件 + 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_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: + # 关闭数据库连接 + self.mysql_manager.close() + self.root.destroy() + + +def main(): + """主函数""" + root = ctk.CTk() + app = DataManagementApp(root) + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/data_management/config.py b/data_management/config.py new file mode 100644 index 0000000..323236f --- /dev/null +++ b/data_management/config.py @@ -0,0 +1,73 @@ +""" +配置文件 +""" +import os + +# ======================== MySQL数据库配置 ======================== +MYSQL_CONFIG = { + 'host': '192.168.1.250', + 'port': 3308, + 'user': 'root', + 'password': 'Aa135790123', + 'database': 'yx_terminal', + 'charset': 'utf8mb4' +} + +# ======================== 数据表映射 ======================== +TABLE_MAPPING = { + '菜品': 'dish_collection_vector_data_info', + '食材': 'inbound_weighing_scale_vector_data_info', + '半加工': 'dish_xxx' # 待替换为实际表名 +} + +# ======================== 图片URL前缀 ======================== +IMAGE_URL_PREFIX = 'http://dev.yixiong-tech.com:8081' + +# ======================== SQLite下载历史数据库 ======================== +# 存储在项目的data_management目录下 +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +SQLITE_DB_PATH = os.path.join(BASE_DIR, 'download_history.db') + +# ======================== UI配置 ======================== +# 外观主题 +APPEARANCE_MODE = "System" # "System", "Dark", "Light" +COLOR_THEME = "blue" # "blue", "green", "dark-blue" + +# 窗口尺寸 +WINDOW_WIDTH = 1600 +WINDOW_HEIGHT = 900 + +# 左侧筛选面板宽度 +LEFT_PANEL_WIDTH = 280 + +# 右侧统计面板宽度 +RIGHT_PANEL_WIDTH = 300 + +# 图片网格配置 +GRID_COLUMNS = 5 # 每行显示5张图片 +THUMBNAIL_SIZE = 150 # 缩略图尺寸 +CARD_PADDING = 10 # 卡片间距 + +# 分页配置 +ITEMS_PER_PAGE = 20 # 每页显示20张图片(4行) + +# ======================== 下载配置 ======================== +# 并发下载数 +DOWNLOAD_THREADS = 5 + +# 下载超时时间(秒) +DOWNLOAD_TIMEOUT = 30 + +# 文件命名规则: {分类}_{时间戳}_{ID}.jpg +# 示例: 食材_20231127_123456_1001.jpg + +# 目录结构选项 +CREATE_SUBFOLDERS = True # 是否按分类创建子文件夹 + +# ======================== 时间快捷选项 ======================== +TIME_SHORTCUTS = { + '今日': 0, + '最近3天': 3, + '最近7天': 7, + '最近30天': 30 +} diff --git a/data_management/database/__init__.py b/data_management/database/__init__.py new file mode 100644 index 0000000..ffe7a28 --- /dev/null +++ b/data_management/database/__init__.py @@ -0,0 +1,7 @@ +""" +数据库模块 +""" +from .mysql_manager import MySQLManager +from .sqlite_manager import SQLiteManager + +__all__ = ['MySQLManager', 'SQLiteManager'] diff --git a/data_management/database/mysql_manager.py b/data_management/database/mysql_manager.py new file mode 100644 index 0000000..9a42f9f --- /dev/null +++ b/data_management/database/mysql_manager.py @@ -0,0 +1,313 @@ +""" +MySQL数据库管理器 +使用SQLAlchemy ORM连接生产数据库(只读) +""" +import os +from datetime import datetime, timedelta +from typing import List, Dict, Optional, Tuple +from sqlalchemy import create_engine, Column, Integer, String, DateTime, Text +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker, Session +from sqlalchemy.pool import QueuePool +import sys + +# 添加父目录到路径 +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from config import MYSQL_CONFIG, TABLE_MAPPING, IMAGE_URL_PREFIX + +Base = declarative_base() + + +class DishCollection(Base): + """菜品采集表模型""" + __tablename__ = TABLE_MAPPING['菜品'] + + id = Column(Integer, primary_key=True, autoincrement=True) + goods_id = Column(Integer, nullable=True, comment='物品ID') + goods_name = Column(String(255), nullable=True, comment='物品名称') + food_pic = Column(String(500), nullable=True, comment='图片URL') + equipment_code = Column(String(100), nullable=True, comment='设备编码') + create_time = Column(DateTime, nullable=True, comment='创建时间') + + +class InboundWeighing(Base): + """食材入库秤表模型""" + __tablename__ = TABLE_MAPPING['食材'] + + id = Column(Integer, primary_key=True, autoincrement=True) + goods_id = Column(Integer, nullable=True, comment='物品ID') + goods_name = Column(String(255), nullable=True, comment='物品名称') + food_pic = Column(String(500), nullable=True, comment='图片URL') + equipment_code = Column(String(100), nullable=True, comment='设备编码') + create_time = Column(DateTime, nullable=True, comment='创建时间') + + +class ProcessedIngredient(Base): + """半加工食材表模型(待替换表名)""" + __tablename__ = TABLE_MAPPING['半加工'] + + id = Column(Integer, primary_key=True, autoincrement=True) + goods_id = Column(Integer, nullable=True, comment='物品ID') + goods_name = Column(String(255), nullable=True, comment='物品名称') + food_pic = Column(String(500), nullable=True, comment='图片URL') + equipment_code = Column(String(100), nullable=True, comment='设备编码') + create_time = Column(DateTime, nullable=True, comment='创建时间') + + +class MySQLManager: + """MySQL数据库管理器(只读)""" + + def __init__(self): + """初始化数据库连接""" + self.engine = None + self.Session = None + self._connect() + + def _connect(self): + """建立数据库连接""" + try: + # 构建连接字符串 + connection_string = ( + f"mysql+pymysql://{MYSQL_CONFIG['user']}:{MYSQL_CONFIG['password']}" + f"@{MYSQL_CONFIG['host']}:{MYSQL_CONFIG['port']}" + f"/{MYSQL_CONFIG['database']}?charset={MYSQL_CONFIG['charset']}" + ) + + # 创建引擎(使用连接池) + self.engine = create_engine( + connection_string, + poolclass=QueuePool, + pool_size=5, + max_overflow=10, + pool_recycle=3600, + echo=False # 设置为True可以看到SQL语句 + ) + + # 创建Session工厂 + self.Session = sessionmaker(bind=self.engine) + + print("MySQL连接成功!") + + except Exception as e: + print(f"MySQL连接失败: {e}") + raise + + def get_model_by_datasource(self, datasource: str): + """根据数据源获取对应的模型""" + model_mapping = { + '菜品': DishCollection, + '食材': InboundWeighing, + '半加工': ProcessedIngredient + } + return model_mapping.get(datasource) + + def query_images( + self, + datasource: str, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + goods_name: Optional[str] = None, + equipment_code: Optional[str] = None, + page: int = 1, + page_size: int = 20 + ) -> Tuple[List[Dict], int]: + """ + 查询图片数据 + + Args: + datasource: 数据源('菜品'/'食材'/'半加工') + start_date: 开始日期 + end_date: 结束日期 + goods_name: 物品名称(模糊搜索) + equipment_code: 设备编码(精确匹配) + page: 页码(从1开始) + page_size: 每页数量 + + Returns: + (数据列表, 总数量) + """ + session: Session = self.Session() + try: + # 获取对应的模型 + Model = self.get_model_by_datasource(datasource) + if Model is None: + return [], 0 + + # 构建查询 + query = session.query(Model) + + # 时间范围筛选 + if start_date: + query = query.filter(Model.create_time >= start_date) + if end_date: + # 结束日期包含当天,所以需要加1天 + end_date_inclusive = end_date + timedelta(days=1) + query = query.filter(Model.create_time < end_date_inclusive) + + # 物品名称筛选(模糊搜索) + if goods_name: + query = query.filter(Model.goods_name.like(f'%{goods_name}%')) + + # 设备编码筛选(精确匹配) + if equipment_code: + query = query.filter(Model.equipment_code == equipment_code) + + # 只查询有图片的记录 + query = query.filter(Model.food_pic.isnot(None)) + query = query.filter(Model.food_pic != '') + + # 获取总数 + total_count = query.count() + + # 按时间倒序排列 + query = query.order_by(Model.create_time.desc()) + + # 分页 + offset = (page - 1) * page_size + query = query.limit(page_size).offset(offset) + + # 执行查询 + results = query.all() + + # 转换为字典列表 + data_list = [] + for item in results: + # 拼接完整图片URL + full_image_url = f"{IMAGE_URL_PREFIX}{item.food_pic}" if item.food_pic else "" + + data_list.append({ + 'id': item.id, + 'goods_id': item.goods_id, + 'goods_name': item.goods_name or '未知', + 'image_url': full_image_url, + 'equipment_code': item.equipment_code or '', + 'create_time': item.create_time.strftime('%Y-%m-%d %H:%M:%S') if item.create_time else '', + 'datasource': datasource + }) + + return data_list, total_count + + except Exception as e: + print(f"查询数据出错: {e}") + return [], 0 + finally: + session.close() + + def get_goods_names(self, datasource: str, limit: int = 100) -> List[str]: + """ + 获取物品名称列表(用于搜索框自动补全) + + Args: + datasource: 数据源 + limit: 最多返回多少个 + + Returns: + 物品名称列表 + """ + session: Session = self.Session() + try: + Model = self.get_model_by_datasource(datasource) + if Model is None: + return [] + + # 查询不重复的物品名称 + results = session.query(Model.goods_name)\ + .filter(Model.goods_name.isnot(None))\ + .filter(Model.goods_name != '')\ + .distinct()\ + .limit(limit)\ + .all() + + return [item[0] for item in results] + + except Exception as e: + print(f"获取物品名称列表出错: {e}") + return [] + finally: + session.close() + + def get_statistics( + self, + datasource: str, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None + ) -> Dict: + """ + 获取统计信息 + + Returns: + 统计数据字典 + """ + session: Session = self.Session() + try: + Model = self.get_model_by_datasource(datasource) + if Model is None: + return {} + + # 基础查询 + base_query = session.query(Model)\ + .filter(Model.food_pic.isnot(None))\ + .filter(Model.food_pic != '') + + if start_date: + base_query = base_query.filter(Model.create_time >= start_date) + if end_date: + end_date_inclusive = end_date + timedelta(days=1) + base_query = base_query.filter(Model.create_time < end_date_inclusive) + + # 总数 + total_count = base_query.count() + + # 今日数量 + today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + today_count = base_query.filter(Model.create_time >= today_start).count() + + # 本周数量 + week_start = today_start - timedelta(days=today_start.weekday()) + week_count = base_query.filter(Model.create_time >= week_start).count() + + # 本月数量 + month_start = today_start.replace(day=1) + month_count = base_query.filter(Model.create_time >= month_start).count() + + # TOP 10物品统计 + from sqlalchemy import func + top_items = session.query( + Model.goods_name, + func.count(Model.id).label('count') + ).filter( + Model.food_pic.isnot(None), + Model.food_pic != '', + Model.goods_name.isnot(None), + Model.goods_name != '' + ) + + if start_date: + top_items = top_items.filter(Model.create_time >= start_date) + if end_date: + top_items = top_items.filter(Model.create_time < end_date + timedelta(days=1)) + + top_items = top_items.group_by(Model.goods_name)\ + .order_by(func.count(Model.id).desc())\ + .limit(10)\ + .all() + + return { + 'total_count': total_count, + 'today_count': today_count, + 'week_count': week_count, + 'month_count': month_count, + 'top_items': [{'name': item[0], 'count': item[1]} for item in top_items] + } + + except Exception as e: + print(f"获取统计信息出错: {e}") + return {} + finally: + session.close() + + def close(self): + """关闭数据库连接""" + if self.engine: + self.engine.dispose() + print("MySQL连接已关闭") diff --git a/data_management/database/sqlite_manager.py b/data_management/database/sqlite_manager.py new file mode 100644 index 0000000..fb5db7f --- /dev/null +++ b/data_management/database/sqlite_manager.py @@ -0,0 +1,250 @@ +""" +SQLite下载历史管理器 +用于记录图片下载历史,不修改生产MySQL数据库 +""" +import os +import sqlite3 +from datetime import datetime +from typing import List, Dict, Optional +import sys + +# 添加父目录到路径 +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from config import SQLITE_DB_PATH + + +class SQLiteManager: + """SQLite下载历史管理器""" + + def __init__(self, db_path: str = SQLITE_DB_PATH): + """初始化SQLite连接""" + self.db_path = db_path + self._init_database() + + def _init_database(self): + """初始化数据库表结构""" + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + # 创建下载历史表 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS download_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + datasource TEXT NOT NULL, + goods_id INTEGER, + goods_name TEXT, + image_url TEXT NOT NULL, + local_path TEXT NOT NULL, + download_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + file_size INTEGER, + UNIQUE(datasource, image_url) + ) + ''') + + # 创建索引 + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_datasource_goods_id + ON download_history(datasource, goods_id) + ''') + + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_download_time + ON download_history(download_time) + ''') + + conn.commit() + conn.close() + + print(f"SQLite数据库初始化成功: {self.db_path}") + + except Exception as e: + print(f"SQLite数据库初始化失败: {e}") + raise + + def add_download_record( + self, + datasource: str, + goods_id: int, + goods_name: str, + image_url: str, + local_path: str, + file_size: int + ) -> bool: + """ + 添加下载记录 + + Returns: + 是否成功 + """ + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute(''' + INSERT OR REPLACE INTO download_history + (datasource, goods_id, goods_name, image_url, local_path, file_size, download_time) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', ( + datasource, + goods_id, + goods_name, + image_url, + local_path, + file_size, + datetime.now() + )) + + conn.commit() + conn.close() + return True + + except Exception as e: + print(f"添加下载记录失败: {e}") + return False + + def is_downloaded(self, datasource: str, image_url: str) -> bool: + """ + 检查图片是否已下载(检查数据库记录 + 文件是否存在) + + Args: + datasource: 数据源 + image_url: 图片URL + + Returns: + 是否已下载 + """ + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute(''' + SELECT local_path FROM download_history + WHERE datasource = ? AND image_url = ? + ''', (datasource, image_url)) + + result = cursor.fetchone() + conn.close() + + if result: + local_path = result[0] + # 检查文件是否真实存在 + return os.path.exists(local_path) + + return False + + except Exception as e: + print(f"检查下载状态失败: {e}") + return False + + def get_download_history( + self, + datasource: Optional[str] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + limit: int = 100 + ) -> List[Dict]: + """ + 获取下载历史 + + Returns: + 下载记录列表 + """ + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + query = 'SELECT * FROM download_history WHERE 1=1' + params = [] + + if datasource: + query += ' AND datasource = ?' + params.append(datasource) + + if start_date: + query += ' AND download_time >= ?' + params.append(start_date.strftime('%Y-%m-%d %H:%M:%S')) + + if end_date: + query += ' AND download_time <= ?' + params.append(end_date.strftime('%Y-%m-%d %H:%M:%S')) + + query += ' ORDER BY download_time DESC LIMIT ?' + params.append(limit) + + cursor.execute(query, params) + + columns = [desc[0] for desc in cursor.description] + results = [] + for row in cursor.fetchall(): + results.append(dict(zip(columns, row))) + + conn.close() + return results + + except Exception as e: + print(f"获取下载历史失败: {e}") + return [] + + def get_download_statistics(self) -> Dict: + """获取下载统计信息""" + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + # 总下载数 + cursor.execute('SELECT COUNT(*) FROM download_history') + total_count = cursor.fetchone()[0] + + # 各数据源下载数 + cursor.execute(''' + SELECT datasource, COUNT(*) as count + FROM download_history + GROUP BY datasource + ''') + datasource_stats = {row[0]: row[1] for row in cursor.fetchall()} + + # 今日下载数 + today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + cursor.execute(''' + SELECT COUNT(*) FROM download_history + WHERE download_time >= ? + ''', (today.strftime('%Y-%m-%d %H:%M:%S'),)) + today_count = cursor.fetchone()[0] + + conn.close() + + return { + 'total_count': total_count, + 'datasource_stats': datasource_stats, + 'today_count': today_count + } + + except Exception as e: + print(f"获取下载统计失败: {e}") + return {} + + def clear_invalid_records(self) -> int: + """清理文件不存在的下载记录""" + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute('SELECT id, local_path FROM download_history') + records = cursor.fetchall() + + deleted_count = 0 + for record_id, local_path in records: + if not os.path.exists(local_path): + cursor.execute('DELETE FROM download_history WHERE id = ?', (record_id,)) + deleted_count += 1 + + conn.commit() + conn.close() + + print(f"已清理 {deleted_count} 条无效记录") + return deleted_count + + except Exception as e: + print(f"清理无效记录失败: {e}") + return 0 diff --git a/data_management/ui/__init__.py b/data_management/ui/__init__.py new file mode 100644 index 0000000..b411460 --- /dev/null +++ b/data_management/ui/__init__.py @@ -0,0 +1,8 @@ +""" +UI模块 +""" +from .filter_panel import FilterPanel +from .preview_panel import PreviewPanel +from .statistics_panel import StatisticsPanel + +__all__ = ['FilterPanel', 'PreviewPanel', 'StatisticsPanel'] diff --git a/data_management/ui/filter_panel.py b/data_management/ui/filter_panel.py new file mode 100644 index 0000000..f0ede9f --- /dev/null +++ b/data_management/ui/filter_panel.py @@ -0,0 +1,278 @@ +""" +左侧筛选面板 +""" +import customtkinter as ctk +from datetime import datetime, timedelta +from typing import Callable, Optional +import sys +import os + +# 添加父目录到路径 +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from config import TIME_SHORTCUTS + + +class FilterPanel(ctk.CTkFrame): + """左侧筛选面板""" + + def __init__( + self, + parent, + on_query_callback: Callable, + on_datasource_change_callback: Optional[Callable] = None, + **kwargs + ): + """ + 初始化筛选面板 + + Args: + parent: 父窗口 + on_query_callback: 查询回调函数 + on_datasource_change_callback: 数据源切换回调函数 + """ + super().__init__(parent, **kwargs) + + self.on_query_callback = on_query_callback + self.on_datasource_change_callback = on_datasource_change_callback + + # 创建UI组件 + self._create_widgets() + + def _create_widgets(self): + """创建UI组件""" + # 标题 + title_label = ctk.CTkLabel( + self, + text="筛选条件", + font=("Arial", 16, "bold") + ) + title_label.pack(pady=(15, 10), padx=15) + + # ==================== 数据源选择 ==================== + datasource_frame = ctk.CTkFrame(self) + datasource_frame.pack(fill="x", padx=15, pady=(0, 10)) + + datasource_label = ctk.CTkLabel( + datasource_frame, + text="数据源:", + font=("Arial", 12, "bold") + ) + datasource_label.pack(pady=(10, 5), padx=10, anchor="w") + + # 数据源单选按钮 + self.datasource_var = ctk.StringVar(value="菜品") + + datasource_options = ["菜品", "食材", "半加工"] + for option in datasource_options: + radio_btn = ctk.CTkRadioButton( + datasource_frame, + text=option, + variable=self.datasource_var, + value=option, + command=self._on_datasource_changed + ) + radio_btn.pack(pady=3, padx=20, anchor="w") + + # ==================== 时间范围 ==================== + time_frame = ctk.CTkFrame(self) + time_frame.pack(fill="x", padx=15, pady=(0, 10)) + + time_label = ctk.CTkLabel( + time_frame, + text="时间范围:", + font=("Arial", 12, "bold") + ) + time_label.pack(pady=(10, 5), padx=10, anchor="w") + + # 快捷时间按钮 + shortcuts_frame = ctk.CTkFrame(time_frame) + shortcuts_frame.pack(fill="x", padx=10, pady=(0, 5)) + + for text, days in TIME_SHORTCUTS.items(): + btn = ctk.CTkButton( + shortcuts_frame, + text=text, + command=lambda d=days: self._set_time_shortcut(d), + width=60, + height=25, + font=("Arial", 10) + ) + btn.pack(side="left", padx=2, pady=5) + + # 开始日期 + start_date_label = ctk.CTkLabel( + time_frame, + text="开始日期:", + font=("Arial", 11) + ) + start_date_label.pack(pady=(5, 2), padx=10, anchor="w") + + self.start_date_entry = ctk.CTkEntry( + time_frame, + placeholder_text="YYYY-MM-DD", + width=200 + ) + self.start_date_entry.pack(pady=(0, 5), padx=10) + + # 结束日期 + end_date_label = ctk.CTkLabel( + time_frame, + text="结束日期:", + font=("Arial", 11) + ) + end_date_label.pack(pady=(5, 2), padx=10, anchor="w") + + self.end_date_entry = ctk.CTkEntry( + time_frame, + placeholder_text="YYYY-MM-DD", + width=200 + ) + self.end_date_entry.pack(pady=(0, 10), padx=10) + + # ==================== 物品筛选 ==================== + goods_frame = ctk.CTkFrame(self) + goods_frame.pack(fill="x", padx=15, pady=(0, 10)) + + goods_label = ctk.CTkLabel( + goods_frame, + text="物品筛选:", + font=("Arial", 12, "bold") + ) + goods_label.pack(pady=(10, 5), padx=10, anchor="w") + + self.goods_search_entry = ctk.CTkEntry( + goods_frame, + placeholder_text="输入物品名称搜索", + width=200 + ) + self.goods_search_entry.pack(pady=(0, 10), padx=10) + + # ==================== 设备编码 ==================== + equipment_frame = ctk.CTkFrame(self) + equipment_frame.pack(fill="x", padx=15, pady=(0, 10)) + + equipment_label = ctk.CTkLabel( + equipment_frame, + text="设备编码:", + font=("Arial", 12, "bold") + ) + equipment_label.pack(pady=(10, 5), padx=10, anchor="w") + + self.equipment_entry = ctk.CTkEntry( + equipment_frame, + placeholder_text="可选,精确匹配", + width=200 + ) + self.equipment_entry.pack(pady=(0, 10), padx=10) + + # ==================== 按钮区域 ==================== + button_frame = ctk.CTkFrame(self) + button_frame.pack(fill="x", padx=15, pady=(10, 15)) + + # 查询按钮 + self.query_button = ctk.CTkButton( + button_frame, + text="查询", + command=self._on_query, + height=35, + fg_color="green", + hover_color="darkgreen" + ) + self.query_button.pack(fill="x", padx=10, pady=(10, 5)) + + # 重置按钮 + self.reset_button = ctk.CTkButton( + button_frame, + text="重置", + command=self._on_reset, + height=35, + fg_color="gray", + hover_color="darkgray" + ) + self.reset_button.pack(fill="x", padx=10, pady=(5, 10)) + + def _set_time_shortcut(self, days: int): + """设置时间快捷选项""" + end_date = datetime.now() + if days == 0: + # 今日 + start_date = end_date.replace(hour=0, minute=0, second=0, microsecond=0) + else: + # 最近N天 + start_date = end_date - timedelta(days=days) + + self.start_date_entry.delete(0, 'end') + self.start_date_entry.insert(0, start_date.strftime('%Y-%m-%d')) + + self.end_date_entry.delete(0, 'end') + self.end_date_entry.insert(0, end_date.strftime('%Y-%m-%d')) + + def _on_datasource_changed(self): + """数据源改变时的回调""" + if self.on_datasource_change_callback: + self.on_datasource_change_callback(self.datasource_var.get()) + + def _on_query(self): + """查询按钮点击""" + # 获取筛选条件 + filters = self.get_filters() + + # 调用回调函数 + if self.on_query_callback: + self.on_query_callback(filters) + + def _on_reset(self): + """重置按钮点击""" + # 清空所有输入 + self.start_date_entry.delete(0, 'end') + self.end_date_entry.delete(0, 'end') + self.goods_search_entry.delete(0, 'end') + self.equipment_entry.delete(0, 'end') + + # 重置数据源为菜品 + self.datasource_var.set("菜品") + + def get_filters(self) -> dict: + """获取当前筛选条件""" + filters = { + 'datasource': self.datasource_var.get(), + 'start_date': None, + 'end_date': None, + 'goods_name': None, + 'equipment_code': None + } + + # 解析开始日期 + start_date_str = self.start_date_entry.get().strip() + if start_date_str: + try: + filters['start_date'] = datetime.strptime(start_date_str, '%Y-%m-%d') + except ValueError: + pass + + # 解析结束日期 + end_date_str = self.end_date_entry.get().strip() + if end_date_str: + try: + filters['end_date'] = datetime.strptime(end_date_str, '%Y-%m-%d') + except ValueError: + pass + + # 物品名称 + goods_name = self.goods_search_entry.get().strip() + if goods_name: + filters['goods_name'] = goods_name + + # 设备编码 + equipment_code = self.equipment_entry.get().strip() + if equipment_code: + filters['equipment_code'] = equipment_code + + return filters + + def set_loading_state(self, loading: bool): + """设置加载状态""" + if loading: + self.query_button.configure(state="disabled", text="查询中...") + else: + self.query_button.configure(state="normal", text="查询") diff --git a/data_management/ui/preview_panel.py b/data_management/ui/preview_panel.py new file mode 100644 index 0000000..2b0e932 --- /dev/null +++ b/data_management/ui/preview_panel.py @@ -0,0 +1,469 @@ +""" +中间图片预览面板 +""" +import customtkinter as ctk +from tkinter import messagebox +from typing import List, Dict, Callable, Optional +import threading +import sys +import os + +# 添加父目录到路径 +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from config import GRID_COLUMNS, THUMBNAIL_SIZE, CARD_PADDING, ITEMS_PER_PAGE +from utils.image_loader import load_image_from_url, create_placeholder_image + + +class ImageCard(ctk.CTkFrame): + """图片卡片组件""" + + def __init__( + self, + parent, + data: Dict, + is_downloaded: bool, + on_check_changed: Callable, + on_preview: Callable, + **kwargs + ): + super().__init__(parent, **kwargs) + + self.data = data + self.is_downloaded = is_downloaded + self.on_check_changed = on_check_changed + self.on_preview = on_preview + + # 勾选状态 + self.check_var = ctk.BooleanVar(value=False) + + self._create_widgets() + self._load_thumbnail() + + def _create_widgets(self): + """创建卡片组件""" + # 勾选框 + self.checkbox = ctk.CTkCheckBox( + self, + text="", + variable=self.check_var, + command=self._on_check, + width=20 + ) + self.checkbox.pack(pady=(5, 0), padx=5, anchor="w") + + # 图片容器 + self.image_label = ctk.CTkLabel( + self, + text="加载中...", + width=THUMBNAIL_SIZE, + height=THUMBNAIL_SIZE + ) + self.image_label.pack(pady=5, padx=5) + self.image_label.bind("", lambda e: self.on_preview(self.data)) + + # 物品名称(加粗) + name_label = ctk.CTkLabel( + self, + text=self.data['goods_name'], + font=("Arial", 11, "bold"), + wraplength=THUMBNAIL_SIZE - 10 + ) + name_label.pack(pady=(0, 2), padx=5) + + # 采集时间 + time_label = ctk.CTkLabel( + self, + text=self.data['create_time'], + font=("Arial", 9), + text_color="gray" + ) + time_label.pack(pady=(0, 2), padx=5) + + # 设备编码 + if self.data['equipment_code']: + equipment_label = ctk.CTkLabel( + self, + text=f"设备: {self.data['equipment_code']}", + font=("Arial", 9), + text_color="gray" + ) + equipment_label.pack(pady=(0, 2), padx=5) + + # 已下载标记 + if self.is_downloaded: + downloaded_label = ctk.CTkLabel( + self, + text="✅ 已下载", + font=("Arial", 10), + text_color="green" + ) + downloaded_label.pack(pady=(0, 5), padx=5) + + # 查看大图按钮 + preview_btn = ctk.CTkButton( + self, + text="查看大图", + command=lambda: self.on_preview(self.data), + width=100, + height=25, + font=("Arial", 10) + ) + preview_btn.pack(pady=(0, 5), padx=5) + + def _load_thumbnail(self): + """异步加载缩略图""" + def load(): + image = load_image_from_url( + self.data['image_url'], + size=(THUMBNAIL_SIZE, THUMBNAIL_SIZE) + ) + + if image: + self.image_label.configure(image=image, text="") + self.image_label.image = image + else: + placeholder = create_placeholder_image( + size=(THUMBNAIL_SIZE, THUMBNAIL_SIZE) + ) + self.image_label.configure(image=placeholder, text="") + self.image_label.image = placeholder + + threading.Thread(target=load, daemon=True).start() + + def _on_check(self): + """勾选框改变""" + self.on_check_changed(self.data['id'], self.check_var.get()) + + def is_checked(self) -> bool: + """是否被勾选""" + return self.check_var.get() + + def set_checked(self, checked: bool): + """设置勾选状态""" + self.check_var.set(checked) + + +class PreviewPanel(ctk.CTkFrame): + """中间图片预览面板""" + + def __init__( + self, + parent, + sqlite_manager, + on_download_callback: Callable, + **kwargs + ): + super().__init__(parent, **kwargs) + + self.sqlite_manager = sqlite_manager + self.on_download_callback = on_download_callback + + # 数据 + self.current_data = [] + self.total_count = 0 + self.current_page = 1 + self.selected_ids = set() + + self._create_widgets() + + def _create_widgets(self): + """创建UI组件""" + # 标题栏 + header_frame = ctk.CTkFrame(self) + header_frame.pack(fill="x", padx=15, pady=(15, 10)) + + title_label = ctk.CTkLabel( + header_frame, + text="图片预览", + font=("Arial", 16, "bold") + ) + title_label.pack(side="left", padx=10) + + # 统计信息 + self.count_label = ctk.CTkLabel( + header_frame, + text="共 0 张 | 已选 0 张", + font=("Arial", 12), + text_color="gray" + ) + self.count_label.pack(side="left", padx=20) + + # 图片网格(可滚动) + self.scroll_frame = ctk.CTkScrollableFrame(self) + self.scroll_frame.pack(fill="both", expand=True, padx=15, pady=(0, 10)) + + # 网格容器 + self.grid_frame = ctk.CTkFrame(self.scroll_frame) + self.grid_frame.pack(fill="both", expand=True) + + # 底部操作栏 + bottom_frame = ctk.CTkFrame(self) + bottom_frame.pack(fill="x", padx=15, pady=(0, 15)) + + # 左侧按钮 + left_buttons = ctk.CTkFrame(bottom_frame) + left_buttons.pack(side="left", padx=10, pady=10) + + self.select_all_btn = ctk.CTkButton( + left_buttons, + text="全选", + command=self._select_all, + width=80, + height=30 + ) + self.select_all_btn.pack(side="left", padx=5) + + self.deselect_all_btn = ctk.CTkButton( + left_buttons, + text="取消全选", + command=self._deselect_all, + width=80, + height=30, + fg_color="gray", + hover_color="darkgray" + ) + self.deselect_all_btn.pack(side="left", padx=5) + + self.download_btn = ctk.CTkButton( + left_buttons, + text="批量下载", + command=self._on_download, + width=100, + height=30, + fg_color="green", + hover_color="darkgreen" + ) + self.download_btn.pack(side="left", padx=5) + + # 分页控件 + page_frame = ctk.CTkFrame(bottom_frame) + page_frame.pack(side="right", padx=10, pady=10) + + self.prev_btn = ctk.CTkButton( + page_frame, + text="上一页", + command=self._prev_page, + width=80, + height=30 + ) + self.prev_btn.pack(side="left", padx=5) + + self.page_label = ctk.CTkLabel( + page_frame, + text="第 1 页", + font=("Arial", 11) + ) + self.page_label.pack(side="left", padx=10) + + self.next_btn = ctk.CTkButton( + page_frame, + text="下一页", + command=self._next_page, + width=80, + height=30 + ) + self.next_btn.pack(side="left", padx=5) + + def load_data(self, data_list: List[Dict], total_count: int, page: int = 1): + """加载图片数据""" + self.current_data = data_list + self.total_count = total_count + self.current_page = page + self.selected_ids.clear() + + self._update_grid() + self._update_stats() + self._update_pagination() + + def _update_grid(self): + """更新图片网格""" + # 清空现有卡片 + for widget in self.grid_frame.winfo_children(): + widget.destroy() + + if not self.current_data: + no_data_label = ctk.CTkLabel( + self.grid_frame, + text="暂无数据", + font=("Arial", 14), + text_color="gray" + ) + no_data_label.grid(row=0, column=0, pady=50, padx=50) + return + + # 创建网格卡片 + for i, data in enumerate(self.current_data): + row = i // GRID_COLUMNS + col = i % GRID_COLUMNS + + # 检查是否已下载 + is_downloaded = self.sqlite_manager.is_downloaded( + data['datasource'], + data['image_url'] + ) + + card = ImageCard( + self.grid_frame, + data=data, + is_downloaded=is_downloaded, + on_check_changed=self._on_check_changed, + on_preview=self._on_preview + ) + card.grid( + row=row, + column=col, + padx=CARD_PADDING, + pady=CARD_PADDING, + sticky="nsew" + ) + + # 配置网格列权重 + for col in range(GRID_COLUMNS): + self.grid_frame.grid_columnconfigure(col, weight=1) + + def _update_stats(self): + """更新统计信息""" + selected_count = len(self.selected_ids) + self.count_label.configure( + text=f"共 {self.total_count} 张 | 已选 {selected_count} 张" + ) + + def _update_pagination(self): + """更新分页控件""" + total_pages = (self.total_count + ITEMS_PER_PAGE - 1) // ITEMS_PER_PAGE + if total_pages == 0: + total_pages = 1 + + self.page_label.configure(text=f"第 {self.current_page}/{total_pages} 页") + + # 更新按钮状态 + self.prev_btn.configure(state="normal" if self.current_page > 1 else "disabled") + self.next_btn.configure(state="normal" if self.current_page < total_pages else "disabled") + + def _on_check_changed(self, item_id: int, checked: bool): + """勾选框改变""" + if checked: + self.selected_ids.add(item_id) + else: + self.selected_ids.discard(item_id) + + self._update_stats() + + def _select_all(self): + """全选""" + for widget in self.grid_frame.winfo_children(): + if isinstance(widget, ImageCard): + widget.set_checked(True) + self.selected_ids.add(widget.data['id']) + + self._update_stats() + + def _deselect_all(self): + """取消全选""" + for widget in self.grid_frame.winfo_children(): + if isinstance(widget, ImageCard): + widget.set_checked(False) + + self.selected_ids.clear() + self._update_stats() + + def _on_download(self): + """批量下载""" + if not self.selected_ids: + messagebox.showwarning("提示", "请先选择要下载的图片") + return + + # 获取选中的图片数据 + selected_data = [ + data for data in self.current_data + if data['id'] in self.selected_ids + ] + + # 调用下载回调 + if self.on_download_callback: + self.on_download_callback(selected_data) + + def _on_preview(self, data: Dict): + """查看大图""" + # 创建预览窗口 + preview_window = ctk.CTkToplevel(self) + preview_window.title(f"预览 - {data['goods_name']}") + preview_window.geometry("800x700") + + # 窗口属性 + preview_window.transient(self) + preview_window.grab_set() + preview_window.lift() + preview_window.focus_set() + + # 居中显示 + preview_window.update_idletasks() + x = (preview_window.winfo_screenwidth() // 2) - 400 + y = (preview_window.winfo_screenheight() // 2) - 350 + preview_window.geometry(f"800x700+{x}+{y}") + + # 信息框架 + info_frame = ctk.CTkFrame(preview_window) + info_frame.pack(fill="x", padx=20, pady=(20, 10)) + + info_text = f""" +物品名称: {data['goods_name']} +采集时间: {data['create_time']} +设备编码: {data['equipment_code']} +数据源: {data['datasource']} + """.strip() + + info_label = ctk.CTkLabel( + info_frame, + text=info_text, + font=("Arial", 12), + justify="left" + ) + info_label.pack(pady=10, padx=10) + + # 图片容器 + image_container = ctk.CTkLabel( + preview_window, + text="加载中...", + width=760, + height=550 + ) + image_container.pack(pady=10, padx=20) + + # 异步加载大图 + def load_large_image(): + image = load_image_from_url(data['image_url'], size=(760, 550)) + if image: + image_container.configure(image=image, text="") + image_container.image = image + else: + image_container.configure(text="图片加载失败") + + threading.Thread(target=load_large_image, daemon=True).start() + + def _prev_page(self): + """上一页""" + if self.current_page > 1: + # 需要重新查询数据(在主应用中处理) + pass + + def _next_page(self): + """下一页""" + total_pages = (self.total_count + ITEMS_PER_PAGE - 1) // ITEMS_PER_PAGE + if self.current_page < total_pages: + # 需要重新查询数据(在主应用中处理) + pass + + def get_current_page(self) -> int: + """获取当前页码""" + return self.current_page + + def clear(self): + """清空数据""" + self.current_data = [] + self.total_count = 0 + self.current_page = 1 + self.selected_ids.clear() + self._update_grid() + self._update_stats() + self._update_pagination() diff --git a/data_management/ui/statistics_panel.py b/data_management/ui/statistics_panel.py new file mode 100644 index 0000000..d51e53c --- /dev/null +++ b/data_management/ui/statistics_panel.py @@ -0,0 +1,296 @@ +""" +右侧统计面板 +""" +import customtkinter as ctk +from typing import Dict, Callable, Optional + + +class StatisticsPanel(ctk.CTkFrame): + """右侧统计面板""" + + def __init__( + self, + parent, + on_refresh_callback: Callable, + on_download_history_callback: Callable, + on_export_callback: Callable, + on_settings_callback: Callable, + **kwargs + ): + super().__init__(parent, **kwargs) + + self.on_refresh_callback = on_refresh_callback + self.on_download_history_callback = on_download_history_callback + self.on_export_callback = on_export_callback + self.on_settings_callback = on_settings_callback + + self._create_widgets() + + def _create_widgets(self): + """创建UI组件""" + # 标题 + title_label = ctk.CTkLabel( + self, + text="统计信息", + font=("Arial", 16, "bold") + ) + title_label.pack(pady=(15, 10), padx=15) + + # ==================== 当前数据源信息 ==================== + datasource_frame = ctk.CTkFrame(self) + datasource_frame.pack(fill="x", padx=15, pady=(0, 10)) + + datasource_title = ctk.CTkLabel( + datasource_frame, + text="当前数据源", + font=("Arial", 12, "bold") + ) + datasource_title.pack(pady=(10, 5), padx=10) + + self.datasource_label = ctk.CTkLabel( + datasource_frame, + text="菜品", + font=("Arial", 14, "bold"), + text_color="blue" + ) + self.datasource_label.pack(pady=(0, 10), padx=10) + + # ==================== 数据统计 ==================== + stats_frame = ctk.CTkFrame(self) + stats_frame.pack(fill="x", padx=15, pady=(0, 10)) + + stats_title = ctk.CTkLabel( + stats_frame, + text="数据统计", + font=("Arial", 12, "bold") + ) + stats_title.pack(pady=(10, 5), padx=10) + + # 查询结果 + self.query_result_label = ctk.CTkLabel( + stats_frame, + text="查询结果: 0 张", + font=("Arial", 11), + anchor="w" + ) + self.query_result_label.pack(fill="x", pady=2, padx=15) + + # 已选择 + self.selected_label = ctk.CTkLabel( + stats_frame, + text="已选择: 0 张", + font=("Arial", 11), + anchor="w" + ) + self.selected_label.pack(fill="x", pady=2, padx=15) + + # 分隔线 + separator1 = ctk.CTkFrame(stats_frame, height=2) + separator1.pack(fill="x", pady=10, padx=15) + + # ==================== 时间分布 ==================== + time_frame = ctk.CTkFrame(self) + time_frame.pack(fill="x", padx=15, pady=(0, 10)) + + time_title = ctk.CTkLabel( + time_frame, + text="时间分布", + font=("Arial", 12, "bold") + ) + time_title.pack(pady=(10, 5), padx=10) + + # 今日 + self.today_label = ctk.CTkLabel( + time_frame, + text="今日: 0 张", + font=("Arial", 11), + anchor="w" + ) + self.today_label.pack(fill="x", pady=2, padx=15) + + # 本周 + self.week_label = ctk.CTkLabel( + time_frame, + text="本周: 0 张", + font=("Arial", 11), + anchor="w" + ) + self.week_label.pack(fill="x", pady=2, padx=15) + + # 本月 + self.month_label = ctk.CTkLabel( + time_frame, + text="本月: 0 张", + font=("Arial", 11), + anchor="w" + ) + self.month_label.pack(fill="x", pady=2, padx=15) + + # 分隔线 + separator2 = ctk.CTkFrame(time_frame, height=2) + separator2.pack(fill="x", pady=10, padx=15) + + # ==================== TOP 10物品排行 ==================== + top_frame = ctk.CTkFrame(self) + top_frame.pack(fill="both", expand=True, padx=15, pady=(0, 10)) + + top_title = ctk.CTkLabel( + top_frame, + text="TOP 10 物品排行", + font=("Arial", 12, "bold") + ) + top_title.pack(pady=(10, 5), padx=10) + + # 可滚动的排行榜 + self.top_scroll = ctk.CTkScrollableFrame(top_frame, height=200) + self.top_scroll.pack(fill="both", expand=True, padx=10, pady=(0, 10)) + + # ==================== 快捷操作 ==================== + actions_frame = ctk.CTkFrame(self) + actions_frame.pack(fill="x", padx=15, pady=(0, 15)) + + actions_title = ctk.CTkLabel( + actions_frame, + text="快捷操作", + font=("Arial", 12, "bold") + ) + actions_title.pack(pady=(10, 5), padx=10) + + # 刷新按钮 + refresh_btn = ctk.CTkButton( + actions_frame, + text="🔄 刷新", + command=self.on_refresh_callback, + height=30 + ) + refresh_btn.pack(fill="x", padx=10, pady=2) + + # 下载历史按钮 + history_btn = ctk.CTkButton( + actions_frame, + text="📜 下载历史", + command=self.on_download_history_callback, + height=30, + fg_color="purple", + hover_color="darkviolet" + ) + history_btn.pack(fill="x", padx=10, pady=2) + + # 导出按钮 + export_btn = ctk.CTkButton( + actions_frame, + text="📊 导出数据", + command=self.on_export_callback, + height=30, + fg_color="orange", + hover_color="darkorange" + ) + export_btn.pack(fill="x", padx=10, pady=2) + + # 设置按钮 + settings_btn = ctk.CTkButton( + actions_frame, + text="⚙️ 设置", + command=self.on_settings_callback, + height=30, + fg_color="gray", + hover_color="darkgray" + ) + settings_btn.pack(fill="x", padx=10, pady=(2, 10)) + + def update_datasource(self, datasource: str): + """更新当前数据源显示""" + self.datasource_label.configure(text=datasource) + + def update_statistics(self, stats: Dict): + """ + 更新统计信息 + + Args: + stats: 统计数据字典,包含: + - total_count: 总数量 + - today_count: 今日数量 + - week_count: 本周数量 + - month_count: 本月数量 + - top_items: TOP 10列表 [{'name': str, 'count': int}, ...] + """ + # 查询结果 + total_count = stats.get('total_count', 0) + self.query_result_label.configure(text=f"查询结果: {total_count} 张") + + # 时间分布 + today_count = stats.get('today_count', 0) + self.today_label.configure(text=f"今日: {today_count} 张") + + week_count = stats.get('week_count', 0) + self.week_label.configure(text=f"本周: {week_count} 张") + + month_count = stats.get('month_count', 0) + self.month_label.configure(text=f"本月: {month_count} 张") + + # TOP 10排行 + self._update_top_items(stats.get('top_items', [])) + + def _update_top_items(self, top_items: list): + """更新TOP 10排行榜""" + # 清空现有内容 + for widget in self.top_scroll.winfo_children(): + widget.destroy() + + if not top_items: + no_data_label = ctk.CTkLabel( + self.top_scroll, + text="暂无数据", + font=("Arial", 11), + text_color="gray" + ) + no_data_label.pack(pady=10) + return + + # 显示TOP 10 + for i, item in enumerate(top_items, 1): + item_frame = ctk.CTkFrame(self.top_scroll) + item_frame.pack(fill="x", pady=2, padx=5) + + # 排名 + rank_label = ctk.CTkLabel( + item_frame, + text=f"{i}.", + font=("Arial", 11, "bold"), + width=30 + ) + rank_label.pack(side="left", padx=(5, 2)) + + # 物品名称 + name_label = ctk.CTkLabel( + item_frame, + text=item['name'], + font=("Arial", 10), + anchor="w" + ) + name_label.pack(side="left", fill="x", expand=True, padx=5) + + # 数量 + count_label = ctk.CTkLabel( + item_frame, + text=f"{item['count']}张", + font=("Arial", 10), + text_color="blue" + ) + count_label.pack(side="right", padx=5) + + def update_selected_count(self, count: int): + """更新已选择数量""" + self.selected_label.configure(text=f"已选择: {count} 张") + + def clear_statistics(self): + """清空统计信息""" + self.query_result_label.configure(text="查询结果: 0 张") + self.selected_label.configure(text="已选择: 0 张") + self.today_label.configure(text="今日: 0 张") + self.week_label.configure(text="本周: 0 张") + self.month_label.configure(text="本月: 0 张") + + # 清空TOP 10 + for widget in self.top_scroll.winfo_children(): + widget.destroy() diff --git a/data_management/utils/__init__.py b/data_management/utils/__init__.py new file mode 100644 index 0000000..3b9f339 --- /dev/null +++ b/data_management/utils/__init__.py @@ -0,0 +1,7 @@ +""" +工具模块 +""" +from .image_downloader import ImageDownloader +from .image_loader import load_image_from_url + +__all__ = ['ImageDownloader', 'load_image_from_url'] diff --git a/data_management/utils/image_downloader.py b/data_management/utils/image_downloader.py new file mode 100644 index 0000000..f1931c1 --- /dev/null +++ b/data_management/utils/image_downloader.py @@ -0,0 +1,182 @@ +""" +图片下载器 +支持多线程批量下载 +""" +import os +import requests +from datetime import datetime +from typing import List, Dict, Callable, Optional +from concurrent.futures import ThreadPoolExecutor, as_completed +import threading +import sys + +# 添加父目录到路径 +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from config import DOWNLOAD_THREADS, DOWNLOAD_TIMEOUT, CREATE_SUBFOLDERS + + +class ImageDownloader: + """图片下载器""" + + def __init__(self, sqlite_manager, max_workers: int = DOWNLOAD_THREADS): + """ + 初始化下载器 + + Args: + sqlite_manager: SQLite管理器实例 + max_workers: 最大并发数 + """ + self.sqlite_manager = sqlite_manager + self.max_workers = max_workers + self.is_cancelled = False + self._lock = threading.Lock() + + def cancel(self): + """取消下载""" + self.is_cancelled = True + + def download_images( + self, + image_data_list: List[Dict], + save_directory: str, + progress_callback: Optional[Callable[[int, int, str], None]] = None, + complete_callback: Optional[Callable[[int, int, List[str]], None]] = None + ): + """ + 批量下载图片 + + Args: + image_data_list: 图片数据列表,每项包含: + - id: 数据库ID + - goods_id: 物品ID + - goods_name: 物品名称 + - image_url: 图片URL + - datasource: 数据源 + - create_time: 创建时间 + save_directory: 保存目录 + progress_callback: 进度回调函数 (当前数, 总数, 当前文件名) + complete_callback: 完成回调函数 (成功数, 失败数, 错误列表) + """ + self.is_cancelled = False + total = len(image_data_list) + success_count = 0 + failed_count = 0 + error_messages = [] + + # 创建保存目录 + os.makedirs(save_directory, exist_ok=True) + + with ThreadPoolExecutor(max_workers=self.max_workers) as executor: + # 提交所有下载任务 + future_to_data = { + executor.submit( + self._download_single_image, + data, + save_directory + ): data for data in image_data_list + } + + # 处理完成的任务 + completed = 0 + for future in as_completed(future_to_data): + if self.is_cancelled: + # 取消所有未完成的任务 + for f in future_to_data: + f.cancel() + break + + data = future_to_data[future] + completed += 1 + + try: + success, error_msg = future.result() + if success: + success_count += 1 + else: + failed_count += 1 + if error_msg: + error_messages.append(error_msg) + + # 调用进度回调 + if progress_callback: + progress_callback(completed, total, data['goods_name']) + + except Exception as e: + failed_count += 1 + error_messages.append(f"{data['goods_name']}: {str(e)}") + + # 调用完成回调 + if complete_callback and not self.is_cancelled: + complete_callback(success_count, failed_count, error_messages) + + def _download_single_image( + self, + data: Dict, + save_directory: str + ) -> tuple[bool, Optional[str]]: + """ + 下载单张图片 + + Returns: + (是否成功, 错误信息) + """ + try: + # 检查是否已下载 + if self.sqlite_manager.is_downloaded(data['datasource'], data['image_url']): + return True, None + + # 构建文件名: {分类}_{时间戳}_{ID}.jpg + # 示例: 食材_20231127143022_1001.jpg + create_time = data.get('create_time', '') + if create_time: + # 移除时间字符串中的特殊字符 + timestamp = create_time.replace('-', '').replace(':', '').replace(' ', '') + else: + timestamp = datetime.now().strftime('%Y%m%d%H%M%S') + + filename = f"{data['datasource']}_{timestamp}_{data['id']}.jpg" + + # 确定保存路径 + if CREATE_SUBFOLDERS: + # 按分类创建子文件夹 + subfolder = os.path.join(save_directory, data['datasource']) + os.makedirs(subfolder, exist_ok=True) + file_path = os.path.join(subfolder, filename) + else: + file_path = os.path.join(save_directory, filename) + + # 下载图片 + response = requests.get( + data['image_url'], + timeout=DOWNLOAD_TIMEOUT, + stream=True + ) + response.raise_for_status() + + # 保存图片 + with open(file_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + + # 获取文件大小 + file_size = os.path.getsize(file_path) + + # 记录下载历史 + self.sqlite_manager.add_download_record( + datasource=data['datasource'], + goods_id=data.get('goods_id'), + goods_name=data['goods_name'], + image_url=data['image_url'], + local_path=file_path, + file_size=file_size + ) + + return True, None + + except requests.exceptions.RequestException as e: + error_msg = f"{data['goods_name']} (ID:{data['id']}): 网络错误 - {str(e)}" + return False, error_msg + except Exception as e: + error_msg = f"{data['goods_name']} (ID:{data['id']}): {str(e)}" + return False, error_msg diff --git a/data_management/utils/image_loader.py b/data_management/utils/image_loader.py new file mode 100644 index 0000000..fe40c6c --- /dev/null +++ b/data_management/utils/image_loader.py @@ -0,0 +1,78 @@ +""" +图片加载工具 +用于从URL加载图片并转换为PIL/CTkImage格式 +""" +import io +import requests +from PIL import Image +from typing import Optional +import customtkinter as ctk + + +def load_image_from_url( + url: str, + size: tuple = (150, 150), + timeout: int = 10 +) -> Optional[ctk.CTkImage]: + """ + 从URL加载图片 + + Args: + url: 图片URL + size: 目标尺寸 (width, height) + timeout: 超时时间(秒) + + Returns: + CTkImage对象,失败返回None + """ + try: + # 下载图片 + response = requests.get(url, timeout=timeout) + response.raise_for_status() + + # 转换为PIL Image + image_data = io.BytesIO(response.content) + pil_image = Image.open(image_data) + + # 转换为RGB(防止RGBA等格式问题) + if pil_image.mode != 'RGB': + pil_image = pil_image.convert('RGB') + + # 创建CTkImage + ctk_image = ctk.CTkImage( + light_image=pil_image, + dark_image=pil_image, + size=size + ) + + return ctk_image + + except Exception as e: + print(f"加载图片失败 {url}: {e}") + return None + + +def create_placeholder_image( + size: tuple = (150, 150), + text: str = "加载失败" +) -> ctk.CTkImage: + """ + 创建占位图片 + + Args: + size: 图片尺寸 + text: 显示文字 + + Returns: + CTkImage对象 + """ + # 创建灰色背景图片 + pil_image = Image.new('RGB', size, color=(200, 200, 200)) + + ctk_image = ctk.CTkImage( + light_image=pil_image, + dark_image=pil_image, + size=size + ) + + return ctk_image diff --git a/requirements.txt b/requirements.txt index b8a8ba0..06673bb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,3 +30,5 @@ requests>=2.28.0 openai>=1.0.0 pandas>=2.0.0 seaborn>=0.12.0 +sqlalchemy>=2.0.0 +pymysql>=1.1.0