【VUE3】练习项目——大事件后台管理

目录

0 前言

1 准备工作

1.1 安装pnpm

1.2 创建vue项目

1.3 Eslint & Prettier的配置

1.4 husky 提交代码检查

1.5 目录调整

1.6 VueRouter4

1.6.1 基础配置

1.6.2 路由跳转

1.7 引入 Element Plus 组件库

1.8 Pinia

1.8.1 优化 

1.9 封装请求工具

1.9.1 安装 axios 与配置框架

1.9.2 示例代码

2 路由配置

3 登录注册

3.1 静态页面

3.2 规则校验

3.3 表单预校验

3.4 封装注册api

3.5 登录校验

4 首页

4.1 静态资源 

4.2 访问拦截

4.3 获取用户数据&动态渲染

4.4 退出功能

4.4.1 下拉菜单

4.4.2 消息弹出框

5 文章分类

5.1 文章分类架子—封装 card 组件

5.2 文章分类渲染

5.2.1 封装获取文章分类列表api

5.2.2 封装 pinia 

5.2.3 页面布局、数据渲染、loading效果及空状态

5.3 弹层表单

5.3.1 表单检测

5.4 更新分类与新增分类功能

5.5 删除分类

6 文章管理

6.1 基础布局 

6.2 中英语言包切换

6.3 封装获取分类列表组件

6.4 封装获取列表接口并格式化时间

6.5 分页渲染

6.6 搜索与重置功能

6.7 新增与编辑功能——抽屉组件

6.8 编辑模板

6.9 图片上传

6.10 富文本编辑器

6.11 添加文章

6.12 编辑回显与提交数据

6.13 删除文章

7 个人中心


0 前言

黑马程序员视频地址:Vue3大事件项目-项目介绍和pnpm创建项目

接口文档:登录 - 黑马程序员-大事件  


1 准备工作

1.1 安装pnpm

官网:pnpm - 速度快、节省磁盘空间的软件包管理器 | pnpm中文文档 | pnpm中文网

安装pnpm命令:

npm i pnpm -g

pnpm创建vue项目命令:

pnpm create vue

命令对比: 

npmyarnpnpm
npm installyarnpnpm install
npm install axiosyarn add axiospnpm add axios
npm install axios -Dyarn add axios -Dpnpm add axios -D
npm uninstall axiosyarn remove axiospnpm remove axios
npm run devyarn devpnpm dev

1.2 创建vue项目

使用pnpm创建vue项目时,选择以下配置

请选择要包含的功能: (↑/↓ 切换,空格选择,a 全选,回车确认)
|  [ ] TypeScript
|  [ ] JSX 支持
|  [+] Router(单页面应用开发)
|  [+] Pinia(状态管理)
|  [ ] Vitest(单元测试)
|  [ ] 端到端测试
|  [+] ESLint(错误预防)
|  [+] Prettier(代码格式化)

标记:警告提示(待解决)

 创建完项目需要进入相应文件夹中,安装所有依赖

pnpm install

1.3 Eslint & Prettier的配置

见 【VUE3】Eslint 与 Prettier 的配置-CSDN博客

推荐使用里面的方案二,即将 prettier 的规则让 eslint 来执行

因为 1.4 中的检查代码需要 eslint 来检查


1.4 husky 提交代码检查

husky 是一个 git hooks 工具  ( git的钩子工具,可以在特定时机执行特定的命令 )

第一步:初始化仓库

git init

第二步:初始化 husky 工具配置

pnpm dlx husky-init; pnpm install

第三步:修改 .husky/pre-commit 文件

pnpm lint

但是这样会有一个问题! 

我们可以打开 package.json 文件,看到里面的 lint 命令对应为:

"lint": "eslint . --fix"

即默认进行的是全量检查,耗时问题,历史问题,因此需要再导入一个包 lint-staged :

第一步:安装

pnpm i lint-staged -D

第二步:配置 package.json 文件

{
  // ... 省略 ...
  "lint-staged": {
    "*.{js,ts,vue}": [
      "eslint --fix"
    ]
  }
}

{
  "scripts": {
    // ... 省略 ...
    "lint-staged": "lint-staged"
  }
}

第三步:修改 .husky/pre-commit 文件

pnpm lint-staged

这样就会只检查修改过的文件,哪怕以前提交的文件有问题,也不会检查报错

可以通过控制 1.3 中的那篇文章的第七节中的 'no-undef': 'off'来模拟提交以前有问题的文件


1.5 目录调整

删除默认文件后,增加 api 与 utils 文件夹

如果使用 sass,则需安装对应依赖

pnpm add sass -D

1.6 VueRouter4

更多内容见官网:Vue Router | Vue.js 的官方路由 

1.6.1 基础配置

import { createRouter, createWebHistory } from 'vue-router'

// createRouter 创建路由实例,===> new VueRouter()
// 1. history模式: createWebHistory()   http://xxx/user
// 2. hash模式: createWebHashHistory()  http://xxx/#/user

// vite 的配置 import.meta.env.BASE_URL 是路由的基准地址,默认是 ’/‘
// https://vitejs.dev/guide/build.html#public-base-path

// 如果将来你部署的域名路径是:http://xxx/my-path/user
// vite.config.ts  添加配置  base: my-path,路由这就会加上 my-path 前缀了

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: []
})

export default router

1.6.2 路由跳转

由于 setup 下,this 指向 undefined ,因此需要引入包创建 router 与 router 对象

<script setup>
import { useRoute, useRouter } from 'vue-router'
const router = useRouter()
const route = useRoute()
const gotoCart = () => {
  console.log(route)
  router.push('/individual')
}
</script>

<template>
  <div>我是App</div>
  <button @click="gotoCart()">跳转购物车页面</button>
</template>

也可以导入 1.6.1 中的 router 对象,调用其身上的 push 方法

ai说前者只能在setup语法糖中使用,只适用于组合式api


1.7 引入 Element Plus 组件库

官方手册:一个 Vue 3 UI 框架 | Element Plus

安装组件库

pnpm install element-plus

按需引入:

第一步:安装插件

pnpm add -D unplugin-vue-components unplugin-auto-import

 第二步:把下列代码插入到你的 Vite 的配置文件中

import { defineConfig } from 'vite'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  // ...
  plugins: [
    // ...
    AutoImport({
      resolvers: [ElementPlusResolver()],
    }),
    Components({
      resolvers: [ElementPlusResolver()],
    }),
  ],
})

全部引入此处不赘述 

注意:

1.在引入之后,无需任何配置,即可使用组件库内的组件

2.并且components下的vue组件也可以直接使用,无需导入


1.8 Pinia

