2200 行 Python 桌面应用拆解:架构设计与关键实现细节(周报收取统计)
一、整体架构
全部代码在单文件中,2200 余行,分为五个层次:
C:\pythoncode\new\weekly_report_manager.py
┌─────────────────────────────────────────────┐ │ MainFrame(主窗口) │ │ 菜单栏 / SplitterWindow / 状态栏 │ ├───────────────┬─────────────────────────────┤ │ 左:TreeCtrl │ 右:wx.Notebook │ │ 供应商树 │ 主页 / 统计报表 / 项目详情 │ ├───────────────┴─────────────────────────────┤ │ UI 组件层(Dialog + Panel) │ │ AddVendorDialog / CreateProjectDialog / │ │ ImportFileDialog / DateToWeekDialog / │ │ WeekStatusGrid / ProjectDetailPanel / │ │ StatisticsPanel │ ├─────────────────────────────────────────────┤ │ FileStructureManager(数据层) │ │ 文件夹读写 / 状态 JSON 管理 / 统计聚合 │ ├─────────────────────────────────────────────┤ │ 工具函数 + 配置 │ │ date_to_quarter_week / week_date_range / │ │ Config / 常量定义 │ └─────────────────────────────────────────────┘关键设计选择:以文件系统为数据库。
没有用 SQLite 或任何数据库,每个周次对应一个文件夹,文件夹里放周报文件和一个.status.json:
# 文件夹路径即是主键 运维项目周报/供应商A/项目X/2026年第1季度/第6周/ ├── 周报_2026W6.pdf # 实际文件 └── .status.json # {"status": "已收取", "note": "2月8日收到", "updated": "..."}这个选择让「打开文件夹」操作变得零成本,数据天然可被文件管理器浏览,迁移和备份也只是xcopy一条命令。代价是每次读状态都要做一次文件 IO,适合这种数据量不大的场景。
二、数据层:FileStructureManager
这是整个程序的核心类,封装了所有文件系统操作。
2.1 层次遍历
四层结构(供应商→项目→季度→周次)对应四个get_*方法,结构一致:
def get_vendors(self): for item in sorted(os.listdir(self.base_dir)): if item == "归档": # 排除归档文件夹 continue if os.path.isdir(...): vendors.append(item) return vendors每层都跳过"归档"目录,这个约定贯穿整个设计——「归档」是一个特殊名称,存在于每一层但永远被遍历跳过。
2.2 状态读写的容错逻辑
def get_week_status(self, vendor, project, quarter, week): week_path = ... if not os.path.exists(week_path): return STATUS_MISSING files = [f for f in os.listdir(week_path) if not f.startswith('.')] meta_file = os.path.join(week_path, ".status.json") if os.path.exists(meta_file): try: with open(meta_file, ...) as f: data = json.load(f) return data.get('status', STATUS_MISSING if not files else STATUS_RECEIVED) except: pass return STATUS_RECEIVED if files else STATUS_MISSING # fallback:有文件就算已收这里有一个优雅的 fallback 机制:即使没有.status.json,也能根据文件夹是否有内容来推断状态。这使得程序可以无缝接管已有的手动建好的文件夹,不需要初始化操作。
2.3 is_past_week:只统计「已发生」的周次
统计报表只显示当前周及之前的数据,核心判断在这里:
def is_past_week(self, quarter_folder, week): today = datetime.date.today() cur_year, cur_quarter, cur_week = date_to_quarter_week(today) cur_week_num = int(cur_week.replace("第", "").replace("周", "")) # ... 解析目标 if tgt_year < cur_year: return True if tgt_year > cur_year: return False if tgt_q_idx < cur_q_idx: return True if tgt_q_idx > cur_q_idx: return False return tgt_week_num <= cur_week_num # <= 包含当前周注意最后一行是<=,当前周也纳入统计。早期版本用了<,导致当前周不显示,这是一个很典型的 off-by-one 问题。
2.4 get_all_past_details:全局数据汇总
def get_all_past_details(self): details = [] for v in self.get_vendors(): for p in self.get_projects(v): for q in self.get_quarters(v, p): for w in self.get_weeks(v, p, q): if self.is_past_week(q, w): status = self.get_week_status(v, p, q, w) details.append({'vendor': v, 'project': p, 'quarter': q, 'week': w, 'status': status}) return details四层嵌套循环遍历整个树,过滤掉未来周次,返回扁平化的明细列表。统计面板的所有 Tab 都基于这个方法的返回值做二次聚合,不重复读文件。
三、日期计算:周四归属法
这是整个项目中逻辑密度最高的部分,值得单独分析。
3.1 问题描述
需要将任意日期映射到「哪年哪季度第几周」。直觉上很简单,但有一个边界情况:
2026年Q1,1月1日是周四。按自然周(周一到周日)算,这一周的周一是2025年12月29日。如果直接用日期的月份判断季度,12月29日会被算成Q4,导致整个周次编号错误。
3.2 解决方案:周四归属法
def date_to_quarter_week(dt): monday = dt - datetime.timedelta(days=dt.weekday()) # 本周一 thursday = monday + datetime.timedelta(days=3) # 本周四 # 用周四所在的月份/年份来判断这周属于哪个季度 month = thursday.month year = thursday.year quarter_idx = (month - 1) // 3 # 季度第1周的起点:季度首日所在自然周的周一 quarter_start = datetime.date(year, quarter_idx * 3 + 1, 1) q_monday = quarter_start - datetime.timedelta(days=quarter_start.weekday()) week_num = (monday - q_monday).days // 7 + 1 return year, QUARTERS[quarter_idx], f"第{max(week_num, 1)}周"核心思路:一周里的「周四」属于哪个月,这一周就属于哪个季度。这和 ISO 8601 年份归属规则精神一致(ISO 8601 用周四判断年份归属)。
验证:
2026年Q1:季度首日1月1日(周四) → 本周周一是12月29日 周四归属:12月29日这周的周四是1月1日,属于1月 → Q1,✓ 第1周范围:2025-12-29(周一) ~ 2026-01-04(周日)3.3 逆向函数:week_date_range
def week_date_range(year, quarter_label, week_num): quarter_start = datetime.date(year, quarter_idx * 3 + 1, 1) q_monday = quarter_start - datetime.timedelta(days=quarter_start.weekday()) wk_monday = q_monday + datetime.timedelta(weeks=week_num - 1) wk_sunday = wk_monday + datetime.timedelta(days=6) return wk_monday, wk_sunday给定「年份+季度+周号」,返回该周的具体日期范围(周一到周日)。两个函数互为逆向,DateToWeekDialog同时使用两者来展示「日期→周次」和「周次→日期范围」。
四、UI 组件设计
4.1 WeekStatusGrid:自定义状态格子
这是最核心的交互控件,用wx.WrapSizer排列一组wx.Button,每个按钮代表一周:
class WeekStatusGrid(wx.Panel): def setup_ui(self): grid = wx.WrapSizer(wx.HORIZONTAL) # 自动换行 for week in self.weeks: btn = wx.Button(self, label=week.replace("第","").replace("周","W"), size=(52, 38)) # 显示为 "6W" 而非 "第6周" btn.Bind(wx.EVT_BUTTON, lambda e, w=week, b=btn: self.on_week_click(e, w, b)) btn.Bind(wx.EVT_RIGHT_DOWN, lambda e, w=week, b=btn: self.on_week_right(e, w, b))左键循环切换状态:
def on_week_click(self, evt, week, btn): current = self.fs_manager.get_week_status(...) next_status = { STATUS_MISSING: STATUS_RECEIVED, STATUS_RECEIVED: STATUS_LATE, STATUS_LATE: STATUS_MISSING, }.get(current, STATUS_RECEIVED) self.fs_manager.set_week_status(..., next_status) self._apply_btn_style(btn, next_status)用字典代替if-elif链,是状态机的一种简洁写法。三个状态循环:未收→已收→迟交→未收。
Lambda 闭包陷阱:注意lambda e, w=week, b=btn:这种写法。如果写成lambda e: self.on_week_click(e, week, btn),由于 Python 的 late binding,所有 lambda 都会捕获循环结束后的最后一个week/btn值。用默认参数w=week可以在定义时捕获当前值,是 wxPython 事件绑定中必须掌握的技巧。
右键菜单:
def on_week_right(self, evt, week, btn): menu = wx.Menu() for status in [STATUS_RECEIVED, STATUS_MISSING, STATUS_LATE]: item = menu.Append(wx.ID_ANY, f"标记为: {status}") self.Bind(wx.EVT_MENU, lambda e, w=week, b=btn, s=status: self.set_status(w, b, s), item) menu.AppendSeparator() # ... 添加备注、打开文件夹 self.PopupMenu(menu)wx.ID_ANY让 wxPython 自动分配菜单项 ID,避免手动管理 ID 冲突。
4.2 ImportFileDialog:拖拽文件导入
拖拽支持通过wx.FileDropTarget实现:
class DropTarget(wx.FileDropTarget): def __init__(self, callback): super().__init__() self.callback = callback def OnDropFiles(self, x, y, filenames): self.callback(filenames) return True # 必须返回 True 表示接受拖拽 # 注册到 ListCtrl drop_target = DropTarget(self._add_files) self.file_list.SetDropTarget(drop_target)这是一个典型的回调注入模式:DropTarget不知道谁是宿主,只持有一个 callable,由对话框注入具体处理逻辑。
文件列表同时支持wx.FileDialog手动选择,两种方式共用同一个_add_files方法处理,避免逻辑重复。
4.3 DateToWeekDialog:实时响应的日期选择器
class DateToWeekDialog(wx.Dialog): def setup_ui(self): self.dp = wx.adv.DatePickerCtrl(panel, style=wx.adv.DP_DROPDOWN | wx.adv.DP_SHOWCENTURY) today = datetime.date.today() self.dp.SetValue(wx.DateTime(today.day, today.month - 1, today.year)) self.dp.Bind(wx.adv.EVT_DATE_CHANGED, self.on_query) # 日期变化即时查询两个注意点:
1.import wx.adv是必须的。wxPython 4.x 中,DatePickerCtrl、EVT_DATE_CHANGED等控件位于wx.adv子模块,需要显式导入。如果只有import wx,控件会静默创建失败或行为异常,不报任何错误——这是一个很难定位的坑。
2.wx.DateTime的月份从 0 开始。today.month - 1是必要的转换,wx.DateTime用 0 表示一月,而 Python 的datetime.date.month从 1 开始。
wx.DateTime 和 Python date 的互转:
def _wx_date_to_py(self, wx_dt): return datetime.date(wx_dt.GetYear(), wx_dt.GetMonth() + 1, wx_dt.GetDay())+1把 wx 的 0-based 月份转回 Python 的 1-based。这个函数虽然只有一行,但每次使用DatePickerCtrl都会用到,值得封装。
4.4 StatisticsPanel:四 Tab 统计报表
class StatisticsPanel(wx.Panel): def setup_ui(self): self.tab = wx.Notebook(self) self._build_overview_cards() self._build_tab_week() # Tab1:按周次查询 self._build_tab_vendor() # Tab2:按供应商查询 self._build_tab_project() # Tab3:按项目查询 self._build_tab_matrix() # Tab4:交叉汇总表Tab4 交叉汇总表用了wx.grid.Grid,而不是用wx.ListCtrl模拟:
import wx.grid as gridlib grid = gridlib.Grid(self.mx_scroll) grid.CreateGrid(len(proj_keys), len(all_weeks)) grid.SetDefaultColSize(62) grid.SetRowLabelSize(160) grid.EnableEditing(False) # 按状态设置格子颜色 for ri, (vendor, project) in enumerate(proj_keys): for ci, week in enumerate(all_weeks): if week not in past_week_set: grid.SetCellBackgroundColour(ri, ci, wx.Colour(220, 220, 220)) elif status == STATUS_RECEIVED: grid.SetCellBackgroundColour(ri, ci, wx.Colour(39, 174, 96)) grid.SetCellValue(ri, ci, "✔") # ... grid.SetCellAlignment(ri, ci, wx.ALIGN_CENTRE, wx.ALIGN_CENTRE)wx.grid.Grid支持单元格级别的背景色、字体、对齐方式,比wx.ListCtrl的 item attribute 方式更直接,尤其适合这种需要全格着色的场景。
五、主窗口架构
5.1 SplitterWindow + Notebook 的组合
self.splitter = wx.SplitterWindow(self, style=wx.SP_3D | wx.SP_LIVE_UPDATE) # 左:深色树形导航 left_panel.SetBackgroundColour(wx.Colour(44, 62, 80)) # 右:Notebook 多 Tab self.notebook = wx.Notebook(self.right_panel) self.splitter.SplitVertically(left_panel, self.right_panel, 220)wx.SP_LIVE_UPDATE使分割线拖动时实时重绘,而不是拖完松手才更新。SetMinimumPaneSize(180)防止左栏被拖到消失。
5.2 树形控件的数据绑定
wxPython 的TreeCtrl通过SetItemData给每个节点绑定任意 Python 对象:
v_item = self.tree.AppendItem(root, f"🏢 {vendor}") self.tree.SetItemData(v_item, ('vendor', vendor)) p_item = self.tree.AppendItem(v_item, f"📋 {project}") self.tree.SetItemData(p_item, ('project', vendor, project))选中节点时,通过GetItemData取回数据,判断类型做不同响应:
def on_tree_select(self, evt): data = self.tree.GetItemData(evt.GetItem()) if data is None: return kind = data[0] if kind == 'vendor': ... elif kind == 'project': vendor, project = data[1], data[2] self.open_project_tab(vendor, project)这比用节点文本做字符串解析要干净得多。
5.3 项目 Tab 的懒加载与缓存
def open_project_tab(self, vendor, project): key = (vendor, project) if key in self.project_panels: # 已有 Tab,直接切换 for i in range(self.notebook.GetPageCount()): if self.notebook.GetPage(i) == self.project_panels[key]: self.notebook.SetSelection(i) return # 新建 Tab panel = ProjectDetailPanel(self.notebook, self.fs_manager, vendor, project) tab_title = f"📋 {project}" if self.notebook.GetPageCount() >= 9: # 最多9个 Tab,超出替换最旧的 self.notebook.DeletePage(2) # 从第3页开始替换(前两页固定) self.notebook.AddPage(panel, tab_title, select=True) self.project_panels[key] = panelself.project_panels字典缓存已打开的面板,重复点击同一项目不会重复创建。当 Tab 数量超过 9 时,删除最旧的(第 2 页之后),防止无限增长。
六、几个值得关注的细节
6.1 wx.ID_CANCEL vs wx.ID_CLOSE
# 错误写法:在 Windows 上会触发系统级窗口关闭事件 wx.Button(panel, wx.ID_CLOSE, "关闭") # 正确写法 wx.Button(panel, wx.ID_CANCEL, "关闭")wx.ID_CLOSE在 Windows 上绑定了系统行为,会导致对话框在某些情况下异常关闭。对话框的「关闭」按钮应该用wx.ID_CANCEL(或手动绑定EndModal)。
6.2 CSV 导出用 utf-8-sig
with open(path, 'w', encoding='utf-8-sig') as f:utf-8-sig会在文件开头写入 BOM(Byte Order Mark)。在 Windows 上用 Excel 直接双击打开 CSV 时,BOM 告诉 Excel 这是 UTF-8 编码,否则中文会乱码。这是一个在国内 Windows 环境下的必要处理。
6.3 wx.LaunchDefaultApplication 打开文件夹
def open_folder(self, week): path = os.path.join(self.fs_manager.base_dir, ...) if os.path.exists(path): wx.LaunchDefaultApplication(path)wx.LaunchDefaultApplication跨平台:在 Windows 上调用 Explorer,在 macOS 上调用 Finder,在 Linux 上调用默认文件管理器,不需要写平台判断。
6.4 FlexGridSizer 做表单布局
form = wx.FlexGridSizer(0, 2, 10, 10) # 行数自动,2列,行间距10,列间距10 form.AddGrowableCol(1) # 第2列(值列)随窗口宽度拉伸 form.Add(wx.StaticText(..., label="供应商名称:"), 0, wx.ALIGN_CENTER_VERTICAL) form.Add(self.txt_name, 1, wx.EXPAND)FlexGridSizer比手动嵌套多个BoxSizer清晰得多,AddGrowableCol(1)让输入框列自动填充剩余宽度,是做对话框表单的标准做法。
七、总结
| 模块 | 关键技术点 |
|---|---|
| 数据层 | 文件系统即数据库;.status.json 状态持久化;fallback 推断机制 |
| 日期计算 | 周四归属法解决季度边界问题;date↔week 互转函数对 |
| WeekStatusGrid | Lambda 闭包 default arg 技巧;字典状态机;WrapSizer 自动换行 |
| 文件导入 | FileDropTarget 回调注入;FileDialog + 拖拽共用处理逻辑 |
| DatePickerCtrl | wx.adv 显式导入;wx.DateTime 月份 0-based 转换 |
| 交叉汇总表 | wx.grid.Grid 单元格着色;ScrolledWindow 嵌套 |
| 主窗口 | TreeCtrl + SetItemData 数据绑定;Notebook Tab 懒加载+缓存 |
| 细节 | ID_CANCEL vs ID_CLOSE;utf-8-sig BOM;LaunchDefaultApplication 跨平台 |
这个项目的整体结构对于同等规模的 wxPython 应用有一定参考价值:用文件系统替代嵌入式数据库,降低了部署复杂度;状态与文件共存于同一目录,保持数据的局部性;UI 层通过事件驱动和回调注入保持解耦。
