From 3c6c20e53414a5887ef7a2b5a41da385343985ee Mon Sep 17 00:00:00 2001 From: stash Date: Thu, 29 Oct 2026 19:21:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E9=A6=96=E9=A1=B5=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E9=85=8D=E7=BD=AE=20+=20=E9=A1=B5=E5=A4=B4=E5=BC=80?= =?UTF-8?q?=E5=85=B3=E9=AA=8C=E8=AF=81=20+=20=E7=BC=96=E8=BE=91=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E4=BB=85=E7=99=BB=E5=BD=95=E5=8F=AF=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settings.sections:显隐 / 顺序 / 标题覆盖 / 预览条数,HomeView 应用 - /admin 新增「首页卡片」Tab,卡片标题使用展示块标题(常用站点等) - 详情页 [编辑] 仅在登录后渲染(canEdit) - 测试:46 passed;页头禁用、卡片隐藏/改标题/限条数、编辑可见性均实测 - 文档:ADMIN-PLAN、README --- README.md | 4 +- docs/ADMIN-PLAN.md | 8 ++ src/db/settings.rs | 31 +++++ stash-web/src/App.css | 4 + stash-web/src/App.tsx | 3 + stash-web/src/components/admin/AdminPage.tsx | 117 +++++++++++++++++- .../components/content/ArticleDetailView.tsx | 11 +- stash-web/src/components/content/HomeView.tsx | 35 ++++-- .../src/components/content/ItemDetailView.tsx | 11 +- stash-web/src/settings.ts | 48 ++++++- 10 files changed, 251 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 5ac7d28..a00d6f3 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,8 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000 ### 登录与设置(v0.1.1 起) - 登录是**页内弹窗**:点右上角用户图标;首次使用弹窗里是「设置密码 + 确认」(至少 8 位); -- 登录后点「站点设置」进入 `/admin`,可编辑页头滚动信息(启用 / 速度 / 条目)与侧栏顺序、显隐; -- 详情页的 `[编辑]` 打开右侧 Drawer,可改标题 / 简介 / 标签 / 正文等(保存后局部刷新,`updated_at` 乐观锁); +- 登录后点「站点设置」进入 `/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 192285b..b2c589b 100644 --- a/docs/ADMIN-PLAN.md +++ b/docs/ADMIN-PLAN.md @@ -167,6 +167,14 @@ DELETE /api/items/:id - 首页应用设置:滚动条目的文本/标签/速度/启用,侧栏按顺序与显隐渲染; - 测试:42 passed(新增 settings 5 例:默认值、版本递增、冲突、校验、坏 JSON 回落)。 +### P1 扩展(2026-10-29,同日) + +- 首页卡片可配置:显隐、顺序、标题覆盖、每卡预览条数(`settings.sections`,前端 `HomeView` 应用); +- 页头滚动信息支持**启用/禁用**(`header.enabled`,关闭后页头中间留空); +- 「编辑」按钮只在登录后出现(`canEdit`,未登录时详情页与文章页不渲染 `[编辑]`); +- 侧栏「首页」与卡片顺序相互独立:侧栏管导航,卡片管首页布局。 + + ### P2(已完成,2026-10-29) - 库新增 `Textarea`(stash-ui v0.2.0):`rows` / `resize` / `invalid`; diff --git a/src/db/settings.rs b/src/db/settings.rs index 0f7a50a..d407bf7 100644 --- a/src/db/settings.rs +++ b/src/db/settings.rs @@ -88,11 +88,25 @@ pub struct SidebarSettings { pub order: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(default)] +pub struct SectionsSettings { + /// 隐藏的首页卡片(分类 id) + pub hidden: Vec, + /// 卡片顺序(分类 id) + pub order: Vec, + /// 标题覆盖:id → 标题 + pub titles: std::collections::HashMap, + /// 预览条数覆盖:id → 条数 + pub limits: std::collections::HashMap, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(default)] pub struct Settings { pub header: HeaderSettings, pub sidebar: SidebarSettings, + pub sections: SectionsSettings, } /// 读到/保存后的完整状态:设置本体 + 版本号(前端提交时要带回来)。 @@ -134,6 +148,22 @@ 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.sections.hidden.len() > 100 || settings.sections.order.len() > 100 { + return Err(StashError::InvalidDirectory("首页卡片配置过长".into())); + } + if settings.sections.titles.len() > 100 || settings.sections.limits.len() > 100 { + return Err(StashError::InvalidDirectory("首页卡片配置过长".into())); + } + for title in settings.sections.titles.values() { + if title.chars().count() > 40 { + return Err(StashError::InvalidDirectory("卡片标题最多 40 个字".into())); + } + } + for limit in settings.sections.limits.values() { + if *limit > 60 { + return Err(StashError::InvalidDirectory("卡片条数最多 60".into())); + } + } Ok(()) } @@ -239,6 +269,7 @@ mod tests { let settings = Settings::default(); assert!(settings.header.enabled); assert_eq!(settings.header.items.len(), 3); + assert!(settings.sections.hidden.is_empty()); assert!(validate(&settings).is_ok()); } diff --git a/stash-web/src/App.css b/stash-web/src/App.css index 299e76b..71a2c7b 100644 --- a/stash-web/src/App.css +++ b/stash-web/src/App.css @@ -840,6 +840,10 @@ grid-template-columns: auto minmax(0, 1fr) auto; } +.admin-section-row { + grid-template-columns: auto minmax(0, 1fr) 96px auto; +} + .admin-item-actions { display: flex; justify-content: flex-end; diff --git a/stash-web/src/App.tsx b/stash-web/src/App.tsx index 1f159b1..5f3c8b9 100644 --- a/stash-web/src/App.tsx +++ b/stash-web/src/App.tsx @@ -256,6 +256,7 @@ export default function App() { categoryTitle={itemCategory.title} onNavigate={navigateHref} onEdit={() => setEditingKey(itemView.itemId)} + canEdit={session?.authenticated === true} /> ) } @@ -276,6 +277,7 @@ export default function App() { onNavigate={navigateHref} onOpenItem={(itemId) => openItem(itemCategory.id, itemId)} onEdit={() => setEditingKey(itemView.itemId)} + canEdit={session?.authenticated === true} /> ) } @@ -391,6 +393,7 @@ export default function App() { diff --git a/stash-web/src/components/admin/AdminPage.tsx b/stash-web/src/components/admin/AdminPage.tsx index da95fb3..ed5c0b9 100644 --- a/stash-web/src/components/admin/AdminPage.tsx +++ b/stash-web/src/components/admin/AdminPage.tsx @@ -20,6 +20,7 @@ import { saveSettings, TONE_OPTIONS, type HeaderItem, + type SectionSettings, type SettingsDocument, type SettingsTone, type SiteSettings, @@ -31,7 +32,12 @@ import { * 只做「全局配置」;条目内容仍在详情页就地编辑(P2)。 * 保存用 `version` 乐观锁,冲突时提示刷新。 */ -type CategorySummary = { id: string; title: string } +type CategorySummary = { + id: string + title: string + /** 首页卡片标题(配了展示块时用块的标题,如「常用站点」) */ + block?: { title?: string } | null +} const clone = (settings: SiteSettings): SiteSettings => structuredClone(settings) @@ -137,6 +143,7 @@ export function AdminPage() { setDraft({ ...draft, header })} /> + ) : tab === 'sections' ? ( + setDraft({ ...draft, sections })} + /> ) : tab === 'sidebar' ? ( void +}) { + const cards = useMemo(() => { + const base = [ + { id: 'local', title: '本地服务' }, + ...categories + .filter((category) => category.id !== 'local') + .map((category) => ({ + id: category.id, + title: category.block?.title ?? category.title, + })), + ] + const ranks = new Map(value.order.map((id, index) => [id, index])) + + return [...base].sort((a, b) => { + const left = ranks.get(a.id) ?? Number.MAX_SAFE_INTEGER + const right = ranks.get(b.id) ?? Number.MAX_SAFE_INTEGER + return left - right + }) + }, [categories, value.order]) + + const setOrder = (ids: string[]) => onChange({ ...value, order: ids }) + + const move = (index: number, step: -1 | 1) => { + const target = index + step + if (target < 0 || target >= cards.length) return + const ids = cards.map((card) => card.id) + const [id] = ids.splice(index, 1) + ids.splice(target, 0, id) + setOrder(ids) + } + + const toggle = (id: string, visible: boolean) => { + const hidden = visible + ? value.hidden.filter((entry) => entry !== id) + : [...new Set([...value.hidden, id])] + onChange({ ...value, hidden, order: cards.map((card) => card.id) }) + } + + const setTitle = (id: string, title: string) => { + const titles = { ...value.titles } + if (title.trim() === '') delete titles[id] + else titles[id] = title + onChange({ ...value, titles }) + } + + const setLimit = (id: string, raw: string) => { + const limits = { ...value.limits } + const limit = Number(raw) + if (raw.trim() === '' || !Number.isFinite(limit) || limit <= 0) delete limits[id] + else limits[id] = Math.min(60, Math.round(limit)) + onChange({ ...value, limits }) + } + + return ( + + + 控制首页卡片:显示 / 隐藏、标题、每张卡预览条数与顺序。留空表示用内容库里的默认值。 + + + {cards.map((card, index) => ( +
+ toggle(card.id, next)} + label={`显示 ${card.title}`} + /> + setTitle(card.id, event.target.value)} + /> + setLimit(card.id, event.target.value)} + /> + move(index, -1)} + onDown={() => move(index, 1)} + onRemove={undefined} + /> +
+ ))} + + {cards.length === 0 && 内容库还没有加载完成。} +
+ ) +} + // ---------- 系统 ---------- function SystemPanel() { diff --git a/stash-web/src/components/content/ArticleDetailView.tsx b/stash-web/src/components/content/ArticleDetailView.tsx index 4c48fbe..9c516f9 100644 --- a/stash-web/src/components/content/ArticleDetailView.tsx +++ b/stash-web/src/components/content/ArticleDetailView.tsx @@ -16,6 +16,8 @@ type ArticleDetailViewProps = { categoryTitle: string onNavigate: (href: string) => void onEdit: () => void + /** 只有登录后才显示 [编辑] */ + canEdit: boolean } export function ArticleDetailView({ @@ -24,6 +26,7 @@ export function ArticleDetailView({ categoryTitle, onNavigate, onEdit, + canEdit, }: ArticleDetailViewProps) { const meta = article.meta ?? ['2 年前', categoryTitle] const crumbs = [ @@ -41,9 +44,11 @@ export function ArticleDetailView({
{article.title} - <button type="button" className="item-edit" title="编辑文章" onClick={onEdit}> - [编辑] - </button> + {canEdit && ( + <button type="button" className="item-edit" title="编辑文章" onClick={onEdit}> + [编辑] + </button> + )}
diff --git a/stash-web/src/components/content/HomeView.tsx b/stash-web/src/components/content/HomeView.tsx index f739133..fc79787 100644 --- a/stash-web/src/components/content/HomeView.tsx +++ b/stash-web/src/components/content/HomeView.tsx @@ -4,33 +4,46 @@ import { ArticleGrid } from './ArticleGrid' import { Empty } from '@stash/ui' import { PosterGrid } from './PosterGrid' import { SectionCard } from './SectionCard' +import { + applySectionSettings, + sectionLimit, + sectionTitle, + type SectionSettings, +} from '../../settings' /** * 首页:把每个分类渲染成一张分区卡片。 * - * 每个分区只展示前若干条(由分类的 previewLimit 决定), - * 右上角 ••• 进入该分类明细,点击卡片 / 海报则进入该条目的详情。 + * 每个分区只展示前若干条;标题、条数、顺序与显隐都可以在 `/admin` + * 的「首页卡片」里覆盖(见 settings.sections)。 */ type HomeViewProps = { categories: Category[] + sections: SectionSettings onOpenDetail: (categoryId: string) => void onOpenItem: (categoryId: string, itemId: string) => void } -export function HomeView({ categories, onOpenDetail, onOpenItem }: HomeViewProps) { +export function HomeView({ categories, sections, onOpenDetail, onOpenItem }: HomeViewProps) { + const cards = applySectionSettings(categories, sections) + return (
- {categories.map((category) => ( + {cards.map((category) => ( onOpenDetail(category.id)} > - {renderPreview(category, onOpenItem)} + {renderPreview(category, sections, onOpenItem)} ))}
@@ -55,6 +68,7 @@ function cardCount(category: Category): number { /** 按分类类型输出对应的预览网格;应用类为空时给出提示文案 */ function renderPreview( category: Category, + sections: SectionSettings, onOpenItem: (categoryId: string, itemId: string) => void, ) { if (category.kind === 'apps') { @@ -65,7 +79,8 @@ function renderPreview( : undefined const source = category.block !== undefined ? (blockGroup?.items ?? []) : flattenAppItems(category) - const limit = category.block?.previewLimit ?? category.previewLimit ?? 4 + const fallback = category.block?.previewLimit ?? category.previewLimit ?? 4 + const limit = sectionLimit(category.id, fallback, sections) const items = source.slice(0, limit) if (items.length === 0) { @@ -78,7 +93,7 @@ function renderPreview( if (category.kind === 'posters') { return ( onOpenItem(category.id, itemId)} /> ) @@ -86,7 +101,7 @@ function renderPreview( return ( onOpenItem(category.id, itemId)} /> ) diff --git a/stash-web/src/components/content/ItemDetailView.tsx b/stash-web/src/components/content/ItemDetailView.tsx index d96ed51..007a0b2 100644 --- a/stash-web/src/components/content/ItemDetailView.tsx +++ b/stash-web/src/components/content/ItemDetailView.tsx @@ -29,6 +29,8 @@ type ItemDetailViewProps = { onOpenItem: (itemId: string) => void /** 打开编辑 Drawer */ onEdit: () => void + /** 只有登录后才显示 [编辑] */ + canEdit: boolean } export function ItemDetailView({ @@ -38,6 +40,7 @@ export function ItemDetailView({ onNavigate, onOpenItem, onEdit, + canEdit, }: ItemDetailViewProps) { return (
@@ -48,9 +51,11 @@ export function ItemDetailView({ {model.title} - <button type="button" className="item-edit" title="编辑条目" onClick={onEdit}> - [编辑] - </button> + {canEdit && ( + <button type="button" className="item-edit" title="编辑条目" onClick={onEdit}> + [编辑] + </button> + )}
diff --git a/stash-web/src/settings.ts b/stash-web/src/settings.ts index 8bc8662..1796dda 100644 --- a/stash-web/src/settings.ts +++ b/stash-web/src/settings.ts @@ -12,6 +12,13 @@ export type HeaderItem = { tone: SettingsTone } +export type SectionSettings = { + hidden: string[] + order: string[] + titles: Record + limits: Record +} + export type SiteSettings = { header: { enabled: boolean @@ -23,6 +30,7 @@ export type SiteSettings = { hidden: string[] order: string[] } + sections: SectionSettings } export type SettingsDocument = { @@ -53,6 +61,7 @@ export function defaultSettings(): SiteSettings { ], }, sidebar: { hidden: [], order: [] }, + sections: { hidden: [], order: [], titles: {}, limits: {} }, } } @@ -115,8 +124,43 @@ export function applySidebarSettings( categories: T[], sidebar: SiteSettings['sidebar'], ): T[] { - const hidden = new Set(sidebar.hidden) - const ranks = new Map(sidebar.order.map((id, index) => [id, index])) + return orderAndFilter(categories, sidebar) +} + +/** 首页卡片:过滤隐藏、按 order 排序(未列出的保持原顺序) */ +export function applySectionSettings( + categories: T[], + sections: SectionSettings, +): T[] { + return orderAndFilter(categories, sections) +} + +/** 卡片标题:设置里的覆盖值优先 */ +export function sectionTitle( + id: string, + fallback: string, + sections: SectionSettings, +): string { + const override = sections.titles[id] + return override !== undefined && override.trim() !== '' ? override : fallback +} + +/** 卡片预览条数:设置里的覆盖值优先 */ +export function sectionLimit( + id: string, + fallback: number, + sections: SectionSettings, +): number { + const override = sections.limits[id] + return typeof override === 'number' && override > 0 ? override : fallback +} + +function orderAndFilter( + categories: T[], + config: { hidden: string[]; order: string[] }, +): T[] { + const hidden = new Set(config.hidden) + const ranks = new Map(config.order.map((id, index) => [id, index])) return categories .filter((category) => !hidden.has(category.id))