diff --git a/HANDOFF.md b/HANDOFF.md index f1d1a87..d8c577b 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -11,7 +11,7 @@ > - 新增 `/interface` 接口一览页;侧栏「查看接口」走站内路由,不再直接开 `/api/projects`; > - 接入 `@stash/ui` 组件库:前后台所有卡片、表格、开关、文字按钮都来自库里, > 明暗主题(浅色 / 深色 / 跟随系统)由令牌接管; -> - 内容库表结构升到 v22 并**有了自动迁移**,卡片模型是 `card` + `card_group`(`section_block` 仅作旧库迁移来源)。 +> - 内容库表结构升到 v23 并**有了自动迁移**,卡片模型是 `card` + `card_group`;`section_block` 与旧 `node` 兼容字段已删除。 ## 0. 一句话现状 @@ -135,11 +135,10 @@ README.md 运行、路由、库结构、登录说明 ``` GET /api/health GET /api/projects GET /api/projects/events (SSE) -GET /api/catalog GET /api/catalog/events (SSE) 旧结构(兼容) GET /api/catalog2 分组树 + 卡片(新前台/后台) GET /api/cards 卡片列表 -POST /api/nodes {key,title,parentKey?,format?} 需登录 -PATCH /api/nodes/{key} {title,parentKey?,sort?,format?} 需登录 +POST /api/nodes {key,title,parentKey?,icon?,sort?,enabled?} 需登录 +PATCH /api/nodes/{key} {title,parentKey?,sort?,icon?,enabled?} 需登录 DELETE /api/nodes/{key} 需登录,级联 POST /api/cards {title} 需登录 PATCH /api/cards/{id} {title?,sort?} 需登录 @@ -162,26 +161,20 @@ DELETE /api/items/{key} 需登录 POST /api/items/{key}/view 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 快照附件 需登录 ``` ## 5. 数据库与迁移 - 文件:开发态在 `target/debug/data/stash.db`;可用 `--root` / 数据目录覆盖(见 `config.rs`)。 -- 当前 `PRAGMA user_version = 22`;`schema::migrate` 逐级升级:0 建全量、15→16→17→18→19→20→21→22、更高版本拒绝启动。 -- 格式在 `entry.format`(apps / posters / articles),同组可混合;`node` 的旧 `view_mode` 仅保留兼容,不再决定渲染。 +- 当前 `PRAGMA user_version = 23`;`schema::migrate` 逐级升级:0 建全量、15→16→17→18→19→20→21→22→23、更高版本拒绝启动。 +- 格式在 `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)。 - 首页滚动:`#page-scroll`(Layout 的 body)是唯一滚动容器,搜索框固定在其上方;左右两栏顶部对齐,左侧到底后通过 `translateY` 吸底,右侧继续滚动;滚动条隐藏;页头实底,搜索框 50% 透明,顶部另有一层上下渐隐的软虚化(`blur(8px)`)。 - 本地服务是运行时卡片,但 `card` 表里有一行 `local-service` 只用于排序;内容来自 `/api/projects`,后端禁止删除/改名/配置分组。 - `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。 - **设置/内容都是 SQLite**:备份用 `/api/backup`,恢复需停机替换文件。 @@ -223,7 +216,7 @@ GET /api/backup SQLite 快照附件 需登录 | 分组移动 | 编辑弹窗里可改「上级分组」(防环);根分组不能移到子级 | | 条目移动 | 内容弹窗里可直接换分组(`PATCH /api/items/{key}` 的 `nodeKey`) | | 分组排序 | 可编辑「排序权重」数值;拖拽排序未做 | -| 旧接口 | `/api/catalog`、`/api/categories/*`、`/api/groups/*` 仍保留给旧组件/兼容,后续可删 | +| 旧接口 | 已删除 `/api/catalog`、`/api/categories/*`、旧 `/api/groups/{id}`;当前只保留 `/api/catalog2` 与 `/api/nodes` | | 登录限流 | 进程内计数,重启清空 | ## 10. 约定与坑 diff --git a/README.md b/README.md index 810ee23..6526f5c 100644 --- a/README.md +++ b/README.md @@ -201,19 +201,15 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000 | `parent_key` | TEXT? | 父节点编码:分组指向所属分区,分区为 NULL | | `title` | TEXT | 展示名:分区标题 / 分组小标题 | | `kind` | TEXT | `section` 顶层分区 / `group` 分区下的分组 | -| `view_mode` | TEXT? | 仅分区:`apps` / `posters` / `articles`,决定渲染形态 | -| `bar_color` | TEXT? | 仅分区:标题左侧 4×16 色条颜色(如 `#3858f6`) | -| `icon` | TEXT? | 分组图标:可以是字符(`🎬`),也可以是 iconfont 图标名(`film_line`) | +| `bar_color` | TEXT? | 标题左侧色条颜色(如 `#3858f6`) | +| `icon` | TEXT? | 图标:可以是字符(`🎬`),也可以是 iconfont 图标名(`film_line`) | | `preview_limit` | INTEGER? | 分组在卡片里的默认预览条数;为空时按类型取默认 | -| `empty_text` | TEXT? | 分组为空时的提示文案 | -| `show_in_sidebar` | INTEGER | 是否出现在侧栏,`0` / `1`;0 表示只当首页卡片/内容,不进导航 | -| `allow_groups` | INTEGER | 是否允许新建子分组,`0` / `1`;与类型无关,后台开关 | -| `enabled` | INTEGER | 是否启用,`0` / `1`;**唯一可见性来源**,停用后侧栏与卡片都不再出现该子树 | +| `enabled` | INTEGER | 是否启用,`0` / `1`;停用后侧栏与卡片都不再出现该子树 | | `sort` | INTEGER | 同级顺序,越小越靠前 | 两条约束写在表上: -- `CHECK`:`section` 必须 `parent_key IS NULL` 且有 `view_mode`;`group` 必须有父节点且无 `view_mode`; +- `CHECK`:`section` 必须 `parent_key IS NULL`;`group` 必须有父节点; - `key` 唯一(UNIQUE),全局不重复。 #### entry —— 内容条目(网站 / 电影 / 文章共用) @@ -223,6 +219,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000 | `id` | INTEGER | 主键,库内唯一标识(自动分配) | | `key` | TEXT | 业务编码,全局唯一(如 `github` / `movie-1`);详情页按它查找 | | `node_key` | TEXT | 直接所属容器,外键 → `node.key`:应用是分组,海报 / 图文是分区本身 | +| `format` | TEXT | 展示格式:`apps` / `posters` / `articles`;逐条设置,同一分组可混合 | | `name` | TEXT | 名称:网站名 / 电影名 / 文章标题 | | `description` | TEXT? | 一句话简介,列表卡片的副标题 | | `icon` | TEXT? | 字符图标,封面缺失时的占位 | @@ -251,7 +248,7 @@ WHERE COALESCE(p.parent_key, p.key) = 'recommend'; 好处是没有冗余列可以写错——条目不会出现「声称属于 A 区、实际挂在 B 区分组下」的情况; `node_key` 还有 `ON UPDATE CASCADE`,改编码时条目自动跟随。 -三种内容共用一张表,只是**用到的字段不同**,形态由所属分区的 `view_mode` 决定: +三种内容共用一张表,只是**用到的字段不同**,形态由每条自己的 `entry.format` 决定: | 类型 | 常用字段 | | --- | --- | @@ -308,8 +305,8 @@ card_group 卡片包含哪些分组 + 每个分组的预览条数与先后 主键 `(card_id, group_key)`:同一张卡片里一个分组只出现一次。 一张卡片可以混合展示多个分组(不同格式也能混),顺序与条数各自独立。 -旧版的 `section_block` 表仍保留在库里(`CREATE TABLE IF NOT EXISTS` 不会删表), -但只作为从旧库迁移到 `card` / `card_group` 的来源,新代码不再读写它。 +旧版的 `section_block` 表已在 v23 迁移中删除;旧库升级时会先把数据回填到 +`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` + `entry_tag` | `name`(→`title`)`cover` `comment` `summary` `href` + `tags` | | 图文详情 | `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` 扫描托管目录后在前端拼接 | ### 侧栏导航 @@ -370,13 +367,13 @@ CES 现场报道(根分组,无子分组 → 直接打开) - 有子分组时渲染成可折叠节点,子项就是子分组; - 没有子分组时整行是链接; - 名字一律用 `node.title`,没有别名,也没有跨分区的收纳层; -- `show_in_sidebar = 0` 的节点不出现;`enabled = 0` 的节点连同整棵子树都不出现; +- `enabled = 0` 的节点连同整棵子树都不出现; - 顺序取 `node.sort`(在「分组管理」里改「排序权重」); - 「本地服务」是前端拼出来的分类,侧栏按 key 直接排除。 ### 首页卡片 -一张卡片 = 一个标题 + 一组勾选的分组,内容不混排: +一张卡片 = 一个标题 + 一组勾选的分组,分组各自渲染、可以混合格式: ```sql -- 看看「常用推荐」这张卡片勾了哪些分组、各自几条 @@ -502,7 +499,6 @@ GET /api/catalog2 "key": "pin", "title": "常用推荐", "icon": "🔖", - "format": "apps", "enabled": true, "sort": 1, "entries": [{ "id": "1", "name": "1", "format": "apps" }], @@ -520,11 +516,9 @@ GET /api/catalog2 } ``` -「本地服务」卡片**不在**这个接口里,它由 `/api/projects` 扫描托管目录后在前端拼接。 +「本地服务」卡片在 `cards` 里也有一行 `local-service`,但内容由 `/api/projects` 运行时生成。 侧栏不再单独下发,前端直接用 `groups` 派生。 -`GET /api/catalog` 是旧版结构(`categories` 数组),仅为兼容保留。 - ### 订阅内容库变化 ```http diff --git a/src/db/catalog.rs b/src/db/catalog.rs index 6e83133..3861ccf 100644 --- a/src/db/catalog.rs +++ b/src/db/catalog.rs @@ -11,23 +11,10 @@ use rusqlite::Connection; use serde_json::{Map, Value, json}; use crate::{ - db::{Catalog, map_error}, + db::map_error, error::StashError, }; -/// 容器节点行:分区与分组共用 -struct NodeRow { - key: String, - title: String, - view_mode: Option, - bar_color: Option, - icon: Option, - preview_limit: Option, - empty_text: Option, - show_in_sidebar: bool, - allow_groups: bool, -} - /// 条目行:与 `entry` 表的列一一对应 struct EntryRow { key: String, @@ -47,19 +34,11 @@ struct EntryRow { likes: i64, } -/// 读出整个内容目录。 -pub fn load(conn: &Connection) -> Result { - Ok(Catalog { - categories: load_sections(conn)?, - }) -} - /// 分组树节点(新视图用) struct TreeNode { key: String, parent: Option, title: String, - view_mode: Option, icon: Option, bar_color: Option, preview_limit: Option, @@ -67,11 +46,11 @@ struct TreeNode { enabled: bool, } -/// 新的原子视图:完整分组树(多层、带格式、带条目),供新首页/侧栏/后台使用。 +/// 新的原子视图:完整分组树(多层、带条目),供新首页/侧栏/后台使用。 pub fn load_tree(conn: &Connection) -> Result { let mut stmt = conn .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", ) .map_err(map_error)?; @@ -82,12 +61,11 @@ pub fn load_tree(conn: &Connection) -> Result { key: row.get(0)?, parent: row.get(1)?, title: row.get(2)?, - view_mode: row.get(3)?, - icon: row.get(4)?, - bar_color: row.get(5)?, - preview_limit: row.get(6)?, - sort: row.get(7)?, - enabled: row.get(8)?, + icon: row.get(3)?, + bar_color: row.get(4)?, + preview_limit: row.get(5)?, + sort: row.get(6)?, + enabled: row.get(7)?, }) }) .map_err(map_error)? @@ -96,48 +74,22 @@ pub fn load_tree(conn: &Connection) -> Result { drop(stmt); - let index: HashMap = nodes - .iter() - .enumerate() - .map(|(idx, node)| (node.key.clone(), idx)) - .collect(); - // 子节点索引(按 sort 已排好序) let mut children: HashMap, Vec> = HashMap::new(); for (idx, node) in nodes.iter().enumerate() { children.entry(node.parent.clone()).or_default().push(idx); } - // 每个节点继承最近根分组的格式 - let mut formats: Vec = 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)?; fn render( conn: &Connection, nodes: &[TreeNode], children: &HashMap, Vec>, - formats: &[String], tags: &HashMap>, idx: usize, ) -> Result { let node = &nodes[idx]; - let format = &formats[idx]; let entries = load_node_entries(conn, &node.key)?; let items = entries .iter() @@ -148,7 +100,7 @@ pub fn load_tree(conn: &Connection) -> Result { .get(&Some(node.key.clone())) .map(|list| { list.iter() - .map(|child| render(conn, nodes, children, formats, tags, *child)) + .map(|child| render(conn, nodes, children, tags, *child)) .collect::, _>>() }) .transpose()? @@ -158,7 +110,6 @@ pub fn load_tree(conn: &Connection) -> Result { "key": node.key, "parentKey": node.parent, "title": node.title, - "format": format, "icon": node.icon.clone().unwrap_or_default(), "barColor": node.bar_color.clone().unwrap_or_default(), "previewLimit": node.preview_limit, @@ -173,7 +124,7 @@ pub fn load_tree(conn: &Connection) -> Result { .get(&None) .map(|list| { list.iter() - .map(|idx| render(conn, &nodes, &children, &formats, &tags, *idx)) + .map(|idx| render(conn, &nodes, &children, &tags, *idx)) .collect::, _>>() }) .transpose()? @@ -203,164 +154,6 @@ fn load_all_tags(conn: &Connection) -> Result>, Stas Ok(grouped) } -/// 读出全部分区,按 `sort` 排序;顺序即首页分区顺序。 -fn load_sections(conn: &Connection) -> Result, 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::, _>>() - .map_err(map_error)?; - - // 先释放语句再发后续查询,避免同一连接上的借用冲突 - drop(stmt); - - // 每个分区对应的首页卡片:展示哪个节点、几条 - let blocks = load_blocks(conn)?; - - sections - .iter() - .map(|section| build_section(conn, section, blocks.get(§ion.key))) - .collect() -} - -/// 读出所有卡片配置,按推导出的分区业务键索引。 -/// -/// 分区不单独存:节点是分组就取它的 `parent_key`,节点是分区就取它自己。 -/// `title` 同样在 SQL 里解析:卡片自己配了标题就用它,否则用被展示节点的标题。 -fn load_blocks(conn: &Connection) -> Result, 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>(3)?, - )) - }) - .map_err(map_error)? - .collect::, _>>() - .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 { - 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) = §ion.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, §ion.key)?; - if section.allow_groups { - object.insert( - "groups".into(), - build_groups(conn, section, &tags)?, - ); - let direct = load_node_entries(conn, §ion.key)?; - let items = direct - .iter() - .map(|entry| item_for(entry, &tags)) - .collect::>(); - object.insert("items".into(), json!(items)); - } else { - object.insert("groups".into(), json!([])); - // 平铺:把(可能存在的)分组里的条目一起取出来,关闭开关时不丢内容 - let entries = load_section_entries(conn, §ion.key)?; - let items = entries - .iter() - .map(|entry| item_for(entry, &tags)) - .collect::>(); - object.insert("items".into(), json!(items)); - } - - Ok(Value::Object(object)) -} - /// 按条目自身的格式组装列表项。 fn item_for(entry: &EntryRow, tags: &HashMap>) -> Value { let mut value = match entry.format.as_str() { @@ -374,55 +167,6 @@ fn item_for(entry: &EntryRow, tags: &HashMap>) -> Value { value } -/// 取出某个分区下的分组,并把条目按 `node_key` 分派进去。 -fn build_groups( - conn: &Connection, - section: &NodeRow, - tags: &HashMap>, -) -> Result { - 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([§ion.key], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, bool>(2)?, - )) - }) - .map_err(map_error)? - .collect::, _>>() - .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::>(); - - 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 { let mut object = Map::new(); object.insert("id".into(), json!(entry.key)); @@ -504,55 +248,9 @@ fn insert_optional(object: &mut Map, key: &str, value: &Option Result, StashError> { +/// 取某个节点直接挂着的条目,按 `sort` 升序。 +fn load_node_entries(conn: &Connection, node_key: &str) -> Result, StashError> { 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::, _>>() - .map_err(map_error)?; - - Ok(entries) -} - -fn load_node_entries(conn: &Connection, node_key: &str) -> Result, StashError> { 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, @@ -589,32 +287,3 @@ fn load_node_entries(conn: &Connection, node_key: &str) -> Result, Ok(entries) } - -/// 取某个分区下所有条目的标签,按条目业务键聚合;一次查询避免 N+1。 -fn load_tags(conn: &Connection, section_key: &str) -> Result>, 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::, _>>() - .map_err(map_error)?; - - let mut grouped: HashMap> = HashMap::new(); - for (entry_key, label) in rows { - grouped.entry(entry_key).or_default().push(label); - } - - Ok(grouped) -} diff --git a/src/db/categories.rs b/src/db/categories.rs deleted file mode 100644 index 30b46f5..0000000 --- a/src/db/categories.rs +++ /dev/null @@ -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, - pub icon: Option, - pub bar_color: Option, - /// 改类型(应用 / 海报 / 图文)时会顺带迁移条目归属 - pub kind: Option, - pub preview_limit: Option, - pub empty_text: Option, - /// 首页卡片预览哪个节点(分区自己或它下面的分组) - pub block_node_key: Option, - /// 是否允许子分组(与类型无关,后台开关) - pub allow_groups: Option, -} - -/// 新建分组(子分类) -#[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, - pub show_in_sidebar: Option, -} - -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, - bar_color: Option, - view_mode: Option, - preview_limit: Option, - empty_text: Option, - 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 = 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 = 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, Option, Option) = - 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); - } -} diff --git a/src/db/groups.rs b/src/db/groups.rs index 9223d7c..2bf1346 100644 --- a/src/db/groups.rs +++ b/src/db/groups.rs @@ -1,16 +1,14 @@ -//! 分组树的管理:多层分组、格式、父级移动与排序。 +//! 分组树的管理:多层分组、父级移动与排序。 //! -//! 数据层只有一张 `node` 树:顶层节点是「根分组」(`kind='section'`,带 -//! `view_mode` 格式),下面的节点都叫分组(`kind='group'`),可以继续嵌套。 -//! 条目挂在任意节点上,格式由最近的根分组决定。 +//! 数据层只有一张 `node` 树:顶层节点是「根分组」(`kind='section'`), +//! 下面的节点都叫分组(`kind='group'`),可以继续嵌套。 +//! 条目的展示格式由 `entry.format` 决定,分组不再保存格式。 use rusqlite::{Connection, OptionalExtension, params}; use serde::Deserialize; use crate::{db::entries, db::map_error, error::StashError}; -const FORMATS: [&str; 3] = ["apps", "posters", "articles"]; - #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NewNode { @@ -20,9 +18,6 @@ pub struct NewNode { /// 为空表示新建一个根分组 #[serde(default)] pub parent_key: Option, - /// 根分组的格式:apps / posters / articles(子分组继承,不需要传) - #[serde(default)] - pub format: Option, /// 图标:可以是 iconfont 名或字符;空字符串表示不显示 #[serde(default)] pub icon: Option, @@ -41,8 +36,6 @@ pub struct UpdateNode { /// 传入即移动到新的父节点;不支持移回根(根分组请直接新建) pub parent_key: Option, pub sort: Option, - /// 只对根分组生效 - pub format: Option, /// 图标:空字符串表示清除图标 pub icon: Option, pub enabled: Option, @@ -78,7 +71,7 @@ pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> { 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) => { let parent_exists: i64 = conn .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 { return Err(StashError::NotFound(format!("父分组不存在: {parent}"))); } - ("group", None) - } - None => { - let format = new.format.unwrap_or_else(|| "apps".into()); - if !FORMATS.contains(&format.as_str()) { - return Err(StashError::InvalidDirectory("格式不合法".into())); - } - ("section", Some(format)) + "group" } + None => "section", }; 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)?; tx.execute( - "INSERT INTO node (key, parent_key, title, kind, view_mode, icon, enabled, sort, show_in_sidebar, allow_groups) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1, 1)", + "INSERT INTO node (key, parent_key, title, kind, icon, enabled, sort) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ key, new.parent_key, title, kind, - view_mode, icon, enabled, 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> { - let current: Option<(String, Option, Option, i64, Option, bool)> = conn + let current: Option<(String, Option, i64, Option, bool)> = conn .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], |row| { Ok(( @@ -157,15 +143,13 @@ pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(), row.get(2)?, row.get(3)?, row.get(4)?, - row.get(5)?, )) }, ) .optional() .map_err(map_error)?; - let Some((current_title, current_parent, current_format, current_sort, current_icon, current_enabled)) = - current + let Some((current_title, current_parent, current_sort, current_icon, current_enabled)) = current else { 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(), }; - 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 { Some(icon) => { let icon = icon.trim().to_string(); @@ -268,10 +232,10 @@ pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(), }; tx.execute( - "UPDATE node SET title = ?1, parent_key = ?2, view_mode = ?3, sort = ?4, - icon = ?5, enabled = ?6 - WHERE key = ?7", - params![title, parent_key, format, final_sort, icon, enabled, key], + "UPDATE node SET title = ?1, parent_key = ?2, sort = ?3, + icon = ?4, enabled = ?5 + WHERE key = ?6", + params![title, parent_key, final_sort, icon, enabled, key], ) .map_err(map_error)?; entries::audit(&tx, "node.update", key, &title)?; diff --git a/src/db/mod.rs b/src/db/mod.rs index d3722c4..ca4cbb1 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -10,7 +10,6 @@ pub mod auth; pub mod cards; -pub mod categories; mod catalog; pub mod entries; pub mod groups; @@ -34,15 +33,6 @@ pub use settings::{Settings, SettingsDocument}; /// 条目编辑(P2)。 pub use entries::{EntryPatch, EntryRecord}; -/// 一次查询得到的完整内容目录。 -/// -/// 侧栏已改为由这份分类树派生,不再单独下发导航数据。 -#[derive(Debug)] -pub struct Catalog { - /// 分类树,结构对应前端 `types.ts` 的 `Category[]` - pub categories: Vec, -} - /// 内容库句柄。 /// /// rusqlite 的连接是 `!Sync` 的同步对象,这里用一把互斥锁包起来: @@ -107,12 +97,6 @@ impl Db { }) } - /// 读出完整的内容目录,供 `/api/catalog` 直接序列化。 - pub fn load_catalog(&self) -> Result { - let conn = self.lock()?; - catalog::load(&conn) - } - /// SQLite 的 `data_version`:**其它连接**提交后该值会变,本连接自己的写入不变。 /// /// 正好用来发现「用户用 DB Browser 改了库」,SSE 靠轮询它来推送刷新。 @@ -217,41 +201,6 @@ impl Db { 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()?; 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`。 @@ -363,8 +307,8 @@ mod tests { .query_row("SELECT COUNT(*) FROM entry", [], |row| row.get(0)) .expect("应能统计条目"); let blocks: i64 = conn - .query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0)) - .expect("应能统计展示块"); + .query_row("SELECT COUNT(*) FROM card", [], |row| row.get(0)) + .expect("应能统计首页卡片"); let tags: i64 = conn .query_row("SELECT COUNT(*) FROM entry_tag", [], |row| row.get(0)) .expect("应能统计标签"); @@ -383,27 +327,26 @@ mod tests { assert_eq!(tags, 48, "标签与元信息共用 entry_tag"); // 正文是一篇一段 Markdown,只有 12 篇文章有 assert_eq!(articles_with_body, 12, "只有图文条目带正文"); - assert_eq!(blocks, 3, "每个分区一个首页展示块"); + assert_eq!(blocks, 3, "每个分区一张首页卡片"); } - /// 分区与分组是同一张表:分组必须挂在分区下,且不能有 view_mode。 + /// 分区与分组是同一张表:分组必须挂在分区下。 #[test] fn groups_are_nodes_under_their_section() { let conn = seeded(); - let (parent_key, view_mode): (String, Option) = conn + let parent_key: String = conn .query_row( - "SELECT p.key, g.view_mode + "SELECT p.key FROM node g JOIN node p ON p.key = g.parent_key WHERE g.kind = 'group' AND g.key = 'ui'", [], - |row| Ok((row.get(0)?, row.get(1)?)), + |row| row.get(0), ) .expect("应能查到分组及其父节点"); assert_eq!(parent_key, "recommend"); - assert!(view_mode.is_none(), "分组不应带 view_mode"); // 分区一定是顶层节点 let root_sections: i64 = conn @@ -419,92 +362,102 @@ mod tests { #[test] fn catalog_matches_the_frontend_model() { let conn = seeded(); - let catalog = catalog::load(&conn).expect("应能组装内容目录"); - let categories = &catalog.categories; + let tree = catalog::load_tree(&conn).expect("应能组装分组树"); + 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]; - assert_eq!(apps["id"], "recommend"); - assert_eq!(apps["kind"], "apps"); - assert_eq!(apps["barColor"], "#3858f6"); - assert_eq!(apps["showInSidebar"], true); - 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]; + let common = groups + .iter() + .find(|node| node["key"] == "common") + .expect("应有常用站点"); + let first_app = &common["entries"][0]; assert_eq!(first_app["name"], "GitHub"); + assert_eq!(first_app["format"], "apps"); assert_eq!(first_app["hot"], true); - // 计数从 JSON 提到了独立列,这里确认读取时又合并回来了 assert_eq!(first_app["views"], 429); - let posters = &categories[1]; - assert_eq!(posters["kind"], "posters"); - // 海报与图文用 title 而不是 name - assert_eq!( - posters["items"][0].get("name"), - None, + let posters = roots + .iter() + .find(|node| node["key"] == "movie") + .expect("应有推荐电影"); + let first_poster = &posters["entries"][0]; + assert_eq!(first_poster["format"], "posters"); + assert!( + first_poster.get("name").is_none(), "海报不应同时下发 name 字段" ); - assert!(posters["items"][0]["title"].is_string()); - // tags 来自 entry_tag 子表 + assert!(first_poster["title"].is_string()); assert_eq!( - posters["items"][0]["tags"] + first_poster["tags"] .as_array() .expect("海报应有标签") .len(), 2 ); - let articles = &categories[2]; - assert_eq!(articles["kind"], "articles"); - // 正文是一整段 Markdown,而不是块数组 - let body = articles["items"][0]["body"] - .as_str() - .expect("图文应有正文"); + let articles = roots + .iter() + .find(|node| node["key"] == "ces") + .expect("应有 CES 现场报道"); + let first_article = &articles["entries"][0]; + 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("\n\n"), "段落之间用空行分隔"); assert!( - articles["items"][0].get("blocks").is_none(), + first_article.get("blocks").is_none(), "不应再下发 blocks 数组" ); - // meta 与海报的 tags 共用 entry_tag 表 assert_eq!( - articles["items"][0]["meta"] + first_article["meta"] .as_array() .expect("图文应有元信息") .len(), 2 ); - // ---- 首页展示块 ---- - // 常用推荐卡片展示「常用站点」分组,最多 4 条 - assert_eq!(apps["block"]["nodeKey"], "common"); - assert_eq!(apps["block"]["previewLimit"], 4); - // 电影/文章没有分组,块直接指向分区自己 - assert_eq!(posters["block"]["nodeKey"], "movie"); - assert_eq!(articles["block"]["nodeKey"], "ces"); + // 卡片只记录勾选的分组;常用推荐卡片展示「常用站点」,最多 4 条 + let (group_key, preview_limit): (String, Option) = conn + .query_row( + "SELECT cg.group_key, cg.preview_limit + FROM card c JOIN card_group cg ON cg.card_id = c.id + WHERE c.key = 'card-recommend'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("应能查到常用推荐卡片"); + assert_eq!(group_key, "common"); + assert_eq!(preview_limit, Some(4)); } - /// 删除分区时,它的展示块一起消失(外键级联),不会留下指向空分区的卡片。 + /// 删除分组时,卡片里勾选它的记录一起消失(外键级联)。 #[test] - fn section_blocks_cascade_with_their_section() { + fn card_groups_cascade_with_their_group() { 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 - .query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0)) - .expect("应能统计展示块"); + .query_row( + "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() { let conn = seeded(); - for table in ["node", "entry", "section_block"] { + for table in ["node", "entry", "card"] { let pk: String = conn .query_row( &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'", []) .expect("应能改分区业务键"); - let catalog = catalog::load(&conn).expect("应能组装内容目录"); - let apps = catalog - .categories + let tree = catalog::load_tree(&conn).expect("应能组装分组树"); + let roots = tree.as_array().expect("根节点应为数组"); + let apps = roots .iter() - .find(|category| category["id"] == "recommend-v2") + .find(|node| node["key"] == "recommend-v2") .expect("应能找到改名后的分区"); - let total: usize = apps["groups"] - .as_array() - .expect("应有分组") - .iter() - .map(|group| group["items"].as_array().expect("应有条目").len()) - .sum(); + 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() + .map_or(0, |items| items.iter().map(count).sum()); + own + children + } - assert_eq!(total, 18, "改名后条目仍应归在这个分区下"); + assert_eq!(count(apps), 18, "改名后条目仍应归在这个分区下"); } /// 基础字段现在是真列,不再塞在 JSON 里:直接按列查询也应该拿得到。 @@ -654,7 +608,19 @@ mod tests { let fresh = Connection::open_in_memory().expect("内存库应能打开"); fresh .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 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;", diff --git a/src/db/schema.rs b/src/db/schema.rs index eab0a39..1fb42b0 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -27,9 +27,8 @@ const SEED_JSON: &str = include_str!("seed.json"); /// 种子会自动重新灌一份干净数据。 const SCHEMA_SQL: &str = r#" -- 内容节点:分区与分组共用一张自引用树 --- kind = 'section' → 顶层分区(首页一张卡片 / 明细页 hero),parent_code 必须为 NULL --- kind = 'group' → 分区下的分组,parent_code 指向所属分区 --- view_mode 只对分区有意义,决定首页与明细页怎么渲染 +-- kind = 'section' → 顶层分区(首页一张卡片 / 明细页 hero),parent_key 必须为 NULL +-- kind = 'group' → 分区下的分组,parent_key 指向分区或上层分组 CREATE TABLE IF NOT EXISTS node ( id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配,不用 AUTOINCREMENT) key TEXT NOT NULL UNIQUE, -- 业务键,全局唯一(如 recommend / ui) @@ -38,22 +37,15 @@ CREATE TABLE IF NOT EXISTS node ( title TEXT NOT NULL, -- 展示名:分区标题 / 分组小标题 kind TEXT NOT NULL CHECK (kind IN ('section', 'group')), -- 容器层级 - view_mode TEXT CHECK (view_mode IN ('apps', 'posters', 'articles')), - -- 分区渲染形态;分组为 NULL - bar_color TEXT, -- 分区标题左侧色条颜色(分组为 NULL) - icon TEXT, -- 分区 hero 字符图标(分组为 NULL) + bar_color TEXT, -- 标题左侧色条颜色 + icon TEXT, -- 字符图标 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)), -- 是否启用(禁用后前台侧栏/卡片不展示) sort INTEGER NOT NULL DEFAULT 0, -- 同级顺序,越小越靠前 CHECK ( - (kind = 'section' AND parent_key IS NULL AND view_mode IS NOT NULL) - OR (kind = 'group' AND parent_key IS NOT NULL AND view_mode IS NULL) + (kind = 'section' AND parent_key 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) ); --- 首页卡片:一行一个卡片,说明展示哪个节点 --- 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:登录与设置 ========== -- 管理员:单用户场景只允许一行 @@ -226,6 +206,41 @@ const V22_SQL: &str = r#" 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 回填。 const V21_SQL: &str = r#" ALTER TABLE entry ADD COLUMN format TEXT NOT NULL DEFAULT 'apps'; @@ -302,7 +317,7 @@ pub struct SeedReport { /// /// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来, /// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。 -pub const SCHEMA_VERSION: i64 = 22; +pub const SCHEMA_VERSION: i64 = 23; fn current_version(conn: &Connection) -> Result { conn.pragma_query_value(None, "user_version", |row| row.get(0)) @@ -372,6 +387,11 @@ pub fn migrate(conn: &Connection) -> Result { version = 22; } + if version == 22 { + conn.execute_batch(V23_SQL).map_err(map_error)?; + version = 23; + } + conn.pragma_update(None, "user_version", version) .map_err(map_error)?; @@ -416,18 +436,14 @@ pub fn seed(conn: &mut Connection) -> Result { } 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) - VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + "INSERT INTO node (key, parent_key, title, kind, bar_color, icon, preview_limit, sort) + VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, ?6)", params![ section.key, section.title, - view, section.bar_color, section.icon, section.preview_limit, - section.empty_text, - i64::from(section.show_in_sidebar), - i64::from(view == "apps"), section.sort, ], ) @@ -437,15 +453,9 @@ pub fn seed(conn: &mut Connection) -> Result { if view == "apps" { for group in §ion.groups { tx.execute( - "INSERT INTO node (key, parent_key, title, kind, show_in_sidebar, sort) - VALUES (?1, ?2, ?3, 'group', ?4, ?5)", - params![ - group.key, - section.key, - group.title, - i64::from(group.show_in_sidebar), - group.sort - ], + "INSERT INTO node (key, parent_key, title, kind, sort) + VALUES (?1, ?2, ?3, 'group', ?4)", + params![group.key, section.key, group.title, group.sort], ) .map_err(map_error)?; report.groups += 1; @@ -461,17 +471,26 @@ pub fn seed(conn: &mut Connection) -> Result { } } - // 首页该分区展示哪个节点:节点必须已经插入(分组或分区本身) + // 首页卡片:仓库里直接生成卡片与勾选的分组 tx.execute( - "INSERT INTO section_block (node_key, title, preview_limit, sort) - VALUES (?1, ?2, ?3, ?4)", + "INSERT INTO card (key, title, sort) VALUES (?1, ?2, ?3)", params![ - section.block.node_key, - section.block.title, - section.block.preview_limit, + format!("card-{}", section.key), + section + .block + .title + .clone() + .unwrap_or_else(|| section.title.clone()), 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| { StashError::Database(format!( "分区 {} 的展示节点 {} 无效: {error}", @@ -481,9 +500,6 @@ pub fn seed(conn: &mut Connection) -> Result { report.blocks += 1; } - // 老结构(分区 → 展示块)同步生成卡片,保证新首页立刻有内容 - tx.execute_batch(BACKFILL_CARDS_SQL).map_err(map_error)?; - tx.commit().map_err(map_error)?; Ok(report) @@ -565,11 +581,6 @@ struct SeedSection { sort: i64, #[serde(default)] preview_limit: Option, - #[serde(default)] - empty_text: Option, - /// 是否出现在侧栏;默认 true - #[serde(default = "default_true")] - show_in_sidebar: bool, /// 仅 apps 分区使用 #[serde(default)] groups: Vec, @@ -601,17 +612,9 @@ struct SeedGroup { key: String, title: String, sort: i64, - /// 是否出现在侧栏;默认 true - #[serde(default = "default_true")] - show_in_sidebar: bool, entries: Vec, } -/// serde 默认值:侧栏默认显示 -fn default_true() -> bool { - true -} - /// 一条内容:字段与 `entry` 表的列一一对应。 /// /// `tags` / `meta` 都写进 `entry_tag`(海报用 tags,图文用 meta); diff --git a/src/server.rs b/src/server.rs index 24a39f3..bd6bee9 100644 --- a/src/server.rs +++ b/src/server.rs @@ -206,31 +206,6 @@ async fn list_projects_api(State(state): State) -> impl IntoResponse { })) } -/// API: 完整内容目录(分区 / 分组 / 条目 + 首页展示块) -/// -/// 数据量很小(几十 KB),一次全量下发,前端搜索仍然在客户端做。 -/// 侧栏由这份数据派生,不再单独下发导航。 -async fn catalog_api(State(state): State) -> 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:内容库被外部修改时通知前端重新拉取。 /// /// 这里轮询 `PRAGMA data_version` 而不是监听数据库文件: @@ -964,172 +939,6 @@ async fn set_card_groups( } } -/// POST /api/categories —— 新建分区(需要登录) -async fn create_category( - State(state): State, - headers: HeaderMap, - Json(body): Json, -) -> 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, - Path(id): Path, - headers: HeaderMap, - Json(body): Json, -) -> 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, - Path(section_id): Path, - headers: HeaderMap, - Json(body): Json, -) -> 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(§ion_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, - Path(id): Path, - headers: HeaderMap, - Json(body): Json, -) -> 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, - Path(id): Path, - 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, - Path(id): Path, - 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 + 内嵌前端 + 托管目录。 /// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、 /// 起监听任务,没法在单元测试里用。 @@ -1148,7 +957,6 @@ fn build_router(state: AppState) -> Router { .route("/api/health", get(health_check)) .route("/api/projects", get(list_projects_api)) .route("/api/projects/events", get(project_events)) - .route("/api/catalog", get(catalog_api)) .route("/api/catalog2", get(catalog2_api)) .route("/api/nodes", post(create_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/{key}/view", post(post_view)) .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("/", get(web_index)) .route("/index.html", get(web_index)) diff --git a/stash-web/src/components/admin/AdminNext.tsx b/stash-web/src/components/admin/AdminNext.tsx index 7a925f0..50c4ccb 100644 --- a/stash-web/src/components/admin/AdminNext.tsx +++ b/stash-web/src/components/admin/AdminNext.tsx @@ -67,7 +67,7 @@ import type { Project } from '../../api' /** * 新版后台:原子模型。 * - * - 分组是唯一容器(可多层、带格式); + * - 分组是唯一容器(可多层); * - 内容是条目,挂在分组上; * - 侧边栏、首页卡片都只是「勾选分组」的展示组合。 */ @@ -575,7 +575,7 @@ function GroupsPanel({ return ( - 说明:分组用于控制前台侧边栏与卡片;可多层嵌套,根分组决定格式。内容(条目)只挂在分组上。 + 说明:分组用于控制前台侧边栏与卡片,可多层嵌套。内容(条目)只挂在分组上,格式由每条内容自己决定。
@@ -593,7 +593,7 @@ function GroupsPanel({ columns={[ { key: 'name', title: '分组名称' }, { key: 'sort', title: '排序权重', width: 110 }, - { key: 'count', title: '关联网页数', width: 120 }, + { key: 'count', title: '关联内容数', width: 120 }, { key: 'status', title: '状态', width: 90 }, { key: 'actions', title: '操作', width: 260, align: 'right' }, ]} diff --git a/stash-web/src/manage.ts b/stash-web/src/manage.ts index 03a0362..7b0ef01 100644 --- a/stash-web/src/manage.ts +++ b/stash-web/src/manage.ts @@ -19,7 +19,6 @@ export type GroupNode = { key: string parentKey?: string | null title: string - format: GroupFormat icon: string barColor: string previewLimit?: number | null @@ -189,13 +188,12 @@ export async function setCardGroups( export function flattenGroups( groups: GroupNode[], depth = 0, -): { key: string; title: string; depth: number; format: GroupFormat; enabled: boolean }[] { +): { key: string; title: string; depth: number; enabled: boolean }[] { return groups.flatMap((group) => [ { key: group.key, title: group.title, depth, - format: group.format, enabled: group.enabled !== false, }, ...flattenGroups(group.children, depth + 1),