feat(web): 地址栏驱动视图,刷新详情页不再回首页
- 新增 data/view.ts 与 useViewRoute:视图与地址双向同步,支持前进后退 - 条目地址带上所属分组,刷新 / 收藏后「返回」有确定落点 - 服务端只把 /c/** 回落到 index.html,其余路径仍返回 404 - 明细页返回按钮去掉左侧箭头图标
This commit is contained in:
+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 必须变化");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user