增加了查看入库秤上传图片的GUI界面。
This commit is contained in:
@@ -7,3 +7,4 @@
|
|||||||
/faiss_vector_db/DishClassification/faiss_index101001/
|
/faiss_vector_db/DishClassification/faiss_index101001/
|
||||||
/faiss_vector_db/faiss_index*/
|
/faiss_vector_db/faiss_index*/
|
||||||
/faiss_vector_db/WholeIngredientRecognition/faiss_index*/
|
/faiss_vector_db/WholeIngredientRecognition/faiss_index*/
|
||||||
|
/data_management/download_history.db
|
||||||
|
|||||||
@@ -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)
|
||||||
|
- ✅ 初始版本发布
|
||||||
|
- ✅ 三栏式界面设计
|
||||||
|
- ✅ 多维度筛选功能
|
||||||
|
- ✅ 批量下载功能
|
||||||
|
- ✅ 下载历史记录
|
||||||
|
- ✅ 统计分析功能
|
||||||
|
- ✅ 数据导出功能
|
||||||
|
|
||||||
|
## 作者
|
||||||
|
|
||||||
|
数字味道团队
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
内部使用项目
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
物联网图片采集管理系统
|
||||||
|
用于从MySQL数据库读取图片URL并进行可视化管理和下载
|
||||||
|
"""
|
||||||
|
__version__ = "1.0.0"
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
数据库模块
|
||||||
|
"""
|
||||||
|
from .mysql_manager import MySQLManager
|
||||||
|
from .sqlite_manager import SQLiteManager
|
||||||
|
|
||||||
|
__all__ = ['MySQLManager', 'SQLiteManager']
|
||||||
@@ -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连接已关闭")
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""
|
||||||
|
UI模块
|
||||||
|
"""
|
||||||
|
from .filter_panel import FilterPanel
|
||||||
|
from .preview_panel import PreviewPanel
|
||||||
|
from .statistics_panel import StatisticsPanel
|
||||||
|
|
||||||
|
__all__ = ['FilterPanel', 'PreviewPanel', 'StatisticsPanel']
|
||||||
@@ -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="查询")
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
工具模块
|
||||||
|
"""
|
||||||
|
from .image_downloader import ImageDownloader
|
||||||
|
from .image_loader import load_image_from_url
|
||||||
|
|
||||||
|
__all__ = ['ImageDownloader', 'load_image_from_url']
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -30,3 +30,5 @@ requests>=2.28.0
|
|||||||
openai>=1.0.0
|
openai>=1.0.0
|
||||||
pandas>=2.0.0
|
pandas>=2.0.0
|
||||||
seaborn>=0.12.0
|
seaborn>=0.12.0
|
||||||
|
sqlalchemy>=2.0.0
|
||||||
|
pymysql>=1.1.0
|
||||||
|
|||||||
Reference in New Issue
Block a user