376 lines
13 KiB
Python
376 lines
13 KiB
Python
|
|
"""
|
||
|
|
Excel图表生成工具
|
||
|
|
- 选择数据列
|
||
|
|
- 图表类型选择(柱状图/折线图/饼图)
|
||
|
|
- matplotlib图表嵌入Qt窗口
|
||
|
|
"""
|
||
|
|
import logging
|
||
|
|
from pathlib import Path
|
||
|
|
from PySide6.QtWidgets import (
|
||
|
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||
|
|
QFrame, QFileDialog, QMessageBox, QComboBox, QListWidget,
|
||
|
|
QListWidgetItem, QAbstractItemView, QSplitter, QGroupBox
|
||
|
|
)
|
||
|
|
from PySide6.QtCore import Qt
|
||
|
|
from PySide6.QtGui import QFont
|
||
|
|
|
||
|
|
from ui.workspace import BaseWorkspace, UploadArea
|
||
|
|
|
||
|
|
try:
|
||
|
|
import pandas as pd
|
||
|
|
HAS_PANDAS = True
|
||
|
|
except ImportError:
|
||
|
|
HAS_PANDAS = False
|
||
|
|
|
||
|
|
try:
|
||
|
|
import matplotlib
|
||
|
|
matplotlib.use('QtAgg')
|
||
|
|
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
|
||
|
|
from matplotlib.figure import Figure
|
||
|
|
import matplotlib.pyplot as plt
|
||
|
|
HAS_MATPLOTLIB = True
|
||
|
|
|
||
|
|
# 设置中文字体
|
||
|
|
plt.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei', 'DejaVu Sans']
|
||
|
|
plt.rcParams['axes.unicode_minus'] = False
|
||
|
|
except ImportError:
|
||
|
|
HAS_MATPLOTLIB = False
|
||
|
|
logging.warning("matplotlib未安装, 图表生成功能不可用")
|
||
|
|
|
||
|
|
|
||
|
|
class ChartCanvas(FigureCanvas if HAS_MATPLOTLIB else QWidget):
|
||
|
|
"""图表画布"""
|
||
|
|
|
||
|
|
def __init__(self, parent=None):
|
||
|
|
if HAS_MATPLOTLIB:
|
||
|
|
self.figure = Figure(figsize=(8, 6), facecolor='#1e293b')
|
||
|
|
super().__init__(self.figure)
|
||
|
|
self.axes = self.figure.add_subplot(111)
|
||
|
|
self.configure_axes()
|
||
|
|
else:
|
||
|
|
super().__init__(parent)
|
||
|
|
|
||
|
|
def configure_axes(self):
|
||
|
|
"""配置坐标轴样式"""
|
||
|
|
self.axes.set_facecolor('#0f172a')
|
||
|
|
self.axes.tick_params(colors='#94a3b8')
|
||
|
|
self.axes.xaxis.label.set_color('#e2e8f0')
|
||
|
|
self.axes.yaxis.label.set_color('#e2e8f0')
|
||
|
|
self.axes.title.set_color('white')
|
||
|
|
|
||
|
|
for spine in self.axes.spines.values():
|
||
|
|
spine.set_color('#334155')
|
||
|
|
|
||
|
|
def clear_chart(self):
|
||
|
|
"""清空图表"""
|
||
|
|
if HAS_MATPLOTLIB:
|
||
|
|
self.axes.clear()
|
||
|
|
self.configure_axes()
|
||
|
|
self.draw()
|
||
|
|
|
||
|
|
def draw_bar_chart(self, x_data, y_data, x_label: str, y_label: str, title: str):
|
||
|
|
"""绘制柱状图"""
|
||
|
|
self.clear_chart()
|
||
|
|
|
||
|
|
colors = ['#fbbf24', '#3b82f6', '#22c55e', '#ef4444', '#8b5cf6', '#ec4899']
|
||
|
|
bars = self.axes.bar(x_data, y_data, color=colors[:len(x_data)])
|
||
|
|
|
||
|
|
self.axes.set_xlabel(x_label)
|
||
|
|
self.axes.set_ylabel(y_label)
|
||
|
|
self.axes.set_title(title, fontsize=14, fontweight='bold')
|
||
|
|
|
||
|
|
# 旋转x轴标签
|
||
|
|
self.axes.tick_params(axis='x', rotation=45)
|
||
|
|
self.figure.tight_layout()
|
||
|
|
self.draw()
|
||
|
|
|
||
|
|
def draw_line_chart(self, x_data, y_data, x_label: str, y_label: str, title: str):
|
||
|
|
"""绘制折线图"""
|
||
|
|
self.clear_chart()
|
||
|
|
|
||
|
|
self.axes.plot(x_data, y_data, color='#fbbf24', linewidth=2, marker='o', markersize=6)
|
||
|
|
self.axes.fill_between(x_data, y_data, alpha=0.2, color='#fbbf24')
|
||
|
|
|
||
|
|
self.axes.set_xlabel(x_label)
|
||
|
|
self.axes.set_ylabel(y_label)
|
||
|
|
self.axes.set_title(title, fontsize=14, fontweight='bold')
|
||
|
|
self.axes.grid(True, alpha=0.3, color='#334155')
|
||
|
|
|
||
|
|
self.axes.tick_params(axis='x', rotation=45)
|
||
|
|
self.figure.tight_layout()
|
||
|
|
self.draw()
|
||
|
|
|
||
|
|
def draw_pie_chart(self, labels, values, title: str):
|
||
|
|
"""绘制饼图"""
|
||
|
|
self.clear_chart()
|
||
|
|
|
||
|
|
colors = ['#fbbf24', '#3b82f6', '#22c55e', '#ef4444', '#8b5cf6',
|
||
|
|
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16']
|
||
|
|
|
||
|
|
wedges, texts, autotexts = self.axes.pie(
|
||
|
|
values,
|
||
|
|
labels=labels,
|
||
|
|
autopct='%1.1f%%',
|
||
|
|
colors=colors[:len(values)],
|
||
|
|
textprops={'color': '#e2e8f0'}
|
||
|
|
)
|
||
|
|
|
||
|
|
for autotext in autotexts:
|
||
|
|
autotext.set_color('#0f172a')
|
||
|
|
autotext.set_fontweight('bold')
|
||
|
|
|
||
|
|
self.axes.set_title(title, fontsize=14, fontweight='bold')
|
||
|
|
self.figure.tight_layout()
|
||
|
|
self.draw()
|
||
|
|
|
||
|
|
def save_chart(self, file_path: str):
|
||
|
|
"""保存图表"""
|
||
|
|
if HAS_MATPLOTLIB:
|
||
|
|
self.figure.savefig(file_path, dpi=150, facecolor='#1e293b', edgecolor='none')
|
||
|
|
|
||
|
|
|
||
|
|
class ExcelChartPage(BaseWorkspace):
|
||
|
|
"""Excel图表生成页面"""
|
||
|
|
|
||
|
|
CHART_TYPES = [
|
||
|
|
("📊 柱状图", "bar"),
|
||
|
|
("📈 折线图", "line"),
|
||
|
|
("🥧 饼图", "pie")
|
||
|
|
]
|
||
|
|
|
||
|
|
def __init__(self, parent=None):
|
||
|
|
super().__init__(parent)
|
||
|
|
self.excel_path = None
|
||
|
|
self.df = None
|
||
|
|
self.setup_chart_ui()
|
||
|
|
|
||
|
|
def setup_chart_ui(self):
|
||
|
|
"""设置图表UI"""
|
||
|
|
self.history_btn.hide()
|
||
|
|
self.export_btn.clicked.connect(self.export_chart)
|
||
|
|
|
||
|
|
# 上传区域
|
||
|
|
self.upload_area = UploadArea("Excel文件 (*.xlsx *.xls)")
|
||
|
|
self.upload_area.files_dropped.connect(self.on_file_added)
|
||
|
|
self.content_layout.addWidget(self.upload_area)
|
||
|
|
|
||
|
|
# 主工作区
|
||
|
|
self.work_area = QSplitter(Qt.Orientation.Horizontal)
|
||
|
|
self.work_area.setVisible(False)
|
||
|
|
|
||
|
|
# 左侧设置面板
|
||
|
|
settings_frame = QFrame()
|
||
|
|
settings_frame.setObjectName("card")
|
||
|
|
settings_frame.setFixedWidth(280)
|
||
|
|
settings_layout = QVBoxLayout(settings_frame)
|
||
|
|
settings_layout.setContentsMargins(16, 16, 16, 16)
|
||
|
|
settings_layout.setSpacing(16)
|
||
|
|
|
||
|
|
# 文件信息
|
||
|
|
self.file_label = QLabel("")
|
||
|
|
self.file_label.setStyleSheet("color: #fbbf24; font-size: 12px;")
|
||
|
|
self.file_label.setWordWrap(True)
|
||
|
|
settings_layout.addWidget(self.file_label)
|
||
|
|
|
||
|
|
# Sheet选择
|
||
|
|
sheet_group = QGroupBox("📋 工作表")
|
||
|
|
sheet_layout = QVBoxLayout(sheet_group)
|
||
|
|
self.sheet_combo = QComboBox()
|
||
|
|
self.sheet_combo.currentTextChanged.connect(self.on_sheet_changed)
|
||
|
|
sheet_layout.addWidget(self.sheet_combo)
|
||
|
|
settings_layout.addWidget(sheet_group)
|
||
|
|
|
||
|
|
# 图表类型
|
||
|
|
type_group = QGroupBox("📊 图表类型")
|
||
|
|
type_layout = QVBoxLayout(type_group)
|
||
|
|
self.type_combo = QComboBox()
|
||
|
|
for text, value in self.CHART_TYPES:
|
||
|
|
self.type_combo.addItem(text, value)
|
||
|
|
self.type_combo.currentIndexChanged.connect(self.update_chart)
|
||
|
|
type_layout.addWidget(self.type_combo)
|
||
|
|
settings_layout.addWidget(type_group)
|
||
|
|
|
||
|
|
# X轴数据(标签列)
|
||
|
|
x_group = QGroupBox("📌 X轴 / 标签列")
|
||
|
|
x_layout = QVBoxLayout(x_group)
|
||
|
|
self.x_combo = QComboBox()
|
||
|
|
self.x_combo.currentIndexChanged.connect(self.update_chart)
|
||
|
|
x_layout.addWidget(self.x_combo)
|
||
|
|
settings_layout.addWidget(x_group)
|
||
|
|
|
||
|
|
# Y轴数据(数值列)
|
||
|
|
y_group = QGroupBox("📈 Y轴 / 数值列")
|
||
|
|
y_layout = QVBoxLayout(y_group)
|
||
|
|
self.y_combo = QComboBox()
|
||
|
|
self.y_combo.currentIndexChanged.connect(self.update_chart)
|
||
|
|
y_layout.addWidget(self.y_combo)
|
||
|
|
settings_layout.addWidget(y_group)
|
||
|
|
|
||
|
|
settings_layout.addStretch()
|
||
|
|
|
||
|
|
# 更换文件按钮
|
||
|
|
change_btn = QPushButton("📂 更换文件")
|
||
|
|
change_btn.setObjectName("secondary_btn")
|
||
|
|
change_btn.clicked.connect(self.change_file)
|
||
|
|
settings_layout.addWidget(change_btn)
|
||
|
|
|
||
|
|
# 生成图表按钮
|
||
|
|
self.generate_btn = QPushButton("⚡ 生成图表")
|
||
|
|
self.generate_btn.setObjectName("primary_btn")
|
||
|
|
self.generate_btn.setMinimumHeight(40)
|
||
|
|
self.generate_btn.clicked.connect(self.update_chart)
|
||
|
|
settings_layout.addWidget(self.generate_btn)
|
||
|
|
|
||
|
|
self.work_area.addWidget(settings_frame)
|
||
|
|
|
||
|
|
# 右侧图表区
|
||
|
|
chart_frame = QFrame()
|
||
|
|
chart_frame.setObjectName("card")
|
||
|
|
chart_layout = QVBoxLayout(chart_frame)
|
||
|
|
chart_layout.setContentsMargins(16, 16, 16, 16)
|
||
|
|
|
||
|
|
if HAS_MATPLOTLIB:
|
||
|
|
self.chart_canvas = ChartCanvas()
|
||
|
|
chart_layout.addWidget(self.chart_canvas)
|
||
|
|
else:
|
||
|
|
no_chart_label = QLabel("matplotlib未安装,无法显示图表")
|
||
|
|
no_chart_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
|
no_chart_label.setStyleSheet("color: #ef4444;")
|
||
|
|
chart_layout.addWidget(no_chart_label)
|
||
|
|
|
||
|
|
self.work_area.addWidget(chart_frame)
|
||
|
|
self.work_area.setSizes([280, 700])
|
||
|
|
|
||
|
|
self.content_layout.addWidget(self.work_area, 1)
|
||
|
|
|
||
|
|
def on_file_added(self, files: list):
|
||
|
|
"""文件添加"""
|
||
|
|
if not files:
|
||
|
|
return
|
||
|
|
|
||
|
|
excel_file = None
|
||
|
|
for f in files:
|
||
|
|
if f.lower().endswith(('.xlsx', '.xls')):
|
||
|
|
excel_file = f
|
||
|
|
break
|
||
|
|
|
||
|
|
if not excel_file:
|
||
|
|
QMessageBox.warning(self, "提示", "请选择Excel文件")
|
||
|
|
return
|
||
|
|
|
||
|
|
self.load_excel(excel_file)
|
||
|
|
|
||
|
|
def load_excel(self, file_path: str):
|
||
|
|
"""加载Excel"""
|
||
|
|
if not HAS_PANDAS:
|
||
|
|
QMessageBox.critical(self, "错误", "pandas未安装,无法读取Excel")
|
||
|
|
return
|
||
|
|
|
||
|
|
try:
|
||
|
|
self.excel_path = file_path
|
||
|
|
self.excel_file = pd.ExcelFile(file_path)
|
||
|
|
|
||
|
|
# 更新sheet下拉框
|
||
|
|
self.sheet_combo.clear()
|
||
|
|
self.sheet_combo.addItems(self.excel_file.sheet_names)
|
||
|
|
|
||
|
|
self.file_label.setText(f"📁 {Path(file_path).name}")
|
||
|
|
|
||
|
|
self.upload_area.setVisible(False)
|
||
|
|
self.work_area.setVisible(True)
|
||
|
|
|
||
|
|
logging.info(f"加载Excel: {file_path}")
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
QMessageBox.critical(self, "错误", f"加载Excel失败:\n{e}")
|
||
|
|
logging.error(f"加载Excel失败: {e}")
|
||
|
|
|
||
|
|
def on_sheet_changed(self, sheet_name: str):
|
||
|
|
"""Sheet变化"""
|
||
|
|
if not sheet_name:
|
||
|
|
return
|
||
|
|
|
||
|
|
try:
|
||
|
|
self.df = pd.read_excel(self.excel_file, sheet_name=sheet_name)
|
||
|
|
|
||
|
|
# 更新列下拉框
|
||
|
|
columns = list(self.df.columns)
|
||
|
|
|
||
|
|
self.x_combo.clear()
|
||
|
|
self.x_combo.addItems([str(c) for c in columns])
|
||
|
|
|
||
|
|
self.y_combo.clear()
|
||
|
|
# 尝试只添加数值列
|
||
|
|
numeric_cols = self.df.select_dtypes(include=['number']).columns.tolist()
|
||
|
|
if numeric_cols:
|
||
|
|
self.y_combo.addItems([str(c) for c in numeric_cols])
|
||
|
|
else:
|
||
|
|
self.y_combo.addItems([str(c) for c in columns])
|
||
|
|
|
||
|
|
self.update_chart()
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logging.error(f"读取工作表失败: {e}")
|
||
|
|
|
||
|
|
def update_chart(self):
|
||
|
|
"""更新图表"""
|
||
|
|
if not HAS_MATPLOTLIB or self.df is None:
|
||
|
|
return
|
||
|
|
|
||
|
|
x_col = self.x_combo.currentText()
|
||
|
|
y_col = self.y_combo.currentText()
|
||
|
|
chart_type = self.type_combo.currentData()
|
||
|
|
|
||
|
|
if not x_col or not y_col:
|
||
|
|
return
|
||
|
|
|
||
|
|
try:
|
||
|
|
# 获取数据
|
||
|
|
x_data = self.df[x_col].astype(str).tolist()
|
||
|
|
y_data = pd.to_numeric(self.df[y_col], errors='coerce').fillna(0).tolist()
|
||
|
|
|
||
|
|
# 限制数据量
|
||
|
|
max_items = 20
|
||
|
|
if len(x_data) > max_items:
|
||
|
|
x_data = x_data[:max_items]
|
||
|
|
y_data = y_data[:max_items]
|
||
|
|
|
||
|
|
title = f"{y_col} by {x_col}"
|
||
|
|
|
||
|
|
if chart_type == "bar":
|
||
|
|
self.chart_canvas.draw_bar_chart(x_data, y_data, x_col, y_col, title)
|
||
|
|
elif chart_type == "line":
|
||
|
|
self.chart_canvas.draw_line_chart(x_data, y_data, x_col, y_col, title)
|
||
|
|
elif chart_type == "pie":
|
||
|
|
self.chart_canvas.draw_pie_chart(x_data, y_data, title)
|
||
|
|
|
||
|
|
logging.debug(f"生成图表: {chart_type}, X={x_col}, Y={y_col}")
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logging.error(f"生成图表失败: {e}")
|
||
|
|
QMessageBox.warning(self, "警告", f"生成图表失败:\n{e}")
|
||
|
|
|
||
|
|
def export_chart(self):
|
||
|
|
"""导出图表"""
|
||
|
|
if not HAS_MATPLOTLIB:
|
||
|
|
return
|
||
|
|
|
||
|
|
file_path, _ = QFileDialog.getSaveFileName(
|
||
|
|
self, "导出图表", "chart.png", "PNG图片 (*.png);;JPEG图片 (*.jpg);;PDF文档 (*.pdf)"
|
||
|
|
)
|
||
|
|
|
||
|
|
if file_path:
|
||
|
|
try:
|
||
|
|
self.chart_canvas.save_chart(file_path)
|
||
|
|
QMessageBox.information(self, "成功", f"图表已导出到:\n{file_path}")
|
||
|
|
logging.info(f"图表导出: {file_path}")
|
||
|
|
except Exception as e:
|
||
|
|
QMessageBox.critical(self, "错误", f"导出失败:\n{e}")
|
||
|
|
|
||
|
|
def change_file(self):
|
||
|
|
"""更换文件"""
|
||
|
|
self.work_area.setVisible(False)
|
||
|
|
self.upload_area.setVisible(True)
|
||
|
|
self.upload_area.open_file_dialog()
|
||
|
|
|