feat(web): 地址栏驱动视图,刷新详情页不再回首页

- 新增 data/view.ts 与 useViewRoute:视图与地址双向同步,支持前进后退
- 条目地址带上所属分组,刷新 / 收藏后「返回」有确定落点
- 服务端只把 /c/** 回落到 index.html,其余路径仍返回 404
- 明细页返回按钮去掉左侧箭头图标
This commit is contained in:
2026-10-24 17:18:30 +08:00
parent 41fed5e64d
commit 19ead7b716
22 changed files with 2098 additions and 1321 deletions
+296 -79
View File
@@ -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 必须变化");
}