From 039ea70edfc7b24e5221d9e0cb59d3a5aeb36b99 Mon Sep 17 00:00:00 2001 From: CNWei Date: Sat, 19 Sep 2026 23:17:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E9=A1=B5=E5=9F=BA=E7=A1=80=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加导航条目和分类数据模型 - 实现导航卡片网格展示 - 支持点击卡片打开链接 - 添加主题样式(渐变色、卡片阴影) - 区分系统默认和自定义条目 - 添加分类统计信息" --- backend/README.md | 39 +- backend/data/nav.json | 148 +++ backend/data/site.json | 4 - backend/data/system-categories.json | 74 -- backend/data/system-sites.json | 62 -- backend/data/user-categories.json | 47 - backend/data/user-sites.json | 14 - backend/src/api.rs | 10 +- backend/src/model.rs | 107 +- backend/src/store.rs | 987 +++++++++--------- docs/backend-site-schema.md | 19 +- docs/frontend-architecture.md | 24 +- docs/submit-site-form.md | 116 -- frontend/README.md | 16 +- frontend/lib/core/constants/app_sizes.dart | 27 - .../home/data/http_home_repository.dart | 1 + .../features/home/data/mock_home_data.dart | 1 + .../home/domain/models/home_data.dart | 6 + .../home/presentation/pages/home_page.dart | 107 +- .../presentation/widgets/confirm_dialog.dart | 91 -- .../presentation/widgets/dialog_buttons.dart | 117 --- .../home/presentation/widgets/link_card.dart | 160 +-- .../widgets/link_section_view.dart | 15 +- .../widgets/site_form_dialog.dart | 757 -------------- .../home/presentation/home_page_test.dart | 195 +--- .../presentation/site_form_dialog_test.dart | 235 ----- 26 files changed, 796 insertions(+), 2583 deletions(-) create mode 100644 backend/data/nav.json delete mode 100644 backend/data/site.json delete mode 100644 backend/data/system-categories.json delete mode 100644 backend/data/system-sites.json delete mode 100644 backend/data/user-categories.json delete mode 100644 backend/data/user-sites.json delete mode 100644 docs/submit-site-form.md delete mode 100644 frontend/lib/features/home/presentation/widgets/confirm_dialog.dart delete mode 100644 frontend/lib/features/home/presentation/widgets/dialog_buttons.dart delete mode 100644 frontend/lib/features/home/presentation/widgets/site_form_dialog.dart delete mode 100644 frontend/test/features/home/presentation/site_form_dialog_test.dart diff --git a/backend/README.md b/backend/README.md index 61a9933..1668252 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 把内置网站标成用户来源」这类越权。 ## 接口 diff --git a/backend/data/nav.json b/backend/data/nav.json new file mode 100644 index 0000000..d063806 --- /dev/null +++ b/backend/data/nav.json @@ -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": [] + } + ] + } + ] +} diff --git a/backend/data/site.json b/backend/data/site.json deleted file mode 100644 index 4821e52..0000000 --- a/backend/data/site.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "Suxing Nav", - "searchHint": "输入关键字搜索全网资源..." -} diff --git a/backend/data/system-categories.json b/backend/data/system-categories.json deleted file mode 100644 index b301c91..0000000 --- a/backend/data/system-categories.json +++ /dev/null @@ -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" - } -] diff --git a/backend/data/system-sites.json b/backend/data/system-sites.json deleted file mode 100644 index ae9b4df..0000000 --- a/backend/data/system-sites.json +++ /dev/null @@ -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": "" - } -] diff --git a/backend/data/user-categories.json b/backend/data/user-categories.json deleted file mode 100644 index a2cac7a..0000000 --- a/backend/data/user-categories.json +++ /dev/null @@ -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" - } -] diff --git a/backend/data/user-sites.json b/backend/data/user-sites.json deleted file mode 100644 index a86ce40..0000000 --- a/backend/data/user-sites.json +++ /dev/null @@ -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": "an@example.com" - } -] diff --git a/backend/src/api.rs b/backend/src/api.rs index 4f156eb..265fe7b 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -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, 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), 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。 diff --git a/backend/src/model.rs b/backend/src/model.rs index 0930d75..3bab5a0 100644 --- a/backend/src/model.rs +++ b/backend/src/model.rs @@ -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, + /// 子分组;只有一级分组会有。 + #[serde(default)] + pub children: Vec, } -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, +} + +/// 对外返回的网站:补上它所属分组的 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 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, } -/// 站点级配置,存在 `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)] diff --git a/backend/src/store.rs b/backend/src/store.rs index a4dd1d2..8bb53bd 100644 --- a/backend/src/store.rs +++ b/backend/src/store.rs @@ -1,7 +1,7 @@ -//! 数据仓库:启动时把四个 JSON 文件读进内存,之后读内存、写文件。 +//! 数据仓库:`data/nav.json` 读进内存,之后读内存、写文件。 //! -//! 「系统内置不可修改」是靠结构保证的:`system-*.json` 只在 `load` 里被读, -//! 模块里根本没有写这两个文件的函数,任何接口都改不到它们。 +//! 侧栏结构和网站数据在同一份文档里,所以「删掉一个网站」和「它的用户分组要不要 +//! 一起清掉」是同一次写入完成的——不会出现网站没了、侧栏还挂着一个空分组的中间状态。 use std::collections::HashSet; use std::fs; @@ -12,183 +12,69 @@ use serde::de::DeserializeOwned; use crate::error::{ApiError, StoreError}; use crate::model::{ - Category, CreateSiteRequest, NavGroupView, NavResponse, Site, SiteMeta, SiteView, Source, + CreateSiteRequest, Group, NavFile, NavGroupView, NavResponse, Site, SiteView, Source, UpdateSiteRequest, }; use crate::util::{generate_id, now_iso8601}; -use crate::validate::{self, SiteDraft, SitePatch}; +use crate::validate::{self, SitePatch}; /// 同一分组里每新增一条,排序值加这么多,给以后手工插入留出空隙。 const ORDER_STEP: i32 = 10; -/// 四个数据文件的位置。 -#[derive(Debug, Clone)] -pub struct DataPaths { - pub site: PathBuf, - pub system_categories: PathBuf, - pub system_sites: PathBuf, - pub user_categories: PathBuf, - pub user_sites: PathBuf, -} - -impl DataPaths { - pub fn in_dir(dir: &Path) -> Self { - Self { - site: dir.join("site.json"), - system_categories: dir.join("system-categories.json"), - system_sites: dir.join("system-sites.json"), - user_categories: dir.join("user-categories.json"), - user_sites: dir.join("user-sites.json"), - } - } -} +/// 分组层级上限:一级 + 子分组。 +const MAX_GROUP_DEPTH: usize = 2; /// 内存态数据仓库。 pub struct Store { - paths: DataPaths, - site: SiteMeta, - system_categories: Vec, - system_sites: Vec, - user_categories: Vec, - user_sites: Vec, + path: PathBuf, + file: NavFile, } impl Store { - /// 从数据目录读取全部数据。系统文件必须存在;用户文件缺失时当空处理, - /// 这样第一次跑起来只需要带上种子数据。 + /// 从数据目录读取 `nav.json`。 pub fn load(dir: &Path) -> Result { - let paths = DataPaths::in_dir(dir); - Ok(Self { - site: read_json(&paths.site, true)?, - system_categories: read_list(&paths.system_categories, true)?, - system_sites: read_list(&paths.system_sites, true)?, - user_categories: read_list(&paths.user_categories, false)?, - user_sites: read_list(&paths.user_sites, false)?, - paths, - }) + let path = dir.join("nav.json"); + let file: NavFile = read_json(&path)?; + Ok(Self { path, file }) } - /// 启动自检。不通过就直接让进程退出——数据文件坏了还继续跑, + /// 启动自检。不通过就让进程直接退出——数据文件坏了还继续跑, /// 只会把更乱的数据写回去。 pub fn verify(&self) -> Result<(), StoreError> { - // 0. 站点配置是必需的:站点名不能空。 - if self.site.name.trim().is_empty() { + if self.file.site.name.trim().is_empty() { return Err(StoreError::Integrity( - "site.json 里的 name 不能为空".to_string(), + "nav.json 里的 site.name 不能为空".to_string(), )); } - - // 1. source 必须和文件归属一致,防止手改 JSON 越权。 - for category in &self.system_categories { - if !category.source.is_system() { - return Err(StoreError::Integrity(format!( - "system-categories.json 里的分组「{}」source 不是 system", - category.id - ))); - } + let mut ids = HashSet::new(); + for group in &self.file.groups { + verify_group(group, 1, &mut ids)?; } - for category in &self.user_categories { - if category.source.is_system() { - return Err(StoreError::Integrity(format!( - "user-categories.json 里的分组「{}」source 不能是 system", - category.id - ))); - } - } - for site in &self.system_sites { - if !site.source.is_system() { - return Err(StoreError::Integrity(format!( - "system-sites.json 里的网站「{}」source 不是 system", - site.id - ))); - } - } - for site in &self.user_sites { - if site.source.is_system() { - return Err(StoreError::Integrity(format!( - "user-sites.json 里的网站「{}」source 不能是 system", - site.id - ))); - } - } - - // 2. id 不能重复。 - ensure_unique(self.categories().map(|c| c.id.as_str()), "分组")?; - ensure_unique(self.sites().map(|s| s.id.as_str()), "网站")?; - - // 3. 分组层级:parentId 必须存在、必须是一级分组、不能自指。 - for category in self.categories() { - if category.parent_id.is_empty() { - continue; - } - if category.parent_id == category.id { - return Err(StoreError::Integrity(format!( - "分组「{}」的 parentId 指向了自己", - category.id - ))); - } - let parent = self.find_category(&category.parent_id).ok_or_else(|| { - StoreError::Integrity(format!( - "分组「{}」的 parentId「{}」不存在", - category.id, category.parent_id - )) - })?; - if !parent.is_root() { - return Err(StoreError::Integrity(format!( - "分组「{}」挂在子分组「{}」下,层级最多两级", - category.id, parent.id - ))); - } - if parent.source.is_system() != category.source.is_system() { - return Err(StoreError::Integrity(format!( - "分组「{}」和它的父分组「{}」来源不一致", - category.id, parent.id - ))); - } - } - - // 4. 每个网站都要挂在一个存在的分组上,而且来源要对得上: - // 内置网站只能在内置分组下,提交的网站只能在用户分组下。 - for site in &self.system_sites { - let category = self.require_category(&site.category_id)?; - if !category.source.is_system() { - return Err(StoreError::Integrity(format!( - "内置网站「{}」挂在了用户分组「{}」下", - site.id, category.id - ))); - } - } - for site in &self.user_sites { - let category = self.require_category(&site.category_id)?; - if category.source.is_system() { - return Err(StoreError::Integrity(format!( - "提交的网站「{}」挂在了内置分组「{}」下", - site.id, category.id - ))); - } - } - Ok(()) } - /// 统计信息,给健康检查用。 + /// 统计信息,给健康检查 / 启动日志用。 pub fn summary(&self) -> Summary { - Summary { - sites: self.system_sites.len() + self.user_sites.len(), - system_sites: self.system_sites.len(), - user_sites: self.user_sites.len(), - categories: self.categories().count(), - } + let mut summary = Summary::default(); + walk_groups(&self.file.groups, &mut |group| { + summary.categories += 1; + for site in &group.sites { + summary.sites += 1; + if site.source.is_system() { + summary.system_sites += 1; + } else { + summary.user_sites += 1; + } + } + }); + summary } /// 侧栏 + 内容区需要的那棵树。 pub fn nav(&self) -> NavResponse { - let mut roots: Vec<&Category> = - self.categories().filter(|c| c.is_root()).collect(); - sort_categories(&mut roots); NavResponse { - site: self.site.clone(), - groups: roots.into_iter().map(|c| self.group_view(c)).collect(), + site: self.file.site.clone(), + groups: self.file.groups.iter().map(group_view).collect(), } } @@ -197,157 +83,127 @@ impl Store { &mut self, request: &CreateSiteRequest, source: Source, - ) -> Result { + ) -> Result { if source.is_system() { return Err(ApiError::internal("新增接口不接受 system 来源")); } let draft = validate::submission(request)?; - // 先改副本,落盘成功后再替换内存:写盘失败时不会留下「内存有、磁盘没有」的状态。 - let mut categories = self.user_categories.clone(); - let mut sites = self.user_sites.clone(); - - let category_id = resolve_user_group(&mut categories, &draft)?; - let order = next_order(&sites, &category_id); + // 先改副本,落盘成功后再替换内存:写盘失败时不会留下「内存有、磁盘没有」。 + let mut file = self.file.clone(); + let category_id = resolve_group_path(&mut file.groups, &draft.group, &draft.sub_group)?; + // id 要在拿到分组的可变借用之前算好,否则借用冲突。 + let id = new_id(site_prefix(source), &collect_ids(&file.groups)); let now = now_iso8601(); + let group = find_group_mut(&mut file.groups, &category_id) + .ok_or_else(|| ApiError::internal("刚建好的分组找不到了"))?; let site = Site { - id: new_id(site_prefix(source), &sites), + id, title: draft.title, url: draft.url, description: draft.description, - category_id, source, - order, + order: next_site_order(group), created_at: now.clone(), updated_at: now, submitted_by: draft.submitted_by, }; - sites.push(site.clone()); + group.sites.push(site.clone()); - write_list(&self.paths.user_categories, &categories)?; - write_list(&self.paths.user_sites, &sites)?; - self.user_categories = categories; - self.user_sites = sites; - Ok(site) + write_json(&self.path, &file)?; + self.file = file; + Ok(SiteView { + editable: site.is_editable(), + site, + category_id, + }) } /// 修改一个网站。系统内置的直接 403。 - pub fn update(&mut self, id: &str, request: &UpdateSiteRequest) -> Result { + pub fn update(&mut self, id: &str, request: &UpdateSiteRequest) -> Result { let patch = validate::patch(request)?; - let index = self.user_sites.iter().position(|s| s.id == id); - let Some(index) = index else { - if self.system_sites.iter().any(|s| s.id == id) { - return Err(ApiError::forbidden(format!( - "「{id}」是系统内置网站,不能修改" - ))); - } + let mut file = self.file.clone(); + + let found = find_site(&file.groups, id).map(|(group, site)| (group.id.clone(), site.source)); + let Some((owner_id, site_source)) = found else { return Err(ApiError::not_found(format!("找不到网站:{id}"))); }; + if site_source.is_system() { + return Err(ApiError::forbidden(format!( + "「{id}」是系统内置网站,不能修改" + ))); + } - let mut categories = self.user_categories.clone(); - let mut sites = self.user_sites.clone(); - - let moved_to = if let Some(group) = &patch.group { + // 目标分组:给了 group 就换分组;只给 subGroup 就沿用当前的一级分组。 + let target_id = if let Some(group) = &patch.group { let sub = patch.sub_group.clone().unwrap_or_default(); - Some(resolve_named_group(&mut categories, group, &sub)?) + resolve_group_path(&mut file.groups, group, &sub)? } else if let Some(sub) = &patch.sub_group { if sub.is_empty() { - None + owner_id.clone() } else { - // 只给子分组:沿用条目当前所属的一级分组。 - let root = root_title_of(&categories, &sites[index].category_id) + let root = root_title_of(&file.groups, &owner_id) .ok_or_else(|| ApiError::internal("当前条目的一级分组找不到"))?; - Some(resolve_named_group(&mut categories, &root, sub)?) + resolve_group_path(&mut file.groups, &root, sub)? } } else { - None + owner_id.clone() }; - // 排序值要在拿到 `sites[index]` 的可变借用之前算好,否则借用冲突。 - let moved_order = moved_to.as_ref().map(|id| next_order(&sites, id)); - - let site = &mut sites[index]; - apply_patch(site, patch); - if let Some(category_id) = moved_to { - if let Some(order) = moved_order { - site.order = order; - } - site.category_id = category_id; - } + let mut site = take_site(&mut file.groups, id) + .ok_or_else(|| ApiError::internal("网站消失在写入过程中"))?; + apply_patch(&mut site, patch); site.updated_at = now_iso8601(); - let updated = site.clone(); + if target_id != owner_id { + let target = find_group_mut(&mut file.groups, &target_id) + .ok_or_else(|| ApiError::internal("目标分组找不到了"))?; + site.order = next_site_order(target); + } - write_list(&self.paths.user_categories, &categories)?; - write_list(&self.paths.user_sites, &sites)?; - self.user_categories = categories; - self.user_sites = sites; - Ok(updated) + let target = find_group_mut(&mut file.groups, &target_id) + .ok_or_else(|| ApiError::internal("目标分组找不到了"))?; + target.sites.push(site.clone()); + + // 挪走之后原分组可能空了,用户分组跟着清掉。 + prune_empty_user_groups(&mut file.groups); + + write_json(&self.path, &file)?; + self.file = file; + Ok(SiteView { + editable: site.is_editable(), + site, + category_id: target_id, + }) } /// 删除一个网站。系统内置的直接 403。 + /// + /// 删掉之后如果它所在的用户分组空了,这个分组会在同一次写入里一起清掉—— + /// 侧栏不会留下一个点进去什么都没有的分组。 pub fn delete(&mut self, id: &str) -> Result<(), ApiError> { - if self.system_sites.iter().any(|s| s.id == id) { + let mut file = self.file.clone(); + let found = find_site(&file.groups, id).map(|(_, site)| site.source); + let Some(site_source) = found else { + return Err(ApiError::not_found(format!("找不到网站:{id}"))); + }; + if site_source.is_system() { return Err(ApiError::forbidden(format!( "「{id}」是系统内置网站,不能删除" ))); } - let mut sites = self.user_sites.clone(); - let before = sites.len(); - sites.retain(|s| s.id != id); - if sites.len() == before { - return Err(ApiError::not_found(format!("找不到网站:{id}"))); - } - write_list(&self.paths.user_sites, &sites)?; - self.user_sites = sites; + + take_site(&mut file.groups, id) + .ok_or_else(|| ApiError::internal("网站消失在写入过程中"))?; + prune_empty_user_groups(&mut file.groups); + + write_json(&self.path, &file)?; + self.file = file; Ok(()) } - - // ---- 内部 ---- - - fn categories(&self) -> impl Iterator { - self.system_categories.iter().chain(self.user_categories.iter()) - } - - fn sites(&self) -> impl Iterator { - self.system_sites.iter().chain(self.user_sites.iter()) - } - - fn find_category(&self, id: &str) -> Option<&Category> { - self.categories().find(|c| c.id == id) - } - - fn require_category(&self, id: &str) -> Result<&Category, StoreError> { - self.find_category(id).ok_or_else(|| { - StoreError::Integrity(format!("分组「{id}」不存在,但有网站挂在它下面")) - }) - } - - fn group_view(&self, category: &Category) -> NavGroupView { - let mut children: Vec<&Category> = self - .categories() - .filter(|c| c.parent_id == category.id) - .collect(); - sort_categories(&mut children); - - let mut sites: Vec<&Site> = self - .sites() - .filter(|s| s.category_id == category.id) - .collect(); - sort_sites(&mut sites); - - NavGroupView { - id: category.id.clone(), - title: category.title.clone(), - source: category.source, - editable: category.is_editable(), - sites: sites.into_iter().map(SiteView::from).collect(), - children: children.into_iter().map(|c| self.group_view(c)).collect(), - } - } } /// 健康检查 / 启动日志用的统计。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct Summary { pub sites: usize, @@ -356,64 +212,174 @@ pub struct Summary { pub categories: usize, } -/// 解析(必要时新建)用户分组,返回网站该挂到的分组 id。 -/// -/// 只在传进来的 `user_categories` 里查找——内置分组不参与, -/// 所以提交的内容不可能落进内置分组。 -fn resolve_user_group( - categories: &mut Vec, - draft: &SiteDraft, -) -> Result { - resolve_named_group(categories, &draft.group, &draft.sub_group) +// ---- 树操作 ---- + +/// 遍历所有分组(含子分组)。 +fn walk_groups(groups: &[Group], visit: &mut dyn FnMut(&Group)) { + for group in groups { + visit(group); + walk_groups(&group.children, visit); + } } -fn resolve_named_group( - categories: &mut Vec, +/// 收集整棵树里用到的所有 id(分组 + 网站),生成新 id 时用它避重。 +fn collect_ids(groups: &[Group]) -> HashSet { + let mut ids = HashSet::new(); + walk_groups(groups, &mut |group| { + ids.insert(group.id.clone()); + for site in &group.sites { + ids.insert(site.id.clone()); + } + }); + ids +} + +fn find_group_mut<'a>(groups: &'a mut [Group], id: &str) -> Option<&'a mut Group> { + for group in groups.iter_mut() { + if group.id == id { + return Some(group); + } + if let Some(found) = find_group_mut(&mut group.children, id) { + return Some(found); + } + } + None +} + +fn find_site<'a>(groups: &'a [Group], id: &str) -> Option<(&'a Group, &'a Site)> { + for group in groups { + if let Some(site) = group.sites.iter().find(|site| site.id == id) { + return Some((group, site)); + } + if let Some(found) = find_site(&group.children, id) { + return Some(found); + } + } + None +} + +/// 把网站从树里取出来。 +fn take_site(groups: &mut [Group], id: &str) -> Option { + for group in groups.iter_mut() { + if let Some(index) = group.sites.iter().position(|site| site.id == id) { + return Some(group.sites.remove(index)); + } + if let Some(site) = take_site(&mut group.children, id) { + return Some(site); + } + } + None +} + +/// 找出某个分组所属的一级分组标题。 +fn root_title_of(groups: &[Group], id: &str) -> Option { + for root in groups { + if root.id == id || root.children.iter().any(|child| child.id == id) { + return Some(root.title.clone()); + } + } + None +} + +fn next_site_order(group: &Group) -> i32 { + group.sites.iter().map(|site| site.order).max().unwrap_or(0) + ORDER_STEP +} + +/// 解析(必要时新建)用户分组,返回网站该挂到的分组 id。 +/// +/// 只在 `source != system` 的组里查找——内置分组不参与, +/// 所以提交的内容不可能落进内置分组。 +fn resolve_group_path( + groups: &mut Vec, group: &str, sub_group: &str, ) -> Result { - let root_id = match categories.iter().find(|c| c.is_root() && c.title == group) { - Some(found) => found.id.clone(), - None => push_category(categories, group, "")?, + let root_id = match groups + .iter() + .position(|item| !item.source.is_system() && item.title == group) + { + Some(index) => groups[index].id.clone(), + None => push_root_group(groups, group)?, }; if sub_group.is_empty() { return Ok(root_id); } - match categories + + let existing_child = groups .iter() - .find(|c| c.parent_id == root_id && c.title == sub_group) - { - Some(found) => Ok(found.id.clone()), - None => push_category(categories, sub_group, &root_id), + .find(|item| item.id == root_id) + .and_then(|root| { + root.children + .iter() + .find(|child| !child.source.is_system() && child.title == sub_group) + }) + .map(|child| child.id.clone()); + if let Some(id) = existing_child { + return Ok(id); } + push_child_group(groups, &root_id, sub_group) } -fn push_category( - categories: &mut Vec, - title: &str, - parent_id: &str, -) -> Result { - let id = new_id("g", categories); - let order = categories - .iter() - .filter(|c| c.parent_id == parent_id) - .map(|c| c.order) - .max() - .unwrap_or(0) - + ORDER_STEP; +fn push_root_group(groups: &mut Vec, title: &str) -> Result { + let id = new_id("g", &collect_ids(groups)); + let order = groups.iter().map(|group| group.order).max().unwrap_or(0) + ORDER_STEP; let now = now_iso8601(); - categories.push(Category { + groups.push(Group { id: id.clone(), title: title.to_string(), - parent_id: parent_id.to_string(), source: Source::Submission, order, created_at: now.clone(), updated_at: now, + sites: Vec::new(), + children: Vec::new(), }); Ok(id) } +fn push_child_group( + groups: &mut [Group], + parent_id: &str, + title: &str, +) -> Result { + let id = new_id("g", &collect_ids(groups)); + let now = now_iso8601(); + let parent = groups + .iter_mut() + .find(|group| group.id == parent_id) + .ok_or_else(|| ApiError::internal("父分组找不到了"))?; + let order = parent + .children + .iter() + .map(|child| child.order) + .max() + .unwrap_or(0) + + ORDER_STEP; + parent.children.push(Group { + id: id.clone(), + title: title.to_string(), + source: Source::Submission, + order, + created_at: now.clone(), + updated_at: now, + sites: Vec::new(), + children: Vec::new(), + }); + Ok(id) +} + +/// 递归清掉「空掉了的用户分组」:没有网站、也没有子分组的用户分组不再保留。 +/// +/// 内置分组永远不动——那是项目维护者手写的结构。 +fn prune_empty_user_groups(groups: &mut Vec) { + for group in groups.iter_mut() { + prune_empty_user_groups(&mut group.children); + } + groups.retain(|group| { + group.source.is_system() || !group.sites.is_empty() || !group.children.is_empty() + }); +} + fn apply_patch(site: &mut Site, patch: SitePatch) { if let Some(value) = patch.title { site.title = value; @@ -426,28 +392,6 @@ fn apply_patch(site: &mut Site, patch: SitePatch) { } } -/// 找出某个分组所属的一级分组标题。 -fn root_title_of(categories: &[Category], category_id: &str) -> Option { - let category = categories.iter().find(|c| c.id == category_id)?; - if category.is_root() { - return Some(category.title.clone()); - } - categories - .iter() - .find(|c| c.id == category.parent_id) - .map(|root| root.title.clone()) -} - -fn next_order(sites: &[Site], category_id: &str) -> i32 { - sites - .iter() - .filter(|s| s.category_id == category_id) - .map(|s| s.order) - .max() - .unwrap_or(0) - + ORDER_STEP -} - fn site_prefix(source: Source) -> &'static str { match source { Source::Submission => "s", @@ -457,11 +401,10 @@ fn site_prefix(source: Source) -> &'static str { } } -/// 生成一个当前集合里没用过的 id。 -fn new_id(prefix: &str, existing: &[T]) -> String { +fn new_id(prefix: &str, used: &HashSet) -> String { for _ in 0..8 { let candidate = generate_id(prefix); - if !existing.iter().any(|item| item.id() == candidate) { + if !used.contains(&candidate) { return candidate; } } @@ -469,71 +412,87 @@ fn new_id(prefix: &str, existing: &[T]) -> String { format!("{prefix}-{}", now_iso8601().replace([':', '-'], "")) } -trait HasId { - fn id(&self) -> &str; -} - -impl HasId for Site { - fn id(&self) -> &str { - &self.id - } -} - -impl HasId for Category { - fn id(&self) -> &str { - &self.id - } -} - -fn sort_categories(items: &mut [&Category]) { - items.sort_by(|a, b| { +fn group_view(group: &Group) -> NavGroupView { + let mut children: Vec<&Group> = group.children.iter().collect(); + children.sort_by(|a, b| { a.order .cmp(&b.order) .then_with(|| a.created_at.cmp(&b.created_at)) .then_with(|| a.id.cmp(&b.id)) }); -} - -fn sort_sites(items: &mut [&Site]) { - items.sort_by(|a, b| { + let mut sites: Vec<&Site> = group.sites.iter().collect(); + sites.sort_by(|a, b| { a.order .cmp(&b.order) .then_with(|| a.created_at.cmp(&b.created_at)) .then_with(|| a.id.cmp(&b.id)) }); + + NavGroupView { + id: group.id.clone(), + title: group.title.clone(), + source: group.source, + editable: group.is_editable(), + sites: sites + .into_iter() + .map(|site| SiteView { + site: site.clone(), + category_id: group.id.clone(), + editable: site.is_editable(), + }) + .collect(), + children: children.into_iter().map(group_view).collect(), + } } -fn ensure_unique<'a>(ids: impl Iterator, label: &str) -> Result<(), StoreError> { - let mut seen = HashSet::new(); - for id in ids { - if !seen.insert(id) { - return Err(StoreError::Integrity(format!("{label} id 重复:{id}"))); +/// 分组层级、id 唯一性、以及「内置分组下只能放内置网站」这几条自检。 +fn verify_group( + group: &Group, + depth: usize, + ids: &mut HashSet, +) -> Result<(), StoreError> { + if depth > MAX_GROUP_DEPTH { + return Err(StoreError::Integrity(format!( + "分组「{}」在第 {depth} 层,最多只能两级", + group.id + ))); + } + if group.title.trim().is_empty() { + return Err(StoreError::Integrity(format!( + "分组「{}」的标题不能为空", + group.id + ))); + } + if !ids.insert(group.id.clone()) { + return Err(StoreError::Integrity(format!( + "分组 id 重复:{}", + group.id + ))); + } + for site in &group.sites { + if !ids.insert(site.id.clone()) { + return Err(StoreError::Integrity(format!( + "网站 id 重复:{}", + site.id + ))); } + if group.source.is_system() != site.source.is_system() { + return Err(StoreError::Integrity(format!( + "分组「{}」和它下面的网站「{}」来源不一致:内置分组只能放内置网站,\ + 提交的网站只能放在用户分组下", + group.id, site.id + ))); + } + } + for child in &group.children { + verify_group(child, depth + 1, ids)?; } Ok(()) } -/// 读一个 JSON 数组。`required` 为 false 时,文件不存在按空数组处理。 -fn read_list(path: &Path, required: bool) -> Result, StoreError> { +fn read_json(path: &Path) -> Result { if !path.exists() { - if required { - return Err(StoreError::Io(format!("找不到数据文件:{}", path.display()))); - } - return Ok(Vec::new()); - } - let text = fs::read_to_string(path) - .map_err(|e| StoreError::Io(format!("{}:{e}", path.display())))?; - serde_json::from_str(&text) - .map_err(|e| StoreError::Json(format!("{}:{e}", path.display()))) -} - -/// 读一个 JSON 对象。`required` 为 false 时,文件不存在会报错并要求补上—— -/// 站点配置是必需项,缺了就说明数据目录不完整。 -fn read_json(path: &Path, required: bool) -> Result { - if !path.exists() { - if required { - return Err(StoreError::Io(format!("找不到数据文件:{}", path.display()))); - } + return Err(StoreError::Io(format!("找不到数据文件:{}", path.display()))); } let text = fs::read_to_string(path) .map_err(|e| StoreError::Io(format!("{}:{e}", path.display())))?; @@ -542,13 +501,13 @@ fn read_json(path: &Path, required: bool) -> Result(path: &Path, value: &[T]) -> Result<(), StoreError> { +fn write_json(path: &Path, value: &NavFile) -> Result<(), StoreError> { if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|e| StoreError::Io(format!("{}:{e}", parent.display())))?; } - let mut text = serde_json::to_string_pretty(value) - .map_err(|e| StoreError::Json(e.to_string()))?; + let mut text = + serde_json::to_string_pretty(value).map_err(|e| StoreError::Json(e.to_string()))?; text.push('\n'); let tmp = path.with_extension("json.tmp"); fs::write(&tmp, text).map_err(|e| StoreError::Io(format!("{}:{e}", tmp.display())))?; @@ -557,38 +516,47 @@ fn write_list(path: &Path, value: &[T]) -> Result<(), StoreError> } #[cfg(test)] -/// 造一份最小的种子数据,供单元测试与接口测试共用。 +/// 造一份最小的 `nav.json`,供单元测试与接口测试共用。 pub fn seed_for_test(dir: &Path) { fs::write( - dir.join("site.json"), - r#"{ "name": "Suxing Nav", "searchHint": "输入关键字搜索全网资源..." }"#, - ) - .expect("写测试数据失败"); - fs::write( - dir.join("system-categories.json"), - r#"[ - { "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" } -]"#, - ) - .expect("写测试数据失败"); - fs::write( - dir.join("system-sites.json"), - r#"[ - { "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": "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": "" } -]"#, + dir.join("nav.json"), + r#"{ + "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": "" } + ], + "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": "" } + ], + "children": [] + } + ] + } + ] +}"#, ) .expect("写测试数据失败"); } @@ -598,10 +566,6 @@ mod tests { use super::*; use tempfile::TempDir; - fn write(path: &Path, text: &str) { - fs::write(path, text).expect("写测试数据失败"); - } - fn store_in(temp: &TempDir) -> Store { seed_for_test(temp.path()); let store = Store::load(temp.path()).expect("加载失败"); @@ -621,19 +585,26 @@ mod tests { } #[test] - fn loads_seed_data_and_builds_nav_tree() { + fn loads_one_file_and_builds_nav_tree() { let temp = TempDir::new().unwrap(); let store = store_in(&temp); let nav = store.nav(); + assert_eq!(nav.site.name, "Suxing Nav"); assert_eq!(nav.groups.len(), 2, "两个一级分组"); assert_eq!(nav.groups[0].id, "common"); - assert_eq!(nav.groups[0].sites.len(), 2, "common 下有两条"); - assert_eq!(nav.groups[1].id, "materials"); - assert_eq!(nav.groups[1].children.len(), 1); + assert_eq!(nav.groups[0].sites.len(), 2); + assert_eq!(nav.groups[0].sites[0].site.id, "github"); + // categoryId 由位置推导出来,仍然输出给前端。 + assert_eq!(nav.groups[0].sites[0].category_id, "common"); + assert!(!nav.groups[0].sites[0].editable); assert_eq!(nav.groups[1].children[0].id, "design"); - assert_eq!(nav.groups[1].children[0].sites[0].site.id, "figma"); - assert!(!nav.groups[1].children[0].sites[0].editable, "内置条目不可编辑"); + assert_eq!(nav.groups[1].children[0].sites[0].category_id, "design"); + + let summary = store.summary(); + assert_eq!(summary.sites, 3); + assert_eq!(summary.system_sites, 3); + assert_eq!(summary.categories, 3); } #[test] @@ -642,22 +613,27 @@ mod tests { let mut store = store_in(&temp); let site = store - .create(&submission("Hugging Face", "https://huggingface.co", "AI 工具", "模型社区"), Source::Submission) + .create( + &submission("Hugging Face", "https://huggingface.co", "AI 工具", "模型社区"), + Source::Submission, + ) .expect("提交应当成功"); + assert!(site.site.id.starts_with("s-")); + assert_eq!(site.site.source, Source::Submission); - assert!(site.id.starts_with("s-"), "用户提交的 id 用 s- 前缀:{}", site.id); - assert!(site.category_id.starts_with("g-"), "分组是新建的:{}", site.category_id); - assert_eq!(site.source, Source::Submission); - assert!(site.is_editable()); - - // 新分组挂在第一位,且是两级结构。 let nav = store.nav(); - let created_root = nav.groups.iter().find(|g| g.id != "common" && g.id != "materials").unwrap(); - assert_eq!(created_root.title, "AI 工具"); - assert_eq!(created_root.children[0].title, "模型社区"); - assert_eq!(created_root.children[0].sites[0].site.id, site.id); + let root = nav + .groups + .iter() + .find(|group| group.title == "AI 工具") + .expect("新分组应当存在"); + assert!(root.editable); + let child = &root.children[0]; + assert_eq!(child.title, "模型社区"); + assert_eq!(child.sites[0].site.id, site.site.id); + assert_eq!(child.sites[0].category_id, child.id); - // 真的写到磁盘了:重新加载还能看到。 + // 重新加载:数据真的落盘了。 let reloaded = Store::load(temp.path()).expect("重新加载失败"); reloaded.verify().expect("重新加载后自检失败"); assert_eq!(reloaded.summary().user_sites, 1); @@ -667,16 +643,22 @@ mod tests { fn same_group_name_is_reused() { let temp = TempDir::new().unwrap(); let mut store = store_in(&temp); - - let first = store + store .create(&submission("A", "https://a.com", "AI 工具", "模型社区"), Source::Submission) .unwrap(); - let second = store + store .create(&submission("B", "https://b.com", "AI 工具", "模型社区"), Source::Submission) .unwrap(); - assert_eq!(first.category_id, second.category_id, "同名分组应当复用"); - assert_eq!(store.summary().categories, 5, "只多了一级 + 子分组两个"); + let nav = store.nav(); + let created: Vec<&NavGroupView> = nav + .groups + .iter() + .filter(|group| group.title == "AI 工具") + .collect(); + assert_eq!(created.len(), 1, "同名分组只应有一个"); + assert_eq!(created[0].children.len(), 1); + assert_eq!(created[0].children[0].sites.len(), 2); } #[test] @@ -685,19 +667,30 @@ mod tests { let mut store = store_in(&temp); // 故意用内置分组的名字提交。 - let site = store - .create(&submission("Fake", "https://fake.com", "素材资源", "UI 设计"), Source::Submission) - .unwrap(); + store + .create( + &submission("Fake", "https://fake.com", "素材资源", "UI 设计"), + Source::Submission, + ) + .expect("提交应当成功"); - assert_ne!(site.category_id, "design", "不能挂进内置子分组"); - assert!(site.category_id.starts_with("g-"), "应当在用户分组里"); - - // 内置分组下的内容没被动过。 let nav = store.nav(); - let materials = nav.groups.iter().find(|g| g.id == "materials").unwrap(); - assert_eq!(materials.children[0].id, "design"); - assert_eq!(materials.children[0].sites.len(), 1, "design 下仍然只有 Figma"); - assert!(materials.children[0].sites[0].site.id == "figma"); + let builtin = nav + .groups + .iter() + .find(|group| group.id == "materials") + .unwrap(); + assert_eq!(builtin.children[0].id, "design"); + assert_eq!(builtin.children[0].sites.len(), 1, "内置子分组没被动过"); + assert!(!builtin.children[0].sites[0].editable); + + // 用户分组里多了一个同名的新分组。 + let user_root = nav + .groups + .iter() + .find(|group| group.editable && group.title == "素材资源") + .expect("应当新建用户分组,而不是复用内置的"); + assert_eq!(user_root.children[0].sites.len(), 1); } #[test] @@ -711,9 +704,10 @@ mod tests { }; let err = store.update("github", &update).unwrap_err(); assert_eq!(err.status(), axum::http::StatusCode::FORBIDDEN); - - let err = store.delete("github").unwrap_err(); - assert_eq!(err.status(), axum::http::StatusCode::FORBIDDEN); + assert_eq!( + store.delete("github").unwrap_err().status(), + axum::http::StatusCode::FORBIDDEN + ); } #[test] @@ -729,16 +723,44 @@ mod tests { url: Some("https://a2.com".to_string()), ..Default::default() }; - let updated = store.update(&site.id, &update).unwrap(); - assert_eq!(updated.title, "新名字"); - assert_eq!(updated.url, "https://a2.com"); - assert_eq!(updated.category_id, site.category_id, "没传 group 就不换分组"); + let updated = store.update(&site.site.id, &update).unwrap(); + assert_eq!(updated.site.title, "新名字"); + assert_eq!(updated.site.url, "https://a2.com"); - store.delete(&site.id).unwrap(); + store.delete(&site.site.id).unwrap(); assert_eq!(store.summary().user_sites, 0); + assert_eq!( + store.delete(&site.site.id).unwrap_err().status(), + axum::http::StatusCode::NOT_FOUND + ); + } - let err = store.delete(&site.id).unwrap_err(); - assert_eq!(err.status(), axum::http::StatusCode::NOT_FOUND); + #[test] + fn deleting_the_last_site_removes_the_empty_user_group() { + let temp = TempDir::new().unwrap(); + let mut store = store_in(&temp); + let first = store + .create(&submission("A", "https://a.com", "AI 工具", "模型社区"), Source::Submission) + .unwrap(); + let second = store + .create(&submission("B", "https://b.com", "AI 工具", "模型社区"), Source::Submission) + .unwrap(); + + // 删掉第一条:分组还在(还有一条)。 + store.delete(&first.site.id).unwrap(); + let nav = store.nav(); + assert!(nav.groups.iter().any(|group| group.title == "AI 工具")); + + // 删掉最后一条:一级分组和子分组一起消失,侧栏不会留下空壳。 + store.delete(&second.site.id).unwrap(); + let nav = store.nav(); + assert!( + !nav.groups.iter().any(|group| group.title == "AI 工具"), + "空掉的用户分组应当一起清掉" + ); + assert_eq!(store.summary().categories, 3, "只剩内置的三个分组"); + // 内置分组一条没动。 + assert_eq!(store.summary().system_sites, 3); } #[test] @@ -754,75 +776,66 @@ mod tests { sub_group: Some("笔记".to_string()), ..Default::default() }; - let updated = store.update(&site.id, &update).unwrap(); - assert_ne!(updated.category_id, site.category_id); + let updated = store.update(&site.site.id, &update).unwrap(); let nav = store.nav(); + // 旧分组空了,被清掉。 + assert!(!nav.groups.iter().any(|group| group.title == "AI 工具")); let moved = nav .groups .iter() - .find(|g| g.title == "效率工具") + .find(|group| group.title == "效率工具") .expect("新分组应当存在"); assert_eq!(moved.children[0].title, "笔记"); - assert_eq!(moved.children[0].sites[0].site.id, site.id); + assert_eq!(moved.children[0].sites[0].site.id, updated.site.id); } #[test] - fn verify_rejects_a_tampered_file() { + fn verify_rejects_a_tampered_source() { let temp = TempDir::new().unwrap(); seed_for_test(temp.path()); - // 把内置网站标成 api 来源,模拟手改 JSON 越权。 - write( - &temp.path().join("system-sites.json"), - r#"[{ "id": "github", "title": "GitHub", "url": "https://github.com", - "categoryId": "common", "source": "api", - "createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z" }]"#, - ); - let store = Store::load(temp.path()).expect("加载应当成功"); - let err = store.verify().unwrap_err(); - assert!(err.to_string().contains("source"), "{err}"); - } + // 把内置分组里的网站标成 api,模拟手改 JSON 越权。 + let path = temp.path().join("nav.json"); + let text = fs::read_to_string(&path) + .unwrap() + .replace( + r#""description": "代码托管与开源社区", "source": "system""#, + r#""description": "代码托管与开源社区", "source": "api""#, + ); + fs::write(&path, text).unwrap(); - #[test] - fn verify_rejects_user_site_under_system_group() { - let temp = TempDir::new().unwrap(); - seed_for_test(temp.path()); - write( - &temp.path().join("user-sites.json"), - r#"[{ "id": "s-1", "title": "A", "url": "https://a.com", - "categoryId": "common", "source": "submission", - "createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z" }]"#, - ); let store = Store::load(temp.path()).expect("加载应当成功"); let err = store.verify().unwrap_err(); - assert!(err.to_string().contains("内置分组"), "{err}"); + assert!(err.to_string().contains("来源不一致"), "{err}"); } #[test] fn verify_rejects_three_level_groups() { let temp = TempDir::new().unwrap(); - seed_for_test(temp.path()); - write( - &temp.path().join("system-categories.json"), - r#"[{ "id": "materials", "title": "素材资源", "parentId": "", "source": "system", "order": 10, - "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": "deep", "title": "更深一层", "parentId": "design", "source": "system", "order": 10, - "createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z" }]"#, - ); - write(&temp.path().join("system-sites.json"), "[]"); + fs::write( + temp.path().join("nav.json"), + r#"{ + "site": { "name": "N", "searchHint": "", "submitUrl": "" }, + "groups": [ + { "id": "a", "title": "A", "source": "system", "order": 10, + "createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z", + "sites": [], + "children": [ + { "id": "b", "title": "B", "source": "system", "order": 10, + "createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z", + "sites": [], + "children": [ + { "id": "c", "title": "C", "source": "system", "order": 10, + "createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z", + "sites": [], "children": [] } + ] } + ] } + ] +}"#, + ) + .unwrap(); let store = Store::load(temp.path()).expect("加载应当成功"); let err = store.verify().unwrap_err(); assert!(err.to_string().contains("两级"), "{err}"); } - - #[test] - fn missing_user_files_are_treated_as_empty() { - let temp = TempDir::new().unwrap(); - seed_for_test(temp.path()); - let store = Store::load(temp.path()).expect("加载失败"); - store.verify().expect("自检失败"); - assert_eq!(store.summary().user_sites, 0); - } } diff --git a/docs/backend-site-schema.md b/docs/backend-site-schema.md index a33ce87..d4e1b60 100644 --- a/docs/backend-site-schema.md +++ b/docs/backend-site-schema.md @@ -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 启动时的自检 diff --git a/docs/frontend-architecture.md b/docs/frontend-architecture.md index d2b696e..c795c68 100644 --- a/docs/frontend-architecture.md +++ b/docs/frontend-architecture.md @@ -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. 后续演进建议 diff --git a/docs/submit-site-form.md b/docs/submit-site-form.md deleted file mode 100644 index 60d4a2a..0000000 --- a/docs/submit-site-form.md +++ /dev/null @@ -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`) | 复用主题里已经配好的提示条,不新增一套浮层 | diff --git a/frontend/README.md b/frontend/README.md index ef998da..7b70655 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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` 里映射成前端模型:分组变成侧栏导航, **只有挂着网站的分组才进内容区**(空分组留在侧栏,点了没有对应内容——和原型一致), diff --git a/frontend/lib/core/constants/app_sizes.dart b/frontend/lib/core/constants/app_sizes.dart index 121fb69..7477c52 100644 --- a/frontend/lib/core/constants/app_sizes.dart +++ b/frontend/lib/core/constants/app_sizes.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`)。 diff --git a/frontend/lib/features/home/data/http_home_repository.dart b/frontend/lib/features/home/data/http_home_repository.dart index 2e0fd62..ff8d3ec 100644 --- a/frontend/lib/features/home/data/http_home_repository.dart +++ b/frontend/lib/features/home/data/http_home_repository.dart @@ -149,6 +149,7 @@ HomeData _toHomeData(List groups, Map siteMeta) { searchHint: siteMeta['searchHint'] as String? ?? _fallbackSearchHint, navNodes: navNodes, sections: sections, + submitUrl: siteMeta['submitUrl'] as String? ?? '', ); } diff --git a/frontend/lib/features/home/data/mock_home_data.dart b/frontend/lib/features/home/data/mock_home_data.dart index 05d8ce7..ee54cf1 100644 --- a/frontend/lib/features/home/data/mock_home_data.dart +++ b/frontend/lib/features/home/data/mock_home_data.dart @@ -80,4 +80,5 @@ const HomeData mockHomeData = HomeData( searchHint: '输入关键字搜索全网资源...', navNodes: mockNavNodes, sections: mockLinkSections, + submitUrl: 'https://mattermost.example.com', ); diff --git a/frontend/lib/features/home/domain/models/home_data.dart b/frontend/lib/features/home/domain/models/home_data.dart index 272621f..0d7ec03 100644 --- a/frontend/lib/features/home/domain/models/home_data.dart +++ b/frontend/lib/features/home/domain/models/home_data.dart @@ -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 navNodes; diff --git a/frontend/lib/features/home/presentation/pages/home_page.dart b/frontend/lib/features/home/presentation/pages/home_page.dart index a90a999..b61fcf0 100644 --- a/frontend/lib/features/home/presentation/pages/home_page.dart +++ b/frontend/lib/features/home/presentation/pages/home_page.dart @@ -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 { void _openItem(LinkItem item) => widget.urlOpener.open(item.url); - /// 打开「提交网站」表单;提交成功后静默刷新一次,让新站点立刻出现在侧栏里。 - Future _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 _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 _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 nodes, - String categoryId, - ) { - for (final NavNode root in nodes) { - if (root.id == categoryId) { - return (group: root.title, subGroup: ''); - } - for (final NavNode child in root.children) { - if (child.id == categoryId) { - return (group: root.title, subGroup: child.title); - } - } + /// 地址在 `data/nav.json` 的 `site.submitUrl`;没配就什么都不做。 + void _openSubmitUrl(HomeData data) { + if (data.submitUrl.isEmpty) { + return; } - return null; + widget.urlOpener.open(data.submitUrl); } void _backToTop() { @@ -293,7 +203,7 @@ class _HomePageState extends State { 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 { key: _sectionKeys[section.id], section: section, onOpen: _openItem, - onEdit: (LinkItem item) => - unawaited(_openEditDialog(data, item)), - onDelete: (LinkItem item) => unawaited(_confirmDelete(item)), ), ), ], diff --git a/frontend/lib/features/home/presentation/widgets/confirm_dialog.dart b/frontend/lib/features/home/presentation/widgets/confirm_dialog.dart deleted file mode 100644 index ecc3362..0000000 --- a/frontend/lib/features/home/presentation/widgets/confirm_dialog.dart +++ /dev/null @@ -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 showConfirmDialog({ - required BuildContext context, - required String title, - required String message, - required String confirmLabel, - String cancelLabel = '取消', - bool danger = false, -}) async { - final bool? confirmed = await showDialog( - 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: [ - 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: [ - 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), - ), - ], - ), - ], - ), - ), - ); - } -} diff --git a/frontend/lib/features/home/presentation/widgets/dialog_buttons.dart b/frontend/lib/features/home/presentation/widgets/dialog_buttons.dart deleted file mode 100644 index 7d1f4a3..0000000 --- a/frontend/lib/features/home/presentation/widgets/dialog_buttons.dart +++ /dev/null @@ -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 createState() => _DialogPrimaryButtonState(); -} - -class _DialogPrimaryButtonState extends State { - 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 createState() => _DialogGhostButtonState(); -} - -class _DialogGhostButtonState extends State { - 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, - ), - ), - ), - ), - ); - } -} diff --git a/frontend/lib/features/home/presentation/widgets/link_card.dart b/frontend/lib/features/home/presentation/widgets/link_card.dart index 3a2db14..40bace6 100644 --- a/frontend/lib/features/home/presentation/widgets/link_card.dart +++ b/frontend/lib/features/home/presentation/widgets/link_card.dart @@ -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 createState() => _LinkCardState(); } @@ -40,8 +26,6 @@ class _LinkCardState extends State { @override Widget build(BuildContext context) { - final bool canManage = widget.item.editable; - return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), @@ -69,68 +53,31 @@ class _LinkCardState extends State { ? AppColors.cardShadowHover : AppColors.cardShadow, ), - child: Stack( + child: Row( children: [ - Row( - children: [ - _CardIcon(initial: widget.item.initial), - const SizedBox(width: AppSizes.cardIconGap), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - widget.item.title, - style: AppTextStyles.cardTitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: AppSizes.cardTitleGap), - Text( - widget.item.description, - style: AppTextStyles.cardDesc, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + _CardIcon(initial: widget.item.initial), + const SizedBox(width: AppSizes.cardIconGap), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.item.title, + style: AppTextStyles.cardTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ), - ], - ), - // 悬停才露出来,且不占正文的位置:不给可修改的卡片留常驻空白, - // 也不让鼠标移上去时文字宽度跳一下。 - if (canManage) - Positioned( - right: 0, - top: 0, - child: AnimatedOpacity( - duration: AppSizes.transition, - opacity: _hovered ? 1 : 0, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - 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!, - ), - ], + const SizedBox(height: AppSizes.cardTitleGap), + Text( + widget.item.description, + style: AppTextStyles.cardDesc, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ), + ], ), + ), ], ), ), @@ -139,69 +86,6 @@ class _LinkCardState extends State { } } -/// 卡片右上角的小动作按钮(编辑 / 删除)。 -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), - ), - ), - ), - ), - ); - } -} - /// 卡片左侧的字母方块(`.card-icon`)。 class _CardIcon extends StatelessWidget { const _CardIcon({required this.initial}); diff --git a/frontend/lib/features/home/presentation/widgets/link_section_view.dart b/frontend/lib/features/home/presentation/widgets/link_section_view.dart index 39834f6..8849dcb 100644 --- a/frontend/lib/features/home/presentation/widgets/link_section_view.dart +++ b/frontend/lib/features/home/presentation/widgets/link_section_view.dart @@ -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 onOpen; - /// 编辑某个站点;为空时卡片不显示编辑入口。 - final ValueChanged? onEdit; - - /// 删除某个站点;为空时卡片不显示删除入口。 - final ValueChanged? 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)), ), ], ); diff --git a/frontend/lib/features/home/presentation/widgets/site_form_dialog.dart b/frontend/lib/features/home/presentation/widgets/site_form_dialog.dart deleted file mode 100644 index a7bdb66..0000000 --- a/frontend/lib/features/home/presentation/widgets/site_form_dialog.dart +++ /dev/null @@ -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 showSiteFormDialog({ - required BuildContext context, - required List navNodes, - required String heading, - required Future Function(SubmitSiteDraft draft) onSubmit, - SubmitSiteDraft? initial, -}) async { - final bool? submitted = await showDialog( - 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 navNodes; - - /// 对话框标题,例如「提交网站」或「编辑网站」。 - final String heading; - - /// 保存动作。抛异常时表单会留在原地并显示原因。 - final Future Function(SubmitSiteDraft draft) onSubmit; - - /// 编辑时的现有内容;新增时为 null。 - final SubmitSiteDraft? initial; - - @override - State createState() => _SiteFormDialogState(); -} - -class _SiteFormDialogState extends State { - 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 _errors = {}; - 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 get _groupSuggestions => widget.navNodes - .where((NavNode node) => node.editable) - .map((NavNode node) => node.title) - .toList(growable: false); - - /// 当前填的分组下已有的用户子分组。 - List 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 []; - } - - /// 某个字段的内容变了:清掉它上一次的错误,让用户边改边看到反馈。 - void _clearError(String field) { - if (_errors[field] == null && _serverError == null) { - return; - } - setState(() { - _errors = {..._errors, field: null}; - _serverError = null; - }); - } - - Future _submit() async { - final Map errors = { - '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: [ - _Header( - heading: widget.heading, - submitting: _submitting, - onClose: () => Navigator.of(context).pop(false), - ), - if (_serverError != null) ...[ - 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) ...[ - 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) ...[ - 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) ...[ - 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: [ - 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: [ - 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: [ - 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) ...[ - 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 values; - final bool enabled; - final ValueChanged onPick; - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - 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: [ - 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: [ - 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 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; -} diff --git a/frontend/test/features/home/presentation/home_page_test.dart b/frontend/test/features/home/presentation/home_page_test.dart index 94cad23..f94b9d6 100644 --- a/frontend/test/features/home/presentation/home_page_test.dart +++ b/frontend/test/features/home/presentation/home_page_test.dart @@ -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( - 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, ['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, ['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, ['https://mattermost.example.com']); }); }); } -/// 返回一条「用户提交」的站点(editable),并记录改删调用。 -class _MutableRepository implements HomeRepository { - int fetches = 0; - final List updated = []; - final List deleted = []; - - static const HomeData _data = HomeData( - siteName: 'Suxing Nav', - searchHint: '输入关键字搜索全网资源...', - navNodes: [NavNode(id: 'g-1', title: 'AI 工具', editable: true)], - sections: [ - LinkSection( - id: 'g-1', - title: 'AI 工具', - items: [ - LinkItem( - id: 's-1', - title: 'Hugging Face', - url: 'https://huggingface.co', - description: '开源模型与数据集社区', - categoryId: 'g-1', - editable: true, - ), - ], - ), - ], - ); - - @override - Future fetchHomeData() async { - fetches++; - return _data; - } - - @override - Future submitSite(SubmitSiteDraft draft) async {} - - @override - Future updateSite(String id, SubmitSiteDraft draft) async { - updated.add(id); - } - - @override - Future deleteSite(String id) async { - deleted.add(id); - } -} - -/// 记录取数次数与提交内容,用来验证页面的联调行为。 -class _RecordingRepository implements HomeRepository { - int fetches = 0; - final List submitted = []; - final List updated = []; - final List deleted = []; - - @override - Future fetchHomeData() async { - fetches++; - return mockHomeData; - } - - @override - Future submitSite(SubmitSiteDraft draft) async { - submitted.add(draft); - } - - @override - Future updateSite(String id, SubmitSiteDraft draft) async { - updated.add(id); - } - - @override - Future deleteSite(String id) async { - deleted.add(id); - } -} - /// 数据一直不回来,用来测加载中的那一帧。 class _PendingRepository implements HomeRepository { final Completer completer = Completer(); diff --git a/frontend/test/features/home/presentation/site_form_dialog_test.dart b/frontend/test/features/home/presentation/site_form_dialog_test.dart deleted file mode 100644 index 5c33a66..0000000 --- a/frontend/test/features/home/presentation/site_form_dialog_test.dart +++ /dev/null @@ -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 submitted = []; - String? failWith; - Completer? gate; - - @override - Future fetchHomeData() async => mockHomeData; - - @override - Future 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 updateSite(String id, SubmitSiteDraft draft) async {} - - @override - Future deleteSite(String id) async {} -} - -/// 侧栏那棵树:内置分组不可选,用户分组可选。 -const List _navNodes = [ - NavNode(id: 'common', title: '常用推荐'), - NavNode( - id: 'g-1', - title: 'AI 工具', - editable: true, - children: [ - NavNode(id: 'g-2', title: '模型社区', editable: true), - ], - ), -]; - -void main() { - late _RecordingRepository repository; - late bool? dialogResult; - - setUp(() { - repository = _RecordingRepository(); - dialogResult = null; - }); - - Future 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 fill(WidgetTester tester, int index, String value) async { - await tester.enterText(find.byType(TextField).at(index), value); - await tester.pump(); - } - - Future 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, 'an@example.com'); - } - - 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, 'an@example.com'); - 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(); - 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( - 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(find.byType(TextField).at(0)).controller!.text, - 'Hugging Face', - ); - expect( - tester.widget(find.byType(TextField).at(1)).controller!.text, - 'https://huggingface.co', - ); - expect( - tester.widget(find.byType(TextField).at(3)).controller!.text, - 'AI 工具', - ); - // 后端 PATCH 不接受 submittedBy,编辑时就不显示这个字段。 - expect(find.text('你的称呼或邮箱'), findsNothing); - // 分组已填好,它下面的子分组建议直接出来。 - // (子分组输入框里也预填了同样的文字,所以这里用 findsWidgets。) - expect(find.text('模型社区'), findsWidgets); - }); -}