feat(admin): 后台新增条目与分组 CRUD
- POST /api/items + DELETE /api/items/{key}:新增应用/海报/图文,按分区/分组挂载;PATCH 支持 nodeKey 移动
- 分组 CRUD:POST /api/categories/{id}/groups、PATCH/DELETE /api/groups/{id}(删除级联组内条目)
- PATCH /api/categories/{id} 增加 blockNodeKey,首页卡片可选择预览分组
- /admin 新增「内容」Tab(新增/删除条目),「分区」Tab 内联分组管理与卡片预览
- 测试 59 passed(条目创建/移动/删除、分组增删改)
This commit is contained in:
+240
-1
@@ -30,6 +30,23 @@ pub struct UpdateCategory {
|
||||
pub kind: Option<String>,
|
||||
pub preview_limit: Option<i64>,
|
||||
pub empty_text: Option<String>,
|
||||
/// 首页卡片预览哪个节点(分区自己或它下面的分组)
|
||||
pub block_node_key: Option<String>,
|
||||
}
|
||||
|
||||
/// 新建分组(子分类)
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct NewGroup {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// 修改分组:改名 / 侧栏显隐
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateGroup {
|
||||
pub title: Option<String>,
|
||||
pub show_in_sidebar: Option<bool>,
|
||||
}
|
||||
|
||||
fn valid_id(id: &str) -> bool {
|
||||
@@ -280,11 +297,172 @@ pub fn update(conn: &mut Connection, id: &str, patch: UpdateCategory) -> Result<
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
// 首页卡片预览哪个节点:分区自己或它下面的分组
|
||||
if let Some(block_node_key) = patch.block_node_key {
|
||||
let block_node_key = block_node_key.trim().to_string();
|
||||
let valid: i64 = tx
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM node n LEFT JOIN node p ON p.key = n.parent_key
|
||||
WHERE n.key = ?1 AND (n.key = ?2 OR p.key = ?2)",
|
||||
params![block_node_key, id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
if valid == 0 {
|
||||
return Err(StashError::InvalidDirectory(
|
||||
"首页卡片只能预览本分区或它的分组".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let updated = tx
|
||||
.execute(
|
||||
"UPDATE section_block SET node_key = ?1, title = NULL
|
||||
WHERE id IN (
|
||||
SELECT b.id FROM section_block b
|
||||
JOIN node n ON n.key = b.node_key
|
||||
LEFT JOIN node p ON p.key = n.parent_key
|
||||
WHERE COALESCE(p.key, n.key) = ?2
|
||||
)",
|
||||
params![block_node_key, id],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
if updated == 0 {
|
||||
let sort: i64 = tx
|
||||
.query_row("SELECT COALESCE(MAX(sort), 0) + 1 FROM section_block", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.map_err(map_error)?;
|
||||
tx.execute(
|
||||
"INSERT INTO section_block (node_key, title, preview_limit, sort)
|
||||
VALUES (?1, NULL, NULL, ?2)",
|
||||
params![block_node_key, sort],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
}
|
||||
}
|
||||
|
||||
entries::audit(&tx, "category.update", id, &title)?;
|
||||
tx.commit().map_err(map_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 在应用类分区下新建分组(子分类)。
|
||||
pub fn create_group(conn: &mut Connection, section_id: &str, new: NewGroup) -> Result<(), StashError> {
|
||||
let id = new.id.trim();
|
||||
let title = new.title.trim();
|
||||
|
||||
if !valid_id(id) {
|
||||
return Err(StashError::InvalidDirectory(
|
||||
"分组标识只能用小写字母、数字与连字符(≤32)".into(),
|
||||
));
|
||||
}
|
||||
if title.is_empty() || title.chars().count() > 40 {
|
||||
return Err(StashError::InvalidDirectory("分组标题需为 1–40 个字".into()));
|
||||
}
|
||||
|
||||
let view_mode: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT view_mode FROM node WHERE key = ?1 AND kind = 'section'",
|
||||
[section_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(map_error)?;
|
||||
|
||||
let Some(view_mode) = view_mode else {
|
||||
return Err(StashError::NotFound(format!("分区不存在: {section_id}")));
|
||||
};
|
||||
if view_mode != "apps" {
|
||||
return Err(StashError::InvalidDirectory(
|
||||
"只有应用类分区可以添加分组".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let exists: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM node WHERE key = ?1", [id], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.map_err(map_error)?;
|
||||
if exists > 0 {
|
||||
return Err(StashError::Conflict(format!("分组已存在: {id}")));
|
||||
}
|
||||
|
||||
let sort: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1",
|
||||
[section_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO node (key, parent_key, title, kind, view_mode, sort, show_in_sidebar)
|
||||
VALUES (?1, ?2, ?3, 'group', NULL, ?4, 1)",
|
||||
params![id, section_id, title, sort],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
entries::audit(conn, "group.create", id, title)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 修改分组:改名 / 侧栏显隐。
|
||||
pub fn update_group(conn: &Connection, id: &str, patch: UpdateGroup) -> Result<(), StashError> {
|
||||
let current: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT title FROM node WHERE key = ?1 AND kind = 'group'",
|
||||
[id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.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,
|
||||
};
|
||||
|
||||
match patch.show_in_sidebar {
|
||||
Some(show) => conn.execute(
|
||||
"UPDATE node SET title = ?1, show_in_sidebar = ?2 WHERE key = ?3",
|
||||
params![title, show, id],
|
||||
),
|
||||
None => conn.execute(
|
||||
"UPDATE node SET title = ?1 WHERE key = ?2",
|
||||
params![title, id],
|
||||
),
|
||||
}
|
||||
.map_err(map_error)?;
|
||||
|
||||
entries::audit(conn, "group.update", id, &title)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除分组:组内条目会一起删除(前端需二次确认)。
|
||||
pub fn remove_group(conn: &Connection, id: &str) -> Result<(), StashError> {
|
||||
let removed = conn
|
||||
.execute("DELETE FROM node WHERE key = ?1 AND kind = 'group'", [id])
|
||||
.map_err(map_error)?;
|
||||
|
||||
if removed == 0 {
|
||||
return Err(StashError::NotFound(format!("分组不存在: {id}")));
|
||||
}
|
||||
|
||||
entries::audit(conn, "group.delete", id, "")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove(conn: &mut Connection, id: &str) -> Result<(), StashError> {
|
||||
if id == "local" {
|
||||
return Err(StashError::InvalidDirectory("本地服务不能删除".into()));
|
||||
@@ -490,8 +668,69 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_posters_to_apps_creates_group_and_moves_entries() {
|
||||
fn creates_updates_and_removes_group() {
|
||||
let mut conn = db();
|
||||
create(&mut conn, section("apps1", "应用", "apps")).expect("应能创建");
|
||||
|
||||
create_group(
|
||||
&mut conn,
|
||||
"apps1",
|
||||
NewGroup {
|
||||
id: "g1".into(),
|
||||
title: "常用".into(),
|
||||
},
|
||||
)
|
||||
.expect("应能建分组");
|
||||
|
||||
update_group(
|
||||
&conn,
|
||||
"g1",
|
||||
UpdateGroup {
|
||||
title: Some("改名".into()),
|
||||
show_in_sidebar: Some(false),
|
||||
},
|
||||
)
|
||||
.expect("应能改分组");
|
||||
|
||||
let (title, show): (String, i64) = conn
|
||||
.query_row(
|
||||
"SELECT title, show_in_sidebar FROM node WHERE key='g1'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.expect("应能查");
|
||||
assert_eq!(title, "改名");
|
||||
assert_eq!(show, 0);
|
||||
|
||||
remove_group(&conn, "g1").expect("应能删分组");
|
||||
let left: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM node WHERE key='g1'", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.expect("应能查");
|
||||
assert_eq!(left, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_only_under_apps_sections() {
|
||||
let mut conn = db();
|
||||
create(&mut conn, section("films", "电影", "posters")).expect("应能创建");
|
||||
assert!(
|
||||
create_group(
|
||||
&mut conn,
|
||||
"films",
|
||||
NewGroup {
|
||||
id: "g1".into(),
|
||||
title: "分组".into()
|
||||
}
|
||||
)
|
||||
.is_err(),
|
||||
"海报分区不能建分组"
|
||||
);
|
||||
}
|
||||
|
||||
#[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("应能建条目");
|
||||
|
||||
+232
-4
@@ -53,6 +53,18 @@ pub struct EntryPatch {
|
||||
pub body: String,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// 可选:把条目移动到另一个分区 / 分组(不传则保持原位)
|
||||
#[serde(default)]
|
||||
pub node_key: Option<String>,
|
||||
}
|
||||
|
||||
/// 新建条目的请求体:`key` 是业务键,`nodeKey` 决定挂在哪个分区 / 分组。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NewEntry {
|
||||
pub key: String,
|
||||
pub node_key: String,
|
||||
pub item: EntryPatch,
|
||||
}
|
||||
|
||||
fn now() -> i64 {
|
||||
@@ -210,9 +222,150 @@ pub fn load(conn: &Connection, key: &str) -> Result<Option<EntryRecord>, StashEr
|
||||
Ok(Some(entry))
|
||||
}
|
||||
|
||||
/// 条目能挂到哪:非应用分区本身,或应用分区下的分组。
|
||||
fn validate_target(conn: &Connection, node_key: &str) -> Result<(), StashError> {
|
||||
let row: Option<(String, Option<String>, Option<String>)> = conn
|
||||
.query_row(
|
||||
"SELECT n.kind, n.view_mode, p.view_mode
|
||||
FROM node n LEFT JOIN node p ON p.key = n.parent_key
|
||||
WHERE n.key = ?1",
|
||||
[node_key],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.optional()
|
||||
.map_err(map_error)?;
|
||||
|
||||
let Some((kind, view_mode, parent_mode)) = row else {
|
||||
return Err(StashError::NotFound(format!("节点不存在: {node_key}")));
|
||||
};
|
||||
|
||||
match kind.as_str() {
|
||||
"group" if parent_mode.as_deref() == Some("apps") => Ok(()),
|
||||
"group" => Err(StashError::InvalidDirectory(
|
||||
"分组只能挂在应用类分区下".into(),
|
||||
)),
|
||||
"section" if view_mode.as_deref() == Some("apps") => Err(StashError::InvalidDirectory(
|
||||
"应用类条目必须选择一个分组".into(),
|
||||
)),
|
||||
"section" => Ok(()),
|
||||
_ => Err(StashError::InvalidDirectory(
|
||||
"条目只能挂在分区或分组下".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_key(key: &str) -> bool {
|
||||
!key.is_empty()
|
||||
&& key.len() <= 64
|
||||
&& key
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
|
||||
}
|
||||
|
||||
fn write_tags(conn: &Connection, key: &str, tags: &[String]) -> Result<(), StashError> {
|
||||
conn.execute("DELETE FROM entry_tag WHERE entry_key = ?1", [key])
|
||||
.map_err(map_error)?;
|
||||
for (index, tag) in tags.iter().enumerate() {
|
||||
conn.execute(
|
||||
"INSERT INTO entry_tag (entry_key, sort, label) VALUES (?1, ?2, ?3)",
|
||||
params![key, index as i64, tag],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 新建条目:用于后台「内容」页添加网址 / 海报 / 图文。
|
||||
pub fn create(conn: &mut Connection, new: NewEntry) -> Result<EntryRecord, StashError> {
|
||||
let key = new.key.trim().to_string();
|
||||
if !valid_key(&key) {
|
||||
return Err(StashError::InvalidDirectory(
|
||||
"标识只能用小写字母、数字与连字符(≤64)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let patch = new.item;
|
||||
validate(&patch)?;
|
||||
let href = normalize_href(&patch.href)?;
|
||||
validate_target(conn, &new.node_key)?;
|
||||
|
||||
let exists: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM entry WHERE key = ?1", [&key], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.map_err(map_error)?;
|
||||
if exists > 0 {
|
||||
return Err(StashError::Conflict(format!("条目已存在: {key}")));
|
||||
}
|
||||
|
||||
let tags: Vec<String> = patch
|
||||
.tags
|
||||
.iter()
|
||||
.map(|tag| tag.trim().to_string())
|
||||
.filter(|tag| !tag.is_empty())
|
||||
.collect();
|
||||
|
||||
let timestamp = now();
|
||||
let tx = conn.transaction().map_err(map_error)?;
|
||||
|
||||
let sort: i64 = tx
|
||||
.query_row(
|
||||
"SELECT COALESCE(MAX(sort), 0) + 1 FROM entry WHERE node_key = ?1",
|
||||
[&new.node_key],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO entry (key, node_key, name, description, icon, href, cover, banner,
|
||||
badge, comment, summary, body, hot, views, likes, sort, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 0, 0, 0, ?13, ?14)",
|
||||
params![
|
||||
key,
|
||||
new.node_key,
|
||||
patch.name.trim(),
|
||||
patch.description,
|
||||
patch.icon,
|
||||
href,
|
||||
patch.cover,
|
||||
patch.banner,
|
||||
patch.badge,
|
||||
patch.comment,
|
||||
patch.summary,
|
||||
patch.body,
|
||||
sort,
|
||||
timestamp
|
||||
],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
write_tags(&tx, &key, &tags)?;
|
||||
tx.execute(
|
||||
"INSERT INTO audit (at, action, target, detail) VALUES (?1, 'item.create', ?2, ?3)",
|
||||
params![timestamp, key, patch.name.trim()],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
tx.commit().map_err(map_error)?;
|
||||
load(conn, &key)?
|
||||
.ok_or_else(|| StashError::NotFound(format!("条目不存在: {key}")))
|
||||
}
|
||||
|
||||
/// 删除条目(后台内容页用)。
|
||||
pub fn remove(conn: &Connection, key: &str) -> Result<(), StashError> {
|
||||
let removed = conn
|
||||
.execute("DELETE FROM entry WHERE key = ?1", [key])
|
||||
.map_err(map_error)?;
|
||||
if removed == 0 {
|
||||
return Err(StashError::NotFound(format!("条目不存在: {key}")));
|
||||
}
|
||||
|
||||
audit(conn, "item.delete", key, "")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新条目;`expected_updated_at` 与库里不一致时冲突。
|
||||
pub fn update(
|
||||
conn: &mut Connection,
|
||||
pub fn update( conn: &mut Connection,
|
||||
key: &str,
|
||||
patch: EntryPatch,
|
||||
expected_updated_at: i64,
|
||||
@@ -220,6 +373,9 @@ pub fn update(
|
||||
validate(&patch)?;
|
||||
// 跳转地址先规范化:补 https、拒绝 javascript: 等危险协议
|
||||
let href = normalize_href(&patch.href)?;
|
||||
if let Some(node_key) = patch.node_key.as_deref() {
|
||||
validate_target(conn, node_key)?;
|
||||
}
|
||||
|
||||
let tags: Vec<String> = patch
|
||||
.tags
|
||||
@@ -236,7 +392,8 @@ pub fn update(
|
||||
"UPDATE entry SET
|
||||
name = ?1, description = ?2, icon = ?3, href = ?4,
|
||||
cover = ?5, banner = ?6, badge = ?7, comment = ?8,
|
||||
summary = ?9, body = ?10, updated_at = ?11
|
||||
summary = ?9, body = ?10, updated_at = ?11,
|
||||
node_key = COALESCE(?14, node_key)
|
||||
WHERE key = ?12 AND updated_at = ?13",
|
||||
params![
|
||||
patch.name.trim(),
|
||||
@@ -251,7 +408,8 @@ pub fn update(
|
||||
patch.body,
|
||||
timestamp,
|
||||
key,
|
||||
expected_updated_at
|
||||
expected_updated_at,
|
||||
patch.node_key
|
||||
],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
@@ -351,6 +509,17 @@ mod tests {
|
||||
target TEXT,
|
||||
detail TEXT
|
||||
);
|
||||
CREATE TABLE node (
|
||||
key TEXT PRIMARY KEY,
|
||||
parent_key TEXT,
|
||||
title TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
view_mode TEXT
|
||||
);
|
||||
INSERT INTO node (key, parent_key, title, kind, view_mode) VALUES
|
||||
('movie', NULL, '推荐电影', 'section', 'posters'),
|
||||
('apps1', NULL, '常用推荐', 'section', 'apps'),
|
||||
('g1', 'apps1', '常用站点', 'group', NULL);
|
||||
INSERT INTO entry (key, node_key, name, updated_at) VALUES ('movie-1', 'movie', '千与千寻', 0);",
|
||||
)
|
||||
.expect("应能建表");
|
||||
@@ -370,6 +539,7 @@ mod tests {
|
||||
summary: "正文".into(),
|
||||
body: "## 标题".into(),
|
||||
tags: vec!["推荐电影".into(), "动画".into()],
|
||||
node_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,4 +610,62 @@ mod tests {
|
||||
let saved = update(&mut conn, "movie-1", input, 0).expect("应能更新");
|
||||
assert_eq!(saved.href, "https://www.zhihu.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_entry_under_section_or_group() {
|
||||
let mut conn = db();
|
||||
let saved = create(
|
||||
&mut conn,
|
||||
NewEntry {
|
||||
key: "new-site".into(),
|
||||
node_key: "g1".into(),
|
||||
item: patch("新站点"),
|
||||
},
|
||||
)
|
||||
.expect("应能创建");
|
||||
assert_eq!(saved.key, "new-site");
|
||||
assert_eq!(saved.name, "新站点");
|
||||
|
||||
let node: String = conn
|
||||
.query_row("SELECT node_key FROM entry WHERE key='new-site'", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.expect("应能查");
|
||||
assert_eq!(node, "g1");
|
||||
|
||||
// 应用分区不能直接挂条目,必须先选分组
|
||||
assert!(
|
||||
create(
|
||||
&mut conn,
|
||||
NewEntry {
|
||||
key: "bad-site".into(),
|
||||
node_key: "apps1".into(),
|
||||
item: patch("错误"),
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_entry_between_nodes() {
|
||||
let mut conn = db();
|
||||
let mut input = patch("搬家");
|
||||
input.node_key = Some("g1".into());
|
||||
update(&mut conn, "movie-1", input, 0).expect("应能移动");
|
||||
|
||||
let node: String = conn
|
||||
.query_row("SELECT node_key FROM entry WHERE key='movie-1'", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.expect("应能查");
|
||||
assert_eq!(node, "g1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletes_entry() {
|
||||
let conn = db();
|
||||
remove(&conn, "movie-1").expect("应能删除");
|
||||
assert!(remove(&conn, "movie-1").is_err(), "重复删除应失败");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +185,16 @@ impl Db {
|
||||
entries::load(&conn, key)
|
||||
}
|
||||
|
||||
pub fn create_entry(&self, new: entries::NewEntry) -> Result<EntryRecord, StashError> {
|
||||
let mut conn = self.lock()?;
|
||||
entries::create(&mut conn, new)
|
||||
}
|
||||
|
||||
pub fn remove_entry(&self, key: &str) -> Result<(), StashError> {
|
||||
let conn = self.lock()?;
|
||||
entries::remove(&conn, key)
|
||||
}
|
||||
|
||||
pub fn update_entry(
|
||||
&self,
|
||||
key: &str,
|
||||
@@ -221,6 +231,25 @@ impl Db {
|
||||
categories::update(&mut conn, id, patch)
|
||||
}
|
||||
|
||||
pub fn create_group(
|
||||
&self,
|
||||
section_id: &str,
|
||||
new: categories::NewGroup,
|
||||
) -> Result<(), StashError> {
|
||||
let mut conn = self.lock()?;
|
||||
categories::create_group(&mut conn, section_id, new)
|
||||
}
|
||||
|
||||
pub fn update_group(&self, id: &str, patch: categories::UpdateGroup) -> Result<(), StashError> {
|
||||
let conn = self.lock()?;
|
||||
categories::update_group(&conn, id, patch)
|
||||
}
|
||||
|
||||
pub fn remove_group(&self, id: &str) -> Result<(), StashError> {
|
||||
let conn = self.lock()?;
|
||||
categories::remove_group(&conn, id)
|
||||
}
|
||||
|
||||
pub fn remove_category(&self, id: &str) -> Result<(), StashError> {
|
||||
let mut conn = self.lock()?;
|
||||
categories::remove(&mut conn, id)
|
||||
|
||||
+140
-1
@@ -718,6 +718,58 @@ async fn post_like(
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/items —— 新建条目(需要登录)
|
||||
async fn create_item(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<crate::db::entries::NewEntry>,
|
||||
) -> Response {
|
||||
if !same_origin(&headers) {
|
||||
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
|
||||
}
|
||||
match read_session(&state, &headers).await {
|
||||
Ok(session) if session.authenticated => {}
|
||||
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
|
||||
Err(response) => return response,
|
||||
}
|
||||
|
||||
let db = Arc::clone(&state.db);
|
||||
match tokio::task::spawn_blocking(move || db.create_entry(body)).await {
|
||||
Ok(Ok(entry)) => Json(entry).into_response(),
|
||||
Ok(Err(StashError::Conflict(message))) => api_error(StatusCode::CONFLICT, &message),
|
||||
Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message),
|
||||
Ok(Err(StashError::InvalidDirectory(message))) => {
|
||||
api_error(StatusCode::BAD_REQUEST, &message)
|
||||
}
|
||||
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /api/items/{key} —— 删除条目(需要登录)
|
||||
async fn delete_item(
|
||||
State(state): State<AppState>,
|
||||
Path(key): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
if !same_origin(&headers) {
|
||||
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
|
||||
}
|
||||
match read_session(&state, &headers).await {
|
||||
Ok(session) if session.authenticated => {}
|
||||
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
|
||||
Err(response) => return response,
|
||||
}
|
||||
|
||||
let db = Arc::clone(&state.db);
|
||||
match tokio::task::spawn_blocking(move || db.remove_entry(&key)).await {
|
||||
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
|
||||
Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message),
|
||||
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/categories —— 新建分区(需要登录)
|
||||
async fn create_category(
|
||||
State(state): State<AppState>,
|
||||
@@ -773,6 +825,90 @@ async fn update_category(
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/categories/{id}/groups —— 新建分组(需要登录)
|
||||
async fn create_group(
|
||||
State(state): State<AppState>,
|
||||
Path(section_id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<crate::db::categories::NewGroup>,
|
||||
) -> Response {
|
||||
if !same_origin(&headers) {
|
||||
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
|
||||
}
|
||||
match read_session(&state, &headers).await {
|
||||
Ok(session) if session.authenticated => {}
|
||||
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
|
||||
Err(response) => return response,
|
||||
}
|
||||
|
||||
let db = Arc::clone(&state.db);
|
||||
match tokio::task::spawn_blocking(move || db.create_group(§ion_id, body)).await {
|
||||
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
|
||||
Ok(Err(StashError::Conflict(message))) => api_error(StatusCode::CONFLICT, &message),
|
||||
Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message),
|
||||
Ok(Err(StashError::InvalidDirectory(message))) => {
|
||||
api_error(StatusCode::BAD_REQUEST, &message)
|
||||
}
|
||||
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// PATCH /api/groups/{id} —— 分组改名 / 显隐(需要登录)
|
||||
async fn update_group(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<crate::db::categories::UpdateGroup>,
|
||||
) -> Response {
|
||||
if !same_origin(&headers) {
|
||||
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
|
||||
}
|
||||
match read_session(&state, &headers).await {
|
||||
Ok(session) if session.authenticated => {}
|
||||
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
|
||||
Err(response) => return response,
|
||||
}
|
||||
|
||||
let db = Arc::clone(&state.db);
|
||||
match tokio::task::spawn_blocking(move || db.update_group(&id, body)).await {
|
||||
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
|
||||
Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message),
|
||||
Ok(Err(StashError::InvalidDirectory(message))) => {
|
||||
api_error(StatusCode::BAD_REQUEST, &message)
|
||||
}
|
||||
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /api/groups/{id} —— 删除分组(需要登录,组内条目级联删除)
|
||||
async fn delete_group(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
if !same_origin(&headers) {
|
||||
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
|
||||
}
|
||||
match read_session(&state, &headers).await {
|
||||
Ok(session) if session.authenticated => {}
|
||||
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
|
||||
Err(response) => return response,
|
||||
}
|
||||
|
||||
let db = Arc::clone(&state.db);
|
||||
match tokio::task::spawn_blocking(move || db.remove_group(&id)).await {
|
||||
Ok(Ok(())) => Json(serde_json::json!({ "ok": true })).into_response(),
|
||||
Ok(Err(StashError::NotFound(message))) => api_error(StatusCode::NOT_FOUND, &message),
|
||||
Ok(Err(StashError::InvalidDirectory(message))) => {
|
||||
api_error(StatusCode::BAD_REQUEST, &message)
|
||||
}
|
||||
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /api/categories/{id} —— 删除分区(需要登录,级联内容)
|
||||
async fn delete_category(
|
||||
State(state): State<AppState>,
|
||||
@@ -826,7 +962,8 @@ fn build_router(state: AppState) -> Router {
|
||||
.route("/api/auth/logout", post(auth_logout))
|
||||
.route("/api/auth/password", patch(auth_password))
|
||||
.route("/api/settings", get(get_settings).patch(patch_settings))
|
||||
.route("/api/items/{key}", get(get_item).patch(patch_item))
|
||||
.route("/api/items/{key}", get(get_item).patch(patch_item).delete(delete_item))
|
||||
.route("/api/items", post(create_item))
|
||||
.route("/api/items/{key}/view", post(post_view))
|
||||
.route("/api/items/{key}/like", post(post_like))
|
||||
.route("/api/categories", post(create_category))
|
||||
@@ -834,6 +971,8 @@ fn build_router(state: AppState) -> Router {
|
||||
"/api/categories/{id}",
|
||||
patch(update_category).delete(delete_category),
|
||||
)
|
||||
.route("/api/categories/{id}/groups", post(create_group))
|
||||
.route("/api/groups/{id}", patch(update_group).delete(delete_group))
|
||||
.route("/api/backup", get(backup_db))
|
||||
.route("/", get(web_index))
|
||||
.route("/index.html", get(web_index))
|
||||
|
||||
Reference in New Issue
Block a user