Vue3.2开发一个博客Demo项目

Vue3.2开发一个博客Demo项目

技术选型

Vue3 + Vue-router + Pinia + ElementPlus

代码地址

https://gitee.com/galen.zhang/vue3-demo/tree/master/tech-blog

Mock后台服务器代码

https://gitee.com/galen.zhang/vue3-demo/tree/master/mock-server

cd mock-server
npm install
npm run dev

创建项目

npm create vue@latest
√ Project name: ... tech-blog
√ Add TypeScript? ... No 
√ Add JSX Support? ... No 
√ Add Vue Router for Single Page Application development? ...  Yes
√ Add Pinia for state management? ...  Yes
√ Add Vitest for Unit Testing? ... No 
√ Add an End-to-End Testing Solution? >> No
√ Add ESLint for code quality? ... Yes
√ Add Prettier for code formatting? ... Yes

cd tech-blog
npm install
npm run format
npm run dev

浏览器访问

http://localhost:5173/


安装Element Plus

官方文档

https://element-plus.org/zh-CN/guide/installation.html

安装Element Plus

npm install element-plus --save
npm install @element-plus/icons-vue

src/main.js 中引入Element Plus

import './assets/main.css'

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'

import * as ElementPlusIconsVue from '@element-plus/icons-vue'

import App from './App.vue'
import router from './router'

const app = createApp(App)

for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
    app.component(key, component)
}

app.use(ElementPlus)
app.use(createPinia())
app.use(router)

app.mount('#app')

路由配置

修改文件 src/router/index.js

import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView
    },
    {
      path: '/about',
      name: 'about',
      component: () => import('../views/AboutView.vue')
    },
    {
      path: '/blog/:id',
      component: () => import('../views/BlogDetailView.vue')
    },
    {
      path: '/blog/:id/edit',
      component: () => import('../views/CreateEditBlogView.vue')
    },
    {
      path: '/create',
      component: () => import('../views/CreateEditBlogView.vue')
    },
    {
      path: '/myBlog',
      component: () => import('../views/MyBlogView.vue')
    },
    {
      path: '/register',
      component: () => import('../views/RegisterView.vue')
    },
    {
      path: '/login',
      component: () => import('../views/LoginView.vue')
    }
  ]
})

export default router

封装网络请求工具类Axios

npm install axios --save
// 进度条
npm install nprogress --save

封装网络请求工具类 src/utils/request.js

import axios from 'axios'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'

export const serverUrl = 'http://localhost:3000'

const service = axios.create({
  baseURL: serverUrl,
  timeout: 5000
})

// Add a request interceptor 全局请求拦截
service.interceptors.request.use(
  function (config) {
    // Do something before request is sent
    const token = localStorage.getItem('token')
    if (token) {
      config.headers['Authorization'] = `Bearer ${token}`
    }
    NProgress.start() // 启动进度条
    // 此处还可以设置token
    return config
  },
  function (error) {
    // Do something with request error
    return Promise.reject(error)
  }
)

// Add a response interceptor 全局相应拦截
service.interceptors.response.use(
  function (response) {
    // Any status code that lie within the range of 2xx cause this function to trigger
    // Do something with response data
    NProgress.done()

    // 如果是固定的数据返回模式,此处可以做继续完整的封装
    const resData = response.data || {}
    if (resData.code == '0') {
      return resData.data
    }
    return Promise.reject(resData.message)
  },
  function (error) {
    // Any status codes that falls outside the range of 2xx cause this function to trigger
    // Do something with response error
    NProgress.done()

    // 此处需要对返回的状态码或者异常信息作统一处理
    return Promise.reject(error)
  }
)

export const get = (url, params) => {
  return service.get(url, {
    params
  })
}

export const post = (url, data) => service.post(url, data)

export const put = (url, data) => service.put(url, data)

export const del = (url, data) => service.delete(url)

后台请求api接口

博客后台请求接口api文件 src/api/blogApi.js

import { get, post, put, del } from "../utils/request";

