From 991264c32cea9617c1dbc3ff048571d90bb96611 Mon Sep 17 00:00:00 2001 From: Kaxi <1042864399@qq.com> Date: Thu, 16 Jul 2026 23:51:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E4=B8=AA=E4=BA=BA=E5=90=8D=E7=89=87=E4=B8=8E=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 4 +- README.md | 5 +- assets/css/main.css | 2 + components/blog/PostCard.vue | 9 + components/layout/AvatarOrbit.client.vue | 98 ++++++ config/site.ts.example | 2 + content.config.ts | 4 + content/posts/docker-musl-build/index.md | 4 + content/posts/docker-user-start/index.md | 4 + content/posts/go-embed-frontend/index.md | 4 + content/posts/go-mcp-gin/index.md | 4 + content/posts/hello-world/index.md | 4 + content/posts/nuxt-content-lessons/index.md | 4 + .../nuxt-tailwind-underline-fix/index.md | 4 + docs/content-growth-plan.md | 267 +++++++++++++++ package.json | 1 + pages/archive.vue | 80 +++++ pages/index.vue | 321 ++++++++++++------ pages/posts/[slug].vue | 109 +++++- pages/series/[name].vue | 83 +++++ pages/series/index.vue | 65 ++++ plugins/scroll-reset.client.ts | 4 +- pnpm-lock.yaml | 30 +- scripts/new-post.js | 5 + utils/posts.ts | 171 ++++++++++ 25 files changed, 1158 insertions(+), 130 deletions(-) create mode 100644 components/layout/AvatarOrbit.client.vue create mode 100644 docs/content-growth-plan.md create mode 100644 pages/archive.vue create mode 100644 pages/series/[name].vue create mode 100644 pages/series/index.vue create mode 100644 utils/posts.ts diff --git a/AGENTS.md b/AGENTS.md index 8f6f57a..e10d474 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,14 +35,14 @@ pnpm new-post # 创建新文章(自动生成 frontmatter 和图片目 4. 封面图放在 `img/` 目录下,路径为 `/posts//img/cover.jpg` ### 页面路由 -- `pages/index.vue` — 首页(社交卡片、导航、网易云热评、不蒜子计数) +- `pages/index.vue` — 首页(个人名片、3D 技术栈头像、社交卡片、导航、网易云热评、不蒜子计数、文章更新入口) - `pages/posts/index.vue` — 文章列表(搜索、标签过滤、分页) - `pages/posts/[slug].vue` — 文章详情(Giscus 评论、右侧 TOC 目录) - `pages/[...slug].vue` — 兜底路由 - 其他页面:cover(封面制作)、friends(友链)、sponsor(赞助)、about(关于) ### 组件分层 -- `components/layout/` — NavBar(面包屑导航 + 头像旋转)、Footer、ThemeToggle +- `components/layout/` — NavBar(面包屑导航 + 头像旋转)、AvatarOrbit(首页 3D 技术栈头像)、Footer、ThemeToggle - `components/blog/` — PostCard、PostToc(基于 IntersectionObserver 的目录)、Giscus - `components/content/ProseImg.vue` — 覆盖 @nuxt/content 默认图片组件,支持相对路径解析 diff --git a/README.md b/README.md index 95071fd..0f38efc 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ - **标签系统** — 文章打标签,首页展示高频标签,点击就能快速筛选 - **封面制作** — 在线生成封面图,支持自定义文字、图标、背景,导出 PNG/SVG +- **首页个人名片** — 头像周围有 3D 技术栈图标环绕,社交入口和常用页面优先展示 - **网易云热评** — 首页随机展示一条音乐评论,每次刷新都不一样 - **暗色模式** — 跟随系统自动切换,也可以手动控制 - **Giscus 评论** — 基于 GitHub Discussions,不用自己搞后端 @@ -57,7 +58,7 @@ pnpm new-post <文章名称> ``` ├── assets/css/ # 全局样式和主题变量 ├── components/ -│ ├── layout/ # 导航栏、页脚、主题切换 +│ ├── layout/ # 导航栏、页脚、主题切换、3D 技术栈头像 │ └── blog/ # 文章卡片、目录、评论 ├── config/site.ts # 全站配置(改这一个文件就行) ├── content/posts/ # Markdown 文章 @@ -69,7 +70,7 @@ pnpm new-post <文章名称> ## 配置 -所有站点配置集中在 `config/site.ts`,站点名称、头像、社交链接、导航菜单、友链、赞助、Giscus 都在这里改,改完自动生效。 +所有站点配置集中在 `config/site.ts`,站点名称、头像、社交链接、导航菜单、友链、赞助、Giscus 都在这里改,改完自动生效。首页头像使用 `components/layout/AvatarOrbit.client.vue` 渲染,技术栈图标、颜色、动画节奏在该组件内维护。 ## 部署 diff --git a/assets/css/main.css b/assets/css/main.css index c1e443e..f0538c1 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -138,7 +138,9 @@ body { .btn-pill { display: inline-flex; align-items: center; + justify-content: center; gap: 0.5rem; + min-height: 2.75rem; padding: 0.5rem 1rem; border-radius: 9999px; border: 1px solid var(--color-border); diff --git a/components/blog/PostCard.vue b/components/blog/PostCard.vue index 0fba8c2..ac81350 100644 --- a/components/blog/PostCard.vue +++ b/components/blog/PostCard.vue @@ -67,6 +67,15 @@ function renderDesc(): string { + + · 更新 {{ formatDate(post.updated) }} + + + · {{ post.series }} + + + · {{ post.difficulty }} + · {{ wordCount }} 字 diff --git a/components/layout/AvatarOrbit.client.vue b/components/layout/AvatarOrbit.client.vue new file mode 100644 index 0000000..c4814cc --- /dev/null +++ b/components/layout/AvatarOrbit.client.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/config/site.ts.example b/config/site.ts.example index 97f2874..d97e065 100644 --- a/config/site.ts.example +++ b/config/site.ts.example @@ -28,6 +28,8 @@ export const siteConfig = { // 导航菜单 navLinks: [ { label: '博客', icon: 'mdi:post-outline', href: '/posts' }, + { label: '归档', icon: 'mdi:archive-outline', href: '/archive' }, + { label: '系列', icon: 'mdi:playlist-edit', href: '/series' }, { label: '关于', icon: 'mdi:information-outline', href: '/about' }, ], diff --git a/content.config.ts b/content.config.ts index 077e41c..153b2bd 100644 --- a/content.config.ts +++ b/content.config.ts @@ -8,12 +8,16 @@ export default defineContentConfig({ schema: z.object({ title: z.string(), published: z.string(), + updated: z.string().nullable().optional(), description: z.string().optional(), image: z.string().optional(), draft: z.boolean().optional(), pinned: z.boolean().optional(), tags: z.array(z.string()).optional(), author: z.string().optional(), + series: z.string().nullable().optional(), + seriesOrder: z.number().nullable().optional(), + difficulty: z.string().nullable().optional(), }), }), }, diff --git a/content/posts/docker-musl-build/index.md b/content/posts/docker-musl-build/index.md index 39ed0a1..00bd776 100644 --- a/content/posts/docker-musl-build/index.md +++ b/content/posts/docker-musl-build/index.md @@ -1,10 +1,14 @@ --- title: Docker 编译 musl 可执行文件 published: 2025-03-27 +updated: 2025-03-27 image: /posts/docker-musl-build/img/cover.svg description: 解决 Go 语言跨平台编译问题,使用 Docker + musl 编译静态链接的可执行文件。 tags: ['Docker', 'Go', '运维'] draft: false +series: Docker 实践笔记 +seriesOrder: 1 +difficulty: 进阶 --- ## 解决问题 diff --git a/content/posts/docker-user-start/index.md b/content/posts/docker-user-start/index.md index 82f6dae..7d2df33 100644 --- a/content/posts/docker-user-start/index.md +++ b/content/posts/docker-user-start/index.md @@ -1,10 +1,14 @@ --- title: Docker 构建时指定用户启动 published: 2025-04-01 +updated: 2025-04-01 image: /posts/docker-user-start/img/cover.svg description: 解决 Docker 容器默认以 root 用户运行导致的权限问题,指定非 root 用户启动应用。 tags: ['Docker', '运维', 'Linux'] draft: false +series: Docker 实践笔记 +seriesOrder: 2 +difficulty: 入门 --- ## 前言 diff --git a/content/posts/go-embed-frontend/index.md b/content/posts/go-embed-frontend/index.md index ca53ecb..d570899 100644 --- a/content/posts/go-embed-frontend/index.md +++ b/content/posts/go-embed-frontend/index.md @@ -1,10 +1,14 @@ --- title: Go 单文件打包实战:把前端塞进二进制 published: 2026-06-06T16:00:00 +updated: 2026-06-06 description: 记录将 Vue 3 前端嵌入 Go 二进制实现单 exe 发布的完整流程,以及遇到的坑和解决方案。 image: /posts/go-embed-frontend/img/cover.jpg tags: ['Go', 'Vue', '编译', '埋坑'] draft: false +series: Go 工程实践 +seriesOrder: 2 +difficulty: 进阶 --- ## 背景 diff --git a/content/posts/go-mcp-gin/index.md b/content/posts/go-mcp-gin/index.md index c175df9..fa682a2 100644 --- a/content/posts/go-mcp-gin/index.md +++ b/content/posts/go-mcp-gin/index.md @@ -1,9 +1,13 @@ --- title: Go-MCP 接入 Gin:从零搭建 MCP 服务器 published: 2026-06-01T00:50:00 +updated: 2026-06-01 description: 介绍如何使用 mark3labs/mcp-go 与 Gin 框架集成,搭建一个带鉴权的 MCP(Model Context Protocol)服务器,让 AI Agent 能直接调用你的后端服务。 image: /posts/go-mcp-gin/img/cover.svg tags: ['Go', 'MCP', 'Gin', 'AI Agent', '后端'] +series: Go 工程实践 +seriesOrder: 1 +difficulty: 进阶 --- ## 什么是 MCP diff --git a/content/posts/hello-world/index.md b/content/posts/hello-world/index.md index 879a388..4749a1b 100644 --- a/content/posts/hello-world/index.md +++ b/content/posts/hello-world/index.md @@ -1,11 +1,15 @@ --- title: Hello World - 欢迎来到尼克的小窝 published: 2026-05-31T08:00:00 +updated: 2026-05-31 description: 博客上线啦!这篇文章介绍本站的技术架构、功能特性和搭建过程中的一些思考。 image: /posts/hello-world/img/cover.jpg pinned: true tags: ['博客', 'Nuxt'] draft: false +series: Nuxt 博客开发 +seriesOrder: 1 +difficulty: 入门 --- ## 博客上线啦! diff --git a/content/posts/nuxt-content-lessons/index.md b/content/posts/nuxt-content-lessons/index.md index 0bcaa55..22919c6 100644 --- a/content/posts/nuxt-content-lessons/index.md +++ b/content/posts/nuxt-content-lessons/index.md @@ -1,10 +1,14 @@ --- title: Nuxt Content v3 踩坑记录:目录、图片与那些反直觉的设计 published: 2026-05-31T14:00:00 +updated: 2026-05-31 description: 从 SvelteKit 迁移到 Nuxt 3 的过程中,遇到了不少 Nuxt Content v3 的坑。这篇文章记录了目录导航、图片路径、ProseImg 组件等问题的排查和解决过程。 image: /posts/nuxt-content-lessons/img/cover.jpg tags: ['Nuxt', '踩坑', 'Nuxt Content'] draft: false +series: Nuxt 博客开发 +seriesOrder: 2 +difficulty: 进阶 --- # 背景 diff --git a/content/posts/nuxt-tailwind-underline-fix/index.md b/content/posts/nuxt-tailwind-underline-fix/index.md index d0fa418..41c4291 100644 --- a/content/posts/nuxt-tailwind-underline-fix/index.md +++ b/content/posts/nuxt-tailwind-underline-fix/index.md @@ -1,10 +1,14 @@ --- title: Nuxt 3 + Tailwind CSS 客户端导航链接下划线闪烁问题 published: 2026-05-31T18:47:00 +updated: 2026-05-31 image: /posts/nuxt-tailwind-underline-fix/img/cover.png description: 记录 Nuxt 3 配合 Tailwind CSS 时,客户端导航导致链接文字出现下划线闪烁的问题及解决方案。 tags: ['Nuxt', 'Tailwind CSS', 'CSS', '踩坑'] author: Nixus +series: Nuxt 博客开发 +seriesOrder: 3 +difficulty: 进阶 --- ## 问题描述 diff --git a/docs/content-growth-plan.md b/docs/content-growth-plan.md new file mode 100644 index 0000000..c557136 --- /dev/null +++ b/docs/content-growth-plan.md @@ -0,0 +1,267 @@ +# SuBlog 内容丰富化方案 + +## 目标 + +在不显著增加维护成本的前提下,让博客从“文章列表”升级为“有结构的个人知识库”。 + +核心原则: + +- 保持一个主要内容源:`content/posts` +- 优先做自动聚合页面,不新增需要长期手动维护的数据源 +- 先增强现有文章,再考虑项目、收藏、短笔记等独立内容类型 +- 每个阶段都能独立上线,不做大而全改造 + +## 当前判断 + +当前项目已经具备比较完整的博客基础: + +- 文章目录结构清晰:`content/posts//index.md` +- frontmatter 已支持标题、日期、描述、封面、标签、置顶等字段 +- 文章列表页已经有搜索、标签过滤、分页 +- 文章详情页已经有目录和评论 + +因此下一步不建议马上新增 `notes`、`projects`、`bookmarks` 等内容系统。它们看起来丰富,但会引入额外维护负担,时间久了容易荒掉。 + +更合适的方向是:继续围绕 `posts` 做结构增强。 + +## 推荐内容模型 + +继续使用现有文章目录: + +```text +content/ +└── posts/ + └── / + ├── index.md + └── img/ +``` + +在文章 frontmatter 中增加少量可选字段: + +```yaml +title: 文章标题 +published: 2026-07-15 +updated: 2026-07-15 +description: 文章描述 +image: /posts/example/img/cover.jpg +tags: + - Go + - Docker +pinned: false +series: Nuxt 博客开发 +seriesOrder: 1 +difficulty: 入门 +``` + +字段说明: + +| 字段 | 是否必填 | 用途 | +|------|----------|------| +| `title` | 是 | 文章标题 | +| `published` | 是 | 发布时间 | +| `description` | 是 | 列表页和 SEO 摘要 | +| `tags` | 是 | 标签筛选和内容聚合 | +| `image` | 否 | 封面图 | +| `pinned` | 否 | 首页或列表页置顶 | +| `updated` | 否 | 展示最近更新 | +| `series` | 否 | 归入某个系列 | +| `seriesOrder` | 否 | 系列内排序 | +| `difficulty` | 否 | 标记阅读难度 | + +## 目录结构设计 + +建议新增的页面和工具尽量围绕现有目录展开: + +```text +pages/ +├── index.vue # 首页:增加最近文章、精选文章或系列入口 +├── posts/ +│ ├── index.vue # 文章列表:保留现有搜索和标签过滤 +│ └── [slug].vue # 文章详情:增加同系列上一篇/下一篇 +├── archive.vue # 归档页:按年月自动聚合文章 +└── series/ + ├── index.vue # 系列列表页 + └── [name].vue # 系列详情页 + +utils/ +└── content-ast.ts # 保持现有 AST 工具 + +composables/ +└── usePostSearch.ts # 保持现有搜索逻辑 +``` + +不建议第一阶段新增: + +```text +content/notes/ +content/projects/ +content/bookmarks/ +``` + +这些目录可以等文章体系稳定后再加。 + +## 页面能力设计 + +### 首页 + +首页保持“个人名片优先”,内容入口作为补充,不要做成文章列表页的重复版本。 + +当前首页建议顺序: + +- 个人身份:头像、站点名、签名、网易云热评、不蒜子计数 +- 个人入口:社交链接、常用页面导航、公告、小日历 +- 内容入口:文章更新、归档入口、系列入口 + +视觉重点放在个人主页氛围上。首页头像由 `components/layout/AvatarOrbit.client.vue` 实现,使用 3D 技术栈图标环绕头像,图标需要保持正面朝向,环绕范围只略宽于头像,避免遮挡头像主体。 + +文章相关模块保持轻量: + +- 最近文章:展示最新 3 到 5 篇 +- 精选文章:读取 `pinned: true` +- 系列入口:展示有 `series` 的文章分组 + +不建议增加太多模块,避免首页维护和视觉负担过重。 + +### 归档页 + +新增 `/archive` 页面。 + +数据来源:所有非草稿文章。 + +展示方式: + +- 按年份分组 +- 年份下按月份分组 +- 每篇文章显示标题、发布日期、标签 + +维护成本:低。页面完全由文章 frontmatter 自动生成。 + +### 系列页 + +新增 `/series` 和 `/series/[name]`。 + +数据来源:文章 frontmatter 中的 `series` 字段。 + +展示方式: + +- `/series` 展示所有系列名称、文章数量、最近更新时间 +- `/series/[name]` 按 `seriesOrder` 或发布时间排序展示文章 + +文章详情页可以增加: + +- 当前系列名称 +- 同系列上一篇 +- 同系列下一篇 + +维护成本:中低。只需要在相关文章里填写 `series` 和 `seriesOrder`。 + +### 文章详情页 + +建议增强但不改变主体结构: + +- 显示 `updated` +- 显示 `difficulty` +- 如果属于系列,显示系列导航 + +这些信息都来自 frontmatter,不需要额外数据文件。 + +## 实施阶段 + +### 第一阶段:低成本增强 + +目标:快速增加内容层次,但不扩大内容源。 + +范围: + +- 增加 `updated`、`series`、`seriesOrder`、`difficulty` 字段支持 +- 首页展示最近文章和置顶文章 +- 文章详情页展示更新时间和难度 + +验收标准: + +- 不影响旧文章显示 +- 未填写新字段的文章正常展示 +- 首页内容入口更明确 + +### 第二阶段:自动聚合页面 + +目标:让已有文章自动形成内容结构。 + +范围: + +- 新增 `/archive` +- 新增 `/series` +- 新增 `/series/[name]` +- 文章详情页增加同系列上一篇/下一篇 + +验收标准: + +- 归档页能按年月正确分组 +- 系列页能按系列正确聚合 +- 系列排序稳定,不受文章读取顺序影响 + +### 第三阶段:再考虑独立内容类型 + +目标:在文章体系稳定后,再判断是否值得扩展。 + +可选方向: + +- `projects`:项目展示 +- `notes`:短笔记 +- `bookmarks`:资源收藏 + +建议只有在满足以下条件时再做: + +- 现有文章更新稳定 +- 首页和文章页结构已经成熟 +- 确认有长期维护这些内容的需求 + +## 维护规则 + +新增文章时只需要遵守一个流程: + +1. 使用 `pnpm new-post ` 创建文章 +2. 填写必填字段:`title`、`published`、`description`、`tags` +3. 如果文章属于系列,再填写 `series` 和 `seriesOrder` +4. 如果文章有明显更新,再填写或更新 `updated` +5. 如果适合推荐,再设置 `pinned: true` + +推荐 frontmatter 模板: + +```yaml +--- +title: '' +published: 2026-07-15 +updated: +description: '' +image: +draft: false +tags: [] +pinned: false +series: +seriesOrder: +difficulty: +--- +``` + +## 不做清单 + +为了控制维护成本,短期不建议做: + +- 独立 CMS 后台 +- 多内容类型并行维护 +- 复杂分类系统 +- 手动维护首页推荐配置 +- 每篇文章强制填写大量元信息 + +## 推荐结论 + +最适合当前项目的方案是: + +```text +一个内容源:posts +少量增强字段:series、seriesOrder、updated、difficulty +三个自动视图:首页内容入口、归档页、系列页 +``` + +这样能明显提升内容丰富度,同时不会把项目变成难维护的 CMS。 diff --git a/package.json b/package.json index a3ba6ec..789a564 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@tailwindcss/typography": "^0.5.19", "nuxt": "^3.17.0", "vue": "^3.5.0", + "vue-icon-cloud": "^1.0.4", "vue-router": "^4.5.0" }, "devDependencies": { diff --git a/pages/archive.vue b/pages/archive.vue new file mode 100644 index 0000000..078fa30 --- /dev/null +++ b/pages/archive.vue @@ -0,0 +1,80 @@ + + + diff --git a/pages/index.vue b/pages/index.vue index e5ea28e..a41a558 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -1,6 +1,7 @@ diff --git a/pages/posts/[slug].vue b/pages/posts/[slug].vue index 3256933..95c042a 100644 --- a/pages/posts/[slug].vue +++ b/pages/posts/[slug].vue @@ -1,5 +1,6 @@ @@ -41,10 +68,24 @@ function formatDate(date: string) {
-
+
+ + 更新 {{ formatDate(post.updated) }} + + + {{ post.difficulty }} + + + {{ post.series }} +

