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);
|
||||
}
|
||||
}
|
||||
|
||||
+110
-1
@@ -62,6 +62,81 @@ fn now() -> i64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 规范化跳转地址:补协议、拒绝危险协议、空值统一成空串。
|
||||
///
|
||||
/// 用户经常直接填 `www.zhihu.com`。原样存进 `href` 会被浏览器当成
|
||||
/// 相对地址(点「跳转到」会跳到 `/c/.../www.zhihu.com`),所以这里统一补
|
||||
/// `https://`。`href` 会原样渲染进 `<a>`,`javascript:` / `data:` 这类
|
||||
/// 协议必须直接拒绝,不能只靠前端过滤。
|
||||
fn normalize_href(raw: &str) -> Result<String, StashError> {
|
||||
let value = raw.trim();
|
||||
if value.is_empty() || value == "#" {
|
||||
return Ok(String::new());
|
||||
}
|
||||
if value.chars().any(char::is_whitespace) {
|
||||
return Err(StashError::InvalidDirectory("跳转地址不能包含空格".into()));
|
||||
}
|
||||
|
||||
// 站内路径(单个斜杠开头);`//host` 是协议相对地址,按外链处理
|
||||
if value.starts_with('/') && !value.starts_with("//") {
|
||||
return Ok(value.to_string());
|
||||
}
|
||||
|
||||
let lower = value.to_ascii_lowercase();
|
||||
if lower.starts_with("http://") || lower.starts_with("https://") {
|
||||
return Ok(value.to_string());
|
||||
}
|
||||
if value.starts_with("//") {
|
||||
return Ok(format!("https:{value}"));
|
||||
}
|
||||
|
||||
// 带「其他协议」的地址一律拒绝(javascript: / data: / vbscript: / file: / mailto: …);
|
||||
// 只放行上面已经处理过的 http(s) 与站内路径。
|
||||
if let Some((scheme, _)) = lower.split_once(':') {
|
||||
let looks_like_scheme = !scheme.is_empty()
|
||||
&& scheme.chars().next().is_some_and(|ch| ch.is_ascii_alphabetic())
|
||||
&& scheme
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '+' || ch == '-' || ch == '.');
|
||||
if looks_like_scheme && !looks_like_domain(value) {
|
||||
return Err(StashError::InvalidDirectory(
|
||||
"跳转地址只支持 http(s)、站内路径或域名".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if looks_like_domain(value) {
|
||||
return Ok(format!("https://{value}"));
|
||||
}
|
||||
|
||||
Err(StashError::InvalidDirectory(
|
||||
"跳转地址需以 http(s):// 或 / 开头".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 裸域名判断:`www.zhihu.com`、`example.com:8080/path` 这类没有协议头的地址。
|
||||
fn looks_like_domain(value: &str) -> bool {
|
||||
let host_port = value.split('/').next().unwrap_or("");
|
||||
let host = host_port.split(':').next().unwrap_or("");
|
||||
if !host.contains('.') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut parts = host.split('.');
|
||||
let Some(tld) = parts.next_back() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
tld.len() >= 2
|
||||
&& tld.chars().all(|ch| ch.is_ascii_alphabetic())
|
||||
&& parts.all(|part| {
|
||||
!part.is_empty()
|
||||
&& part
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
|
||||
})
|
||||
}
|
||||
|
||||
fn validate(patch: &EntryPatch) -> Result<(), StashError> {
|
||||
if patch.name.trim().is_empty() {
|
||||
return Err(StashError::InvalidDirectory("标题不能为空".into()));
|
||||
@@ -143,6 +218,8 @@ pub fn update(
|
||||
expected_updated_at: i64,
|
||||
) -> Result<EntryRecord, StashError> {
|
||||
validate(&patch)?;
|
||||
// 跳转地址先规范化:补 https、拒绝 javascript: 等危险协议
|
||||
let href = normalize_href(&patch.href)?;
|
||||
|
||||
let tags: Vec<String> = patch
|
||||
.tags
|
||||
@@ -165,7 +242,7 @@ pub fn update(
|
||||
patch.name.trim(),
|
||||
patch.description,
|
||||
patch.icon,
|
||||
patch.href,
|
||||
href,
|
||||
patch.cover,
|
||||
patch.banner,
|
||||
patch.badge,
|
||||
@@ -331,4 +408,36 @@ mod tests {
|
||||
let error = update(&mut conn, "none", patch("X"), 0).expect_err("应不存在");
|
||||
assert!(matches!(error, StashError::Conflict(_) | StashError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_links_so_jump_works() {
|
||||
// 裸域名补 https,避免被浏览器当成相对地址
|
||||
assert_eq!(normalize_href("www.zhihu.com").unwrap(), "https://www.zhihu.com");
|
||||
assert_eq!(
|
||||
normalize_href("example.com:8080/path").unwrap(),
|
||||
"https://example.com:8080/path"
|
||||
);
|
||||
// 已带协议的保持原样;站内路径保持站内
|
||||
assert_eq!(normalize_href("https://example.com/a").unwrap(), "https://example.com/a");
|
||||
assert_eq!(normalize_href("/git_helper/").unwrap(), "/git_helper/");
|
||||
// 空值和占位符统一成空串
|
||||
assert_eq!(normalize_href(" ").unwrap(), "");
|
||||
assert_eq!(normalize_href("#").unwrap(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_dangerous_or_unknown_links() {
|
||||
for bad in ["javascript:alert(1)", "data:text/html,hi", "mailto:[email protected]", "not a url"] {
|
||||
assert!(normalize_href(bad).is_err(), "{bad} 应被拒绝");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_stores_normalized_href() {
|
||||
let mut conn = db();
|
||||
let mut input = patch("知乎");
|
||||
input.href = "www.zhihu.com".into();
|
||||
let saved = update(&mut conn, "movie-1", input, 0).expect("应能更新");
|
||||
assert_eq!(saved.href, "https://www.zhihu.com");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +212,15 @@ impl Db {
|
||||
categories::create(&mut conn, new)
|
||||
}
|
||||
|
||||
pub fn update_category(
|
||||
&self,
|
||||
id: &str,
|
||||
patch: categories::UpdateCategory,
|
||||
) -> Result<(), StashError> {
|
||||
let mut conn = self.lock()?;
|
||||
categories::update(&mut conn, id, patch)
|
||||
}
|
||||
|
||||
pub fn remove_category(&self, id: &str) -> Result<(), StashError> {
|
||||
let mut conn = self.lock()?;
|
||||
categories::remove(&mut conn, id)
|
||||
|
||||
@@ -86,6 +86,12 @@ pub struct SidebarSettings {
|
||||
pub hidden: Vec<String>,
|
||||
/// 分类显示顺序(id);未列出的排在后面,保持原有顺序
|
||||
pub order: Vec<String>,
|
||||
/// 隐藏的分组,键为 `分区id/分组id`
|
||||
pub hidden_groups: Vec<String>,
|
||||
/// 强制显示的分组(覆盖数据库里的 show_in_sidebar=0)
|
||||
pub shown_groups: Vec<String>,
|
||||
/// 每个分区下分组的顺序:分区 id → 分组 id 列表
|
||||
pub group_order: std::collections::HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
@@ -172,6 +178,12 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> {
|
||||
if settings.sidebar.hidden.len() > 100 || settings.sidebar.order.len() > 100 {
|
||||
return Err(StashError::InvalidDirectory("侧栏配置过长".into()));
|
||||
}
|
||||
if settings.sidebar.hidden_groups.len() > 200
|
||||
|| settings.sidebar.shown_groups.len() > 200
|
||||
|| settings.sidebar.group_order.len() > 100
|
||||
{
|
||||
return Err(StashError::InvalidDirectory("侧栏分组配置过长".into()));
|
||||
}
|
||||
if settings.sections.hidden.len() > 100 || settings.sections.order.len() > 100 {
|
||||
return Err(StashError::InvalidDirectory("首页卡片配置过长".into()));
|
||||
}
|
||||
|
||||
+32
-1
@@ -745,6 +745,34 @@ async fn create_category(
|
||||
}
|
||||
}
|
||||
|
||||
/// PATCH /api/categories/{id} —— 修改分区(需要登录)
|
||||
async fn update_category(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<crate::db::categories::UpdateCategory>,
|
||||
) -> 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_category(&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/categories/{id} —— 删除分区(需要登录,级联内容)
|
||||
async fn delete_category(
|
||||
State(state): State<AppState>,
|
||||
@@ -802,7 +830,10 @@ fn build_router(state: AppState) -> Router {
|
||||
.route("/api/items/{key}/view", post(post_view))
|
||||
.route("/api/items/{key}/like", post(post_like))
|
||||
.route("/api/categories", post(create_category))
|
||||
.route("/api/categories/{id}", axum::routing::delete(delete_category))
|
||||
.route(
|
||||
"/api/categories/{id}",
|
||||
patch(update_category).delete(delete_category),
|
||||
)
|
||||
.route("/api/backup", get(backup_db))
|
||||
.route("/", get(web_index))
|
||||
.route("/index.html", get(web_index))
|
||||
|
||||
@@ -958,6 +958,28 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 侧栏里的子分类(分组):缩进一层,弱化文字 */
|
||||
.admin-nav-group-row {
|
||||
padding-left: var(--su-space-6);
|
||||
}
|
||||
|
||||
.admin-nav-group-row .admin-item-title {
|
||||
color: var(--su-text-sub);
|
||||
font-size: var(--su-font-size-sm);
|
||||
}
|
||||
|
||||
/* 归类页:一行一个分区,标题 / 类型 / 图标 / 色条 / 条数 / 保存 / 删除 */
|
||||
.admin-category-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) 150px 80px 110px 90px auto auto;
|
||||
align-items: center;
|
||||
gap: var(--su-space-2);
|
||||
}
|
||||
|
||||
.admin-category-row > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 删除按钮:可删的用危险色,不可删的用弱化色(都取主题令牌) */
|
||||
.admin-item-actions .admin-remove {
|
||||
color: var(--su-color-danger);
|
||||
|
||||
@@ -30,6 +30,7 @@ import { useCatalog } from './useCatalog'
|
||||
import { useViewRoute } from './useViewRoute'
|
||||
import { fetchSession, logout as logoutRequest, type SessionState } from './auth'
|
||||
import {
|
||||
applySidebarGroups,
|
||||
applySidebarSettings,
|
||||
defaultSettings,
|
||||
fetchSettings,
|
||||
@@ -81,15 +82,18 @@ export default function App() {
|
||||
)
|
||||
|
||||
// 侧栏不显示「本地服务」,也不显示被标记 show_in_sidebar = 0 的分区;
|
||||
// 再按 /admin 里的顺序与显隐设置重排一遍
|
||||
// 再按 /admin 里的顺序、显隐与分组设置重排一遍
|
||||
const sidebarCategories = useMemo(
|
||||
() =>
|
||||
applySidebarGroups(
|
||||
applySidebarSettings(
|
||||
categories.filter(
|
||||
(category) => category.id !== 'local' && category.showInSidebar !== false,
|
||||
),
|
||||
settings.settings.sidebar,
|
||||
),
|
||||
settings.settings.sidebar,
|
||||
),
|
||||
[categories, settings.settings.sidebar],
|
||||
)
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
TONE_OPTIONS,
|
||||
updateCategory,
|
||||
type HeaderItem,
|
||||
type ProfileSettings,
|
||||
type SectionSettings,
|
||||
@@ -40,11 +41,24 @@ import {
|
||||
* 只做「全局配置」;条目内容仍在详情页就地编辑(P2)。
|
||||
* 保存用 `version` 乐观锁,冲突时提示刷新。
|
||||
*/
|
||||
type CategoryGroupSummary = {
|
||||
id: string
|
||||
name: string
|
||||
/** 数据库里的默认显隐;缺省视为显示 */
|
||||
showInSidebar?: boolean
|
||||
}
|
||||
|
||||
type CategorySummary = {
|
||||
id: string
|
||||
title: string
|
||||
kind?: 'apps' | 'posters' | 'articles'
|
||||
icon?: string
|
||||
barColor?: string
|
||||
previewLimit?: number
|
||||
/** 首页卡片标题(配了展示块时用块的标题,如「常用站点」) */
|
||||
block?: { title?: string } | null
|
||||
/** 应用类分区下的分组(子分类) */
|
||||
groups?: CategoryGroupSummary[]
|
||||
}
|
||||
|
||||
const clone = (settings: SiteSettings): SiteSettings => structuredClone(settings)
|
||||
@@ -173,6 +187,7 @@ export function AdminPage() {
|
||||
items={[
|
||||
{ key: 'header', label: '页头滚动信息' },
|
||||
{ key: 'sections', label: '首页卡片' },
|
||||
{ key: 'categories', label: '归类' },
|
||||
{ key: 'sidebar', label: '侧栏导航' },
|
||||
{ key: 'profile', label: '资料' },
|
||||
{ key: 'system', label: '系统' },
|
||||
@@ -194,6 +209,12 @@ export function AdminPage() {
|
||||
onDelete={requestDelete}
|
||||
onReload={load}
|
||||
/>
|
||||
) : tab === 'categories' ? (
|
||||
<CategoryManager
|
||||
categories={categories}
|
||||
onReload={load}
|
||||
onDelete={requestDelete}
|
||||
/>
|
||||
) : tab === 'sidebar' ? (
|
||||
<SidebarForm
|
||||
categories={categories}
|
||||
@@ -211,6 +232,8 @@ export function AdminPage() {
|
||||
<SystemPanel />
|
||||
)}
|
||||
|
||||
{/* 「归类」每行自己保存,不参与整页设置的保存/撤销 */}
|
||||
{tab !== 'categories' && (
|
||||
<Space gap="sm" justify="end" className="admin-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -225,6 +248,7 @@ export function AdminPage() {
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
@@ -409,17 +433,65 @@ function SidebarForm({
|
||||
onChange({ ...value, hidden, order: ordered.map((category) => category.id) })
|
||||
}
|
||||
|
||||
const orderedGroups = (category: CategorySummary) => {
|
||||
const ranks = new Map(
|
||||
(value.group_order[category.id] ?? []).map((id, index) => [id, index] as const),
|
||||
)
|
||||
return [...(category.groups ?? [])].sort((a, b) => {
|
||||
const left = ranks.get(a.id) ?? Number.MAX_SAFE_INTEGER
|
||||
const right = ranks.get(b.id) ?? Number.MAX_SAFE_INTEGER
|
||||
if (left === right) return 0
|
||||
return left - right
|
||||
})
|
||||
}
|
||||
|
||||
const groupVisible = (category: CategorySummary, group: CategoryGroupSummary) => {
|
||||
const key = `${category.id}/${group.id}`
|
||||
return (
|
||||
!value.hidden_groups.includes(key) &&
|
||||
(group.showInSidebar !== false || value.shown_groups.includes(key))
|
||||
)
|
||||
}
|
||||
|
||||
const toggleGroup = (
|
||||
category: CategorySummary,
|
||||
group: CategoryGroupSummary,
|
||||
visible: boolean,
|
||||
) => {
|
||||
const key = `${category.id}/${group.id}`
|
||||
const hidden_groups = visible
|
||||
? value.hidden_groups.filter((entry) => entry !== key)
|
||||
: [...new Set([...value.hidden_groups, key])]
|
||||
const shown_groups = visible
|
||||
? [...new Set([...value.shown_groups, key])]
|
||||
: value.shown_groups.filter((entry) => entry !== key)
|
||||
onChange({ ...value, hidden_groups, shown_groups })
|
||||
}
|
||||
|
||||
const moveGroup = (category: CategorySummary, index: number, step: -1 | 1) => {
|
||||
const groups = orderedGroups(category)
|
||||
const target = index + step
|
||||
if (target < 0 || target >= groups.length) return
|
||||
|
||||
const ids = groups.map((group) => group.id)
|
||||
const [id] = ids.splice(index, 1)
|
||||
ids.splice(target, 0, id)
|
||||
onChange({ ...value, group_order: { ...value.group_order, [category.id]: ids } })
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="column" gap="sm" align="stretch" className="admin-form">
|
||||
<Text size="sm" tone="muted">
|
||||
顺序决定侧栏显示位置;关掉的分类仍可通过地址直接访问。
|
||||
顺序决定侧栏显示位置;关掉的分类仍可通过地址直接访问。应用类分区下的子分类(分组)可以单独显隐与排序。
|
||||
</Text>
|
||||
|
||||
{ordered.map((category, index) => {
|
||||
const visible = !value.hidden.includes(category.id)
|
||||
const groups = category.kind === 'apps' ? orderedGroups(category) : []
|
||||
|
||||
return (
|
||||
<div className="admin-item-row admin-nav-row" key={category.id}>
|
||||
<div className="admin-nav-block" key={category.id}>
|
||||
<div className="admin-item-row admin-nav-row">
|
||||
<Switch
|
||||
checked={visible}
|
||||
onChange={(next) => toggle(category.id, next)}
|
||||
@@ -432,6 +504,25 @@ function SidebarForm({
|
||||
onRemove={() => onDelete(category)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{groups.map((group, groupIndex) => (
|
||||
<div
|
||||
className="admin-item-row admin-nav-row admin-nav-group-row"
|
||||
key={group.id}
|
||||
>
|
||||
<Switch
|
||||
checked={groupVisible(category, group)}
|
||||
onChange={(next) => toggleGroup(category, group, next)}
|
||||
label={`显示 ${group.name}`}
|
||||
/>
|
||||
<Text className="admin-item-title">{group.name}</Text>
|
||||
<IconButtonGroup
|
||||
onUp={() => moveGroup(category, groupIndex, -1)}
|
||||
onDown={() => moveGroup(category, groupIndex, 1)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -618,6 +709,130 @@ function CategoryCreator({ onCreated }: { onCreated: () => void }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- 归类(分类管理) ----------
|
||||
|
||||
const KIND_OPTIONS = [
|
||||
{ value: 'apps', label: '应用' },
|
||||
{ value: 'posters', label: '海报' },
|
||||
{ value: 'articles', label: '图文' },
|
||||
]
|
||||
|
||||
function CategoryManager({
|
||||
categories,
|
||||
onReload,
|
||||
onDelete,
|
||||
}: {
|
||||
categories: CategorySummary[]
|
||||
onReload: () => void
|
||||
onDelete: (card: CategorySummary) => void
|
||||
}) {
|
||||
return (
|
||||
<Space direction="column" gap="sm" align="stretch" className="admin-form">
|
||||
<Text size="sm" tone="muted">
|
||||
决定每个分区归到哪一类(应用 / 海报 / 图文),以及标题、图标、色条与预览条数。改类型会自动迁移条目:切到「应用」会建默认分组并把条目挪进去。
|
||||
</Text>
|
||||
|
||||
{categories.map((category) => (
|
||||
<CategoryRow
|
||||
key={category.id}
|
||||
category={category}
|
||||
onReload={onReload}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
|
||||
{categories.length === 0 && <Text tone="muted">内容库还没有加载完成。</Text>}
|
||||
|
||||
<CategoryCreator onCreated={onReload} />
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryRow({
|
||||
category,
|
||||
onReload,
|
||||
onDelete,
|
||||
}: {
|
||||
category: CategorySummary
|
||||
onReload: () => void
|
||||
onDelete: (card: CategorySummary) => void
|
||||
}) {
|
||||
const [title, setTitle] = useState(category.title)
|
||||
const [kind, setKind] = useState(category.kind ?? 'apps')
|
||||
const [icon, setIcon] = useState(category.icon ?? '')
|
||||
const [color, setColor] = useState(category.barColor ?? '')
|
||||
const [limit, setLimit] = useState(
|
||||
category.previewLimit === undefined ? '' : String(category.previewLimit),
|
||||
)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true)
|
||||
const result = await updateCategory(category.id, {
|
||||
title,
|
||||
kind,
|
||||
icon,
|
||||
barColor: color,
|
||||
previewLimit: limit.trim() === '' ? undefined : Number(limit),
|
||||
})
|
||||
setBusy(false)
|
||||
|
||||
if (!result.ok) {
|
||||
toast.error(result.error)
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(`已保存「${title}」`)
|
||||
onReload()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-category-row">
|
||||
<Input
|
||||
value={title}
|
||||
placeholder="标题"
|
||||
aria-label={`${category.title} 标题`}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
<Select
|
||||
options={KIND_OPTIONS}
|
||||
value={kind}
|
||||
onChange={(next) => setKind(next as typeof kind)}
|
||||
/>
|
||||
<Input
|
||||
value={icon}
|
||||
placeholder="图标"
|
||||
aria-label={`${category.title} 图标`}
|
||||
onChange={(event) => setIcon(event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
value={color}
|
||||
placeholder="#3858f6"
|
||||
aria-label={`${category.title} 色条`}
|
||||
onChange={(event) => setColor(event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={limit}
|
||||
placeholder="条数"
|
||||
aria-label={`${category.title} 预览条数`}
|
||||
onChange={(event) => setLimit(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(category)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- 资料 ----------
|
||||
|
||||
function ProfileForm({
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@stash/ui'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ItemDetailModel } from '../../data/detail'
|
||||
import { resolveHref } from '../../data/detail'
|
||||
import type { RelatedItem } from '../../data/types'
|
||||
import { RelatedList } from './RelatedList'
|
||||
|
||||
@@ -49,6 +50,8 @@ export function ItemDetailView({
|
||||
}: ItemDetailViewProps) {
|
||||
const [views, setViews] = useState(model.views)
|
||||
const [likes, setLikes] = useState(model.likes)
|
||||
// 历史数据可能是裸域名或占位符 #;统一解析成可跳转地址后再渲染
|
||||
const jumpHref = resolveHref(model.href)
|
||||
const [liked, setLiked] = useState(() => {
|
||||
try {
|
||||
return window.localStorage.getItem(`stash-liked:${itemKey}`) === '1'
|
||||
@@ -202,14 +205,14 @@ export function ItemDetailView({
|
||||
/>
|
||||
</Space>
|
||||
|
||||
{/* 占位数据没有真实地址时,按钮置灰而不是打开空白页 */}
|
||||
{model.href === '#' ? (
|
||||
<button type="button" className="item-jump" disabled title="暂无跳转地址">
|
||||
{/* 没有真实地址(空 / # / 不安全协议)时置灰,避免打开错误页面 */}
|
||||
{jumpHref === undefined ? (
|
||||
<button type="button" className="item-jump" disabled title="暂无跳转地址,可在编辑里添加">
|
||||
跳转到
|
||||
<Icon name="a-external_link_line1" />
|
||||
</button>
|
||||
) : (
|
||||
<a className="item-jump" href={model.href} target="_blank" rel="noreferrer">
|
||||
<a className="item-jump" href={jumpHref} target="_blank" rel="noreferrer">
|
||||
跳转到
|
||||
<Icon name="a-external_link_line1" />
|
||||
</a>
|
||||
|
||||
@@ -188,11 +188,15 @@ export function ItemEditor({ open, itemKey, onClose, onSaved }: ItemEditorProps)
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="跳转地址" htmlFor="edit-href">
|
||||
<FormField
|
||||
label="跳转地址"
|
||||
htmlFor="edit-href"
|
||||
hint="可填 https://、www. 开头的域名或站内路径 /foo;留空则不显示跳转"
|
||||
>
|
||||
<Input
|
||||
id="edit-href"
|
||||
value={form.href}
|
||||
placeholder="https://…"
|
||||
placeholder="https://… 或 /project/"
|
||||
onChange={(event) => update({ href: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@@ -38,10 +38,8 @@ export function CategoryNav({
|
||||
const items: MenuItem[] = [
|
||||
{ key: HOME_KEY, label: '首页' },
|
||||
...categories.map((category) => {
|
||||
const groups =
|
||||
category.kind === 'apps'
|
||||
? category.groups.filter((group) => group.showInSidebar !== false)
|
||||
: []
|
||||
// 分组已由 App 按 /admin 的侧栏设置过滤、排序(含 show_in_sidebar 覆盖)
|
||||
const groups = category.kind === 'apps' ? category.groups : []
|
||||
|
||||
return {
|
||||
key: categoryKey(category.id),
|
||||
|
||||
@@ -28,8 +28,7 @@ export type ItemDetailModel = {
|
||||
}
|
||||
|
||||
/** 把应用条目转成详情模型 */
|
||||
export function appDetailModel(item: AppItem): ItemDetailModel {
|
||||
return {
|
||||
export function appDetailModel(item: AppItem): ItemDetailModel { return {
|
||||
title: item.name,
|
||||
cover: item.cover,
|
||||
shape: 'square',
|
||||
@@ -73,3 +72,38 @@ export function posterToRelatedItem(item: PosterItem): RelatedItem {
|
||||
cover: item.cover,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把库里的 `href` 解析成真正可跳转的地址。
|
||||
*
|
||||
* 兼容历史数据里没写协议的裸域名(`www.zhihu.com` → `https://…`);
|
||||
* 空值、`#` 与不安全/看不懂的协议返回 `undefined`,由页面把按钮置灰。
|
||||
*/
|
||||
export function resolveHref(raw: string | undefined): string | undefined {
|
||||
const value = raw?.trim() ?? ''
|
||||
if (value === '' || value === '#') return undefined
|
||||
|
||||
// 站内路径;`//host` 是协议相对地址,按外链补 https
|
||||
if (value.startsWith('/') && !value.startsWith('//')) return value
|
||||
if (/^https?:\/\//i.test(value)) return value
|
||||
if (value.startsWith('//')) return `https:${value}`
|
||||
|
||||
// 其他协议(javascript: / data: / mailto: …)一律不当作跳转
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return undefined
|
||||
|
||||
return looksLikeDomain(value) ? `https://${value}` : undefined
|
||||
}
|
||||
|
||||
function looksLikeDomain(value: string): boolean {
|
||||
const [hostPort = ''] = value.split('/')
|
||||
const [host = ''] = hostPort.split(':')
|
||||
if (!host.includes('.')) return false
|
||||
|
||||
const parts = host.split('.')
|
||||
const tld = parts.pop() ?? ''
|
||||
return (
|
||||
tld.length >= 2 &&
|
||||
/^[a-z]+$/i.test(tld) &&
|
||||
parts.every((part) => /^[a-z0-9-]+$/i.test(part))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* 接口是公开读、登录后写;写的时候带 `version` 做乐观锁,
|
||||
* 冲突(多标签页同时改)时返回 409,页面提示刷新。
|
||||
*/
|
||||
import type { Category } from './data/types'
|
||||
|
||||
export type SettingsTone = 'plain' | 'primary' | 'success' | 'neutral'
|
||||
|
||||
export type HeaderItem = {
|
||||
@@ -39,6 +41,12 @@ export type SiteSettings = {
|
||||
sidebar: {
|
||||
hidden: string[]
|
||||
order: string[]
|
||||
/** 隐藏的分组:`分区id/分组id` */
|
||||
hidden_groups: string[]
|
||||
/** 强制显示的分组(覆盖数据库 show_in_sidebar=0) */
|
||||
shown_groups: string[]
|
||||
/** 每个分区下的分组顺序:分区 id → 分组 id 列表 */
|
||||
group_order: Record<string, string[]>
|
||||
}
|
||||
sections: SectionSettings
|
||||
profile: ProfileSettings
|
||||
@@ -71,7 +79,13 @@ export function defaultSettings(): SiteSettings {
|
||||
{ text: '{status}', tag: '状态', tone: 'success' },
|
||||
],
|
||||
},
|
||||
sidebar: { hidden: [], order: [] },
|
||||
sidebar: {
|
||||
hidden: [],
|
||||
order: [],
|
||||
hidden_groups: [],
|
||||
shown_groups: [],
|
||||
group_order: {},
|
||||
},
|
||||
sections: { hidden: [], order: [], titles: {}, limits: {} },
|
||||
profile: {
|
||||
name: '本地管理员',
|
||||
@@ -151,6 +165,42 @@ export function applySectionSettings<T extends { id: string }>(
|
||||
return orderAndFilter(categories, sections)
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧栏子分类(分组):按设置过滤、排序。
|
||||
*
|
||||
* 数据库里的 `show_in_sidebar` 是默认值;`shown_groups` 可以把它强行打开,
|
||||
* `hidden_groups` 则再隐藏一层。
|
||||
*/
|
||||
export function applySidebarGroups(
|
||||
categories: Category[],
|
||||
sidebar: SiteSettings['sidebar'],
|
||||
): Category[] {
|
||||
const hidden = new Set(sidebar.hidden_groups)
|
||||
const shown = new Set(sidebar.shown_groups)
|
||||
|
||||
return categories.map((category) => {
|
||||
if (category.kind !== 'apps') return category
|
||||
|
||||
const ranks = new Map(
|
||||
(sidebar.group_order[category.id] ?? []).map((id, index) => [id, index] as const),
|
||||
)
|
||||
const groups = category.groups
|
||||
.filter((group) => {
|
||||
const key = `${category.id}/${group.id}`
|
||||
return !hidden.has(key) && (group.showInSidebar !== false || shown.has(key))
|
||||
})
|
||||
.map((group, index) => ({
|
||||
group,
|
||||
index,
|
||||
rank: ranks.get(group.id) ?? Number.MAX_SAFE_INTEGER,
|
||||
}))
|
||||
.sort((a, b) => (a.rank === b.rank ? a.index - b.index : a.rank - b.rank))
|
||||
.map((entry) => entry.group)
|
||||
|
||||
return { ...category, groups }
|
||||
})
|
||||
}
|
||||
|
||||
/** 卡片标题:设置里的覆盖值优先 */
|
||||
export function sectionTitle(
|
||||
id: string,
|
||||
@@ -223,3 +273,32 @@ export async function deleteCategory(id: string): Promise<SimpleResult> {
|
||||
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
|
||||
}
|
||||
}
|
||||
|
||||
/** 修改分区(归类、标题、图标、色条、预览条数) */
|
||||
export type UpdateCategoryInput = {
|
||||
title?: string
|
||||
icon?: string
|
||||
barColor?: string
|
||||
kind?: 'apps' | 'posters' | 'articles'
|
||||
previewLimit?: number
|
||||
emptyText?: string
|
||||
}
|
||||
|
||||
export async function updateCategory(
|
||||
id: string,
|
||||
patch: UpdateCategoryInput,
|
||||
): Promise<SimpleResult> {
|
||||
try {
|
||||
const response = await fetch(`/api/categories/${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 : '网络错误' }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user