""" 主窗口 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("", self._toggle) w.bind("", self._on_enter) w.bind("", 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("", self._on_pick) self._listbox.bind("", self._on_pick) self._listbox.bind("", lambda e: self._close()) self._listbox.focus_set() # 点击外部关闭 self._popup.bind("", lambda e: self.after(100, self._check_focus)) # 绑定全局点击关闭(鼠标点击 popup 外) self._grab_bind = self.winfo_toplevel().bind( "", 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("", 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=" 未选择任何文件")