feat: 实现导航页基础功能
- 添加导航条目和分类数据模型 - 实现导航卡片网格展示 - 支持点击卡片打开链接 - 添加主题样式(渐变色、卡片阴影) - 区分系统默认和自定义条目 - 添加分类统计信息"
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
# 后端数据结构设计:网站与分组
|
||||
|
||||
> 本文是设计稿,已按此实现。代码在 `backend/`,接口与运行方式见
|
||||
> [backend/README.md](../backend/README.md)。
|
||||
|
||||
## 1. 需求与边界
|
||||
|
||||
- 后端是 Rust,**数据用 JSON 文件存储**,不引入数据库。
|
||||
- 网站分两类:
|
||||
- **系统内置**:随项目发布,只读,只有项目维护者能改。
|
||||
- **可修改**:用户提交、或第三方通过接口推送进来的,能改、能删。
|
||||
- **不需要审核**:提交即上线。
|
||||
- **图标沿用当前逻辑**:前端取名称首字渲染,后端不存图标。
|
||||
- **不统计访问量**。
|
||||
- **提交的网站不能放进内置分组**:提交时必须自带分组名(或分组 + 子分组名),
|
||||
后端只在「用户创建的分组」里找或建;内置分组只有项目维护者自己维护。
|
||||
|
||||
## 2. 一个网站条目的完整结构
|
||||
|
||||
系统内置的一条(`index3.html` 里的 GitHub):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "github",
|
||||
"title": "GitHub",
|
||||
"url": "https://github.com",
|
||||
"description": "代码托管与开源社区",
|
||||
"categoryId": "common",
|
||||
"source": "system",
|
||||
"order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48+08:00",
|
||||
"updatedAt": "2026-09-13T10:29:48+08:00",
|
||||
"submittedBy": ""
|
||||
}
|
||||
```
|
||||
|
||||
用户提交的一条:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "s-20260919-8f3a1c",
|
||||
"title": "Hugging Face",
|
||||
"url": "https://huggingface.co",
|
||||
"description": "开源模型与数据集社区",
|
||||
"categoryId": "g-20260919-4d21ab",
|
||||
"source": "submission",
|
||||
"order": 0,
|
||||
"createdAt": "2026-09-19T14:02:11+08:00",
|
||||
"updatedAt": "2026-09-19T14:02:11+08:00",
|
||||
"submittedBy": "[email protected]"
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `id` | string | 是 | 唯一标识,同时是接口路径里的 id。命名规则见 3.1。 |
|
||||
| `title` | string | 是 | 显示名称。前端取它的首字当图标。上限 60 字符。 |
|
||||
| `url` | string | 是 | 跳转地址,必须是可解析的 `http` / `https`。 |
|
||||
| `description` | string | 否 | 一句话描述,卡片副标题。上限 120 字符。 |
|
||||
| `categoryId` | string | 是 | 所属分组或子分组的 id。**必须指向用户创建的分组**(见第 5 节)。 |
|
||||
| `source` | enum | 是 | `system` / `submission` / `api`。它同时决定这条能不能被修改。 |
|
||||
| `order` | int | 否 | 同一分组内的排序值,小的在前,默认 0。内置条目靠它固定顺序,提交进来的默认追加到末尾。 |
|
||||
| `createdAt` | string | 是 | 创建时间,ISO 8601 带时区。 |
|
||||
| `updatedAt` | string | 是 | 最后修改时间,ISO 8601 带时区。 |
|
||||
| `submittedBy` | string | 否 | 提交来源标识:用户提交放邮箱或用户名,接口推送放调用方标识,内置为空。 |
|
||||
|
||||
### 3.1 id 命名规则
|
||||
|
||||
| 来源 | 规则 | 例子 |
|
||||
| --- | --- | --- |
|
||||
| 系统内置网站 | 手写,语义化,小写字母 + 数字 + `-` | `github`、`can-i-use` |
|
||||
| 用户提交网站 | 后端生成:`s-` + 日期 + 随机后缀 | `s-20260919-8f3a1c` |
|
||||
| 接口推送网站 | 后端生成:`a-` + 日期 + 随机后缀 | `a-20260919-2b77d0` |
|
||||
| 用户创建分组 | 后端生成:`g-` + 日期 + 随机后缀 | `g-20260919-4d21ab` |
|
||||
|
||||
前缀分开是为了**一眼看出这条是谁写的**,也从生成规则上杜绝和内置 id 撞车。
|
||||
|
||||
### 3.2 source:唯一决定「能不能改」的字段
|
||||
|
||||
```rust
|
||||
pub enum SiteSource {
|
||||
System, // 系统内置,只读
|
||||
Submission, // 用户提交
|
||||
Api, // 接口推送
|
||||
}
|
||||
```
|
||||
|
||||
- 只有 `source == "system"` 的条目不可修改:修改、删除接口一律返回 403。
|
||||
- 不单独存 `editable` 字段,它由 `source` 推导。存两份状态迟早不一致,
|
||||
比如手改 JSON 把 `source` 改成 `system`、`editable` 却还是 `true`。
|
||||
- 访客接口返回时附带派生的 `editable` 给前端用,不落盘。
|
||||
|
||||
## 4. 提交时为什么必须自带分组
|
||||
|
||||
分组也是同样的两套:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "materials",
|
||||
"title": "素材资源",
|
||||
"parentId": "",
|
||||
"source": "system",
|
||||
"order": 20,
|
||||
"createdAt": "2026-09-13T10:29:48+08:00",
|
||||
"updatedAt": "2026-09-13T10:29:48+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "design",
|
||||
"title": "UI 设计",
|
||||
"parentId": "materials",
|
||||
"source": "system",
|
||||
"order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48+08:00",
|
||||
"updatedAt": "2026-09-13T10:29:48+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
- `parentId` 为空 = 一级分组;不为空 = 子分组,指向它所属的一级分组。
|
||||
- 层级上限两级,和侧栏的结构一致(`index3.html` 就是两级)。
|
||||
- `source == "system"` 的分组只看不改,由项目维护者手写 JSON 维护。
|
||||
- `source != "system"` 的分组只可能由提交产生。
|
||||
|
||||
### 4.1 提交接口的入参
|
||||
|
||||
提交方**不能指定 `categoryId`**,只能给分组名:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Hugging Face",
|
||||
"url": "https://huggingface.co",
|
||||
"description": "开源模型与数据集社区",
|
||||
"group": "AI 工具",
|
||||
"subGroup": "模型社区",
|
||||
"submittedBy": "[email protected]"
|
||||
}
|
||||
```
|
||||
|
||||
后端按这个顺序处理:
|
||||
|
||||
1. 在**用户创建的分组**里按名字找一级分组 `AI 工具`;找不到就新建一个
|
||||
(`source = "submission"`,id 自动生成)。
|
||||
2. 如果带了 `subGroup`,在 `AI 工具` 下面按名字找子分组;找不到就新建。
|
||||
3. 新建网站条目,`categoryId` 指向解析出来的子分组(没带 `subGroup` 就指向一级分组)。
|
||||
|
||||
规则上:
|
||||
|
||||
- 内置分组**永远不参与**第 1、2 步的查找,所以提交的内容不可能落进内置分组。
|
||||
- 同一个父级下同名的用户分组**复用**,不会每提交一次就多出一个同名分组。
|
||||
如果希望「每次提交都新建」,把查找那一步去掉即可。
|
||||
- 修改接口同样不允许把已有条目挪进内置分组,判定标准和创建时一致。
|
||||
|
||||
## 5. 两种数据如何隔离
|
||||
|
||||
```
|
||||
backend/data/
|
||||
├── site.json # 站点名与搜索框提示,只读
|
||||
├── system-categories.json # 内置分组,只读,项目维护者手写
|
||||
├── system-sites.json # 内置网站,只读
|
||||
├── user-categories.json # 提交时创建的分组,可写
|
||||
└── user-sites.json # 提交 / 推送的网站,可写
|
||||
```
|
||||
|
||||
- 读:启动时全量加载进内存,之后都从内存读,不再碰磁盘。
|
||||
- 写:只写 `user-*.json` 两个文件。**代码里不存在写 `system-*.json` 的路径**,
|
||||
「不可修改」靠结构保证,而不是靠每处调用都记得判断。
|
||||
- 写盘用「先写临时文件再改名」,避免写到一半断电把文件写坏。
|
||||
- JSON 用 2 空格缩进、保留中文原文,方便直接看 diff。
|
||||
|
||||
### 5.1 启动时的自检
|
||||
|
||||
启动时校验一遍,不通过就直接启动失败(防止手改 JSON 越权):
|
||||
|
||||
- `system-*.json` 里的条目 `source` 必须全是 `system`;
|
||||
`user-*.json` 里必须全不是。
|
||||
- 每个网站的 `categoryId` 必须存在,并且**分组来源要和网站来源一致**:
|
||||
内置网站只能挂在系统分组下,提交的网站只能挂在用户分组下。
|
||||
- 分组 `parentId` 指向的分组必须存在,且不能出现两级以上、不能自己指向自己。
|
||||
|
||||
## 6. 校验规则
|
||||
|
||||
写入(用户提交 / 接口推送)时逐条校验,任何一条不过就整体拒绝并返回原因:
|
||||
|
||||
| 规则 | 说明 |
|
||||
| --- | --- |
|
||||
| `id` 唯一 | 与已有网站都不冲突 |
|
||||
| `title` 非空、≤ 60 字符 | 去掉首尾空白后判断 |
|
||||
| `url` 合法 | 可解析,协议必须是 `http` / `https`,且不能是 localhost 与内网地址 |
|
||||
| `description` ≤ 120 字符 | 允许为空 |
|
||||
| `group` 非空、≤ 30 字符 | 没有它就没地方放,直接拒绝 |
|
||||
| `subGroup` ≤ 30 字符 | 可选;给了就必须和 `group` 一起解析成两级 |
|
||||
| `source` 只能是 `submission` / `api` | 外部写入不允许声称自己是 `system` |
|
||||
|
||||
## 7. 和前端现有模型的关系
|
||||
|
||||
前端 `LinkItem` 目前用到 `id` / `title` / `url` / `description` 四个字段,
|
||||
本次结构是它的超集,前端不改也能读出来;`categoryId`、`source`、`editable`
|
||||
等字段等前端要用时再接。
|
||||
|
||||
字段名统一用 **camelCase**,和前端 Dart 的 `fromJson` 对齐,前端不用做名字转换;
|
||||
Rust 侧用 `#[serde(rename_all = "camelCase")]` 映射。
|
||||
|
||||
**新加字段一律带 `#[serde(default)]`**,这样以后往结构里加字段,老 JSON 仍然能读,
|
||||
不需要写迁移脚本。
|
||||
|
||||
## 8. 这次没有放进结构里的东西
|
||||
|
||||
按「先保留当前逻辑」处理,以下都不存;以后要用再加字段(加字段不需要迁移):
|
||||
|
||||
| 没放的 | 原因 | 以后要加时 |
|
||||
| --- | --- | --- |
|
||||
| `icon` | 前端当前是取 `title` 首字渲染 | 加 `icon` 字段,存 favicon 地址或 emoji |
|
||||
| `tags` | 当前侧栏和卡片都没有标签 | 加 `tags: []`,提交接口再放开这个入参 |
|
||||
| `visits` | 不统计访问量 | 要么由后端跳转接口累加,要么接第三方统计 |
|
||||
| `status` | 不需要审核,提交即上线 | 加 `status` 字段并给默认值 `published` |
|
||||
@@ -0,0 +1,429 @@
|
||||
# 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 # 右下角圆形回到顶部
|
||||
├── dialog_buttons.dart # 对话框的主/次按钮
|
||||
├── confirm_dialog.dart # 通用确认框(删除前确认)
|
||||
├── site_form_dialog.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 }, groups: [ { …, sites: [] } ] }
|
||||
│ ▲
|
||||
│ │ 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` | 57 个用例通过,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`、
|
||||
连不上后端分别给出对应提示;缺字段的单条数据被跳过而不是整页失败。
|
||||
- 加载状态:数据没回来时显示「正在加载…」;失败时显示原因和「重试」;点重试会重新取数并在成功后渲染内容。
|
||||
- 提交表单:空表单提交会逐项提示且不发请求;内网地址被拦下;合法内容按 camelCase
|
||||
字段发给仓库;后端报错时留在表单里显示原话;提交中按钮变「提交中…」且不可再点;
|
||||
模板里只列用户分组(内置分组不出现在建议里),点建议会填进输入框。
|
||||
- 联调:点侧栏「提交网站」打开表单;提交成功后静默重新拉一次数据并弹提示。
|
||||
- 编辑 / 删除:可修改的条目才有入口;编辑表单预填现有内容且不显示提交者字段;
|
||||
删除要先确认,点取消什么都不发生;确认后调删除接口并静默刷新;内置条目没有这两个入口。
|
||||
- 站点信息:站点名与搜索框提示来自后端 `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` 主题。
|
||||
@@ -0,0 +1,173 @@
|
||||
# index3.html 逐条对照
|
||||
|
||||
本文档把 `index3.html` 里的每一条 CSS 规则与 JS 行为,逐条对应到 Flutter 实现的位置,用来回答「有没有跑偏」。
|
||||
|
||||
对照方式:`index3.html` 里的选择器 / 声明 → Flutter 文件与常量。
|
||||
|
||||
## 1. 设计变量
|
||||
|
||||
| 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` | `AppColors.cardShadow` |
|
||||
| `--shadow-hover` | `AppColors.cardShadowHover` |
|
||||
| `--sidebar-width: 230px` | `AppSizes.sidebarWidth` |
|
||||
| `--sidebar-collapsed-width: 64px` | `AppSizes.sidebarCollapsedWidth` |
|
||||
|
||||
## 2. 全局
|
||||
|
||||
| index3.html | Flutter |
|
||||
| --- | --- |
|
||||
| `* { box-sizing: border-box; margin: 0; padding: 0 }` | Flutter 无 box-sizing 概念,等价于默认行为 |
|
||||
| `html { scroll-behavior: smooth }` | `Scrollable.ensureVisible(..., duration: 300ms)` |
|
||||
| `body { background-color: var(--bg-color) }` | `Scaffold.backgroundColor = AppColors.background` |
|
||||
| `body { color: var(--text-main) }` | `AppTheme.light` + 各文字样式自带颜色 |
|
||||
| `body { font-family: -apple-system, ... }` | 不指定 `fontFamily`,沿用系统字体栈 |
|
||||
| `body { display: flex; min-height: 100vh }` | `Scaffold.body = Stack[ Row[侧栏, Expanded(内容)] ]` |
|
||||
|
||||
## 3. 侧栏
|
||||
|
||||
| index3.html | Flutter |
|
||||
| --- | --- |
|
||||
| `.sidebar { width: 230px }` | `SidebarNav` 根 `AnimatedContainer.width` |
|
||||
| `.sidebar { background-color: card-bg }` | `AppColors.surface` |
|
||||
| `.sidebar { border-right: 1px solid border }` | `BoxDecoration(border: Border(right: ...))` |
|
||||
| `.sidebar { padding: 20px 12px 12px 12px }` | `sidebarPaddingTop / Horizontal / Bottom` |
|
||||
| `.sidebar { flex-direction: column }` | `Column` |
|
||||
| `.sidebar { height: 100vh }` | `Row(crossAxisAlignment: stretch)` |
|
||||
| `.sidebar { transition: width .3s cubic-bezier(.4,0,.2,1) }` | `sidebarTransition` + `Curves.fastOutSlowIn`(该曲线即 Material standard easing) |
|
||||
| `.sidebar { overflow-x: hidden }` | 根部 `ClipRect` |
|
||||
| `body.sidebar-collapsed .sidebar { width: 64px; padding: 20px 8px 12px 8px }` | `sidebarCollapsedWidth` + `sidebarCollapsedPaddingHorizontal` |
|
||||
| `.sidebar-header { height: 40px; margin-bottom: 24px; 居中 }` | `_SidebarHeader`(`sidebarHeaderHeight` / `sidebarHeaderGap`) |
|
||||
| `.logo { font-size: 18px; font-weight: 700; color: primary }` | `AppTextStyles.logo` |
|
||||
| `.logo-icon { font-size: 20px; font-weight: 800; color: primary }` | `AppTextStyles.logoIcon` |
|
||||
| `body.sidebar-collapsed .logo-text { display: none }` | 折叠时渲染 `logoIcon` 分支 |
|
||||
| `body.sidebar-collapsed .logo-icon { display: block }` | 同上 |
|
||||
| `.nav-container { flex: 1; overflow-y: auto; overflow-x: hidden }` | `Expanded(child: SingleChildScrollView(...))` |
|
||||
|
||||
## 4. 导航
|
||||
|
||||
| index3.html | Flutter |
|
||||
| --- | --- |
|
||||
| `.nav-item-wrapper { margin-bottom: 4px }` | `_NavEntry` 的 `Padding(bottom: navItemGap)` |
|
||||
| `.nav-link { padding: 11px 16px; border-radius: 8px }` | `navItemPaddingVertical/Horizontal`、`navItemRadius` |
|
||||
| `.nav-link { font-size: 14px; font-weight: 500 }` | `AppTextStyles.navItem` |
|
||||
| `.nav-link { color: text-sub }` | `AppColors.textSecondary` |
|
||||
| `.nav-link { justify-content: space-between }` | `Row(mainAxisAlignment: spaceBetween)` |
|
||||
| `.nav-link { cursor: pointer }` | `MouseRegion(cursor: SystemMouseCursors.click)` |
|
||||
| `.nav-link { transition: all .2s }` | `AnimatedContainer(duration: 200ms, curve: Curves.ease)` |
|
||||
| `.nav-link:hover { background-color: #f8fafc; color: primary }` | `AppColors.navItemHover` + `_hovered` |
|
||||
| `.nav-link.active { background-color: #eff6ff; color: primary }` | `AppColors.primaryFaint` + `selected` |
|
||||
| `.arrow-icon { font-size: 14px; font-weight: bold; color: #94a3b8; font-family: monospace }` | `AppTextStyles.navArrow` |
|
||||
| `.arrow-icon { transition: transform .3s ease }` | `AnimatedRotation(duration: 300ms, curve: Curves.ease)` |
|
||||
| `.nav-item-wrapper.open > .nav-link .arrow-icon { transform: rotate(90deg) }` | `turns: open ? 0.25 : 0` |
|
||||
| `.sub-menu { padding-left: 16px; max-height: 0; overflow: hidden }` | `subMenuIndent` + `AnimatedSize`(未展开时不渲染子项) |
|
||||
| `.sub-menu { transition: max-height .3s cubic-bezier(0,1,0,1) }` | `curve: const Cubic(0, 1, 0, 1)` |
|
||||
| `.open .sub-menu { max-height: 500px; transition: max-height .3s ease-in-out }` | `curve: Curves.easeInOut` |
|
||||
| `.sub-menu .nav-link { padding: 8px 16px; font-size: 13px }` | `_SubNavItem` + `AppTextStyles.subNavItem` |
|
||||
| `body.sidebar-collapsed .nav-text { display: none }` | 折叠时 `_NavRow` 不渲染任何子节点 |
|
||||
| `body.sidebar-collapsed .arrow-icon { display: none }` | 折叠时 `_NavRow` 走无 trailing 分支 |
|
||||
| `body.sidebar-collapsed .sub-menu { display: none }` | 折叠时根本不构建 `AnimatedSize` |
|
||||
| `body.sidebar-collapsed .nav-link { justify-content: center; padding: 12px 0 }` | `navItemCollapsedPaddingVertical` + 水平 0 |
|
||||
|
||||
## 5. 侧栏底部
|
||||
|
||||
| index3.html | Flutter |
|
||||
| --- | --- |
|
||||
| `.sidebar-footer { border-top: 1px solid border; padding-top: 12px; margin-top: 8px }` | `_SidebarFooter` |
|
||||
| `.submit-btn { padding: 10px 16px; border-radius: 8px; font-size: 14px; font-weight: 500 }` | `submitButtonPaddingVertical/Horizontal`、`AppTextStyles.submitButton` |
|
||||
| `.submit-btn { color: text-sub; transition: all .2s }` | `AnimatedContainer(curve: Curves.ease)` |
|
||||
| `.submit-btn:hover { background-color: #f1f5f9; color: primary }` | `AppColors.navItemHoverDeep` |
|
||||
| `.submit-btn` 的 `title="提交网站"` | `Tooltip('提交网站')` |
|
||||
| `body.sidebar-collapsed .submit-text { display: none }` | 折叠时不渲染文字 |
|
||||
|
||||
## 6. 主内容区与顶栏
|
||||
|
||||
| index3.html | Flutter |
|
||||
| --- | --- |
|
||||
| `.main-content { margin-left: 230px / 64px }` | `Row[ SizedBox(width: 侧栏), Expanded(内容) ]` |
|
||||
| `.main-content { transition: margin-left .3s cubic-bezier(.4,0,.2,1) }` | 侧栏宽度动画,同一曲线 |
|
||||
| `.main-content { padding: 24px 40px 40px 40px }` | `contentPaddingTop / Horizontal / Bottom` |
|
||||
| `.top-bar { gap: 16px; max-width: 750px; margin: 0 auto 36px auto }` | `TopBar`(`topBarGap` / `topBarMaxWidth` / `topBarMarginBottom` + `Center`) |
|
||||
| `.toggle-btn { width/height: 44px; border-radius: 12px; border: 1px solid border }` | `toggleButtonSize` / `toggleButtonRadius` |
|
||||
| `.toggle-btn { box-shadow: var(--shadow) }` | `AppColors.cardShadow` |
|
||||
| `.toggle-btn { font-size: 18px; color: text-sub }` | `Text('☰', fontSize: 18)` |
|
||||
| `.toggle-btn { transition: all .2s }` | `AnimatedContainer(curve: Curves.ease)` |
|
||||
| `.toggle-btn:hover { border-color: primary; color: primary }` | `_hovered` 分支 |
|
||||
| `.toggle-btn` 的 `title="展开/折叠侧边栏"` | `Tooltip('展开/折叠侧边栏')` |
|
||||
| `.search-container { flex: 1 }` | `Expanded(child: LinkSearchField)` |
|
||||
| `.search-box { height: 44px; padding: 0 20px; border-radius: 12px; font-size: 15px }` | `searchHeight` / `searchPaddingHorizontal` / `searchRadius` / `AppTextStyles.searchInput` |
|
||||
| `.search-box { box-shadow: var(--shadow) }` | 未聚焦时的 `boxShadow` |
|
||||
| `.search-box:focus { border-color: primary; box-shadow: 0 0 0 3px rgba(59,130,246,.15) }` | 聚焦时**只用**光晕(覆盖默认阴影,与 CSS 一致) |
|
||||
| `.search-box { transition: border-color .2s, box-shadow .2s }` | `AnimatedContainer(curve: Curves.ease)` |
|
||||
|
||||
## 7. 分组与卡片
|
||||
|
||||
| index3.html | Flutter |
|
||||
| --- | --- |
|
||||
| `.section-title { font-size: 16px; font-weight: 600; margin-bottom: 16px }` | `AppTextStyles.sectionTitle` + `sectionTitleGap` |
|
||||
| `.section-title::before { width: 4px; height: 16px; margin-right: 8px; border-radius: 2px }` | `_SectionTitle` 的竖条(`sectionAccent*`) |
|
||||
| `.grid { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px }` | `AppBreakpoints.gridColumnsFor` + `gridGap` |
|
||||
| `.grid { margin-bottom: 36px }` | 每个分区统一 `sectionGap` 下间距(最后一个也一样) |
|
||||
| `.card { border-radius: 10px; padding: 14px 16px; border: 1px solid border }` | `cardRadius` / `cardPadding*` / `AppColors.border` |
|
||||
| `.card { box-shadow: var(--shadow); transition: transform .2s, box-shadow .2s }` | `AnimatedContainer(curve: Curves.ease)` |
|
||||
| `.card:hover { transform: translateY(-2px); box-shadow: var(--shadow-hover) }` | `cardHoverLift` + `cardShadowHover` |
|
||||
| `.card-icon { width/height: 36px; border-radius: 8px; background: #f1f5f9; color: primary }` | `_CardIcon`(`cardIconSize` / `cardIconRadius` / `AppColors.iconSurface`) |
|
||||
| `.card-icon { font-weight: 600; margin-right: 12px }` | `AppTextStyles.cardIcon` + `cardIconGap` |
|
||||
| `.card-icon` 未设 font-size,继承 body 的 16px | `AppTextStyles.cardIcon.fontSize = 16` |
|
||||
| `.card-info { overflow: hidden }` + `.card-title/.card-desc` 单行省略 | `Expanded` + `maxLines: 1, overflow: ellipsis` |
|
||||
| `.card-title { font-size: 14px; font-weight: 600 }` | `AppTextStyles.cardTitle` |
|
||||
| `.card-desc { font-size: 12px; color: text-sub; margin-top: 2px }` | `AppTextStyles.cardDesc` + `cardTitleGap` |
|
||||
|
||||
## 8. 回到顶部
|
||||
|
||||
| index3.html | Flutter |
|
||||
| --- | --- |
|
||||
| `.back-to-top { position: fixed; bottom: 30px; right: 30px }` | `Positioned(right: 30, bottom: 30)`(`backToTopOffset`) |
|
||||
| `.back-to-top { width/height: 44px; border-radius: 50% }` | `backToTopSize` + `BoxShape.circle` |
|
||||
| `.back-to-top { font-size: 18px; color: text-sub; box-shadow: var(--shadow) }` | `Text('↑', fontSize: 18)` + `cardShadow` |
|
||||
| `.back-to-top { transition: all .2s ease }` | `AnimatedContainer(curve: Curves.ease)` |
|
||||
| `.back-to-top:hover { color/border-color: primary; transform: translateY(-3px); box-shadow: hover }` | `backToTopHoverLift` + `cardShadowHover` |
|
||||
| `.back-to-top` 的 `title="回到顶部"` | `Tooltip('回到顶部')` |
|
||||
|
||||
## 9. JS 行为
|
||||
|
||||
| index3.html 的脚本 | Flutter |
|
||||
| --- | --- |
|
||||
| 点 `#toggleBtn` → `body.classList.toggle('sidebar-collapsed')` | `HomePage._toggleSidebar` |
|
||||
| 点 `.has-sub` → 若已折叠先移除 `sidebar-collapsed` | `HomePage._toggleGroup` 里 `collapsed` 为真时先 `_collapsedOverride = false` |
|
||||
| 点 `.has-sub` → `parentItem.classList.toggle('open')` | 同一批 id 集合 `_openGroupIds` 的增删 |
|
||||
|
||||
## 10. 当前实现没有任何原型之外的行为
|
||||
|
||||
以下都是「原型里没有」的行为,**都不实现**:
|
||||
|
||||
| 原型的状态 | 当前实现 |
|
||||
| --- | --- |
|
||||
| 搜索框没有任何 JS | 只接收输入,不过滤内容;没有空状态、没有清空按钮 |
|
||||
| `active` 写死在「常用推荐」上 | 高亮固定不动,点其它导航项也不会变 |
|
||||
| 没有媒体查询 | 侧栏始终 230px(点折叠按钮变 64px),主内容内边距始终 `24/40/40/40` |
|
||||
| `#icon`、`#stock`、`#dev`、`#api` 没有对应元素 | 这四个导航项点击后没有任何反应(不滚动、不报错) |
|
||||
| `#submit` 没有对应元素 | 点「提交网站」没有任何反应 |
|
||||
| 只有 `#common`(3 张卡)与 `#design`(2 张卡)两段内容 | 内容区就是这两段、五张卡片 |
|
||||
|
||||
历史上曾经额外加过、现已全部删除的:滚动的侧栏联动、折叠态首字短标签、分组包含当前分区时的强调色、点二级条目自动展开分组、卡片上弹出网址的 tooltip、搜索过滤与空状态、点导航切换高亮、窄屏自动折叠、补齐缺失的四个分区、提交按钮的提示。
|
||||
|
||||
## 11. 验证方式
|
||||
|
||||
- `flutter analyze` → No issues found
|
||||
- `flutter test` → 36 个用例通过
|
||||
- `flutter build web --no-wasm-dry-run` → 构建成功
|
||||
|
||||
其中几条专门守住「不加戏」的边界:
|
||||
|
||||
- 滚动主内容时,侧栏第一行仍是**同一个 widget 实例**(没有被重建),高亮不变。
|
||||
- 折叠态下 `navText('常用推荐')`、`navText('提交网站')` 都找不到——文字确实被隐藏了,与 `display: none` 一致。
|
||||
- 输入搜索词后,仍然是 2 个分区、5 张卡片。
|
||||
- 点 `#icon` 这类没有对应元素的导航,滚动偏移不变。
|
||||
- 点「提交网站」不会弹出任何东西。
|
||||
@@ -0,0 +1,116 @@
|
||||
# 提交网站表单设计
|
||||
|
||||
> 已按本文实现(含「错误色 / 可点建议 / 提交后刷新」三条确认)。代码在
|
||||
> `frontend/lib/features/home/presentation/widgets/submit_site_dialog.dart`。
|
||||
> 实现与本文有几处按实际取舍收敛,差异记在第 8 节。
|
||||
|
||||
## 1. 目标与约束
|
||||
|
||||
- 入口:侧栏底部的「提交网站」按钮(现在是个空按钮)。原型里没有这个表单,
|
||||
所以这里定完之后再动手写。
|
||||
- 只管**用户提交**这一条路(`POST /api/sites`)。接口推送是给第三方用的,
|
||||
走令牌,不在页面上暴露。
|
||||
- **表单里没有「选择分组」这个动作**:后端不接受 `categoryId`,只接受分组名,
|
||||
而且提交的内容永远不会落进内置分组。表单要顺着这个规则设计,
|
||||
不能让用户以为可以把站点塞进「常用推荐」「素材资源」这些内置分组。
|
||||
- 视觉只能用现有设计令牌(`index3.html` 那套),不引入新的配色和圆角体系。
|
||||
|
||||
## 2. 交互流程
|
||||
|
||||
```
|
||||
点侧栏「提交网站」
|
||||
│
|
||||
▼
|
||||
弹出对话框(遮罩 + 白底卡片,居中)
|
||||
│ 填写(改动某个字段时清掉它上一次的错误)
|
||||
▼
|
||||
点「提交」
|
||||
├─ 有字段不合格 → 定位到第一个不合格的字段并提示,不发请求
|
||||
└─ 都合格 → 按钮进入「提交中…」,禁用整张表单
|
||||
├─ 201 → 关掉对话框 + 顶部提示「已提交,感谢」+ 重新拉一次 /api/nav
|
||||
└─ 其它 → 留在对话框里,顶部显示后端返回的原因,可以改了再提交
|
||||
```
|
||||
|
||||
关闭方式:右上角 ×、点遮罩、「取消」按钮、Esc 键。提交中不允许关闭(避免请求还没回来
|
||||
就把界面收了)。
|
||||
|
||||
## 3. 字段定义
|
||||
|
||||
字段与后端 `POST /api/sites` 的入参一一对应,校验规则也和后端保持一致——
|
||||
前端先拦一遍是为了即时反馈,后端那一遍才是权威。
|
||||
|
||||
| 字段 | 控件 | 必填 | 限制 | 不合格时的提示 | 后端字段 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 网站名称 | 单行输入 | 是 | ≤ 60 字 | 请填写网站名称 / 网站名称最多 60 个字 | `title` |
|
||||
| 网站地址 | 单行输入 | 是 | 可解析的 http(s),不能是本机或内网地址 | 请填写网站地址 / 地址要以 http:// 或 https:// 开头 / 不能填本机或内网地址 | `url` |
|
||||
| 一句话描述 | 多行输入(3 行) | 否 | ≤ 120 字 | 描述最多 120 个字 | `description` |
|
||||
| 分组 | 单行输入 | 是 | ≤ 30 字 | 请填写分组名称 | `group` |
|
||||
| 子分组 | 单行输入 | 否 | ≤ 30 字 | 子分组名称最多 30 个字 | `subGroup` |
|
||||
| 你的称呼或邮箱 | 单行输入 | 否 | ≤ 120 字 | 最多 120 个字 | `submittedBy` |
|
||||
|
||||
### 3.1 分组与子分组怎么填
|
||||
|
||||
- 两个都是**填名字**,不是选内置分类。placeholder 分别写:
|
||||
「例如:AI 工具(会创建你自己的分组)」「例如:模型社区(可不填)」。
|
||||
- 分组下面给一行浅色说明:**「提交的网站会放在你自己的分组里,不会进内置分类。」**
|
||||
这句话必须留着,否则用户会以为能提交到「常用推荐」。
|
||||
- 名字和已有的用户分组重名时会**复用**那个分组,不会重复创建——
|
||||
所以两个输入框下面给一排可点的建议:分组建议列出现有用户分组,
|
||||
子分组建议列出所选分组下已有的用户子分组(数据来自 `/api/nav` 里
|
||||
`editable == true` 的分组)。点一下即填入,不想用就直接手打新名字。
|
||||
- 子分组留空表示直接放在一级分组下。
|
||||
|
||||
## 4. 布局与视觉
|
||||
|
||||
沿用现有令牌,不新增尺寸:
|
||||
|
||||
| 部分 | 取值 |
|
||||
| --- | --- |
|
||||
| 遮罩 | `rgba(17, 24, 39, .45)` |
|
||||
| 卡片 | 白底 + 1px `#e2e8f0` 描边 + 12px 圆角 + `cardShadowHover` |
|
||||
| 卡片宽度 | 480px,两侧留 24px(窄屏时自适应) |
|
||||
| 卡片内边距 | 24px |
|
||||
| 标题 | 「提交网站」,`AppTextStyles.sectionTitle`,右侧一个 × 关闭按钮 |
|
||||
| 字段标签 | 13px,`--text-sub`,与输入框间隔 6px |
|
||||
| 输入框 | 复用搜索框那套:44px 高、1px 描边、12px 圆角、聚焦时主色边框 + 3px 光晕 |
|
||||
| 多行框 | 同上,高度 88px(3 行),垂直内边距 10px |
|
||||
| 字段间距 | 16px |
|
||||
| 底部按钮 | 右侧并排:`取消`(描边次级)+ `提交`(主色实底,8px 圆角——和侧栏「提交网站」一致) |
|
||||
| 字段错误文案 | 12px,紧随输入框下方,间隔 4px |
|
||||
|
||||
## 5. 状态
|
||||
|
||||
| 状态 | 表现 |
|
||||
| --- | --- |
|
||||
| 初始 | 所有输入为空,「提交」可点 |
|
||||
| 校验失败 | 不合格的字段旁显示提示,输入框边框变错误色;不发请求 |
|
||||
| 提交中 | 「提交」文案变「提交中…」并禁用,整张表单只读,× / 遮罩 / Esc 都不响应 |
|
||||
| 提交失败 | 卡片顶部一条错误条,显示后端 `{"error": "…"}` 里的原话;表单恢复可编辑 |
|
||||
| 提交成功 | 关闭卡片,弹一条「已提交,感谢」提示,并**静默**重新拉一次 `/api/nav`(不闪加载页) |
|
||||
|
||||
## 6. 三点确认结果
|
||||
|
||||
1. **错误色**:已新增 `AppColors.danger = #E5484D` 与它 8% 的叠加色
|
||||
`dangerFaint`,只用在错误提示和错误边框上。
|
||||
2. **分组建议**:保留,做成可点胶囊;只列 `editable == true` 的分组,
|
||||
内置分组不出现(点了也进不去)。
|
||||
3. **提交成功后刷新**:保留,用静默刷新——页面继续显示旧内容,
|
||||
拉到新数据再整体替换,不会闪一下「正在加载」。
|
||||
|
||||
## 7. 实现落点
|
||||
|
||||
| 文件 | 职责 |
|
||||
| --- | --- |
|
||||
| `features/home/presentation/widgets/submit_site_dialog.dart` | 对话框本体:字段、校验提示、提交按钮 |
|
||||
| `features/home/data/home_repository.dart` | 契约加一个 `submitSite(...)` |
|
||||
| `features/home/data/http_home_repository.dart` | 实现:`POST /api/sites`,把后端的中文错误原样抛给界面 |
|
||||
| `features/home/data/mock_home_repository.dart` | 测试用:记录调用,不真的发请求 |
|
||||
| `features/home/presentation/pages/home_page.dart` | 「提交网站」按钮改为打开对话框;成功后静默重新取数并提示 |
|
||||
|
||||
## 8. 实现与本文的差异
|
||||
|
||||
| 本文原写法 | 实际实现 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| 失焦时校验 | 提交时统一校验,改动字段时清掉该字段的错误 | 少一套焦点状态,行为对用户更可预期 |
|
||||
| 底部按钮是「主色胶囊」 | 主色实底 + 8px 圆角 | `index3.html` 的调色板里没有胶囊圆角,侧栏「提交网站」也是 8px |
|
||||
| 成功提示在页面顶部 | 底部提示条(`SnackBar`) | 复用主题里已经配好的提示条,不新增一套浮层 |
|
||||
Reference in New Issue
Block a user