feat(admin): 首页卡片配置 + 页头开关验证 + 编辑按钮仅登录可见
- settings.sections:显隐 / 顺序 / 标题覆盖 / 预览条数,HomeView 应用 - /admin 新增「首页卡片」Tab,卡片标题使用展示块标题(常用站点等) - 详情页 [编辑] 仅在登录后渲染(canEdit) - 测试:46 passed;页头禁用、卡片隐藏/改标题/限条数、编辑可见性均实测 - 文档:ADMIN-PLAN、README
This commit is contained in:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user