feat(web): 地址栏驱动视图,刷新详情页不再回首页
- 新增 data/view.ts 与 useViewRoute:视图与地址双向同步,支持前进后退 - 条目地址带上所属分组,刷新 / 收藏后「返回」有确定落点 - 服务端只把 /c/** 回落到 index.html,其余路径仍返回 404 - 明细页返回按钮去掉左侧箭头图标
This commit is contained in:
+288
-195
@@ -1,9 +1,11 @@
|
||||
//! 内容库的查询组装:把关系表还原成前端 `types.ts` 里的 `Category[]`。
|
||||
//! 内容库的查询组装:把统一的 node 树还原成前端 `types.ts` 想要的三种形态。
|
||||
//!
|
||||
//! 这里输出的是 `serde_json::Value` 而不是一堆 Rust DTO,理由是:
|
||||
//! 条目的可变字段本来就存在 JSON 列里,直接用 Value 拼装可以保证
|
||||
//! 「用户往 JSON 里加什么字段,前端就能读到什么字段」,不需要每加一个
|
||||
//! 字段就改一次 Rust 结构体。
|
||||
//! 库里只有一套容器树(分区 → 分组),前端需要 `apps / posters / articles`
|
||||
//! 的可辨识联合,那是**查询层按 `node.view_mode` 组装**的事。
|
||||
//!
|
||||
//! 所有关联都走业务键(`key`),SQL 里能直接看懂指向的是哪个分区 / 分组。
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use serde_json::{Map, Value, json};
|
||||
@@ -13,64 +15,66 @@ use crate::{
|
||||
error::StashError,
|
||||
};
|
||||
|
||||
/// 分类行:与 `category` 表一一对应
|
||||
struct CategoryRow {
|
||||
id: String,
|
||||
kind: String,
|
||||
/// 容器节点行:分区与分组共用
|
||||
struct NodeRow {
|
||||
key: String,
|
||||
title: String,
|
||||
bar_color: String,
|
||||
icon: String,
|
||||
view_mode: Option<String>,
|
||||
bar_color: Option<String>,
|
||||
icon: Option<String>,
|
||||
preview_limit: Option<i64>,
|
||||
empty_text: Option<String>,
|
||||
show_in_sidebar: bool,
|
||||
}
|
||||
|
||||
/// 导航行:侧栏自引用树的原始形态
|
||||
struct NavRow {
|
||||
id: String,
|
||||
parent_id: Option<String>,
|
||||
label: String,
|
||||
category_id: Option<String>,
|
||||
group_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 条目行:`data` 里是尚未展开的 JSON 文本
|
||||
/// 条目行:与 `entry` 表的列一一对应
|
||||
struct EntryRow {
|
||||
id: String,
|
||||
group_id: Option<String>,
|
||||
key: String,
|
||||
node_key: String,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
icon: Option<String>,
|
||||
href: Option<String>,
|
||||
cover: Option<String>,
|
||||
banner: Option<String>,
|
||||
badge: Option<String>,
|
||||
comment: Option<String>,
|
||||
summary: Option<String>,
|
||||
body: Option<String>,
|
||||
hot: bool,
|
||||
views: i64,
|
||||
likes: i64,
|
||||
data: String,
|
||||
}
|
||||
|
||||
/// 读出整个内容目录:分类树 + 侧栏导航。
|
||||
/// 读出整个内容目录。
|
||||
pub fn load(conn: &Connection) -> Result<Catalog, StashError> {
|
||||
Ok(Catalog {
|
||||
categories: load_categories(conn)?,
|
||||
nav: load_nav(conn)?,
|
||||
categories: load_sections(conn)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// 读出全部分类,按 `sort` 排序;顺序即首页分区顺序。
|
||||
fn load_categories(conn: &Connection) -> Result<Vec<Value>, StashError> {
|
||||
/// 读出全部分区,按 `sort` 排序;顺序即首页分区顺序。
|
||||
fn load_sections(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",
|
||||
"SELECT key, title, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar
|
||||
FROM node
|
||||
WHERE kind = 'section'
|
||||
ORDER BY sort, key",
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
let categories = stmt
|
||||
let sections = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(CategoryRow {
|
||||
id: row.get(0)?,
|
||||
kind: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
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)?,
|
||||
})
|
||||
})
|
||||
.map_err(map_error)?
|
||||
@@ -80,36 +84,102 @@ fn load_categories(conn: &Connection) -> Result<Vec<Value>, StashError> {
|
||||
// 先释放语句再发后续查询,避免同一连接上的借用冲突
|
||||
drop(stmt);
|
||||
|
||||
categories
|
||||
// 每个分区对应的首页卡片:展示哪个节点、几条
|
||||
let blocks = load_blocks(conn)?;
|
||||
|
||||
sections
|
||||
.iter()
|
||||
.map(|category| build_category(conn, category))
|
||||
.map(|section| build_section(conn, section, blocks.get(§ion.key)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 按 kind 分派到不同的组装方式。
|
||||
fn build_category(conn: &Connection, category: &CategoryRow) -> Result<Value, StashError> {
|
||||
/// 读出所有卡片配置,按推导出的分区业务键索引。
|
||||
///
|
||||
/// 分区不单独存:节点是分组就取它的 `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!(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));
|
||||
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));
|
||||
|
||||
// 可选字段:没配就不下发,交给前端用 `?? 默认值` 处理
|
||||
if let Some(limit) = category.preview_limit {
|
||||
if let Some(limit) = section.preview_limit {
|
||||
object.insert("previewLimit".into(), json!(limit));
|
||||
}
|
||||
if let Some(empty_text) = &category.empty_text {
|
||||
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 category.kind.as_str() {
|
||||
"apps" => object.insert("groups".into(), build_groups(conn, category)?),
|
||||
"posters" | "articles" => object.insert("items".into(), build_items(conn, category)?),
|
||||
match view_mode {
|
||||
"apps" => object.insert("groups".into(), build_groups(conn, section)?),
|
||||
"posters" => object.insert("items".into(), build_poster_items(conn, section)?),
|
||||
"articles" => object.insert("items".into(), build_article_items(conn, section)?),
|
||||
other => {
|
||||
return Err(StashError::Database(format!(
|
||||
"分类 {} 的 kind 非法: {other}",
|
||||
category.id
|
||||
"分区 {} 的 view_mode 非法: {other}",
|
||||
section.key
|
||||
)));
|
||||
}
|
||||
};
|
||||
@@ -117,17 +187,23 @@ fn build_category(conn: &Connection, category: &CategoryRow) -> Result<Value, St
|
||||
Ok(Value::Object(object))
|
||||
}
|
||||
|
||||
/// 应用类:先取出分组,再把条目按 `group_id` 分派进去。
|
||||
///
|
||||
/// 只用两条查询(分组一条、条目一条)而不是每组一条,避免 N+1。
|
||||
fn build_groups(conn: &Connection, category: &CategoryRow) -> Result<Value, StashError> {
|
||||
/// 应用类:取出分组节点,再把条目按 `node_key` 分派进去。
|
||||
fn build_groups(conn: &Connection, section: &NodeRow) -> Result<Value, StashError> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, name FROM app_group WHERE category_id = ?1 ORDER BY sort, id")
|
||||
.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([&category.id], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
.query_map([§ion.key], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, bool>(2)?,
|
||||
))
|
||||
})
|
||||
.map_err(map_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
@@ -135,23 +211,22 @@ fn build_groups(conn: &Connection, category: &CategoryRow) -> Result<Value, Stas
|
||||
|
||||
drop(stmt);
|
||||
|
||||
let entries = load_entries(conn, &category.id)?;
|
||||
|
||||
let entries = load_entries(conn, §ion.key)?;
|
||||
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")?));
|
||||
}
|
||||
for (group_key, group_title, show_in_sidebar) in groups {
|
||||
let items = entries
|
||||
.iter()
|
||||
// 保持 entry.sort 决定的顺序
|
||||
.filter(|entry| entry.node_key == group_key)
|
||||
.map(app_item)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
rendered_groups.push(json!({
|
||||
"id": group_id,
|
||||
"name": group_name,
|
||||
// 对外用业务键,不用数字主键
|
||||
"id": group_key,
|
||||
"name": group_title,
|
||||
"showInSidebar": show_in_sidebar,
|
||||
"items": items,
|
||||
}));
|
||||
}
|
||||
@@ -159,38 +234,147 @@ fn build_groups(conn: &Connection, category: &CategoryRow) -> Result<Value, Stas
|
||||
Ok(Value::Array(rendered_groups))
|
||||
}
|
||||
|
||||
/// 海报 / 图文类:没有分组,直接输出条目数组。
|
||||
fn build_items(conn: &Connection, category: &CategoryRow) -> Result<Value, StashError> {
|
||||
let entries = load_entries(conn, &category.id)?;
|
||||
/// 海报类:竖版卡片要 `title` / `cover` / `tags`。
|
||||
fn build_poster_items(conn: &Connection, section: &NodeRow) -> Result<Value, StashError> {
|
||||
let entries = load_entries(conn, §ion.key)?;
|
||||
let tags = load_tags(conn, §ion.key)?;
|
||||
|
||||
let items = entries
|
||||
.iter()
|
||||
.map(|entry| entry_object(entry, &category.id, "title").map(Value::Object))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
.map(|entry| poster_item(entry, tags.get(&entry.key)))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(Value::Array(items))
|
||||
}
|
||||
|
||||
/// 取某个分类下的全部条目,按 `sort` 升序。
|
||||
fn load_entries(conn: &Connection, category_id: &str) -> Result<Vec<EntryRow>, StashError> {
|
||||
/// 图文类:需要 `title` / `desc` / `cover` / `meta` / `body`。
|
||||
fn build_article_items(conn: &Connection, section: &NodeRow) -> Result<Value, StashError> {
|
||||
let entries = load_entries(conn, §ion.key)?;
|
||||
let tags = load_tags(conn, §ion.key)?;
|
||||
|
||||
let items = entries
|
||||
.iter()
|
||||
.map(|entry| article_item(entry, tags.get(&entry.key)))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(Value::Array(items))
|
||||
}
|
||||
|
||||
/// 应用条目:`name` / `desc` / `icon` / `href` 是必填,其余按需下发。
|
||||
fn app_item(entry: &EntryRow) -> Value {
|
||||
let mut object = Map::new();
|
||||
object.insert("id".into(), json!(entry.key));
|
||||
object.insert("name".into(), json!(entry.name));
|
||||
// 列表卡片必须有副标题,空字符串比缺字段安全
|
||||
object.insert(
|
||||
"desc".into(),
|
||||
json!(entry.description.clone().unwrap_or_default()),
|
||||
);
|
||||
object.insert("icon".into(), json!(entry.icon.clone().unwrap_or_default()));
|
||||
object.insert(
|
||||
"href".into(),
|
||||
json!(entry.href.clone().unwrap_or_else(|| "#".to_string())),
|
||||
);
|
||||
|
||||
insert_optional(&mut object, "cover", &entry.cover);
|
||||
insert_optional(&mut object, "banner", &entry.banner);
|
||||
insert_optional(&mut object, "badge", &entry.badge);
|
||||
insert_optional(&mut object, "comment", &entry.comment);
|
||||
|
||||
// hot 只在为真时下发,前端判 `item.hot === true`
|
||||
if entry.hot {
|
||||
object.insert("hot".into(), json!(true));
|
||||
}
|
||||
|
||||
object.insert("views".into(), json!(entry.views));
|
||||
object.insert("likes".into(), json!(entry.likes));
|
||||
|
||||
Value::Object(object)
|
||||
}
|
||||
|
||||
/// 海报条目:前端用 `title` 而不是 `name`。
|
||||
fn poster_item(entry: &EntryRow, tags: Option<&Vec<String>>) -> Value {
|
||||
let mut object = Map::new();
|
||||
object.insert("id".into(), json!(entry.key));
|
||||
object.insert("title".into(), json!(entry.name));
|
||||
object.insert("cover".into(), json!(entry.cover.clone().unwrap_or_default()));
|
||||
|
||||
if let Some(tags) = tags.filter(|tags| !tags.is_empty()) {
|
||||
object.insert("tags".into(), json!(tags));
|
||||
}
|
||||
|
||||
insert_optional(&mut object, "comment", &entry.comment);
|
||||
insert_optional(&mut object, "summary", &entry.summary);
|
||||
insert_optional(&mut object, "href", &entry.href);
|
||||
|
||||
object.insert("views".into(), json!(entry.views));
|
||||
object.insert("likes".into(), json!(entry.likes));
|
||||
|
||||
Value::Object(object)
|
||||
}
|
||||
|
||||
/// 图文条目:`meta` 来自 entry_tag,`body` 是 Markdown 正文。
|
||||
fn article_item(entry: &EntryRow, meta: Option<&Vec<String>>) -> Value {
|
||||
let mut object = Map::new();
|
||||
object.insert("id".into(), json!(entry.key));
|
||||
object.insert("title".into(), json!(entry.name));
|
||||
object.insert(
|
||||
"desc".into(),
|
||||
json!(entry.description.clone().unwrap_or_default()),
|
||||
);
|
||||
object.insert("cover".into(), json!(entry.cover.clone().unwrap_or_default()));
|
||||
|
||||
if let Some(meta) = meta.filter(|meta| !meta.is_empty()) {
|
||||
object.insert("meta".into(), json!(meta));
|
||||
}
|
||||
if let Some(body) = entry.body.as_ref().filter(|body| !body.trim().is_empty()) {
|
||||
object.insert("body".into(), json!(body));
|
||||
}
|
||||
|
||||
Value::Object(object)
|
||||
}
|
||||
|
||||
/// 可选字段:`None` 就不下发,前端用 `??` 兜底。
|
||||
fn insert_optional(object: &mut Map<String, Value>, key: &str, value: &Option<String>) {
|
||||
if let Some(value) = value {
|
||||
object.insert(key.into(), json!(value));
|
||||
}
|
||||
}
|
||||
|
||||
/// 取某个分区下的全部条目,按 `sort` 升序。
|
||||
///
|
||||
/// 条目只存 `node_key`,所属分区由父节点推导:
|
||||
/// 父节点是分组就取它的 `parent_key`,父节点是分区就取它自己。
|
||||
fn load_entries(conn: &Connection, section_key: &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",
|
||||
"SELECT e.key, e.node_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
|
||||
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([category_id], |row| {
|
||||
.query_map([section_key], |row| {
|
||||
Ok(EntryRow {
|
||||
id: row.get(0)?,
|
||||
group_id: row.get(1)?,
|
||||
key: row.get(0)?,
|
||||
node_key: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
views: row.get(3)?,
|
||||
likes: row.get(4)?,
|
||||
data: row.get(5)?,
|
||||
description: row.get(3)?,
|
||||
icon: row.get(4)?,
|
||||
href: row.get(5)?,
|
||||
cover: row.get(6)?,
|
||||
banner: row.get(7)?,
|
||||
badge: row.get(8)?,
|
||||
comment: row.get(9)?,
|
||||
summary: row.get(10)?,
|
||||
body: row.get(11)?,
|
||||
hot: row.get(12)?,
|
||||
views: row.get(13)?,
|
||||
likes: row.get(14)?,
|
||||
})
|
||||
})
|
||||
.map_err(map_error)?
|
||||
@@ -200,122 +384,31 @@ fn load_entries(conn: &Connection, category_id: &str) -> Result<Vec<EntryRow>, S
|
||||
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> {
|
||||
/// 取某个分区下所有条目的标签,按条目业务键聚合;一次查询避免 N+1。
|
||||
fn load_tags(conn: &Connection, section_key: &str) -> Result<HashMap<String, Vec<String>>, StashError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, parent_id, label, category_id, group_id
|
||||
FROM nav_entry
|
||||
ORDER BY sort, id",
|
||||
"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([], |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)?,
|
||||
})
|
||||
.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)?;
|
||||
|
||||
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,
|
||||
}));
|
||||
let mut grouped: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for (entry_key, label) in rows {
|
||||
grouped.entry(entry_key).or_default().push(label);
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
Ok(grouped)
|
||||
}
|
||||
|
||||
+296
-79
@@ -21,15 +21,13 @@ use crate::{config::Config, error::StashError};
|
||||
|
||||
pub use schema::SeedReport;
|
||||
|
||||
/// 一次查询得到的完整内容目录:分类树 + 侧栏导航。
|
||||
/// 一次查询得到的完整内容目录。
|
||||
///
|
||||
/// 两者总是一起下发:首页与侧栏同时需要,拆成两个接口只会多一次往返。
|
||||
/// 侧栏已改为由这份分类树派生,不再单独下发导航数据。
|
||||
#[derive(Debug)]
|
||||
pub struct Catalog {
|
||||
/// 分类树,结构对应前端 `types.ts` 的 `Category[]`
|
||||
pub categories: Vec<Value>,
|
||||
/// 侧栏导航,结构对应前端 `nav.ts` 的 `{ topLevel, groups }`
|
||||
pub nav: Value,
|
||||
}
|
||||
|
||||
/// 内容库句柄。
|
||||
@@ -59,41 +57,36 @@ impl Db {
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
// 先校验结构版本,再动任何东西:旧库直接报错,避免用旧表跑新查询
|
||||
let version = schema::check_version(&conn)?;
|
||||
|
||||
schema::apply(&conn)?;
|
||||
|
||||
// 结构版本:开发期只写不校验,等结构稳定后再拿它做迁移判断
|
||||
conn.pragma_update(None, "user_version", 1)
|
||||
// 写入当前结构版本,供下次启动比对
|
||||
conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION)
|
||||
.map_err(map_error)?;
|
||||
|
||||
// 空库才灌种子:用户删掉内置条目后重启不会被重新塞回来
|
||||
let existing: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM category", [], |row| row.get(0))
|
||||
.query_row("SELECT COUNT(*) FROM node", [], |row| row.get(0))
|
||||
.map_err(map_error)?;
|
||||
|
||||
if existing == 0 {
|
||||
// 全新库是常态;版本号为 0 但库里已经有数据属于异常(比如手工清表),给条提示
|
||||
if version != 0 {
|
||||
warn!("结构版本为 {version} 但节点表为空,将按空库重新导入种子");
|
||||
}
|
||||
|
||||
let report = schema::seed(&mut conn)?;
|
||||
info!(
|
||||
"内容库为空,已导入内嵌种子:{} 分类 / {} 分组 / {} 条目 / {} 导航节点",
|
||||
report.categories, report.groups, report.entries, report.nav_items
|
||||
"内容库为空,已导入内嵌种子:{} 分区 / {} 分组 / {} 条目 / {} 标签 / {} 展示块",
|
||||
report.sections, report.groups, report.entries, report.tags, report.blocks
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"内容库已存在,沿用现有数据:{}",
|
||||
crate::config::display_path(&config.db_path)
|
||||
);
|
||||
|
||||
// 开发阶段新增过表(比如侧栏导航),旧库不会被补数据。
|
||||
// 这里给一条明确提示,避免用户对着空侧栏猜原因。
|
||||
let nav_nodes: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM nav_entry", [], |row| row.get(0))
|
||||
.map_err(map_error)?;
|
||||
|
||||
if nav_nodes == 0 {
|
||||
warn!(
|
||||
"内容库有分类但侧栏导航为空:如果这是旧版本留下的库,删掉 {} 后重启即可重新导入种子",
|
||||
crate::config::display_path(&config.db_path)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
@@ -101,7 +94,7 @@ impl Db {
|
||||
})
|
||||
}
|
||||
|
||||
/// 读出完整的分类树与侧栏导航,供 `/api/catalog` 直接序列化。
|
||||
/// 读出完整的内容目录,供 `/api/catalog` 直接序列化。
|
||||
pub fn load_catalog(&self) -> Result<Catalog, StashError> {
|
||||
let conn = self.lock()?;
|
||||
catalog::load(&conn)
|
||||
@@ -129,6 +122,18 @@ pub(crate) fn map_error(error: rusqlite::Error) -> StashError {
|
||||
StashError::Database(error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Db {
|
||||
/// 测试用:包一个空的内存库。
|
||||
///
|
||||
/// 路由测试只关心静态资源与回落规则,不查内容,所以既不建表也不灌种子。
|
||||
pub(crate) fn in_memory() -> Self {
|
||||
Self {
|
||||
conn: Mutex::new(Connection::open_in_memory().expect("内存库应能打开")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -144,26 +149,78 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_fills_every_category_group_and_entry() {
|
||||
fn seed_fills_every_section_group_and_entry() {
|
||||
let conn = seeded();
|
||||
|
||||
let categories: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM category", [], |row| row.get(0))
|
||||
.expect("应能统计分类");
|
||||
let sections: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM node WHERE kind = 'section'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("应能统计分区");
|
||||
let groups: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM app_group", [], |row| row.get(0))
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM node WHERE kind = 'group'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("应能统计分组");
|
||||
let entries: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM entry", [], |row| row.get(0))
|
||||
.expect("应能统计条目");
|
||||
let nav_items: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM nav_entry", [], |row| row.get(0))
|
||||
.expect("应能统计导航节点");
|
||||
let blocks: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0))
|
||||
.expect("应能统计展示块");
|
||||
let tags: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM entry_tag", [], |row| row.get(0))
|
||||
.expect("应能统计标签");
|
||||
let articles_with_body: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM entry WHERE body IS NOT NULL",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("应能统计带正文的条目");
|
||||
|
||||
assert_eq!(categories, 3, "种子应含 常用推荐 / 推荐电影 / CES 三个分类");
|
||||
assert_eq!(sections, 3, "种子应含 常用推荐 / 推荐电影 / CES 三个分区");
|
||||
assert_eq!(groups, 5, "常用推荐应有 5 个分组");
|
||||
assert_eq!(entries, 42, "18 应用 + 12 电影 + 12 图文");
|
||||
assert_eq!(nav_items, 8, "1 个顶层直达 + 2 个折叠分组 + 5 个叶子");
|
||||
// 12 部电影各 2 个 tags,12 篇图文各 2 个 meta
|
||||
assert_eq!(tags, 48, "标签与元信息共用 entry_tag");
|
||||
// 正文是一篇一段 Markdown,只有 12 篇文章有
|
||||
assert_eq!(articles_with_body, 12, "只有图文条目带正文");
|
||||
assert_eq!(blocks, 3, "每个分区一个首页展示块");
|
||||
}
|
||||
|
||||
/// 分区与分组是同一张表:分组必须挂在分区下,且不能有 view_mode。
|
||||
#[test]
|
||||
fn groups_are_nodes_under_their_section() {
|
||||
let conn = seeded();
|
||||
|
||||
let (parent_key, view_mode): (String, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT p.key, g.view_mode
|
||||
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)?)),
|
||||
)
|
||||
.expect("应能查到分组及其父节点");
|
||||
|
||||
assert_eq!(parent_key, "recommend");
|
||||
assert!(view_mode.is_none(), "分组不应带 view_mode");
|
||||
|
||||
// 分区一定是顶层节点
|
||||
let root_sections: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM node WHERE kind = 'section' AND parent_key IS NULL",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("应能统计数据");
|
||||
assert_eq!(root_sections, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -178,8 +235,16 @@ mod tests {
|
||||
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];
|
||||
assert_eq!(first_app["name"], "GitHub");
|
||||
assert_eq!(first_app["hot"], true);
|
||||
@@ -195,63 +260,182 @@ mod tests {
|
||||
"海报不应同时下发 name 字段"
|
||||
);
|
||||
assert!(posters["items"][0]["title"].is_string());
|
||||
// tags 来自 entry_tag 子表
|
||||
assert_eq!(
|
||||
posters["items"][0]["tags"]
|
||||
.as_array()
|
||||
.expect("海报应有标签")
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
|
||||
let articles = &categories[2];
|
||||
assert_eq!(articles["kind"], "articles");
|
||||
assert!(articles["items"][0]["blocks"].is_array(), "图文正文应是块数组");
|
||||
|
||||
// ---- 侧栏导航 ----
|
||||
let nav = &catalog.nav;
|
||||
let top_level = nav["topLevel"].as_array().expect("应有顶层项");
|
||||
assert_eq!(top_level.len(), 1);
|
||||
assert_eq!(top_level[0]["label"], "常用推荐");
|
||||
assert_eq!(top_level[0]["target"]["categoryId"], "recommend");
|
||||
// 正文是一整段 Markdown,而不是块数组
|
||||
let body = articles["items"][0]["body"]
|
||||
.as_str()
|
||||
.expect("图文应有正文");
|
||||
assert!(body.contains("## 设计细节"), "正文应含小标题: {body}");
|
||||
assert!(body.contains("!["), "正文应含配图语法: {body}");
|
||||
assert!(body.contains("\n\n"), "段落之间用空行分隔");
|
||||
assert!(
|
||||
top_level[0]["target"].get("groupId").is_none(),
|
||||
"整分类直达时不应下发 groupId"
|
||||
articles["items"][0].get("blocks").is_none(),
|
||||
"不应再下发 blocks 数组"
|
||||
);
|
||||
|
||||
let nav_groups = nav["groups"].as_array().expect("应有折叠分组");
|
||||
assert_eq!(nav_groups.len(), 2);
|
||||
assert_eq!(nav_groups[0]["label"], "素材资源");
|
||||
assert_eq!(nav_groups[0]["leaves"].as_array().expect("应有叶子").len(), 3);
|
||||
assert_eq!(nav_groups[1]["label"], "开发工具");
|
||||
assert_eq!(nav_groups[1]["leaves"][0]["label"], "前端调试工具");
|
||||
assert_eq!(nav_groups[1]["leaves"][0]["target"]["groupId"], "dev");
|
||||
}
|
||||
|
||||
/// 删除分类时,指向它的导航叶子必须一起消失(外键级联),
|
||||
/// 否则侧栏会留下点了没反应的死链接。
|
||||
#[test]
|
||||
fn nav_leaves_cascade_when_their_category_is_removed() {
|
||||
let conn = seeded();
|
||||
conn.execute("DELETE FROM category WHERE id = 'recommend'", [])
|
||||
.expect("应能删除分类");
|
||||
|
||||
let catalog = catalog::load(&conn).expect("应能组装内容目录");
|
||||
|
||||
assert!(
|
||||
catalog.nav["topLevel"]
|
||||
// meta 与海报的 tags 共用 entry_tag 表
|
||||
assert_eq!(
|
||||
articles["items"][0]["meta"]
|
||||
.as_array()
|
||||
.expect("应有顶层数组")
|
||||
.is_empty(),
|
||||
"指向已删分类的顶层链接应被级联删除"
|
||||
.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");
|
||||
}
|
||||
|
||||
/// 删除分区时,它的展示块一起消失(外键级联),不会留下指向空分区的卡片。
|
||||
#[test]
|
||||
fn broken_json_names_the_offending_entry() {
|
||||
fn section_blocks_cascade_with_their_section() {
|
||||
let conn = seeded();
|
||||
conn.execute("DELETE FROM node WHERE key = 'recommend' AND kind = 'section'", [])
|
||||
.expect("应能删除分区");
|
||||
|
||||
let remaining: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM section_block", [], |row| row.get(0))
|
||||
.expect("应能统计展示块");
|
||||
|
||||
assert_eq!(remaining, 2, "三个展示块应随分区删除只剩两个");
|
||||
}
|
||||
|
||||
/// 删除分组节点时,组内条目也要一起删掉。
|
||||
#[test]
|
||||
fn entries_cascade_when_their_group_is_removed() {
|
||||
let conn = seeded();
|
||||
|
||||
let before: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM entry", [], |row| row.get(0))
|
||||
.expect("应能统计条目");
|
||||
|
||||
conn.execute(
|
||||
"UPDATE entry SET data = '{oops' WHERE category_id = 'recommend' AND id = 'github'",
|
||||
"DELETE FROM node WHERE kind = 'group' AND key = 'ui' AND parent_key = 'recommend'",
|
||||
[],
|
||||
)
|
||||
.expect("应能写入坏数据");
|
||||
.expect("应能删除分组");
|
||||
|
||||
let error = catalog::load(&conn).expect_err("坏 JSON 应报错");
|
||||
let message = error.to_string();
|
||||
let after: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM entry", [], |row| row.get(0))
|
||||
.expect("应能统计条目");
|
||||
|
||||
assert!(message.contains("recommend/github"), "报错要能定位到具体条目: {message}");
|
||||
assert_eq!(before - after, 5, "ui 分组原有 5 个应用,应被级联删除");
|
||||
}
|
||||
|
||||
/// 主键是 `id`(SQLite rowid),`key` 是唯一业务键;外键仍指向 key。
|
||||
#[test]
|
||||
fn primary_key_is_id_and_key_is_unique() {
|
||||
let conn = seeded();
|
||||
|
||||
for table in ["node", "entry", "section_block"] {
|
||||
let pk: String = conn
|
||||
.query_row(
|
||||
&format!("SELECT name FROM pragma_table_info('{table}') WHERE pk = 1"),
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("应能查到主键列");
|
||||
assert_eq!(pk, "id", "{table} 的主键应为 id");
|
||||
}
|
||||
|
||||
let duplicate = conn.execute(
|
||||
"INSERT INTO entry (key, node_key, name)
|
||||
VALUES ('github', 'common', '重复')",
|
||||
[],
|
||||
);
|
||||
assert!(duplicate.is_err(), "key 必须唯一");
|
||||
}
|
||||
|
||||
/// `key` 是业务键:不允许重复,否则详情页按它查找会命中错行。
|
||||
#[test]
|
||||
fn entry_key_is_unique() {
|
||||
let conn = seeded();
|
||||
let duplicate = conn.execute(
|
||||
"INSERT INTO entry (key, node_key, name)
|
||||
SELECT key, node_key, '重复条目'
|
||||
FROM entry WHERE key = 'github'",
|
||||
[],
|
||||
);
|
||||
|
||||
assert!(duplicate.is_err(), "重复的条目 key 必须被拒绝");
|
||||
}
|
||||
|
||||
/// 条目不再存所属分区,分区由 `node_key` 推导:
|
||||
/// 改分区业务键后,它的分组与条目应当自动归到新键下。
|
||||
#[test]
|
||||
fn section_membership_is_derived_from_parent() {
|
||||
let conn = seeded();
|
||||
|
||||
conn.execute("UPDATE node SET key = 'recommend-v2' WHERE key = 'recommend'", [])
|
||||
.expect("应能改分区业务键");
|
||||
|
||||
let catalog = catalog::load(&conn).expect("应能组装内容目录");
|
||||
let apps = catalog
|
||||
.categories
|
||||
.iter()
|
||||
.find(|category| category["id"] == "recommend-v2")
|
||||
.expect("应能找到改名后的分区");
|
||||
|
||||
let total: usize = apps["groups"]
|
||||
.as_array()
|
||||
.expect("应有分组")
|
||||
.iter()
|
||||
.map(|group| group["items"].as_array().expect("应有条目").len())
|
||||
.sum();
|
||||
|
||||
assert_eq!(total, 18, "改名后条目仍应归在这个分区下");
|
||||
}
|
||||
|
||||
/// 基础字段现在是真列,不再塞在 JSON 里:直接按列查询也应该拿得到。
|
||||
#[test]
|
||||
fn entry_fields_are_columns_not_json_blobs() {
|
||||
let conn = seeded();
|
||||
|
||||
let (name, href, cover, hot, views): (String, String, String, i64, i64) = conn
|
||||
.query_row(
|
||||
"SELECT name, href, cover, hot, views FROM entry WHERE key = 'github'",
|
||||
[],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get(4)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.expect("应能按列读取条目");
|
||||
|
||||
assert_eq!(name, "GitHub");
|
||||
assert_eq!(href, "#");
|
||||
assert!(cover.starts_with("https://"));
|
||||
assert_eq!(hot, 1, "布尔字段以 0/1 存储");
|
||||
assert_eq!(views, 429);
|
||||
|
||||
// 旧结构里那个 data 列已经不存在了
|
||||
let has_data_column: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('entry') WHERE name = 'data'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("应能查询表结构");
|
||||
assert_eq!(has_data_column, 0, "entry 不应再有 data JSON 列");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -262,6 +446,39 @@ mod tests {
|
||||
assert!(db.data_version().expect("应能读取 data_version") >= 0);
|
||||
}
|
||||
|
||||
/// 全新库(user_version = 0)允许直接建表;当前版本也允许。
|
||||
#[test]
|
||||
fn accepts_fresh_or_current_schema_version() {
|
||||
let conn = Connection::open_in_memory().expect("内存库应能打开");
|
||||
|
||||
assert_eq!(
|
||||
schema::check_version(&conn).expect("全新库应放行"),
|
||||
0,
|
||||
"新库的 user_version 应为 0"
|
||||
);
|
||||
|
||||
conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION)
|
||||
.expect("应能写入版本");
|
||||
assert_eq!(
|
||||
schema::check_version(&conn).expect("当前版本应放行"),
|
||||
schema::SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
/// 结构变过之后,旧库必须被拒绝并提示删库,而不是用旧表跑新查询。
|
||||
#[test]
|
||||
fn rejects_database_from_older_schema() {
|
||||
let conn = Connection::open_in_memory().expect("内存库应能打开");
|
||||
conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION - 1)
|
||||
.expect("应能写入旧版本号");
|
||||
|
||||
let error = schema::check_version(&conn).expect_err("旧库必须被拒绝");
|
||||
let message = error.to_string();
|
||||
|
||||
assert!(message.contains("结构版本"), "报错要说明原因: {message}");
|
||||
assert!(message.contains("删除数据库"), "报错要给出处理方式: {message}");
|
||||
}
|
||||
|
||||
/// 回归测试:外部连接提交后,服务端连接读到的 data_version 必须变化。
|
||||
///
|
||||
/// 这是「用户用 DB Browser 改库,页面自动刷新」整条链路的地基;
|
||||
@@ -287,14 +504,14 @@ mod tests {
|
||||
|
||||
// 模拟外部工具:另开一个连接提交一次写事务
|
||||
let external = Connection::open(&path).expect("外部连接应能打开");
|
||||
external
|
||||
.execute(
|
||||
"UPDATE entry SET name = '改过的名字' WHERE category_id = 'recommend' AND id = 'github'",
|
||||
[],
|
||||
)
|
||||
let changed = external
|
||||
.execute("UPDATE entry SET name = '改过的名字' WHERE key = 'github'", [])
|
||||
.expect("外部写入应成功");
|
||||
drop(external);
|
||||
|
||||
// 没有实际改动就不会推进 data_version,这里先确认确实改到了行
|
||||
assert_eq!(changed, 1, "外部更新应命中 github 条目");
|
||||
|
||||
let after = db.data_version().expect("应能读取新版本");
|
||||
assert_ne!(before, after, "外部提交后 data_version 必须变化");
|
||||
}
|
||||
|
||||
+279
-236
@@ -1,13 +1,19 @@
|
||||
//! 内容库的表结构与种子导入。
|
||||
//!
|
||||
//! 表结构刻意保持「薄」:分类和分组是关系表,条目除 id / 排序 / 计数外的
|
||||
//! 全部字段都塞进 `entry.data` 的 JSON 列。原因是前端 `types.ts` 用的是
|
||||
//! 可辨识联合(apps / posters / articles),三种形态字段差异很大,
|
||||
//! 拆成三张表会让查询和后续加字段都变啰嗦;JSON 列则天然与前端结构 1:1。
|
||||
//! 两条设计原则:
|
||||
//!
|
||||
//! 1. **一个容器树**:分区(section)与分组(group)是同一种东西——「装条目的容器」,
|
||||
//! 合并在 `node` 一张自引用表里,用 `kind` 区分层级。
|
||||
//! 2. **内容与展示分离**:`nav_item` 不存内容,只存侧栏的排列(哪个节点、叫什么、放在哪)。
|
||||
//!
|
||||
//! 命名规则:
|
||||
//!
|
||||
//! - 所有表用**业务编码 `code`** 做主键(`recommend` / `ui` / `github`),不再有数字 id;
|
||||
//! - 外键统一写成 `<目标>_code`:`parent_code`、`section_code`、`node_code`、`entry_code`;
|
||||
//! - 改编码时下游由 `ON UPDATE CASCADE` 自动跟随。
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::{db::map_error, error::StashError};
|
||||
|
||||
@@ -20,69 +26,114 @@ const SEED_JSON: &str = include_str!("seed.json");
|
||||
/// 不实现版本迁移:需要改结构时删掉 `data/stash.db` 重启即可,
|
||||
/// 种子会自动重新灌一份干净数据。
|
||||
const SCHEMA_SQL: &str = r#"
|
||||
-- 分类:首页的一张大卡片
|
||||
CREATE TABLE IF NOT EXISTS category (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('apps', 'posters', 'articles')),
|
||||
title TEXT NOT NULL,
|
||||
bar_color TEXT NOT NULL,
|
||||
icon TEXT NOT NULL,
|
||||
preview_limit INTEGER,
|
||||
empty_text TEXT,
|
||||
sort INTEGER NOT NULL DEFAULT 0
|
||||
-- 内容节点:分区与分组共用一张自引用树
|
||||
-- kind = 'section' → 顶层分区(首页一张卡片 / 明细页 hero),parent_code 必须为 NULL
|
||||
-- kind = 'group' → 分区下的分组,parent_code 指向所属分区
|
||||
-- view_mode 只对分区有意义,决定首页与明细页怎么渲染
|
||||
CREATE TABLE IF NOT EXISTS node (
|
||||
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配,不用 AUTOINCREMENT)
|
||||
key TEXT NOT NULL UNIQUE, -- 业务键,全局唯一(如 recommend / ui)
|
||||
parent_key TEXT REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
-- 父节点业务键:分组指向分区,分区为 NULL
|
||||
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)
|
||||
preview_limit INTEGER, -- 首页预览条数;NULL 时前端按类型取默认
|
||||
empty_text TEXT, -- 分区为空时的提示文案
|
||||
show_in_sidebar INTEGER NOT NULL DEFAULT 1 CHECK (show_in_sidebar IN (0, 1)),
|
||||
-- 是否出现在侧栏:0 表示只当首页卡片/内容,不进导航
|
||||
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)
|
||||
)
|
||||
);
|
||||
|
||||
-- 应用类分类的分组:明细页按组展示,首页把各组前几项拼成预览
|
||||
CREATE TABLE IF NOT EXISTS app_group (
|
||||
category_id TEXT NOT NULL REFERENCES category(id) ON DELETE CASCADE,
|
||||
id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
sort INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (category_id, id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS node_parent_idx ON node(parent_key, sort);
|
||||
|
||||
-- 条目:应用 / 海报 / 图文共用一张表
|
||||
-- name 展示名(应用是 name,海报与图文是 title)
|
||||
-- group_id 只有应用类用得上,其余为 NULL
|
||||
-- data 该条目其余字段的 JSON,键名与前端 types.ts 一致
|
||||
-- 条目:网站 / 电影 / 文章都在这张表里,基础字段各占一列
|
||||
CREATE TABLE IF NOT EXISTS entry (
|
||||
category_id TEXT NOT NULL REFERENCES category(id) ON DELETE CASCADE,
|
||||
id TEXT NOT NULL,
|
||||
group_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
views INTEGER NOT NULL DEFAULT 0,
|
||||
likes INTEGER NOT NULL DEFAULT 0,
|
||||
sort INTEGER NOT NULL DEFAULT 0,
|
||||
data TEXT NOT NULL,
|
||||
PRIMARY KEY (category_id, id)
|
||||
id INTEGER PRIMARY KEY, -- 主键(SQLite rowid,自动分配)
|
||||
key TEXT NOT NULL UNIQUE, -- 业务键,全局唯一(如 github / movie-1)
|
||||
node_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
-- 直接所属容器:应用是分组,海报/图文是分区本身
|
||||
-- 所属分区不单独存:由 node_key 推导(分组看它的 parent_key)
|
||||
name TEXT NOT NULL, -- 名称:网站名 / 电影名 / 文章标题
|
||||
description TEXT, -- 一句话简介,列表卡片的副标题
|
||||
icon TEXT, -- 字符图标(封面缺失时的占位)
|
||||
href TEXT, -- 点击后打开的页面;空表示暂无可跳转地址
|
||||
cover TEXT, -- 封面图:应用头像 / 海报 / 文章配图
|
||||
banner TEXT, -- 详情页横幅图
|
||||
badge TEXT, -- 详情页标签行上的一句话标记(如「推荐好站」)
|
||||
comment TEXT, -- 详情页的一句话评价(引用块)
|
||||
summary TEXT, -- 详情页正文段落(电影简介这类长文本)
|
||||
body TEXT, -- 文章正文(Markdown):一篇一段文本
|
||||
hot INTEGER NOT NULL DEFAULT 0 CHECK (hot IN (0, 1)),-- 是否热门:列表里显示 HOT 徽标
|
||||
views INTEGER NOT NULL DEFAULT 0, -- 浏览量,详情页操作条「眼睛」后的数字
|
||||
likes INTEGER NOT NULL DEFAULT 0, -- 点赞数,详情页操作条「赞」后的数字
|
||||
sort INTEGER NOT NULL DEFAULT 0 -- 条目顺序(组内或分区内),越小越靠前
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS entry_group_idx ON entry(category_id, group_id, sort);
|
||||
CREATE INDEX IF NOT EXISTS entry_parent_idx ON entry(node_key, sort);
|
||||
|
||||
-- 侧栏导航:自引用树,一条表同时表达两种节点
|
||||
-- parent_id 为 NULL → 顶层项
|
||||
-- category_id 非 NULL → 叶子,点击后切到该分类(可带分组)
|
||||
-- category_id 为 NULL → 可折叠分组,它的子节点通过 parent_id 指向它
|
||||
-- 层级和标签必须单独存:侧栏的「素材资源 / 开发工具」分组名与分类名不同,
|
||||
-- 同一个分组还能被多个叶子复用(如「前端调试工具」和「在线编辑器」都指向 recommend/dev)。
|
||||
CREATE TABLE IF NOT EXISTS nav_entry (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_id TEXT REFERENCES nav_entry(id) ON DELETE CASCADE,
|
||||
label TEXT NOT NULL,
|
||||
category_id TEXT REFERENCES category(id) ON DELETE CASCADE,
|
||||
group_id TEXT,
|
||||
sort INTEGER NOT NULL DEFAULT 0
|
||||
-- 条目的行内文本列表:海报的 tags、图文的 meta 都放这里(有序、一对多)
|
||||
CREATE TABLE IF NOT EXISTS entry_tag (
|
||||
entry_key TEXT NOT NULL REFERENCES entry(key) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
-- 所属条目业务键
|
||||
sort INTEGER NOT NULL DEFAULT 0, -- 展示顺序
|
||||
label TEXT NOT NULL, -- 标签文字(如「推荐电影」「2 年前」)
|
||||
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 -- 卡片顺序
|
||||
);
|
||||
"#;
|
||||
|
||||
/// 种子导入结果,仅用于启动日志。
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct SeedReport {
|
||||
pub categories: usize,
|
||||
pub sections: usize,
|
||||
pub groups: usize,
|
||||
pub entries: usize,
|
||||
/// 侧栏导航节点数(顶层项 + 叶子)
|
||||
pub nav_items: usize,
|
||||
pub tags: usize,
|
||||
/// 首页展示块数量(每个分区一个)
|
||||
pub blocks: usize,
|
||||
}
|
||||
|
||||
/// 当前表结构版本,写在 SQLite 的 `PRAGMA user_version` 里。
|
||||
///
|
||||
/// 开发阶段不做迁移:版本对不上时直接拒绝启动,让用户删库重建,
|
||||
/// 比悄悄用旧表跑新查询(最终表现为 500)好排查得多。
|
||||
/// 结构调整时把这个数字 +1。
|
||||
pub const SCHEMA_VERSION: i64 = 15;
|
||||
|
||||
/// 校验库结构版本:0 表示全新库,允许;其它非当前版本一律拒绝。
|
||||
pub fn check_version(conn: &Connection) -> Result<i64, StashError> {
|
||||
let version: i64 = conn
|
||||
.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||
.map_err(map_error)?;
|
||||
|
||||
if version != 0 && version != SCHEMA_VERSION {
|
||||
return Err(StashError::Database(format!(
|
||||
"内容库结构版本为 {version},当前程序需要 {SCHEMA_VERSION}。\
|
||||
开发阶段不做自动迁移,请删除数据库后重启(种子会自动重新导入)。"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
/// 建表(幂等):每次启动都跑一遍,缺表就补,已有数据不动。
|
||||
@@ -92,8 +143,8 @@ pub fn apply(conn: &Connection) -> Result<(), StashError> {
|
||||
|
||||
/// 把内嵌种子写进空库。
|
||||
///
|
||||
/// 只在数据库文件是本次启动新建时调用,所以不存在「覆盖用户数据」的问题;
|
||||
/// 用户删掉库以后重启会重新得到一份干净的内置内容,这也是开发期的重置手段。
|
||||
/// 只用业务编码,不需要先插入再回查自增 id:父子关系在种子文件里已经写清楚,
|
||||
/// 按「分区 → 分组 → 条目 → 侧栏」的顺序插入即可满足外键。
|
||||
pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
|
||||
let seed: SeedFile = serde_json::from_str(SEED_JSON)
|
||||
.map_err(|error| StashError::Database(format!("种子文件不是合法 JSON: {error}")))?;
|
||||
@@ -102,173 +153,144 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
|
||||
// 单事务写入:避免中途失败留下半个内容库
|
||||
let tx = conn.transaction().map_err(map_error)?;
|
||||
|
||||
for category in &seed.categories {
|
||||
let kind = category.kind.as_str();
|
||||
for section in &seed.sections {
|
||||
let view = section.view.as_str();
|
||||
|
||||
// 只认识前端支持的三种形态,拼错时立刻报错而不是静默写脏数据
|
||||
if !matches!(kind, "apps" | "posters" | "articles") {
|
||||
if !matches!(view, "apps" | "posters" | "articles") {
|
||||
return Err(StashError::Database(format!(
|
||||
"种子分类 {} 的 kind 非法: {kind}",
|
||||
category.id
|
||||
"种子分区 {} 的 view 非法: {view}",
|
||||
section.key
|
||||
)));
|
||||
}
|
||||
|
||||
// 只有 apps 分区才允许有分组:其余类型直接挂条目
|
||||
if view != "apps" && !section.groups.is_empty() {
|
||||
return Err(StashError::Database(format!(
|
||||
"种子分区 {} 是 {view} 类型,不应包含 groups",
|
||||
section.key
|
||||
)));
|
||||
}
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO category (id, kind, title, bar_color, icon, preview_limit, empty_text, sort)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
"INSERT INTO node (key, parent_key, title, kind, view_mode, bar_color, icon, preview_limit, empty_text, show_in_sidebar, sort)
|
||||
VALUES (?1, NULL, ?2, 'section', ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![
|
||||
category.id,
|
||||
kind,
|
||||
category.title,
|
||||
category.bar_color,
|
||||
category.icon,
|
||||
category.preview_limit,
|
||||
category.empty_text,
|
||||
category.sort,
|
||||
section.key,
|
||||
section.title,
|
||||
view,
|
||||
section.bar_color,
|
||||
section.icon,
|
||||
section.preview_limit,
|
||||
section.empty_text,
|
||||
i64::from(section.show_in_sidebar),
|
||||
section.sort,
|
||||
],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
report.categories += 1;
|
||||
report.sections += 1;
|
||||
|
||||
if kind == "apps" {
|
||||
for group in &category.groups {
|
||||
if view == "apps" {
|
||||
for group in §ion.groups {
|
||||
tx.execute(
|
||||
"INSERT INTO app_group (category_id, id, name, sort) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![category.id, group.id, group.name, group.sort],
|
||||
"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
|
||||
],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
report.groups += 1;
|
||||
|
||||
for (index, item) in group.items.iter().enumerate() {
|
||||
insert_entry(&tx, &category.id, Some(&group.id), index, item)?;
|
||||
report.entries += 1;
|
||||
for (index, entry) in group.entries.iter().enumerate() {
|
||||
insert_entry(&tx, &group.key, index, entry, &mut report)?;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
} else {
|
||||
// 海报与图文没有分组,直接挂在分区上(node_key 就是分区自己)
|
||||
for (index, entry) in section.entries.iter().enumerate() {
|
||||
insert_entry(&tx, §ion.key, index, entry, &mut report)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 海报与图文没有分组,直接挂在分类下
|
||||
for (index, item) in category.items.iter().enumerate() {
|
||||
insert_entry(&tx, &category.id, None, index, item)?;
|
||||
report.entries += 1;
|
||||
}
|
||||
// 首页该分区展示哪个节点:节点必须已经插入(分组或分区本身)
|
||||
tx.execute(
|
||||
"INSERT INTO section_block (node_key, title, preview_limit, sort)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![
|
||||
section.block.node_key,
|
||||
section.block.title,
|
||||
section.block.preview_limit,
|
||||
section.sort,
|
||||
],
|
||||
)
|
||||
.map_err(|error| {
|
||||
StashError::Database(format!(
|
||||
"分区 {} 的展示节点 {} 无效: {error}",
|
||||
section.key, section.block.node_key
|
||||
))
|
||||
})?;
|
||||
report.blocks += 1;
|
||||
}
|
||||
|
||||
// 侧栏导航必须放在分类之后插入:叶子对 category(id) 有外键依赖
|
||||
report.nav_items = seed_nav(&tx, &seed.nav)?;
|
||||
|
||||
tx.commit().map_err(map_error)?;
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// 导入侧栏导航。
|
||||
/// 写入一个条目:基础字段进 entry,行内标签进 entry_tag。
|
||||
///
|
||||
/// 先插顶层项(parent_id 为空),再插各分组的叶子——自引用外键要求父节点先存在。
|
||||
/// 返回写入的节点总数。
|
||||
fn seed_nav(tx: &rusqlite::Transaction<'_>, nav: &SeedNav) -> Result<usize, StashError> {
|
||||
let mut inserted = 0usize;
|
||||
|
||||
// 顶层直达项与折叠分组是同一层的兄弟节点,必须共用一个递增序号,
|
||||
// 否则两组序号都从 0 开始、互相撞号,最终顺序会退化成按 id 排序。
|
||||
let mut top_sort = 0usize;
|
||||
|
||||
// 顶层直达项:有 category_id,渲染成不带展开箭头的链接
|
||||
for leaf in &nav.top_level {
|
||||
insert_nav_node(tx, leaf, None, top_sort)?;
|
||||
top_sort += 1;
|
||||
inserted += 1;
|
||||
}
|
||||
|
||||
// 折叠分组:本身没有 category_id,子节点才是真正的链接
|
||||
for group in &nav.groups {
|
||||
tx.execute(
|
||||
"INSERT INTO nav_entry (id, parent_id, label, category_id, group_id, sort)
|
||||
VALUES (?1, NULL, ?2, NULL, NULL, ?3)",
|
||||
params![group.id, group.label, top_sort as i64],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
top_sort += 1;
|
||||
inserted += 1;
|
||||
|
||||
// 组内叶子单独编号:它们是另一个层级,序号从 0 起不会和顶层冲突
|
||||
for (leaf_index, leaf) in group.leaves.iter().enumerate() {
|
||||
insert_nav_node(tx, leaf, Some(&group.id), leaf_index)?;
|
||||
inserted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(inserted)
|
||||
}
|
||||
|
||||
/// 写入一个导航叶子节点,指向「分类 + 可选分组」。
|
||||
fn insert_nav_node(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
leaf: &SeedNavLeaf,
|
||||
parent_id: Option<&str>,
|
||||
sort: usize,
|
||||
) -> Result<(), StashError> {
|
||||
tx.execute(
|
||||
"INSERT INTO nav_entry (id, parent_id, label, category_id, group_id, sort)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![
|
||||
leaf.id,
|
||||
parent_id,
|
||||
leaf.label,
|
||||
leaf.target.category_id,
|
||||
leaf.target.group_id,
|
||||
sort as i64,
|
||||
],
|
||||
)
|
||||
.map_err(|error| {
|
||||
// 外键失败通常意味着种子里的 target 写错了分类 id,把节点名带进报错方便定位
|
||||
StashError::Database(format!("写入导航节点 {} 失败: {error}", leaf.id))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 写入一个条目,并把 `views` / `likes` 从 JSON 里提到独立列。
|
||||
///
|
||||
/// 计数放列上是为了让用户用任何 SQLite 工具都能直接改数,
|
||||
/// 也避免将来自增时重写整个 JSON。
|
||||
/// `node_key` 是直接容器的业务键;所属分区由它推导,不单独存。
|
||||
fn insert_entry(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
category_id: &str,
|
||||
group_id: Option<&str>,
|
||||
node_key: &str,
|
||||
index: usize,
|
||||
item: &SeedItem,
|
||||
entry: &SeedEntry,
|
||||
report: &mut SeedReport,
|
||||
) -> Result<(), StashError> {
|
||||
let mut extra = item.extra.clone();
|
||||
let views = take_count(&mut extra, "views");
|
||||
let likes = take_count(&mut extra, "likes");
|
||||
let data = Value::Object(extra).to_string();
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO entry (category_id, id, group_id, name, views, likes, sort, data)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
"INSERT INTO entry (
|
||||
key, node_key, name, description, icon, href, cover,
|
||||
banner, badge, comment, summary, body, hot, views, likes, sort
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
|
||||
params![
|
||||
category_id,
|
||||
item.id,
|
||||
group_id,
|
||||
item.name,
|
||||
views,
|
||||
likes,
|
||||
entry.key,
|
||||
node_key,
|
||||
entry.name,
|
||||
entry.description,
|
||||
entry.icon,
|
||||
entry.href,
|
||||
entry.cover,
|
||||
entry.banner,
|
||||
entry.badge,
|
||||
entry.comment,
|
||||
entry.summary,
|
||||
entry.body,
|
||||
i64::from(entry.hot),
|
||||
entry.views,
|
||||
entry.likes,
|
||||
index as i64,
|
||||
data,
|
||||
],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
report.entries += 1;
|
||||
|
||||
/// 从 JSON 对象里取出计数值并删除该键,缺省或类型不对时按 0 处理。
|
||||
fn take_count(extra: &mut Map<String, Value>, key: &str) -> i64 {
|
||||
match extra.remove(key) {
|
||||
Some(Value::Number(number)) => number.as_i64().unwrap_or(0),
|
||||
_ => 0,
|
||||
// 标签:海报的 tags 与图文的 meta 共用一张子表,按出现顺序编号
|
||||
for (sort, label) in entry.tags.iter().chain(entry.meta.iter()).enumerate() {
|
||||
tx.execute(
|
||||
"INSERT INTO entry_tag (entry_key, sort, label) VALUES (?1, ?2, ?3)",
|
||||
params![entry.key, sort as i64, label],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
report.tags += 1;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -278,54 +300,16 @@ fn take_count(extra: &mut Map<String, Value>, key: &str) -> i64 {
|
||||
/// 种子文件根节点
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SeedFile {
|
||||
categories: Vec<SeedCategory>,
|
||||
/// 侧栏导航;缺省时侧栏为空,但不影响分类内容
|
||||
#[serde(default)]
|
||||
nav: SeedNav,
|
||||
sections: Vec<SeedSection>,
|
||||
}
|
||||
|
||||
/// 侧栏导航:顶层直达项 + 可折叠分组
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SeedNav {
|
||||
#[serde(default)]
|
||||
top_level: Vec<SeedNavLeaf>,
|
||||
#[serde(default)]
|
||||
groups: Vec<SeedNavGroup>,
|
||||
}
|
||||
|
||||
/// 一个可折叠分组,本身不可点击,只负责收纳叶子
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SeedNavGroup {
|
||||
id: String,
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
leaves: Vec<SeedNavLeaf>,
|
||||
}
|
||||
|
||||
/// 导航叶子:点击后切到 `target` 指定的分类 / 分组
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SeedNavLeaf {
|
||||
id: String,
|
||||
label: String,
|
||||
target: SeedNavTarget,
|
||||
}
|
||||
|
||||
/// 导航目标;`group_id` 为空表示直接打开整个分类
|
||||
/// 一个分区(顶层节点)
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SeedNavTarget {
|
||||
category_id: String,
|
||||
#[serde(default)]
|
||||
group_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 一个分类;`groups` 与 `items` 按 kind 二者取一
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SeedCategory {
|
||||
id: String,
|
||||
kind: String,
|
||||
struct SeedSection {
|
||||
key: String,
|
||||
/// 渲染形态:apps / posters / articles
|
||||
view: String,
|
||||
title: String,
|
||||
bar_color: String,
|
||||
icon: String,
|
||||
@@ -334,28 +318,87 @@ struct SeedCategory {
|
||||
preview_limit: Option<i64>,
|
||||
#[serde(default)]
|
||||
empty_text: Option<String>,
|
||||
/// 是否出现在侧栏;默认 true
|
||||
#[serde(default = "default_true")]
|
||||
show_in_sidebar: bool,
|
||||
/// 仅 apps 分区使用
|
||||
#[serde(default)]
|
||||
groups: Vec<SeedGroup>,
|
||||
/// 非 apps 分区直接挂条目
|
||||
#[serde(default)]
|
||||
items: Vec<SeedItem>,
|
||||
entries: Vec<SeedEntry>,
|
||||
/// 首页卡片展示哪个节点(分组编码,或分区自己的编码)
|
||||
block: SeedBlockConfig,
|
||||
}
|
||||
|
||||
/// 应用类分类下的一个分组
|
||||
/// 首页分区的展示配置
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SeedBlockConfig {
|
||||
/// 要展示的节点业务键;通常是分组,电影/文章填分区自己
|
||||
node_key: String,
|
||||
/// 块标题;空则用节点 title
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
/// 卡片显示几条;空则用前端默认
|
||||
#[serde(default)]
|
||||
preview_limit: Option<i64>,
|
||||
}
|
||||
|
||||
/// 分区下的一个分组
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SeedGroup {
|
||||
id: String,
|
||||
name: String,
|
||||
key: String,
|
||||
title: String,
|
||||
sort: i64,
|
||||
items: Vec<SeedItem>,
|
||||
/// 是否出现在侧栏;默认 true
|
||||
#[serde(default = "default_true")]
|
||||
show_in_sidebar: bool,
|
||||
entries: Vec<SeedEntry>,
|
||||
}
|
||||
|
||||
/// 一条内容;除 id / name 外全部字段原样落进 JSON 列。
|
||||
/// serde 默认值:侧栏默认显示
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// 一条内容:字段与 `entry` 表的列一一对应。
|
||||
///
|
||||
/// 用 `flatten` 收集剩余键,这样种子文件里新增字段不需要同步改 Rust 结构。
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SeedItem {
|
||||
id: String,
|
||||
/// `tags` / `meta` 都写进 `entry_tag`(海报用 tags,图文用 meta);
|
||||
/// `body` 是文章的 Markdown 正文,一篇一段文本。
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SeedEntry {
|
||||
key: String,
|
||||
name: String,
|
||||
#[serde(flatten)]
|
||||
extra: Map<String, Value>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
#[serde(default)]
|
||||
href: Option<String>,
|
||||
#[serde(default)]
|
||||
cover: Option<String>,
|
||||
#[serde(default)]
|
||||
banner: Option<String>,
|
||||
#[serde(default)]
|
||||
badge: Option<String>,
|
||||
#[serde(default)]
|
||||
comment: Option<String>,
|
||||
#[serde(default)]
|
||||
summary: Option<String>,
|
||||
/// 文章正文(Markdown)
|
||||
#[serde(default)]
|
||||
body: Option<String>,
|
||||
#[serde(default)]
|
||||
hot: bool,
|
||||
#[serde(default)]
|
||||
views: i64,
|
||||
#[serde(default)]
|
||||
likes: i64,
|
||||
#[serde(default)]
|
||||
tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
meta: Vec<String>,
|
||||
}
|
||||
|
||||
+177
-546
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user