feat(admin): 跳转规范化、侧栏子分类与归类管理
- 条目保存时规范化 href(裸域名补 https,拒绝 javascript:/data: 等协议),详情按钮按解析结果置灰/跳转
- settings.sidebar 增加 hidden_groups / shown_groups / group_order,侧栏 Tab 支持分组显隐与排序
- 新增 PATCH /api/categories/{id} 与「归类」Tab:编辑类型/标题/图标/色条/条数;改类型自动迁移条目与首页卡片
- 测试 54 passed(href 规范化 3 例、分类更新 3 例)
This commit is contained in:
+325
-1
@@ -3,7 +3,7 @@
|
||||
//! 分区就是 `node` 表里 `kind='section'` 的行;删除会级联分组、条目与展示块。
|
||||
//! 新增时顺手建一条首页展示块,保证它立刻出现在首页。
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{db::map_error, error::StashError, db::entries};
|
||||
@@ -19,6 +19,19 @@ pub struct NewCategory {
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
/// 修改分区:字段都可选,只改传进来的。
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateCategory {
|
||||
pub title: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub bar_color: Option<String>,
|
||||
/// 改类型(应用 / 海报 / 图文)时会顺带迁移条目归属
|
||||
pub kind: Option<String>,
|
||||
pub preview_limit: Option<i64>,
|
||||
pub empty_text: Option<String>,
|
||||
}
|
||||
|
||||
fn valid_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 32
|
||||
@@ -84,6 +97,194 @@ pub fn create(conn: &mut Connection, new: NewCategory) -> Result<(), StashError>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct SectionRow {
|
||||
title: String,
|
||||
icon: Option<String>,
|
||||
bar_color: Option<String>,
|
||||
view_mode: Option<String>,
|
||||
preview_limit: Option<i64>,
|
||||
empty_text: Option<String>,
|
||||
}
|
||||
|
||||
fn valid_color(color: &str) -> bool {
|
||||
if color.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let hex = color.strip_prefix('#').unwrap_or("");
|
||||
(hex.len() == 3 || hex.len() == 6) && hex.chars().all(|ch| ch.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// 修改分区:标题 / 图标 / 色条 / 预览条数 / 空态文案,以及「归类」类型。
|
||||
///
|
||||
/// 改类型时迁移数据,保证切换后条目不丢:
|
||||
/// - 海报 / 图文 → 应用:确保有一个分组,把直接挂在分区上的条目挪进去,
|
||||
/// 首页卡片也改指向该分组;
|
||||
/// - 应用 → 海报 / 图文:卡片从分组挪回分区,分组保留(不影响条目读取)。
|
||||
pub fn update(conn: &mut Connection, id: &str, patch: UpdateCategory) -> Result<(), StashError> {
|
||||
if id == "local" {
|
||||
return Err(StashError::InvalidDirectory(
|
||||
"「本地服务」是运行时分类,不能编辑".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let tx = conn.transaction().map_err(map_error)?;
|
||||
|
||||
let current = tx
|
||||
.query_row(
|
||||
"SELECT title, icon, bar_color, view_mode, preview_limit, empty_text
|
||||
FROM node WHERE key = ?1 AND kind = 'section'",
|
||||
[id],
|
||||
|row| {
|
||||
Ok(SectionRow {
|
||||
title: row.get(0)?,
|
||||
icon: row.get(1)?,
|
||||
bar_color: row.get(2)?,
|
||||
view_mode: row.get(3)?,
|
||||
preview_limit: row.get(4)?,
|
||||
empty_text: row.get(5)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(map_error)?;
|
||||
|
||||
let Some(current) = current else {
|
||||
return Err(StashError::NotFound(format!("分区不存在: {id}")));
|
||||
};
|
||||
|
||||
let title = match patch.title {
|
||||
Some(title) => {
|
||||
let title = title.trim().to_string();
|
||||
if title.is_empty() || title.chars().count() > 40 {
|
||||
return Err(StashError::InvalidDirectory("标题需为 1–40 个字".into()));
|
||||
}
|
||||
title
|
||||
}
|
||||
None => current.title,
|
||||
};
|
||||
|
||||
let icon = match patch.icon {
|
||||
Some(icon) => {
|
||||
let icon = icon.trim().to_string();
|
||||
if icon.chars().count() > 8 {
|
||||
return Err(StashError::InvalidDirectory("图标最多 8 个字符".into()));
|
||||
}
|
||||
(!icon.is_empty()).then_some(icon)
|
||||
}
|
||||
None => current.icon,
|
||||
};
|
||||
|
||||
let bar_color = match patch.bar_color {
|
||||
Some(color) => {
|
||||
let color = color.trim().to_ascii_lowercase();
|
||||
if !valid_color(&color) {
|
||||
return Err(StashError::InvalidDirectory(
|
||||
"色条需为 #rgb 或 #rrggbb".into(),
|
||||
));
|
||||
}
|
||||
(!color.is_empty()).then_some(color)
|
||||
}
|
||||
None => current.bar_color,
|
||||
};
|
||||
|
||||
let empty_text = match patch.empty_text {
|
||||
Some(text) => {
|
||||
let text = text.trim().to_string();
|
||||
(!text.is_empty()).then_some(text)
|
||||
}
|
||||
None => current.empty_text,
|
||||
};
|
||||
|
||||
let preview_limit = match patch.preview_limit {
|
||||
Some(limit) => {
|
||||
if !(1..=60).contains(&limit) {
|
||||
return Err(StashError::InvalidDirectory("预览条数需在 1–60 之间".into()));
|
||||
}
|
||||
Some(limit)
|
||||
}
|
||||
None => current.preview_limit,
|
||||
};
|
||||
|
||||
let kind = match patch.kind {
|
||||
Some(kind) => {
|
||||
if !KINDS.contains(&kind.as_str()) {
|
||||
return Err(StashError::InvalidDirectory("类型不合法".into()));
|
||||
}
|
||||
kind
|
||||
}
|
||||
None => current
|
||||
.view_mode
|
||||
.clone()
|
||||
.ok_or_else(|| StashError::Database(format!("分区 {id} 缺少 view_mode")))?,
|
||||
};
|
||||
|
||||
let was_apps = current.view_mode.as_deref() == Some("apps");
|
||||
let is_apps = kind == "apps";
|
||||
|
||||
if is_apps && !was_apps {
|
||||
// 切到应用:条目必须有分组可挂;没有就建一个「全部」
|
||||
let group_count: i64 = tx
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM node WHERE kind = 'group' AND parent_key = ?1",
|
||||
[id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
let group_key = if group_count == 0 {
|
||||
let group_key = format!("{id}-all");
|
||||
tx.execute(
|
||||
"INSERT INTO node (key, parent_key, title, kind, sort, show_in_sidebar)
|
||||
VALUES (?1, ?2, '全部', 'group', 1, 1)",
|
||||
params![group_key, id],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
group_key
|
||||
} else {
|
||||
tx.query_row(
|
||||
"SELECT key FROM node WHERE kind = 'group' AND parent_key = ?1
|
||||
ORDER BY sort, key LIMIT 1",
|
||||
[id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(map_error)?
|
||||
};
|
||||
|
||||
tx.execute(
|
||||
"UPDATE entry SET node_key = ?1 WHERE node_key = ?2",
|
||||
params![group_key, id],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
tx.execute(
|
||||
"UPDATE section_block SET node_key = ?1 WHERE node_key = ?2",
|
||||
params![group_key, id],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
} else if !is_apps && was_apps {
|
||||
// 离开应用:首页卡片从「某个分组」挪回分区,标题交还给分区
|
||||
tx.execute(
|
||||
"UPDATE section_block SET node_key = ?1, title = NULL
|
||||
WHERE node_key IN (
|
||||
SELECT key FROM node WHERE kind = 'group' AND parent_key = ?1
|
||||
)",
|
||||
[id],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
}
|
||||
|
||||
tx.execute(
|
||||
"UPDATE node SET title = ?1, icon = ?2, bar_color = ?3, view_mode = ?4,
|
||||
preview_limit = ?5, empty_text = ?6
|
||||
WHERE key = ?7",
|
||||
params![title, icon, bar_color, kind, preview_limit, empty_text, id],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
entries::audit(&tx, "category.update", id, &title)?;
|
||||
tx.commit().map_err(map_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove(conn: &mut Connection, id: &str) -> Result<(), StashError> {
|
||||
if id == "local" {
|
||||
return Err(StashError::InvalidDirectory("本地服务不能删除".into()));
|
||||
@@ -116,9 +317,17 @@ mod tests {
|
||||
title TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
view_mode TEXT,
|
||||
icon TEXT,
|
||||
bar_color TEXT,
|
||||
preview_limit INTEGER,
|
||||
empty_text TEXT,
|
||||
sort INTEGER NOT NULL DEFAULT 0,
|
||||
show_in_sidebar INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE TABLE entry (
|
||||
key TEXT PRIMARY KEY,
|
||||
node_key TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE section_block (
|
||||
id INTEGER PRIMARY KEY,
|
||||
node_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
@@ -199,4 +408,119 @@ mod tests {
|
||||
.expect_err("重复应失败");
|
||||
assert!(matches!(error, StashError::Conflict(_)));
|
||||
}
|
||||
|
||||
fn section(id: &str, title: &str, kind: &str) -> NewCategory {
|
||||
NewCategory {
|
||||
id: id.into(),
|
||||
title: title.into(),
|
||||
kind: kind.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updates_fields_and_rejects_bad_values() {
|
||||
let mut conn = db();
|
||||
create(&mut conn, section("books", "书单", "posters")).expect("应能创建");
|
||||
|
||||
update(
|
||||
&mut conn,
|
||||
"books",
|
||||
UpdateCategory {
|
||||
title: Some("读过的书".into()),
|
||||
icon: Some("📚".into()),
|
||||
bar_color: Some("#ff6034".into()),
|
||||
preview_limit: Some(8),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("应能更新");
|
||||
|
||||
let (title, icon, color, limit): (String, Option<String>, Option<String>, Option<i64>) =
|
||||
conn.query_row(
|
||||
"SELECT title, icon, bar_color, preview_limit FROM node WHERE key='books'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||
)
|
||||
.expect("应能查");
|
||||
assert_eq!(title, "读过的书");
|
||||
assert_eq!(icon.as_deref(), Some("📚"));
|
||||
assert_eq!(color.as_deref(), Some("#ff6034"));
|
||||
assert_eq!(limit, Some(8));
|
||||
|
||||
assert!(update(&mut conn, "books", UpdateCategory { bar_color: Some("red".into()), ..Default::default() }).is_err());
|
||||
assert!(update(&mut conn, "books", UpdateCategory { kind: Some("videos".into()), ..Default::default() }).is_err());
|
||||
assert!(update(&mut conn, "local", UpdateCategory { title: Some("x".into()), ..Default::default() }).is_err());
|
||||
assert!(matches!(
|
||||
update(&mut conn, "none", UpdateCategory::default()).expect_err("应不存在"),
|
||||
StashError::NotFound(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_apps_to_posters_moves_card_back_to_section() {
|
||||
let mut conn = db();
|
||||
create(&mut conn, section("books", "书单", "apps")).expect("应能创建");
|
||||
conn.execute(
|
||||
"INSERT INTO node (key, parent_key, title, kind, sort) VALUES ('books-g', 'books', '分组', 'group', 1)",
|
||||
[],
|
||||
)
|
||||
.expect("应能建分组");
|
||||
conn.execute(
|
||||
"INSERT INTO entry (key, node_key) VALUES ('b1', 'books-g')",
|
||||
[],
|
||||
)
|
||||
.expect("应能建条目");
|
||||
conn.execute(
|
||||
"INSERT INTO section_block (node_key, title, sort) VALUES ('books-g', '分组', 1)",
|
||||
[],
|
||||
)
|
||||
.expect("应能建卡片");
|
||||
|
||||
update(&mut conn, "books", UpdateCategory { kind: Some("posters".into()), ..Default::default() })
|
||||
.expect("应能改类型");
|
||||
|
||||
let block: String = conn
|
||||
.query_row("SELECT node_key FROM section_block", [], |row| row.get(0))
|
||||
.expect("应能查");
|
||||
assert_eq!(block, "books", "卡片应挪回分区");
|
||||
let mode: String = conn
|
||||
.query_row("SELECT view_mode FROM node WHERE key='books'", [], |row| row.get(0))
|
||||
.expect("应能查");
|
||||
assert_eq!(mode, "posters");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_posters_to_apps_creates_group_and_moves_entries() {
|
||||
let mut conn = db();
|
||||
create(&mut conn, section("films", "电影", "posters")).expect("应能创建");
|
||||
conn.execute("INSERT INTO entry (key, node_key) VALUES ('f1', 'films')", [])
|
||||
.expect("应能建条目");
|
||||
conn.execute(
|
||||
"INSERT INTO section_block (node_key, title, sort) VALUES ('films', '电影', 1)",
|
||||
[],
|
||||
)
|
||||
.expect("应能建卡片");
|
||||
|
||||
update(&mut conn, "films", UpdateCategory { kind: Some("apps".into()), ..Default::default() })
|
||||
.expect("应能改类型");
|
||||
|
||||
let (entry_node, block_node): (String, String) = conn
|
||||
.query_row(
|
||||
"SELECT (SELECT node_key FROM entry WHERE key='f1'),
|
||||
(SELECT node_key FROM section_block)",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.expect("应能查");
|
||||
assert_eq!(entry_node, "films-all", "条目应挂到默认分组");
|
||||
assert_eq!(block_node, "films-all", "首页卡片应指向默认分组");
|
||||
let groups: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM node WHERE kind='group' AND parent_key='films'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("应能查");
|
||||
assert_eq!(groups, 1);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user