{{ post.title }}

@@ -89,6 +130,62 @@ function formatDate(date: string) {
+
+
+
+

当前系列

+

{{ post.series }}

+
+ + 查看全部 {{ seriesPosts.length }} 篇 + +
+ +
+ +

上一篇

+

{{ previousSeriesPost.title }}

+
+
+ 已经是本系列第一篇 +
+ + +

下一篇

+

{{ nextSeriesPost.title }}

+
+
+ 已经是本系列最后一篇 +
+
+
+
diff --git a/pages/series/[name].vue b/pages/series/[name].vue new file mode 100644 index 0000000..0e4d484 --- /dev/null +++ b/pages/series/[name].vue @@ -0,0 +1,83 @@ + + + diff --git a/pages/series/index.vue b/pages/series/index.vue new file mode 100644 index 0000000..5a3a29f --- /dev/null +++ b/pages/series/index.vue @@ -0,0 +1,65 @@ + + + diff --git a/plugins/scroll-reset.client.ts b/plugins/scroll-reset.client.ts index 08983e3..2aea237 100644 --- a/plugins/scroll-reset.client.ts +++ b/plugins/scroll-reset.client.ts @@ -1,4 +1,4 @@ -export default defineNuxtPlugin(() => { +export default defineNuxtPlugin((nuxtApp) => { if (typeof window === 'undefined') return let observer: IntersectionObserver | null = null @@ -38,7 +38,7 @@ export default defineNuxtPlugin(() => { }) // 首次加载 - onMounted(() => { + nuxtApp.hook('app:mounted', () => { setTimeout(initScrollSpy, 100) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dc09cc..9b6616d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,13 +10,16 @@ importers: dependencies: '@tailwindcss/typography': specifier: ^0.5.19 - version: 0.5.19(tailwindcss@3.4.19(yaml@2.9.0)) + version: 0.5.20(tailwindcss@3.4.19(yaml@2.9.0)) nuxt: specifier: ^3.17.0 version: 3.21.6(@parcel/watcher@2.5.6)(@vue/compiler-sfc@3.5.35)(better-sqlite3@12.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.10.0))(ioredis@5.11.0)(magicast@0.5.3)(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(srvx@0.11.16)(terser@5.48.0)(typescript@5.8.2)(vite@7.3.3(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(yaml@2.9.0) vue: specifier: ^3.5.0 version: 3.5.35(typescript@5.8.2) + vue-icon-cloud: + specifier: ^1.0.4 + version: 1.0.4(typescript@5.8.2) vue-router: specifier: ^4.5.0 version: 4.6.4(vue@3.5.35(typescript@5.8.2)) @@ -29,7 +32,7 @@ importers: version: 3.14.0(better-sqlite3@12.10.0)(magicast@0.5.3) '@nuxtjs/color-mode': specifier: ^4.0.0 - version: 4.0.0(magicast@0.5.3) + version: 4.0.1(magicast@0.5.3) '@nuxtjs/sitemap': specifier: ^8.0.0 version: 8.0.15(@nuxt/schema@3.21.6)(magicast@0.5.3)(nuxt@3.21.6(@parcel/watcher@2.5.6)(@vue/compiler-sfc@3.5.35)(better-sqlite3@12.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.10.0))(ioredis@5.11.0)(magicast@0.5.3)(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(srvx@0.11.16)(terser@5.48.0)(typescript@5.8.2)(vite@7.3.3(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(yaml@2.9.0))(vite@7.3.3(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.35(typescript@5.8.2))(zod@3.25.76) @@ -743,8 +746,8 @@ packages: rollup-plugin-visualizer: optional: true - '@nuxtjs/color-mode@4.0.0': - resolution: {integrity: sha512-xyaVR/TPLdMuRa2VOgH6b75jvmFEsn9QKL6ISldaAw38ooFJfWY1ts2F3ye43wcT/goCbcuvPuskF2f8yUZhlw==} + '@nuxtjs/color-mode@4.0.1': + resolution: {integrity: sha512-eiA7hWXi5zNHaYKyJFCGF6i0wFZtuvR7KDXZ6jiSvwxjCpRFwphrw0MOSmNfArTSSsT1wpW+/2H92cejeVfUlg==} '@nuxtjs/mdc@0.22.0': resolution: {integrity: sha512-4jVc967snO5oOZtVhDnLi2Nu8kSsvGU66bsufKGU/Ixsj5lzA3HdaLMJEkFNs8AXtVXJUbkZ2PhvTttFHo8Kgw==} @@ -1534,10 +1537,10 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@tailwindcss/typography@0.5.19': - resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} + '@tailwindcss/typography@0.5.20': + resolution: {integrity: sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==} peerDependencies: - tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + tailwindcss: '>=3.0.0 || >=4.0.0 || insiders' '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} @@ -4763,6 +4766,9 @@ packages: vue-devtools-stub@0.1.0: resolution: {integrity: sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ==} + vue-icon-cloud@1.0.4: + resolution: {integrity: sha512-YzSpnLyK5jqSbTek1zVpmchTa9JC4pYVgb4rd2994kqjtYBe4xSmLok3kqPaDqUUUlBOA0ewWGL0afEGBbBtug==} + vue-router@4.6.4: resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} peerDependencies: @@ -5788,7 +5794,7 @@ snapshots: - vue-tsc - yaml - '@nuxtjs/color-mode@4.0.0(magicast@0.5.3)': + '@nuxtjs/color-mode@4.0.1(magicast@0.5.3)': dependencies: '@nuxt/kit': 4.4.6(magicast@0.5.3) exsolve: 1.0.8 @@ -6376,7 +6382,7 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@tailwindcss/typography@0.5.19(tailwindcss@3.4.19(yaml@2.9.0))': + '@tailwindcss/typography@0.5.20(tailwindcss@3.4.19(yaml@2.9.0))': dependencies: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.19(yaml@2.9.0) @@ -10379,6 +10385,12 @@ snapshots: vue-devtools-stub@0.1.0: {} + vue-icon-cloud@1.0.4(typescript@5.8.2): + dependencies: + vue: 3.5.35(typescript@5.8.2) + transitivePeerDependencies: + - typescript + vue-router@4.6.4(vue@3.5.35(typescript@5.8.2)): dependencies: '@vue/devtools-api': 6.6.4 diff --git a/scripts/new-post.js b/scripts/new-post.js index 6ad786e..56c6e33 100644 --- a/scripts/new-post.js +++ b/scripts/new-post.js @@ -26,10 +26,15 @@ mkdirSync(imgDir, { recursive: true }) const content = `--- title: ${name} published: ${date} +updated: description: image: /posts/${name}/img/cover.jpg tags: [] draft: false +pinned: false +series: +seriesOrder: +difficulty: --- 在这里写文章内容... diff --git a/utils/posts.ts b/utils/posts.ts new file mode 100644 index 0000000..c96ae3d --- /dev/null +++ b/utils/posts.ts @@ -0,0 +1,171 @@ +export type BlogPost = { + id?: string + path?: string + title?: string + description?: string + published?: string + updated?: string | null + image?: string | null + draft?: boolean + pinned?: boolean + tags?: string[] + series?: string | null + seriesOrder?: number | null + difficulty?: string | null + body?: unknown +} + +export type ArchiveMonth = { + month: string + label: string + posts: BlogPost[] +} + +export type ArchiveYear = { + year: string + months: ArchiveMonth[] +} + +export type SeriesSummary = { + name: string + path: string + count: number + posts: BlogPost[] + latestPost: BlogPost + latestTime: number + tags: string[] +} + +export function isVisiblePost(post: BlogPost): boolean { + return !post.draft +} + +export function sortPostsByPublished(posts: BlogPost[]): BlogPost[] { + return [...posts].sort((a, b) => getPostTime(b.published) - getPostTime(a.published)) +} + +export function sortPostsForSeries(posts: BlogPost[]): BlogPost[] { + return [...posts].sort((a, b) => { + const orderA = typeof a.seriesOrder === 'number' ? a.seriesOrder : Number.MAX_SAFE_INTEGER + const orderB = typeof b.seriesOrder === 'number' ? b.seriesOrder : Number.MAX_SAFE_INTEGER + if (orderA !== orderB) return orderA - orderB + return getPostTime(a.published) - getPostTime(b.published) + }) +} + +export function getArchiveGroups(posts: BlogPost[]): ArchiveYear[] { + const years = new Map>() + + for (const post of sortPostsByPublished(posts)) { + const date = getPostDate(post.published) + if (!date) continue + + const year = String(date.getFullYear()) + const month = String(date.getMonth() + 1).padStart(2, '0') + + if (!years.has(year)) { + years.set(year, new Map()) + } + + const months = years.get(year)! + if (!months.has(month)) { + months.set(month, []) + } + + months.get(month)!.push(post) + } + + return [...years.entries()] + .sort(([a], [b]) => Number(b) - Number(a)) + .map(([year, months]) => ({ + year, + months: [...months.entries()] + .sort(([a], [b]) => Number(b) - Number(a)) + .map(([month, monthPosts]) => ({ + month, + label: `${Number(month)} 月`, + posts: monthPosts, + })), + })) +} + +export function getSeriesSummaries(posts: BlogPost[]): SeriesSummary[] { + const groups = new Map() + + for (const post of posts) { + const name = normalizeSeriesName(post.series) + if (!name) continue + + if (!groups.has(name)) { + groups.set(name, []) + } + groups.get(name)!.push(post) + } + + return [...groups.entries()] + .map(([name, items]) => { + const seriesPosts = sortPostsForSeries(items) + const latestPost = sortPostsByPublished(seriesPosts)[0] + const tags = [...new Set(seriesPosts.flatMap(post => post.tags || []))].slice(0, 6) + + return { + name, + path: getSeriesPath(name), + count: seriesPosts.length, + posts: seriesPosts, + latestPost, + latestTime: Math.max(...seriesPosts.map(post => getPostTime(post.updated || post.published))), + tags, + } + }) + .sort((a, b) => b.latestTime - a.latestTime) +} + +export function normalizeSeriesName(series?: string | null): string { + return typeof series === 'string' ? series.trim() : '' +} + +export function getSeriesPath(name: string): string { + return `/series/${encodeURIComponent(name)}` +} + +export function decodeRouteParam(value: string): string { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + +export function formatPostDate(date?: string | null): string { + if (!date) return '' + + return new Date(date).toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric', + }) +} + +export function getWordCount(post: BlogPost): number { + const text = JSON.stringify(post.body || '').replace(/<[^>]*>/g, '') + const chinese = text.match(/[一-龥]/g) || [] + const english = text.match(/[a-zA-Z]+/g) || [] + return chinese.length + english.length +} + +export function getReadTime(post: BlogPost): number { + return Math.max(1, Math.ceil(getWordCount(post) / 300)) +} + +function getPostDate(date?: string | null): Date | null { + if (!date) return null + + const value = new Date(date) + if (Number.isNaN(value.getTime())) return null + return value +} + +function getPostTime(date?: string | null): number { + return getPostDate(date)?.getTime() || 0 +}