见文档:【VUE3】Pinia-CSDN博客

注意:由于创建项目时勾选了Pinia,所以此处不需要再手动安装配置Pinia,直接可以使用

但是持久化还需手动配置,此处不再赘述

1.8.1 优化 

将 main.js 中关于 pinia 的代码抽离到 store/index.js 中,并且将 store/modules 中的所有仓库文件统一从 index.js 中导出,方便管理且简化代码 

// main.js
// ...
import pinia from './stores'

// ...
app.use(pinia)

// ...
// store/index.js

import { createPinia } from 'pinia'
import persist from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(persist)

export default pinia

export * from '@/stores/modules/user'
export * from '@/stores/modules/count'

// 上面的代码等同于
// import { useUserStore, ... } from '@/stores/modules/user'
// export { useUserStore, ... }
// 组件.vue

import { useUserStore, useCountStore } from '@/stores'

const userStore = useUserStore()
const userCount = useCountStore()

1.9 封装请求工具

手册:axios中文文档|axios中文网 | axios 

1.9.1 安装 axios 与配置框架

1.安装 axios

pnpm add axios

2.框架代码

// utils/request.js

import axios from 'axios'

const baseURL = 'http://big-event-vue-api-t.itheima.net'

const instance = axios.create({
  // TODO 1. 基础地址,超时时间
})

instance.interceptors.request.use(
  (config) => {
    // TODO 2. 携带token
    return config
  },
  (err) => Promise.reject(err)
)

instance.interceptors.response.use(
  (res) => {
    // TODO 3. 处理业务失败
    // TODO 4. 摘取核心响应数据
    return res
  },
  (err) => {
    // TODO 5. 处理401错误
    return Promise.reject(err)
  }
)

export default instance

1.9.2 示例代码

// utils/request.js

import axios from 'axios'
import { useUserStore } from '@/stores'
import { ElMessage } from 'element-plus'
import router from '@/router'
const baseURL = 'http://big-event-vue-api-t.itheima.net'

const instance = axios.create({
  // TODO 1. 基础地址,超时时间
  baseURL,
  timeout: 100000
})

instance.interceptors.request.use(
  (config) => {
    // TODO 2. 携带token
    const userStore = useUserStore()
    if (userStore.token) {
      config.headers.Authorization = userStore.token
    }
    return config
  },
  (err) => Promise.reject(err)
)

instance.interceptors.response.use(
  (res) => {
    // TODO 3. 处理业务失败
    if (res.data.code === 0) {
      return res
    }
    ElMessage({ message: res.data.message || '服务异常', type: 'error' })
    // TODO 4. 摘取核心响应数据
    return Promise.reject(res.data)
  },
  (err) => {
    ElMessage({ message: err.response.data.message || '服务异常', type: 'error' })
    // TODO 5. 处理401错误
    if (err.response?.status === 401) {
      router.push('/login')
    }
    return Promise.reject(err)
  }
)

export default instance

2 路由配置

示例:采用 路由懒加载

// router/index.js

import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    { path: '/login', component: () => import('@/views/login/LoginPage.vue') },
    {
      path: '/',
      component: () => import('@/views/layout/LayoutContainer.vue'),
      redirect: '/article/manage',
      children: [
        { path: '/article/manage', component: () => import('@/views/article/ArticleManage.vue') },
        { path: '/article/channel', component: () => import('@/views/article/ArticleChannel.vue') },
        { path: '/user/profile', component: () => import('@/views/user/UserProfile.vue') },
        { path: '/user/avatar', component: () => import('@/views/user/UserAvatar.vue') },
        { path: 'user/password', component: () => import('@/views/user/UserPassword.vue') }
      ]
    }
  ]
})

export default router

记得准备路由出口,如:

// App.vue

<template>
  <router-view></router-view>
</template>

3 登录注册

3.1 静态页面

1.安装 element-plus 图标库

pnpm i @element-plus/icons-vue

2.静态结构准备

注意:登录页面与注册页面使用 v-if 与 v-else 控制切换

// views/login/LoginPage.vue

<script setup>
import { User, Lock } from '@element-plus/icons-vue'
import { ref } from 'vue'
const isRegister = ref(true)
</script>

<template>
  <el-row class="login-page">
    <el-col :span="12" class="bg"></el-col>
    <el-col :span="6" :offset="3" class="form">
      <el-form ref="form" size="large" autocomplete="off" v-if="isRegister">
        <el-form-item>
          <h1>注册</h1>
        </el-form-item>
        <el-form-item>
          <el-input :prefix-icon="User" placeholder="请输入用户名"></el-input>
        </el-form-item>
        <el-form-item>
          <el-input :prefix-icon="Lock" type="password" placeholder="请输入密码"></el-input>
        </el-form-item>
        <el-form-item>
          <el-input :prefix-icon="Lock" type="password" placeholder="请输入再次密码"></el-input>
        </el-form-item>
        <el-form-item>
          <el-button class="button" type="primary" auto-insert-space> 注册 </el-button>
        </el-form-item>
        <el-form-item class="flex">
          <el-link type="info" :underline="false" @click="isRegister = false"> ← 返回 </el-link>
        </el-form-item>
      </el-form>
      <el-form ref="form" size="large" autocomplete="off" v-else>
        <el-form-item>
          <h1>登录</h1>
        </el-form-item>
        <el-form-item>
          <el-input :prefix-icon="User" placeholder="请输入用户名"></el-input>
        </el-form-item>
        <el-form-item>
          <el-input
            name="password"
            :prefix-icon="Lock"
            type="password"
            placeholder="请输入密码"
          ></el-input>
        </el-form-item>
        <el-form-item class="flex">
          <div class="flex">
            <el-checkbox>记住我</el-checkbox>
            <el-link type="primary" :underline="false">忘记密码?</el-link>
          </div>
        </el-form-item>
        <el-form-item>
          <el-button class="button" type="primary" auto-insert-space>登录</el-button>
        </el-form-item>
        <el-form-item class="flex">
          <el-link type="info" :underline="false" @click="isRegister = true"> 注册 → </el-link>
        </el-form-item>
      </el-form>
    </el-col>
  </el-row>
</template>

<style lang="scss" scoped>
.login-page {
  height: 100vh;
  background-color: #fff;
  .bg {
    background:
      url('@/assets/logo2.png') no-repeat 60% center / 240px auto,
      url('@/assets/login_bg.jpg') no-repeat center / cover;
    border-radius: 0 20px 20px 0;
  }
  .form {
    display: flex;
    flex-direction: column;
    justify-content: center;
    user-select: none;
    .title {
      margin: 0 auto;
    }
    .button {
      width: 100%;
    }
    .flex {
      width: 100%;
      display: flex;
      justify-content: space-between;
    }
  }
}
</style>