// 获取博客列表
export async function getBlogList(opt = {}) {
    const { page = 1, pageSize = 10, category = '', keyword = '', my = false } = opt;
    return get(`/api/blogs?page=${page}&pageSize=${pageSize}&category=${category}&keyword=${keyword}&my=${my}`);
}

// 创建博客
export async function createBlog(data) {
    return post('/api/blogs', data);
}

// 编辑博客
export async function editBlog(id, data) {
    return put(`/api/blogs/${id}`, data);
}

// 删除博客
export async function deleteBlog(id) {
    return delete(`/api/blogs/${id}`);
}

// 根据 ID 获取单个博客
export async function getBlogById(id) {
    return get(`/api/blogs/${id}`);
}

// 点赞博客
export async function likeBlog(id) {
    return post(`/api/blogs/${id}/like`);
}

// 取消点赞博客
export async function unlikeBlog(id) {
    return del(`/api/blogs/${id}/like`);
}

// 收藏博客
export async function favoriteBlog(id) {
    return post(`/api/blogs/${id}/favorite`);
}

// 取消收藏博客
export async function unfavoriteBlog(id) {
    return del(`/api/blogs/${id}/favorite`);
}

export default {
    getBlogList,
    createBlog,
    editBlog,
    deleteBlog,
    getBlogById,
    likeBlog,
    unlikeBlog,
    favoriteBlog,
    unfavoriteBlog
}

封装hooks函数

修改页面标题

添加文件 src/hooks/usePageTitle.js

import { ref, isRef, onMounted, onBeforeUnmount, watchEffect } from 'vue'

const NAME = 'TechBlog'

function usePageTitle(title) {
  const originalTitle = ref(document.title)

  // 更新网页标题
  const updatePageTitle = () => {
    const titleValue = isRef(title) ? title.value : title
    if (!titleValue) {
      return
    }
    document.title = titleValue + ' - ' + NAME
  }

  if (isRef(title)) {
    watchEffect(updatePageTitle)
  }

  // 在组件挂载时更新网页标题
  onMounted(() => {
    updatePageTitle()
  })

  // 在组件卸载时恢复原始网页标题
  onBeforeUnmount(() => {
    document.title = originalTitle.value
  })

  return {
    updatePageTitle
  }
}

export default usePageTitle

在页面上使用,如登录页面

<script setup>
import usePageTitle from '../hooks/usePageTitle'

usePageTitle('登录')
</script>

封装首页导航栏组件

添加文件 src/components/NavMenu.vue

