feat: 实现导航页基础功能

- 添加导航条目和分类数据模型
- 实现导航卡片网格展示
- 支持点击卡片打开链接
- 添加主题样式(渐变色、卡片阴影)
- 区分系统默认和自定义条目
- 添加分类统计信息"
This commit is contained in:
2026-09-19 23:17:36 +08:00
parent 1c98340fc5
commit 039ea70edf
26 changed files with 796 additions and 2583 deletions
+28 -11
View File
@@ -27,22 +27,39 @@ cargo clippy --all-targets
```
data/
├── site.json # 站点名与搜索框提示,只读
├── system-categories.json # 内置分组,只读,自己手写维护
├── system-sites.json # 内置网站,只读
├── user-categories.json # 提交时创建的分组,自动写
└── user-sites.json # 提交 / 推送的网站,自动写
└── nav.json # 站点信息 + 分组结构 + 网站,全在这一份文档里
```
侧栏的结构和网站数据放在同一个文件里,删除网站和「它的分组要不要一起清掉」
是同一次写入完成的——不会出现网站没了、侧栏还挂着一个空分组的中间状态。
写盘用「先写临时文件再改名」,不会因为写到一半断电把文件写坏。
```json
{
"site": { "name": "Suxing Nav", "searchHint": "…", "submitUrl": "https://mattermost…" },
"groups": [
{
"id": "common", "title": "常用推荐", "source": "system", "order": 10,
"createdAt": "…", "updatedAt": "…",
"sites": [ { "id": "github", "title": "GitHub", "url": "…", "source": "system", … } ],
"children": []
}
]
}
```
启动时会自检,任何一条不通过就直接退出(避免带着坏数据继续跑):
- `system-*.json` 里的 `source` 必须全是 `system`,`user-*.json` 里必须全不是;
- 网站 id、分组 id 不能重复;
- 每个网站的 `categoryId` 必须存在,且**内置网站只能挂在内置分组下、
提交的网站只能挂在用户分组下**;
- 分组的 `parentId` 必须存在、必须是一级分组(最多两级),也不能指向自己。
- `site.name` 不能为空;
- 分组 id、网站 id 不能重复(两者共用一个 id 空间);
- 分组最多两级;
- **内置分组下只能放内置网站,用户分组下只能放用户网站**。
写盘用「先写临时文件再改名」,不会因为写到一半断电把文件写坏。
### 内置与用户数据怎么隔离
以前是「系统文件只读、用户文件可写」,现在合并成一个文件,隔离改成靠 `source`:
写操作只会碰 `source != system` 的分组和网站,内置的那部分只会被原样读出来再写回去。
启动自检会拦住「手改 JSON 把内置网站标成用户来源」这类越权。
## 接口
+148
View File
@@ -0,0 +1,148 @@
{
"site": {
"name": "Suxing Nav",
"searchHint": "输入关键字搜索全网资源...",
"submitUrl": "https://mattermost.example.com"
},
"groups": [
{
"id": "common",
"title": "常用推荐",
"source": "system",
"order": 10,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"sites": [
{
"id": "github",
"title": "GitHub",
"url": "https://github.com",
"description": "代码托管与开源社区",
"source": "system",
"order": 10,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"submittedBy": ""
},
{
"id": "zhihu",
"title": "知乎",
"url": "https://www.zhihu.com",
"description": "深度内容问答社区",
"source": "system",
"order": 20,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"submittedBy": ""
},
{
"id": "bilibili",
"title": "哔哩哔哩",
"url": "https://www.bilibili.com",
"description": "弹幕视频分享网站",
"source": "system",
"order": 30,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"submittedBy": ""
}
],
"children": []
},
{
"id": "materials",
"title": "素材资源",
"source": "system",
"order": 20,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"sites": [],
"children": [
{
"id": "design",
"title": "UI 设计",
"source": "system",
"order": 10,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"sites": [
{
"id": "figma",
"title": "Figma",
"url": "https://www.figma.com",
"description": "协同界面设计工具",
"source": "system",
"order": 10,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"submittedBy": ""
},
{
"id": "dribbble",
"title": "Dribbble",
"url": "https://dribbble.com",
"description": "全球设计作品分享社区",
"source": "system",
"order": 20,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"submittedBy": ""
}
],
"children": []
},
{
"id": "icon",
"title": "图标库",
"source": "system",
"order": 20,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"sites": [],
"children": []
},
{
"id": "stock",
"title": "高清壁纸",
"source": "system",
"order": 30,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"sites": [],
"children": []
}
]
},
{
"id": "dev-tools",
"title": "开发工具",
"source": "system",
"order": 30,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"sites": [],
"children": [
{
"id": "dev",
"title": "前端框架",
"source": "system",
"order": 10,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"sites": [],
"children": []
},
{
"id": "api",
"title": "在线 API",
"source": "system",
"order": 20,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"sites": [],
"children": []
}
]
}
]
}
-4
View File
@@ -1,4 +0,0 @@
{
"name": "Suxing Nav",
"searchHint": "输入关键字搜索全网资源..."
}
-74
View File
@@ -1,74 +0,0 @@
[
{
"id": "common",
"title": "常用推荐",
"parentId": "",
"source": "system",
"order": 10,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z"
},
{
"id": "materials",
"title": "素材资源",
"parentId": "",
"source": "system",
"order": 20,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z"
},
{
"id": "design",
"title": "UI 设计",
"parentId": "materials",
"source": "system",
"order": 10,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z"
},
{
"id": "icon",
"title": "图标库",
"parentId": "materials",
"source": "system",
"order": 20,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z"
},
{
"id": "stock",
"title": "高清壁纸",
"parentId": "materials",
"source": "system",
"order": 30,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z"
},
{
"id": "dev-tools",
"title": "开发工具",
"parentId": "",
"source": "system",
"order": 30,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z"
},
{
"id": "dev",
"title": "前端框架",
"parentId": "dev-tools",
"source": "system",
"order": 10,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z"
},
{
"id": "api",
"title": "在线 API",
"parentId": "dev-tools",
"source": "system",
"order": 20,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z"
}
]
-62
View File
@@ -1,62 +0,0 @@
[
{
"id": "github",
"title": "GitHub",
"url": "https://github.com",
"description": "代码托管与开源社区",
"categoryId": "common",
"source": "system",
"order": 10,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"submittedBy": ""
},
{
"id": "zhihu",
"title": "知乎",
"url": "https://www.zhihu.com",
"description": "深度内容问答社区",
"categoryId": "common",
"source": "system",
"order": 20,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"submittedBy": ""
},
{
"id": "bilibili",
"title": "哔哩哔哩",
"url": "https://www.bilibili.com",
"description": "弹幕视频分享网站",
"categoryId": "common",
"source": "system",
"order": 30,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"submittedBy": ""
},
{
"id": "figma",
"title": "Figma",
"url": "https://www.figma.com",
"description": "协同界面设计工具",
"categoryId": "design",
"source": "system",
"order": 10,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"submittedBy": ""
},
{
"id": "dribbble",
"title": "Dribbble",
"url": "https://dribbble.com",
"description": "全球设计作品分享社区",
"categoryId": "design",
"source": "system",
"order": 20,
"createdAt": "2026-09-13T10:29:48Z",
"updatedAt": "2026-09-13T10:29:48Z",
"submittedBy": ""
}
]
-47
View File
@@ -1,47 +0,0 @@
[
{
"id": "g-20260919-c8460f",
"title": "嗯嗯",
"parentId": "",
"source": "submission",
"order": 10,
"createdAt": "2026-09-19T13:30:25Z",
"updatedAt": "2026-09-19T13:30:25Z"
},
{
"id": "g-20260919-e78b21",
"title": "收拾收拾",
"parentId": "g-20260919-c8460f",
"source": "submission",
"order": 10,
"createdAt": "2026-09-19T13:32:03Z",
"updatedAt": "2026-09-19T13:32:03Z"
},
{
"id": "g-20260919-460478",
"title": "AI 工具",
"parentId": "",
"source": "submission",
"order": 20,
"createdAt": "2026-09-19T13:38:10Z",
"updatedAt": "2026-09-19T13:38:10Z"
},
{
"id": "g-20260919-6b3fa7",
"title": "模型社区",
"parentId": "g-20260919-460478",
"source": "submission",
"order": 10,
"createdAt": "2026-09-19T13:38:10Z",
"updatedAt": "2026-09-19T13:38:10Z"
},
{
"id": "g-20260919-91da17",
"title": "反反复复",
"parentId": "g-20260919-c8460f",
"source": "submission",
"order": 20,
"createdAt": "2026-09-19T13:39:53Z",
"updatedAt": "2026-09-19T13:39:53Z"
}
]
-14
View File
@@ -1,14 +0,0 @@
[
{
"id": "s-20260919-f103b8",
"title": "Hugging Face",
"url": "https://huggingface.co",
"description": "开源模型与数据集社区",
"categoryId": "g-20260919-6b3fa7",
"source": "submission",
"order": 10,
"createdAt": "2026-09-19T13:38:10Z",
"updatedAt": "2026-09-19T13:38:10Z",
"submittedBy": "[email protected]"
}
]
+4 -6
View File
@@ -9,9 +9,7 @@ use axum::{Json, Router};
use tower_http::cors::CorsLayer;
use crate::error::ApiError;
use crate::model::{
CreateSiteRequest, NavResponse, Site, SiteView, Source, UpdateSiteRequest,
};
use crate::model::{CreateSiteRequest, NavResponse, SiteView, Source, UpdateSiteRequest};
use crate::store::{Store, Summary};
/// 全局状态。仓库放在互斥锁里:接口可能并发到达,而写文件必须串行。
@@ -81,7 +79,7 @@ async fn update_site(
) -> Result<Json<SiteView>, ApiError> {
let mut store = lock(&state)?;
let site = store.update(&id, &request)?;
Ok(Json(SiteView::from(site)))
Ok(Json(site))
}
async fn delete_site(
@@ -99,8 +97,8 @@ fn create(
source: Source,
) -> Result<(StatusCode, Json<SiteView>), ApiError> {
let mut store = lock(state)?;
let site: Site = store.create(&request, source)?;
Ok((StatusCode::CREATED, Json(SiteView::from(site))))
let site: SiteView = store.create(&request, source)?;
Ok((StatusCode::CREATED, Json(site)))
}
/// 取锁并把「锁被毒化」翻译成 500。
+49 -58
View File
@@ -1,11 +1,14 @@
//! 落盘数据结构与对外返回结构。
//! 数据结构:落盘的一棵树、对外返回的视图、以及请求入参。
//!
//! 落盘只有一个文件 `data/nav.json`:站点信息、分组 / 子分组、以及挂在分组下的
//! 网站全在一份文档里。这样删除一个网站时,它的分组是否需要一起清掉,
//! 和网站本身在同一次写入里完成,不会出现「网站没了、分组还空着」的中间状态。
use serde::{Deserialize, Serialize};
/// 数据来源。**唯一决定条目能不能被修改的字段。**
///
/// 不额外存 `editable`:存两份状态迟早会不一致,比如手改 JSON 把 `source`
/// 改成 `system`、`editable` 却还是 `true`。对外返回时按这里推导。
/// 不额外存 `editable`:存两份状态迟早会不一致。对外返回时按这里推导。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Source {
@@ -24,14 +27,28 @@ impl Source {
}
}
/// 一个网站条目。
/// 站点级配置,由项目维护者手改。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SiteMeta {
/// 站点名,显示在侧栏顶部与浏览器标题。
pub name: String,
/// 搜索框占位文案。
#[serde(default)]
pub search_hint: String,
/// 「提交网站」跳转的地址(Mattermost 之类的收集入口)。
#[serde(default)]
pub submit_url: String,
}
/// 一个网站。
///
/// 字段名在 JSON 里是 camelCase(和前端 Dart 的 `fromJson` 一致),
/// 以后新增字段一律加 `#[serde(default)]`,老 JSON 文件仍然能读,不必写迁移。
/// 网站不再自己记 `categoryId`——它就挂在某个分组的 `sites` 里,
/// 归属关系由位置表达,删除分组时不会留下指向空处的引用。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Site {
/// 唯一标识,也是接口路径里的 id。
/// 唯一标识。
pub id: String,
/// 显示名称。前端取它的首字当图标。
pub title: String,
@@ -39,8 +56,6 @@ pub struct Site {
pub url: String,
#[serde(default)]
pub description: String,
/// 所属分组或子分组的 id。
pub category_id: String,
pub source: Source,
/// 同一分组内的排序值,小的在前。
#[serde(default)]
@@ -53,71 +68,58 @@ pub struct Site {
}
impl Site {
/// 能不能被接口修改。
pub fn is_editable(&self) -> bool {
!self.source.is_system()
}
}
/// 分组。`parent_id` 为空表示一级分组,不为空表示子分组。
/// 一个分组:可以挂网站,也可以带子分组(层级最多两级)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Category {
pub struct Group {
pub id: String,
pub title: String,
#[serde(default)]
pub parent_id: String,
pub source: Source,
#[serde(default)]
pub order: i32,
pub created_at: String,
pub updated_at: String,
/// 直接挂在这个分组下的网站。
#[serde(default)]
pub sites: Vec<Site>,
/// 子分组;只有一级分组会有。
#[serde(default)]
pub children: Vec<Group>,
}
impl Category {
/// 是不是一级分组。
pub fn is_root(&self) -> bool {
self.parent_id.is_empty()
}
/// 能不能被接口改动(提交时新建的分组才能被后续提交复用,内置分组只看不改)。
impl Group {
pub fn is_editable(&self) -> bool {
!self.source.is_system()
}
}
/// 对外返回的网站:在落盘结构上补一个推导出来的 `editable`。
/// `data/nav.json` 的完整内容。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NavFile {
pub site: SiteMeta,
pub groups: Vec<Group>,
}
/// 对外返回的网站:补上它所属分组的 id 和推导出来的 `editable`。
///
/// 这里存一份 `Site` 的拷贝而不是借用:接口层需要先把互斥锁放开、
/// 再去序列化响应,借用会让生命周期绑死在锁上。
/// `categoryId` 在落盘结构里已经由位置表达了,但接口仍然输出它——
/// 前端编辑时要靠它反查出分组名。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SiteView {
#[serde(flatten)]
pub site: Site,
/// 由 `source` 推导,不落盘。前端据此决定要不要显示编辑/删除入口。
pub category_id: String,
pub editable: bool,
}
impl From<Site> for SiteView {
fn from(site: Site) -> Self {
Self {
editable: site.is_editable(),
site,
}
}
}
impl From<&Site> for SiteView {
fn from(site: &Site) -> Self {
Self::from(site.clone())
}
}
/// 对外返回的分组节点:带上自己直接挂着的网站与下一级子分组。
///
/// 前端侧栏与内容区需要的正是这棵树,所以直接在接口层拼好,
/// 前端不用再拿两份数组自己组装。
/// 对外返回的分组节点。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NavGroupView {
@@ -133,22 +135,11 @@ pub struct NavGroupView {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NavResponse {
/// 站点级配置,前端不再自己写死站点名和搜索框提示。
/// 站点级配置:前端不再自己写死站点名、搜索提示和提交入口。
pub site: SiteMeta,
pub groups: Vec<NavGroupView>,
}
/// 站点级配置,存在 `data/site.json`,由项目维护者手改。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SiteMeta {
/// 站点名,显示在侧栏顶部与浏览器标题。
pub name: String,
/// 搜索框占位文案。
#[serde(default)]
pub search_hint: String,
}
/// 提交 / 推送一个网站的入参。
///
/// **没有 `categoryId`**:提交方只能给分组名,由后端在「用户创建的分组」里
@@ -156,7 +147,7 @@ pub struct SiteMeta {
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateSiteRequest {
// 下面三个字段也用 `default`:缺字段时不要让 axum 抛 422 和英文错误,
// 这三个也用 `default`:缺字段时不要抛 422 和英文错误,
// 交给 `validate` 给出统一的中文 400 说明。
#[serde(default)]
pub title: String,
@@ -167,7 +158,7 @@ pub struct CreateSiteRequest {
/// 一级分组名。
#[serde(default)]
pub group: String,
/// 子分组名,可选;给了就挂到两级下面。
/// 子分组名,可选。
#[serde(default)]
pub sub_group: String,
#[serde(default)]
+500 -487
View File
File diff suppressed because it is too large Load Diff