feat: 优化首页个人名片与内容入口

This commit is contained in:
2026-07-16 23:51:55 +08:00
parent 92360e4f49
commit 991264c32c
25 changed files with 1158 additions and 130 deletions
+2 -2
View File
@@ -35,14 +35,14 @@ pnpm new-post <name> # 创建新文章(自动生成 frontmatter 和图片目
4. 封面图放在 `img/` 目录下,路径为 `/posts/<slug>/img/cover.jpg` 4. 封面图放在 `img/` 目录下,路径为 `/posts/<slug>/img/cover.jpg`
### 页面路由 ### 页面路由
- `pages/index.vue` — 首页(社交卡片、导航、网易云热评、不蒜子计数) - `pages/index.vue` — 首页(个人名片、3D 技术栈头像、社交卡片、导航、网易云热评、不蒜子计数、文章更新入口
- `pages/posts/index.vue` — 文章列表(搜索、标签过滤、分页) - `pages/posts/index.vue` — 文章列表(搜索、标签过滤、分页)
- `pages/posts/[slug].vue` — 文章详情(Giscus 评论、右侧 TOC 目录) - `pages/posts/[slug].vue` — 文章详情(Giscus 评论、右侧 TOC 目录)
- `pages/[...slug].vue` — 兜底路由 - `pages/[...slug].vue` — 兜底路由
- 其他页面:cover(封面制作)、friends(友链)、sponsor(赞助)、about(关于) - 其他页面:cover(封面制作)、friends(友链)、sponsor(赞助)、about(关于)
### 组件分层 ### 组件分层
- `components/layout/` — NavBar(面包屑导航 + 头像旋转)、Footer、ThemeToggle - `components/layout/` — NavBar(面包屑导航 + 头像旋转)、AvatarOrbit(首页 3D 技术栈头像)、Footer、ThemeToggle
- `components/blog/` — PostCard、PostToc(基于 IntersectionObserver 的目录)、Giscus - `components/blog/` — PostCard、PostToc(基于 IntersectionObserver 的目录)、Giscus
- `components/content/ProseImg.vue` — 覆盖 @nuxt/content 默认图片组件,支持相对路径解析 - `components/content/ProseImg.vue` — 覆盖 @nuxt/content 默认图片组件,支持相对路径解析
+3 -2
View File
@@ -16,6 +16,7 @@
- **标签系统** — 文章打标签,首页展示高频标签,点击就能快速筛选 - **标签系统** — 文章打标签,首页展示高频标签,点击就能快速筛选
- **封面制作** — 在线生成封面图,支持自定义文字、图标、背景,导出 PNG/SVG - **封面制作** — 在线生成封面图,支持自定义文字、图标、背景,导出 PNG/SVG
- **首页个人名片** — 头像周围有 3D 技术栈图标环绕,社交入口和常用页面优先展示
- **网易云热评** — 首页随机展示一条音乐评论,每次刷新都不一样 - **网易云热评** — 首页随机展示一条音乐评论,每次刷新都不一样
- **暗色模式** — 跟随系统自动切换,也可以手动控制 - **暗色模式** — 跟随系统自动切换,也可以手动控制
- **Giscus 评论** — 基于 GitHub Discussions,不用自己搞后端 - **Giscus 评论** — 基于 GitHub Discussions,不用自己搞后端
@@ -57,7 +58,7 @@ pnpm new-post <文章名称>
``` ```
├── assets/css/ # 全局样式和主题变量 ├── assets/css/ # 全局样式和主题变量
├── components/ ├── components/
│ ├── layout/ # 导航栏、页脚、主题切换 │ ├── layout/ # 导航栏、页脚、主题切换、3D 技术栈头像
│ └── blog/ # 文章卡片、目录、评论 │ └── blog/ # 文章卡片、目录、评论
├── config/site.ts # 全站配置(改这一个文件就行) ├── config/site.ts # 全站配置(改这一个文件就行)
├── content/posts/ # Markdown 文章 ├── content/posts/ # Markdown 文章
@@ -69,7 +70,7 @@ pnpm new-post <文章名称>
## 配置 ## 配置
所有站点配置集中在 `config/site.ts`,站点名称、头像、社交链接、导航菜单、友链、赞助、Giscus 都在这里改,改完自动生效。 所有站点配置集中在 `config/site.ts`,站点名称、头像、社交链接、导航菜单、友链、赞助、Giscus 都在这里改,改完自动生效。首页头像使用 `components/layout/AvatarOrbit.client.vue` 渲染,技术栈图标、颜色、动画节奏在该组件内维护。
## 部署 ## 部署
+2
View File
@@ -138,7 +138,9 @@ body {
.btn-pill { .btn-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center;
gap: 0.5rem; gap: 0.5rem;
min-height: 2.75rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-radius: 9999px; border-radius: 9999px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
+9
View File
@@ -67,6 +67,15 @@ function renderDesc(): string {
<time v-if="post.published" class="shrink-0 text-sm text-muted-foreground whitespace-nowrap"> <time v-if="post.published" class="shrink-0 text-sm text-muted-foreground whitespace-nowrap">
{{ formatDate(post.published) }} {{ formatDate(post.published) }}
</time> </time>
<span v-if="post.updated" class="shrink-0 text-sm text-muted-foreground whitespace-nowrap">
· 更新 {{ formatDate(post.updated) }}
</span>
<span v-if="post.series" class="shrink-0 text-sm text-muted-foreground whitespace-nowrap">
· {{ post.series }}
</span>
<span v-if="post.difficulty" class="shrink-0 text-sm text-muted-foreground whitespace-nowrap">
· {{ post.difficulty }}
</span>
<span class="shrink-0 text-sm text-muted-foreground whitespace-nowrap"> <span class="shrink-0 text-sm text-muted-foreground whitespace-nowrap">
· {{ wordCount }} · {{ wordCount }}
</span> </span>
+98
View File
@@ -0,0 +1,98 @@
<script setup lang="ts">
import IconCloud from 'vue-icon-cloud'
defineProps<{
src: string
alt: string
}>()
const techSlugs = [
'javascript',
'typescript',
'vuedotjs',
'nuxt',
'tailwindcss',
'html5',
'css',
'nodedotjs',
'go',
'docker',
'git',
'github',
'nginx',
'postgresql',
'linux',
'cloudflare',
]
const cloudImages = techSlugs.map(slug => `https://cdn.simpleicons.org/${slug}/${slug}`)
</script>
<template>
<div class="avatar-cloud">
<IconCloud :images="cloudImages" />
<div class="avatar-core">
<img
:src="src"
:alt="alt"
class="avatar-image"
/>
</div>
</div>
</template>
<style scoped>
.avatar-cloud {
--cloud-size: clamp(12rem, 31vw, 14rem);
--avatar-size: clamp(6.4rem, 19vw, 7.35rem);
position: relative;
display: grid;
place-items: center;
width: var(--cloud-size);
height: var(--cloud-size);
flex-shrink: 0;
}
.avatar-cloud::before {
content: "";
position: absolute;
inset: 17%;
border-radius: 9999px;
background: radial-gradient(circle, color-mix(in srgb, var(--color-primary) 14%, transparent), transparent 64%);
opacity: 0.8;
}
.avatar-cloud :deep(canvas) {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.12));
}
.avatar-core {
position: relative;
z-index: 2;
display: grid;
place-items: center;
width: var(--avatar-size);
height: var(--avatar-size);
pointer-events: none;
}
.avatar-image {
width: 100%;
height: 100%;
border: 1px solid color-mix(in srgb, var(--color-text) 12%, transparent);
border-radius: 9999px;
object-fit: cover;
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.14);
}
.dark .avatar-image {
border-color: color-mix(in srgb, var(--color-text) 14%, transparent);
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.42);
}
</style>
+2
View File
@@ -28,6 +28,8 @@ export const siteConfig = {
// 导航菜单 // 导航菜单
navLinks: [ navLinks: [
{ label: '博客', icon: 'mdi:post-outline', href: '/posts' }, { 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' }, { label: '关于', icon: 'mdi:information-outline', href: '/about' },
], ],
+4
View File
@@ -8,12 +8,16 @@ export default defineContentConfig({
schema: z.object({ schema: z.object({
title: z.string(), title: z.string(),
published: z.string(), published: z.string(),
updated: z.string().nullable().optional(),
description: z.string().optional(), description: z.string().optional(),
image: z.string().optional(), image: z.string().optional(),
draft: z.boolean().optional(), draft: z.boolean().optional(),
pinned: z.boolean().optional(), pinned: z.boolean().optional(),
tags: z.array(z.string()).optional(), tags: z.array(z.string()).optional(),
author: z.string().optional(), author: z.string().optional(),
series: z.string().nullable().optional(),
seriesOrder: z.number().nullable().optional(),
difficulty: z.string().nullable().optional(),
}), }),
}), }),
}, },
+4
View File
@@ -1,10 +1,14 @@
--- ---
title: Docker 编译 musl 可执行文件 title: Docker 编译 musl 可执行文件
published: 2025-03-27 published: 2025-03-27
updated: 2025-03-27
image: /posts/docker-musl-build/img/cover.svg image: /posts/docker-musl-build/img/cover.svg
description: 解决 Go 语言跨平台编译问题,使用 Docker + musl 编译静态链接的可执行文件。 description: 解决 Go 语言跨平台编译问题,使用 Docker + musl 编译静态链接的可执行文件。
tags: ['Docker', 'Go', '运维'] tags: ['Docker', 'Go', '运维']
draft: false draft: false
series: Docker 实践笔记
seriesOrder: 1
difficulty: 进阶
--- ---
## 解决问题 ## 解决问题
+4
View File
@@ -1,10 +1,14 @@
--- ---
title: Docker 构建时指定用户启动 title: Docker 构建时指定用户启动
published: 2025-04-01 published: 2025-04-01
updated: 2025-04-01
image: /posts/docker-user-start/img/cover.svg image: /posts/docker-user-start/img/cover.svg
description: 解决 Docker 容器默认以 root 用户运行导致的权限问题,指定非 root 用户启动应用。 description: 解决 Docker 容器默认以 root 用户运行导致的权限问题,指定非 root 用户启动应用。
tags: ['Docker', '运维', 'Linux'] tags: ['Docker', '运维', 'Linux']
draft: false draft: false
series: Docker 实践笔记
seriesOrder: 2
difficulty: 入门
--- ---
## 前言 ## 前言
+4
View File
@@ -1,10 +1,14 @@
--- ---
title: Go 单文件打包实战:把前端塞进二进制 title: Go 单文件打包实战:把前端塞进二进制
published: 2026-06-06T16:00:00 published: 2026-06-06T16:00:00
updated: 2026-06-06
description: 记录将 Vue 3 前端嵌入 Go 二进制实现单 exe 发布的完整流程,以及遇到的坑和解决方案。 description: 记录将 Vue 3 前端嵌入 Go 二进制实现单 exe 发布的完整流程,以及遇到的坑和解决方案。
image: /posts/go-embed-frontend/img/cover.jpg image: /posts/go-embed-frontend/img/cover.jpg
tags: ['Go', 'Vue', '编译', '埋坑'] tags: ['Go', 'Vue', '编译', '埋坑']
draft: false draft: false
series: Go 工程实践
seriesOrder: 2
difficulty: 进阶
--- ---
## 背景 ## 背景
+4
View File
@@ -1,9 +1,13 @@
--- ---
title: Go-MCP 接入 Gin:从零搭建 MCP 服务器 title: Go-MCP 接入 Gin:从零搭建 MCP 服务器
published: 2026-06-01T00:50:00 published: 2026-06-01T00:50:00
updated: 2026-06-01
description: 介绍如何使用 mark3labs/mcp-go 与 Gin 框架集成,搭建一个带鉴权的 MCPModel Context Protocol)服务器,让 AI Agent 能直接调用你的后端服务。 description: 介绍如何使用 mark3labs/mcp-go 与 Gin 框架集成,搭建一个带鉴权的 MCPModel Context Protocol)服务器,让 AI Agent 能直接调用你的后端服务。
image: /posts/go-mcp-gin/img/cover.svg image: /posts/go-mcp-gin/img/cover.svg
tags: ['Go', 'MCP', 'Gin', 'AI Agent', '后端'] tags: ['Go', 'MCP', 'Gin', 'AI Agent', '后端']
series: Go 工程实践
seriesOrder: 1
difficulty: 进阶
--- ---
## 什么是 MCP ## 什么是 MCP
+4
View File
@@ -1,11 +1,15 @@
--- ---
title: Hello World - 欢迎来到尼克的小窝 title: Hello World - 欢迎来到尼克的小窝
published: 2026-05-31T08:00:00 published: 2026-05-31T08:00:00
updated: 2026-05-31
description: 博客上线啦!这篇文章介绍本站的技术架构、功能特性和搭建过程中的一些思考。 description: 博客上线啦!这篇文章介绍本站的技术架构、功能特性和搭建过程中的一些思考。
image: /posts/hello-world/img/cover.jpg image: /posts/hello-world/img/cover.jpg
pinned: true pinned: true
tags: ['博客', 'Nuxt'] tags: ['博客', 'Nuxt']
draft: false draft: false
series: Nuxt 博客开发
seriesOrder: 1
difficulty: 入门
--- ---
## 博客上线啦! ## 博客上线啦!
@@ -1,10 +1,14 @@
--- ---
title: Nuxt Content v3 踩坑记录:目录、图片与那些反直觉的设计 title: Nuxt Content v3 踩坑记录:目录、图片与那些反直觉的设计
published: 2026-05-31T14:00:00 published: 2026-05-31T14:00:00
updated: 2026-05-31
description: 从 SvelteKit 迁移到 Nuxt 3 的过程中,遇到了不少 Nuxt Content v3 的坑。这篇文章记录了目录导航、图片路径、ProseImg 组件等问题的排查和解决过程。 description: 从 SvelteKit 迁移到 Nuxt 3 的过程中,遇到了不少 Nuxt Content v3 的坑。这篇文章记录了目录导航、图片路径、ProseImg 组件等问题的排查和解决过程。
image: /posts/nuxt-content-lessons/img/cover.jpg image: /posts/nuxt-content-lessons/img/cover.jpg
tags: ['Nuxt', '踩坑', 'Nuxt Content'] tags: ['Nuxt', '踩坑', 'Nuxt Content']
draft: false draft: false
series: Nuxt 博客开发
seriesOrder: 2
difficulty: 进阶
--- ---
# 背景 # 背景
@@ -1,10 +1,14 @@
--- ---
title: Nuxt 3 + Tailwind CSS 客户端导航链接下划线闪烁问题 title: Nuxt 3 + Tailwind CSS 客户端导航链接下划线闪烁问题
published: 2026-05-31T18:47:00 published: 2026-05-31T18:47:00
updated: 2026-05-31
image: /posts/nuxt-tailwind-underline-fix/img/cover.png image: /posts/nuxt-tailwind-underline-fix/img/cover.png
description: 记录 Nuxt 3 配合 Tailwind CSS 时,客户端导航导致链接文字出现下划线闪烁的问题及解决方案。 description: 记录 Nuxt 3 配合 Tailwind CSS 时,客户端导航导致链接文字出现下划线闪烁的问题及解决方案。
tags: ['Nuxt', 'Tailwind CSS', 'CSS', '踩坑'] tags: ['Nuxt', 'Tailwind CSS', 'CSS', '踩坑']
author: Nixus author: Nixus
series: Nuxt 博客开发
seriesOrder: 3
difficulty: 进阶
--- ---
## 问题描述 ## 问题描述
+267
View File
@@ -0,0 +1,267 @@
# SuBlog 内容丰富化方案
## 目标
在不显著增加维护成本的前提下,让博客从“文章列表”升级为“有结构的个人知识库”。
核心原则:
- 保持一个主要内容源:`content/posts`
- 优先做自动聚合页面,不新增需要长期手动维护的数据源
- 先增强现有文章,再考虑项目、收藏、短笔记等独立内容类型
- 每个阶段都能独立上线,不做大而全改造
## 当前判断
当前项目已经具备比较完整的博客基础:
- 文章目录结构清晰:`content/posts/<slug>/index.md`
- frontmatter 已支持标题、日期、描述、封面、标签、置顶等字段
- 文章列表页已经有搜索、标签过滤、分页
- 文章详情页已经有目录和评论
因此下一步不建议马上新增 `notes``projects``bookmarks` 等内容系统。它们看起来丰富,但会引入额外维护负担,时间久了容易荒掉。
更合适的方向是:继续围绕 `posts` 做结构增强。
## 推荐内容模型
继续使用现有文章目录:
```text
content/
└── posts/
└── <slug>/
├── 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 <slug>` 创建文章
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。
+1
View File
@@ -14,6 +14,7 @@
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"nuxt": "^3.17.0", "nuxt": "^3.17.0",
"vue": "^3.5.0", "vue": "^3.5.0",
"vue-icon-cloud": "^1.0.4",
"vue-router": "^4.5.0" "vue-router": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
+80
View File
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { siteConfig } from '~/config/site'
import { formatPostDate, getArchiveGroups, isVisiblePost } from '~/utils/posts'
useSeoMeta({
title: `归档 - ${siteConfig.siteName}`,
description: `${siteConfig.siteName} 的文章时间线`,
})
const { data: posts } = await useAsyncData('archive-posts', () =>
queryCollection('posts')
.order('published', 'DESC')
.all()
)
const archiveGroups = computed(() => getArchiveGroups((posts.value || []).filter(isVisiblePost)))
const totalPosts = computed(() =>
archiveGroups.value.reduce((total, year) => total + year.months.reduce((sum, month) => sum + month.posts.length, 0), 0)
)
</script>
<template>
<div class="container mx-auto max-w-4xl px-4 py-12">
<header class="mb-12 text-center anim-fade-in-up">
<h1 class="mb-4 text-4xl font-bold">文章归档</h1>
<p class="text-muted-foreground">按时间整理所有文章方便回头翻旧账</p>
<p class="mt-2 text-sm text-muted-foreground"> {{ totalPosts }} 篇文章</p>
</header>
<div v-if="archiveGroups.length" class="space-y-10 anim-fade-in-up anim-delay-1">
<section v-for="year in archiveGroups" :key="year.year">
<div class="mb-5 flex items-center gap-3">
<h2 class="text-2xl font-bold">{{ year.year }}</h2>
<span class="h-px flex-1" style="background-color: var(--color-border);" />
</div>
<div class="space-y-6">
<div v-for="month in year.months" :key="`${year.year}-${month.month}`" class="grid gap-4 md:grid-cols-[6rem_1fr]">
<div class="text-sm font-medium text-muted-foreground">{{ month.label }}</div>
<div class="space-y-3">
<NuxtLink
v-for="post in month.posts"
:key="post.path || post.id"
:to="post.path || '/posts'"
class="group block rounded-lg border p-4 transition-colors hover:bg-muted"
style="border-color: var(--color-border);"
>
<div class="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<time v-if="post.published">{{ formatPostDate(post.published) }}</time>
<span v-if="post.series">· {{ post.series }}</span>
<span v-if="post.difficulty">· {{ post.difficulty }}</span>
</div>
<h3 class="font-semibold transition-opacity group-hover:opacity-70">
{{ post.title }}
</h3>
<p v-if="post.description" class="mt-1 text-sm text-muted-foreground">
{{ post.description }}
</p>
<div v-if="post.tags?.length" class="mt-3 flex flex-wrap gap-1.5">
<span v-for="tag in post.tags" :key="tag" class="tag-pill">
{{ tag }}
</span>
</div>
</NuxtLink>
</div>
</div>
</div>
</section>
</div>
<div v-else class="rounded-xl border p-8 text-center text-muted-foreground anim-fade-in-up anim-delay-1" style="border-color: var(--color-border); background-color: var(--color-bg-card);">
<Icon icon="mdi:archive-outline" class="mx-auto mb-3 h-8 w-8" />
暂无可归档文章
</div>
</div>
</template>
+195 -92
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
import { siteConfig } from '~/config/site' import { siteConfig } from '~/config/site'
import { formatPostDate, getSeriesSummaries, isVisiblePost, sortPostsByPublished } from '~/utils/posts'
const now = new Date() const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
@@ -14,6 +15,36 @@ type WeekendStatus = {
value: string value: string
} }
onMounted(() => {
const el = document.getElementById('busuanzi_value_site_pv')
if (!el) return
el.textContent = '1'
const animate = (target: number) => {
if (target <= 1) return
const duration = 800
const start = performance.now()
const tick = (now: number) => {
const progress = Math.min((now - start) / duration, 1)
const eased = 1 - Math.pow(1 - progress, 3)
el.textContent = Math.floor(1 + (target - 1) * eased).toLocaleString()
if (progress < 1) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}
const observer = new MutationObserver(() => {
const val = parseInt(el.textContent || '0', 10)
if (val > 1) {
observer.disconnect()
animate(val)
}
})
observer.observe(el, { childList: true, characterData: true, subtree: true })
setTimeout(() => observer.disconnect(), 10000)
})
// 从 API 获取法定节假日 // 从 API 获取法定节假日
const { data: holidaysData } = await useAsyncData('holidays', () => const { data: holidaysData } = await useAsyncData('holidays', () =>
$fetch('https://timor.tech/api/holiday/year/' + today.getFullYear()).catch(() => null) $fetch('https://timor.tech/api/holiday/year/' + today.getFullYear()).catch(() => null)
@@ -59,103 +90,82 @@ useHead({
// 网易云热评 // 网易云热评
const { data: hitokoto } = await useAsyncData('wyy-comment', () => $fetch<{ user: string; music: string; content: string }>('/api/wyy')) const { data: hitokoto } = await useAsyncData('wyy-comment', () => $fetch<{ user: string; music: string; content: string }>('/api/wyy'))
onMounted(() => { // 首页内容入口
const el = document.getElementById('busuanzi_value_site_pv') const { data: homePosts } = await useAsyncData('home-posts', () =>
if (!el) return queryCollection('posts')
el.textContent = '1' .order('pinned', 'DESC')
.order('published', 'DESC')
.all()
)
const animate = (target: number) => { const visibleHomePosts = computed(() => sortPostsByPublished((homePosts.value || []).filter(isVisiblePost)))
if (target <= 1) return const featuredPosts = computed(() => {
const duration = 800 const posts = [
const start = performance.now() ...visibleHomePosts.value.filter(post => post.pinned),
const tick = (now: number) => { ...visibleHomePosts.value.filter(post => !post.pinned),
const progress = Math.min((now - start) / duration, 1) ]
const eased = 1 - Math.pow(1 - progress, 3) const seen = new Set<string>()
el.textContent = Math.floor(1 + (target - 1) * eased).toLocaleString()
if (progress < 1) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}
const observer = new MutationObserver(() => { return posts.filter((post) => {
const val = parseInt(el.textContent || '0', 10) const key = post.path || post.id || post.title || ''
if (val > 1) { if (!key || seen.has(key)) return false
observer.disconnect() seen.add(key)
animate(val) return true
} }).slice(0, 5)
})
observer.observe(el, { childList: true, characterData: true, subtree: true })
setTimeout(() => observer.disconnect(), 10000)
}) })
const homeSeriesSummaries = computed(() => getSeriesSummaries(visibleHomePosts.value))
const homeSeries = computed(() => homeSeriesSummaries.value.slice(0, 3))
const homeSeriesCount = computed(() => homeSeriesSummaries.value.length)
const homeTagCount = computed(() => new Set(visibleHomePosts.value.flatMap(post => post.tags || [])).size)
const contentLinks = [
{ label: '文章', icon: 'mdi:post-outline', href: '/posts' },
{ label: '归档', icon: 'mdi:archive-outline', href: '/archive' },
{ label: '系列', icon: 'mdi:playlist-edit', href: '/series' },
]
const secondaryNavLinks = computed(() =>
siteConfig.navLinks.filter(link => !contentLinks.some(item => item.href === link.href))
)
</script> </script>
<template> <template>
<div class="flex flex-col items-center gap-8 px-4 pt-4 pb-12"> <div class="mx-auto flex w-full max-w-4xl flex-col gap-10 px-4 py-8 sm:py-12">
<!-- 公告卡片 --> <!-- 站点身份 -->
<div class="w-full max-w-2xl text-center anim-fade-in-up"> <section class="anim-fade-in-up">
<div class="announcement-happy rounded-xl p-6"> <div class="flex flex-col items-center gap-5 text-center sm:flex-row sm:items-center sm:text-left">
<p class="announcement-text text-sm md:text-base"> <LayoutAvatarOrbit
<strong>欢迎来到{{ siteConfig.siteName }}~ 用代码创造快乐用热爱点亮生活 (゚ヮ゚)</strong>
</p>
<p class="announcement-text text-sm md:text-base mt-1">
遇到问题的话可以去 <a href="https://github.com/wishesl/SuBlog/issues" target="_blank" rel="noopener noreferrer">Issues</a> 提反馈哦会尽快修的
</p>
</div>
</div>
<!-- 头像 -->
<div class="anim-fade-in-up anim-delay-1">
<img
:src="siteConfig.avatar" :src="siteConfig.avatar"
:alt="siteConfig.siteName" :alt="siteConfig.siteName"
class="h-32 w-32 rounded-full shadow-lg"
/> />
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-muted-foreground">{{ siteConfig.subtitle }}</p>
<h1 class="mt-1 text-4xl font-bold sm:text-5xl">{{ siteConfig.siteName }}</h1>
<p class="mt-3 text-base leading-relaxed text-muted-foreground">
{{ siteConfig.description }}{{ siteConfig.bio }}
</p>
<div v-if="hitokoto" class="mt-4 text-sm leading-relaxed text-muted-foreground">
<p class="italic">"{{ hitokoto.content }}"</p>
<p class="mt-1 text-xs opacity-70"> {{ hitokoto.user }} · {{ hitokoto.music }}</p>
</div> </div>
<!-- 标题 + 口号 + 访客 --> <p class="mt-4 text-sm text-muted-foreground">
<div class="text-center anim-fade-in-up anim-delay-2"> 您是第 <span id="busuanzi_value_site_pv" class="font-medium text-primary"></span> 个访客
<h1 class="text-4xl font-bold mb-2">{{ siteConfig.siteName }}</h1>
<p v-if="hitokoto" class="text-sm italic leading-relaxed mb-1" style="color: var(--color-text-secondary)">
"{{ hitokoto.content }}"
</p> </p>
<p v-if="hitokoto" class="text-xs opacity-60 mb-1" style="color: var(--color-text-secondary)">
{{ hitokoto.user }} · {{ hitokoto.music }}
</p>
<p class="text-sm mt-6" style="color: var(--color-text-secondary)">
您是第 <span id="busuanzi_value_site_pv" class="font-medium" style="color: var(--color-primary)"></span> 个访客
</p>
</div>
<!-- 放假倒计时 -->
<div class="w-full max-w-sm anim-fade-in-up anim-delay-3">
<div class="card relative overflow-hidden py-4">
<div class="flex items-center justify-center gap-6 text-sm">
<div class="flex items-center gap-2">
<img src="https://api.iconify.design/mdi:calendar-weekend.svg?color=%239ca3af" alt="" class="w-4 h-4" />
<span class="text-muted-foreground">{{ weekendStatus.label }}</span>
<span class="font-semibold" style="color: var(--color-primary);">{{ weekendStatus.value }}</span>
</div>
<template v-if="nextHoliday">
<span class="text-muted-foreground/30">|</span>
<div class="flex items-center gap-2">
<img src="https://api.iconify.design/mdi:pine-tree.svg?color=%239ca3af" alt="" class="w-4 h-4" />
<span class="text-muted-foreground">{{ nextHoliday.name }}</span>
<span class="font-semibold" style="color: var(--color-primary);">{{ holidayDays }} </span>
</div>
</template>
</div>
</div> </div>
</div> </div>
<!-- 社交卡片 --> </section>
<div class="w-full max-w-2xl mx-auto anim-fade-in-up anim-delay-4">
<div class="card relative overflow-hidden py-6"> <!-- 辅助信息 -->
<div class="absolute inset-0 flex items-center justify-center pointer-events-none select-none z-0"> <section class="anim-fade-in-up anim-delay-1">
<span class="text-5xl font-black tracking-widest" style="color: var(--color-text); opacity: 0.04;">社交</span> <div class="grid gap-4 md:grid-cols-2">
</div> <div class="rounded-xl border px-5 py-4" style="border-color: var(--color-border); background-color: var(--color-bg-card);">
<div class="relative z-10 px-6 flex flex-wrap gap-3 justify-center"> <h2 class="mb-3 text-sm font-semibold text-muted-foreground">社交</h2>
<div class="flex flex-wrap gap-2">
<a <a
v-for="link in siteConfig.socialLinks" v-for="link in siteConfig.socialLinks"
:key="link.name" :key="link.name"
@@ -167,33 +177,126 @@ onMounted(() => {
<img <img
:src="`https://api.iconify.design/${link.icon}.svg?color=${encodeURIComponent(link.color || 'currentColor')}`" :src="`https://api.iconify.design/${link.icon}.svg?color=${encodeURIComponent(link.color || 'currentColor')}`"
:alt="link.name" :alt="link.name"
class="w-5 h-5" class="h-5 w-5"
/> />
<span>{{ link.name }}</span> <span>{{ link.name }}</span>
</a> </a>
</div> </div>
</div> </div>
</div>
<!-- 导航卡片 --> <div class="rounded-xl border px-5 py-4" style="border-color: var(--color-border); background-color: var(--color-bg-card);">
<div class="w-full max-w-2xl mx-auto anim-fade-in-up anim-delay-5"> <h2 class="mb-3 text-sm font-semibold text-muted-foreground">更多页面</h2>
<div class="card relative overflow-hidden py-6"> <div class="flex flex-wrap gap-2">
<div class="absolute inset-0 flex items-center justify-center pointer-events-none select-none z-0">
<span class="text-5xl font-black tracking-widest" style="color: var(--color-text); opacity: 0.04;">导航</span>
</div>
<div class="relative z-10 px-6 flex flex-wrap gap-3 justify-center">
<NuxtLink <NuxtLink
v-for="link in siteConfig.navLinks" v-for="link in secondaryNavLinks"
:key="link.href" :key="link.href"
:to="link.href" :to="link.href"
class="btn-pill" class="btn-pill"
> >
<Icon :icon="link.icon" class="w-5 h-5" /> <Icon :icon="link.icon" class="h-5 w-5" />
<span>{{ link.label }}</span> <span>{{ link.label }}</span>
</NuxtLink> </NuxtLink>
</div> </div>
</div> </div>
<div class="rounded-xl border px-5 py-4" style="border-color: var(--color-border); background-color: var(--color-bg-card);">
<h2 class="mb-3 text-sm font-semibold text-muted-foreground">公告</h2>
<div class="announcement-happy">
<p class="announcement-text text-sm leading-relaxed">
<strong>欢迎来到{{ siteConfig.siteName }}~ 用代码创造快乐用热爱点亮生活 (゚ヮ゚)</strong>
</p>
<p class="announcement-text mt-1 text-sm leading-relaxed">
遇到问题的话可以去 <a href="https://github.com/wishesl/SuBlog/issues" target="_blank" rel="noopener noreferrer">Issues</a> 提反馈哦会尽快修的
</p>
</div> </div>
</div>
<div class="rounded-xl border px-5 py-4" style="border-color: var(--color-border); background-color: var(--color-bg-card);">
<h2 class="mb-3 text-sm font-semibold text-muted-foreground">小日历</h2>
<div class="flex flex-wrap items-center gap-x-5 gap-y-3 text-sm">
<div class="flex items-center gap-2">
<Icon icon="mdi:calendar-weekend" class="h-5 w-5 text-muted-foreground" aria-hidden="true" />
<span class="text-muted-foreground">{{ weekendStatus.label }}</span>
<span class="font-semibold text-primary">{{ weekendStatus.value }}</span>
</div>
<div v-if="nextHoliday" class="flex items-center gap-2">
<Icon icon="mdi:pine-tree" class="h-5 w-5 text-muted-foreground" aria-hidden="true" />
<span class="text-muted-foreground">{{ nextHoliday.name }}</span>
<span class="font-semibold text-primary">{{ holidayDays }} </span>
</div>
</div>
</div>
</div>
</section>
<!-- 内容入口 -->
<section class="anim-fade-in-up anim-delay-2">
<div class="mb-5 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 class="text-2xl font-bold">文章更新</h2>
<p class="mt-1 text-sm text-muted-foreground">
{{ visibleHomePosts.length }} 篇文章 · {{ homeSeriesCount }} 个系列 · {{ homeTagCount }} 个标签
</p>
</div>
<div class="flex flex-wrap gap-2">
<NuxtLink
v-for="link in contentLinks"
:key="link.href"
:to="link.href"
class="btn-pill"
>
<Icon :icon="link.icon" class="h-5 w-5" />
<span>{{ link.label }}</span>
</NuxtLink>
</div>
</div>
<div class="overflow-hidden rounded-xl border" style="border-color: var(--color-border); background-color: var(--color-bg-card);">
<NuxtLink
v-for="post in featuredPosts"
:key="post.path || post.id"
:to="post.path || '/posts'"
class="group block border-b px-5 py-4 transition-colors last:border-b-0 hover:bg-muted"
style="border-color: var(--color-border);"
>
<div class="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span
v-if="post.pinned"
class="rounded-full px-2 py-0.5 font-medium"
style="background: var(--color-text); color: var(--color-bg);"
>
置顶
</span>
<time v-if="post.published">{{ formatPostDate(post.published) }}</time>
<span v-if="post.series">· {{ post.series }}</span>
<span v-if="post.difficulty">· {{ post.difficulty }}</span>
</div>
<h3 class="font-semibold transition-opacity group-hover:opacity-70">
{{ post.title }}
</h3>
<p v-if="post.description" class="mt-1 line-clamp-2 text-sm leading-relaxed text-muted-foreground">
{{ post.description }}
</p>
</NuxtLink>
<p v-if="featuredPosts.length === 0" class="px-5 py-6 text-center text-sm text-muted-foreground">
暂无文章
</p>
</div>
<div v-if="homeSeries.length" class="mt-4 flex flex-wrap items-center gap-2">
<span class="text-sm text-muted-foreground">系列入口</span>
<NuxtLink
v-for="series in homeSeries"
:key="series.name"
:to="series.path"
class="tag-pill"
>
{{ series.name }} · {{ series.count }}
</NuxtLink>
</div>
</section>
</div> </div>
</template> </template>
+103 -6
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { siteConfig } from '~/config/site' import { siteConfig } from '~/config/site'
import { formatPostDate, isVisiblePost, sortPostsForSeries } from '~/utils/posts'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -28,12 +29,38 @@ useSeoMeta({
twitterImage: post.value.image || siteConfig.ogImage, twitterImage: post.value.image || siteConfig.ogImage,
}) })
const { data: seriesSourcePosts } = await useAsyncData(`series-source-${slug}`, () =>
queryCollection('posts')
.order('published', 'ASC')
.all()
)
const seriesPosts = computed(() => {
if (!post.value?.series) return []
return sortPostsForSeries(
(seriesSourcePosts.value || [])
.filter(isVisiblePost)
.filter(item => item.series === post.value?.series)
)
})
const currentSeriesIndex = computed(() =>
seriesPosts.value.findIndex(item => item.path === post.value?.path)
)
const previousSeriesPost = computed(() => {
if (currentSeriesIndex.value <= 0) return null
return seriesPosts.value[currentSeriesIndex.value - 1]
})
const nextSeriesPost = computed(() => {
if (currentSeriesIndex.value < 0) return null
return seriesPosts.value[currentSeriesIndex.value + 1] || null
})
function formatDate(date: string) { function formatDate(date: string) {
return new Date(date).toLocaleDateString('zh-CN', { return formatPostDate(date)
year: 'numeric',
month: 'long',
day: 'numeric',
})
} }
</script> </script>
@@ -41,10 +68,24 @@ function formatDate(date: string) {
<article v-if="post" class="container mx-auto max-w-3xl px-4 py-12"> <article v-if="post" class="container mx-auto max-w-3xl px-4 py-12">
<header class="mb-8 anim-fade-in-up"> <header class="mb-8 anim-fade-in-up">
<div class="mb-4 flex items-center gap-2"> <div class="mb-4 flex flex-wrap items-center gap-x-3 gap-y-2">
<time v-if="post.published" class="text-sm text-muted-foreground"> <time v-if="post.published" class="text-sm text-muted-foreground">
{{ formatDate(post.published) }} {{ formatDate(post.published) }}
</time> </time>
<span v-if="post.updated" class="text-sm text-muted-foreground">
更新 {{ formatDate(post.updated) }}
</span>
<span v-if="post.difficulty" class="text-sm text-muted-foreground">
{{ post.difficulty }}
</span>
<NuxtLink
v-if="post.series"
:to="`/series/${encodeURIComponent(post.series)}`"
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium hover:opacity-80"
style="background: var(--color-bg-hover); color: var(--color-primary);"
>
{{ post.series }}
</NuxtLink>
</div> </div>
<h1 class="mb-4 text-4xl font-bold">{{ post.title }}</h1> <h1 class="mb-4 text-4xl font-bold">{{ post.title }}</h1>
@@ -89,6 +130,62 @@ function formatDate(date: string) {
<ContentRenderer :value="post" /> <ContentRenderer :value="post" />
</div> </div>
<section
v-if="post.series && seriesPosts.length > 1"
class="mt-12 pt-8 anim-fade-in-up anim-delay-2"
style="border-top: 1px solid var(--color-border)"
>
<div class="mb-4 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<p class="text-sm text-muted-foreground">当前系列</p>
<h2 class="text-xl font-semibold">{{ post.series }}</h2>
</div>
<NuxtLink
:to="`/series/${encodeURIComponent(post.series)}`"
class="text-sm hover:underline"
style="color: var(--color-primary)"
>
查看全部 {{ seriesPosts.length }}
</NuxtLink>
</div>
<div class="grid gap-3 md:grid-cols-2">
<NuxtLink
v-if="previousSeriesPost"
:to="previousSeriesPost.path || '/posts'"
class="group rounded-lg border p-4 transition-colors hover:bg-muted"
style="border-color: var(--color-border);"
>
<p class="mb-1 text-xs text-muted-foreground">上一篇</p>
<p class="font-medium transition-opacity group-hover:opacity-70">{{ previousSeriesPost.title }}</p>
</NuxtLink>
<div
v-else
class="rounded-lg border p-4 text-sm text-muted-foreground"
style="border-color: var(--color-border);"
>
已经是本系列第一篇
</div>
<NuxtLink
v-if="nextSeriesPost"
:to="nextSeriesPost.path || '/posts'"
class="group rounded-lg border p-4 text-right transition-colors hover:bg-muted"
style="border-color: var(--color-border);"
>
<p class="mb-1 text-xs text-muted-foreground">下一篇</p>
<p class="font-medium transition-opacity group-hover:opacity-70">{{ nextSeriesPost.title }}</p>
</NuxtLink>
<div
v-else
class="rounded-lg border p-4 text-right text-sm text-muted-foreground"
style="border-color: var(--color-border);"
>
已经是本系列最后一篇
</div>
</div>
</section>
<div class="anim-fade-in-up anim-delay-2"> <div class="anim-fade-in-up anim-delay-2">
<BlogGiscus class="mt-16" /> <BlogGiscus class="mt-16" />
</div> </div>
+83
View File
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { siteConfig } from '~/config/site'
import { decodeRouteParam, formatPostDate, getReadTime, isVisiblePost, normalizeSeriesName, sortPostsForSeries } from '~/utils/posts'
const route = useRoute()
const rawName = Array.isArray(route.params.name) ? route.params.name[0] : route.params.name
const seriesName = decodeRouteParam(String(rawName || ''))
const { data: posts } = await useAsyncData(`series-${seriesName}`, () =>
queryCollection('posts')
.order('published', 'ASC')
.all()
)
const seriesPosts = computed(() =>
sortPostsForSeries(
(posts.value || [])
.filter(isVisiblePost)
.filter(post => normalizeSeriesName(post.series) === seriesName)
)
)
if (!seriesPosts.value.length) {
throw createError({ statusCode: 404, message: '系列不存在' })
}
useSeoMeta({
title: `${seriesName} - ${siteConfig.siteName}`,
description: `${siteConfig.siteName} 的「${seriesName}」系列文章`,
})
</script>
<template>
<div class="container mx-auto max-w-4xl px-4 py-12">
<header class="mb-12 anim-fade-in-up">
<NuxtLink to="/series" class="mb-5 inline-flex items-center gap-1 text-sm hover:underline" style="color: var(--color-primary)">
<Icon icon="mdi:arrow-left" class="h-4 w-4" />
返回系列
</NuxtLink>
<h1 class="mb-4 text-4xl font-bold">{{ seriesName }}</h1>
<p class="text-muted-foreground">
{{ seriesPosts.length }} 篇文章按阅读顺序排列
</p>
</header>
<div class="space-y-4 anim-fade-in-up anim-delay-1">
<NuxtLink
v-for="(post, index) in seriesPosts"
:key="post.path || post.id"
:to="post.path || '/posts'"
class="group grid gap-4 rounded-xl border p-5 transition-all hover:-translate-y-0.5 hover:shadow-lg md:grid-cols-[3.5rem_1fr]"
style="border-color: var(--color-border); background-color: var(--color-bg-card);"
>
<div class="flex h-12 w-12 items-center justify-center rounded-full text-lg font-bold" style="background-color: var(--color-bg-hover); color: var(--color-primary);">
{{ post.seriesOrder || index + 1 }}
</div>
<div class="min-w-0">
<div class="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<time v-if="post.published">{{ formatPostDate(post.published) }}</time>
<span>· {{ getReadTime(post) }} 分钟</span>
<span v-if="post.difficulty">· {{ post.difficulty }}</span>
</div>
<h2 class="text-xl font-semibold transition-opacity group-hover:opacity-70">
{{ post.title }}
</h2>
<p v-if="post.description" class="mt-2 text-sm text-muted-foreground">
{{ post.description }}
</p>
<div v-if="post.tags?.length" class="mt-3 flex flex-wrap gap-1.5">
<span v-for="tag in post.tags" :key="tag" class="tag-pill">
{{ tag }}
</span>
</div>
</div>
</NuxtLink>
</div>
</div>
</template>
+65
View File
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { siteConfig } from '~/config/site'
import { formatPostDate, getSeriesSummaries, isVisiblePost } from '~/utils/posts'
useSeoMeta({
title: `文章系列 - ${siteConfig.siteName}`,
description: `${siteConfig.siteName} 的系列文章合集`,
})
const { data: posts } = await useAsyncData('series-list-posts', () =>
queryCollection('posts')
.order('published', 'DESC')
.all()
)
const seriesList = computed(() => getSeriesSummaries((posts.value || []).filter(isVisiblePost)))
</script>
<template>
<div class="container mx-auto max-w-5xl px-4 py-12">
<header class="mb-12 text-center anim-fade-in-up">
<h1 class="mb-4 text-4xl font-bold">文章系列</h1>
<p class="text-muted-foreground">把同一个主题的文章串起来读起来更连贯</p>
<p class="mt-2 text-sm text-muted-foreground"> {{ seriesList.length }} 个系列</p>
</header>
<div v-if="seriesList.length" class="grid gap-5 md:grid-cols-2 anim-fade-in-up anim-delay-1">
<NuxtLink
v-for="series in seriesList"
:key="series.name"
:to="series.path"
class="group rounded-xl border p-5 transition-all hover:-translate-y-0.5 hover:shadow-lg"
style="border-color: var(--color-border); background-color: var(--color-bg-card);"
>
<div class="mb-4 flex items-start justify-between gap-4">
<div class="min-w-0">
<h2 class="truncate text-xl font-semibold transition-opacity group-hover:opacity-70">
{{ series.name }}
</h2>
<p class="mt-1 text-sm text-muted-foreground">
{{ series.count }} 篇文章 · 最近更新 {{ formatPostDate(series.latestPost.updated || series.latestPost.published) }}
</p>
</div>
<Icon icon="mdi:chevron-right" class="mt-1 h-5 w-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-1" />
</div>
<p v-if="series.latestPost.description" class="mb-4 line-clamp-2 text-sm text-muted-foreground">
{{ series.latestPost.description }}
</p>
<div v-if="series.tags.length" class="flex flex-wrap gap-1.5">
<span v-for="tag in series.tags" :key="tag" class="tag-pill">
{{ tag }}
</span>
</div>
</NuxtLink>
</div>
<div v-else class="rounded-xl border p-8 text-center text-muted-foreground anim-fade-in-up anim-delay-1" style="border-color: var(--color-border); background-color: var(--color-bg-card);">
<Icon icon="mdi:playlist-remove" class="mx-auto mb-3 h-8 w-8" />
还没有系列给文章 frontmatter 添加 series 字段后会自动出现
</div>
</div>
</template>
+2 -2
View File
@@ -1,4 +1,4 @@
export default defineNuxtPlugin(() => { export default defineNuxtPlugin((nuxtApp) => {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
let observer: IntersectionObserver | null = null let observer: IntersectionObserver | null = null
@@ -38,7 +38,7 @@ export default defineNuxtPlugin(() => {
}) })
// 首次加载 // 首次加载
onMounted(() => { nuxtApp.hook('app:mounted', () => {
setTimeout(initScrollSpy, 100) setTimeout(initScrollSpy, 100)
}) })
}) })
+21 -9
View File
@@ -10,13 +10,16 @@ importers:
dependencies: dependencies:
'@tailwindcss/typography': '@tailwindcss/typography':
specifier: ^0.5.19 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: nuxt:
specifier: ^3.17.0 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) 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: vue:
specifier: ^3.5.0 specifier: ^3.5.0
version: 3.5.35(typescript@5.8.2) 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: vue-router:
specifier: ^4.5.0 specifier: ^4.5.0
version: 4.6.4(vue@3.5.35(typescript@5.8.2)) 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) version: 3.14.0(better-sqlite3@12.10.0)(magicast@0.5.3)
'@nuxtjs/color-mode': '@nuxtjs/color-mode':
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.0.0(magicast@0.5.3) version: 4.0.1(magicast@0.5.3)
'@nuxtjs/sitemap': '@nuxtjs/sitemap':
specifier: ^8.0.0 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) 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: rollup-plugin-visualizer:
optional: true optional: true
'@nuxtjs/color-mode@4.0.0': '@nuxtjs/color-mode@4.0.1':
resolution: {integrity: sha512-xyaVR/TPLdMuRa2VOgH6b75jvmFEsn9QKL6ISldaAw38ooFJfWY1ts2F3ye43wcT/goCbcuvPuskF2f8yUZhlw==} resolution: {integrity: sha512-eiA7hWXi5zNHaYKyJFCGF6i0wFZtuvR7KDXZ6jiSvwxjCpRFwphrw0MOSmNfArTSSsT1wpW+/2H92cejeVfUlg==}
'@nuxtjs/mdc@0.22.0': '@nuxtjs/mdc@0.22.0':
resolution: {integrity: sha512-4jVc967snO5oOZtVhDnLi2Nu8kSsvGU66bsufKGU/Ixsj5lzA3HdaLMJEkFNs8AXtVXJUbkZ2PhvTttFHo8Kgw==} resolution: {integrity: sha512-4jVc967snO5oOZtVhDnLi2Nu8kSsvGU66bsufKGU/Ixsj5lzA3HdaLMJEkFNs8AXtVXJUbkZ2PhvTttFHo8Kgw==}
@@ -1534,10 +1537,10 @@ packages:
'@standard-schema/spec@1.1.0': '@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@tailwindcss/typography@0.5.19': '@tailwindcss/typography@0.5.20':
resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} resolution: {integrity: sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==}
peerDependencies: 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': '@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
@@ -4763,6 +4766,9 @@ packages:
vue-devtools-stub@0.1.0: vue-devtools-stub@0.1.0:
resolution: {integrity: sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ==} resolution: {integrity: sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ==}
vue-icon-cloud@1.0.4:
resolution: {integrity: sha512-YzSpnLyK5jqSbTek1zVpmchTa9JC4pYVgb4rd2994kqjtYBe4xSmLok3kqPaDqUUUlBOA0ewWGL0afEGBbBtug==}
vue-router@4.6.4: vue-router@4.6.4:
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
peerDependencies: peerDependencies:
@@ -5788,7 +5794,7 @@ snapshots:
- vue-tsc - vue-tsc
- yaml - yaml
'@nuxtjs/color-mode@4.0.0(magicast@0.5.3)': '@nuxtjs/color-mode@4.0.1(magicast@0.5.3)':
dependencies: dependencies:
'@nuxt/kit': 4.4.6(magicast@0.5.3) '@nuxt/kit': 4.4.6(magicast@0.5.3)
exsolve: 1.0.8 exsolve: 1.0.8
@@ -6376,7 +6382,7 @@ snapshots:
'@standard-schema/spec@1.1.0': {} '@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: dependencies:
postcss-selector-parser: 6.0.10 postcss-selector-parser: 6.0.10
tailwindcss: 3.4.19(yaml@2.9.0) tailwindcss: 3.4.19(yaml@2.9.0)
@@ -10379,6 +10385,12 @@ snapshots:
vue-devtools-stub@0.1.0: {} 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)): vue-router@4.6.4(vue@3.5.35(typescript@5.8.2)):
dependencies: dependencies:
'@vue/devtools-api': 6.6.4 '@vue/devtools-api': 6.6.4
+5
View File
@@ -26,10 +26,15 @@ mkdirSync(imgDir, { recursive: true })
const content = `--- const content = `---
title: ${name} title: ${name}
published: ${date} published: ${date}
updated:
description: description:
image: /posts/${name}/img/cover.jpg image: /posts/${name}/img/cover.jpg
tags: [] tags: []
draft: false draft: false
pinned: false
series:
seriesOrder:
difficulty:
--- ---
在这里写文章内容... 在这里写文章内容...
+171
View File
@@ -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<string, Map<string, BlogPost[]>>()
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<string, BlogPost[]>()
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
}