Files
Navius/docs/frontend-architecture.md
T
CNWei 039ea70edf feat: 实现导航页基础功能
- 添加导航条目和分类数据模型
- 实现导航卡片网格展示
- 支持点击卡片打开链接
- 添加主题样式(渐变色、卡片阴影)
- 区分系统默认和自定义条目
- 添加分类统计信息"
2026-09-19 23:17:36 +08:00

420 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Navius Flutter 前端结构与实现说明
## 1. 范围与前提
本文档描述 `frontend/`(Flutter Web 前端)的目录结构、分层理由,以及它如何从静态原型落地为可维护的产品代码。
约束条件:
- 目标平台只有 **Web**,不做移动端 / 桌面端适配。
- 数据来自 **Rust 后端的 `GET /api/nav`**(本地 mock 保留给测试用)。
- **不修改 `backend/`(Rust)**,只在架构上为将来接入后端留好接口位置。
- **视觉与交互基准是项目根目录的 `index3.html`**,逐条对齐、不自行发挥。
整理目标按优先级排列:**可读**(新人能顺着目录找到代码)→ **可测**(核心逻辑不依赖浏览器)→ **可扩展**(接后端时改动面最小)。
> 逐条核对(每一条 CSS 规则、每一段 JS 对应到哪个文件)见
> [index3-alignment.md](./index3-alignment.md);本文讲结构与取舍。
## 2. 基准:根目录的 index3.html
`index3.html` 是一份独立静态原型,也是本轮实现的唯一视觉基准:
```
┌──────────────┬──────────────────────────────────────────────┐
│ Suxing Nav │ ☰ 🔍 搜索框 ← 750px 居中顶栏 │
│ │ │
│ 常用推荐 │ ▍常用推荐 │
│ 素材资源 › │ ┌──────┐ ┌──────┐ ┌──────┐ │
│ ├ UI 设计 │ │ 卡片 │ │ 卡片 │ │ 卡片 │ │
│ ├ 图标库 │ └──────┘ └──────┘ └──────┘ │
│ └ 高清壁纸 │ │
│ 开发工具 › │ ▍素材资源 - UI 设计 │
│ ├ 前端框架 │ ┌──────┐ ┌──────┐ │
│ └ 在线 API │ └──────┘ └──────┘ │
│ │ │
│ ────────── │ ⬆ 回到 │
│ 提交网站 │ 顶部 │
└──────────────┴──────────────────────────────────────────────┘
230px(可折叠到 64px)
```
它是一份**原型**而不是成品:搜索框没有任何行为、导航的 `active` 是写死的、折叠后只剩一排空方块、导航里七个锚点有五个没有对应分区、整份文件没有任何媒体查询。这些差距在本轮实现中全部补齐,补齐内容见第 6 节。
## 3. 最终目录结构
仓库整体:
```
Navius/
├── index.html # 早期静态原型(历史版本,保留作参考)
├── index3.html # 当前视觉基准(静态原型,不被构建流程引用)
├── backend/ # Rust 后端,本轮未改动
│ ├── Cargo.toml
│ └── src/main.rs
├── docs/ # 项目级文档
│ └── frontend-architecture.md
└── frontend/ # Flutter Web 前端
├── lib/ # 应用代码(见下方展开)
├── test/ # 测试代码,目录结构与 lib/ 镜像对应
├── web/ # Web 平台资源(index.html、manifest、图标)
├── android/ ios/ linux/ macos/ windows/ # Flutter 脚手架生成,Web 目标不使用
├── analysis_options.yaml # 静态分析与 lint 规则
└── pubspec.yaml # 依赖与项目描述
```
`lib/` 展开:
```
lib/
├── main.dart # 入口:runApp 一行
├── app.dart # 应用根组件:主题配置 + 依赖装配
│
├── core/ # 与业务无关的基础设施
│ ├── constants/
│ │ ├── app_sizes.dart # 尺寸与动效常量
│ │ └── app_breakpoints.dart # 网格列数计算(等价 CSS auto-fill)
│ ├── services/
│ │ ├── url_opener.dart # 打开链接的抽象接口
│ │ └── web_url_opener.dart # Web 实现(唯一依赖 package:web 的文件)
│ ├── theme/
│ │ ├── app_colors.dart # 颜色与阴影
│ │ ├── app_text_styles.dart # 语义化文字样式
│ │ └── app_theme.dart # ThemeData
│ └── utils/
│ └── text.dart # 取首字符(中文 / emoji 安全)
│
└── features/ # 一个功能一个目录
└── home/ # 首页:导航站主界面
├── data/
│ ├── home_repository.dart # 仓库契约(读 / 提交 / 编辑 / 删除,异步)
│ ├── http_home_repository.dart # 后端实现:四个接口 + 映射成前端模型
│ ├── mock_home_data.dart # mock 数据(3 个一级导航 / 2 个分区 / 5 个站点)
│ └── mock_home_repository.dart # mock 仓库实现(测试用)
├── domain/models/
│ ├── nav_node.dart # 侧栏导航节点(叶子 / 可展开分组)
│ ├── link_item.dart # 站点条目模型(含搜索匹配规则)
│ ├── link_section.dart # 分区模型(含分区过滤)
│ └── home_data.dart # 首页聚合根(导航树 + 分区 + 跨分区搜索)
└── presentation/
├── pages/home_page.dart # 首页:布局、状态与交互
└── widgets/
├── sidebar_nav.dart # 侧栏:折叠、层级导航、提交入口
├── top_bar.dart # 顶部控制栏:折叠按钮 + 搜索框
├── link_search_field.dart # 搜索框(含聚焦光晕)
├── link_section_view.dart # 分区标题 + 自适应网格
├── link_card.dart # 站点卡片
└── back_to_top_button.dart # 右下角圆形回到顶部
```
`test/` 展开(与 `lib/` 一一对应):
```
test/
├── core/constants/app_breakpoints_test.dart # 断点与网格列数
├── features/home/
│ ├── data/mock_home_repository_test.dart # 导航树、数据自洽性与搜索规则
│ └── presentation/
│ ├── home_page_test.dart # 布局 / 折叠 / 子菜单 / 搜索 / 联动
│ └── link_card_test.dart # 卡片渲染、点击、悬停
└── helpers/fake_url_opener.dart # 假实现,避免测试真的弹浏览器
```
## 4. 依赖方向与数据流
依赖方向是单向的,箭头表示「可以引用」:
```
main.dart → app.dart
│
▼
features/home ──▶ core/
```
两条硬规则:
1. `core/` 不引用 `features/`。
2. `domain/` 不引用 `data/` 与 `presentation/`。
数据流:
```
后端 GET /api/nav ──▶ { site: { name, searchHint, submitUrl }, groups: [ … ] }
│ ▲
│ │ POST /api/sites(提交)
│ │ PATCH /api/sites/{id}(编辑)
│ │ DELETE /api/sites/{id}(删除)
▼ │ ← 这三个接口页面上暂时不用
HttpHomeRepository ──实现──▶ HomeRepository(读 / 提交 / 编辑 / 删除)
│ 把分组树映射成前端模型:分组 → 侧栏导航;有网站的组 → 内容分区;
│ 每个网站带 editable(页面暂时不据此渲染任何入口)
▼
HomePage State:_load() 拿到 HomeData,并在加载中 / 失败(可重试)/ 完成三种状态间切换
│ (页面持有交互状态:当前分区、侧栏折叠、已展开的分组)
▼
展示组件:只接收数据 + 回调,不自己取数、不自己跳转
```
## 5. 设计令牌:从 CSS 到常量
`index3.html` 用 CSS 变量组织样式,这里逐条映射到 `core/theme` 与 `core/constants`,组件只引用语义名,不再出现十六进制色值或魔法数字。
| index3.html 中的来源 | 取值 | Flutter 对应 |
| --- | --- | --- |
| `--bg-color` | `#f7f9fb` | `AppColors.background` |
| `--card-bg` | `#ffffff` | `AppColors.surface` |
| `--primary-color` | `#3b82f6` | `AppColors.primary` |
| `--text-main` | `#1e293b` | `AppColors.textPrimary` |
| `--text-sub` | `#64748b` | `AppColors.textSecondary` |
| `--border-color` | `#e2e8f0` | `AppColors.border` |
| `--shadow` | `0 4px 6px -1px rgba(0,0,0,.05)` + `0 2px 4px -1px rgba(0,0,0,.03)` | `AppColors.cardShadow` |
| `--shadow-hover` | `0 10px 15px -3px rgba(0,0,0,.08)` + `0 4px 6px -2px rgba(0,0,0,.04)` | `AppColors.cardShadowHover` |
| `.nav-link.active` | `#eff6ff` | `AppColors.primaryFaint` |
| `.nav-link:hover` | `#f8fafc` | `AppColors.navItemHover` |
| `.submit-btn:hover` | `#f1f5f9` | `AppColors.navItemHoverDeep` |
| `.arrow-icon` | `#94a3b8` | `AppColors.navArrow` |
| `.card-icon` 底色 | `#f1f5f9` | `AppColors.iconSurface` |
| `.search-box:focus` 光晕 | `0 0 0 3px rgba(59,130,246,.15)` | `AppColors.focusRing` + `AppSizes.focusRingWidth` |
| `--sidebar-width` | `230px` | `AppSizes.sidebarWidth` |
| `--sidebar-collapsed-width` | `64px` | `AppSizes.sidebarCollapsedWidth` |
| `.sidebar` 内边距 | `20px 12px 12px 12px` | `sidebarPaddingTop / Horizontal / Bottom` |
| 折叠态内边距 | `20px 8px 12px 8px` | `sidebarCollapsedPaddingHorizontal` |
| 折叠动画 | `width .3s cubic-bezier(.4,0,.2,1)` | `sidebarTransition` + `Curves.fastOutSlowIn` |
| `.sidebar-header` | `height:40px; margin-bottom:24px`,logo 居中 | `sidebarHeaderHeight`、`sidebarHeaderGap` |
| `.logo` | `18px / 700` | `AppTextStyles.logo` |
| `.logo-icon` | `20px / 800` | `AppTextStyles.logoIcon` |
| `.nav-link` | `padding: 11px 16px`,圆角 8px,14px / 500 | `navItemPaddingVertical/Horizontal`、`navItemRadius`、`AppTextStyles.navItem` |
| `.nav-item-wrapper` | `margin-bottom: 4px` | `navItemGap` |
| 折叠态 `.nav-link` | `padding: 12px 0` + 居中 | `navItemCollapsedPaddingVertical` |
| `.sub-menu` | `padding-left: 16px`,`.3s` 高度动画 | `subMenuIndent`、`subMenuTransition` |
| `.sub-menu .nav-link` | `padding: 8px 16px`,13px | `subNavItemPaddingVertical/Horizontal`、`AppTextStyles.subNavItem` |
| `.sidebar-footer` | 上边框 + `padding-top:12px; margin-top:8px` | `sidebarFooterPaddingTop`、`sidebarFooterMarginTop` |
| `.submit-btn` | `padding: 10px 16px`,圆角 8px | `submitButtonPaddingVertical/Horizontal` |
| `.main-content` | `padding: 24px 40px 40px 40px` | `contentPaddingTop / Horizontal / Bottom` |
| `.top-bar` | `max-width:750px; gap:16px; margin-bottom:36px`,水平居中 | `topBarMaxWidth`、`topBarGap`、`topBarMarginBottom` |
| `.toggle-btn` | `44×44`,圆角 12px | `toggleButtonSize`、`toggleButtonRadius` |
| `.search-box` | `height:44px; padding: 0 20px`,圆角 12px,15px | `searchHeight`、`searchPaddingHorizontal`、`searchRadius`、`AppTextStyles.searchInput` |
| `.section-title` | `16px / 600`,`::before` 为 4×16 竖条 | `AppTextStyles.sectionTitle`、`sectionAccentWidth/Height/Radius/Gap` |
| 分区下间距 / 网格间距 | `36px` / `16px` | `sectionGap`、`gridGap` |
| `.grid` | `repeat(auto-fill, minmax(220px, 1fr))` | `gridMinItemWidth` + `AppBreakpoints.gridColumnsFor` |
| `.card` | 圆角 10px,内边距 `14px 16px`,悬停 `translateY(-2px)` | `cardRadius`、`cardPaddingVertical/Horizontal`、`cardHoverLift` |
| `.card-icon` | `36px`,圆角 8px,右侧间距 12px | `cardIconSize`、`cardIconRadius`、`cardIconGap` |
| `.card-title` / `.card-desc` | `14px / 600` / `12px`,单行省略 | `AppTextStyles.cardTitle` / `cardDesc` |
| `.back-to-top` | `44×44` 圆形,距右下角 `30px`,悬停 `translateY(-3px)` | `backToTopSize`、`backToTopOffset`、`backToTopHoverLift` |
| `transition: 0.2s` | 200ms | `AppSizes.transition` |
## 6. 明确不实现的原型缺口
当前实现是 `index3.html` 的逐条还原,**没有补任何原型之外的行为**。原型里没做的,这里也不做:
| 原型的状态 | 当前实现 |
| --- | --- |
| 搜索框没有任何 JS | 只接收输入,不过滤内容 |
| `active` 写死在「常用推荐」上 | 高亮固定不动 |
| 没有媒体查询 | 侧栏始终 230px(可手动折到 64px),主内容内边距始终 `24/40/40/40` |
| `#icon`、`#stock`、`#dev`、`#api`、`#submit` 没有对应元素 | 点击后没有任何反应 |
| 只有 `#common`、`#design` 两段内容 | 内容区就是两段、五张卡片 |
唯一必须偏离 CSS 的地方是分区标题的换行:`.section-title` 是 `display:flex`,
浏览器会把过长文字折行,而 Flutter 的 `Row` 会直接溢出报错,
所以标题用 `Flexible` 包住并单行省略。这条差异会随着标题文案的调整而消失。
### 响应式规则
| 视口宽度 | 布局 |
| --- | --- |
| ≥ 900px | 侧栏默认展开(230px) |
| < 900px | 主内容内边距 40 → 20,侧栏默认折叠(64px) |
断点取 900 而不是常见的 768:侧栏 230 + 主内容左右内边距 + 一个 220 的卡片,
再留出可读留白,900 是这套栅格开始舒展的下限。规则写成纯函数
`AppBreakpoints.layoutFor(width)`,可以被单元测试覆盖。
「默认折叠」而不是「强制折叠」:用户手动切换过之后,
`_collapsedOverride` 就接管了控制权,拖窗口不会再覆盖用户的意愿。
### 网格列数
```dart
columns = floor((availableWidth + gap) / (minItemWidth + gap))
```
这与 CSS 的 `repeat(auto-fill, minmax(220px, 1fr))` 等价:比如可用宽度 456 时正好放下两列(220×2 + 16),455 时退回一列。测试里有一个用例专门验证这条边界。
## 7. 为什么这样整理
### 7.1 为什么 `main.dart` 只留几行
入口文件的职责只有一个:把根组件挂到 Flutter 引擎上。任何额外逻辑(主题、依赖装配、路由)放进 `main.dart` 都会让「程序从哪里开始」变得模糊,也没法在测试里绕开它。
### 7.2 为什么是 `core` + `features`,而不是 `models/` `services/` `widgets/`
按技术类型平铺,在项目只有一两个页面时看着整齐;页面一多就会出现「改一个功能要翻五个目录」,而且同名文件互相污染。
按功能切分后,`features/home/` 里的东西只服务于首页,删掉整个目录不影响其它部分。`core/` 则只放与业务无关的东西——主题、尺寸常量、断点计算、取首字符、打开链接的能力。判断标准很简单:**如果它能被原样复制到另一个 App 里,它就属于 `core`**。
### 7.3 为什么功能内部再分 `data / domain / presentation`
- `domain/` 是模型与规则,不依赖 Flutter、不依赖网络,最容易测试。
- `data/` 是「数据从哪来」:后端实现、mock 实现,以及它们共同实现的仓库契约。
- `presentation/` 是「数据怎么显示」,包含页面与组件。
好处是接后端时改动落点明确:只加一个 `data/` 下的实现类,`domain/` 与 `presentation/` 基本不动。
### 7.4 为什么导航用一棵树(`NavNode`)而不是两个列表
侧栏里同时存在两种条目:直接跳转的(常用推荐)和可展开的(素材资源、开发工具)。
如果拆成「一级列表 + 二级字典」,页面就要在两份数据之间来回对照,
「某个分区属于哪个分组」这种问题会散落在多处。
用同一棵树表达,`NavNode.leaves` 能把树拍平成内容区需要的顺序,
`HomeData.groupIdOf(id)` 能回答「这个分区属于哪个分组」,
`isGroup` 决定渲染成跳转项还是展开项。树是唯一真相。
### 7.5 为什么搜索规则写在 `domain` 而不是页面里
`HomeData.search()` 与 `LinkItem.matches()` 是纯函数,不依赖任何 Widget。这样「搜什么、怎么算命中、命中后空分区要不要保留」这几条规则可以直接被单元测试覆盖,不必挂载界面。放到页面里就得先 pump 一整棵组件树才能验证一条过滤规则。
### 7.6 为什么要有 `Repository` 接口 + `Mock` 实现
页面只依赖接口,不依赖具体数据源。这换来两件事:接 Rust 后端时新增一个实现类即可;测试里可以注入任意数据集,不用为「测试专用数据」改动生产代码。
### 7.7 为什么仓库返回 `Future`
接上后端之前,`fetchHomeData` 是同步的:mock 数据是恒定常量,套一层 `Future`
只会凭空多出 loading 与 error 两个分支。
接上 `GET /api/nav` 之后这两个状态变成真实存在的——网络会慢、会断、后端会重启——
所以契约改成 `Future<HomeData>`,页面里相应地有了加载中、加载失败(带重试)、
加载完成三种状态。改动只落在 `data/` 与页面的取数那一处,模型和展示组件没动。
### 7.8 为什么把「打开链接」抽成 `UrlOpener`
卡片直接调用 `web.window.open` 会让组件无法在测试里验证,也会让平台相关代码散落在 UI 里。抽象成接口后,`package:web` 只出现在 `web_url_opener.dart` 一个文件里,测试可以注入假实现,断言「点了哪张卡片、打开了哪个地址」。
### 7.9 为什么不做滚动联动
侧栏的 `active` 只由点击决定,滚动内容区不会改动侧栏。
曾经实现过一版「滚到哪个分区、侧栏就高亮哪一项」的联动,
结果是滚动右侧时左侧一直在闪,观感像有只鼠标在侧栏上一路划过去。
原因有两层:一是内容区每个分区只有一百多像素高,连续滚动时高亮会
一个接一个地亮起;二是当时的路由是「滚动监听 → 页面 `setState`
→ 整棵子树(含侧栏)重建」,为了压住这个抖动又叠了去抖、
激活线、程序化滚动的抑制标志,越修越复杂。
最后直接去掉联动。理由:
1. 左侧栏和右侧内容区没有因果关系,滚动内容区不该触发侧栏重建。
去掉后这一点是结构性的——`initState` 里不再给 `ScrollController`
挂监听,滚动只重绘内容区,侧栏一个 widget 都不会重建。
2. 这个导航站的分区标题本身就在页面上,用户不需要侧栏替他回答
「我在哪一节」。
3. 原型里 `active` 本来就是静态的,去掉联动反而更接近给定设计。
测试里有一条用例守住这条边界:滚动到内容底部后,
断言侧栏第一行的 `AnimatedContainer` 仍是**同一个 widget 实例**
(没有重建),且高亮位置不变。
### 7.10 为什么折叠态显示首字,而不是照搬原型的空白
原型折叠后把 `.nav-text` 全部 `display:none` 且没有准备图标,
侧栏会变成一列没有信息的空方块,用户没有任何线索知道点哪个是哪个。
改成显示标题首字,是在不引入图标资源的前提下、与原设计语言最一致的做法——
logo 折叠后显示的也正是站点名的首字「S」。同理,取首字用 `runes`
而不是 `substring`,中文与 emoji 才不会被截成半个字符。
### 7.11 为什么子菜单收起时把子项从树里移除
CSS 的 `max-height: 0` 只是视觉上收起了子菜单,DOM 里那些链接还在:
屏幕阅读器仍会读到,键盘 Tab 也会跳进去。Flutter 里对应的做法是用
`AnimatedSize` 配合「关掉时不渲染子项」,既保留高度渐变的观感,
又让语义树与点击命中都保持干净。
### 7.12 为什么组件只发回调、状态集中在页面
搜索词、当前分区、侧栏折叠状态、已展开的分组都由页面持有,
组件只负责显示并向上发回调。这样同一个数据不会有两个真相来源。
只有纯本地的、与外界无关的瞬时状态(悬停)才允许留在组件内部。
### 7.13 为什么把颜色、尺寸、文字样式集中
组件里散落十六进制颜色和魔法数字,会让「改主题色」变成全局搜索替换。现在颜色在 `app_colors.dart`、尺寸在 `app_sizes.dart`、文字样式在 `app_text_styles.dart`,组件只引用语义名。要改主色,只改一处。
### 7.14 为什么测试目录镜像 `lib/`
`test/features/home/presentation/link_card_test.dart` 对应 `lib/features/home/presentation/widgets/link_card.dart`。看测试路径就能猜出被测文件的位置,避免测试文件越堆越多之后找不到归属。
### 7.15 为什么保留看起来「没用」的 `cupertino_icons`
项目不使用 Cupertino 图标,但 Flutter 构建时会引用 CupertinoIcons 字体,移除依赖会导致构建出现字体缺失警告。保留模板自带的版本,是为了构建输出干净。
### 7.16 为什么坚持「注释写为什么,不写是什么」
「给变量赋值」这类注释没有信息量,代码本身就能表达。真正值得写下来的是**取舍**:为什么断点取 900、为什么点击时要关掉滚动联动、为什么折叠态用首字。
这类信息一旦丢失,后来者很容易「好心」改回去。
## 8. 编码约定
`analysis_options.yaml` 在 `flutter_lints` 之上补充了这些规则:
- `strict-raw-types`:禁止裸泛型(如 `List` 而非 `List<String>`)。
- `always_declare_return_types`:函数与方法必须显式声明返回类型。
- `avoid_print`:禁止遗留调试输出。
- `prefer_single_quotes`:统一单引号,减少无意义的 diff。
- `require_trailing_commas`:配合 `dart format` 减少改动噪音。
- `sort_child_properties_last`:`child` 固定在参数末尾。
- `unawaited_futures`:未处理的 `Future` 必须显式处理。
未由 lint 强制、但代码里保持一致的几条:
- 依赖方向由人工把关:新增共享代码前先判断它属于 `core` 还是某个功能。
- 每个公开类与公开成员都写 `///` 文档注释,且注释解释「为什么」。
- 组件的构造函数以 `super.key` 开头,参数按「必填数据 → 可选数据 → 回调」排列。
- 能用 `const` 的地方一律加 `const`。
## 9. 验证结果
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 静态分析 | `flutter analyze` | 1 条告警(`sidebar_nav.dart` 里 `_NavRow` 的 `super.key` 从未被传入,见下方说明) |
| 单元与组件测试 | `flutter test` | 47 个用例通过,6 个失败(都是侧栏实现与既有用例不一致,见下方说明) |
| Web 构建 | `flutter build web --no-wasm-dry-run` | 构建成功 |
> 上面这两处不是本轮对接引入的:`e2cc3ac` 那次提交重写了侧栏(行内改用 `Container`、
> 折叠态改回显示首字、logo 换成 `AnimatedCrossFade`),而 `test/` 里那几条用例
> 描述的是重写前的行为。对接后端时没有动侧栏,也没有改这些用例。
测试覆盖的关键行为:
- 网格列数:与 CSS `auto-fill` 规则一致,含「刚好放下两列」的边界。
- 导航树:一级条目形态、叶子 id 与原型锚点一致、`active` 取第一个条目。
- mock 数据自洽性:只有两个分区、共五张卡片,`#icon` 等原型里没有内容的锚点取不到分区,id 唯一、字段完整、地址合法。
- 布局:侧栏展开常驻并渲染 logo 与三条导航、子菜单默认收起、两个分区与五张卡片全部渲染。
- 侧栏折叠:折叠到 64px 且 logo 变首字、导航与提交文字都消失、再点可展开回来。
- 分组子菜单:点击展开、再点收起、折叠状态下点分组会先展开侧栏。
- 导航:高亮固定写在第一个条目上;点有对应分区的导航会滚过去且高亮不变;点 `#icon` 这类没有对应元素的导航滚动偏移不变。
- 搜索框:输入后仍是两个分区、五张卡片(不过滤)。
- 滚动:滚动主内容不会重建侧栏、也不会改动高亮;回到顶部把偏移归零。
- 其它:点击卡片把对应 URL 交给 `UrlOpener`;点「提交网站」跳到后端配的地址;卡片渲染首字母、名称与描述,悬停上浮量等于 `AppSizes.cardHoverLift`。
- 后端对接:请求打到 `/api/nav`;分组映射成侧栏导航树;只有挂着网站的分组进内容区;
子分组标题带父级前缀;网站顺序按后端给的排;非 200、非 JSON、缺 `groups`、
连不上后端分别给出对应提示;缺字段的单条数据被跳过而不是整页失败。
- 加载状态:数据没回来时显示「正在加载…」;失败时显示原因和「重试」;点重试会重新取数并在成功后渲染内容。
- 站点信息:站点名、搜索框提示、提交入口地址都来自后端 `site` 字段,前端不再写死。
## 10. 后续演进建议
按优先级:
1. **做提交网站的界面。** 后端已经有了 `POST /api/sites`(提交)与 `/api/sites/push`(推送),
前端侧栏底部的「提交网站」目前还是个空按钮——`index3.html` 里没有这个表单,
所以要先定表单长什么样再动手。
2. **同步那 6 条侧栏用例。** 要么把侧栏改回用例描述的行为,要么按现在的实现改用例。
3. **换成真实图标。** 目前卡片左侧是站点名首字母、折叠态是导航首字。若要改成站点图标,需要引入 favicon 抓取或本地图标资源,并处理加载失败的占位。
4. **替换 PWA 图标。** `web/icons/` 与 `web/favicon.png` 仍是 Flutter 默认图标,需要替换为项目自己的图标。
4. **记住用户的偏好。** 侧栏折叠状态、展开的分组、当前分区现在都是内存状态,刷新即丢失;可以写进 `localStorage`。
5. **编辑模式。** 导航站的下一步通常是允许用户自行增删分组与站点,那会需要把状态提升为可持久化的模型,并给 `HomeRepository` 增加写入接口。
6. **深色模式。** 现在的颜色令牌是单套浅色值,做法是在 `AppColors` 中按 `Brightness` 提供两套值,并在 `AppTheme` 里增加 `dark` 主题。