feat: 实现导航页基础功能

- 添加导航条目和分类数据模型
- 实现导航卡片网格展示
- 支持点击卡片打开链接
- 添加主题样式(渐变色、卡片阴影)
- 区分系统默认和自定义条目
- 添加分类统计信息"
This commit is contained in:
2026-09-19 21:53:15 +08:00
parent e2cc3ac30f
commit 1c98340fc5
42 changed files with 6054 additions and 140 deletions
+219
View File
@@ -0,0 +1,219 @@
# 后端数据结构设计:网站与分组
> 本文是设计稿,已按此实现。代码在 `backend/`,接口与运行方式见
> [backend/README.md](../backend/README.md)。
## 1. 需求与边界
- 后端是 Rust,**数据用 JSON 文件存储**,不引入数据库。
- 网站分两类:
- **系统内置**:随项目发布,只读,只有项目维护者能改。
- **可修改**:用户提交、或第三方通过接口推送进来的,能改、能删。
- **不需要审核**:提交即上线。
- **图标沿用当前逻辑**:前端取名称首字渲染,后端不存图标。
- **不统计访问量**。
- **提交的网站不能放进内置分组**:提交时必须自带分组名(或分组 + 子分组名),
后端只在「用户创建的分组」里找或建;内置分组只有项目维护者自己维护。
## 2. 一个网站条目的完整结构
系统内置的一条(`index3.html` 里的 GitHub):
```json
{
"id": "github",
"title": "GitHub",
"url": "https://github.com",
"description": "代码托管与开源社区",
"categoryId": "common",
"source": "system",
"order": 10,
"createdAt": "2026-09-13T10:29:48+08:00",
"updatedAt": "2026-09-13T10:29:48+08:00",
"submittedBy": ""
}
```
用户提交的一条:
```json
{
"id": "s-20260919-8f3a1c",
"title": "Hugging Face",
"url": "https://huggingface.co",
"description": "开源模型与数据集社区",
"categoryId": "g-20260919-4d21ab",
"source": "submission",
"order": 0,
"createdAt": "2026-09-19T14:02:11+08:00",
"updatedAt": "2026-09-19T14:02:11+08:00",
"submittedBy": "[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/
├── site.json # 站点名与搜索框提示,只读
├── system-categories.json # 内置分组,只读,项目维护者手写
├── system-sites.json # 内置网站,只读
├── user-categories.json # 提交时创建的分组,可写
└── user-sites.json # 提交 / 推送的网站,可写
```
- 读:启动时全量加载进内存,之后都从内存读,不再碰磁盘。
- 写:只写 `user-*.json` 两个文件。**代码里不存在写 `system-*.json` 的路径**,
「不可修改」靠结构保证,而不是靠每处调用都记得判断。
- 写盘用「先写临时文件再改名」,避免写到一半断电把文件写坏。
- JSON 用 2 空格缩进、保留中文原文,方便直接看 diff。
### 5.1 启动时的自检
启动时校验一遍,不通过就直接启动失败(防止手改 JSON 越权):
- `system-*.json` 里的条目 `source` 必须全是 `system`;
`user-*.json` 里必须全不是。
- 每个网站的 `categoryId` 必须存在,并且**分组来源要和网站来源一致**:
内置网站只能挂在系统分组下,提交的网站只能挂在用户分组下。
- 分组 `parentId` 指向的分组必须存在,且不能出现两级以上、不能自己指向自己。
## 6. 校验规则
写入(用户提交 / 接口推送)时逐条校验,任何一条不过就整体拒绝并返回原因:
| 规则 | 说明 |
| --- | --- |
| `id` 唯一 | 与已有网站都不冲突 |
| `title` 非空、≤ 60 字符 | 去掉首尾空白后判断 |
| `url` 合法 | 可解析,协议必须是 `http` / `https`,且不能是 localhost 与内网地址 |
| `description` ≤ 120 字符 | 允许为空 |
| `group` 非空、≤ 30 字符 | 没有它就没地方放,直接拒绝 |
| `subGroup` ≤ 30 字符 | 可选;给了就必须和 `group` 一起解析成两级 |
| `source` 只能是 `submission` / `api` | 外部写入不允许声称自己是 `system` |
## 7. 和前端现有模型的关系
前端 `LinkItem` 目前用到 `id` / `title` / `url` / `description` 四个字段,
本次结构是它的超集,前端不改也能读出来;`categoryId`、`source`、`editable`
等字段等前端要用时再接。
字段名统一用 **camelCase**,和前端 Dart 的 `fromJson` 对齐,前端不用做名字转换;
Rust 侧用 `#[serde(rename_all = "camelCase")]` 映射。
**新加字段一律带 `#[serde(default)]`**,这样以后往结构里加字段,老 JSON 仍然能读,
不需要写迁移脚本。
## 8. 这次没有放进结构里的东西
按「先保留当前逻辑」处理,以下都不存;以后要用再加字段(加字段不需要迁移):
| 没放的 | 原因 | 以后要加时 |
| --- | --- | --- |
| `icon` | 前端当前是取 `title` 首字渲染 | 加 `icon` 字段,存 favicon 地址或 emoji |
| `tags` | 当前侧栏和卡片都没有标签 | 加 `tags: []`,提交接口再放开这个入参 |
| `visits` | 不统计访问量 | 要么由后端跳转接口累加,要么接第三方统计 |
| `status` | 不需要审核,提交即上线 | 加 `status` 字段并给默认值 `published` |