Files
nl-tools/tools/excel/chart.py
2025-12-11 09:47:00 +08:00

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()