当前位置: 首页 > news >正文

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 中,DatePickerCtrlEVT_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 互转函数对
WeekStatusGridLambda 闭包 default arg 技巧;字典状态机;WrapSizer 自动换行
文件导入FileDropTarget 回调注入;FileDialog + 拖拽共用处理逻辑
DatePickerCtrlwx.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 层通过事件驱动和回调注入保持解耦。

http://www.cnnetsun.cn/news/1261432.html

相关文章:

  • 开源OpenClaw部署指南
  • 广州信号灯厂家售后
  • 2026 年数据库变更审批工具怎么选?NineData 凭什么排进第一梯队
  • 硬核!高精度高可靠性位置检测解决方案 | 编码器/电感式/方案分享
  • 鱼眼相机标定矫正详细步骤
  • 阿里云 ECS 部署 OpenClaw 完整教程(10 分钟快速上手)
  • 吃透 SQL 执行顺序:从根源解决 80% 的 SQL 坑
  • Vector人工智能研究院:传统AI解释方法难以适应智能体时代需求
  • 宜家:对妇女的暴力行为从来都不只是影响妇女。
  • synchronized关键字的底层实现
  • 别被云端AI割韭菜了:90%企业的AI转型都在白花钱
  • 航空造“机”,低空管“域”:数字孪生才是空天产业机域一体的操作系统
  • 道生一,一生二,二生气,气生万物。一到十的物理变化《函谷门》
  • 实测!AiPy + OpenClaw = AI界最佳拍档!
  • 你的ChatBI(问数)准确率不到%?带你深度拆解%准确率的高德ChatBI案例
  • 想用 Claude Code 做 AI 编程,很多人其实卡在了接入这一步
  • 做 AI 测试用例系统时,Prompt、MCP、Agent、Skills、OpenClaw 到底分别是什么?
  • 鸿蒙常见问题分析三十三:如何解决Column子组件超出容器边界
  • JUC 并发编程:对可见性、有序性与 volatile的理解
  • RAG检索瓶颈突破实战指南(非常详细),Multi-HyDE与Adaptive HyDE从入门到精通,收藏这一篇就够了!
  • 实现一下简单的聊天功能,包括聊天消息自适应大小
  • KohakuRAG:层次化RAG的新范式
  • 关于DBeaver的一些配置
  • 性能优化-前端性能优化相关
  • asp毕业设计——基于asp+access的网上选题系统设计与实现(毕业论文+程序源码)——网上选题系统
  • 【OS】操作系统分类及用户界面
  • 探索 Stanford Alpaca: 一个强大的深度学习框架
  • 探索超轻量级人脸识别神器:Ultra Light Fast Generic Face Detector 1MB
  • 【亲测免费】 推荐一款强大的数据可视化库——Plotly.js
  • MAPPO动作类型改进(二)——MAPPO+连续环境