feat(admin): 首页卡片配置 + 页头开关验证 + 编辑按钮仅登录可见
- settings.sections:显隐 / 顺序 / 标题覆盖 / 预览条数,HomeView 应用 - /admin 新增「首页卡片」Tab,卡片标题使用展示块标题(常用站点等) - 详情页 [编辑] 仅在登录后渲染(canEdit) - 测试:46 passed;页头禁用、卡片隐藏/改标题/限条数、编辑可见性均实测 - 文档:ADMIN-PLAN、README
This commit is contained in:
@@ -34,8 +34,8 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000
|
|||||||
### 登录与设置(v0.1.1 起)
|
### 登录与设置(v0.1.1 起)
|
||||||
|
|
||||||
- 登录是**页内弹窗**:点右上角用户图标;首次使用弹窗里是「设置密码 + 确认」(至少 8 位);
|
- 登录是**页内弹窗**:点右上角用户图标;首次使用弹窗里是「设置密码 + 确认」(至少 8 位);
|
||||||
- 登录后点「站点设置」进入 `/admin`,可编辑页头滚动信息(启用 / 速度 / 条目)与侧栏顺序、显隐;
|
- 登录后点「站点设置」进入 `/admin`,可编辑页头滚动信息(启用 / 速度 / 条目)、首页卡片(显隐 / 标题 / 条数 / 顺序)与侧栏顺序、显隐;
|
||||||
- 详情页的 `[编辑]` 打开右侧 Drawer,可改标题 / 简介 / 标签 / 正文等(保存后局部刷新,`updated_at` 乐观锁);
|
- 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁;
|
||||||
- `/admin` 的「系统」页可以改密码、下载整库备份(`VACUUM INTO` 快照);
|
- `/admin` 的「系统」页可以改密码、下载整库备份(`VACUUM INTO` 快照);
|
||||||
- 密码用 argon2id 哈希存在 `data/stash.db` 的 `admin` 表,会话是 HttpOnly + SameSite=Strict 的 Cookie(7 天);
|
- 密码用 argon2id 哈希存在 `data/stash.db` 的 `admin` 表,会话是 HttpOnly + SameSite=Strict 的 Cookie(7 天);
|
||||||
- 读接口公开(本地工具可只读使用),写接口(设置、改密等)要求登录;
|
- 读接口公开(本地工具可只读使用),写接口(设置、改密等)要求登录;
|
||||||
|
|||||||
@@ -167,6 +167,14 @@ DELETE /api/items/:id
|
|||||||
- 首页应用设置:滚动条目的文本/标签/速度/启用,侧栏按顺序与显隐渲染;
|
- 首页应用设置:滚动条目的文本/标签/速度/启用,侧栏按顺序与显隐渲染;
|
||||||
- 测试:42 passed(新增 settings 5 例:默认值、版本递增、冲突、校验、坏 JSON 回落)。
|
- 测试:42 passed(新增 settings 5 例:默认值、版本递增、冲突、校验、坏 JSON 回落)。
|
||||||
|
|
||||||
|
### P1 扩展(2026-10-29,同日)
|
||||||
|
|
||||||
|
- 首页卡片可配置:显隐、顺序、标题覆盖、每卡预览条数(`settings.sections`,前端 `HomeView` 应用);
|
||||||
|
- 页头滚动信息支持**启用/禁用**(`header.enabled`,关闭后页头中间留空);
|
||||||
|
- 「编辑」按钮只在登录后出现(`canEdit`,未登录时详情页与文章页不渲染 `[编辑]`);
|
||||||
|
- 侧栏「首页」与卡片顺序相互独立:侧栏管导航,卡片管首页布局。
|
||||||
|
|
||||||
|
|
||||||
### P2(已完成,2026-10-29)
|
### P2(已完成,2026-10-29)
|
||||||
|
|
||||||
- 库新增 `Textarea`(stash-ui v0.2.0):`rows` / `resize` / `invalid`;
|
- 库新增 `Textarea`(stash-ui v0.2.0):`rows` / `resize` / `invalid`;
|
||||||
|
|||||||
@@ -88,11 +88,25 @@ pub struct SidebarSettings {
|
|||||||
pub order: Vec<String>,
|
pub order: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct SectionsSettings {
|
||||||
|
/// 隐藏的首页卡片(分类 id)
|
||||||
|
pub hidden: Vec<String>,
|
||||||
|
/// 卡片顺序(分类 id)
|
||||||
|
pub order: Vec<String>,
|
||||||
|
/// 标题覆盖:id → 标题
|
||||||
|
pub titles: std::collections::HashMap<String, String>,
|
||||||
|
/// 预览条数覆盖:id → 条数
|
||||||
|
pub limits: std::collections::HashMap<String, u32>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub header: HeaderSettings,
|
pub header: HeaderSettings,
|
||||||
pub sidebar: SidebarSettings,
|
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 {
|
if settings.sidebar.hidden.len() > 100 || settings.sidebar.order.len() > 100 {
|
||||||
return Err(StashError::InvalidDirectory("侧栏配置过长".into()));
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +269,7 @@ mod tests {
|
|||||||
let settings = Settings::default();
|
let settings = Settings::default();
|
||||||
assert!(settings.header.enabled);
|
assert!(settings.header.enabled);
|
||||||
assert_eq!(settings.header.items.len(), 3);
|
assert_eq!(settings.header.items.len(), 3);
|
||||||
|
assert!(settings.sections.hidden.is_empty());
|
||||||
assert!(validate(&settings).is_ok());
|
assert!(validate(&settings).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -840,6 +840,10 @@
|
|||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-section-row {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) 96px auto;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-item-actions {
|
.admin-item-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ export default function App() {
|
|||||||
categoryTitle={itemCategory.title}
|
categoryTitle={itemCategory.title}
|
||||||
onNavigate={navigateHref}
|
onNavigate={navigateHref}
|
||||||
onEdit={() => setEditingKey(itemView.itemId)}
|
onEdit={() => setEditingKey(itemView.itemId)}
|
||||||
|
canEdit={session?.authenticated === true}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -276,6 +277,7 @@ export default function App() {
|
|||||||
onNavigate={navigateHref}
|
onNavigate={navigateHref}
|
||||||
onOpenItem={(itemId) => openItem(itemCategory.id, itemId)}
|
onOpenItem={(itemId) => openItem(itemCategory.id, itemId)}
|
||||||
onEdit={() => setEditingKey(itemView.itemId)}
|
onEdit={() => setEditingKey(itemView.itemId)}
|
||||||
|
canEdit={session?.authenticated === true}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -391,6 +393,7 @@ export default function App() {
|
|||||||
|
|
||||||
<HomeView
|
<HomeView
|
||||||
categories={visibleCategories}
|
categories={visibleCategories}
|
||||||
|
sections={settings.settings.sections}
|
||||||
onOpenDetail={openDetail}
|
onOpenDetail={openDetail}
|
||||||
onOpenItem={openItem}
|
onOpenItem={openItem}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
saveSettings,
|
saveSettings,
|
||||||
TONE_OPTIONS,
|
TONE_OPTIONS,
|
||||||
type HeaderItem,
|
type HeaderItem,
|
||||||
|
type SectionSettings,
|
||||||
type SettingsDocument,
|
type SettingsDocument,
|
||||||
type SettingsTone,
|
type SettingsTone,
|
||||||
type SiteSettings,
|
type SiteSettings,
|
||||||
@@ -31,7 +32,12 @@ import {
|
|||||||
* 只做「全局配置」;条目内容仍在详情页就地编辑(P2)。
|
* 只做「全局配置」;条目内容仍在详情页就地编辑(P2)。
|
||||||
* 保存用 `version` 乐观锁,冲突时提示刷新。
|
* 保存用 `version` 乐观锁,冲突时提示刷新。
|
||||||
*/
|
*/
|
||||||
type CategorySummary = { id: string; title: string }
|
type CategorySummary = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
/** 首页卡片标题(配了展示块时用块的标题,如「常用站点」) */
|
||||||
|
block?: { title?: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
const clone = (settings: SiteSettings): SiteSettings => structuredClone(settings)
|
const clone = (settings: SiteSettings): SiteSettings => structuredClone(settings)
|
||||||
|
|
||||||
@@ -137,6 +143,7 @@ export function AdminPage() {
|
|||||||
<Tabs
|
<Tabs
|
||||||
items={[
|
items={[
|
||||||
{ key: 'header', label: '页头滚动信息' },
|
{ key: 'header', label: '页头滚动信息' },
|
||||||
|
{ key: 'sections', label: '首页卡片' },
|
||||||
{ key: 'sidebar', label: '侧栏导航' },
|
{ key: 'sidebar', label: '侧栏导航' },
|
||||||
{ key: 'system', label: '系统' },
|
{ key: 'system', label: '系统' },
|
||||||
]}
|
]}
|
||||||
@@ -155,6 +162,12 @@ export function AdminPage() {
|
|||||||
value={draft.header}
|
value={draft.header}
|
||||||
onChange={(header) => setDraft({ ...draft, header })}
|
onChange={(header) => setDraft({ ...draft, header })}
|
||||||
/>
|
/>
|
||||||
|
) : tab === 'sections' ? (
|
||||||
|
<SectionForm
|
||||||
|
categories={categories}
|
||||||
|
value={draft.sections}
|
||||||
|
onChange={(sections) => setDraft({ ...draft, sections })}
|
||||||
|
/>
|
||||||
) : tab === 'sidebar' ? (
|
) : tab === 'sidebar' ? (
|
||||||
<SidebarForm
|
<SidebarForm
|
||||||
categories={categories}
|
categories={categories}
|
||||||
@@ -365,6 +378,108 @@ function SidebarForm({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 首页卡片表单 ----------
|
||||||
|
|
||||||
|
function SectionForm({
|
||||||
|
categories,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
categories: CategorySummary[]
|
||||||
|
value: SectionSettings
|
||||||
|
onChange: (sections: SectionSettings) => 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 (
|
||||||
|
<Space direction="column" gap="sm" align="stretch" className="admin-form">
|
||||||
|
<Text size="sm" tone="muted">
|
||||||
|
控制首页卡片:显示 / 隐藏、标题、每张卡预览条数与顺序。留空表示用内容库里的默认值。
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{cards.map((card, index) => (
|
||||||
|
<div className="admin-item-row admin-section-row" key={card.id}>
|
||||||
|
<Switch
|
||||||
|
checked={!value.hidden.includes(card.id)}
|
||||||
|
onChange={(next) => toggle(card.id, next)}
|
||||||
|
label={`显示 ${card.title}`}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={value.titles[card.id] ?? ''}
|
||||||
|
placeholder={card.title}
|
||||||
|
aria-label={`${card.title} 标题`}
|
||||||
|
onChange={(event) => setTitle(card.id, event.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={value.limits[card.id] === undefined ? '' : String(value.limits[card.id])}
|
||||||
|
placeholder="条数"
|
||||||
|
aria-label={`${card.title} 预览条数`}
|
||||||
|
onChange={(event) => setLimit(card.id, event.target.value)}
|
||||||
|
/>
|
||||||
|
<IconButtonGroup
|
||||||
|
onUp={() => move(index, -1)}
|
||||||
|
onDown={() => move(index, 1)}
|
||||||
|
onRemove={undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{cards.length === 0 && <Text tone="muted">内容库还没有加载完成。</Text>}
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 系统 ----------
|
// ---------- 系统 ----------
|
||||||
|
|
||||||
function SystemPanel() {
|
function SystemPanel() {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ type ArticleDetailViewProps = {
|
|||||||
categoryTitle: string
|
categoryTitle: string
|
||||||
onNavigate: (href: string) => void
|
onNavigate: (href: string) => void
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
|
/** 只有登录后才显示 [编辑] */
|
||||||
|
canEdit: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ArticleDetailView({
|
export function ArticleDetailView({
|
||||||
@@ -24,6 +26,7 @@ export function ArticleDetailView({
|
|||||||
categoryTitle,
|
categoryTitle,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
canEdit,
|
||||||
}: ArticleDetailViewProps) {
|
}: ArticleDetailViewProps) {
|
||||||
const meta = article.meta ?? ['2 年前', categoryTitle]
|
const meta = article.meta ?? ['2 年前', categoryTitle]
|
||||||
const crumbs = [
|
const crumbs = [
|
||||||
@@ -41,9 +44,11 @@ export function ArticleDetailView({
|
|||||||
<header className="article-head">
|
<header className="article-head">
|
||||||
<Title level={1} as="h2" className="article-heading">
|
<Title level={1} as="h2" className="article-heading">
|
||||||
{article.title}
|
{article.title}
|
||||||
<button type="button" className="item-edit" title="编辑文章" onClick={onEdit}>
|
{canEdit && (
|
||||||
[编辑]
|
<button type="button" className="item-edit" title="编辑文章" onClick={onEdit}>
|
||||||
</button>
|
[编辑]
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<div className="article-meta">
|
<div className="article-meta">
|
||||||
|
|||||||
@@ -4,33 +4,46 @@ import { ArticleGrid } from './ArticleGrid'
|
|||||||
import { Empty } from '@stash/ui'
|
import { Empty } from '@stash/ui'
|
||||||
import { PosterGrid } from './PosterGrid'
|
import { PosterGrid } from './PosterGrid'
|
||||||
import { SectionCard } from './SectionCard'
|
import { SectionCard } from './SectionCard'
|
||||||
|
import {
|
||||||
|
applySectionSettings,
|
||||||
|
sectionLimit,
|
||||||
|
sectionTitle,
|
||||||
|
type SectionSettings,
|
||||||
|
} from '../../settings'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 首页:把每个分类渲染成一张分区卡片。
|
* 首页:把每个分类渲染成一张分区卡片。
|
||||||
*
|
*
|
||||||
* 每个分区只展示前若干条(由分类的 previewLimit 决定),
|
* 每个分区只展示前若干条;标题、条数、顺序与显隐都可以在 `/admin`
|
||||||
* 右上角 ••• 进入该分类明细,点击卡片 / 海报则进入该条目的详情。
|
* 的「首页卡片」里覆盖(见 settings.sections)。
|
||||||
*/
|
*/
|
||||||
type HomeViewProps = {
|
type HomeViewProps = {
|
||||||
categories: Category[]
|
categories: Category[]
|
||||||
|
sections: SectionSettings
|
||||||
onOpenDetail: (categoryId: string) => void
|
onOpenDetail: (categoryId: string) => void
|
||||||
onOpenItem: (categoryId: string, itemId: 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 (
|
return (
|
||||||
<div className="home-view">
|
<div className="home-view">
|
||||||
{categories.map((category) => (
|
{cards.map((category) => (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
key={category.id}
|
key={category.id}
|
||||||
// 卡片标题跟着实际展示的内容走:配了展示块就用块标题(如「常用站点」),
|
// 卡片标题跟着实际展示的内容走:配了展示块就用块标题(如「常用站点」),
|
||||||
// 没配(如「本地服务」)才用分类标题
|
// 没配(如「本地服务」)才用分类标题;设置里的覆盖值优先
|
||||||
title={category.block?.title ?? category.title}
|
title={sectionTitle(
|
||||||
|
category.id,
|
||||||
|
category.block?.title ?? category.title,
|
||||||
|
sections,
|
||||||
|
)}
|
||||||
count={cardCount(category)}
|
count={cardCount(category)}
|
||||||
barColor={category.barColor}
|
barColor={category.barColor}
|
||||||
onMore={() => onOpenDetail(category.id)}
|
onMore={() => onOpenDetail(category.id)}
|
||||||
>
|
>
|
||||||
{renderPreview(category, onOpenItem)}
|
{renderPreview(category, sections, onOpenItem)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -55,6 +68,7 @@ function cardCount(category: Category): number {
|
|||||||
/** 按分类类型输出对应的预览网格;应用类为空时给出提示文案 */
|
/** 按分类类型输出对应的预览网格;应用类为空时给出提示文案 */
|
||||||
function renderPreview(
|
function renderPreview(
|
||||||
category: Category,
|
category: Category,
|
||||||
|
sections: SectionSettings,
|
||||||
onOpenItem: (categoryId: string, itemId: string) => void,
|
onOpenItem: (categoryId: string, itemId: string) => void,
|
||||||
) {
|
) {
|
||||||
if (category.kind === 'apps') {
|
if (category.kind === 'apps') {
|
||||||
@@ -65,7 +79,8 @@ function renderPreview(
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const source = category.block !== undefined ? (blockGroup?.items ?? []) : flattenAppItems(category)
|
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)
|
const items = source.slice(0, limit)
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
@@ -78,7 +93,7 @@ function renderPreview(
|
|||||||
if (category.kind === 'posters') {
|
if (category.kind === 'posters') {
|
||||||
return (
|
return (
|
||||||
<PosterGrid
|
<PosterGrid
|
||||||
items={category.items.slice(0, category.previewLimit ?? 6)}
|
items={category.items.slice(0, sectionLimit(category.id, category.previewLimit ?? 6, sections))}
|
||||||
onOpen={(itemId) => onOpenItem(category.id, itemId)}
|
onOpen={(itemId) => onOpenItem(category.id, itemId)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -86,7 +101,7 @@ function renderPreview(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ArticleGrid
|
<ArticleGrid
|
||||||
items={category.items.slice(0, category.previewLimit ?? 4)}
|
items={category.items.slice(0, sectionLimit(category.id, category.previewLimit ?? 4, sections))}
|
||||||
onOpen={(itemId) => onOpenItem(category.id, itemId)}
|
onOpen={(itemId) => onOpenItem(category.id, itemId)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ type ItemDetailViewProps = {
|
|||||||
onOpenItem: (itemId: string) => void
|
onOpenItem: (itemId: string) => void
|
||||||
/** 打开编辑 Drawer */
|
/** 打开编辑 Drawer */
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
|
/** 只有登录后才显示 [编辑] */
|
||||||
|
canEdit: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ItemDetailView({
|
export function ItemDetailView({
|
||||||
@@ -38,6 +40,7 @@ export function ItemDetailView({
|
|||||||
onNavigate,
|
onNavigate,
|
||||||
onOpenItem,
|
onOpenItem,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
canEdit,
|
||||||
}: ItemDetailViewProps) {
|
}: ItemDetailViewProps) {
|
||||||
return (
|
return (
|
||||||
<div className="detail-view item-view">
|
<div className="detail-view item-view">
|
||||||
@@ -48,9 +51,11 @@ export function ItemDetailView({
|
|||||||
<Card padding="none" className="item-card">
|
<Card padding="none" className="item-card">
|
||||||
<Title level={1} as="h2" weight="semibold" className="item-title">
|
<Title level={1} as="h2" weight="semibold" className="item-title">
|
||||||
{model.title}
|
{model.title}
|
||||||
<button type="button" className="item-edit" title="编辑条目" onClick={onEdit}>
|
{canEdit && (
|
||||||
[编辑]
|
<button type="button" className="item-edit" title="编辑条目" onClick={onEdit}>
|
||||||
</button>
|
[编辑]
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<div className={`item-cover item-cover--${model.shape}`}>
|
<div className={`item-cover item-cover--${model.shape}`}>
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ export type HeaderItem = {
|
|||||||
tone: SettingsTone
|
tone: SettingsTone
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SectionSettings = {
|
||||||
|
hidden: string[]
|
||||||
|
order: string[]
|
||||||
|
titles: Record<string, string>
|
||||||
|
limits: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
export type SiteSettings = {
|
export type SiteSettings = {
|
||||||
header: {
|
header: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
@@ -23,6 +30,7 @@ export type SiteSettings = {
|
|||||||
hidden: string[]
|
hidden: string[]
|
||||||
order: string[]
|
order: string[]
|
||||||
}
|
}
|
||||||
|
sections: SectionSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SettingsDocument = {
|
export type SettingsDocument = {
|
||||||
@@ -53,6 +61,7 @@ export function defaultSettings(): SiteSettings {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
sidebar: { hidden: [], order: [] },
|
sidebar: { hidden: [], order: [] },
|
||||||
|
sections: { hidden: [], order: [], titles: {}, limits: {} },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,8 +124,43 @@ export function applySidebarSettings<T extends { id: string }>(
|
|||||||
categories: T[],
|
categories: T[],
|
||||||
sidebar: SiteSettings['sidebar'],
|
sidebar: SiteSettings['sidebar'],
|
||||||
): T[] {
|
): T[] {
|
||||||
const hidden = new Set(sidebar.hidden)
|
return orderAndFilter(categories, sidebar)
|
||||||
const ranks = new Map(sidebar.order.map((id, index) => [id, index]))
|
}
|
||||||
|
|
||||||
|
/** 首页卡片:过滤隐藏、按 order 排序(未列出的保持原顺序) */
|
||||||
|
export function applySectionSettings<T extends { id: string }>(
|
||||||
|
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<T extends { id: string }>(
|
||||||
|
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
|
return categories
|
||||||
.filter((category) => !hidden.has(category.id))
|
.filter((category) => !hidden.has(category.id))
|
||||||
|
|||||||
Reference in New Issue
Block a user