feat(content): 分组对所有内容类型生效,统一分区/分组/条目模型

- 分区=容器,分组=任意类型的可选子容器,条目可挂分区或分组(直属分区)
- catalog 所有类型统一输出 groups + items,条目查询按节点取
- 详情/侧栏/首页卡片预览支持所有类型的分组;多组显示小标题 + 未分组
- 后台「分区」「侧栏导航」对所有类型可建组;「内容」弹窗分组可选直属分区,编辑可换组
- 修复首页卡片配置为「整个分区」时预览为空
- 测试 59 passed
This commit is contained in:
2026-10-30 18:56:17 +08:00
parent cc08188c1e
commit c84c41dc7d
11 changed files with 264 additions and 200 deletions
+2 -3
View File
@@ -177,7 +177,7 @@ export default function App() {
const detailViewFor = (categoryId: string): View => {
const category = categories.find((item) => item.id === categoryId)
if (category?.kind === 'apps' && category.block !== undefined) {
if (category?.block !== undefined) {
const group = category.groups.find((item) => item.id === category.block?.nodeKey)
if (group !== undefined) {
return { type: 'detail', categoryId, groupId: group.id }
@@ -189,9 +189,8 @@ export default function App() {
const openDetail = (categoryId: string) => go(detailViewFor(categoryId))
/** 条目所在的分组:应用按组组织,海报 / 图文直接挂在分区下 */
/** 条目所在的分组:应用 / 海报 / 图文都可能有分组 */
const groupOfItem = (category: Category, itemId: string): string | undefined => {
if (category.kind !== 'apps') return undefined
return category.groups.find((group) => group.items.some((item) => item.id === itemId))?.id
}
+29 -28
View File
@@ -503,7 +503,7 @@ function SidebarForm({
{ordered.map((category, index) => {
const visible = !value.hidden.includes(category.id)
const groups = category.kind === 'apps' ? orderedGroups(category) : []
const groups = orderedGroups(category)
return (
<div className="admin-nav-block" key={category.id}>
@@ -539,7 +539,7 @@ function SidebarForm({
</div>
))}
{category.kind === 'apps' && (
{(
<div className="admin-nav-group-row">
<NewGroupRow sectionId={category.id} onReload={onReload} />
</div>
@@ -762,12 +762,8 @@ function CategoryManager({
onReload={onReload}
onDelete={onDelete}
/>
{category.kind === 'apps' && (
<>
<BlockSelect section={category} onReload={onReload} />
<GroupManager section={category} onReload={onReload} />
</>
)}
<BlockSelect section={category} onReload={onReload} />
<GroupManager section={category} onReload={onReload} />
</div>
))}
@@ -1094,20 +1090,21 @@ function ContentManager({
const entries: EntryRow[] =
section === undefined
? []
: section.kind === 'apps'
? (section.groups ?? []).flatMap((group) =>
: [
...(section.groups ?? []).flatMap((group) =>
(group.items ?? []).map((item) => ({
...item,
groupName: group.name,
sectionId: section.id,
groupId: group.id,
})),
)
: (section.items ?? []).map((item) => ({
),
...(section.items ?? []).map((item) => ({
...item,
groupName: undefined,
sectionId: section.id,
}))
})),
]
const removeEntry = async () => {
if (pendingEntry === null) return
@@ -1274,7 +1271,7 @@ function EntryDialog({
const section =
categories.find((category) => category.id === sectionId) ?? categories[0]
const groups = section?.kind === 'apps' ? (section.groups ?? []) : []
const groups = section?.groups ?? []
// 打开时重置:新建用当前选择,编辑先拉完整条目
useEffect(() => {
@@ -1284,7 +1281,11 @@ function EntryDialog({
const startSection =
defaultLocation?.sectionId ?? categories[0]?.id ?? ''
setSectionId(startSection)
setGroupId(defaultLocation?.groupId ?? '')
setGroupId(
defaultLocation?.groupId ??
categories.find((category) => category.id === startSection)?.groups?.[0]?.id ??
'',
)
if (entryKey === null) {
setLoading(false)
@@ -1321,19 +1322,16 @@ function EntryDialog({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, entryKey])
// 分区切换后保证分组仍然合法
// 分区切换后保证分组仍然合法(空字符串 = 直属分区)
useEffect(() => {
if (section?.kind !== 'apps') {
setGroupId('')
return
}
if (!groups.some((group) => group.id === groupId)) {
if (section === undefined) return
if (groupId !== '' && !groups.some((group) => group.id === groupId)) {
setGroupId(groups[0]?.id ?? '')
}
}, [section, groups, groupId])
const nodeKey =
section === undefined ? '' : section.kind === 'apps' ? groupId : section.id
section === undefined ? '' : groupId !== '' ? groupId : section.id
const update = (patch: Partial<EntryForm>) =>
setForm((previous) => ({ ...previous, ...patch }))
@@ -1430,13 +1428,16 @@ function EntryDialog({
onChange={setSectionId}
/>
</div>
{section?.kind === 'apps' && (
<div style={{ width: 220 }}>
{section !== undefined && (
<div style={{ width: 240 }}>
<Select
options={groups.map((group) => ({
value: group.id,
label: group.name,
}))}
options={[
{ value: '', label: '直属分区(不分组)' },
...groups.map((group) => ({
value: group.id,
label: group.name,
})),
]}
value={groupId}
onChange={setGroupId}
/>
+40 -12
View File
@@ -1,5 +1,11 @@
import { Breadcrumb, Card, Empty, Space, Title } from '@stash/ui'
import { categoryCount, type Category } from '../../data/types'
import {
categoryCount,
type AppItem,
type ArticleItem,
type Category,
type PosterItem,
} from '../../data/types'
import { viewPath } from '../../data/view'
import { AppGrid } from './AppGrid'
import { CesCardGrid } from './CesCardGrid'
@@ -25,7 +31,13 @@ type DetailViewProps = {
}
export function DetailView({ category, groupId, onNavigate, onOpenItem }: DetailViewProps) {
const groups = category.kind === 'apps' ? category.groups : []
// 三种类型共用「分区 → 分组 → 条目」结构;这里抹平联合类型,渲染时再按 kind 分派
type RenderItem = AppItem | PosterItem | ArticleItem
const groups = category.groups as {
id: string
name: string
items: RenderItem[]
}[]
const activeGroup =
groupId !== undefined ? groups.find((group) => group.id === groupId) : undefined
@@ -43,32 +55,48 @@ export function DetailView({ category, groupId, onNavigate, onOpenItem }: Detail
{ label: category.title, href: viewPath({ type: 'detail', categoryId: category.id }) },
]
function renderBody() {
/** 按类型渲染一组条目 */
function renderItems(items: RenderItem[]) {
if (category.kind === 'posters') {
return <PosterGrid items={category.items} variant="detail" onOpen={onOpenItem} />
return <PosterGrid items={items as PosterItem[]} variant="detail" onOpen={onOpenItem} />
}
if (category.kind === 'articles') {
return <CesCardGrid items={category.items} onOpen={onOpenItem} />
return <CesCardGrid items={items as ArticleItem[]} onOpen={onOpenItem} />
}
return <AppGrid items={items as AppItem[]} variant="detail" onOpen={onOpenItem} />
}
function renderBody() {
if (activeGroup !== undefined) {
return <AppGrid items={activeGroup.items} variant="detail" onOpen={onOpenItem} />
return renderItems(activeGroup.items)
}
if (category.groups.length === 1) {
return <AppGrid items={category.groups[0].items} variant="detail" onOpen={onOpenItem} />
const direct = (category.items ?? []) as RenderItem[]
if (groups.length === 0) {
return direct.length > 0 ? (
renderItems(direct)
) : (
<Empty text={'emptyText' in category ? (category.emptyText ?? '暂无内容') : '暂无内容'} />
)
}
if (category.groups.length === 0) {
return <Empty text={category.emptyText ?? '暂无内容'} />
if (groups.length === 1 && direct.length === 0) {
return renderItems(groups[0].items)
}
return (
<Space direction="column" gap="lg" align="stretch">
{category.groups.map((group) => (
<GroupBlock key={group.id} group={group} onOpen={onOpenItem} />
{groups.map((group) => (
<GroupBlock key={group.id} name={group.name}>
{renderItems(group.items)}
</GroupBlock>
))}
{direct.length > 0 && (
<GroupBlock name="未分组">{renderItems(direct)}</GroupBlock>
)}
</Space>
)
}
@@ -1,23 +1,24 @@
import { Space, Title } from '@stash/ui'
import type { AppGroup } from '../../data/types'
import { AppGrid } from './AppGrid'
import type { ReactNode } from 'react'
/**
* 明细页的分组块:小标题 + 该组的应用网格。
* 分类被拆成多个分组时重复使用,单个分组时明细页不渲染标题。
* 明细页的分组块:小标题 + 该组的内容网格。
*
* 具体网格由调用方传入(应用 / 海报 / 图文共用同一层外壳),
* 这样三种类型的「分组」渲染方式保持一致。
*/
type GroupBlockProps = {
group: AppGroup
onOpen?: (itemId: string) => void
name: string
children: ReactNode
}
export function GroupBlock({ group, onOpen }: GroupBlockProps) {
export function GroupBlock({ name, children }: GroupBlockProps) {
return (
<Space direction="column" gap="sm" align="stretch" className="group-block">
<Title level={4} as="h3">
{group.name}
{name}
</Title>
<AppGrid items={group.items} variant="detail" onOpen={onOpen} />
{children}
</Space>
)
}
+20 -10
View File
@@ -57,7 +57,7 @@ export function HomeView({ categories, sections, onOpenDetail, onOpenItem }: Hom
* 否则(电影 / 文章 / 本地服务)数整个分区。
*/
function cardCount(category: Category): number {
if (category.kind === 'apps' && category.block !== undefined) {
if (category.block !== undefined) {
const group = category.groups.find((item) => item.id === category.block?.nodeKey)
if (group !== undefined) return group.items.length
}
@@ -65,6 +65,12 @@ function cardCount(category: Category): number {
return categoryCount(category)
}
/** 某个分区配的「首页预览分组」;没配或找不到返回 undefined */
function blockGroup<T>(category: { block?: { nodeKey?: string }; groups: { id: string; items: T[] }[] }) {
if (category.block?.nodeKey === undefined) return undefined
return category.groups.find((group) => group.id === category.block?.nodeKey)
}
/** 按分类类型输出对应的预览网格;应用类为空时给出提示文案 */
function renderPreview(
category: Category,
@@ -72,13 +78,9 @@ function renderPreview(
onOpenItem: (categoryId: string, itemId: string) => void,
) {
if (category.kind === 'apps') {
// 配了展示块就只展示那个分组;没有配置(如「本地服务」)则退回全部条目
const blockGroup =
category.block !== undefined
? category.groups.find((group) => group.id === category.block?.nodeKey)
: undefined
const source = category.block !== undefined ? (blockGroup?.items ?? []) : flattenAppItems(category)
// 配了展示块且指向分组时只展示该组;展示块指向分区本身(「整个分区」)时展示全部条目
const group = blockGroup(category)
const source = group !== undefined ? group.items : flattenAppItems(category)
const fallback = category.block?.previewLimit ?? category.previewLimit ?? 4
const limit = sectionLimit(category.id, fallback, sections)
const items = source.slice(0, limit)
@@ -91,17 +93,25 @@ function renderPreview(
}
if (category.kind === 'posters') {
const group = blockGroup(category)
const source = group
? group.items
: [...category.items, ...category.groups.flatMap((item) => item.items)]
return (
<PosterGrid
items={category.items.slice(0, sectionLimit(category.id, category.previewLimit ?? 6, sections))}
items={source.slice(0, sectionLimit(category.id, category.previewLimit ?? 6, sections))}
onOpen={(itemId) => onOpenItem(category.id, itemId)}
/>
)
}
const group = blockGroup(category)
const source = group
? group.items
: [...category.items, ...category.groups.flatMap((item) => item.items)]
return (
<ArticleGrid
items={category.items.slice(0, sectionLimit(category.id, category.previewLimit ?? 4, sections))}
items={source.slice(0, sectionLimit(category.id, category.previewLimit ?? 4, sections))}
onOpen={(itemId) => onOpenItem(category.id, itemId)}
/>
)
@@ -38,8 +38,9 @@ export function CategoryNav({
const items: MenuItem[] = [
{ key: HOME_KEY, label: '首页' },
...categories.map((category) => {
// 分组已由 App 按 /admin 的侧栏设置过滤、排序(含 show_in_sidebar 覆盖)
const groups = category.kind === 'apps' ? category.groups : []
// 分组已由 App 按 /admin 的侧栏设置过滤、排序(含 show_in_sidebar 覆盖);
// 应用 / 海报 / 图文都支持分组
const groups = category.groups
return {
key: categoryKey(category.id),
+42 -12
View File
@@ -31,14 +31,16 @@ export type AppItem = {
}
/** 应用分组:明细页按组展示,首页按展示块取其中一组做预览 */
export type AppGroup = {
export type ContentGroup<T> = {
id: string
name: string
/** 是否出现在侧栏;缺省视为 true */
showInSidebar?: boolean
items: AppItem[]
items: T[]
}
export type AppGroup = ContentGroup<AppItem>
/** 竖版海报条目(电影) */
export type PosterItem = {
id: string
@@ -97,6 +99,8 @@ export type AppCategory = {
/** 明细页 hero 区的字符图标 */
icon: string
groups: AppGroup[]
/** 直接挂在分区上的条目(未分组) */
items?: AppItem[]
/** 首页预览条数,默认 4 */
previewLimit?: number
/** 首页预览为空时的提示文案 */
@@ -114,6 +118,8 @@ export type PosterCategory = {
title: string
barColor: string
icon: string
groups: ContentGroup<PosterItem>[]
/** 直接挂在分区上的条目(未分组) */
items: PosterItem[]
/** 首页预览条数,默认 6 */
previewLimit?: number
@@ -130,6 +136,8 @@ export type ArticleCategory = {
title: string
barColor: string
icon: string
groups: ContentGroup<ArticleItem>[]
/** 直接挂在分区上的条目(未分组) */
items: ArticleItem[]
/** 首页预览条数,默认 4 */
previewLimit?: number
@@ -141,23 +149,28 @@ export type ArticleCategory = {
export type Category = AppCategory | PosterCategory | ArticleCategory
/** 分类内的条目总数,用于角标与明细页计数 */
/** 分类内的条目总数(直属 + 分组的),用于角标与明细页计数 */
export function categoryCount(category: Category): number {
if (category.kind === 'apps') {
return category.groups.reduce((total, group) => total + group.items.length, 0)
}
return category.items.length
const direct = category.items?.length ?? 0
const grouped = category.groups.reduce((total, group) => total + group.items.length, 0)
return direct + grouped
}
/** 把应用类分类的分组拍平,首页预览用 */
/** 把应用类分类的条目拍平(分组 + 直属),首页预览用 */
export function flattenAppItems(category: AppCategory): AppItem[] {
return category.groups.flatMap((group) => group.items)
return [
...category.groups.flatMap((group) => group.items),
...(category.items ?? []),
]
}
/** 按 id 在分类里查找条目;详情页用 */
export function findAppItem(category: Category, itemId: string): AppItem | undefined {
if (category.kind !== 'apps') return undefined
const direct = (category.items ?? []).find((item) => item.id === itemId)
if (direct !== undefined) return direct
for (const group of category.groups) {
const found = group.items.find((item) => item.id === itemId)
if (found !== undefined) return found
@@ -184,7 +197,13 @@ export function findPosterItem(
itemId: string,
): PosterItem | undefined {
if (category.kind !== 'posters') return undefined
return category.items.find((item) => item.id === itemId)
const direct = category.items.find((item) => item.id === itemId)
if (direct !== undefined) return direct
for (const group of category.groups) {
const found = group.items.find((item) => item.id === itemId)
if (found !== undefined) return found
}
return undefined
}
/** 取同分类下的其他海报,用于详情页的「推荐阅读」 */
@@ -195,7 +214,12 @@ export function relatedPosterItems(
): PosterItem[] {
if (category.kind !== 'posters') return []
return category.items.filter((item) => item.id !== excludeId).slice(0, limit)
return [
...category.items,
...category.groups.flatMap((group) => group.items),
]
.filter((item) => item.id !== excludeId)
.slice(0, limit)
}
/** 按 id 查找图文条目 */
@@ -204,5 +228,11 @@ export function findArticleItem(
itemId: string,
): ArticleItem | undefined {
if (category.kind !== 'articles') return undefined
return category.items.find((item) => item.id === itemId)
const direct = category.items.find((item) => item.id === itemId)
if (direct !== undefined) return direct
for (const group of category.groups) {
const found = group.items.find((item) => item.id === itemId)
if (found !== undefined) return found
}
return undefined
}
+33 -21
View File
@@ -175,30 +175,42 @@ export function applySidebarGroups(
categories: Category[],
sidebar: SiteSettings['sidebar'],
): Category[] {
return categories.map((category) => {
if (category.kind === 'apps') {
return { ...category, groups: filterGroups(category.groups, category.id, sidebar) }
}
if (category.kind === 'posters') {
return { ...category, groups: filterGroups(category.groups, category.id, sidebar) }
}
return { ...category, groups: filterGroups(category.groups, category.id, sidebar) }
})
}
/** 按设置过滤、排序某个分区下的分组 */
function filterGroups<T extends { id: string }>(
groups: T[],
categoryId: string,
sidebar: SiteSettings['sidebar'],
): T[] {
const hidden = new Set(sidebar.hidden_groups)
const shown = new Set(sidebar.shown_groups)
const ranks = new Map(
(sidebar.group_order[categoryId] ?? []).map((id, index) => [id, index] as const),
)
return categories.map((category) => {
if (category.kind !== 'apps') return category
const ranks = new Map(
(sidebar.group_order[category.id] ?? []).map((id, index) => [id, index] as const),
)
const groups = category.groups
.filter((group) => {
const key = `${category.id}/${group.id}`
return !hidden.has(key) && (group.showInSidebar !== false || shown.has(key))
})
.map((group, index) => ({
group,
index,
rank: ranks.get(group.id) ?? Number.MAX_SAFE_INTEGER,
}))
.sort((a, b) => (a.rank === b.rank ? a.index - b.index : a.rank - b.rank))
.map((entry) => entry.group)
return { ...category, groups }
})
return groups
.filter((group) => {
const key = `${categoryId}/${group.id}`
const showInSidebar = (group as { showInSidebar?: boolean }).showInSidebar
return !hidden.has(key) && (showInSidebar !== false || shown.has(key))
})
.map((group, index) => ({
group,
index,
rank: ranks.get(group.id) ?? Number.MAX_SAFE_INTEGER,
}))
.sort((a, b) => (a.rank === b.rank ? a.index - b.index : a.rank - b.rank))
.map((entry) => entry.group)
}
/** 卡片标题:设置里的覆盖值优先 */