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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user