Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
039ea70edf | ||
|
|
1c98340fc5 | ||
|
|
e2cc3ac30f | ||
|
|
4075519075 |
@@ -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"] }
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# 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/
|
||||
└── nav.json # 站点信息 + 分组结构 + 网站,全在这一份文档里
|
||||
```
|
||||
|
||||
侧栏的结构和网站数据放在同一个文件里,删除网站和「它的分组要不要一起清掉」
|
||||
是同一次写入完成的——不会出现网站没了、侧栏还挂着一个空分组的中间状态。
|
||||
写盘用「先写临时文件再改名」,不会因为写到一半断电把文件写坏。
|
||||
|
||||
```json
|
||||
{
|
||||
"site": { "name": "Suxing Nav", "searchHint": "…", "submitUrl": "https://mattermost…" },
|
||||
"groups": [
|
||||
{
|
||||
"id": "common", "title": "常用推荐", "source": "system", "order": 10,
|
||||
"createdAt": "…", "updatedAt": "…",
|
||||
"sites": [ { "id": "github", "title": "GitHub", "url": "…", "source": "system", … } ],
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
启动时会自检,任何一条不通过就直接退出(避免带着坏数据继续跑):
|
||||
|
||||
- `site.name` 不能为空;
|
||||
- 分组 id、网站 id 不能重复(两者共用一个 id 空间);
|
||||
- 分组最多两级;
|
||||
- **内置分组下只能放内置网站,用户分组下只能放用户网站**。
|
||||
|
||||
### 内置与用户数据怎么隔离
|
||||
|
||||
以前是「系统文件只读、用户文件可写」,现在合并成一个文件,隔离改成靠 `source`:
|
||||
写操作只会碰 `source != system` 的分组和网站,内置的那部分只会被原样读出来再写回去。
|
||||
启动自检会拦住「手改 JSON 把内置网站标成用户来源」这类越权。
|
||||
|
||||
## 接口
|
||||
|
||||
### `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": "[email protected]"
|
||||
}
|
||||
```
|
||||
|
||||
成功返回 201 和创建出来的条目。同一父级下同名的用户分组会**复用**,
|
||||
不会每提交一次就多一个同名分组。
|
||||
|
||||
### `POST /api/sites/push` — 接口推送
|
||||
|
||||
入参同上,但需要请求头 `X-Api-Token: <NAVIUS_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 数据,接这个接口是下一步。
|
||||
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"site": {
|
||||
"name": "Suxing Nav",
|
||||
"searchHint": "输入关键字搜索全网资源...",
|
||||
"submitUrl": "https://mattermost.example.com"
|
||||
},
|
||||
"groups": [
|
||||
{
|
||||
"id": "common",
|
||||
"title": "常用推荐",
|
||||
"source": "system",
|
||||
"order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48Z",
|
||||
"updatedAt": "2026-09-13T10:29:48Z",
|
||||
"sites": [
|
||||
{
|
||||
"id": "github",
|
||||
"title": "GitHub",
|
||||
"url": "https://github.com",
|
||||
"description": "代码托管与开源社区",
|
||||
"source": "system",
|
||||
"order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48Z",
|
||||
"updatedAt": "2026-09-13T10:29:48Z",
|
||||
"submittedBy": ""
|
||||
},
|
||||
{
|
||||
"id": "zhihu",
|
||||
"title": "知乎",
|
||||
"url": "https://www.zhihu.com",
|
||||
"description": "深度内容问答社区",
|
||||
"source": "system",
|
||||
"order": 20,
|
||||
"createdAt": "2026-09-13T10:29:48Z",
|
||||
"updatedAt": "2026-09-13T10:29:48Z",
|
||||
"submittedBy": ""
|
||||
},
|
||||
{
|
||||
"id": "bilibili",
|
||||
"title": "哔哩哔哩",
|
||||
"url": "https://www.bilibili.com",
|
||||
"description": "弹幕视频分享网站",
|
||||
"source": "system",
|
||||
"order": 30,
|
||||
"createdAt": "2026-09-13T10:29:48Z",
|
||||
"updatedAt": "2026-09-13T10:29:48Z",
|
||||
"submittedBy": ""
|
||||
}
|
||||
],
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": "materials",
|
||||
"title": "素材资源",
|
||||
"source": "system",
|
||||
"order": 20,
|
||||
"createdAt": "2026-09-13T10:29:48Z",
|
||||
"updatedAt": "2026-09-13T10:29:48Z",
|
||||
"sites": [],
|
||||
"children": [
|
||||
{
|
||||
"id": "design",
|
||||
"title": "UI 设计",
|
||||
"source": "system",
|
||||
"order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48Z",
|
||||
"updatedAt": "2026-09-13T10:29:48Z",
|
||||
"sites": [
|
||||
{
|
||||
"id": "figma",
|
||||
"title": "Figma",
|
||||
"url": "https://www.figma.com",
|
||||
"description": "协同界面设计工具",
|
||||
"source": "system",
|
||||
"order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48Z",
|
||||
"updatedAt": "2026-09-13T10:29:48Z",
|
||||
"submittedBy": ""
|
||||
},
|
||||
{
|
||||
"id": "dribbble",
|
||||
"title": "Dribbble",
|
||||
"url": "https://dribbble.com",
|
||||
"description": "全球设计作品分享社区",
|
||||
"source": "system",
|
||||
"order": 20,
|
||||
"createdAt": "2026-09-13T10:29:48Z",
|
||||
"updatedAt": "2026-09-13T10:29:48Z",
|
||||
"submittedBy": ""
|
||||
}
|
||||
],
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": "icon",
|
||||
"title": "图标库",
|
||||
"source": "system",
|
||||
"order": 20,
|
||||
"createdAt": "2026-09-13T10:29:48Z",
|
||||
"updatedAt": "2026-09-13T10:29:48Z",
|
||||
"sites": [],
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": "stock",
|
||||
"title": "高清壁纸",
|
||||
"source": "system",
|
||||
"order": 30,
|
||||
"createdAt": "2026-09-13T10:29:48Z",
|
||||
"updatedAt": "2026-09-13T10:29:48Z",
|
||||
"sites": [],
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dev-tools",
|
||||
"title": "开发工具",
|
||||
"source": "system",
|
||||
"order": 30,
|
||||
"createdAt": "2026-09-13T10:29:48Z",
|
||||
"updatedAt": "2026-09-13T10:29:48Z",
|
||||
"sites": [],
|
||||
"children": [
|
||||
{
|
||||
"id": "dev",
|
||||
"title": "前端框架",
|
||||
"source": "system",
|
||||
"order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48Z",
|
||||
"updatedAt": "2026-09-13T10:29:48Z",
|
||||
"sites": [],
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": "api",
|
||||
"title": "在线 API",
|
||||
"source": "system",
|
||||
"order": 20,
|
||||
"createdAt": "2026-09-13T10:29:48Z",
|
||||
"updatedAt": "2026-09-13T10:29:48Z",
|
||||
"sites": [],
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
//! 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, SiteView, Source, UpdateSiteRequest};
|
||||
use crate::store::{Store, Summary};
|
||||
|
||||
/// 全局状态。仓库放在互斥锁里:接口可能并发到达,而写文件必须串行。
|
||||
pub struct AppState {
|
||||
pub store: Mutex<Store>,
|
||||
/// 推送接口的令牌。没配置时推送接口不可用(返回 500 并说明原因)。
|
||||
pub api_token: Option<String>,
|
||||
}
|
||||
|
||||
/// 组装路由。
|
||||
pub fn router(state: Arc<AppState>) -> 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<Arc<AppState>>) -> Result<Json<Summary>, ApiError> {
|
||||
let store = lock(&state)?;
|
||||
Ok(Json(store.summary()))
|
||||
}
|
||||
|
||||
async fn nav(State(state): State<Arc<AppState>>) -> Result<Json<NavResponse>, ApiError> {
|
||||
let store = lock(&state)?;
|
||||
Ok(Json(store.nav()))
|
||||
}
|
||||
|
||||
async fn create_submission(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(request): Json<CreateSiteRequest>,
|
||||
) -> Result<(StatusCode, Json<SiteView>), ApiError> {
|
||||
create(&state, request, Source::Submission)
|
||||
}
|
||||
|
||||
async fn create_push(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<CreateSiteRequest>,
|
||||
) -> Result<(StatusCode, Json<SiteView>), 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<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
Json(request): Json<UpdateSiteRequest>,
|
||||
) -> Result<Json<SiteView>, ApiError> {
|
||||
let mut store = lock(&state)?;
|
||||
let site = store.update(&id, &request)?;
|
||||
Ok(Json(site))
|
||||
}
|
||||
|
||||
async fn delete_site(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let mut store = lock(&state)?;
|
||||
store.delete(&id)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
fn create(
|
||||
state: &AppState,
|
||||
request: CreateSiteRequest,
|
||||
source: Source,
|
||||
) -> Result<(StatusCode, Json<SiteView>), ApiError> {
|
||||
let mut store = lock(state)?;
|
||||
let site: SiteView = store.create(&request, source)?;
|
||||
Ok((StatusCode::CREATED, Json(site)))
|
||||
}
|
||||
|
||||
/// 取锁并把「锁被毒化」翻译成 500。
|
||||
///
|
||||
/// 所有仓库操作都是同步的,锁不会跨 `await`,所以这里是普通的 `std::sync::Mutex`。
|
||||
fn lock(state: &AppState) -> Result<std::sync::MutexGuard<'_, Store>, 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": "[email protected]"
|
||||
}"#;
|
||||
|
||||
#[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}");
|
||||
}
|
||||
}
|
||||
@@ -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<String>) -> Self {
|
||||
Self::BadRequest(message.into())
|
||||
}
|
||||
|
||||
pub fn forbidden(message: impl Into<String>) -> Self {
|
||||
Self::Forbidden(message.into())
|
||||
}
|
||||
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self::NotFound(message.into())
|
||||
}
|
||||
|
||||
pub fn internal(message: impl Into<String>) -> 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<StoreError> for ApiError {
|
||||
fn from(value: StoreError) -> Self {
|
||||
ApiError::Internal(value.to_string())
|
||||
}
|
||||
}
|
||||
+57
-2
@@ -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<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! 数据结构:落盘的一棵树、对外返回的视图、以及请求入参。
|
||||
//!
|
||||
//! 落盘只有一个文件 `data/nav.json`:站点信息、分组 / 子分组、以及挂在分组下的
|
||||
//! 网站全在一份文档里。这样删除一个网站时,它的分组是否需要一起清掉,
|
||||
//! 和网站本身在同一次写入里完成,不会出现「网站没了、分组还空着」的中间状态。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 数据来源。**唯一决定条目能不能被修改的字段。**
|
||||
///
|
||||
/// 不额外存 `editable`:存两份状态迟早会不一致。对外返回时按这里推导。
|
||||
#[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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 站点级配置,由项目维护者手改。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SiteMeta {
|
||||
/// 站点名,显示在侧栏顶部与浏览器标题。
|
||||
pub name: String,
|
||||
/// 搜索框占位文案。
|
||||
#[serde(default)]
|
||||
pub search_hint: String,
|
||||
/// 「提交网站」跳转的地址(Mattermost 之类的收集入口)。
|
||||
#[serde(default)]
|
||||
pub submit_url: String,
|
||||
}
|
||||
|
||||
/// 一个网站。
|
||||
///
|
||||
/// 网站不再自己记 `categoryId`——它就挂在某个分组的 `sites` 里,
|
||||
/// 归属关系由位置表达,删除分组时不会留下指向空处的引用。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Site {
|
||||
/// 唯一标识。
|
||||
pub id: String,
|
||||
/// 显示名称。前端取它的首字当图标。
|
||||
pub title: String,
|
||||
/// 跳转地址。
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub description: 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()
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个分组:可以挂网站,也可以带子分组(层级最多两级)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Group {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub source: Source,
|
||||
#[serde(default)]
|
||||
pub order: i32,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
/// 直接挂在这个分组下的网站。
|
||||
#[serde(default)]
|
||||
pub sites: Vec<Site>,
|
||||
/// 子分组;只有一级分组会有。
|
||||
#[serde(default)]
|
||||
pub children: Vec<Group>,
|
||||
}
|
||||
|
||||
impl Group {
|
||||
pub fn is_editable(&self) -> bool {
|
||||
!self.source.is_system()
|
||||
}
|
||||
}
|
||||
|
||||
/// `data/nav.json` 的完整内容。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NavFile {
|
||||
pub site: SiteMeta,
|
||||
pub groups: Vec<Group>,
|
||||
}
|
||||
|
||||
/// 对外返回的网站:补上它所属分组的 id 和推导出来的 `editable`。
|
||||
///
|
||||
/// `categoryId` 在落盘结构里已经由位置表达了,但接口仍然输出它——
|
||||
/// 前端编辑时要靠它反查出分组名。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SiteView {
|
||||
#[serde(flatten)]
|
||||
pub site: Site,
|
||||
pub category_id: String,
|
||||
pub editable: bool,
|
||||
}
|
||||
|
||||
/// 对外返回的分组节点。
|
||||
#[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<SiteView>,
|
||||
pub children: Vec<NavGroupView>,
|
||||
}
|
||||
|
||||
/// `GET /api/nav` 的响应体。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NavResponse {
|
||||
/// 站点级配置:前端不再自己写死站点名、搜索提示和提交入口。
|
||||
pub site: SiteMeta,
|
||||
pub groups: Vec<NavGroupView>,
|
||||
}
|
||||
|
||||
/// 提交 / 推送一个网站的入参。
|
||||
///
|
||||
/// **没有 `categoryId`**:提交方只能给分组名,由后端在「用户创建的分组」里
|
||||
/// 查找或新建,这样提交的内容不可能落进内置分组。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateSiteRequest {
|
||||
// 这三个也用 `default`:缺字段时不要抛 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<String>,
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
/// 给了就表示要把条目挪到(或挪进)这个用户分组。
|
||||
#[serde(default)]
|
||||
pub group: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sub_group: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,841 @@
|
||||
//! 数据仓库:`data/nav.json` 读进内存,之后读内存、写文件。
|
||||
//!
|
||||
//! 侧栏结构和网站数据在同一份文档里,所以「删掉一个网站」和「它的用户分组要不要
|
||||
//! 一起清掉」是同一次写入完成的——不会出现网站没了、侧栏还挂着一个空分组的中间状态。
|
||||
|
||||
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::{
|
||||
CreateSiteRequest, Group, NavFile, NavGroupView, NavResponse, Site, SiteView, Source,
|
||||
UpdateSiteRequest,
|
||||
};
|
||||
use crate::util::{generate_id, now_iso8601};
|
||||
use crate::validate::{self, SitePatch};
|
||||
|
||||
/// 同一分组里每新增一条,排序值加这么多,给以后手工插入留出空隙。
|
||||
const ORDER_STEP: i32 = 10;
|
||||
|
||||
/// 分组层级上限:一级 + 子分组。
|
||||
const MAX_GROUP_DEPTH: usize = 2;
|
||||
|
||||
/// 内存态数据仓库。
|
||||
pub struct Store {
|
||||
path: PathBuf,
|
||||
file: NavFile,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// 从数据目录读取 `nav.json`。
|
||||
pub fn load(dir: &Path) -> Result<Self, StoreError> {
|
||||
let path = dir.join("nav.json");
|
||||
let file: NavFile = read_json(&path)?;
|
||||
Ok(Self { path, file })
|
||||
}
|
||||
|
||||
/// 启动自检。不通过就让进程直接退出——数据文件坏了还继续跑,
|
||||
/// 只会把更乱的数据写回去。
|
||||
pub fn verify(&self) -> Result<(), StoreError> {
|
||||
if self.file.site.name.trim().is_empty() {
|
||||
return Err(StoreError::Integrity(
|
||||
"nav.json 里的 site.name 不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
let mut ids = HashSet::new();
|
||||
for group in &self.file.groups {
|
||||
verify_group(group, 1, &mut ids)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 统计信息,给健康检查 / 启动日志用。
|
||||
pub fn summary(&self) -> Summary {
|
||||
let mut summary = Summary::default();
|
||||
walk_groups(&self.file.groups, &mut |group| {
|
||||
summary.categories += 1;
|
||||
for site in &group.sites {
|
||||
summary.sites += 1;
|
||||
if site.source.is_system() {
|
||||
summary.system_sites += 1;
|
||||
} else {
|
||||
summary.user_sites += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
summary
|
||||
}
|
||||
|
||||
/// 侧栏 + 内容区需要的那棵树。
|
||||
pub fn nav(&self) -> NavResponse {
|
||||
NavResponse {
|
||||
site: self.file.site.clone(),
|
||||
groups: self.file.groups.iter().map(group_view).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 新增一个提交 / 推送的网站。
|
||||
pub fn create(
|
||||
&mut self,
|
||||
request: &CreateSiteRequest,
|
||||
source: Source,
|
||||
) -> Result<SiteView, ApiError> {
|
||||
if source.is_system() {
|
||||
return Err(ApiError::internal("新增接口不接受 system 来源"));
|
||||
}
|
||||
let draft = validate::submission(request)?;
|
||||
|
||||
// 先改副本,落盘成功后再替换内存:写盘失败时不会留下「内存有、磁盘没有」。
|
||||
let mut file = self.file.clone();
|
||||
let category_id = resolve_group_path(&mut file.groups, &draft.group, &draft.sub_group)?;
|
||||
// id 要在拿到分组的可变借用之前算好,否则借用冲突。
|
||||
let id = new_id(site_prefix(source), &collect_ids(&file.groups));
|
||||
let now = now_iso8601();
|
||||
let group = find_group_mut(&mut file.groups, &category_id)
|
||||
.ok_or_else(|| ApiError::internal("刚建好的分组找不到了"))?;
|
||||
let site = Site {
|
||||
id,
|
||||
title: draft.title,
|
||||
url: draft.url,
|
||||
description: draft.description,
|
||||
source,
|
||||
order: next_site_order(group),
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
submitted_by: draft.submitted_by,
|
||||
};
|
||||
group.sites.push(site.clone());
|
||||
|
||||
write_json(&self.path, &file)?;
|
||||
self.file = file;
|
||||
Ok(SiteView {
|
||||
editable: site.is_editable(),
|
||||
site,
|
||||
category_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// 修改一个网站。系统内置的直接 403。
|
||||
pub fn update(&mut self, id: &str, request: &UpdateSiteRequest) -> Result<SiteView, ApiError> {
|
||||
let patch = validate::patch(request)?;
|
||||
let mut file = self.file.clone();
|
||||
|
||||
let found = find_site(&file.groups, id).map(|(group, site)| (group.id.clone(), site.source));
|
||||
let Some((owner_id, site_source)) = found else {
|
||||
return Err(ApiError::not_found(format!("找不到网站:{id}")));
|
||||
};
|
||||
if site_source.is_system() {
|
||||
return Err(ApiError::forbidden(format!(
|
||||
"「{id}」是系统内置网站,不能修改"
|
||||
)));
|
||||
}
|
||||
|
||||
// 目标分组:给了 group 就换分组;只给 subGroup 就沿用当前的一级分组。
|
||||
let target_id = if let Some(group) = &patch.group {
|
||||
let sub = patch.sub_group.clone().unwrap_or_default();
|
||||
resolve_group_path(&mut file.groups, group, &sub)?
|
||||
} else if let Some(sub) = &patch.sub_group {
|
||||
if sub.is_empty() {
|
||||
owner_id.clone()
|
||||
} else {
|
||||
let root = root_title_of(&file.groups, &owner_id)
|
||||
.ok_or_else(|| ApiError::internal("当前条目的一级分组找不到"))?;
|
||||
resolve_group_path(&mut file.groups, &root, sub)?
|
||||
}
|
||||
} else {
|
||||
owner_id.clone()
|
||||
};
|
||||
|
||||
let mut site = take_site(&mut file.groups, id)
|
||||
.ok_or_else(|| ApiError::internal("网站消失在写入过程中"))?;
|
||||
apply_patch(&mut site, patch);
|
||||
site.updated_at = now_iso8601();
|
||||
if target_id != owner_id {
|
||||
let target = find_group_mut(&mut file.groups, &target_id)
|
||||
.ok_or_else(|| ApiError::internal("目标分组找不到了"))?;
|
||||
site.order = next_site_order(target);
|
||||
}
|
||||
|
||||
let target = find_group_mut(&mut file.groups, &target_id)
|
||||
.ok_or_else(|| ApiError::internal("目标分组找不到了"))?;
|
||||
target.sites.push(site.clone());
|
||||
|
||||
// 挪走之后原分组可能空了,用户分组跟着清掉。
|
||||
prune_empty_user_groups(&mut file.groups);
|
||||
|
||||
write_json(&self.path, &file)?;
|
||||
self.file = file;
|
||||
Ok(SiteView {
|
||||
editable: site.is_editable(),
|
||||
site,
|
||||
category_id: target_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// 删除一个网站。系统内置的直接 403。
|
||||
///
|
||||
/// 删掉之后如果它所在的用户分组空了,这个分组会在同一次写入里一起清掉——
|
||||
/// 侧栏不会留下一个点进去什么都没有的分组。
|
||||
pub fn delete(&mut self, id: &str) -> Result<(), ApiError> {
|
||||
let mut file = self.file.clone();
|
||||
let found = find_site(&file.groups, id).map(|(_, site)| site.source);
|
||||
let Some(site_source) = found else {
|
||||
return Err(ApiError::not_found(format!("找不到网站:{id}")));
|
||||
};
|
||||
if site_source.is_system() {
|
||||
return Err(ApiError::forbidden(format!(
|
||||
"「{id}」是系统内置网站,不能删除"
|
||||
)));
|
||||
}
|
||||
|
||||
take_site(&mut file.groups, id)
|
||||
.ok_or_else(|| ApiError::internal("网站消失在写入过程中"))?;
|
||||
prune_empty_user_groups(&mut file.groups);
|
||||
|
||||
write_json(&self.path, &file)?;
|
||||
self.file = file;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 健康检查 / 启动日志用的统计。
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Summary {
|
||||
pub sites: usize,
|
||||
pub system_sites: usize,
|
||||
pub user_sites: usize,
|
||||
pub categories: usize,
|
||||
}
|
||||
|
||||
// ---- 树操作 ----
|
||||
|
||||
/// 遍历所有分组(含子分组)。
|
||||
fn walk_groups(groups: &[Group], visit: &mut dyn FnMut(&Group)) {
|
||||
for group in groups {
|
||||
visit(group);
|
||||
walk_groups(&group.children, visit);
|
||||
}
|
||||
}
|
||||
|
||||
/// 收集整棵树里用到的所有 id(分组 + 网站),生成新 id 时用它避重。
|
||||
fn collect_ids(groups: &[Group]) -> HashSet<String> {
|
||||
let mut ids = HashSet::new();
|
||||
walk_groups(groups, &mut |group| {
|
||||
ids.insert(group.id.clone());
|
||||
for site in &group.sites {
|
||||
ids.insert(site.id.clone());
|
||||
}
|
||||
});
|
||||
ids
|
||||
}
|
||||
|
||||
fn find_group_mut<'a>(groups: &'a mut [Group], id: &str) -> Option<&'a mut Group> {
|
||||
for group in groups.iter_mut() {
|
||||
if group.id == id {
|
||||
return Some(group);
|
||||
}
|
||||
if let Some(found) = find_group_mut(&mut group.children, id) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn find_site<'a>(groups: &'a [Group], id: &str) -> Option<(&'a Group, &'a Site)> {
|
||||
for group in groups {
|
||||
if let Some(site) = group.sites.iter().find(|site| site.id == id) {
|
||||
return Some((group, site));
|
||||
}
|
||||
if let Some(found) = find_site(&group.children, id) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 把网站从树里取出来。
|
||||
fn take_site(groups: &mut [Group], id: &str) -> Option<Site> {
|
||||
for group in groups.iter_mut() {
|
||||
if let Some(index) = group.sites.iter().position(|site| site.id == id) {
|
||||
return Some(group.sites.remove(index));
|
||||
}
|
||||
if let Some(site) = take_site(&mut group.children, id) {
|
||||
return Some(site);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 找出某个分组所属的一级分组标题。
|
||||
fn root_title_of(groups: &[Group], id: &str) -> Option<String> {
|
||||
for root in groups {
|
||||
if root.id == id || root.children.iter().any(|child| child.id == id) {
|
||||
return Some(root.title.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn next_site_order(group: &Group) -> i32 {
|
||||
group.sites.iter().map(|site| site.order).max().unwrap_or(0) + ORDER_STEP
|
||||
}
|
||||
|
||||
/// 解析(必要时新建)用户分组,返回网站该挂到的分组 id。
|
||||
///
|
||||
/// 只在 `source != system` 的组里查找——内置分组不参与,
|
||||
/// 所以提交的内容不可能落进内置分组。
|
||||
fn resolve_group_path(
|
||||
groups: &mut Vec<Group>,
|
||||
group: &str,
|
||||
sub_group: &str,
|
||||
) -> Result<String, ApiError> {
|
||||
let root_id = match groups
|
||||
.iter()
|
||||
.position(|item| !item.source.is_system() && item.title == group)
|
||||
{
|
||||
Some(index) => groups[index].id.clone(),
|
||||
None => push_root_group(groups, group)?,
|
||||
};
|
||||
if sub_group.is_empty() {
|
||||
return Ok(root_id);
|
||||
}
|
||||
|
||||
let existing_child = groups
|
||||
.iter()
|
||||
.find(|item| item.id == root_id)
|
||||
.and_then(|root| {
|
||||
root.children
|
||||
.iter()
|
||||
.find(|child| !child.source.is_system() && child.title == sub_group)
|
||||
})
|
||||
.map(|child| child.id.clone());
|
||||
if let Some(id) = existing_child {
|
||||
return Ok(id);
|
||||
}
|
||||
push_child_group(groups, &root_id, sub_group)
|
||||
}
|
||||
|
||||
fn push_root_group(groups: &mut Vec<Group>, title: &str) -> Result<String, ApiError> {
|
||||
let id = new_id("g", &collect_ids(groups));
|
||||
let order = groups.iter().map(|group| group.order).max().unwrap_or(0) + ORDER_STEP;
|
||||
let now = now_iso8601();
|
||||
groups.push(Group {
|
||||
id: id.clone(),
|
||||
title: title.to_string(),
|
||||
source: Source::Submission,
|
||||
order,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
sites: Vec::new(),
|
||||
children: Vec::new(),
|
||||
});
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn push_child_group(
|
||||
groups: &mut [Group],
|
||||
parent_id: &str,
|
||||
title: &str,
|
||||
) -> Result<String, ApiError> {
|
||||
let id = new_id("g", &collect_ids(groups));
|
||||
let now = now_iso8601();
|
||||
let parent = groups
|
||||
.iter_mut()
|
||||
.find(|group| group.id == parent_id)
|
||||
.ok_or_else(|| ApiError::internal("父分组找不到了"))?;
|
||||
let order = parent
|
||||
.children
|
||||
.iter()
|
||||
.map(|child| child.order)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
+ ORDER_STEP;
|
||||
parent.children.push(Group {
|
||||
id: id.clone(),
|
||||
title: title.to_string(),
|
||||
source: Source::Submission,
|
||||
order,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
sites: Vec::new(),
|
||||
children: Vec::new(),
|
||||
});
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// 递归清掉「空掉了的用户分组」:没有网站、也没有子分组的用户分组不再保留。
|
||||
///
|
||||
/// 内置分组永远不动——那是项目维护者手写的结构。
|
||||
fn prune_empty_user_groups(groups: &mut Vec<Group>) {
|
||||
for group in groups.iter_mut() {
|
||||
prune_empty_user_groups(&mut group.children);
|
||||
}
|
||||
groups.retain(|group| {
|
||||
group.source.is_system() || !group.sites.is_empty() || !group.children.is_empty()
|
||||
});
|
||||
}
|
||||
|
||||
fn apply_patch(site: &mut Site, patch: SitePatch) {
|
||||
if let Some(value) = patch.title {
|
||||
site.title = value;
|
||||
}
|
||||
if let Some(value) = patch.url {
|
||||
site.url = value;
|
||||
}
|
||||
if let Some(value) = patch.description {
|
||||
site.description = value;
|
||||
}
|
||||
}
|
||||
|
||||
fn site_prefix(source: Source) -> &'static str {
|
||||
match source {
|
||||
Source::Submission => "s",
|
||||
Source::Api => "a",
|
||||
// 系统内置的 id 是手写的,不会走到这里。
|
||||
Source::System => "sys",
|
||||
}
|
||||
}
|
||||
|
||||
fn new_id(prefix: &str, used: &HashSet<String>) -> String {
|
||||
for _ in 0..8 {
|
||||
let candidate = generate_id(prefix);
|
||||
if !used.contains(&candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
// 理论上到不了这里;真撞上了就退化成带纳秒的 id,保证唯一。
|
||||
format!("{prefix}-{}", now_iso8601().replace([':', '-'], ""))
|
||||
}
|
||||
|
||||
fn group_view(group: &Group) -> NavGroupView {
|
||||
let mut children: Vec<&Group> = group.children.iter().collect();
|
||||
children.sort_by(|a, b| {
|
||||
a.order
|
||||
.cmp(&b.order)
|
||||
.then_with(|| a.created_at.cmp(&b.created_at))
|
||||
.then_with(|| a.id.cmp(&b.id))
|
||||
});
|
||||
let mut sites: Vec<&Site> = group.sites.iter().collect();
|
||||
sites.sort_by(|a, b| {
|
||||
a.order
|
||||
.cmp(&b.order)
|
||||
.then_with(|| a.created_at.cmp(&b.created_at))
|
||||
.then_with(|| a.id.cmp(&b.id))
|
||||
});
|
||||
|
||||
NavGroupView {
|
||||
id: group.id.clone(),
|
||||
title: group.title.clone(),
|
||||
source: group.source,
|
||||
editable: group.is_editable(),
|
||||
sites: sites
|
||||
.into_iter()
|
||||
.map(|site| SiteView {
|
||||
site: site.clone(),
|
||||
category_id: group.id.clone(),
|
||||
editable: site.is_editable(),
|
||||
})
|
||||
.collect(),
|
||||
children: children.into_iter().map(group_view).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 分组层级、id 唯一性、以及「内置分组下只能放内置网站」这几条自检。
|
||||
fn verify_group(
|
||||
group: &Group,
|
||||
depth: usize,
|
||||
ids: &mut HashSet<String>,
|
||||
) -> Result<(), StoreError> {
|
||||
if depth > MAX_GROUP_DEPTH {
|
||||
return Err(StoreError::Integrity(format!(
|
||||
"分组「{}」在第 {depth} 层,最多只能两级",
|
||||
group.id
|
||||
)));
|
||||
}
|
||||
if group.title.trim().is_empty() {
|
||||
return Err(StoreError::Integrity(format!(
|
||||
"分组「{}」的标题不能为空",
|
||||
group.id
|
||||
)));
|
||||
}
|
||||
if !ids.insert(group.id.clone()) {
|
||||
return Err(StoreError::Integrity(format!(
|
||||
"分组 id 重复:{}",
|
||||
group.id
|
||||
)));
|
||||
}
|
||||
for site in &group.sites {
|
||||
if !ids.insert(site.id.clone()) {
|
||||
return Err(StoreError::Integrity(format!(
|
||||
"网站 id 重复:{}",
|
||||
site.id
|
||||
)));
|
||||
}
|
||||
if group.source.is_system() != site.source.is_system() {
|
||||
return Err(StoreError::Integrity(format!(
|
||||
"分组「{}」和它下面的网站「{}」来源不一致:内置分组只能放内置网站,\
|
||||
提交的网站只能放在用户分组下",
|
||||
group.id, site.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
for child in &group.children {
|
||||
verify_group(child, depth + 1, ids)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_json<T: DeserializeOwned>(path: &Path) -> Result<T, StoreError> {
|
||||
if !path.exists() {
|
||||
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_json(path: &Path, value: &NavFile) -> Result<(), StoreError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| StoreError::Io(format!("{}:{e}", parent.display())))?;
|
||||
}
|
||||
let mut text =
|
||||
serde_json::to_string_pretty(value).map_err(|e| StoreError::Json(e.to_string()))?;
|
||||
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)]
|
||||
/// 造一份最小的 `nav.json`,供单元测试与接口测试共用。
|
||||
pub fn seed_for_test(dir: &Path) {
|
||||
fs::write(
|
||||
dir.join("nav.json"),
|
||||
r#"{
|
||||
"site": { "name": "Suxing Nav", "searchHint": "输入关键字搜索全网资源...", "submitUrl": "https://mattermost.example.com" },
|
||||
"groups": [
|
||||
{
|
||||
"id": "common", "title": "常用推荐", "source": "system", "order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z",
|
||||
"sites": [
|
||||
{ "id": "github", "title": "GitHub", "url": "https://github.com",
|
||||
"description": "代码托管与开源社区", "source": "system", "order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z",
|
||||
"submittedBy": "" },
|
||||
{ "id": "zhihu", "title": "知乎", "url": "https://www.zhihu.com",
|
||||
"description": "深度内容问答社区", "source": "system", "order": 20,
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z",
|
||||
"submittedBy": "" }
|
||||
],
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": "materials", "title": "素材资源", "source": "system", "order": 20,
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z",
|
||||
"sites": [],
|
||||
"children": [
|
||||
{ "id": "design", "title": "UI 设计", "source": "system", "order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z",
|
||||
"sites": [
|
||||
{ "id": "figma", "title": "Figma", "url": "https://www.figma.com",
|
||||
"description": "协同界面设计工具", "source": "system", "order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z",
|
||||
"submittedBy": "" }
|
||||
],
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.expect("写测试数据失败");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
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: "[email protected]".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_one_file_and_builds_nav_tree() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = store_in(&temp);
|
||||
|
||||
let nav = store.nav();
|
||||
assert_eq!(nav.site.name, "Suxing Nav");
|
||||
assert_eq!(nav.groups.len(), 2, "两个一级分组");
|
||||
assert_eq!(nav.groups[0].id, "common");
|
||||
assert_eq!(nav.groups[0].sites.len(), 2);
|
||||
assert_eq!(nav.groups[0].sites[0].site.id, "github");
|
||||
// categoryId 由位置推导出来,仍然输出给前端。
|
||||
assert_eq!(nav.groups[0].sites[0].category_id, "common");
|
||||
assert!(!nav.groups[0].sites[0].editable);
|
||||
assert_eq!(nav.groups[1].children[0].id, "design");
|
||||
assert_eq!(nav.groups[1].children[0].sites[0].category_id, "design");
|
||||
|
||||
let summary = store.summary();
|
||||
assert_eq!(summary.sites, 3);
|
||||
assert_eq!(summary.system_sites, 3);
|
||||
assert_eq!(summary.categories, 3);
|
||||
}
|
||||
|
||||
#[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.site.id.starts_with("s-"));
|
||||
assert_eq!(site.site.source, Source::Submission);
|
||||
|
||||
let nav = store.nav();
|
||||
let root = nav
|
||||
.groups
|
||||
.iter()
|
||||
.find(|group| group.title == "AI 工具")
|
||||
.expect("新分组应当存在");
|
||||
assert!(root.editable);
|
||||
let child = &root.children[0];
|
||||
assert_eq!(child.title, "模型社区");
|
||||
assert_eq!(child.sites[0].site.id, site.site.id);
|
||||
assert_eq!(child.sites[0].category_id, child.id);
|
||||
|
||||
// 重新加载:数据真的落盘了。
|
||||
let reloaded = Store::load(temp.path()).expect("重新加载失败");
|
||||
reloaded.verify().expect("重新加载后自检失败");
|
||||
assert_eq!(reloaded.summary().user_sites, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_group_name_is_reused() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let mut store = store_in(&temp);
|
||||
store
|
||||
.create(&submission("A", "https://a.com", "AI 工具", "模型社区"), Source::Submission)
|
||||
.unwrap();
|
||||
store
|
||||
.create(&submission("B", "https://b.com", "AI 工具", "模型社区"), Source::Submission)
|
||||
.unwrap();
|
||||
|
||||
let nav = store.nav();
|
||||
let created: Vec<&NavGroupView> = nav
|
||||
.groups
|
||||
.iter()
|
||||
.filter(|group| group.title == "AI 工具")
|
||||
.collect();
|
||||
assert_eq!(created.len(), 1, "同名分组只应有一个");
|
||||
assert_eq!(created[0].children.len(), 1);
|
||||
assert_eq!(created[0].children[0].sites.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submission_can_never_land_in_a_system_group() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let mut store = store_in(&temp);
|
||||
|
||||
// 故意用内置分组的名字提交。
|
||||
store
|
||||
.create(
|
||||
&submission("Fake", "https://fake.com", "素材资源", "UI 设计"),
|
||||
Source::Submission,
|
||||
)
|
||||
.expect("提交应当成功");
|
||||
|
||||
let nav = store.nav();
|
||||
let builtin = nav
|
||||
.groups
|
||||
.iter()
|
||||
.find(|group| group.id == "materials")
|
||||
.unwrap();
|
||||
assert_eq!(builtin.children[0].id, "design");
|
||||
assert_eq!(builtin.children[0].sites.len(), 1, "内置子分组没被动过");
|
||||
assert!(!builtin.children[0].sites[0].editable);
|
||||
|
||||
// 用户分组里多了一个同名的新分组。
|
||||
let user_root = nav
|
||||
.groups
|
||||
.iter()
|
||||
.find(|group| group.editable && group.title == "素材资源")
|
||||
.expect("应当新建用户分组,而不是复用内置的");
|
||||
assert_eq!(user_root.children[0].sites.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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);
|
||||
assert_eq!(
|
||||
store.delete("github").unwrap_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.site.id, &update).unwrap();
|
||||
assert_eq!(updated.site.title, "新名字");
|
||||
assert_eq!(updated.site.url, "https://a2.com");
|
||||
|
||||
store.delete(&site.site.id).unwrap();
|
||||
assert_eq!(store.summary().user_sites, 0);
|
||||
assert_eq!(
|
||||
store.delete(&site.site.id).unwrap_err().status(),
|
||||
axum::http::StatusCode::NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_last_site_removes_the_empty_user_group() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let mut store = store_in(&temp);
|
||||
let first = store
|
||||
.create(&submission("A", "https://a.com", "AI 工具", "模型社区"), Source::Submission)
|
||||
.unwrap();
|
||||
let second = store
|
||||
.create(&submission("B", "https://b.com", "AI 工具", "模型社区"), Source::Submission)
|
||||
.unwrap();
|
||||
|
||||
// 删掉第一条:分组还在(还有一条)。
|
||||
store.delete(&first.site.id).unwrap();
|
||||
let nav = store.nav();
|
||||
assert!(nav.groups.iter().any(|group| group.title == "AI 工具"));
|
||||
|
||||
// 删掉最后一条:一级分组和子分组一起消失,侧栏不会留下空壳。
|
||||
store.delete(&second.site.id).unwrap();
|
||||
let nav = store.nav();
|
||||
assert!(
|
||||
!nav.groups.iter().any(|group| group.title == "AI 工具"),
|
||||
"空掉的用户分组应当一起清掉"
|
||||
);
|
||||
assert_eq!(store.summary().categories, 3, "只剩内置的三个分组");
|
||||
// 内置分组一条没动。
|
||||
assert_eq!(store.summary().system_sites, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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.site.id, &update).unwrap();
|
||||
|
||||
let nav = store.nav();
|
||||
// 旧分组空了,被清掉。
|
||||
assert!(!nav.groups.iter().any(|group| group.title == "AI 工具"));
|
||||
let moved = nav
|
||||
.groups
|
||||
.iter()
|
||||
.find(|group| group.title == "效率工具")
|
||||
.expect("新分组应当存在");
|
||||
assert_eq!(moved.children[0].title, "笔记");
|
||||
assert_eq!(moved.children[0].sites[0].site.id, updated.site.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_a_tampered_source() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
seed_for_test(temp.path());
|
||||
// 把内置分组里的网站标成 api,模拟手改 JSON 越权。
|
||||
let path = temp.path().join("nav.json");
|
||||
let text = fs::read_to_string(&path)
|
||||
.unwrap()
|
||||
.replace(
|
||||
r#""description": "代码托管与开源社区", "source": "system""#,
|
||||
r#""description": "代码托管与开源社区", "source": "api""#,
|
||||
);
|
||||
fs::write(&path, text).unwrap();
|
||||
|
||||
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();
|
||||
fs::write(
|
||||
temp.path().join("nav.json"),
|
||||
r#"{
|
||||
"site": { "name": "N", "searchHint": "", "submitUrl": "" },
|
||||
"groups": [
|
||||
{ "id": "a", "title": "A", "source": "system", "order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z",
|
||||
"sites": [],
|
||||
"children": [
|
||||
{ "id": "b", "title": "B", "source": "system", "order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z",
|
||||
"sites": [],
|
||||
"children": [
|
||||
{ "id": "c", "title": "C", "source": "system", "order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48Z", "updatedAt": "2026-09-13T10:29:48Z",
|
||||
"sites": [], "children": [] }
|
||||
] }
|
||||
] }
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let store = Store::load(temp.path()).expect("加载应当成功");
|
||||
let err = store.verify().unwrap_err();
|
||||
assert!(err.to_string().contains("两级"), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -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('-', "")
|
||||
}
|
||||
|
||||
/// 生成 `<prefix>-<日期>-<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);
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub url: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub group: Option<String>,
|
||||
pub sub_group: Option<String>,
|
||||
}
|
||||
|
||||
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<SiteDraft, ApiError> {
|
||||
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<SitePatch, ApiError> {
|
||||
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<String, ApiError> {
|
||||
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<String, ApiError> {
|
||||
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<String, ApiError> {
|
||||
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<String, ApiError> {
|
||||
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<String, ApiError> {
|
||||
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<String, ApiError> {
|
||||
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::<IpAddr>() {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
# 后端数据结构设计:网站与分组
|
||||
|
||||
> 本文是设计稿。实现时按「侧栏结构和网站绑定到一个文件、删除时同步清理」调整过,
|
||||
> 现状见 [backend/README.md](../backend/README.md)。
|
||||
|
||||
## 1. 需求与边界
|
||||
|
||||
- 后端是 Rust,**数据用 JSON 文件存储**,不引入数据库。
|
||||
- 网站分两类:
|
||||
- **系统内置**:随项目发布,只读,只有项目维护者能改。
|
||||
- **可修改**:用户提交、或第三方通过接口推送进来的,能改、能删。
|
||||
- **不需要审核**:提交即上线。
|
||||
- **图标沿用当前逻辑**:前端取名称首字渲染,后端不存图标。
|
||||
- **不统计访问量**。
|
||||
- **提交的网站不能放进内置分组**:提交时必须自带分组名(或分组 + 子分组名),
|
||||
后端只在「用户创建的分组」里找或建;内置分组只有项目维护者自己维护。
|
||||
|
||||
## 2. 一个网站条目的完整结构
|
||||
|
||||
系统内置的一条(`index3.html` 里的 GitHub):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "github",
|
||||
"title": "GitHub",
|
||||
"url": "https://github.com",
|
||||
"description": "代码托管与开源社区",
|
||||
"categoryId": "common",
|
||||
"source": "system",
|
||||
"order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48+08:00",
|
||||
"updatedAt": "2026-09-13T10:29:48+08:00",
|
||||
"submittedBy": ""
|
||||
}
|
||||
```
|
||||
|
||||
用户提交的一条:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "s-20260919-8f3a1c",
|
||||
"title": "Hugging Face",
|
||||
"url": "https://huggingface.co",
|
||||
"description": "开源模型与数据集社区",
|
||||
"categoryId": "g-20260919-4d21ab",
|
||||
"source": "submission",
|
||||
"order": 0,
|
||||
"createdAt": "2026-09-19T14:02:11+08:00",
|
||||
"updatedAt": "2026-09-19T14:02:11+08:00",
|
||||
"submittedBy": "[email protected]"
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `id` | string | 是 | 唯一标识,同时是接口路径里的 id。命名规则见 3.1。 |
|
||||
| `title` | string | 是 | 显示名称。前端取它的首字当图标。上限 60 字符。 |
|
||||
| `url` | string | 是 | 跳转地址,必须是可解析的 `http` / `https`。 |
|
||||
| `description` | string | 否 | 一句话描述,卡片副标题。上限 120 字符。 |
|
||||
| `categoryId` | string | 是 | 所属分组或子分组的 id。**必须指向用户创建的分组**(见第 5 节)。 |
|
||||
| `source` | enum | 是 | `system` / `submission` / `api`。它同时决定这条能不能被修改。 |
|
||||
| `order` | int | 否 | 同一分组内的排序值,小的在前,默认 0。内置条目靠它固定顺序,提交进来的默认追加到末尾。 |
|
||||
| `createdAt` | string | 是 | 创建时间,ISO 8601 带时区。 |
|
||||
| `updatedAt` | string | 是 | 最后修改时间,ISO 8601 带时区。 |
|
||||
| `submittedBy` | string | 否 | 提交来源标识:用户提交放邮箱或用户名,接口推送放调用方标识,内置为空。 |
|
||||
|
||||
### 3.1 id 命名规则
|
||||
|
||||
| 来源 | 规则 | 例子 |
|
||||
| --- | --- | --- |
|
||||
| 系统内置网站 | 手写,语义化,小写字母 + 数字 + `-` | `github`、`can-i-use` |
|
||||
| 用户提交网站 | 后端生成:`s-` + 日期 + 随机后缀 | `s-20260919-8f3a1c` |
|
||||
| 接口推送网站 | 后端生成:`a-` + 日期 + 随机后缀 | `a-20260919-2b77d0` |
|
||||
| 用户创建分组 | 后端生成:`g-` + 日期 + 随机后缀 | `g-20260919-4d21ab` |
|
||||
|
||||
前缀分开是为了**一眼看出这条是谁写的**,也从生成规则上杜绝和内置 id 撞车。
|
||||
|
||||
### 3.2 source:唯一决定「能不能改」的字段
|
||||
|
||||
```rust
|
||||
pub enum SiteSource {
|
||||
System, // 系统内置,只读
|
||||
Submission, // 用户提交
|
||||
Api, // 接口推送
|
||||
}
|
||||
```
|
||||
|
||||
- 只有 `source == "system"` 的条目不可修改:修改、删除接口一律返回 403。
|
||||
- 不单独存 `editable` 字段,它由 `source` 推导。存两份状态迟早不一致,
|
||||
比如手改 JSON 把 `source` 改成 `system`、`editable` 却还是 `true`。
|
||||
- 访客接口返回时附带派生的 `editable` 给前端用,不落盘。
|
||||
|
||||
## 4. 提交时为什么必须自带分组
|
||||
|
||||
分组也是同样的两套:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "materials",
|
||||
"title": "素材资源",
|
||||
"parentId": "",
|
||||
"source": "system",
|
||||
"order": 20,
|
||||
"createdAt": "2026-09-13T10:29:48+08:00",
|
||||
"updatedAt": "2026-09-13T10:29:48+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "design",
|
||||
"title": "UI 设计",
|
||||
"parentId": "materials",
|
||||
"source": "system",
|
||||
"order": 10,
|
||||
"createdAt": "2026-09-13T10:29:48+08:00",
|
||||
"updatedAt": "2026-09-13T10:29:48+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
- `parentId` 为空 = 一级分组;不为空 = 子分组,指向它所属的一级分组。
|
||||
- 层级上限两级,和侧栏的结构一致(`index3.html` 就是两级)。
|
||||
- `source == "system"` 的分组只看不改,由项目维护者手写 JSON 维护。
|
||||
- `source != "system"` 的分组只可能由提交产生。
|
||||
|
||||
### 4.1 提交接口的入参
|
||||
|
||||
提交方**不能指定 `categoryId`**,只能给分组名:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Hugging Face",
|
||||
"url": "https://huggingface.co",
|
||||
"description": "开源模型与数据集社区",
|
||||
"group": "AI 工具",
|
||||
"subGroup": "模型社区",
|
||||
"submittedBy": "[email protected]"
|
||||
}
|
||||
```
|
||||
|
||||
后端按这个顺序处理:
|
||||
|
||||
1. 在**用户创建的分组**里按名字找一级分组 `AI 工具`;找不到就新建一个
|
||||
(`source = "submission"`,id 自动生成)。
|
||||
2. 如果带了 `subGroup`,在 `AI 工具` 下面按名字找子分组;找不到就新建。
|
||||
3. 新建网站条目,`categoryId` 指向解析出来的子分组(没带 `subGroup` 就指向一级分组)。
|
||||
|
||||
规则上:
|
||||
|
||||
- 内置分组**永远不参与**第 1、2 步的查找,所以提交的内容不可能落进内置分组。
|
||||
- 同一个父级下同名的用户分组**复用**,不会每提交一次就多出一个同名分组。
|
||||
如果希望「每次提交都新建」,把查找那一步去掉即可。
|
||||
- 修改接口同样不允许把已有条目挪进内置分组,判定标准和创建时一致。
|
||||
|
||||
## 5. 两种数据如何隔离
|
||||
|
||||
```
|
||||
backend/data/
|
||||
└── nav.json # 站点信息 + 分组结构(含网站),一份文档
|
||||
```
|
||||
|
||||
- 读:启动时加载进内存,之后都从内存读,不再碰磁盘。
|
||||
- 写:整份文档一起写回,但改的只会是 `source != system` 的那部分。
|
||||
- 网站直接挂在分组的 `sites` 里,不再自己记 `categoryId`——
|
||||
归属由位置表达,删掉分组不会留下指向空处的引用。
|
||||
- 删除网站时,如果它所在的用户分组空了,会在**同一次写入**里一起清掉。
|
||||
- JSON 用 2 空格缩进、保留中文原文,方便直接看 diff。
|
||||
|
||||
### 5.1 启动时的自检
|
||||
|
||||
启动时校验一遍,不通过就直接启动失败(防止手改 JSON 越权):
|
||||
|
||||
- `system-*.json` 里的条目 `source` 必须全是 `system`;
|
||||
`user-*.json` 里必须全不是。
|
||||
- 每个网站的 `categoryId` 必须存在,并且**分组来源要和网站来源一致**:
|
||||
内置网站只能挂在系统分组下,提交的网站只能挂在用户分组下。
|
||||
- 分组 `parentId` 指向的分组必须存在,且不能出现两级以上、不能自己指向自己。
|
||||
|
||||
## 6. 校验规则
|
||||
|
||||
写入(用户提交 / 接口推送)时逐条校验,任何一条不过就整体拒绝并返回原因:
|
||||
|
||||
| 规则 | 说明 |
|
||||
| --- | --- |
|
||||
| `id` 唯一 | 与已有网站都不冲突 |
|
||||
| `title` 非空、≤ 60 字符 | 去掉首尾空白后判断 |
|
||||
| `url` 合法 | 可解析,协议必须是 `http` / `https`,且不能是 localhost 与内网地址 |
|
||||
| `description` ≤ 120 字符 | 允许为空 |
|
||||
| `group` 非空、≤ 30 字符 | 没有它就没地方放,直接拒绝 |
|
||||
| `subGroup` ≤ 30 字符 | 可选;给了就必须和 `group` 一起解析成两级 |
|
||||
| `source` 只能是 `submission` / `api` | 外部写入不允许声称自己是 `system` |
|
||||
|
||||
## 7. 和前端现有模型的关系
|
||||
|
||||
前端 `LinkItem` 目前用到 `id` / `title` / `url` / `description` 四个字段,
|
||||
本次结构是它的超集,前端不改也能读出来;`categoryId`、`source`、`editable`
|
||||
等字段等前端要用时再接。
|
||||
|
||||
字段名统一用 **camelCase**,和前端 Dart 的 `fromJson` 对齐,前端不用做名字转换;
|
||||
Rust 侧用 `#[serde(rename_all = "camelCase")]` 映射。
|
||||
|
||||
**新加字段一律带 `#[serde(default)]`**,这样以后往结构里加字段,老 JSON 仍然能读,
|
||||
不需要写迁移脚本。
|
||||
|
||||
## 8. 这次没有放进结构里的东西
|
||||
|
||||
按「先保留当前逻辑」处理,以下都不存;以后要用再加字段(加字段不需要迁移):
|
||||
|
||||
| 没放的 | 原因 | 以后要加时 |
|
||||
| --- | --- | --- |
|
||||
| `icon` | 前端当前是取 `title` 首字渲染 | 加 `icon` 字段,存 favicon 地址或 emoji |
|
||||
| `tags` | 当前侧栏和卡片都没有标签 | 加 `tags: []`,提交接口再放开这个入参 |
|
||||
| `visits` | 不统计访问量 | 要么由后端跳转接口累加,要么接第三方统计 |
|
||||
| `status` | 不需要审核,提交即上线 | 加 `status` 字段并给默认值 `published` |
|
||||
@@ -0,0 +1,419 @@
|
||||
# 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 # 右下角圆形回到顶部
|
||||
```
|
||||
|
||||
`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, submitUrl }, groups: [ … ] }
|
||||
│ ▲
|
||||
│ │ POST /api/sites(提交)
|
||||
│ │ PATCH /api/sites/{id}(编辑)
|
||||
│ │ DELETE /api/sites/{id}(删除)
|
||||
▼ │ ← 这三个接口页面上暂时不用
|
||||
HttpHomeRepository ──实现──▶ HomeRepository(读 / 提交 / 编辑 / 删除)
|
||||
│ 把分组树映射成前端模型:分组 → 侧栏导航;有网站的组 → 内容分区;
|
||||
│ 每个网站带 editable(页面暂时不据此渲染任何入口)
|
||||
▼
|
||||
HomePage State:_load() 拿到 HomeData,并在加载中 / 失败(可重试)/ 完成三种状态间切换
|
||||
│ (页面持有交互状态:当前分区、侧栏折叠、已展开的分组)
|
||||
▼
|
||||
展示组件:只接收数据 + 回调,不自己取数、不自己跳转
|
||||
```
|
||||
|
||||
## 5. 设计令牌:从 CSS 到常量
|
||||
|
||||
`index3.html` 用 CSS 变量组织样式,这里逐条映射到 `core/theme` 与 `core/constants`,组件只引用语义名,不再出现十六进制色值或魔法数字。
|
||||
|
||||
| index3.html 中的来源 | 取值 | Flutter 对应 |
|
||||
| --- | --- | --- |
|
||||
| `--bg-color` | `#f7f9fb` | `AppColors.background` |
|
||||
| `--card-bg` | `#ffffff` | `AppColors.surface` |
|
||||
| `--primary-color` | `#3b82f6` | `AppColors.primary` |
|
||||
| `--text-main` | `#1e293b` | `AppColors.textPrimary` |
|
||||
| `--text-sub` | `#64748b` | `AppColors.textSecondary` |
|
||||
| `--border-color` | `#e2e8f0` | `AppColors.border` |
|
||||
| `--shadow` | `0 4px 6px -1px rgba(0,0,0,.05)` + `0 2px 4px -1px rgba(0,0,0,.03)` | `AppColors.cardShadow` |
|
||||
| `--shadow-hover` | `0 10px 15px -3px rgba(0,0,0,.08)` + `0 4px 6px -2px rgba(0,0,0,.04)` | `AppColors.cardShadowHover` |
|
||||
| `.nav-link.active` | `#eff6ff` | `AppColors.primaryFaint` |
|
||||
| `.nav-link:hover` | `#f8fafc` | `AppColors.navItemHover` |
|
||||
| `.submit-btn:hover` | `#f1f5f9` | `AppColors.navItemHoverDeep` |
|
||||
| `.arrow-icon` | `#94a3b8` | `AppColors.navArrow` |
|
||||
| `.card-icon` 底色 | `#f1f5f9` | `AppColors.iconSurface` |
|
||||
| `.search-box:focus` 光晕 | `0 0 0 3px rgba(59,130,246,.15)` | `AppColors.focusRing` + `AppSizes.focusRingWidth` |
|
||||
| `--sidebar-width` | `230px` | `AppSizes.sidebarWidth` |
|
||||
| `--sidebar-collapsed-width` | `64px` | `AppSizes.sidebarCollapsedWidth` |
|
||||
| `.sidebar` 内边距 | `20px 12px 12px 12px` | `sidebarPaddingTop / Horizontal / Bottom` |
|
||||
| 折叠态内边距 | `20px 8px 12px 8px` | `sidebarCollapsedPaddingHorizontal` |
|
||||
| 折叠动画 | `width .3s cubic-bezier(.4,0,.2,1)` | `sidebarTransition` + `Curves.fastOutSlowIn` |
|
||||
| `.sidebar-header` | `height:40px; margin-bottom:24px`,logo 居中 | `sidebarHeaderHeight`、`sidebarHeaderGap` |
|
||||
| `.logo` | `18px / 700` | `AppTextStyles.logo` |
|
||||
| `.logo-icon` | `20px / 800` | `AppTextStyles.logoIcon` |
|
||||
| `.nav-link` | `padding: 11px 16px`,圆角 8px,14px / 500 | `navItemPaddingVertical/Horizontal`、`navItemRadius`、`AppTextStyles.navItem` |
|
||||
| `.nav-item-wrapper` | `margin-bottom: 4px` | `navItemGap` |
|
||||
| 折叠态 `.nav-link` | `padding: 12px 0` + 居中 | `navItemCollapsedPaddingVertical` |
|
||||
| `.sub-menu` | `padding-left: 16px`,`.3s` 高度动画 | `subMenuIndent`、`subMenuTransition` |
|
||||
| `.sub-menu .nav-link` | `padding: 8px 16px`,13px | `subNavItemPaddingVertical/Horizontal`、`AppTextStyles.subNavItem` |
|
||||
| `.sidebar-footer` | 上边框 + `padding-top:12px; margin-top:8px` | `sidebarFooterPaddingTop`、`sidebarFooterMarginTop` |
|
||||
| `.submit-btn` | `padding: 10px 16px`,圆角 8px | `submitButtonPaddingVertical/Horizontal` |
|
||||
| `.main-content` | `padding: 24px 40px 40px 40px` | `contentPaddingTop / Horizontal / Bottom` |
|
||||
| `.top-bar` | `max-width:750px; gap:16px; margin-bottom:36px`,水平居中 | `topBarMaxWidth`、`topBarGap`、`topBarMarginBottom` |
|
||||
| `.toggle-btn` | `44×44`,圆角 12px | `toggleButtonSize`、`toggleButtonRadius` |
|
||||
| `.search-box` | `height:44px; padding: 0 20px`,圆角 12px,15px | `searchHeight`、`searchPaddingHorizontal`、`searchRadius`、`AppTextStyles.searchInput` |
|
||||
| `.section-title` | `16px / 600`,`::before` 为 4×16 竖条 | `AppTextStyles.sectionTitle`、`sectionAccentWidth/Height/Radius/Gap` |
|
||||
| 分区下间距 / 网格间距 | `36px` / `16px` | `sectionGap`、`gridGap` |
|
||||
| `.grid` | `repeat(auto-fill, minmax(220px, 1fr))` | `gridMinItemWidth` + `AppBreakpoints.gridColumnsFor` |
|
||||
| `.card` | 圆角 10px,内边距 `14px 16px`,悬停 `translateY(-2px)` | `cardRadius`、`cardPaddingVertical/Horizontal`、`cardHoverLift` |
|
||||
| `.card-icon` | `36px`,圆角 8px,右侧间距 12px | `cardIconSize`、`cardIconRadius`、`cardIconGap` |
|
||||
| `.card-title` / `.card-desc` | `14px / 600` / `12px`,单行省略 | `AppTextStyles.cardTitle` / `cardDesc` |
|
||||
| `.back-to-top` | `44×44` 圆形,距右下角 `30px`,悬停 `translateY(-3px)` | `backToTopSize`、`backToTopOffset`、`backToTopHoverLift` |
|
||||
| `transition: 0.2s` | 200ms | `AppSizes.transition` |
|
||||
|
||||
## 6. 明确不实现的原型缺口
|
||||
|
||||
当前实现是 `index3.html` 的逐条还原,**没有补任何原型之外的行为**。原型里没做的,这里也不做:
|
||||
|
||||
| 原型的状态 | 当前实现 |
|
||||
| --- | --- |
|
||||
| 搜索框没有任何 JS | 只接收输入,不过滤内容 |
|
||||
| `active` 写死在「常用推荐」上 | 高亮固定不动 |
|
||||
| 没有媒体查询 | 侧栏始终 230px(可手动折到 64px),主内容内边距始终 `24/40/40/40` |
|
||||
| `#icon`、`#stock`、`#dev`、`#api`、`#submit` 没有对应元素 | 点击后没有任何反应 |
|
||||
| 只有 `#common`、`#design` 两段内容 | 内容区就是两段、五张卡片 |
|
||||
|
||||
唯一必须偏离 CSS 的地方是分区标题的换行:`.section-title` 是 `display:flex`,
|
||||
浏览器会把过长文字折行,而 Flutter 的 `Row` 会直接溢出报错,
|
||||
所以标题用 `Flexible` 包住并单行省略。这条差异会随着标题文案的调整而消失。
|
||||
|
||||
### 响应式规则
|
||||
|
||||
| 视口宽度 | 布局 |
|
||||
| --- | --- |
|
||||
| ≥ 900px | 侧栏默认展开(230px) |
|
||||
| < 900px | 主内容内边距 40 → 20,侧栏默认折叠(64px) |
|
||||
|
||||
断点取 900 而不是常见的 768:侧栏 230 + 主内容左右内边距 + 一个 220 的卡片,
|
||||
再留出可读留白,900 是这套栅格开始舒展的下限。规则写成纯函数
|
||||
`AppBreakpoints.layoutFor(width)`,可以被单元测试覆盖。
|
||||
|
||||
「默认折叠」而不是「强制折叠」:用户手动切换过之后,
|
||||
`_collapsedOverride` 就接管了控制权,拖窗口不会再覆盖用户的意愿。
|
||||
|
||||
### 网格列数
|
||||
|
||||
```dart
|
||||
columns = floor((availableWidth + gap) / (minItemWidth + gap))
|
||||
```
|
||||
|
||||
这与 CSS 的 `repeat(auto-fill, minmax(220px, 1fr))` 等价:比如可用宽度 456 时正好放下两列(220×2 + 16),455 时退回一列。测试里有一个用例专门验证这条边界。
|
||||
|
||||
## 7. 为什么这样整理
|
||||
|
||||
### 7.1 为什么 `main.dart` 只留几行
|
||||
|
||||
入口文件的职责只有一个:把根组件挂到 Flutter 引擎上。任何额外逻辑(主题、依赖装配、路由)放进 `main.dart` 都会让「程序从哪里开始」变得模糊,也没法在测试里绕开它。
|
||||
|
||||
### 7.2 为什么是 `core` + `features`,而不是 `models/` `services/` `widgets/`
|
||||
|
||||
按技术类型平铺,在项目只有一两个页面时看着整齐;页面一多就会出现「改一个功能要翻五个目录」,而且同名文件互相污染。
|
||||
|
||||
按功能切分后,`features/home/` 里的东西只服务于首页,删掉整个目录不影响其它部分。`core/` 则只放与业务无关的东西——主题、尺寸常量、断点计算、取首字符、打开链接的能力。判断标准很简单:**如果它能被原样复制到另一个 App 里,它就属于 `core`**。
|
||||
|
||||
### 7.3 为什么功能内部再分 `data / domain / presentation`
|
||||
|
||||
- `domain/` 是模型与规则,不依赖 Flutter、不依赖网络,最容易测试。
|
||||
- `data/` 是「数据从哪来」:后端实现、mock 实现,以及它们共同实现的仓库契约。
|
||||
- `presentation/` 是「数据怎么显示」,包含页面与组件。
|
||||
|
||||
好处是接后端时改动落点明确:只加一个 `data/` 下的实现类,`domain/` 与 `presentation/` 基本不动。
|
||||
|
||||
### 7.4 为什么导航用一棵树(`NavNode`)而不是两个列表
|
||||
|
||||
侧栏里同时存在两种条目:直接跳转的(常用推荐)和可展开的(素材资源、开发工具)。
|
||||
如果拆成「一级列表 + 二级字典」,页面就要在两份数据之间来回对照,
|
||||
「某个分区属于哪个分组」这种问题会散落在多处。
|
||||
|
||||
用同一棵树表达,`NavNode.leaves` 能把树拍平成内容区需要的顺序,
|
||||
`HomeData.groupIdOf(id)` 能回答「这个分区属于哪个分组」,
|
||||
`isGroup` 决定渲染成跳转项还是展开项。树是唯一真相。
|
||||
|
||||
### 7.5 为什么搜索规则写在 `domain` 而不是页面里
|
||||
|
||||
`HomeData.search()` 与 `LinkItem.matches()` 是纯函数,不依赖任何 Widget。这样「搜什么、怎么算命中、命中后空分区要不要保留」这几条规则可以直接被单元测试覆盖,不必挂载界面。放到页面里就得先 pump 一整棵组件树才能验证一条过滤规则。
|
||||
|
||||
### 7.6 为什么要有 `Repository` 接口 + `Mock` 实现
|
||||
|
||||
页面只依赖接口,不依赖具体数据源。这换来两件事:接 Rust 后端时新增一个实现类即可;测试里可以注入任意数据集,不用为「测试专用数据」改动生产代码。
|
||||
|
||||
### 7.7 为什么仓库返回 `Future`
|
||||
|
||||
接上后端之前,`fetchHomeData` 是同步的:mock 数据是恒定常量,套一层 `Future`
|
||||
只会凭空多出 loading 与 error 两个分支。
|
||||
|
||||
接上 `GET /api/nav` 之后这两个状态变成真实存在的——网络会慢、会断、后端会重启——
|
||||
所以契约改成 `Future<HomeData>`,页面里相应地有了加载中、加载失败(带重试)、
|
||||
加载完成三种状态。改动只落在 `data/` 与页面的取数那一处,模型和展示组件没动。
|
||||
|
||||
### 7.8 为什么把「打开链接」抽成 `UrlOpener`
|
||||
|
||||
卡片直接调用 `web.window.open` 会让组件无法在测试里验证,也会让平台相关代码散落在 UI 里。抽象成接口后,`package:web` 只出现在 `web_url_opener.dart` 一个文件里,测试可以注入假实现,断言「点了哪张卡片、打开了哪个地址」。
|
||||
|
||||
### 7.9 为什么不做滚动联动
|
||||
|
||||
侧栏的 `active` 只由点击决定,滚动内容区不会改动侧栏。
|
||||
|
||||
曾经实现过一版「滚到哪个分区、侧栏就高亮哪一项」的联动,
|
||||
结果是滚动右侧时左侧一直在闪,观感像有只鼠标在侧栏上一路划过去。
|
||||
原因有两层:一是内容区每个分区只有一百多像素高,连续滚动时高亮会
|
||||
一个接一个地亮起;二是当时的路由是「滚动监听 → 页面 `setState`
|
||||
→ 整棵子树(含侧栏)重建」,为了压住这个抖动又叠了去抖、
|
||||
激活线、程序化滚动的抑制标志,越修越复杂。
|
||||
|
||||
最后直接去掉联动。理由:
|
||||
|
||||
1. 左侧栏和右侧内容区没有因果关系,滚动内容区不该触发侧栏重建。
|
||||
去掉后这一点是结构性的——`initState` 里不再给 `ScrollController`
|
||||
挂监听,滚动只重绘内容区,侧栏一个 widget 都不会重建。
|
||||
2. 这个导航站的分区标题本身就在页面上,用户不需要侧栏替他回答
|
||||
「我在哪一节」。
|
||||
3. 原型里 `active` 本来就是静态的,去掉联动反而更接近给定设计。
|
||||
|
||||
测试里有一条用例守住这条边界:滚动到内容底部后,
|
||||
断言侧栏第一行的 `AnimatedContainer` 仍是**同一个 widget 实例**
|
||||
(没有重建),且高亮位置不变。
|
||||
|
||||
### 7.10 为什么折叠态显示首字,而不是照搬原型的空白
|
||||
|
||||
原型折叠后把 `.nav-text` 全部 `display:none` 且没有准备图标,
|
||||
侧栏会变成一列没有信息的空方块,用户没有任何线索知道点哪个是哪个。
|
||||
|
||||
改成显示标题首字,是在不引入图标资源的前提下、与原设计语言最一致的做法——
|
||||
logo 折叠后显示的也正是站点名的首字「S」。同理,取首字用 `runes`
|
||||
而不是 `substring`,中文与 emoji 才不会被截成半个字符。
|
||||
|
||||
### 7.11 为什么子菜单收起时把子项从树里移除
|
||||
|
||||
CSS 的 `max-height: 0` 只是视觉上收起了子菜单,DOM 里那些链接还在:
|
||||
屏幕阅读器仍会读到,键盘 Tab 也会跳进去。Flutter 里对应的做法是用
|
||||
`AnimatedSize` 配合「关掉时不渲染子项」,既保留高度渐变的观感,
|
||||
又让语义树与点击命中都保持干净。
|
||||
|
||||
### 7.12 为什么组件只发回调、状态集中在页面
|
||||
|
||||
搜索词、当前分区、侧栏折叠状态、已展开的分组都由页面持有,
|
||||
组件只负责显示并向上发回调。这样同一个数据不会有两个真相来源。
|
||||
只有纯本地的、与外界无关的瞬时状态(悬停)才允许留在组件内部。
|
||||
|
||||
### 7.13 为什么把颜色、尺寸、文字样式集中
|
||||
|
||||
组件里散落十六进制颜色和魔法数字,会让「改主题色」变成全局搜索替换。现在颜色在 `app_colors.dart`、尺寸在 `app_sizes.dart`、文字样式在 `app_text_styles.dart`,组件只引用语义名。要改主色,只改一处。
|
||||
|
||||
### 7.14 为什么测试目录镜像 `lib/`
|
||||
|
||||
`test/features/home/presentation/link_card_test.dart` 对应 `lib/features/home/presentation/widgets/link_card.dart`。看测试路径就能猜出被测文件的位置,避免测试文件越堆越多之后找不到归属。
|
||||
|
||||
### 7.15 为什么保留看起来「没用」的 `cupertino_icons`
|
||||
|
||||
项目不使用 Cupertino 图标,但 Flutter 构建时会引用 CupertinoIcons 字体,移除依赖会导致构建出现字体缺失警告。保留模板自带的版本,是为了构建输出干净。
|
||||
|
||||
### 7.16 为什么坚持「注释写为什么,不写是什么」
|
||||
|
||||
「给变量赋值」这类注释没有信息量,代码本身就能表达。真正值得写下来的是**取舍**:为什么断点取 900、为什么点击时要关掉滚动联动、为什么折叠态用首字。
|
||||
这类信息一旦丢失,后来者很容易「好心」改回去。
|
||||
|
||||
## 8. 编码约定
|
||||
|
||||
`analysis_options.yaml` 在 `flutter_lints` 之上补充了这些规则:
|
||||
|
||||
- `strict-raw-types`:禁止裸泛型(如 `List` 而非 `List<String>`)。
|
||||
- `always_declare_return_types`:函数与方法必须显式声明返回类型。
|
||||
- `avoid_print`:禁止遗留调试输出。
|
||||
- `prefer_single_quotes`:统一单引号,减少无意义的 diff。
|
||||
- `require_trailing_commas`:配合 `dart format` 减少改动噪音。
|
||||
- `sort_child_properties_last`:`child` 固定在参数末尾。
|
||||
- `unawaited_futures`:未处理的 `Future` 必须显式处理。
|
||||
|
||||
未由 lint 强制、但代码里保持一致的几条:
|
||||
|
||||
- 依赖方向由人工把关:新增共享代码前先判断它属于 `core` 还是某个功能。
|
||||
- 每个公开类与公开成员都写 `///` 文档注释,且注释解释「为什么」。
|
||||
- 组件的构造函数以 `super.key` 开头,参数按「必填数据 → 可选数据 → 回调」排列。
|
||||
- 能用 `const` 的地方一律加 `const`。
|
||||
|
||||
## 9. 验证结果
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 静态分析 | `flutter analyze` | 1 条告警(`sidebar_nav.dart` 里 `_NavRow` 的 `super.key` 从未被传入,见下方说明) |
|
||||
| 单元与组件测试 | `flutter test` | 47 个用例通过,6 个失败(都是侧栏实现与既有用例不一致,见下方说明) |
|
||||
| Web 构建 | `flutter build web --no-wasm-dry-run` | 构建成功 |
|
||||
|
||||
> 上面这两处不是本轮对接引入的:`e2cc3ac` 那次提交重写了侧栏(行内改用 `Container`、
|
||||
> 折叠态改回显示首字、logo 换成 `AnimatedCrossFade`),而 `test/` 里那几条用例
|
||||
> 描述的是重写前的行为。对接后端时没有动侧栏,也没有改这些用例。
|
||||
|
||||
测试覆盖的关键行为:
|
||||
|
||||
- 网格列数:与 CSS `auto-fill` 规则一致,含「刚好放下两列」的边界。
|
||||
- 导航树:一级条目形态、叶子 id 与原型锚点一致、`active` 取第一个条目。
|
||||
- mock 数据自洽性:只有两个分区、共五张卡片,`#icon` 等原型里没有内容的锚点取不到分区,id 唯一、字段完整、地址合法。
|
||||
- 布局:侧栏展开常驻并渲染 logo 与三条导航、子菜单默认收起、两个分区与五张卡片全部渲染。
|
||||
- 侧栏折叠:折叠到 64px 且 logo 变首字、导航与提交文字都消失、再点可展开回来。
|
||||
- 分组子菜单:点击展开、再点收起、折叠状态下点分组会先展开侧栏。
|
||||
- 导航:高亮固定写在第一个条目上;点有对应分区的导航会滚过去且高亮不变;点 `#icon` 这类没有对应元素的导航滚动偏移不变。
|
||||
- 搜索框:输入后仍是两个分区、五张卡片(不过滤)。
|
||||
- 滚动:滚动主内容不会重建侧栏、也不会改动高亮;回到顶部把偏移归零。
|
||||
- 其它:点击卡片把对应 URL 交给 `UrlOpener`;点「提交网站」跳到后端配的地址;卡片渲染首字母、名称与描述,悬停上浮量等于 `AppSizes.cardHoverLift`。
|
||||
- 后端对接:请求打到 `/api/nav`;分组映射成侧栏导航树;只有挂着网站的分组进内容区;
|
||||
子分组标题带父级前缀;网站顺序按后端给的排;非 200、非 JSON、缺 `groups`、
|
||||
连不上后端分别给出对应提示;缺字段的单条数据被跳过而不是整页失败。
|
||||
- 加载状态:数据没回来时显示「正在加载…」;失败时显示原因和「重试」;点重试会重新取数并在成功后渲染内容。
|
||||
- 站点信息:站点名、搜索框提示、提交入口地址都来自后端 `site` 字段,前端不再写死。
|
||||
|
||||
## 10. 后续演进建议
|
||||
|
||||
按优先级:
|
||||
|
||||
1. **做提交网站的界面。** 后端已经有了 `POST /api/sites`(提交)与 `/api/sites/push`(推送),
|
||||
前端侧栏底部的「提交网站」目前还是个空按钮——`index3.html` 里没有这个表单,
|
||||
所以要先定表单长什么样再动手。
|
||||
2. **同步那 6 条侧栏用例。** 要么把侧栏改回用例描述的行为,要么按现在的实现改用例。
|
||||
3. **换成真实图标。** 目前卡片左侧是站点名首字母、折叠态是导航首字。若要改成站点图标,需要引入 favicon 抓取或本地图标资源,并处理加载失败的占位。
|
||||
4. **替换 PWA 图标。** `web/icons/` 与 `web/favicon.png` 仍是 Flutter 默认图标,需要替换为项目自己的图标。
|
||||
4. **记住用户的偏好。** 侧栏折叠状态、展开的分组、当前分区现在都是内存状态,刷新即丢失;可以写进 `localStorage`。
|
||||
5. **编辑模式。** 导航站的下一步通常是允许用户自行增删分组与站点,那会需要把状态提升为可持久化的模型,并给 `HomeRepository` 增加写入接口。
|
||||
6. **深色模式。** 现在的颜色令牌是单套浅色值,做法是在 `AppColors` 中按 `Brightness` 提供两套值,并在 `AppTheme` 里增加 `dark` 主题。
|
||||
@@ -0,0 +1,173 @@
|
||||
# index3.html 逐条对照
|
||||
|
||||
本文档把 `index3.html` 里的每一条 CSS 规则与 JS 行为,逐条对应到 Flutter 实现的位置,用来回答「有没有跑偏」。
|
||||
|
||||
对照方式:`index3.html` 里的选择器 / 声明 → Flutter 文件与常量。
|
||||
|
||||
## 1. 设计变量
|
||||
|
||||
| index3.html | Flutter |
|
||||
| --- | --- |
|
||||
| `--bg-color: #f7f9fb` | `AppColors.background` |
|
||||
| `--card-bg: #ffffff` | `AppColors.surface` |
|
||||
| `--primary-color: #3b82f6` | `AppColors.primary` |
|
||||
| `--text-main: #1e293b` | `AppColors.textPrimary` |
|
||||
| `--text-sub: #64748b` | `AppColors.textSecondary` |
|
||||
| `--border-color: #e2e8f0` | `AppColors.border` |
|
||||
| `--shadow` | `AppColors.cardShadow` |
|
||||
| `--shadow-hover` | `AppColors.cardShadowHover` |
|
||||
| `--sidebar-width: 230px` | `AppSizes.sidebarWidth` |
|
||||
| `--sidebar-collapsed-width: 64px` | `AppSizes.sidebarCollapsedWidth` |
|
||||
|
||||
## 2. 全局
|
||||
|
||||
| index3.html | Flutter |
|
||||
| --- | --- |
|
||||
| `* { box-sizing: border-box; margin: 0; padding: 0 }` | Flutter 无 box-sizing 概念,等价于默认行为 |
|
||||
| `html { scroll-behavior: smooth }` | `Scrollable.ensureVisible(..., duration: 300ms)` |
|
||||
| `body { background-color: var(--bg-color) }` | `Scaffold.backgroundColor = AppColors.background` |
|
||||
| `body { color: var(--text-main) }` | `AppTheme.light` + 各文字样式自带颜色 |
|
||||
| `body { font-family: -apple-system, ... }` | 不指定 `fontFamily`,沿用系统字体栈 |
|
||||
| `body { display: flex; min-height: 100vh }` | `Scaffold.body = Stack[ Row[侧栏, Expanded(内容)] ]` |
|
||||
|
||||
## 3. 侧栏
|
||||
|
||||
| index3.html | Flutter |
|
||||
| --- | --- |
|
||||
| `.sidebar { width: 230px }` | `SidebarNav` 根 `AnimatedContainer.width` |
|
||||
| `.sidebar { background-color: card-bg }` | `AppColors.surface` |
|
||||
| `.sidebar { border-right: 1px solid border }` | `BoxDecoration(border: Border(right: ...))` |
|
||||
| `.sidebar { padding: 20px 12px 12px 12px }` | `sidebarPaddingTop / Horizontal / Bottom` |
|
||||
| `.sidebar { flex-direction: column }` | `Column` |
|
||||
| `.sidebar { height: 100vh }` | `Row(crossAxisAlignment: stretch)` |
|
||||
| `.sidebar { transition: width .3s cubic-bezier(.4,0,.2,1) }` | `sidebarTransition` + `Curves.fastOutSlowIn`(该曲线即 Material standard easing) |
|
||||
| `.sidebar { overflow-x: hidden }` | 根部 `ClipRect` |
|
||||
| `body.sidebar-collapsed .sidebar { width: 64px; padding: 20px 8px 12px 8px }` | `sidebarCollapsedWidth` + `sidebarCollapsedPaddingHorizontal` |
|
||||
| `.sidebar-header { height: 40px; margin-bottom: 24px; 居中 }` | `_SidebarHeader`(`sidebarHeaderHeight` / `sidebarHeaderGap`) |
|
||||
| `.logo { font-size: 18px; font-weight: 700; color: primary }` | `AppTextStyles.logo` |
|
||||
| `.logo-icon { font-size: 20px; font-weight: 800; color: primary }` | `AppTextStyles.logoIcon` |
|
||||
| `body.sidebar-collapsed .logo-text { display: none }` | 折叠时渲染 `logoIcon` 分支 |
|
||||
| `body.sidebar-collapsed .logo-icon { display: block }` | 同上 |
|
||||
| `.nav-container { flex: 1; overflow-y: auto; overflow-x: hidden }` | `Expanded(child: SingleChildScrollView(...))` |
|
||||
|
||||
## 4. 导航
|
||||
|
||||
| index3.html | Flutter |
|
||||
| --- | --- |
|
||||
| `.nav-item-wrapper { margin-bottom: 4px }` | `_NavEntry` 的 `Padding(bottom: navItemGap)` |
|
||||
| `.nav-link { padding: 11px 16px; border-radius: 8px }` | `navItemPaddingVertical/Horizontal`、`navItemRadius` |
|
||||
| `.nav-link { font-size: 14px; font-weight: 500 }` | `AppTextStyles.navItem` |
|
||||
| `.nav-link { color: text-sub }` | `AppColors.textSecondary` |
|
||||
| `.nav-link { justify-content: space-between }` | `Row(mainAxisAlignment: spaceBetween)` |
|
||||
| `.nav-link { cursor: pointer }` | `MouseRegion(cursor: SystemMouseCursors.click)` |
|
||||
| `.nav-link { transition: all .2s }` | `AnimatedContainer(duration: 200ms, curve: Curves.ease)` |
|
||||
| `.nav-link:hover { background-color: #f8fafc; color: primary }` | `AppColors.navItemHover` + `_hovered` |
|
||||
| `.nav-link.active { background-color: #eff6ff; color: primary }` | `AppColors.primaryFaint` + `selected` |
|
||||
| `.arrow-icon { font-size: 14px; font-weight: bold; color: #94a3b8; font-family: monospace }` | `AppTextStyles.navArrow` |
|
||||
| `.arrow-icon { transition: transform .3s ease }` | `AnimatedRotation(duration: 300ms, curve: Curves.ease)` |
|
||||
| `.nav-item-wrapper.open > .nav-link .arrow-icon { transform: rotate(90deg) }` | `turns: open ? 0.25 : 0` |
|
||||
| `.sub-menu { padding-left: 16px; max-height: 0; overflow: hidden }` | `subMenuIndent` + `AnimatedSize`(未展开时不渲染子项) |
|
||||
| `.sub-menu { transition: max-height .3s cubic-bezier(0,1,0,1) }` | `curve: const Cubic(0, 1, 0, 1)` |
|
||||
| `.open .sub-menu { max-height: 500px; transition: max-height .3s ease-in-out }` | `curve: Curves.easeInOut` |
|
||||
| `.sub-menu .nav-link { padding: 8px 16px; font-size: 13px }` | `_SubNavItem` + `AppTextStyles.subNavItem` |
|
||||
| `body.sidebar-collapsed .nav-text { display: none }` | 折叠时 `_NavRow` 不渲染任何子节点 |
|
||||
| `body.sidebar-collapsed .arrow-icon { display: none }` | 折叠时 `_NavRow` 走无 trailing 分支 |
|
||||
| `body.sidebar-collapsed .sub-menu { display: none }` | 折叠时根本不构建 `AnimatedSize` |
|
||||
| `body.sidebar-collapsed .nav-link { justify-content: center; padding: 12px 0 }` | `navItemCollapsedPaddingVertical` + 水平 0 |
|
||||
|
||||
## 5. 侧栏底部
|
||||
|
||||
| index3.html | Flutter |
|
||||
| --- | --- |
|
||||
| `.sidebar-footer { border-top: 1px solid border; padding-top: 12px; margin-top: 8px }` | `_SidebarFooter` |
|
||||
| `.submit-btn { padding: 10px 16px; border-radius: 8px; font-size: 14px; font-weight: 500 }` | `submitButtonPaddingVertical/Horizontal`、`AppTextStyles.submitButton` |
|
||||
| `.submit-btn { color: text-sub; transition: all .2s }` | `AnimatedContainer(curve: Curves.ease)` |
|
||||
| `.submit-btn:hover { background-color: #f1f5f9; color: primary }` | `AppColors.navItemHoverDeep` |
|
||||
| `.submit-btn` 的 `title="提交网站"` | `Tooltip('提交网站')` |
|
||||
| `body.sidebar-collapsed .submit-text { display: none }` | 折叠时不渲染文字 |
|
||||
|
||||
## 6. 主内容区与顶栏
|
||||
|
||||
| index3.html | Flutter |
|
||||
| --- | --- |
|
||||
| `.main-content { margin-left: 230px / 64px }` | `Row[ SizedBox(width: 侧栏), Expanded(内容) ]` |
|
||||
| `.main-content { transition: margin-left .3s cubic-bezier(.4,0,.2,1) }` | 侧栏宽度动画,同一曲线 |
|
||||
| `.main-content { padding: 24px 40px 40px 40px }` | `contentPaddingTop / Horizontal / Bottom` |
|
||||
| `.top-bar { gap: 16px; max-width: 750px; margin: 0 auto 36px auto }` | `TopBar`(`topBarGap` / `topBarMaxWidth` / `topBarMarginBottom` + `Center`) |
|
||||
| `.toggle-btn { width/height: 44px; border-radius: 12px; border: 1px solid border }` | `toggleButtonSize` / `toggleButtonRadius` |
|
||||
| `.toggle-btn { box-shadow: var(--shadow) }` | `AppColors.cardShadow` |
|
||||
| `.toggle-btn { font-size: 18px; color: text-sub }` | `Text('☰', fontSize: 18)` |
|
||||
| `.toggle-btn { transition: all .2s }` | `AnimatedContainer(curve: Curves.ease)` |
|
||||
| `.toggle-btn:hover { border-color: primary; color: primary }` | `_hovered` 分支 |
|
||||
| `.toggle-btn` 的 `title="展开/折叠侧边栏"` | `Tooltip('展开/折叠侧边栏')` |
|
||||
| `.search-container { flex: 1 }` | `Expanded(child: LinkSearchField)` |
|
||||
| `.search-box { height: 44px; padding: 0 20px; border-radius: 12px; font-size: 15px }` | `searchHeight` / `searchPaddingHorizontal` / `searchRadius` / `AppTextStyles.searchInput` |
|
||||
| `.search-box { box-shadow: var(--shadow) }` | 未聚焦时的 `boxShadow` |
|
||||
| `.search-box:focus { border-color: primary; box-shadow: 0 0 0 3px rgba(59,130,246,.15) }` | 聚焦时**只用**光晕(覆盖默认阴影,与 CSS 一致) |
|
||||
| `.search-box { transition: border-color .2s, box-shadow .2s }` | `AnimatedContainer(curve: Curves.ease)` |
|
||||
|
||||
## 7. 分组与卡片
|
||||
|
||||
| index3.html | Flutter |
|
||||
| --- | --- |
|
||||
| `.section-title { font-size: 16px; font-weight: 600; margin-bottom: 16px }` | `AppTextStyles.sectionTitle` + `sectionTitleGap` |
|
||||
| `.section-title::before { width: 4px; height: 16px; margin-right: 8px; border-radius: 2px }` | `_SectionTitle` 的竖条(`sectionAccent*`) |
|
||||
| `.grid { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px }` | `AppBreakpoints.gridColumnsFor` + `gridGap` |
|
||||
| `.grid { margin-bottom: 36px }` | 每个分区统一 `sectionGap` 下间距(最后一个也一样) |
|
||||
| `.card { border-radius: 10px; padding: 14px 16px; border: 1px solid border }` | `cardRadius` / `cardPadding*` / `AppColors.border` |
|
||||
| `.card { box-shadow: var(--shadow); transition: transform .2s, box-shadow .2s }` | `AnimatedContainer(curve: Curves.ease)` |
|
||||
| `.card:hover { transform: translateY(-2px); box-shadow: var(--shadow-hover) }` | `cardHoverLift` + `cardShadowHover` |
|
||||
| `.card-icon { width/height: 36px; border-radius: 8px; background: #f1f5f9; color: primary }` | `_CardIcon`(`cardIconSize` / `cardIconRadius` / `AppColors.iconSurface`) |
|
||||
| `.card-icon { font-weight: 600; margin-right: 12px }` | `AppTextStyles.cardIcon` + `cardIconGap` |
|
||||
| `.card-icon` 未设 font-size,继承 body 的 16px | `AppTextStyles.cardIcon.fontSize = 16` |
|
||||
| `.card-info { overflow: hidden }` + `.card-title/.card-desc` 单行省略 | `Expanded` + `maxLines: 1, overflow: ellipsis` |
|
||||
| `.card-title { font-size: 14px; font-weight: 600 }` | `AppTextStyles.cardTitle` |
|
||||
| `.card-desc { font-size: 12px; color: text-sub; margin-top: 2px }` | `AppTextStyles.cardDesc` + `cardTitleGap` |
|
||||
|
||||
## 8. 回到顶部
|
||||
|
||||
| index3.html | Flutter |
|
||||
| --- | --- |
|
||||
| `.back-to-top { position: fixed; bottom: 30px; right: 30px }` | `Positioned(right: 30, bottom: 30)`(`backToTopOffset`) |
|
||||
| `.back-to-top { width/height: 44px; border-radius: 50% }` | `backToTopSize` + `BoxShape.circle` |
|
||||
| `.back-to-top { font-size: 18px; color: text-sub; box-shadow: var(--shadow) }` | `Text('↑', fontSize: 18)` + `cardShadow` |
|
||||
| `.back-to-top { transition: all .2s ease }` | `AnimatedContainer(curve: Curves.ease)` |
|
||||
| `.back-to-top:hover { color/border-color: primary; transform: translateY(-3px); box-shadow: hover }` | `backToTopHoverLift` + `cardShadowHover` |
|
||||
| `.back-to-top` 的 `title="回到顶部"` | `Tooltip('回到顶部')` |
|
||||
|
||||
## 9. JS 行为
|
||||
|
||||
| index3.html 的脚本 | Flutter |
|
||||
| --- | --- |
|
||||
| 点 `#toggleBtn` → `body.classList.toggle('sidebar-collapsed')` | `HomePage._toggleSidebar` |
|
||||
| 点 `.has-sub` → 若已折叠先移除 `sidebar-collapsed` | `HomePage._toggleGroup` 里 `collapsed` 为真时先 `_collapsedOverride = false` |
|
||||
| 点 `.has-sub` → `parentItem.classList.toggle('open')` | 同一批 id 集合 `_openGroupIds` 的增删 |
|
||||
|
||||
## 10. 当前实现没有任何原型之外的行为
|
||||
|
||||
以下都是「原型里没有」的行为,**都不实现**:
|
||||
|
||||
| 原型的状态 | 当前实现 |
|
||||
| --- | --- |
|
||||
| 搜索框没有任何 JS | 只接收输入,不过滤内容;没有空状态、没有清空按钮 |
|
||||
| `active` 写死在「常用推荐」上 | 高亮固定不动,点其它导航项也不会变 |
|
||||
| 没有媒体查询 | 侧栏始终 230px(点折叠按钮变 64px),主内容内边距始终 `24/40/40/40` |
|
||||
| `#icon`、`#stock`、`#dev`、`#api` 没有对应元素 | 这四个导航项点击后没有任何反应(不滚动、不报错) |
|
||||
| `#submit` 没有对应元素 | 点「提交网站」没有任何反应 |
|
||||
| 只有 `#common`(3 张卡)与 `#design`(2 张卡)两段内容 | 内容区就是这两段、五张卡片 |
|
||||
|
||||
历史上曾经额外加过、现已全部删除的:滚动的侧栏联动、折叠态首字短标签、分组包含当前分区时的强调色、点二级条目自动展开分组、卡片上弹出网址的 tooltip、搜索过滤与空状态、点导航切换高亮、窄屏自动折叠、补齐缺失的四个分区、提交按钮的提示。
|
||||
|
||||
## 11. 验证方式
|
||||
|
||||
- `flutter analyze` → No issues found
|
||||
- `flutter test` → 36 个用例通过
|
||||
- `flutter build web --no-wasm-dry-run` → 构建成功
|
||||
|
||||
其中几条专门守住「不加戏」的边界:
|
||||
|
||||
- 滚动主内容时,侧栏第一行仍是**同一个 widget 实例**(没有被重建),高亮不变。
|
||||
- 折叠态下 `navText('常用推荐')`、`navText('提交网站')` 都找不到——文字确实被隐藏了,与 `display: none` 一致。
|
||||
- 输入搜索词后,仍然是 2 个分区、5 张卡片。
|
||||
- 点 `#icon` 这类没有对应元素的导航,滚动偏移不变。
|
||||
- 点「提交网站」不会弹出任何东西。
|
||||
+65
-11
@@ -1,17 +1,71 @@
|
||||
# frontend
|
||||
# Navius 前端(Flutter Web)
|
||||
|
||||
A new Flutter project.
|
||||
导航站前端,使用 Flutter 构建。当前目标平台只有 Web,数据来自 Rust 后端
|
||||
(`GET /api/nav`)。
|
||||
|
||||
## Getting Started
|
||||
界面基准是仓库根目录的 `index3.html`:可折叠的层级侧栏 + 居中的顶部控制栏
|
||||
(折叠按钮 + 搜索框)+ 分组卡片网格 + 右下角圆形回到顶部。
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
## 和后端一起跑
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
前端启动时会请求后端,所以两个都要起:
|
||||
|
||||
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
||||
```bash
|
||||
cd backend && cargo run # 默认 http://127.0.0.1:8080
|
||||
cd frontend && flutter run -d chrome
|
||||
```
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
后端地址默认是 `http://127.0.0.1:8080`,打包或换环境时用编译期变量覆盖:
|
||||
|
||||
```bash
|
||||
flutter build web --dart-define=NAVIUS_API_BASE=https://api.example.com
|
||||
```
|
||||
|
||||
后端没起来时页面会显示「加载失败」和具体原因,并给一个「重试」按钮。
|
||||
|
||||
侧栏底部的「提交网站」直接跳到后端配好的收集入口(`nav.json` 的 `site.submitUrl`,
|
||||
填 Mattermost 地址即可)。页面上不再有提交表单——收集和讨论都在 Mattermost 里做,
|
||||
网站数据由后端接口写入。
|
||||
|
||||
**页面上没有编辑和删除入口**:卡片只管展示和跳转,用户提交的站点照样由后端接口
|
||||
(`PATCH` / `DELETE`)维护,只是不从这里操作。
|
||||
|
||||
站点名、搜索框提示、提交入口地址都来自后端(`nav.json` 的 `site`),前端不再写死。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
flutter pub get # 拉取依赖
|
||||
flutter run -d chrome # 本地运行
|
||||
flutter analyze # 静态分析
|
||||
flutter test # 单元测试与组件测试
|
||||
flutter build web # 构建产物输出到 build/web
|
||||
```
|
||||
|
||||
## 目录约定
|
||||
|
||||
- `lib/core/`:与业务无关的基础设施——主题、尺寸常量、断点计算、打开链接的能力。
|
||||
- `lib/features/<功能>/`:按功能划分,内部再分 `data`(数据来源与仓库)、`domain`(模型与规则)、`presentation`(页面与组件)。
|
||||
- `test/`:目录结构与 `lib/` 保持一致。
|
||||
|
||||
依赖方向固定为 `features/ → core/`:`core` 不引用任何功能代码,
|
||||
功能之间也不互相引用。
|
||||
|
||||
## 接后端的位置
|
||||
|
||||
页面只依赖仓库接口,不关心数据来自后端还是本地 mock:
|
||||
|
||||
- 契约:`lib/features/home/data/home_repository.dart`
|
||||
- 后端实现:`lib/features/home/data/http_home_repository.dart`
|
||||
- mock 实现:`lib/features/home/data/mock_home_repository.dart`(测试用)
|
||||
|
||||
接口对应关系:读 `GET /api/nav`、提交 `POST /api/sites`、编辑 `PATCH /api/sites/{id}`、
|
||||
删除 `DELETE /api/sites/{id}`。仓库契约里四个方法都在,界面目前只用到「读」和
|
||||
「跳转到提交入口」,另外三个留给后台工具或以后再做界面时用。
|
||||
|
||||
接口返回的分组树在 `http_home_repository.dart` 里映射成前端模型:分组变成侧栏导航,
|
||||
**只有挂着网站的分组才进内容区**(空分组留在侧栏,点了没有对应内容——和原型一致),
|
||||
子分组的内容标题会带上父级(「素材资源 - UI 设计」)。
|
||||
|
||||
完整的目录树、设计令牌映射、响应式规则与实现取舍见
|
||||
[docs/frontend-architecture.md](../docs/frontend-architecture.md)。
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'core/services/web_url_opener.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'features/home/data/http_home_repository.dart';
|
||||
import 'features/home/presentation/pages/home_page.dart';
|
||||
|
||||
/// 应用根组件。
|
||||
///
|
||||
/// 只负责三件事:配置全局主题、装配依赖、指定首页。
|
||||
/// 这里是「具体实现」接起来的地方,所以只有它需要知道首页数据来自后端:
|
||||
/// [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(
|
||||
title: 'Suxing Nav',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.light,
|
||||
home: HomePage(
|
||||
repository: HttpHomeRepository(),
|
||||
urlOpener: const WebUrlOpener(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/// 网格列数计算。
|
||||
///
|
||||
/// `index3.html` 的 `.grid` 用的是 CSS Grid 的
|
||||
/// `repeat(auto-fill, minmax(220px, 1fr))`;Flutter 没有等价写法,
|
||||
/// 这里把它的规则换算成「给定可用宽度能放几列」,写成纯函数便于单测。
|
||||
///
|
||||
/// 原型没有媒体查询,所以这里也不做断点分支——宽度由调用方传入。
|
||||
abstract final class AppBreakpoints {
|
||||
/// 给定可用宽度与最小单元宽度,算出网格能放几列。
|
||||
///
|
||||
/// 等价于 CSS:`列数 = floor((可用宽度 + 间距) / (最小单元 + 间距))`。
|
||||
static int gridColumnsFor(
|
||||
double availableWidth, {
|
||||
double minItemWidth = 220,
|
||||
double gap = 16,
|
||||
}) {
|
||||
if (availableWidth <= minItemWidth) {
|
||||
return 1;
|
||||
}
|
||||
final int columns =
|
||||
((availableWidth + gap) / (minItemWidth + gap)).floor();
|
||||
return columns < 1 ? 1 : columns;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 尺寸与动效常量。
|
||||
///
|
||||
/// 数值逐条对应 `index3.html` 的 CSS 变量与规则,组件里不再出现魔法数字。
|
||||
abstract final class AppSizes {
|
||||
// ---- 侧栏 ----
|
||||
|
||||
/// 侧栏展开宽度(`--sidebar-width`,230px)。
|
||||
static const double sidebarWidth = 230;
|
||||
|
||||
/// 侧栏折叠宽度(`--sidebar-collapsed-width`,64px)。
|
||||
static const double sidebarCollapsedWidth = 64;
|
||||
|
||||
/// 侧栏内边距(`padding: 20px 12px 12px 12px`)。
|
||||
static const double sidebarPaddingTop = 20;
|
||||
static const double sidebarPaddingHorizontal = 12;
|
||||
static const double sidebarPaddingBottom = 12;
|
||||
|
||||
/// 折叠后的左右内边距(`padding: 20px 8px 12px 8px`)。
|
||||
static const double sidebarCollapsedPaddingHorizontal = 8;
|
||||
|
||||
/// logo 区域高度与下方间距(`.sidebar-header`,`height:40px; margin-bottom:24px`)。
|
||||
static const double sidebarHeaderHeight = 40;
|
||||
static const double sidebarHeaderGap = 24;
|
||||
|
||||
/// 侧栏展开 / 折叠的动画(`width .3s cubic-bezier(.4, 0, .2, 1)`)。
|
||||
///
|
||||
/// 该贝塞尔曲线就是 Material 的 standard easing,Flutter 里对应
|
||||
/// [Curves.fastOutSlowIn]。
|
||||
static const Duration sidebarTransition = Duration(milliseconds: 300);
|
||||
|
||||
// ---- 导航项 ----
|
||||
|
||||
/// 导航项圆角(`border-radius: 8px`)。
|
||||
static const double navItemRadius = 8;
|
||||
|
||||
/// 导航项内边距(`.nav-link`,`padding: 11px 16px`)。
|
||||
static const double navItemPaddingVertical = 11;
|
||||
static const double navItemPaddingHorizontal = 16;
|
||||
|
||||
/// 导航项之间的间距(`.nav-item-wrapper`,`margin-bottom: 4px`)。
|
||||
static const double navItemGap = 4;
|
||||
|
||||
/// 折叠状态下导航项的内边距(`padding: 12px 0`)。
|
||||
static const double navItemCollapsedPaddingVertical = 12;
|
||||
|
||||
/// 子菜单项内边距与字号(`.sub-menu .nav-link`,`padding: 8px 16px`)。
|
||||
static const double subNavItemPaddingVertical = 8;
|
||||
static const double subNavItemPaddingHorizontal = 16;
|
||||
|
||||
/// 子菜单相对父级的缩进(`.sub-menu`,`padding-left: 16px`)。
|
||||
static const double subMenuIndent = 16;
|
||||
|
||||
/// 子菜单展开 / 收起的动画时长(`transition: .3s`)。
|
||||
static const Duration subMenuTransition = Duration(milliseconds: 300);
|
||||
|
||||
/// 折叠箭头的点击热区宽度。
|
||||
static const double navArrowBoxWidth = 18;
|
||||
|
||||
// ---- 侧栏底部 ----
|
||||
|
||||
/// 底部分隔线与内边距(`.sidebar-footer`)。
|
||||
static const double sidebarFooterPaddingTop = 12;
|
||||
static const double sidebarFooterMarginTop = 8;
|
||||
|
||||
/// 提交按钮内边距(`.submit-btn`,`padding: 10px 16px`)。
|
||||
static const double submitButtonPaddingVertical = 10;
|
||||
static const double submitButtonPaddingHorizontal = 16;
|
||||
|
||||
// ---- 主内容区 ----
|
||||
|
||||
/// 主内容区内边距(`padding: 24px 40px 40px 40px`)。
|
||||
static const double contentPaddingTop = 24;
|
||||
static const double contentPaddingHorizontal = 40;
|
||||
static const double contentPaddingBottom = 40;
|
||||
|
||||
// ---- 顶部控制栏 ----
|
||||
|
||||
/// 顶栏最大宽度与垂直间距(`.top-bar`,`max-width: 750px; margin-bottom: 36px`)。
|
||||
static const double topBarMaxWidth = 750;
|
||||
static const double topBarGap = 16;
|
||||
static const double topBarMarginBottom = 36;
|
||||
|
||||
/// 折叠按钮尺寸与圆角(`.toggle-btn`,44×44,圆角 12px)。
|
||||
static const double toggleButtonSize = 44;
|
||||
static const double toggleButtonRadius = 12;
|
||||
|
||||
// ---- 搜索框 ----
|
||||
|
||||
/// 搜索框高度与内边距(`.search-box`,`height:44px; padding: 0 20px`)。
|
||||
static const double searchHeight = 44;
|
||||
static const double searchPaddingHorizontal = 20;
|
||||
|
||||
/// 搜索框圆角(`border-radius: 12px`)。
|
||||
static const double searchRadius = 12;
|
||||
|
||||
/// 聚焦光晕宽度(`0 0 0 3px`)。
|
||||
static const double focusRingWidth = 3;
|
||||
|
||||
// ---- 分组与卡片 ----
|
||||
|
||||
/// 分组标题与网格之间的间距(`margin-bottom: 16px`)。
|
||||
static const double sectionTitleGap = 16;
|
||||
|
||||
/// 标题前的竖条尺寸(`width: 4px; height: 16px`)。
|
||||
static const double sectionAccentWidth = 4;
|
||||
static const double sectionAccentHeight = 16;
|
||||
|
||||
/// 竖条与标题文字的间距(`margin-right: 8px`)。
|
||||
static const double sectionAccentGap = 8;
|
||||
|
||||
/// 竖条圆角(`border-radius: 2px`)。
|
||||
static const double sectionAccentRadius = 2;
|
||||
|
||||
/// 分组之间的间距(`.grid` 的 `margin-bottom: 36px`)。
|
||||
static const double sectionGap = 36;
|
||||
|
||||
/// 网格间距(`gap: 16px`)。
|
||||
static const double gridGap = 16;
|
||||
|
||||
/// 网格单元最小宽度(`minmax(220px, 1fr)`)。
|
||||
static const double gridMinItemWidth = 220;
|
||||
|
||||
/// 卡片圆角(`border-radius: 10px`)。
|
||||
static const double cardRadius = 10;
|
||||
|
||||
/// 卡片内边距(`padding: 14px 16px`)。
|
||||
static const double cardPaddingVertical = 14;
|
||||
static const double cardPaddingHorizontal = 16;
|
||||
|
||||
/// 卡片悬停时上移的距离(`translateY(-2px)`)。
|
||||
static const double cardHoverLift = 2;
|
||||
|
||||
/// 卡片图标尺寸(`.card-icon`,36px)。
|
||||
static const double cardIconSize = 36;
|
||||
|
||||
/// 卡片图标圆角(`border-radius: 8px`)。
|
||||
static const double cardIconRadius = 8;
|
||||
|
||||
/// 图标与文字之间的间距(`margin-right: 12px`)。
|
||||
static const double cardIconGap = 12;
|
||||
|
||||
/// 卡片标题与描述的间距(`margin-top: 2px`)。
|
||||
static const double cardTitleGap = 2;
|
||||
|
||||
// ---- 回到顶部 ----
|
||||
|
||||
/// 按钮尺寸(`.back-to-top`,44×44,圆角 50%)。
|
||||
static const double backToTopSize = 44;
|
||||
|
||||
/// 距视口右下角的距离(`bottom: 30px; right: 30px`)。
|
||||
static const double backToTopOffset = 30;
|
||||
|
||||
/// 悬停时上移距离(`translateY(-3px)`)。
|
||||
static const double backToTopHoverLift = 3;
|
||||
|
||||
// ---- 通用 ----
|
||||
|
||||
/// 通用过渡时长(CSS 里的 `transition: 0.2s`)。
|
||||
static const Duration transition = Duration(milliseconds: 200);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/// 打开外部链接的能力抽象。
|
||||
///
|
||||
/// 界面层只依赖这个接口:一来把「打开链接」这种平台相关行为挡在 UI 之外,
|
||||
/// 二来测试里可以注入假实现,避免真的弹出浏览器。
|
||||
abstract interface class UrlOpener {
|
||||
/// 打开 [url]。
|
||||
void open(String url);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:web/web.dart' as web;
|
||||
|
||||
import 'url_opener.dart';
|
||||
|
||||
/// [UrlOpener] 的 Web 实现:在新标签页打开链接。
|
||||
///
|
||||
/// 当前项目只面向 Web,因此这是唯一的实现;
|
||||
/// 以后要支持其它平台,只需新增实现类,界面代码无需改动。
|
||||
class WebUrlOpener implements UrlOpener {
|
||||
const WebUrlOpener();
|
||||
|
||||
@override
|
||||
void open(String url) {
|
||||
web.window.open(url, '_blank');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 全局配色。
|
||||
///
|
||||
/// 取值逐条对应 `index.html` 里 `:root` 的 CSS 变量,
|
||||
/// 组件只引用这里的语义名,不再出现十六进制字面量。
|
||||
/// 将来调色只需要改这个文件。
|
||||
abstract final class AppColors {
|
||||
// ---- 基础色 ----
|
||||
|
||||
/// 页面底色(`--bg-color`)。
|
||||
static const Color background = Color(0xFFF7F9FB);
|
||||
|
||||
/// 卡片 / 侧栏底色(`--card-bg`)。
|
||||
static const Color surface = Color(0xFFFFFFFF);
|
||||
|
||||
/// 主色(`--primary-color`):logo、导航选中态、图标文字、焦点边框。
|
||||
static const Color primary = Color(0xFF3B82F6);
|
||||
|
||||
/// 主要文字(`--text-main`):标题与卡片标题。
|
||||
static const Color textPrimary = Color(0xFF1E293B);
|
||||
|
||||
/// 次要文字(`--text-sub`):描述、未选中的导航项。
|
||||
static const Color textSecondary = Color(0xFF64748B);
|
||||
|
||||
/// 描边(`--border-color`):侧栏右边界、卡片边框、搜索框边框。
|
||||
static const Color border = Color(0xFFE2E8F0);
|
||||
|
||||
/// 导航项悬停 / 选中底色(`.nav-item:hover`)。
|
||||
static const Color primaryFaint = Color(0xFFEFF6FF);
|
||||
|
||||
/// 导航项悬停底色(`.nav-link:hover`,比选中态更浅)。
|
||||
static const Color navItemHover = Color(0xFFF8FAFC);
|
||||
|
||||
/// 提交按钮悬停底色(`.submit-btn:hover`),与卡片图标底色同值。
|
||||
static const Color navItemHoverDeep = Color(0xFFF1F5F9);
|
||||
|
||||
/// 折叠箭头颜色(`.arrow-icon`)。
|
||||
static const Color navArrow = Color(0xFF94A3B8);
|
||||
|
||||
/// 卡片图标底色(`.card-icon`)。
|
||||
static const Color iconSurface = Color(0xFFF1F5F9);
|
||||
|
||||
/// 搜索框聚焦时的外发光(`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`)。
|
||||
static const List<BoxShadow> cardShadow = <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
blurRadius: 6,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
BoxShadow(
|
||||
color: Color(0x08000000),
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
];
|
||||
|
||||
/// 卡片悬停阴影(`--shadow-hover`)。
|
||||
static const List<BoxShadow> cardShadowHover = <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: Color(0x14000000),
|
||||
blurRadius: 15,
|
||||
offset: Offset(0, 10),
|
||||
),
|
||||
BoxShadow(
|
||||
color: Color(0x0A000000),
|
||||
blurRadius: 6,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app_colors.dart';
|
||||
|
||||
/// 语义化文字样式。
|
||||
///
|
||||
/// 字号与字重对应 `index.html` 的 CSS;字体族不显式指定,
|
||||
/// 沿用 HTML 里的系统字体栈(`-apple-system, Segoe UI, ...`)。
|
||||
/// 颜色留空的样式由调用方按状态着色(例如导航项的选中 / 未选中)。
|
||||
abstract final class AppTextStyles {
|
||||
/// 侧栏 logo 文字(`.logo`,18px / 700)。
|
||||
static const TextStyle logo = TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
);
|
||||
|
||||
/// 侧栏折叠后的 logo 图标(`.logo-icon`,20px / 800)。
|
||||
static const TextStyle logoIcon = TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.primary,
|
||||
);
|
||||
|
||||
/// 侧栏一级导航项(`.nav-link`,14px / 500)。
|
||||
static const TextStyle navItem = TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
);
|
||||
|
||||
/// 侧栏二级导航项(`.sub-menu .nav-link`,13px / 500)。
|
||||
static const TextStyle subNavItem = TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
);
|
||||
|
||||
/// 折叠箭头(`.arrow-icon`,14px)。
|
||||
static const TextStyle navArrow = TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.navArrow,
|
||||
fontFamily: 'monospace',
|
||||
height: 1,
|
||||
);
|
||||
|
||||
/// 提交按钮文字(`.submit-btn`,14px / 500)。
|
||||
static const TextStyle submitButton = TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
);
|
||||
|
||||
/// 分组标题(`.section-title`,16px / 600)。
|
||||
static const TextStyle sectionTitle = TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
);
|
||||
|
||||
/// 搜索框正文(`.search-box`,15px)。
|
||||
static const TextStyle searchInput = TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.textPrimary,
|
||||
);
|
||||
|
||||
/// 搜索框占位文字。
|
||||
static const TextStyle searchHint = TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.textSecondary,
|
||||
);
|
||||
|
||||
/// 卡片标题(`.card-title`,14px / 600)。
|
||||
static const TextStyle cardTitle = TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
);
|
||||
|
||||
/// 卡片描述(`.card-desc`,12px)。
|
||||
static const TextStyle cardDesc = TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
);
|
||||
|
||||
/// 卡片图标里的首字母(`.card-icon`)。
|
||||
///
|
||||
/// 原型没有给 .card-icon 设 font-size,它继承 body 的浏览器默认字号 16px。
|
||||
static const TextStyle cardIcon = TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primary,
|
||||
);
|
||||
|
||||
/// 空状态主文案。
|
||||
static const TextStyle emptyTitle = TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
);
|
||||
|
||||
/// 空状态补充说明。
|
||||
static const TextStyle emptyMessage = TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app_colors.dart';
|
||||
|
||||
/// 全局主题。
|
||||
abstract final class AppTheme {
|
||||
/// 浅色主题(当前唯一主题,取值来自 index.html 的设计变量)。
|
||||
static ThemeData get light {
|
||||
return ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: AppColors.primary,
|
||||
primary: AppColors.primary,
|
||||
surface: AppColors.surface,
|
||||
),
|
||||
scaffoldBackgroundColor: AppColors.background,
|
||||
dividerColor: AppColors.border,
|
||||
iconTheme: const IconThemeData(color: AppColors.textSecondary, size: 20),
|
||||
// 搜索框自己绘制边框与聚焦光晕,这里只负责光标颜色。
|
||||
textSelectionTheme: const TextSelectionThemeData(
|
||||
cursorColor: AppColors.primary,
|
||||
selectionColor: AppColors.focusRing,
|
||||
selectionHandleColor: AppColors.primary,
|
||||
),
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: AppColors.textPrimary,
|
||||
contentTextStyle: TextStyle(color: AppColors.surface, fontSize: 14),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/// 取字符串的第一个字符。
|
||||
///
|
||||
/// 用 `runes` 而不是 `substring(0, 1)`:中文与 emoji 不会被截成半个字符。
|
||||
/// 侧栏折叠后的短标签、卡片左上角的字母图标都依赖这条规则,
|
||||
/// 放在这里保证三处(logo、导航、卡片)表现一致。
|
||||
String firstChar(String text, {String fallback = '·'}) {
|
||||
if (text.isEmpty) {
|
||||
return fallback;
|
||||
}
|
||||
return String.fromCharCode(text.runes.first);
|
||||
}
|
||||
|
||||
/// 字符数(不是字节数)。
|
||||
///
|
||||
/// 和后端 `util::char_count` 的口径一致:先去掉首尾空白,再数字符。
|
||||
/// 中文一个字算一个,所以「最多 60 个字符」用 `runes.length` 而不是 `length`。
|
||||
int charCount(String text) => text.trim().runes.length;
|
||||
@@ -0,0 +1,36 @@
|
||||
import '../domain/models/home_data.dart';
|
||||
import '../domain/models/submit_site_draft.dart';
|
||||
|
||||
/// 首页数据仓库契约。
|
||||
///
|
||||
/// 界面层只认这个接口,不关心数据来自本地 mock 还是 Rust 后端。
|
||||
///
|
||||
/// 两个方法都是异步的:接上后端之后「加载中 / 提交中」和「失败」都是必须
|
||||
/// 表达的状态,所以契约从一开始就按异步来——mock 实现直接返回已经就绪的值。
|
||||
abstract interface class HomeRepository {
|
||||
/// 读取首页需要的全部数据。
|
||||
Future<HomeData> fetchHomeData();
|
||||
|
||||
/// 提交一个网站。
|
||||
///
|
||||
/// 成功不返回内容:后端会回创建出来的条目,但页面用不到(提交完重新拉一次
|
||||
/// `/api/nav` 就有了)。失败时抛 [HomeFailure],`message` 可以直接展示。
|
||||
Future<void> submitSite(SubmitSiteDraft draft);
|
||||
|
||||
/// 修改一个已有的网站(只能改用户提交 / 接口推送进来的,系统内置的会返回 403)。
|
||||
Future<void> updateSite(String id, SubmitSiteDraft draft);
|
||||
|
||||
/// 删除一个已有的网站(同样只对可修改的条目有效)。
|
||||
Future<void> deleteSite(String id);
|
||||
}
|
||||
|
||||
/// 取数或提交失败。[message] 是可以直接显示给用户的说明。
|
||||
class HomeFailure implements Exception {
|
||||
const HomeFailure(this.message);
|
||||
|
||||
/// 面向用户的说明。
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
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<HomeData> 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<String, dynamic>) {
|
||||
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<String, dynamic> ? rawSite : const <String, dynamic>{},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> submitSite(SubmitSiteDraft draft) async {
|
||||
final Uri uri = Uri.parse('$_baseUrl/api/sites');
|
||||
final http.Response response;
|
||||
try {
|
||||
response = await _client
|
||||
.post(
|
||||
uri,
|
||||
headers: const <String, String>{
|
||||
'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<void> 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 <String, String>{
|
||||
'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<void> 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<Object?> groups, Map<String, dynamic> siteMeta) {
|
||||
final List<NavNode> navNodes = <NavNode>[];
|
||||
final List<LinkSection> sections = <LinkSection>[];
|
||||
for (final Object? group in groups) {
|
||||
if (group is Map<String, dynamic>) {
|
||||
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,
|
||||
submitUrl: siteMeta['submitUrl'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
NavNode _navNode(
|
||||
Map<String, dynamic> json, {
|
||||
required String parentTitle,
|
||||
required List<LinkSection> 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<LinkItem> sites = _sites(json);
|
||||
if (sites.isNotEmpty && id.isNotEmpty) {
|
||||
sections.add(LinkSection(id: id, title: sectionTitle, items: sites));
|
||||
}
|
||||
|
||||
final List<NavNode> children = <NavNode>[];
|
||||
final Object? rawChildren = json['children'];
|
||||
if (rawChildren is List) {
|
||||
for (final Object? child in rawChildren) {
|
||||
if (child is Map<String, dynamic>) {
|
||||
children.add(
|
||||
_navNode(child, parentTitle: title, sections: sections),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return NavNode(
|
||||
id: id,
|
||||
title: title,
|
||||
children: children,
|
||||
// 后端给了 editable;缺省按内置处理(内置不可选)。
|
||||
editable: json['editable'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
List<LinkItem> _sites(Map<String, dynamic> json) {
|
||||
final Object? raw = json['sites'];
|
||||
if (raw is! List) {
|
||||
return const <LinkItem>[];
|
||||
}
|
||||
final List<LinkItem> items = <LinkItem>[];
|
||||
for (final Object? site in raw) {
|
||||
if (site is! Map<String, dynamic>) {
|
||||
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<String, dynamic> && decoded['error'] is String) {
|
||||
return decoded['error'] as String;
|
||||
}
|
||||
} catch (_) {
|
||||
// 不是 JSON 就退回原文。
|
||||
}
|
||||
return body.isEmpty ? '没有返回内容' : body;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import '../domain/models/home_data.dart';
|
||||
import '../domain/models/link_item.dart';
|
||||
import '../domain/models/link_section.dart';
|
||||
import '../domain/models/nav_node.dart';
|
||||
|
||||
/// 侧栏导航树,与 `index3.html` 的 `<ul class="nav-group">` 一一对应。
|
||||
const List<NavNode> mockNavNodes = <NavNode>[
|
||||
NavNode(id: 'common', title: '常用推荐'),
|
||||
NavNode(
|
||||
id: 'materials',
|
||||
title: '素材资源',
|
||||
children: <NavNode>[
|
||||
NavNode(id: 'design', title: 'UI 设计'),
|
||||
NavNode(id: 'icon', title: '图标库'),
|
||||
NavNode(id: 'stock', title: '高清壁纸'),
|
||||
],
|
||||
),
|
||||
NavNode(
|
||||
id: 'dev-tools',
|
||||
title: '开发工具',
|
||||
children: <NavNode>[
|
||||
NavNode(id: 'dev', title: '前端框架'),
|
||||
NavNode(id: 'api', title: '在线 API'),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
/// 内容区分组,与 `index3.html` 的两段内容一一对应。
|
||||
///
|
||||
/// 原型只写了 `#common` 与 `#design` 两段;导航里的 `#icon`、`#stock`、
|
||||
/// `#dev`、`#api` 在原型中没有对应元素,点击不会发生任何事——这里保持一致。
|
||||
const List<LinkSection> mockLinkSections = <LinkSection>[
|
||||
LinkSection(
|
||||
id: 'common',
|
||||
title: '常用推荐',
|
||||
items: <LinkItem>[
|
||||
LinkItem(
|
||||
id: 'github',
|
||||
title: 'GitHub',
|
||||
url: 'https://github.com',
|
||||
description: '代码托管与开源社区',
|
||||
),
|
||||
LinkItem(
|
||||
id: 'zhihu',
|
||||
title: '知乎',
|
||||
url: 'https://www.zhihu.com',
|
||||
description: '深度内容问答社区',
|
||||
),
|
||||
LinkItem(
|
||||
id: 'bilibili',
|
||||
title: '哔哩哔哩',
|
||||
url: 'https://www.bilibili.com',
|
||||
description: '弹幕视频分享网站',
|
||||
),
|
||||
],
|
||||
),
|
||||
LinkSection(
|
||||
id: 'design',
|
||||
title: '素材资源 - UI 设计',
|
||||
items: <LinkItem>[
|
||||
LinkItem(
|
||||
id: 'figma',
|
||||
title: 'Figma',
|
||||
url: 'https://www.figma.com',
|
||||
description: '协同界面设计工具',
|
||||
),
|
||||
LinkItem(
|
||||
id: 'dribbble',
|
||||
title: 'Dribbble',
|
||||
url: 'https://dribbble.com',
|
||||
description: '全球设计作品分享社区',
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
/// 首页聚合数据。
|
||||
const HomeData mockHomeData = HomeData(
|
||||
siteName: 'Suxing Nav',
|
||||
searchHint: '输入关键字搜索全网资源...',
|
||||
navNodes: mockNavNodes,
|
||||
sections: mockLinkSections,
|
||||
submitUrl: 'https://mattermost.example.com',
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
import '../domain/models/home_data.dart';
|
||||
import '../domain/models/submit_site_draft.dart';
|
||||
import 'home_repository.dart';
|
||||
import 'mock_home_data.dart';
|
||||
|
||||
/// [HomeRepository] 的本地实现,返回 [mockHomeData]。
|
||||
///
|
||||
/// 单独建一个类而不是让页面直接读常量:页面因此只依赖接口,
|
||||
/// 换成后端实现时不用碰界面代码,测试里也能注入任意数据集。
|
||||
class MockHomeRepository implements HomeRepository {
|
||||
const MockHomeRepository();
|
||||
|
||||
@override
|
||||
Future<HomeData> fetchHomeData() async => mockHomeData;
|
||||
|
||||
@override
|
||||
Future<void> submitSite(SubmitSiteDraft draft) async {
|
||||
// mock 实现什么都不做:需要断言提交内容的用例会注入自己的假实现。
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateSite(String id, SubmitSiteDraft draft) async {}
|
||||
|
||||
@override
|
||||
Future<void> deleteSite(String id) async {}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'link_item.dart';
|
||||
import 'link_section.dart';
|
||||
import 'nav_node.dart';
|
||||
|
||||
/// 首页一次性需要的全部数据。
|
||||
///
|
||||
/// 打包成一个聚合对象:页面只调用一次仓库、只持有一份状态,
|
||||
/// 后续换成后端接口时也只需要在一个响应里组装出它。
|
||||
class HomeData {
|
||||
const HomeData({
|
||||
required this.siteName,
|
||||
required this.searchHint,
|
||||
required this.navNodes,
|
||||
required this.sections,
|
||||
this.submitUrl = '',
|
||||
});
|
||||
|
||||
/// 站点名,显示在侧栏顶部与浏览器标题。
|
||||
final String siteName;
|
||||
|
||||
/// 搜索框占位文案。
|
||||
final String searchHint;
|
||||
|
||||
/// 「提交网站」跳转的地址(Mattermost 之类的收集入口),来自后端 `site.submitUrl`。
|
||||
///
|
||||
/// 为空表示后端没配,点了就不做任何事。
|
||||
final String submitUrl;
|
||||
|
||||
/// 侧栏导航树。
|
||||
final List<NavNode> navNodes;
|
||||
|
||||
/// 内容区分组。
|
||||
///
|
||||
/// 数量可能少于导航叶子:原型里 `#icon`、`#stock`、`#dev`、`#api`
|
||||
/// 没有对应元素,点击时什么也不会发生。
|
||||
final List<LinkSection> sections;
|
||||
|
||||
/// 导航树里的全部叶子,按自上而下的顺序展开。
|
||||
List<NavNode> get navLeaves => navNodes
|
||||
.expand((NavNode node) => node.leaves)
|
||||
.toList(growable: false);
|
||||
|
||||
/// 全部站点(扁平化)。
|
||||
List<LinkItem> get allItems => <LinkItem>[
|
||||
for (final LinkSection section in sections) ...section.items,
|
||||
];
|
||||
|
||||
/// 站点总数。
|
||||
int get itemCount =>
|
||||
sections.fold(0, (int sum, LinkSection s) => sum + s.itemCount);
|
||||
|
||||
/// 原型把 `.active` 写死在第一个导航条目上,这里取同一个值的来源。
|
||||
String? get activeSectionId =>
|
||||
navLeaves.isEmpty ? null : navLeaves.first.id;
|
||||
|
||||
/// 按 id 取分区;原型里没有对应元素时返回 null。
|
||||
LinkSection? sectionById(String id) {
|
||||
for (final LinkSection section in sections) {
|
||||
if (section.id == id) {
|
||||
return section;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import '../../../../core/utils/text.dart';
|
||||
|
||||
/// 一张站点卡片,对应 `index.html` 里的 `.card`。
|
||||
class LinkItem {
|
||||
const LinkItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.url,
|
||||
this.categoryId = '',
|
||||
this.description = '',
|
||||
this.editable = false,
|
||||
});
|
||||
|
||||
/// 从 JSON 构造(将来接后端接口时使用)。
|
||||
factory LinkItem.fromJson(Map<String, dynamic> json) {
|
||||
return 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,
|
||||
);
|
||||
}
|
||||
|
||||
/// 唯一标识。
|
||||
final String id;
|
||||
|
||||
/// 站点名称,同时用于生成卡片左上角的首字母图标。
|
||||
final String title;
|
||||
|
||||
/// 跳转地址。
|
||||
final String url;
|
||||
|
||||
/// 所属分组 id。编辑时要靠它反查出分组名,才能把表单预填对。
|
||||
final String categoryId;
|
||||
|
||||
/// 一句话描述。
|
||||
final String description;
|
||||
|
||||
/// 这条能不能改。
|
||||
///
|
||||
/// 后端按 `source != "system"` 推导出 `editable`:系统内置的条目只读,
|
||||
/// 卡片上不会出现编辑/删除;用户提交和接口推送进来的可以改、可以删。
|
||||
final bool editable;
|
||||
|
||||
/// 卡片图标显示的首字符。
|
||||
String get initial => firstChar(title).toUpperCase();
|
||||
|
||||
/// 展示用域名。
|
||||
String get host => Uri.tryParse(url)?.host ?? url;
|
||||
|
||||
/// 序列化为 JSON。
|
||||
Map<String, dynamic> toJson() => <String, dynamic>{
|
||||
'id': id,
|
||||
'title': title,
|
||||
'url': url,
|
||||
'categoryId': categoryId,
|
||||
'description': description,
|
||||
'editable': editable,
|
||||
};
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
other is LinkItem &&
|
||||
other.id == id &&
|
||||
other.title == title &&
|
||||
other.url == url &&
|
||||
other.categoryId == categoryId &&
|
||||
other.description == description &&
|
||||
other.editable == editable;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(id, title, url, categoryId, description, editable);
|
||||
|
||||
@override
|
||||
String toString() => 'LinkItem(id: $id, title: $title)';
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'link_item.dart';
|
||||
|
||||
/// 一个分组,对应 `index.html` 里带标题的一组卡片。
|
||||
class LinkSection {
|
||||
const LinkSection({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.items,
|
||||
});
|
||||
|
||||
/// 从 JSON 构造。
|
||||
///
|
||||
/// `items` 做了类型防御:字段缺失或类型不对时退化成空列表,
|
||||
/// 避免后端返回异常数据时整个页面直接崩掉。
|
||||
factory LinkSection.fromJson(Map<String, dynamic> json) {
|
||||
final Object? rawItems = json['items'];
|
||||
final List<LinkItem> items = rawItems is List
|
||||
? rawItems
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(LinkItem.fromJson)
|
||||
.toList(growable: false)
|
||||
: const <LinkItem>[];
|
||||
|
||||
return LinkSection(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
items: items,
|
||||
);
|
||||
}
|
||||
|
||||
/// 唯一标识,同时用作锚点。
|
||||
final String id;
|
||||
|
||||
/// 分组标题。
|
||||
final String title;
|
||||
|
||||
/// 分组下的站点。
|
||||
final List<LinkItem> items;
|
||||
|
||||
/// 站点数量。
|
||||
int get itemCount => items.length;
|
||||
|
||||
/// 序列化为 JSON。
|
||||
Map<String, dynamic> toJson() => <String, dynamic>{
|
||||
'id': id,
|
||||
'title': title,
|
||||
'items': items.map((LinkItem item) => item.toJson()).toList(growable: false),
|
||||
};
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
if (other is! LinkSection ||
|
||||
other.id != id ||
|
||||
other.title != title ||
|
||||
other.items.length != items.length) {
|
||||
return false;
|
||||
}
|
||||
for (int index = 0; index < items.length; index++) {
|
||||
if (items[index] != other.items[index]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(id, title, Object.hashAll(items));
|
||||
|
||||
@override
|
||||
String toString() => 'LinkSection(id: $id, title: $title, items: $itemCount)';
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import '../../../../core/utils/text.dart';
|
||||
|
||||
/// 侧栏导航节点。
|
||||
///
|
||||
/// `index3.html` 的侧栏是两层的:一级条目要么直接指向一个分区(如「常用推荐」),
|
||||
/// 要么是一个可以展开的分组(如「素材资源」),分组下面是若干二级条目。
|
||||
/// 这里用同一棵树表达两种形态——[children] 为空即为直接跳转的叶子。
|
||||
class NavNode {
|
||||
const NavNode({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.children = const <NavNode>[],
|
||||
this.editable = false,
|
||||
});
|
||||
|
||||
/// 唯一标识;叶子节点同时也是内容区的锚点 id。
|
||||
final String id;
|
||||
|
||||
/// 显示文字。
|
||||
final String title;
|
||||
|
||||
/// 子节点;为空表示这是一个直接跳转的条目。
|
||||
final List<NavNode> children;
|
||||
|
||||
/// 这个分组是不是用户创建的(后端返回的 `editable`)。
|
||||
///
|
||||
/// 提交表单据此给出「已有的用户分组」建议:内置分组不可选,
|
||||
/// 因为提交的内容永远进不了内置分组。
|
||||
final bool editable;
|
||||
|
||||
/// 是否是可展开的分组。
|
||||
bool get isGroup => children.isNotEmpty;
|
||||
|
||||
/// 该节点覆盖的所有叶子(自身是叶子时返回自身,否则递归收集后代)。
|
||||
List<NavNode> get leaves {
|
||||
if (children.isEmpty) {
|
||||
return <NavNode>[this];
|
||||
}
|
||||
return children
|
||||
.expand((NavNode child) => child.leaves)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// 侧栏折叠时显示的短标签。
|
||||
///
|
||||
/// 取标题首字,与 logo 折叠成「S」是同一套规则;
|
||||
/// 用 `runes` 保证中文与 emoji 不会被截成半个字符。
|
||||
String get shortLabel => firstChar(title);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
other is NavNode && other.id == id && other.title == title;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, title);
|
||||
|
||||
@override
|
||||
String toString() => 'NavNode(id: $id, title: $title, children: ${children.length})';
|
||||
}
|
||||
@@ -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<String, dynamic> toJson() => <String, dynamic>{
|
||||
'title': title,
|
||||
'url': url,
|
||||
'description': description,
|
||||
'group': group,
|
||||
'subGroup': subGroup,
|
||||
'submittedBy': submittedBy,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import 'dart:async';
|
||||
|
||||
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 '../widgets/back_to_top_button.dart';
|
||||
import '../widgets/link_section_view.dart';
|
||||
import '../widgets/sidebar_nav.dart';
|
||||
import '../widgets/top_bar.dart';
|
||||
|
||||
/// 首页:可折叠侧栏 + 顶部控制栏 + 分组卡片网格。
|
||||
///
|
||||
/// 数据来自后端 `GET /api/nav`,所以页面要表达三种状态:加载中、加载失败
|
||||
/// (带重试)、加载完成。布局与交互仍然以项目根目录的 `index3.html` 为准。
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({
|
||||
super.key,
|
||||
required this.repository,
|
||||
required this.urlOpener,
|
||||
});
|
||||
|
||||
/// 数据来源(当前是 HTTP 实现,测试里注入 mock)。
|
||||
final HomeRepository repository;
|
||||
|
||||
/// 打开链接的能力(当前是 Web 实现)。
|
||||
final UrlOpener urlOpener;
|
||||
|
||||
@override
|
||||
State<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
HomeData? _data;
|
||||
String? _error;
|
||||
bool _loading = true;
|
||||
|
||||
/// 有对应分区的分组,用于「点导航滚动过去」;数据到了之后才建。
|
||||
Map<String, GlobalKey> _sectionKeys = <String, GlobalKey>{};
|
||||
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
/// 已展开的分组。原型初始全部收起。
|
||||
final Set<String> _openGroupIds = <String>{};
|
||||
|
||||
/// 侧栏是否折叠。原型初始是展开的(body 上没有 sidebar-collapsed)。
|
||||
bool _collapsed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 向后端取一次数据。失败时把说明留在 `_error` 里给用户看,并允许重试。
|
||||
///
|
||||
/// [showLoading] 为 false 时是「静默刷新」:页面继续显示旧内容,
|
||||
/// 失败了只提示一句,不会把已经渲染好的页面换成错误页。
|
||||
Future<void> _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 = <String, GlobalKey>{
|
||||
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`。
|
||||
void _toggleSidebar() {
|
||||
setState(() => _collapsed = !_collapsed);
|
||||
}
|
||||
|
||||
/// `.has-sub` 的点击:折叠时先展开侧栏,再切换该分组的 open。
|
||||
void _toggleGroup(String groupId) {
|
||||
setState(() {
|
||||
if (_collapsed) {
|
||||
_collapsed = false;
|
||||
_openGroupIds.add(groupId);
|
||||
return;
|
||||
}
|
||||
if (!_openGroupIds.remove(groupId)) {
|
||||
_openGroupIds.add(groupId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 点导航叶子:滚到对应分区。
|
||||
///
|
||||
/// 后端允许存在没有网站的分组,这类分组在内容区没有对应元素,
|
||||
/// 点了不会有任何反应——和原型里那几个空锚点一致。
|
||||
void _goToSection(String sectionId) {
|
||||
final BuildContext? sectionContext = _sectionKeys[sectionId]?.currentContext;
|
||||
if (sectionContext == null) {
|
||||
return;
|
||||
}
|
||||
unawaited(
|
||||
Scrollable.ensureVisible(
|
||||
sectionContext,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openItem(LinkItem item) => widget.urlOpener.open(item.url);
|
||||
|
||||
/// 「提交网站」跳到后端配好的收集入口(Mattermost 之类)。
|
||||
///
|
||||
/// 地址在 `data/nav.json` 的 `site.submitUrl`;没配就什么都不做。
|
||||
void _openSubmitUrl(HomeData data) {
|
||||
if (data.submitUrl.isEmpty) {
|
||||
return;
|
||||
}
|
||||
widget.urlOpener.open(data.submitUrl);
|
||||
}
|
||||
|
||||
void _backToTop() {
|
||||
_scrollController.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 400),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 布局 ----
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: _buildBody(),
|
||||
);
|
||||
}
|
||||
|
||||
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: <Widget>[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
SidebarNav(
|
||||
siteName: data.siteName,
|
||||
navNodes: data.navNodes,
|
||||
// 原型把 active 写死在第一个条目上。
|
||||
activeSectionId: data.activeSectionId,
|
||||
collapsed: _collapsed,
|
||||
openGroupIds: _openGroupIds,
|
||||
onToggleGroup: _toggleGroup,
|
||||
onSectionSelected: _goToSection,
|
||||
onSubmitSite: () => _openSubmitUrl(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(
|
||||
AppSizes.contentPaddingHorizontal,
|
||||
AppSizes.contentPaddingTop,
|
||||
AppSizes.contentPaddingHorizontal,
|
||||
AppSizes.contentPaddingBottom,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
TopBar(
|
||||
searchController: _searchController,
|
||||
searchHint: data.searchHint,
|
||||
sidebarCollapsed: _collapsed,
|
||||
onToggleSidebar: _toggleSidebar,
|
||||
),
|
||||
for (final LinkSection section in data.sections)
|
||||
Padding(
|
||||
// 原型里每个 .grid 都带 margin-bottom: 36px。
|
||||
padding: const EdgeInsets.only(bottom: AppSizes.sectionGap),
|
||||
child: LinkSectionView(
|
||||
key: _sectionKeys[section.id],
|
||||
section: section,
|
||||
onOpen: _openItem,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载中 / 加载失败时占满内容区的提示。
|
||||
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: <Widget>[
|
||||
if (loading) ...<Widget>[
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(height: AppSizes.gridGap),
|
||||
],
|
||||
if (title != null) ...<Widget>[
|
||||
Text(title!, style: AppTextStyles.emptyTitle),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
Text(
|
||||
message,
|
||||
style: AppTextStyles.emptyMessage,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (onRetry != null) ...<Widget>[
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/constants/app_sizes.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
|
||||
/// 右下角的圆形「回到顶部」按钮(`.back-to-top`)。
|
||||
///
|
||||
/// 原型的按钮是一直显示的,这里保持一致;悬停时上移 3px、
|
||||
/// 边框与图标变主色、阴影加深。
|
||||
class BackToTopButton extends StatefulWidget {
|
||||
const BackToTopButton({super.key, required this.onTap});
|
||||
|
||||
/// 点击回调。
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<BackToTopButton> createState() => _BackToTopButtonState();
|
||||
}
|
||||
|
||||
class _BackToTopButtonState extends State<BackToTopButton> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: '回到顶部',
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: AppSizes.transition,
|
||||
// `.back-to-top { transition: all 0.2s ease }`。
|
||||
curve: Curves.ease,
|
||||
transform: Matrix4.translationValues(
|
||||
0,
|
||||
_hovered ? -AppSizes.backToTopHoverLift : 0,
|
||||
0,
|
||||
),
|
||||
width: AppSizes.backToTopSize,
|
||||
height: AppSizes.backToTopSize,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(
|
||||
color: _hovered ? AppColors.primary : AppColors.border,
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: _hovered
|
||||
? AppColors.cardShadowHover
|
||||
: AppColors.cardShadow,
|
||||
),
|
||||
// 原型这里就是一个 ↑ 字符(font-size: 18px)。
|
||||
child: Text(
|
||||
'↑',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: _hovered
|
||||
? AppColors.primary
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
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 '../../domain/models/link_item.dart';
|
||||
|
||||
/// 站点卡片,对应 `index3.html` 里的 `.card`。
|
||||
///
|
||||
/// 结构是「36px 的字母方块 + 名称 + 描述」,悬停时上移 2px 并加强阴影。
|
||||
class LinkCard extends StatefulWidget {
|
||||
const LinkCard({super.key, required this.item, required this.onOpen});
|
||||
|
||||
/// 站点数据。
|
||||
final LinkItem item;
|
||||
|
||||
/// 打开回调。
|
||||
final VoidCallback onOpen;
|
||||
|
||||
@override
|
||||
State<LinkCard> createState() => _LinkCardState();
|
||||
}
|
||||
|
||||
class _LinkCardState extends State<LinkCard> {
|
||||
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.onOpen,
|
||||
child: AnimatedContainer(
|
||||
duration: AppSizes.transition,
|
||||
// `.card { transition: transform .2s, box-shadow .2s }`。
|
||||
curve: Curves.ease,
|
||||
transform: Matrix4.translationValues(
|
||||
0,
|
||||
_hovered ? -AppSizes.cardHoverLift : 0,
|
||||
0,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSizes.cardPaddingHorizontal,
|
||||
vertical: AppSizes.cardPaddingVertical,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(color: AppColors.border),
|
||||
borderRadius: BorderRadius.circular(AppSizes.cardRadius),
|
||||
boxShadow: _hovered
|
||||
? AppColors.cardShadowHover
|
||||
: AppColors.cardShadow,
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
_CardIcon(initial: widget.item.initial),
|
||||
const SizedBox(width: AppSizes.cardIconGap),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 卡片左侧的字母方块(`.card-icon`)。
|
||||
class _CardIcon extends StatelessWidget {
|
||||
const _CardIcon({required this.initial});
|
||||
|
||||
final String initial;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: AppSizes.cardIconSize,
|
||||
height: AppSizes.cardIconSize,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.iconSurface,
|
||||
borderRadius: BorderRadius.circular(AppSizes.cardIconRadius),
|
||||
),
|
||||
child: Text(initial, style: AppTextStyles.cardIcon),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/constants/app_sizes.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/theme/app_text_styles.dart';
|
||||
|
||||
/// 搜索框。
|
||||
class LinkSearchField extends StatefulWidget {
|
||||
const LinkSearchField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.hintText,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final String hintText;
|
||||
|
||||
@override
|
||||
State<LinkSearchField> createState() => _LinkSearchFieldState();
|
||||
}
|
||||
|
||||
class _LinkSearchFieldState extends State<LinkSearchField> {
|
||||
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) {
|
||||
return GestureDetector(
|
||||
// 输入框只占中间一行的高度,点上下留白也让它聚焦——
|
||||
// 原型里整个 44px 高的盒子就是一个 <input>。
|
||||
onTap: _focusNode.requestFocus,
|
||||
child: AnimatedContainer(
|
||||
duration: AppSizes.transition,
|
||||
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,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppSizes.searchRadius),
|
||||
boxShadow: <BoxShadow>[
|
||||
if (_focused)
|
||||
const BoxShadow(color: AppColors.focusRing, spreadRadius: 3)
|
||||
else
|
||||
...AppColors.cardShadow,
|
||||
],
|
||||
),
|
||||
// 垂直居中不要靠固定的 contentPadding。
|
||||
// 那个写法只在「字体行高 + 上下 padding 正好等于内容区高度」时成立,
|
||||
// 换一个字体(浏览器里的系统字体行高通常更高)就会被挤偏。
|
||||
// 这里让 TextField 按自身行高确定高度,再用 Align 把这一行放进
|
||||
// 44px 的内容区里居中——和浏览器渲染单行 <input> 的方式一致,
|
||||
// 与字体行高无关。
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/constants/app_breakpoints.dart';
|
||||
import '../../../../core/constants/app_sizes.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/theme/app_text_styles.dart';
|
||||
import '../../domain/models/link_item.dart';
|
||||
import '../../domain/models/link_section.dart';
|
||||
import 'link_card.dart';
|
||||
|
||||
/// 一个分组:标题 + 自适应网格。
|
||||
class LinkSectionView extends StatelessWidget {
|
||||
const LinkSectionView({
|
||||
super.key,
|
||||
required this.section,
|
||||
required this.onOpen,
|
||||
});
|
||||
|
||||
/// 分组数据。
|
||||
final LinkSection section;
|
||||
|
||||
/// 打开某个站点。
|
||||
final ValueChanged<LinkItem> onOpen;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
_SectionTitle(title: section.title),
|
||||
const SizedBox(height: AppSizes.sectionTitleGap),
|
||||
LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
final int columns = AppBreakpoints.gridColumnsFor(
|
||||
constraints.maxWidth,
|
||||
minItemWidth: AppSizes.gridMinItemWidth,
|
||||
gap: AppSizes.gridGap,
|
||||
);
|
||||
final double itemWidth =
|
||||
(constraints.maxWidth - AppSizes.gridGap * (columns - 1)) /
|
||||
columns;
|
||||
|
||||
return Wrap(
|
||||
spacing: AppSizes.gridGap,
|
||||
runSpacing: AppSizes.gridGap,
|
||||
children: <Widget>[
|
||||
for (final LinkItem item in section.items)
|
||||
SizedBox(
|
||||
width: itemWidth,
|
||||
child: LinkCard(item: item, onOpen: () => onOpen(item)),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 分组标题:左侧一条 4×16 的主色竖条 + 文字(`.section-title::before`)。
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
const _SectionTitle({required this.title});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: AppSizes.sectionAccentWidth,
|
||||
height: AppSizes.sectionAccentHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(AppSizes.sectionAccentRadius),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSizes.sectionAccentGap),
|
||||
// 用 Flexible 包住:窄屏(侧栏展开后主内容只剩一百多像素)时
|
||||
// 「素材资源 - UI 设计」这类长标题会溢出,这里退化成省略号。
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
style: AppTextStyles.sectionTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
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';
|
||||
|
||||
/// 侧栏:logo + 层级导航 + 底部提交入口[cite: 1]。
|
||||
class SidebarNav extends StatelessWidget {
|
||||
const SidebarNav({
|
||||
super.key,
|
||||
required this.siteName,
|
||||
required this.navNodes,
|
||||
required this.activeSectionId,
|
||||
required this.collapsed,
|
||||
required this.openGroupIds,
|
||||
required this.onToggleGroup,
|
||||
required this.onSectionSelected,
|
||||
required this.onSubmitSite,
|
||||
required this.onToggleCollapse,
|
||||
});
|
||||
|
||||
final String siteName;
|
||||
final List<NavNode> navNodes;
|
||||
final String? activeSectionId;
|
||||
final bool collapsed;
|
||||
final Set<String> openGroupIds;
|
||||
final ValueChanged<String> onToggleGroup;
|
||||
final ValueChanged<String> onSectionSelected;
|
||||
final VoidCallback onSubmitSite;
|
||||
final VoidCallback onToggleCollapse;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double horizontalPadding = collapsed
|
||||
? AppSizes.sidebarCollapsedPaddingHorizontal
|
||||
: AppSizes.sidebarPaddingHorizontal;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: AppSizes.sidebarTransition,
|
||||
curve: Curves.fastOutSlowIn,
|
||||
width: collapsed
|
||||
? AppSizes.sidebarCollapsedWidth
|
||||
: AppSizes.sidebarWidth,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border(right: BorderSide(color: AppColors.border)),
|
||||
),
|
||||
child: ClipRect(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
horizontalPadding,
|
||||
AppSizes.sidebarPaddingTop,
|
||||
horizontalPadding,
|
||||
AppSizes.sidebarPaddingBottom,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
_SidebarHeader(siteName: siteName, collapsed: collapsed),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
for (final NavNode node in navNodes)
|
||||
_NavEntry(
|
||||
key: ValueKey(node.id),
|
||||
node: node,
|
||||
collapsed: collapsed,
|
||||
activeSectionId: activeSectionId,
|
||||
open: openGroupIds.contains(node.id),
|
||||
onToggleGroup: onToggleGroup,
|
||||
onSectionSelected: onSectionSelected,
|
||||
onToggleCollapse: onToggleCollapse,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_SidebarFooter(
|
||||
collapsed: collapsed,
|
||||
onSubmitSite: onSubmitSite,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SidebarHeader extends StatelessWidget {
|
||||
const _SidebarHeader({required this.siteName, required this.collapsed});
|
||||
|
||||
final String siteName;
|
||||
final bool collapsed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSizes.sidebarHeaderGap),
|
||||
child: SizedBox(
|
||||
height: AppSizes.sidebarHeaderHeight,
|
||||
child: Center(
|
||||
child: AnimatedCrossFade(
|
||||
duration: AppSizes.sidebarTransition,
|
||||
crossFadeState: collapsed
|
||||
? CrossFadeState.showSecond
|
||||
: CrossFadeState.showFirst,
|
||||
firstChild: Text(
|
||||
siteName,
|
||||
style: AppTextStyles.logo,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
secondChild: Text(
|
||||
firstChar(siteName),
|
||||
style: AppTextStyles.logoIcon,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavEntry extends StatelessWidget {
|
||||
const _NavEntry({
|
||||
super.key,
|
||||
required this.node,
|
||||
required this.collapsed,
|
||||
required this.activeSectionId,
|
||||
required this.open,
|
||||
required this.onToggleGroup,
|
||||
required this.onSectionSelected,
|
||||
required this.onToggleCollapse,
|
||||
});
|
||||
|
||||
final NavNode node;
|
||||
final bool collapsed;
|
||||
final String? activeSectionId;
|
||||
final bool open;
|
||||
final ValueChanged<String> onToggleGroup;
|
||||
final ValueChanged<String> onSectionSelected;
|
||||
final VoidCallback onToggleCollapse;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!node.isGroup) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSizes.navItemGap),
|
||||
child: _NavRow(
|
||||
title: node.title,
|
||||
collapsed: collapsed,
|
||||
selected: node.id == activeSectionId,
|
||||
onTap: () => onSectionSelected(node.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSizes.navItemGap),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
_NavRow(
|
||||
title: node.title,
|
||||
collapsed: collapsed,
|
||||
selected: false,
|
||||
onTap: () {
|
||||
if (collapsed) {
|
||||
onToggleCollapse();
|
||||
}
|
||||
onToggleGroup(node.id);
|
||||
},
|
||||
trailing: _ArrowIcon(open: open),
|
||||
),
|
||||
if (!collapsed)
|
||||
AnimatedSize(
|
||||
duration: AppSizes.subMenuTransition,
|
||||
curve: open
|
||||
? Curves.easeInOut
|
||||
: const Cubic(0, 1, 0, 1),
|
||||
alignment: Alignment.topCenter,
|
||||
child: open
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: AppSizes.subMenuIndent,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
for (final NavNode child in node.children)
|
||||
_SubNavItem(
|
||||
key: ValueKey(child.id),
|
||||
title: child.title,
|
||||
active: child.id == activeSectionId,
|
||||
onTap: () => onSectionSelected(child.id),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox(height: 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ArrowIcon extends StatelessWidget {
|
||||
const _ArrowIcon({required this.open});
|
||||
|
||||
final bool open;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedRotation(
|
||||
duration: AppSizes.subMenuTransition,
|
||||
curve: Curves.ease,
|
||||
turns: open ? 0.25 : 0,
|
||||
child: const Text('›', style: AppTextStyles.navArrow),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavRow extends StatefulWidget {
|
||||
const _NavRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.collapsed,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final bool collapsed;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
State<_NavRow> createState() => _NavRowState();
|
||||
}
|
||||
|
||||
class _NavRowState extends State<_NavRow> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color foreground = widget.selected || _hovered
|
||||
? AppColors.primary
|
||||
: AppColors.textSecondary;
|
||||
|
||||
final Color backgroundColor = widget.selected
|
||||
? AppColors.primaryFaint
|
||||
: (_hovered ? AppColors.navItemHover : Colors.transparent);
|
||||
|
||||
final Widget rowContent = MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: widget.collapsed
|
||||
? 0
|
||||
: AppSizes.navItemPaddingHorizontal,
|
||||
vertical: widget.collapsed
|
||||
? AppSizes.navItemCollapsedPaddingVertical
|
||||
: AppSizes.navItemPaddingVertical,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(AppSizes.navItemRadius),
|
||||
),
|
||||
child: widget.collapsed
|
||||
? Center(
|
||||
child: Text(
|
||||
firstChar(widget.title),
|
||||
style: AppTextStyles.navItem.copyWith(
|
||||
color: foreground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: AppTextStyles.navItem.copyWith(
|
||||
color: foreground,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (widget.trailing != null) widget.trailing!,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (widget.collapsed) {
|
||||
return Tooltip(message: widget.title, child: rowContent);
|
||||
}
|
||||
return rowContent;
|
||||
}
|
||||
}
|
||||
|
||||
class _SubNavItem extends StatefulWidget {
|
||||
const _SubNavItem({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.active,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final bool active;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<_SubNavItem> createState() => _SubNavItemState();
|
||||
}
|
||||
|
||||
class _SubNavItemState extends State<_SubNavItem> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color foreground = widget.active || _hovered
|
||||
? AppColors.primary
|
||||
: AppColors.textSecondary;
|
||||
|
||||
final Color backgroundColor = widget.active
|
||||
? AppColors.primaryFaint
|
||||
: (_hovered ? AppColors.navItemHover : Colors.transparent);
|
||||
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSizes.subNavItemPaddingHorizontal,
|
||||
vertical: AppSizes.subNavItemPaddingVertical,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(AppSizes.navItemRadius),
|
||||
),
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: AppTextStyles.subNavItem.copyWith(color: foreground),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SidebarFooter extends StatelessWidget {
|
||||
const _SidebarFooter({
|
||||
required this.collapsed,
|
||||
required this.onSubmitSite,
|
||||
});
|
||||
|
||||
final bool collapsed;
|
||||
final VoidCallback onSubmitSite;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: AppSizes.sidebarFooterMarginTop),
|
||||
padding: const EdgeInsets.only(top: AppSizes.sidebarFooterPaddingTop),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(top: BorderSide(color: AppColors.border)),
|
||||
),
|
||||
child: _SubmitButton(
|
||||
collapsed: collapsed,
|
||||
onTap: onSubmitSite,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SubmitButton extends StatefulWidget {
|
||||
const _SubmitButton({required this.collapsed, required this.onTap});
|
||||
|
||||
final bool collapsed;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<_SubmitButton> createState() => _SubmitButtonState();
|
||||
}
|
||||
|
||||
class _SubmitButtonState extends State<_SubmitButton> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color backgroundColor = _hovered
|
||||
? AppColors.navItemHover
|
||||
: Colors.transparent;
|
||||
|
||||
final Widget button = MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: widget.collapsed
|
||||
? 0
|
||||
: AppSizes.submitButtonPaddingHorizontal,
|
||||
vertical: widget.collapsed
|
||||
? AppSizes.navItemCollapsedPaddingVertical
|
||||
: AppSizes.submitButtonPaddingVertical,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(AppSizes.navItemRadius),
|
||||
),
|
||||
child: widget.collapsed
|
||||
? Center(
|
||||
child: Text(
|
||||
'+',
|
||||
style: AppTextStyles.submitButton.copyWith(
|
||||
color: _hovered
|
||||
? AppColors.primary
|
||||
: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
'提交网站',
|
||||
style: AppTextStyles.submitButton.copyWith(
|
||||
color: _hovered
|
||||
? AppColors.primary
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Tooltip(message: '提交网站', child: button);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/constants/app_sizes.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import 'link_search_field.dart';
|
||||
|
||||
/// 顶部控制栏:折叠按钮 + 搜索框。
|
||||
///
|
||||
/// 对应 `index3.html` 的 `.top-bar`:整体宽 750px 并且**水平居中**
|
||||
/// (`margin: 0 auto 36px auto`),下方与第一组卡片留 36px。
|
||||
class TopBar extends StatelessWidget {
|
||||
const TopBar({
|
||||
super.key,
|
||||
required this.searchController,
|
||||
required this.searchHint,
|
||||
required this.sidebarCollapsed,
|
||||
required this.onToggleSidebar,
|
||||
});
|
||||
|
||||
/// 搜索框控制器。
|
||||
final TextEditingController searchController;
|
||||
|
||||
/// 搜索框占位文案。
|
||||
final String searchHint;
|
||||
|
||||
/// 侧栏当前是否折叠,用于按钮的提示文案。
|
||||
final bool sidebarCollapsed;
|
||||
|
||||
/// 折叠 / 展开侧栏。
|
||||
final VoidCallback onToggleSidebar;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSizes.topBarMarginBottom),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: AppSizes.topBarMaxWidth,
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
_ToggleButton(
|
||||
collapsed: sidebarCollapsed,
|
||||
onTap: onToggleSidebar,
|
||||
),
|
||||
const SizedBox(width: AppSizes.topBarGap),
|
||||
Expanded(
|
||||
child: LinkSearchField(
|
||||
controller: searchController,
|
||||
hintText: searchHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 折叠按钮(`.toggle-btn`)。
|
||||
class _ToggleButton extends StatefulWidget {
|
||||
const _ToggleButton({required this.collapsed, required this.onTap});
|
||||
|
||||
final bool collapsed;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<_ToggleButton> createState() => _ToggleButtonState();
|
||||
}
|
||||
|
||||
class _ToggleButtonState extends State<_ToggleButton> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color foreground = _hovered
|
||||
? AppColors.primary
|
||||
: AppColors.textSecondary;
|
||||
|
||||
return Tooltip(
|
||||
// 原型写的是 title="展开/折叠侧边栏",文案固定。
|
||||
message: '展开/折叠侧边栏',
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: AppSizes.transition,
|
||||
// `.toggle-btn { transition: all 0.2s }`。
|
||||
curve: Curves.ease,
|
||||
width: AppSizes.toggleButtonSize,
|
||||
height: AppSizes.toggleButtonSize,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(
|
||||
color: _hovered ? AppColors.primary : AppColors.border,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppSizes.toggleButtonRadius),
|
||||
boxShadow: AppColors.cardShadow,
|
||||
),
|
||||
// 原型这里就是一个 ☰ 字符(font-size: 18px)。
|
||||
child: Text(
|
||||
'☰',
|
||||
style: TextStyle(fontSize: 18, color: foreground),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+3
-118
@@ -1,122 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
// This widget is the root of your application.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Flutter Demo',
|
||||
theme: ThemeData(
|
||||
// This is the theme of your application.
|
||||
//
|
||||
// TRY THIS: Try running your application with "flutter run". You'll see
|
||||
// the application has a purple toolbar. Then, without quitting the app,
|
||||
// try changing the seedColor in the colorScheme below to Colors.green
|
||||
// and then invoke "hot reload" (save your changes or press the "hot
|
||||
// reload" button in a Flutter-supported IDE, or press "r" if you used
|
||||
// the command line to start the app).
|
||||
//
|
||||
// Notice that the counter didn't reset back to zero; the application
|
||||
// state is not lost during the reload. To reset the state, use hot
|
||||
// restart instead.
|
||||
//
|
||||
// This works for code too, not just values: Most code changes can be
|
||||
// tested with just a hot reload.
|
||||
colorScheme: .fromSeed(seedColor: Colors.deepPurple),
|
||||
),
|
||||
home: const MyHomePage(title: 'Flutter Demo Home Page'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MyHomePage extends StatefulWidget {
|
||||
const MyHomePage({super.key, required this.title});
|
||||
|
||||
// This widget is the home page of your application. It is stateful, meaning
|
||||
// that it has a State object (defined below) that contains fields that affect
|
||||
// how it looks.
|
||||
|
||||
// This class is the configuration for the state. It holds the values (in this
|
||||
// case the title) provided by the parent (in this case the App widget) and
|
||||
// used by the build method of the State. Fields in a Widget subclass are
|
||||
// always marked "final".
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<MyHomePage> createState() => _MyHomePageState();
|
||||
}
|
||||
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
int _counter = 0;
|
||||
|
||||
void _incrementCounter() {
|
||||
setState(() {
|
||||
// This call to setState tells the Flutter framework that something has
|
||||
// changed in this State, which causes it to rerun the build method below
|
||||
// so that the display can reflect the updated values. If we changed
|
||||
// _counter without calling setState(), then the build method would not be
|
||||
// called again, and so nothing would appear to happen.
|
||||
_counter++;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// This method is rerun every time setState is called, for instance as done
|
||||
// by the _incrementCounter method above.
|
||||
//
|
||||
// The Flutter framework has been optimized to make rerunning build methods
|
||||
// fast, so that you can just rebuild anything that needs updating rather
|
||||
// than having to individually change instances of widgets.
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// TRY THIS: Try changing the color here to a specific color (to
|
||||
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
|
||||
// change color while the other colors stay the same.
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
// Here we take the value from the MyHomePage object that was created by
|
||||
// the App.build method, and use it to set our appbar title.
|
||||
title: Text(widget.title),
|
||||
),
|
||||
body: Center(
|
||||
// Center is a layout widget. It takes a single child and positions it
|
||||
// in the middle of the parent.
|
||||
child: Column(
|
||||
// Column is also a layout widget. It takes a list of children and
|
||||
// arranges them vertically. By default, it sizes itself to fit its
|
||||
// children horizontally, and tries to be as tall as its parent.
|
||||
//
|
||||
// Column has various properties to control how it sizes itself and
|
||||
// how it positions its children. Here we use mainAxisAlignment to
|
||||
// center the children vertically; the main axis here is the vertical
|
||||
// axis because Columns are vertical (the cross axis would be
|
||||
// horizontal).
|
||||
//
|
||||
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
|
||||
// action in the IDE, or press "p" in the console), to see the
|
||||
// wireframe for each widget.
|
||||
mainAxisAlignment: .center,
|
||||
children: [
|
||||
const Text('You have pushed the button this many times:'),
|
||||
Text(
|
||||
'$_counter',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _incrementCounter,
|
||||
tooltip: 'Increment',
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
runApp(const NaviusApp());
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
@@ -208,6 +232,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "15.3.0"
|
||||
web:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
sdks:
|
||||
dart: ">=3.12.2 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
|
||||
@@ -34,7 +34,8 @@ dependencies:
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frontend/core/constants/app_breakpoints.dart';
|
||||
|
||||
void main() {
|
||||
group('AppBreakpoints.gridColumnsFor', () {
|
||||
test('宽度不足一个单元时退化为单列', () {
|
||||
expect(AppBreakpoints.gridColumnsFor(200), 1);
|
||||
expect(AppBreakpoints.gridColumnsFor(220), 1);
|
||||
});
|
||||
|
||||
test('刚好放下两列与放不下时的边界', () {
|
||||
// 两列 = 220 * 2 + 16 = 456
|
||||
expect(AppBreakpoints.gridColumnsFor(456), 2);
|
||||
expect(AppBreakpoints.gridColumnsFor(455), 1);
|
||||
});
|
||||
|
||||
test('宽度越大列数越多', () {
|
||||
expect(AppBreakpoints.gridColumnsFor(900), 3);
|
||||
expect(AppBreakpoints.gridColumnsFor(1200), 5);
|
||||
});
|
||||
|
||||
test('与 CSS 的 auto-fill 规则一致', () {
|
||||
// 列数 = floor((可用宽度 + 间距) / (最小单元 + 间距))
|
||||
for (final double width in <double>[300, 520, 740, 1080, 1440]) {
|
||||
final int columns = AppBreakpoints.gridColumnsFor(width);
|
||||
final double used = columns * 220 + (columns - 1) * 16;
|
||||
expect(used, lessThanOrEqualTo(width));
|
||||
expect(used + 236, greaterThan(width));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
<String>['常用推荐', '素材资源'],
|
||||
);
|
||||
expect(data.navNodes[0].isGroup, isFalse, reason: '没有子分组就是直接跳转的条目');
|
||||
expect(data.navNodes[1].isGroup, isTrue);
|
||||
expect(
|
||||
data.navNodes[1].children.map((NavNode node) => node.title),
|
||||
<String>['UI 设计', '图标库'],
|
||||
);
|
||||
});
|
||||
|
||||
test('只有挂着网站的分组才进内容区', () {
|
||||
expect(
|
||||
data.sections.map((LinkSection section) => section.id),
|
||||
<String>['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),
|
||||
<String>['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<HomeFailure>().having(
|
||||
(HomeFailure failure) => failure.message,
|
||||
'message',
|
||||
contains('找不到数据'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('返回的不是 JSON 时给出明确提示', () async {
|
||||
final HttpHomeRepository repository = repositoryReturning(
|
||||
'<html>502 Bad Gateway</html>',
|
||||
);
|
||||
await expectLater(
|
||||
repository.fetchHomeData(),
|
||||
throwsA(
|
||||
isA<HomeFailure>().having(
|
||||
(HomeFailure failure) => failure.message,
|
||||
'message',
|
||||
contains('不是合法 JSON'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('缺少 groups 时给出明确提示', () async {
|
||||
final HttpHomeRepository repository = repositoryReturning('{"ok":true}');
|
||||
await expectLater(
|
||||
repository.fetchHomeData(),
|
||||
throwsA(
|
||||
isA<HomeFailure>().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<HomeFailure>().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: '[email protected]',
|
||||
);
|
||||
|
||||
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<String, dynamic> body =
|
||||
jsonDecode(captured!.body) as Map<String, dynamic>;
|
||||
expect(body['title'], 'Hugging Face');
|
||||
expect(body['url'], 'https://huggingface.co');
|
||||
expect(body['group'], 'AI 工具');
|
||||
expect(body['subGroup'], '模型社区');
|
||||
expect(body['submittedBy'], '[email protected]');
|
||||
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<HomeFailure>().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<HomeFailure>().having(
|
||||
(HomeFailure failure) => failure.message,
|
||||
'message',
|
||||
contains('连不上后端'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 用来模拟「连不上」的异常。
|
||||
class SocketExceptionStub implements Exception {
|
||||
const SocketExceptionStub();
|
||||
|
||||
@override
|
||||
String toString() => 'Connection refused';
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter_test/flutter_test.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';
|
||||
|
||||
void main() {
|
||||
const MockHomeRepository repository = MockHomeRepository();
|
||||
late HomeData data;
|
||||
|
||||
// 取数改成异步之后,每个用例开始前先拿一次。
|
||||
setUp(() async {
|
||||
data = await repository.fetchHomeData();
|
||||
});
|
||||
|
||||
group('导航树(对照 index3.html 的 nav-group)', () {
|
||||
test('三个一级条目:一个直接跳转,两个可展开分组', () {
|
||||
expect(
|
||||
data.navNodes.map((NavNode node) => node.title),
|
||||
<String>['常用推荐', '素材资源', '开发工具'],
|
||||
);
|
||||
expect(data.navNodes[0].isGroup, isFalse);
|
||||
expect(data.navNodes[1].children.length, 3);
|
||||
expect(data.navNodes[2].children.length, 2);
|
||||
});
|
||||
|
||||
test('叶子 id 与原型里的锚点一致', () {
|
||||
expect(
|
||||
data.navLeaves.map((NavNode node) => node.id),
|
||||
<String>['common', 'design', 'icon', 'stock', 'dev', 'api'],
|
||||
);
|
||||
});
|
||||
|
||||
test('active 固定取第一个条目', () {
|
||||
expect(data.activeSectionId, 'common');
|
||||
});
|
||||
});
|
||||
|
||||
group('内容区(对照 index3.html 的两段内容)', () {
|
||||
test('只有两个分区', () {
|
||||
expect(
|
||||
data.sections.map((LinkSection s) => s.title),
|
||||
<String>['常用推荐', '素材资源 - UI 设计'],
|
||||
);
|
||||
});
|
||||
|
||||
test('两个分区共五张卡片,与原型的卡片数一致', () {
|
||||
expect(data.sectionById('common')!.items, hasLength(3));
|
||||
expect(data.sectionById('design')!.items, hasLength(2));
|
||||
expect(data.itemCount, 5);
|
||||
expect(data.allItems, hasLength(5));
|
||||
});
|
||||
|
||||
test('原型里没有对应元素的锚点取不到分区', () {
|
||||
for (final String id in <String>['icon', 'stock', 'dev', 'api']) {
|
||||
expect(data.sectionById(id), isNull, reason: '$id 不应该有内容');
|
||||
}
|
||||
});
|
||||
|
||||
test('分区 id 不重复', () {
|
||||
final Set<String> ids = data.sections
|
||||
.map((LinkSection s) => s.id)
|
||||
.toSet();
|
||||
expect(ids, hasLength(data.sections.length));
|
||||
});
|
||||
|
||||
test('条目字段完整且地址合法', () {
|
||||
for (final LinkItem item in data.allItems) {
|
||||
expect(item.id, isNotEmpty);
|
||||
expect(item.title, isNotEmpty);
|
||||
expect(item.description, isNotEmpty);
|
||||
expect(item.url, startsWith('https://'));
|
||||
expect(item.host, isNotEmpty);
|
||||
}
|
||||
});
|
||||
|
||||
test('条目 id 全局唯一', () {
|
||||
final List<String> ids = data.allItems
|
||||
.map((LinkItem item) => item.id)
|
||||
.toList();
|
||||
expect(ids.toSet(), hasLength(ids.length));
|
||||
});
|
||||
});
|
||||
|
||||
group('LinkItem.initial', () {
|
||||
test('英文取首字母并大写', () {
|
||||
expect(
|
||||
const LinkItem(id: 'a', title: 'github', url: 'https://a.com').initial,
|
||||
'G',
|
||||
);
|
||||
});
|
||||
|
||||
test('中文取第一个字,不会被截成半个字符', () {
|
||||
expect(
|
||||
const LinkItem(id: 'a', title: '知乎', url: 'https://a.com').initial,
|
||||
'知',
|
||||
);
|
||||
});
|
||||
|
||||
test('空标题有兜底字符', () {
|
||||
expect(
|
||||
const LinkItem(id: 'a', title: '', url: 'https://a.com').initial,
|
||||
'·',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('NavNode.shortLabel', () {
|
||||
test('取首字', () {
|
||||
expect(const NavNode(id: 'x', title: '素材资源').shortLabel, '素');
|
||||
expect(const NavNode(id: 'x', title: 'GitHub').shortLabel, 'G');
|
||||
expect(const NavNode(id: 'x', title: '').shortLabel, '·');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
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/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 '../../../helpers/fake_url_opener.dart';
|
||||
|
||||
void main() {
|
||||
late FakeUrlOpener urlOpener;
|
||||
|
||||
setUp(() {
|
||||
urlOpener = FakeUrlOpener();
|
||||
});
|
||||
|
||||
Future<void> pumpPage(
|
||||
WidgetTester tester, {
|
||||
// 高度取 420:原型只有两段内容,视口再高就滚不动了,
|
||||
// 「回到顶部」这类用例也就没有意义。
|
||||
Size size = const Size(900, 420),
|
||||
HomeRepository repository = const MockHomeRepository(),
|
||||
/// 加载中的用例要停在第一帧,不能等它 settle。
|
||||
bool settle = true,
|
||||
}) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: HomePage(
|
||||
repository: repository,
|
||||
urlOpener: urlOpener,
|
||||
),
|
||||
),
|
||||
);
|
||||
// 数据现在来自 Future,等它落地后再断言。
|
||||
if (settle) {
|
||||
await tester.pumpAndSettle();
|
||||
} else {
|
||||
await tester.pump();
|
||||
}
|
||||
}
|
||||
|
||||
/// 侧栏里的导航文字(避免和内容区的分区标题同名而混淆)。
|
||||
Finder navText(String label) {
|
||||
return find.descendant(
|
||||
of: find.byType(SidebarNav),
|
||||
matching: find.text(label),
|
||||
);
|
||||
}
|
||||
|
||||
/// 内容区的分区标题。
|
||||
Finder sectionTitle(String label) {
|
||||
return find.descendant(
|
||||
of: find.byType(LinkSectionView),
|
||||
matching: find.text(label),
|
||||
);
|
||||
}
|
||||
|
||||
double sidebarWidth(WidgetTester tester) {
|
||||
return tester.getSize(find.byType(SidebarNav)).width;
|
||||
}
|
||||
|
||||
Color? navColor(WidgetTester tester, String label) {
|
||||
return tester.widget<Text>(navText(label)).style?.color;
|
||||
}
|
||||
|
||||
/// 一级条目当前的底色(透明表示没有高亮)。
|
||||
List<String> highlightedRows(WidgetTester tester) {
|
||||
final List<String> rows = <String>[];
|
||||
for (final String label in <String>['常用推荐', '素材资源', '开发工具']) {
|
||||
final AnimatedContainer container = tester.widget<AnimatedContainer>(
|
||||
find
|
||||
.ancestor(
|
||||
of: navText(label),
|
||||
matching: find.byType(AnimatedContainer),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
final Color? color = (container.decoration! as BoxDecoration).color;
|
||||
if (color != null && color.toARGB32() != 0) {
|
||||
rows.add(label);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// 主内容区的滚动控制器。
|
||||
///
|
||||
/// 侧栏的导航容器也是一个 SingleChildScrollView,所以这里用
|
||||
/// 「搜索框的祖先」来精确定位主滚动视图,而不是靠 `.first` / `.last`。
|
||||
ScrollController mainController(WidgetTester tester) {
|
||||
final Finder mainScroll = find.ancestor(
|
||||
of: find.byType(TextField),
|
||||
matching: find.byType(SingleChildScrollView),
|
||||
);
|
||||
return tester.widget<SingleChildScrollView>(mainScroll).controller!;
|
||||
}
|
||||
|
||||
double scrollOffset(WidgetTester tester) => mainController(tester).offset;
|
||||
|
||||
Future<void> expandMaterials(WidgetTester tester) async {
|
||||
await tester.tap(navText('素材资源'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// 折叠态下导航项是空行(没有文字也没有图标),只能按位置定位:
|
||||
/// 0 是侧栏本身,1~3 是三条一级导航。
|
||||
Finder collapsedRow(int index) {
|
||||
return find
|
||||
.descendant(
|
||||
of: find.byType(SidebarNav),
|
||||
matching: find.byType(AnimatedContainer),
|
||||
)
|
||||
.at(index);
|
||||
}
|
||||
|
||||
group('布局', () {
|
||||
testWidgets('侧栏展开常驻,渲染 logo、三条导航与底部提交入口', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
expect(find.byType(SidebarNav), findsOneWidget);
|
||||
expect(sidebarWidth(tester), AppSizes.sidebarWidth);
|
||||
expect(find.text('Suxing Nav'), findsOneWidget);
|
||||
|
||||
expect(navText('常用推荐'), findsOneWidget);
|
||||
expect(navText('素材资源'), findsOneWidget);
|
||||
expect(navText('开发工具'), findsOneWidget);
|
||||
expect(navText('提交网站'), findsOneWidget);
|
||||
expect(find.byType(AppBar), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('子菜单默认收起', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
expect(navText('UI 设计'), findsNothing);
|
||||
expect(navText('图标库'), findsNothing);
|
||||
expect(navText('前端框架'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('两个分区与五张卡片,与原型一致', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
expect(find.byType(LinkSectionView), findsNWidgets(2));
|
||||
expect(find.byType(LinkCard), findsNWidgets(5));
|
||||
expect(sectionTitle('常用推荐'), findsOneWidget);
|
||||
expect(sectionTitle('素材资源 - UI 设计'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('侧栏折叠(对应 #toggleBtn)', () {
|
||||
testWidgets('点折叠按钮后宽度变为 64,logo 变成首字', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('展开/折叠侧边栏'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(sidebarWidth(tester), AppSizes.sidebarCollapsedWidth);
|
||||
expect(find.text('Suxing Nav'), findsNothing);
|
||||
expect(find.text('S'), findsOneWidget);
|
||||
// 折叠态下 .nav-text 与 .submit-text 都被隐藏,只剩空行。
|
||||
expect(navText('常用推荐'), findsNothing);
|
||||
expect(navText('提交网站'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('再点一次可以展开回来', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('展开/折叠侧边栏'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byTooltip('展开/折叠侧边栏'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(sidebarWidth(tester), AppSizes.sidebarWidth);
|
||||
expect(find.text('Suxing Nav'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('分组子菜单(对应 .has-sub 的脚本)', () {
|
||||
testWidgets('点击分组展开子菜单,再点收起', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
await expandMaterials(tester);
|
||||
expect(navText('UI 设计'), findsOneWidget);
|
||||
expect(navText('图标库'), findsOneWidget);
|
||||
expect(navText('高清壁纸'), findsOneWidget);
|
||||
|
||||
await tester.tap(navText('素材资源'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(navText('UI 设计'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('折叠状态下点分组会先展开侧栏', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('展开/折叠侧边栏'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sidebarWidth(tester), AppSizes.sidebarCollapsedWidth);
|
||||
|
||||
await tester.tap(collapsedRow(2));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(sidebarWidth(tester), AppSizes.sidebarWidth);
|
||||
expect(navText('UI 设计'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('导航与内容', () {
|
||||
testWidgets('高亮固定写在第一个条目上', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
expect(navColor(tester, '常用推荐'), AppColors.primary);
|
||||
expect(navColor(tester, '素材资源'), AppColors.textSecondary);
|
||||
expect(highlightedRows(tester), <String>['常用推荐']);
|
||||
});
|
||||
|
||||
testWidgets('点有对应分区的导航会滚过去,高亮不变', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
final double before = scrollOffset(tester);
|
||||
await expandMaterials(tester);
|
||||
await tester.tap(navText('UI 设计'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(scrollOffset(tester), greaterThan(before));
|
||||
expect(navColor(tester, '常用推荐'), AppColors.primary);
|
||||
expect(highlightedRows(tester), <String>['常用推荐']);
|
||||
});
|
||||
|
||||
testWidgets('点没有对应分区的导航不会有任何反应', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
await expandMaterials(tester);
|
||||
await tester.tap(navText('UI 设计'));
|
||||
await tester.pumpAndSettle();
|
||||
final double afterScroll = scrollOffset(tester);
|
||||
|
||||
// #icon 在原型里没有对应元素。
|
||||
await tester.tap(navText('图标库'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(scrollOffset(tester), afterScroll);
|
||||
});
|
||||
|
||||
testWidgets('搜索框只接收输入,不过滤内容', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'GitHub');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(LinkSectionView), findsNWidgets(2));
|
||||
expect(find.byType(LinkCard), findsNWidgets(5));
|
||||
expect(
|
||||
tester.widget<TextField>(find.byType(TextField)).controller?.text,
|
||||
'GitHub',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('滚动', () {
|
||||
testWidgets('滚动主内容不会重建侧栏,也不会改动高亮', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
final ScrollController controller = mainController(tester);
|
||||
|
||||
AnimatedContainer firstRow() {
|
||||
return tester.widget<AnimatedContainer>(
|
||||
find
|
||||
.ancestor(
|
||||
of: navText('常用推荐'),
|
||||
matching: find.byType(AnimatedContainer),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
}
|
||||
|
||||
final AnimatedContainer beforeScroll = firstRow();
|
||||
|
||||
controller.jumpTo(controller.position.maxScrollExtent);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(
|
||||
identical(beforeScroll, firstRow()),
|
||||
isTrue,
|
||||
reason: '滚动主内容时侧栏被重建了',
|
||||
);
|
||||
expect(highlightedRows(tester), <String>['常用推荐']);
|
||||
});
|
||||
|
||||
testWidgets('回到顶部把偏移归零', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
final ScrollController controller = mainController(tester);
|
||||
|
||||
controller.jumpTo(controller.position.maxScrollExtent);
|
||||
await tester.pump();
|
||||
expect(scrollOffset(tester), greaterThan(0));
|
||||
|
||||
await tester.tap(find.byTooltip('回到顶部'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(scrollOffset(tester), 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('其它', () {
|
||||
testWidgets('点击卡片交由 UrlOpener 打开对应地址', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
await tester.tap(find.text('GitHub'));
|
||||
await tester.pump();
|
||||
|
||||
expect(urlOpener.openedUrls, <String>['https://github.com']);
|
||||
});
|
||||
|
||||
testWidgets('点提交网站不会有任何反应', (WidgetTester tester) async {
|
||||
await pumpPage(tester);
|
||||
|
||||
await tester.tap(navText('提交网站'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
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 {
|
||||
await pumpPage(tester);
|
||||
|
||||
await tester.tap(navText('提交网站'));
|
||||
await tester.pump();
|
||||
|
||||
// 地址来自后端 `site.submitUrl`,页面不再自己弹表单。
|
||||
expect(urlOpener.openedUrls, <String>['https://mattermost.example.com']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 数据一直不回来,用来测加载中的那一帧。
|
||||
class _PendingRepository implements HomeRepository {
|
||||
final Completer<HomeData> completer = Completer<HomeData>();
|
||||
|
||||
@override
|
||||
Future<HomeData> fetchHomeData() => completer.future;
|
||||
|
||||
@override
|
||||
Future<void> submitSite(SubmitSiteDraft draft) async {}
|
||||
|
||||
@override
|
||||
Future<void> updateSite(String id, SubmitSiteDraft draft) async {}
|
||||
|
||||
@override
|
||||
Future<void> deleteSite(String id) async {}
|
||||
}
|
||||
|
||||
/// 永远失败。
|
||||
class _FailingRepository implements HomeRepository {
|
||||
const _FailingRepository(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Future<HomeData> fetchHomeData() async => throw HomeFailure(message);
|
||||
|
||||
@override
|
||||
Future<void> submitSite(SubmitSiteDraft draft) async {}
|
||||
|
||||
@override
|
||||
Future<void> updateSite(String id, SubmitSiteDraft draft) async {}
|
||||
|
||||
@override
|
||||
Future<void> deleteSite(String id) async {}
|
||||
}
|
||||
|
||||
/// 第一次失败、第二次成功,用来验证重试按钮真的重新取数。
|
||||
class _FlakyRepository implements HomeRepository {
|
||||
int calls = 0;
|
||||
|
||||
@override
|
||||
Future<HomeData> fetchHomeData() async {
|
||||
calls++;
|
||||
if (calls == 1) {
|
||||
throw const HomeFailure('后端没起来');
|
||||
}
|
||||
return mockHomeData;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> submitSite(SubmitSiteDraft draft) async {}
|
||||
|
||||
@override
|
||||
Future<void> updateSite(String id, SubmitSiteDraft draft) async {}
|
||||
|
||||
@override
|
||||
Future<void> deleteSite(String id) async {}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/gestures.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/domain/models/link_item.dart';
|
||||
import 'package:frontend/features/home/presentation/widgets/link_card.dart';
|
||||
|
||||
const LinkItem _item = LinkItem(
|
||||
id: 'github',
|
||||
title: 'GitHub',
|
||||
url: 'https://github.com',
|
||||
description: '代码托管与开源社区',
|
||||
);
|
||||
|
||||
void main() {
|
||||
late int openCount;
|
||||
|
||||
setUp(() {
|
||||
openCount = 0;
|
||||
});
|
||||
|
||||
Future<void> pumpCard(
|
||||
WidgetTester tester, {
|
||||
LinkItem item = _item,
|
||||
Size size = const Size(280, 200),
|
||||
}) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 260,
|
||||
child: LinkCard(item: item, onOpen: () => openCount++),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
testWidgets('渲染首字母图标、名称与描述', (WidgetTester tester) async {
|
||||
await pumpCard(tester);
|
||||
|
||||
expect(find.text('G'), findsOneWidget);
|
||||
expect(find.text('GitHub'), findsOneWidget);
|
||||
expect(find.text('代码托管与开源社区'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('没有描述时不报错', (WidgetTester tester) async {
|
||||
await pumpCard(
|
||||
tester,
|
||||
item: const LinkItem(id: 'x', title: '示例', url: 'https://example.com'),
|
||||
);
|
||||
|
||||
expect(find.text('示'), findsOneWidget);
|
||||
expect(find.text('示例'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('点击触发打开回调', (WidgetTester tester) async {
|
||||
await pumpCard(tester);
|
||||
|
||||
await tester.tap(find.text('GitHub'));
|
||||
await tester.pump();
|
||||
|
||||
expect(openCount, 1);
|
||||
});
|
||||
|
||||
testWidgets('悬停时上浮', (WidgetTester tester) async {
|
||||
await pumpCard(tester);
|
||||
|
||||
// AnimatedContainer 目标 transform 的 y 分量就是上浮距离:
|
||||
// 直接断言它,不受 Tooltip 等外层 RenderObject 的影响。
|
||||
double lift() {
|
||||
final AnimatedContainer container = tester.widget<AnimatedContainer>(
|
||||
find.descendant(
|
||||
of: find.byType(LinkCard),
|
||||
matching: find.byType(AnimatedContainer),
|
||||
),
|
||||
);
|
||||
return container.transform?.getTranslation().y ?? 0;
|
||||
}
|
||||
|
||||
expect(lift(), 0);
|
||||
|
||||
final TestGesture gesture = await tester.createGesture(
|
||||
kind: PointerDeviceKind.mouse,
|
||||
);
|
||||
await gesture.addPointer(location: tester.getCenter(find.byType(LinkCard)));
|
||||
addTearDown(gesture.removePointer);
|
||||
await gesture.moveTo(tester.getCenter(find.byType(LinkCard)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(lift(), -AppSizes.cardHoverLift);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:frontend/core/services/url_opener.dart';
|
||||
|
||||
/// [UrlOpener] 的测试替身:只记录调用,不真的打开浏览器。
|
||||
class FakeUrlOpener implements UrlOpener {
|
||||
/// 依次记录的链接。
|
||||
final List<String> openedUrls = <String>[];
|
||||
|
||||
@override
|
||||
void open(String url) {
|
||||
openedUrls.add(url);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
// This is a basic Flutter widget test.
|
||||
//
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:frontend/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
// Verify that our counter starts at 0.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('1'), findsNothing);
|
||||
|
||||
// Tap the '+' icon and trigger a frame.
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pump();
|
||||
|
||||
// Verify that our counter has incremented.
|
||||
expect(find.text('0'), findsNothing);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
+21
-22
@@ -1,45 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<!--
|
||||
If you are serving your web app in a path other than the root, change the
|
||||
href value below to reflect the base path you are serving from.
|
||||
|
||||
The path provided below has to start and end with a slash "/" in order for
|
||||
it to work correctly.
|
||||
|
||||
For more details:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||
|
||||
This is a placeholder for base href that will be replaced by the value of
|
||||
the `--base-href` argument provided to `flutter build`.
|
||||
如果应用部署在子路径下,把下面的 href 改成对应前缀,
|
||||
必须以 "/" 开头和结尾。构建时会由 flutter build 的 --base-href 替换。
|
||||
-->
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="A new Flutter project.">
|
||||
<meta name="description" content="Suxing Nav —— 极简导航站,把常用站点收进一个页面,随手可用。">
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<!-- 浏览器地址栏与移动端状态栏配色,与 AppColors.primary 保持一致 -->
|
||||
<meta name="theme-color" content="#3B82F6">
|
||||
|
||||
<!-- iOS 添加到主屏幕时使用的元信息 -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="frontend">
|
||||
<meta name="apple-mobile-web-app-title" content="Suxing Nav">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
|
||||
<title>frontend</title>
|
||||
<title>Suxing Nav</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
|
||||
<style>
|
||||
/* Flutter 引擎加载完成前 body 是空的,先把底色铺成 AppColors.background,
|
||||
避免首屏闪一下白。 */
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: #F7F9FB;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
You can customize the "flutter_bootstrap.js" script.
|
||||
This is useful to provide a custom configuration to the Flutter loader
|
||||
or to give the user feedback during the initialization process.
|
||||
|
||||
For more details:
|
||||
* https://docs.flutter.dev/platform-integration/web/initialization
|
||||
flutter_bootstrap.js 可以自定义,用来给 Flutter 加载器传参,
|
||||
或在初始化期间给用户反馈。详见 Flutter 官方 Web 初始化文档。
|
||||
-->
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
</body>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"short_name": "frontend",
|
||||
"name": "Suxing Nav",
|
||||
"short_name": "Suxing Nav",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"description": "A new Flutter project.",
|
||||
"orientation": "portrait-primary",
|
||||
"background_color": "#F7F9FB",
|
||||
"theme_color": "#3B82F6",
|
||||
"description": "极简导航站,把常用站点收进一个页面,随手可用。",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>简约导航 - 极简网关</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #f7f9fb;
|
||||
--card-bg: #ffffff;
|
||||
--primary-color: #3b82f6;
|
||||
--text-main: #1e293b;
|
||||
--text-sub: #64748b;
|
||||
--border-color: #e2e8f0;
|
||||
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
|
||||
--shadow-hover: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-main);
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 侧边栏 */
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
background-color: var(--card-bg);
|
||||
border-right: 1px solid var(--border-color);
|
||||
padding: 24px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 32px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: block;
|
||||
padding: 12px 16px;
|
||||
color: var(--text-sub);
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-item:hover, .nav-item.active {
|
||||
background-color: #eff6ff;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.main-content {
|
||||
margin-left: 220px;
|
||||
flex: 1;
|
||||
padding: 40px;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
/* 搜索框 */
|
||||
.search-container {
|
||||
margin-bottom: 40px;
|
||||
max-width: 650px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: 100%;
|
||||
padding: 14px 20px;
|
||||
font-size: 15px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background-color: var(--card-bg);
|
||||
box-shadow: var(--shadow);
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.search-box:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
/* 分组与卡片 */
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-main);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-title::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background-color: var(--primary-color);
|
||||
margin-right: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
box-shadow: var(--shadow);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-hover);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background-color: #f1f5f9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-info {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-sub);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- 侧边导航 -->
|
||||
<aside class="sidebar">
|
||||
<div class="logo">Suxing Nav</div>
|
||||
<a href="#common" class="nav-item active">常用推荐</a>
|
||||
<a href="#dev" class="nav-item">开发工具</a>
|
||||
<a href="#design" class="nav-item">设计资源</a>
|
||||
<a href="#media" class="nav-item">影音资讯</a>
|
||||
</aside>
|
||||
|
||||
<!-- 主体内容 -->
|
||||
<main class="main-content">
|
||||
<!-- 搜索框 -->
|
||||
<div class="search-container">
|
||||
<input type="text" class="search-box" placeholder="输入关键字搜索全网资源...">
|
||||
</div>
|
||||
|
||||
<!-- 分组 1 -->
|
||||
<div id="common">
|
||||
<div class="section-title">常用推荐</div>
|
||||
<div class="grid">
|
||||
<a href="#" class="card">
|
||||
<div class="card-icon">G</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title">GitHub</div>
|
||||
<div class="card-desc">代码托管与开源社区</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="#" class="card">
|
||||
<div class="card-icon">Z</div>
|
||||
<div class="card-title-wrap">
|
||||
<div class="card-title">知乎</div>
|
||||
<div class="card-desc">深度内容问答社区</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="#" class="card">
|
||||
<div class="card-icon">B</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title">哔哩哔哩</div>
|
||||
<div class="card-desc">弹幕视频分享网站</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="#" class="card">
|
||||
<div class="card-icon">V</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title">V2EX</div>
|
||||
<div class="card-desc">创意工作者讨论社区</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分组 2 -->
|
||||
<div id="dev">
|
||||
<div class="section-title">开发工具</div>
|
||||
<div class="grid">
|
||||
<a href="#" class="card">
|
||||
<div class="card-icon">S</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title">Stack Overflow</div>
|
||||
<div class="card-desc">程序员技术问答</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="#" class="card">
|
||||
<div class="card-icon">J</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title">掘金</div>
|
||||
<div class="card-desc">开发者技术社区</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+516
@@ -0,0 +1,516 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>简约导航 - 极简网关</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #f7f9fb;
|
||||
--card-bg: #ffffff;
|
||||
--primary-color: #3b82f6;
|
||||
--text-main: #1e293b;
|
||||
--text-sub: #64748b;
|
||||
--border-color: #e2e8f0;
|
||||
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
|
||||
--shadow-hover: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -2px rgba(0, 0, 0, 0.04);
|
||||
|
||||
--sidebar-width: 230px;
|
||||
--sidebar-collapsed-width: 64px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-main);
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 侧边栏 */
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background-color: var(--card-bg);
|
||||
border-right: 1px solid var(--border-color);
|
||||
padding: 20px 12px 12px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
z-index: 100;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body.sidebar-collapsed .sidebar {
|
||||
width: var(--sidebar-collapsed-width);
|
||||
padding: 20px 8px 12px 8px;
|
||||
}
|
||||
|
||||
/* Logo 居中显示 */
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24px;
|
||||
height: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
display: none;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
body.sidebar-collapsed .logo-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.sidebar-collapsed .logo-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 菜单区域 */
|
||||
.nav-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nav-item-wrapper {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 11px 16px;
|
||||
color: var(--text-sub);
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background-color: #f8fafc;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background-color: #eff6ff;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* 小箭头样式与旋转特效 */
|
||||
.arrow-icon {
|
||||
font-size: 14px;
|
||||
transition: transform 0.3s ease;
|
||||
color: #94a3b8;
|
||||
font-family: monospace;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.nav-item-wrapper.open > .nav-link .arrow-icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* 子菜单 */
|
||||
.sub-menu {
|
||||
list-style: none;
|
||||
padding-left: 16px;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s cubic-bezier(0, 1, 0, 1);
|
||||
}
|
||||
|
||||
.nav-item-wrapper.open .sub-menu {
|
||||
max-height: 500px;
|
||||
transition: max-height 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.sub-menu .nav-link {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 侧边栏底部提交入口 */
|
||||
.sidebar-footer {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
color: var(--text-sub);
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
background-color: #f1f5f9;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* 折叠状态隐藏样式 */
|
||||
body.sidebar-collapsed .nav-text,
|
||||
body.sidebar-collapsed .arrow-icon,
|
||||
body.sidebar-collapsed .sub-menu,
|
||||
body.sidebar-collapsed .submit-text {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.sidebar-collapsed .nav-link,
|
||||
body.sidebar-collapsed .submit-btn {
|
||||
justify-content: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.main-content {
|
||||
margin-left: var(--sidebar-width);
|
||||
flex: 1;
|
||||
padding: 24px 40px 40px 40px;
|
||||
transition: margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
body.sidebar-collapsed .main-content {
|
||||
margin-left: var(--sidebar-collapsed-width);
|
||||
}
|
||||
|
||||
/* 顶部控制栏 */
|
||||
.top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
max-width: 750px;
|
||||
margin: 0 auto 36px auto;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 18px;
|
||||
color: var(--text-sub);
|
||||
cursor: pointer;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.search-container {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 20px;
|
||||
font-size: 15px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background-color: var(--card-bg);
|
||||
box-shadow: var(--shadow);
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.search-box:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
/* 分组与卡片 */
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-main);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-title::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background-color: var(--primary-color);
|
||||
margin-right: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
box-shadow: var(--shadow);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-hover);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background-color: #f1f5f9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-info {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-sub);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 纯圆形回到顶部按钮 */
|
||||
.back-to-top {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
right: 30px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 50%;
|
||||
color: var(--text-sub);
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow);
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
z-index: 90;
|
||||
}
|
||||
|
||||
.back-to-top:hover {
|
||||
color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--shadow-hover);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- 侧边导航 -->
|
||||
<aside class="sidebar">
|
||||
<!-- Logo 居中 -->
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
<span class="logo-text">Suxing Nav</span>
|
||||
<span class="logo-icon">S</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 纯文字菜单(已移除所有 emoji 图标) -->
|
||||
<div class="nav-container">
|
||||
<ul class="nav-group">
|
||||
<li class="nav-item-wrapper">
|
||||
<a href="#common" class="nav-link active">
|
||||
<span class="nav-text">常用推荐</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item-wrapper">
|
||||
<div class="nav-link has-sub">
|
||||
<span class="nav-text">素材资源</span>
|
||||
<span class="arrow-icon">›</span>
|
||||
</div>
|
||||
<ul class="sub-menu">
|
||||
<li><a href="#design" class="nav-link"><span class="nav-text">UI 设计</span></a></li>
|
||||
<li><a href="#icon" class="nav-link"><span class="nav-text">图标库</span></a></li>
|
||||
<li><a href="#stock" class="nav-link"><span class="nav-text">高清壁纸</span></a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li class="nav-item-wrapper">
|
||||
<div class="nav-link has-sub">
|
||||
<span class="nav-text">开发工具</span>
|
||||
<span class="arrow-icon">›</span>
|
||||
</div>
|
||||
<ul class="sub-menu">
|
||||
<li><a href="#dev" class="nav-link"><span class="nav-text">前端框架</span></a></li>
|
||||
<li><a href="#api" class="nav-link"><span class="nav-text">在线 API</span></a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 侧边栏底部纯文字提交按钮 -->
|
||||
<div class="sidebar-footer">
|
||||
<a href="#submit" class="submit-btn" title="提交网站">
|
||||
<span class="submit-text">提交网站</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主体内容 -->
|
||||
<main class="main-content">
|
||||
<!-- 顶部控制栏 -->
|
||||
<div class="top-bar">
|
||||
<button class="toggle-btn" id="toggleBtn" title="展开/折叠侧边栏">☰</button>
|
||||
<div class="search-container">
|
||||
<input type="text" class="search-box" placeholder="输入关键字搜索全网资源...">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分组 1 -->
|
||||
<div id="common">
|
||||
<div class="section-title">常用推荐</div>
|
||||
<div class="grid">
|
||||
<a href="#" class="card">
|
||||
<div class="card-icon">G</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title">GitHub</div>
|
||||
<div class="card-desc">代码托管与开源社区</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="#" class="card">
|
||||
<div class="card-icon">Z</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title">知乎</div>
|
||||
<div class="card-desc">深度内容问答社区</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="#" class="card">
|
||||
<div class="card-icon">B</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title">哔哩哔哩</div>
|
||||
<div class="card-desc">弹幕视频分享网站</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分组 2 -->
|
||||
<div id="design">
|
||||
<div class="section-title">素材资源 - UI 设计</div>
|
||||
<div class="grid">
|
||||
<a href="#" class="card">
|
||||
<div class="card-icon">F</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title">Figma</div>
|
||||
<div class="card-desc">协同界面设计工具</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="#" class="card">
|
||||
<div class="card-icon">D</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title">Dribbble</div>
|
||||
<div class="card-desc">全球设计作品分享社区</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 圆形回到顶部按钮 -->
|
||||
<a href="#" class="back-to-top" title="回到顶部">↑</a>
|
||||
|
||||
<script>
|
||||
// 1. 顶部按钮控制侧边栏折叠/展开
|
||||
const toggleBtn = document.getElementById('toggleBtn');
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
document.body.classList.toggle('sidebar-collapsed');
|
||||
});
|
||||
|
||||
// 2. 小箭头菜单折叠/展开
|
||||
const subMenuTriggers = document.querySelectorAll('.has-sub');
|
||||
subMenuTriggers.forEach(trigger => {
|
||||
trigger.addEventListener('click', () => {
|
||||
if (document.body.classList.contains('sidebar-collapsed')) {
|
||||
document.body.classList.remove('sidebar-collapsed');
|
||||
}
|
||||
const parentItem = trigger.closest('.nav-item-wrapper');
|
||||
parentItem.classList.toggle('open');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user