vitepress博客模板搭建

vitepress博客搭建

个人博客技术栈更新,快速搭建一个vitepress自定义博客

建议去博客查看文章,观感更佳。原文地址

模板仓库:
vitepress-blog-template

前言

服务器过期快一年了,博客也快一年没更新了,最近重新搭建了一下博客,记录一下搭建过程。

以前的博客是使用vuepress搭建的,这次换成了vitepress,vitepress是vuepress的下一代,使用vite构建,性能更好,体验更好

缺点:vitepress的插件生态还没有vuepress那么丰富,很多功能需要自己实现

优点:vitepress可配置项、api都比较多,大部分功能都能实现

相关链接:
vuepress博客
vuepress仓库地址
vitepress博客
vitepress仓库地址

旧版本预览:

首页 文章 标签

博客介绍

  • 自定义首页
  • 网站加载页
  • 全文搜索
  • 全文图片放大
  • 网站访问量统计
  • GitHub评论系统
  • 自动配置侧边栏
  • 自动打包部署GitHub Pages
  • 自动统计文章字数/阅读时间/最近更新时间
  • 未完待续...

1. 安装

vitepress官方文档

Node.js 18 及以上版本,推荐使用pnpm安装

sh 复制代码
# 创建项目并安装 VitePress 依赖
mkdir blog
cd blog
pnpm init
pnpm add -D vitepress

# 使用 VitePress CLI 初始化目录结构
pnpm vitepress init
sh 复制代码
┌ Welcome to VitePress!
│
◇ Where should VitePress initialize the config?
│ ./docs
│
◇ Site title:
│ My Awesome Project
│
◇ Site description:
│ A VitePress Site
│
◇ Theme:
│ ● Default Theme (Out of the box, good-looking docs)
│ ○ Default Theme + Customization
│ ○ Custom Theme
│
◇ Use TypeScript for config and theme files?
│  Yes
│
◆ Add VitePress npm scripts to package.json?
│  Yes
└

2. 运行

sh 复制代码
pnpm docs:dev

3. 结构

官方文档:vitepress目录结构

需要手动新建文件夹,我的目录结构如下:

.
├─ .github                # 配置GitHub Actions
├─ docs
│  ├─ .vitepress
│  │  ├─ components       # 自定义组件
│  │  ├─ plugins          # 自定义插件
│  │  ├─ theme            # 主题配置
│  │  ├─ utils            # 工具函数
│  │  └─ config.mts       # 配置文件
│  ├─ 2024
│  │  └─ xx.md            # 文章
│  ├─ img                 # 文章图片
│  ├─ pages               # 自定义页面
│  ├─ public              # 静态资源
│  └─ index.md            # 首页
└─ package.json

4. 导航栏

配置文件:/docs/.vitepress/config.mts

4.1 标题

官方文档:vitepress站点标题和图标

ts{2} 复制代码
export default defineConfig({
  title:'山不让尘,川不辞盈',
  // ...
})

4.2 搜索

官方文档:vitepress搜索

有多种方式可以实现,我采用的是 vitepress-plugin-pagefind 插件

该插件支持i18n,具体配置请查看文档

sh 复制代码
pnpm add vitepress-plugin-pagefind pagefind
ts{1,7-18} 复制代码
import { pagefindPlugin } from 'vitepress-plugin-pagefind'

export default defineConfig({
  title:'山不让尘,川不辞盈',
  vite:{
    plugins:[
     pagefindPlugin({
        btnPlaceholder: '搜索',
        placeholder: '搜索文档',
        emptyText: '空空如也',
        heading: '共: {{searchResult}} 条结果',
        customSearchQuery(input) {
          return input
            .replace(/[\u4E00-\u9FA5]/g, ' $& ')
            .replace(/\s+/g, ' ')
            .trim()
        },
      }),
    ]
  }
  // ...
})

4.3 导航链接

官方文档:vitepress导航链接

配置中的link是md文件的地址,比如:/pages/about 对应 docs/pages/about.md

ts{4-19} 复制代码
export default defineConfig({
  title:'山不让尘,川不辞盈',
  themeConfig:{
   nav: [
      { text: '主页', link: '/' },
      { text: '闲聊', link: '/pages/comment' },
      { text: '关于', link: '/pages/about' },
      {
        text: '推荐',
        items: [
          {
            items: [
              { text: '实用网页', link: '/pages/webPage' },
              { text: '工具插件', link: '/pages/tools' },
            ],
          },
        ],
      },
    ],
  }
  // ...
})

4.4 社交链接

官方文档:vitepress社交链接

ts{3-15} 复制代码
export default defineConfig({
  themeConfig: {
    socialLinks: [
      { icon: 'github', link: 'https://github.com/vuejs/vitepress' },
      { icon: 'twitter', link: '...' },
      // 可以通过将 SVG 作为字符串传递来添加自定义图标:
      {
        icon: {
          svg: '<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Dribbble</title><path d="M12...6.38z"/></svg>',
        },
        link: '...',
        // 也可以为无障碍添加一个自定义标签 (可选但推荐):
        ariaLabel: 'cool link',
      },
    ],
  },
})

4.5 效果

配置完后效果图:

5. 布局框架

5.1 Naive UI

Naive UI 文档

(1)安装

图标库:xicons material

时间库:dayjs

sh 复制代码
pnpm add -D @css-render/vue3-ssr naive-ui @vicons/material dayjs
(2)配置

新建 .vitepress/theme/index.ts 文件

ts 复制代码
import { defineComponent, h, inject } from 'vue'
import DefaultTheme from 'vitepress/theme'
import { NConfigProvider } from 'naive-ui'
import { setup } from '@css-render/vue3-ssr'
import { useRoute } from 'vitepress'

const { Layout } = DefaultTheme

const CssRenderStyle = defineComponent({
  setup() {
    const collect = inject<() => string>('css-render-collect')
    return {
      style: collect ? collect() : '',
    }
  },
  render() {
    return h('css-render-style', {
      innerHTML: this.style,
    })
  },
})

const VitepressPath = defineComponent({
  setup() {
    const route = useRoute()
    return () => {
      return h('vitepress-path', null, [route.path])
    }
  },
})

