feat(content): 分组对所有内容类型生效,统一分区/分组/条目模型
- 分区=容器,分组=任意类型的可选子容器,条目可挂分区或分组(直属分区) - catalog 所有类型统一输出 groups + items,条目查询按节点取 - 详情/侧栏/首页卡片预览支持所有类型的分组;多组显示小标题 + 未分组 - 后台「分区」「侧栏导航」对所有类型可建组;「内容」弹窗分组可选直属分区,编辑可换组 - 修复首页卡片配置为「整个分区」时预览为空 - 测试 59 passed
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user