3.2 规则校验

官方文档:Form 表单 | Element Plus

四大校验方式:

        1.非空校验:required

        2.长度校验:min、max

        3.正则校验:pattern

        4.自定义校验 :validator

第一步:声明表单数据对象与规则对象,其中表单数据对象必须是响应式的 

const formData = ref({
  username: '',
  password: '',
  repassword: ''
})
const formRules = {
  username: [
    { required: true, message: '用户名不能为空!', trigger: 'blur' },
 // blur是失去焦点事件
    { min: 5, max: 10, message: '用户名必须为5-10位字符', trigger: 'blur' }
  ],
  password: [
    { required: true, message: '密码不能为空!', trigger: 'blur' },
    { pattern: /^\S{8,15}$/, message: '密码必须为8-15位非空字符', trigger: 'change' }
 // change为改变时校验,发现当检验提示错误后,若触发失焦事件,会导致提示消失,因此不推荐
  ]
}

第二步:给整个表单的大标签绑定数据对象与规则对象

<el-form
...
:model="formData"
:rules="formRules"
>
<!-- ... -->
</el-form>

第三步:给输入框绑定数据,并且给表单元素标签绑定要使用的规则

<el-form-item prop="username">    ❗
  <el-input
    :prefix-icon="User"
    placeholder="请输入用户名"
    v-model="formData.username"    ❗
  ></el-input>
</el-form-item>

自定义校验方式:

使用方式相同,只是配置规则时,validator 指向一个函数

参数:

        rule:当前校验规则相关的信息

        value:所校验的表单元素目前的表单值

        callback:无论成功还是失败,都需要 callback 回调

const formRules = {
  // ...
  repassword: [
    {
      validator: (rule, value, callback) => {
        if (value !== formData.value.password) {
          callback(new Error('两次密码不一致,请重新输入!'))
        } else {
          callback()
        }
      },
      trigger: 'blur'
    }
  ]
}

3.3 表单预校验

由于vue3的特性,即导入的组件内的方法默认不会暴露,因此需要获取组件对象,然后再获取,如:

第一步:获取组件对象

详细见 【VUE3】组合式API-CSDN博客 中的 9.2

const form = ref()
<el-form
ref="form"    ❗
size="large"
autocomplete="off"
v-if="isRegister"
:model="formData"
:rules="formRules"
>

第二步:调用 validate 方法

validate 方法即对整个表单的内容进行验证,接收一个回调函数,或返回 Promise

const register = async () => {
  await form.value.validate()    ❗
  await userRegisterService(formData.value)    // 这个是提交数据api
  ElMessage.success('注册成功!')
  isRegister.value = false    // 切换登录
}

注意:因为之前我们设置了插件来帮我们自动导入Element组件,所以上述代码中的ElMessage方法我们没有导入,但是eslint会报错,可以在 eslint.config.js 中来配置,让其不报错,位置及代码如下:

// ...

export default defineConfig([
  // ...
  globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),

  {
    languageOptions: {
      globals: {
        ...globals.browser,
        // 除了以下这三行,其他的都是eslint.config.js中自带的,直接把这三行插入到这即可
        ElMessage: 'readonly',
        ElMessageBox: 'readonly',
        ElLoading: 'readonly'
      }
    }
  },
  // ...
])

3.4 封装注册api

// api/user.js

import request from '@/utils/request'

export const userRegisterService = ({ username, password, repassword }) =>
  request.post('/api/reg', { username, password, repassword })

调用见 2.2.3 


3.5 登录校验

与注册校验相同,此处不再赘述

需要注意的是,记得在请求完成后,将token存到store中,并且跳转页面


4 首页

4.1 静态资源 

<script setup>
import {
  Management,
  Promotion,
  UserFilled,
  User,
  Crop,
  EditPen,
  SwitchButton,
  CaretBottom
} from '@element-plus/icons-vue'
import avatar from '@/assets/default.png'
</script>

<template>
  <el-container class="layout-container">
    <el-aside width="200px">
      <div class="el-aside__logo"></div>
      <el-menu
        active-text-color="#ffd04b"
        background-color="#232323"
        :default-active="$route.path"
        text-color="#fff"
        router
      >
        <el-menu-item index="/article/channel">
          <el-icon><Management /></el-icon>
          <span>文章分类</span>
        </el-menu-item>
        <el-menu-item index="/article/manage">
          <el-icon><Promotion /></el-icon>
          <span>文章管理</span>
        </el-menu-item>
        <el-sub-menu index="/user">
          <template #title>
            <el-icon><UserFilled /></el-icon>
            <span>个人中心</span>
          </template>
          <el-menu-item index="/user/profile">
            <el-icon><User /></el-icon>
            <span>基本资料</span>
          </el-menu-item>
          <el-menu-item index="/user/avatar">
            <el-icon><Crop /></el-icon>
            <span>更换头像</span>
          </el-menu-item>
          <el-menu-item index="/user/password">
            <el-icon><EditPen /></el-icon>
            <span>重置密码</span>
          </el-menu-item>
        </el-sub-menu>
      </el-menu>
    </el-aside>
    <el-container>
      <el-header>
        <div>黑马程序员:<strong>小帅鹏</strong></div>
        <el-dropdown placement="bottom-end">
          <span class="el-dropdown__box">
            <el-avatar :src="avatar" />
            <el-icon><CaretBottom /></el-icon>
          </span>
          <template #dropdown>
            <el-dropdown-menu>
              <el-dropdown-item command="profile" :icon="User"
                >基本资料</el-dropdown-item
              >
              <el-dropdown-item command="avatar" :icon="Crop"
                >更换头像</el-dropdown-item
              >
              <el-dropdown-item command="password" :icon="EditPen"
                >重置密码</el-dropdown-item
              >
              <el-dropdown-item command="logout" :icon="SwitchButton"
                >退出登录</el-dropdown-item
              >
            </el-dropdown-menu>
          </template>
        </el-dropdown>
      </el-header>
      <el-main>
        <router-view></router-view>
      </el-main>
      <el-footer>大事件 ©2023 Created by 黑马程序员</el-footer>
    </el-container>
  </el-container>
</template>

<style lang="scss" scoped>
.layout-container {
  height: 100vh;
  .el-aside {
    background-color: #232323;
    &__logo {
      height: 120px;
      background: url('@/assets/logo.png') no-repeat center / 120px auto;
    }
    .el-menu {
      border-right: none;
    }
  }
  .el-header {
    background-color: #fff;
    display: flex;
    align-items: center;
    justify-content: space-between;
    .el-dropdown__box {
      display: flex;
      align-items: center;
      .el-icon {
        color: #999;
        margin-left: 10px;
      }

      &:active,
      &:focus {
        outline: none;
      }
    }
  }
  .el-footer {
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 14px;
    color: #666;
  }
}
</style>

