feat(admin): 分区增加「允许子分组」开关(与类型解耦)

- node 新增 allow_groups(迁移 v18):应用默认开、海报/图文默认关,已有分组的非应用分区自动开启
- 后台每行一个「子分组」开关;分组管理、卡片预览、内容弹窗选组、侧栏建组都跟随开关
- 关闭开关时条目平铺且不丢内容;首页卡片指向分组会自动挪回分区
- 三种类型共用同一模板,类型只决定渲染方式
- 测试 59 passed(迁移 15→18、开关建组、类型切换)
This commit is contained in:
2026-10-30 20:05:33 +08:00
parent 8df9ecdfda
commit be7a0747ed
8 changed files with 167 additions and 57 deletions
+8 -1
View File
@@ -985,11 +985,18 @@
/* 归类页:一行一个分区,标题 / 类型 / 图标 / 色条 / 条数 / 保存 / 删除 */
.admin-category-row {
display: grid;
grid-template-columns: minmax(0, 1.4fr) 150px 80px 110px 90px auto auto;
grid-template-columns: minmax(0, 1.4fr) 150px 80px 110px 90px auto auto auto;
align-items: center;
gap: var(--su-space-2);
}
.admin-toggle {
display: flex;
align-items: center;
gap: var(--su-space-2);
white-space: nowrap;
}
.admin-category-row > * {
min-width: 0;
}
+25 -10
View File
@@ -66,6 +66,8 @@ type CategorySummary = {
icon?: string
barColor?: string
previewLimit?: number
/** 是否允许子分组(与类型无关的开关) */
allowGroups?: boolean
/** 首页卡片标题与预览节点 */
block?: { title?: string; nodeKey?: string } | null
/** 应用类分区下的分组(子分类) */
@@ -539,7 +541,7 @@ function SidebarForm({
</div>
))}
{category.kind === 'apps' && (
{category.allowGroups === true && (
<div className="admin-nav-group-row">
<NewGroupRow sectionId={category.id} onReload={onReload} />
</div>
@@ -751,8 +753,9 @@ function CategoryManager({
return (
<Space direction="column" gap="lg" align="stretch" className="admin-form">
<Text size="sm" tone="muted">
分区决定内容归到哪一类(应用 / 海报 / 图文)。应用类分区下面可以建分组(子分类),
分组会出现在侧栏,也可以作为首页卡片的预览来源;改类型会自动迁移条目。
三种类型使用同一套模板:分区决定内容归到哪一类(应用 / 海报 / 图文),
「子分组」开关决定要不要分组。开启后可建分组、分组会出现在侧栏,也可以作为首页卡片预览;
关闭后条目按平铺渲染,已有分组里的内容不会丢。
</Text>
{categories.map((category) => (
@@ -762,7 +765,7 @@ function CategoryManager({
onReload={onReload}
onDelete={onDelete}
/>
{category.kind === 'apps' && (
{category.allowGroups === true && (
<>
<BlockSelect section={category} onReload={onReload} />
<GroupManager section={category} onReload={onReload} />
@@ -794,6 +797,7 @@ function CategoryRow({
const [limit, setLimit] = useState(
category.previewLimit === undefined ? '' : String(category.previewLimit),
)
const [allowGroups, setAllowGroups] = useState(category.allowGroups === true)
const [busy, setBusy] = useState(false)
const save = async () => {
@@ -804,6 +808,7 @@ function CategoryRow({
icon,
barColor: color,
previewLimit: limit.trim() === '' ? undefined : Number(limit),
allowGroups,
})
setBusy(false)
@@ -848,6 +853,16 @@ function CategoryRow({
aria-label={`${category.title} 预览条数`}
onChange={(event) => setLimit(event.target.value)}
/>
<div className="admin-toggle">
<Switch
checked={allowGroups}
onChange={setAllowGroups}
label={`${category.title} 允许子分组`}
/>
<Text size="sm" tone="muted">
子分组
</Text>
</div>
<Button variant="secondary" size="sm" loading={busy} onClick={save}>
保存
</Button>
@@ -1275,7 +1290,7 @@ function EntryDialog({
const section =
categories.find((category) => category.id === sectionId) ?? categories[0]
const groups = section?.kind === 'apps' ? (section.groups ?? []) : []
const groups = section?.allowGroups === true ? (section.groups ?? []) : []
// 打开时重置:新建用当前选择,编辑先拉完整条目
useEffect(() => {
@@ -1288,7 +1303,7 @@ function EntryDialog({
const startCategory = categories.find((category) => category.id === startSection)
setGroupId(
defaultLocation?.groupId ??
(startCategory?.kind === 'apps' ? (startCategory.groups?.[0]?.id ?? '') : ''),
(startCategory?.allowGroups === true ? (startCategory.groups?.[0]?.id ?? '') : ''),
)
if (entryKey === null) {
@@ -1326,9 +1341,9 @@ function EntryDialog({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, entryKey])
// 只有应用类分区有分组;其余类型始终是直属分区
// 只有开启「允许子分组」的分区有分组;其余始终是直属分区
useEffect(() => {
if (section?.kind !== 'apps') {
if (section?.allowGroups !== true) {
if (groupId !== '') setGroupId('')
return
}
@@ -1340,7 +1355,7 @@ function EntryDialog({
const nodeKey =
section === undefined
? ''
: section.kind === 'apps' && groupId !== ''
: section.allowGroups === true && groupId !== ''
? groupId
: section.id
@@ -1439,7 +1454,7 @@ function EntryDialog({
onChange={setSectionId}
/>
</div>
{section?.kind === 'apps' && (
{section?.allowGroups === true && (
<div style={{ width: 240 }}>
<Select
options={[
@@ -38,11 +38,8 @@ export function CategoryNav({
const items: MenuItem[] = [
{ key: HOME_KEY, label: '首页' },
...categories.map((category) => {
// 只有应用类有分组;空分组不出现在侧栏(后台仍保留)
const groups =
category.kind === 'apps'
? category.groups.filter((group) => group.items.length > 0)
: []
// 「允许子分组」的分区才有 groups;空分组不出现在侧栏(后台仍保留)
const groups = category.groups.filter((group) => group.items.length > 0)
return {
key: categoryKey(category.id),
+2
View File
@@ -296,6 +296,8 @@ export type UpdateCategoryInput = {
emptyText?: string
/** 首页卡片预览哪个节点(分区自己或其分组) */
blockNodeKey?: string
/** 是否允许子分组(与类型无关的开关) */
allowGroups?: boolean
}
export async function updateCategory(