> ## Content Index
> Fetch the complete content index at: https://blog.vercanti.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Flet 资源管理器
- URL: https://blog.vercanti.com/flet-zi-yuan-guan-li-qi/
- Published: 2026-08-28T14:34:28.000Z
- Updated: 2026-08-28T14:48:28.000Z
- Description: 一个用 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)
- Author: yellowdog
- Tags: Python, Flet

一个用 Python + [Flet](https://flet.dev) (基于 Flutter) 实现的 Windows 资源管理器仿制品。

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

---

## 目录

- [快速开始](#%E5%BF%AB%E9%80%9F%E5%BC%80%E5%A7%8B)
- [功能清单](#%E5%8A%9F%E8%83%BD%E6%B8%85%E5%8D%95)
- [键盘快捷键](#%E9%94%AE%E7%9B%98%E5%BF%AB%E6%8D%B7%E9%94%AE)
- [项目结构](#%E9%A1%B9%E7%9B%AE%E7%BB%93%E6%9E%84)
- [架构与设计](#%E6%9E%B6%E6%9E%84%E4%B8%8E%E8%AE%BE%E8%AE%A1)  
  - [核心概念：单向数据流](#%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)
  - [文件系统层 (fs\_utils.py)](#%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F%E5%B1%82-fsutilspy)
  - [UI 层 (main.py)](#ui-%E5%B1%82-mainpy)
- [API 参考](#api-%E5%8F%82%E8%80%83)
- [Flet 0.85 已知坑与解法](#flet-085-%E5%B7%B2%E7%9F%A5%E5%9D%91%E4%B8%8E%E8%A7%A3%E6%B3%95)
- [测试](#%E6%B5%8B%E8%AF%95)
- [开发指南：如何扩展](#%E5%BC%80%E5%8F%91%E6%8C%87%E5%8D%97%EF%BC%9A%E5%A6%82%E4%BD%95%E6%89%A9%E5%B1%95)
- [故障排查](#%E6%95%85%E9%9A%9C%E6%8E%92%E6%9F%A5)
- [依赖与版本](#%E4%BE%9D%E8%B5%96%E4%B8%8E%E7%89%88%E6%9C%AC)
- [许可](#%E8%AE%B8%E5%8F%AF)

---

## 快速开始

### 环境要求

- Python 3.10+
- Windows / macOS / Linux（驱动器列表只在 Windows 显示盘符）
- [uv](https://github.com/astral-sh/uv)（推荐）或 pip

### 安装并运行

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

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

# 启动
uv run python main.py

```

### 指定启动目录

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

```

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

### Web 模式

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

```

**注意**：web 模式使用 Pyodide 在浏览器里跑 Python，本项目用了 `psutil` \+ `pywin32`，**Web 模式无法工作**。如需 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` — 单标签页状态

```python
@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` — 全局状态 + 操作方法

```python
@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 依赖**。

#### 数据类

```python
@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

```

#### 核心函数

```python
# 目录浏览
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

```python
# 创建状态
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 不刷新。

```python
# ❌ 失败：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`、`_counter` ✅ `tick`、`counter`

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

```python
# ❌ 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`

**解法**（必须两步同时上）：

```python
# 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：

```python
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 不被右键触发

```python
# ❌ 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`：

```python
# ❌ 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`。

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

```

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

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

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

```

### 10\. Web 模式 = Pyodide

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

---

## 测试

### 单元测试（状态层）

```powershell
uv run python tests_state.py

```

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

### 端到端测试

```powershell
uv run python tests_full.py

```

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

### 手动测试沙盒

```powershell
# 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

```

### 截图工具

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

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

```

输出到 `screenshot.png`。

---

## 开发指南：如何扩展

### 添加新的操作

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

**1\. 在 `fs_utils.py` 加纯函数**（如果需要）：

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

```

**2\. 在 `AppState` 加方法**：

```python
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` 或右键菜单）：

```python
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`，而是扩展 `DialogHost` 的 `mode`：

```python
# 在 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. 在 `ActionToolbar` 的 `view_menu_items` 加菜单项

### 添加新的快捷键

直接在 `make_keyboard_handler()` 里加 `elif`：

```python
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 调试日志

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

```

### 验证状态没问题

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

```powershell
uv run python tests_full.py

```

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

---

## 依赖与版本

### 运行时

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

```

### 开发时（可选）

```toml
[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](https://flet.dev) —— 用 Python 写 Flutter
- [uv](https://github.com/astral-sh/uv) —— 极速 Python 包管理
- 灵感来自 Windows 11 资源管理器