feat: 优化首页个人名片与内容入口
This commit is contained in:
@@ -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 默认图片组件,支持相对路径解析
|
||||||
|
|
||||||
|
|||||||
@@ -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` 渲染,技术栈图标、颜色、动画节奏在该组件内维护。
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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' },
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: 进阶
|
||||||
---
|
---
|
||||||
|
|
||||||
## 解决问题
|
## 解决问题
|
||||||
|
|||||||
@@ -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: 入门
|
||||||
---
|
---
|
||||||
|
|
||||||
## 前言
|
## 前言
|
||||||
|
|||||||
@@ -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: 进阶
|
||||||
---
|
---
|
||||||
|
|
||||||
## 背景
|
## 背景
|
||||||
|
|||||||
@@ -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 框架集成,搭建一个带鉴权的 MCP(Model Context Protocol)服务器,让 AI Agent 能直接调用你的后端服务。
|
description: 介绍如何使用 mark3labs/mcp-go 与 Gin 框架集成,搭建一个带鉴权的 MCP(Model 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
|
||||||
|
|||||||
@@ -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: 进阶
|
||||||
---
|
---
|
||||||
|
|
||||||
## 问题描述
|
## 问题描述
|
||||||
|
|||||||
@@ -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。
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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>
|
||||||
+194
-91
@@ -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 })
|
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)
|
||||||
|
|
||||||
setTimeout(() => observer.disconnect(), 10000)
|
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
@@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatDate(date: string) {
|
const { data: seriesSourcePosts } = await useAsyncData(`series-source-${slug}`, () =>
|
||||||
return new Date(date).toLocaleDateString('zh-CN', {
|
queryCollection('posts')
|
||||||
year: 'numeric',
|
.order('published', 'ASC')
|
||||||
month: 'long',
|
.all()
|
||||||
day: 'numeric',
|
)
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return formatPostDate(date)
|
||||||
}
|
}
|
||||||
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
+21
-9
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user