feat(blog): 英文标签、中文分类与列表阅读时间;Plume RAG 规则与提交脚本

This commit is contained in:
祀梦
2026-03-29 00:28:45 +08:00
parent 05349b2628
commit 8da5cf2593
18 changed files with 578 additions and 34 deletions

View File

@@ -1,5 +1,6 @@
import { defineClientConfig } from 'vuepress/client'
import RImg from './theme/components/RImg.vue'
import './theme/styles/custom.css'
export default defineClientConfig({
enhance({ app }) {

View File

@@ -3,12 +3,23 @@ import { plumeTheme } from 'vuepress-theme-plume'
import { viteBundler } from '@vuepress/bundler-vite'
import { commentPlugin } from '@vuepress/plugin-comment'
import { umamiAnalyticsPlugin } from '@vuepress/plugin-umami-analytics'
import { enrichBlogReadingTimePlugin } from './plugins/enrichBlogReadingTime'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
/** 博客目录名 → 中文分类展示Plume 从路径自动生成分类,此处仅改展示名) */
const BLOG_CATEGORY_LABEL_ZH: Record<string, string> = {
blog: '博客',
competition: '竞赛',
technology: '技术',
website: '网站',
elysia: '爱莉希雅',
collect: '合集',
}
export default defineUserConfig({
base: '/',
lang: 'zh-CN',
@@ -23,6 +34,7 @@ export default defineUserConfig({
viteOptions: {
resolve: {
alias: {
'@theme/Blog/VPPostItem.vue': path.resolve(__dirname, './theme/Blog/VPPostItem.vue'),
},
},
},
@@ -62,7 +74,12 @@ export default defineUserConfig({
layout: 'left',
// width: 300,
compact: true
}
},
categoriesTransform: (categories) =>
categories.map((c) => ({
...c,
name: BLOG_CATEGORY_LABEL_ZH[c.name] ?? c.name,
})),
},
/**
@@ -106,5 +123,6 @@ export default defineUserConfig({
domains: ['www.simengweb.com'],
cache: true
}),
enrichBlogReadingTimePlugin(),
],
})

View File

@@ -0,0 +1,61 @@
import fs from 'node:fs'
import { pathToFileURL } from 'node:url'
import type { App, PluginFunction } from 'vuepress'
const HMR_TAIL = `if (import.meta.webpackHot) {
import.meta.webpackHot.accept()
if (__VUE_HMR_RUNTIME__.updateBlogPostData) {
__VUE_HMR_RUNTIME__.updateBlogPostData(blogPostData)
}
}
if (import.meta.hot) {
import.meta.hot.accept(({ blogPostData }) => {
__VUE_HMR_RUNTIME__.updateBlogPostData(blogPostData)
})
}
`
async function enrichBlogData(app: App): Promise<void> {
const tempFile = app.dir.temp('internal/blogData.js')
if (!fs.existsSync(tempFile)) return
let blogPostData: Record<string, unknown>[]
try {
const url = `${pathToFileURL(tempFile).href}?t=${Date.now()}`
const mod = await import(url) as { blogPostData: Record<string, unknown>[] }
blogPostData = mod.blogPostData.map(p => ({ ...p }))
}
catch {
return
}
const byPath = new Map<string, { minutes: number; words: number }>()
for (const page of app.pages) {
const rt = page.data.readingTime as { minutes: number; words: number } | undefined
if (rt && typeof rt.words === 'number') byPath.set(page.path, rt)
}
for (const post of blogPostData) {
const rt = byPath.get(post.path as string)
if (rt) post.readingTime = rt
}
await app.writeTemp(
'internal/blogData.js',
`export const blogPostData = ${JSON.stringify(blogPostData)}\n\n${HMR_TAIL}\n`,
)
}
/**
* 在主题写入 blogData 之后,把各页的 readingTime字数、分钟并入博客列表数据
* 供自定义 VPPostItem 在 /blog/ 列表展示。
*/
export function enrichBlogReadingTimePlugin(): PluginFunction {
return () => ({
name: 'enrich-blog-reading-time',
onPrepared: async (app: App) => {
await enrichBlogData(app)
},
})
}

View File

@@ -0,0 +1,416 @@
<script lang="ts" setup>
import type { ReadingTime } from '@vuepress/plugin-reading-time/client'
import { getReadingTimeLocale, useReadingTimeLocaleConfig } from '@vuepress/plugin-reading-time/client'
import type { BlogPostCoverStyle, ThemeBlogPostItem } from 'vuepress-theme-plume/shared'
import VPLink from '@theme/VPLink.vue'
import { isMobile as _isMobile } from '@vuepress/helper/client'
import { computed, onMounted, ref } from 'vue'
import { withBase } from 'vuepress/client'
import { useData, useInternalLink, useTagColors } from 'vuepress-theme-plume/composables'
type PostItem = ThemeBlogPostItem & { readingTime?: ReadingTime }
const props = defineProps<{
post: PostItem
index: number
}>()
const isMobile = ref(false)
onMounted(() => {
isMobile.value = _isMobile(navigator.userAgent)
window.addEventListener('resize', () => {
isMobile.value = _isMobile(navigator.userAgent)
})
})
const { blog } = useData()
const colors = useTagColors()
const { categories: categoriesLink, tags: tagsLink } = useInternalLink()
const readingLocale = useReadingTimeLocaleConfig()
const createTime = computed(() => props.post.createTime?.split(/\s|T/)[0].replace(/\//g, '-'))
const categoryList = computed(() => props.post.categoryList ?? [])
const readingTimeText = computed(() => {
const rt = props.post.readingTime
const loc = readingLocale.value
if (!rt || !loc) return null
return getReadingTimeLocale(rt, loc)
})
const sticky = computed(() => {
if (typeof props.post.sticky === 'boolean') {
return props.post.sticky
}
else if (typeof props.post.sticky === 'number') {
return props.post.sticky >= 0
}
return false
})
const tags = computed(() => {
const tagTheme = blog.value.tagsTheme ?? 'colored'
return (props.post.tags ?? [])
.slice(0, 4)
.map(tag => ({
name: tag,
className: colors.value[tag] ? `vp-tag-${colors.value[tag]}` : `tag-${tagTheme}`,
}))
})
const cover = computed<BlogPostCoverStyle | null>(() => {
if (!props.post.cover)
return null
const opt = blog.value.postCover ?? 'right'
const options = typeof opt === 'string' ? { layout: opt } : opt
return { layout: 'right', ratio: '4:3', ...options, ...props.post.coverStyle }
})
const coverLayout = computed(() => {
if (isMobile.value)
return 'top'
const layout = cover.value?.layout ?? 'right'
const odd = (props.index + 1) % 2 === 1
if (layout === 'odd-left')
return odd ? 'left' : 'right'
if (layout === 'odd-right')
return odd ? 'right' : 'left'
return layout
})
const coverCompact = computed(() => {
if (props.post.excerpt || coverLayout.value === 'top')
return false
return cover.value?.compact ?? false
})
const coverStyles = computed(() => {
if (!cover.value)
return null
let ratio: number
if (typeof cover.value.ratio === 'number') {
ratio = cover.value.ratio
}
else {
const [w, h] = cover.value.ratio!.split(/[:/]/).map(Number)
ratio = h / w
}
if (coverLayout.value === 'left' || coverLayout.value === 'right') {
const w = cover.value.width ?? 240
return { width: `${w}px`, height: `${w * ratio}px` }
}
return { height: 0, paddingBottom: `${ratio * 100}%` }
})
</script>
<template>
<div
class="vp-blog-post-item" data-allow-mismatch
:class="{ 'has-cover': post.cover, [coverLayout]: cover, 'draft': post.draft }"
>
<div
v-if="post.cover" class="post-cover" data-allow-mismatch
:class="{ compact: coverCompact }" :style="coverStyles"
>
<img :src="withBase(post.cover)" :alt="post.title" loading="lazy">
</div>
<div class="blog-post-item-content">
<h3>
<span v-if="sticky" class="sticky">TOP</span>
<span v-if="post.draft" class="draft">DRAFT</span>
<span v-if="post.encrypt" class="icon-lock vpi-lock" />
<VPLink :href="post.path" :text="post.title" />
</h3>
<div class="post-meta">
<div v-if="categoryList.length" class="category-list">
<span class="icon vpi-folder" />
<template v-for="(cate, i) in categoryList" :key="i">
<VPLink :href="categoriesLink ? `${categoriesLink.link}?id=${cate.id}` : undefined">
{{ cate.name }}
</VPLink>
<span v-if="i !== categoryList.length - 1">/</span>
</template>
</div>
<div v-if="tags.length" class="tag-list">
<span class="icon vpi-tag" />
<template v-for="tag in tags" :key="tag.name">
<VPLink
class="tag"
:class="tag.className"
:href="tagsLink ? `${tagsLink.link}?tag=${tag.name}` : undefined"
>
{{ tag.name }}
</VPLink>
</template>
</div>
<div v-if="readingTimeText" class="reading-time">
<span class="icon vpi-books" />
<span>{{ readingTimeText.words }}</span>
<span>{{ readingTimeText.time }}</span>
</div>
<div v-if="createTime" class="create-time">
<span class="icon vpi-clock" />
<span>{{ createTime }}</span>
</div>
</div>
<div v-if="post.excerpt" class="vp-doc excerpt" v-html="post.excerpt" />
</div>
</div>
</template>
<style scoped>
.vp-blog-post-item {
padding: 16px;
margin: 0 -16px;
background-color: var(--vp-c-bg);
transition: background-color var(--vp-t-color);
}
.vp-blog-post-item.draft {
background-color: var(--vp-c-warning-soft);
}
.vp-blog-post-item.has-cover:where(.left, .right) {
display: flex;
gap: 20px;
}
@media (max-width: 419px) {
.vp-blog-post-item.has-cover:where(.left, .right) {
display: block;
gap: unset;
}
}
.vp-blog-post-item.has-cover.right {
flex-direction: row-reverse;
}
.post-cover {
position: relative;
align-self: center;
overflow: hidden;
border-radius: 8px;
}
.vp-blog-post-item.has-cover.left .post-cover.compact {
margin: -24px 0 -24px -20px;
}
.vp-blog-post-item.has-cover.right .post-cover.compact {
margin: -24px -20px -24px 0;
}
.vp-blog-post-item.has-cover.top .post-cover {
margin: -16px -16px 16px;
border-radius: 0;
}
@media (min-width: 419px) {
.vp-blog-post-item.has-cover.top .post-cover {
width: calc(100% + 40px);
margin: -24px -20px 24px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
}
.post-cover img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s;
transform: scale(1);
}
.vp-blog-post-item.has-cover:hover .post-cover img {
transform: scale(1.02);
}
.vp-blog-post-item.has-cover.left .post-cover.compact {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.vp-blog-post-item.has-cover.right .post-cover.compact {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.blog-post-item-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.vp-blog-post-item.has-cover .blog-post-item-content {
flex: 1 2;
}
.blog-post-item-content .sticky,
.blog-post-item-content .draft {
display: inline-block;
padding: 3px 6px;
margin-right: 0.5rem;
font-size: 13px;
font-weight: 600;
line-height: 1;
color: var(--vp-c-text-2);
background-color: var(--vp-c-brand-soft);
border-radius: 4px;
transition: var(--vp-t-color);
transition-property: color, background-color;
}
.blog-post-item-content .draft {
color: var(--vp-c-warning-1);
background-color: var(--vp-c-warning-soft);
}
.blog-post-item-content .icon-lock {
width: 1em;
height: 1em;
margin-right: 8px;
margin-left: 3px;
color: var(--vp-c-text-3);
transition: var(--vp-t-color);
transition-property: color;
}
.blog-post-item-content h3 {
display: flex;
align-items: center;
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--vp-c-text-1);
transition: color var(--vp-t-color);
}
.blog-post-item-content h3 a {
color: inherit;
text-decoration: none;
}
.blog-post-item-content h3:hover {
color: var(--vp-c-brand-1);
}
.blog-post-item-content h3:hover .sticky {
color: var(--vp-c-text-2);
}
.blog-post-item-content .excerpt {
margin-top: 12px;
}
@media (min-width: 768px) {
.vp-blog-post-item {
padding: 24px 20px;
margin: 0;
border-radius: 8px;
box-shadow: var(--vp-shadow-1);
transition: var(--vp-t-color);
transition-property: background-color, color, box-shadow;
will-change: transform;
}
.vp-blog-post-item:hover {
box-shadow: var(--vp-shadow-2);
}
.blog-post-item-content .post-meta {
margin-bottom: 0;
}
}
.blog-post-item-content .post-meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
justify-content: flex-start;
font-size: 14px;
font-weight: 400;
color: var(--vp-c-text-2);
transition: color var(--vp-t-color);
}
.blog-post-item-content .post-meta > div {
display: flex;
align-items: center;
justify-content: flex-start;
}
.blog-post-item-content .post-meta .reading-time {
gap: 6px;
}
.blog-post-item-content .post-meta .tag-list {
display: flex;
align-items: center;
}
.blog-post-item-content .post-meta .tag-list .tag {
display: inline-block;
padding: 3px 5px;
margin-right: 6px;
font-size: 12px;
line-height: 1;
color: var(--vp-tag-color);
background-color: var(--vp-tag-bg);
border-radius: 3px;
transition: color var(--vp-t-color), background-color var(--vp-t-color);
}
.blog-post-item-content .post-meta .tag-list .tag:last-of-type {
margin-right: 0;
}
.blog-post-item-content .post-meta .icon {
width: 14px;
height: 14px;
margin-right: 0.3rem;
color: var(--vp-c-text-3);
transition: color var(--vp-t-color);
}
.blog-post-item-content .post-meta a {
font-weight: normal;
color: inherit;
text-decoration: none;
}
.excerpt.vp-doc :deep(p) {
margin: 0.5rem 0;
}
.excerpt.vp-doc :deep(p:first-of-type) {
margin-top: 0;
}
.excerpt.vp-doc :deep(p:last-of-type) {
margin-bottom: 0;
}
.excerpt.vp-doc :deep(p strong) {
color: var(--vp-c-text-2);
transition: color var(--vp-t-color);
}
.excerpt.vp-doc :deep(div[class*="language-"]) {
margin: 16px -16px;
}
@media (min-width: 496px) {
.excerpt.vp-doc :deep(div[class*="language-"]) {
margin: 16px 0;
}
}
</style>

View File

@@ -1,3 +1,31 @@
/**
* 博客:分类(中文展示名)与标签(英文)之间留出间距;分类路径本身前后略松一些
*/
.vp-blog-post-item .post-meta .category-list {
padding: 0.3em 0.65em;
margin-right: 0.75rem;
margin-left: 0.15rem;
border-radius: 4px;
}
.vp-blog-post-item .post-meta .category-list .icon {
margin-right: 0.45rem;
}
.vp-blog-post-item .post-meta .category-list a + span {
margin-inline: 0.2em;
}
/* 文章页面包屑:各级目录名前后略留白 */
.vp-breadcrumb ol {
gap: 6px 10px;
}
.vp-breadcrumb ol li .breadcrumb:not(.current) {
padding: 0.15em 0.5em;
border-radius: 4px;
}
:root {
/** 主题颜色 */