Files
cwExcel/app/ui/main_window.py
2026-07-18 15:04:19 +08:00

677 lines
26 KiB
Python

"""
主窗口 UI 组件
"""
import os
import sys
import threading
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import openpyxl
# ---- 配色 ----
C = {
"bg": "#f0f2f5",
"white": "#ffffff",
"text": "#1a1a2e",
"text_muted": "#6b7280",
"accent": "#4f46e5",
"accent_hover": "#4338ca",
"accent_light": "#eef2ff",
"border": "#e5e7eb",
"success": "#059669",
}
# 列宽
COL_W = [220, 1, 220, 1, 220, 1, 200]
COL_X = [0]
for w in COL_W[:-1]:
COL_X.append(COL_X[-1] + w)
ROW_H = 44
CELL_PAD = 8
# ======================================================================
# 自定义下拉组件
# ======================================================================
class Dropdown(tk.Frame):
"""纯自定义下拉选择器 — 不用任何系统 Combobox"""
def __init__(self, parent, values=None, default="", on_select=None,
font=("Microsoft YaHei", 10), width=180, **kw):
super().__init__(parent, bg=C["white"], highlightbackground=C["border"],
highlightthickness=1, cursor="hand2", **kw)
self.values = values or []
self._selected = default or ""
self._on_select = on_select
self._font = font
self._popup = None
self._hover = False
# 文字标签
self._label = tk.Label(
self, text=self._selected or "请选择",
font=font, bg=C["white"], fg=C["text"] if self._selected else C["text_muted"],
anchor=tk.W, padx=8,
)
self._label.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
# 箭头
self._arrow = tk.Label(
self, text="", font=("Microsoft YaHei", 8),
bg=C["white"], fg="#9ca3af", padx=6,
)
self._arrow.pack(side=tk.RIGHT, fill=tk.Y)
# 事件绑定
for w in (self, self._label, self._arrow):
w.bind("<Button-1>", self._toggle)
w.bind("<Enter>", self._on_enter)
w.bind("<Leave>", self._on_leave)
def _on_enter(self, _):
self._hover = True
self.configure(highlightbackground=C["accent"])
self._label.configure(bg="#f9fafb")
self._arrow.configure(bg="#f9fafb", fg=C["accent"])
def _on_leave(self, _):
self._hover = False
if not self._popup:
self.configure(highlightbackground=C["border"])
self._label.configure(bg=C["white"])
self._arrow.configure(bg=C["white"], fg="#9ca3af")
def _toggle(self, event=None):
if self._popup:
self._close()
else:
self._open()
def _open(self):
self.configure(highlightbackground=C["accent"])
self._label.configure(bg="#f9fafb")
self._arrow.configure(bg="#f9fafb", fg=C["accent"])
self._popup = tk.Toplevel(self)
self._popup.overrideredirect(True)
self._popup.configure(bg=C["white"])
# 计算位置
x = self.winfo_rootx()
y = self.winfo_rooty() + self.winfo_height() + 1
w = self.winfo_width()
item_h = 32
max_h = min(len(self.values) * item_h, 240)
list_h = max(item_h, max_h)
self._popup.geometry(f"{w}x{list_h}+{x}+{y}")
# 外边框
border = tk.Frame(self._popup, bg=C["border"])
border.pack(fill=tk.BOTH, expand=True)
# 列表
self._listbox = tk.Listbox(
border, font=self._font,
bg=C["white"], fg=C["text"],
selectbackground=C["accent_light"],
selectforeground=C["accent"],
selectborderwidth=0,
activestyle="none",
highlightthickness=0,
borderwidth=0,
relief=tk.FLAT,
height=len(self.values),
)
self._listbox.pack(fill=tk.BOTH, expand=True, padx=1, pady=1)
for i, v in enumerate(self.values):
self._listbox.insert(tk.END, f" {v}")
if v == self._selected:
self._listbox.selection_set(i)
self._listbox.see(i)
self._listbox.bind("<ButtonRelease-1>", self._on_pick)
self._listbox.bind("<Return>", self._on_pick)
self._listbox.bind("<Escape>", lambda e: self._close())
self._listbox.focus_set()
# 点击外部关闭
self._popup.bind("<FocusOut>", lambda e: self.after(100, self._check_focus))
# 绑定全局点击关闭(鼠标点击 popup 外)
self._grab_bind = self.winfo_toplevel().bind(
"<Button-1>", lambda e: self._on_global_click(e), add="+"
)
def _on_global_click(self, event):
if self._popup:
px, py = event.x_root, event.y_root
px2 = self._popup.winfo_rootx()
py2 = self._popup.winfo_rooty()
pw = self._popup.winfo_width()
ph = self._popup.winfo_height()
if not (px2 <= px <= px2 + pw and py2 <= py <= py2 + ph):
self._close()
def _check_focus(self):
if self._popup and not self._popup.focus_displayof():
self._close()
def _close(self, *_):
if self._popup:
self._popup.destroy()
self._popup = None
self._listbox = None
self.configure(highlightbackground=C["border"])
self._label.configure(bg=C["white"])
self._arrow.configure(bg=C["white"], fg="#9ca3af")
# 移除全局绑定
try:
self.winfo_toplevel().unbind("<Button-1>", self._grab_bind)
except Exception:
pass
def _on_pick(self, event):
sel = self._listbox.curselection()
if sel:
idx = sel[0]
self._selected = self.values[idx]
self._label.configure(text=self._selected, fg=C["text"])
if self._on_select:
self._on_select(self._selected)
self._close()
# ── 公开 API ──
def get(self):
return self._selected
def set_values(self, values, default=None):
self.values = values
if default is not None:
self._selected = default
self._label.configure(text=default, fg=C["text"] if default else C["text_muted"])
# 如果已打开则重建
if self._popup:
self._close()
self._open()
# ======================================================================
# 主窗口
# ======================================================================
class MainWindow:
"""应用程序主窗口"""
def __init__(self, root: tk.Tk):
self.root = root
self.root.configure(bg=C["bg"])
self._files_data = []
self._build_ui()
def _build_ui(self):
# ── 选择卡片 ──
card = tk.Frame(self.root, bg=C["white"])
card.pack(fill=tk.X, padx=24, pady=(20, 0))
card_border = tk.Frame(card, bg=C["white"],
highlightbackground=C["border"],
highlightthickness=1)
card_border.pack(fill=tk.X)
tk.Label(
card_border, text="📂 选择 Excel 文件",
font=("Microsoft YaHei", 12, "bold"),
bg=C["white"], fg=C["text"], anchor=tk.W,
).pack(fill=tk.X, padx=20, pady=(16, 10))
input_row = tk.Frame(card_border, bg=C["white"])
input_row.pack(fill=tk.X, padx=20, pady=(0, 10))
entry_border = tk.Frame(
input_row, bg=C["white"],
highlightbackground=C["border"], highlightthickness=1,
)
entry_border.pack(side=tk.LEFT, fill=tk.X, expand=True)
self._path_entry = tk.Entry(
entry_border,
font=("Microsoft YaHei", 10),
bg="#f9fafb", fg=C["text_muted"],
relief=tk.FLAT, borderwidth=0,
readonlybackground="#f9fafb",
)
self._path_entry.pack(fill=tk.X, padx=10, pady=8)
self._path_entry.insert(0, "请选择 Excel 文件(.xlsx / .xls,可多选)")
self._path_entry.configure(state="readonly")
self._browse_btn = tk.Button(
input_row, text="浏览...",
font=("Microsoft YaHei", 10, "bold"),
bg=C["accent"], fg="white", relief=tk.FLAT,
activebackground=C["accent_hover"], activeforeground="white",
cursor="hand2", padx=20, pady=7, borderwidth=0,
command=self._on_select_files,
)
self._browse_btn.pack(side=tk.LEFT, padx=(8, 0))
status_bar = tk.Frame(card_border, bg="#f9fafb")
status_bar.pack(fill=tk.X)
self._status_label = tk.Label(
status_bar, text=" 未选择任何文件",
font=("Microsoft YaHei", 9),
bg="#f9fafb", fg=C["text_muted"], anchor=tk.W,
)
self._status_label.pack(side=tk.LEFT, padx=16, pady=(6, 6))
# ── 表格区域 ──
self._table_area = tk.Frame(self.root, bg=C["bg"])
self._table_area.pack(fill=tk.BOTH, expand=True, padx=24, pady=(12, 0))
# ── 日志面板(底部固定高度,内部独立滚动) ──
self._log_frame = tk.Frame(self.root, bg="white", height=130)
self._log_frame.pack(fill=tk.X, padx=24, pady=(8, 8))
self._log_frame.pack_propagate(False)
log_header = tk.Frame(self._log_frame, bg="white")
log_header.pack(fill=tk.X)
tk.Label(log_header, text="📋 处理日志",
font=("Microsoft YaHei", 9, "bold"),
bg="white", fg=C["text"], anchor=tk.W,
).pack(side=tk.LEFT, padx=10, pady=(6, 2))
log_text_frame = tk.Frame(self._log_frame, bg="white")
log_text_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 8))
self._log_text = tk.Text(
log_text_frame,
font=("Consolas", 9),
bg="#f9fafb", fg=C["text"],
wrap=tk.WORD, state=tk.DISABLED,
relief=tk.FLAT, borderwidth=0,
height=6,
)
log_scrollbar = tk.Scrollbar(log_text_frame, orient=tk.VERTICAL,
command=self._log_text.yview)
self._log_text.configure(yscrollcommand=log_scrollbar.set)
log_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self._log_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
self._show_empty()
# ==================================================================
# 空状态
# ==================================================================
def _show_empty(self):
for w in self._table_area.winfo_children():
w.destroy()
drop = tk.Frame(self._table_area, bg="#fafafe",
highlightbackground="#c7d2fe",
highlightthickness=2)
drop.pack(fill=tk.BOTH, expand=True)
inner = tk.Frame(drop, bg="#fafafe")
inner.place(relx=0.5, rely=0.5, anchor=tk.CENTER)
tk.Label(inner, text="📂", font=("Segoe UI Emoji", 40),
bg="#fafafe", fg="#c7d2fe").pack(pady=(0, 4))
tk.Label(inner, text="点击上方「浏览...」按钮选择文件",
font=("Microsoft YaHei", 13, "bold"),
bg="#fafafe", fg=C["text_muted"]).pack()
tk.Label(inner, text="支持 .xlsx、.xls 格式,可同时选择多个文件",
font=("Microsoft YaHei", 9),
bg="#fafafe", fg="#c0c4cc").pack(pady=(2, 0))
# ==================================================================
# 读取 xlsx 信息(用内置 zipfile,避免 openpyxl 打包问题)
# ==================================================================
def _read_xlsx_info(self, filepath):
import pandas as pd
sheet_names = []
columns = []
for engine in ["openpyxl", "xlrd"]:
try:
xls = pd.ExcelFile(filepath, engine=engine)
sheet_names = xls.sheet_names
if sheet_names:
df = pd.read_excel(xls, sheet_name=sheet_names[0], nrows=1)
columns = [str(c) for c in df.columns.tolist()]
xls.close()
break
except Exception:
continue
return sheet_names, columns
# ==================================================================
# 选择文件
# ==================================================================
def _on_select_files(self):
paths = filedialog.askopenfilenames(
title="选择 Excel 文件",
filetypes=[
("Excel 文件", "*.xlsx *.xls"),
("Excel 2007+", "*.xlsx"),
("Excel 97-2003", "*.xls"),
],
)
if not paths:
return
for p in paths:
if any(d["path"] == p for d in self._files_data):
continue
try:
sheet_names, columns = self._read_xlsx_info(p)
first_sheet = sheet_names[0] if sheet_names else None
self._files_data.append({
"path": p,
"filename": os.path.basename(p),
"sheet_names": sheet_names,
"columns": columns,
"selected_sheet": first_sheet,
"selected_col": columns[0] if columns else None,
"parse_mode": "横向排列",
})
except Exception as e:
messagebox.showerror("读取失败", f"无法读取文件:\n{os.path.basename(p)}\n\n{e}")
continue
n = len(self._files_data)
if n == 0:
self._path_entry.configure(state="normal")
self._path_entry.delete(0, tk.END)
self._path_entry.insert(0, "请选择 Excel 文件(.xlsx / .xls,可多选)")
self._path_entry.configure(state="readonly", fg=C["text_muted"])
self._status_label.configure(text=" 未选择任何文件")
elif n == 1:
self._path_entry.configure(state="normal")
self._path_entry.delete(0, tk.END)
self._path_entry.insert(0, self._files_data[0]["path"])
self._path_entry.configure(state="readonly", fg=C["text"])
self._status_label.configure(
text=f" 已选择 1 个文件 | 点击「浏览...」可继续添加",
fg=C["success"],
)
else:
self._path_entry.configure(state="normal")
self._path_entry.delete(0, tk.END)
self._path_entry.insert(0, f"已选择 {n} 个文件")
self._path_entry.configure(state="readonly", fg=C["text"])
self._status_label.configure(
text=f" 已选择 {n} 个文件 | 点击「浏览...」可继续添加",
fg=C["success"],
)
if self._files_data:
self._build_table()
else:
self._show_empty()
# ==================================================================
# 构建表格
# ==================================================================
def _build_table(self):
for w in self._table_area.winfo_children():
w.destroy()
self._table_frame = tk.Frame(self._table_area, bg=C["bg"])
self._table_frame.pack(fill=tk.BOTH, expand=True)
self._draw_header()
for fi, fd in enumerate(self._files_data):
self._draw_row(fi, fd)
# ── 确认按钮 ──
btn_bar = tk.Frame(self._table_frame, bg=C["bg"])
btn_bar.pack(fill=tk.X, pady=(16, 0))
self._confirm_btn = tk.Button(
btn_bar, text="✅ 开始解析",
font=("Microsoft YaHei", 12, "bold"),
bg=C["accent"], fg="white", relief=tk.FLAT,
activebackground=C["accent_hover"], activeforeground="white",
cursor="hand2", padx=28, pady=10, borderwidth=0,
command=self._on_confirm,
)
self._confirm_btn.pack(side=tk.RIGHT)
self._progress_label = tk.Label(
btn_bar, text="",
font=("Microsoft YaHei", 9),
bg=C["bg"], fg=C["text_muted"], anchor=tk.E,
)
self._progress_label.pack(side=tk.RIGHT, padx=(0, 12))
def _draw_header(self):
hdr = tk.Frame(self._table_frame, bg=C["white"], height=38)
hdr.pack(fill=tk.X)
hdr.pack_propagate(False)
headers = ["文件名", "工作表", "解析列", "解析方式"]
for i, title in enumerate(headers):
col_idx = i * 2
x, w = COL_X[col_idx], COL_W[col_idx]
tk.Label(
hdr, text=title, font=("Microsoft YaHei", 10, "bold"),
bg=C["white"], fg=C["text"], anchor=tk.W,
).place(x=x + CELL_PAD, y=0, width=w - CELL_PAD * 2, height=38)
if i < 3:
tk.Frame(hdr, bg=C["border"], width=1).place(
x=COL_X[col_idx + 1], y=6, height=26
)
tk.Frame(self._table_frame, bg=C["border"], height=1).pack(fill=tk.X)
def _draw_row(self, fi: int, fd: dict):
row = tk.Frame(self._table_frame, bg=C["white"], height=ROW_H)
row.pack(fill=tk.X)
row.pack_propagate(False)
# ── 文件名 ──
x, w = COL_X[0], COL_W[0]
tk.Label(
row, text=f"📄 {fd['filename']}",
font=("Microsoft YaHei", 10), bg=C["white"], fg=C["text"], anchor=tk.W,
).place(x=x + CELL_PAD, y=0, width=w - CELL_PAD * 2, height=ROW_H)
tk.Frame(row, bg=C["border"], width=1).place(x=COL_X[1], y=6, height=ROW_H - 12)
# ── 工作表(自定义 Dropdown) ──
x, w = COL_X[2], COL_W[2]
dd_sheet = Dropdown(
row,
values=fd["sheet_names"],
default=fd["selected_sheet"] or "",
on_select=lambda val, i=fi: self._on_sheet_change(i, val),
)
dd_sheet.place(x=x + CELL_PAD, y=6, width=w - CELL_PAD * 2, height=ROW_H - 12)
fd["sheet_dd"] = dd_sheet
tk.Frame(row, bg=C["border"], width=1).place(x=COL_X[3], y=6, height=ROW_H - 12)
# ── 解析列(自定义 Dropdown) ──
x, w = COL_X[4], COL_W[4]
dd_col = Dropdown(
row,
values=fd["columns"],
default=fd["selected_col"] or "",
on_select=lambda val, i=fi: self._on_col_change(i, val),
)
dd_col.place(x=x + CELL_PAD, y=6, width=w - CELL_PAD * 2, height=ROW_H - 12)
fd["col_dd"] = dd_col
tk.Frame(row, bg=C["border"], width=1).place(x=COL_X[5], y=6, height=ROW_H - 12)
# ── 解析方式(自定义 Dropdown) ──
x, w = COL_X[6], COL_W[6]
dd_mode = Dropdown(
row,
values=["横向排列", "纵向排序"],
default=fd["parse_mode"],
on_select=lambda val, i=fi: self._on_mode_change(i, val),
)
dd_mode.place(x=x + CELL_PAD, y=6, width=w - CELL_PAD * 2, height=ROW_H - 12)
fd["mode_dd"] = dd_mode
fd["row_frame"] = row
tk.Frame(self._table_frame, bg=C["border"], height=1).pack(fill=tk.X)
# ==================================================================
# 切换工作表 → 更新解析列
# ==================================================================
def _on_sheet_change(self, idx: int, new_sheet: str):
fd = self._files_data[idx]
fd["selected_sheet"] = new_sheet
try:
import pandas as pd
for engine in ["openpyxl", "xlrd"]:
try:
df = pd.read_excel(fd["path"], sheet_name=new_sheet, nrows=1, engine=engine)
columns = [str(c) for c in df.columns.tolist()]
break
except Exception:
continue
fd["columns"] = columns
first_col = columns[0] if columns else None
fd["selected_col"] = first_col
fd["col_dd"].set_values(columns, default=first_col)
except Exception as e:
messagebox.showerror("错误", f"读取工作表失败: {e}")
# ==================================================================
# 下拉选择回调
# ==================================================================
def _on_col_change(self, idx: int, val: str):
self._files_data[idx]["selected_col"] = val
def _on_mode_change(self, idx: int, val: str):
self._files_data[idx]["parse_mode"] = val
# ==================================================================
# 日志输出
# ==================================================================
def _log_write(self, text: str):
"""向底部日志面板追加一行"""
self._log_text.configure(state=tk.NORMAL)
self._log_text.insert(tk.END, text + "\n")
self._log_text.see(tk.END)
self._log_text.configure(state=tk.DISABLED)
def _log_clear(self):
"""清空日志面板"""
self._log_text.configure(state=tk.NORMAL)
self._log_text.delete("1.0", tk.END)
self._log_text.configure(state=tk.DISABLED)
# ==================================================================
# 确认解析
# ==================================================================
def _on_confirm(self):
if not self._files_data:
return
self._confirm_btn.configure(state=tk.DISABLED, text="⏳ 解析中...")
self._log_clear()
self._log_write("🚀 开始解析...")
# 捕获 print 输出到日志面板
class _LogWriter:
def __init__(self, root, log_func):
self._root = root
self._log = log_func
self._buf = ""
def write(self, s):
self._buf += s
if "\n" in self._buf:
lines = self._buf.split("\n")
self._buf = lines[-1]
for line in lines[:-1]:
stripped = line.strip()
if stripped:
self._root.after(0, lambda t=stripped: self._log(t))
def flush(self):
if self._buf.strip():
self._root.after(0, lambda t=self._buf.strip(): self._log(t))
self._buf = ""
def _run():
import sys
from app import finance_parse as fp
total = len(self._files_data)
success = 0
old_stdout = sys.stdout
old_stderr = sys.stderr
log_writer = _LogWriter(self.root, self._log_write)
sys.stdout = log_writer
sys.stderr = open(os.devnull, "w")
try:
for i, fd in enumerate(self._files_data):
fname = fd["filename"]
self.root.after(0, lambda n=i+1, fn=fname:
self._log_write(f"⏳ [{n}/{total}] 正在处理: {fn}"))
try:
save_path, amount = fp.parse_finance_data(
file_path=fd["path"],
target_index=fd["col_dd"].get(),
is_horizontal=(fd["mode_dd"].get() == "横向排列"),
sheet_name=fd["sheet_dd"].get(),
)
success += 1
fd["result"] = save_path
fd["amount"] = amount
self.root.after(0, lambda fn=fname, sp=save_path, amt=amount:
self._log_write(f"✅ [{success}/{total}] 完成: {fn}{amt}"))
except Exception as e:
fd["error"] = str(e)
self.root.after(0, lambda fn=fname, err=str(e):
self._log_write(f"❌ [{i+1}/{total}] 失败: {fn}{err}"))
finally:
sys.stdout = old_stdout
sys.stderr = old_stderr
self.root.after(0, lambda: self._on_parse_done(success, total))
threading.Thread(target=_run, daemon=True).start()
def _on_parse_done(self, success: int, total: int):
self._confirm_btn.configure(state=tk.NORMAL, text="✅ 开始解析")
# 从列表中移除已成功解析的文件,保留失败的
self._files_data = [fd for fd in self._files_data if "error" in fd]
if success == total:
self._log_write(f"🎉 全部 {total} 个文件解析完成!结果已保存到原文件所在目录。")
messagebox.showinfo("解析完成",
f"全部 {total} 个文件解析完成!\n\n结果已保存到原文件所在目录。")
else:
failed_count = len(self._files_data)
self._log_write(f"⚠️ 完成 {success}/{total},有 {failed_count} 个文件失败。")
errors = []
for fd in self._files_data:
if "error" in fd:
errors.append(f"{fd['filename']}: {fd['error']}")
msg = f"完成 {success}/{total}\n\n失败:\n" + "\n".join(errors[:5])
messagebox.showwarning("解析完成(部分失败)", msg)
# 刷新 UI:清除成功项后重建表格或显示空状态
if self._files_data:
self._build_table()
n = len(self._files_data)
self._status_label.configure(
text=f" 剩余 {n} 个失败文件 | 可调整配置后重新解析",
fg="#dc2626",
)
else:
self._show_empty()
self._path_entry.configure(state="normal")
self._path_entry.delete(0, tk.END)
self._path_entry.insert(0, "请选择 Excel 文件(.xlsx / .xls,可多选)")
self._path_entry.configure(state="readonly", fg=C["text_muted"])
self._status_label.configure(text=" 未选择任何文件")