feat(admin): 首页卡片配置 + 页头开关验证 + 编辑按钮仅登录可见

- settings.sections:显隐 / 顺序 / 标题覆盖 / 预览条数,HomeView 应用
- /admin 新增「首页卡片」Tab,卡片标题使用展示块标题(常用站点等)
- 详情页 [编辑] 仅在登录后渲染(canEdit)
- 测试:46 passed;页头禁用、卡片隐藏/改标题/限条数、编辑可见性均实测
- 文档:ADMIN-PLAN、README
This commit is contained in:
stash
2026-10-29 19:21:34 +08:00
parent 8f688885ed
commit 3c6c20e534
10 changed files with 251 additions and 21 deletions
+116 -1
View File
@@ -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() {
<Tabs
items={[
{ key: 'header', label: '页头滚动信息' },
{ key: 'sections', label: '首页卡片' },
{ key: 'sidebar', label: '侧栏导航' },
{ key: 'system', label: '系统' },
]}
@@ -155,6 +162,12 @@ export function AdminPage() {
value={draft.header}
onChange={(header) => setDraft({ ...draft, header })}
/>
) : tab === 'sections' ? (
<SectionForm
categories={categories}
value={draft.sections}
onChange={(sections) => setDraft({ ...draft, sections })}
/>
) : tab === 'sidebar' ? (
<SidebarForm
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() {