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:
+19
-11
@@ -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, §ion.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, §ion.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
@@ -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
@@ -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, §ion.view, index, entry, &mut report)?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 海报与图文没有分组,直接挂在分区上(node_key 就是分区自己)
|
||||
for (index, entry) in section.entries.iter().enumerate() {
|
||||
insert_entry(&tx, §ion.key, index, entry, &mut report)?;
|
||||
insert_entry(&tx, §ion.key, §ion.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,
|
||||
|
||||
Reference in New Issue
Block a user