4.2 访问拦截

官方手册:导航守卫 | Vue Router 

// router/index.js
import { useUserStore } from '@/stores'

// ...
router.beforeEach((to) => {
  const userStore = useUserStore()
  if (!userStore.token && to.path !== '/login') return '/login'
})

默认是直接放行

根据返回值决定,是放行还是拦截

返回值:

1. undefined / true :直接放行

2. false :拦截到 from 的地址页面

3. 具体路径  或     路径对象         :拦截到对于的地址

        ‘/login’        { name: 'login' }


4.3 获取用户数据&动态渲染

如果使用生命周期函数,记得从vue中导入

此处不再赘述,具体见黑马文档

1. 配置api

2. 在store中封装请求api并存储的函数

3. 在首页中调用store的函数


4.4 退出功能

4.4.1 下拉菜单

手册:Dropdown 下拉菜单 | Element Plus

见其中的指令事件 

<el-dropdown placement="bottom-end" @command="handleCommand">
  <span class="el-dropdown__box">
    <el-avatar :src="userStore.userInfo.user_pic || avatar" />
    <el-icon><CaretBottom /></el-icon>
  </span>
  <template #dropdown>
    <el-dropdown-menu>
      <el-dropdown-item command="profile" :icon="User">基本资料</el-dropdown-item>
      <el-dropdown-item command="avatar" :icon="Crop">更换头像</el-dropdown-item>
      <el-dropdown-item command="password" :icon="EditPen">重置密码</el-dropdown-item>
      <el-dropdown-item command="logout" :icon="SwitchButton">退出登录</el-dropdown-item>
    </el-dropdown-menu>
  </template>
</el-dropdown>

注意:给整个下拉菜单绑定一个command监听事件,并给一个回调函数,当点击选项时,会触发此回调函数,并将选项中的command值传给回调函数,可以用一个形参接收

// ...

const handleCommand = async (command) => {
  if (command === 'logout') {
    // 退出登录
    await ElMessageBox.confirm(
      '您确定要退出登录吗?',
      '温馨提示',
      {
        type: 'warning',
        confirmButtonText:'确定',
        cancelButtonText:'取消'
      }
    )
    // 清空内容
    userStore.removeToken()
    userStore.removeUserInfo()
    // 跳转登录页面
    router.push('/login')
  } else {
    // 跳转
    router.push(`/user/${command}`)
  }
}

4.4.2 消息弹出框

手册:MessageBox 消息弹框 | Element Plus

见其中的确认消息

注意:

1.给消息弹出框使用异步标识符 async await,否则还没点击确认,就已经退出了

2.由于配置了element组件库的自动导入,有时候如果上面再手动导入相应的模块时,反而会失效


5 文章分类

5.1 文章分类架子—封装 card 组件

官方手册:Card 卡片 | Element Plus

插槽基础知识: 

【VUE2】第三期——样式冲突、组件通信、异步更新、自定义指令、插槽!!-CSDN博客

<!-- components/pageContainer.vue -->

<script setup>
defineProps({
  title:{
    type: String,
    required: true
  }
})
</script>

<template>
  <el-card class="page-container">
    <template #header>
      <div class="header">
        <span>{{ title }}</span>
        <slot name="extra"></slot>
      </div>
    </template>
    <slot></slot>
  </el-card>
</template>

<style lang="scss" scoped>
.page-container {
  min-height: 100%;
  box-sizing: border-box;
  .header {
    display: flex;
    align-items: center;
    justify-content: space-between;
  }
}
</style>

调用组件

<template>
  <page-container title="频道管理">    <!--传参-->
    <template #extra>    <!--具名插槽-->
      <el-button type="primary">添加分类</el-button>
    </template>
    主体部分    <!--默认插槽(即除了具名插槽以外的部分)-->
  </page-container>
</template>

5.2 文章分类渲染

5.2.1 封装获取文章分类列表api

// api/article.js

import request from '@/utils/request'

// 获取文章列表
export const artGetArticleListService = () => request.get('/my/cate/list')

5.2.2 封装 pinia 

// stores/modules/article.js

import { defineStore } from 'pinia'
import { ref } from 'vue'
import { artGetArticleListService } from '@/api/article'
export const useArticleStore = defineStore('article', () => {
  // 文章分类列表
  const articleClassList = ref()
  // 控制loading效果开关
  const articleLoading = ref(false)
  // 获取文章分类列表
  const getArticleClassList = async () => {
    articleLoading.value = true
    const res = await artGetArticleListService()
    articleClassList.value = res.data.data
    articleLoading.value = false
  }

  return {
    articleClassList,
    getArticleClassList,
    articleLoading
  }
})

在 stores/index.js 中导出

export * from '@/stores/modules/article'

5.2.3 页面布局、数据渲染、loading效果及空状态

使用到的组件:

Button 按钮 | Element Plus

Table 表格 | Element Plus  

Loading 加载 | Element Plus

Empty 空状态 | Element Plus

完整代码示例: 

<!--views/article/ArticleChannel.vue-->

<script setup>
// 导入按钮图标
import { Edit, Delete } from '@element-plus/icons-vue'
// 导入pinia
import { useArticleStore } from '@/stores'
const articleStore = useArticleStore()
// 获取数据并存入pinia
articleStore.getArticleClassList()
// 每一个item的点击事件,获取索引与整个item对象,其他参数见 表格手册 中 的 自定义列模板
const handleEdit = ($index, row) => {
  console.log($index, row)
}
const handleDelete = ($index, row) => {
  console.log($index, row)
}
</script>

<template>
  <page-container title="频道管理">
    <template #extra>
      <el-button type="primary">添加分类</el-button>
    </template>
    <el-table
      :data="articleStore.articleClassList" <!--绑定列表数据-->
      style="width: 100%"
      v-loading="articleStore.articleLoading"
    >
      <el-table-column label="序号" type="index" width="80"></el-table-column>
      <!--label是显示的文字,type="index"是显示序号,width是宽度,prop是绑定数据-->
      <el-table-column prop="cate_name" label="分类名"></el-table-column>
      <el-table-column prop="cate_alias" label="分类别名"></el-table-column>
      <el-table-column label="操作" width="150">
        <!--这里通过默认插槽后的变量名,获取插槽传来的值-->
        <template #default="{ $index, row }">
          <el-button
            type="primary"    <!--按钮类型(颜色)这里不应该写注释,使用时记得删掉-->
            :icon="Edit"      <!--按钮图标-->
            circle            <!--设置为圆形-->
            plain             <!--设置为镂空-->
            @click="handleEdit($index, row)"
          ></el-button>
          <el-button
            type="danger"
            :icon="Delete"
            circle
            plain
            @click="handleDelete($index, row)"
          ></el-button>
        </template>
      </el-table-column>
      <!--通过empty具名插槽来设置无数据时的状态-->
      <template #empty>
        <el-empty description="无数据" />
      </template>
    </el-table>
  </page-container>