const NaiveUIProvider = defineComponent({
  render() {
    return h(
      NConfigProvider,
      { abstract: true, inlineThemeDisabled: true },
      {
        default: () => [
          h(Layout, null, { default: this.$slots.default?.() }),
          import.meta.env.SSR ? [h(CssRenderStyle), h(VitepressPath)] : null,
        ],
      }
    )
  },
})

export default {
  extends: DefaultTheme,
  Layout: NaiveUIProvider,
  enhanceApp: ({ app }) => {
    if (import.meta.env.SSR) {
      const { collect } = setup(app)
      app.provide('css-render-collect', collect)
    }
  },
}

.vitepress/config.mts 文件

ts{3,8-10,12-30} 复制代码
import { defineConfig } from 'vitepress'

const fileAndStyles: Record<string, string> = {}

export default defineConfig({
  // ...
  vite: {
    ssr: {
      noExternal: ['naive-ui', 'date-fns', 'vueuc'],
    },
  },
  postRender(context) {
    const styleRegex = /<css-render-style>((.|\s)+)<\/css-render-style>/
    const vitepressPathRegex = /<vitepress-path>(.+)<\/vitepress-path>/
    const style = styleRegex.exec(context.content)?.[1]
    const vitepressPath = vitepressPathRegex.exec(context.content)?.[1]
    if (vitepressPath && style) {
      fileAndStyles[vitepressPath] = style
    }
    context.content = context.content.replace(styleRegex, '')
    context.content = context.content.replace(vitepressPathRegex, '')
  },
  transformHtml(code, id) {
    const html = id.split('/').pop()
    if (!html) return
    const style = fileAndStyles[`/${html}`]
    if (style) {
      return code.replace(/<\/head>/, `${style}</head>`)
    }
  },
  // ...
})
(3)解决ts报错

安装vite

sh 复制代码
pnpm add -D vite vue

根目录下新建 type.d.ts 文件

ts 复制代码
/// <reference types="vite/client" />

interface ImportMetaEnv {}

interface ImportMeta {
  readonly env: ImportMetaEnv
}
(5)测试

docs/index.md 文件中测试

js 复制代码
<script setup>
import { NButton } from 'naive-ui'
</script>

<NButton>Hello World</NButton>

按钮正常出现 ,则配置完成

5.2 Sass

此项为选配,按需安装

(1)安装
sh 复制代码
pnpm add -D sass
(2)忽视告警

安装sass会出现此告警,目前没有发现什么问题

Deprecation Warning: The legacy JS API is deprecated and will be removed in Dart Sass 2.0.0

.vitepress/config.mts

ts{5-13} 复制代码
import { defineConfig } from 'vite'

export default defineConfig({
  // ...
  vite:{
    css: {
     preprocessorOptions: {
       scss: {
          api: 'modern-compiler', // or 'modern'
        },
      },
    },
  }
  // ...
})

6. 首页

默认首页是docs/index.md

6.1 自定义组件

由于想自己写首页样式,所以仅保留 layout: home

新建 docs/.vitepress/components/ArticleList.vue 文件

vue 复制代码
<script setup lang="ts"></script>

<template>
  <div class="artical-list"></div>
</template>

<style scoped lang="scss">
.artical-list {
  width: 100%;
  height: 100%;
  color: var(--black-color-1);
}
</style>

修改 docs/index.md 文件

md 复制代码
---
layout: home
---

<script setup>
import ArticleList from './.vitepress/components/ArticleList.vue'
</script>

<ArticleList />

此时首页会白屏,因为自定义组件没有内容

6.2 设置文章信息

首先得了解 vitepress frontmatter . 文章可以在顶部自定义信息,如标题、描述、作者、标签、时间等,自定义信息后,可使用各种api访问

  • 在 docs文件夹下新建 2023、2024 、pages 文件夹,移入示例文章

    此项目的是以文件夹的名称按年份排序,个人习惯,可根据个人需求调整

  • frontmatter 可自定义key,我的配置如下(每个文章都需要配置):

    md 复制代码
    ---
    title: vitepress博客搭建
    date: 2024-11-12
    info: 个人博客技术栈更新,快速搭建一个vitepress自定义博客
    tags:
      - vitepress
      - vuepress
    ---
  • 示例md

    next/pre 指的是文章底部的下一篇/上一篇

    sidebar 指的是左侧文章列表

    about.md 文件

    md 复制代码
    ---
    title: 关于我
    date: 2024-11-12
    prev: false
    next: false
    ---
    
    # 关于我
    
    这里是关于我页面

    webPage.md 文件

    md 复制代码
    ---
    title: 实用网页
    date: 2024-11-12
    prev: false
    next: false
    sidebar: false
    ---
    
    # 实用网页
    
    这里是实用网页页面

6.3 首页获取文章列表

官方文档:vitepress createcontentloader

VitePress 提供了一个 createContentLoader 辅助函数,可通过它获取到匹配的文章列表信息

  • ESM模式 package.json

    json 复制代码
    "type": "module"
  • 新建 .vitepress/utils/posts.data.ts 文件

    ts 复制代码
    import { createContentLoader } from 'vitepress'
    
    export default createContentLoader('../docs/*/*.md' /* options */)
  • 在主页组件中引入并打印

    .vitepress/components/ArticleList.vue 此处用了类型忽略

    ts 复制代码
    // @ts-ignore
    import { data as posts } from '../utils/posts.data'
    console.log(posts)

6.4 时间线展示文章

