refactor(db): 删除旧分区展示块与兼容字段

- v23 迁移删除 section_block 表,以及 node 的 view_mode / empty_text / show_in_sidebar / allow_groups
- 重建 node 表并保留 44 条内容、15 个分组、5 张卡片,外键检查通过
- 种子库直接生成 card / card_group,不再依赖 section_block
- 删除旧 /api/catalog、/api/categories、/api/groups/{id} 与 categories 模块
- 分组接口不再接收/返回 format,前端 GroupNode 同步移除
- README/HANDOFF 更新到 v23;管理页提示改为『格式由每条内容决定』
This commit is contained in:
2026-11-02 20:24:08 +08:00
parent 4621f8cb22
commit 27e411fd2c
10 changed files with 213 additions and 1652 deletions
+8 -15
View File
@@ -11,7 +11,7 @@
> - 新增 `/interface` 接口一览页;侧栏「查看接口」走站内路由,不再直接开 `/api/projects`; > - 新增 `/interface` 接口一览页;侧栏「查看接口」走站内路由,不再直接开 `/api/projects`;
> - 接入 `@stash/ui` 组件库:前后台所有卡片、表格、开关、文字按钮都来自库里, > - 接入 `@stash/ui` 组件库:前后台所有卡片、表格、开关、文字按钮都来自库里,
> 明暗主题(浅色 / 深色 / 跟随系统)由令牌接管; > 明暗主题(浅色 / 深色 / 跟随系统)由令牌接管;
> - 内容库表结构升到 v22 并**有了自动迁移**,卡片模型是 `card` + `card_group`(`section_block` 仅作旧库迁移来源)。 > - 内容库表结构升到 v23 并**有了自动迁移**,卡片模型是 `card` + `card_group`;`section_block` 与旧 `node` 兼容字段已删除。
## 0. 一句话现状 ## 0. 一句话现状
@@ -135,11 +135,10 @@ README.md 运行、路由、库结构、登录说明
``` ```
GET /api/health GET /api/health
GET /api/projects GET /api/projects/events (SSE) GET /api/projects GET /api/projects/events (SSE)
GET /api/catalog GET /api/catalog/events (SSE) 旧结构(兼容)
GET /api/catalog2 分组树 + 卡片(新前台/后台) GET /api/catalog2 分组树 + 卡片(新前台/后台)
GET /api/cards 卡片列表 GET /api/cards 卡片列表
POST /api/nodes {key,title,parentKey?,format?} 需登录 POST /api/nodes {key,title,parentKey?,icon?,sort?,enabled?} 需登录
PATCH /api/nodes/{key} {title,parentKey?,sort?,format?} 需登录 PATCH /api/nodes/{key} {title,parentKey?,sort?,icon?,enabled?} 需登录
DELETE /api/nodes/{key} 需登录,级联 DELETE /api/nodes/{key} 需登录,级联
POST /api/cards {title} 需登录 POST /api/cards {title} 需登录
PATCH /api/cards/{id} {title?,sort?} 需登录 PATCH /api/cards/{id} {title?,sort?} 需登录
@@ -162,26 +161,20 @@ DELETE /api/items/{key} 需登录
POST /api/items/{key}/view POST /api/items/{key}/view
POST /api/items/{key}/like {liked} POST /api/items/{key}/like {liked}
POST /api/categories {id,title,kind} 需登录
PATCH /api/categories/{id} {title,icon,barColor,kind,previewLimit,emptyText,blockNodeKey,allowGroups} 需登录
POST /api/categories/{id}/groups {id,title} 需登录,仅应用分区
DELETE /api/categories/{id} 需登录,级联,local 不可删
PATCH /api/groups/{id} {title,showInSidebar} 需登录
DELETE /api/groups/{id} 需登录,级联组内条目
GET /api/backup SQLite 快照附件 需登录 GET /api/backup SQLite 快照附件 需登录
``` ```
## 5. 数据库与迁移 ## 5. 数据库与迁移
- 文件:开发态在 `target/debug/data/stash.db`;可用 `--root` / 数据目录覆盖(见 `config.rs`)。 - 文件:开发态在 `target/debug/data/stash.db`;可用 `--root` / 数据目录覆盖(见 `config.rs`)。
- 当前 `PRAGMA user_version = 22`;`schema::migrate` 逐级升级:0 建全量、15→16→17→18→19→20→21→22、更高版本拒绝启动。 - 当前 `PRAGMA user_version = 23`;`schema::migrate` 逐级升级:0 建全量、15→16→17→18→19→20→21→22→23、更高版本拒绝启动。
- 格式在 `entry.format`(apps / posters / articles),同组可混合;`node` 的旧 `view_mode` 仅保留兼容,不再决定渲染。 - 格式在 `entry.format`(apps / posters / articles),同组可混合;`node` 不再保存格式。
- v23 迁移删除 `section_block` 表与 `node` 的 `view_mode / empty_text / show_in_sidebar / allow_groups` 字段。
- `settings.sidebar.show_profile / show_service` 控制侧栏两张卡片显隐(默认 true)。 - `settings.sidebar.show_profile / show_service` 控制侧栏两张卡片显隐(默认 true)。
- 首页滚动:`#page-scroll`(Layout 的 body)是唯一滚动容器,搜索框固定在其上方;左右两栏顶部对齐,左侧到底后通过 `translateY` 吸底,右侧继续滚动;滚动条隐藏;页头实底,搜索框 50% 透明,顶部另有一层上下渐隐的软虚化(`blur(8px)`)。 - 首页滚动:`#page-scroll`(Layout 的 body)是唯一滚动容器,搜索框固定在其上方;左右两栏顶部对齐,左侧到底后通过 `translateY` 吸底,右侧继续滚动;滚动条隐藏;页头实底,搜索框 50% 透明,顶部另有一层上下渐隐的软虚化(`blur(8px)`)。
- 本地服务是运行时卡片,但 `card` 表里有一行 `local-service` 只用于排序;内容来自 `/api/projects`,后端禁止删除/改名/配置分组。 - 本地服务是运行时卡片,但 `card` 表里有一行 `local-service` 只用于排序;内容来自 `/api/projects`,后端禁止删除/改名/配置分组。
- `node` 是统一分组树(根节点 `kind='section'`,子节点 `kind='group'` 可多层);`enabled` 控制启停;`icon` 可为空。 - `node` 是统一分组树(根节点 `kind='section'`,子节点 `kind='group'` 可多层);`enabled` 控制启停;`icon` 可为空。
- 主要表:`node`、`entry`、`entry_tag`、`section_block`、`admin`、`session`、`setting`、`audit`。 - 主要表:`node`、`entry`、`entry_tag`、`card`、`card_group`、`admin`、`session`、`setting`、`audit`。
- `entry.updated_at` 用于编辑乐观锁;`setting` 表 `key='site'` 存站点 JSON。 - `entry.updated_at` 用于编辑乐观锁;`setting` 表 `key='site'` 存站点 JSON。
- **设置/内容都是 SQLite**:备份用 `/api/backup`,恢复需停机替换文件。 - **设置/内容都是 SQLite**:备份用 `/api/backup`,恢复需停机替换文件。
@@ -223,7 +216,7 @@ GET /api/backup SQLite 快照附件 需登录
| 分组移动 | 编辑弹窗里可改「上级分组」(防环);根分组不能移到子级 | | 分组移动 | 编辑弹窗里可改「上级分组」(防环);根分组不能移到子级 |
| 条目移动 | 内容弹窗里可直接换分组(`PATCH /api/items/{key}` 的 `nodeKey`) | | 条目移动 | 内容弹窗里可直接换分组(`PATCH /api/items/{key}` 的 `nodeKey`) |
| 分组排序 | 可编辑「排序权重」数值;拖拽排序未做 | | 分组排序 | 可编辑「排序权重」数值;拖拽排序未做 |
| 旧接口 | `/api/catalog`、`/api/categories/*`、`/api/groups/*` 仍保留给旧组件/兼容,后续可删 | | 旧接口 | 已删除 `/api/catalog`、`/api/categories/*`、旧 `/api/groups/{id}`;当前只保留 `/api/catalog2` 与 `/api/nodes` |
| 登录限流 | 进程内计数,重启清空 | | 登录限流 | 进程内计数,重启清空 |
## 10. 约定与坑 ## 10. 约定与坑
+12 -18
View File
@@ -201,19 +201,15 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
| `parent_key` | TEXT? | 父节点编码:分组指向所属分区,分区为 NULL | | `parent_key` | TEXT? | 父节点编码:分组指向所属分区,分区为 NULL |
| `title` | TEXT | 展示名:分区标题 / 分组小标题 | | `title` | TEXT | 展示名:分区标题 / 分组小标题 |
| `kind` | TEXT | `section` 顶层分区 / `group` 分区下的分组 | | `kind` | TEXT | `section` 顶层分区 / `group` 分区下的分组 |
| `view_mode` | TEXT? | 仅分区:`apps` / `posters` / `articles`,决定渲染形态 | | `bar_color` | TEXT? | 标题左侧色条颜色(如 `#3858f6`) |
| `bar_color` | TEXT? | 仅分区:标题左侧 4×16 色条颜色(如 `#3858f6`) | | `icon` | TEXT? | 图标:可以是字符(`🎬`),也可以是 iconfont 图标名(`film_line`) |
| `icon` | TEXT? | 分组图标:可以是字符(`🎬`),也可以是 iconfont 图标名(`film_line`) |
| `preview_limit` | INTEGER? | 分组在卡片里的默认预览条数;为空时按类型取默认 | | `preview_limit` | INTEGER? | 分组在卡片里的默认预览条数;为空时按类型取默认 |
| `empty_text` | TEXT? | 分组为空时的提示文案 | | `enabled` | INTEGER | 是否启用,`0` / `1`;停用后侧栏与卡片都不再出现该子树 |
| `show_in_sidebar` | INTEGER | 是否出现在侧栏,`0` / `1`;0 表示只当首页卡片/内容,不进导航 |
| `allow_groups` | INTEGER | 是否允许新建子分组,`0` / `1`;与类型无关,后台开关 |
| `enabled` | INTEGER | 是否启用,`0` / `1`;**唯一可见性来源**,停用后侧栏与卡片都不再出现该子树 |
| `sort` | INTEGER | 同级顺序,越小越靠前 | | `sort` | INTEGER | 同级顺序,越小越靠前 |
两条约束写在表上: 两条约束写在表上:
- `CHECK`:`section` 必须 `parent_key IS NULL` 且有 `view_mode`;`group` 必须有父节点且无 `view_mode`; - `CHECK`:`section` 必须 `parent_key IS NULL`;`group` 必须有父节点;
- `key` 唯一(UNIQUE),全局不重复。 - `key` 唯一(UNIQUE),全局不重复。
#### entry —— 内容条目(网站 / 电影 / 文章共用) #### entry —— 内容条目(网站 / 电影 / 文章共用)
@@ -223,6 +219,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
| `id` | INTEGER | 主键,库内唯一标识(自动分配) | | `id` | INTEGER | 主键,库内唯一标识(自动分配) |
| `key` | TEXT | 业务编码,全局唯一(如 `github` / `movie-1`);详情页按它查找 | | `key` | TEXT | 业务编码,全局唯一(如 `github` / `movie-1`);详情页按它查找 |
| `node_key` | TEXT | 直接所属容器,外键 → `node.key`:应用是分组,海报 / 图文是分区本身 | | `node_key` | TEXT | 直接所属容器,外键 → `node.key`:应用是分组,海报 / 图文是分区本身 |
| `format` | TEXT | 展示格式:`apps` / `posters` / `articles`;逐条设置,同一分组可混合 |
| `name` | TEXT | 名称:网站名 / 电影名 / 文章标题 | | `name` | TEXT | 名称:网站名 / 电影名 / 文章标题 |
| `description` | TEXT? | 一句话简介,列表卡片的副标题 | | `description` | TEXT? | 一句话简介,列表卡片的副标题 |
| `icon` | TEXT? | 字符图标,封面缺失时的占位 | | `icon` | TEXT? | 字符图标,封面缺失时的占位 |
@@ -251,7 +248,7 @@ WHERE COALESCE(p.parent_key, p.key) = 'recommend';
好处是没有冗余列可以写错——条目不会出现「声称属于 A 区、实际挂在 B 区分组下」的情况; 好处是没有冗余列可以写错——条目不会出现「声称属于 A 区、实际挂在 B 区分组下」的情况;
`node_key` 还有 `ON UPDATE CASCADE`,改编码时条目自动跟随。 `node_key` 还有 `ON UPDATE CASCADE`,改编码时条目自动跟随。
三种内容共用一张表,只是**用到的字段不同**,形态由所属分区的 `view_mode` 决定: 三种内容共用一张表,只是**用到的字段不同**,形态由每条自己的 `entry.format` 决定:
| 类型 | 常用字段 | | 类型 | 常用字段 |
| --- | --- | | --- | --- |
@@ -308,8 +305,8 @@ card_group 卡片包含哪些分组 + 每个分组的预览条数与先后
主键 `(card_id, group_key)`:同一张卡片里一个分组只出现一次。 主键 `(card_id, group_key)`:同一张卡片里一个分组只出现一次。
一张卡片可以混合展示多个分组(不同格式也能混),顺序与条数各自独立。 一张卡片可以混合展示多个分组(不同格式也能混),顺序与条数各自独立。
旧版的 `section_block` 表仍保留在库里(`CREATE TABLE IF NOT EXISTS` 不会删表), 旧版的 `section_block` 表已在 v23 迁移中删除;旧库升级时会先把数据回填到
但只作为从旧库迁移到 `card` / `card_group` 的来源,新代码不再读写它。 `card` / `card_group`,再删表,不会丢内容。
### 表关系 ### 表关系
@@ -347,7 +344,7 @@ card 1 ──── n card_group ──── 1 node(勾选的分组 + 每个
| 应用详情 | `entry`(apps) | `name` `description` `icon` `href` `cover` `banner` `badge` `comment` `views` `likes` | | 应用详情 | `entry`(apps) | `name` `description` `icon` `href` `cover` `banner` `badge` `comment` `views` `likes` |
| 海报详情 | `entry` + `entry_tag` | `name`(→`title`)`cover` `comment` `summary` `href` + `tags` | | 海报详情 | `entry` + `entry_tag` | `name`(→`title`)`cover` `comment` `summary` `href` + `tags` |
| 图文详情 | `entry` + `entry_tag` | `name`(→`title`)`description` `cover` `body` + `meta` | | 图文详情 | `entry` + `entry_tag` | `name`(→`title`)`description` `cover` `body` + `meta` |
| 侧栏导航 | `node` | 走 `parent_key` 的分组树,`show_in_sidebar = 0` 与 `enabled = 0` 的节点跳过;「本地服务」由前端排除 | | 侧栏导航 | `node` | 走 `parent_key` 的分组树,`enabled = 0` 的节点跳过;「本地服务」由前端排除 |
| 本地服务 | 不在库里 | 由 `/api/projects` 扫描托管目录后在前端拼接 | | 本地服务 | 不在库里 | 由 `/api/projects` 扫描托管目录后在前端拼接 |
### 侧栏导航 ### 侧栏导航
@@ -370,13 +367,13 @@ CES 现场报道(根分组,无子分组 → 直接打开)
- 有子分组时渲染成可折叠节点,子项就是子分组; - 有子分组时渲染成可折叠节点,子项就是子分组;
- 没有子分组时整行是链接; - 没有子分组时整行是链接;
- 名字一律用 `node.title`,没有别名,也没有跨分区的收纳层; - 名字一律用 `node.title`,没有别名,也没有跨分区的收纳层;
- `show_in_sidebar = 0` 的节点不出现;`enabled = 0` 的节点连同整棵子树都不出现; - `enabled = 0` 的节点连同整棵子树都不出现;
- 顺序取 `node.sort`(在「分组管理」里改「排序权重」); - 顺序取 `node.sort`(在「分组管理」里改「排序权重」);
- 「本地服务」是前端拼出来的分类,侧栏按 key 直接排除。 - 「本地服务」是前端拼出来的分类,侧栏按 key 直接排除。
### 首页卡片 ### 首页卡片
一张卡片 = 一个标题 + 一组勾选的分组,内容不混排: 一张卡片 = 一个标题 + 一组勾选的分组,分组各自渲染、可以混合格式:
```sql ```sql
-- 看看「常用推荐」这张卡片勾了哪些分组、各自几条 -- 看看「常用推荐」这张卡片勾了哪些分组、各自几条
@@ -502,7 +499,6 @@ GET /api/catalog2
"key": "pin", "key": "pin",
"title": "常用推荐", "title": "常用推荐",
"icon": "🔖", "icon": "🔖",
"format": "apps",
"enabled": true, "enabled": true,
"sort": 1, "sort": 1,
"entries": [{ "id": "1", "name": "1", "format": "apps" }], "entries": [{ "id": "1", "name": "1", "format": "apps" }],
@@ -520,11 +516,9 @@ GET /api/catalog2
} }
``` ```
「本地服务」卡片**不在**这个接口里,它由 `/api/projects` 扫描托管目录后在前端拼接。 「本地服务」卡片在 `cards` 里也有一行 `local-service`,但内容由 `/api/projects` 运行时生成。
侧栏不再单独下发,前端直接用 `groups` 派生。 侧栏不再单独下发,前端直接用 `groups` 派生。
`GET /api/catalog` 是旧版结构(`categories` 数组),仅为兼容保留。
### 订阅内容库变化 ### 订阅内容库变化
```http ```http
+12 -343
View File
@@ -11,23 +11,10 @@ use rusqlite::Connection;
use serde_json::{Map, Value, json}; use serde_json::{Map, Value, json};
use crate::{ use crate::{
db::{Catalog, map_error}, db::map_error,
error::StashError, error::StashError,
}; };
/// 容器节点行:分区与分组共用
struct NodeRow {
key: String,
title: String,
view_mode: Option<String>,
bar_color: Option<String>,
icon: Option<String>,
preview_limit: Option<i64>,
empty_text: Option<String>,
show_in_sidebar: bool,
allow_groups: bool,
}
/// 条目行:与 `entry` 表的列一一对应 /// 条目行:与 `entry` 表的列一一对应
struct EntryRow { struct EntryRow {
key: String, key: String,
@@ -47,19 +34,11 @@ struct EntryRow {
likes: i64, likes: i64,
} }
/// 读出整个内容目录。
pub fn load(conn: &Connection) -> Result<Catalog, StashError> {
Ok(Catalog {
categories: load_sections(conn)?,
})
}
/// 分组树节点(新视图用) /// 分组树节点(新视图用)
struct TreeNode { struct TreeNode {
key: String, key: String,
parent: Option<String>, parent: Option<String>,
title: String, title: String,
view_mode: Option<String>,
icon: Option<String>, icon: Option<String>,
bar_color: Option<String>, bar_color: Option<String>,
preview_limit: Option<i64>, preview_limit: Option<i64>,
@@ -67,11 +46,11 @@ struct TreeNode {
enabled: bool, enabled: bool,
} }
/// 新的原子视图:完整分组树(多层、带格式、带条目),供新首页/侧栏/后台使用。 /// 新的原子视图:完整分组树(多层、带条目),供新首页/侧栏/后台使用。
pub fn load_tree(conn: &Connection) -> Result<Value, StashError> { pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
"SELECT key, parent_key, title, view_mode, icon, bar_color, preview_limit, sort, enabled "SELECT key, parent_key, title, icon, bar_color, preview_limit, sort, enabled
FROM node ORDER BY sort, key", FROM node ORDER BY sort, key",
) )
.map_err(map_error)?; .map_err(map_error)?;
@@ -82,12 +61,11 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
key: row.get(0)?, key: row.get(0)?,
parent: row.get(1)?, parent: row.get(1)?,
title: row.get(2)?, title: row.get(2)?,
view_mode: row.get(3)?, icon: row.get(3)?,
icon: row.get(4)?, bar_color: row.get(4)?,
bar_color: row.get(5)?, preview_limit: row.get(5)?,
preview_limit: row.get(6)?, sort: row.get(6)?,
sort: row.get(7)?, enabled: row.get(7)?,
enabled: row.get(8)?,
}) })
}) })
.map_err(map_error)? .map_err(map_error)?
@@ -96,48 +74,22 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
drop(stmt); drop(stmt);
let index: HashMap<String, usize> = nodes
.iter()
.enumerate()
.map(|(idx, node)| (node.key.clone(), idx))
.collect();
// 子节点索引(按 sort 已排好序) // 子节点索引(按 sort 已排好序)
let mut children: HashMap<Option<String>, Vec<usize>> = HashMap::new(); let mut children: HashMap<Option<String>, Vec<usize>> = HashMap::new();
for (idx, node) in nodes.iter().enumerate() { for (idx, node) in nodes.iter().enumerate() {
children.entry(node.parent.clone()).or_default().push(idx); children.entry(node.parent.clone()).or_default().push(idx);
} }
// 每个节点继承最近根分组的格式
let mut formats: Vec<String> = vec![String::new(); nodes.len()];
for idx in 0..nodes.len() {
let mut cursor = Some(idx);
let mut format = "apps".to_string();
while let Some(current) = cursor {
if let Some(mode) = &nodes[current].view_mode {
format = mode.clone();
break;
}
cursor = nodes[current]
.parent
.as_ref()
.and_then(|parent| index.get(parent).copied());
}
formats[idx] = format;
}
let tags = load_all_tags(conn)?; let tags = load_all_tags(conn)?;
fn render( fn render(
conn: &Connection, conn: &Connection,
nodes: &[TreeNode], nodes: &[TreeNode],
children: &HashMap<Option<String>, Vec<usize>>, children: &HashMap<Option<String>, Vec<usize>>,
formats: &[String],
tags: &HashMap<String, Vec<String>>, tags: &HashMap<String, Vec<String>>,
idx: usize, idx: usize,
) -> Result<Value, StashError> { ) -> Result<Value, StashError> {
let node = &nodes[idx]; let node = &nodes[idx];
let format = &formats[idx];
let entries = load_node_entries(conn, &node.key)?; let entries = load_node_entries(conn, &node.key)?;
let items = entries let items = entries
.iter() .iter()
@@ -148,7 +100,7 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
.get(&Some(node.key.clone())) .get(&Some(node.key.clone()))
.map(|list| { .map(|list| {
list.iter() list.iter()
.map(|child| render(conn, nodes, children, formats, tags, *child)) .map(|child| render(conn, nodes, children, tags, *child))
.collect::<Result<Vec<_>, _>>() .collect::<Result<Vec<_>, _>>()
}) })
.transpose()? .transpose()?
@@ -158,7 +110,6 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
"key": node.key, "key": node.key,
"parentKey": node.parent, "parentKey": node.parent,
"title": node.title, "title": node.title,
"format": format,
"icon": node.icon.clone().unwrap_or_default(), "icon": node.icon.clone().unwrap_or_default(),
"barColor": node.bar_color.clone().unwrap_or_default(), "barColor": node.bar_color.clone().unwrap_or_default(),
"previewLimit": node.preview_limit, "previewLimit": node.preview_limit,
@@ -173,7 +124,7 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
.get(&None) .get(&None)
.map(|list| { .map(|list| {
list.iter() list.iter()
.map(|idx| render(conn, &nodes, &children, &formats, &tags, *idx)) .map(|idx| render(conn, &nodes, &children, &tags, *idx))
.collect::<Result<Vec<_>, _>>() .collect::<Result<Vec<_>, _>>()
}) })
.transpose()? .transpose()?
@@ -203,164 +154,6 @@ fn load_all_tags(conn: &Connection) -> Result<HashMap<String, Vec<String>>, Stas
Ok(grouped) Ok(grouped)
} }
/// 读出全部分区,按 `sort` 排序;顺序即首页分区顺序。
fn load_sections(conn: &Connection) -> Result<Vec<Value>, StashError> {
let mut stmt = conn
.prepare(
"SELECT key, title, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar, allow_groups
FROM node
WHERE kind = 'section'
ORDER BY sort, key",
)
.map_err(map_error)?;
let sections = stmt
.query_map([], |row| {
Ok(NodeRow {
key: row.get(0)?,
title: row.get(1)?,
view_mode: row.get(2)?,
bar_color: row.get(3)?,
icon: row.get(4)?,
preview_limit: row.get(5)?,
empty_text: row.get(6)?,
show_in_sidebar: row.get(7)?,
allow_groups: row.get(8)?,
})
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
// 先释放语句再发后续查询,避免同一连接上的借用冲突
drop(stmt);
// 每个分区对应的首页卡片:展示哪个节点、几条
let blocks = load_blocks(conn)?;
sections
.iter()
.map(|section| build_section(conn, section, blocks.get(&section.key)))
.collect()
}
/// 读出所有卡片配置,按推导出的分区业务键索引。
///
/// 分区不单独存:节点是分组就取它的 `parent_key`,节点是分区就取它自己。
/// `title` 同样在 SQL 里解析:卡片自己配了标题就用它,否则用被展示节点的标题。
fn load_blocks(conn: &Connection) -> Result<HashMap<String, Value>, StashError> {
let mut stmt = conn
.prepare(
"SELECT COALESCE(p.key, n.key) AS section_key,
b.node_key,
COALESCE(b.title, n.title),
b.preview_limit
FROM section_block b
JOIN node n ON n.key = b.node_key
LEFT JOIN node p ON p.key = n.parent_key
ORDER BY b.sort, b.node_key",
)
.map_err(map_error)?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<i64>>(3)?,
))
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
let mut blocks = HashMap::new();
for (section_key, node_key, title, preview_limit) in rows {
let mut block = Map::new();
block.insert("nodeKey".into(), json!(node_key));
block.insert("title".into(), json!(title));
if let Some(limit) = preview_limit {
block.insert("previewLimit".into(), json!(limit));
}
// 同一分区配了多个卡片时只保留第一个:当前 UI 一张卡片展示一个节点
blocks.entry(section_key).or_insert(Value::Object(block));
}
Ok(blocks)
}
/// 按 `view_mode` 分派到不同的组装方式。
fn build_section(
conn: &Connection,
section: &NodeRow,
block: Option<&Value>,
) -> Result<Value, StashError> {
let view_mode = section
.view_mode
.as_deref()
.ok_or_else(|| StashError::Database(format!("分区 {} 缺少 view_mode", section.key)))?;
let mut object = Map::new();
object.insert("id".into(), json!(section.key));
object.insert("kind".into(), json!(view_mode));
object.insert("title".into(), json!(section.title));
object.insert(
"barColor".into(),
json!(section.bar_color.clone().unwrap_or_default()),
);
object.insert("icon".into(), json!(section.icon.clone().unwrap_or_default()));
object.insert("showInSidebar".into(), json!(section.show_in_sidebar));
object.insert("allowGroups".into(), json!(section.allow_groups));
// 可选字段:没配就不下发,交给前端用 `?? 默认值` 处理
if let Some(limit) = section.preview_limit {
object.insert("previewLimit".into(), json!(limit));
}
if let Some(empty_text) = &section.empty_text {
object.insert("emptyText".into(), json!(empty_text));
}
if let Some(block) = block {
object.insert("block".into(), block.clone());
}
match view_mode {
"apps" | "posters" | "articles" => {}
other => {
return Err(StashError::Database(format!(
"分区 {} 的 view_mode 非法: {other}",
section.key
)));
}
}
// 「允许子分组」开启:分区 → 分组 → 条目;关闭:全部条目平铺
let tags = load_tags(conn, &section.key)?;
if section.allow_groups {
object.insert(
"groups".into(),
build_groups(conn, section, &tags)?,
);
let direct = load_node_entries(conn, &section.key)?;
let items = direct
.iter()
.map(|entry| item_for(entry, &tags))
.collect::<Vec<_>>();
object.insert("items".into(), json!(items));
} else {
object.insert("groups".into(), json!([]));
// 平铺:把(可能存在的)分组里的条目一起取出来,关闭开关时不丢内容
let entries = load_section_entries(conn, &section.key)?;
let items = entries
.iter()
.map(|entry| item_for(entry, &tags))
.collect::<Vec<_>>();
object.insert("items".into(), json!(items));
}
Ok(Value::Object(object))
}
/// 按条目自身的格式组装列表项。 /// 按条目自身的格式组装列表项。
fn item_for(entry: &EntryRow, tags: &HashMap<String, Vec<String>>) -> Value { fn item_for(entry: &EntryRow, tags: &HashMap<String, Vec<String>>) -> Value {
let mut value = match entry.format.as_str() { let mut value = match entry.format.as_str() {
@@ -374,55 +167,6 @@ fn item_for(entry: &EntryRow, tags: &HashMap<String, Vec<String>>) -> Value {
value value
} }
/// 取出某个分区下的分组,并把条目按 `node_key` 分派进去。
fn build_groups(
conn: &Connection,
section: &NodeRow,
tags: &HashMap<String, Vec<String>>,
) -> Result<Value, StashError> {
let mut stmt = conn
.prepare(
"SELECT key, title, show_in_sidebar FROM node
WHERE kind = 'group' AND parent_key = ?1
ORDER BY sort, key",
)
.map_err(map_error)?;
let groups = stmt
.query_map([&section.key], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, bool>(2)?,
))
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
drop(stmt);
let mut rendered_groups = Vec::with_capacity(groups.len());
for (group_key, group_title, show_in_sidebar) in groups {
let entries = load_node_entries(conn, &group_key)?;
let items = entries
.iter()
.map(|entry| item_for(entry, tags))
.collect::<Vec<_>>();
rendered_groups.push(json!({
// 对外用业务键,不用数字主键
"id": group_key,
"name": group_title,
"showInSidebar": show_in_sidebar,
"items": items,
}));
}
Ok(Value::Array(rendered_groups))
}
/// 应用条目:`name` / `desc` / `icon` / `href` 是必填,其余按需下发。
fn app_item(entry: &EntryRow) -> Value { fn app_item(entry: &EntryRow) -> Value {
let mut object = Map::new(); let mut object = Map::new();
object.insert("id".into(), json!(entry.key)); object.insert("id".into(), json!(entry.key));
@@ -504,55 +248,9 @@ fn insert_optional(object: &mut Map<String, Value>, key: &str, value: &Option<St
} }
} }
/// 取某个分区下的全部条目,按 `sort` 升序。 /// 取某个节点直接挂着的条目,按 `sort` 升序。
/// fn load_node_entries(conn: &Connection, node_key: &str) -> Result<Vec<EntryRow>, StashError> {
/// 条目只存 `node_key`,所属分区由父节点推导:
/// 父节点是分组就取它的 `parent_key`,父节点是分区就取它自己。
/// 取某个分区下的全部条目(含分组内的),海报 / 图文平铺用。
fn load_section_entries(
conn: &Connection,
section_key: &str,
) -> Result<Vec<EntryRow>, StashError> {
let mut stmt = conn let mut stmt = conn
.prepare(
"SELECT e.key, e.name, e.description, e.icon, e.href, e.cover,
e.banner, e.badge, e.comment, e.summary, e.body, e.hot, e.views, e.likes,
COALESCE(e.format, 'apps')
FROM entry e
JOIN node p ON p.key = e.node_key
WHERE COALESCE(p.parent_key, p.key) = ?1
ORDER BY e.sort, e.key",
)
.map_err(map_error)?;
let entries = stmt
.query_map([section_key], |row| {
Ok(EntryRow {
key: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
icon: row.get(3)?,
href: row.get(4)?,
cover: row.get(5)?,
banner: row.get(6)?,
badge: row.get(7)?,
comment: row.get(8)?,
summary: row.get(9)?,
body: row.get(10)?,
hot: row.get(11)?,
views: row.get(12)?,
likes: row.get(13)?,
format: row.get(14)?,
})
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
Ok(entries)
}
fn load_node_entries(conn: &Connection, node_key: &str) -> Result<Vec<EntryRow>, StashError> { let mut stmt = conn
.prepare( .prepare(
"SELECT e.key, e.name, e.description, e.icon, e.href, e.cover, "SELECT e.key, e.name, e.description, e.icon, e.href, e.cover,
e.banner, e.badge, e.comment, e.summary, e.body, e.hot, e.views, e.likes, e.banner, e.badge, e.comment, e.summary, e.body, e.hot, e.views, e.likes,
@@ -589,32 +287,3 @@ fn load_node_entries(conn: &Connection, node_key: &str) -> Result<Vec<EntryRow>,
Ok(entries) Ok(entries)
} }
/// 取某个分区下所有条目的标签,按条目业务键聚合;一次查询避免 N+1。
fn load_tags(conn: &Connection, section_key: &str) -> Result<HashMap<String, Vec<String>>, StashError> {
let mut stmt = conn
.prepare(
"SELECT t.entry_key, t.label
FROM entry_tag t
JOIN entry e ON e.key = t.entry_key
JOIN node p ON p.key = e.node_key
WHERE COALESCE(p.parent_key, p.key) = ?1
ORDER BY t.entry_key, t.sort",
)
.map_err(map_error)?;
let rows = stmt
.query_map([section_key], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
let mut grouped: HashMap<String, Vec<String>> = HashMap::new();
for (entry_key, label) in rows {
grouped.entry(entry_key).or_default().push(label);
}
Ok(grouped)
}
-827
View File
@@ -1,827 +0,0 @@
//! 分区(首页卡片 / 侧栏项)的新增与删除。
//!
//! 分区就是 `node` 表里 `kind='section'` 的行;删除会级联分组、条目与展示块。
//! 新增时顺手建一条首页展示块,保证它立刻出现在首页。
use rusqlite::{Connection, OptionalExtension, params};
use serde::Deserialize;
use crate::{db::map_error, error::StashError, db::entries};
const KINDS: [&str; 3] = ["apps", "posters", "articles"];
#[derive(Debug, Clone, Deserialize)]
pub struct NewCategory {
/// 业务键,只允许小写字母、数字与连字符
pub id: String,
pub title: String,
/// apps / posters / articles
pub kind: String,
}
/// 修改分区:字段都可选,只改传进来的。
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateCategory {
pub title: Option<String>,
pub icon: Option<String>,
pub bar_color: Option<String>,
/// 改类型(应用 / 海报 / 图文)时会顺带迁移条目归属
pub kind: Option<String>,
pub preview_limit: Option<i64>,
pub empty_text: Option<String>,
/// 首页卡片预览哪个节点(分区自己或它下面的分组)
pub block_node_key: Option<String>,
/// 是否允许子分组(与类型无关,后台开关)
pub allow_groups: Option<bool>,
}
/// 新建分组(子分类)
#[derive(Debug, Clone, Deserialize)]
pub struct NewGroup {
pub id: String,
pub title: String,
}
/// 修改分组:改名 / 侧栏显隐
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateGroup {
pub title: Option<String>,
pub show_in_sidebar: Option<bool>,
}
fn valid_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 32
&& id
.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
}
pub fn create(conn: &mut Connection, new: NewCategory) -> Result<(), StashError> {
let id = new.id.trim();
let title = new.title.trim();
if !valid_id(id) {
return Err(StashError::InvalidDirectory(
"标识只能用小写字母、数字与连字符(≤32)".into(),
));
}
if title.is_empty() || title.chars().count() > 40 {
return Err(StashError::InvalidDirectory("标题需为 1–40 个字".into()));
}
if !KINDS.contains(&new.kind.as_str()) {
return Err(StashError::InvalidDirectory("类型不合法".into()));
}
let exists: i64 = conn
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [id], |row| row.get(0))
.map_err(map_error)?;
if exists > 0 {
return Err(StashError::Conflict(format!("分区已存在: {id}")));
}
let tx = conn.transaction().map_err(map_error)?;
let sort: i64 = tx
.query_row(
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE kind = 'section'",
[],
|row| row.get(0),
)
.map_err(map_error)?;
tx.execute(
"INSERT INTO node (key, parent_key, title, kind, view_mode, allow_groups, sort, show_in_sidebar)
VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, 1)",
params![id, title, new.kind, i64::from(new.kind == "apps"), sort],
)
.map_err(map_error)?;
let block_sort: i64 = tx
.query_row(
"SELECT COALESCE(MAX(sort), 0) + 1 FROM section_block",
[],
|row| row.get(0),
)
.map_err(map_error)?;
tx.execute(
"INSERT INTO section_block (node_key, title, preview_limit, sort) VALUES (?1, NULL, NULL, ?2)",
params![id, block_sort],
)
.map_err(map_error)?;
entries::audit(&tx, "category.create", id, title)?;
tx.commit().map_err(map_error)?;
Ok(())
}
struct SectionRow {
title: String,
icon: Option<String>,
bar_color: Option<String>,
view_mode: Option<String>,
preview_limit: Option<i64>,
empty_text: Option<String>,
allow_groups: bool,
}
fn valid_color(color: &str) -> bool {
if color.is_empty() {
return true;
}
let hex = color.strip_prefix('#').unwrap_or("");
(hex.len() == 3 || hex.len() == 6) && hex.chars().all(|ch| ch.is_ascii_hexdigit())
}
/// 修改分区:标题 / 图标 / 色条 / 预览条数 / 空态文案,以及「归类」类型。
///
/// 改类型时迁移数据,保证切换后条目不丢:
/// - 海报 / 图文 → 应用:确保有一个分组,把直接挂在分区上的条目挪进去,
/// 首页卡片也改指向该分组;
/// - 应用 → 海报 / 图文:卡片从分组挪回分区,分组保留(不影响条目读取)。
pub fn update(conn: &mut Connection, id: &str, patch: UpdateCategory) -> Result<(), StashError> {
if id == "local" {
return Err(StashError::InvalidDirectory(
"「本地服务」是运行时分类,不能编辑".into(),
));
}
let tx = conn.transaction().map_err(map_error)?;
let current = tx
.query_row(
"SELECT title, icon, bar_color, view_mode, preview_limit, empty_text, allow_groups
FROM node WHERE key = ?1 AND kind = 'section'",
[id],
|row| {
Ok(SectionRow {
title: row.get(0)?,
icon: row.get(1)?,
bar_color: row.get(2)?,
view_mode: row.get(3)?,
preview_limit: row.get(4)?,
empty_text: row.get(5)?,
allow_groups: row.get(6)?,
})
},
)
.optional()
.map_err(map_error)?;
let Some(current) = current else {
return Err(StashError::NotFound(format!("分区不存在: {id}")));
};
let title = match patch.title {
Some(title) => {
let title = title.trim().to_string();
if title.is_empty() || title.chars().count() > 40 {
return Err(StashError::InvalidDirectory("标题需为 1–40 个字".into()));
}
title
}
None => current.title,
};
let icon = match patch.icon {
Some(icon) => {
let icon = icon.trim().to_string();
if icon.chars().count() > 8 {
return Err(StashError::InvalidDirectory("图标最多 8 个字符".into()));
}
(!icon.is_empty()).then_some(icon)
}
None => current.icon,
};
let bar_color = match patch.bar_color {
Some(color) => {
let color = color.trim().to_ascii_lowercase();
if !valid_color(&color) {
return Err(StashError::InvalidDirectory(
"色条需为 #rgb 或 #rrggbb".into(),
));
}
(!color.is_empty()).then_some(color)
}
None => current.bar_color,
};
let empty_text = match patch.empty_text {
Some(text) => {
let text = text.trim().to_string();
(!text.is_empty()).then_some(text)
}
None => current.empty_text,
};
let preview_limit = match patch.preview_limit {
Some(limit) => {
if !(1..=60).contains(&limit) {
return Err(StashError::InvalidDirectory("预览条数需在 1–60 之间".into()));
}
Some(limit)
}
None => current.preview_limit,
};
let kind = match patch.kind {
Some(kind) => {
if !KINDS.contains(&kind.as_str()) {
return Err(StashError::InvalidDirectory("类型不合法".into()));
}
kind
}
None => current
.view_mode
.clone()
.ok_or_else(|| StashError::Database(format!("分区 {id} 缺少 view_mode")))?,
};
let was_apps = current.view_mode.as_deref() == Some("apps");
let is_apps = kind == "apps";
let allow_groups = patch.allow_groups.unwrap_or(current.allow_groups);
if is_apps && !was_apps && allow_groups {
// 切到应用且开启子分组:条目必须有分组可挂;没有就建一个「全部」
let group_count: i64 = tx
.query_row(
"SELECT COUNT(*) FROM node WHERE kind = 'group' AND parent_key = ?1",
[id],
|row| row.get(0),
)
.map_err(map_error)?;
let group_key = if group_count == 0 {
let group_key = format!("{id}-all");
tx.execute(
"INSERT INTO node (key, parent_key, title, kind, sort, show_in_sidebar)
VALUES (?1, ?2, '全部', 'group', 1, 1)",
params![group_key, id],
)
.map_err(map_error)?;
group_key
} else {
tx.query_row(
"SELECT key FROM node WHERE kind = 'group' AND parent_key = ?1
ORDER BY sort, key LIMIT 1",
[id],
|row| row.get(0),
)
.map_err(map_error)?
};
tx.execute(
"UPDATE entry SET node_key = ?1 WHERE node_key = ?2",
params![group_key, id],
)
.map_err(map_error)?;
tx.execute(
"UPDATE section_block SET node_key = ?1 WHERE node_key = ?2",
params![group_key, id],
)
.map_err(map_error)?;
}
// 关闭子分组:首页卡片如果指着分组,挪回分区,避免标题/预览错位
if !allow_groups {
tx.execute(
"UPDATE section_block SET node_key = ?1, title = NULL
WHERE node_key IN (
SELECT key FROM node WHERE kind = 'group' AND parent_key = ?1
)",
[id],
)
.map_err(map_error)?;
}
tx.execute(
"UPDATE node SET title = ?1, icon = ?2, bar_color = ?3, view_mode = ?4,
preview_limit = ?5, empty_text = ?6, allow_groups = ?7
WHERE key = ?8",
params![
title,
icon,
bar_color,
kind,
preview_limit,
empty_text,
allow_groups,
id
],
)
.map_err(map_error)?;
// 首页卡片预览哪个节点:分区自己或它下面的分组
if let Some(block_node_key) = patch.block_node_key {
let block_node_key = block_node_key.trim().to_string();
let valid: i64 = tx
.query_row(
"SELECT COUNT(*) FROM node n LEFT JOIN node p ON p.key = n.parent_key
WHERE n.key = ?1 AND (n.key = ?2 OR p.key = ?2)",
params![block_node_key, id],
|row| row.get(0),
)
.map_err(map_error)?;
if valid == 0 {
return Err(StashError::InvalidDirectory(
"首页卡片只能预览本分区或它的分组".into(),
));
}
let updated = tx
.execute(
"UPDATE section_block SET node_key = ?1, title = NULL
WHERE id IN (
SELECT b.id FROM section_block b
JOIN node n ON n.key = b.node_key
LEFT JOIN node p ON p.key = n.parent_key
WHERE COALESCE(p.key, n.key) = ?2
)",
params![block_node_key, id],
)
.map_err(map_error)?;
if updated == 0 {
let sort: i64 = tx
.query_row("SELECT COALESCE(MAX(sort), 0) + 1 FROM section_block", [], |row| {
row.get(0)
})
.map_err(map_error)?;
tx.execute(
"INSERT INTO section_block (node_key, title, preview_limit, sort)
VALUES (?1, NULL, NULL, ?2)",
params![block_node_key, sort],
)
.map_err(map_error)?;
}
}
entries::audit(&tx, "category.update", id, &title)?;
tx.commit().map_err(map_error)?;
Ok(())
}
/// 在应用类分区下新建分组(子分类)。
pub fn create_group(conn: &mut Connection, section_id: &str, new: NewGroup) -> Result<(), StashError> {
let id = new.id.trim();
let title = new.title.trim();
if !valid_id(id) {
return Err(StashError::InvalidDirectory(
"分组标识只能用小写字母、数字与连字符(≤32)".into(),
));
}
if title.is_empty() || title.chars().count() > 40 {
return Err(StashError::InvalidDirectory("分组标题需为 1–40 个字".into()));
}
let allow_groups: Option<bool> = conn
.query_row(
"SELECT allow_groups FROM node WHERE key = ?1 AND kind = 'section'",
[section_id],
|row| row.get(0),
)
.optional()
.map_err(map_error)?;
let Some(allow_groups) = allow_groups else {
return Err(StashError::NotFound(format!("分区不存在: {section_id}")));
};
if !allow_groups {
return Err(StashError::InvalidDirectory(
"该分区未开启「允许子分组」".into(),
));
}
let exists: i64 = conn
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [id], |row| {
row.get(0)
})
.map_err(map_error)?;
if exists > 0 {
return Err(StashError::Conflict(format!("分组已存在: {id}")));
}
let sort: i64 = conn
.query_row(
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1",
[section_id],
|row| row.get(0),
)
.map_err(map_error)?;
conn.execute(
"INSERT INTO node (key, parent_key, title, kind, view_mode, sort, show_in_sidebar)
VALUES (?1, ?2, ?3, 'group', NULL, ?4, 1)",
params![id, section_id, title, sort],
)
.map_err(map_error)?;
entries::audit(conn, "group.create", id, title)?;
Ok(())
}
/// 修改分组:改名 / 侧栏显隐。
pub fn update_group(conn: &Connection, id: &str, patch: UpdateGroup) -> Result<(), StashError> {
let current: Option<String> = conn
.query_row(
"SELECT title FROM node WHERE key = ?1 AND kind = 'group'",
[id],
|row| row.get(0),
)
.optional()
.map_err(map_error)?;
let Some(current) = current else {
return Err(StashError::NotFound(format!("分组不存在: {id}")));
};
let title = match patch.title {
Some(title) => {
let title = title.trim().to_string();
if title.is_empty() || title.chars().count() > 40 {
return Err(StashError::InvalidDirectory("分组标题需为 1–40 个字".into()));
}
title
}
None => current,
};
match patch.show_in_sidebar {
Some(show) => conn.execute(
"UPDATE node SET title = ?1, show_in_sidebar = ?2 WHERE key = ?3",
params![title, show, id],
),
None => conn.execute(
"UPDATE node SET title = ?1 WHERE key = ?2",
params![title, id],
),
}
.map_err(map_error)?;
entries::audit(conn, "group.update", id, &title)?;
Ok(())
}
/// 删除分组:组内条目会一起删除(前端需二次确认)。
pub fn remove_group(conn: &Connection, id: &str) -> Result<(), StashError> {
let removed = conn
.execute("DELETE FROM node WHERE key = ?1 AND kind = 'group'", [id])
.map_err(map_error)?;
if removed == 0 {
return Err(StashError::NotFound(format!("分组不存在: {id}")));
}
entries::audit(conn, "group.delete", id, "")?;
Ok(())
}
pub fn remove(conn: &mut Connection, id: &str) -> Result<(), StashError> {
if id == "local" {
return Err(StashError::InvalidDirectory("本地服务不能删除".into()));
}
let removed = conn
.execute("DELETE FROM node WHERE key = ?1 AND kind = 'section'", [id])
.map_err(map_error)?;
if removed == 0 {
return Err(StashError::NotFound(format!("分区不存在: {id}")));
}
entries::audit(conn, "category.delete", id, "")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn db() -> Connection {
let conn = Connection::open_in_memory().expect("内存库应能打开");
conn.execute_batch(
"PRAGMA foreign_keys = ON;
CREATE TABLE node (
id INTEGER PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
parent_key TEXT REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
title TEXT NOT NULL,
kind TEXT NOT NULL,
view_mode TEXT,
icon TEXT,
bar_color TEXT,
preview_limit INTEGER,
empty_text TEXT,
sort INTEGER NOT NULL DEFAULT 0,
show_in_sidebar INTEGER NOT NULL DEFAULT 1,
allow_groups INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE entry (
key TEXT PRIMARY KEY,
node_key TEXT NOT NULL
);
CREATE TABLE section_block (
id INTEGER PRIMARY KEY,
node_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
title TEXT,
preview_limit INTEGER,
sort INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at INTEGER NOT NULL,
action TEXT NOT NULL,
target TEXT,
detail TEXT
);",
)
.expect("应能建表");
conn
}
#[test]
fn create_then_delete() {
let mut conn = db();
create(
&mut conn,
NewCategory {
id: "books".into(),
title: "书单".into(),
kind: "posters".into(),
},
)
.expect("应能创建");
let blocks: i64 = conn
.query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0))
.expect("应能查");
assert_eq!(blocks, 1);
remove(&mut conn, "books").expect("应能删除");
let nodes: i64 = conn
.query_row("SELECT COUNT(*) FROM node", [], |row| row.get(0))
.expect("应能查");
assert_eq!(nodes, 0);
}
#[test]
fn rejects_bad_id_and_duplicate() {
let mut conn = db();
assert!(
create(
&mut conn,
NewCategory {
id: "Bad Id".into(),
title: "x".into(),
kind: "apps".into()
}
)
.is_err()
);
create(
&mut conn,
NewCategory {
id: "books".into(),
title: "书单".into(),
kind: "apps".into(),
},
)
.expect("应能创建");
let error = create(
&mut conn,
NewCategory {
id: "books".into(),
title: "书单".into(),
kind: "apps".into(),
},
)
.expect_err("重复应失败");
assert!(matches!(error, StashError::Conflict(_)));
}
fn section(id: &str, title: &str, kind: &str) -> NewCategory {
NewCategory {
id: id.into(),
title: title.into(),
kind: kind.into(),
}
}
#[test]
fn updates_fields_and_rejects_bad_values() {
let mut conn = db();
create(&mut conn, section("books", "书单", "posters")).expect("应能创建");
update(
&mut conn,
"books",
UpdateCategory {
title: Some("读过的书".into()),
icon: Some("📚".into()),
bar_color: Some("#ff6034".into()),
preview_limit: Some(8),
..Default::default()
},
)
.expect("应能更新");
let (title, icon, color, limit): (String, Option<String>, Option<String>, Option<i64>) =
conn.query_row(
"SELECT title, icon, bar_color, preview_limit FROM node WHERE key='books'",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.expect("应能查");
assert_eq!(title, "读过的书");
assert_eq!(icon.as_deref(), Some("📚"));
assert_eq!(color.as_deref(), Some("#ff6034"));
assert_eq!(limit, Some(8));
assert!(update(&mut conn, "books", UpdateCategory { bar_color: Some("red".into()), ..Default::default() }).is_err());
assert!(update(&mut conn, "books", UpdateCategory { kind: Some("videos".into()), ..Default::default() }).is_err());
assert!(update(&mut conn, "local", UpdateCategory { title: Some("x".into()), ..Default::default() }).is_err());
assert!(matches!(
update(&mut conn, "none", UpdateCategory::default()).expect_err("应不存在"),
StashError::NotFound(_)
));
}
#[test]
fn switching_apps_to_posters_keeps_groups_until_disabled() {
let mut conn = db();
create(&mut conn, section("books", "书单", "apps")).expect("应能创建");
conn.execute(
"INSERT INTO node (key, parent_key, title, kind, sort) VALUES ('books-g', 'books', '分组', 'group', 1)",
[],
)
.expect("应能建分组");
conn.execute("INSERT INTO entry (key, node_key) VALUES ('b1', 'books-g')", [])
.expect("应能建条目");
// create() 会顺手建一张分区卡片;这里只保留分组卡片,模拟真实配置
conn.execute("DELETE FROM section_block", []).expect("应能清卡片");
conn.execute(
"INSERT INTO section_block (node_key, title, sort) VALUES ('books-g', '分组', 1)",
[],
)
.expect("应能建卡片");
// 类型切到海报但子分组仍开启:分组和卡片都保留
update(
&mut conn,
"books",
UpdateCategory {
kind: Some("posters".into()),
..Default::default()
},
)
.expect("应能改类型");
let block: String = conn
.query_row("SELECT node_key FROM section_block", [], |row| row.get(0))
.expect("应能查");
assert_eq!(block, "books-g");
// 关闭子分组:卡片挪回分区
update(
&mut conn,
"books",
UpdateCategory {
allow_groups: Some(false),
..Default::default()
},
)
.expect("应能关闭");
let block: String = conn
.query_row("SELECT node_key FROM section_block", [], |row| row.get(0))
.expect("应能查");
assert_eq!(block, "books", "关闭子分组后卡片应指回分区");
}
#[test]
fn creates_updates_and_removes_group() {
let mut conn = db();
create(&mut conn, section("apps1", "应用", "apps")).expect("应能创建");
create_group(
&mut conn,
"apps1",
NewGroup {
id: "g1".into(),
title: "常用".into(),
},
)
.expect("应能建分组");
update_group(
&conn,
"g1",
UpdateGroup {
title: Some("改名".into()),
show_in_sidebar: Some(false),
},
)
.expect("应能改分组");
let (title, show): (String, i64) = conn
.query_row(
"SELECT title, show_in_sidebar FROM node WHERE key='g1'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.expect("应能查");
assert_eq!(title, "改名");
assert_eq!(show, 0);
remove_group(&conn, "g1").expect("应能删分组");
let left: i64 = conn
.query_row("SELECT COUNT(*) FROM node WHERE key='g1'", [], |row| {
row.get(0)
})
.expect("应能查");
assert_eq!(left, 0);
}
#[test]
fn groups_only_under_apps_sections() {
let mut conn = db();
create(&mut conn, section("films", "电影", "posters")).expect("应能创建");
assert!(
create_group(
&mut conn,
"films",
NewGroup {
id: "g1".into(),
title: "片单".into()
}
)
.is_err(),
"未开启子分组的分区不能建组"
);
// 打开「允许子分组」后,海报分区也可以建组
update(
&mut conn,
"films",
UpdateCategory {
allow_groups: Some(true),
..Default::default()
},
)
.expect("应能开启");
create_group(
&mut conn,
"films",
NewGroup {
id: "g1".into(),
title: "片单".into(),
},
)
.expect("开启后应能建组");
}
#[test]
fn switching_posters_to_apps_creates_group_and_moves_entries() { let mut conn = db();
create(&mut conn, section("films", "电影", "posters")).expect("应能创建");
conn.execute("INSERT INTO entry (key, node_key) VALUES ('f1', 'films')", [])
.expect("应能建条目");
conn.execute(
"INSERT INTO section_block (node_key, title, sort) VALUES ('films', '电影', 1)",
[],
)
.expect("应能建卡片");
update(
&mut conn,
"films",
UpdateCategory {
kind: Some("apps".into()),
allow_groups: Some(true),
..Default::default()
},
)
.expect("应能改类型");
let (entry_node, block_node): (String, String) = conn
.query_row(
"SELECT (SELECT node_key FROM entry WHERE key='f1'),
(SELECT node_key FROM section_block)",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.expect("应能查");
assert_eq!(entry_node, "films-all", "条目应挂到默认分组");
assert_eq!(block_node, "films-all", "首页卡片应指向默认分组");
let groups: i64 = conn
.query_row(
"SELECT COUNT(*) FROM node WHERE kind='group' AND parent_key='films'",
[],
|row| row.get(0),
)
.expect("应能查");
assert_eq!(groups, 1);
}
}
+16 -52
View File
@@ -1,16 +1,14 @@
//! 分组树的管理:多层分组、格式、父级移动与排序。 //! 分组树的管理:多层分组、父级移动与排序。
//! //!
//! 数据层只有一张 `node` 树:顶层节点是「根分组」(`kind='section'`,带 //! 数据层只有一张 `node` 树:顶层节点是「根分组」(`kind='section'`),
//! `view_mode` 格式),下面的节点都叫分组(`kind='group'`),可以继续嵌套。 //! 下面的节点都叫分组(`kind='group'`),可以继续嵌套。
//! 条目挂在任意节点上,格式由最近的根分组决定。 //! 条目的展示格式由 `entry.format` 决定,分组不再保存格式。
use rusqlite::{Connection, OptionalExtension, params}; use rusqlite::{Connection, OptionalExtension, params};
use serde::Deserialize; use serde::Deserialize;
use crate::{db::entries, db::map_error, error::StashError}; use crate::{db::entries, db::map_error, error::StashError};
const FORMATS: [&str; 3] = ["apps", "posters", "articles"];
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct NewNode { pub struct NewNode {
@@ -20,9 +18,6 @@ pub struct NewNode {
/// 为空表示新建一个根分组 /// 为空表示新建一个根分组
#[serde(default)] #[serde(default)]
pub parent_key: Option<String>, pub parent_key: Option<String>,
/// 根分组的格式:apps / posters / articles(子分组继承,不需要传)
#[serde(default)]
pub format: Option<String>,
/// 图标:可以是 iconfont 名或字符;空字符串表示不显示 /// 图标:可以是 iconfont 名或字符;空字符串表示不显示
#[serde(default)] #[serde(default)]
pub icon: Option<String>, pub icon: Option<String>,
@@ -41,8 +36,6 @@ pub struct UpdateNode {
/// 传入即移动到新的父节点;不支持移回根(根分组请直接新建) /// 传入即移动到新的父节点;不支持移回根(根分组请直接新建)
pub parent_key: Option<String>, pub parent_key: Option<String>,
pub sort: Option<i64>, pub sort: Option<i64>,
/// 只对根分组生效
pub format: Option<String>,
/// 图标:空字符串表示清除图标 /// 图标:空字符串表示清除图标
pub icon: Option<String>, pub icon: Option<String>,
pub enabled: Option<bool>, pub enabled: Option<bool>,
@@ -78,7 +71,7 @@ pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> {
return Err(StashError::Conflict(format!("分组已存在: {key}"))); return Err(StashError::Conflict(format!("分组已存在: {key}")));
} }
let (kind, view_mode) = match new.parent_key.as_deref() { let kind = match new.parent_key.as_deref() {
Some(parent) => { Some(parent) => {
let parent_exists: i64 = conn let parent_exists: i64 = conn
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [parent], |row| { .query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [parent], |row| {
@@ -88,15 +81,9 @@ pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> {
if parent_exists == 0 { if parent_exists == 0 {
return Err(StashError::NotFound(format!("父分组不存在: {parent}"))); return Err(StashError::NotFound(format!("父分组不存在: {parent}")));
} }
("group", None) "group"
}
None => {
let format = new.format.unwrap_or_else(|| "apps".into());
if !FORMATS.contains(&format.as_str()) {
return Err(StashError::InvalidDirectory("格式不合法".into()));
}
("section", Some(format))
} }
None => "section",
}; };
let default_sort: i64 = match new.parent_key.as_deref() { let default_sort: i64 = match new.parent_key.as_deref() {
@@ -126,14 +113,13 @@ pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> {
let tx = conn.transaction().map_err(map_error)?; let tx = conn.transaction().map_err(map_error)?;
tx.execute( tx.execute(
"INSERT INTO node (key, parent_key, title, kind, view_mode, icon, enabled, sort, show_in_sidebar, allow_groups) "INSERT INTO node (key, parent_key, title, kind, icon, enabled, sort)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1, 1)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![ params![
key, key,
new.parent_key, new.parent_key,
title, title,
kind, kind,
view_mode,
icon, icon,
enabled, enabled,
sort sort
@@ -146,9 +132,9 @@ pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> {
} }
pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(), StashError> { pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(), StashError> {
let current: Option<(String, Option<String>, Option<String>, i64, Option<String>, bool)> = conn let current: Option<(String, Option<String>, i64, Option<String>, bool)> = conn
.query_row( .query_row(
"SELECT title, parent_key, view_mode, sort, icon, enabled FROM node WHERE key = ?1", "SELECT title, parent_key, sort, icon, enabled FROM node WHERE key = ?1",
[key], [key],
|row| { |row| {
Ok(( Ok((
@@ -157,15 +143,13 @@ pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(),
row.get(2)?, row.get(2)?,
row.get(3)?, row.get(3)?,
row.get(4)?, row.get(4)?,
row.get(5)?,
)) ))
}, },
) )
.optional() .optional()
.map_err(map_error)?; .map_err(map_error)?;
let Some((current_title, current_parent, current_format, current_sort, current_icon, current_enabled)) = let Some((current_title, current_parent, current_sort, current_icon, current_enabled)) = current
current
else { else {
return Err(StashError::NotFound(format!("分组不存在: {key}"))); return Err(StashError::NotFound(format!("分组不存在: {key}")));
}; };
@@ -218,26 +202,6 @@ pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(),
None => current_parent.clone(), None => current_parent.clone(),
}; };
let is_root = parent_key.is_none();
let format = if is_root {
match patch.format {
Some(format) => {
if !FORMATS.contains(&format.as_str()) {
return Err(StashError::InvalidDirectory("格式不合法".into()));
}
Some(format)
}
None => current_format,
}
} else {
// 子分组没有自己的格式,跟随根分组
None
};
if is_root && format.is_none() {
return Err(StashError::InvalidDirectory("根分组缺少格式".into()));
}
let icon = match patch.icon { let icon = match patch.icon {
Some(icon) => { Some(icon) => {
let icon = icon.trim().to_string(); let icon = icon.trim().to_string();
@@ -268,10 +232,10 @@ pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(),
}; };
tx.execute( tx.execute(
"UPDATE node SET title = ?1, parent_key = ?2, view_mode = ?3, sort = ?4, "UPDATE node SET title = ?1, parent_key = ?2, sort = ?3,
icon = ?5, enabled = ?6 icon = ?4, enabled = ?5
WHERE key = ?7", WHERE key = ?6",
params![title, parent_key, format, final_sort, icon, enabled, key], params![title, parent_key, final_sort, icon, enabled, key],
) )
.map_err(map_error)?; .map_err(map_error)?;
entries::audit(&tx, "node.update", key, &title)?; entries::audit(&tx, "node.update", key, &title)?;
+93 -127
View File
@@ -10,7 +10,6 @@
pub mod auth; pub mod auth;
pub mod cards; pub mod cards;
pub mod categories;
mod catalog; mod catalog;
pub mod entries; pub mod entries;
pub mod groups; pub mod groups;
@@ -34,15 +33,6 @@ pub use settings::{Settings, SettingsDocument};
/// 条目编辑(P2)。 /// 条目编辑(P2)。
pub use entries::{EntryPatch, EntryRecord}; pub use entries::{EntryPatch, EntryRecord};
/// 一次查询得到的完整内容目录。
///
/// 侧栏已改为由这份分类树派生,不再单独下发导航数据。
#[derive(Debug)]
pub struct Catalog {
/// 分类树,结构对应前端 `types.ts` 的 `Category[]`
pub categories: Vec<Value>,
}
/// 内容库句柄。 /// 内容库句柄。
/// ///
/// rusqlite 的连接是 `!Sync` 的同步对象,这里用一把互斥锁包起来: /// rusqlite 的连接是 `!Sync` 的同步对象,这里用一把互斥锁包起来:
@@ -107,12 +97,6 @@ impl Db {
}) })
} }
/// 读出完整的内容目录,供 `/api/catalog` 直接序列化。
pub fn load_catalog(&self) -> Result<Catalog, StashError> {
let conn = self.lock()?;
catalog::load(&conn)
}
/// SQLite 的 `data_version`:**其它连接**提交后该值会变,本连接自己的写入不变。 /// SQLite 的 `data_version`:**其它连接**提交后该值会变,本连接自己的写入不变。
/// ///
/// 正好用来发现「用户用 DB Browser 改了库」,SSE 靠轮询它来推送刷新。 /// 正好用来发现「用户用 DB Browser 改了库」,SSE 靠轮询它来推送刷新。
@@ -217,41 +201,6 @@ impl Db {
entries::set_like(&conn, key, liked) entries::set_like(&conn, key, liked)
} }
// ---------- 分区(首页卡片 / 侧栏项) ----------
pub fn create_category(&self, new: categories::NewCategory) -> Result<(), StashError> {
let mut conn = self.lock()?;
categories::create(&mut conn, new)
}
pub fn update_category(
&self,
id: &str,
patch: categories::UpdateCategory,
) -> Result<(), StashError> {
let mut conn = self.lock()?;
categories::update(&mut conn, id, patch)
}
pub fn create_group(
&self,
section_id: &str,
new: categories::NewGroup,
) -> Result<(), StashError> {
let mut conn = self.lock()?;
categories::create_group(&mut conn, section_id, new)
}
pub fn update_group(&self, id: &str, patch: categories::UpdateGroup) -> Result<(), StashError> {
let conn = self.lock()?;
categories::update_group(&conn, id, patch)
}
pub fn remove_group(&self, id: &str) -> Result<(), StashError> {
let conn = self.lock()?;
categories::remove_group(&conn, id)
}
// ---------- 原子模型:分组树 / 卡片 ---------- // ---------- 原子模型:分组树 / 卡片 ----------
/// 完整分组树(多层,带条目) /// 完整分组树(多层,带条目)
@@ -303,11 +252,6 @@ impl Db {
let mut conn = self.lock()?; let mut conn = self.lock()?;
cards::set_groups(&mut conn, id, groups) cards::set_groups(&mut conn, id, groups)
} }
pub fn remove_category(&self, id: &str) -> Result<(), StashError> {
let mut conn = self.lock()?;
categories::remove(&mut conn, id)
}
} }
/// 把 rusqlite 的错误统一收敛成 `StashError::Database`。 /// 把 rusqlite 的错误统一收敛成 `StashError::Database`。
@@ -363,8 +307,8 @@ mod tests {
.query_row("SELECT COUNT(*) FROM entry", [], |row| row.get(0)) .query_row("SELECT COUNT(*) FROM entry", [], |row| row.get(0))
.expect("应能统计条目"); .expect("应能统计条目");
let blocks: i64 = conn let blocks: i64 = conn
.query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0)) .query_row("SELECT COUNT(*) FROM card", [], |row| row.get(0))
.expect("应能统计展示块"); .expect("应能统计首页卡片");
let tags: i64 = conn let tags: i64 = conn
.query_row("SELECT COUNT(*) FROM entry_tag", [], |row| row.get(0)) .query_row("SELECT COUNT(*) FROM entry_tag", [], |row| row.get(0))
.expect("应能统计标签"); .expect("应能统计标签");
@@ -383,27 +327,26 @@ mod tests {
assert_eq!(tags, 48, "标签与元信息共用 entry_tag"); assert_eq!(tags, 48, "标签与元信息共用 entry_tag");
// 正文是一篇一段 Markdown,只有 12 篇文章有 // 正文是一篇一段 Markdown,只有 12 篇文章有
assert_eq!(articles_with_body, 12, "只有图文条目带正文"); assert_eq!(articles_with_body, 12, "只有图文条目带正文");
assert_eq!(blocks, 3, "每个分区一个首页展示块"); assert_eq!(blocks, 3, "每个分区一张首页卡片");
} }
/// 分区与分组是同一张表:分组必须挂在分区下,且不能有 view_mode。 /// 分区与分组是同一张表:分组必须挂在分区下。
#[test] #[test]
fn groups_are_nodes_under_their_section() { fn groups_are_nodes_under_their_section() {
let conn = seeded(); let conn = seeded();
let (parent_key, view_mode): (String, Option<String>) = conn let parent_key: String = conn
.query_row( .query_row(
"SELECT p.key, g.view_mode "SELECT p.key
FROM node g FROM node g
JOIN node p ON p.key = g.parent_key JOIN node p ON p.key = g.parent_key
WHERE g.kind = 'group' AND g.key = 'ui'", WHERE g.kind = 'group' AND g.key = 'ui'",
[], [],
|row| Ok((row.get(0)?, row.get(1)?)), |row| row.get(0),
) )
.expect("应能查到分组及其父节点"); .expect("应能查到分组及其父节点");
assert_eq!(parent_key, "recommend"); assert_eq!(parent_key, "recommend");
assert!(view_mode.is_none(), "分组不应带 view_mode");
// 分区一定是顶层节点 // 分区一定是顶层节点
let root_sections: i64 = conn let root_sections: i64 = conn
@@ -419,92 +362,102 @@ mod tests {
#[test] #[test]
fn catalog_matches_the_frontend_model() { fn catalog_matches_the_frontend_model() {
let conn = seeded(); let conn = seeded();
let catalog = catalog::load(&conn).expect("应能组装内容目录"); let tree = catalog::load_tree(&conn).expect("应能组装分组树");
let categories = &catalog.categories; let roots = tree.as_array().expect("根节点应为数组");
assert_eq!(roots.len(), 3);
assert_eq!(categories.len(), 3); let apps = roots
.iter()
.find(|node| node["key"] == "recommend")
.expect("应有常用推荐");
let groups = apps["children"].as_array().expect("应有子分组");
assert_eq!(groups.len(), 5);
let apps = &categories[0]; let common = groups
assert_eq!(apps["id"], "recommend"); .iter()
assert_eq!(apps["kind"], "apps"); .find(|node| node["key"] == "common")
assert_eq!(apps["barColor"], "#3858f6"); .expect("应有常用站点");
assert_eq!(apps["showInSidebar"], true); let first_app = &common["entries"][0];
assert_eq!(apps["groups"].as_array().expect("应有分组").len(), 5);
// 常用站点只作为首页卡片展示,不进侧栏
assert_eq!(
apps["groups"][0]["showInSidebar"], false,
"常用站点不应出现在侧栏"
);
assert_eq!(apps["groups"][1]["showInSidebar"], true);
let first_app = &apps["groups"][0]["items"][0];
assert_eq!(first_app["name"], "GitHub"); assert_eq!(first_app["name"], "GitHub");
assert_eq!(first_app["format"], "apps");
assert_eq!(first_app["hot"], true); assert_eq!(first_app["hot"], true);
// 计数从 JSON 提到了独立列,这里确认读取时又合并回来了
assert_eq!(first_app["views"], 429); assert_eq!(first_app["views"], 429);
let posters = &categories[1]; let posters = roots
assert_eq!(posters["kind"], "posters"); .iter()
// 海报与图文用 title 而不是 name .find(|node| node["key"] == "movie")
assert_eq!( .expect("应有推荐电影");
posters["items"][0].get("name"), let first_poster = &posters["entries"][0];
None, assert_eq!(first_poster["format"], "posters");
assert!(
first_poster.get("name").is_none(),
"海报不应同时下发 name 字段" "海报不应同时下发 name 字段"
); );
assert!(posters["items"][0]["title"].is_string()); assert!(first_poster["title"].is_string());
// tags 来自 entry_tag 子表
assert_eq!( assert_eq!(
posters["items"][0]["tags"] first_poster["tags"]
.as_array() .as_array()
.expect("海报应有标签") .expect("海报应有标签")
.len(), .len(),
2 2
); );
let articles = &categories[2]; let articles = roots
assert_eq!(articles["kind"], "articles"); .iter()
// 正文是一整段 Markdown,而不是块数组 .find(|node| node["key"] == "ces")
let body = articles["items"][0]["body"] .expect("应有 CES 现场报道");
.as_str() let first_article = &articles["entries"][0];
.expect("图文应有正文"); assert_eq!(first_article["format"], "articles");
let body = first_article["body"].as_str().expect("图文应有正文");
assert!(body.contains("## 设计细节"), "正文应含小标题: {body}"); assert!(body.contains("## 设计细节"), "正文应含小标题: {body}");
assert!(body.contains("!["), "正文应含配图语法: {body}"); assert!(body.contains("!["), "正文应含配图语法: {body}");
assert!(body.contains("\n\n"), "段落之间用空行分隔"); assert!(body.contains("\n\n"), "段落之间用空行分隔");
assert!( assert!(
articles["items"][0].get("blocks").is_none(), first_article.get("blocks").is_none(),
"不应再下发 blocks 数组" "不应再下发 blocks 数组"
); );
// meta 与海报的 tags 共用 entry_tag 表
assert_eq!( assert_eq!(
articles["items"][0]["meta"] first_article["meta"]
.as_array() .as_array()
.expect("图文应有元信息") .expect("图文应有元信息")
.len(), .len(),
2 2
); );
// ---- 首页展示块 ---- // 卡片只记录勾选的分组;常用推荐卡片展示「常用站点」,最多 4 条
// 常用推荐卡片展示「常用站点」分组,最多 4 条 let (group_key, preview_limit): (String, Option<i64>) = conn
assert_eq!(apps["block"]["nodeKey"], "common"); .query_row(
assert_eq!(apps["block"]["previewLimit"], 4); "SELECT cg.group_key, cg.preview_limit
// 电影/文章没有分组,块直接指向分区自己 FROM card c JOIN card_group cg ON cg.card_id = c.id
assert_eq!(posters["block"]["nodeKey"], "movie"); WHERE c.key = 'card-recommend'",
assert_eq!(articles["block"]["nodeKey"], "ces"); [],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.expect("应能查到常用推荐卡片");
assert_eq!(group_key, "common");
assert_eq!(preview_limit, Some(4));
} }
/// 删除分区时,它的展示块一起消失(外键级联),不会留下指向空分区的卡片。 /// 删除分组时,卡片里勾选它的记录一起消失(外键级联)。
#[test] #[test]
fn section_blocks_cascade_with_their_section() { fn card_groups_cascade_with_their_group() {
let conn = seeded(); let conn = seeded();
conn.execute("DELETE FROM node WHERE key = 'recommend' AND kind = 'section'", [])
.expect("应能删除分区"); conn.execute(
"DELETE FROM node WHERE kind = 'group' AND key = 'common' AND parent_key = 'recommend'",
[],
)
.expect("应能删除分组");
let remaining: i64 = conn let remaining: i64 = conn
.query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0)) .query_row(
.expect("应能统计展示块"); "SELECT COUNT(*) FROM card_group WHERE group_key = 'common'",
[],
|row| row.get(0),
)
.expect("应能统计卡片分组");
assert_eq!(remaining, 2, "三个展示块应随分区删除只剩两个"); assert_eq!(remaining, 0, "卡片里对 common 的勾选应被级联删除");
} }
/// 删除分组节点时,组内条目也要一起删掉。 /// 删除分组节点时,组内条目也要一起删掉。
@@ -534,7 +487,7 @@ mod tests {
fn primary_key_is_id_and_key_is_unique() { fn primary_key_is_id_and_key_is_unique() {
let conn = seeded(); let conn = seeded();
for table in ["node", "entry", "section_block"] { for table in ["node", "entry", "card"] {
let pk: String = conn let pk: String = conn
.query_row( .query_row(
&format!("SELECT name FROM pragma_table_info('{table}') WHERE pk = 1"), &format!("SELECT name FROM pragma_table_info('{table}') WHERE pk = 1"),
@@ -576,21 +529,22 @@ mod tests {
conn.execute("UPDATE node SET key = 'recommend-v2' WHERE key = 'recommend'", []) conn.execute("UPDATE node SET key = 'recommend-v2' WHERE key = 'recommend'", [])
.expect("应能改分区业务键"); .expect("应能改分区业务键");
let catalog = catalog::load(&conn).expect("应能组装内容目录"); let tree = catalog::load_tree(&conn).expect("应能组装分组树");
let apps = catalog let roots = tree.as_array().expect("根节点应为数组");
.categories let apps = roots
.iter() .iter()
.find(|category| category["id"] == "recommend-v2") .find(|node| node["key"] == "recommend-v2")
.expect("应能找到改名后的分区"); .expect("应能找到改名后的分区");
let total: usize = apps["groups"] fn count(node: &serde_json::Value) -> usize {
let own = node["entries"].as_array().map_or(0, |items| items.len());
let children = node["children"]
.as_array() .as_array()
.expect("应有分组") .map_or(0, |items| items.iter().map(count).sum());
.iter() own + children
.map(|group| group["items"].as_array().expect("应有条目").len()) }
.sum();
assert_eq!(total, 18, "改名后条目仍应归在这个分区下"); assert_eq!(count(apps), 18, "改名后条目仍应归在这个分区下");
} }
/// 基础字段现在是真列,不再塞在 JSON 里:直接按列查询也应该拿得到。 /// 基础字段现在是真列,不再塞在 JSON 里:直接按列查询也应该拿得到。
@@ -654,7 +608,19 @@ mod tests {
let fresh = Connection::open_in_memory().expect("内存库应能打开"); let fresh = Connection::open_in_memory().expect("内存库应能打开");
fresh fresh
.execute_batch( .execute_batch(
"CREATE TABLE node (key TEXT PRIMARY KEY, parent_key TEXT, title TEXT, kind TEXT, view_mode TEXT); "CREATE TABLE node (
key TEXT PRIMARY KEY,
parent_key TEXT,
title TEXT,
kind TEXT,
view_mode TEXT,
bar_color TEXT,
icon TEXT,
preview_limit INTEGER,
empty_text TEXT,
show_in_sidebar INTEGER NOT NULL DEFAULT 1,
sort INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE entry (key TEXT PRIMARY KEY, node_key TEXT NOT NULL, name TEXT NOT NULL); CREATE TABLE entry (key TEXT PRIMARY KEY, node_key TEXT NOT NULL, name TEXT NOT NULL);
CREATE TABLE section_block (id INTEGER PRIMARY KEY, node_key TEXT NOT NULL, title TEXT, preview_limit INTEGER, sort INTEGER NOT NULL DEFAULT 0); CREATE TABLE section_block (id INTEGER PRIMARY KEY, node_key TEXT NOT NULL, title TEXT, preview_limit INTEGER, sort INTEGER NOT NULL DEFAULT 0);
PRAGMA user_version = 15;", PRAGMA user_version = 15;",
+67 -64
View File
@@ -27,9 +27,8 @@ const SEED_JSON: &str = include_str!("seed.json");
/// 种子会自动重新灌一份干净数据。 /// 种子会自动重新灌一份干净数据。
const SCHEMA_SQL: &str = r#" const SCHEMA_SQL: &str = r#"
-- 内容节点:分区与分组共用一张自引用树 -- 内容节点:分区与分组共用一张自引用树
-- kind = 'section' → 顶层分区(首页一张卡片 / 明细页 hero),parent_code 必须为 NULL -- kind = 'section' → 顶层分区(首页一张卡片 / 明细页 hero),parent_key 必须为 NULL
-- kind = 'group' → 分区下的分组,parent_code 指向所属分区 -- kind = 'group' → 分区下的分组,parent_key 指向分区或上层分组
-- view_mode 只对分区有意义,决定首页与明细页怎么渲染
CREATE TABLE IF NOT EXISTS node ( CREATE TABLE IF NOT EXISTS node (
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配,不用 AUTOINCREMENT) id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配,不用 AUTOINCREMENT)
key TEXT NOT NULL UNIQUE, -- 业务键,全局唯一(如 recommend / ui) key TEXT NOT NULL UNIQUE, -- 业务键,全局唯一(如 recommend / ui)
@@ -38,22 +37,15 @@ CREATE TABLE IF NOT EXISTS node (
title TEXT NOT NULL, -- 展示名:分区标题 / 分组小标题 title TEXT NOT NULL, -- 展示名:分区标题 / 分组小标题
kind TEXT NOT NULL CHECK (kind IN ('section', 'group')), kind TEXT NOT NULL CHECK (kind IN ('section', 'group')),
-- 容器层级 -- 容器层级
view_mode TEXT CHECK (view_mode IN ('apps', 'posters', 'articles')), bar_color TEXT, -- 标题左侧色条颜色
-- 分区渲染形态;分组为 NULL icon TEXT, -- 字符图标
bar_color TEXT, -- 分区标题左侧色条颜色(分组为 NULL)
icon TEXT, -- 分区 hero 字符图标(分组为 NULL)
preview_limit INTEGER, -- 首页预览条数;NULL 时前端按类型取默认 preview_limit INTEGER, -- 首页预览条数;NULL 时前端按类型取默认
empty_text TEXT, -- 分区为空时的提示文案
show_in_sidebar INTEGER NOT NULL DEFAULT 1 CHECK (show_in_sidebar IN (0, 1)),
-- 是否出现在侧栏:0 表示只当首页卡片/内容,不进导航
allow_groups INTEGER NOT NULL DEFAULT 0 CHECK (allow_groups IN (0, 1)),
-- 是否允许子分组(与类型无关,后台开关)
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
-- 是否启用(禁用后前台侧栏/卡片不展示) -- 是否启用(禁用后前台侧栏/卡片不展示)
sort INTEGER NOT NULL DEFAULT 0, -- 同级顺序,越小越靠前 sort INTEGER NOT NULL DEFAULT 0, -- 同级顺序,越小越靠前
CHECK ( CHECK (
(kind = 'section' AND parent_key IS NULL AND view_mode IS NOT NULL) (kind = 'section' AND parent_key IS NULL)
OR (kind = 'group' AND parent_key IS NOT NULL AND view_mode IS NULL) OR (kind = 'group' AND parent_key IS NOT NULL)
) )
); );
@@ -95,18 +87,6 @@ CREATE TABLE IF NOT EXISTS entry_tag (
PRIMARY KEY (entry_key, sort) PRIMARY KEY (entry_key, sort)
); );
-- 首页卡片:一行一个卡片,说明展示哪个节点
-- node_key 指向要展示的节点(通常是分组;电影/文章这类无分组的填分区自己)
-- 「属于哪个分区」不单独存:分组看 parent_key,分区就是它自己,查询时推导
CREATE TABLE IF NOT EXISTS section_block (
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配)
node_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
-- 要展示的节点业务键
title TEXT, -- 块标题;NULL 时用节点 title
preview_limit INTEGER, -- 卡片里显示几条;NULL 时用前端默认
sort INTEGER NOT NULL DEFAULT 0 -- 卡片顺序
);
-- ========== v16:登录与设置 ========== -- ========== v16:登录与设置 ==========
-- 管理员:单用户场景只允许一行 -- 管理员:单用户场景只允许一行
@@ -226,6 +206,41 @@ const V22_SQL: &str = r#"
INSERT OR IGNORE INTO card (key, title, sort) VALUES ('local-service', '本地服务', -1); INSERT OR IGNORE INTO card (key, title, sort) VALUES ('local-service', '本地服务', -1);
"#; "#;
/// v22 → v23:删除旧分区展示块与 node 上的兼容字段,node 只保留当前模型需要的列。
const V23_SQL: &str = r#"
PRAGMA foreign_keys = OFF;
DROP TABLE IF EXISTS section_block;
CREATE TABLE node_new (
id INTEGER PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
parent_key TEXT REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
title TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('section', 'group')),
bar_color TEXT,
icon TEXT,
preview_limit INTEGER,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
sort INTEGER NOT NULL DEFAULT 0,
CHECK (
(kind = 'section' AND parent_key IS NULL)
OR (kind = 'group' AND parent_key IS NOT NULL)
)
);
INSERT INTO node_new (key, parent_key, title, kind, bar_color, icon, preview_limit, enabled, sort)
SELECT key, parent_key, title, kind, bar_color, icon, preview_limit, enabled, sort
FROM node;
DROP TABLE node;
ALTER TABLE node_new RENAME TO node;
CREATE INDEX IF NOT EXISTS node_parent_idx ON node(parent_key, sort);
PRAGMA foreign_keys = ON;
"#;
/// v20 → v21:格式从分组下沉到条目;老数据按所属根分组的 view_mode 回填。 /// v20 → v21:格式从分组下沉到条目;老数据按所属根分组的 view_mode 回填。
const V21_SQL: &str = r#" const V21_SQL: &str = r#"
ALTER TABLE entry ADD COLUMN format TEXT NOT NULL DEFAULT 'apps'; ALTER TABLE entry ADD COLUMN format TEXT NOT NULL DEFAULT 'apps';
@@ -302,7 +317,7 @@ pub struct SeedReport {
/// ///
/// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来, /// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来,
/// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。 /// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。
pub const SCHEMA_VERSION: i64 = 22; pub const SCHEMA_VERSION: i64 = 23;
fn current_version(conn: &Connection) -> Result<i64, StashError> { fn current_version(conn: &Connection) -> Result<i64, StashError> {
conn.pragma_query_value(None, "user_version", |row| row.get(0)) conn.pragma_query_value(None, "user_version", |row| row.get(0))
@@ -372,6 +387,11 @@ pub fn migrate(conn: &Connection) -> Result<i64, StashError> {
version = 22; version = 22;
} }
if version == 22 {
conn.execute_batch(V23_SQL).map_err(map_error)?;
version = 23;
}
conn.pragma_update(None, "user_version", version) conn.pragma_update(None, "user_version", version)
.map_err(map_error)?; .map_err(map_error)?;
@@ -416,18 +436,14 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
} }
tx.execute( tx.execute(
"INSERT INTO node (key, parent_key, title, kind, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar, allow_groups, sort) "INSERT INTO node (key, parent_key, title, kind, bar_color, icon, preview_limit, sort)
VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, ?6)",
params![ params![
section.key, section.key,
section.title, section.title,
view,
section.bar_color, section.bar_color,
section.icon, section.icon,
section.preview_limit, section.preview_limit,
section.empty_text,
i64::from(section.show_in_sidebar),
i64::from(view == "apps"),
section.sort, section.sort,
], ],
) )
@@ -437,15 +453,9 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
if view == "apps" { if view == "apps" {
for group in &section.groups { for group in &section.groups {
tx.execute( tx.execute(
"INSERT INTO node (key, parent_key, title, kind, show_in_sidebar, sort) "INSERT INTO node (key, parent_key, title, kind, sort)
VALUES (?1, ?2, ?3, 'group', ?4, ?5)", VALUES (?1, ?2, ?3, 'group', ?4)",
params![ params![group.key, section.key, group.title, group.sort],
group.key,
section.key,
group.title,
i64::from(group.show_in_sidebar),
group.sort
],
) )
.map_err(map_error)?; .map_err(map_error)?;
report.groups += 1; report.groups += 1;
@@ -461,17 +471,26 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
} }
} }
// 首页该分区展示哪个节点:节点必须已经插入(分组或分区本身) // 首页卡片:仓库里直接生成卡片与勾选的分组
tx.execute( tx.execute(
"INSERT INTO section_block (node_key, title, preview_limit, sort) "INSERT INTO card (key, title, sort) VALUES (?1, ?2, ?3)",
VALUES (?1, ?2, ?3, ?4)",
params![ params![
section.block.node_key, format!("card-{}", section.key),
section.block.title, section
section.block.preview_limit, .block
.title
.clone()
.unwrap_or_else(|| section.title.clone()),
section.sort, section.sort,
], ],
) )
.map_err(map_error)?;
let card_id = tx.last_insert_rowid();
tx.execute(
"INSERT INTO card_group (card_id, group_key, sort, preview_limit)
VALUES (?1, ?2, 0, ?3)",
params![card_id, section.block.node_key, section.block.preview_limit],
)
.map_err(|error| { .map_err(|error| {
StashError::Database(format!( StashError::Database(format!(
"分区 {} 的展示节点 {} 无效: {error}", "分区 {} 的展示节点 {} 无效: {error}",
@@ -481,9 +500,6 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
report.blocks += 1; report.blocks += 1;
} }
// 老结构(分区 → 展示块)同步生成卡片,保证新首页立刻有内容
tx.execute_batch(BACKFILL_CARDS_SQL).map_err(map_error)?;
tx.commit().map_err(map_error)?; tx.commit().map_err(map_error)?;
Ok(report) Ok(report)
@@ -565,11 +581,6 @@ struct SeedSection {
sort: i64, sort: i64,
#[serde(default)] #[serde(default)]
preview_limit: Option<i64>, preview_limit: Option<i64>,
#[serde(default)]
empty_text: Option<String>,
/// 是否出现在侧栏;默认 true
#[serde(default = "default_true")]
show_in_sidebar: bool,
/// 仅 apps 分区使用 /// 仅 apps 分区使用
#[serde(default)] #[serde(default)]
groups: Vec<SeedGroup>, groups: Vec<SeedGroup>,
@@ -601,17 +612,9 @@ struct SeedGroup {
key: String, key: String,
title: String, title: String,
sort: i64, sort: i64,
/// 是否出现在侧栏;默认 true
#[serde(default = "default_true")]
show_in_sidebar: bool,
entries: Vec<SeedEntry>, entries: Vec<SeedEntry>,
} }
/// serde 默认值:侧栏默认显示
fn default_true() -> bool {
true
}
/// 一条内容:字段与 `entry` 表的列一一对应。 /// 一条内容:字段与 `entry` 表的列一一对应。
/// ///
/// `tags` / `meta` 都写进 `entry_tag`(海报用 tags,图文用 meta); /// `tags` / `meta` 都写进 `entry_tag`(海报用 tags,图文用 meta);
-199
View File
@@ -206,31 +206,6 @@ async fn list_projects_api(State(state): State<AppState>) -> impl IntoResponse {
})) }))
} }
/// API: 完整内容目录(分区 / 分组 / 条目 + 首页展示块)
///
/// 数据量很小(几十 KB),一次全量下发,前端搜索仍然在客户端做。
/// 侧栏由这份数据派生,不再单独下发导航。
async fn catalog_api(State(state): State<AppState>) -> Response {
let db = state.db.clone();
// rusqlite 是同步的,查询扔进阻塞线程池,别占住 async 执行器
match tokio::task::spawn_blocking(move || db.load_catalog()).await {
Ok(Ok(catalog)) => Json(serde_json::json!({
"categories": catalog.categories,
}))
.into_response(),
Ok(Err(error)) => {
// 典型原因是用户手改坏了 JSON,错误信息里已带条目 id
warn!("读取内容库失败: {error}");
error.into_response()
}
Err(error) => {
warn!("内容库查询任务崩溃: {error}");
StashError::Database(format!("内容库查询任务崩溃: {error}")).into_response()
}
}
}
/// SSE:内容库被外部修改时通知前端重新拉取。 /// SSE:内容库被外部修改时通知前端重新拉取。
/// ///
/// 这里轮询 `PRAGMA data_version` 而不是监听数据库文件: /// 这里轮询 `PRAGMA data_version` 而不是监听数据库文件:
@@ -964,172 +939,6 @@ async fn set_card_groups(
} }
} }
/// POST /api/categories —— 新建分区(需要登录)
async fn create_category(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<crate::db::categories::NewCategory>,
) -> Response {
if !same_origin(&headers) {
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
}
match read_session(&state, &headers).await {
Ok(session) if session.authenticated => {}
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
Err(response) => return response,
}
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.create_category(body)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
Ok(Err(StashError::Conflict(message))) => api_error(StatusCode::CONFLICT, &message),
Ok(Err(StashError::InvalidDirectory(message))) => {
api_error(StatusCode::BAD_REQUEST, &message)
}
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// PATCH /api/categories/{id} —— 修改分区(需要登录)
async fn update_category(
State(state): State<AppState>,
Path(id): Path<String>,
headers: HeaderMap,
Json(body): Json<crate::db::categories::UpdateCategory>,
) -> Response {
if !same_origin(&headers) {
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
}
match read_session(&state, &headers).await {
Ok(session) if session.authenticated => {}
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
Err(response) => return response,
}
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.update_category(&id, body)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message),
Ok(Err(StashError::InvalidDirectory(message))) => {
api_error(StatusCode::BAD_REQUEST, &message)
}
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// POST /api/categories/{id}/groups —— 新建分组(需要登录)
async fn create_group(
State(state): State<AppState>,
Path(section_id): Path<String>,
headers: HeaderMap,
Json(body): Json<crate::db::categories::NewGroup>,
) -> Response {
if !same_origin(&headers) {
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
}
match read_session(&state, &headers).await {
Ok(session) if session.authenticated => {}
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
Err(response) => return response,
}
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.create_group(&section_id, body)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
Ok(Err(StashError::Conflict(message))) => api_error(StatusCode::CONFLICT, &message),
Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message),
Ok(Err(StashError::InvalidDirectory(message))) => {
api_error(StatusCode::BAD_REQUEST, &message)
}
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// PATCH /api/groups/{id} —— 分组改名 / 显隐(需要登录)
async fn update_group(
State(state): State<AppState>,
Path(id): Path<String>,
headers: HeaderMap,
Json(body): Json<crate::db::categories::UpdateGroup>,
) -> Response {
if !same_origin(&headers) {
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
}
match read_session(&state, &headers).await {
Ok(session) if session.authenticated => {}
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
Err(response) => return response,
}
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.update_group(&id, body)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message),
Ok(Err(StashError::InvalidDirectory(message))) => {
api_error(StatusCode::BAD_REQUEST, &message)
}
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// DELETE /api/groups/{id} —— 删除分组(需要登录,组内条目级联删除)
async fn delete_group(
State(state): State<AppState>,
Path(id): Path<String>,
headers: HeaderMap,
) -> Response {
if !same_origin(&headers) {
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
}
match read_session(&state, &headers).await {
Ok(session) if session.authenticated => {}
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
Err(response) => return response,
}
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.remove_group(&id)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message),
Ok(Err(StashError::InvalidDirectory(message))) => {
api_error(StatusCode::BAD_REQUEST, &message)
}
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// DELETE /api/categories/{id} —— 删除分区(需要登录,级联内容)
async fn delete_category(
State(state): State<AppState>,
Path(id): Path<String>,
headers: HeaderMap,
) -> Response {
if !same_origin(&headers) {
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
}
match read_session(&state, &headers).await {
Ok(session) if session.authenticated => {}
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
Err(response) => return response,
}
let db = Arc::clone(&state.db);
match tokio::task::spawn_blocking(move || db.remove_category(&id)).await {
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message),
Ok(Err(StashError::InvalidDirectory(message))) => {
api_error(StatusCode::BAD_REQUEST, &message)
}
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// 组装路由:API + 内嵌前端 + 托管目录。 /// 组装路由:API + 内嵌前端 + 托管目录。
/// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、 /// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、
/// 起监听任务,没法在单元测试里用。 /// 起监听任务,没法在单元测试里用。
@@ -1148,7 +957,6 @@ fn build_router(state: AppState) -> Router {
.route("/api/health", get(health_check)) .route("/api/health", get(health_check))
.route("/api/projects", get(list_projects_api)) .route("/api/projects", get(list_projects_api))
.route("/api/projects/events", get(project_events)) .route("/api/projects/events", get(project_events))
.route("/api/catalog", get(catalog_api))
.route("/api/catalog2", get(catalog2_api)) .route("/api/catalog2", get(catalog2_api))
.route("/api/nodes", post(create_node)) .route("/api/nodes", post(create_node))
.route("/api/nodes/{key}", patch(update_node).delete(delete_node)) .route("/api/nodes/{key}", patch(update_node).delete(delete_node))
@@ -1166,13 +974,6 @@ fn build_router(state: AppState) -> Router {
.route("/api/items", post(create_item)) .route("/api/items", post(create_item))
.route("/api/items/{key}/view", post(post_view)) .route("/api/items/{key}/view", post(post_view))
.route("/api/items/{key}/like", post(post_like)) .route("/api/items/{key}/like", post(post_like))
.route("/api/categories", post(create_category))
.route(
"/api/categories/{id}",
patch(update_category).delete(delete_category),
)
.route("/api/categories/{id}/groups", post(create_group))
.route("/api/groups/{id}", patch(update_group).delete(delete_group))
.route("/api/backup", get(backup_db)) .route("/api/backup", get(backup_db))
.route("/", get(web_index)) .route("/", get(web_index))
.route("/index.html", get(web_index)) .route("/index.html", get(web_index))
+3 -3
View File
@@ -67,7 +67,7 @@ import type { Project } from '../../api'
/** /**
* 新版后台:原子模型。 * 新版后台:原子模型。
* *
* - 分组是唯一容器(可多层、带格式); * - 分组是唯一容器(可多层);
* - 内容是条目,挂在分组上; * - 内容是条目,挂在分组上;
* - 侧边栏、首页卡片都只是「勾选分组」的展示组合。 * - 侧边栏、首页卡片都只是「勾选分组」的展示组合。
*/ */
@@ -575,7 +575,7 @@ function GroupsPanel({
return ( return (
<Space direction="column" gap="md" align="stretch" className="admin-form"> <Space direction="column" gap="md" align="stretch" className="admin-form">
<Text size="sm" tone="muted"> <Text size="sm" tone="muted">
说明:分组用于控制前台侧边栏与卡片;可多层嵌套,根分组决定格式。内容(条目)只挂在分组上。 说明:分组用于控制前台侧边栏与卡片,可多层嵌套。内容(条目)只挂在分组上,格式由每条内容自己决定。
</Text> </Text>
<div className="group-toolbar"> <div className="group-toolbar">
@@ -593,7 +593,7 @@ function GroupsPanel({
columns={[ columns={[
{ key: 'name', title: '分组名称' }, { key: 'name', title: '分组名称' },
{ key: 'sort', title: '排序权重', width: 110 }, { key: 'sort', title: '排序权重', width: 110 },
{ key: 'count', title: '关联网页数', width: 120 }, { key: 'count', title: '关联内容数', width: 120 },
{ key: 'status', title: '状态', width: 90 }, { key: 'status', title: '状态', width: 90 },
{ key: 'actions', title: '操作', width: 260, align: 'right' }, { key: 'actions', title: '操作', width: 260, align: 'right' },
]} ]}
+1 -3
View File
@@ -19,7 +19,6 @@ export type GroupNode = {
key: string key: string
parentKey?: string | null parentKey?: string | null
title: string title: string
format: GroupFormat
icon: string icon: string
barColor: string barColor: string
previewLimit?: number | null previewLimit?: number | null
@@ -189,13 +188,12 @@ export async function setCardGroups(
export function flattenGroups( export function flattenGroups(
groups: GroupNode[], groups: GroupNode[],
depth = 0, depth = 0,
): { key: string; title: string; depth: number; format: GroupFormat; enabled: boolean }[] { ): { key: string; title: string; depth: number; enabled: boolean }[] {
return groups.flatMap((group) => [ return groups.flatMap((group) => [
{ {
key: group.key, key: group.key,
title: group.title, title: group.title,
depth, depth,
format: group.format,
enabled: group.enabled !== false, enabled: group.enabled !== false,
}, },
...flattenGroups(group.children, depth + 1), ...flattenGroups(group.children, depth + 1),