<script setup>
import { watch, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router';

const router = useRouter();
const route = useRoute();
const activeMenu = ref('');

// 监听route.query.category的变化,实时更新activeMenu的值
watch(() => route.query.category, (val) => {
    activeMenu.value = val;
});

function handleSelect(index) {
    router.push({ path: '/', query: { category: index } });
}
</script>

<template>
    <div class="menu">
        <el-menu :default-active="activeMenu" mode="horizontal" @select="handleSelect">
            <el-menu-item index="frontend">前端</el-menu-item>
            <el-menu-item index="java">Java</el-menu-item>
            <el-menu-item index="python">Python</el-menu-item>
            <el-menu-item index="mini-program">小程序</el-menu-item>
        </el-menu>
    </div>
</template>
  
<style scoped>
.menu {
    margin-bottom: 20px;
}
</style>

封装首页搜索组件

添加文件 src/components/SearchInput.vue

<script setup>
import { useRouter, useRoute } from 'vue-router';
import { watch, ref } from 'vue';

const router = useRouter();
const route = useRoute();
const keyword = ref(route.query.keyword || '');

watch(() => route.query.keyword, (value) => {
    keyword.value = value || '';
});

function handleSearch() {
    const path = route.path;
    const newQuery = { ...route.query, keyword: keyword.value };
    router.push({ path, query: newQuery });
}

function handleClear() {
    keyword.value = '';
    const path = route.path;
    const newQuery = { ...route.query, keyword: '' };
    router.push({ path, query: newQuery });
}
</script>

<template>
    <div class="search">
        <el-input v-model="keyword" placeholder="请输入关键字" @keyup.enter.native="handleSearch" @clear="handleClear" clearable>
            <el-button slot="append" icon="el-icon-search" @click="handleSearch"></el-button>
        </el-input>
    </div>
</template>

<style scoped>
.search {
    width: 240px;
}
</style>

用户注册页

form表单提交数据 src/views/RegisterView.vue

<script setup>
import { useRouter } from 'vue-router';
import { ref, reactive } from 'vue';
import { register } from '../api/userApi';
import usePageTitle from '@/hooks/usePageTitle'

usePageTitle('注册')

const router = useRouter();
const formRef = ref()
const registerForm = reactive({
    username: '',
    password: '',
    confirmPassword: '',
    nickname: '',
});

const rules = {
    username: [
        { required: true, message: '请输入用户名', trigger: 'blur' },
        { pattern: /^[A-Za-z0-9_]+$/, message: '用户名只能包含字母、数字和下划线' },
    ],
    password: [
        { required: true, message: '请输入密码', trigger: 'blur' },
        { min: 5, max: 10, message: '密码长度在5到10个字符之间', trigger: 'blur' },
    ],
    confirmPassword: [
        { required: true, message: '请输入确认密码', trigger: 'blur' },
        {
            validator: (rule, value, callback) => {
                if (value !== registerForm.password) {
                    callback(new Error('两次输入的密码不一致'));
                } else {
                    callback();
                }
            },
            trigger: 'blur',
        },
    ],
};

function handleRegister(formEl) {
    // 表单验证
    if (!formEl) return
    formEl.validate(async (valid) => {
        if (valid) {
            // 执行注册逻辑
            await register(registerForm);
            router.push('/login');
        } else {
            console.log('表单校验失败');
            return false;
        }
    });
}

</script>

<template>
    <div class="register">
        <div class="register-container">
            <h2 class="register-title">新用户注册</h2>
            <el-form :model="registerForm" :rules="rules" ref="formRef" label-position="top" class="register-form">
                <el-form-item label="用户名" prop="username">
                    <el-input v-model="registerForm.username"></el-input>
                </el-form-item>
                <el-form-item label="密码" prop="password">
                    <el-input type="password" v-model="registerForm.password"></el-input>
                </el-form-item>
                <el-form-item label="确认密码" prop="confirmPassword">
                    <el-input type="password" v-model="registerForm.confirmPassword"></el-input>
                </el-form-item>
                <el-form-item label="昵称">
                    <el-input v-model="registerForm.nickname"></el-input>
                </el-form-item>
                <el-form-item>
                    <el-button type="primary" @click="handleRegister(formRef)">注册</el-button>
                    &nbsp;&nbsp;
                    <router-link to="/login">已有账号?去登录</router-link>
                </el-form-item>
            </el-form>
        </div>
    </div>
</template>

  
<style scoped>
.register {
    display: flex;
    align-items: center;
    justify-content: center;
    height: 80vh;
}

.register-container {
    width: 400px;
    padding: 20px;
    background-color: #f6f6f6;
    border-radius: 4px;
}

.register-title {
    text-align: center;
    margin-bottom: 20px;
}

.register-form {
    margin-top: 20px;
}
</style>

用户登录页

在用户登录后,存储token到本地 src/views/LoginView.vue

<script setup>
import { ref, reactive } from 'vue';
import { useRouter } from 'vue-router';
import userApi from '../api/userApi';
import usePageTitle from '@/hooks/usePageTitle'

usePageTitle('登录')

const router = useRouter();

const formRef = ref()
const loginForm = reactive({
    username: '',
    password: '',
});

const rules = {
    username: [
        { required: true, message: '请输入用户名', trigger: 'blur' },
        { pattern: /^[A-Za-z0-9_]+$/, message: '用户名只能包含字母、数字和下划线' },
    ],
    password: [
        { required: true, message: '请输入密码', trigger: 'blur' },
        { min: 5, max: 10, message: '密码长度在5到10个字符之间', trigger: 'blur' },
    ],
};

function handleLogin(formRef) {
    // 表单验证
    formRef.validate(async (valid) => {
        if (!valid) {
            console.log('表单校验失败');
            return false;
        }

        // 执行登录逻辑
        const { token } = await userApi.login(loginForm);
        // 存储token
        localStorage.setItem('token', token);
        router.push('/');
    });
}
</script>

<template>
    <div class="login">
        <div class="login-container">
            <h2 class="login-title">用户登录</h2>
            <el-form :model="loginForm" :rules="rules" ref="formRef" label-position="top" class="login-form">
                <el-form-item label="用户名" prop="username">
                    <el-input v-model="loginForm.username"></el-input>
                </el-form-item>
                <el-form-item label="密码" prop="password">
                    <el-input type="password" v-model="loginForm.password"></el-input>
                </el-form-item>
                <el-form-item>
                    <el-button type="primary" @click="handleLogin(formRef)">登录</el-button>
                    &nbsp;&nbsp;
                    <router-link to="/register">没有账号?去注册</router-link>
                </el-form-item>
            </el-form>
        </div>
    </div>
</template>

<style scoped>
.login {
    display: flex;
    align-items: center;
    justify-content: center;
    height: 80vh;
}

.login-container {
    width: 400px;
    padding: 20px;
    background-color: #f6f6f6;
    border-radius: 4px;
}

.login-title {
    text-align: center;
    margin-bottom: 20px;
}

.login-form {
    margin-top: 20px;
}
</style>

状态管理Pinia

Pinia使用例子
src/stores/counter.js

import { ref, computed } from 'vue'
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const doubleCount = computed(() => count.value * 2)
  function increment() {
    count.value++
  }

  return { count, doubleCount, increment }
})

