- 添加导航条目和分类数据模型 - 实现导航卡片网格展示 - 支持点击卡片打开链接 - 添加主题样式(渐变色、卡片阴影) - 区分系统默认和自定义条目 - 添加分类统计信息"
26 KiB
Navius Flutter 前端结构与实现说明
1. 范围与前提
本文档描述 frontend/(Flutter Web 前端)的目录结构、分层理由,以及它如何从静态原型落地为可维护的产品代码。
约束条件:
- 目标平台只有 Web,不做移动端 / 桌面端适配。
- 数据来自 Rust 后端的
GET /api/nav(本地 mock 保留给测试用)。 - 不修改
backend/(Rust),只在架构上为将来接入后端留好接口位置。 - 视觉与交互基准是项目根目录的
index3.html,逐条对齐、不自行发挥。
整理目标按优先级排列:可读(新人能顺着目录找到代码)→ 可测(核心逻辑不依赖浏览器)→ 可扩展(接后端时改动面最小)。
逐条核对(每一条 CSS 规则、每一段 JS 对应到哪个文件)见 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/
两条硬规则:
core/不引用features/。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 就接管了控制权,拖窗口不会再覆盖用户的意愿。
网格列数
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
→ 整棵子树(含侧栏)重建」,为了压住这个抖动又叠了去抖、
激活线、程序化滚动的抑制标志,越修越复杂。
最后直接去掉联动。理由:
- 左侧栏和右侧内容区没有因果关系,滚动内容区不该触发侧栏重建。
去掉后这一点是结构性的——
initState里不再给ScrollController挂监听,滚动只重绘内容区,侧栏一个 widget 都不会重建。 - 这个导航站的分区标题本身就在页面上,用户不需要侧栏替他回答 「我在哪一节」。
- 原型里
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. 后续演进建议
按优先级:
- 做提交网站的界面。 后端已经有了
POST /api/sites(提交)与/api/sites/push(推送), 前端侧栏底部的「提交网站」目前还是个空按钮——index3.html里没有这个表单, 所以要先定表单长什么样再动手。 - 同步那 6 条侧栏用例。 要么把侧栏改回用例描述的行为,要么按现在的实现改用例。
- 换成真实图标。 目前卡片左侧是站点名首字母、折叠态是导航首字。若要改成站点图标,需要引入 favicon 抓取或本地图标资源,并处理加载失败的占位。
- 替换 PWA 图标。
web/icons/与web/favicon.png仍是 Flutter 默认图标,需要替换为项目自己的图标。 - 记住用户的偏好。 侧栏折叠状态、展开的分组、当前分区现在都是内存状态,刷新即丢失;可以写进
localStorage。 - 编辑模式。 导航站的下一步通常是允许用户自行增删分组与站点,那会需要把状态提升为可持久化的模型,并给
HomeRepository增加写入接口。 - 深色模式。 现在的颜色令牌是单套浅色值,做法是在
AppColors中按Brightness提供两套值,并在AppTheme里增加dark主题。