此处可以自行设计,我使用的是时间线展示。

  • 首先,需要定义一些暗色和亮色的公共样式

    官方文档:vitepress 自定义css

    新建 .vitepress/theme/styles/global.css & .vitepress/theme/styles/rewrite.css 文件

    此处是区分重写样式和个人自定义的样式,可根据个人需求调整

    global.css 文件

    css 复制代码
    @import './rewrite.css';
    
    :root {
      --border-color-1: rgb(242, 243, 245);
      --black-color-1: rgb(60, 60, 67);
      --grey-color-1: rgb(134, 144, 156);
      --grey-color-2: rgb(229, 230, 235);
      --blue-color-1: rgb(22, 93, 255);
      --border-radius: 8px;
      img {
        display: block;
        margin: auto;
        cursor: pointer;
      }
      .vp-doc {
        h2:first-of-type {
          margin: 10px 0 16px;
          padding-top: 15px;
        }
      }
      .VPHome {
        margin-bottom: 23px;
      }
    }
    
    .dark {
      --border-color-1: rgba(255, 255, 255, 0.08);
      --black-color-1: rgba(255, 255, 255, 0.9);
      --grey-color-1: rgba(255, 255, 255, 0.5);
      --grey-color-2: rgb(72, 72, 73);
      --blue-color-1: rgb(60, 126, 255);
    }

    rewrite.css 文件

    css 复制代码
    .VPNavBar.home.top {
    border-bottom: 1px solid #f2f3f5;
    }
    
    .dark {
    .VPNavBar.home.top {
      border-bottom: 1px solid #000;
    }
    }
    
    :root {
    .vp-doc .custom-block {
      padding: 8px 16px;
    }
    
    .vp-doc .custom-block :first-child:first-child {
      margin: 8px 0;
    }
    
    .VPMenuGroup > .title {
      font-size: 0.7em;
    }
    
    /* 链接 */
    
    .vp-doc a {
      background: linear-gradient(var(--vp-c-brand-soft), var(--vp-c-brand-soft)) no-repeat center
        bottom / 100% 2px;
      text-decoration: none;
      transition: 0.2s;
    }
    
    .vp-doc a:hover {
      border-radius: 0.2em;
      background: linear-gradient(var(--vp-c-brand-soft), var(--vp-c-brand-soft)) no-repeat center
        bottom / 100% 100%;
    }
    
    .vp-doc strong {
      background: linear-gradient(var(--vp-c-brand-soft), var(--vp-c-brand-soft)) no-repeat center
        bottom / 100% 40%;
    }
    
    .vp-doc s {
      opacity: 0.6;
    }
    
    /* 文章目录hover */
    .VPDocOutlineItem.root > li > a,
    .VPDocOutlineItem.nested > li > a {
      padding-left: 5px;
      padding-right: 5px;
      border-radius: 5px;
    }
    .VPDocOutlineItem.root > li > a:first-child:hover,
    .VPDocOutlineItem.root > li > a:first-child.active,
    .VPDocOutlineItem.nested li > a:hover,
    .VPDocOutlineItem.nested li > a.active {
      background-color: var(--grayA3);
    }
    }
  • 导入样式

    官方文档:vitepress 自定义主题

    新建 .vitepress/theme/index.ts 文件

    ts 复制代码
    import './styles/global.css'
    // ...
  • 主页设计

    不多解释,放置一个头像 docs/public/assets/avatar.jpg 即可。

    注意:此处深色区域,过滤了 /pages/ 路径下的文章,因为该路径下的文件是作为独立页面展示的,参考博客中的关于我/闲聊

    ts 复制代码
    <script setup lang="ts">
    import { NTimeline, NTimelineItem, NIcon, NBackTop, NTag } from 'naive-ui'
    import { useRouter } from 'vitepress'
    import dayjs from 'dayjs'
    import { EmailOutlined, DiscountOutlined } from '@vicons/material'
    // @ts-ignore
    import { data as posts } from '../utils/posts.data'
    const router = useRouter()
    const list = posts
      .filter((item) => !item.url.includes('/pages/'))
      .map((item) => ({
        ...item,
        unixDate: dayjs(item.frontmatter.date).unix(),
      }))
      .sort((a, b) => b.unixDate - a.unixDate)
      .map((item) => {
        const { unixDate, ...rest } = item
        return rest
      })
    const jump = (path: string) => {
      router.go(path)
    }
    </script>
    
    <template>
      <div class="artical-list">
        <section class="left-wrapper">
          <img class="avatar" src="/assets/avatar.jpg" alt="avatar" />
          <p class="name">holden</p>
          <p class="text">快不快乐有天总过去</p>
          <div class="email">
            <NIcon :size="23">
              <EmailOutlined />
            </NIcon>
            holden.lee@aliyun.com
          </div>
        </section>
        <section class="right-wrapper">
          <n-timeline size="large">
            <n-timeline-item v-for="item in list">
              <template #icon>
                <div class="icon">
                  <p>{{ dayjs(item.frontmatter.date).format('YYYY-MM-DD') }}</p>
                  <div class="dot"></div>
                </div>
              </template>
              <template #default>
                <div class="card" @click="jump(item.url)">
                  <div class="title">{{ item.frontmatter.title }}</div>
                  <div class="tags">
                    <n-tag :bordered="false" type="info" v-for="tagItem in item.frontmatter.tags">
                      {{ tagItem }}
                      <template #icon>
                        <n-icon :size="16" :component="DiscountOutlined" />
                      </template>
                    </n-tag>
                  </div>
                  <div class="info">{{ item.frontmatter.info ?? '无简介' }}</div>
                  <div class="date">{{ dayjs(item.frontmatter.date).format('YYYY-MM-DD') }}</div>
                </div>
              </template>
            </n-timeline-item>
          </n-timeline>
        </section>
        <n-back-top :right="10" />
      </div>
    </template>
    
    <style scoped lang="scss">
    .artical-list {
      width: 100%;
      height: 100%;
      color: var(--black-color-1);
      display: flex;
    
      .left-wrapper {
        position: sticky;
        top: 92px;
        margin-top: 3vh;
        border: 1px solid var(--border-color-1);
        width: 250px;
        height: 300px;
        display: flex;
        flex-direction: column;
        align-items: center;
        border-radius: var(--border-radius);
    
        p {
          margin: 0;
        }
    
        .avatar {
          width: 100px;
          border-radius: 100%;
          user-select: none;
          cursor: auto;
          margin: 40px 0 0 0;
        }
    
        .name {
          font-size: 20px;
          margin: 10px 0;
        }
    
        .text {
          font-size: 14px;
          color: var(--grey-color-1);
          user-select: none;
        }
    
        .email {
          width: 100%;
          height: 25px;
          display: flex;
          align-items: center;
          justify-content: center;
          cursor: pointer;
          margin-top: 10px;
        }
      }
    
      .right-wrapper {
        margin-left: 150px;
        margin-top: 3vh;
        width: calc(100% - 250px - 150px);
        min-width: 300px;
    
        :deep(.n-timeline-item-timeline__line) {
          background-color: var(--grey-color-2);
        }
    
        .card {
          cursor: pointer;
        }
    
        .icon {
          width: 6px;
          height: 6px;
          position: relative;
    
          p {
            position: absolute;
            margin: 0;
            width: 130px;
            left: -140px;
            top: -2px;
            font-size: 12px;
            line-height: 12px;
            height: 12px;
            text-align: right;
          }
    
          .dot {
            width: 100%;
            height: 100%;
            border-radius: 100%;
            background-color: var(--blue-color-1);
          }
        }
    
        .card {
          width: 100%;
          min-height: 120px;
          color: var(--black-color-1);
          border: 1px solid var(--border-color-1);
          border-radius: var(--border-radius);
          padding: 15px;
          display: flex;
          flex-direction: column;
          justify-content: space-around;
    
          .title {
            font-size: 20px;
            font-weight: 700;
            cursor: pointer;
          }
    
          .tags {
            width: 100%;
            display: flex;
            flex-wrap: wrap;
    
            .n-tag {
              margin-right: 10px;
            }
          }
    
          .info,
          .date {
            font-size: 14px;
            color: var(--grey-color-1);
            margin-top: 5px;
            overflow: hidden;
            white-space: nowrap;
            text-overflow: ellipsis;
          }
    
          .date {
            display: none;
          }
        }
      }
    }
    
    @media (max-width: 730px) {
      .artical-list {
        flex-direction: column;
    
        .left-wrapper {
          min-width: 300px;
          width: 100%;
          position: static;
        }
    
        .right-wrapper {
          margin-left: 0;
          width: 100%;
    
          .icon {
            p {
              display: none;
            }
          }
        }
    
        .card {
          .date {
            display: block !important;
          }
    
          .info {
            display: none !important;
          }
        }
      }
    }
    </style>
  • 效果图