在页面中使用 counter

<script setup>
import { useCounterStore } from '../stores/counter'

const counterStore = useCounterStore()
// 解构会导致响应式丢失
// const { count, doubleCount, increment } = useCounterStore()
</script>

<template>
  <main>
    <h1>{{ counterStore.count }}</h1>
    <h1>{{ counterStore.doubleCount }}</h1>
    <button @click="counterStore.increment">加1</button>
  </main>
</template>

<style scoped></style>

使用Pinia管理用户状态

添加文件 src/stores/user.js

import { reactive } from 'vue'
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', () => {
  const userInfo = reactive({
    username: '',
    nickname: ''
  })

  function setUserInfo({ username, nickname }) {
    userInfo.username = username
    userInfo.nickname = nickname
  }

  function clearUserInfo() {
    userInfo.username = ''
    userInfo.nickname = ''
  }

  return { userInfo, setUserInfo, clearUserInfo }
})

在首页 src/App.vue 中获取用户信息

<script setup>
import { RouterLink, RouterView } from 'vue-router'
import NavMenu from './components/NavMenu.vue';
import SearchInput from './components/SearchInput.vue'
import UserInfo from './components/UserInfo.vue';
import { getUserInfo } from './api/userApi';
import { useUserStore } from './stores/user';

const userStore = useUserStore()

getUserInfo().then((res) => {
  console.log(res)
  userStore.setUserInfo(res)
})
</script>

<template>
  <div class="container">
    <!-- 顶部导航栏 -->
    <div class="navbar">
      <div class="navbar-left">
        <div class="logo">
          <RouterLink to="/">TechBlog</RouterLink>
        </div>
        <NavMenu class="nav-menu" />
        <SearchInput />
      </div>
      <div class="navbar-right">
        <UserInfo />
      </div>
    </div>

    <!-- 主体部分 -->
    <div class="main">
      <div class="content">
        <router-view></router-view>
      </div>
    </div>
  </div>
</template>

<style scoped>
.container {
  width: 100%;
}

.navbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0px;
  border-bottom: solid 1px var(--el-menu-border-color);
}

.navbar-left {
  display: flex;
  align-items: center;
}

.nav-menu {
  border-bottom: none;
  margin-left: 24px;
  margin-bottom: 0px;
}

.logo {
  font-size: 18px;
  font-weight: bold;
}

.logo a {
  text-decoration: none;
}

.navbar-right {
  display: flex;
  align-items: center;
}

.main {
  width: 100%;
  max-width: 1000px;
  margin: 0 auto;
  padding: 24px;
}

.content {
  padding: 24px;
}
</style>

