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
+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
}