</template>

5.3 弹层表单

手册:Dialog 对话框 | Element Plus

封装成组件:

<!--view/article/components/ChannelEdit.vue-->

<script setup>
import { ref } from 'vue'
const dialogVisible = ref(false)    // 控制显示与隐藏
// 封装控制开关的方法
const open = (row) => {    // row用来接收外部传进来的值
  dialogVisible.value = true
  console.log(row)
}
// 向外暴露出方法
defineExpose({
  open
})
</script>

<template>
  <el-dialog v-model="dialogVisible" title="Tips" width="500">
    <span>消息标题</span>
    <template #footer>
      <div class="dialog-footer">
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary" @click="dialogVisible = false"> 确定 </el-button>
      </div>
    </template>
  </el-dialog>
</template>

使用:

<!--views/article/ArticleChannel.vue-->

<script setup>
import { Edit, Delete } from '@element-plus/icons-vue'
import { useArticleStore } from '@/stores'
// 导入组件
import ChannelEdit from './components/ChannelEdit.vue'
import { ref } from 'vue'
const articleStore = useArticleStore()
// 获取组件对象,以获取内部方法
const dialog = ref()
articleStore.getArticleClassList()
// 编辑按钮事件
const handleEdit = ($index, row) => {
  // 使用组件内部提供的方法,向组件传递当前item的值
  dialog.value.open(row)
}
// 删除按钮事件
const handleDelete = ($index, row) => {
  console.log($index, row)
}
// 新增按钮事件
const onAddChannel = () => {
  // 使用组件内部提供的方法,向组件传递空对象
  dialog.value.open({})
}
</script>

<template>
  <page-container title="频道管理">
    <template #extra>
      <el-button type="primary" @click="onAddChannel()">添加分类</el-button>
    </template>
    <el-table
      :data="articleStore.articleClassList"
      style="width: 100%"
      v-loading="articleStore.articleLoading"
    >
      <el-table-column label="序号" type="index" width="80"></el-table-column>
      <el-table-column prop="cate_name" label="分类名"></el-table-column>
      <el-table-column prop="cate_alias" label="分类别名"></el-table-column>
      <el-table-column label="操作" width="150">
        <template #default="{ $index, row }">
          <el-button
            type="primary"
            :icon="Edit"
            circle
            plain
            @click="handleEdit($index, row)"
          ></el-button>
          <el-button
            type="danger"
            :icon="Delete"
            circle
            plain
            @click="handleDelete($index, row)"
          ></el-button>
        </template>
      </el-table-column>
      <template #empty>
        <el-empty description="无数据" />
      </template>
    </el-table>
  </page-container>
  <!--弹层组件-->
  <ChannelEdit ref="dialog"></ChannelEdit>
</template>

5.3.1 表单检测

规则校验见3.2 

<!--views/article/components/ChannelEdit.vue-->

<script setup>
// ...
// 提供表单数据对象
const formData = ref({
  cate_name: '',
  cate_alias: ''
})
// 提供校验规则对象
const formRules = {
  cate_name: [
    { required: true, message: '分类名称不能为空!', trigger: 'blur' },
    { pattern: /^\S{2,6}$/, message: '请输入2-6位非空字符', trigger: 'blur' }
  ],
  cate_alias: [
    { required: true, message: '分类别名不能为空!', trigger: 'blur' },
    { pattern: /^[A-Za-z0-9]{2,9}$/, message: '请输入2-9位字母或数字', trigger: 'blur' }
  ]
}
</script>

<!--下面只需要注意绑定四个值即可-->
<template>
  <el-dialog v-model="dialogVisible" title="新增频道" width="500">
    <el-form label-width="100px" style="padding-right: 30px" :model="formData" :rules="formRules">
      <el-form-item label="分类名称" prop="cate_name">
        <el-input
          placeholder="请输入分类名称"
          v-model="formData.cate_name"
          minlength="2"
          maxlength="6"
        ></el-input>
      </el-form-item>
      <el-form-item label="分类别名" prop="cate_alias">
        <el-input
          placeholder="请输入分类别名"
          v-model="formData.cate_alias"
          minlength="2"
          maxlength="9"
        ></el-input>
      </el-form-item>
    </el-form>
    <template #footer>
      <div class="dialog-footer">
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary" @click="dialogVisible = false"> 确定 </el-button>
      </div>
    </template>
  </el-dialog>
</template>

5.4 更新分类与新增分类功能

第一步:封装接口api

// api/article.js
// ...

// 新增文章分类
export const artAddArticleClassService = ({ cate_name, cate_alias }) =>
  request.post('/my/cate/add', { cate_name, cate_alias })

// 更新文章分类
export const artUpdateArticleClassService = ({ id, cate_name, cate_alias }) =>
  request.put('/my/cate/info', {
    id,
    cate_name,
    cate_alias
  })

第二步:子组件校验数据、提交数据、触发事件

// views/article/components/ChannelEdit.vue

// ...
// 定义向父组件触发的事件
const emit = defineEmits(['success'])
// 获取表单对象
const form = ref()
// 提交方法
const onSubmit = async () => {
  // 提交校验
  await form.value.validate()
  // 发送数据
  formData.value.id
    ? await artUpdateArticleClassService(formData.value)
    : await artAddArticleClassService(formData.value)
  // 提示框
  ElMessage.success(`${formData.value.id ? '已保存!' : '新增成功!'}`)
  // 关闭弹窗
  dialogVisible.value = false
  // 向父组件发送事件,刷新数据
  emit('success')
}

第三步:父组件监听事件并刷新数据

// views/article/ArticleChannel.vue

// ...
// 监听子组件success事件
const onSuccess = () => {
  articleStore.getArticleClassList()
}
<!--弹层组件-->
<ChannelEdit ref="dialog" @success="onSuccess"></ChannelEdit>

5.5 删除分类

第一步:封装api接口

// api/article.js
// ...

// 删除文章分类
export const artDeleteArticleClassService = (id) =>
  request.delete('/my/cate/del', {
    params: {
      id
    }
  })

第二步:调用接口及确认弹窗

// views/article/ArticleChannel.vue
// ...