封装用户菜单组件

添加文件 src/components/UserInfo.vue

<script setup>
import { RouterLink, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user';

const { userInfo, clearUserInfo } = useUserStore()
const router = useRouter();

const goCreateEditBlog = () => {
  router.push('/create')
}

const goMyBlogs = () => {
  router.push('/myBlog')
}

const logout = () => {
  clearUserInfo()
  localStorage.removeItem('token')
  router.push('/login')
}
</script>

<template>
  <div>
    <!-- 如果userInfo.userinfo 存在则显示userInfo.userinfo,否则显示登录链接-->
    <div class="dropdown" v-if="userInfo.username">
      <el-dropdown>
        <span class="el-dropdown-link">
          {{userInfo.nickname || userInfo.username}}
          <el-icon class="el-icon--right">
            <arrow-down />
          </el-icon>
        </span>
        <template #dropdown>
          <el-dropdown-menu>
            <el-dropdown-item @click="goCreateEditBlog"><el-icon><Edit /></el-icon>创建博客</el-dropdown-item>
            <el-dropdown-item @click="goMyBlogs"><el-icon><Files /></el-icon>我的博客</el-dropdown-item>
            <el-dropdown-item @click="logout"><el-icon><SwitchButton /></el-icon>注销</el-dropdown-item>
          </el-dropdown-menu>
        </template>
      </el-dropdown>
    </div>
    <RouterLink v-else to="/login">登录</RouterLink>
  </div>
</template> 

添加hooks函数,判断登录状态

添加文件 src/hooks/useNavToHome.js

import { watch } from 'vue';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/stores/user';

function useNavToHome() {
    const router = useRouter();
    const { userInfo } = useUserStore()
    // 监听 userInfo.username ,如果有值,说明用户已经登录,跳转到首页
    watch(() => userInfo.username, (val) => {
        if (val) {
            router.push('/');
        }
    }, { immediate: true });
}

export default useNavToHome

在注册、登录页面中添加,如果已登录则跳转到首页

import useNavToHome from '@/hooks/useNavToHome'

useNavToHome()

首页列表分页展示博客信息

添加卡片式组件,展示博客关键信息 src/components/BlogCard.vue

<script setup>
import { formatDate } from '@/utils/date';

defineProps({
    blog: {
        type: Object,
        required: true,
    },
});


</script>

<template>
    <div class="blog-card">
        <div class="top">
            <div class="author-category-time">
                <el-icon>
                    <User />
                </el-icon>{{ blog.author }}
                <i class="el-icon-folder"></i>{{ blog.category }}
                <i class="el-icon-time"></i>{{ formatDate(blog.updatedAt) }}
            </div>
        </div>
        <div class="middle">
            <router-link :to="`/blog/${ blog.id }`">
                <h4 class="title">{{ blog.title }}</h4>
            </router-link>

            <p class="description">{{ blog.description }}</p>
        </div>
        <div class="bottom">
            <div class="stats">
                <el-icon>
                    <Pointer />
                </el-icon>{{ blog.likes }}
                <el-icon>
                    <Star />
                </el-icon>{{ blog.favorites }}
                <el-icon>
                    <ChatLineSquare />
                </el-icon>{{ blog.comments }}
            </div>
        </div>
    </div>
</template>
  
  
  
<style scoped>
.blog-card {
    background-color: #ffffff;
    border-radius: 10px;
    box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);
    padding: 20px;
    transition: background-color 0.3s ease;
}

.blog-card:hover {
    background-color: #f5f5f5;
}

.top {
    display: flex;
    align-items: center;
    margin-bottom: 10px;
}

.author-category-time i {
    margin-right: 5px;
}

.middle {
    margin-bottom: 10px;
}

.title {
    font-size: 20px;
}

.description {
    color: #666666;
}

.bottom {
    display: flex;
    align-items: center;
}

.stats i {
    margin-right: 5px;
}</style>

列表分页显示 src/views/HomeView.vue

<script setup>
import { ref, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getBlogList } from '@/api/blogApi'
import usePageTitle from '@/hooks/usePageTitle'
import BlogCard from '@/components/BlogCard.vue'