7. 文章侧边栏

官方文档:vitepress 侧边栏

正常情况下,需要手动配置侧边栏

ts 复制代码
export default {
  themeConfig: {
    sidebar: [
      {
        text: 'Guide',
        items: [
          { text: 'Introduction', link: '/introduction' },
          { text: 'Getting Started', link: '/getting-started' },
          ...
        ]
      }
    ]
  }
}

7.1 使用插件

插件可以自动生成侧边栏并且根据文章名称日期排序

vitepress-sidebar

sh 复制代码
pnpm add -D vitepress-sidebar

.vitepress/config.mts 文件,具体配置请看官方文档

ts 复制代码
import { generateSidebar } from 'vitepress-sidebar'
// ...

const autoSidebar = () => {
  let result: any = generateSidebar({
    documentRootPath: '/docs',
    collapseDepth: 2,
    useTitleFromFrontmatter: true,
    sortMenusByFrontmatterDate: true,
    sortMenusOrderByDescending: true,
  })
  return result.map((year) => ({
    ...year,
    items: year.items.reverse(),
  }))
}

export default defineConfig({
  // ...
  themeConfig: {
    sidebar: autoSidebar(),
  },
  // ...
})

配置完成后的效果

7.2 优化

侧边栏中,会显示docs文件夹下所有的md,包括了我们需要单独显示的pages目录

如果直接在autoSideBar函数中过滤pages目录下的文件,则无法跳转,因此得从页面下手,css隐藏。(如果不需要隐藏该目录的话,以下步骤忽视)

  • 新建 .vitepress/theme/MyLayout.vue 文件

    ts 复制代码
    <script setup lang="ts">
    import DefaultTheme from 'vitepress/theme'
    import { useRoute } from 'vitepress'
    import { watch, nextTick, onMounted } from 'vue'
    const { Layout } = DefaultTheme
    const route = useRoute()
    
    onMounted(() => {
      hideSpecificSidebarItem()
    })
    
    watch(
      () => route.path,
      (_, oldPath) => {
        if (oldPath === '/') {
          nextTick(() => {
            hideSpecificSidebarItem()
          })
        }
      }
    )
    
    // 隐藏pages
    function hideSpecificSidebarItem() {
      const sidebarItems = document.querySelectorAll(
        '#VPSidebarNav > .group'
      ) as NodeListOf<HTMLElement>
      sidebarItems.forEach((item, index) => {
        const textContent = item.querySelector('.text')?.textContent?.trim()
        if (textContent === 'pages') {
          item.style.display = 'none'
          sidebarItems[index + 1].style.borderTop = 'none'
        }
      })
    }
    </script>
    
    <template>
      <Layout></Layout>
    </template>
    
    <style scoped lang="scss"></style>
  • 修改 .vitepress/theme/index.ts 文件

    ts 复制代码
    import MyLayout from './MyLayout.vue'
    // ...
    
    const NaiveUIProvider = defineComponent({
      render() {
        return h(
          NConfigProvider,
          { abstract: true, inlineThemeDisabled: true },
          {
            default: () => [
              h(MyLayout, null, { default: this.$slots.default?.() }),
              import.meta.env.SSR ? [h(CssRenderStyle), h(VitepressPath)] : null,
            ],
          }
        )
      },
    })
    
    // ...
  • 效果

