Flet 资源管理器

一个用 Python + Flet(https://flet.dev) (基于 Flutter) 实现的 Windows 资源管理器仿制品。 支持多标签页、完整的导航历史、文件操作、右键菜单、键盘快捷键,UI 完全声明式,跨平台。 - 核心概念:单向数据流(#%E6%A0%B8%E5%BF%83%E6%A6%82%E5%BF%B5%EF%BC%9A%E5%8D%95%E5%90%91%E6%95%B0%E6%8D%AE%E6%B5%81) - 状态层 (state.py)(#%E7%8A%B6%E6%80%81%E5%B1%82-statepy)

分享

一个用 Python + Flet (基于 Flutter) 实现的 Windows 资源管理器仿制品。

支持多标签页、完整的导航历史、文件操作、右键菜单、键盘快捷键,UI 完全声明式,跨平台。


目录


快速开始

环境要求

  • Python 3.10+
  • Windows / macOS / Linux(驱动器列表只在 Windows 显示盘符)
  • uv(推荐)或 pip

安装并运行

# 克隆/拷贝到本地
cd D:\code\python\side\flet_explorer

# 安装依赖(首次大约 1-2 分钟,要下载 flet-desktop ~30MB)
uv sync

# 启动
uv run python main.py

指定启动目录

$env:FLET_EXPLORER_INITIAL = "D:\my\folder"
uv run python main.py

未设置时默认打开 "此电脑"。

Web 模式

uv run flet run --web --port 8765 main.py

注意:web 模式使用 Pyodide 在浏览器里跑 Python,本项目用了 psutil + pywin32Web 模式无法工作。如需 web 版本要重写文件系统层。


功能清单

✅ 已实现

类别 功能
标签页 新建、关闭、切换;每个标签独立保留路径/历史/选择/视图模式
导航 后退、前进、向上、刷新;完整的历史栈(含 forward 分支截断)
地址栏 可点击的面包屑跳转;点击空白处切换为文本编辑模式直接输入路径
侧边栏 此电脑、快速访问(主文件夹/桌面/下载/文档/图片/音乐/视频)、所有驱动器
侧边栏长按 长按任意条目在新标签页打开
此电脑视图 所有驱动器以磁贴呈现,带容量进度条(>90% 红 / >75% 黄 / 其他蓝)
文件浏览 三种视图:详细信息 / 列表 / 大图标
排序 按名称 / 修改日期 / 类型 / 大小,升降序切换,点击列标题切换
选择 单击单选,Ctrl+点击多选,Ctrl+A 全选
打开 双击文件夹进入,双击文件以系统默认程序打开
文件操作 新建文件夹/文本文档、剪切、复制、粘贴、重命名、删除(含递归删文件夹)
冲突处理 自动追加 " - 副本 (N)" 后缀
对话框 新建文件夹、重命名、删除确认(带文件名预览)
搜索 当前路径递归搜索(限 500 条结果),结果直接显示
隐藏文件 切换显示/隐藏(含 Windows 的 FILE_ATTRIBUTE_HIDDEN
右键菜单 文件行右键菜单:打开/剪切/复制/重命名/删除,文件夹多 "在新标签页打开"
键盘快捷键 F5/F2/Del/ESC/Backspace、Ctrl+C/X/V/T/W/A/N 全套
打开系统资源管理器 一键跳到系统的 explorer.exe
状态栏 项目数、选中数、剪贴板状态、最近操作消息

🚧 未实现(可作为练手扩展)

  • 文件缩略图(图片/视频预览)
  • 拖拽移动/复制
  • 树形侧边栏展开
  • 文件属性详情对话框
  • Shift+点击范围选择
  • 文件搜索全局后台索引
  • 历史记录持久化
  • 主题切换 UI

键盘快捷键

快捷键 动作
F5 刷新当前目录
F2 重命名(单选时)
Delete 删除选中项(带确认)
Backspace 后退
Esc 关闭所有对话框 + 取消选择
Ctrl+C 复制选中项到剪贴板
Ctrl+X 剪切选中项
Ctrl+V 粘贴到当前目录
Ctrl+A 全选当前目录
Ctrl+T 新建标签页
Ctrl+W 关闭当前标签页(至少保留 1 个)
Ctrl+N 新建文件夹(当前目录非"此电脑"时)

项目结构

flet_explorer/
├── main.py             ← UI 组件 + main() 入口(约 1100 行)
├── state.py            ← AppState / TabState 观察者模型(约 230 行)
├── fs_utils.py         ← 文件系统操作(纯函数 + dataclass)
├── tests_state.py      ← 状态层单元测试
├── tests_full.py       ← 端到端测试(含真实文件系统操作)
├── snap.py             ← 调试用:窗口截图工具
├── pyproject.toml      ← uv/PEP 621 项目配置
├── uv.lock             ← 锁文件
├── README.md           ← 本文档
└── .venv/              ← uv 虚拟环境(自动生成)

依赖关系:单向,main.py → state.py → fs_utils.py
fs_utils.py 不 import 任何 Flet 或 state 的东西 —— 它是纯 Python,可独立复用。


架构与设计

核心概念:单向数据流

                ┌──────────────────────────┐
                │                          │
                ▼                          │
       ┌──────────────┐                    │
       │   AppState   │  ← 唯一可信源       │
       │  (Observable) │                    │
       └──────┬───────┘                    │
              │ notify                     │ mutate
              │ (订阅者收到)                │ via state.method()
              ▼                            │
       ┌──────────────┐                    │
       │  Components  │ ──── on_click ─────┘
       │ (@ft.component)│
       └──────────────┘
              │
              ▼
            Flutter
           (UI 渲染)

核心规则(违反任一条,UI 就不刷新):

  1. 所有状态都在 AppState(包括对话框开关、文本草稿)
  2. 组件不直接 mutate 状态,只调用 state.xxx() 方法
  3. 每个 state 方法末尾必须触发 state 级通知(直接修改 state 的字段就行,或调 _bump()
  4. 组件订阅传入的 Observable args/kwargs,不订阅内部访问的嵌套对象

状态层 (state.py)

TabState — 单标签页状态

@ft.observable
@dataclass
class TabState:
    path: str = THIS_PC
    history: list[str] = field(default_factory=lambda: [THIS_PC])
    history_index: int = 0
    view_mode: str = "details"      # details | list | icons
    sort_by: str = "name"
    sort_desc: bool = False
    selected: set[str] = field(default_factory=set)
    search_query: str = ""
    refresh_token: int = 0          # 强制 FolderView 重读目录

只持有数据。can_go_back() / can_go_forward() / title 是只读派生。

AppState — 全局状态 + 操作方法

@ft.observable
@dataclass
class AppState:
    tabs: list[TabState] = field(default_factory=lambda: [TabState()])
    active_index: int = 0
    clipboard_paths: list[str] = field(default_factory=list)
    clipboard_op: str = "copy"      # copy | cut
    show_hidden: bool = False
    status_message: str = ""
    rename_target: str = ""         # 非空 → 显示重命名对话框
    rename_draft: str = ""
    delete_targets: list[str] = field(default_factory=list)
    new_folder_open: bool = False
    new_folder_draft: str = "新建文件夹"
    tick: int = 0                   # 由 _bump() 自增触发通知

所有方法都在 AppState 上:

  • 导航navigate(path) / go_back() / go_forward() / go_up() / refresh()
  • 标签new_tab(path) / close_tab(i) / activate(i)
  • 选择toggle_select(path, ctrl) / select_only(path) / clear_selection() / select_all(paths)
  • 视图set_view_mode(mode) / set_sort_by(by) / toggle_sort_desc() / toggle_show_hidden()
  • 搜索set_search(q) / clear_search()
  • 剪贴板set_clipboard(paths, op) / clear_clipboard()
  • 对话框触发request_rename() / request_delete() / request_new_folder() / close_dialogs()

每个 tab 变更方法都以 self._bump() 结尾,触发 state 级通知,所有订阅了 state 的组件都会重渲染。

文件系统层 (fs_utils.py)

纯函数 + 数据类。零 Flet 依赖

数据类

@dataclass(frozen=True)
class FileEntry:
    name: str
    path: str
    is_dir: bool
    size: int
    modified: float
    ext: str
    # 派生属性
    @property
    def size_str(self) -> str: ...      # "1.5 MB"
    @property
    def modified_str(self) -> str: ...  # "2026/05/23 04:56"
    @property
    def type_str(self) -> str: ...      # "文件夹" / "PY 文件"


@dataclass(frozen=True)
class DriveInfo:
    letter: str
    label: str
    total: int
    used: int
    free: int
    fstype: str
    @property
    def percent(self) -> float: ...     # 已使用比例 0-1

核心函数

# 目录浏览
list_directory(path, show_hidden=False) -> list[FileEntry]
sort_entries(entries, by="name", desc=False) -> list[FileEntry]  # 文件夹永远在前

# 驱动器
get_drives() -> list[DriveInfo]            # Windows 用 psutil 枚举,其他平台用挂载点

# 路径辅助
parent_path(path) -> str | None            # "C:\" 的父级是 THIS_PC
path_segments(path) -> list[tuple[str, str]]   # 给面包屑用
is_this_pc(path) -> bool
get_quick_access() -> list[tuple[label, path, icon_name]]

# 文件操作(都返回 (ok, ...) 错误友好)
create_folder(parent, name) -> (ok, path, err)
create_file(parent, name) -> (ok, path, err)
rename_path(old, new_name) -> (ok, new_path, err)
copy_paths(sources, dest_dir) -> (count, errors)
move_paths(sources, dest_dir) -> (count, errors)
delete_path(path) -> (ok, err)               # 递归删除文件夹
unique_destination(target) -> str            # 解决重名

# 搜索
search_directory(root, query, max_results=500) -> list[FileEntry]

# 系统集成
open_with_default_app(path)
open_in_system_explorer(path)

# 格式化
format_size(num_bytes) -> str               # "1.5 MB"

UI 层 (main.py)

完全声明式,每个 UI 块是一个 @ft.component 函数。

组件树

Root(state)
└── Container (bgcolor="#1e272e", expand=True)
    └── Column
        ├── TabBar(state)
        │   └── Row [TabChip x N, IconButton "+"]
        ├── NavToolbar(state)
        │   └── Row [back, forward, up, refresh, BreadcrumbBar, search field]
        ├── ActionToolbar(state)
        │   └── Row [新建▼, cut, copy, paste, rename, delete, 排序▼, 查看▼, 启动]
        ├── Row (expand=True)
        │   ├── Sidebar(state)
        │   ├── VerticalDivider
        │   └── ContentArea(state)
        │       ├── ThisPCView(state)            ← path == THIS_PC
        │       │   └── Row [DriveTile x N]
        │       └── FolderView(state)            ← 否则
        │           ├── DetailsHeader(state)     ← view_mode == details
        │           └── Column [FileRow x N]
        ├── StatusBar(state)
        └── DialogHost(state)                    ← 渲染当前活动对话框

每个组件订阅什么

只要把 state 作为 kwarg 传入,组件就会自动订阅 state 上的所有字段变化。这就是为什么所有 mutation 都得过 state —— 让 state 知道,从而通知订阅者。


API 参考

state.py 完整 API

# 创建状态
s = AppState()

# 导航
s.navigate("D:\\code")          # 跳转到路径
s.go_back()                     # 后退(如果历史允许)
s.go_forward()                  # 前进
s.go_up()                       # 上一级目录
s.refresh()                     # 重新读取当前目录

# 标签管理
s.new_tab("D:\\")               # 新建标签,自动激活
s.close_tab(0)                  # 关闭索引 0 的标签(至少留一个)
s.activate(2)                   # 切换到标签 2

# 选择
s.toggle_select("path/to/file")           # 单选
s.toggle_select("path", ctrl=True)         # 多选/取消
s.select_only("path")                      # 重置选择为单个
s.clear_selection()
s.select_all([paths])

# 视图
s.set_view_mode("icons")                   # details | list | icons
s.set_sort_by("size")                      # 同 key 再次调用切换升降序
s.toggle_sort_desc()
s.toggle_show_hidden()

# 搜索
s.set_search("foo")                        # 触发当前目录递归搜索
s.clear_search()

# 剪贴板
s.set_clipboard(["a.txt", "b.py"], "copy")
s.clear_clipboard()

# 对话框(设置即触发显示)
s.request_rename()                         # 需要选中恰好 1 项
s.request_delete()                         # 需要至少 1 项选中
s.request_new_folder()                     # 需要非 "此电脑"
s.close_dialogs()                          # 关闭所有对话框

# 当前活动 tab(属性)
s.active                                   # → TabState
s.active.path                              # 当前路径
s.active.selected                          # 选中的 set[str]
s.active.history                           # 历史栈

Flet 0.85 已知坑与解法

这部分是本项目最有价值的实战经验。逐条都是真实踩过的坑。

1. Observable 只订阅 args/kwargs

症状:mutate 嵌套对象,UI 不刷新。

# ❌ 失败:tab 不在订阅图里
def NavToolbar(state):
    tab = state.active
    return ft.IconButton(on_click=lambda: tab.path = "X")

解法:所有变更走 state 方法 + state 字段写入触发通知。

2. 下划线前缀字段不通知

Flet observable 源码:if name.startswith("_"): no notify

_tick_countertickcounter

3. lambda 默认参数被事件参数顶掉

# ❌ Flet 把 event 当成第一个位置参数 → dest = Event(...)
on_click=lambda dest=target: state.navigate(dest)

# ✅ 显式吃事件
on_click=lambda _e=None, dest=target: state.navigate(dest)

4. use_dialog IndexError

File "use_dialog.py", line 98
  prev_lists["controls"][prev_idx] = dialog
IndexError: list assignment index out of range

触发条件

  • 同一组件多次 use_dialog
  • 重新渲染时把新的 AlertDialog 实例传给 use_dialog

解法(必须两步同时上):

# 1. 一个组件只 use_dialog 一次
mode = "delete" if dts else "rename" if rt else "newfolder" if nf else None

# 2. 用 use_memo 让 dialog 实例在同 mode 内稳定
dialog = ft.use_memo(build_dialog, dependencies=[mode])
ft.use_dialog(dialog)

5. memo'd dialog 的闭包陈旧

use_memo 缓存了对话框 → 里面 lambda 捕获的 state 是首次渲染时的快照 → 用户输入后 confirm 拿不到新值。

解法:把草稿提升到 AppState,lambda 直接读 state:

ft.TextField(
    value=state.new_folder_draft,
    on_change=lambda e: setattr(state, "new_folder_draft", e.control.value),
)

def confirm():
    name = state.new_folder_draft.strip()  # 总是读最新

6. ContextMenu 的 items 不被右键触发

# ❌ items 需要手动 await ctx.open()
ContextMenu(items=[...], secondary_trigger=...)

# ✅ 右键自动触发要用 secondary_items
ContextMenu(secondary_items=[PopupMenuItem(...)], secondary_trigger=...)

而且 secondary_items必须PopupMenuItem,塞 ft.Text 不报错但点击无效。

7. PopupMenuItem 没有 text 参数

Flet 0.85 把 text=... 移除了,必须用 content

# ❌ Flet < 0.80
PopupMenuItem(text="文件夹", icon=ft.Icons.FOLDER)

# ✅ Flet 0.85
PopupMenuItem(content=ft.Row([ft.Icon(ft.Icons.FOLDER), ft.Text("文件夹")]))

8. Container 没有 on_double_tap

字段不存在,赋值会报 unexpected keyword argument

# ✅ 用 GestureDetector
ft.GestureDetector(
    content=container_body,
    on_tap=on_select,
    on_double_tap=on_open,
)

9. Flutter 桌面窗口的 GDI 截屏全灰

Flutter 用 DirectComposition,普通 mss / ImageGrab 截不到。

# ✅ include_layered_windows=True 是救命参数
ImageGrab.grab(bbox=rect, all_screens=True, include_layered_windows=True)

10. Web 模式 = Pyodide

flet run --web 把 Python 编译到浏览器(Pyodide)。psutilpywin32 不可用。本项目只支持桌面。要 web 部署需另写一个浏览器友好的文件系统层。


测试

单元测试(状态层)

uv run python tests_state.py

覆盖:tab navigation、history truncation、tabs、selection、view/sort、fs operations、drives、path segments、notification。

端到端测试

uv run python tests_full.py

覆盖:完整的 AppState 操作流程 + 真实文件系统操作(在临时目录里)。

手动测试沙盒

# 1. 创建沙盒
New-Item -ItemType Directory -Path .\sandbox -Force
"hello" | Out-File .\sandbox\test.txt -NoNewline
New-Item -ItemType Directory -Path .\sandbox\subdir -Force

# 2. 启动 app 直接打开沙盒
$env:FLET_EXPLORER_INITIAL = "$pwd\sandbox"
uv run python main.py

# 3. 测完清理
Remove-Item .\sandbox -Recurse -Force

截图工具

# 截当前 Flet 窗口
uv run python snap.py

# 移动到固定位置再截
uv run python snap.py --move

输出到 screenshot.png


开发指南:如何扩展

添加新的操作

举例:实现"复制完整路径"功能。

1. 在 fs_utils.py 加纯函数(如果需要):

def copy_to_clipboard_text(text: str) -> None:
    import subprocess
    subprocess.run(["clip"], input=text.encode("utf-16le"), check=False)

2. 在 AppState 加方法

def copy_path_to_clipboard(self) -> None:
    if not self.active.selected:
        return
    text = "\n".join(self.active.selected)
    fsu.copy_to_clipboard_text(text)
    self.status_message = "已复制完整路径"

3. 在 UI 加按钮(如 ActionToolbar 或右键菜单):

ft.IconButton(
    icon=ft.Icons.LINK,
    tooltip="复制完整路径",
    disabled=not has_sel,
    on_click=lambda: state.copy_path_to_clipboard(),
)

4. 测试 —— 在 tests_full.py 加用例。

添加新的对话框

由于 Flet 0.85 use_dialog 的限制,不要ft.use_dialog,而是扩展 DialogHostmode

# 在 state.py
class AppState:
    properties_target: str = ""        # 新增字段

    def request_properties(self) -> None:
        if len(self.active.selected) == 1:
            self.properties_target = next(iter(self.active.selected))

# 在 main.py DialogHost
mode = ("delete" if dts
        else "rename" if rt
        else "newfolder" if nf_open
        else "properties" if state.properties_target  # ← 新增
        else None)

def build_dialog():
    ...
    if mode == "properties":
        return ft.AlertDialog(...)

添加新的视图模式

  1. TabState.view_mode 已是 str,扩展可选值
  2. FolderView 里加 if tab.view_mode == "new_mode": 分支
  3. ActionToolbarview_menu_items 加菜单项

添加新的快捷键

直接在 make_keyboard_handler() 里加 elif

elif ctrl and shift and key == "n":     # Ctrl+Shift+N
    state.request_new_folder()

性能优化建议

  • 大目录列表(>1000 项):用 ft.ListView(controls=rows) 替代 ft.Column(controls=rows),自动虚拟化
  • 搜索:移到 page.run_thread 异步执行
  • 缩略图:用 LRU 缓存,避免重复读图

故障排查

症状 可能原因 解决
窗口启动后白屏/纯灰 渲染异常,多半是观察者 bug 看终端 stderr,搜 IndexError
点击按钮没反应 lambda dest=... 捕获被事件顶掉 lambda _e=None, dest=...
对话框打开第二次崩溃 use_dialog 内部 bug use_memo 缓存 dialog 身份
输入框输入完确认没生效 dialog 被 memo,闭包陈旧 把草稿放 state
文件名乱码 终端编码问题 PowerShell 不需要管,Python 内部都 UTF-8
启动很慢(>10s) 首次加载 Flutter desktop runtime 正常,后续启动 ~2s
Web 模式启动后空白 Pyodide 不支持 psutil/win32 用桌面模式
截图全灰 Flutter GPU 渲染 GDI 截不到 include_layered_windows=True
鼠标点击错位 DPI 缩放或坐标系混淆 snap.py 用物理像素,windows-mcp 也用物理像素,别混

启用 Flet 调试日志

$env:FLET_LOG_LEVEL = "debug"
uv run python main.py 2>&1 | Tee-Object run.log

验证状态没问题

如果 UI 不正常但你不确定是状态还是渲染问题,跑端到端测试:

uv run python tests_full.py

通过 = 状态层 OK,问题在 UI 绑定。失败 = 状态层有 bug。


依赖与版本

运行时

# pyproject.toml
dependencies = [
    "flet[all]>=0.85,<0.90",
    "psutil>=7.0",
]

开发时(可选)

[dependency-groups]
dev = [
    "mss",
    "pywin32",          # Windows 截图/窗口操作
    "pillow",
    "windows-capture",  # 备用截图方案
]

测试通过的版本组合

  • Python 3.12.13
  • Flet 0.85.1
  • Windows 11 Pro
  • uv 0.5+

Flet 1.0 alpha API 大致兼容,但 use_dialog 内部实现可能改变,需重新测试本文件 "Flet 0.85 已知坑" 第 4-5 条。


项目获得的核心经验

  1. 响应式 UI 的本质是订阅图 —— 框架不会神奇地知道你读了什么,它只能跟踪它能"看见"的引用。
  2. 单一可信源(Single Source of Truth)让状态推理变简单 —— 全放 AppState,配合 Observer 模式。
  3. 不可变更新比就地修改更友好 —— tabs = tabs + [x]tabs.append(x) 对 diff 算法更友好。
  4. 框架 bug 是常态 —— 准备好读源码(venv 里的 .py 都是可读的)+ 写 workaround。
  5. 可测性 = 代码质量 —— fs_utils 全是纯函数,写测试一行实现一行测试。
  6. 桌面应用的 DPI 是一等公民 —— 别用裸 win32 坐标,先统一一套坐标系。
  7. 状态的"形状"决定渲染拓扑 —— 对话框状态放 AppState 还是组件本地,对 memo 行为影响巨大。
  8. GUI 自动化测试很脆 —— 优先写状态层测试,UI 用截图人工 review。

许可

MIT (随项目附带的 pyproject.toml 决定,可自行修改)


致谢

  • Flet —— 用 Python 写 Flutter
  • uv —— 极速 Python 包管理
  • 灵感来自 Windows 11 资源管理器

阅读更多

Web 安全基础

1. HTML 转义(服务端渲染必须): 2. CSP(Content Security Policy): 3. HttpOnly Cookie:防止 JS 读取会话 Cookie: 4. 前端框架防护: 攻击者在第三方网站构造一个表单,诱导已登录用户提交,浏览器会自动携带目标站的 Cookie。 触发条件: 1. 用户已登录目标网站(Cookie 有效) 2. 目标 API 仅凭 Cookie 识别用户身份 3. 请求来源未验证 1. CSRF Token(推荐): 2. SameSite Cookie: 3. 验证 Origin/Referer 头:

By yellowdog

HTTP 协议深度指南

HTTP(HyperText Transfer Protocol)是 Web 的基础传输协议,基于 TCP/IP,采用请求/响应模型。 相关文档:Web安全基础(/web-an-quan-ji-chu/) FastAPI完全指南(/fastapi-wan-quan-zhi-nan/) Nginx完全指南(/nginx-wan-quan-zhi-nan/) 幂等性:多次执行相同请求,服务器状态结果相同。PUT /users/1 多次执行结果一致;POST /users 每次创建新资源,非幂等。 浏览器直接从本地缓存读取,不向服务器发送请求。 缓存命中时,状

By yellowdog

系统设计基础

SLA 对照表: 选择建议:无状态服务(Web 层、API 层)优先水平扩展;数据库初期垂直扩展,达到瓶颈后考虑分库分表或读写分离。 缓存穿透(查询不存在的 key,每次都打到 DB): 缓存击穿(热点 key 过期,瞬间大量请求打到 DB): 缓存雪崩(大量 key 同时过期,或缓存服务宕机): 令牌桶 Python 实现: Redis 实现分布式限流(滑动窗口): URL 命名规则: Cursor 分页响应格式: 雪花算法结构(64 bit): 定义:分布式系统不能同时满足以下三个特性: 在分布式环境中 P 是必须保证的,所以实际是 CP vs AP

By yellowdog

算法思路与模板

二分查找要求序列有序,每次将搜索范围缩减一半,时间复杂度 O(log n)。 两个指针从两端向中间收缩,常用于有序数组。 滑动窗口维护一个满足条件的区间 left, right,right 不断向右扩张,条件不满足时收缩 left。 滑动窗口通用框架: 1. 确定"子问题":原问题可以分解为哪些规模更小的同类问题 2. 定义 dpi 或 dpij 的含义,要足够清晰 3. 推导状态转移方程 4. 确定初始状态(边界条件) 5. 确定计算顺序(确保依赖的子问题先计算) 每件物品最多选一次。dpj = 容量为 j 时的最大价值,逆序遍历容量防止重复选取。 每

By yellowdog