diff --git a/HANDOFF.md b/HANDOFF.md index d6b1ab5..db2c562 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -97,7 +97,7 @@ README.md 运行、路由、库结构、登录说明 ### `/admin`(新原子模型) | Tab | 能力 | | --- | --- | -| 分组管理 | 多层分组树:新建根/子分组、改名、根分组格式(应用/海报/图文)、删除(级联子分组与条目) | +| 分组管理 | 树形表格:新建根分组(左侧)、加子分组、编辑(名称/上级/格式/图标/排序/状态)、删除;行内状态开关即时生效;图标可留空 | | 内容 | 选分组新增/编辑/删除条目;条目只属于一个分组 | | 侧边栏 | 分组树逐项勾选(`settings.sidebar.tree_hidden`),子分组可单独勾选 | | 首页卡片 | 新建/改名/删除卡片;勾选若干分组(可混合格式),每组单独设条数 | @@ -164,8 +164,8 @@ GET /api/backup SQLite 快照附件 需登录 ## 5. 数据库与迁移 - 文件:开发态在 `target/debug/data/stash.db`;可用 `--root` / 数据目录覆盖(见 `config.rs`)。 -- 当前 `PRAGMA user_version = 19`;`schema::migrate` 逐级升级:0 建全量、15→16→17→18→19、更高版本拒绝启动。 -- `node` 是统一分组树(根节点 `kind='section'` 带 `view_mode` 格式,子节点 `kind='group'` 可多层);`card` / `card_group` 是首页卡片的展示组合(卡片不拥有内容)。 +- 当前 `PRAGMA user_version = 20`;`schema::migrate` 逐级升级:0 建全量、15→16→17→18→19→20、更高版本拒绝启动。 +- `node` 是统一分组树(根节点 `kind='section'` 带 `view_mode` 格式,子节点 `kind='group'` 可多层);`enabled` 控制启停;`icon` 可为空。 - 主要表:`node`、`entry`、`entry_tag`、`section_block`、`admin`、`session`、`setting`、`audit`。 - `entry.updated_at` 用于编辑乐观锁;`setting` 表 `key='site'` 存站点 JSON。 - **设置/内容都是 SQLite**:备份用 `/api/backup`,恢复需停机替换文件。 @@ -205,8 +205,9 @@ GET /api/backup SQLite 快照附件 需登录 | 图片上传 | 只支持 URL;上传需存储目录、大小/类型校验 | | 拖拽排序 | 分组/卡片顺序暂用创建顺序;上移下移/拖拽未做 | | 多用户/角色 | 未做,当前单管理员 | -| 分组移动 UI | 后端 `PATCH /api/nodes/{key}` 支持 `parentKey` 移动与防环;后台暂未暴露移动入口 | +| 分组移动 | 编辑弹窗里可改「上级分组」(防环);根分组不能移到子级 | | 条目移动 | 内容弹窗里可直接换分组(`PATCH /api/items/{key}` 的 `nodeKey`) | +| 分组排序 | 可编辑「排序权重」数值;拖拽排序未做 | | 旧接口 | `/api/catalog`、`/api/categories/*`、`/api/groups/*` 仍保留给旧组件/兼容,后续可删 | | 登录限流 | 进程内计数,重启清空 | diff --git a/README.md b/README.md index 05c3dd0..ed51a18 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 a567631..11e5398 100644 --- a/docs/ADMIN-PLAN.md +++ b/docs/ADMIN-PLAN.md @@ -147,6 +147,14 @@ DELETE /api/items/:id ## 11. 实施记录 +### P10(2026-10-31,分组管理页重做) + +- 参考 `TT-Helper/需求/5297/1.html` 重做分组管理:树形表格(分组名称 / 排序权重 / 关联网页数 / 状态 / 操作),去掉「展开全部 / 折叠全部」,新建根分组放左侧; +- 分组弹窗:上级分组(可移动、防环)、名称、格式(根)、**图标(可留空)**、排序权重、状态; +- 数据层:`node.enabled`(迁移 v20)控制启停,禁用分组在前台侧栏/卡片隐藏;`node.icon` 支持 iconfont 名、字符或空; +- 排序修复:同一父级下编辑排序权重不再被当成「移动」重置为末尾; +- 验证:无图标/有图标、排序 5、禁用后前台隐藏、折叠行均通过;`cargo test` 59 passed。 + ### P9(2026-10-30,前台切换) - **前台**:`AppNext` 改为读 `/api/catalog2`,首页按 `card` 勾选的分组渲染(可混合格式,每组条数独立),侧栏按分组树 + `tree_hidden` 渲染,分组页 `/g/`、条目页 `/g//i/`; diff --git a/src/db/catalog.rs b/src/db/catalog.rs index ff0f60c..8c80e2e 100644 --- a/src/db/catalog.rs +++ b/src/db/catalog.rs @@ -63,13 +63,14 @@ struct TreeNode { bar_color: Option, preview_limit: Option, sort: i64, + enabled: bool, } /// 新的原子视图:完整分组树(多层、带格式、带条目),供新首页/侧栏/后台使用。 pub fn load_tree(conn: &Connection) -> Result { let mut stmt = conn .prepare( - "SELECT key, parent_key, title, view_mode, icon, bar_color, preview_limit, sort + "SELECT key, parent_key, title, view_mode, icon, bar_color, preview_limit, sort, enabled FROM node ORDER BY sort, key", ) .map_err(map_error)?; @@ -85,6 +86,7 @@ pub fn load_tree(conn: &Connection) -> Result { bar_color: row.get(5)?, preview_limit: row.get(6)?, sort: row.get(7)?, + enabled: row.get(8)?, }) }) .map_err(map_error)? @@ -160,6 +162,7 @@ pub fn load_tree(conn: &Connection) -> Result { "barColor": node.bar_color.clone().unwrap_or_default(), "previewLimit": node.preview_limit, "sort": node.sort, + "enabled": node.enabled, "entries": items, "children": child_values, })) diff --git a/src/db/groups.rs b/src/db/groups.rs index 215be24..9223d7c 100644 --- a/src/db/groups.rs +++ b/src/db/groups.rs @@ -23,6 +23,15 @@ pub struct NewNode { /// 根分组的格式:apps / posters / articles(子分组继承,不需要传) #[serde(default)] pub format: Option, + /// 图标:可以是 iconfont 名或字符;空字符串表示不显示 + #[serde(default)] + pub icon: Option, + /// 排序权重;不传则排到同级末尾 + #[serde(default)] + pub sort: Option, + /// 是否启用,默认 true + #[serde(default)] + pub enabled: Option, } #[derive(Debug, Clone, Default, Deserialize)] @@ -34,6 +43,9 @@ pub struct UpdateNode { pub sort: Option, /// 只对根分组生效 pub format: Option, + /// 图标:空字符串表示清除图标 + pub icon: Option, + pub enabled: Option, } fn valid_key(key: &str) -> bool { @@ -87,7 +99,7 @@ pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> { } }; - let sort: i64 = match new.parent_key.as_deref() { + let default_sort: i64 = match new.parent_key.as_deref() { Some(parent) => conn .query_row( "SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1", @@ -103,12 +115,29 @@ pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> { ) .map_err(map_error)?, }; + let sort = new.sort.unwrap_or(default_sort); + let icon = new + .icon + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let enabled = new.enabled.unwrap_or(true); let tx = conn.transaction().map_err(map_error)?; tx.execute( - "INSERT INTO node (key, parent_key, title, kind, view_mode, sort, show_in_sidebar, allow_groups) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, 1)", - params![key, new.parent_key, title, kind, view_mode, sort], + "INSERT INTO node (key, parent_key, title, kind, view_mode, icon, enabled, sort, show_in_sidebar, allow_groups) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1, 1)", + params![ + key, + new.parent_key, + title, + kind, + view_mode, + icon, + enabled, + sort + ], ) .map_err(map_error)?; entries::audit(&tx, "node.create", key, title)?; @@ -117,16 +146,27 @@ pub fn create(conn: &mut Connection, new: NewNode) -> Result<(), StashError> { } pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(), StashError> { - let current: Option<(String, Option, Option, i64)> = conn + let current: Option<(String, Option, Option, i64, Option, bool)> = conn .query_row( - "SELECT title, parent_key, view_mode, sort FROM node WHERE key = ?1", + "SELECT title, parent_key, view_mode, sort, icon, enabled FROM node WHERE key = ?1", [key], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + )) + }, ) .optional() .map_err(map_error)?; - let Some((current_title, current_parent, current_format, current_sort)) = current else { + let Some((current_title, current_parent, current_format, current_sort, current_icon, current_enabled)) = + current + else { return Err(StashError::NotFound(format!("分组不存在: {key}"))); }; @@ -198,24 +238,40 @@ pub fn update(conn: &mut Connection, key: &str, patch: UpdateNode) -> Result<(), return Err(StashError::InvalidDirectory("根分组缺少格式".into())); } + let icon = match patch.icon { + Some(icon) => { + let icon = icon.trim().to_string(); + (!icon.is_empty()).then_some(icon) + } + None => current_icon, + }; + let enabled = patch.enabled.unwrap_or(current_enabled); + let sort = patch.sort.unwrap_or(current_sort); let tx = conn.transaction().map_err(map_error)?; - // 移动时重新排到新父级末尾;不移动则保留原顺序 - let final_sort = match patch.parent_key { - Some(parent) => tx - .query_row( - "SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1 AND key <> ?2", - params![parent, key], - |row| row.get::<_, i64>(0), - ) - .map_err(map_error)?, - None => sort, + // 只有真正换了父级才排到末尾;同一父级下编辑排序权重时按传入值保存 + let parent_changed = patch + .parent_key + .as_deref() + .is_some_and(|parent| Some(parent) != current_parent.as_deref()); + let final_sort = if parent_changed { + let parent = patch.parent_key.as_deref().unwrap_or_default(); + tx.query_row( + "SELECT COALESCE(MAX(sort), 0) + 1 FROM node WHERE parent_key = ?1 AND key <> ?2", + params![parent, key], + |row| row.get::<_, i64>(0), + ) + .map_err(map_error)? + } else { + sort }; tx.execute( - "UPDATE node SET title = ?1, parent_key = ?2, view_mode = ?3, sort = ?4 WHERE key = ?5", - params![title, parent_key, format, final_sort, key], + "UPDATE node SET title = ?1, parent_key = ?2, view_mode = ?3, sort = ?4, + icon = ?5, enabled = ?6 + WHERE key = ?7", + params![title, parent_key, format, final_sort, icon, enabled, key], ) .map_err(map_error)?; entries::audit(&tx, "node.update", key, &title)?; diff --git a/src/db/schema.rs b/src/db/schema.rs index 3614ed4..1b13661 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -48,6 +48,8 @@ CREATE TABLE IF NOT EXISTS node ( -- 是否出现在侧栏:0 表示只当首页卡片/内容,不进导航 allow_groups INTEGER NOT NULL DEFAULT 0 CHECK (allow_groups IN (0, 1)), -- 是否允许子分组(与类型无关,后台开关) + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + -- 是否启用(禁用后前台侧栏/卡片不展示) sort INTEGER NOT NULL DEFAULT 0, -- 同级顺序,越小越靠前 CHECK ( (kind = 'section' AND parent_key IS NULL AND view_mode IS NOT NULL) @@ -210,6 +212,11 @@ WHERE kind = 'section' AND key IN ( ); "#; +/// v19 → v20:分组增加启停状态。 +const V20_SQL: &str = r#" +ALTER TABLE node ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1; +"#; + /// v18 → v19:首页卡片改为「卡片 + 勾选分组」。 const V19_SQL: &str = r#" CREATE TABLE IF NOT EXISTS card ( @@ -269,7 +276,7 @@ pub struct SeedReport { /// /// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来, /// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。 -pub const SCHEMA_VERSION: i64 = 19; +pub const SCHEMA_VERSION: i64 = 20; fn current_version(conn: &Connection) -> Result { conn.pragma_query_value(None, "user_version", |row| row.get(0)) @@ -324,6 +331,11 @@ pub fn migrate(conn: &Connection) -> Result { version = 19; } + if version == 19 { + conn.execute_batch(V20_SQL).map_err(map_error)?; + version = 20; + } + conn.pragma_update(None, "user_version", version) .map_err(map_error)?; diff --git a/stash-web/src/App.css b/stash-web/src/App.css index 0e6eeae..9789a5a 100644 --- a/stash-web/src/App.css +++ b/stash-web/src/App.css @@ -1048,6 +1048,186 @@ gap: var(--su-space-2); } +/* 分组管理:参考后台的树形表格 */ +.group-toolbar { + display: flex; + justify-content: flex-start; +} + +.group-table-wrap { + overflow: hidden; + border: 1px solid var(--su-card-border); + border-radius: var(--su-radius-md); + background-color: var(--su-bg-card); +} + +.group-table { + width: 100%; + border-collapse: collapse; + font-size: var(--su-font-size-md); +} + +.group-table th { + padding: var(--su-space-3) var(--su-space-4); + background-color: var(--su-bg-subtle); + border-bottom: 1px solid var(--su-card-border); + color: var(--su-text-muted); + font-size: var(--su-font-size-sm); + font-weight: 600; + text-align: left; + white-space: nowrap; +} + +.group-table td { + padding: var(--su-space-3) var(--su-space-4); + border-bottom: 1px solid var(--su-card-border); + vertical-align: middle; +} + +.group-table tbody tr:last-child td { + border-bottom: none; +} + +.group-table tbody tr:hover { + background-color: var(--su-bg-subtle); +} + +.group-col-sort { + width: 110px; +} + +.group-col-count { + width: 120px; + text-align: center !important; +} + +.group-col-status { + width: 90px; + text-align: center !important; +} + +.group-col-actions { + width: 190px; + text-align: right !important; +} + +.group-name-cell { + display: flex; + align-items: center; + gap: var(--su-space-2); +} + +.group-caret { + width: 20px; + flex: none; + padding: 0; + border: none; + background: none; + color: var(--su-text-muted); + font-size: var(--su-font-size-sm); + line-height: 1; + cursor: pointer; +} + +.group-caret-empty { + cursor: default; +} + +.group-mark { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 20px; + color: var(--su-color-primary); + font-size: var(--su-font-size-lg); +} + +.group-mark-empty { + width: 20px; +} + +.group-mark-text { + font-size: var(--su-font-size-md); +} + +.group-name { + color: var(--su-text-main); + font-weight: 500; +} + +.group-format { + 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); +} + +.group-sort { + color: var(--su-text-muted); + font-family: var(--su-font-mono); + font-size: var(--su-font-size-sm); +} + +.group-count { + color: var(--su-text-sub); + text-align: center; +} + +.group-actions { + display: flex; + justify-content: flex-end; + gap: var(--su-space-3); +} + +.group-action { + padding: 0; + border: none; + background: none; + color: var(--su-color-primary); + font-size: var(--su-font-size-sm); + cursor: pointer; +} + +.group-action:hover { + text-decoration: underline; +} + +.group-action-danger { + color: var(--su-color-danger); +} + +.group-empty { + padding: var(--su-space-6) 0 !important; + color: var(--su-text-muted); + text-align: center; +} + +.group-icon-field { + display: flex; + align-items: center; + gap: var(--su-space-2); +} + +.group-icon-preview { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: 1px solid var(--su-border-strong); + border-radius: var(--su-radius-sm); + background-color: var(--su-bg-subtle); +} + +.group-dialog-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--su-space-3); +} + .admin-category-row > * { min-width: 0; } diff --git a/stash-web/src/AppNext.tsx b/stash-web/src/AppNext.tsx index e5ee81c..d871662 100644 --- a/stash-web/src/AppNext.tsx +++ b/stash-web/src/AppNext.tsx @@ -241,6 +241,7 @@ export default function AppNext() { const build = (nodes: GroupNode[], parentKey: string): MenuItem[] => sortNodes(nodes, parentKey).flatMap((node) => { + if (node.enabled === false) return [] if (hidden.has(node.key)) return build(node.children, node.key) const children = build(node.children, node.key) return [ @@ -295,7 +296,10 @@ export default function AppNext() { function renderCard(card: CardItem) { const resolved = card.groups .map((ref) => ({ ref, group: findGroup(manage.groups, ref.groupKey) })) - .filter((item): item is { ref: typeof item.ref; group: GroupNode } => item.group !== undefined) + .filter( + (item): item is { ref: typeof item.ref; group: GroupNode } => + item.group !== undefined && item.group.enabled !== false, + ) const total = resolved.reduce((sum, item) => sum + groupEntryCount(item.group), 0) const first = resolved[0]?.group diff --git a/stash-web/src/components/admin/AdminNext.tsx b/stash-web/src/components/admin/AdminNext.tsx index 8d3fe94..8829682 100644 --- a/stash-web/src/components/admin/AdminNext.tsx +++ b/stash-web/src/components/admin/AdminNext.tsx @@ -3,6 +3,7 @@ import { Card, FormField, Icon, + ICON_NAMES, Input, Modal, Notice, @@ -16,6 +17,7 @@ import { Title, Toaster, toast, + type IconName, } from '@stash/ui' import { useEffect, useMemo, useState } from 'react' import { changePassword, fetchSession, logout, type SessionState } from '../../auth' @@ -329,6 +331,62 @@ export function AdminNext() { // ---------- 分组管理 ---------- +function countGroupEntries(group: GroupNode): number { + return group.entries.length + group.children.reduce((total, child) => total + countGroupEntries(child), 0) +} + +function flattenWithDepth( + groups: GroupNode[], + depth = 0, +): { node: GroupNode; depth: number }[] { + return groups.flatMap((node) => [ + { node, depth }, + ...flattenWithDepth(node.children, depth + 1), + ]) +} + +function descendantKeys(group: GroupNode): Set { + const keys = new Set([group.key]) + const walk = (nodes: GroupNode[]) => { + for (const node of nodes) { + keys.add(node.key) + walk(node.children) + } + } + walk(group.children) + return keys +} + +const FORMAT_LABEL: Record = { + apps: '应用', + posters: '海报', + articles: '图文', +} + +function GroupMark({ icon }: { icon: string }) { + if (icon.trim() === '') { + return