- 分区=容器,分组=任意类型的可选子容器,条目可挂分区或分组(直属分区) - catalog 所有类型统一输出 groups + items,条目查询按节点取 - 详情/侧栏/首页卡片预览支持所有类型的分组;多组显示小标题 + 未分组 - 后台「分区」「侧栏导航」对所有类型可建组;「内容」弹窗分组可选直属分区,编辑可换组 - 修复首页卡片配置为「整个分区」时预览为空 - 测试 59 passed
119 lines
4.1 KiB
TypeScript
119 lines
4.1 KiB
TypeScript
import { categoryCount, flattenAppItems, type Category } from '../../data/types'
|
|
import { AppGrid } from './AppGrid'
|
|
import { ArticleGrid } from './ArticleGrid'
|
|
import { Empty } from '@stash/ui'
|
|
import { PosterGrid } from './PosterGrid'
|
|
import { SectionCard } from './SectionCard'
|
|
import {
|
|
applySectionSettings,
|
|
sectionLimit,
|
|
sectionTitle,
|
|
type SectionSettings,
|
|
} from '../../settings'
|
|
|
|
/**
|
|
* 首页:把每个分类渲染成一张分区卡片。
|
|
*
|
|
* 每个分区只展示前若干条;标题、条数、顺序与显隐都可以在 `/admin`
|
|
* 的「首页卡片」里覆盖(见 settings.sections)。
|
|
*/
|
|
type HomeViewProps = {
|
|
categories: Category[]
|
|
sections: SectionSettings
|
|
onOpenDetail: (categoryId: string) => void
|
|
onOpenItem: (categoryId: string, itemId: string) => void
|
|
}
|
|
|
|
export function HomeView({ categories, sections, onOpenDetail, onOpenItem }: HomeViewProps) {
|
|
const cards = applySectionSettings(categories, sections)
|
|
|
|
return (
|
|
<div className="home-view">
|
|
{cards.map((category) => (
|
|
<SectionCard
|
|
key={category.id}
|
|
// 卡片标题跟着实际展示的内容走:配了展示块就用块标题(如「常用站点」),
|
|
// 没配(如「本地服务」)才用分类标题;设置里的覆盖值优先
|
|
title={sectionTitle(
|
|
category.id,
|
|
category.block?.title ?? category.title,
|
|
sections,
|
|
)}
|
|
count={cardCount(category)}
|
|
barColor={category.barColor}
|
|
onMore={() => onOpenDetail(category.id)}
|
|
>
|
|
{renderPreview(category, sections, onOpenItem)}
|
|
</SectionCard>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 卡片右上角的数量角标。
|
|
*
|
|
* 配了「分组展示块」时,卡片实际展示的是那个分组,所以数量也应该只数它;
|
|
* 否则(电影 / 文章 / 本地服务)数整个分区。
|
|
*/
|
|
function cardCount(category: Category): number {
|
|
if (category.block !== undefined) {
|
|
const group = category.groups.find((item) => item.id === category.block?.nodeKey)
|
|
if (group !== undefined) return group.items.length
|
|
}
|
|
|
|
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,
|
|
sections: SectionSettings,
|
|
onOpenItem: (categoryId: string, itemId: string) => void,
|
|
) {
|
|
if (category.kind === 'apps') {
|
|
// 配了展示块且指向分组时只展示该组;展示块指向分区本身(「整个分区」)时展示全部条目
|
|
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)
|
|
|
|
if (items.length === 0) {
|
|
return <Empty text={category.emptyText ?? '暂无内容'} />
|
|
}
|
|
|
|
return <AppGrid items={items} onOpen={(itemId) => onOpenItem(category.id, itemId)} />
|
|
}
|
|
|
|
if (category.kind === 'posters') {
|
|
const group = blockGroup(category)
|
|
const source = group
|
|
? group.items
|
|
: [...category.items, ...category.groups.flatMap((item) => item.items)]
|
|
return (
|
|
<PosterGrid
|
|
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={source.slice(0, sectionLimit(category.id, category.previewLimit ?? 4, sections))}
|
|
onOpen={(itemId) => onOpenItem(category.id, itemId)}
|
|
/>
|
|
)
|
|
}
|