usePageTitle('首页')

// // 定义 list total
const list = ref([])
const total = ref(0)
const currentPage = ref(1)
const pageSizeRef = ref(10)

const $route = useRoute()
const $router = useRouter()

// 使用 watchEffect监听url参数page、pageSize、category、keyword的变化,然后调用获取博客列表的接口
watchEffect(async () => {
  const query = $route.query
  currentPage.value = Number(query.page) || 1
  pageSizeRef.value = Number(query.pageSize) || 10

  const page = Number(query.page) || 1
  const pageSize = Number(query.pageSize) || 10
  const category = query.category || ''
  const keyword = query.keyword || ''
  const res = await getBlogList({ page, pageSize, category, keyword })
  list.value = res.list
  total.value = res.total
})

const handlePageChange = (page) => {
  const path = $route.path
  const query = { ...$route.query, page }
  $router.push({ path, query })
}
</script>

<template>
  <main>
    <div class="blog-container" v-for="item in list" :key="item.id">
      <BlogCard :blog="item" />
    </div>
    <div class="pagination">
      <el-pagination v-model="currentPage" :total="total" :page-size="pageSizeRef" layout="prev, pager, next"
        :pager-count="7"
        @current-change="handlePageChange"></el-pagination>
    </div>
  </main>
</template>

<style scoped>
.blog-container {
  margin-bottom: 16px;
}

.pagination {
  display: flex;
  justify-content: center;
  margin-top: 20px;
}
</style>

博客详情页

博客内容存储为marddown格式的文本

安装markdown工具 markdown-it

npm install markdown-it --save

博客详情页 src/view/BlogDetailView.vue

<script setup>
import { reactive, onMounted, toRef, toRaw } from 'vue'
import { useRoute } from 'vue-router'
import { getBlogById } from '../api/blogApi'
import usePageTitle from '@/hooks/usePageTitle'
import { formatDate } from '@/utils/date'
import MarkdownIt from 'markdown-it'

const md = MarkdownIt()

const $route = useRoute()
const blogId = $route.params.id || ''

const blogInfo = reactive({})
// onMounted 获取博客详情
onMounted(async () => {
    const res = await getBlogById(blogId)
    console.log(res)
    blogInfo.title = res.title
    blogInfo.content = res.content
    blogInfo.createdAt = res.createdAt
    blogInfo.updatedAt = res.updatedAt
    blogInfo.category = res.category
    blogInfo.author = res.author
    blogInfo.id = res.id
    blogInfo.likes = res.likes
    blogInfo.comments = res.comments
    blogInfo.favorites = res.favorites
    blogInfo.isLiked = res.isLiked
    blogInfo.isFavorited = res.isFavorited
})

usePageTitle(toRef(blogInfo, 'title'))

</script>

<template>
    <main>
        <!-- 显示 title author createdAt content -->
        <div class="blog-detail">
            <h2>{{ blogInfo.title }}</h2>
            <div class="blog-info">
                <el-icon>
                    <User />
                </el-icon><span class="author">{{ blogInfo.author }}</span>
                <span class="createdAt">{{ formatDate(blogInfo.createdAt) }}</span>
                <span class="category">{{ blogInfo.category }}</span>
            </div>
            <div class="content" v-html="md.render(toRaw(blogInfo.content) || '')"></div>
        </div>
    </main>
</template>

<style scoped>
.blog-detail {
    margin-bottom: 16px;
}

.blog-detail h2 {
    font-size: 24px;
    font-weight: bold;
    margin-bottom: 16px;
}

.blog-detail .blog-info {
    display: flex;
    justify-content: space-between;
    margin-bottom: 16px;
}

.blog-detail .blog-info .author {
    color: #999;
}

.content {
    line-height: 1.8;
    font-size: 16px;
    color: #333;
}
</style>

封装分页组件

添加文件 src/components/ListPage.vue

<script setup>
import { useRoute, useRouter } from 'vue-router'

const $route = useRoute()
const $router = useRouter()