// 删除按钮事件
const handleDelete = async ($index, row) => {
  // 提示框——确认删除
  await ElMessageBox.confirm('你确认要删除吗?', '温馨提示', {
    type: 'warning',
    confirmButtonText: '确认',
    cancelButtonText: '取消'
  })
  // 调用api
  await artDeleteArticleClassService(row.id)
  // 刷新数据
  articleStore.getArticleClassList()
  // 成功提示
  ElMessage.success('删除成功!')
}

6 文章管理

6.1 基础布局 

运用了表单、表格、链接、按钮等组件

 注意:表格绑定数据除了可以用prop,也可以选择在内部使用作用域插槽来渲染数据

<!--views/article/ArticleManage.vue-->

<script setup>
import { ref } from 'vue'
import { Edit, Delete } from '@element-plus/icons-vue'
const articleList = ref([
  {
    Id: 5961,
    title: '新的文章啊',
    pub_date: '2022-07-10 14:53:52.604',
    state: '已发布',
    cate_name: '体育'
  },
  {
    Id: 5962,
    title: '新的文章啊',
    pub_date: '2022-07-10 14:54:30.904',
    state: '草稿',
    cate_name: '体育'
  }
])
// 编辑事件
const onEdit = (row) => {
  console.log(row)
}

// 删除事件
const onDelete = (row) => {
  console.log(row)
}
</script>

<template>
  <page-container title="文章管理">
    <template #extra>
      <el-button type="primary">新增文章</el-button>
    </template>
    <!--筛选区域——表单-->
    <el-form inline>
      <el-form-item label="文章分类:">
        <el-select style="width: 240px">
          <el-option label="新闻" value="新闻"></el-option>
          <el-option label="体育" value="体育"></el-option>
        </el-select>
      </el-form-item>
      <el-form-item label="发布状态:">
        <el-select style="width: 240px">
          <el-option label="已发布" value="已发布"></el-option>
          <el-option label="草稿" value="草稿"></el-option>
        </el-select>
      </el-form-item>
      <el-form-item>
        <el-button type="primary">搜索</el-button>
        <el-button>重置</el-button>
      </el-form-item>
    </el-form>
    <!--列表区域——表格-->
    <el-table :data="articleList">
      <el-table-column label="文章标题" prop="title">
        <template #default="{ row }">
          <el-link type="primary" :underline="false">{{ row.title }}</el-link>
        </template>
      </el-table-column>
      <el-table-column label="分类" prop="cate_name"></el-table-column>
      <el-table-column label="发表时间" prop="pub_date"></el-table-column>
      <el-table-column label="状态" prop="state"></el-table-column>
      <el-table-column label="操作">
        <template #default="{ row }">
          <el-button type="primary" circle plain :icon="Edit" @click="onEdit(row)"></el-button>
          <el-button type="danger" circle plain :icon="Delete" @click="onDelete(row)"></el-button>
        </template>
      </el-table-column>
    </el-table>
  </page-container>
</template>

6.2 中英语言包切换

手册:全局配置 | Element Plus

注意:

1.默认为英文

2.切换的是组件库内部的文字,自己写的文字不能切换

3.切换的是el-config-provider内部的组件 

<!--App.vue-->

<script setup>
// import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import en from 'element-plus/dist/locale/en.mjs'
</script>
<template>
  <el-config-provider :locale="en">
    <router-view></router-view>
  </el-config-provider>
</template>

6.3 封装获取分类列表组件

<!--views/article/components/ChannelSelect.vue-->

<script setup>
import { artGetArticleClassListService } from '@/api/article'
import { ref } from 'vue'
// 存储文章分类列表
const articleClassList = ref({})
// 获取文章分类列表
const getArticleClassList = async () => {
  const res = await artGetArticleClassListService()
  articleClassList.value = res.data.data
}
getArticleClassList()
// 接收数据
defineProps({
  modelValue: {
    type: [Number, String]
  }
})
// 定义事件
const emit = defineEmits(['update:modelValue'])
</script>
<template>
  <el-select
    style="width: 240px"
    :modelValue="modelValue"
    @update:modelValue="emit('update:modelValue', $event)"
  >
    <el-option
      v-for="classList in articleClassList"
      :key="classList.id"
      :label="classList.cate_name"
      :value="classList.id"
    ></el-option>
  </el-select>
</template>
<!--vews/article/ArticleManage.vue-->

<script setup>
// ...
// 统一管理获取文章列表参数数据
const params = ref({
  pagenum: 1,
  pagesize: 2,
  cate_id: '',
  state: ''
})
</script>

<template>
  <page-container title="文章管理">
    <template #extra>
      <el-button type="primary">新增文章</el-button>
    </template>
    <!--筛选区域——表单-->
    <el-form inline>
      <el-form-item label="文章分类:">
        <ChannelSelect v-model="params.cate_id"></ChannelSelect>// 向组件传数据,并监听事件
      </el-form-item>
      <el-form-item label="发布状态:">
        <el-select style="width: 240px" v-model="params.state">
          <el-option label="已发布" value="已发布"></el-option>
          <el-option label="草稿" value="草稿"></el-option>
        </el-select>
      </el-form-item>
      <el-form-item>
        <el-button type="primary">搜索</el-button>
        <el-button>重置</el-button>
      </el-form-item>
    </el-form>
    <!-- ... -->
  </page-container>
</template>

6.4 封装获取列表接口并格式化时间

第一步:封装获取文章列表接口

// api/article.js

// ...

// 获取文章列表
export const artGetArticleListService = (params) =>
  request.get('/my/article/list', {
    params
  })

第二步:调用接口,获取数据

// views/article/ArticleManage.vue
import { artGetArticleListService } from '@/api/article'

// ...
// 统一管理获取文章列表参数数据
const params = ref({
  pagenum: 1,
  pagesize: 5,
  cate_id: '',
  state: ''
})
// 文章数据列表
const articleList = ref([])
// 文章总条数
const articleToatlNum = ref()
// 获取文章列表
const getArticleList = async () => {
  const res = await artGetArticleListService(params.value)
  articleList.value = res.data.data
  articleToatlNum.value = res.data.total
}
getArticleList()

第三步:封装格式化时间工具

// utils/format.js

import { dayjs } from 'element-plus'

export const formatTime = (time) => dayjs(time).format('YYYY年MM月DD日')

第四步:使用

<!-- views/article/ArticleManage.vue -->

<script setup>
<!-- ... -->
import { formatTime } from '@/utils/format'
<!-- ... -->
</script>

<!-- ... -->
<el-table-column label="发表时间" prop="pub_date">
  <template #default="{ row }">
    {{ formatTime(row.pub_date) }}
  </template>