8. 文章信息统计

  • 新建 .vitepress/utils/getReadingTime.ts 文件

    ts 复制代码
    export function getWords(content: string): RegExpMatchArray | null {
      // 仅匹配英文单词,忽略标点和纯数字
      return content.match(/\b[a-zA-Z]+(?:['-]?[a-zA-Z]+)?\b/gu)
    }
    
    export function getChinese(content: string): RegExpMatchArray | null {
      // 匹配中文字符
      return content.match(/[\u4E00-\u9FD5]/gu)
    }
    
    export function getEnWordCount(content: string): number {
      // 英文单词数量
      return getWords(content)?.length || 0
    }
    
    export function getCnWordCount(content: string): number {
      // 中文字符数量
      return getChinese(content)?.length || 0
    }
    
    export function getWordNumber(content: string): number {
      // 总字数统计
      const enWordCount = getEnWordCount(content)
      const cnWordCount = getCnWordCount(content)
      return enWordCount + cnWordCount
    }
    
    export function getReadingTime(content: string, cnWordPerMinute = 350, enWordPerMinute = 160) {
      const trimmedContent = content.trim()
      const enWord = getEnWordCount(trimmedContent)
      const cnWord = getCnWordCount(trimmedContent)
    
      const totalWords = enWord + cnWord
      const words = totalWords >= 1000 ? `${Math.round(totalWords / 100) / 10}k` : totalWords
    
      const readingTime = cnWord / cnWordPerMinute + enWord / enWordPerMinute
      const readTime = Math.ceil(readingTime)
    
      return {
        readTime,
        words,
      }
    }
  • 新建 .vitepress/plugins/headerPlugin.ts 文件

    ts 复制代码
    import { Plugin } from 'vite'
    import { getReadingTime } from '../utils/getReadingTime'
    import fs from 'fs'
    
    export function HeaderPlugin(): Plugin {
      return {
        name: 'header-plugin',
        enforce: 'pre',
        async transform(code, id) {
          if (!id.match(/\.md\b/)) return null
    
          const cleanContent = cleanMarkdownContent(code)
    
          // 获取文件的最近更新时间
          const lastUpdated = getLastUpdatedTime(id)
    
          // 获取阅读时间和字数
          const { readTime, words } = getReadingTime(cleanContent)
    
          // 插入组件到文章中
          code = insertReadingTimeAndWords(
            `<ArticleHeader readTime="${readTime}" words="${words}" lastUpdated="${lastUpdated}" />`,
            code
          )
          return code
        },
      }
    }
    
    // 获取文件的最近更新时间
    function getLastUpdatedTime(filePath: string): string {
      const stats = fs.statSync(filePath)
      const lastModifiedTime = stats.mtime
      return lastModifiedTime.toLocaleString()
    }
    
    // 插入目标字符串到第一个一级标题后
    function insertReadingTimeAndWords(target: string, source: string) {
      const headerRegex = /(^#\s.+$)/m
      return source.replace(headerRegex, `$1\n\n${target}`)
    }
    
    // 去掉 Frontmatter
    function cleanMarkdownContent(content: string): string {
      return content.replace(/^---[\s\S]+?---\n+/g, '').trim()
    }
  • .vitepress/config.mts 文件

    ts 复制代码
    import { HeaderPlugin } from './plugins/headerPlugin'
    // ...
    export default defineConfig({
      vite: [
        // ...
        HeaderPlugin(),
      ],
    })
  • 新建 .vitepress/components/ArticleHeader.vue 组件

    ts 复制代码
    <script setup lang="ts">
    import {
      AccessTimeFilled,
      ArticleOutlined,
      BorderColorOutlined,
      UpdateOutlined,
      DiscountOutlined,
    } from '@vicons/material'
    import { NIcon, NTag } from 'naive-ui'
    import { useData } from 'vitepress'
    import dayjs from 'dayjs'
    const { frontmatter } = useData()
    defineProps<{
      readTime: string
      words: string
      lastUpdated: string
    }>()
    </script>
    
    <template>
      <div class="header">
        <section class="info">
          <div class="read">
            <NIcon :size="20">
              <AccessTimeFilled />
            </NIcon>
            阅读时间:
            <p>{{ readTime }}</p>
            分钟
          </div>
          <div class="words">
            <NIcon :size="20">
              <ArticleOutlined />
            </NIcon>
            文章字数:
            <p>{{ words }}</p>
            字
          </div>
          <div class="write">
            <NIcon :size="18">
              <BorderColorOutlined />
            </NIcon>
            发布日期:
            <p>{{ dayjs(frontmatter.date).format('YYYY-MM-DD') }}</p>
          </div>
          <div class="update">
            <NIcon :size="20">
              <UpdateOutlined />
            </NIcon>
            最近更新:
            <p>{{ dayjs(lastUpdated).format('YYYY-MM-DD') }}</p>
          </div>
        </section>
        <section class="tags">
          <n-tag :bordered="false" type="info" v-for="item in frontmatter.tags">
            {{ item }}
            <template #icon>
              <n-icon :size="16" :component="DiscountOutlined" />
            </template>
          </n-tag>
        </section>
      </div>
    </template>
    
    <style scoped lang="scss">
    .header {
      width: 100%;
    
      .info {
        width: 100%;
        display: flex;
        margin-top: 5px;
        margin-bottom: 5px;
        flex-wrap: wrap;
        font-size: 14px;
        color: var(--grey-color-1);
    
        .read,
        .words,
        .write,
        .update {
          display: flex;
          align-items: center;
          justify-content: center;
          margin-right: 8px;
    
          p {
            margin: 0 5px;
          }
    
          i {
            margin-right: 2px;
          }
        }
      }
    
      .tags {
        width: 100%;
        display: flex;
        flex-wrap: wrap;
    
        .n-tag {
          margin-right: 10px;
          margin-bottom: 10px;
        }
      }
    }
    </style>
  • 配置全局组件

    .vitepress/theme/index.ts

    ts 复制代码
    import ArticleHeader from '../components/ArticleHeader.vue'
    // ...
    export default {
      extends: DefaultTheme,
      Layout: NaiveUIProvider,
      enhanceApp: ({ app }) => {
        import ArticleHeader from '../components/ArticleHeader.vue'
        if (import.meta.env.SSR) {
          const { collect } = setup(app)
          app.provide('css-render-collect', collect)
        }
      },
    }
  • 效果

9. 评论插件

我使用的是:@giscus/vue,无跟踪,无广告,永久免费,github邮箱通知,支持暗色切换。

9.1 安装配置

  • 新建一个 公开 仓库,打开仓库 Settings,勾选 Discussions,开启评论区

    私有仓库的话访客无法查看讨论

  • GitHub 安装 giscus

    点击此处安装

  • giscus 配置

    安装完毕后,点击 Configure 配置 giscus,选中刚刚创建的仓库,点击保存

  • 项目中安装

    sh 复制代码
    pnpm add -D @giscus/vue

9.2 获取设置

  • 去官方文档获取设置

    点击这里去获取

  • 填写自己的仓库信息

  • 滚到到下边,获取设置

9.3 使用

利用默认布局组件 Layout 的 doc-after 插槽将 giscus 组件放入页面中

官方文档:vitepress 布局插槽

.vitepress/theme/MyLayout.vue 文件

ts 复制代码
<script setup lang="ts">
import Giscus from '@giscus/vue'
import { useRoute,useData } from "vitepress";
const { page } = useData()
// ...
</script>
<template>
  <Layout>
     <template #doc-after>
        <div style="margin-top: 24px">
          <Giscus
            :key="page.filePath"
            repo="lee-holden/vitepress-blog-template"
            repo-id="R_kgDONRAkeA"
            category="Announcements"
            category-id="IC_kwDONRAkeM4CkXRA"
            mapping="title"
            strict="0"
            reactions-enabled="1"
            emit-metadata="0"
            input-position="top"
            lang="zh-CN"
            crossorigin="anonymous"
          />
        </div>
    </template>
  </Layout>
</template>

<style scoped lang="scss"></style>

效果

9.4 优化

尝试切换亮/暗样式会发现评论组件不会跟随切换,这需要与 giscus 通信实现。

giscus 可以通过 message 与 giscus iframe 通信,所以我们在切换样式时通知 giscus 同步切换即可,恰好vitepress提供了 isDark 数据,我们可以监听它进行切换

官方文档:vitepress useData

官方文档:giscus-to-parent-message-events

.vitepress/theme/MyLayout.vue 文件

ts 复制代码
<script setup lang="ts">
const { page, isDark } = useData()
import { useRoute, useData, inBrowser } from 'vitepress'

watch(isDark, (dark) => {
  if (!inBrowser) return

  const iframe = document.querySelector('giscus-widget')?.shadowRoot?.querySelector('iframe')

  iframe?.contentWindow?.postMessage(
    { giscus: { setConfig: { theme: dark ? 'dark' : 'light' } } },
    'https://giscus.app'
  )
})

// ...
</script>

<template>
  <Layout>
    <template #doc-after>
      <div style="margin-top: 24px">
        <Giscus
          :key="page.filePath"
          repo="lee-holden/vitepress-blog-template"
          repo-id="R_kgDONRAkeA"
          category="Announcements"
          category-id="IC_kwDONRAkeM4CkXRA"
          mapping="title"
          strict="0"
          reactions-enabled="1"
          emit-metadata="0"
          input-position="top"
          :theme="isDark ? 'dark' : 'light'"
          lang="zh-CN"
          crossorigin="anonymous"
        />
      </div>
    </template>
  </Layout>
</template>

<style scoped lang="scss"></style>

效果

10. 项目配置

10.1 prettier

  • 安装vscode拓展:Prettier - Code formatter

  • 安装prettier库

    sh 复制代码
    pnpm add -D prettier
  • 项目根目录,新建 .prettierrc 文件

    json 复制代码
    {
      "printWidth": 100,
      "tabWidth": 2,
      "useTabs": false,
      "semi": false,
      "singleQuote": true,
      "quoteProps": "as-needed",
      "jsxSingleQuote": false,
      "trailingComma": "es5",
      "bracketSpacing": true,
      "jsxBracketSameLine": false,
      "arrowParens": "always",
      "proseWrap": "preserve",
      "htmlWhitespaceSensitivity": "css",
      "endOfLine": "lf"
    }
  • 项目根目录,新建 .vscode/settings.json

    json 复制代码
    {
      "editor.defaultFormatter": "esbenp.prettier-vscode",
      "editor.formatOnSave": true,
      "[javascript]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
      },
      "[typescript]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
      },
      "[json]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
      },
      "[html]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
      },
      "[css]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
      },
      "[scss]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
      },
      "[vue]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
      },
      "prettier.configPath": "./.prettierrc"
    }
  • 项目根目录,新建 .prettierignore 文件

    json 复制代码
    cache
    node_modules
    dist
    temp
    public
    !docs
  • 格式化全部文件

    sh 复制代码
    pnpm prettier --write .

