feat(db): 内容库迁入 SQLite,分类与侧栏导航改为数据库驱动

This commit is contained in:
2026-10-23 20:22:23 +08:00
parent 386c0f77c7
commit 41fed5e64d
23 changed files with 2867 additions and 319 deletions
+24 -2
View File
@@ -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
View File
@@ -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"
+5 -1
View File
@@ -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}
-267
View File
@@ -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]
}
+61
View File
@@ -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
View File
@@ -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: [] }
+85
View File
@@ -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 }
}