增加了查看入库秤上传图片的GUI界面。

This commit is contained in:
2025-11-27 10:46:53 +08:00
parent c74ac621a6
commit 1bedc16bd8
16 changed files with 2703 additions and 0 deletions
+1
View File
@@ -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
+212
View File
@@ -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)
- ✅ 初始版本发布
- ✅ 三栏式界面设计
- ✅ 多维度筛选功能
- ✅ 批量下载功能
- ✅ 下载历史记录
- ✅ 统计分析功能
- ✅ 数据导出功能
## 作者
数字味道团队
## 许可证
内部使用项目
+5
View File
@@ -0,0 +1,5 @@
"""
物联网图片采集管理系统
用于从MySQL数据库读取图片URL并进行可视化管理和下载
"""
__version__ = "1.0.0"
+522
View File
@@ -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()
+73
View File
@@ -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
}
+7
View File
@@ -0,0 +1,7 @@
"""
数据库模块
"""
from .mysql_manager import MySQLManager
from .sqlite_manager import SQLiteManager
__all__ = ['MySQLManager', 'SQLiteManager']
+313
View File
@@ -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连接已关闭")
+250
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
"""
UI模块
"""
from .filter_panel import FilterPanel
from .preview_panel import PreviewPanel
from .statistics_panel import StatisticsPanel
__all__ = ['FilterPanel', 'PreviewPanel', 'StatisticsPanel']
+278
View File
@@ -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="查询")
+469
View File
@@ -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("<Button-1>", 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()
+296
View File
@@ -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()
+7
View File
@@ -0,0 +1,7 @@
"""
工具模块
"""
from .image_downloader import ImageDownloader
from .image_loader import load_image_from_url
__all__ = ['ImageDownloader', 'load_image_from_url']
+182
View File
@@ -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
+78
View File
@@ -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
+2
View File
@@ -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