10.2 git

  • 项目根目录,新建 .gitignore 文件

    json 复制代码
    node_modules
    .temp
    docs/.vitepress/cache
    dist
    cache
    .eslintcache
    components.d.ts
    .env.local
    .env.\*.local
    npm-debug.log*
    yarn-debug.log*
    yarn-error.log*
    pnpm-debug.log*
    meta.json
  • 配置git仓库并且推送

    sh 复制代码
    git init
    git add .
    git commit -m 'first commit'
    git remote add origin git@github.com:lee-holden/vitepress-blog-template.git
    git push -u origin master

11. 自动部署GitHub Pages

官方文档:vitepress 部署

  • 设置根目录

    官方文档:vitepress 根目录设置

    如果你使用的是 Github 页面并部署到 user.github.io/repo/,请将 base 设置为 /repo/。

    .vitepress/config.mts 文件

    ts 复制代码
    // ...
    export default defineConfig({
      base: '/vitepress-blog-template/', // 替换成你的仓库名称
      // ...
    })

    .vitepress/components/ArticleList.vue

    ts 复制代码
    <script setup lang="ts">
    // ...
    const jump = (path: string) => {
      router.go('vitepress-blog-template' + path)
    }
    </script>
    
    // ...
  • 开启GitHub Pages 功能

  • 项目根目录,新建 .github/workflows/deploy.yml

    官方示例

    yml 复制代码
    # 构建 VitePress 站点并将其部署到 GitHub Pages 的示例工作流程
    #
    name: Deploy VitePress site to Pages
    
    on:
      # 在针对 `main` 分支的推送上运行。如果你
      # 使用 `master` 分支作为默认分支,请将其更改为 `master`
      push:
        branches: [main]
    
      # 允许你从 Actions 选项卡手动运行此工作流程
      workflow_dispatch:
    
    # 设置 GITHUB_TOKEN 的权限,以允许部署到 GitHub Pages
    permissions:
      contents: read
      pages: write
      id-token: write
    
    # 只允许同时进行一次部署,跳过正在运行和最新队列之间的运行队列
    # 但是,不要取消正在进行的运行,因为我们希望允许这些生产部署完成
    concurrency:
      group: pages
      cancel-in-progress: false
    
    jobs:
      # 构建工作
      build:
        runs-on: ubuntu-latest
        steps:
          - name: Checkout
            uses: actions/checkout@v4
            with:
              fetch-depth: 0 # 如果未启用 lastUpdated,则不需要
          # - uses: pnpm/action-setup@v3 # 如果使用 pnpm,请取消此区域注释
          #   with:
          #     version: 9
          # - uses: oven-sh/setup-bun@v1 # 如果使用 Bun,请取消注释
          - name: Setup Node
            uses: actions/setup-node@v4
            with:
              node-version: 20
              cache: npm # 或 pnpm / yarn
          - name: Setup Pages
            uses: actions/configure-pages@v4
          - name: Install dependencies
            run: npm ci # 或 pnpm install / yarn install / bun install
          - name: Build with VitePress
            run: npm run docs:build # 或 pnpm docs:build / yarn docs:build / bun run docs:build
          - name: Upload artifact
            uses: actions/upload-pages-artifact@v3
            with:
              path: docs/.vitepress/dist
    
      # 部署工作
      deploy:
        environment:
          name: github-pages
          url: ${{ steps.deployment.outputs.page_url }}
        needs: build
        runs-on: ubuntu-latest
        name: Deploy
        steps:
          - name: Deploy to GitHub Pages
            id: deployment
            uses: actions/deploy-pages@v4

    我的pnpm配置

    yml 复制代码
    name: Deploy VitePress site to Pages
    
    on:
      push:
        branches:
          - master
          - main
      workflow_dispatch:
    
    permissions:
      contents: read
      pages: write
      id-token: write
    
    concurrency:
      group: pages
      cancel-in-progress: false
    
    jobs:
      build:
        runs-on: ubuntu-latest
        steps:
          - name: Checkout
            uses: actions/checkout@v4
            with:
              fetch-depth: 1 # 如果启用了 vitepress lastUpdated,则改成 0
          - uses: pnpm/action-setup@v3
            with:
              version: 9
          - name: Setup Node
            uses: actions/setup-node@v4
            with:
              node-version: 20
              cache: pnpm
          - name: Setup Pages
            uses: actions/configure-pages@v4
          - name: Install dependencies
            run: pnpm install
          - name: Build with VitePress
            run: pnpm docs:build
          - name: Upload artifact
            uses: actions/upload-pages-artifact@v3
            with:
              path: docs/.vitepress/dist
    
      # 部署工作
      deploy:
        environment:
          name: github-pages
          url: ${{ steps.deployment.outputs.page_url }}
        needs: build
        runs-on: ubuntu-latest
        name: Deploy
        steps:
          - name: Deploy to GitHub Pages
            id: deployment
            uses: actions/deploy-pages@v4
  • 推送代码到仓库,查看Action

    此处可以看到,Action已经成功运行,点进去可以看到build和部署进程,如果出现报错,可以查看报错信息

  • 部署成功

    vitepress-blog-template

