一、整体架构

全部代码在单文件中,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 互转函数对
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 层通过事件驱动和回调注入保持解耦。

Logo

助力合肥开发者学习交流的技术社区,不定期举办线上线下活动,欢迎大家的加入

更多推荐