---
description: "层级的形状：靠一个 Set 记着开了哪些，深度决定缩进、箭头随开合旋转。数据天然嵌套、想一眼看结构又不愿一次摊开（文件目录、组织架构、菜单），就是它"
---

# 树形（可展开的嵌套列表）

层级的形状：靠一个 `Set<id>` 记着开了哪些，深度决定缩进、箭头随开合旋转。数据天然嵌套、想一眼看结构又不愿一次摊开（文件目录、组织架构、菜单），就是它。

<iframe src="/skills/dashboard/assets/demo-tree-view.html"
        style="width:100%;height:640px;border:1px solid #8884;border-radius:10px"
        loading="lazy" title="形状 demo：树形"></iframe>

试这几下：

- 点箭头或整行 —— 展开/收起，▶ 旋转 90° 成 ▼；父节点右侧标「N 项」
- 展开 **src / features / products/** —— 首次进入用骨架条撑 ~400ms 模拟远程懒加载，加载完缓存不再骨架
- 点**全部展开 / 全部收起** —— 一次性铺开或折起所有目录（懒节点也顺带触发）
- Tab 聚焦到树容器，↑↓ 移动焦点，Enter/Space 触发当前行；叶子文件用 toast 报出完整路径
- 顶部信息条实时跟着状态走：「已展开 N 个目录 · 可见 M 行」，收起后可见行数一目了然

## 规矩

- **展开状态是一个 `Set<id>`**：递归组件按稳定 id 键控，深度决定缩进（demo 用 `depth × 20px`），箭头方向反映开合。demo 放 `useState`，重进就丢；真实版可选择进 URL / `localStorage` 让刷新保持形状
- **树是递归组件**：每个节点自己判断「是文件夹？开了没？」，子节点共用同一份 `expanded / onToggle`，不要在父层特判层数
- **叶子和文件夹点击语义不同**：文件夹只切换展开；叶子路由到详情或触发操作，demo 里 toast 出完整路径（`parent/child/leaf.tsx`）代表这层动作
- **深层节点独立取数**：真实版每个文件夹展开时按自己的 id 单查 `Repository.list({ parentId })`（照 `add-list-view` 的懒加载套路），避免一次拉整棵树；demo 把整棵树预置在内存，用 `LAZY` 集合 + 420ms 定时器 + 骨架条模拟远程延迟，`loaded` 集当客户端缓存，展开过的懒节点再开就秒开

> 蓝本：`add-tree-view.md`[^add-tree-view]（open-dashboard @ `aa9815f`，MIT，Invariants 已消化进上面「规矩」）

??? abstract "demo 源码：`assets/demo-tree-view.html`（自包含、未压缩）"

    ```html
    --8<-- "skills/dashboard/assets/demo-tree-view.html"
    ```

[^add-tree-view]: [`add-tree-view.md`](https://github.com/ahpxex/open-dashboard/blob/aa9815f426e9520841c4aa15a341bfd9f866091f/.claude/skills/add-component/references/add-tree-view.md)，2026-07-28 读。