12. 访问统计

用的是 busuanzi

  • 安装

    sh 复制代码
    pnpm add -D busuanzi.pure.js
  • .vitepress/theme/index.ts 文件

    ts 复制代码
    import { inBrowser } from 'vitepress'
    import busuanzi from 'busuanzi.pure.js'
    // ...
    
    export default {
      extends: DefaultTheme,
      Layout: NaiveUIProvider,
      enhanceApp: ({ app, router }) => {
        app.component('ArticleHeader', ArticleHeader)
        if (import.meta.env.SSR) {
          const { collect } = setup(app)
          app.provide('css-render-collect', collect)
        }
        if (inBrowser) {
          router.onAfterRouteChanged = () => {
            busuanzi.fetch()
          }
        }
      },
    }
  • .vitepress/theme/MyLayout.vue 文件

    在网站底部插槽放入,官方文档:vitepress 布局插槽

    ts 复制代码
    <template>
      <Layout>
        <template #layout-bottom>
          <div class="bottom">
            <div>
              本站总访问量
              <span id="busuanzi_value_site_pv" class="font-bold">--</span> 次 本站访客数
              <span id="busuanzi_value_site_uv" class="font-bold">--</span> 人次
            </div>
            <p>前端狗都不如 © 2021-2024 holden</p>
          </div>
        </template>
      </Layout>
      <!-- ... -->
    </template>
    
    <style lang="scss" scoped>
    .bottom {
      margin-left: 5%;
      width: 90%;
      height: 100px;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      border-top: 1px solid var(--border-color-1);
      text-align: center;
    
      p {
        margin-top: 5px;
      }
    }
    </style>
  • 效果

    可以自行调整底部样式

13. 网站加载