</el-table-column>
<!-- ... -->

6.5 分页渲染

手册:Pagination 分页 | Element Plus 

<!--分页器-->
<el-pagination
  v-model:current-page="params.pagenum"
  v-model:page-size="params.pagesize"
  :page-sizes="[2, 3, 5, 10]"
  :background="true"
  layout="jumper, total, sizes, prev, pager, next"
  :total="articleToatlNum"
  @size-change="onSize"
  @current-change="onCurrent"
  style="justify-content: flex-end; margin-top: 20px"
/>
// 单页条数改变时触发
const onSize = (size) => {
  // 重置为第一页
  params.value.pagenum = 1
  // 改变每页数量
  params.value.pagesize = size
  // 重新渲染
  getArticleList()
}
// 页码改变时触发
const onCurrent = (page) => {
  // 改变页码
  params.value.pagenum = page
  // 重新渲染
  getArticleList()
}

添加loading效果:

此处不再赘述


6.6 搜索与重置功能

给按钮绑定点击事件即可

// views/article/ArticleManage.vue

// 搜索
const onSearch = () => {
  // 重回第一页
  params.value.pagenum = 1
  // 重新获取数据并渲染
  getArticleList()
}

// 重置
const onReset = () => {
  // 重回第一页
  params.value.pagenum = 1
  // 清空搜索条件
  params.value.cate_id = ''
  params.value.state = ''
  // 重新获取数据并渲染
  getArticleList()
}

空状态处理:

<!--views/article/ArticleManage.vue-->

<!-- ... -->
      </el-table-column>
      <template #empty>
        <el-empty description="无数据" />
      </template>
    </el-table>
<!-- ... -->

6.7 新增与编辑功能——抽屉组件

手册:Drawer 抽屉 | Element Plus

<!--views/article/components/ArticleEdit.vue-->

<script setup>
import { ref } from 'vue'
const isShow = ref(false)
// 封装向外暴露的方法——用来控制抽屉是否显示与判断是什么类型的
const open = (data) => {
  // 显示抽屉
  isShow.value = true
  console.log(data)
}
// 向外暴露对象
defineExpose({
  open
})
</script>

<template>
  <el-drawer v-model="isShow" title="标题">
    <span>正文</span>
  </el-drawer>
</template>
<!--views/article/ArticleManage.vue-->

<script setup>
// ...
import ArticleEdit from './components/ArticleEdit.vue'

// ...
// 获取抽屉组件对象
const articleEditRef = ref()

// 新增文章事件
const onAdd = () => {
  articleEditRef.value.open({})
}
// 编辑文章事件
const onEdit = (row) => {
  articleEditRef.value.open(row)
}

// 删除文章事件
const onDelete = (row) => {
  console.log(row)
}
</script>

<template>
<!-- ... -->
  </page-container>
  <!--抽屉-->
  <ArticleEdit ref="articleEditRef"></ArticleEdit>
</template>

6.8 编辑模板

<!--views/article/ArticleManage.vue-->

<script setup>
import { ref } from 'vue'
import ChannelSelect from './ChannelSelect.vue'
const isShow = ref(false)
// 封装向外暴露的方法——用来控制抽屉是否显示与判断是什么类型的
const open = (data) => {
  // 显示抽屉
  isShow.value = true
  // 判断编辑还是新增
  if (data.id) {
    // id存在,则为编辑
  } else {
    // id不存在,则为新增
    // 清空表单数据
    formModel.value = { ...formDefault }
  }
}
// 向外暴露对象
defineExpose({
  open
})
// 默认表单数据
const formDefault = {
  title: '',
  cate_id: '',
  cover_img: '',
  content: '',
  state: ''
}

// 表单数据
const formModel = ref({ ...formDefault })
</script>

<template>
  <el-drawer
    v-model="isShow"
    :title="formModel.id ? '编辑文章' : '添加文章'"
    direction="rtl"
    size="50%"
  >
    <!-- 发表文章表单 -->
    <el-form :model="formModel" ref="formRef" label-width="100px">
      <el-form-item label="文章标题" prop="title">
        <el-input v-model="formModel.title" placeholder="请输入标题"></el-input>
      </el-form-item>
      <el-form-item label="文章分类" prop="cate_id">
        <channel-select v-model="formModel.cate_id" width="100%"></channel-select>
      </el-form-item>
      <el-form-item label="文章封面" prop="cover_img"> 文件上传 </el-form-item>
      <el-form-item label="文章内容" prop="content">
        <div class="editor">富文本编辑器</div>
      </el-form-item>
      <el-form-item>
        <el-button type="primary">发布</el-button>
        <el-button type="info">草稿</el-button>
      </el-form-item>
    </el-form>
  </el-drawer>
</template>

上述代码中给分类选择组件传递了宽度参数,若想使用,应将选择组件内部按如下配置:

// 接收数据
defineProps({
  modelValue: {
    type: [Number, String]
  },
  width: {
    type: String,
    default: 'width: 240px'
  }
})
  <el-select
    :style="width"
    :modelValue="modelValue"
    @update:modelValue="emit('update:modelValue', $event)"
  >

注意:黑马程序的案例中并没有给默认值,仍然可以显示一定宽度,但是新版貌似必须给一个宽度,否则不能正常显示


6.9 图片上传

手册:Upload 上传 | Element Plus

        <el-upload
          class="avatar-uploader"
          :auto-upload="false"
          :show-file-list="false"
          :on-change="onUploadFile"
        >
          <img v-if="imageUrl" :src="imageUrl" class="avatar" />
          <el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
        </el-upload>

该组件默认会自动将选择的图片上传到指定的服务器上,需要手动关闭

一般选择与其他表单数据一起上传到服务器上

// 接收图片链接
const imageUrl = ref()

// 上传图像
const onUploadFile = (uploadFile) => {
  // 回显
  imageUrl.value = URL.createObjectURL(uploadFile.raw)
  // 将图片保存到表单数据对象中
  formModel.value.cover_img = uploadFile.raw
}

样式可以从手册中截取,如:

<style scoped>
.avatar-uploader .avatar {
  width: 178px;
  height: 178px;
  display: block;
}
</style>

<style>
.avatar-uploader .el-upload {
  border: 1px dashed var(--el-border-color);
  border-radius: 6px;
  cursor: pointer;
  position: relative;
  overflow: hidden;
  transition: var(--el-transition-duration-fast);
}

.avatar-uploader .el-upload:hover {
  border-color: var(--el-color-primary);
}

.el-icon.avatar-uploader-icon {
  font-size: 28px;
  color: #8c939d;
  width: 178px;
  height: 178px;
  text-align: center;
}
</style>

