diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 2927186..bd69723 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -4,3 +4,14 @@ version = "0.1.0" edition = "2024" [dependencies] +axum = "0.8" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +time = { version = "0.3", features = ["formatting"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net"] } +tower-http = { version = "0.6", features = ["cors"] } +url = "2" + +[dev-dependencies] +tempfile = "3" +tower = { version = "0.5", features = ["util"] } diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..61a9933 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,151 @@ +# Navius 后端 + +Rust + axum,数据用 JSON 文件存储,不依赖数据库。 + +网站分两种:**系统内置**(只读,随项目发布)和**提交/推送**(可改可删)。 +「不可修改」不是靠每处判断,而是靠**结构**保证:`system-*.json` 只在启动时读, +代码里没有任何写这两个文件的路径。 + +## 运行 + +```bash +cd backend +cargo run # 默认监听 127.0.0.1:8080,数据目录 ./data +cargo test # 34 个单元测试 + 接口测试 +cargo clippy --all-targets +``` + +### 环境变量 + +| 变量 | 默认值 | 说明 | +| --- | --- | --- | +| `NAVIUS_ADDR` | `127.0.0.1:8080` | 监听地址 | +| `NAVIUS_DATA_DIR` | `data` | 数据目录 | +| `NAVIUS_API_TOKEN` | 未设置 | 推送接口的令牌;不设置时 `/api/sites/push` 不可用(提交接口不受影响) | + +## 数据文件 + +``` +data/ +├── site.json # 站点名与搜索框提示,只读 +├── system-categories.json # 内置分组,只读,自己手写维护 +├── system-sites.json # 内置网站,只读 +├── user-categories.json # 提交时创建的分组,自动写 +└── user-sites.json # 提交 / 推送的网站,自动写 +``` + +启动时会自检,任何一条不通过就直接退出(避免带着坏数据继续跑): + +- `system-*.json` 里的 `source` 必须全是 `system`,`user-*.json` 里必须全不是; +- 网站 id、分组 id 不能重复; +- 每个网站的 `categoryId` 必须存在,且**内置网站只能挂在内置分组下、 + 提交的网站只能挂在用户分组下**; +- 分组的 `parentId` 必须存在、必须是一级分组(最多两级),也不能指向自己。 + +写盘用「先写临时文件再改名」,不会因为写到一半断电把文件写坏。 + +## 接口 + +### `GET /api/health` + +```json +{ "sites": 5, "systemSites": 5, "userSites": 0, "categories": 8 } +``` + +### `GET /api/nav` + +侧栏 + 内容区需要的那棵树。每个网站上都带一个推导出来的 `editable` +(`source != "system"`),前端据此决定要不要显示编辑/删除入口。 + +```json +{ + "site": { "name": "Suxing Nav", "searchHint": "输入关键字搜索全网资源..." }, + "groups": [ + { + "id": "common", "title": "常用推荐", "source": "system", "editable": false, + "sites": [ + { "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": "", "editable": false } + ], + "children": [] + } + ] +} +``` + +`site` 来自 `data/site.json`:站点名和搜索框提示都由后端提供,前端不再写死。 + +### `POST /api/sites` — 用户提交 + +**不接受 `categoryId`**,只接受分组名;后端只在「用户创建的分组」里查找或新建, +所以提交的内容不可能落进内置分组。 + +```json +{ + "title": "Hugging Face", + "url": "https://huggingface.co", + "description": "开源模型与数据集社区", + "group": "AI 工具", + "subGroup": "模型社区", + "submittedBy": "an@example.com" +} +``` + +成功返回 201 和创建出来的条目。同一父级下同名的用户分组会**复用**, +不会每提交一次就多一个同名分组。 + +### `POST /api/sites/push` — 接口推送 + +入参同上,但需要请求头 `X-Api-Token: `, +创建出来的条目 `source` 记为 `api`(id 用 `a-` 前缀)。 + +### `PATCH /api/sites/{id}` — 修改 + +只改传进来的字段,可传 `title` / `url` / `description` / `group` / `subGroup`。 +系统内置的条目返回 403;传 `group` 时同样只能在用户分组里找或建。 + +### `DELETE /api/sites/{id}` — 删除 + +系统内置的条目返回 403,删掉了返回 204。 + +### 错误响应 + +统一是 `{"error": "中文说明"}`: + +| 状态码 | 场景 | +| --- | --- | +| 400 | 入参不合法(标题为空/超长、url 非法或指向内网、缺 group 等) | +| 403 | 改删系统内置条目、推送令牌不对 | +| 404 | 找不到条目 | +| 500 | 写盘失败、推送接口未配置令牌 | + +## 字段长度限制 + +标题 60 字、描述 120 字、分组名 30 字、`submittedBy` 120 字,都按**字符**算 +(中文一个字算一个)。集中在 `src/validate.rs` 顶部,改规则只改那一处。 + +## 模块 + +``` +src/ +├── main.rs # 启动:读数据 → 自检 → 起 HTTP +├── api.rs # 路由、令牌校验、把仓库错误翻译成状态码 +├── store.rs # 内存态仓库:增删改、构建导航树、原子写盘 +├── model.rs # 落盘结构、请求入参、对外返回结构 +├── validate.rs # 校验与归一化 +├── error.rs # ApiError(→ HTTP)与 StoreError(→ 启动失败) +└── util.rs # 时间格式化与 id 生成 +``` + +## 已知取舍 + +- **跨域是全开的**(`CorsLayer::permissive`)。前端是 Flutter Web,开发时不同源; + 上线后如果只从自己的域名访问,应改成只允许那个来源。 +- **提交接口没有限流**。按需求「不需要审核、提交即上线」,公开写接口建议后面补 + 一层限流或简单的验证码。 +- **`Cargo.lock` 目前在 `.gitignore` 里**。这是可执行程序,建议把它纳入版本管理, + 保证每台机器装到的依赖版本一致。 +- 前端目前还在用本地 mock 数据,接这个接口是下一步。 diff --git a/backend/data/site.json b/backend/data/site.json new file mode 100644 index 0000000..4821e52 --- /dev/null +++ b/backend/data/site.json @@ -0,0 +1,4 @@ +{ + "name": "Suxing Nav", + "searchHint": "输入关键字搜索全网资源..." +} diff --git a/backend/data/system-categories.json b/backend/data/system-categories.json new file mode 100644 index 0000000..b301c91 --- /dev/null +++ b/backend/data/system-categories.json @@ -0,0 +1,74 @@ +[ + { + "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 new file mode 100644 index 0000000..ae9b4df --- /dev/null +++ b/backend/data/system-sites.json @@ -0,0 +1,62 @@ +[ + { + "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 new file mode 100644 index 0000000..a2cac7a --- /dev/null +++ b/backend/data/user-categories.json @@ -0,0 +1,47 @@ +[ + { + "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 new file mode 100644 index 0000000..a86ce40 --- /dev/null +++ b/backend/data/user-sites.json @@ -0,0 +1,14 @@ +[ + { + "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 new file mode 100644 index 0000000..4f156eb --- /dev/null +++ b/backend/src/api.rs @@ -0,0 +1,411 @@ +//! HTTP 接口层:路由、鉴权、把仓库错误翻译成状态码。 + +use std::sync::{Arc, Mutex}; + +use axum::extract::{Path, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::routing::{get, patch}; +use axum::{Json, Router}; +use tower_http::cors::CorsLayer; + +use crate::error::ApiError; +use crate::model::{ + CreateSiteRequest, NavResponse, Site, SiteView, Source, UpdateSiteRequest, +}; +use crate::store::{Store, Summary}; + +/// 全局状态。仓库放在互斥锁里:接口可能并发到达,而写文件必须串行。 +pub struct AppState { + pub store: Mutex, + /// 推送接口的令牌。没配置时推送接口不可用(返回 500 并说明原因)。 + pub api_token: Option, +} + +/// 组装路由。 +pub fn router(state: Arc) -> Router { + Router::new() + .route("/api/health", get(health)) + .route("/api/nav", get(nav)) + // 用户提交:公开。 + .route("/api/sites", axum::routing::post(create_submission)) + // 接口推送:需要令牌,记成 api 来源。 + .route("/api/sites/push", axum::routing::post(create_push)) + .route("/api/sites/{id}", patch(update_site).delete(delete_site)) + // 前端是 Flutter Web,开发时和后端不同源,这里放开跨域。 + // 生产环境如果只从自己的域名访问,应改成只允许那个来源。 + .layer(CorsLayer::permissive()) + .with_state(state) +} + +async fn health(State(state): State>) -> Result, ApiError> { + let store = lock(&state)?; + Ok(Json(store.summary())) +} + +async fn nav(State(state): State>) -> Result, ApiError> { + let store = lock(&state)?; + Ok(Json(store.nav())) +} + +async fn create_submission( + State(state): State>, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + create(&state, request, Source::Submission) +} + +async fn create_push( + State(state): State>, + headers: HeaderMap, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + let Some(expected) = state.api_token.as_deref() else { + return Err(ApiError::internal( + "服务端没有配置 NAVIUS_API_TOKEN,推送接口未启用", + )); + }; + let provided = headers + .get("x-api-token") + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + if !constant_time_eq(provided, expected) { + return Err(ApiError::forbidden("接口令牌不正确")); + } + create(&state, request, Source::Api) +} + +async fn update_site( + State(state): State>, + Path(id): Path, + Json(request): Json, +) -> Result, ApiError> { + let mut store = lock(&state)?; + let site = store.update(&id, &request)?; + Ok(Json(SiteView::from(site))) +} + +async fn delete_site( + State(state): State>, + Path(id): Path, +) -> Result { + let mut store = lock(&state)?; + store.delete(&id)?; + Ok(StatusCode::NO_CONTENT) +} + +fn create( + state: &AppState, + request: CreateSiteRequest, + 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)))) +} + +/// 取锁并把「锁被毒化」翻译成 500。 +/// +/// 所有仓库操作都是同步的,锁不会跨 `await`,所以这里是普通的 `std::sync::Mutex`。 +fn lock(state: &AppState) -> Result, ApiError> { + state + .store + .lock() + .map_err(|_| ApiError::internal("内部状态锁已失效,请重启服务")) +} + +/// 定长时间比较,避免用令牌的响应时间反推正确值。 +fn constant_time_eq(a: &str, b: &str) -> bool { + let (a, b) = (a.as_bytes(), b.as_bytes()); + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Method, Request}; + use tempfile::TempDir; + use tower::ServiceExt; + + use crate::store::seed_for_test; + + #[test] + fn compares_tokens_exactly() { + assert!(constant_time_eq("secret", "secret")); + assert!(!constant_time_eq("secret", "secreu")); + assert!(!constant_time_eq("secret", "secret-longer")); + assert!(!constant_time_eq("", "secret")); + assert!(constant_time_eq("", "")); + } + + /// 起一个带种子数据的路由,直接打请求,不用真的监听端口。 + fn app(temp: &TempDir, token: Option<&str>) -> Router { + seed_for_test(temp.path()); + let store = Store::load(temp.path()).expect("加载失败"); + store.verify().expect("自检失败"); + let state = Arc::new(AppState { + store: Mutex::new(store), + api_token: token.map(|value| value.to_string()), + }); + router(state) + } + + async fn call( + app: &Router, + method: Method, + uri: &str, + body: Option<&str>, + token: Option<&str>, + ) -> (StatusCode, String) { + let mut builder = Request::builder().method(method).uri(uri); + if let Some(token) = token { + builder = builder.header("x-api-token", token); + } + let request = match body { + Some(json) => builder + .header("content-type", "application/json") + .body(Body::from(json.to_string())) + .expect("构造请求失败"), + None => builder.body(Body::empty()).expect("构造请求失败"), + }; + let response = app.clone().oneshot(request).await.expect("请求失败"); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024) + .await + .expect("读响应失败"); + (status, String::from_utf8_lossy(&bytes).to_string()) + } + + fn json(text: &str) -> serde_json::Value { + serde_json::from_str(text).expect("响应不是合法 JSON") + } + + const SUBMISSION: &str = r#"{ + "title": "Hugging Face", + "url": "https://huggingface.co", + "description": "开源模型与数据集社区", + "group": "AI 工具", + "subGroup": "模型社区", + "submittedBy": "an@example.com" + }"#; + + #[tokio::test] + async fn health_and_nav_expose_seed_data() { + let temp = TempDir::new().unwrap(); + let app = app(&temp, None); + + let (status, body) = call(&app, Method::GET, "/api/health", None, None).await; + assert_eq!(status, StatusCode::OK); + let health = json(&body); + assert_eq!(health["sites"], 3); + assert_eq!(health["systemSites"], 3); + assert_eq!(health["userSites"], 0); + assert_eq!(health["categories"], 3); + + let (status, body) = call(&app, Method::GET, "/api/nav", None, None).await; + assert_eq!(status, StatusCode::OK); + let nav = json(&body); + assert_eq!(nav["site"]["name"], "Suxing Nav"); + assert_eq!(nav["site"]["searchHint"], "输入关键字搜索全网资源..."); + let groups = nav["groups"].as_array().expect("groups 应当是数组"); + assert_eq!(groups.len(), 2, "两个一级分组:{body}"); + assert_eq!(groups[0]["id"], "common"); + assert_eq!(groups[0]["sites"][0]["id"], "github"); + assert_eq!(groups[0]["sites"][0]["editable"], false, "内置条目不可编辑"); + assert_eq!(groups[1]["id"], "materials"); + assert_eq!(groups[1]["children"][0]["id"], "design"); + assert_eq!(groups[1]["children"][0]["sites"][0]["id"], "figma"); + } + + #[tokio::test] + async fn submission_creates_group_and_persists_to_disk() { + let temp = TempDir::new().unwrap(); + let app = app(&temp, None); + + let (status, body) = call(&app, Method::POST, "/api/sites", Some(SUBMISSION), None).await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + let created = json(&body); + assert!(created["id"].as_str().unwrap().starts_with("s-"), "{body}"); + assert!( + created["categoryId"].as_str().unwrap().starts_with("g-"), + "{body}" + ); + assert_eq!(created["source"], "submission"); + assert_eq!(created["editable"], true); + + // 换一个实例重新加载,数据应当还在磁盘上。 + let reloaded = Store::load(temp.path()).expect("重新加载失败"); + reloaded.verify().expect("重新加载后自检失败"); + assert_eq!(reloaded.summary().user_sites, 1); + assert_eq!(reloaded.summary().categories, 5, "多了一级 + 子分组"); + } + + #[tokio::test] + async fn same_group_name_is_reused_instead_of_duplicated() { + let temp = TempDir::new().unwrap(); + let app = app(&temp, None); + + let (_, first) = call(&app, Method::POST, "/api/sites", Some(SUBMISSION), None).await; + let (_, second) = call( + &app, + Method::POST, + "/api/sites", + Some(r#"{"title":"Ollama","url":"https://ollama.com","group":"AI 工具","subGroup":"模型社区"}"#), + None, + ) + .await; + assert_eq!( + json(&first)["categoryId"], + json(&second)["categoryId"], + "同名分组应当复用" + ); + } + + #[tokio::test] + async fn submission_never_lands_in_a_system_group() { + let temp = TempDir::new().unwrap(); + let app = app(&temp, None); + + // 故意用内置分组的名字提交。 + let (status, body) = call( + &app, + Method::POST, + "/api/sites", + Some(r#"{"title":"Fake","url":"https://fake.com","group":"素材资源","subGroup":"UI 设计"}"#), + None, + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + assert_ne!(json(&body)["categoryId"], "design", "不能落进内置子分组"); + + // 内置分组的内容没被动过。 + let (_, nav) = call(&app, Method::GET, "/api/nav", None, None).await; + let nav = json(&nav); + let materials = nav["groups"] + .as_array() + .unwrap() + .iter() + .find(|group| group["id"] == "materials") + .expect("materials 分组应当存在"); + assert_eq!(materials["children"][0]["sites"].as_array().unwrap().len(), 1); + assert_eq!(materials["children"][0]["sites"][0]["id"], "figma"); + } + + #[tokio::test] + async fn system_entries_are_read_only_over_http() { + let temp = TempDir::new().unwrap(); + let app = app(&temp, None); + + let (status, body) = call( + &app, + Method::PATCH, + "/api/sites/github", + Some(r#"{"title":"hacked"}"#), + None, + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + + let (status, body) = call(&app, Method::DELETE, "/api/sites/github", None, None).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + } + + #[tokio::test] + async fn user_sites_can_be_updated_and_deleted() { + let temp = TempDir::new().unwrap(); + let app = app(&temp, None); + + let (_, created) = call(&app, Method::POST, "/api/sites", Some(SUBMISSION), None).await; + let id = json(&created)["id"].as_str().unwrap().to_string(); + + let (status, body) = call( + &app, + Method::PATCH, + &format!("/api/sites/{id}"), + Some(r#"{"title":"新名字","url":"https://huggingface.co/models"}"#), + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(json(&body)["title"], "新名字"); + assert_eq!(json(&body)["url"], "https://huggingface.co/models"); + + let (status, _) = call(&app, Method::DELETE, &format!("/api/sites/{id}"), None, None).await; + assert_eq!(status, StatusCode::NO_CONTENT); + + let (status, body) = call(&app, Method::DELETE, &format!("/api/sites/{id}"), None, None).await; + assert_eq!(status, StatusCode::NOT_FOUND, "{body}"); + } + + #[tokio::test] + async fn bad_input_is_rejected_with_400() { + let temp = TempDir::new().unwrap(); + let app = app(&temp, None); + + for body in [ + // 标题为空 + r#"{"title":" ","url":"https://a.com","group":"G"}"#, + // 没有分组 + r#"{"title":"A","url":"https://a.com"}"#, + // 内网地址 + r#"{"title":"A","url":"http://192.168.1.1","group":"G"}"#, + // 协议不对 + r#"{"title":"A","url":"ftp://a.com","group":"G"}"#, + ] { + let (status, response) = call(&app, Method::POST, "/api/sites", Some(body), None).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body} -> {response}"); + } + } + + #[tokio::test] + async fn push_requires_the_configured_token() { + let temp = TempDir::new().unwrap(); + let app = app(&temp, Some("secret")); + let payload = r#"{"title":"Pushed","url":"https://pushed.example.com","group":"API 推送"}"#; + + let (status, body) = call(&app, Method::POST, "/api/sites/push", Some(payload), None).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + + let (status, body) = call( + &app, + Method::POST, + "/api/sites/push", + Some(payload), + Some("wrong"), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + + let (status, body) = call( + &app, + Method::POST, + "/api/sites/push", + Some(payload), + Some("secret"), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + let pushed = json(&body); + assert_eq!(pushed["source"], "api"); + assert!(pushed["id"].as_str().unwrap().starts_with("a-"), "{body}"); + } + + #[tokio::test] + async fn push_is_unavailable_without_a_token_configured() { + let temp = TempDir::new().unwrap(); + let app = app(&temp, None); + let payload = r#"{"title":"Pushed","url":"https://pushed.example.com","group":"API 推送"}"#; + + let (status, body) = + call(&app, Method::POST, "/api/sites/push", Some(payload), Some("any")).await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR, "{body}"); + assert!(body.contains("NAVIUS_API_TOKEN"), "{body}"); + } +} diff --git a/backend/src/error.rs b/backend/src/error.rs new file mode 100644 index 0000000..b2852a7 --- /dev/null +++ b/backend/src/error.rs @@ -0,0 +1,97 @@ +//! 两类错误:接口错误(直接映射成 HTTP 状态码)与存储错误(启动阶段直接退出)。 + +use axum::Json; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde_json::json; + +/// 接口层错误。`message` 是直接返回给调用方的中文说明。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ApiError { + /// 入参不合法,400。 + BadRequest(String), + /// 想改系统内置的内容,403。 + Forbidden(String), + /// 找不到条目,404。 + NotFound(String), + /// 服务端自己的问题(写盘失败等),500。 + Internal(String), +} + +impl ApiError { + pub fn bad_request(message: impl Into) -> Self { + Self::BadRequest(message.into()) + } + + pub fn forbidden(message: impl Into) -> Self { + Self::Forbidden(message.into()) + } + + pub fn not_found(message: impl Into) -> Self { + Self::NotFound(message.into()) + } + + pub fn internal(message: impl Into) -> Self { + Self::Internal(message.into()) + } + + pub fn message(&self) -> &str { + match self { + Self::BadRequest(m) + | Self::Forbidden(m) + | Self::NotFound(m) + | Self::Internal(m) => m, + } + } + + pub fn status(&self) -> StatusCode { + match self { + Self::BadRequest(_) => StatusCode::BAD_REQUEST, + Self::Forbidden(_) => StatusCode::FORBIDDEN, + Self::NotFound(_) => StatusCode::NOT_FOUND, + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +impl std::fmt::Display for ApiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message()) + } +} + +impl std::error::Error for ApiError {} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let status = self.status(); + (status, Json(json!({ "error": self.message() }))).into_response() + } +} + +/// 加载与写盘阶段的错误。这些错误不会变成 HTTP 响应,而是让进程直接退出: +/// 数据文件读不出来或者自检不通过,服务继续跑只会写出更乱的数据。 +#[derive(Debug)] +pub enum StoreError { + Io(String), + Json(String), + Integrity(String), +} + +impl std::fmt::Display for StoreError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(m) => write!(f, "读取/写入失败:{m}"), + Self::Json(m) => write!(f, "JSON 解析失败:{m}"), + Self::Integrity(m) => write!(f, "数据自检不通过:{m}"), + } + } +} + +impl std::error::Error for StoreError {} + +impl From for ApiError { + fn from(value: StoreError) -> Self { + ApiError::Internal(value.to_string()) + } +} diff --git a/backend/src/main.rs b/backend/src/main.rs index e7a11a9..3ea3bfd 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,3 +1,58 @@ -fn main() { - println!("Hello, world!"); +//! Navius 后端:JSON 文件存储的导航站服务。 +//! +//! 启动流程:读数据目录 → 自检 → 起 HTTP。 +//! 自检不过直接退出:数据文件坏了还继续跑,只会把更乱的数据写回去。 + +mod api; +mod error; +mod model; +mod store; +mod util; +mod validate; + +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use store::Store; + +#[tokio::main] +async fn main() { + let data_dir = std::env::var("NAVIUS_DATA_DIR").unwrap_or_else(|_| "data".to_string()); + let addr = std::env::var("NAVIUS_ADDR").unwrap_or_else(|_| "127.0.0.1:8080".to_string()); + let api_token = std::env::var("NAVIUS_API_TOKEN") + .ok() + .filter(|token| !token.is_empty()); + + if let Err(error) = run(PathBuf::from(data_dir), addr, api_token).await { + eprintln!("启动失败:{error}"); + std::process::exit(1); + } +} + +async fn run( + data_dir: PathBuf, + addr: String, + api_token: Option, +) -> Result<(), Box> { + let store = Store::load(&data_dir)?; + store.verify()?; + + let summary = store.summary(); + println!("数据目录:{}", data_dir.display()); + println!( + "网站 {} 条(内置 {} / 提交 {}),分组 {} 个", + summary.sites, summary.system_sites, summary.user_sites, summary.categories + ); + if api_token.is_none() { + println!("未设置 NAVIUS_API_TOKEN,/api/sites/push 不可用(提交接口不受影响)"); + } + + let state = Arc::new(api::AppState { + store: Mutex::new(store), + api_token, + }); + let listener = tokio::net::TcpListener::bind(&addr).await?; + println!("监听 http://{addr}"); + axum::serve(listener, api::router(state)).await?; + Ok(()) } diff --git a/backend/src/model.rs b/backend/src/model.rs new file mode 100644 index 0000000..0930d75 --- /dev/null +++ b/backend/src/model.rs @@ -0,0 +1,192 @@ +//! 落盘数据结构与对外返回结构。 + +use serde::{Deserialize, Serialize}; + +/// 数据来源。**唯一决定条目能不能被修改的字段。** +/// +/// 不额外存 `editable`:存两份状态迟早会不一致,比如手改 JSON 把 `source` +/// 改成 `system`、`editable` 却还是 `true`。对外返回时按这里推导。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Source { + /// 系统内置:随项目发布,只读,只有项目维护者手改 JSON 能改。 + System, + /// 用户提交。 + Submission, + /// 第三方通过接口推送。 + Api, +} + +impl Source { + /// 系统内置的条目不可修改。 + pub fn is_system(self) -> bool { + matches!(self, Source::System) + } +} + +/// 一个网站条目。 +/// +/// 字段名在 JSON 里是 camelCase(和前端 Dart 的 `fromJson` 一致), +/// 以后新增字段一律加 `#[serde(default)]`,老 JSON 文件仍然能读,不必写迁移。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Site { + /// 唯一标识,也是接口路径里的 id。 + pub id: String, + /// 显示名称。前端取它的首字当图标。 + pub title: String, + /// 跳转地址。 + pub url: String, + #[serde(default)] + pub description: String, + /// 所属分组或子分组的 id。 + pub category_id: String, + pub source: Source, + /// 同一分组内的排序值,小的在前。 + #[serde(default)] + pub order: i32, + pub created_at: String, + pub updated_at: String, + /// 提交来源标识;系统内置为空。 + #[serde(default)] + pub submitted_by: String, +} + +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 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, +} + +impl Category { + /// 是不是一级分组。 + pub fn is_root(&self) -> bool { + self.parent_id.is_empty() + } + + /// 能不能被接口改动(提交时新建的分组才能被后续提交复用,内置分组只看不改)。 + pub fn is_editable(&self) -> bool { + !self.source.is_system() + } +} + +/// 对外返回的网站:在落盘结构上补一个推导出来的 `editable`。 +/// +/// 这里存一份 `Site` 的拷贝而不是借用:接口层需要先把互斥锁放开、 +/// 再去序列化响应,借用会让生命周期绑死在锁上。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SiteView { + #[serde(flatten)] + pub site: Site, + /// 由 `source` 推导,不落盘。前端据此决定要不要显示编辑/删除入口。 + 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 { + pub id: String, + pub title: String, + pub source: Source, + pub editable: bool, + pub sites: Vec, + pub children: Vec, +} + +/// `GET /api/nav` 的响应体。 +#[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`**:提交方只能给分组名,由后端在「用户创建的分组」里 +/// 查找或新建,这样提交的内容不可能落进内置分组。 +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateSiteRequest { + // 下面三个字段也用 `default`:缺字段时不要让 axum 抛 422 和英文错误, + // 交给 `validate` 给出统一的中文 400 说明。 + #[serde(default)] + pub title: String, + #[serde(default)] + pub url: String, + #[serde(default)] + pub description: String, + /// 一级分组名。 + #[serde(default)] + pub group: String, + /// 子分组名,可选;给了就挂到两级下面。 + #[serde(default)] + pub sub_group: String, + #[serde(default)] + pub submitted_by: String, +} + +/// 修改一个网站的入参,只改传进来的字段。 +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSiteRequest { + #[serde(default)] + pub title: Option, + #[serde(default)] + pub url: Option, + #[serde(default)] + pub description: Option, + /// 给了就表示要把条目挪到(或挪进)这个用户分组。 + #[serde(default)] + pub group: Option, + #[serde(default)] + pub sub_group: Option, +} diff --git a/backend/src/store.rs b/backend/src/store.rs new file mode 100644 index 0000000..a4dd1d2 --- /dev/null +++ b/backend/src/store.rs @@ -0,0 +1,828 @@ +//! 数据仓库:启动时把四个 JSON 文件读进内存,之后读内存、写文件。 +//! +//! 「系统内置不可修改」是靠结构保证的:`system-*.json` 只在 `load` 里被读, +//! 模块里根本没有写这两个文件的函数,任何接口都改不到它们。 + +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use serde::de::DeserializeOwned; + +use crate::error::{ApiError, StoreError}; +use crate::model::{ + Category, CreateSiteRequest, NavGroupView, NavResponse, Site, SiteMeta, SiteView, Source, + UpdateSiteRequest, +}; +use crate::util::{generate_id, now_iso8601}; +use crate::validate::{self, SiteDraft, 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"), + } + } +} + +/// 内存态数据仓库。 +pub struct Store { + paths: DataPaths, + site: SiteMeta, + system_categories: Vec, + system_sites: Vec, + user_categories: Vec, + user_sites: Vec, +} + +impl Store { + /// 从数据目录读取全部数据。系统文件必须存在;用户文件缺失时当空处理, + /// 这样第一次跑起来只需要带上种子数据。 + 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, + }) + } + + /// 启动自检。不通过就直接让进程退出——数据文件坏了还继续跑, + /// 只会把更乱的数据写回去。 + pub fn verify(&self) -> Result<(), StoreError> { + // 0. 站点配置是必需的:站点名不能空。 + if self.site.name.trim().is_empty() { + return Err(StoreError::Integrity( + "site.json 里的 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 + ))); + } + } + 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(), + } + } + + /// 侧栏 + 内容区需要的那棵树。 + 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(), + } + } + + /// 新增一个提交 / 推送的网站。 + pub fn create( + &mut self, + request: &CreateSiteRequest, + source: Source, + ) -> 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 now = now_iso8601(); + let site = Site { + id: new_id(site_prefix(source), &sites), + title: draft.title, + url: draft.url, + description: draft.description, + category_id, + source, + order, + created_at: now.clone(), + updated_at: now, + submitted_by: draft.submitted_by, + }; + 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) + } + + /// 修改一个网站。系统内置的直接 403。 + 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}」是系统内置网站,不能修改" + ))); + } + return Err(ApiError::not_found(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 { + let sub = patch.sub_group.clone().unwrap_or_default(); + Some(resolve_named_group(&mut categories, group, &sub)?) + } else if let Some(sub) = &patch.sub_group { + if sub.is_empty() { + None + } else { + // 只给子分组:沿用条目当前所属的一级分组。 + let root = root_title_of(&categories, &sites[index].category_id) + .ok_or_else(|| ApiError::internal("当前条目的一级分组找不到"))?; + Some(resolve_named_group(&mut categories, &root, sub)?) + } + } else { + None + }; + + // 排序值要在拿到 `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; + } + site.updated_at = now_iso8601(); + let updated = 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(updated) + } + + /// 删除一个网站。系统内置的直接 403。 + pub fn delete(&mut self, id: &str) -> Result<(), ApiError> { + if self.system_sites.iter().any(|s| s.id == id) { + 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; + 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)] +#[serde(rename_all = "camelCase")] +pub struct Summary { + pub sites: usize, + pub system_sites: usize, + pub user_sites: usize, + 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 resolve_named_group( + categories: &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, "")?, + }; + if sub_group.is_empty() { + return Ok(root_id); + } + match categories + .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), + } +} + +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; + let now = now_iso8601(); + categories.push(Category { + id: id.clone(), + title: title.to_string(), + parent_id: parent_id.to_string(), + source: Source::Submission, + order, + created_at: now.clone(), + updated_at: now, + }); + Ok(id) +} + +fn apply_patch(site: &mut Site, patch: SitePatch) { + if let Some(value) = patch.title { + site.title = value; + } + if let Some(value) = patch.url { + site.url = value; + } + if let Some(value) = patch.description { + site.description = value; + } +} + +/// 找出某个分组所属的一级分组标题。 +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", + Source::Api => "a", + // 系统内置的 id 是手写的,不会走到这里。 + Source::System => "sys", + } +} + +/// 生成一个当前集合里没用过的 id。 +fn new_id(prefix: &str, existing: &[T]) -> String { + for _ in 0..8 { + let candidate = generate_id(prefix); + if !existing.iter().any(|item| item.id() == candidate) { + return candidate; + } + } + // 理论上到不了这里;真撞上了就退化成带纳秒的 id,保证唯一。 + 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| { + 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| { + a.order + .cmp(&b.order) + .then_with(|| a.created_at.cmp(&b.created_at)) + .then_with(|| a.id.cmp(&b.id)) + }); +} + +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}"))); + } + } + Ok(()) +} + +/// 读一个 JSON 数组。`required` 为 false 时,文件不存在按空数组处理。 +fn read_list(path: &Path, required: bool) -> Result, StoreError> { + 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()))); + } + } + 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()))) +} + +/// 原子写:先写临时文件再改名,避免写到一半断电把数据文件写坏。 +fn write_list(path: &Path, value: &[T]) -> 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()))?; + text.push('\n'); + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, text).map_err(|e| StoreError::Io(format!("{}:{e}", tmp.display())))?; + fs::rename(&tmp, path).map_err(|e| StoreError::Io(format!("{}:{e}", path.display())))?; + Ok(()) +} + +#[cfg(test)] +/// 造一份最小的种子数据,供单元测试与接口测试共用。 +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": "" } +]"#, + ) + .expect("写测试数据失败"); +} + +#[cfg(test)] +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("加载失败"); + store.verify().expect("自检失败"); + store + } + + fn submission(title: &str, url: &str, group: &str, sub_group: &str) -> CreateSiteRequest { + CreateSiteRequest { + title: title.to_string(), + url: url.to_string(), + description: "一句话描述".to_string(), + group: group.to_string(), + sub_group: sub_group.to_string(), + submitted_by: "an@example.com".to_string(), + } + } + + #[test] + fn loads_seed_data_and_builds_nav_tree() { + let temp = TempDir::new().unwrap(); + let store = store_in(&temp); + + let nav = store.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[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, "内置条目不可编辑"); + } + + #[test] + fn submission_creates_its_own_group_and_lands_there() { + let temp = TempDir::new().unwrap(); + let mut store = store_in(&temp); + + let site = store + .create(&submission("Hugging Face", "https://huggingface.co", "AI 工具", "模型社区"), Source::Submission) + .expect("提交应当成功"); + + 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 reloaded = Store::load(temp.path()).expect("重新加载失败"); + reloaded.verify().expect("重新加载后自检失败"); + assert_eq!(reloaded.summary().user_sites, 1); + } + + #[test] + fn same_group_name_is_reused() { + 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(); + + assert_eq!(first.category_id, second.category_id, "同名分组应当复用"); + assert_eq!(store.summary().categories, 5, "只多了一级 + 子分组两个"); + } + + #[test] + fn submission_can_never_land_in_a_system_group() { + let temp = TempDir::new().unwrap(); + let mut store = store_in(&temp); + + // 故意用内置分组的名字提交。 + let site = store + .create(&submission("Fake", "https://fake.com", "素材资源", "UI 设计"), Source::Submission) + .unwrap(); + + 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"); + } + + #[test] + fn system_sites_cannot_be_updated_or_deleted() { + let temp = TempDir::new().unwrap(); + let mut store = store_in(&temp); + + let update = UpdateSiteRequest { + title: Some("改个名字".to_string()), + ..Default::default() + }; + 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); + } + + #[test] + fn user_sites_can_be_updated_and_deleted() { + let temp = TempDir::new().unwrap(); + let mut store = store_in(&temp); + let site = store + .create(&submission("A", "https://a.com", "AI 工具", ""), Source::Submission) + .unwrap(); + + let update = UpdateSiteRequest { + title: Some("新名字".to_string()), + 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 就不换分组"); + + store.delete(&site.id).unwrap(); + assert_eq!(store.summary().user_sites, 0); + + let err = store.delete(&site.id).unwrap_err(); + assert_eq!(err.status(), axum::http::StatusCode::NOT_FOUND); + } + + #[test] + fn update_can_move_a_site_between_user_groups() { + let temp = TempDir::new().unwrap(); + let mut store = store_in(&temp); + let site = store + .create(&submission("A", "https://a.com", "AI 工具", ""), Source::Submission) + .unwrap(); + + let update = UpdateSiteRequest { + group: Some("效率工具".to_string()), + sub_group: Some("笔记".to_string()), + ..Default::default() + }; + let updated = store.update(&site.id, &update).unwrap(); + assert_ne!(updated.category_id, site.category_id); + + let nav = store.nav(); + let moved = nav + .groups + .iter() + .find(|g| g.title == "效率工具") + .expect("新分组应当存在"); + assert_eq!(moved.children[0].title, "笔记"); + assert_eq!(moved.children[0].sites[0].site.id, site.id); + } + + #[test] + fn verify_rejects_a_tampered_file() { + 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}"); + } + + #[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}"); + } + + #[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"), "[]"); + 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/backend/src/util.rs b/backend/src/util.rs new file mode 100644 index 0000000..9c59af8 --- /dev/null +++ b/backend/src/util.rs @@ -0,0 +1,91 @@ +//! 与业务无关的小工具:时间格式化与 id 生成。 + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; + +/// 进程内自增计数,用来保证同一纳秒内生成的 id 也不重复。 +static COUNTER: AtomicU64 = AtomicU64::new(0); + +/// 当前时间的 ISO 8601 UTC 字符串,例如 `2026-09-19T06:02:11Z`。 +/// +/// 统一用 UTC:客户端拿到的时刻永远无歧义,展示成哪个时区是前端的事。 +/// 截掉秒以下的小数位,和手写的种子数据保持同一种格式。 +pub fn now_iso8601() -> String { + let now = OffsetDateTime::now_utc(); + // `replace_nanosecond` 返回 Result(纳秒可能越界),0 一定合法,取不到就用原值。 + let now = now.replace_nanosecond(0).unwrap_or(now); + now.format(&Rfc3339) + .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()) +} + +/// 今天的日期,格式 `20260919`,只用来拼可读的 id。 +pub fn today_compact() -> String { + let today = now_iso8601(); + today.get(0..10).unwrap_or("1970-01-01").replace('-', "") +} + +/// 生成 `-<日期>-<6 位十六进制>` 形式的 id。 +/// +/// 日期段让人一眼看出创建时间;随机段用「纳秒 + 自增计数」混合后取低 24 位, +/// 不引第三方随机数库,也不依赖操作系统的随机源。 +pub fn generate_id(prefix: &str) -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0); + let counter = COUNTER.fetch_add(0x9E37_79B9_7F4A_7C15, Ordering::Relaxed); + let mixed = splitmix64(nanos ^ counter); + format!("{prefix}-{}-{:06x}", today_compact(), mixed & 0xFF_FFFF) +} + +/// splitmix64:把低位不稳定的输入(纳秒)打散,让取出来的后 24 位分布更均匀。 +fn splitmix64(seed: u64) -> u64 { + let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + +/// 字符数(不是字节数)。 +/// +/// 用 `chars()` 而不是 `len()`:中文一个字是 3 个字节,按字节数判断长度会误判。 +pub fn char_count(value: &str) -> usize { + value.trim().chars().count() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn now_iso8601_uses_utc_and_seconds_precision() { + let now = now_iso8601(); + assert!(now.ends_with('Z'), "should end with Z: {now}"); + assert_eq!(now.len(), 20, "should look like 2026-09-19T06:02:11Z: {now}"); + } + + #[test] + fn today_compact_has_no_separators() { + let today = today_compact(); + assert_eq!(today.len(), 8, "should look like 20260919: {today}"); + assert!(today.chars().all(|c| c.is_ascii_digit()), "{today}"); + } + + #[test] + fn generate_id_is_prefixed_and_unique() { + let first = generate_id("s"); + let second = generate_id("s"); + assert!(first.starts_with("s-"), "{first}"); + assert_eq!(first.len(), 17, "s-20260919-8f3a1c: {first}"); + assert_ne!(first, second, "连续两次生成不应该相同"); + } + + #[test] + fn char_count_counts_chars_not_bytes() { + assert_eq!(char_count(" 知乎 "), 2); + assert_eq!(char_count("abc"), 3); + assert_eq!(char_count(" "), 0); + } +} diff --git a/backend/src/validate.rs b/backend/src/validate.rs new file mode 100644 index 0000000..18d1f32 --- /dev/null +++ b/backend/src/validate.rs @@ -0,0 +1,299 @@ +//! 写入前的校验与归一化。 +//! +//! 所有外部写入(用户提交、接口推送、修改)都必须先过这里, +//! 保证进内存的数据是干净的:空白已去掉、长度有上限、url 可用。 + +use std::net::IpAddr; + +use url::Url; + +use crate::error::ApiError; +use crate::model::{CreateSiteRequest, UpdateSiteRequest}; +use crate::util::char_count; + +/// 字段长度上限,集中放在这里,改规则只改这一处。 +const MAX_TITLE_CHARS: usize = 60; +const MAX_DESCRIPTION_CHARS: usize = 120; +const MAX_GROUP_CHARS: usize = 30; +const MAX_SUBMITTED_BY_CHARS: usize = 120; + +/// 校验并归一化之后的提交内容。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SiteDraft { + pub title: String, + pub url: String, + pub description: String, + pub group: String, + pub sub_group: String, + pub submitted_by: String, +} + +/// 校验并归一化之后的修改内容;`None` 表示这个字段不改。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SitePatch { + pub title: Option, + pub url: Option, + pub description: Option, + pub group: Option, + pub sub_group: Option, +} + +impl SitePatch { + /// 这次修改是不是什么都没带。 + pub fn is_empty(&self) -> bool { + self.title.is_none() + && self.url.is_none() + && self.description.is_none() + && self.group.is_none() + && self.sub_group.is_none() + } +} + +/// 校验提交 / 推送的入参。 +pub fn submission(request: &CreateSiteRequest) -> Result { + Ok(SiteDraft { + title: title(&request.title)?, + url: url(&request.url)?, + description: description(&request.description)?, + group: group(&request.group)?, + sub_group: optional_group("subGroup", &request.sub_group)?, + submitted_by: submitted_by(&request.submitted_by)?, + }) +} + +/// 校验修改的入参。 +pub fn patch(request: &UpdateSiteRequest) -> Result { + let mut result = SitePatch::default(); + if let Some(value) = &request.title { + result.title = Some(title(value)?); + } + if let Some(value) = &request.url { + result.url = Some(url(value)?); + } + if let Some(value) = &request.description { + result.description = Some(description(value)?); + } + if let Some(value) = &request.group { + result.group = Some(group(value)?); + } + if let Some(value) = &request.sub_group { + result.sub_group = Some(optional_group("subGroup", value)?); + } + if result.is_empty() { + return Err(ApiError::bad_request( + "请求里没有任何可修改的字段:title / url / description / group / subGroup 至少要给一个", + )); + } + Ok(result) +} + +fn title(raw: &str) -> Result { + let value = raw.trim(); + if value.is_empty() { + return Err(ApiError::bad_request("title 不能为空")); + } + if char_count(value) > MAX_TITLE_CHARS { + return Err(ApiError::bad_request(format!( + "title 最多 {MAX_TITLE_CHARS} 个字符,收到 {} 个", + char_count(value) + ))); + } + Ok(value.to_string()) +} + +fn description(raw: &str) -> Result { + let value = raw.trim(); + if char_count(value) > MAX_DESCRIPTION_CHARS { + return Err(ApiError::bad_request(format!( + "description 最多 {MAX_DESCRIPTION_CHARS} 个字符,收到 {} 个", + char_count(value) + ))); + } + Ok(value.to_string()) +} + +fn group(raw: &str) -> Result { + let value = raw.trim(); + if value.is_empty() { + return Err(ApiError::bad_request( + "group 不能为空:提交的网站必须自带一个分组名,不能放进内置分组", + )); + } + if char_count(value) > MAX_GROUP_CHARS { + return Err(ApiError::bad_request(format!( + "group 最多 {MAX_GROUP_CHARS} 个字符,收到 {} 个", + char_count(value) + ))); + } + Ok(value.to_string()) +} + +fn optional_group(field: &str, raw: &str) -> Result { + let value = raw.trim(); + if char_count(value) > MAX_GROUP_CHARS { + return Err(ApiError::bad_request(format!( + "{field} 最多 {MAX_GROUP_CHARS} 个字符,收到 {} 个", + char_count(value) + ))); + } + Ok(value.to_string()) +} + +fn submitted_by(raw: &str) -> Result { + let value = raw.trim(); + if char_count(value) > MAX_SUBMITTED_BY_CHARS { + return Err(ApiError::bad_request(format!( + "submittedBy 最多 {MAX_SUBMITTED_BY_CHARS} 个字符" + ))); + } + Ok(value.to_string()) +} + +/// 地址必须是可解析的 http(s),且不能指向本机或内网。 +/// +/// 后端并不会去抓这个地址,这条规则是为了保持收录的数据干净: +/// 内网地址、localhost 对访客没有意义,出现在导航站里只会是坏链接。 +fn url(raw: &str) -> Result { + let value = raw.trim(); + let parsed = Url::parse(value) + .map_err(|_| ApiError::bad_request(format!("url 不是合法地址:{value}")))?; + match parsed.scheme() { + "http" | "https" => {} + other => { + return Err(ApiError::bad_request(format!( + "url 协议只能是 http 或 https,收到 {other}" + ))); + } + } + let host = parsed + .host_str() + .ok_or_else(|| ApiError::bad_request("url 缺少主机名"))?; + if is_internal_host(host) { + return Err(ApiError::bad_request(format!( + "url 不能是本机或内网地址:{host}" + ))); + } + Ok(value.to_string()) +} + +fn is_internal_host(host: &str) -> bool { + let host = host.to_ascii_lowercase(); + if host == "localhost" || host.ends_with(".localhost") || host.ends_with(".local") { + return true; + } + // IPv6 的 host_str() 带方括号(例如 `[::1]`),去掉后才能当 IP 解析。 + let bare = host + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .unwrap_or(&host); + match bare.parse::() { + Ok(IpAddr::V4(v4)) => { + v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified() + } + Ok(IpAddr::V6(v6)) => v6.is_loopback() || v6.is_unspecified(), + Err(_) => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request(title: &str, url: &str, group: &str) -> CreateSiteRequest { + CreateSiteRequest { + title: title.to_string(), + url: url.to_string(), + description: String::new(), + group: group.to_string(), + sub_group: String::new(), + submitted_by: String::new(), + } + } + + #[test] + fn accepts_normal_submission_and_trims() { + let draft = submission(&request(" Hugging Face ", " https://huggingface.co ", " AI 工具 ")) + .expect("应当通过"); + assert_eq!(draft.title, "Hugging Face"); + assert_eq!(draft.url, "https://huggingface.co"); + assert_eq!(draft.group, "AI 工具"); + assert_eq!(draft.sub_group, ""); + } + + #[test] + fn rejects_empty_title() { + let err = submission(&request(" ", "https://a.com", "组")).unwrap_err(); + assert_eq!(err.status(), axum::http::StatusCode::BAD_REQUEST); + assert!(err.message().contains("title")); + } + + #[test] + fn rejects_too_long_title_counting_chars() { + let long = "一".repeat(MAX_TITLE_CHARS + 1); + assert!(submission(&request(&long, "https://a.com", "组")).is_err()); + let exact = "一".repeat(MAX_TITLE_CHARS); + assert!(submission(&request(&exact, "https://a.com", "组")).is_ok()); + } + + #[test] + fn rejects_empty_group() { + let err = submission(&request("标题", "https://a.com", " ")).unwrap_err(); + assert!(err.message().contains("group")); + } + + #[test] + fn rejects_non_http_scheme() { + assert!(submission(&request("标题", "ftp://a.com", "组")).is_err()); + assert!(submission(&request("标题", "javascript:alert(1)", "组")).is_err()); + assert!(submission(&request("标题", "不是地址", "组")).is_err()); + assert!(submission(&request("标题", "http://a.com", "组")).is_ok()); + } + + #[test] + fn rejects_internal_hosts() { + for url in [ + "http://localhost:8080", + "http://127.0.0.1", + "http://10.0.0.5", + "http://192.168.1.10", + "http://172.16.0.1", + "http://169.254.0.1", + "http://0.0.0.0", + "http://[::1]", + "http://nas.local", + ] { + assert!( + submission(&request("标题", url, "组")).is_err(), + "{url} 应当被拒绝" + ); + } + } + + #[test] + fn rejects_too_long_description() { + let mut req = request("标题", "https://a.com", "组"); + req.description = "字".repeat(MAX_DESCRIPTION_CHARS + 1); + assert!(submission(&req).is_err()); + } + + #[test] + fn rejects_too_long_sub_group() { + let mut req = request("标题", "https://a.com", "组"); + req.sub_group = "字".repeat(MAX_GROUP_CHARS + 1); + assert!(submission(&req).is_err()); + } + + #[test] + fn patch_requires_at_least_one_field() { + let empty = UpdateSiteRequest::default(); + assert!(patch(&empty).is_err()); + + let with_title = UpdateSiteRequest { + title: Some(" 新名字 ".to_string()), + ..Default::default() + }; + let result = patch(&with_title).expect("应当通过"); + assert_eq!(result.title.as_deref(), Some("新名字")); + assert!(result.url.is_none()); + } +} diff --git a/docs/backend-site-schema.md b/docs/backend-site-schema.md new file mode 100644 index 0000000..a33ce87 --- /dev/null +++ b/docs/backend-site-schema.md @@ -0,0 +1,219 @@ +# 后端数据结构设计:网站与分组 + +> 本文是设计稿,已按此实现。代码在 `backend/`,接口与运行方式见 +> [backend/README.md](../backend/README.md)。 + +## 1. 需求与边界 + +- 后端是 Rust,**数据用 JSON 文件存储**,不引入数据库。 +- 网站分两类: + - **系统内置**:随项目发布,只读,只有项目维护者能改。 + - **可修改**:用户提交、或第三方通过接口推送进来的,能改、能删。 +- **不需要审核**:提交即上线。 +- **图标沿用当前逻辑**:前端取名称首字渲染,后端不存图标。 +- **不统计访问量**。 +- **提交的网站不能放进内置分组**:提交时必须自带分组名(或分组 + 子分组名), + 后端只在「用户创建的分组」里找或建;内置分组只有项目维护者自己维护。 + +## 2. 一个网站条目的完整结构 + +系统内置的一条(`index3.html` 里的 GitHub): + +```json +{ + "id": "github", + "title": "GitHub", + "url": "https://github.com", + "description": "代码托管与开源社区", + "categoryId": "common", + "source": "system", + "order": 10, + "createdAt": "2026-09-13T10:29:48+08:00", + "updatedAt": "2026-09-13T10:29:48+08:00", + "submittedBy": "" +} +``` + +用户提交的一条: + +```json +{ + "id": "s-20260919-8f3a1c", + "title": "Hugging Face", + "url": "https://huggingface.co", + "description": "开源模型与数据集社区", + "categoryId": "g-20260919-4d21ab", + "source": "submission", + "order": 0, + "createdAt": "2026-09-19T14:02:11+08:00", + "updatedAt": "2026-09-19T14:02:11+08:00", + "submittedBy": "an@example.com" +} +``` + +## 3. 字段说明 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `id` | string | 是 | 唯一标识,同时是接口路径里的 id。命名规则见 3.1。 | +| `title` | string | 是 | 显示名称。前端取它的首字当图标。上限 60 字符。 | +| `url` | string | 是 | 跳转地址,必须是可解析的 `http` / `https`。 | +| `description` | string | 否 | 一句话描述,卡片副标题。上限 120 字符。 | +| `categoryId` | string | 是 | 所属分组或子分组的 id。**必须指向用户创建的分组**(见第 5 节)。 | +| `source` | enum | 是 | `system` / `submission` / `api`。它同时决定这条能不能被修改。 | +| `order` | int | 否 | 同一分组内的排序值,小的在前,默认 0。内置条目靠它固定顺序,提交进来的默认追加到末尾。 | +| `createdAt` | string | 是 | 创建时间,ISO 8601 带时区。 | +| `updatedAt` | string | 是 | 最后修改时间,ISO 8601 带时区。 | +| `submittedBy` | string | 否 | 提交来源标识:用户提交放邮箱或用户名,接口推送放调用方标识,内置为空。 | + +### 3.1 id 命名规则 + +| 来源 | 规则 | 例子 | +| --- | --- | --- | +| 系统内置网站 | 手写,语义化,小写字母 + 数字 + `-` | `github`、`can-i-use` | +| 用户提交网站 | 后端生成:`s-` + 日期 + 随机后缀 | `s-20260919-8f3a1c` | +| 接口推送网站 | 后端生成:`a-` + 日期 + 随机后缀 | `a-20260919-2b77d0` | +| 用户创建分组 | 后端生成:`g-` + 日期 + 随机后缀 | `g-20260919-4d21ab` | + +前缀分开是为了**一眼看出这条是谁写的**,也从生成规则上杜绝和内置 id 撞车。 + +### 3.2 source:唯一决定「能不能改」的字段 + +```rust +pub enum SiteSource { + System, // 系统内置,只读 + Submission, // 用户提交 + Api, // 接口推送 +} +``` + +- 只有 `source == "system"` 的条目不可修改:修改、删除接口一律返回 403。 +- 不单独存 `editable` 字段,它由 `source` 推导。存两份状态迟早不一致, + 比如手改 JSON 把 `source` 改成 `system`、`editable` 却还是 `true`。 +- 访客接口返回时附带派生的 `editable` 给前端用,不落盘。 + +## 4. 提交时为什么必须自带分组 + +分组也是同样的两套: + +```json +{ + "id": "materials", + "title": "素材资源", + "parentId": "", + "source": "system", + "order": 20, + "createdAt": "2026-09-13T10:29:48+08:00", + "updatedAt": "2026-09-13T10:29:48+08:00" +} +``` + +```json +{ + "id": "design", + "title": "UI 设计", + "parentId": "materials", + "source": "system", + "order": 10, + "createdAt": "2026-09-13T10:29:48+08:00", + "updatedAt": "2026-09-13T10:29:48+08:00" +} +``` + +- `parentId` 为空 = 一级分组;不为空 = 子分组,指向它所属的一级分组。 +- 层级上限两级,和侧栏的结构一致(`index3.html` 就是两级)。 +- `source == "system"` 的分组只看不改,由项目维护者手写 JSON 维护。 +- `source != "system"` 的分组只可能由提交产生。 + +### 4.1 提交接口的入参 + +提交方**不能指定 `categoryId`**,只能给分组名: + +```json +{ + "title": "Hugging Face", + "url": "https://huggingface.co", + "description": "开源模型与数据集社区", + "group": "AI 工具", + "subGroup": "模型社区", + "submittedBy": "an@example.com" +} +``` + +后端按这个顺序处理: + +1. 在**用户创建的分组**里按名字找一级分组 `AI 工具`;找不到就新建一个 + (`source = "submission"`,id 自动生成)。 +2. 如果带了 `subGroup`,在 `AI 工具` 下面按名字找子分组;找不到就新建。 +3. 新建网站条目,`categoryId` 指向解析出来的子分组(没带 `subGroup` 就指向一级分组)。 + +规则上: + +- 内置分组**永远不参与**第 1、2 步的查找,所以提交的内容不可能落进内置分组。 +- 同一个父级下同名的用户分组**复用**,不会每提交一次就多出一个同名分组。 + 如果希望「每次提交都新建」,把查找那一步去掉即可。 +- 修改接口同样不允许把已有条目挪进内置分组,判定标准和创建时一致。 + +## 5. 两种数据如何隔离 + +``` +backend/data/ +├── site.json # 站点名与搜索框提示,只读 +├── system-categories.json # 内置分组,只读,项目维护者手写 +├── system-sites.json # 内置网站,只读 +├── user-categories.json # 提交时创建的分组,可写 +└── user-sites.json # 提交 / 推送的网站,可写 +``` + +- 读:启动时全量加载进内存,之后都从内存读,不再碰磁盘。 +- 写:只写 `user-*.json` 两个文件。**代码里不存在写 `system-*.json` 的路径**, + 「不可修改」靠结构保证,而不是靠每处调用都记得判断。 +- 写盘用「先写临时文件再改名」,避免写到一半断电把文件写坏。 +- JSON 用 2 空格缩进、保留中文原文,方便直接看 diff。 + +### 5.1 启动时的自检 + +启动时校验一遍,不通过就直接启动失败(防止手改 JSON 越权): + +- `system-*.json` 里的条目 `source` 必须全是 `system`; + `user-*.json` 里必须全不是。 +- 每个网站的 `categoryId` 必须存在,并且**分组来源要和网站来源一致**: + 内置网站只能挂在系统分组下,提交的网站只能挂在用户分组下。 +- 分组 `parentId` 指向的分组必须存在,且不能出现两级以上、不能自己指向自己。 + +## 6. 校验规则 + +写入(用户提交 / 接口推送)时逐条校验,任何一条不过就整体拒绝并返回原因: + +| 规则 | 说明 | +| --- | --- | +| `id` 唯一 | 与已有网站都不冲突 | +| `title` 非空、≤ 60 字符 | 去掉首尾空白后判断 | +| `url` 合法 | 可解析,协议必须是 `http` / `https`,且不能是 localhost 与内网地址 | +| `description` ≤ 120 字符 | 允许为空 | +| `group` 非空、≤ 30 字符 | 没有它就没地方放,直接拒绝 | +| `subGroup` ≤ 30 字符 | 可选;给了就必须和 `group` 一起解析成两级 | +| `source` 只能是 `submission` / `api` | 外部写入不允许声称自己是 `system` | + +## 7. 和前端现有模型的关系 + +前端 `LinkItem` 目前用到 `id` / `title` / `url` / `description` 四个字段, +本次结构是它的超集,前端不改也能读出来;`categoryId`、`source`、`editable` +等字段等前端要用时再接。 + +字段名统一用 **camelCase**,和前端 Dart 的 `fromJson` 对齐,前端不用做名字转换; +Rust 侧用 `#[serde(rename_all = "camelCase")]` 映射。 + +**新加字段一律带 `#[serde(default)]`**,这样以后往结构里加字段,老 JSON 仍然能读, +不需要写迁移脚本。 + +## 8. 这次没有放进结构里的东西 + +按「先保留当前逻辑」处理,以下都不存;以后要用再加字段(加字段不需要迁移): + +| 没放的 | 原因 | 以后要加时 | +| --- | --- | --- | +| `icon` | 前端当前是取 `title` 首字渲染 | 加 `icon` 字段,存 favicon 地址或 emoji | +| `tags` | 当前侧栏和卡片都没有标签 | 加 `tags: []`,提交接口再放开这个入参 | +| `visits` | 不统计访问量 | 要么由后端跳转接口累加,要么接第三方统计 | +| `status` | 不需要审核,提交即上线 | 加 `status` 字段并给默认值 `published` | diff --git a/docs/frontend-architecture.md b/docs/frontend-architecture.md new file mode 100644 index 0000000..d2b696e --- /dev/null +++ b/docs/frontend-architecture.md @@ -0,0 +1,429 @@ +# Navius Flutter 前端结构与实现说明 + +## 1. 范围与前提 + +本文档描述 `frontend/`(Flutter Web 前端)的目录结构、分层理由,以及它如何从静态原型落地为可维护的产品代码。 + +约束条件: + +- 目标平台只有 **Web**,不做移动端 / 桌面端适配。 +- 数据来自 **Rust 后端的 `GET /api/nav`**(本地 mock 保留给测试用)。 +- **不修改 `backend/`(Rust)**,只在架构上为将来接入后端留好接口位置。 +- **视觉与交互基准是项目根目录的 `index3.html`**,逐条对齐、不自行发挥。 + +整理目标按优先级排列:**可读**(新人能顺着目录找到代码)→ **可测**(核心逻辑不依赖浏览器)→ **可扩展**(接后端时改动面最小)。 + +> 逐条核对(每一条 CSS 规则、每一段 JS 对应到哪个文件)见 +> [index3-alignment.md](./index3-alignment.md);本文讲结构与取舍。 + +## 2. 基准:根目录的 index3.html + +`index3.html` 是一份独立静态原型,也是本轮实现的唯一视觉基准: + +``` +┌──────────────┬──────────────────────────────────────────────┐ +│ Suxing Nav │ ☰ 🔍 搜索框 ← 750px 居中顶栏 │ +│ │ │ +│ 常用推荐 │ ▍常用推荐 │ +│ 素材资源 › │ ┌──────┐ ┌──────┐ ┌──────┐ │ +│ ├ UI 设计 │ │ 卡片 │ │ 卡片 │ │ 卡片 │ │ +│ ├ 图标库 │ └──────┘ └──────┘ └──────┘ │ +│ └ 高清壁纸 │ │ +│ 开发工具 › │ ▍素材资源 - UI 设计 │ +│ ├ 前端框架 │ ┌──────┐ ┌──────┐ │ +│ └ 在线 API │ └──────┘ └──────┘ │ +│ │ │ +│ ────────── │ ⬆ 回到 │ +│ 提交网站 │ 顶部 │ +└──────────────┴──────────────────────────────────────────────┘ + 230px(可折叠到 64px) +``` + +它是一份**原型**而不是成品:搜索框没有任何行为、导航的 `active` 是写死的、折叠后只剩一排空方块、导航里七个锚点有五个没有对应分区、整份文件没有任何媒体查询。这些差距在本轮实现中全部补齐,补齐内容见第 6 节。 + +## 3. 最终目录结构 + +仓库整体: + +``` +Navius/ +├── index.html # 早期静态原型(历史版本,保留作参考) +├── index3.html # 当前视觉基准(静态原型,不被构建流程引用) +├── backend/ # Rust 后端,本轮未改动 +│ ├── Cargo.toml +│ └── src/main.rs +├── docs/ # 项目级文档 +│ └── frontend-architecture.md +└── frontend/ # Flutter Web 前端 + ├── lib/ # 应用代码(见下方展开) + ├── test/ # 测试代码,目录结构与 lib/ 镜像对应 + ├── web/ # Web 平台资源(index.html、manifest、图标) + ├── android/ ios/ linux/ macos/ windows/ # Flutter 脚手架生成,Web 目标不使用 + ├── analysis_options.yaml # 静态分析与 lint 规则 + └── pubspec.yaml # 依赖与项目描述 +``` + +`lib/` 展开: + +``` +lib/ +├── main.dart # 入口:runApp 一行 +├── app.dart # 应用根组件:主题配置 + 依赖装配 +│ +├── core/ # 与业务无关的基础设施 +│ ├── constants/ +│ │ ├── app_sizes.dart # 尺寸与动效常量 +│ │ └── app_breakpoints.dart # 网格列数计算(等价 CSS auto-fill) +│ ├── services/ +│ │ ├── url_opener.dart # 打开链接的抽象接口 +│ │ └── web_url_opener.dart # Web 实现(唯一依赖 package:web 的文件) +│ ├── theme/ +│ │ ├── app_colors.dart # 颜色与阴影 +│ │ ├── app_text_styles.dart # 语义化文字样式 +│ │ └── app_theme.dart # ThemeData +│ └── utils/ +│ └── text.dart # 取首字符(中文 / emoji 安全) +│ +└── features/ # 一个功能一个目录 + └── home/ # 首页:导航站主界面 + ├── data/ + │ ├── home_repository.dart # 仓库契约(读 / 提交 / 编辑 / 删除,异步) + │ ├── http_home_repository.dart # 后端实现:四个接口 + 映射成前端模型 + │ ├── mock_home_data.dart # mock 数据(3 个一级导航 / 2 个分区 / 5 个站点) + │ └── mock_home_repository.dart # mock 仓库实现(测试用) + ├── domain/models/ + │ ├── nav_node.dart # 侧栏导航节点(叶子 / 可展开分组) + │ ├── link_item.dart # 站点条目模型(含搜索匹配规则) + │ ├── link_section.dart # 分区模型(含分区过滤) + │ └── home_data.dart # 首页聚合根(导航树 + 分区 + 跨分区搜索) + └── presentation/ + ├── pages/home_page.dart # 首页:布局、状态与交互 + └── widgets/ + ├── sidebar_nav.dart # 侧栏:折叠、层级导航、提交入口 + ├── top_bar.dart # 顶部控制栏:折叠按钮 + 搜索框 + ├── link_search_field.dart # 搜索框(含聚焦光晕) + ├── link_section_view.dart # 分区标题 + 自适应网格 + ├── link_card.dart # 站点卡片 + ├── back_to_top_button.dart # 右下角圆形回到顶部 + ├── dialog_buttons.dart # 对话框的主/次按钮 + ├── confirm_dialog.dart # 通用确认框(删除前确认) + ├── site_form_dialog.dart # 网站表单(新增 / 编辑共用) + └── ... +``` + +`test/` 展开(与 `lib/` 一一对应): + +``` +test/ +├── core/constants/app_breakpoints_test.dart # 断点与网格列数 +├── features/home/ +│ ├── data/mock_home_repository_test.dart # 导航树、数据自洽性与搜索规则 +│ └── presentation/ +│ ├── home_page_test.dart # 布局 / 折叠 / 子菜单 / 搜索 / 联动 +│ └── link_card_test.dart # 卡片渲染、点击、悬停 +└── helpers/fake_url_opener.dart # 假实现,避免测试真的弹浏览器 +``` + +## 4. 依赖方向与数据流 + +依赖方向是单向的,箭头表示「可以引用」: + +``` +main.dart → app.dart + │ + ▼ + features/home ──▶ core/ +``` + +两条硬规则: + +1. `core/` 不引用 `features/`。 +2. `domain/` 不引用 `data/` 与 `presentation/`。 + +数据流: + +``` +后端 GET /api/nav ──▶ { site: { name, searchHint }, groups: [ { …, sites: [] } ] } + │ ▲ + │ │ POST /api/sites(提交) + │ │ PATCH /api/sites/{id}(编辑) + │ │ DELETE /api/sites/{id}(删除) + ▼ │ +HttpHomeRepository ──实现──▶ HomeRepository(读 / 提交 / 编辑 / 删除) + │ 把分组树映射成前端模型:分组 → 侧栏导航;有网站的组 → 内容分区; + │ 每个网站带 editable,决定卡片上有没有编辑/删除入口 + ▼ +HomePage State:_load() 拿到 HomeData,并在加载中 / 失败(可重试)/ 完成三种状态间切换 + │ (页面持有交互状态:当前分区、侧栏折叠、已展开的分组) + ▼ +展示组件:只接收数据 + 回调,不自己取数、不自己跳转 +``` + +## 5. 设计令牌:从 CSS 到常量 + +`index3.html` 用 CSS 变量组织样式,这里逐条映射到 `core/theme` 与 `core/constants`,组件只引用语义名,不再出现十六进制色值或魔法数字。 + +| index3.html 中的来源 | 取值 | Flutter 对应 | +| --- | --- | --- | +| `--bg-color` | `#f7f9fb` | `AppColors.background` | +| `--card-bg` | `#ffffff` | `AppColors.surface` | +| `--primary-color` | `#3b82f6` | `AppColors.primary` | +| `--text-main` | `#1e293b` | `AppColors.textPrimary` | +| `--text-sub` | `#64748b` | `AppColors.textSecondary` | +| `--border-color` | `#e2e8f0` | `AppColors.border` | +| `--shadow` | `0 4px 6px -1px rgba(0,0,0,.05)` + `0 2px 4px -1px rgba(0,0,0,.03)` | `AppColors.cardShadow` | +| `--shadow-hover` | `0 10px 15px -3px rgba(0,0,0,.08)` + `0 4px 6px -2px rgba(0,0,0,.04)` | `AppColors.cardShadowHover` | +| `.nav-link.active` | `#eff6ff` | `AppColors.primaryFaint` | +| `.nav-link:hover` | `#f8fafc` | `AppColors.navItemHover` | +| `.submit-btn:hover` | `#f1f5f9` | `AppColors.navItemHoverDeep` | +| `.arrow-icon` | `#94a3b8` | `AppColors.navArrow` | +| `.card-icon` 底色 | `#f1f5f9` | `AppColors.iconSurface` | +| `.search-box:focus` 光晕 | `0 0 0 3px rgba(59,130,246,.15)` | `AppColors.focusRing` + `AppSizes.focusRingWidth` | +| `--sidebar-width` | `230px` | `AppSizes.sidebarWidth` | +| `--sidebar-collapsed-width` | `64px` | `AppSizes.sidebarCollapsedWidth` | +| `.sidebar` 内边距 | `20px 12px 12px 12px` | `sidebarPaddingTop / Horizontal / Bottom` | +| 折叠态内边距 | `20px 8px 12px 8px` | `sidebarCollapsedPaddingHorizontal` | +| 折叠动画 | `width .3s cubic-bezier(.4,0,.2,1)` | `sidebarTransition` + `Curves.fastOutSlowIn` | +| `.sidebar-header` | `height:40px; margin-bottom:24px`,logo 居中 | `sidebarHeaderHeight`、`sidebarHeaderGap` | +| `.logo` | `18px / 700` | `AppTextStyles.logo` | +| `.logo-icon` | `20px / 800` | `AppTextStyles.logoIcon` | +| `.nav-link` | `padding: 11px 16px`,圆角 8px,14px / 500 | `navItemPaddingVertical/Horizontal`、`navItemRadius`、`AppTextStyles.navItem` | +| `.nav-item-wrapper` | `margin-bottom: 4px` | `navItemGap` | +| 折叠态 `.nav-link` | `padding: 12px 0` + 居中 | `navItemCollapsedPaddingVertical` | +| `.sub-menu` | `padding-left: 16px`,`.3s` 高度动画 | `subMenuIndent`、`subMenuTransition` | +| `.sub-menu .nav-link` | `padding: 8px 16px`,13px | `subNavItemPaddingVertical/Horizontal`、`AppTextStyles.subNavItem` | +| `.sidebar-footer` | 上边框 + `padding-top:12px; margin-top:8px` | `sidebarFooterPaddingTop`、`sidebarFooterMarginTop` | +| `.submit-btn` | `padding: 10px 16px`,圆角 8px | `submitButtonPaddingVertical/Horizontal` | +| `.main-content` | `padding: 24px 40px 40px 40px` | `contentPaddingTop / Horizontal / Bottom` | +| `.top-bar` | `max-width:750px; gap:16px; margin-bottom:36px`,水平居中 | `topBarMaxWidth`、`topBarGap`、`topBarMarginBottom` | +| `.toggle-btn` | `44×44`,圆角 12px | `toggleButtonSize`、`toggleButtonRadius` | +| `.search-box` | `height:44px; padding: 0 20px`,圆角 12px,15px | `searchHeight`、`searchPaddingHorizontal`、`searchRadius`、`AppTextStyles.searchInput` | +| `.section-title` | `16px / 600`,`::before` 为 4×16 竖条 | `AppTextStyles.sectionTitle`、`sectionAccentWidth/Height/Radius/Gap` | +| 分区下间距 / 网格间距 | `36px` / `16px` | `sectionGap`、`gridGap` | +| `.grid` | `repeat(auto-fill, minmax(220px, 1fr))` | `gridMinItemWidth` + `AppBreakpoints.gridColumnsFor` | +| `.card` | 圆角 10px,内边距 `14px 16px`,悬停 `translateY(-2px)` | `cardRadius`、`cardPaddingVertical/Horizontal`、`cardHoverLift` | +| `.card-icon` | `36px`,圆角 8px,右侧间距 12px | `cardIconSize`、`cardIconRadius`、`cardIconGap` | +| `.card-title` / `.card-desc` | `14px / 600` / `12px`,单行省略 | `AppTextStyles.cardTitle` / `cardDesc` | +| `.back-to-top` | `44×44` 圆形,距右下角 `30px`,悬停 `translateY(-3px)` | `backToTopSize`、`backToTopOffset`、`backToTopHoverLift` | +| `transition: 0.2s` | 200ms | `AppSizes.transition` | + +## 6. 明确不实现的原型缺口 + +当前实现是 `index3.html` 的逐条还原,**没有补任何原型之外的行为**。原型里没做的,这里也不做: + +| 原型的状态 | 当前实现 | +| --- | --- | +| 搜索框没有任何 JS | 只接收输入,不过滤内容 | +| `active` 写死在「常用推荐」上 | 高亮固定不动 | +| 没有媒体查询 | 侧栏始终 230px(可手动折到 64px),主内容内边距始终 `24/40/40/40` | +| `#icon`、`#stock`、`#dev`、`#api`、`#submit` 没有对应元素 | 点击后没有任何反应 | +| 只有 `#common`、`#design` 两段内容 | 内容区就是两段、五张卡片 | + +唯一必须偏离 CSS 的地方是分区标题的换行:`.section-title` 是 `display:flex`, +浏览器会把过长文字折行,而 Flutter 的 `Row` 会直接溢出报错, +所以标题用 `Flexible` 包住并单行省略。这条差异会随着标题文案的调整而消失。 + +### 响应式规则 + +| 视口宽度 | 布局 | +| --- | --- | +| ≥ 900px | 侧栏默认展开(230px) | +| < 900px | 主内容内边距 40 → 20,侧栏默认折叠(64px) | + +断点取 900 而不是常见的 768:侧栏 230 + 主内容左右内边距 + 一个 220 的卡片, +再留出可读留白,900 是这套栅格开始舒展的下限。规则写成纯函数 +`AppBreakpoints.layoutFor(width)`,可以被单元测试覆盖。 + +「默认折叠」而不是「强制折叠」:用户手动切换过之后, +`_collapsedOverride` 就接管了控制权,拖窗口不会再覆盖用户的意愿。 + +### 网格列数 + +```dart +columns = floor((availableWidth + gap) / (minItemWidth + gap)) +``` + +这与 CSS 的 `repeat(auto-fill, minmax(220px, 1fr))` 等价:比如可用宽度 456 时正好放下两列(220×2 + 16),455 时退回一列。测试里有一个用例专门验证这条边界。 + +## 7. 为什么这样整理 + +### 7.1 为什么 `main.dart` 只留几行 + +入口文件的职责只有一个:把根组件挂到 Flutter 引擎上。任何额外逻辑(主题、依赖装配、路由)放进 `main.dart` 都会让「程序从哪里开始」变得模糊,也没法在测试里绕开它。 + +### 7.2 为什么是 `core` + `features`,而不是 `models/` `services/` `widgets/` + +按技术类型平铺,在项目只有一两个页面时看着整齐;页面一多就会出现「改一个功能要翻五个目录」,而且同名文件互相污染。 + +按功能切分后,`features/home/` 里的东西只服务于首页,删掉整个目录不影响其它部分。`core/` 则只放与业务无关的东西——主题、尺寸常量、断点计算、取首字符、打开链接的能力。判断标准很简单:**如果它能被原样复制到另一个 App 里,它就属于 `core`**。 + +### 7.3 为什么功能内部再分 `data / domain / presentation` + +- `domain/` 是模型与规则,不依赖 Flutter、不依赖网络,最容易测试。 +- `data/` 是「数据从哪来」:后端实现、mock 实现,以及它们共同实现的仓库契约。 +- `presentation/` 是「数据怎么显示」,包含页面与组件。 + +好处是接后端时改动落点明确:只加一个 `data/` 下的实现类,`domain/` 与 `presentation/` 基本不动。 + +### 7.4 为什么导航用一棵树(`NavNode`)而不是两个列表 + +侧栏里同时存在两种条目:直接跳转的(常用推荐)和可展开的(素材资源、开发工具)。 +如果拆成「一级列表 + 二级字典」,页面就要在两份数据之间来回对照, +「某个分区属于哪个分组」这种问题会散落在多处。 + +用同一棵树表达,`NavNode.leaves` 能把树拍平成内容区需要的顺序, +`HomeData.groupIdOf(id)` 能回答「这个分区属于哪个分组」, +`isGroup` 决定渲染成跳转项还是展开项。树是唯一真相。 + +### 7.5 为什么搜索规则写在 `domain` 而不是页面里 + +`HomeData.search()` 与 `LinkItem.matches()` 是纯函数,不依赖任何 Widget。这样「搜什么、怎么算命中、命中后空分区要不要保留」这几条规则可以直接被单元测试覆盖,不必挂载界面。放到页面里就得先 pump 一整棵组件树才能验证一条过滤规则。 + +### 7.6 为什么要有 `Repository` 接口 + `Mock` 实现 + +页面只依赖接口,不依赖具体数据源。这换来两件事:接 Rust 后端时新增一个实现类即可;测试里可以注入任意数据集,不用为「测试专用数据」改动生产代码。 + +### 7.7 为什么仓库返回 `Future` + +接上后端之前,`fetchHomeData` 是同步的:mock 数据是恒定常量,套一层 `Future` +只会凭空多出 loading 与 error 两个分支。 + +接上 `GET /api/nav` 之后这两个状态变成真实存在的——网络会慢、会断、后端会重启—— +所以契约改成 `Future`,页面里相应地有了加载中、加载失败(带重试)、 +加载完成三种状态。改动只落在 `data/` 与页面的取数那一处,模型和展示组件没动。 + +### 7.8 为什么把「打开链接」抽成 `UrlOpener` + +卡片直接调用 `web.window.open` 会让组件无法在测试里验证,也会让平台相关代码散落在 UI 里。抽象成接口后,`package:web` 只出现在 `web_url_opener.dart` 一个文件里,测试可以注入假实现,断言「点了哪张卡片、打开了哪个地址」。 + +### 7.9 为什么不做滚动联动 + +侧栏的 `active` 只由点击决定,滚动内容区不会改动侧栏。 + +曾经实现过一版「滚到哪个分区、侧栏就高亮哪一项」的联动, +结果是滚动右侧时左侧一直在闪,观感像有只鼠标在侧栏上一路划过去。 +原因有两层:一是内容区每个分区只有一百多像素高,连续滚动时高亮会 +一个接一个地亮起;二是当时的路由是「滚动监听 → 页面 `setState` +→ 整棵子树(含侧栏)重建」,为了压住这个抖动又叠了去抖、 +激活线、程序化滚动的抑制标志,越修越复杂。 + +最后直接去掉联动。理由: + +1. 左侧栏和右侧内容区没有因果关系,滚动内容区不该触发侧栏重建。 + 去掉后这一点是结构性的——`initState` 里不再给 `ScrollController` + 挂监听,滚动只重绘内容区,侧栏一个 widget 都不会重建。 +2. 这个导航站的分区标题本身就在页面上,用户不需要侧栏替他回答 + 「我在哪一节」。 +3. 原型里 `active` 本来就是静态的,去掉联动反而更接近给定设计。 + +测试里有一条用例守住这条边界:滚动到内容底部后, +断言侧栏第一行的 `AnimatedContainer` 仍是**同一个 widget 实例** +(没有重建),且高亮位置不变。 + +### 7.10 为什么折叠态显示首字,而不是照搬原型的空白 + +原型折叠后把 `.nav-text` 全部 `display:none` 且没有准备图标, +侧栏会变成一列没有信息的空方块,用户没有任何线索知道点哪个是哪个。 + +改成显示标题首字,是在不引入图标资源的前提下、与原设计语言最一致的做法—— +logo 折叠后显示的也正是站点名的首字「S」。同理,取首字用 `runes` +而不是 `substring`,中文与 emoji 才不会被截成半个字符。 + +### 7.11 为什么子菜单收起时把子项从树里移除 + +CSS 的 `max-height: 0` 只是视觉上收起了子菜单,DOM 里那些链接还在: +屏幕阅读器仍会读到,键盘 Tab 也会跳进去。Flutter 里对应的做法是用 +`AnimatedSize` 配合「关掉时不渲染子项」,既保留高度渐变的观感, +又让语义树与点击命中都保持干净。 + +### 7.12 为什么组件只发回调、状态集中在页面 + +搜索词、当前分区、侧栏折叠状态、已展开的分组都由页面持有, +组件只负责显示并向上发回调。这样同一个数据不会有两个真相来源。 +只有纯本地的、与外界无关的瞬时状态(悬停)才允许留在组件内部。 + +### 7.13 为什么把颜色、尺寸、文字样式集中 + +组件里散落十六进制颜色和魔法数字,会让「改主题色」变成全局搜索替换。现在颜色在 `app_colors.dart`、尺寸在 `app_sizes.dart`、文字样式在 `app_text_styles.dart`,组件只引用语义名。要改主色,只改一处。 + +### 7.14 为什么测试目录镜像 `lib/` + +`test/features/home/presentation/link_card_test.dart` 对应 `lib/features/home/presentation/widgets/link_card.dart`。看测试路径就能猜出被测文件的位置,避免测试文件越堆越多之后找不到归属。 + +### 7.15 为什么保留看起来「没用」的 `cupertino_icons` + +项目不使用 Cupertino 图标,但 Flutter 构建时会引用 CupertinoIcons 字体,移除依赖会导致构建出现字体缺失警告。保留模板自带的版本,是为了构建输出干净。 + +### 7.16 为什么坚持「注释写为什么,不写是什么」 + +「给变量赋值」这类注释没有信息量,代码本身就能表达。真正值得写下来的是**取舍**:为什么断点取 900、为什么点击时要关掉滚动联动、为什么折叠态用首字。 +这类信息一旦丢失,后来者很容易「好心」改回去。 + +## 8. 编码约定 + +`analysis_options.yaml` 在 `flutter_lints` 之上补充了这些规则: + +- `strict-raw-types`:禁止裸泛型(如 `List` 而非 `List`)。 +- `always_declare_return_types`:函数与方法必须显式声明返回类型。 +- `avoid_print`:禁止遗留调试输出。 +- `prefer_single_quotes`:统一单引号,减少无意义的 diff。 +- `require_trailing_commas`:配合 `dart format` 减少改动噪音。 +- `sort_child_properties_last`:`child` 固定在参数末尾。 +- `unawaited_futures`:未处理的 `Future` 必须显式处理。 + +未由 lint 强制、但代码里保持一致的几条: + +- 依赖方向由人工把关:新增共享代码前先判断它属于 `core` 还是某个功能。 +- 每个公开类与公开成员都写 `///` 文档注释,且注释解释「为什么」。 +- 组件的构造函数以 `super.key` 开头,参数按「必填数据 → 可选数据 → 回调」排列。 +- 能用 `const` 的地方一律加 `const`。 + +## 9. 验证结果 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 静态分析 | `flutter analyze` | 1 条告警(`sidebar_nav.dart` 里 `_NavRow` 的 `super.key` 从未被传入,见下方说明) | +| 单元与组件测试 | `flutter test` | 57 个用例通过,6 个失败(都是侧栏实现与既有用例不一致,见下方说明) | +| Web 构建 | `flutter build web --no-wasm-dry-run` | 构建成功 | + +> 上面这两处不是本轮对接引入的:`e2cc3ac` 那次提交重写了侧栏(行内改用 `Container`、 +> 折叠态改回显示首字、logo 换成 `AnimatedCrossFade`),而 `test/` 里那几条用例 +> 描述的是重写前的行为。对接后端时没有动侧栏,也没有改这些用例。 + +测试覆盖的关键行为: + +- 网格列数:与 CSS `auto-fill` 规则一致,含「刚好放下两列」的边界。 +- 导航树:一级条目形态、叶子 id 与原型锚点一致、`active` 取第一个条目。 +- mock 数据自洽性:只有两个分区、共五张卡片,`#icon` 等原型里没有内容的锚点取不到分区,id 唯一、字段完整、地址合法。 +- 布局:侧栏展开常驻并渲染 logo 与三条导航、子菜单默认收起、两个分区与五张卡片全部渲染。 +- 侧栏折叠:折叠到 64px 且 logo 变首字、导航与提交文字都消失、再点可展开回来。 +- 分组子菜单:点击展开、再点收起、折叠状态下点分组会先展开侧栏。 +- 导航:高亮固定写在第一个条目上;点有对应分区的导航会滚过去且高亮不变;点 `#icon` 这类没有对应元素的导航滚动偏移不变。 +- 搜索框:输入后仍是两个分区、五张卡片(不过滤)。 +- 滚动:滚动主内容不会重建侧栏、也不会改动高亮;回到顶部把偏移归零。 +- 其它:点击卡片把对应 URL 交给 `UrlOpener`;点「提交网站」没有任何反应;卡片渲染首字母、名称与描述,悬停上浮量等于 `AppSizes.cardHoverLift`。 +- 后端对接:请求打到 `/api/nav`;分组映射成侧栏导航树;只有挂着网站的分组进内容区; + 子分组标题带父级前缀;网站顺序按后端给的排;非 200、非 JSON、缺 `groups`、 + 连不上后端分别给出对应提示;缺字段的单条数据被跳过而不是整页失败。 +- 加载状态:数据没回来时显示「正在加载…」;失败时显示原因和「重试」;点重试会重新取数并在成功后渲染内容。 +- 提交表单:空表单提交会逐项提示且不发请求;内网地址被拦下;合法内容按 camelCase + 字段发给仓库;后端报错时留在表单里显示原话;提交中按钮变「提交中…」且不可再点; + 模板里只列用户分组(内置分组不出现在建议里),点建议会填进输入框。 +- 联调:点侧栏「提交网站」打开表单;提交成功后静默重新拉一次数据并弹提示。 +- 编辑 / 删除:可修改的条目才有入口;编辑表单预填现有内容且不显示提交者字段; + 删除要先确认,点取消什么都不发生;确认后调删除接口并静默刷新;内置条目没有这两个入口。 +- 站点信息:站点名与搜索框提示来自后端 `site` 字段,前端不再写死。 + +## 10. 后续演进建议 + +按优先级: + +1. **做提交网站的界面。** 后端已经有了 `POST /api/sites`(提交)与 `/api/sites/push`(推送), + 前端侧栏底部的「提交网站」目前还是个空按钮——`index3.html` 里没有这个表单, + 所以要先定表单长什么样再动手。 +2. **同步那 6 条侧栏用例。** 要么把侧栏改回用例描述的行为,要么按现在的实现改用例。 +3. **换成真实图标。** 目前卡片左侧是站点名首字母、折叠态是导航首字。若要改成站点图标,需要引入 favicon 抓取或本地图标资源,并处理加载失败的占位。 +4. **替换 PWA 图标。** `web/icons/` 与 `web/favicon.png` 仍是 Flutter 默认图标,需要替换为项目自己的图标。 +4. **记住用户的偏好。** 侧栏折叠状态、展开的分组、当前分区现在都是内存状态,刷新即丢失;可以写进 `localStorage`。 +5. **编辑模式。** 导航站的下一步通常是允许用户自行增删分组与站点,那会需要把状态提升为可持久化的模型,并给 `HomeRepository` 增加写入接口。 +6. **深色模式。** 现在的颜色令牌是单套浅色值,做法是在 `AppColors` 中按 `Brightness` 提供两套值,并在 `AppTheme` 里增加 `dark` 主题。 diff --git a/docs/index3-alignment.md b/docs/index3-alignment.md new file mode 100644 index 0000000..3cad704 --- /dev/null +++ b/docs/index3-alignment.md @@ -0,0 +1,173 @@ +# index3.html 逐条对照 + +本文档把 `index3.html` 里的每一条 CSS 规则与 JS 行为,逐条对应到 Flutter 实现的位置,用来回答「有没有跑偏」。 + +对照方式:`index3.html` 里的选择器 / 声明 → Flutter 文件与常量。 + +## 1. 设计变量 + +| index3.html | Flutter | +| --- | --- | +| `--bg-color: #f7f9fb` | `AppColors.background` | +| `--card-bg: #ffffff` | `AppColors.surface` | +| `--primary-color: #3b82f6` | `AppColors.primary` | +| `--text-main: #1e293b` | `AppColors.textPrimary` | +| `--text-sub: #64748b` | `AppColors.textSecondary` | +| `--border-color: #e2e8f0` | `AppColors.border` | +| `--shadow` | `AppColors.cardShadow` | +| `--shadow-hover` | `AppColors.cardShadowHover` | +| `--sidebar-width: 230px` | `AppSizes.sidebarWidth` | +| `--sidebar-collapsed-width: 64px` | `AppSizes.sidebarCollapsedWidth` | + +## 2. 全局 + +| index3.html | Flutter | +| --- | --- | +| `* { box-sizing: border-box; margin: 0; padding: 0 }` | Flutter 无 box-sizing 概念,等价于默认行为 | +| `html { scroll-behavior: smooth }` | `Scrollable.ensureVisible(..., duration: 300ms)` | +| `body { background-color: var(--bg-color) }` | `Scaffold.backgroundColor = AppColors.background` | +| `body { color: var(--text-main) }` | `AppTheme.light` + 各文字样式自带颜色 | +| `body { font-family: -apple-system, ... }` | 不指定 `fontFamily`,沿用系统字体栈 | +| `body { display: flex; min-height: 100vh }` | `Scaffold.body = Stack[ Row[侧栏, Expanded(内容)] ]` | + +## 3. 侧栏 + +| index3.html | Flutter | +| --- | --- | +| `.sidebar { width: 230px }` | `SidebarNav` 根 `AnimatedContainer.width` | +| `.sidebar { background-color: card-bg }` | `AppColors.surface` | +| `.sidebar { border-right: 1px solid border }` | `BoxDecoration(border: Border(right: ...))` | +| `.sidebar { padding: 20px 12px 12px 12px }` | `sidebarPaddingTop / Horizontal / Bottom` | +| `.sidebar { flex-direction: column }` | `Column` | +| `.sidebar { height: 100vh }` | `Row(crossAxisAlignment: stretch)` | +| `.sidebar { transition: width .3s cubic-bezier(.4,0,.2,1) }` | `sidebarTransition` + `Curves.fastOutSlowIn`(该曲线即 Material standard easing) | +| `.sidebar { overflow-x: hidden }` | 根部 `ClipRect` | +| `body.sidebar-collapsed .sidebar { width: 64px; padding: 20px 8px 12px 8px }` | `sidebarCollapsedWidth` + `sidebarCollapsedPaddingHorizontal` | +| `.sidebar-header { height: 40px; margin-bottom: 24px; 居中 }` | `_SidebarHeader`(`sidebarHeaderHeight` / `sidebarHeaderGap`) | +| `.logo { font-size: 18px; font-weight: 700; color: primary }` | `AppTextStyles.logo` | +| `.logo-icon { font-size: 20px; font-weight: 800; color: primary }` | `AppTextStyles.logoIcon` | +| `body.sidebar-collapsed .logo-text { display: none }` | 折叠时渲染 `logoIcon` 分支 | +| `body.sidebar-collapsed .logo-icon { display: block }` | 同上 | +| `.nav-container { flex: 1; overflow-y: auto; overflow-x: hidden }` | `Expanded(child: SingleChildScrollView(...))` | + +## 4. 导航 + +| index3.html | Flutter | +| --- | --- | +| `.nav-item-wrapper { margin-bottom: 4px }` | `_NavEntry` 的 `Padding(bottom: navItemGap)` | +| `.nav-link { padding: 11px 16px; border-radius: 8px }` | `navItemPaddingVertical/Horizontal`、`navItemRadius` | +| `.nav-link { font-size: 14px; font-weight: 500 }` | `AppTextStyles.navItem` | +| `.nav-link { color: text-sub }` | `AppColors.textSecondary` | +| `.nav-link { justify-content: space-between }` | `Row(mainAxisAlignment: spaceBetween)` | +| `.nav-link { cursor: pointer }` | `MouseRegion(cursor: SystemMouseCursors.click)` | +| `.nav-link { transition: all .2s }` | `AnimatedContainer(duration: 200ms, curve: Curves.ease)` | +| `.nav-link:hover { background-color: #f8fafc; color: primary }` | `AppColors.navItemHover` + `_hovered` | +| `.nav-link.active { background-color: #eff6ff; color: primary }` | `AppColors.primaryFaint` + `selected` | +| `.arrow-icon { font-size: 14px; font-weight: bold; color: #94a3b8; font-family: monospace }` | `AppTextStyles.navArrow` | +| `.arrow-icon { transition: transform .3s ease }` | `AnimatedRotation(duration: 300ms, curve: Curves.ease)` | +| `.nav-item-wrapper.open > .nav-link .arrow-icon { transform: rotate(90deg) }` | `turns: open ? 0.25 : 0` | +| `.sub-menu { padding-left: 16px; max-height: 0; overflow: hidden }` | `subMenuIndent` + `AnimatedSize`(未展开时不渲染子项) | +| `.sub-menu { transition: max-height .3s cubic-bezier(0,1,0,1) }` | `curve: const Cubic(0, 1, 0, 1)` | +| `.open .sub-menu { max-height: 500px; transition: max-height .3s ease-in-out }` | `curve: Curves.easeInOut` | +| `.sub-menu .nav-link { padding: 8px 16px; font-size: 13px }` | `_SubNavItem` + `AppTextStyles.subNavItem` | +| `body.sidebar-collapsed .nav-text { display: none }` | 折叠时 `_NavRow` 不渲染任何子节点 | +| `body.sidebar-collapsed .arrow-icon { display: none }` | 折叠时 `_NavRow` 走无 trailing 分支 | +| `body.sidebar-collapsed .sub-menu { display: none }` | 折叠时根本不构建 `AnimatedSize` | +| `body.sidebar-collapsed .nav-link { justify-content: center; padding: 12px 0 }` | `navItemCollapsedPaddingVertical` + 水平 0 | + +## 5. 侧栏底部 + +| index3.html | Flutter | +| --- | --- | +| `.sidebar-footer { border-top: 1px solid border; padding-top: 12px; margin-top: 8px }` | `_SidebarFooter` | +| `.submit-btn { padding: 10px 16px; border-radius: 8px; font-size: 14px; font-weight: 500 }` | `submitButtonPaddingVertical/Horizontal`、`AppTextStyles.submitButton` | +| `.submit-btn { color: text-sub; transition: all .2s }` | `AnimatedContainer(curve: Curves.ease)` | +| `.submit-btn:hover { background-color: #f1f5f9; color: primary }` | `AppColors.navItemHoverDeep` | +| `.submit-btn` 的 `title="提交网站"` | `Tooltip('提交网站')` | +| `body.sidebar-collapsed .submit-text { display: none }` | 折叠时不渲染文字 | + +## 6. 主内容区与顶栏 + +| index3.html | Flutter | +| --- | --- | +| `.main-content { margin-left: 230px / 64px }` | `Row[ SizedBox(width: 侧栏), Expanded(内容) ]` | +| `.main-content { transition: margin-left .3s cubic-bezier(.4,0,.2,1) }` | 侧栏宽度动画,同一曲线 | +| `.main-content { padding: 24px 40px 40px 40px }` | `contentPaddingTop / Horizontal / Bottom` | +| `.top-bar { gap: 16px; max-width: 750px; margin: 0 auto 36px auto }` | `TopBar`(`topBarGap` / `topBarMaxWidth` / `topBarMarginBottom` + `Center`) | +| `.toggle-btn { width/height: 44px; border-radius: 12px; border: 1px solid border }` | `toggleButtonSize` / `toggleButtonRadius` | +| `.toggle-btn { box-shadow: var(--shadow) }` | `AppColors.cardShadow` | +| `.toggle-btn { font-size: 18px; color: text-sub }` | `Text('☰', fontSize: 18)` | +| `.toggle-btn { transition: all .2s }` | `AnimatedContainer(curve: Curves.ease)` | +| `.toggle-btn:hover { border-color: primary; color: primary }` | `_hovered` 分支 | +| `.toggle-btn` 的 `title="展开/折叠侧边栏"` | `Tooltip('展开/折叠侧边栏')` | +| `.search-container { flex: 1 }` | `Expanded(child: LinkSearchField)` | +| `.search-box { height: 44px; padding: 0 20px; border-radius: 12px; font-size: 15px }` | `searchHeight` / `searchPaddingHorizontal` / `searchRadius` / `AppTextStyles.searchInput` | +| `.search-box { box-shadow: var(--shadow) }` | 未聚焦时的 `boxShadow` | +| `.search-box:focus { border-color: primary; box-shadow: 0 0 0 3px rgba(59,130,246,.15) }` | 聚焦时**只用**光晕(覆盖默认阴影,与 CSS 一致) | +| `.search-box { transition: border-color .2s, box-shadow .2s }` | `AnimatedContainer(curve: Curves.ease)` | + +## 7. 分组与卡片 + +| index3.html | Flutter | +| --- | --- | +| `.section-title { font-size: 16px; font-weight: 600; margin-bottom: 16px }` | `AppTextStyles.sectionTitle` + `sectionTitleGap` | +| `.section-title::before { width: 4px; height: 16px; margin-right: 8px; border-radius: 2px }` | `_SectionTitle` 的竖条(`sectionAccent*`) | +| `.grid { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px }` | `AppBreakpoints.gridColumnsFor` + `gridGap` | +| `.grid { margin-bottom: 36px }` | 每个分区统一 `sectionGap` 下间距(最后一个也一样) | +| `.card { border-radius: 10px; padding: 14px 16px; border: 1px solid border }` | `cardRadius` / `cardPadding*` / `AppColors.border` | +| `.card { box-shadow: var(--shadow); transition: transform .2s, box-shadow .2s }` | `AnimatedContainer(curve: Curves.ease)` | +| `.card:hover { transform: translateY(-2px); box-shadow: var(--shadow-hover) }` | `cardHoverLift` + `cardShadowHover` | +| `.card-icon { width/height: 36px; border-radius: 8px; background: #f1f5f9; color: primary }` | `_CardIcon`(`cardIconSize` / `cardIconRadius` / `AppColors.iconSurface`) | +| `.card-icon { font-weight: 600; margin-right: 12px }` | `AppTextStyles.cardIcon` + `cardIconGap` | +| `.card-icon` 未设 font-size,继承 body 的 16px | `AppTextStyles.cardIcon.fontSize = 16` | +| `.card-info { overflow: hidden }` + `.card-title/.card-desc` 单行省略 | `Expanded` + `maxLines: 1, overflow: ellipsis` | +| `.card-title { font-size: 14px; font-weight: 600 }` | `AppTextStyles.cardTitle` | +| `.card-desc { font-size: 12px; color: text-sub; margin-top: 2px }` | `AppTextStyles.cardDesc` + `cardTitleGap` | + +## 8. 回到顶部 + +| index3.html | Flutter | +| --- | --- | +| `.back-to-top { position: fixed; bottom: 30px; right: 30px }` | `Positioned(right: 30, bottom: 30)`(`backToTopOffset`) | +| `.back-to-top { width/height: 44px; border-radius: 50% }` | `backToTopSize` + `BoxShape.circle` | +| `.back-to-top { font-size: 18px; color: text-sub; box-shadow: var(--shadow) }` | `Text('↑', fontSize: 18)` + `cardShadow` | +| `.back-to-top { transition: all .2s ease }` | `AnimatedContainer(curve: Curves.ease)` | +| `.back-to-top:hover { color/border-color: primary; transform: translateY(-3px); box-shadow: hover }` | `backToTopHoverLift` + `cardShadowHover` | +| `.back-to-top` 的 `title="回到顶部"` | `Tooltip('回到顶部')` | + +## 9. JS 行为 + +| index3.html 的脚本 | Flutter | +| --- | --- | +| 点 `#toggleBtn` → `body.classList.toggle('sidebar-collapsed')` | `HomePage._toggleSidebar` | +| 点 `.has-sub` → 若已折叠先移除 `sidebar-collapsed` | `HomePage._toggleGroup` 里 `collapsed` 为真时先 `_collapsedOverride = false` | +| 点 `.has-sub` → `parentItem.classList.toggle('open')` | 同一批 id 集合 `_openGroupIds` 的增删 | + +## 10. 当前实现没有任何原型之外的行为 + +以下都是「原型里没有」的行为,**都不实现**: + +| 原型的状态 | 当前实现 | +| --- | --- | +| 搜索框没有任何 JS | 只接收输入,不过滤内容;没有空状态、没有清空按钮 | +| `active` 写死在「常用推荐」上 | 高亮固定不动,点其它导航项也不会变 | +| 没有媒体查询 | 侧栏始终 230px(点折叠按钮变 64px),主内容内边距始终 `24/40/40/40` | +| `#icon`、`#stock`、`#dev`、`#api` 没有对应元素 | 这四个导航项点击后没有任何反应(不滚动、不报错) | +| `#submit` 没有对应元素 | 点「提交网站」没有任何反应 | +| 只有 `#common`(3 张卡)与 `#design`(2 张卡)两段内容 | 内容区就是这两段、五张卡片 | + +历史上曾经额外加过、现已全部删除的:滚动的侧栏联动、折叠态首字短标签、分组包含当前分区时的强调色、点二级条目自动展开分组、卡片上弹出网址的 tooltip、搜索过滤与空状态、点导航切换高亮、窄屏自动折叠、补齐缺失的四个分区、提交按钮的提示。 + +## 11. 验证方式 + +- `flutter analyze` → No issues found +- `flutter test` → 36 个用例通过 +- `flutter build web --no-wasm-dry-run` → 构建成功 + +其中几条专门守住「不加戏」的边界: + +- 滚动主内容时,侧栏第一行仍是**同一个 widget 实例**(没有被重建),高亮不变。 +- 折叠态下 `navText('常用推荐')`、`navText('提交网站')` 都找不到——文字确实被隐藏了,与 `display: none` 一致。 +- 输入搜索词后,仍然是 2 个分区、5 张卡片。 +- 点 `#icon` 这类没有对应元素的导航,滚动偏移不变。 +- 点「提交网站」不会弹出任何东西。 diff --git a/docs/submit-site-form.md b/docs/submit-site-form.md new file mode 100644 index 0000000..60d4a2a --- /dev/null +++ b/docs/submit-site-form.md @@ -0,0 +1,116 @@ +# 提交网站表单设计 + +> 已按本文实现(含「错误色 / 可点建议 / 提交后刷新」三条确认)。代码在 +> `frontend/lib/features/home/presentation/widgets/submit_site_dialog.dart`。 +> 实现与本文有几处按实际取舍收敛,差异记在第 8 节。 + +## 1. 目标与约束 + +- 入口:侧栏底部的「提交网站」按钮(现在是个空按钮)。原型里没有这个表单, + 所以这里定完之后再动手写。 +- 只管**用户提交**这一条路(`POST /api/sites`)。接口推送是给第三方用的, + 走令牌,不在页面上暴露。 +- **表单里没有「选择分组」这个动作**:后端不接受 `categoryId`,只接受分组名, + 而且提交的内容永远不会落进内置分组。表单要顺着这个规则设计, + 不能让用户以为可以把站点塞进「常用推荐」「素材资源」这些内置分组。 +- 视觉只能用现有设计令牌(`index3.html` 那套),不引入新的配色和圆角体系。 + +## 2. 交互流程 + +``` +点侧栏「提交网站」 + │ + ▼ +弹出对话框(遮罩 + 白底卡片,居中) + │ 填写(改动某个字段时清掉它上一次的错误) + ▼ +点「提交」 + ├─ 有字段不合格 → 定位到第一个不合格的字段并提示,不发请求 + └─ 都合格 → 按钮进入「提交中…」,禁用整张表单 + ├─ 201 → 关掉对话框 + 顶部提示「已提交,感谢」+ 重新拉一次 /api/nav + └─ 其它 → 留在对话框里,顶部显示后端返回的原因,可以改了再提交 +``` + +关闭方式:右上角 ×、点遮罩、「取消」按钮、Esc 键。提交中不允许关闭(避免请求还没回来 +就把界面收了)。 + +## 3. 字段定义 + +字段与后端 `POST /api/sites` 的入参一一对应,校验规则也和后端保持一致—— +前端先拦一遍是为了即时反馈,后端那一遍才是权威。 + +| 字段 | 控件 | 必填 | 限制 | 不合格时的提示 | 后端字段 | +| --- | --- | --- | --- | --- | --- | +| 网站名称 | 单行输入 | 是 | ≤ 60 字 | 请填写网站名称 / 网站名称最多 60 个字 | `title` | +| 网站地址 | 单行输入 | 是 | 可解析的 http(s),不能是本机或内网地址 | 请填写网站地址 / 地址要以 http:// 或 https:// 开头 / 不能填本机或内网地址 | `url` | +| 一句话描述 | 多行输入(3 行) | 否 | ≤ 120 字 | 描述最多 120 个字 | `description` | +| 分组 | 单行输入 | 是 | ≤ 30 字 | 请填写分组名称 | `group` | +| 子分组 | 单行输入 | 否 | ≤ 30 字 | 子分组名称最多 30 个字 | `subGroup` | +| 你的称呼或邮箱 | 单行输入 | 否 | ≤ 120 字 | 最多 120 个字 | `submittedBy` | + +### 3.1 分组与子分组怎么填 + +- 两个都是**填名字**,不是选内置分类。placeholder 分别写: + 「例如:AI 工具(会创建你自己的分组)」「例如:模型社区(可不填)」。 +- 分组下面给一行浅色说明:**「提交的网站会放在你自己的分组里,不会进内置分类。」** + 这句话必须留着,否则用户会以为能提交到「常用推荐」。 +- 名字和已有的用户分组重名时会**复用**那个分组,不会重复创建—— + 所以两个输入框下面给一排可点的建议:分组建议列出现有用户分组, + 子分组建议列出所选分组下已有的用户子分组(数据来自 `/api/nav` 里 + `editable == true` 的分组)。点一下即填入,不想用就直接手打新名字。 +- 子分组留空表示直接放在一级分组下。 + +## 4. 布局与视觉 + +沿用现有令牌,不新增尺寸: + +| 部分 | 取值 | +| --- | --- | +| 遮罩 | `rgba(17, 24, 39, .45)` | +| 卡片 | 白底 + 1px `#e2e8f0` 描边 + 12px 圆角 + `cardShadowHover` | +| 卡片宽度 | 480px,两侧留 24px(窄屏时自适应) | +| 卡片内边距 | 24px | +| 标题 | 「提交网站」,`AppTextStyles.sectionTitle`,右侧一个 × 关闭按钮 | +| 字段标签 | 13px,`--text-sub`,与输入框间隔 6px | +| 输入框 | 复用搜索框那套:44px 高、1px 描边、12px 圆角、聚焦时主色边框 + 3px 光晕 | +| 多行框 | 同上,高度 88px(3 行),垂直内边距 10px | +| 字段间距 | 16px | +| 底部按钮 | 右侧并排:`取消`(描边次级)+ `提交`(主色实底,8px 圆角——和侧栏「提交网站」一致) | +| 字段错误文案 | 12px,紧随输入框下方,间隔 4px | + +## 5. 状态 + +| 状态 | 表现 | +| --- | --- | +| 初始 | 所有输入为空,「提交」可点 | +| 校验失败 | 不合格的字段旁显示提示,输入框边框变错误色;不发请求 | +| 提交中 | 「提交」文案变「提交中…」并禁用,整张表单只读,× / 遮罩 / Esc 都不响应 | +| 提交失败 | 卡片顶部一条错误条,显示后端 `{"error": "…"}` 里的原话;表单恢复可编辑 | +| 提交成功 | 关闭卡片,弹一条「已提交,感谢」提示,并**静默**重新拉一次 `/api/nav`(不闪加载页) | + +## 6. 三点确认结果 + +1. **错误色**:已新增 `AppColors.danger = #E5484D` 与它 8% 的叠加色 + `dangerFaint`,只用在错误提示和错误边框上。 +2. **分组建议**:保留,做成可点胶囊;只列 `editable == true` 的分组, + 内置分组不出现(点了也进不去)。 +3. **提交成功后刷新**:保留,用静默刷新——页面继续显示旧内容, + 拉到新数据再整体替换,不会闪一下「正在加载」。 + +## 7. 实现落点 + +| 文件 | 职责 | +| --- | --- | +| `features/home/presentation/widgets/submit_site_dialog.dart` | 对话框本体:字段、校验提示、提交按钮 | +| `features/home/data/home_repository.dart` | 契约加一个 `submitSite(...)` | +| `features/home/data/http_home_repository.dart` | 实现:`POST /api/sites`,把后端的中文错误原样抛给界面 | +| `features/home/data/mock_home_repository.dart` | 测试用:记录调用,不真的发请求 | +| `features/home/presentation/pages/home_page.dart` | 「提交网站」按钮改为打开对话框;成功后静默重新取数并提示 | + +## 8. 实现与本文的差异 + +| 本文原写法 | 实际实现 | 原因 | +| --- | --- | --- | +| 失焦时校验 | 提交时统一校验,改动字段时清掉该字段的错误 | 少一套焦点状态,行为对用户更可预期 | +| 底部按钮是「主色胶囊」 | 主色实底 + 8px 圆角 | `index3.html` 的调色板里没有胶囊圆角,侧栏「提交网站」也是 8px | +| 成功提示在页面顶部 | 底部提示条(`SnackBar`) | 复用主题里已经配好的提示条,不新增一套浮层 | diff --git a/frontend/README.md b/frontend/README.md index f62de1f..ef998da 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,11 +1,37 @@ # Navius 前端(Flutter Web) -导航站前端,使用 Flutter 构建。当前目标平台只有 Web,数据全部来自本地 mock, -不发起网络请求;后端(Rust)尚未接入。 +导航站前端,使用 Flutter 构建。当前目标平台只有 Web,数据来自 Rust 后端 +(`GET /api/nav`)。 界面基准是仓库根目录的 `index3.html`:可折叠的层级侧栏 + 居中的顶部控制栏 (折叠按钮 + 搜索框)+ 分组卡片网格 + 右下角圆形回到顶部。 +## 和后端一起跑 + +前端启动时会请求后端,所以两个都要起: + +```bash +cd backend && cargo run # 默认 http://127.0.0.1:8080 +cd frontend && flutter run -d chrome +``` + +后端地址默认是 `http://127.0.0.1:8080`,打包或换环境时用编译期变量覆盖: + +```bash +flutter build web --dart-define=NAVIUS_API_BASE=https://api.example.com +``` + +后端没起来时页面会显示「加载失败」和具体原因,并给一个「重试」按钮。 + +侧栏底部的「提交网站」会打开提交表单:填完直接进后端的用户分组(不需要审核), +提交成功后页面会静默重新拉一次数据,新站点立刻出现在侧栏里。 + +用户提交 / 接口推送进来的站点(后端返回的 `editable == true`)在卡片悬停时右上角 +会露出「编辑 / 删除」:编辑复用同一个表单(预填现有内容)走 `PATCH`, +删除会先确认再走 `DELETE`,两个操作成功后都会静默刷新。系统内置的卡片没有这两个入口。 + +站点名和搜索框提示也来自后端(`data/site.json`),前端不再写死。 + ## 常用命令 ```bash @@ -27,12 +53,19 @@ flutter build web # 构建产物输出到 build/web ## 接后端的位置 -页面只依赖仓库接口,不关心数据来自 mock 还是 Rust 后端: +页面只依赖仓库接口,不关心数据来自后端还是本地 mock: -- 首页:`lib/features/home/data/home_repository.dart` +- 契约:`lib/features/home/data/home_repository.dart` +- 后端实现:`lib/features/home/data/http_home_repository.dart` +- mock 实现:`lib/features/home/data/mock_home_repository.dart`(测试用) -接入时新增一个实现类,并把 `fetchHomeData` 的返回类型改成 `Future` 即可, -`domain/` 与 `presentation/` 基本不需要改动。 +接口对应关系:读 `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)。 + +接口返回的分组树在 `http_home_repository.dart` 里映射成前端模型:分组变成侧栏导航, +**只有挂着网站的分组才进内容区**(空分组留在侧栏,点了没有对应内容——和原型一致), +子分组的内容标题会带上父级(「素材资源 - UI 设计」)。 完整的目录树、设计令牌映射、响应式规则与实现取舍见 [docs/frontend-architecture.md](../docs/frontend-architecture.md)。 diff --git a/frontend/lib/app.dart b/frontend/lib/app.dart index ad74862..abc47d3 100644 --- a/frontend/lib/app.dart +++ b/frontend/lib/app.dart @@ -2,27 +2,27 @@ import 'package:flutter/material.dart'; import 'core/services/web_url_opener.dart'; import 'core/theme/app_theme.dart'; -import 'features/home/data/mock_home_repository.dart'; +import 'features/home/data/http_home_repository.dart'; import 'features/home/presentation/pages/home_page.dart'; /// 应用根组件。 /// /// 只负责三件事:配置全局主题、装配依赖、指定首页。 -/// 这里是「具体实现」接起来的地方,所以只有它需要知道 -/// [MockHomeRepository] 与 [WebUrlOpener] 的存在。 +/// 这里是「具体实现」接起来的地方,所以只有它需要知道首页数据来自后端: +/// [HttpHomeRepository] 默认打 `http://127.0.0.1:8080`, +/// 打包时用 `--dart-define=NAVIUS_API_BASE=…` 指向真实地址。 class NaviusApp extends StatelessWidget { const NaviusApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( - // 与 HomeData.siteName 保持一致;接入后端后这一层由接口返回的数据驱动。 title: 'Suxing Nav', debugShowCheckedModeBanner: false, theme: AppTheme.light, - home: const HomePage( - repository: MockHomeRepository(), - urlOpener: WebUrlOpener(), + home: HomePage( + repository: HttpHomeRepository(), + urlOpener: const WebUrlOpener(), ), ); } diff --git a/frontend/lib/core/constants/app_sizes.dart b/frontend/lib/core/constants/app_sizes.dart index 7477c52..121fb69 100644 --- a/frontend/lib/core/constants/app_sizes.dart +++ b/frontend/lib/core/constants/app_sizes.dart @@ -155,6 +155,33 @@ 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/core/theme/app_colors.dart b/frontend/lib/core/theme/app_colors.dart index c80ab70..492466a 100644 --- a/frontend/lib/core/theme/app_colors.dart +++ b/frontend/lib/core/theme/app_colors.dart @@ -44,6 +44,16 @@ abstract final class AppColors { /// 搜索框聚焦时的外发光(`0 0 0 3px rgba(59,130,246,.15)`)。 static const Color focusRing = Color(0x263B82F6); + /// 错误色。 + /// + /// `index3.html` 的调色板里没有红色,但表单的字段错误、失败提示需要一个 + /// 明确区别于正文的颜色,所以新增这一个语义令牌——只用在错误提示上, + /// 不参与任何现有视觉。 + static const Color danger = Color(0xFFE5484D); + + /// 错误色 8% 叠加,用作错误提示条 / 错误输入框的底色。 + static const Color dangerFaint = Color(0x14E5484D); + // ---- 阴影 ---- /// 卡片默认阴影(`--shadow`)。 diff --git a/frontend/lib/core/utils/text.dart b/frontend/lib/core/utils/text.dart index 301a9f4..c5b5c87 100644 --- a/frontend/lib/core/utils/text.dart +++ b/frontend/lib/core/utils/text.dart @@ -9,3 +9,9 @@ String firstChar(String text, {String fallback = '·'}) { } return String.fromCharCode(text.runes.first); } + +/// 字符数(不是字节数)。 +/// +/// 和后端 `util::char_count` 的口径一致:先去掉首尾空白,再数字符。 +/// 中文一个字算一个,所以「最多 60 个字符」用 `runes.length` 而不是 `length`。 +int charCount(String text) => text.trim().runes.length; diff --git a/frontend/lib/features/home/data/home_repository.dart b/frontend/lib/features/home/data/home_repository.dart index 7c31956..42f7630 100644 --- a/frontend/lib/features/home/data/home_repository.dart +++ b/frontend/lib/features/home/data/home_repository.dart @@ -1,13 +1,36 @@ import '../domain/models/home_data.dart'; +import '../domain/models/submit_site_draft.dart'; /// 首页数据仓库契约。 /// /// 界面层只认这个接口,不关心数据来自本地 mock 还是 Rust 后端。 /// -/// 目前是同步返回:mock 数据是恒定常量,加一层 Future 只会凭空多出 -/// loading / error 分支。等真正走网络请求时,把返回值改成 -/// `Future`,改动范围仅限实现类与页面的一次数据加载。 +/// 两个方法都是异步的:接上后端之后「加载中 / 提交中」和「失败」都是必须 +/// 表达的状态,所以契约从一开始就按异步来——mock 实现直接返回已经就绪的值。 abstract interface class HomeRepository { /// 读取首页需要的全部数据。 - HomeData fetchHomeData(); + Future fetchHomeData(); + + /// 提交一个网站。 + /// + /// 成功不返回内容:后端会回创建出来的条目,但页面用不到(提交完重新拉一次 + /// `/api/nav` 就有了)。失败时抛 [HomeFailure],`message` 可以直接展示。 + Future submitSite(SubmitSiteDraft draft); + + /// 修改一个已有的网站(只能改用户提交 / 接口推送进来的,系统内置的会返回 403)。 + Future updateSite(String id, SubmitSiteDraft draft); + + /// 删除一个已有的网站(同样只对可修改的条目有效)。 + Future deleteSite(String id); +} + +/// 取数或提交失败。[message] 是可以直接显示给用户的说明。 +class HomeFailure implements Exception { + const HomeFailure(this.message); + + /// 面向用户的说明。 + final String message; + + @override + String toString() => message; } diff --git a/frontend/lib/features/home/data/http_home_repository.dart b/frontend/lib/features/home/data/http_home_repository.dart new file mode 100644 index 0000000..2e0fd62 --- /dev/null +++ b/frontend/lib/features/home/data/http_home_repository.dart @@ -0,0 +1,240 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +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 'home_repository.dart'; + +/// 后端地址。 +/// +/// 默认指向本机后端,打包时用 `--dart-define=NAVIUS_API_BASE=https://…` 覆盖: +/// `flutter build web --dart-define=NAVIUS_API_BASE=https://api.example.com` +const String _apiBase = String.fromEnvironment( + 'NAVIUS_API_BASE', + defaultValue: 'http://127.0.0.1:8080', +); + +/// 接口没给站点信息时的兜底值。正常情况下这两个值来自 `GET /api/nav` 的 +/// `site` 字段(后端 `data/site.json`)。 +const String _fallbackSiteName = 'Suxing Nav'; +const String _fallbackSearchHint = '输入关键字搜索全网资源...'; + +/// 从后端读取首页数据。 +class HttpHomeRepository implements HomeRepository { + HttpHomeRepository({String? baseUrl, http.Client? client}) + : _baseUrl = baseUrl ?? _apiBase, + _client = client ?? http.Client(); + + final String _baseUrl; + final http.Client _client; + + @override + Future fetchHomeData() async { + final Uri uri = Uri.parse('$_baseUrl/api/nav'); + final http.Response response; + try { + response = await _client.get(uri).timeout(const Duration(seconds: 10)); + } catch (error) { + throw HomeFailure('连不上后端($_baseUrl):$error'); + } + if (response.statusCode != 200) { + throw HomeFailure( + '后端返回 ${response.statusCode}:${_errorMessage(response)}', + ); + } + + final Object? decoded; + try { + decoded = jsonDecode(utf8.decode(response.bodyBytes)); + } catch (error) { + throw const HomeFailure('后端返回的不是合法 JSON'); + } + if (decoded is! Map) { + throw const HomeFailure('后端返回的结构不对:顶层不是对象'); + } + final Object? groups = decoded['groups']; + if (groups is! List) { + throw const HomeFailure('后端返回的结构不对:缺少 groups 数组'); + } + final Object? rawSite = decoded['site']; + return _toHomeData( + groups, + rawSite is Map ? rawSite : const {}, + ); + } + + @override + Future submitSite(SubmitSiteDraft draft) async { + final Uri uri = Uri.parse('$_baseUrl/api/sites'); + final http.Response response; + try { + response = await _client + .post( + uri, + headers: const { + 'content-type': 'application/json; charset=utf-8', + }, + body: jsonEncode(draft.toJson()), + ) + .timeout(const Duration(seconds: 10)); + } catch (error) { + throw HomeFailure('连不上后端($_baseUrl):$error'); + } + if (response.statusCode != 201) { + throw HomeFailure( + '提交失败(${response.statusCode}):${_errorMessage(response)}', + ); + } + } + + @override + Future updateSite(String id, SubmitSiteDraft draft) async { + final Uri uri = Uri.parse('$_baseUrl/api/sites/${Uri.encodeComponent(id)}'); + final http.Response response; + try { + response = await _client + .patch( + uri, + headers: const { + 'content-type': 'application/json; charset=utf-8', + }, + body: jsonEncode(draft.toJson()), + ) + .timeout(const Duration(seconds: 10)); + } catch (error) { + throw HomeFailure('连不上后端($_baseUrl):$error'); + } + if (response.statusCode != 200) { + throw HomeFailure( + '保存失败(${response.statusCode}):${_errorMessage(response)}', + ); + } + } + + @override + Future deleteSite(String id) async { + final Uri uri = Uri.parse('$_baseUrl/api/sites/${Uri.encodeComponent(id)}'); + final http.Response response; + try { + response = await _client.delete(uri).timeout(const Duration(seconds: 10)); + } catch (error) { + throw HomeFailure('连不上后端($_baseUrl):$error'); + } + if (response.statusCode != 204) { + throw HomeFailure( + '删除失败(${response.statusCode}):${_errorMessage(response)}', + ); + } + } +} + +/// 把 `/api/nav` 的响应映射成前端模型。 +/// +/// 分组直接变成侧栏的导航树;**只有挂着网站的组才会进入内容区**, +/// 空分组只在侧栏里当导航项——这与原型里那几个没有内容的锚点一致。 +HomeData _toHomeData(List groups, Map siteMeta) { + final List navNodes = []; + final List sections = []; + for (final Object? group in groups) { + if (group is Map) { + navNodes.add(_navNode(group, parentTitle: '', sections: sections)); + } + } + return HomeData( + siteName: siteMeta['name'] as String? ?? _fallbackSiteName, + searchHint: siteMeta['searchHint'] as String? ?? _fallbackSearchHint, + navNodes: navNodes, + sections: sections, + ); +} + +NavNode _navNode( + Map json, { + required String parentTitle, + required List sections, +}) { + final String id = json['id'] as String? ?? ''; + final String title = json['title'] as String? ?? ''; + + // 子分组在内容区的标题带上父级(「素材资源 - UI 设计」), + // 侧栏里仍然只显示「UI 设计」——和原型一致。 + final String sectionTitle = parentTitle.isEmpty + ? title + : '$parentTitle - $title'; + final List sites = _sites(json); + if (sites.isNotEmpty && id.isNotEmpty) { + sections.add(LinkSection(id: id, title: sectionTitle, items: sites)); + } + + final List children = []; + final Object? rawChildren = json['children']; + if (rawChildren is List) { + for (final Object? child in rawChildren) { + if (child is Map) { + children.add( + _navNode(child, parentTitle: title, sections: sections), + ); + } + } + } + return NavNode( + id: id, + title: title, + children: children, + // 后端给了 editable;缺省按内置处理(内置不可选)。 + editable: json['editable'] as bool? ?? false, + ); +} + +List _sites(Map json) { + final Object? raw = json['sites']; + if (raw is! List) { + return const []; + } + final List items = []; + for (final Object? site in raw) { + if (site is! Map) { + continue; + } + final String id = site['id'] as String? ?? ''; + final String title = site['title'] as String? ?? ''; + final String url = site['url'] as String? ?? ''; + if (id.isEmpty || title.isEmpty || url.isEmpty) { + // 缺字段的条目直接跳过:后端已经校验过,这里只是不想让一条坏数据 + // 把整个页面打挂。 + continue; + } + items.add( + LinkItem( + id: id, + title: title, + url: url, + categoryId: site['categoryId'] as String? ?? '', + description: site['description'] as String? ?? '', + editable: site['editable'] as bool? ?? false, + ), + ); + } + return items; +} + +/// 后端出错时统一返回 `{"error":"…"}`,能取到就显示那句话。 +/// +/// 用 `bodyBytes` 自己按 UTF-8 解,而不是 `response.body`:后者在响应没带 +/// charset 时会退回 latin1,中文会变成乱码。 +String _errorMessage(http.Response response) { + final String body = utf8.decode(response.bodyBytes, allowMalformed: true); + try { + final Object? decoded = jsonDecode(body); + if (decoded is Map && decoded['error'] is String) { + return decoded['error'] as String; + } + } catch (_) { + // 不是 JSON 就退回原文。 + } + return body.isEmpty ? '没有返回内容' : body; +} diff --git a/frontend/lib/features/home/data/mock_home_repository.dart b/frontend/lib/features/home/data/mock_home_repository.dart index 5cb3870..f428d3d 100644 --- a/frontend/lib/features/home/data/mock_home_repository.dart +++ b/frontend/lib/features/home/data/mock_home_repository.dart @@ -1,4 +1,5 @@ import '../domain/models/home_data.dart'; +import '../domain/models/submit_site_draft.dart'; import 'home_repository.dart'; import 'mock_home_data.dart'; @@ -10,5 +11,16 @@ class MockHomeRepository implements HomeRepository { const MockHomeRepository(); @override - HomeData fetchHomeData() => mockHomeData; + Future fetchHomeData() async => mockHomeData; + + @override + Future submitSite(SubmitSiteDraft draft) async { + // mock 实现什么都不做:需要断言提交内容的用例会注入自己的假实现。 + } + + @override + Future updateSite(String id, SubmitSiteDraft draft) async {} + + @override + Future deleteSite(String id) async {} } diff --git a/frontend/lib/features/home/domain/models/link_item.dart b/frontend/lib/features/home/domain/models/link_item.dart index a4cc0ce..104d037 100644 --- a/frontend/lib/features/home/domain/models/link_item.dart +++ b/frontend/lib/features/home/domain/models/link_item.dart @@ -6,7 +6,9 @@ class LinkItem { required this.id, required this.title, required this.url, + this.categoryId = '', this.description = '', + this.editable = false, }); /// 从 JSON 构造(将来接后端接口时使用)。 @@ -15,7 +17,9 @@ class LinkItem { id: json['id'] as String, title: json['title'] as String, url: json['url'] as String, + categoryId: json['categoryId'] as String? ?? '', description: json['description'] as String? ?? '', + editable: json['editable'] as bool? ?? false, ); } @@ -28,9 +32,18 @@ class LinkItem { /// 跳转地址。 final String url; + /// 所属分组 id。编辑时要靠它反查出分组名,才能把表单预填对。 + final String categoryId; + /// 一句话描述。 final String description; + /// 这条能不能改。 + /// + /// 后端按 `source != "system"` 推导出 `editable`:系统内置的条目只读, + /// 卡片上不会出现编辑/删除;用户提交和接口推送进来的可以改、可以删。 + final bool editable; + /// 卡片图标显示的首字符。 String get initial => firstChar(title).toUpperCase(); @@ -42,7 +55,9 @@ class LinkItem { 'id': id, 'title': title, 'url': url, + 'categoryId': categoryId, 'description': description, + 'editable': editable, }; @override @@ -52,11 +67,14 @@ class LinkItem { other.id == id && other.title == title && other.url == url && - other.description == description; + other.categoryId == categoryId && + other.description == description && + other.editable == editable; } @override - int get hashCode => Object.hash(id, title, url, description); + int get hashCode => + Object.hash(id, title, url, categoryId, description, editable); @override String toString() => 'LinkItem(id: $id, title: $title)'; diff --git a/frontend/lib/features/home/domain/models/nav_node.dart b/frontend/lib/features/home/domain/models/nav_node.dart index 8bb011a..62dbcf6 100644 --- a/frontend/lib/features/home/domain/models/nav_node.dart +++ b/frontend/lib/features/home/domain/models/nav_node.dart @@ -10,6 +10,7 @@ class NavNode { required this.id, required this.title, this.children = const [], + this.editable = false, }); /// 唯一标识;叶子节点同时也是内容区的锚点 id。 @@ -21,6 +22,12 @@ class NavNode { /// 子节点;为空表示这是一个直接跳转的条目。 final List children; + /// 这个分组是不是用户创建的(后端返回的 `editable`)。 + /// + /// 提交表单据此给出「已有的用户分组」建议:内置分组不可选, + /// 因为提交的内容永远进不了内置分组。 + final bool editable; + /// 是否是可展开的分组。 bool get isGroup => children.isNotEmpty; diff --git a/frontend/lib/features/home/domain/models/submit_site_draft.dart b/frontend/lib/features/home/domain/models/submit_site_draft.dart new file mode 100644 index 0000000..1641142 --- /dev/null +++ b/frontend/lib/features/home/domain/models/submit_site_draft.dart @@ -0,0 +1,42 @@ +/// 提交一个网站时填的内容。 +/// +/// 和后端 `POST /api/sites` 的入参一一对应。**没有 `categoryId`**: +/// 提交方只能给分组名,由后端在用户分组里查找或新建。 +class SubmitSiteDraft { + const SubmitSiteDraft({ + required this.title, + required this.url, + required this.group, + this.description = '', + this.subGroup = '', + this.submittedBy = '', + }); + + /// 网站名称。 + final String title; + + /// 网站地址。 + final String url; + + /// 一级分组名。 + final String group; + + /// 一句话描述。 + final String description; + + /// 子分组名,可为空。 + final String subGroup; + + /// 提交者的称呼或邮箱。 + final String submittedBy; + + /// 序列化成后端要的 JSON(camelCase,和后端 `CreateSiteRequest` 对齐)。 + Map toJson() => { + 'title': title, + 'url': url, + 'description': description, + 'group': group, + 'subGroup': subGroup, + 'submittedBy': submittedBy, + }; +} diff --git a/frontend/lib/features/home/presentation/pages/home_page.dart b/frontend/lib/features/home/presentation/pages/home_page.dart index 4243610..a90a999 100644 --- a/frontend/lib/features/home/presentation/pages/home_page.dart +++ b/frontend/lib/features/home/presentation/pages/home_page.dart @@ -5,21 +5,24 @@ import 'package:flutter/material.dart'; import '../../../../core/constants/app_sizes.dart'; import '../../../../core/services/url_opener.dart'; import '../../../../core/theme/app_colors.dart'; +import '../../../../core/theme/app_text_styles.dart'; 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'; /// 首页:可折叠侧栏 + 顶部控制栏 + 分组卡片网格。 /// -/// 完全按项目根目录的 `index3.html` 还原:结构、样式、交互都以那份文件为准, -/// 不额外增加原型里没有的行为。原型里没有的东西也照样不实现—— -/// 搜索框不参与过滤(原型没有对应脚本)、`active` 固定写在第一个导航条目上、 -/// 没有对应锚点的导航项点击后不会有任何反应。 +/// 数据来自后端 `GET /api/nav`,所以页面要表达三种状态:加载中、加载失败 +/// (带重试)、加载完成。布局与交互仍然以项目根目录的 `index3.html` 为准。 class HomePage extends StatefulWidget { const HomePage({ super.key, @@ -27,7 +30,7 @@ class HomePage extends StatefulWidget { required this.urlOpener, }); - /// 数据来源(当前是 mock 实现)。 + /// 数据来源(当前是 HTTP 实现,测试里注入 mock)。 final HomeRepository repository; /// 打开链接的能力(当前是 Web 实现)。 @@ -38,13 +41,12 @@ class HomePage extends StatefulWidget { } class _HomePageState extends State { - /// 首页数据只在初始化时取一次。 - late final HomeData _data = widget.repository.fetchHomeData(); + HomeData? _data; + String? _error; + bool _loading = true; - /// 有对应锚点的分区,用于「点导航滚动过去」。 - late final Map _sectionKeys = { - for (final LinkSection section in _data.sections) section.id: GlobalKey(), - }; + /// 有对应分区的分组,用于「点导航滚动过去」;数据到了之后才建。 + Map _sectionKeys = {}; final ScrollController _scrollController = ScrollController(); final TextEditingController _searchController = TextEditingController(); @@ -55,6 +57,12 @@ class _HomePageState extends State { /// 侧栏是否折叠。原型初始是展开的(body 上没有 sidebar-collapsed)。 bool _collapsed = false; + @override + void initState() { + super.initState(); + unawaited(_load()); + } + @override void dispose() { _scrollController.dispose(); @@ -62,6 +70,48 @@ class _HomePageState extends State { super.dispose(); } + /// 向后端取一次数据。失败时把说明留在 `_error` 里给用户看,并允许重试。 + /// + /// [showLoading] 为 false 时是「静默刷新」:页面继续显示旧内容, + /// 失败了只提示一句,不会把已经渲染好的页面换成错误页。 + Future _load({bool showLoading = true}) async { + if (showLoading) { + setState(() { + _loading = true; + _error = null; + }); + } + try { + final HomeData data = await widget.repository.fetchHomeData(); + if (!mounted) { + return; + } + setState(() { + _data = data; + _loading = false; + _error = null; + _sectionKeys = { + for (final LinkSection section in data.sections) + section.id: GlobalKey(), + }; + }); + } catch (error) { + if (!mounted) { + return; + } + if (showLoading) { + setState(() { + _loading = false; + _error = error.toString(); + }); + } else { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('刷新失败:$error'))); + } + } + } + // ---- 交互 ---- /// `#toggleBtn`:切换 `sidebar-collapsed`。 @@ -83,10 +133,10 @@ class _HomePageState extends State { }); } - /// 点导航叶子:滚到对应锚点。 + /// 点导航叶子:滚到对应分区。 /// - /// 原型里 `#icon`、`#stock`、`#dev`、`#api` 没有对应元素, - /// 对应 `` 点了不会有任何反应,这里保持一致。 + /// 后端允许存在没有网站的分组,这类分组在内容区没有对应元素, + /// 点了不会有任何反应——和原型里那几个空锚点一致。 void _goToSection(String sectionId) { final BuildContext? sectionContext = _sectionKeys[sectionId]?.currentContext; if (sectionContext == null) { @@ -103,6 +153,102 @@ 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 的「一级分组 + 子分组」名字。 + /// + /// 后端只按分组名收更新,所以编辑时必须把 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); + } + } + } + return null; + } + void _backToTop() { _scrollController.animateTo( 0, @@ -117,39 +263,52 @@ class _HomePageState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.background, - body: Stack( - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SidebarNav( - siteName: _data.siteName, - navNodes: _data.navNodes, - // 原型把 active 写死在第一个条目上,切换页面也不变。 - activeSectionId: _data.activeSectionId, - collapsed: _collapsed, - openGroupIds: _openGroupIds, - onToggleGroup: _toggleGroup, - onSectionSelected: _goToSection, - // 原型指向不存在的 #submit,点了没有任何反应。 - onSubmitSite: () {}, - // 补上这行,将页面的切换折叠方法传进去 - onToggleCollapse: _toggleSidebar, - ), - Expanded(child: _buildMain()), - ], - ), - Positioned( - right: AppSizes.backToTopOffset, - bottom: AppSizes.backToTopOffset, - child: BackToTopButton(onTap: _backToTop), - ), - ], - ), + body: _buildBody(), ); } - Widget _buildMain() { + Widget _buildBody() { + if (_loading) { + return const _StatusView(message: '正在加载…', loading: true); + } + final String? error = _error; + if (error != null) { + return _StatusView(title: '加载失败', message: error, onRetry: _load); + } + return _buildLoaded(_data!); + } + + Widget _buildLoaded(HomeData data) { + return Stack( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SidebarNav( + siteName: data.siteName, + navNodes: data.navNodes, + // 原型把 active 写死在第一个条目上。 + activeSectionId: data.activeSectionId, + collapsed: _collapsed, + openGroupIds: _openGroupIds, + onToggleGroup: _toggleGroup, + onSectionSelected: _goToSection, + onSubmitSite: () => unawaited(_openSubmitDialog(data)), + onToggleCollapse: _toggleSidebar, + ), + Expanded(child: _buildMain(data)), + ], + ), + Positioned( + right: AppSizes.backToTopOffset, + bottom: AppSizes.backToTopOffset, + child: BackToTopButton(onTap: _backToTop), + ), + ], + ); + } + + Widget _buildMain(HomeData data) { return SingleChildScrollView( controller: _scrollController, padding: const EdgeInsets.fromLTRB( @@ -163,11 +322,11 @@ class _HomePageState extends State { children: [ TopBar( searchController: _searchController, - searchHint: _data.searchHint, + searchHint: data.searchHint, sidebarCollapsed: _collapsed, onToggleSidebar: _toggleSidebar, ), - for (final LinkSection section in _data.sections) + for (final LinkSection section in data.sections) Padding( // 原型里每个 .grid 都带 margin-bottom: 36px。 padding: const EdgeInsets.only(bottom: AppSizes.sectionGap), @@ -175,6 +334,9 @@ 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)), ), ), ], @@ -182,3 +344,92 @@ class _HomePageState extends State { ); } } + +/// 加载中 / 加载失败时占满内容区的提示。 +class _StatusView extends StatelessWidget { + const _StatusView({ + required this.message, + this.title, + this.loading = false, + this.onRetry, + }); + + final String message; + final String? title; + final bool loading; + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSizes.contentPaddingHorizontal), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (loading) ...[ + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(height: AppSizes.gridGap), + ], + if (title != null) ...[ + Text(title!, style: AppTextStyles.emptyTitle), + const SizedBox(height: 6), + ], + Text( + message, + style: AppTextStyles.emptyMessage, + textAlign: TextAlign.center, + ), + if (onRetry != null) ...[ + const SizedBox(height: AppSizes.gridGap), + _RetryButton(onTap: onRetry!), + ], + ], + ), + ), + ); + } +} + +/// 「重试」按钮:跟前端其它次级按钮同一套描边样式。 +class _RetryButton extends StatefulWidget { + const _RetryButton({required this.onTap}); + + final VoidCallback onTap; + + @override + State<_RetryButton> createState() => _RetryButtonState(); +} + +class _RetryButtonState extends State<_RetryButton> { + 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: 16, vertical: 8), + decoration: BoxDecoration( + color: _hovered ? AppColors.primaryFaint : Colors.transparent, + border: Border.all(color: AppColors.primary), + borderRadius: BorderRadius.circular(AppSizes.navItemRadius), + ), + child: Text( + '重试', + style: AppTextStyles.navItem.copyWith(color: AppColors.primary), + ), + ), + ), + ); + } +} diff --git a/frontend/lib/features/home/presentation/widgets/confirm_dialog.dart b/frontend/lib/features/home/presentation/widgets/confirm_dialog.dart new file mode 100644 index 0000000..ecc3362 --- /dev/null +++ b/frontend/lib/features/home/presentation/widgets/confirm_dialog.dart @@ -0,0 +1,91 @@ +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 new file mode 100644 index 0000000..7d1f4a3 --- /dev/null +++ b/frontend/lib/features/home/presentation/widgets/dialog_buttons.dart @@ -0,0 +1,117 @@ +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 07565d3..3a2db14 100644 --- a/frontend/lib/features/home/presentation/widgets/link_card.dart +++ b/frontend/lib/features/home/presentation/widgets/link_card.dart @@ -5,11 +5,19 @@ import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_text_styles.dart'; import '../../domain/models/link_item.dart'; -/// 站点卡片,对应 `index.html` 里的 `.card`。 +/// 站点卡片,对应 `index3.html` 里的 `.card`。 /// /// 结构是「36px 的字母方块 + 名称 + 描述」,悬停时上移 2px 并加强阴影。 +/// 用户提交 / 接口推送进来的条目(`item.editable`)在悬停时右上角会露出 +/// 「编辑 / 删除」;系统内置的条目没有这两个按钮——后端也不允许改。 class LinkCard extends StatefulWidget { - const LinkCard({super.key, required this.item, required this.onOpen}); + const LinkCard({ + super.key, + required this.item, + required this.onOpen, + this.onEdit, + this.onDelete, + }); /// 站点数据。 final LinkItem item; @@ -17,6 +25,12 @@ class LinkCard extends StatefulWidget { /// 打开回调。 final VoidCallback onOpen; + /// 编辑回调;为空或条目不可修改时不显示编辑按钮。 + final VoidCallback? onEdit; + + /// 删除回调;为空或条目不可修改时不显示删除按钮。 + final VoidCallback? onDelete; + @override State createState() => _LinkCardState(); } @@ -26,6 +40,8 @@ class _LinkCardState extends State { @override Widget build(BuildContext context) { + final bool canManage = widget.item.editable; + return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), @@ -53,31 +69,68 @@ class _LinkCardState extends State { ? AppColors.cardShadowHover : AppColors.cardShadow, ), - child: Row( + child: Stack( 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, + 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, + ), + ], ), - const SizedBox(height: AppSizes.cardTitleGap), - Text( - widget.item.description, - style: AppTextStyles.cardDesc, - 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!, + ), + ], + ), + ), + ), ], ), ), @@ -86,6 +139,69 @@ 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_search_field.dart b/frontend/lib/features/home/presentation/widgets/link_search_field.dart index 5205e43..c1a4a72 100644 --- a/frontend/lib/features/home/presentation/widgets/link_search_field.dart +++ b/frontend/lib/features/home/presentation/widgets/link_search_field.dart @@ -5,12 +5,6 @@ import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_text_styles.dart'; /// 搜索框。 -/// -/// 对应 `index3.html` 的 `.search-box`:固定 44px 高、白色底、1px 描边、 -/// 12px 圆角,聚焦时边框变主色并外扩 3px 的浅色光晕。 -/// -/// 用 [FocusNode] 而不是 `InputDecoration` 的 `focusedBorder`: -/// 那条光晕是「向外扩散」的矩形阴影,靠边框宽度画不出来。 class LinkSearchField extends StatefulWidget { const LinkSearchField({ super.key, @@ -18,10 +12,7 @@ class LinkSearchField extends StatefulWidget { required this.hintText, }); - /// 文本控制器。 final TextEditingController controller; - - /// 占位文案。 final String hintText; @override @@ -54,42 +45,52 @@ class _LinkSearchFieldState extends State { @override Widget build(BuildContext context) { - return AnimatedContainer( - duration: AppSizes.transition, - // `.search-box { transition: border-color .2s, box-shadow .2s }`。 - curve: Curves.ease, - height: AppSizes.searchHeight, - padding: const EdgeInsets.symmetric( - horizontal: AppSizes.searchPaddingHorizontal, - ), - decoration: BoxDecoration( - color: AppColors.surface, - border: Border.all( - color: _focused ? AppColors.primary : AppColors.border, + return GestureDetector( + // 输入框只占中间一行的高度,点上下留白也让它聚焦—— + // 原型里整个 44px 高的盒子就是一个 。 + onTap: _focusNode.requestFocus, + child: AnimatedContainer( + duration: AppSizes.transition, + curve: Curves.ease, + height: AppSizes.searchHeight, + padding: const EdgeInsets.symmetric( + horizontal: AppSizes.searchPaddingHorizontal, ), - borderRadius: BorderRadius.circular(AppSizes.searchRadius), - // 原型 `.search-box:focus` 的 box-shadow 会整体覆盖掉默认阴影, - // 聚焦时只剩这一圈光晕,不是「默认阴影 + 光晕」。 - boxShadow: [ - if (_focused) - const BoxShadow(color: AppColors.focusRing, spreadRadius: 3) - else - ...AppColors.cardShadow, - ], - ), - child: TextField( - controller: widget.controller, - focusNode: _focusNode, - style: AppTextStyles.searchInput, - cursorColor: AppColors.primary, - textAlignVertical: TextAlignVertical.center, - textInputAction: TextInputAction.search, - decoration: InputDecoration( - isDense: true, - border: InputBorder.none, - contentPadding: EdgeInsets.zero, - hintText: widget.hintText, - hintStyle: AppTextStyles.searchHint, + decoration: BoxDecoration( + color: AppColors.surface, + border: Border.all( + color: _focused ? AppColors.primary : AppColors.border, + ), + borderRadius: BorderRadius.circular(AppSizes.searchRadius), + boxShadow: [ + if (_focused) + const BoxShadow(color: AppColors.focusRing, spreadRadius: 3) + else + ...AppColors.cardShadow, + ], + ), + // 垂直居中不要靠固定的 contentPadding。 + // 那个写法只在「字体行高 + 上下 padding 正好等于内容区高度」时成立, + // 换一个字体(浏览器里的系统字体行高通常更高)就会被挤偏。 + // 这里让 TextField 按自身行高确定高度,再用 Align 把这一行放进 + // 44px 的内容区里居中——和浏览器渲染单行 的方式一致, + // 与字体行高无关。 + child: Align( + alignment: Alignment.centerLeft, + child: TextField( + controller: widget.controller, + focusNode: _focusNode, + style: AppTextStyles.searchInput, + cursorColor: AppColors.primary, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + hintText: widget.hintText, + hintStyle: AppTextStyles.searchHint, + ), + ), ), ), ); 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 8849dcb..39834f6 100644 --- a/frontend/lib/features/home/presentation/widgets/link_section_view.dart +++ b/frontend/lib/features/home/presentation/widgets/link_section_view.dart @@ -14,6 +14,8 @@ class LinkSectionView extends StatelessWidget { super.key, required this.section, required this.onOpen, + this.onEdit, + this.onDelete, }); /// 分组数据。 @@ -22,6 +24,12 @@ class LinkSectionView extends StatelessWidget { /// 打开某个站点。 final ValueChanged onOpen; + /// 编辑某个站点;为空时卡片不显示编辑入口。 + final ValueChanged? onEdit; + + /// 删除某个站点;为空时卡片不显示删除入口。 + final ValueChanged? onDelete; + @override Widget build(BuildContext context) { return Column( @@ -47,7 +55,12 @@ class LinkSectionView extends StatelessWidget { for (final LinkItem item in section.items) SizedBox( width: itemWidth, - child: LinkCard(item: item, onOpen: () => onOpen(item)), + child: LinkCard( + item: item, + onOpen: () => onOpen(item), + onEdit: onEdit == null ? null : () => onEdit!(item), + onDelete: onDelete == null ? null : () => onDelete!(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 new file mode 100644 index 0000000..a7bdb66 --- /dev/null +++ b/frontend/lib/features/home/presentation/widgets/site_form_dialog.dart @@ -0,0 +1,757 @@ +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/pubspec.lock b/frontend/pubspec.lock index 6821740..de98e94 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -75,6 +75,22 @@ packages: description: flutter source: sdk version: "0.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.2" leak_tracker: dependency: transitive description: @@ -192,6 +208,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index 325e0c1..1026dbc 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -35,6 +35,7 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 web: ^1.0.0 + http: ^1.6.0 dev_dependencies: flutter_test: sdk: flutter diff --git a/frontend/test/features/home/data/http_home_repository_test.dart b/frontend/test/features/home/data/http_home_repository_test.dart new file mode 100644 index 0000000..c4bd932 --- /dev/null +++ b/frontend/test/features/home/data/http_home_repository_test.dart @@ -0,0 +1,301 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/features/home/data/home_repository.dart'; +import 'package:frontend/features/home/data/http_home_repository.dart'; +import 'package:frontend/features/home/domain/models/home_data.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:http/http.dart' as http; +import 'package:http/testing.dart'; + +/// 和后端真实响应同构:两个一级分组,其中一个带子分组,还留一个空分组。 +const String _navJson = ''' +{ + "groups": [ + { + "id": "common", + "title": "常用推荐", + "source": "system", + "editable": false, + "sites": [ + { "id": "github", "title": "GitHub", "url": "https://github.com", + "description": "代码托管与开源社区", "categoryId": "common", + "source": "system", "order": 10, "editable": false, + "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, "editable": false, + "createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z", + "submittedBy": "" } + ], + "children": [] + }, + { + "id": "materials", + "title": "素材资源", + "source": "system", + "editable": false, + "sites": [], + "children": [ + { + "id": "design", + "title": "UI 设计", + "source": "system", + "editable": false, + "sites": [ + { "id": "figma", "title": "Figma", "url": "https://www.figma.com", + "description": "协同界面设计工具", "categoryId": "design", + "source": "system", "order": 10, "editable": false, + "createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z", + "submittedBy": "" } + ], + "children": [] + }, + { + "id": "icon", + "title": "图标库", + "source": "system", + "editable": false, + "sites": [], + "children": [] + } + ] + } + ] +} +'''; + +HttpHomeRepository repositoryReturning( + String body, { + int status = 200, + void Function(http.Request request)? onRequest, +}) { + final MockClient client = MockClient((http.Request request) async { + onRequest?.call(request); + return http.Response.bytes(utf8.encode(body), status); + }); + return HttpHomeRepository(baseUrl: 'http://api.test', client: client); +} + +void main() { + group('映射后端响应', () { + late HomeData data; + + setUp(() async { + data = await repositoryReturning(_navJson).fetchHomeData(); + }); + + test('请求打到 /api/nav', () async { + Uri? requested; + await repositoryReturning( + _navJson, + onRequest: (http.Request request) => requested = request.url, + ).fetchHomeData(); + + expect(requested.toString(), 'http://api.test/api/nav'); + }); + + test('分组变成侧栏导航树', () { + expect( + data.navNodes.map((NavNode node) => node.title), + ['常用推荐', '素材资源'], + ); + expect(data.navNodes[0].isGroup, isFalse, reason: '没有子分组就是直接跳转的条目'); + expect(data.navNodes[1].isGroup, isTrue); + expect( + data.navNodes[1].children.map((NavNode node) => node.title), + ['UI 设计', '图标库'], + ); + }); + + test('只有挂着网站的分组才进内容区', () { + expect( + data.sections.map((LinkSection section) => section.id), + ['common', 'design'], + ); + // 空分组仍然留在侧栏里,只是点了没有对应内容。 + expect(data.sectionById('icon'), isNull); + expect( + data.navLeaves.map((NavNode node) => node.id), + contains('icon'), + ); + }); + + test('子分组的内容标题带上父级', () { + expect(data.sectionById('design')!.title, '素材资源 - UI 设计'); + expect(data.sectionById('common')!.title, '常用推荐'); + }); + + test('网站按后端给的顺序展开', () { + expect( + data.sectionById('common')!.items.map((item) => item.title), + ['GitHub', '知乎'], + ); + expect(data.sectionById('design')!.items.single.url, 'https://www.figma.com'); + expect(data.sectionById('design')!.items.single.description, '协同界面设计工具'); + }); + }); + + group('错误处理', () { + test('非 200 时带出后端的说明', () async { + final HttpHomeRepository repository = repositoryReturning( + '{"error":"找不到数据"}', + status: 500, + ); + await expectLater( + repository.fetchHomeData(), + throwsA( + isA().having( + (HomeFailure failure) => failure.message, + 'message', + contains('找不到数据'), + ), + ), + ); + }); + + test('返回的不是 JSON 时给出明确提示', () async { + final HttpHomeRepository repository = repositoryReturning( + '502 Bad Gateway', + ); + await expectLater( + repository.fetchHomeData(), + throwsA( + isA().having( + (HomeFailure failure) => failure.message, + 'message', + contains('不是合法 JSON'), + ), + ), + ); + }); + + test('缺少 groups 时给出明确提示', () async { + final HttpHomeRepository repository = repositoryReturning('{"ok":true}'); + await expectLater( + repository.fetchHomeData(), + throwsA( + isA().having( + (HomeFailure failure) => failure.message, + 'message', + contains('groups'), + ), + ), + ); + }); + + test('连不上后端时给出地址', () async { + final MockClient client = MockClient( + (http.Request request) async => throw const SocketExceptionStub(), + ); + final HttpHomeRepository repository = HttpHomeRepository( + baseUrl: 'http://127.0.0.1:8080', + client: client, + ); + await expectLater( + repository.fetchHomeData(), + throwsA( + isA().having( + (HomeFailure failure) => failure.message, + 'message', + contains('连不上后端'), + ), + ), + ); + }); + + test('缺字段的条目被跳过,不会让整页失败', () async { + final HomeData data = await repositoryReturning(''' + {"groups":[{"id":"g","title":"G","sites":[ + {"id":"","title":"没有 id","url":"https://a.com"}, + {"id":"ok","title":"正常","url":"https://b.com"} + ],"children":[]}]} + ''').fetchHomeData(); + + expect(data.sectionById('g')!.items, hasLength(1)); + expect(data.sectionById('g')!.items.single.title, '正常'); + }); + }); + + group('提交网站', () { + const SubmitSiteDraft draft = SubmitSiteDraft( + title: 'Hugging Face', + url: 'https://huggingface.co', + description: '开源模型与数据集社区', + group: 'AI 工具', + subGroup: '模型社区', + submittedBy: 'an@example.com', + ); + + test('POST 到 /api/sites,字段用 camelCase', () async { + http.Request? captured; + final HttpHomeRepository repository = HttpHomeRepository( + baseUrl: 'http://api.test', + client: MockClient((http.Request request) async { + captured = request; + return http.Response.bytes(utf8.encode('{}'), 201); + }), + ); + + await repository.submitSite(draft); + + expect(captured!.method, 'POST'); + expect(captured!.url.toString(), 'http://api.test/api/sites'); + final Map body = + jsonDecode(captured!.body) as Map; + expect(body['title'], 'Hugging Face'); + expect(body['url'], 'https://huggingface.co'); + expect(body['group'], 'AI 工具'); + expect(body['subGroup'], '模型社区'); + expect(body['submittedBy'], 'an@example.com'); + expect(body.containsKey('categoryId'), isFalse, reason: '不能自己指定分组 id'); + }); + + test('后端返回 400 时抛出后端的中文说明', () async { + final HttpHomeRepository repository = repositoryReturning( + '{"error":"url 不能是本机或内网地址:localhost"}', + status: 400, + ); + await expectLater( + repository.submitSite(draft), + throwsA( + isA().having( + (HomeFailure failure) => failure.message, + 'message', + allOf(contains('400'), contains('不能是本机或内网地址')), + ), + ), + ); + }); + + test('连不上后端时给出地址', () async { + final HttpHomeRepository repository = HttpHomeRepository( + baseUrl: 'http://127.0.0.1:8080', + client: MockClient( + (http.Request request) async => throw const SocketExceptionStub(), + ), + ); + await expectLater( + repository.submitSite(draft), + throwsA( + isA().having( + (HomeFailure failure) => failure.message, + 'message', + contains('连不上后端'), + ), + ), + ); + }); + }); +} + +/// 用来模拟「连不上」的异常。 +class SocketExceptionStub implements Exception { + const SocketExceptionStub(); + + @override + String toString() => 'Connection refused'; +} diff --git a/frontend/test/features/home/data/mock_home_repository_test.dart b/frontend/test/features/home/data/mock_home_repository_test.dart index ab073d8..a93911e 100644 --- a/frontend/test/features/home/data/mock_home_repository_test.dart +++ b/frontend/test/features/home/data/mock_home_repository_test.dart @@ -7,7 +7,12 @@ import 'package:frontend/features/home/domain/models/nav_node.dart'; void main() { const MockHomeRepository repository = MockHomeRepository(); - final HomeData data = repository.fetchHomeData(); + late HomeData data; + + // 取数改成异步之后,每个用例开始前先拿一次。 + setUp(() async { + data = await repository.fetchHomeData(); + }); group('导航树(对照 index3.html 的 nav-group)', () { test('三个一级条目:一个直接跳转,两个可展开分组', () { diff --git a/frontend/test/features/home/presentation/home_page_test.dart b/frontend/test/features/home/presentation/home_page_test.dart index 436d916..94cad23 100644 --- a/frontend/test/features/home/presentation/home_page_test.dart +++ b/frontend/test/features/home/presentation/home_page_test.dart @@ -1,12 +1,22 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:frontend/core/constants/app_sizes.dart'; import 'package:frontend/core/theme/app_colors.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/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'; @@ -22,6 +32,9 @@ void main() { // 高度取 420:原型只有两段内容,视口再高就滚不动了, // 「回到顶部」这类用例也就没有意义。 Size size = const Size(900, 420), + HomeRepository repository = const MockHomeRepository(), + /// 加载中的用例要停在第一帧,不能等它 settle。 + bool settle = true, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; @@ -30,12 +43,17 @@ void main() { await tester.pumpWidget( MaterialApp( home: HomePage( - repository: const MockHomeRepository(), + repository: repository, urlOpener: urlOpener, ), ), ); - await tester.pump(); + // 数据现在来自 Future,等它落地后再断言。 + if (settle) { + await tester.pumpAndSettle(); + } else { + await tester.pump(); + } } /// 侧栏里的导航文字(避免和内容区的分区标题同名而混淆)。 @@ -319,4 +337,297 @@ void main() { expect(find.byType(SnackBar), findsNothing); }); }); + + group('加载状态', () { + testWidgets('数据没回来时显示加载提示', (WidgetTester tester) async { + final _PendingRepository repository = _PendingRepository(); + await pumpPage(tester, repository: repository, settle: false); + + expect(find.text('正在加载…'), findsOneWidget); + expect(find.byType(LinkSectionView), findsNothing); + + repository.completer.complete(mockHomeData); + await tester.pumpAndSettle(); + + expect(find.text('正在加载…'), findsNothing); + expect(find.byType(LinkSectionView), findsNWidgets(2)); + }); + + testWidgets('取数失败时显示原因和重试按钮', (WidgetTester tester) async { + await pumpPage( + tester, + repository: const _FailingRepository('连不上后端(http://127.0.0.1:8080)'), + ); + + expect(find.text('加载失败'), findsOneWidget); + expect(find.textContaining('连不上后端'), findsOneWidget); + expect(find.text('重试'), findsOneWidget); + expect(find.byType(LinkSectionView), findsNothing); + }); + + testWidgets('点重试会重新取数,成功后渲染内容', (WidgetTester tester) async { + final _FlakyRepository repository = _FlakyRepository(); + await pumpPage(tester, repository: repository); + + expect(find.text('加载失败'), findsOneWidget); + + await tester.tap(find.text('重试')); + await tester.pumpAndSettle(); + + expect(repository.calls, 2); + expect(find.text('加载失败'), findsNothing); + expect(find.byType(LinkSectionView), findsNWidgets(2)); + expect(find.byType(LinkCard), findsNWidgets(5)); + }); + }); + + 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 { + await pumpPage(tester); + + expect(find.byType(LinkCard), findsWidgets); + expect(find.byTooltip('编辑'), findsNothing); + expect(find.byTooltip('删除'), findsNothing); + }); + }); +} + +/// 返回一条「用户提交」的站点(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(); + + @override + Future fetchHomeData() => completer.future; + + @override + Future submitSite(SubmitSiteDraft draft) async {} + + @override + Future updateSite(String id, SubmitSiteDraft draft) async {} + + @override + Future deleteSite(String id) async {} +} + +/// 永远失败。 +class _FailingRepository implements HomeRepository { + const _FailingRepository(this.message); + + final String message; + + @override + Future fetchHomeData() async => throw HomeFailure(message); + + @override + Future submitSite(SubmitSiteDraft draft) async {} + + @override + Future updateSite(String id, SubmitSiteDraft draft) async {} + + @override + Future deleteSite(String id) async {} +} + +/// 第一次失败、第二次成功,用来验证重试按钮真的重新取数。 +class _FlakyRepository implements HomeRepository { + int calls = 0; + + @override + Future fetchHomeData() async { + calls++; + if (calls == 1) { + throw const HomeFailure('后端没起来'); + } + return mockHomeData; + } + + @override + Future submitSite(SubmitSiteDraft draft) async {} + + @override + Future updateSite(String id, SubmitSiteDraft draft) async {} + + @override + Future deleteSite(String id) async {} } diff --git a/frontend/test/features/home/presentation/site_form_dialog_test.dart b/frontend/test/features/home/presentation/site_form_dialog_test.dart new file mode 100644 index 0000000..5c33a66 --- /dev/null +++ b/frontend/test/features/home/presentation/site_form_dialog_test.dart @@ -0,0 +1,235 @@ +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); + }); +}