defineProps({
  total: {
    type: Number,
    required: true
  },
  currentPage: {
    type: Number,
    required: true
  },
  pageSizeRef: {
    type: Number,
    required: true
  }
})

const handlePageChange = (newPage) => {
  $router.push({
    query: {
      ...$route.query,
      page: newPage
    }
  })
};
</script>

<template>
  <div class="pagination">
    <el-pagination
      :current-page="currentPage"
      :page-size="pageSizeRef"
      :total="total"
      layout="prev, pager, next"
      @current-change="handlePageChange"
    ></el-pagination>
  </div>
</template>

<style scoped>
.pagination {
  display: flex;
  justify-content: center;
  margin-top: 20px;
}
</style>

我的博客页面

使用table表格展示列表数据 src/views/MyBlogView.vue

<script setup>
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import usePageTitle from '@/hooks/usePageTitle'
import useNavToLogin from '@/hooks/useNavToLogin'
import useGetBlogList from '@/hooks/useGetBlogList'
import ListPage from '@/components/ListPage.vue';
import { deleteBlog } from '@/api/blogApi';

const $router = useRouter()

useNavToLogin() // 未登录时跳转到登录页

usePageTitle('我的博客')

const { list, total, currentPage, pageSizeRef, getBlogListFn } = useGetBlogList({ my: true })

// 编辑博客,跳转到编辑页
const handleEdit = (blog) => {
    $router.push(`/blog/${blog.id}/edit`)
}

// 删除博客
const handleDelete = async (blog) => {
    // confirm 验证
    const confirm = await ElMessageBox.confirm('此操作将永久删除该博客, 是否继续?', '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
    })
    if (confirm !== 'confirm') return

    try {
        await deleteBlog(blog.id)
        getBlogListFn() // 删除成功后重新获取博客列表
    } catch (error) {
        console.log(error)
    }
}
</script>

<template>
    <div class="title-container">
        <h2>我的博客</h2>
        <router-link to="/create">
            <el-button type="primary">创建博客</el-button>
        </router-link>
    </div>

    <!-- 用 table 显示博客列表,包括 title category likes favorites comments createdAt ,再加两个操作按钮"编辑" "删除" -->
    <el-table :data="list" border style="width: 100%">
        <!-- 标题列,点击标题链接到博客详情页 -->
        <el-table-column label="标题">
            <template #default="{ row }">
                <router-link :to="`/blog/${row.id}`">{{ row.title }}</router-link>
            </template>
        </el-table-column>
        <el-table-column prop="category" label="分类"></el-table-column>
        <el-table-column prop="likes" label="点赞数"></el-table-column>
        <el-table-column prop="favorites" label="收藏数"></el-table-column>
        <el-table-column prop="comments" label="评论数"></el-table-column>
        <el-table-column prop="createdAt" label="创建时间"></el-table-column>
        <el-table-column label="操作">
            <template #default="{ row }">
                <el-button type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
                <el-button type="danger" size="small" @click="handleDelete(row)">删除</el-button>
            </template>
        </el-table-column>
    </el-table>

    <!--分页组件-->
    <ListPage :total="total" :currentPage="currentPage" :pageSizeRef="pageSizeRef" />
</template>

<style scoped>
.title-container {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 20px;
}
</style>

新建、编辑博客页面

添加文件 src/views/CreateEditBlogView.vue

<script setup>
import { onMounted, reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import usePageTitle from '@/hooks/usePageTitle'
import useNavToLogin from '@/hooks/useNavToLogin'
import { getBlogById, createBlog, editBlog } from '../api/blogApi';

useNavToLogin() // 未登录时跳转到登录页

const $route = useRoute()
const $router = useRouter()

const id = $route.params.id
// 定义 title ,根据 id 判断 title
const title = id ? '编辑博客' : '创建博客'
usePageTitle(title)

// onMounted 时候,如果有 id ,则获取博客详情
const blogDetail = reactive({})
onMounted(async () => {
    if (!id) return
    const data = await getBlogById(id)
    blogDetail.title = data.title
    blogDetail.content = data.content
    blogDetail.category = data.category
})

// 表单验证
const validateForm = () => {
    if (!blogDetail.title) {
        alert('请输入标题');
        return false;
    }
    if (!blogDetail.content) {
        alert('请输入内容');
        return false;
    }
    if (!blogDetail.category) {
        alert('请选择类型');
        return false;
    }
    return true;
};

// 确认提交
const confirmSubmit = async () => {
    const confirm = await ElMessageBox.confirm('提交数据?', '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning',
    });
    return confirm === 'confirm';
};

