feat(db): 内容库迁入 SQLite,分类与侧栏导航改为数据库驱动
This commit is contained in:
@@ -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: [] }
|
||||
|
||||
Reference in New Issue
Block a user