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 kind: Option<String>,
|
||||||
pub preview_limit: Option<i64>,
|
pub preview_limit: Option<i64>,
|
||||||
pub empty_text: Option<String>,
|
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 {
|
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)?;
|
.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)?;
|
entries::audit(&tx, "category.update", id, &title)?;
|
||||||
tx.commit().map_err(map_error)?;
|
tx.commit().map_err(map_error)?;
|
||||||
Ok(())
|
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> {
|
pub fn remove(conn: &mut Connection, id: &str) -> Result<(), StashError> {
|
||||||
if id == "local" {
|
if id == "local" {
|
||||||
return Err(StashError::InvalidDirectory("本地服务不能删除".into()));
|
return Err(StashError::InvalidDirectory("本地服务不能删除".into()));
|
||||||
@@ -490,8 +668,69 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn switching_posters_to_apps_creates_group_and_moves_entries() {
|
fn creates_updates_and_removes_group() {
|
||||||
let mut conn = db();
|
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("应能创建");
|
create(&mut conn, section("films", "电影", "posters")).expect("应能创建");
|
||||||
conn.execute("INSERT INTO entry (key, node_key) VALUES ('f1', 'films')", [])
|
conn.execute("INSERT INTO entry (key, node_key) VALUES ('f1', 'films')", [])
|
||||||
.expect("应能建条目");
|
.expect("应能建条目");
|
||||||
|
|||||||
+232
-4
@@ -53,6 +53,18 @@ pub struct EntryPatch {
|
|||||||
pub body: String,
|
pub body: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tags: Vec<String>,
|
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 {
|
fn now() -> i64 {
|
||||||
@@ -210,9 +222,150 @@ pub fn load(conn: &Connection, key: &str) -> Result<Option<EntryRecord>, StashEr
|
|||||||
Ok(Some(entry))
|
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` 与库里不一致时冲突。
|
/// 更新条目;`expected_updated_at` 与库里不一致时冲突。
|
||||||
pub fn update(
|
pub fn update( conn: &mut Connection,
|
||||||
conn: &mut Connection,
|
|
||||||
key: &str,
|
key: &str,
|
||||||
patch: EntryPatch,
|
patch: EntryPatch,
|
||||||
expected_updated_at: i64,
|
expected_updated_at: i64,
|
||||||
@@ -220,6 +373,9 @@ pub fn update(
|
|||||||
validate(&patch)?;
|
validate(&patch)?;
|
||||||
// 跳转地址先规范化:补 https、拒绝 javascript: 等危险协议
|
// 跳转地址先规范化:补 https、拒绝 javascript: 等危险协议
|
||||||
let href = normalize_href(&patch.href)?;
|
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
|
let tags: Vec<String> = patch
|
||||||
.tags
|
.tags
|
||||||
@@ -236,7 +392,8 @@ pub fn update(
|
|||||||
"UPDATE entry SET
|
"UPDATE entry SET
|
||||||
name = ?1, description = ?2, icon = ?3, href = ?4,
|
name = ?1, description = ?2, icon = ?3, href = ?4,
|
||||||
cover = ?5, banner = ?6, badge = ?7, comment = ?8,
|
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",
|
WHERE key = ?12 AND updated_at = ?13",
|
||||||
params![
|
params![
|
||||||
patch.name.trim(),
|
patch.name.trim(),
|
||||||
@@ -251,7 +408,8 @@ pub fn update(
|
|||||||
patch.body,
|
patch.body,
|
||||||
timestamp,
|
timestamp,
|
||||||
key,
|
key,
|
||||||
expected_updated_at
|
expected_updated_at,
|
||||||
|
patch.node_key
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(map_error)?;
|
.map_err(map_error)?;
|
||||||
@@ -351,6 +509,17 @@ mod tests {
|
|||||||
target TEXT,
|
target TEXT,
|
||||||
detail 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);",
|
INSERT INTO entry (key, node_key, name, updated_at) VALUES ('movie-1', 'movie', '千与千寻', 0);",
|
||||||
)
|
)
|
||||||
.expect("应能建表");
|
.expect("应能建表");
|
||||||
@@ -370,6 +539,7 @@ mod tests {
|
|||||||
summary: "正文".into(),
|
summary: "正文".into(),
|
||||||
body: "## 标题".into(),
|
body: "## 标题".into(),
|
||||||
tags: vec!["推荐电影".into(), "动画".into()],
|
tags: vec!["推荐电影".into(), "动画".into()],
|
||||||
|
node_key: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,4 +610,62 @@ mod tests {
|
|||||||
let saved = update(&mut conn, "movie-1", input, 0).expect("应能更新");
|
let saved = update(&mut conn, "movie-1", input, 0).expect("应能更新");
|
||||||
assert_eq!(saved.href, "https://www.zhihu.com");
|
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)
|
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(
|
pub fn update_entry(
|
||||||
&self,
|
&self,
|
||||||
key: &str,
|
key: &str,
|
||||||
@@ -221,6 +231,25 @@ impl Db {
|
|||||||
categories::update(&mut conn, id, patch)
|
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> {
|
pub fn remove_category(&self, id: &str) -> Result<(), StashError> {
|
||||||
let mut conn = self.lock()?;
|
let mut conn = self.lock()?;
|
||||||
categories::remove(&mut conn, id)
|
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 —— 新建分区(需要登录)
|
/// POST /api/categories —— 新建分区(需要登录)
|
||||||
async fn create_category(
|
async fn create_category(
|
||||||
State(state): State<AppState>,
|
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} —— 删除分区(需要登录,级联内容)
|
/// DELETE /api/categories/{id} —— 删除分区(需要登录,级联内容)
|
||||||
async fn delete_category(
|
async fn delete_category(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
@@ -826,7 +962,8 @@ fn build_router(state: AppState) -> Router {
|
|||||||
.route("/api/auth/logout", post(auth_logout))
|
.route("/api/auth/logout", post(auth_logout))
|
||||||
.route("/api/auth/password", patch(auth_password))
|
.route("/api/auth/password", patch(auth_password))
|
||||||
.route("/api/settings", get(get_settings).patch(patch_settings))
|
.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}/view", post(post_view))
|
||||||
.route("/api/items/{key}/like", post(post_like))
|
.route("/api/items/{key}/like", post(post_like))
|
||||||
.route("/api/categories", post(create_category))
|
.route("/api/categories", post(create_category))
|
||||||
@@ -834,6 +971,8 @@ fn build_router(state: AppState) -> Router {
|
|||||||
"/api/categories/{id}",
|
"/api/categories/{id}",
|
||||||
patch(update_category).delete(delete_category),
|
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("/api/backup", get(backup_db))
|
||||||
.route("/", get(web_index))
|
.route("/", get(web_index))
|
||||||
.route("/index.html", get(web_index))
|
.route("/index.html", get(web_index))
|
||||||
|
|||||||
@@ -980,6 +980,62 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 分区管理:一个分区一块,下面挂卡片预览与子分组 */
|
||||||
|
.admin-section-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--su-space-3);
|
||||||
|
padding-bottom: var(--su-space-4);
|
||||||
|
border-bottom: 1px dashed var(--su-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-block-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--su-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-groups {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--su-space-2);
|
||||||
|
padding-left: var(--su-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-group-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--su-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-new-group-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 190px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--su-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容页:新增条目表单与条目列表 */
|
||||||
|
.admin-content-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--su-space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-content-form .admin-col-span {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-entry-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 140px auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--su-space-2);
|
||||||
|
padding: var(--su-space-2) 0;
|
||||||
|
border-bottom: 1px solid var(--su-border);
|
||||||
|
}
|
||||||
|
|
||||||
/* 删除按钮:可删的用危险色,不可删的用弱化色(都取主题令牌) */
|
/* 删除按钮:可删的用危险色,不可删的用弱化色(都取主题令牌) */
|
||||||
.admin-item-actions .admin-remove {
|
.admin-item-actions .admin-remove {
|
||||||
color: var(--su-color-danger);
|
color: var(--su-color-danger);
|
||||||
|
|||||||
@@ -22,11 +22,14 @@ import { changePassword, fetchSession, logout, type SessionState } from '../../a
|
|||||||
import { LoginDialog } from '../auth/LoginDialog'
|
import { LoginDialog } from '../auth/LoginDialog'
|
||||||
import {
|
import {
|
||||||
createCategory,
|
createCategory,
|
||||||
|
createGroup,
|
||||||
deleteCategory,
|
deleteCategory,
|
||||||
|
deleteGroup,
|
||||||
fetchSettings,
|
fetchSettings,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
TONE_OPTIONS,
|
TONE_OPTIONS,
|
||||||
updateCategory,
|
updateCategory,
|
||||||
|
updateGroup,
|
||||||
type HeaderItem,
|
type HeaderItem,
|
||||||
type ProfileSettings,
|
type ProfileSettings,
|
||||||
type SectionSettings,
|
type SectionSettings,
|
||||||
@@ -34,6 +37,7 @@ import {
|
|||||||
type SettingsTone,
|
type SettingsTone,
|
||||||
type SiteSettings,
|
type SiteSettings,
|
||||||
} from '../../settings'
|
} from '../../settings'
|
||||||
|
import { createItem, deleteItem } from '../../items'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 站点设置页(/admin):页头滚动信息 + 侧栏顺序/显隐。
|
* 站点设置页(/admin):页头滚动信息 + 侧栏顺序/显隐。
|
||||||
@@ -41,11 +45,18 @@ import {
|
|||||||
* 只做「全局配置」;条目内容仍在详情页就地编辑(P2)。
|
* 只做「全局配置」;条目内容仍在详情页就地编辑(P2)。
|
||||||
* 保存用 `version` 乐观锁,冲突时提示刷新。
|
* 保存用 `version` 乐观锁,冲突时提示刷新。
|
||||||
*/
|
*/
|
||||||
|
type EntrySummary = {
|
||||||
|
id: string
|
||||||
|
name?: string
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
type CategoryGroupSummary = {
|
type CategoryGroupSummary = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
/** 数据库里的默认显隐;缺省视为显示 */
|
/** 数据库里的默认显隐;缺省视为显示 */
|
||||||
showInSidebar?: boolean
|
showInSidebar?: boolean
|
||||||
|
items?: EntrySummary[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type CategorySummary = {
|
type CategorySummary = {
|
||||||
@@ -55,10 +66,12 @@ type CategorySummary = {
|
|||||||
icon?: string
|
icon?: string
|
||||||
barColor?: string
|
barColor?: string
|
||||||
previewLimit?: number
|
previewLimit?: number
|
||||||
/** 首页卡片标题(配了展示块时用块的标题,如「常用站点」) */
|
/** 首页卡片标题与预览节点 */
|
||||||
block?: { title?: string } | null
|
block?: { title?: string; nodeKey?: string } | null
|
||||||
/** 应用类分区下的分组(子分类) */
|
/** 应用类分区下的分组(子分类) */
|
||||||
groups?: CategoryGroupSummary[]
|
groups?: CategoryGroupSummary[]
|
||||||
|
/** 海报 / 图文类分区直接挂的条目 */
|
||||||
|
items?: EntrySummary[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const clone = (settings: SiteSettings): SiteSettings => structuredClone(settings)
|
const clone = (settings: SiteSettings): SiteSettings => structuredClone(settings)
|
||||||
@@ -187,7 +200,8 @@ export function AdminPage() {
|
|||||||
items={[
|
items={[
|
||||||
{ key: 'header', label: '页头滚动信息' },
|
{ key: 'header', label: '页头滚动信息' },
|
||||||
{ key: 'sections', label: '首页卡片' },
|
{ key: 'sections', label: '首页卡片' },
|
||||||
{ key: 'categories', label: '归类' },
|
{ key: 'content', label: '内容' },
|
||||||
|
{ key: 'categories', label: '分区' },
|
||||||
{ key: 'sidebar', label: '侧栏导航' },
|
{ key: 'sidebar', label: '侧栏导航' },
|
||||||
{ key: 'profile', label: '资料' },
|
{ key: 'profile', label: '资料' },
|
||||||
{ key: 'system', label: '系统' },
|
{ key: 'system', label: '系统' },
|
||||||
@@ -209,6 +223,8 @@ export function AdminPage() {
|
|||||||
onDelete={requestDelete}
|
onDelete={requestDelete}
|
||||||
onReload={load}
|
onReload={load}
|
||||||
/>
|
/>
|
||||||
|
) : tab === 'content' ? (
|
||||||
|
<ContentManager categories={categories} onReload={load} />
|
||||||
) : tab === 'categories' ? (
|
) : tab === 'categories' ? (
|
||||||
<CategoryManager
|
<CategoryManager
|
||||||
categories={categories}
|
categories={categories}
|
||||||
@@ -232,8 +248,8 @@ export function AdminPage() {
|
|||||||
<SystemPanel />
|
<SystemPanel />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 「归类」每行自己保存,不参与整页设置的保存/撤销 */}
|
{/* 「内容」「分区」都是逐项保存,不参与整页设置的保存/撤销 */}
|
||||||
{tab !== 'categories' && (
|
{tab !== 'categories' && tab !== 'content' && (
|
||||||
<Space gap="sm" justify="end" className="admin-actions">
|
<Space gap="sm" justify="end" className="admin-actions">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -709,7 +725,7 @@ function CategoryCreator({ onCreated }: { onCreated: () => void }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 归类(分类管理) ----------
|
// ---------- 分区与分组管理 ----------
|
||||||
|
|
||||||
const KIND_OPTIONS = [
|
const KIND_OPTIONS = [
|
||||||
{ value: 'apps', label: '应用' },
|
{ value: 'apps', label: '应用' },
|
||||||
@@ -727,18 +743,26 @@ function CategoryManager({
|
|||||||
onDelete: (card: CategorySummary) => void
|
onDelete: (card: CategorySummary) => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Space direction="column" gap="sm" align="stretch" className="admin-form">
|
<Space direction="column" gap="lg" align="stretch" className="admin-form">
|
||||||
<Text size="sm" tone="muted">
|
<Text size="sm" tone="muted">
|
||||||
决定每个分区归到哪一类(应用 / 海报 / 图文),以及标题、图标、色条与预览条数。改类型会自动迁移条目:切到「应用」会建默认分组并把条目挪进去。
|
分区决定内容归到哪一类(应用 / 海报 / 图文)。应用类分区下面可以建分组(子分类),
|
||||||
|
分组会出现在侧栏,也可以作为首页卡片的预览来源;改类型会自动迁移条目。
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
|
<div className="admin-section-block" key={category.id}>
|
||||||
<CategoryRow
|
<CategoryRow
|
||||||
key={category.id}
|
|
||||||
category={category}
|
category={category}
|
||||||
onReload={onReload}
|
onReload={onReload}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
|
{category.kind === 'apps' && (
|
||||||
|
<>
|
||||||
|
<BlockSelect section={category} onReload={onReload} />
|
||||||
|
<GroupManager section={category} onReload={onReload} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{categories.length === 0 && <Text tone="muted">内容库还没有加载完成。</Text>}
|
{categories.length === 0 && <Text tone="muted">内容库还没有加载完成。</Text>}
|
||||||
@@ -833,6 +857,512 @@ function CategoryRow({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 首页卡片预览节点 ----------
|
||||||
|
|
||||||
|
function BlockSelect({ section, onReload }: { section: CategorySummary; onReload: () => void }) {
|
||||||
|
const options = [
|
||||||
|
{ value: section.id, label: '整个分区' },
|
||||||
|
...(section.groups ?? []).map((group) => ({ value: group.id, label: group.name })),
|
||||||
|
]
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const change = async (next: string) => {
|
||||||
|
setBusy(true)
|
||||||
|
const result = await updateCategory(section.id, { blockNodeKey: next })
|
||||||
|
setBusy(false)
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.success('首页卡片预览已切换')
|
||||||
|
onReload()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-block-row">
|
||||||
|
<Text size="sm" tone="muted">
|
||||||
|
首页卡片预览
|
||||||
|
</Text>
|
||||||
|
<div style={{ width: 220 }}>
|
||||||
|
<Select
|
||||||
|
options={options}
|
||||||
|
value={section.block?.nodeKey ?? section.id}
|
||||||
|
onChange={(next) => void change(next)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{busy && (
|
||||||
|
<Text size="sm" tone="muted">
|
||||||
|
保存中…
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 分组(子分类)管理 ----------
|
||||||
|
|
||||||
|
function GroupManager({ section, onReload }: { section: CategorySummary; onReload: () => void }) {
|
||||||
|
const groups = section.groups ?? []
|
||||||
|
const [pending, setPending] = useState<CategoryGroupSummary | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (pending === null) return
|
||||||
|
setBusy(true)
|
||||||
|
const result = await deleteGroup(pending.id)
|
||||||
|
setBusy(false)
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.success(`已删除分组「${pending.name}」`)
|
||||||
|
setPending(null)
|
||||||
|
onReload()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-groups">
|
||||||
|
<Text size="sm" tone="muted">
|
||||||
|
子分组:可改名、控制侧栏显隐;删除会连同组内条目一起删除。
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{groups.map((group) => (
|
||||||
|
<GroupRow
|
||||||
|
key={group.id}
|
||||||
|
group={group}
|
||||||
|
onReload={onReload}
|
||||||
|
onDelete={() => setPending(group)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<NewGroupRow sectionId={section.id} onReload={onReload} />
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
open={pending !== null}
|
||||||
|
title={pending === null ? '删除分组' : `删除分组「${pending.name}」?`}
|
||||||
|
description="组内所有条目会一起删除,且不可恢复。"
|
||||||
|
busy={busy}
|
||||||
|
onCancel={() => setPending(null)}
|
||||||
|
onConfirm={() => void remove()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupRow({
|
||||||
|
group,
|
||||||
|
onReload,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
group: CategoryGroupSummary
|
||||||
|
onReload: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
}) {
|
||||||
|
const [title, setTitle] = useState(group.name)
|
||||||
|
const [visible, setVisible] = useState(group.showInSidebar !== false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setBusy(true)
|
||||||
|
const result = await updateGroup(group.id, {
|
||||||
|
title,
|
||||||
|
showInSidebar: visible,
|
||||||
|
})
|
||||||
|
setBusy(false)
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.success(`已保存分组「${title}」`)
|
||||||
|
onReload()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-group-row">
|
||||||
|
<Switch
|
||||||
|
checked={visible}
|
||||||
|
onChange={setVisible}
|
||||||
|
label={`侧栏显示 ${group.name}`}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
aria-label={`${group.name} 名称`}
|
||||||
|
onChange={(event) => setTitle(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button variant="secondary" size="sm" loading={busy} onClick={save}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="admin-remove"
|
||||||
|
icon={<Icon name="delete_2_line" />}
|
||||||
|
aria-label="删除分组"
|
||||||
|
onClick={onDelete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewGroupRow({ sectionId, onReload }: { sectionId: string; onReload: () => void }) {
|
||||||
|
const [id, setId] = useState('')
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setBusy(true)
|
||||||
|
const result = await createGroup(sectionId, { id: id.trim(), title: title.trim() })
|
||||||
|
setBusy(false)
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.success('分组已创建')
|
||||||
|
setId('')
|
||||||
|
setTitle('')
|
||||||
|
onReload()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-new-group-row">
|
||||||
|
<Input
|
||||||
|
value={id}
|
||||||
|
placeholder="分组标识,如 ui-kit"
|
||||||
|
aria-label="新分组标识"
|
||||||
|
onChange={(event) => setId(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
placeholder="分组名称,如 设计资源"
|
||||||
|
aria-label="新分组名称"
|
||||||
|
onChange={(event) => setTitle(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon={<Icon name="add_line" />}
|
||||||
|
loading={busy}
|
||||||
|
disabled={id.trim() === '' || title.trim() === ''}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
新增分组
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 内容(新增条目) ----------
|
||||||
|
|
||||||
|
function ContentManager({
|
||||||
|
categories,
|
||||||
|
onReload,
|
||||||
|
}: {
|
||||||
|
categories: CategorySummary[]
|
||||||
|
onReload: () => void
|
||||||
|
}) {
|
||||||
|
const [sectionId, setSectionId] = useState(categories[0]?.id ?? '')
|
||||||
|
const [groupId, setGroupId] = useState('')
|
||||||
|
const [key, setKey] = useState('')
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [href, setHref] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [icon, setIcon] = useState('')
|
||||||
|
const [cover, setCover] = useState('')
|
||||||
|
const [tags, setTags] = useState('')
|
||||||
|
const [comment, setComment] = useState('')
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [pendingEntry, setPendingEntry] = useState<EntrySummary | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
const section =
|
||||||
|
categories.find((category) => category.id === sectionId) ?? categories[0]
|
||||||
|
const groups = section?.kind === 'apps' ? (section.groups ?? []) : []
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (categories.length === 0) return
|
||||||
|
if (!categories.some((category) => category.id === sectionId)) {
|
||||||
|
setSectionId(categories[0].id)
|
||||||
|
}
|
||||||
|
}, [categories, sectionId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (section?.kind !== 'apps') return
|
||||||
|
if (!groups.some((group) => group.id === groupId)) {
|
||||||
|
setGroupId(groups[0]?.id ?? '')
|
||||||
|
}
|
||||||
|
}, [section, groups, groupId])
|
||||||
|
|
||||||
|
const nodeKey =
|
||||||
|
section === undefined ? '' : section.kind === 'apps' ? groupId : section.id
|
||||||
|
|
||||||
|
const entries =
|
||||||
|
section === undefined
|
||||||
|
? []
|
||||||
|
: section.kind === 'apps'
|
||||||
|
? (section.groups ?? []).flatMap((group) =>
|
||||||
|
(group.items ?? []).map((item) => ({
|
||||||
|
...item,
|
||||||
|
groupName: group.name,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
: (section.items ?? []).map((item) => ({ ...item, groupName: undefined }))
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (section === undefined || nodeKey === '') {
|
||||||
|
toast.error('请先选择分区与分组')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true)
|
||||||
|
const result = await createItem({
|
||||||
|
key: key.trim(),
|
||||||
|
nodeKey,
|
||||||
|
item: {
|
||||||
|
name: name.trim(),
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
tags: tags
|
||||||
|
.split(/[,,]/)
|
||||||
|
.map((tag) => tag.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
badge: '',
|
||||||
|
comment,
|
||||||
|
summary: '',
|
||||||
|
body,
|
||||||
|
cover,
|
||||||
|
banner: '',
|
||||||
|
href,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
setBusy(false)
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(`已添加「${name.trim()}」`)
|
||||||
|
setKey('')
|
||||||
|
setName('')
|
||||||
|
setHref('')
|
||||||
|
setDescription('')
|
||||||
|
setIcon('')
|
||||||
|
setCover('')
|
||||||
|
setTags('')
|
||||||
|
setComment('')
|
||||||
|
setBody('')
|
||||||
|
onReload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeEntry = async () => {
|
||||||
|
if (pendingEntry === null) return
|
||||||
|
setDeleting(true)
|
||||||
|
const result = await deleteItem(pendingEntry.id)
|
||||||
|
setDeleting(false)
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.success('条目已删除')
|
||||||
|
setPendingEntry(null)
|
||||||
|
onReload()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="column" gap="lg" align="stretch" className="admin-form">
|
||||||
|
<Text size="sm" tone="muted">
|
||||||
|
在下面新增网址 / 海报 / 图文条目,并选择它挂在哪个分区、哪个分组(应用类必须选分组)。
|
||||||
|
新增后,分组出现在侧栏与首页卡片的条件是它被设为显示。
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Space gap="sm" wrap>
|
||||||
|
<div style={{ width: 200 }}>
|
||||||
|
<Select
|
||||||
|
options={categories.map((category) => ({
|
||||||
|
value: category.id,
|
||||||
|
label: category.title,
|
||||||
|
}))}
|
||||||
|
value={section?.id ?? ''}
|
||||||
|
onChange={setSectionId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{section?.kind === 'apps' && (
|
||||||
|
<div style={{ width: 220 }}>
|
||||||
|
<Select
|
||||||
|
options={groups.map((group) => ({ value: group.id, label: group.name }))}
|
||||||
|
value={groupId}
|
||||||
|
onChange={setGroupId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section?.kind === 'apps' && groups.length === 0 && (
|
||||||
|
<Text size="sm" tone="muted">
|
||||||
|
这个分区还没有分组,请先到「分区」Tab 新建分组。
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<div className="admin-content-form">
|
||||||
|
<Input
|
||||||
|
value={key}
|
||||||
|
placeholder="标识,如 my-site"
|
||||||
|
aria-label="条目标识"
|
||||||
|
onChange={(event) => setKey(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
placeholder="标题,如 我的站点"
|
||||||
|
aria-label="条目标题"
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={href}
|
||||||
|
placeholder="跳转地址,如 https://example.com 或 /project/"
|
||||||
|
aria-label="跳转地址"
|
||||||
|
onChange={(event) => setHref(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={icon}
|
||||||
|
placeholder="字符图标,如 G"
|
||||||
|
aria-label="图标"
|
||||||
|
onChange={(event) => setIcon(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={description}
|
||||||
|
placeholder="一句话简介"
|
||||||
|
aria-label="简介"
|
||||||
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={cover}
|
||||||
|
placeholder="封面 / 海报图片地址"
|
||||||
|
aria-label="封面地址"
|
||||||
|
onChange={(event) => setCover(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={tags}
|
||||||
|
placeholder="标签,用逗号分隔"
|
||||||
|
aria-label="标签"
|
||||||
|
onChange={(event) => setTags(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={comment}
|
||||||
|
placeholder="一句话评价"
|
||||||
|
aria-label="一句话评价"
|
||||||
|
onChange={(event) => setComment(event.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="admin-col-span">
|
||||||
|
<Textarea
|
||||||
|
rows={4}
|
||||||
|
value={body}
|
||||||
|
placeholder="正文(图文 / Markdown),可留空"
|
||||||
|
aria-label="正文"
|
||||||
|
onChange={(event) => setBody(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Space gap="sm">
|
||||||
|
<Button
|
||||||
|
loading={busy}
|
||||||
|
icon={<Icon name="add_line" />}
|
||||||
|
disabled={
|
||||||
|
section === undefined ||
|
||||||
|
nodeKey === '' ||
|
||||||
|
key.trim() === '' ||
|
||||||
|
name.trim() === ''
|
||||||
|
}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
新增条目
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Space direction="column" gap="sm" align="stretch">
|
||||||
|
<Text size="sm" tone="muted">
|
||||||
|
{section?.title ?? ''} 下已有 {entries.length} 条
|
||||||
|
</Text>
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<div className="admin-entry-row" key={entry.id}>
|
||||||
|
<Text>{entry.name ?? entry.title ?? entry.id}</Text>
|
||||||
|
<Text size="sm" tone="muted">
|
||||||
|
{entry.groupName ?? '直属分区'}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="admin-remove"
|
||||||
|
icon={<Icon name="delete_2_line" />}
|
||||||
|
aria-label="删除条目"
|
||||||
|
onClick={() => setPendingEntry(entry)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
open={pendingEntry !== null}
|
||||||
|
title={
|
||||||
|
pendingEntry === null
|
||||||
|
? '删除条目'
|
||||||
|
: `删除「${pendingEntry.name ?? pendingEntry.title ?? pendingEntry.id}」?`
|
||||||
|
}
|
||||||
|
description="删除后不可恢复。"
|
||||||
|
busy={deleting}
|
||||||
|
onCancel={() => setPendingEntry(null)}
|
||||||
|
onConfirm={() => void removeEntry()}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 通用删除确认(页面级) ----------
|
||||||
|
|
||||||
|
function ConfirmModal({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
busy,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
busy: boolean
|
||||||
|
onCancel: () => void
|
||||||
|
onConfirm: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={busy ? () => undefined : onCancel}
|
||||||
|
title={title}
|
||||||
|
size="sm"
|
||||||
|
footer={
|
||||||
|
<Space gap="sm" justify="end">
|
||||||
|
<Button variant="secondary" disabled={busy} onClick={onCancel}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
loading={busy}
|
||||||
|
icon={<Icon name="delete_2_line" />}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text>{description}</Text>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 资料 ----------
|
// ---------- 资料 ----------
|
||||||
|
|
||||||
function ProfileForm({
|
function ProfileForm({
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export function ItemEditor({ open, itemKey, onClose, onSaved }: ItemEditorProps)
|
|||||||
|
|
||||||
const result = await saveItem(entry.key, entry.updated_at, {
|
const result = await saveItem(entry.key, entry.updated_at, {
|
||||||
...form,
|
...form,
|
||||||
|
icon: entry.icon,
|
||||||
tags: form.tags
|
tags: form.tags
|
||||||
.split(/[,,]/)
|
.split(/[,,]/)
|
||||||
.map((tag) => tag.trim())
|
.map((tag) => tag.trim())
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type EntryRecord = {
|
|||||||
export type EntryInput = {
|
export type EntryInput = {
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
|
icon: string
|
||||||
tags: string[]
|
tags: string[]
|
||||||
badge: string
|
badge: string
|
||||||
comment: string
|
comment: string
|
||||||
@@ -78,3 +79,41 @@ export async function saveItem(
|
|||||||
return { ok: false, status: 0, error: error instanceof Error ? error.message : '网络错误' }
|
return { ok: false, status: 0, error: error instanceof Error ? error.message : '网络错误' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 新建条目:指定分区 / 分组与字段 */
|
||||||
|
export async function createItem(input: {
|
||||||
|
key: string
|
||||||
|
nodeKey: string
|
||||||
|
item: EntryInput
|
||||||
|
}): Promise<SimpleResult> {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/items', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
})
|
||||||
|
if (response.ok) return { ok: true }
|
||||||
|
const payload = (await response.json().catch(() => null)) as { error?: string } | null
|
||||||
|
return { ok: false, error: payload?.error ?? `创建失败(${response.status})` }
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除条目 */
|
||||||
|
export async function deleteItem(key: string): Promise<SimpleResult> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/items/${encodeURIComponent(key)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
})
|
||||||
|
if (response.ok) return { ok: true }
|
||||||
|
const payload = (await response.json().catch(() => null)) as { error?: string } | null
|
||||||
|
return { ok: false, error: payload?.error ?? `删除失败(${response.status})` }
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SimpleResult = { ok: true } | { ok: false; error: string }
|
||||||
|
|||||||
@@ -282,6 +282,8 @@ export type UpdateCategoryInput = {
|
|||||||
kind?: 'apps' | 'posters' | 'articles'
|
kind?: 'apps' | 'posters' | 'articles'
|
||||||
previewLimit?: number
|
previewLimit?: number
|
||||||
emptyText?: string
|
emptyText?: string
|
||||||
|
/** 首页卡片预览哪个节点(分区自己或其分组) */
|
||||||
|
blockNodeKey?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCategory(
|
export async function updateCategory(
|
||||||
@@ -302,3 +304,60 @@ export async function updateCategory(
|
|||||||
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
|
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 分组(子分类) ----------
|
||||||
|
|
||||||
|
export async function createGroup(
|
||||||
|
sectionId: string,
|
||||||
|
input: { id: string; title: string },
|
||||||
|
): Promise<SimpleResult> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/categories/${encodeURIComponent(sectionId)}/groups`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (response.ok) return { ok: true }
|
||||||
|
const payload = (await response.json().catch(() => null)) as { error?: string } | null
|
||||||
|
return { ok: false, error: payload?.error ?? `创建失败(${response.status})` }
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateGroup(
|
||||||
|
id: string,
|
||||||
|
patch: { title?: string; showInSidebar?: boolean },
|
||||||
|
): Promise<SimpleResult> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/groups/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
})
|
||||||
|
if (response.ok) return { ok: true }
|
||||||
|
const payload = (await response.json().catch(() => null)) as { error?: string } | null
|
||||||
|
return { ok: false, error: payload?.error ?? `保存失败(${response.status})` }
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGroup(id: string): Promise<SimpleResult> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/groups/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
})
|
||||||
|
if (response.ok) return { ok: true }
|
||||||
|
const payload = (await response.json().catch(() => null)) as { error?: string } | null
|
||||||
|
return { ok: false, error: payload?.error ?? `删除失败(${response.status})` }
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user