// 提交
const submit = async () => {
    if (!validateForm()) return;

    if (!await confirmSubmit()) return;

    const data = {
        title: blogDetail.title,
        content: blogDetail.content,
        category: blogDetail.category
    }

    if (id) {
        await editBlog(id, data)
    } else {
        await createBlog(data)
    }

    $router.push('/myBlog')
}
</script>

<template>
    <div class="top-container">
        <div class="top-left">
            <h2>{{ title }}</h2>
        </div>
        <div class="top-right">
            <el-button type="primary" @click="submit">提交</el-button>
        </div>
    </div>

    <div class="title-container">
        <div class="title-left">
            <span class="label">标题</span>
            <el-input v-model="blogDetail.title" placeholder="请输入标题" style="width: 500px;"></el-input>
        </div>
        <div class="title-right">
            <span class="label">类型</span>
            <el-select v-model="blogDetail.category" placeholder="请选择类型">
                <el-option label="前端" value="前端"></el-option>
                <el-option label="Java" value="Java"></el-option>
                <el-option label="Python" value="Python"></el-option>
                <el-option label="小程序" value="小程序"></el-option>
            </el-select>
        </div>
    </div>

    <div class="content-container">
        <el-input type="textarea" :rows="20" v-model="blogDetail.content" placeholder="请输入内容"
            style="width: 100%;"></el-input>
    </div>
</template>

<style scoped>
.top-container {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 10px;
    /* border: 1px solid #ccc; */
}

.top-left {
    flex-grow: 1;
}

.top-right {
    margin-left: 10px;
}

.title-container {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 10px;
    /* border: 1px solid #ccc; */
}

.title-left,
.title-right {
    display: flex;
    align-items: center;
}

.title-right {
    margin-left: 20px;
}

.label {
    display: inline-block;
    width: 45px;
}
</style>

性能优化--打包时拆分第三方依赖

打包项目

npm run build

可以查看打包后每个文件的大小

安装 Import Cost 插件,可以在每个vue文件中,查看第三方依赖库的文件大小

Vue、Element-Plus 可以拆分出来,减小首页index文件的大小

修改配置文件 vite.config.js

  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // 将 Vue 和 Element Plus 单独打包为一个公共块
          vue: ['vue'],
          'element-plus': ['element-plus'],
          'markdown-it': ['markdown-it']
        }
      }
    }
  }
相关推荐
cs_dn_Jie27 分钟前
钉钉 H5 微应用 手机端调试
前端·javascript·vue.js·vue·钉钉
Yaml43 小时前
智能化健身房管理:Spring Boot与Vue的创新解决方案
前端·spring boot·后端·mysql·vue·健身房管理
计算机-秋大田12 小时前
基于Spring Boot的船舶监造系统的设计与实现,LW+源码+讲解
java·论文阅读·spring boot·后端·vue
Yaml41 天前
Spring Boot 与 Vue 共筑二手书籍交易卓越平台
java·spring boot·后端·mysql·spring·vue·二手书籍
清灵xmf1 天前
在 Vue 中实现与优化轮询技术
前端·javascript·vue·轮询
琴~~2 天前
前端根据后端返回的文本流逐个展示文本内容
前端·javascript·vue
程序员徐师兄2 天前
基于 JavaWeb 的宠物商城系统(附源码,文档)
java·vue·springboot·宠物·宠物商城
shareloke3 天前
让Erupt框架支持.vue文件做自定义页面模版
vue
你白勺男孩TT3 天前
Vue项目中点击按钮后浏览器屏幕变黑,再次点击恢复的解决方法
vue.js·vue·springboot
虞泽3 天前
鸢尾博客项目开源
java·spring boot·vue·vue3·博客