feat: 格式从分组下沉到条目(v21)

- entry 新增 format(apps/posters/articles),老数据按根分组 view_mode 回填;seed 按分区写入
- 分组管理移除格式字段/显示,根分组只作容器
- 内容弹窗新增逐条格式选择,列表增加格式列;ItemEditor 保存时保留原格式
- 前台按 entry.format 渲染:卡片/分组页支持同组混合格式,详情按条目格式选模板
- 迁移后 live 20 应用 / 13 海报;cargo test 59 passed;Playwright 验证混合展示
This commit is contained in:
2026-10-31 14:26:28 +08:00
parent 8d094175fd
commit 11f992cdad
12 changed files with 173 additions and 72 deletions
+19 -11
View File
@@ -31,6 +31,7 @@ struct NodeRow {
/// 条目行:与 `entry` 表的列一一对应
struct EntryRow {
key: String,
format: String,
name: String,
description: Option<String>,
icon: Option<String>,
@@ -140,7 +141,7 @@ pub fn load_tree(conn: &Connection) -> Result<Value, StashError> {
let entries = load_node_entries(conn, &node.key)?;
let items = entries
.iter()
.map(|entry| item_for(entry, format, tags))
.map(|entry| item_for(entry, tags))
.collect::<Vec<_>>();
let child_values = children
@@ -338,12 +339,12 @@ fn build_section(
if section.allow_groups {
object.insert(
"groups".into(),
build_groups(conn, section, view_mode, &tags)?,
build_groups(conn, section, &tags)?,
);
let direct = load_node_entries(conn, &section.key)?;
let items = direct
.iter()
.map(|entry| item_for(entry, view_mode, &tags))
.map(|entry| item_for(entry, &tags))
.collect::<Vec<_>>();
object.insert("items".into(), json!(items));
} else {
@@ -352,7 +353,7 @@ fn build_section(
let entries = load_section_entries(conn, &section.key)?;
let items = entries
.iter()
.map(|entry| item_for(entry, view_mode, &tags))
.map(|entry| item_for(entry, &tags))
.collect::<Vec<_>>();
object.insert("items".into(), json!(items));
}
@@ -360,20 +361,23 @@ fn build_section(
Ok(Value::Object(object))
}
/// 按条目类型组装列表项。
fn item_for(entry: &EntryRow, view_mode: &str, tags: &HashMap<String, Vec<String>>) -> Value {
match view_mode {
/// 按条目自身的格式组装列表项。
fn item_for(entry: &EntryRow, tags: &HashMap<String, Vec<String>>) -> Value {
let mut value = match entry.format.as_str() {
"posters" => poster_item(entry, tags.get(&entry.key)),
"articles" => article_item(entry, tags.get(&entry.key)),
_ => app_item(entry),
};
if let Some(object) = value.as_object_mut() {
object.insert("format".into(), json!(entry.format));
}
value
}
/// 取出某个分区下的分组,并把条目按 `node_key` 分派进去。
fn build_groups(
conn: &Connection,
section: &NodeRow,
view_mode: &str,
tags: &HashMap<String, Vec<String>>,
) -> Result<Value, StashError> {
let mut stmt = conn
@@ -403,7 +407,7 @@ fn build_groups(
let entries = load_node_entries(conn, &group_key)?;
let items = entries
.iter()
.map(|entry| item_for(entry, view_mode, tags))
.map(|entry| item_for(entry, tags))
.collect::<Vec<_>>();
rendered_groups.push(json!({
@@ -512,7 +516,8 @@ fn load_section_entries(
let mut stmt = conn
.prepare(
"SELECT e.key, e.name, e.description, e.icon, e.href, e.cover,
e.banner, e.badge, e.comment, e.summary, e.body, e.hot, e.views, e.likes
e.banner, e.badge, e.comment, e.summary, e.body, e.hot, e.views, e.likes,
COALESCE(e.format, 'apps')
FROM entry e
JOIN node p ON p.key = e.node_key
WHERE COALESCE(p.parent_key, p.key) = ?1
@@ -537,6 +542,7 @@ fn load_section_entries(
hot: row.get(11)?,
views: row.get(12)?,
likes: row.get(13)?,
format: row.get(14)?,
})
})
.map_err(map_error)?
@@ -549,7 +555,8 @@ fn load_section_entries(
fn load_node_entries(conn: &Connection, node_key: &str) -> Result<Vec<EntryRow>, StashError> { let mut stmt = conn
.prepare(
"SELECT e.key, e.name, e.description, e.icon, e.href, e.cover,
e.banner, e.badge, e.comment, e.summary, e.body, e.hot, e.views, e.likes
e.banner, e.badge, e.comment, e.summary, e.body, e.hot, e.views, e.likes,
COALESCE(e.format, 'apps')
FROM entry e
WHERE e.node_key = ?1
ORDER BY e.sort, e.key",
@@ -573,6 +580,7 @@ fn load_node_entries(conn: &Connection, node_key: &str) -> Result<Vec<EntryRow>,
hot: row.get(11)?,
views: row.get(12)?,
likes: row.get(13)?,
format: row.get(14)?,
})
})
.map_err(map_error)?
+33 -16
View File
@@ -16,6 +16,7 @@ const MAX_TAG_LEN: usize = 40;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct EntryRecord {
pub key: String,
pub format: String,
pub name: String,
pub description: String,
pub icon: String,
@@ -57,6 +58,9 @@ pub struct EntryPatch {
/// 可选:把条目移动到另一个分区 / 分组(不传则保持原位)
#[serde(default)]
pub node_key: Option<String>,
/// 条目自身的展示格式:apps / posters / articles;不传默认 apps
#[serde(default)]
pub format: Option<String>,
}
/// 新建条目的请求体:`key` 是业务键,`nodeKey` 决定挂在哪个分区 / 分组。
@@ -151,6 +155,11 @@ fn looks_like_domain(value: &str) -> bool {
}
fn validate(patch: &EntryPatch) -> Result<(), StashError> {
if let Some(format) = patch.format.as_deref() {
if !matches!(format, "apps" | "posters" | "articles") {
return Err(StashError::InvalidDirectory("格式不合法".into()));
}
}
if patch.name.trim().is_empty() {
return Err(StashError::InvalidDirectory("标题不能为空".into()));
}
@@ -181,7 +190,7 @@ fn validate(patch: &EntryPatch) -> Result<(), StashError> {
pub fn load(conn: &Connection, key: &str) -> Result<Option<EntryRecord>, StashError> {
let row = conn
.query_row(
"SELECT key, name, COALESCE(description, ''), COALESCE(icon, ''), COALESCE(href, ''),
"SELECT key, COALESCE(format, 'apps'), name, COALESCE(description, ''), COALESCE(icon, ''), COALESCE(href, ''),
COALESCE(cover, ''), COALESCE(banner, ''), COALESCE(badge, ''),
COALESCE(comment, ''), COALESCE(summary, ''), COALESCE(body, ''), updated_at
FROM entry WHERE key = ?1",
@@ -189,18 +198,19 @@ pub fn load(conn: &Connection, key: &str) -> Result<Option<EntryRecord>, StashEr
|row| {
Ok(EntryRecord {
key: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
icon: row.get(3)?,
href: row.get(4)?,
cover: row.get(5)?,
banner: row.get(6)?,
badge: row.get(7)?,
comment: row.get(8)?,
summary: row.get(9)?,
body: row.get(10)?,
format: row.get(1)?,
name: row.get(2)?,
description: row.get(3)?,
icon: row.get(4)?,
href: row.get(5)?,
cover: row.get(6)?,
banner: row.get(7)?,
badge: row.get(8)?,
comment: row.get(9)?,
summary: row.get(10)?,
body: row.get(11)?,
tags: Vec::new(),
updated_at: row.get(11)?,
updated_at: row.get(12)?,
})
},
)
@@ -285,6 +295,7 @@ pub fn create(conn: &mut Connection, new: NewEntry) -> Result<EntryRecord, Stash
let patch = new.item;
validate(&patch)?;
let format = patch.format.clone().unwrap_or_else(|| "apps".into());
let href = normalize_href(&patch.href)?;
validate_target(conn, &new.node_key)?;
@@ -316,12 +327,13 @@ pub fn create(conn: &mut Connection, new: NewEntry) -> Result<EntryRecord, Stash
.map_err(map_error)?;
tx.execute(
"INSERT INTO entry (key, node_key, name, description, icon, href, cover, banner,
"INSERT INTO entry (key, node_key, format, 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)",
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 0, 0, 0, ?14, ?15)",
params![
key,
new.node_key,
format,
patch.name.trim(),
patch.description,
patch.icon,
@@ -370,6 +382,7 @@ pub fn update( conn: &mut Connection,
expected_updated_at: i64,
) -> Result<EntryRecord, StashError> {
validate(&patch)?;
let format = patch.format.clone().unwrap_or_else(|| "apps".into());
// 跳转地址先规范化:补 https、拒绝 javascript: 等危险协议
let href = normalize_href(&patch.href)?;
if let Some(node_key) = patch.node_key.as_deref() {
@@ -392,7 +405,8 @@ pub fn update( conn: &mut Connection,
name = ?1, description = ?2, icon = ?3, href = ?4,
cover = ?5, banner = ?6, badge = ?7, comment = ?8,
summary = ?9, body = ?10, updated_at = ?11,
node_key = COALESCE(?14, node_key)
node_key = COALESCE(?14, node_key),
format = ?15
WHERE key = ?12 AND updated_at = ?13",
params![
patch.name.trim(),
@@ -408,7 +422,8 @@ pub fn update( conn: &mut Connection,
timestamp,
key,
expected_updated_at,
patch.node_key
patch.node_key,
format
],
)
.map_err(map_error)?;
@@ -488,6 +503,7 @@ mod tests {
"CREATE TABLE entry (
key TEXT PRIMARY KEY,
node_key TEXT NOT NULL,
format TEXT NOT NULL DEFAULT 'apps',
name TEXT NOT NULL,
description TEXT, icon TEXT, href TEXT, cover TEXT, banner TEXT,
badge TEXT, comment TEXT, summary TEXT, body TEXT,
@@ -539,6 +555,7 @@ mod tests {
body: "## 标题".into(),
tags: vec!["推荐电影".into(), "动画".into()],
node_key: None,
format: None,
}
}
+30 -5
View File
@@ -66,6 +66,7 @@ CREATE TABLE IF NOT EXISTS entry (
node_key TEXT NOT NULL REFERENCES node(key) ON DELETE CASCADE ON UPDATE CASCADE,
-- 直接所属容器:应用是分组,海报/图文是分区本身
-- 所属分区不单独存:由 node_key 推导(分组看它的 parent_key)
format TEXT NOT NULL DEFAULT 'apps', -- 条目自身的展示格式:apps / posters / articles
name TEXT NOT NULL, -- 名称:网站名 / 电影名 / 文章标题
description TEXT, -- 一句话简介,列表卡片的副标题
icon TEXT, -- 字符图标(封面缺失时的占位)
@@ -217,6 +218,23 @@ const V20_SQL: &str = r#"
ALTER TABLE node ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1;
"#;
/// v20 → v21:格式从分组下沉到条目;老数据按所属根分组的 view_mode 回填。
const V21_SQL: &str = r#"
ALTER TABLE entry ADD COLUMN format TEXT NOT NULL DEFAULT 'apps';
WITH RECURSIVE roots(key, root_key, view_mode) AS (
SELECT key, key, view_mode FROM node WHERE parent_key IS NULL
UNION ALL
SELECT n.key, r.root_key, r.view_mode
FROM node n JOIN roots r ON n.parent_key = r.key
)
UPDATE entry
SET format = COALESCE(
(SELECT view_mode FROM roots WHERE roots.key = entry.node_key),
'apps'
);
"#;
/// v18 → v19:首页卡片改为「卡片 + 勾选分组」。
const V19_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS card (
@@ -276,7 +294,7 @@ pub struct SeedReport {
///
/// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来,
/// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。
pub const SCHEMA_VERSION: i64 = 20;
pub const SCHEMA_VERSION: i64 = 21;
fn current_version(conn: &Connection) -> Result<i64, StashError> {
conn.pragma_query_value(None, "user_version", |row| row.get(0))
@@ -336,6 +354,11 @@ pub fn migrate(conn: &Connection) -> Result<i64, StashError> {
version = 20;
}
if version == 20 {
conn.execute_batch(V21_SQL).map_err(map_error)?;
version = 21;
}
conn.pragma_update(None, "user_version", version)
.map_err(map_error)?;
@@ -415,13 +438,13 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
report.groups += 1;
for (index, entry) in group.entries.iter().enumerate() {
insert_entry(&tx, &group.key, index, entry, &mut report)?;
insert_entry(&tx, &group.key, &section.view, index, entry, &mut report)?;
}
}
} else {
// 海报与图文没有分组,直接挂在分区上(node_key 就是分区自己)
for (index, entry) in section.entries.iter().enumerate() {
insert_entry(&tx, &section.key, index, entry, &mut report)?;
insert_entry(&tx, &section.key, &section.view, index, entry, &mut report)?;
}
}
@@ -459,18 +482,20 @@ pub fn seed(conn: &mut Connection) -> Result<SeedReport, StashError> {
fn insert_entry(
tx: &rusqlite::Transaction<'_>,
node_key: &str,
format: &str,
index: usize,
entry: &SeedEntry,
report: &mut SeedReport,
) -> Result<(), StashError> {
tx.execute(
"INSERT INTO entry (
key, node_key, name, description, icon, href, cover,
key, node_key, format, name, description, icon, href, cover,
banner, badge, comment, summary, body, hot, views, likes, sort
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
params![
entry.key,
node_key,
format,
entry.name,
entry.description,
entry.icon,