2200 行 Python 桌面应用拆解:架构设计与关键实现细节(周报收取统计)
这是最核心的交互控件,用排列一组wx.Buttongrid = wx.WrapSizer(wx.HORIZONTAL) # 自动换行btn = wx.Button(self, label=week.replace("第","").replace("周","W"),size=(52, 38)) # 显示为 "6W" 而非 "第6周"用字典代替if-elif链,是状态机的一种简洁写法。三个状态循环:未
一、整体架构
全部代码在单文件中,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] = panel
self.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 层通过事件驱动和回调注入保持解耦。
更多推荐



所有评论(0)