feat(admin): 跳转规范化、侧栏子分类与归类管理
- 条目保存时规范化 href(裸域名补 https,拒绝 javascript:/data: 等协议),详情按钮按解析结果置灰/跳转
- settings.sidebar 增加 hidden_groups / shown_groups / group_order,侧栏 Tab 支持分组显隐与排序
- 新增 PATCH /api/categories/{id} 与「归类」Tab:编辑类型/标题/图标/色条/条数;改类型自动迁移条目与首页卡片
- 测试 54 passed(href 规范化 3 例、分类更新 3 例)
This commit is contained in:
@@ -26,6 +26,7 @@ import {
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
TONE_OPTIONS,
|
||||
updateCategory,
|
||||
type HeaderItem,
|
||||
type ProfileSettings,
|
||||
type SectionSettings,
|
||||
@@ -40,11 +41,24 @@ import {
|
||||
* 只做「全局配置」;条目内容仍在详情页就地编辑(P2)。
|
||||
* 保存用 `version` 乐观锁,冲突时提示刷新。
|
||||
*/
|
||||
type CategoryGroupSummary = {
|
||||
id: string
|
||||
name: string
|
||||
/** 数据库里的默认显隐;缺省视为显示 */
|
||||
showInSidebar?: boolean
|
||||
}
|
||||
|
||||
type CategorySummary = {
|
||||
id: string
|
||||
title: string
|
||||
kind?: 'apps' | 'posters' | 'articles'
|
||||
icon?: string
|
||||
barColor?: string
|
||||
previewLimit?: number
|
||||
/** 首页卡片标题(配了展示块时用块的标题,如「常用站点」) */
|
||||
block?: { title?: string } | null
|
||||
/** 应用类分区下的分组(子分类) */
|
||||
groups?: CategoryGroupSummary[]
|
||||
}
|
||||
|
||||
const clone = (settings: SiteSettings): SiteSettings => structuredClone(settings)
|
||||
@@ -173,6 +187,7 @@ export function AdminPage() {
|
||||
items={[
|
||||
{ key: 'header', label: '页头滚动信息' },
|
||||
{ key: 'sections', label: '首页卡片' },
|
||||
{ key: 'categories', label: '归类' },
|
||||
{ key: 'sidebar', label: '侧栏导航' },
|
||||
{ key: 'profile', label: '资料' },
|
||||
{ key: 'system', label: '系统' },
|
||||
@@ -194,6 +209,12 @@ export function AdminPage() {
|
||||
onDelete={requestDelete}
|
||||
onReload={load}
|
||||
/>
|
||||
) : tab === 'categories' ? (
|
||||
<CategoryManager
|
||||
categories={categories}
|
||||
onReload={load}
|
||||
onDelete={requestDelete}
|
||||
/>
|
||||
) : tab === 'sidebar' ? (
|
||||
<SidebarForm
|
||||
categories={categories}
|
||||
@@ -211,20 +232,23 @@ export function AdminPage() {
|
||||
<SystemPanel />
|
||||
)}
|
||||
|
||||
<Space gap="sm" justify="end" className="admin-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setDraft(clone(draft))
|
||||
void load()
|
||||
}}
|
||||
>
|
||||
撤销改动
|
||||
</Button>
|
||||
<Button loading={saving} icon={<Icon name="check_line" />} onClick={save}>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
{/* 「归类」每行自己保存,不参与整页设置的保存/撤销 */}
|
||||
{tab !== 'categories' && (
|
||||
<Space gap="sm" justify="end" className="admin-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setDraft(clone(draft))
|
||||
void load()
|
||||
}}
|
||||
>
|
||||
撤销改动
|
||||
</Button>
|
||||
<Button loading={saving} icon={<Icon name="check_line" />} onClick={save}>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
@@ -409,28 +433,95 @@ function SidebarForm({
|
||||
onChange({ ...value, hidden, order: ordered.map((category) => category.id) })
|
||||
}
|
||||
|
||||
const orderedGroups = (category: CategorySummary) => {
|
||||
const ranks = new Map(
|
||||
(value.group_order[category.id] ?? []).map((id, index) => [id, index] as const),
|
||||
)
|
||||
return [...(category.groups ?? [])].sort((a, b) => {
|
||||
const left = ranks.get(a.id) ?? Number.MAX_SAFE_INTEGER
|
||||
const right = ranks.get(b.id) ?? Number.MAX_SAFE_INTEGER
|
||||
if (left === right) return 0
|
||||
return left - right
|
||||
})
|
||||
}
|
||||
|
||||
const groupVisible = (category: CategorySummary, group: CategoryGroupSummary) => {
|
||||
const key = `${category.id}/${group.id}`
|
||||
return (
|
||||
!value.hidden_groups.includes(key) &&
|
||||
(group.showInSidebar !== false || value.shown_groups.includes(key))
|
||||
)
|
||||
}
|
||||
|
||||
const toggleGroup = (
|
||||
category: CategorySummary,
|
||||
group: CategoryGroupSummary,
|
||||
visible: boolean,
|
||||
) => {
|
||||
const key = `${category.id}/${group.id}`
|
||||
const hidden_groups = visible
|
||||
? value.hidden_groups.filter((entry) => entry !== key)
|
||||
: [...new Set([...value.hidden_groups, key])]
|
||||
const shown_groups = visible
|
||||
? [...new Set([...value.shown_groups, key])]
|
||||
: value.shown_groups.filter((entry) => entry !== key)
|
||||
onChange({ ...value, hidden_groups, shown_groups })
|
||||
}
|
||||
|
||||
const moveGroup = (category: CategorySummary, index: number, step: -1 | 1) => {
|
||||
const groups = orderedGroups(category)
|
||||
const target = index + step
|
||||
if (target < 0 || target >= groups.length) return
|
||||
|
||||
const ids = groups.map((group) => group.id)
|
||||
const [id] = ids.splice(index, 1)
|
||||
ids.splice(target, 0, id)
|
||||
onChange({ ...value, group_order: { ...value.group_order, [category.id]: ids } })
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="column" gap="sm" align="stretch" className="admin-form">
|
||||
<Text size="sm" tone="muted">
|
||||
顺序决定侧栏显示位置;关掉的分类仍可通过地址直接访问。
|
||||
顺序决定侧栏显示位置;关掉的分类仍可通过地址直接访问。应用类分区下的子分类(分组)可以单独显隐与排序。
|
||||
</Text>
|
||||
|
||||
{ordered.map((category, index) => {
|
||||
const visible = !value.hidden.includes(category.id)
|
||||
const groups = category.kind === 'apps' ? orderedGroups(category) : []
|
||||
|
||||
return (
|
||||
<div className="admin-item-row admin-nav-row" key={category.id}>
|
||||
<Switch
|
||||
checked={visible}
|
||||
onChange={(next) => toggle(category.id, next)}
|
||||
label={`显示 ${category.title}`}
|
||||
/>
|
||||
<Text className="admin-item-title">{category.title}</Text>
|
||||
<IconButtonGroup
|
||||
onUp={() => move(index, -1)}
|
||||
onDown={() => move(index, 1)}
|
||||
onRemove={() => onDelete(category)}
|
||||
/>
|
||||
<div className="admin-nav-block" key={category.id}>
|
||||
<div className="admin-item-row admin-nav-row">
|
||||
<Switch
|
||||
checked={visible}
|
||||
onChange={(next) => toggle(category.id, next)}
|
||||
label={`显示 ${category.title}`}
|
||||
/>
|
||||
<Text className="admin-item-title">{category.title}</Text>
|
||||
<IconButtonGroup
|
||||
onUp={() => move(index, -1)}
|
||||
onDown={() => move(index, 1)}
|
||||
onRemove={() => onDelete(category)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{groups.map((group, groupIndex) => (
|
||||
<div
|
||||
className="admin-item-row admin-nav-row admin-nav-group-row"
|
||||
key={group.id}
|
||||
>
|
||||
<Switch
|
||||
checked={groupVisible(category, group)}
|
||||
onChange={(next) => toggleGroup(category, group, next)}
|
||||
label={`显示 ${group.name}`}
|
||||
/>
|
||||
<Text className="admin-item-title">{group.name}</Text>
|
||||
<IconButtonGroup
|
||||
onUp={() => moveGroup(category, groupIndex, -1)}
|
||||
onDown={() => moveGroup(category, groupIndex, 1)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -618,6 +709,130 @@ function CategoryCreator({ onCreated }: { onCreated: () => void }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- 归类(分类管理) ----------
|
||||
|
||||
const KIND_OPTIONS = [
|
||||
{ value: 'apps', label: '应用' },
|
||||
{ value: 'posters', label: '海报' },
|
||||
{ value: 'articles', label: '图文' },
|
||||
]
|
||||
|
||||
function CategoryManager({
|
||||
categories,
|
||||
onReload,
|
||||
onDelete,
|
||||
}: {
|
||||
categories: CategorySummary[]
|
||||
onReload: () => void
|
||||
onDelete: (card: CategorySummary) => void
|
||||
}) {
|
||||
return (
|
||||
<Space direction="column" gap="sm" align="stretch" className="admin-form">
|
||||
<Text size="sm" tone="muted">
|
||||
决定每个分区归到哪一类(应用 / 海报 / 图文),以及标题、图标、色条与预览条数。改类型会自动迁移条目:切到「应用」会建默认分组并把条目挪进去。
|
||||
</Text>
|
||||
|
||||
{categories.map((category) => (
|
||||
<CategoryRow
|
||||
key={category.id}
|
||||
category={category}
|
||||
onReload={onReload}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
|
||||
{categories.length === 0 && <Text tone="muted">内容库还没有加载完成。</Text>}
|
||||
|
||||
<CategoryCreator onCreated={onReload} />
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryRow({
|
||||
category,
|
||||
onReload,
|
||||
onDelete,
|
||||
}: {
|
||||
category: CategorySummary
|
||||
onReload: () => void
|
||||
onDelete: (card: CategorySummary) => void
|
||||
}) {
|
||||
const [title, setTitle] = useState(category.title)
|
||||
const [kind, setKind] = useState(category.kind ?? 'apps')
|
||||
const [icon, setIcon] = useState(category.icon ?? '')
|
||||
const [color, setColor] = useState(category.barColor ?? '')
|
||||
const [limit, setLimit] = useState(
|
||||
category.previewLimit === undefined ? '' : String(category.previewLimit),
|
||||
)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true)
|
||||
const result = await updateCategory(category.id, {
|
||||
title,
|
||||
kind,
|
||||
icon,
|
||||
barColor: color,
|
||||
previewLimit: limit.trim() === '' ? undefined : Number(limit),
|
||||
})
|
||||
setBusy(false)
|
||||
|
||||
if (!result.ok) {
|
||||
toast.error(result.error)
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(`已保存「${title}」`)
|
||||
onReload()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-category-row">
|
||||
<Input
|
||||
value={title}
|
||||
placeholder="标题"
|
||||
aria-label={`${category.title} 标题`}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
<Select
|
||||
options={KIND_OPTIONS}
|
||||
value={kind}
|
||||
onChange={(next) => setKind(next as typeof kind)}
|
||||
/>
|
||||
<Input
|
||||
value={icon}
|
||||
placeholder="图标"
|
||||
aria-label={`${category.title} 图标`}
|
||||
onChange={(event) => setIcon(event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
value={color}
|
||||
placeholder="#3858f6"
|
||||
aria-label={`${category.title} 色条`}
|
||||
onChange={(event) => setColor(event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={limit}
|
||||
placeholder="条数"
|
||||
aria-label={`${category.title} 预览条数`}
|
||||
onChange={(event) => setLimit(event.target.value)}
|
||||
/>
|
||||
<Button variant="secondary" size="sm" loading={busy} onClick={save}>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="admin-remove"
|
||||
icon={<Icon name="delete_2_line" />}
|
||||
aria-label="删除"
|
||||
onClick={() => onDelete(category)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- 资料 ----------
|
||||
|
||||
function ProfileForm({
|
||||
|
||||
Reference in New Issue
Block a user