feat: 实现导航页基础功能

- 添加导航条目和分类数据模型
- 实现导航卡片网格展示
- 支持点击卡片打开链接
- 添加主题样式(渐变色、卡片阴影)
- 区分系统默认和自定义条目
- 添加分类统计信息"
This commit is contained in:
2026-09-19 23:17:36 +08:00
parent 1c98340fc5
commit 039ea70edf
26 changed files with 796 additions and 2583 deletions
+28 -11
View File
@@ -27,22 +27,39 @@ cargo clippy --all-targets
```
data/
├── site.json # 站点名与搜索框提示,只读
├── system-categories.json # 内置分组,只读,自己手写维护
├── system-sites.json # 内置网站,只读
├── user-categories.json # 提交时创建的分组,自动写
└── user-sites.json # 提交 / 推送的网站,自动写
└── nav.json # 站点信息 + 分组结构 + 网站,全在这一份文档里
```
侧栏的结构和网站数据放在同一个文件里,删除网站和「它的分组要不要一起清掉」
是同一次写入完成的——不会出现网站没了、侧栏还挂着一个空分组的中间状态。
写盘用「先写临时文件再改名」,不会因为写到一半断电把文件写坏。
```json
{
"site": { "name": "Suxing Nav", "searchHint": "…", "submitUrl": "https://mattermost…" },
"groups": [
{
"id": "common", "title": "常用推荐", "source": "system", "order": 10,
"createdAt": "…", "updatedAt": "…",
"sites": [ { "id": "github", "title": "GitHub", "url": "…", "source": "system", … } ],
"children": []
}
]
}
```
启动时会自检,任何一条不通过就直接退出(避免带着坏数据继续跑):
- `system-*.json` 里的 `source` 必须全是 `system`,`user-*.json` 里必须全不是;
- 网站 id、分组 id 不能重复;
- 每个网站的 `categoryId` 必须存在,且**内置网站只能挂在内置分组下、
提交的网站只能挂在用户分组下**;
- 分组的 `parentId` 必须存在、必须是一级分组(最多两级),也不能指向自己。
- `site.name` 不能为空;
- 分组 id、网站 id 不能重复(两者共用一个 id 空间);
- 分组最多两级;
- **内置分组下只能放内置网站,用户分组下只能放用户网站**。
写盘用「先写临时文件再改名」,不会因为写到一半断电把文件写坏。
### 内置与用户数据怎么隔离
以前是「系统文件只读、用户文件可写」,现在合并成一个文件,隔离改成靠 `source`:
写操作只会碰 `source != system` 的分组和网站,内置的那部分只会被原样读出来再写回去。
启动自检会拦住「手改 JSON 把内置网站标成用户来源」这类越权。
## 接口
+148
View File
@@ -0,0 +1,148 @@
{
"site": {
"name": "Suxing Nav",
"searchHint": "输入关键字搜索全网资源...",
"submitUrl": "https://mattermost.example.com"
},
"groups": [
{
"id": "common",
"title": "常用推荐",
"source": "system",
"order": 10,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"sites": [
{
"id": "github",
"title": "GitHub",
"url": "https://github.com",
"description": "代码托管与开源社区",
"source": "system",
"order": 10,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"submittedBy": ""
},
{
"id": "zhihu",
"title": "知乎",
"url": "https://www.zhihu.com",
"description": "深度内容问答社区",
"source": "system",
"order": 20,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"submittedBy": ""
},
{
"id": "bilibili",
"title": "哔哩哔哩",
"url": "https://www.bilibili.com",
"description": "弹幕视频分享网站",
"source": "system",
"order": 30,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"submittedBy": ""
}
],
"children": []
},
{
"id": "materials",
"title": "素材资源",
"source": "system",
"order": 20,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"sites": [],
"children": [
{
"id": "design",
"title": "UI 设计",
"source": "system",
"order": 10,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"sites": [
{
"id": "figma",
"title": "Figma",
"url": "https://www.figma.com",
"description": "协同界面设计工具",
"source": "system",
"order": 10,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"submittedBy": ""
},
{
"id": "dribbble",
"title": "Dribbble",
"url": "https://dribbble.com",
"description": "全球设计作品分享社区",
"source": "system",
"order": 20,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"submittedBy": ""
}
],
"children": []
},
{
"id": "icon",
"title": "图标库",
"source": "system",
"order": 20,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"sites": [],
"children": []
},
{
"id": "stock",
"title": "高清壁纸",
"source": "system",
"order": 30,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"sites": [],
"children": []
}
]
},
{
"id": "dev-tools",
"title": "开发工具",
"source": "system",
"order": 30,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"sites": [],
"children": [
{
"id": "dev",
"title": "前端框架",
"source": "system",
"order": 10,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"sites": [],
"children": []
},
{
"id": "api",
"title": "在线 API",
"source": "system",
"order": 20,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"sites": [],
"children": []
}
]
}
]
}
-4
View File
@@ -1,4 +0,0 @@
{
"name": "Suxing Nav",
"searchHint": "输入关键字搜索全网资源..."
}
-74
View File
@@ -1,74 +0,0 @@
[
{
"id": "common",
"title": "常用推荐",
"parentId": "",
"source": "system",
"order": 10,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z"
},
{
"id": "materials",
"title": "素材资源",
"parentId": "",
"source": "system",
"order": 20,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z"
},
{
"id": "design",
"title": "UI 设计",
"parentId": "materials",
"source": "system",
"order": 10,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z"
},
{
"id": "icon",
"title": "图标库",
"parentId": "materials",
"source": "system",
"order": 20,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z"
},
{
"id": "stock",
"title": "高清壁纸",
"parentId": "materials",
"source": "system",
"order": 30,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z"
},
{
"id": "dev-tools",
"title": "开发工具",
"parentId": "",
"source": "system",
"order": 30,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z"
},
{
"id": "dev",
"title": "前端框架",
"parentId": "dev-tools",
"source": "system",
"order": 10,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z"
},
{
"id": "api",
"title": "在线 API",
"parentId": "dev-tools",
"source": "system",
"order": 20,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z"
}
]
-62
View File
@@ -1,62 +0,0 @@
[
{
"id": "github",
"title": "GitHub",
"url": "https://github.com",
"description": "代码托管与开源社区",
"categoryId": "common",
"source": "system",
"order": 10,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"submittedBy": ""
},
{
"id": "zhihu",
"title": "知乎",
"url": "https://www.zhihu.com",
"description": "深度内容问答社区",
"categoryId": "common",
"source": "system",
"order": 20,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"submittedBy": ""
},
{
"id": "bilibili",
"title": "哔哩哔哩",
"url": "https://www.bilibili.com",
"description": "弹幕视频分享网站",
"categoryId": "common",
"source": "system",
"order": 30,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"submittedBy": ""
},
{
"id": "figma",
"title": "Figma",
"url": "https://www.figma.com",
"description": "协同界面设计工具",
"categoryId": "design",
"source": "system",
"order": 10,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"submittedBy": ""
},
{
"id": "dribbble",
"title": "Dribbble",
"url": "https://dribbble.com",
"description": "全球设计作品分享社区",
"categoryId": "design",
"source": "system",
"order": 20,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"submittedBy": ""
}
]
-47
View File
@@ -1,47 +0,0 @@
[
{
"id": "g-20260919-c8460f",
"title": "嗯嗯",
"parentId": "",
"source": "submission",
"order": 10,
"createdAt": "2026-09-19T13:30:25Z",
"updatedAt": "2026-09-19T13:30:25Z"
},
{
"id": "g-20260919-e78b21",
"title": "收拾收拾",
"parentId": "g-20260919-c8460f",
"source": "submission",
"order": 10,
"createdAt": "2026-09-19T13:32:03Z",
"updatedAt": "2026-09-19T13:32:03Z"
},
{
"id": "g-20260919-460478",
"title": "AI 工具",
"parentId": "",
"source": "submission",
"order": 20,
"createdAt": "2026-09-19T13:38:10Z",
"updatedAt": "2026-09-19T13:38:10Z"
},
{
"id": "g-20260919-6b3fa7",
"title": "模型社区",
"parentId": "g-20260919-460478",
"source": "submission",
"order": 10,
"createdAt": "2026-09-19T13:38:10Z",
"updatedAt": "2026-09-19T13:38:10Z"
},
{
"id": "g-20260919-91da17",
"title": "反反复复",
"parentId": "g-20260919-c8460f",
"source": "submission",
"order": 20,
"createdAt": "2026-09-19T13:39:53Z",
"updatedAt": "2026-09-19T13:39:53Z"
}
]
-14
View File
@@ -1,14 +0,0 @@
[
{
"id": "s-20260919-f103b8",
"title": "Hugging Face",
"url": "https://huggingface.co",
"description": "开源模型与数据集社区",
"categoryId": "g-20260919-6b3fa7",
"source": "submission",
"order": 10,
"createdAt": "2026-09-19T13:38:10Z",
"updatedAt": "2026-09-19T13:38:10Z",
"submittedBy": "[email protected]"
}
]
+4 -6
View File
@@ -9,9 +9,7 @@ use axum::{Json, Router};
use tower_http::cors::CorsLayer;
use crate::error::ApiError;
use crate::model::{
CreateSiteRequest, NavResponse, Site, SiteView, Source, UpdateSiteRequest,
};
use crate::model::{CreateSiteRequest, NavResponse, SiteView, Source, UpdateSiteRequest};
use crate::store::{Store, Summary};
/// 全局状态。仓库放在互斥锁里:接口可能并发到达,而写文件必须串行。
@@ -81,7 +79,7 @@ async fn update_site(
) -> Result<Json<SiteView>, ApiError> {
let mut store = lock(&state)?;
let site = store.update(&id, &request)?;
Ok(Json(SiteView::from(site)))
Ok(Json(site))
}
async fn delete_site(
@@ -99,8 +97,8 @@ fn create(
source: Source,
) -> Result<(StatusCode, Json<SiteView>), ApiError> {
let mut store = lock(state)?;
let site: Site = store.create(&request, source)?;
Ok((StatusCode::CREATED, Json(SiteView::from(site))))
let site: SiteView = store.create(&request, source)?;
Ok((StatusCode::CREATED, Json(site)))
}
/// 取锁并把「锁被毒化」翻译成 500。
+49 -58
View File
@@ -1,11 +1,14 @@
//! 落盘数据结构与对外返回结构。
//! 数据结构:落盘的一棵树、对外返回的视图、以及请求入参。
//!
//! 落盘只有一个文件 `data/nav.json`:站点信息、分组 / 子分组、以及挂在分组下的
//! 网站全在一份文档里。这样删除一个网站时,它的分组是否需要一起清掉,
//! 和网站本身在同一次写入里完成,不会出现「网站没了、分组还空着」的中间状态。
use serde::{Deserialize, Serialize};
/// 数据来源。**唯一决定条目能不能被修改的字段。**
///
/// 不额外存 `editable`:存两份状态迟早会不一致,比如手改 JSON 把 `source`
/// 改成 `system`、`editable` 却还是 `true`。对外返回时按这里推导。
/// 不额外存 `editable`:存两份状态迟早会不一致。对外返回时按这里推导。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Source {
@@ -24,14 +27,28 @@ impl Source {
}
}
/// 一个网站条目。
/// 站点级配置,由项目维护者手改。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SiteMeta {
/// 站点名,显示在侧栏顶部与浏览器标题。
pub name: String,
/// 搜索框占位文案。
#[serde(default)]
pub search_hint: String,
/// 「提交网站」跳转的地址(Mattermost 之类的收集入口)。
#[serde(default)]
pub submit_url: String,
}
/// 一个网站。
///
/// 字段名在 JSON 里是 camelCase(和前端 Dart 的 `fromJson` 一致),
/// 以后新增字段一律加 `#[serde(default)]`,老 JSON 文件仍然能读,不必写迁移。
/// 网站不再自己记 `categoryId`——它就挂在某个分组的 `sites` 里,
/// 归属关系由位置表达,删除分组时不会留下指向空处的引用。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Site {
/// 唯一标识,也是接口路径里的 id。
/// 唯一标识。
pub id: String,
/// 显示名称。前端取它的首字当图标。
pub title: String,
@@ -39,8 +56,6 @@ pub struct Site {
pub url: String,
#[serde(default)]
pub description: String,
/// 所属分组或子分组的 id。
pub category_id: String,
pub source: Source,
/// 同一分组内的排序值,小的在前。
#[serde(default)]
@@ -53,71 +68,58 @@ pub struct Site {
}
impl Site {
/// 能不能被接口修改。
pub fn is_editable(&self) -> bool {
!self.source.is_system()
}
}
/// 分组。`parent_id` 为空表示一级分组,不为空表示子分组。
/// 一个分组:可以挂网站,也可以带子分组(层级最多两级)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Category {
pub struct Group {
pub id: String,
pub title: String,
#[serde(default)]
pub parent_id: String,
pub source: Source,
#[serde(default)]
pub order: i32,
pub created_at: String,
pub updated_at: String,
/// 直接挂在这个分组下的网站。
#[serde(default)]
pub sites: Vec<Site>,
/// 子分组;只有一级分组会有。
#[serde(default)]
pub children: Vec<Group>,
}
impl Category {
/// 是不是一级分组。
pub fn is_root(&self) -> bool {
self.parent_id.is_empty()
}
/// 能不能被接口改动(提交时新建的分组才能被后续提交复用,内置分组只看不改)。
impl Group {
pub fn is_editable(&self) -> bool {
!self.source.is_system()
}
}
/// 对外返回的网站:在落盘结构上补一个推导出来的 `editable`。
/// `data/nav.json` 的完整内容。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NavFile {
pub site: SiteMeta,
pub groups: Vec<Group>,
}
/// 对外返回的网站:补上它所属分组的 id 和推导出来的 `editable`。
///
/// 这里存一份 `Site` 的拷贝而不是借用:接口层需要先把互斥锁放开、
/// 再去序列化响应,借用会让生命周期绑死在锁上。
/// `categoryId` 在落盘结构里已经由位置表达了,但接口仍然输出它——
/// 前端编辑时要靠它反查出分组名。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SiteView {
#[serde(flatten)]
pub site: Site,
/// 由 `source` 推导,不落盘。前端据此决定要不要显示编辑/删除入口。
pub category_id: String,
pub editable: bool,
}
impl From<Site> for SiteView {
fn from(site: Site) -> Self {
Self {
editable: site.is_editable(),
site,
}
}
}
impl From<&Site> for SiteView {
fn from(site: &Site) -> Self {
Self::from(site.clone())
}
}
/// 对外返回的分组节点:带上自己直接挂着的网站与下一级子分组。
///
/// 前端侧栏与内容区需要的正是这棵树,所以直接在接口层拼好,
/// 前端不用再拿两份数组自己组装。
/// 对外返回的分组节点。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NavGroupView {
@@ -133,22 +135,11 @@ pub struct NavGroupView {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NavResponse {
/// 站点级配置,前端不再自己写死站点名和搜索框提示。
/// 站点级配置:前端不再自己写死站点名、搜索提示和提交入口。
pub site: SiteMeta,
pub groups: Vec<NavGroupView>,
}
/// 站点级配置,存在 `data/site.json`,由项目维护者手改。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SiteMeta {
/// 站点名,显示在侧栏顶部与浏览器标题。
pub name: String,
/// 搜索框占位文案。
#[serde(default)]
pub search_hint: String,
}
/// 提交 / 推送一个网站的入参。
///
/// **没有 `categoryId`**:提交方只能给分组名,由后端在「用户创建的分组」里
@@ -156,7 +147,7 @@ pub struct SiteMeta {
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateSiteRequest {
// 下面三个字段也用 `default`:缺字段时不要让 axum 抛 422 和英文错误,
// 这三个也用 `default`:缺字段时不要抛 422 和英文错误,
// 交给 `validate` 给出统一的中文 400 说明。
#[serde(default)]
pub title: String,
@@ -167,7 +158,7 @@ pub struct CreateSiteRequest {
/// 一级分组名。
#[serde(default)]
pub group: String,
/// 子分组名,可选;给了就挂到两级下面。
/// 子分组名,可选。
#[serde(default)]
pub sub_group: String,
#[serde(default)]
+495 -482
View File
File diff suppressed because it is too large Load Diff
+8 -11
View File
@@ -1,7 +1,7 @@
# 后端数据结构设计:网站与分组
> 本文是设计稿,已按此实现。代码在 `backend/`,接口与运行方式见
> [backend/README.md](../backend/README.md)。
> 本文是设计稿。实现时按「侧栏结构和网站绑定到一个文件、删除时同步清理」调整过,
> 现状见 [backend/README.md](../backend/README.md)。
## 1. 需求与边界
@@ -158,17 +158,14 @@ pub enum SiteSource {
```
backend/data/
├── site.json # 站点名与搜索框提示,只读
├── system-categories.json # 内置分组,只读,项目维护者手写
├── system-sites.json # 内置网站,只读
├── user-categories.json # 提交时创建的分组,可写
└── user-sites.json # 提交 / 推送的网站,可写
└── nav.json # 站点信息 + 分组结构(含网站),一份文档
```
- 读:启动时全量加载进内存,之后都从内存读,不再碰磁盘。
- 写:只写 `user-*.json` 两个文件。**代码里不存在写 `system-*.json` 的路径**,
「不可修改」靠结构保证,而不是靠每处调用都记得判断。
- 写盘用「先写临时文件再改名」,避免写到一半断电把文件写坏。
- 读:启动时加载进内存,之后都从内存读,不再碰磁盘。
- 写:整份文档一起写回,但改的只会是 `source != system` 的那部分。
- 网站直接挂在分组的 `sites` 里,不再自己记 `categoryId`——
归属由位置表达,删掉分组不会留下指向空处的引用。
- 删除网站时,如果它所在的用户分组空了,会在**同一次写入**里一起清掉。
- JSON 用 2 空格缩进、保留中文原文,方便直接看 diff。
### 5.1 启动时的自检
+7 -17
View File
@@ -104,11 +104,7 @@ lib/
├── 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 # 网站表单(新增 / 编辑共用)
└── ...
└── back_to_top_button.dart # 右下角圆形回到顶部
```
`test/` 展开(与 `lib/` 一一对应):
@@ -143,15 +139,15 @@ main.dart → app.dart
数据流:
```
后端 GET /api/nav ──▶ { site: { name, searchHint }, groups: [ { …, sites: [] } ] }
后端 GET /api/nav ──▶ { site: { name, searchHint, submitUrl }, groups: [ … ] }
│ ▲
│ │ POST /api/sites(提交)
│ │ PATCH /api/sites/{id}(编辑)
│ │ DELETE /api/sites/{id}(删除)
▼ │
▼ │ ← 这三个接口页面上暂时不用
HttpHomeRepository ──实现──▶ HomeRepository(读 / 提交 / 编辑 / 删除)
│ 把分组树映射成前端模型:分组 → 侧栏导航;有网站的组 → 内容分区;
│ 每个网站带 editable,决定卡片上有没有编辑/删除入口
│ 每个网站带 editable(页面暂时不据此渲染任何入口)
▼
HomePage State:_load() 拿到 HomeData,并在加载中 / 失败(可重试)/ 完成三种状态间切换
│ (页面持有交互状态:当前分区、侧栏折叠、已展开的分组)
@@ -383,7 +379,7 @@ CSS 的 `max-height: 0` 只是视觉上收起了子菜单,DOM 里那些链接
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 静态分析 | `flutter analyze` | 1 条告警(`sidebar_nav.dart` 里 `_NavRow` 的 `super.key` 从未被传入,见下方说明) |
| 单元与组件测试 | `flutter test` | 57 个用例通过,6 个失败(都是侧栏实现与既有用例不一致,见下方说明) |
| 单元与组件测试 | `flutter test` | 47 个用例通过,6 个失败(都是侧栏实现与既有用例不一致,见下方说明) |
| Web 构建 | `flutter build web --no-wasm-dry-run` | 构建成功 |
> 上面这两处不是本轮对接引入的:`e2cc3ac` 那次提交重写了侧栏(行内改用 `Container`、
@@ -401,18 +397,12 @@ CSS 的 `max-height: 0` 只是视觉上收起了子菜单,DOM 里那些链接
- 导航:高亮固定写在第一个条目上;点有对应分区的导航会滚过去且高亮不变;点 `#icon` 这类没有对应元素的导航滚动偏移不变。
- 搜索框:输入后仍是两个分区、五张卡片(不过滤)。
- 滚动:滚动主内容不会重建侧栏、也不会改动高亮;回到顶部把偏移归零。
- 其它:点击卡片把对应 URL 交给 `UrlOpener`;点「提交网站」没有任何反应;卡片渲染首字母、名称与描述,悬停上浮量等于 `AppSizes.cardHoverLift`。
- 其它:点击卡片把对应 URL 交给 `UrlOpener`;点「提交网站」跳到后端配的地址;卡片渲染首字母、名称与描述,悬停上浮量等于 `AppSizes.cardHoverLift`。
- 后端对接:请求打到 `/api/nav`;分组映射成侧栏导航树;只有挂着网站的分组进内容区;
子分组标题带父级前缀;网站顺序按后端给的排;非 200、非 JSON、缺 `groups`、
连不上后端分别给出对应提示;缺字段的单条数据被跳过而不是整页失败。
- 加载状态:数据没回来时显示「正在加载…」;失败时显示原因和「重试」;点重试会重新取数并在成功后渲染内容。
- 提交表单:空表单提交会逐项提示且不发请求;内网地址被拦下;合法内容按 camelCase
字段发给仓库;后端报错时留在表单里显示原话;提交中按钮变「提交中…」且不可再点;
模板里只列用户分组(内置分组不出现在建议里),点建议会填进输入框。
- 联调:点侧栏「提交网站」打开表单;提交成功后静默重新拉一次数据并弹提示。
- 编辑 / 删除:可修改的条目才有入口;编辑表单预填现有内容且不显示提交者字段;
删除要先确认,点取消什么都不发生;确认后调删除接口并静默刷新;内置条目没有这两个入口。
- 站点信息:站点名与搜索框提示来自后端 `site` 字段,前端不再写死。
- 站点信息:站点名、搜索框提示、提交入口地址都来自后端 `site` 字段,前端不再写死。
## 10. 后续演进建议
-116
View File
@@ -1,116 +0,0 @@
# 提交网站表单设计
> 已按本文实现(含「错误色 / 可点建议 / 提交后刷新」三条确认)。代码在
> `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`) | 复用主题里已经配好的提示条,不新增一套浮层 |
+8 -8
View File
@@ -23,14 +23,14 @@ flutter build web --dart-define=NAVIUS_API_BASE=https://api.example.com
后端没起来时页面会显示「加载失败」和具体原因,并给一个「重试」按钮。
侧栏底部的「提交网站」会打开提交表单:填完直接进后端的用户分组(不需要审核),
提交成功后页面会静默重新拉一次数据,新站点立刻出现在侧栏里。
侧栏底部的「提交网站」直接跳到后端配好的收集入口(`nav.json` 的 `site.submitUrl`,
填 Mattermost 地址即可)。页面上不再有提交表单——收集和讨论都在 Mattermost 里做,
网站数据由后端接口写入。
用户提交 / 接口推送进来的站点(后端返回的 `editable == true`)在卡片悬停时右上角
会露出「编辑 / 删除」:编辑复用同一个表单(预填现有内容)走 `PATCH`,
删除会先确认再走 `DELETE`,两个操作成功后都会静默刷新。系统内置的卡片没有这两个入口。
**页面上没有编辑和删除入口**:卡片只管展示和跳转,用户提交的站点照样由后端接口
(`PATCH` / `DELETE`)维护,只是不从这里操作。
站点名和搜索框提示也来自后端(`data/site.json`),前端不再写死。
站点名、搜索框提示、提交入口地址都来自后端(`nav.json` 的 `site`),前端不再写死。
## 常用命令
@@ -60,8 +60,8 @@ flutter build web # 构建产物输出到 build/web
- mock 实现:`lib/features/home/data/mock_home_repository.dart`(测试用)
接口对应关系:读 `GET /api/nav`、提交 `POST /api/sites`、编辑 `PATCH /api/sites/{id}`、
删除 `DELETE /api/sites/{id}`。表单在 `presentation/widgets/site_form_dialog.dart`
(新增和编辑共用),设计见 [docs/submit-site-form.md](../docs/submit-site-form.md)。
删除 `DELETE /api/sites/{id}`。仓库契约里四个方法都在,界面目前只用到「读」和
「跳转到提交入口」,另外三个留给后台工具或以后再做界面时用。
接口返回的分组树在 `http_home_repository.dart` 里映射成前端模型:分组变成侧栏导航,
**只有挂着网站的分组才进内容区**(空分组留在侧栏,点了没有对应内容——和原型一致),
@@ -155,33 +155,6 @@ abstract final class AppSizes {
/// 悬停时上移距离(`translateY(-3px)`)。
static const double backToTopHoverLift = 3;
// ---- 通用间距 ----
//
// 上面的常量按「用在哪」命名;表单这类地方间距是通用的,
// 这里给一套通用刻度,避免在组件里写裸数字。
static const double spaceXs = 4;
static const double spaceSm = 8;
static const double spaceMd = 12;
static const double spaceLg = 16;
static const double spaceXl = 24;
// ---- 表单 ----
/// 对话框内边距。
static const double dialogPadding = 24;
/// 对话框最大宽度。
static const double dialogMaxWidth = 480;
/// 字段标签与输入框之间的间距。
static const double fieldLabelGap = 6;
/// 多行输入框高度(3 行)。
static const double fieldMultilineHeight = 88;
/// 对话框右上角关闭按钮的点击热区。
static const double iconButtonSize = 28;
// ---- 通用 ----
/// 通用过渡时长(CSS 里的 `transition: 0.2s`)。
@@ -149,6 +149,7 @@ HomeData _toHomeData(List<Object?> groups, Map<String, dynamic> siteMeta) {
searchHint: siteMeta['searchHint'] as String? ?? _fallbackSearchHint,
navNodes: navNodes,
sections: sections,
submitUrl: siteMeta['submitUrl'] as String? ?? '',
);
}
@@ -80,4 +80,5 @@ const HomeData mockHomeData = HomeData(
searchHint: '输入关键字搜索全网资源...',
navNodes: mockNavNodes,
sections: mockLinkSections,
submitUrl: 'https://mattermost.example.com',
);
@@ -12,6 +12,7 @@ class HomeData {
required this.searchHint,
required this.navNodes,
required this.sections,
this.submitUrl = '',
});
/// 站点名,显示在侧栏顶部与浏览器标题。
@@ -20,6 +21,11 @@ class HomeData {
/// 搜索框占位文案。
final String searchHint;
/// 「提交网站」跳转的地址(Mattermost 之类的收集入口),来自后端 `site.submitUrl`。
///
/// 为空表示后端没配,点了就不做任何事。
final String submitUrl;
/// 侧栏导航树。
final List<NavNode> navNodes;
@@ -10,13 +10,9 @@ import '../../data/home_repository.dart';
import '../../domain/models/home_data.dart';
import '../../domain/models/link_item.dart';
import '../../domain/models/link_section.dart';
import '../../domain/models/nav_node.dart';
import '../../domain/models/submit_site_draft.dart';
import '../widgets/back_to_top_button.dart';
import '../widgets/confirm_dialog.dart';
import '../widgets/link_section_view.dart';
import '../widgets/sidebar_nav.dart';
import '../widgets/site_form_dialog.dart';
import '../widgets/top_bar.dart';
/// 首页:可折叠侧栏 + 顶部控制栏 + 分组卡片网格。
@@ -153,100 +149,14 @@ class _HomePageState extends State<HomePage> {
void _openItem(LinkItem item) => widget.urlOpener.open(item.url);
/// 打开「提交网站」表单;提交成功后静默刷新一次,让新站点立刻出现在侧栏里。
Future<void> _openSubmitDialog(HomeData data) async {
final bool saved = await showSiteFormDialog(
context: context,
navNodes: data.navNodes,
heading: '提交网站',
onSubmit: (SubmitSiteDraft draft) => widget.repository.submitSite(draft),
);
if (!saved || !mounted) {
return;
}
_toast('已提交,感谢');
await _load(showLoading: false);
}
/// 编辑一个用户提交的站点:表单预填现有内容,保存走 `PATCH /api/sites/{id}`。
Future<void> _openEditDialog(HomeData data, LinkItem item) async {
final ({String group, String subGroup})? path = _groupPathOf(
data.navNodes,
item.categoryId,
);
final bool saved = await showSiteFormDialog(
context: context,
navNodes: data.navNodes,
heading: '编辑网站',
initial: SubmitSiteDraft(
title: item.title,
url: item.url,
description: item.description,
group: path?.group ?? '',
subGroup: path?.subGroup ?? '',
),
onSubmit: (SubmitSiteDraft draft) =>
widget.repository.updateSite(item.id, draft),
);
if (!saved || !mounted) {
return;
}
_toast('已保存');
await _load(showLoading: false);
}
/// 删除一个用户提交的站点:先确认,再走 `DELETE /api/sites/{id}`。
Future<void> _confirmDelete(LinkItem item) async {
final bool confirmed = await showConfirmDialog(
context: context,
title: '删除网站',
message: '确定要删除「${item.title}」吗?删掉之后不能恢复。',
confirmLabel: '删除',
danger: true,
);
if (!confirmed || !mounted) {
return;
}
try {
await widget.repository.deleteSite(item.id);
} catch (error) {
if (mounted) {
_toast('删除失败:$error');
}
return;
}
if (!mounted) {
return;
}
_toast('已删除');
await _load(showLoading: false);
}
void _toast(String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
/// 从侧栏树里反查某个分组 id 的「一级分组 + 子分组」名字。
/// 「提交网站」跳到后端配好的收集入口(Mattermost 之类)。
///
/// 后端只按分组名收更新,所以编辑时必须把 id 还原成名字;
/// 层级最多两级,这里只查两轮。
({String group, String subGroup})? _groupPathOf(
List<NavNode> nodes,
String categoryId,
) {
for (final NavNode root in nodes) {
if (root.id == categoryId) {
return (group: root.title, subGroup: '');
/// 地址在 `data/nav.json` 的 `site.submitUrl`;没配就什么都不做。
void _openSubmitUrl(HomeData data) {
if (data.submitUrl.isEmpty) {
return;
}
for (final NavNode child in root.children) {
if (child.id == categoryId) {
return (group: root.title, subGroup: child.title);
}
}
}
return null;
widget.urlOpener.open(data.submitUrl);
}
void _backToTop() {
@@ -293,7 +203,7 @@ class _HomePageState extends State<HomePage> {
openGroupIds: _openGroupIds,
onToggleGroup: _toggleGroup,
onSectionSelected: _goToSection,
onSubmitSite: () => unawaited(_openSubmitDialog(data)),
onSubmitSite: () => _openSubmitUrl(data),
onToggleCollapse: _toggleSidebar,
),
Expanded(child: _buildMain(data)),
@@ -334,9 +244,6 @@ class _HomePageState extends State<HomePage> {
key: _sectionKeys[section.id],
section: section,
onOpen: _openItem,
onEdit: (LinkItem item) =>
unawaited(_openEditDialog(data, item)),
onDelete: (LinkItem item) => unawaited(_confirmDelete(item)),
),
),
],
@@ -1,91 +0,0 @@
import 'package:flutter/material.dart';
import '../../../../core/constants/app_sizes.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import 'dialog_buttons.dart';
/// 通用确认框,返回 true 表示用户点了确认。
///
/// [danger] 为 true 时确认按钮用错误色,用在删除这类不可逆操作上。
Future<bool> showConfirmDialog({
required BuildContext context,
required String title,
required String message,
required String confirmLabel,
String cancelLabel = '取消',
bool danger = false,
}) async {
final bool? confirmed = await showDialog<bool>(
context: context,
barrierColor: const Color(0x73111827),
builder: (BuildContext context) => _ConfirmDialog(
title: title,
message: message,
confirmLabel: confirmLabel,
cancelLabel: cancelLabel,
danger: danger,
),
);
return confirmed ?? false;
}
class _ConfirmDialog extends StatelessWidget {
const _ConfirmDialog({
required this.title,
required this.message,
required this.confirmLabel,
required this.cancelLabel,
required this.danger,
});
final String title;
final String message;
final String confirmLabel;
final String cancelLabel;
final bool danger;
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
elevation: 0,
insetPadding: const EdgeInsets.all(AppSizes.dialogPadding),
child: Container(
constraints: const BoxConstraints(maxWidth: 360),
decoration: BoxDecoration(
color: AppColors.surface,
border: Border.all(color: AppColors.border),
borderRadius: BorderRadius.circular(AppSizes.cardRadius),
boxShadow: AppColors.cardShadowHover,
),
padding: const EdgeInsets.all(AppSizes.dialogPadding),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(title, style: AppTextStyles.sectionTitle),
const SizedBox(height: AppSizes.spaceSm),
Text(message, style: AppTextStyles.emptyMessage),
const SizedBox(height: AppSizes.spaceXl),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
DialogGhostButton(
label: cancelLabel,
onTap: () => Navigator.of(context).pop(false),
),
const SizedBox(width: AppSizes.spaceMd),
DialogPrimaryButton(
label: confirmLabel,
danger: danger,
onTap: () => Navigator.of(context).pop(true),
),
],
),
],
),
),
);
}
}
@@ -1,117 +0,0 @@
import 'package:flutter/material.dart';
import '../../../../core/constants/app_sizes.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
/// 对话框里的主按钮:实底、8px 圆角(和侧栏「提交网站」一致)。
///
/// [danger] 为 true 时用错误色,用在「删除」这类不可逆操作上。
class DialogPrimaryButton extends StatefulWidget {
const DialogPrimaryButton({
super.key,
required this.label,
required this.onTap,
this.danger = false,
});
/// 按钮文字。
final String label;
/// 点击回调;为 null 表示禁用。
final VoidCallback? onTap;
/// 是否用错误色(删除类操作)。
final bool danger;
@override
State<DialogPrimaryButton> createState() => _DialogPrimaryButtonState();
}
class _DialogPrimaryButtonState extends State<DialogPrimaryButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final bool enabled = widget.onTap != null;
final Color background = widget.danger
? AppColors.danger
: AppColors.primary;
return MouseRegion(
cursor: enabled ? SystemMouseCursors.click : SystemMouseCursors.basic,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: AppSizes.transition,
padding: const EdgeInsets.symmetric(
horizontal: AppSizes.cardPaddingHorizontal,
vertical: 10,
),
decoration: BoxDecoration(
color: enabled ? background : AppColors.border,
borderRadius: BorderRadius.circular(AppSizes.navItemRadius),
),
child: Text(
widget.label,
style: AppTextStyles.submitButton.copyWith(
color: enabled ? AppColors.surface : AppColors.textSecondary,
fontWeight: _hovered ? FontWeight.w600 : FontWeight.w500,
),
),
),
),
);
}
}
/// 对话框里的次级按钮:描边、透明底。
class DialogGhostButton extends StatefulWidget {
const DialogGhostButton({super.key, required this.label, required this.onTap});
/// 按钮文字。
final String label;
/// 点击回调;为 null 表示禁用。
final VoidCallback? onTap;
@override
State<DialogGhostButton> createState() => _DialogGhostButtonState();
}
class _DialogGhostButtonState extends State<DialogGhostButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final bool enabled = widget.onTap != null;
return MouseRegion(
cursor: enabled ? SystemMouseCursors.click : SystemMouseCursors.basic,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: AppSizes.transition,
padding: const EdgeInsets.symmetric(
horizontal: AppSizes.cardPaddingHorizontal,
vertical: 9,
),
decoration: BoxDecoration(
color: _hovered ? AppColors.primaryFaint : Colors.transparent,
border: Border.all(color: AppColors.border),
borderRadius: BorderRadius.circular(AppSizes.navItemRadius),
),
child: Text(
widget.label,
style: AppTextStyles.submitButton.copyWith(
color: enabled ? AppColors.textSecondary : AppColors.navArrow,
),
),
),
),
);
}
}
@@ -8,16 +8,8 @@ import '../../domain/models/link_item.dart';
/// 站点卡片,对应 `index3.html` 里的 `.card`。
///
/// 结构是「36px 的字母方块 + 名称 + 描述」,悬停时上移 2px 并加强阴影。
/// 用户提交 / 接口推送进来的条目(`item.editable`)在悬停时右上角会露出
/// 「编辑 / 删除」;系统内置的条目没有这两个按钮——后端也不允许改。
class LinkCard extends StatefulWidget {
const LinkCard({
super.key,
required this.item,
required this.onOpen,
this.onEdit,
this.onDelete,
});
const LinkCard({super.key, required this.item, required this.onOpen});
/// 站点数据。
final LinkItem item;
@@ -25,12 +17,6 @@ class LinkCard extends StatefulWidget {
/// 打开回调。
final VoidCallback onOpen;
/// 编辑回调;为空或条目不可修改时不显示编辑按钮。
final VoidCallback? onEdit;
/// 删除回调;为空或条目不可修改时不显示删除按钮。
final VoidCallback? onDelete;
@override
State<LinkCard> createState() => _LinkCardState();
}
@@ -40,8 +26,6 @@ class _LinkCardState extends State<LinkCard> {
@override
Widget build(BuildContext context) {
final bool canManage = widget.item.editable;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
@@ -69,9 +53,7 @@ class _LinkCardState extends State<LinkCard> {
? AppColors.cardShadowHover
: AppColors.cardShadow,
),
child: Stack(
children: <Widget>[
Row(
child: Row(
children: <Widget>[
_CardIcon(initial: widget.item.initial),
const SizedBox(width: AppSizes.cardIconGap),
@@ -98,104 +80,6 @@ class _LinkCardState extends State<LinkCard> {
),
],
),
// 悬停才露出来,且不占正文的位置:不给可修改的卡片留常驻空白,
// 也不让鼠标移上去时文字宽度跳一下。
if (canManage)
Positioned(
right: 0,
top: 0,
child: AnimatedOpacity(
duration: AppSizes.transition,
opacity: _hovered ? 1 : 0,
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (widget.onEdit != null)
_CardAction(
icon: '✎',
tooltip: '编辑',
background: AppColors.surface,
onTap: widget.onEdit!,
),
if (widget.onEdit != null && widget.onDelete != null)
const SizedBox(width: AppSizes.spaceXs),
if (widget.onDelete != null)
_CardAction(
icon: '✕',
tooltip: '删除',
background: AppColors.surface,
danger: true,
onTap: widget.onDelete!,
),
],
),
),
),
],
),
),
),
);
}
}
/// 卡片右上角的小动作按钮(编辑 / 删除)。
class _CardAction extends StatefulWidget {
const _CardAction({
required this.icon,
required this.tooltip,
required this.background,
required this.onTap,
this.danger = false,
});
final String icon;
final String tooltip;
final Color background;
final VoidCallback onTap;
final bool danger;
@override
State<_CardAction> createState() => _CardActionState();
}
class _CardActionState extends State<_CardAction> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final Color foreground = _hovered
? (widget.danger ? AppColors.danger : AppColors.primary)
: AppColors.navArrow;
return Tooltip(
message: widget.tooltip,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
// 只处理自己的点击,不要让卡片把事件吃掉(否则会去打开网站)。
onTap: widget.onTap,
child: AnimatedContainer(
duration: AppSizes.transition,
width: AppSizes.iconButtonSize,
height: AppSizes.iconButtonSize,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _hovered
? (widget.danger
? AppColors.dangerFaint
: AppColors.primaryFaint)
: widget.background,
border: Border.all(color: AppColors.border),
borderRadius: BorderRadius.circular(AppSizes.navItemRadius),
),
child: Text(
widget.icon,
style: AppTextStyles.cardDesc.copyWith(color: foreground),
),
),
),
),
);
@@ -14,8 +14,6 @@ class LinkSectionView extends StatelessWidget {
super.key,
required this.section,
required this.onOpen,
this.onEdit,
this.onDelete,
});
/// 分组数据。
@@ -24,12 +22,6 @@ class LinkSectionView extends StatelessWidget {
/// 打开某个站点。
final ValueChanged<LinkItem> onOpen;
/// 编辑某个站点;为空时卡片不显示编辑入口。
final ValueChanged<LinkItem>? onEdit;
/// 删除某个站点;为空时卡片不显示删除入口。
final ValueChanged<LinkItem>? onDelete;
@override
Widget build(BuildContext context) {
return Column(
@@ -55,12 +47,7 @@ class LinkSectionView extends StatelessWidget {
for (final LinkItem item in section.items)
SizedBox(
width: itemWidth,
child: LinkCard(
item: item,
onOpen: () => onOpen(item),
onEdit: onEdit == null ? null : () => onEdit!(item),
onDelete: onDelete == null ? null : () => onDelete!(item),
),
child: LinkCard(item: item, onOpen: () => onOpen(item)),
),
],
);
@@ -1,757 +0,0 @@
import 'package:flutter/material.dart';
import '../../../../core/constants/app_sizes.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../../../core/utils/text.dart';
import '../../domain/models/nav_node.dart';
import '../../domain/models/submit_site_draft.dart';
import 'dialog_buttons.dart';
/// 打开网站表单对话框,返回 true 表示保存成功了。
///
/// 新增和编辑共用这一个表单:新增时 [onSubmit] 打 `POST /api/sites`,
/// 编辑时打 `PATCH /api/sites/{id}`,并用 [initial] 预填现有内容。
Future<bool> showSiteFormDialog({
required BuildContext context,
required List<NavNode> navNodes,
required String heading,
required Future<void> Function(SubmitSiteDraft draft) onSubmit,
SubmitSiteDraft? initial,
}) async {
final bool? submitted = await showDialog<bool>(
context: context,
barrierColor: const Color(0x73111827),
builder: (BuildContext context) => SiteFormDialog(
navNodes: navNodes,
heading: heading,
onSubmit: onSubmit,
initial: initial,
),
);
return submitted ?? false;
}
/// 网站表单(新增 / 编辑)。
///
/// 字段与后端 `POST /api/sites` 一一对应:**没有「选择分组」这个动作**,
/// 只有分组名——后端只会在用户创建的分组里查找或新建,提交的内容永远进不了
/// 内置分组,所以界面上也要把这件事说清楚,并只给用户分组的建议。
class SiteFormDialog extends StatefulWidget {
const SiteFormDialog({
super.key,
required this.navNodes,
required this.heading,
required this.onSubmit,
this.initial,
});
/// 侧栏那棵树,用来给出「已有的用户分组」建议。
final List<NavNode> navNodes;
/// 对话框标题,例如「提交网站」或「编辑网站」。
final String heading;
/// 保存动作。抛异常时表单会留在原地并显示原因。
final Future<void> Function(SubmitSiteDraft draft) onSubmit;
/// 编辑时的现有内容;新增时为 null。
final SubmitSiteDraft? initial;
@override
State<SiteFormDialog> createState() => _SiteFormDialogState();
}
class _SiteFormDialogState extends State<SiteFormDialog> {
late final TextEditingController _title;
late final TextEditingController _url;
late final TextEditingController _description;
late final TextEditingController _group;
late final TextEditingController _subGroup;
late final TextEditingController _submittedBy;
Map<String, String?> _errors = <String, String?>{};
String? _serverError;
bool _submitting = false;
@override
void initState() {
super.initState();
final SubmitSiteDraft? initial = widget.initial;
_title = TextEditingController(text: initial?.title ?? '');
_url = TextEditingController(text: initial?.url ?? '');
_description = TextEditingController(text: initial?.description ?? '');
_group = TextEditingController(text: initial?.group ?? '');
_subGroup = TextEditingController(text: initial?.subGroup ?? '');
_submittedBy = TextEditingController(text: initial?.submittedBy ?? '');
}
@override
void dispose() {
_title.dispose();
_url.dispose();
_description.dispose();
_group.dispose();
_subGroup.dispose();
_submittedBy.dispose();
super.dispose();
}
/// 已有的用户分组名(内置分组不出现在这里——选了也用不了)。
List<String> get _groupSuggestions => widget.navNodes
.where((NavNode node) => node.editable)
.map((NavNode node) => node.title)
.toList(growable: false);
/// 当前填的分组下已有的用户子分组。
List<String> get _subGroupSuggestions {
final String group = _group.text.trim();
for (final NavNode node in widget.navNodes) {
if (node.editable && node.title == group) {
return node.children
.where((NavNode child) => child.editable)
.map((NavNode child) => child.title)
.toList(growable: false);
}
}
return const <String>[];
}
/// 某个字段的内容变了:清掉它上一次的错误,让用户边改边看到反馈。
void _clearError(String field) {
if (_errors[field] == null && _serverError == null) {
return;
}
setState(() {
_errors = <String, String?>{..._errors, field: null};
_serverError = null;
});
}
Future<void> _submit() async {
final Map<String, String?> errors = <String, String?>{
'title': _validateTitle(_title.text),
'url': _validateUrl(_url.text),
'description': _validateDescription(_description.text),
'group': _validateGroup(_group.text),
'subGroup': _validateSubGroup(_subGroup.text),
'submittedBy': _validateSubmittedBy(_submittedBy.text),
};
setState(() {
_errors = errors;
_serverError = null;
});
if (errors.values.any((String? message) => message != null)) {
return;
}
setState(() => _submitting = true);
try {
await widget.onSubmit(
SubmitSiteDraft(
title: _title.text.trim(),
url: _url.text.trim(),
description: _description.text.trim(),
group: _group.text.trim(),
subGroup: _subGroup.text.trim(),
submittedBy: _submittedBy.text.trim(),
),
);
if (!mounted) {
return;
}
Navigator.of(context).pop(true);
} catch (error) {
if (!mounted) {
return;
}
// 后端的中文说明原样显示,用户能看懂哪里不对。
setState(() {
_submitting = false;
_serverError = error.toString();
});
}
}
@override
Widget build(BuildContext context) {
return PopScope(
// 请求还在路上时不允许关闭:关了界面请求也回不来,用户会以为丢了。
canPop: !_submitting,
child: Dialog(
backgroundColor: Colors.transparent,
elevation: 0,
insetPadding: const EdgeInsets.all(AppSizes.dialogPadding),
child: Container(
constraints: const BoxConstraints(maxWidth: AppSizes.dialogMaxWidth),
decoration: BoxDecoration(
color: AppColors.surface,
border: Border.all(color: AppColors.border),
borderRadius: BorderRadius.circular(AppSizes.cardRadius),
boxShadow: AppColors.cardShadowHover,
),
padding: const EdgeInsets.all(AppSizes.dialogPadding),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
_Header(
heading: widget.heading,
submitting: _submitting,
onClose: () => Navigator.of(context).pop(false),
),
if (_serverError != null) ...<Widget>[
const SizedBox(height: AppSizes.spaceLg),
_ErrorBanner(message: _serverError!),
],
const SizedBox(height: AppSizes.spaceLg),
_InputBox(
label: '网站名称',
controller: _title,
hint: '例如:GitHub',
enabled: !_submitting,
error: _errors['title'],
onChanged: () => _clearError('title'),
),
const SizedBox(height: AppSizes.spaceLg),
_InputBox(
label: '网站地址',
controller: _url,
hint: 'https://…',
enabled: !_submitting,
error: _errors['url'],
onChanged: () => _clearError('url'),
),
const SizedBox(height: AppSizes.spaceLg),
_InputBox(
label: '一句话描述',
controller: _description,
hint: '这个站点是做什么的(可不填)',
enabled: !_submitting,
error: _errors['description'],
multiline: true,
onChanged: () => _clearError('description'),
),
const SizedBox(height: AppSizes.spaceLg),
_InputBox(
label: '分组',
controller: _group,
hint: '例如:AI 工具',
enabled: !_submitting,
error: _errors['group'],
onChanged: () {
_clearError('group');
// 分组一变,子分组建议跟着换。
setState(() {});
},
),
const SizedBox(height: AppSizes.fieldLabelGap),
const Text(
'提交的网站会放在你自己的分组里,不会进内置分类。',
style: AppTextStyles.emptyMessage,
),
if (_groupSuggestions.isNotEmpty) ...<Widget>[
const SizedBox(height: AppSizes.spaceSm),
_Suggestions(
label: '已有的分组',
values: _groupSuggestions,
enabled: !_submitting,
onPick: (String value) {
_group.text = value;
_clearError('group');
setState(() {});
},
),
],
const SizedBox(height: AppSizes.spaceLg),
_InputBox(
label: '子分组',
controller: _subGroup,
hint: '例如:模型社区(可不填)',
enabled: !_submitting,
error: _errors['subGroup'],
onChanged: () => _clearError('subGroup'),
),
if (_subGroupSuggestions.isNotEmpty) ...<Widget>[
const SizedBox(height: AppSizes.spaceSm),
_Suggestions(
label: '已有的子分组',
values: _subGroupSuggestions,
enabled: !_submitting,
onPick: (String value) {
_subGroup.text = value;
_clearError('subGroup');
setState(() {});
},
),
],
// 编辑时不让改提交者:后端 PATCH 本来也不接受这个字段。
if (widget.initial == null) ...<Widget>[
const SizedBox(height: AppSizes.spaceLg),
_InputBox(
label: '你的称呼或邮箱',
controller: _submittedBy,
hint: '可不填,方便回访时找你',
enabled: !_submitting,
error: _errors['submittedBy'],
onChanged: () => _clearError('submittedBy'),
),
],
const SizedBox(height: AppSizes.spaceXl),
_Actions(
submitting: _submitting,
onCancel: () => Navigator.of(context).pop(false),
onSubmit: _submit,
),
],
),
),
),
),
);
}
}
/// 标题行:标题 + 关闭按钮。
class _Header extends StatelessWidget {
const _Header({
required this.heading,
required this.submitting,
required this.onClose,
});
final String heading;
final bool submitting;
final VoidCallback onClose;
@override
Widget build(BuildContext context) {
return Row(
children: <Widget>[
Expanded(
child: Text(heading, style: AppTextStyles.sectionTitle),
),
_IconButton(
icon: '×',
tooltip: '关闭',
onTap: submitting ? null : onClose,
),
],
);
}
}
/// 后端返回的失败说明,放在表单顶部。
class _ErrorBanner extends StatelessWidget {
const _ErrorBanner({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSizes.spaceMd,
vertical: 10,
),
decoration: BoxDecoration(
color: AppColors.dangerFaint,
borderRadius: BorderRadius.circular(AppSizes.navItemRadius),
),
child: Text(
message,
style: AppTextStyles.emptyMessage.copyWith(color: AppColors.danger),
),
);
}
}
/// 带标签、错误提示的输入框。
///
/// 视觉复用搜索框那一套(44px 高 / 12px 圆角 / 聚焦光晕):
/// 输入框按自身行高定高,再用 Align 放进盒子里居中,不靠固定 padding 凑高度。
class _InputBox extends StatefulWidget {
const _InputBox({
required this.label,
required this.controller,
required this.hint,
required this.enabled,
required this.onChanged,
this.error,
this.multiline = false,
});
final String label;
final TextEditingController controller;
final String hint;
final bool enabled;
final VoidCallback onChanged;
final String? error;
final bool multiline;
@override
State<_InputBox> createState() => _InputBoxState();
}
class _InputBoxState extends State<_InputBox> {
final FocusNode _focusNode = FocusNode();
bool _focused = false;
@override
void initState() {
super.initState();
_focusNode.addListener(_handleFocusChange);
}
@override
void dispose() {
_focusNode
..removeListener(_handleFocusChange)
..dispose();
super.dispose();
}
void _handleFocusChange() {
if (_focusNode.hasFocus != _focused) {
setState(() => _focused = _focusNode.hasFocus);
}
}
@override
Widget build(BuildContext context) {
final bool hasError = widget.error != null;
final Color borderColor = hasError
? AppColors.danger
: (_focused ? AppColors.primary : AppColors.border);
final Widget field = TextField(
controller: widget.controller,
focusNode: _focusNode,
enabled: widget.enabled,
onChanged: (_) => widget.onChanged(),
maxLines: widget.multiline ? 3 : 1,
style: AppTextStyles.searchInput,
cursorColor: AppColors.primary,
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
hintText: widget.hint,
hintStyle: AppTextStyles.searchHint,
),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(widget.label, style: AppTextStyles.emptyMessage),
const SizedBox(height: AppSizes.fieldLabelGap),
GestureDetector(
// 点盒子里输入框以外的留白也能聚焦。
onTap: widget.enabled ? _focusNode.requestFocus : null,
child: AnimatedContainer(
duration: AppSizes.transition,
curve: Curves.ease,
height: widget.multiline
? AppSizes.fieldMultilineHeight
: AppSizes.searchHeight,
padding: EdgeInsets.symmetric(
horizontal: AppSizes.cardPaddingHorizontal,
vertical: widget.multiline ? 10 : 0,
),
decoration: BoxDecoration(
color: widget.enabled ? AppColors.surface : AppColors.background,
border: Border.all(color: borderColor),
borderRadius: BorderRadius.circular(AppSizes.searchRadius),
// 聚焦时只剩光晕(原型 `.search-box:focus` 就是这个行为)。
boxShadow: <BoxShadow>[
if (_focused)
const BoxShadow(color: AppColors.focusRing, spreadRadius: 3)
else if (hasError)
const BoxShadow(
color: AppColors.dangerFaint,
spreadRadius: 3,
)
else
...AppColors.cardShadow,
],
),
child: Align(
alignment: widget.multiline
? Alignment.topLeft
: Alignment.centerLeft,
child: field,
),
),
),
if (widget.error != null) ...<Widget>[
const SizedBox(height: AppSizes.spaceXs),
Text(
widget.error!,
style: AppTextStyles.emptyMessage.copyWith(
color: AppColors.danger,
),
),
],
],
);
}
}
/// 一行可点的建议值。
class _Suggestions extends StatelessWidget {
const _Suggestions({
required this.label,
required this.values,
required this.enabled,
required this.onPick,
});
final String label;
final List<String> values;
final bool enabled;
final ValueChanged<String> onPick;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: const EdgeInsets.only(top: 5),
child: Text(label, style: AppTextStyles.emptyMessage),
),
const SizedBox(width: AppSizes.spaceSm),
Expanded(
child: Wrap(
spacing: AppSizes.spaceSm,
runSpacing: AppSizes.spaceSm,
children: <Widget>[
for (final String value in values)
_SuggestionChip(
label: value,
onTap: enabled ? () => onPick(value) : null,
),
],
),
),
],
);
}
}
class _SuggestionChip extends StatefulWidget {
const _SuggestionChip({required this.label, required this.onTap});
final String label;
final VoidCallback? onTap;
@override
State<_SuggestionChip> createState() => _SuggestionChipState();
}
class _SuggestionChipState extends State<_SuggestionChip> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: AppSizes.transition,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: _hovered ? AppColors.primaryFaint : AppColors.iconSurface,
borderRadius: BorderRadius.circular(AppSizes.navItemRadius),
),
child: Text(
widget.label,
style: AppTextStyles.cardDesc.copyWith(
color: _hovered ? AppColors.primary : AppColors.textSecondary,
),
),
),
),
);
}
}
/// 底部按钮:左「取消」,右「提交」。
class _Actions extends StatelessWidget {
const _Actions({
required this.submitting,
required this.onCancel,
required this.onSubmit,
});
final bool submitting;
final VoidCallback onCancel;
final VoidCallback onSubmit;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
DialogGhostButton(label: '取消', onTap: submitting ? null : onCancel),
const SizedBox(width: AppSizes.spaceMd),
DialogPrimaryButton(
label: submitting ? '提交中…' : '提交',
onTap: submitting ? null : onSubmit,
),
],
);
}
}
class _IconButton extends StatefulWidget {
const _IconButton({
required this.icon,
required this.tooltip,
required this.onTap,
});
final String icon;
final String tooltip;
final VoidCallback? onTap;
@override
State<_IconButton> createState() => _IconButtonState();
}
class _IconButtonState extends State<_IconButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return Tooltip(
message: widget.tooltip,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: AppSizes.transition,
width: AppSizes.iconButtonSize,
height: AppSizes.iconButtonSize,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _hovered ? AppColors.iconSurface : Colors.transparent,
borderRadius: BorderRadius.circular(AppSizes.navItemRadius),
),
child: Text(
widget.icon,
style: AppTextStyles.cardTitle.copyWith(
color: AppColors.textSecondary,
),
),
),
),
),
);
}
}
// ---- 校验:口径与后端 validate.rs 保持一致 ----
const int _maxTitle = 60;
const int _maxDescription = 120;
const int _maxGroup = 30;
const int _maxSubmittedBy = 120;
String? _validateTitle(String value) {
if (value.trim().isEmpty) {
return '请填写网站名称';
}
if (charCount(value) > _maxTitle) {
return '网站名称最多 $_maxTitle 个字';
}
return null;
}
String? _validateUrl(String value) {
final String text = value.trim();
if (text.isEmpty) {
return '请填写网站地址';
}
final Uri? uri = Uri.tryParse(text);
if (uri == null || uri.host.isEmpty) {
return '地址要以 http:// 或 https:// 开头';
}
if (uri.scheme != 'http' && uri.scheme != 'https') {
return '地址只能是 http:// 或 https://';
}
if (_isInternalHost(uri.host)) {
return '不能填本机或内网地址';
}
return null;
}
String? _validateDescription(String value) {
if (charCount(value) > _maxDescription) {
return '描述最多 $_maxDescription 个字';
}
return null;
}
String? _validateGroup(String value) {
final String text = value.trim();
if (text.isEmpty) {
return '请填写分组名称';
}
if (charCount(text) > _maxGroup) {
return '分组名称最多 $_maxGroup 个字';
}
return null;
}
String? _validateSubGroup(String value) {
if (charCount(value) > _maxGroup) {
return '子分组名称最多 $_maxGroup 个字';
}
return null;
}
String? _validateSubmittedBy(String value) {
if (charCount(value) > _maxSubmittedBy) {
return '最多 $_maxSubmittedBy 个字';
}
return null;
}
/// 前端只拦最明显的几种内网地址,权威判断在后端。
bool _isInternalHost(String host) {
final String h = host.toLowerCase();
if (h == 'localhost' || h.endsWith('.localhost') || h.endsWith('.local')) {
return true;
}
if (h == '0.0.0.0' || h == '::1' || h == '[::1]') {
return true;
}
final List<String> parts = h.split('.');
if (parts.length != 4) {
return false;
}
final int? first = int.tryParse(parts[0]);
final int? second = int.tryParse(parts[1]);
if (first == null || second == null) {
return false;
}
if (first == 127 || first == 10) {
return true;
}
if (first == 192 && second == 168) {
return true;
}
if (first == 172 && second >= 16 && second <= 31) {
return true;
}
return first == 169 && second == 254;
}
@@ -8,15 +8,11 @@ import 'package:frontend/features/home/data/home_repository.dart';
import 'package:frontend/features/home/data/mock_home_data.dart';
import 'package:frontend/features/home/data/mock_home_repository.dart';
import 'package:frontend/features/home/domain/models/home_data.dart';
import 'package:frontend/features/home/domain/models/link_item.dart';
import 'package:frontend/features/home/domain/models/link_section.dart';
import 'package:frontend/features/home/domain/models/nav_node.dart';
import 'package:frontend/features/home/domain/models/submit_site_draft.dart';
import 'package:frontend/features/home/presentation/pages/home_page.dart';
import 'package:frontend/features/home/presentation/widgets/link_card.dart';
import 'package:frontend/features/home/presentation/widgets/link_section_view.dart';
import 'package:frontend/features/home/presentation/widgets/sidebar_nav.dart';
import 'package:frontend/features/home/presentation/widgets/site_form_dialog.dart';
import '../../../helpers/fake_url_opener.dart';
@@ -382,197 +378,18 @@ void main() {
});
group('提交网站', () {
testWidgets('点侧栏底部按钮打开表单;提交成功后刷新一次并提示', (WidgetTester tester) async {
final _RecordingRepository repository = _RecordingRepository();
// 表单比默认视口高,用大一点的窗口,免得字段在屏幕外点不到。
await pumpPage(
tester,
size: const Size(1000, 1200),
repository: repository,
);
expect(repository.fetches, 1);
await tester.tap(navText('提交网站'));
await tester.pumpAndSettle();
expect(find.text('网站名称'), findsOneWidget);
expect(find.text('提交的网站会放在你自己的分组里,不会进内置分类。'), findsOneWidget);
// 页面里除了表单还有顶部搜索框,这里限定在对话框内部找输入框。
Finder field(int index) => find
.descendant(
of: find.byType(SiteFormDialog),
matching: find.byType(TextField),
)
.at(index);
await tester.enterText(field(0), 'Hugging Face');
await tester.enterText(field(1), 'https://huggingface.co');
await tester.enterText(field(3), 'AI 工具');
await tester.pump();
await tester.tap(find.text('提交'));
await tester.pumpAndSettle();
expect(repository.submitted, hasLength(1));
expect(repository.submitted.single.group, 'AI 工具');
expect(repository.fetches, 2, reason: '提交成功后要重新拉一次数据');
expect(find.text('已提交,感谢'), findsOneWidget);
expect(find.text('网站名称'), findsNothing, reason: '对话框已经关掉');
});
});
group('编辑与删除', () {
testWidgets('可修改的条目能编辑,保存后刷新', (WidgetTester tester) async {
final _MutableRepository repository = _MutableRepository();
await pumpPage(
tester,
size: const Size(1000, 1200),
repository: repository,
);
expect(repository.fetches, 1);
await tester.tap(find.byTooltip('编辑'));
await tester.pumpAndSettle();
// 表单预填了现有内容,提交者字段不出现。
expect(find.text('编辑网站'), findsOneWidget);
expect(
tester
.widget<TextField>(
find
.descendant(
of: find.byType(SiteFormDialog),
matching: find.byType(TextField),
)
.at(0),
)
.controller!
.text,
'Hugging Face',
);
await tester.tap(find.text('提交'));
await tester.pumpAndSettle();
expect(repository.updated, <String>['s-1']);
expect(repository.fetches, 2, reason: '保存成功后要重新拉一次数据');
expect(find.text('已保存'), findsOneWidget);
});
testWidgets('删除会先确认,确认后才真的删并刷新', (WidgetTester tester) async {
final _MutableRepository repository = _MutableRepository();
await pumpPage(
tester,
size: const Size(1000, 1200),
repository: repository,
);
await tester.tap(find.byTooltip('删除'));
await tester.pumpAndSettle();
expect(find.textContaining('确定要删除「Hugging Face」'), findsOneWidget);
// 先点取消:什么都不该发生。
await tester.tap(find.text('取消'));
await tester.pumpAndSettle();
expect(repository.deleted, isEmpty);
await tester.tap(find.byTooltip('删除'));
await tester.pumpAndSettle();
await tester.tap(find.text('删除'));
await tester.pumpAndSettle();
expect(repository.deleted, <String>['s-1']);
expect(repository.fetches, 2);
expect(find.text('已删除'), findsOneWidget);
});
testWidgets('内置条目没有编辑和删除入口', (WidgetTester tester) async {
testWidgets('点侧栏底部按钮跳到后端配的提交入口', (WidgetTester tester) async {
await pumpPage(tester);
expect(find.byType(LinkCard), findsWidgets);
expect(find.byTooltip('编辑'), findsNothing);
expect(find.byTooltip('删除'), findsNothing);
await tester.tap(navText('提交网站'));
await tester.pump();
// 地址来自后端 `site.submitUrl`,页面不再自己弹表单。
expect(urlOpener.openedUrls, <String>['https://mattermost.example.com']);
});
});
}
/// 返回一条「用户提交」的站点(editable),并记录改删调用。
class _MutableRepository implements HomeRepository {
int fetches = 0;
final List<String> updated = <String>[];
final List<String> deleted = <String>[];
static const HomeData _data = HomeData(
siteName: 'Suxing Nav',
searchHint: '输入关键字搜索全网资源...',
navNodes: <NavNode>[NavNode(id: 'g-1', title: 'AI 工具', editable: true)],
sections: <LinkSection>[
LinkSection(
id: 'g-1',
title: 'AI 工具',
items: <LinkItem>[
LinkItem(
id: 's-1',
title: 'Hugging Face',
url: 'https://huggingface.co',
description: '开源模型与数据集社区',
categoryId: 'g-1',
editable: true,
),
],
),
],
);
@override
Future<HomeData> fetchHomeData() async {
fetches++;
return _data;
}
@override
Future<void> submitSite(SubmitSiteDraft draft) async {}
@override
Future<void> updateSite(String id, SubmitSiteDraft draft) async {
updated.add(id);
}
@override
Future<void> deleteSite(String id) async {
deleted.add(id);
}
}
/// 记录取数次数与提交内容,用来验证页面的联调行为。
class _RecordingRepository implements HomeRepository {
int fetches = 0;
final List<SubmitSiteDraft> submitted = <SubmitSiteDraft>[];
final List<String> updated = <String>[];
final List<String> deleted = <String>[];
@override
Future<HomeData> fetchHomeData() async {
fetches++;
return mockHomeData;
}
@override
Future<void> submitSite(SubmitSiteDraft draft) async {
submitted.add(draft);
}
@override
Future<void> updateSite(String id, SubmitSiteDraft draft) async {
updated.add(id);
}
@override
Future<void> deleteSite(String id) async {
deleted.add(id);
}
}
/// 数据一直不回来,用来测加载中的那一帧。
class _PendingRepository implements HomeRepository {
final Completer<HomeData> completer = Completer<HomeData>();
@@ -1,235 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/features/home/data/home_repository.dart';
import 'package:frontend/features/home/data/mock_home_data.dart';
import 'package:frontend/features/home/domain/models/home_data.dart';
import 'package:frontend/features/home/domain/models/nav_node.dart';
import 'package:frontend/features/home/domain/models/submit_site_draft.dart';
import 'package:frontend/features/home/presentation/widgets/site_form_dialog.dart';
/// 记录提交内容的假仓库。[failWith] 有值时模拟后端报错,[gate] 用来卡住请求。
class _RecordingRepository implements HomeRepository {
final List<SubmitSiteDraft> submitted = <SubmitSiteDraft>[];
String? failWith;
Completer<void>? gate;
@override
Future<HomeData> fetchHomeData() async => mockHomeData;
@override
Future<void> submitSite(SubmitSiteDraft draft) async {
if (gate != null) {
await gate!.future;
}
final String? failure = failWith;
if (failure != null) {
throw HomeFailure(failure);
}
submitted.add(draft);
}
@override
Future<void> updateSite(String id, SubmitSiteDraft draft) async {}
@override
Future<void> deleteSite(String id) async {}
}
/// 侧栏那棵树:内置分组不可选,用户分组可选。
const List<NavNode> _navNodes = <NavNode>[
NavNode(id: 'common', title: '常用推荐'),
NavNode(
id: 'g-1',
title: 'AI 工具',
editable: true,
children: <NavNode>[
NavNode(id: 'g-2', title: '模型社区', editable: true),
],
),
];
void main() {
late _RecordingRepository repository;
late bool? dialogResult;
setUp(() {
repository = _RecordingRepository();
dialogResult = null;
});
Future<void> openDialog(
WidgetTester tester, {
String heading = '提交网站',
SubmitSiteDraft? initial,
}) async {
// 表单比默认测试视口高,放大一点免得字段跑到屏幕外。
tester.view.physicalSize = const Size(1000, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (BuildContext context) => TextButton(
onPressed: () async {
dialogResult = await showSiteFormDialog(
context: context,
navNodes: _navNodes,
heading: heading,
initial: initial,
onSubmit: (SubmitSiteDraft draft) =>
repository.submitSite(draft),
);
},
child: const Text('打开'),
),
),
),
),
);
await tester.tap(find.text('打开'));
await tester.pumpAndSettle();
}
/// 按顺序填字段:0 名称、1 地址、2 描述、3 分组、4 子分组、5 称呼。
Future<void> fill(WidgetTester tester, int index, String value) async {
await tester.enterText(find.byType(TextField).at(index), value);
await tester.pump();
}
Future<void> fillValidForm(WidgetTester tester) async {
await fill(tester, 0, 'Hugging Face');
await fill(tester, 1, 'https://huggingface.co');
await fill(tester, 2, '开源模型与数据集社区');
await fill(tester, 3, 'AI 工具');
await fill(tester, 4, '模型社区');
await fill(tester, 5, '[email protected]');
}
testWidgets('空表单提交会逐项提示,且不发请求', (WidgetTester tester) async {
await openDialog(tester);
await tester.tap(find.text('提交'));
await tester.pumpAndSettle();
expect(find.text('请填写网站名称'), findsOneWidget);
expect(find.text('请填写网站地址'), findsOneWidget);
expect(find.text('请填写分组名称'), findsOneWidget);
expect(repository.submitted, isEmpty);
expect(dialogResult, isNull, reason: '校验没过时对话框不关');
});
testWidgets('内网地址会被拦下来', (WidgetTester tester) async {
await openDialog(tester);
await fill(tester, 0, '内网');
await fill(tester, 1, 'http://192.168.1.10');
await fill(tester, 3, 'AI 工具');
await tester.tap(find.text('提交'));
await tester.pumpAndSettle();
expect(find.text('不能填本机或内网地址'), findsOneWidget);
expect(repository.submitted, isEmpty);
});
testWidgets('合法内容提交给仓库,成功后关闭并返回 true', (WidgetTester tester) async {
await openDialog(tester);
await fillValidForm(tester);
await tester.tap(find.text('提交'));
await tester.pumpAndSettle();
expect(repository.submitted, hasLength(1));
final SubmitSiteDraft draft = repository.submitted.single;
expect(draft.title, 'Hugging Face');
expect(draft.url, 'https://huggingface.co');
expect(draft.group, 'AI 工具');
expect(draft.subGroup, '模型社区');
expect(draft.submittedBy, '[email protected]');
expect(dialogResult, isTrue);
expect(find.text('取消'), findsNothing, reason: '对话框已经关掉');
});
testWidgets('后端报错时留在表单里显示原因', (WidgetTester tester) async {
repository.failWith = 'url 不能是本机或内网地址:localhost';
await openDialog(tester);
await fillValidForm(tester);
await tester.tap(find.text('提交'));
await tester.pumpAndSettle();
expect(find.textContaining('不能是本机或内网地址'), findsOneWidget);
expect(repository.submitted, isEmpty);
expect(dialogResult, isNull);
expect(find.text('取消'), findsOneWidget, reason: '还能改了再提交');
});
testWidgets('提交中按钮变成「提交中…」且不可再点', (WidgetTester tester) async {
repository.gate = Completer<void>();
await openDialog(tester);
await fillValidForm(tester);
await tester.tap(find.text('提交'));
await tester.pump();
expect(find.text('提交中…'), findsOneWidget);
repository.gate!.complete();
await tester.pumpAndSettle();
expect(dialogResult, isTrue);
});
testWidgets('只给用户分组建议,点一下填进输入框', (WidgetTester tester) async {
await openDialog(tester);
// 内置分组不出现在建议里。
expect(find.text('AI 工具'), findsOneWidget, reason: '用户分组建议');
expect(find.text('常用推荐'), findsNothing, reason: '内置分组不可选');
await tester.tap(find.text('AI 工具'));
await tester.pumpAndSettle();
final TextField groupField = tester.widget<TextField>(
find.byType(TextField).at(3),
);
expect(groupField.controller!.text, 'AI 工具');
// 分组定了之后,它下面已有的子分组才出现。
expect(find.text('模型社区'), findsOneWidget);
});
testWidgets('编辑模式预填现有内容,且不显示提交者字段', (WidgetTester tester) async {
await openDialog(
tester,
heading: '编辑网站',
initial: const SubmitSiteDraft(
title: 'Hugging Face',
url: 'https://huggingface.co',
description: '开源模型与数据集社区',
group: 'AI 工具',
subGroup: '模型社区',
),
);
expect(find.text('编辑网站'), findsOneWidget);
expect(
tester.widget<TextField>(find.byType(TextField).at(0)).controller!.text,
'Hugging Face',
);
expect(
tester.widget<TextField>(find.byType(TextField).at(1)).controller!.text,
'https://huggingface.co',
);
expect(
tester.widget<TextField>(find.byType(TextField).at(3)).controller!.text,
'AI 工具',
);
// 后端 PATCH 不接受 submittedBy,编辑时就不显示这个字段。
expect(find.text('你的称呼或邮箱'), findsNothing);
// 分组已填好,它下面的子分组建议直接出来。
// (子分组输入框里也预填了同样的文字,所以这里用 findsWidgets。)
expect(find.text('模型社区'), findsWidgets);
});
}