feat(db): 内容库迁入 SQLite,分类与侧栏导航改为数据库驱动

This commit is contained in:
2026-10-23 20:22:23 +08:00
parent 386c0f77c7
commit 41fed5e64d
23 changed files with 2867 additions and 319 deletions
+321
View File
@@ -0,0 +1,321 @@
//! 内容库的查询组装:把关系表还原成前端 `types.ts` 里的 `Category[]`。
//!
//! 这里输出的是 `serde_json::Value` 而不是一堆 Rust DTO,理由是:
//! 条目的可变字段本来就存在 JSON 列里,直接用 Value 拼装可以保证
//! 「用户往 JSON 里加什么字段,前端就能读到什么字段」,不需要每加一个
//! 字段就改一次 Rust 结构体。
use rusqlite::Connection;
use serde_json::{Map, Value, json};
use crate::{
db::{Catalog, map_error},
error::StashError,
};
/// 分类行:与 `category` 表一一对应
struct CategoryRow {
id: String,
kind: String,
title: String,
bar_color: String,
icon: String,
preview_limit: Option<i64>,
empty_text: Option<String>,
}
/// 导航行:侧栏自引用树的原始形态
struct NavRow {
id: String,
parent_id: Option<String>,
label: String,
category_id: Option<String>,
group_id: Option<String>,
}
/// 条目行:`data` 里是尚未展开的 JSON 文本
struct EntryRow {
id: String,
group_id: Option<String>,
name: String,
views: i64,
likes: i64,
data: String,
}
/// 读出整个内容目录:分类树 + 侧栏导航。
pub fn load(conn: &Connection) -> Result<Catalog, StashError> {
Ok(Catalog {
categories: load_categories(conn)?,
nav: load_nav(conn)?,
})
}
/// 读出全部分类,按 `sort` 排序;顺序即首页分区顺序。
fn load_categories(conn: &Connection) -> Result<Vec<Value>, StashError> {
let mut stmt = conn
.prepare(
"SELECT id, kind, title, bar_color, icon, preview_limit, empty_text
FROM category
ORDER BY sort, id",
)
.map_err(map_error)?;
let categories = stmt
.query_map([], |row| {
Ok(CategoryRow {
id: row.get(0)?,
kind: row.get(1)?,
title: row.get(2)?,
bar_color: row.get(3)?,
icon: row.get(4)?,
preview_limit: row.get(5)?,
empty_text: row.get(6)?,
})
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
// 先释放语句再发后续查询,避免同一连接上的借用冲突
drop(stmt);
categories
.iter()
.map(|category| build_category(conn, category))
.collect()
}
/// 按 kind 分派到不同的组装方式。
fn build_category(conn: &Connection, category: &CategoryRow) -> Result<Value, StashError> {
let mut object = Map::new();
object.insert("id".into(), json!(category.id));
object.insert("kind".into(), json!(category.kind));
object.insert("title".into(), json!(category.title));
object.insert("barColor".into(), json!(category.bar_color));
object.insert("icon".into(), json!(category.icon));
// 可选字段:没配就不下发,交给前端用 `?? 默认值` 处理
if let Some(limit) = category.preview_limit {
object.insert("previewLimit".into(), json!(limit));
}
if let Some(empty_text) = &category.empty_text {
object.insert("emptyText".into(), json!(empty_text));
}
match category.kind.as_str() {
"apps" => object.insert("groups".into(), build_groups(conn, category)?),
"posters" | "articles" => object.insert("items".into(), build_items(conn, category)?),
other => {
return Err(StashError::Database(format!(
"分类 {} 的 kind 非法: {other}",
category.id
)));
}
};
Ok(Value::Object(object))
}
/// 应用类:先取出分组,再把条目按 `group_id` 分派进去。
///
/// 只用两条查询(分组一条、条目一条)而不是每组一条,避免 N+1。
fn build_groups(conn: &Connection, category: &CategoryRow) -> Result<Value, StashError> {
let mut stmt = conn
.prepare("SELECT id, name FROM app_group WHERE category_id = ?1 ORDER BY sort, id")
.map_err(map_error)?;
let groups = stmt
.query_map([&category.id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
drop(stmt);
let entries = load_entries(conn, &category.id)?;
let mut rendered_groups = Vec::with_capacity(groups.len());
for (group_id, group_name) in groups {
let mut items = Vec::new();
// 保持 entry.sort 决定的顺序;group_id 为空的条目不属于任何分组
for entry in entries.iter().filter(|entry| {
entry.group_id.as_deref() == Some(group_id.as_str())
}) {
items.push(Value::Object(entry_object(entry, &category.id, "name")?));
}
rendered_groups.push(json!({
"id": group_id,
"name": group_name,
"items": items,
}));
}
Ok(Value::Array(rendered_groups))
}
/// 海报 / 图文类:没有分组,直接输出条目数组。
fn build_items(conn: &Connection, category: &CategoryRow) -> Result<Value, StashError> {
let entries = load_entries(conn, &category.id)?;
let items = entries
.iter()
.map(|entry| entry_object(entry, &category.id, "title").map(Value::Object))
.collect::<Result<Vec<_>, _>>()?;
Ok(Value::Array(items))
}
/// 取某个分类下的全部条目,按 `sort` 升序。
fn load_entries(conn: &Connection, category_id: &str) -> Result<Vec<EntryRow>, StashError> {
let mut stmt = conn
.prepare(
"SELECT id, group_id, name, views, likes, data
FROM entry
WHERE category_id = ?1
ORDER BY sort, id",
)
.map_err(map_error)?;
let entries = stmt
.query_map([category_id], |row| {
Ok(EntryRow {
id: row.get(0)?,
group_id: row.get(1)?,
name: row.get(2)?,
views: row.get(3)?,
likes: row.get(4)?,
data: row.get(5)?,
})
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
Ok(entries)
}
/// 把一条条目展开成对象。
///
/// `name_key` 决定展示名落在哪个字段:应用用 `name`,海报与图文用 `title`。
/// 数据库列优先于 JSON,用户即便在 `data` 里写了同名键也不会顶掉真实值。
fn entry_object(
entry: &EntryRow,
category_id: &str,
name_key: &str,
) -> Result<Map<String, Value>, StashError> {
let parsed: Value = serde_json::from_str(&entry.data).map_err(|error| {
// 把位置写进报错里,用户改坏 JSON 时能立刻定位到是哪一行
StashError::Database(format!(
"条目 {category_id}/{} 的 data 列不是合法 JSON: {error}",
entry.id
))
})?;
let Value::Object(mut object) = parsed else {
return Err(StashError::Database(format!(
"条目 {category_id}/{} 的 data 列必须是 JSON 对象",
entry.id
)));
};
object.insert("id".into(), json!(entry.id));
object.insert(name_key.into(), json!(entry.name));
object.insert("views".into(), json!(entry.views));
object.insert("likes".into(), json!(entry.likes));
Ok(object)
}
// ---------------------------------------------------------------------------
// 侧栏导航
// ---------------------------------------------------------------------------
/// 读出侧栏导航,组装成 `{ topLevel, groups }`,结构与 frontend 的 `nav.ts` 一致。
///
/// 侧栏只有两层(顶层项 / 分组内的叶子),所以不需要通用递归:
/// 顶层里带 `category_id` 的是直达链接,不带的是可折叠分组,其子节点即叶子。
fn load_nav(conn: &Connection) -> Result<Value, StashError> {
let mut stmt = conn
.prepare(
"SELECT id, parent_id, label, category_id, group_id
FROM nav_entry
ORDER BY sort, id",
)
.map_err(map_error)?;
let rows = stmt
.query_map([], |row| {
Ok(NavRow {
id: row.get(0)?,
parent_id: row.get(1)?,
label: row.get(2)?,
category_id: row.get(3)?,
group_id: row.get(4)?,
})
})
.map_err(map_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(map_error)?;
drop(stmt);
let mut top_level = Vec::new();
let mut groups = Vec::new();
for row in &rows {
// 有 parent_id 的都是分组内的叶子,在这里由分组统一收集
if row.parent_id.is_some() {
continue;
}
if row.category_id.is_some() {
// 顶层直达项
top_level.push(nav_leaf(row));
continue;
}
// 可折叠分组:收集它的子节点
let leaves = rows
.iter()
.filter(|child| child.parent_id.as_deref() == Some(row.id.as_str()))
.filter(|child| child.category_id.is_some())
.map(nav_leaf)
.collect::<Vec<_>>();
groups.push(json!({
"id": row.id,
"label": row.label,
"leaves": leaves,
}));
}
Ok(json!({
"topLevel": top_level,
"groups": groups,
}))
}
/// 把一个导航叶子转成前端结构;`groupId` 为空时不下发该键。
fn nav_leaf(row: &NavRow) -> Value {
let mut target = Map::new();
target.insert(
"categoryId".into(),
json!(row.category_id.clone().unwrap_or_default()),
);
if let Some(group_id) = &row.group_id {
target.insert("groupId".into(), json!(group_id));
}
json!({
"id": row.id,
"label": row.label,
"target": Value::Object(target),
})
}