部署Github Pages后发现,白屏时间比较长,此时可以考虑使用加载页

  • 新建 .vitepress/components/Loading.vue 文件

    ts 复制代码
    <script setup lang="ts"></script>
    
    <template>
      <div class="loading">
        <div class="loader">
          <div v-for="_ in 5"></div>
        </div>
      </div>
    </template>
    
    <style scoped lang="scss">
    $color: #3451b2;
    
    .loading {
      width: 100vw;
      height: 100vh;
      display: flex;
      justify-content: center;
      align-items: center;
    }
    
    .loader {
      position: relative;
    }
    .loader > div:nth-child(2) {
      -webkit-animation: pacman-balls 1s -0.99s infinite linear;
      animation: pacman-balls 1s -0.99s infinite linear;
    }
    .loader > div:nth-child(3) {
      -webkit-animation: pacman-balls 1s -0.66s infinite linear;
      animation: pacman-balls 1s -0.66s infinite linear;
    }
    .loader > div:nth-child(4) {
      -webkit-animation: pacman-balls 1s -0.33s infinite linear;
      animation: pacman-balls 1s -0.33s infinite linear;
    }
    .loader > div:nth-child(5) {
      -webkit-animation: pacman-balls 1s 0s infinite linear;
      animation: pacman-balls 1s 0s infinite linear;
    }
    .loader > div:first-of-type {
      width: 0px;
      height: 0px;
      border-right: 25px solid transparent;
      border-top: 25px solid $color;
      border-left: 25px solid $color;
      border-bottom: 25px solid $color;
      border-radius: 25px;
      -webkit-animation: rotate_pacman_half_up 0.5s 0s infinite;
      animation: rotate_pacman_half_up 0.5s 0s infinite;
      position: relative;
      left: -30px;
    }
    .loader > div:nth-child(2) {
      width: 0px;
      height: 0px;
      border-right: 25px solid transparent;
      border-top: 25px solid $color;
      border-left: 25px solid $color;
      border-bottom: 25px solid $color;
      border-radius: 25px;
      -webkit-animation: rotate_pacman_half_down 0.5s 0s infinite;
      animation: rotate_pacman_half_down 0.5s 0s infinite;
      margin-top: -50px;
      position: relative;
      left: -30px;
    }
    .loader > div:nth-child(3),
    .loader > div:nth-child(4),
    .loader > div:nth-child(5),
    .loader > div:nth-child(6) {
      background-color: $color;
      width: 15px;
      height: 15px;
      border-radius: 100%;
      margin: 2px;
      width: 10px;
      height: 10px;
      position: absolute;
      -webkit-transform: translate(0, -6.25px);
      transform: translate(0, -6.25px);
      top: 25px;
      left: 70px;
    }
    @-webkit-keyframes cube-transition {
      25% {
        -webkit-transform: translateX(50px) scale(0.5) rotate(-90deg);
        transform: translateX(50px) scale(0.5) rotate(-90deg);
      }
      50% {
        -webkit-transform: translate(50px, 50px) rotate(-180deg);
        transform: translate(50px, 50px) rotate(-180deg);
      }
      75% {
        -webkit-transform: translateY(50px) scale(0.5) rotate(-270deg);
        transform: translateY(50px) scale(0.5) rotate(-270deg);
      }
      100% {
        -webkit-transform: rotate(-360deg);
        transform: rotate(-360deg);
      }
    }
    @keyframes cube-transition {
      25% {
        -webkit-transform: translateX(50px) scale(0.5) rotate(-90deg);
        transform: translateX(50px) scale(0.5) rotate(-90deg);
      }
      50% {
        -webkit-transform: translate(50px, 50px) rotate(-180deg);
        transform: translate(50px, 50px) rotate(-180deg);
      }
      75% {
        -webkit-transform: translateY(50px) scale(0.5) rotate(-270deg);
        transform: translateY(50px) scale(0.5) rotate(-270deg);
      }
      100% {
        -webkit-transform: rotate(-360deg);
        transform: rotate(-360deg);
      }
    }
    @-webkit-keyframes pacman-balls {
      75% {
        opacity: 0.7;
      }
      100% {
        -webkit-transform: translate(-100px, -6.25px);
        transform: translate(-100px, -6.25px);
      }
    }
    @keyframes pacman-balls {
      75% {
        opacity: 0.7;
      }
      100% {
        -webkit-transform: translate(-100px, -6.25px);
        transform: translate(-100px, -6.25px);
      }
    }
    @-webkit-keyframes rotate_pacman_half_down {
      0% {
        -webkit-transform: rotate(90deg);
        transform: rotate(90deg);
      }
      50% {
        -webkit-transform: rotate(0deg);
        transform: rotate(0deg);
      }
      100% {
        -webkit-transform: rotate(90deg);
        transform: rotate(90deg);
      }
    }
    @keyframes rotate_pacman_half_down {
      0% {
        -webkit-transform: rotate(90deg);
        transform: rotate(90deg);
      }
      50% {
        -webkit-transform: rotate(0deg);
        transform: rotate(0deg);
      }
      100% {
        -webkit-transform: rotate(90deg);
        transform: rotate(90deg);
      }
    }
    @-webkit-keyframes rotate_pacman_half_up {
      0% {
        -webkit-transform: rotate(270deg);
        transform: rotate(270deg);
      }
      50% {
        -webkit-transform: rotate(360deg);
        transform: rotate(360deg);
      }
      100% {
        -webkit-transform: rotate(270deg);
        transform: rotate(270deg);
      }
    }
    @keyframes rotate_pacman_half_up {
      0% {
        -webkit-transform: rotate(270deg);
        transform: rotate(270deg);
      }
      50% {
        -webkit-transform: rotate(360deg);
        transform: rotate(360deg);
      }
      100% {
        -webkit-transform: rotate(270deg);
        transform: rotate(270deg);
      }
    }
    </style>
  • .vitepress/theme/MyLayout.vue 文件

    ts 复制代码
    <script setup lang="ts">
    // ...
    import { watch, nextTick, onMounted, ref } from 'vue'
    import Loading from '../components/Loading.vue'
    const loading = ref(true)
    
    onMounted(() => {
      loading.value = false
    })
    
    // ...
    </script>
    
    <template>
      <Loading v-show="loading" />
      <Layout v-show="!loading">
        <!-- ... -->
      </Layout>
    </template>
  • 效果

14. 图片放大

vitepress文章中,图片点击没有任何效果,可以使用 vitepress-plugin-image-viewer 这个插件

  • 安装

    If you use pnpm to install, you need to install viewerjs additionally.

    sh 复制代码
    pnpm add vitepress-plugin-image-viewer viewerjs
  • .vitepress/theme/index.ts 文件

    ts 复制代码
    // ...
    import 'viewerjs/dist/viewer.min.css'
    import imageViewer from 'vitepress-plugin-image-viewer'
    import vImageViewer from 'vitepress-plugin-image-viewer/lib/vImageViewer.vue'
    
    // ...
    export default {
      extends: DefaultTheme,
      Layout: NaiveUIProvider,
      enhanceApp: ({ app, router }) => {
        app.component('ArticleHeader', ArticleHeader)
        app.component('vImageViewer', vImageViewer)
        if (import.meta.env.SSR) {
          const { collect } = setup(app)
          app.provide('css-render-collect', collect)
        }
        if (inBrowser) {
          router.onAfterRouteChanged = () => {
            busuanzi.fetch()
          }
        }
      },
      setup() {
        const route = useRoute()
        imageViewer(route)
      },
    }
  • 效果

总结

从0创建vitepress博客,一步步来,收获满满。

有什么问题欢迎到评论区咨询,一起交流学习。

相关推荐
code_shenbing1 小时前
跨平台WPF框架Avalonia教程 三
前端·microsoft·ui·c#·wpf·跨平台·界面设计
白臻2 小时前
使用element-plus el-table中使用el-image层级冲突table表格会覆盖预览的图片等问题
前端·vue.js·elementui
北极糊的狐2 小时前
vue使用List.forEach遍历集合元素
前端·javascript·vue.js
晓看天色*2 小时前
[JAVA]MyBatis框架—获取SqlSession对象
java·开发语言·前端
ZVAyIVqt0UFji2 小时前
Reactflow图形库结合Dagre算法实现函数资源关系图
开发语言·前端·javascript·ecmascript
luckilyil3 小时前
前端—Cursor编辑器
前端·编辑器
cooldream20093 小时前
快速上手 Vue 3 的高效组件库Element Plus
前端·javascript·vue.js·element plus
我是苏苏3 小时前
Web开发:ORM框架之使用Freesql的DbFrist封装常见功能
java·前端·jvm
疯狂的沙粒3 小时前
Vue项目开发 vue实例挂载的过程?
前端·javascript·vue.js
吃葡萄不吐葡萄皮嘻嘻3 小时前
el-table实现最后一行合计功能并合并指定单元格
前端·vue.js·elementui