diff --git a/HANDOFF.md b/HANDOFF.md index 7e5e4b1..63bcc43 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -97,8 +97,8 @@ README.md 运行、路由、库结构、登录说明 ### `/admin`(新原子模型) | Tab | 能力 | | --- | --- | -| 分组管理 | 树形表格:新建根分组(左侧)、加子分组、编辑(名称/上级/格式/图标/排序/状态)、删除;行内状态开关即时生效;图标可留空 | -| 内容 | 选分组以列表展示条目(选主分组时包含所有子分组);列:标题 / 跳转链接 / 所属分组 / 操作;弹窗新增/编辑,编辑时显示不可改的「标识」 | +| 分组管理 | 树形表格:新建根分组(左侧)、加子分组、编辑(名称/上级/图标/排序/状态)、删除;格式已下沉到条目,不再在分组上设置 | +| 内容 | 选分组以列表展示条目(选主分组时包含所有子分组);列:标题 / 格式 / 跳转链接 / 所属分组 / 操作;每条内容单独选格式(应用/海报/图文),同一分组可混合 | | 侧边栏 | 分组树逐项勾选,开关在右侧、**拨动即保存**(无单独保存/撤销按钮);子分组可单独勾选 | | 首页卡片 | 左侧卡片列表 + 右侧分组表格;新建卡片弹窗输入标题;卡片可拖动排序(影响首页顺序);表格内开关/条数即时保存,标题用「保存」按钮 | | 页头 | 启用开关、速度、条目、标签与色调 | @@ -164,8 +164,9 @@ GET /api/backup SQLite 快照附件 需登录 ## 5. 数据库与迁移 - 文件:开发态在 `target/debug/data/stash.db`;可用 `--root` / 数据目录覆盖(见 `config.rs`)。 -- 当前 `PRAGMA user_version = 20`;`schema::migrate` 逐级升级:0 建全量、15→16→17→18→19→20、更高版本拒绝启动。 -- `node` 是统一分组树(根节点 `kind='section'` 带 `view_mode` 格式,子节点 `kind='group'` 可多层);`enabled` 控制启停;`icon` 可为空。 +- 当前 `PRAGMA user_version = 21`;`schema::migrate` 逐级升级:0 建全量、15→16→17→18→19→20→21、更高版本拒绝启动。 +- 格式在 `entry.format`(apps / posters / articles),同组可混合;`node` 的旧 `view_mode` 仅保留兼容,不再决定渲染。 +- `node` 是统一分组树(根节点 `kind='section'`,子节点 `kind='group'` 可多层);`enabled` 控制启停;`icon` 可为空。 - 主要表:`node`、`entry`、`entry_tag`、`section_block`、`admin`、`session`、`setting`、`audit`。 - `entry.updated_at` 用于编辑乐观锁;`setting` 表 `key='site'` 存站点 JSON。 - **设置/内容都是 SQLite**:备份用 `/api/backup`,恢复需停机替换文件。 diff --git a/README.md b/README.md index 31fff13..8cb1f57 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000 ### 登录与设置(v0.1.1 起) - 登录是**页内弹窗**:点右上角用户图标;首次使用弹窗里是「设置密码 + 确认」(至少 8 位); -- 登录后点「站点设置」进入 `/admin`:分组管理(树形表格,可建根/子分组、图标可空、排序权重、启停、删除)、内容(选分组以列表查看/编辑条目,主分组含子分组)、侧边栏(勾选分组树)、首页卡片(建卡 + 勾选分组,可混合格式),以及页头滚动信息、资料与系统设置; +- 登录后点「站点设置」进入 `/admin`:分组管理(树形表格,可建根/子分组、图标可空、排序权重、启停、删除)、内容(选分组以列表查看/编辑条目,每条可单独设格式,主分组含子分组)、侧边栏(勾选分组树)、首页卡片(左侧卡片列表 + 分组表格),以及页头滚动信息、资料与系统设置; - 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁; - `/admin` 的「系统」页可以改密码、下载整库备份(`VACUUM INTO` 快照); - 密码用 argon2id 哈希存在 `data/stash.db` 的 `admin` 表,会话是 HttpOnly + SameSite=Strict 的 Cookie(7 天); diff --git a/docs/ADMIN-PLAN.md b/docs/ADMIN-PLAN.md index b27afed..307084f 100644 --- a/docs/ADMIN-PLAN.md +++ b/docs/ADMIN-PLAN.md @@ -147,6 +147,14 @@ DELETE /api/items/:id ## 11. 实施记录 +### P16(2026-10-31,格式下沉到条目) + +- 数据库 v21:`entry.format`(apps / posters / articles),老数据按所属根分组的 `view_mode` 自动回填;新库 seed 按分区写入; +- 分组管理移除「格式」字段与显示,根分组只作为容器; +- 内容弹窗新增「格式」选择,列表增加「格式」列;同一条目可单独设为应用/海报/图文; +- 前台改为按 `entry.format` 渲染:首页卡片、分组页同一分组内可同时出现应用网格与海报/图文网格;详情页按条目格式选择模板; +- 验证:迁移后 20 应用 / 13 海报;在应用分组内新建海报条目后,内容列表、首页卡片、分组页均能混合展示。 + ### P15(2026-10-31,卡片新建弹窗、标题保存/删除、拖动排序) - 新建卡片改为弹窗:输入标题 → 确认新建 / 取消关闭;创建后自动选中新表; diff --git a/src/db/catalog.rs b/src/db/catalog.rs index 4df6a16..6e83133 100644 --- a/src/db/catalog.rs +++ b/src/db/catalog.rs @@ -31,6 +31,7 @@ struct NodeRow { /// 条目行:与 `entry` 表的列一一对应 struct EntryRow { key: String, + format: String, name: String, description: Option, icon: Option, @@ -140,7 +141,7 @@ pub fn load_tree(conn: &Connection) -> Result { 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::>(); 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::>(); 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::>(); 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>) -> Value { - match view_mode { +/// 按条目自身的格式组装列表项。 +fn item_for(entry: &EntryRow, tags: &HashMap>) -> 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>, ) -> Result { 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::>(); 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, 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, hot: row.get(11)?, views: row.get(12)?, likes: row.get(13)?, + format: row.get(14)?, }) }) .map_err(map_error)? diff --git a/src/db/entries.rs b/src/db/entries.rs index d42dadb..a91a969 100644 --- a/src/db/entries.rs +++ b/src/db/entries.rs @@ -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, + /// 条目自身的展示格式:apps / posters / articles;不传默认 apps + #[serde(default)] + pub format: Option, } /// 新建条目的请求体:`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, 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, 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 Result Result { 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, } } diff --git a/src/db/schema.rs b/src/db/schema.rs index 1b13661..dc0ac96 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -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 { conn.pragma_query_value(None, "user_version", |row| row.get(0)) @@ -336,6 +354,11 @@ pub fn migrate(conn: &Connection) -> Result { 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 { 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 { 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, diff --git a/stash-web/src/App.css b/stash-web/src/App.css index 52d882e..90b9c13 100644 --- a/stash-web/src/App.css +++ b/stash-web/src/App.css @@ -348,6 +348,10 @@ color: var(--su-text-sub); } +.mixed-format-block + .mixed-format-block { + margin-top: var(--su-space-4); +} + .group-crumbs { display: flex; align-items: center; @@ -1231,7 +1235,20 @@ /* 内容页:与分组管理一致的列表 */ .entry-table .entry-col-href { - width: 40%; + width: 36%; +} + +.entry-table .entry-col-format { + width: 80px; +} + +.entry-format { + display: inline-block; + padding: 0 var(--su-space-2); + border-radius: var(--su-radius-xs); + background-color: var(--su-bg-subtle); + color: var(--su-text-muted); + font-size: var(--su-font-size-sm); } .entry-table .entry-col-group { diff --git a/stash-web/src/AppNext.tsx b/stash-web/src/AppNext.tsx index d871662..081f390 100644 --- a/stash-web/src/AppNext.tsx +++ b/stash-web/src/AppNext.tsx @@ -144,8 +144,12 @@ function collectEntries(group: GroupNode): Record[] { } function defaultLimit(group: GroupNode): number { - const fallback = group.format === 'apps' ? 4 : group.format === 'posters' ? 6 : 4 - return group.previewLimit ?? fallback + return group.previewLimit ?? 6 +} + +function entryFormat(entry: Record): 'apps' | 'posters' | 'articles' { + const value = entry.format + return value === 'posters' || value === 'articles' ? value : 'apps' } function matches(entry: Record, keyword: string): boolean { @@ -273,13 +277,25 @@ export default function AppNext() { const items = filtered.slice(0, limit) const open = (itemId: string) => navigate(`/g/${encodeURIComponent(group.key)}/i/${encodeURIComponent(itemId)}`) - if (group.format === 'posters') { - return - } - if (group.format === 'articles') { - return - } - return + return ( + <> + {(['apps', 'posters', 'articles'] as const).map((format) => { + const subset = items.filter((entry) => entryFormat(entry) === format) + if (subset.length === 0) return null + return ( +
+ {format === 'posters' ? ( + + ) : format === 'articles' ? ( + + ) : ( + + )} +
+ ) + })} + + ) } function renderGroupBlock(group: GroupNode, limit: number, showTitle: boolean) { @@ -423,7 +439,9 @@ export default function AppNext() { { label: String(entry.name ?? entry.title ?? entry.id) }, ] - if (group.format === 'articles') { + const format = entryFormat(entry) + + if (format === 'articles') { const article = entry as unknown as ArticleItem return ( item.id !== entry.id) + .filter((item) => item.id !== entry.id && entryFormat(item as unknown as Record) === format) .slice(0, 4) .map((item) => posterToRelatedItem(item as unknown as PosterItem)) : group.entries - .filter((item) => item.id !== entry.id) + .filter((item) => item.id !== entry.id && entryFormat(item as unknown as Record) === format) .slice(0, 4) .map((item) => toRelatedItem(item as unknown as AppItem)) diff --git a/stash-web/src/components/admin/AdminNext.tsx b/stash-web/src/components/admin/AdminNext.tsx index 43f3be0..ea64aa4 100644 --- a/stash-web/src/components/admin/AdminNext.tsx +++ b/stash-web/src/components/admin/AdminNext.tsx @@ -499,7 +499,6 @@ function GroupsPanel({ )} {node.title} - {FORMAT_LABEL[node.format]} {node.sort} @@ -583,7 +582,6 @@ function GroupDialog({ const [key, setKey] = useState('') const [title, setTitle] = useState('') const [parentKey, setParentKey] = useState('') - const [format, setFormat] = useState('apps') const [icon, setIcon] = useState('') const [sort, setSort] = useState('') const [enabled, setEnabled] = useState(true) @@ -599,7 +597,6 @@ function GroupDialog({ setKey(state.group.key) setTitle(state.group.title) setParentKey(state.group.parentKey ?? '') - setFormat(state.group.format) setIcon(state.group.icon) setSort(String(state.group.sort)) setEnabled(state.group.enabled) @@ -608,7 +605,6 @@ function GroupDialog({ setKey('') setTitle('') - setFormat('apps') setIcon('') setSort('') setEnabled(true) @@ -625,7 +621,6 @@ function GroupDialog({ const rootMode = parentKey === '' || isRootEdit const showKey = editing === undefined - const submit = async () => { if (title.trim() === '') { setError('分组名称不能为空') @@ -645,9 +640,7 @@ function GroupDialog({ icon, sort: sort.trim() === '' ? undefined : Number(sort), enabled, - ...(isRootEdit - ? { format } - : { parentKey: parentKey }), + ...(isRootEdit ? {} : { parentKey }), }) setBusy(false) if (!result.ok) { @@ -663,7 +656,6 @@ function GroupDialog({ key: key.trim(), title: title.trim(), parentKey: rootMode ? undefined : parentKey, - format: rootMode ? format : undefined, icon, sort: sort.trim() === '' ? undefined : Number(sort), enabled, @@ -750,16 +742,6 @@ function GroupDialog({ )} - {rootMode && ( - - update({ format: next as GroupFormat })} + /> + +