也可以用黑马程序员的代码:scss写法

<style scoped lang="scss">
.avatar-uploader {
  :deep() {
    .avatar {
      width: 178px;
      height: 178px;
      display: block;
    }
    .el-upload {
      border: 1px dashed var(--el-border-color);
      border-radius: 6px;
      cursor: pointer;
      position: relative;
      overflow: hidden;
      transition: var(--el-transition-duration-fast);
    }
    .el-upload:hover {
      border-color: var(--el-color-primary);
    }
    .el-icon.avatar-uploader-icon {
      font-size: 28px;
      color: #8c939d;
      width: 178px;
      height: 178px;
      text-align: center;
    }
  }
}
.editor {
  width: 100%;
  :deep(.ql-editor) {
    min-height: 200px;
  }
}
</style>

6.10 富文本编辑器

官网:VueQuill | Rich Text Editor Component for Vue 3

第一步:安装包

pnpm add @vueup/vue-quill@latest

第二部:局部使用

<script setup>
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
// ...
</script>

<!-- .... -->

<QuillEditor theme="snow" />

第三步:样式美化

<style scoped lang="scss">
/*
    ...
*/
.editor {
  width: 100%;
  :deep(.ql-editor) {
    min-height: 200px;
  }
}
</style>

6.11 添加文章

第一步:封装接口

// 发布文章
export const artPublishArticle = (data) => request.post('/my/article/add', data)

第二步:编写点击事件

重点:

1.清空富文本编辑器的内容

2.普通对象数据转化成formdata数据类型

// 定义事件
const emit = defineEmits(['success'])

// 获取富文本编辑器对象,以便使用其身上的方法来清空数据
const editorRef = ref()

// 提交数据
const onPublish = async (state) => {
  // 设置发布类型
  formModel.value.state = state
  // 数据转换成 formData 类型
  const fd = new FormData()
  for (let key in formModel.value) {
    fd.append(key, formModel.value[key])
  }
  // 调用接口
  if (formModel.value.id) {
    // id存在,则为编辑
  } else {
    // 否则为新增
    // 提交数据
    await artPublishArticle(fd)

    // 关闭抽屉
    isShow.value = false
    // 通知父组件成功,以做相应处理
    emit('success', 'add')
    // 清空表单中的图片与富文本编辑器的内容    ❗️这里的内容位置在6.12中有做改变
    imageUrl.value = ''
    editorRef.value.setHTML('')
    // 弹窗提示
    ElMessage.success('成功提交!')
  }
}
<el-form-item>
  <el-button @click="onPublish('已发布')" type="primary">发布</el-button>
  <el-button @click="onPublish('草稿')" type="info">草稿</el-button>
</el-form-item>

 第三步:父组件监听事件并跳转最后一页

重点:计算最后一页 

// 成功提交处理函数
const onSuccess = (type) => {
  if (type === 'add') {
    // 新增文章处理
    // 计算最后一页
    const lastPage = Math.ceil((articleToatlNum.value + 1) / params.value.pagesize)
    // 跳转到最后一页
    params.value.pagenum = lastPage
  }
  // 重新渲染数据
  getArticleList()
}
 <!--抽屉-->
  <ArticleEdit ref="articleEditRef" @success="onSuccess"></ArticleEdit>

第四步:表单校验

这里不再赘述 


6.12 编辑回显与提交数据

第一步:在request.js中导出基地址

export { baseURL }

第二步:封装api

// 获取文章详情数据
export const artGetArticleDetailService = (id) =>
  request.get('/my/article/info', { params: { id } })

// 更新文章详情
export const artUpdateArticleDetailService = (data) => request.put('/my/article/info', data)

第三步:在判断编辑还是新增事件中使用接口,同时添加了loading效果

注意:此处将得到的图片链接又转换成了file格式,因为后端不支持

import { artGetArticleDetailService } from '@/api/article'
import axios from 'axios'
import { baseURL } from '@/utils/request'

// loading
const isLoading = ref(false)
// 封装向外暴露的方法——用来控制抽屉是否显示与判断是什么类型的
const open = async (data) => {
  // 显示抽屉
  isShow.value = true
  // 判断编辑还是新增
  if (data.id) {
    // id存在,则为编辑
    // 将id存到formModel中
    formModel.value.id = data.id
    isLoading.value = true
    // 获取文章详情数据
    const res = await artGetArticleDetailService(data.id)
    formModel.value = res.data.data
    // 拼接图片链接
    imageUrl.value = baseURL + formModel.value.cover_img
    // 将url转为file
    const file = await imageUrlToFile(imageUrl.value, formModel.value.cover_img)
    // 重新存入formModel
    formModel.value.cover_img = file
    isLoading.value = false
  } else {
    // id不存在,则为新增
    // 清空表单数据
    formModel.value = { ...formDefault }
    // 清空表单中的图片与富文本编辑器的内容 ❗️6.11中的内容移到这了
    imageUrl.value = ''
    editorRef.value.setHTML('')
  }
}

// ai
// 将网络图片地址转换为File对象
async function imageUrlToFile(url, fileName) {
  try {
    // 第一步:使用axios获取网络图片数据
    const response = await axios.get(url, { responseType: 'arraybuffer' })
    const imageData = response.data

    // 第二步:将图片数据转换为Blob对象
    const blob = new Blob([imageData], { type: response.headers['content-type'] })

    // 第三步:创建一个新的File对象
    const file = new File([blob], fileName, { type: blob.type })

    return file
  } catch (error) {
    console.error('将图片转换为File对象时发生错误:', error)
    throw error
  }
}

第四步:提交数据

import { artUpdateArticleDetailService } from '@/api/article'
// 提交数据
const onPublish = async (state) => {
  // 设置发布类型
  formModel.value.state = state
  // 数据转换成 formData 类型
  const fd = new FormData()
  for (let key in formModel.value) {
    fd.append(key, formModel.value[key])
  }
  // 调用接口
  if (formModel.value.id) {
    // id存在,则为编辑
    // 提交数据
    await artUpdateArticleDetailService(fd)
    // 关闭抽屉
    isShow.value = false
    // 通知父组件成功,以做相应处理
    emit('success', 'edit')
    // 弹窗提示
    ElMessage.success('修改成功!')
  } else {
    // 否则为新增
    // 提交数据
    await artPublishArticle(fd)
    // 关闭抽屉
    isShow.value = false
    // 通知父组件成功,以做相应处理
    emit('success', 'add')
    // 弹窗提示
    ElMessage.success('提交成功!')
  }
}

6.13 删除文章

此部分见黑马文档,此处不再赘述


7 个人中心

此部分暂不更新,至此本文完 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值