feat(db): 内容库迁入 SQLite,分类与侧栏导航改为数据库驱动
This commit is contained in:
+24
-2
@@ -7,7 +7,7 @@ import { ItemDetailView } from './components/content/ItemDetailView'
|
||||
import { SiteHeader } from './components/header/SiteHeader'
|
||||
import { SearchBar } from './components/search/SearchBar'
|
||||
import { Sidebar } from './components/sidebar/Sidebar'
|
||||
import { buildCategories } from './data/catalog'
|
||||
import { withLocalCategory } from './data/local'
|
||||
import {
|
||||
appDetailModel,
|
||||
posterDetailModel,
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
relatedPosterItems,
|
||||
} from './data/types'
|
||||
import { useProjects } from './useProjects'
|
||||
import { useCatalog } from './useCatalog'
|
||||
import './App.css'
|
||||
|
||||
/**
|
||||
@@ -39,13 +40,18 @@ type View =
|
||||
|
||||
export default function App() {
|
||||
const { status, projects, error, live, refreshing, refresh } = useProjects()
|
||||
// 内容目录来自后端 SQLite 内容库;本地服务分类仍由托管项目动态生成
|
||||
const catalog = useCatalog()
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [view, setView] = useState<View>({ type: 'home' })
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const categories = useMemo(() => buildCategories(projects), [projects])
|
||||
const categories = useMemo(
|
||||
() => withLocalCategory(projects, catalog.categories),
|
||||
[projects, catalog.categories],
|
||||
)
|
||||
const visibleCategories = useMemo(
|
||||
() => filterCategories(categories, keyword),
|
||||
[categories, keyword],
|
||||
@@ -184,6 +190,7 @@ export default function App() {
|
||||
<div className="layout">
|
||||
<Sidebar
|
||||
projects={projects}
|
||||
nav={catalog.nav}
|
||||
live={live}
|
||||
refreshing={refreshing}
|
||||
onRefresh={refresh}
|
||||
@@ -217,8 +224,23 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 内容库读取失败通常是用户改坏了 data 列里的 JSON,错误信息会带条目 id */}
|
||||
{catalog.status === 'error' && (
|
||||
<div className="notice notice-error" role="alert">
|
||||
<strong>内容库读取失败</strong>
|
||||
<p>{catalog.error}</p>
|
||||
<button type="button" className="notice-action" onClick={catalog.reload}>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailView === undefined && itemView === undefined && (
|
||||
<>
|
||||
{catalog.status === 'loading' && (
|
||||
<p className="section-empty">正在读取内容库…</p>
|
||||
)}
|
||||
|
||||
<HomeView
|
||||
categories={visibleCategories}
|
||||
onOpenDetail={openDetail}
|
||||
|
||||
+75
-1
@@ -1,3 +1,6 @@
|
||||
import type { NavModel } from './data/nav'
|
||||
import type { Category } from './data/types'
|
||||
|
||||
export type Project = {
|
||||
name: string
|
||||
route_path: string
|
||||
@@ -9,7 +12,6 @@ type ProjectsPayload = {
|
||||
|
||||
const PROJECTS_ENDPOINT = '/api/projects'
|
||||
const EVENTS_ENDPOINT = '/api/projects/events'
|
||||
|
||||
/** 读取服务端扫描出的项目列表。 */
|
||||
export async function fetchProjects(signal?: AbortSignal): Promise<Project[]> {
|
||||
const response = await fetch(PROJECTS_ENDPOINT, {
|
||||
@@ -67,3 +69,75 @@ export function subscribeProjects(handlers: ProjectEventsHandlers): () => void {
|
||||
source.close()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 内容目录:数据来自后端的 SQLite 内容库(data/stash.db),不再是前端静态数组
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CATALOG_ENDPOINT = '/api/catalog'
|
||||
const CATALOG_EVENTS_ENDPOINT = '/api/catalog/events'
|
||||
|
||||
type CatalogPayload = {
|
||||
categories?: Category[]
|
||||
nav?: NavModel
|
||||
}
|
||||
|
||||
/** 内容目录的完整返回:分类树 + 侧栏导航 */
|
||||
export type Catalog = {
|
||||
categories: Category[]
|
||||
nav: NavModel
|
||||
}
|
||||
|
||||
/** 服务端字段可能缺失,统一在这里补成空数组,组件层就不用判空 */
|
||||
function normalizeNav(nav: NavModel | undefined): NavModel {
|
||||
return {
|
||||
topLevel: Array.isArray(nav?.topLevel) ? nav.topLevel : [],
|
||||
groups: Array.isArray(nav?.groups) ? nav.groups : [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取完整内容目录。
|
||||
*
|
||||
* 数据量只有几十 KB,一次全量取回即可;搜索仍然在前端按关键字过滤。
|
||||
* 分类与导航一起下发,因为首页和侧栏同时需要它们。
|
||||
*/
|
||||
export async function fetchCatalog(signal?: AbortSignal): Promise<Catalog> {
|
||||
const response = await fetch(CATALOG_ENDPOINT, {
|
||||
signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`请求 ${CATALOG_ENDPOINT} 失败:HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as CatalogPayload
|
||||
|
||||
return {
|
||||
categories: Array.isArray(payload.categories) ? payload.categories : [],
|
||||
nav: normalizeNav(payload.nav),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅内容库变更。
|
||||
*
|
||||
* 服务端用 SQLite 的 data_version 检测「外部工具改了库」,事件里不带数据,
|
||||
* 只表示「该重新拉一次了」,避免两套数据结构需要保持同步。
|
||||
*/
|
||||
export function subscribeCatalog(onChange: () => void): () => void {
|
||||
if (typeof EventSource === 'undefined') {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const source = new EventSource(CATALOG_EVENTS_ENDPOINT)
|
||||
const handleChange = () => onChange()
|
||||
|
||||
source.addEventListener('catalog', handleChange)
|
||||
|
||||
return () => {
|
||||
source.removeEventListener('catalog', handleChange)
|
||||
source.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { useState } from 'react'
|
||||
import { NAV_GROUPS, NAV_TOP_LEVEL, type NavTarget } from '../../data/nav'
|
||||
import type { NavModel, NavTarget } from '../../data/nav'
|
||||
|
||||
/**
|
||||
* 分类导航树:顶层直达链接 + 可折叠分组。
|
||||
* 点击叶子节点不跳转页面,而是把目标(分类 + 可选分组)交给父组件的 onNavigate,
|
||||
*
|
||||
* 导航内容来自内容库(`nav_entry` 表),由父组件透传进来;
|
||||
* 点击叶子节点不跳转页面,而是把目标(分类 + 可选分组)交给 onNavigate,
|
||||
* 由父组件切换右侧的明细视图。
|
||||
*/
|
||||
type CategoryNavProps = {
|
||||
/** 侧栏导航数据;内容库为空时是空模型 */
|
||||
nav: NavModel
|
||||
/** 当前明细视图的分类 / 分组,用于高亮 */
|
||||
activeCategoryId?: string
|
||||
activeGroupId?: string
|
||||
onNavigate: (target: NavTarget) => void
|
||||
}
|
||||
|
||||
export function CategoryNav({ activeCategoryId, activeGroupId, onNavigate }: CategoryNavProps) {
|
||||
export function CategoryNav({ nav, activeCategoryId, activeGroupId, onNavigate }: CategoryNavProps) {
|
||||
const [openGroups, setOpenGroups] = useState<string[]>([])
|
||||
|
||||
const toggle = (id: string) => {
|
||||
@@ -27,7 +31,7 @@ export function CategoryNav({ activeCategoryId, activeGroupId, onNavigate }: Cat
|
||||
return (
|
||||
<nav className="nav-card card-box" aria-label="站点分类">
|
||||
<ul className="nav-group">
|
||||
{NAV_TOP_LEVEL.map((leaf) => (
|
||||
{nav.topLevel.map((leaf) => (
|
||||
<li className="nav-item" key={leaf.id}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -43,7 +47,7 @@ export function CategoryNav({ activeCategoryId, activeGroupId, onNavigate }: Cat
|
||||
</li>
|
||||
))}
|
||||
|
||||
{NAV_GROUPS.map((group) => (
|
||||
{nav.groups.map((group) => (
|
||||
<li className={`nav-item${isOpen(group.id) ? ' open' : ''}`} key={group.id}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Project } from '../../api'
|
||||
import type { NavTarget } from '../../data/nav'
|
||||
import type { NavModel, NavTarget } from '../../data/nav'
|
||||
import { CategoryNav } from './CategoryNav'
|
||||
import { ProfileCard } from './ProfileCard'
|
||||
import { ServicePanel } from './ServicePanel'
|
||||
@@ -10,6 +10,8 @@ import { ServicePanel } from './ServicePanel'
|
||||
*/
|
||||
type SidebarProps = {
|
||||
projects: Project[]
|
||||
/** 侧栏导航数据,来自内容库 */
|
||||
nav: NavModel
|
||||
live: boolean
|
||||
refreshing: boolean
|
||||
onRefresh: () => void
|
||||
@@ -20,6 +22,7 @@ type SidebarProps = {
|
||||
|
||||
export function Sidebar({
|
||||
projects,
|
||||
nav,
|
||||
live,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
@@ -39,6 +42,7 @@ export function Sidebar({
|
||||
/>
|
||||
|
||||
<CategoryNav
|
||||
nav={nav}
|
||||
activeCategoryId={activeCategoryId}
|
||||
activeGroupId={activeGroupId}
|
||||
onNavigate={onNavigate}
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
import type {
|
||||
AppCategory,
|
||||
AppGroup,
|
||||
AppItem,
|
||||
ArticleBlock,
|
||||
ArticleCategory,
|
||||
Category,
|
||||
PosterCategory,
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* 占位内容:沿用 index4.html 模板里的示例数据。
|
||||
*
|
||||
* 除「本地服务」由后端项目列表动态生成外,其余分类都是静态占位,
|
||||
* 等后端提供分类 / 收藏接口后再替换。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 详情页占位统计:按 id 生成稳定的数值,
|
||||
* 保证同一站点每次打开看到的是同一组数字,而不是随机跳变。
|
||||
*/
|
||||
function stat(seed: string, min: number, max: number): number {
|
||||
let hash = 0
|
||||
for (const char of seed) {
|
||||
hash = (hash * 31 + char.charCodeAt(0)) % 100_000
|
||||
}
|
||||
return min + (hash % (max - min + 1))
|
||||
}
|
||||
|
||||
/** 只有少数条目带 HOT 徽标 —— 主题里也不是每个作者都带 */
|
||||
const HOT_IDS = new Set(['github', 'bilibili', 'wallhaven'])
|
||||
|
||||
/** 生成一条占位站点,详情页需要的头像 / 横幅 / 评价也一并补齐 */
|
||||
const app = (id: string, name: string, desc: string, icon: string, href = '#'): AppItem => ({
|
||||
id,
|
||||
name,
|
||||
desc,
|
||||
icon,
|
||||
href,
|
||||
cover: `https://picsum.photos/seed/${id}/200/200`,
|
||||
banner: `https://picsum.photos/seed/${id}-banner/800/300`,
|
||||
comment: '收录在本地导航里的常用站点,点开就能直达。',
|
||||
badge: '推荐好站',
|
||||
hot: HOT_IDS.has(id),
|
||||
views: stat(id, 60, 980),
|
||||
likes: stat(`${id}-like`, 3, 88),
|
||||
})
|
||||
|
||||
/** 常用推荐:第一个分组是首页预览展示的四项 */
|
||||
const RECOMMEND_GROUPS: AppGroup[] = [
|
||||
{
|
||||
id: 'common',
|
||||
name: '常用站点',
|
||||
items: [
|
||||
app('github', 'GitHub', '代码托管与开源社区', 'G'),
|
||||
app('zhihu', '知乎', '深度内容问答社区', 'Z'),
|
||||
app('bilibili', '哔哩哔哩', '弹幕视频分享网站', 'B'),
|
||||
app('v2ex', 'V2EX', '创意工作者讨论社区', 'V'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ui',
|
||||
name: 'UI 设计资源',
|
||||
items: [
|
||||
app('wallhaven', 'Wallhaven', 'WallBase 创办人失踪后推出的精选壁纸站', 'W'),
|
||||
app('500px', '500px', '有着世界上最好的照片分享社区', '5'),
|
||||
app('chuangyiren', '创意人', '去掉了形式主义描述的原创社区', 'C'),
|
||||
app('topys', '顶尖文案', '全球顶尖创意分享平台 OPEN YOUR EYES', 'T'),
|
||||
app('uichina', 'UI中国', '前身为 iconfans,国内专业的 UI 设计平台', 'U'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'vector',
|
||||
name: '矢量图标',
|
||||
items: [
|
||||
app('iconfont', 'Iconfont', '阿里妈妈矢量图标库', 'I'),
|
||||
app('feather', 'Feather Icons', '简单漂亮的开源图标库', 'F'),
|
||||
app('fontawesome', 'FontAwesome', '图标字体与 CSS 框架', 'A'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wallpaper',
|
||||
name: '免费壁纸',
|
||||
items: [
|
||||
app('unsplash', 'Unsplash', '免费高清无版权摄影图片', 'U'),
|
||||
app('pexels', 'Pexels', '才华横溢的摄影师分享的素材', 'P'),
|
||||
app('pixabay', 'Pixabay', '超过 2.7 百万张优质图片和视频', 'X'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dev',
|
||||
name: '开发工具',
|
||||
items: [
|
||||
app('codepen', 'CodePen', '前端在线代码演示和练习场', 'C'),
|
||||
app('stackoverflow', 'Stack Overflow', '全球程序员技术问答社区', 'S'),
|
||||
app('github-dev', 'GitHub', '代码托管与开源协作社区', 'G'),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const RECOMMEND: AppCategory = {
|
||||
id: 'recommend',
|
||||
kind: 'apps',
|
||||
title: '常用推荐',
|
||||
barColor: '#3858f6',
|
||||
icon: '🔖',
|
||||
groups: RECOMMEND_GROUPS,
|
||||
}
|
||||
|
||||
/** 电影占位数据:标题 + 一句简介,简介同时用作详情页正文 */
|
||||
const MOVIE_SEEDS: [string, string][] = [
|
||||
[
|
||||
'千与千寻 千と千尋の神隠し (2001)',
|
||||
'少女千寻误入神灵世界,为救回变成猪的父母,在汤屋打工并逐渐学会担当。',
|
||||
],
|
||||
[
|
||||
'美丽人生 La vita è bella (1997)',
|
||||
'二战集中营里,父亲用一场「游戏」保护儿子,让他相信世界仍然美好。',
|
||||
],
|
||||
[
|
||||
'这个杀手不太冷 Léon (1994)',
|
||||
'孤独的职业杀手莱昂收留了邻居少女玛蒂尔达,两个边缘人相互依靠。',
|
||||
],
|
||||
[
|
||||
'阿甘正传 Forrest Gump (1994)',
|
||||
'智商不高的阿甘凭着一股单纯的执着,意外见证并参与了几十年美国历史。',
|
||||
],
|
||||
['霸王别姬 (1993)', '两位京剧伶人半个世纪的悲欢离合,与时代洪流交织在一起。'],
|
||||
[
|
||||
'肖申克的救赎 The Shawshank Redemption',
|
||||
'蒙冤入狱的银行家安迪,在漫长牢狱中守住希望并完成自救。',
|
||||
],
|
||||
[
|
||||
'泰坦尼克号 Titanic (1997)',
|
||||
'沉船之夜,来自不同阶层的杰克与露丝相爱,也被人性的选择所考验。',
|
||||
],
|
||||
['盗梦空间 Inception (2010)', '盗梦团队潜入层层梦境,试图在目标的潜意识里植入一个念头。'],
|
||||
[
|
||||
'星际穿越 Interstellar (2014)',
|
||||
'地球濒临枯竭,父亲穿越虫洞寻找新家园,与女儿跨越时间重逢。',
|
||||
],
|
||||
[
|
||||
'楚门的世界 The Truman Show (1998)',
|
||||
'楚门渐渐发现,自己的整个人生其实是一档被全世界围观的真人秀。',
|
||||
],
|
||||
['疯狂动物城 Zootopia (2016)', '兔子警官与狐狸搭档,在动物城里侦破一桩失踪案,也打破偏见。'],
|
||||
[
|
||||
'大话西游之大圣娶亲 (1995)',
|
||||
'至尊宝在爱情与宿命之间反复抉择,最终戴上金箍踏上取经路。',
|
||||
],
|
||||
]
|
||||
|
||||
const MOVIES: PosterCategory = {
|
||||
id: 'movie',
|
||||
kind: 'posters',
|
||||
title: '推荐电影',
|
||||
barColor: '#ff6034',
|
||||
icon: '🎬',
|
||||
items: MOVIE_SEEDS.map(([title, summary], index) => {
|
||||
const id = `movie-${index + 1}`
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
cover: `https://picsum.photos/seed/m${index + 1}/300/450`,
|
||||
tags: ['推荐电影', '豆瓣电影 Top250'],
|
||||
comment: '收藏在本地片单里,闲下来就重看一遍。',
|
||||
summary,
|
||||
href: '#',
|
||||
views: stat(id, 300, 1500),
|
||||
likes: stat(`${id}-like`, 3, 60),
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
const ARTICLE_SEEDS = [
|
||||
['可塑性极强的玻璃,在设计师手里有了更多可能', '前沿科技'],
|
||||
['废旧共享单车如何变成微型共享书屋?', '环保设计'],
|
||||
['荧光灯管与混凝土块的组合,可以玩出多少花样?', '材料探索'],
|
||||
['混凝土做的模块化收纳框,还能改成搁架用', '家居模块'],
|
||||
['这个用拉伸材料做的头盔,还能帮你呼叫救护车', '智能穿戴'],
|
||||
['你如何选择字体,简单易读还是好看有趣?', '视觉体验'],
|
||||
['家里空间小?你需要几把可以悬挂在墙上的椅子', '空间收纳'],
|
||||
['宜家厨房家具又被改造了,色彩是亮点', '色彩灵感'],
|
||||
['这款诞生 50 年的扶手椅,推出了特别版配色', '经典产品'],
|
||||
['让家和办公室界限变得模糊的一组家具', '办公家具'],
|
||||
['这个酒店设计奖,挖掘过去一年体验最好的酒店', '空间建筑'],
|
||||
['一款可移动办公桌,挂墙上当作白板就能写写画画', '移动办公'],
|
||||
]
|
||||
|
||||
/** 生成演示用正文;接入真实数据后由后端提供 blocks */
|
||||
function buildArticleBlocks(title: string, desc: string, cover: string): ArticleBlock[] {
|
||||
return [
|
||||
{
|
||||
type: 'paragraph',
|
||||
text: `开放与明亮的氛围,在室内空间中尤为注重。这条以「${desc}」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。`,
|
||||
},
|
||||
{ type: 'heading', text: '设计细节' },
|
||||
{
|
||||
type: 'paragraph',
|
||||
text: `《${title}》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。`,
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
text: '在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。',
|
||||
},
|
||||
{ type: 'image', src: cover },
|
||||
{
|
||||
type: 'paragraph',
|
||||
text: '文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const CES: ArticleCategory = {
|
||||
id: 'ces',
|
||||
kind: 'articles',
|
||||
title: 'CES 现场报道',
|
||||
barColor: '#ffba3a',
|
||||
icon: '🏷️',
|
||||
items: ARTICLE_SEEDS.map(([title, desc], index) => {
|
||||
const cover = `https://picsum.photos/seed/a${index + 1}/400/250`
|
||||
|
||||
return {
|
||||
id: `ces-${index + 1}`,
|
||||
title: `${title} | 这个设计了不起`,
|
||||
desc,
|
||||
cover,
|
||||
meta: ['2 年前', 'CES 现场报道'],
|
||||
blocks: buildArticleBlocks(title, desc, cover),
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
/**
|
||||
* 把后端项目列表转成「本地服务」分类。
|
||||
* href 用真实路由,详情页的「跳转」按钮直接打开对应静态站点。
|
||||
*/
|
||||
function buildLocalCategory(items: AppItem[]): AppCategory {
|
||||
return {
|
||||
id: 'local',
|
||||
kind: 'apps',
|
||||
title: '本地服务',
|
||||
barColor: '#3858f6',
|
||||
icon: '🖥️',
|
||||
groups: items.length > 0 ? [{ id: 'local-all', name: '全部项目', items }] : [],
|
||||
previewLimit: 8,
|
||||
emptyText: '还没有项目:在托管目录下建一个子目录并放入 index.html,刷新后就会出现在这里。',
|
||||
}
|
||||
}
|
||||
|
||||
/** 组装首页 + 明细所需的全部分类,本地服务始终排第一 */
|
||||
export function buildCategories(projects: { name: string; route_path: string }[]): Category[] {
|
||||
const localItems: AppItem[] = projects.map((project) => ({
|
||||
id: project.route_path,
|
||||
name: project.name,
|
||||
desc: project.route_path,
|
||||
icon: project.name.slice(0, 1).toUpperCase(),
|
||||
href: project.route_path,
|
||||
// 本地项目没有封面图,详情页退化为字符头像
|
||||
comment: '本地静态站点,由 Stash 直接托管。',
|
||||
badge: '本地项目',
|
||||
views: 0,
|
||||
likes: 0,
|
||||
}))
|
||||
|
||||
return [buildLocalCategory(localItems), RECOMMEND, MOVIES, CES]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { AppCategory, AppItem, Category } from './types'
|
||||
|
||||
/**
|
||||
* 「本地服务」分类的组装逻辑。
|
||||
*
|
||||
* 这一块不来自内容库:托管目录里有哪些项目是由文件系统决定的,
|
||||
* 后端每次扫描后通过 `/api/projects` 推送,所以本地分类始终在前端拼出来,
|
||||
* 内容库只负责其余的分类(常用推荐 / 推荐电影 / CES 现场报道)。
|
||||
*/
|
||||
|
||||
/** 后端 `/api/projects` 返回的项目结构 */
|
||||
export type HostedProject = {
|
||||
name: string
|
||||
route_path: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 把托管项目映射成应用条目。
|
||||
*
|
||||
* id 直接用路由地址,保证同一项目在前端各处(首页卡片、明细、详情)唯一。
|
||||
* 本地项目没有封面图,详情页会退化成字符头像。
|
||||
*/
|
||||
function toLocalItem(project: HostedProject): AppItem {
|
||||
return {
|
||||
id: project.route_path,
|
||||
name: project.name,
|
||||
desc: project.route_path,
|
||||
icon: project.name.slice(0, 1).toUpperCase(),
|
||||
href: project.route_path,
|
||||
comment: '本地静态站点,由 Stash 直接托管。',
|
||||
badge: '本地项目',
|
||||
views: 0,
|
||||
likes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成「本地服务」分类;没有项目时给出一句引导文案 */
|
||||
export function buildLocalCategory(projects: HostedProject[]): AppCategory {
|
||||
const items = projects.map(toLocalItem)
|
||||
|
||||
return {
|
||||
id: 'local',
|
||||
kind: 'apps',
|
||||
title: '本地服务',
|
||||
barColor: '#3858f6',
|
||||
icon: '🖥️',
|
||||
groups: items.length > 0 ? [{ id: 'local-all', name: '全部项目', items }] : [],
|
||||
previewLimit: 8,
|
||||
emptyText: '还没有项目:在托管目录下建一个子目录并放入 index.html,刷新后就会出现在这里。',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接首页全部分类:本地服务永远排第一,其后跟进内容库返回的分类。
|
||||
*/
|
||||
export function withLocalCategory(
|
||||
projects: HostedProject[],
|
||||
categories: Category[],
|
||||
): Category[] {
|
||||
return [buildLocalCategory(projects), ...categories]
|
||||
}
|
||||
+12
-31
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* 侧栏分类导航的数据定义。
|
||||
* 侧栏分类导航的数据结构。
|
||||
*
|
||||
* 叶子节点通过 target 指向「分类 + 可选分组」,点击后右侧切换到对应明细。
|
||||
* 标签保持与现有侧栏一致,避免改动已定稿的侧栏。
|
||||
* 导航数据存在内容库的 `nav_entry` 表里(自引用树),随 `/api/catalog` 一起下发;
|
||||
* 这里只保留类型定义和空模型兜底,不再有写死的导航常量。
|
||||
*/
|
||||
|
||||
export type NavTarget = {
|
||||
@@ -11,44 +11,25 @@ export type NavTarget = {
|
||||
groupId?: string
|
||||
}
|
||||
|
||||
/** 一个可点击的导航叶子 */
|
||||
export type NavLeaf = {
|
||||
id: string
|
||||
label: string
|
||||
target: NavTarget
|
||||
}
|
||||
|
||||
/** 可折叠分组:本身不可点击,只负责收纳叶子 */
|
||||
export type NavGroupNode = {
|
||||
id: string
|
||||
label: string
|
||||
leaves: NavLeaf[]
|
||||
}
|
||||
|
||||
/** 顶层直达链接 */
|
||||
export const NAV_TOP_LEVEL: NavLeaf[] = [
|
||||
{ id: 'recommend', label: '常用推荐', target: { categoryId: 'recommend' } },
|
||||
]
|
||||
/** 侧栏的完整数据模型:顶层直达项 + 可折叠分组 */
|
||||
export type NavModel = {
|
||||
topLevel: NavLeaf[]
|
||||
groups: NavGroupNode[]
|
||||
}
|
||||
|
||||
/** 可折叠分组 */
|
||||
export const NAV_GROUPS: NavGroupNode[] = [
|
||||
{
|
||||
id: 'assets',
|
||||
label: '素材资源',
|
||||
leaves: [
|
||||
{ id: 'ui', label: 'UI 设计资源', target: { categoryId: 'recommend', groupId: 'ui' } },
|
||||
{ id: 'vector', label: '矢量图标', target: { categoryId: 'recommend', groupId: 'vector' } },
|
||||
{
|
||||
id: 'wallpaper',
|
||||
label: '免费壁纸',
|
||||
target: { categoryId: 'recommend', groupId: 'wallpaper' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tools',
|
||||
label: '开发工具',
|
||||
leaves: [
|
||||
{ id: 'dev', label: '前端调试工具', target: { categoryId: 'recommend', groupId: 'dev' } },
|
||||
{ id: 'code', label: '在线编辑器', target: { categoryId: 'recommend', groupId: 'dev' } },
|
||||
],
|
||||
},
|
||||
]
|
||||
/** 内容库还没有导航数据时的兜底,避免渲染时到处判空 */
|
||||
export const EMPTY_NAV: NavModel = { topLevel: [], groups: [] }
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { fetchCatalog, subscribeCatalog } from './api'
|
||||
import { EMPTY_NAV, type NavModel } from './data/nav'
|
||||
import type { Category } from './data/types'
|
||||
|
||||
export type CatalogStatus = 'loading' | 'ready' | 'error'
|
||||
|
||||
export type CatalogState = {
|
||||
status: CatalogStatus
|
||||
categories: Category[]
|
||||
/** 侧栏导航;与分类同一次请求下发 */
|
||||
nav: NavModel
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export type CatalogController = CatalogState & {
|
||||
/** 手动重新拉取;内容库被外部修改后也会自动调用 */
|
||||
reload: () => void
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容目录:首屏拉取 + 监听服务端推送 + 手动重载。
|
||||
*
|
||||
* 与 `useProjects` 的区别是失败策略:项目列表失败会保留旧数据,
|
||||
* 内容目录同样保留旧数据 —— 用户正在改库时偶尔读到坏 JSON 不应该把页面清空。
|
||||
*/
|
||||
export function useCatalog(): CatalogController {
|
||||
const [state, setState] = useState<CatalogState>({
|
||||
status: 'loading',
|
||||
categories: [],
|
||||
nav: EMPTY_NAV,
|
||||
error: null,
|
||||
})
|
||||
const controllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
/**
|
||||
* 拉一次内容目录。
|
||||
*
|
||||
* 用 ref 保存当前的 AbortController:外部改动可能连续触发多次刷新,
|
||||
* 只保留最后一次请求,避免旧响应把新数据覆盖回去。
|
||||
*/
|
||||
const load = useCallback(() => {
|
||||
controllerRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
controllerRef.current = controller
|
||||
|
||||
fetchCatalog(controller.signal)
|
||||
.then((catalog) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setState({
|
||||
status: 'ready',
|
||||
categories: catalog.categories,
|
||||
nav: catalog.nav,
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setState((previous) => ({
|
||||
// 已经有内容时只提示错误,不清空列表
|
||||
status: previous.categories.length > 0 ? 'ready' : 'error',
|
||||
categories: previous.categories,
|
||||
nav: previous.nav,
|
||||
error: toErrorMessage(error),
|
||||
}))
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 首屏加载:setState 只发生在 Promise 回调里,避免 effect 内同步 setState
|
||||
useEffect(() => {
|
||||
load()
|
||||
return () => controllerRef.current?.abort()
|
||||
}, [load])
|
||||
|
||||
// 内容库被外部修改时,服务端推送一次「变了」,这里重新拉取
|
||||
useEffect(() => subscribeCatalog(load), [load])
|
||||
|
||||
return { ...state, reload: load }
|
||||
}
|
||||
Reference in New Issue
Block a user