[vue3]大事件-admin

发布于:2024-06-18 ⋅ 阅读:(58) ⋅ 点赞:(0)

介绍

pnpm

pnpm是最新的包管理器, 比同类工具快2倍, 更节省磁盘空间

  1. npm->yarn->pnpm
  2. 官网 https://www.pnpm.cn/
  3. 安装 npm install -g pnpm
  4. 创建项目 pnpm create vue

Eslint代码校验

Eslint专注于代码规范校验, prettier专注于代码风格校验(格式化)

prettier官网: Prettier · Opinionated Code Formatter

前置操作

  • 安装eslint插件, 配置保存时自动修复(参考前端环境)
  • 禁用格式化插件 prettier
  • 关闭编辑器的保存格式代码选项 (参考前端环境)

  • 确保项目中安装了 prettier 依赖包

代码风格介绍

  1. 单引号
  2. 不使用分号
  3. 代码宽度80字符,超出换行
  4. 不加对象|数组最后逗号
  5. 换行符不限制(win mac不一致)

代码规范介绍

  1. vue组件名称必须多单词组成(忽略index.vue)
  2. props解构警告(关闭)

代码配置

module.exports = {
 ... ...
  rules: {
    // prettier专注于代码的美观度 (格式化工具)
    'prettier/prettier': [
      'warn',
      {
        singleQuote: true, // 单引号
        semi: false, // 无分号
        printWidth: 80, // 每行宽度至多80字符
        trailingComma: 'none', // 不加对象|数组最后逗号
        endOfLine: 'auto' // 换行符号不限制(win mac 不一致)
      }
    ],
    // ESLint关注于规范, 如果不符合规范,报错
    'vue/multi-word-component-names': [
      'warn',
      {
        ignores: ['index'] // vue组件名称多单词组成(忽略index.vue)
      }
    ],
    'vue/no-setup-props-destructure': ['off'], // 关闭 props 解构的校验 (props解构丢失响应式)
    // 添加未定义变量错误提示,create-vue@3.6.3 关闭,这里加上是为了支持下一个章节演示。
    'no-undef': 'error'
  },
... ...
}

提交代码检查

保证提交到仓库的代码是符合规范的, 没有错误的, husky是一个git hooks工具 (git钩子工具, 在特定时机执行特定的命令)

  1. 官网: Husky
  2. 初始化git仓库( Git Bash终端 ), 命令: git init
  3. 初始化husky工具配置, 命令: pnpm dlx husky-init && pnpm install
  4. 修改配置文件
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

pnpm lint
  1. 执行流程: 提交代码触发per-commit文件执行, 文件中配置了pnpm lint命令, 该命令执行后, 会检查配置范围内的文件, 是否符合规范, 不合格会尝试处理, 合格后才可以提交代码
{
  "scripts": {
    ... ...
    "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
    ... ...
  },
}
  1. pnpm lint是全量检查, 存在耗时问题, 历史问题,
  2. 如果希望团队的每个人只对自己的代码负责, 往下看

暂存区eslint校验

  1. 安装包: pnpm i lint-staged -D
  2. 配置命令
{
  "scripts": {
    ... ...
    "lint-staged": "lint-staged"
  },
  ... ...
  "lint-staged": {
    "*.{js,ts,vue}": [
      "eslint --fix"
    ]
  }
}
  1. 修改配置文件
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

pnpm lint-staged

规范目录

默认生成的目录结构不完全满足开发的需求, 需要做一些自定义改动

  1. 删除项目多余文件和默认样式文件
  2. 清除不必要的代码
  3. 新增utils文件夹和api文件夹
  4. 拷贝初始化样式, 并在main.js引用

import '@/assets/main.scss'

  1. 安装sass预处理器

pnpm add sass -D

ElementPlus

根据官网, 按需引入elementPlus

官网: 一个 Vue 3 UI 框架 | Element Plus

步骤

  1. 安装 pnpm install element-plus
  2. 配置按需引入
  • 安装插件 npm install -D unplugin-vue-components unplugin-auto-import
// elementPlus按需引入
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: [
    vue(),
    AutoImport({
      resolvers: [ElementPlusResolver()]
    }),
    Components({
      resolvers: [ElementPlusResolver()]
    })
  ],
})
  1. 直接使用组件
  2. 彩蛋: component中的所有组件也会被自动注册, 使用更方便

构建仓库

1.0创建仓库

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

export const useUserStore = defineStore(
  'big-user',
  () => {
    // token相关
    const token = ref('')
    const setToken = (newToken) => {
      token.value = newToken
    }
    const removeToken = () => {
      token.value = ''
    }

    return {
      token,
      setToken,
      removeToken,
    }
  },
  {
    persist: true
  }
)

2.0管理仓库

// 创建仓库实例
// 配置持久化插件
// 目的: 简化main.js中的代码
import { createPinia } from 'pinia'
import persist from 'pinia-plugin-persistedstate' //pinia持久化插件

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

export default pinia

// 统一导出
// 目的:  简化导入仓库的路径
// 解释:  所有的仓库在该文件统一导出, 导入时无需纠结路径问题
// 示例:  import { useUserStore } from '@/stores'
export * from './modules/user'

3.0挂载仓库

... ...
import pinia from '@/stores/index'
appp.use(pinia)
... ...

封装axios

1.0常见需求

2.0安装命令

  • pnpm add axios

3.0模块代码

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 request = axios.create({
  // TODO 1. 基础地址,超时时间
  baseURL,
  timeout: 10000
})

// 请求拦截器
request.interceptors.request.use(
  // 对请求做处理
  (config) => {
    // TODO 2. 携带token
    const useStore = useUserStore()
    if (useStore.token) {
      config.headers.Authorization = useStore.token
    }
    return config
  },
  // 对错误做处理
  (err) => Promise.reject(err)
)

// 响应拦截器
request.interceptors.response.use(
  // 2xx的响应
  (res) => {
    // TODO 4. 摘取核心响应数据
    if (res.data.code === 0) {
      return res
    }
    // TODO 3. 处理业务失败
    // 处理业务失败, 给错误提示,抛出错误
    ElMessage.error(res.data.message || '服务异常')
    return Promise.reject(res.data)
  },
  // 超出2xx的响应
  (err) => {
    // TODO 5. 处理401错误
    // 错误的特殊情况 => 401 权限不足 或 token 过期 => 拦截到登录
    if (err.response?.status === 401) {
      router.push('/login')
    }

    // 错误的默认情况 => 只要给提示
    ElMessage.error(err.response.data.message || '服务异常')
    return Promise.reject(err)
  }
)

export default request
export { baseURL }

路由设计

1.0路由划分

  1. 登录页 -> 一级路由
  2. layout -> 一级路由
  • 文章分类 -> 二级路由
  • 文章管理 -> 二级路由
  • 基本资料 -> 二级路由
  • 更换头像 -> 二级路由
  • 重置密码 -> 二级路由

2.0约定规则

3.0配置规则和路由出口

import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores'

// createRouter()创建路由对象
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
<template>
  <RouterView></RouterView>
</template>
<template>
  <div>layout架子</div>
  <div>
    <!-- 二级路由出口 -->
    <router-view></router-view>
  <div>
</template>

4.0手动修改路由,进行测试

登录注册

1.0效果展示

2.0需求分析
  1. 注册登录页的静态结构, 切换效果
  2. 注册功能(校验+注册)
  3. 登录功能(校验+登录+存token)
3.0搭建静态
  1. 使用布局组件辅助布局
  • el-row表示一行,一行分成24份
  • el-col表示一列

(1) :span="12" 代表在一行中,占12份 (50%)

(2) :span="6" 表示在一行中,占6份 (25%)

(3) :offset="3" 代表在一行中,左侧margin份数

  1. 安装需要的图标库
  • pnpm i @element-plus/icons-vue
  1. 使用el-form 表单组件

(1)el-form-item 表单的一行 (一个表单域)

(2)el-input 表单元素(输入框)

  1. 通过v-if切换表单组件
<script setup>
import { User, Lock } from '@element-plus/icons-vue'
const isRegister = ref(false)
</script>

<template>
  <el-row class="login-page">
    <el-col :span="12" class="bg"></el-col>
    <el-col :span="6" :offset="3" class="form">
      <!-- 1,注册表单 -->
      <el-form
        :model="formModel"
        :rules="rules"
        ref="form"
        size="large"
        autocomplete="off"
        v-if="isRegister"
      >
       ......
      </el-form>

      <!-- 2,登录表单 -->
      <el-form
        :model="formModel"
        :rules="rules"
        ref="form"
        size="large"
        autocomplete="off"
        v-else
      >
        ... ...
      </el-form>
    </el-col>
  </el-row>
</template>
4.0注册功能

完成表单校验

<script setup>
import { ref, watch } from 'vue'


// 整个的用于提交的form数据对象
const formModel = ref({
  username: 'dsj007',
  password: 'dsj007',
  repassword: ''
})

// 表单的校验规则
// 1. 非空校验 required: true      message消息提示,  trigger触发校验的时机 blur change
// 2. 长度校验 min:xx, max: xx
// 3. 正则校验 pattern: 正则规则    \S 非空字符
// 4. 自定义校验 => 自己写逻辑校验 (校验函数)
//    validator: (rule, value, callback)
//    (1) rule  当前校验规则相关的信息
//    (2) value 所校验的表单元素目前的表单值
//    (3) callback 无论成功还是失败,都需要 callback 回调
//        - callback() 校验成功
//        - callback(new Error(错误信息)) 校验失败
const rules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' },
    { min: 5, max: 10, message: '用户名必须是 5-10位 的字符', trigger: 'blur' }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    {
      pattern: /^\S{6,15}$/,
      message: '密码必须是 6-15位 的非空字符',
      trigger: 'blur'
    }
  ],
  repassword: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    {
      pattern: /^\S{6,15}$/,
      message: '密码必须是 6-15位 的非空字符',
      trigger: 'blur'
    },
    {
      validator: (rule, value, callback) => {
        // 判断 value 和 当前 form 中收集的 password 是否一致
        if (value !== formModel.value.password) {
          callback(new Error('两次输入密码不一致'))
        } else {
          callback() // 就算校验成功,也需要callback
        }
      },
      trigger: 'blur'
    }
  ]
}
</script>

<template>
  <!-- 
      表单校验相关步骤
       (1) el-form => :model="ruleForm"      绑定的整个form的数据对象 { xxx, xxx, xxx }
       (2) el-form => :rules="rules"         绑定的整个rules规则对象  { xxx, xxx, xxx }
       (3) 表单元素 => v-model="ruleForm.xxx" 给表单元素,绑定form的子属性
       (4) el-form-item => prop配置生效的是哪个校验规则 (和rules中的字段要对应)
  -->
  <el-row class="login-page">
    <el-col :span="6" :offset="3" class="form">
      <!-- 注册相关表单 -->
      <el-form
        :model="formModel"
        :rules="rules"
        ref="form"
        size="large"
        autocomplete="off"
        v-if="isRegister"
      >
        <el-form-item>
          <h1>注册</h1>
        </el-form-item>
        <el-form-item prop="username">
          <el-input
            v-model="formModel.username"
            :prefix-icon="User"
            placeholder="请输入用户名"
          ></el-input>
        </el-form-item>
        <el-form-item prop="password">
          <el-input
            v-model="formModel.password"
            :prefix-icon="Lock"
            type="password"
            placeholder="请输入密码"
          ></el-input>
        </el-form-item>
        <el-form-item prop="repassword">
          <el-input
            v-model="formModel.repassword"
            :prefix-icon="Lock"
            type="password"
            placeholder="请输入再次密码"
          ></el-input>
        </el-form-item>
      </el-form>

      <!-- 登录相关表单 -->
      ... ...
    </el-col>
  </el-row>
</template>

注册请求

<script setup>
import { userRegisterService } from '@/api/user.js'
const form = ref()

// 整个的用于提交的form数据对象
const formModel = ref({
  username: 'dsj007',
  password: 'dsj007',
  repassword: ''
})

// 注册
const register = async () => {
  // 注册成功之前,先进行校验,校验成功 → 请求,校验失败 → 自动提示
  await form.value.validate()
  await userRegisterService(formModel.value)
  ElMessage.success('注册成功')
  isRegister.value = false
}
</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
        :model="formModel"
        :rules="rules"
        ref="form"
        size="large"
        autocomplete="off"
        v-if="isRegister"
      >
        ... ...
        <el-form-item>
          <el-button
            @click="register"
            class="button"
            type="primary"
            auto-insert-space
          >
            注册
          </el-button>
        </el-form-item>
        ... ...
      </el-form>
    </el-col>
  </el-row>
</template>
import request from '@/utils/request'

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

解决报错

原因: 配置了组件自动按需导入, 使用ElMessage组件不需要手动导入, 但是eslint工具会认为该组件未声明

解决: eslintrc中声明全局变量名

module.exports = {
 ... ...
  // 声明全局变量, 解决误报错
  globals: {
    ElMessage: 'readonly',
    ElMessageBox: 'readonly',
    ElLoading: 'readonly'
  }
}
5.0登录功能

字段校验

<script setup>
const rules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' },
    { min: 5, max: 10, message: '用户名必须是 5-10位 的字符', trigger: 'blur' }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    {
      pattern: /^\S{6,15}$/,
      message: '密码必须是 6-15位 的非空字符',
      trigger: 'blur'
    }
  ],
}
</script>

<template>
  <el-row class="login-page">
    <el-col :span="6" :offset="3" class="form">
      <!-- 登录相关表单 -->
      <el-form
        :model="formModel"
        :rules="rules"
        ref="form"
        size="large"
        autocomplete="off"
        v-else
      >
        <el-form-item prop="username">
          <el-input
            v-model="formModel.username"
            :prefix-icon="User"
            placeholder="请输入用户名"
          ></el-input>
        </el-form-item>
        <el-form-item prop="password">
          <el-input
            v-model="formModel.password"
            name="password"
            :prefix-icon="Lock"
            type="password"
            placeholder="请输入密码"
          ></el-input>
        </el-form-item>
      </el-form>
    </el-col>
  </el-row>
</template>

登录请求

<script setup>
import { userLoginService } from '@/api/user.js'
import { ref, watch } from 'vue'
import { useUserStore } from '@/stores'
import { useRouter } from 'vue-router'

const isRegister = ref(false)
const form = ref()

// 整个的用于提交的form数据对象
const formModel = ref({
  username: 'dsj007',
  password: 'dsj007',
  repassword: ''
})


// 登录
const userStore = useUserStore()
const router = useRouter()
const login = async () => {
  await form.value.validate()
  const res = await userLoginService(formModel.value)
  userStore.setToken(res.data.token)
  ElMessage.success('登录成功')
  router.push('/')
}

// 切换的时候,重置表单内容
watch(isRegister, () => {
  formModel.value = {
    username: '',
    password: '',
    repassword: ''
  }
})
</script>

<template>
  <el-row class="login-page">
      ... ...
      <!-- 登录相关表单 -->
      <el-form
        :model="formModel"
        :rules="rules"
        ref="form"
        size="large"
        autocomplete="off"
        v-else
      >
        ... ...
        <el-form-item>
          <el-button
            @click="login"
            class="button"
            type="primary"
            auto-insert-space
            >登录</el-button
          >
        </el-form-item>
      </el-form>
  </el-row>
</template>
import request from '@/utils/request'

// 登录接口
export const userLoginService = ({ username, password }) =>
  request.post('/api/login', { username, password })
import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useUserStore = defineStore(
  'big-user',
  () => {
    // token相关
    const token = ref('')
    const setToken = (newToken) => {
      token.value = newToken
    }
    const removeToken = () => {
      token.value = ''
    }

    return {
      token,
      setToken,
      removeToken,
    }
  },
  {
    persist: true
  }
)

首页

1.0需求分析
  1. 基本架子拆解(菜单组件的使用)
  2. 登录访问拦截
  3. 用户基本信息获取&渲染
  4. 退出功能
2.0静态拆解
<template>
  <!-- 
    el-menu 整个菜单组件
      :default-active="$route.path"  配置默认高亮的菜单项
      router  router选项开启,el-menu-item 的 index 就是点击跳转的路径

    el-menu-item 菜单项
      index="/article/channel" 配置的是访问的跳转路径,配合default-active的值,实现高亮
  -->
  <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">
          <!-- 多级菜单的标题 - 具名插槽 title -->
          <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>{{
            userStore.user.nickname || userStore.user.username
          }}</strong>
        </div>
        <el-dropdown placement="bottom-end" @command="handleCommand">
          <!-- 展示给用户,默认看到的 -->
          <span class="el-dropdown__box">
            <el-avatar :src="userStore.user.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>
      </el-header>
      <el-main>
        <router-view></router-view>
      </el-main>
      <el-footer>大事件 ©2023 Created by 黑马程序员</el-footer>
    </el-container>
  </el-container>
</template>
3.0登录访问拦截

router4与rouer3的导航守卫写法略有区别

import { useUserStore } from '@/stores'
... ...
// 路由前置导航守卫
// 不再用next()方法放行-> 改用return返回值控制
// 返回值:
// 1. undefined || true: 放行
// 2. false: 阻止(返回来时地址)
// 3. '.login' || { path: '/login' } : 跳转页面
router.beforeEach((to) => {
  // 没有token且去往非登录页 => 强制去登录页
  const useStore = useUserStore()
  if (!useStore.token && to.path !== '/login') return '/login'
})
4.0基本信息获取&渲染
<script setup>
// 默认头像
import avatar from '@/assets/default.png'
import { useUserStore } from '@/stores'
import { onMounted } from 'vue'
// 获取用户信息
onMounted(() => {
  userStore.getUser()
})
</script>

<template>
  <el-container class="layout-container">
    <!-- 侧边栏 -->
    ... ...
    <el-container>
      <!-- 头部区域 -->
      <!-- 渲染用户信息 -->
      <el-header>
        <div>
          黑马程序员:<strong>{{
            userStore.user.nickname || userStore.user.username
          }}</strong>
        </div>
        <el-dropdown placement="bottom-end" @command="handleCommand">
          <!-- 展示给用户,默认看到的 -->
          <span class="el-dropdown__box">
            <el-avatar :src="userStore.user.user_pic || avatar" />
            <el-icon><CaretBottom /></el-icon>
          </span>

          <!-- 折叠的下拉部分 -->
          <template #dropdown>
              ... ...
          </template>
        </el-dropdown>
      </el-header>
      <!-- 中间区域 -->
      ... ...
      <!-- 底部区域 -->
      ... ...
    </el-container>
  </el-container>
</template>
import request from '@/utils/request'

// 获取用户基本信息
export const userGetInfoService = () => request.get('/my/userinfo')
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { userGetInfoService } from '@/api/user'

export const useUserStore = defineStore(
  'big-user',
  () => {
    // 用户基本信息
    const user = ref({})
    const getUser = async () => {
      const res = await userGetInfoService() // 请求获取数据
      user.value = res.data.data
    }
    const setUser = (obj) => {
      user.value = obj
    }

    return {
      user,
      getUser,
      setUser
    }
  },
  {
    persist: true
  }
)
5.0退出功能
<script setup>
import { useUserStore } from '@/stores'
import { useRouter } from 'vue-router'
const userStore = useUserStore()
const router = useRouter()

// 下拉菜单
const handleCommand = async (key) => {
  if (key === 'logout') {
    // 退出操作
    await ElMessageBox.confirm('你确认要进行退出么', '温馨提示', {
      type: 'warning',
      confirmButtonText: '确认',
      cancelButtonText: '取消'
    })

    // 清除本地的数据 (token + user信息)
    userStore.removeToken()
    userStore.setUser({})
    router.push('/login')
  } else {
    // 跳转操作
    router.push(`/user/${key}`)
  }
}
</script>

<template>
  <el-container class="layout-container">
    ... ...
    <el-container>
      <el-header>
        <div>
          黑马程序员:<strong>{{
            userStore.user.nickname || userStore.user.username
          }}</strong>
        </div>
        <el-dropdown placement="bottom-end" @command="handleCommand">
          <!-- 展示给用户,默认看到的 -->
          <span class="el-dropdown__box">
            <el-avatar :src="userStore.user.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>
      </el-header>
       ... ...
    </el-container>
  </el-container>
</template>

文章分类页

需求

  1. 基本架子 PageContainer 封装
  2. 文章分类渲染 &loading处理
  3. 文章分类添加编辑
  4. 文章分类删除
1.0封装PageContainer

<script setup>
defineProps({
  title: {
    required: true,
    type: String
  }
})
</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;
    justify-content: space-between;
    align-items: center;
  }
}
</style>
2.0文章渲染和loading
import request from '@/utils/request'
// 分类:获取文章分类
export const artGetChannelsService = () => request.get('/my/cate/list')
<script setup>
import { onMounted, ref } from 'vue'
import { Edit, Delete } from '@element-plus/icons-vue'
import { artGetChannelsService, artDelChannelService } from '@/api/article'
const loading = ref(false) // 控制loading

// 分类列表
const channels = ref([])

// 1.获取分类列表
const getChannels = async () => {
  loading.value = true
  const res = await artGetChannelsService()
  channels.value = res.data.data
  loading.value = false
}

onMounted(() => {
  getChannels()
})
</script>

<template>
  <PageContainer title="文章分类">
    <template #extra>
      <el-button @click="addChannel" type="primary">添加分类</el-button>
    </template>
    <template #default>
       <!-- 2.渲染表格内容 -->
       <!-- 3.添加表格loading  -->
      <el-table v-loading="loading" :data="channels" style="width: 100%">
        <el-table-column
          type="index"
          label="序号"
          width="100"
        ></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="{ row }">
            <el-button
              :icon="Edit"
              circle
              plain
              type="primary"
            ></el-button>
            <el-button
              :icon="Delete"
              circle
              plain
              type="danger"
            ></el-button>
          </template>
        </el-table-column>
        // 4.添加空提示
        <template #empty>
          <el-empty description="没有数据"></el-empty>
        </template>
      </el-table>
    </template>
  </PageContainer>
</template>
3.0封装弹层组件
<script setup>
import { ref } from 'vue'
const dialogVisible = ref(false) // 控制弹出框

// 组件对外暴露一个方法 open,基于open传来的参数,区分添加还是编辑
// open({})  => 表单无需渲染,说明是添加
// open({ id, cate_name, ... })  => 表单需要渲染,说明是编辑
// open调用后,可以打开弹窗
const open = (row) => {
  dialogVisible.value = true
}

// 向外暴露方法
defineExpose({
  open
})
</script>

<template>
  <el-dialog
    v-model="dialogVisible"
    :title="formModel.id ? '编辑分类' : '添加分类'"
    width="30%"
  >
    测试内容
    <template #footer>
      <span class="dialog-footer">
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary" @click="onSubmit"> 确认 </el-button>
      </span>
    </template>
  </el-dialog>
</template>
<script setup>
import ChannelDialog from './component/ChannelDialog.vue'
const dialog = ref(null) // 弹窗组件DOM
// 添加
const addChannel = () => {
  dialog.value.open({})
}

// 编辑
const onEdit = (row) => {
  dialog.value.open(row)
}

onMounted(() => {
  getChannels()
})
</script>

<template>
  <PageContainer title="文章分类">
    <template #extra>
      <el-button @click="addChannel" type="primary">添加分类</el-button>
    </template>
    <template #default>
      <el-table v-loading="loading" :data="channels" style="width: 100%">
        <el-table-column label="操作" width="150">
          <template #default="{ row }">
            <el-button
              :icon="Edit"
              circle
              plain
              type="primary"
              @click="onEdit(row)"
            ></el-button>
            <el-button
              :icon="Delete"
              circle
              plain
              type="danger"
              @click="onDel(row)"
            ></el-button>
          </template>
        </el-table-column>
      </el-table>
      // 弹层组件
      <ChannelDialog ref="dialog" @success="success"></ChannelDialog>
    </template>
  </PageContainer>
</template>
4.0完成添加编辑
<script setup>
import { artEditChannelService, artAddChannelService } from '@/api/article.js'
const dialogVisible = ref(false) // 控制弹出框
const formRef = ref() // 表单DOM, 用于预校验
// 表单数据
const formModel = ref({
  cate_name: '',
  cate_alias: ''
})
// 1.检验规则
const rules = {
  cate_name: [
    { required: true, message: '请输入分类名称', trigger: 'blur' },
    {
      pattern: /^\S{1,10}$/,
      message: '分类名必须是 1-10 位的非空字符',
      trigger: 'blur'
    }
  ],
  cate_alias: [
    { required: true, message: '请输入分类别名', trigger: 'blur' },
    {
      pattern: /^[a-zA-Z0-9]{1,15}$/,
      message: '分类名必须是 1-15 位的字母或数字',
      trigger: 'blur'
    }
  ]
}

// 2.表单提交
const emit = defineEmits(['success'])
const onSubmit = async () => {
  // 提交前校验
  await formRef.value.validate()
  // 区分是否编辑
  const isEdit = formModel.value.id
  if (isEdit) {
    await artEditChannelService(formModel.value)
    ElMessage.success('编辑成功')
  } else {
    await artAddChannelService(formModel.value)
    ElMessage.success('添加成功')
  }
  dialogVisible.value = false
  // 通知父组件更新数据
  emit('success')
}
</script>

<template>
  <el-dialog
    v-model="dialogVisible"
    :title="formModel.id ? '编辑分类' : '添加分类'"
    width="30%"
  >
    <el-form
      ref="formRef"
      :model="formModel"
      :rules="rules"
      label-width="100px"
      style="padding-right: 30px"
    >
      <el-form-item label="分类名称" prop="cate_name">
        <el-input
          v-model="formModel.cate_name"
          placeholder="请输入分类名称"
        ></el-input>
      </el-form-item>
      <el-form-item label="分类别名" prop="cate_alias">
        <el-input
          v-model="formModel.cate_alias"
          placeholder="请输入分类别名"
        ></el-input>
      </el-form-item>
    </el-form>
    <template #footer>
      <span class="dialog-footer">
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary" @click="onSubmit"> 确认 </el-button>
      </span>
    </template>
  </el-dialog>
</template>
import request from '@/utils/request'
// 分类:添加文章分类
export const artAddChannelService = (data) => request.post('/my/cate/add', data)
// 分类:编辑文章分类
export const artEditChannelService = (data) => request.put('/my/cate/info', data)
<script setup>
... ...
// 操作成功,更新数据
const success = () => {
  getChannels()
}
</script>

<template>
  <PageContainer title="文章分类">
      ... ...
      <ChannelDialog ref="dialog" @success="success"></ChannelDialog>
    </template>
  </PageContainer>
</template>
5.0删除文章分类
import request from '@/utils/request'
// 分类:删除文章分类
export const artDelChannelService = (id) =>
  request.delete('/my/cate/del', {
    params: { id }
})
<script setup>
import { artGetChannelsService, artDelChannelService } from '@/api/article'

// 删除
const onDel = async (row) => {
  await ElMessageBox.confirm('你确认要删除该分类么', '温馨提示', {
    type: 'warning',
    confirmButtonText: '确认',
    cancelButtonText: '取消'
  })
  await artDelChannelService(row.id)
  ElMessage.success('删除成功')
  getChannels()
}
</script>

<template>
  <PageContainer title="文章分类">
    <template #default>
      <el-table v-loading="loading" :data="channels" style="width: 100%">
        <el-table-column label="操作" width="150">
          <template #default="{ row }">
            <el-button
              ... ...
              @click="onDel(row)"
            ></el-button>
          </template>
        </el-table-column>
      </el-table>
    </template>
  </PageContainer>
</template>

文章管理页

需求

  1. 文章列表渲染(搜索 & 分页)
  2. 添加文章(抽屉&文件上传&富文本)
  3. 编辑文章(共用抽屉)
  4. 删除文章
1.0静态结构搭建

<template>
  <PageContainer title="文章管理">
    <template #extra>
      <el-button type="primary" @click="addArticle">添加文章</el-button>
    </template>
    <template #default>
      <!-- 筛选 -->
      <el-form inline>
        <el-form-item label="文章分类:">
          <!--vue2: v-model => :value + @input -->
          <!--vue3: v-model => :modelValue + @update:modelValue -->
          <ChannelSelect v-model="params.cate_id"></ChannelSelect>
        </el-form-item>
        <el-form-item label="发布状态:">
          <el-select 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" @click="onSearch">搜索</el-button>
          <el-button @click="onReset">重置</el-button>
        </el-form-item>
      </el-form>

      <!-- 表格 -->
      <el-table :data="articleList" v-loading="loading">
        <el-table-column label="文章标题">
          <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">
          <template #default="{ row }">
            {{ formatTime(row.pub_date) }}
          </template>
        </el-table-column>
        <el-table-column label="状态" prop="state"></el-table-column>
        <el-table-column label="操作">
          <template #default="{ row }">
            <!-- 编辑按钮 -->
            <el-button
              circle
              plain
              type="primary"
              :icon="Edit"
              @click="onEdit(row)"
            ></el-button>
            <el-button
              circle
              plain
              type="danger"
              :icon="Delete"
              @click="onDel(row)"
            ></el-button>
          </template>
        </el-table-column>
      </el-table>

      <!-- 分页 -->
      <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="total"
        @size-change="onSizeChange"
        @current-change="onCurrentChange"
        style="margin-top: 20px; justify-content: flex-end"
      >
      </el-pagination>
    </template>
  </PageContainer>
</template>
2.0中文国际化处理
<script setup>
import zhCn from 'element-plus/dist/locale/zh-cn'
</script>

<template>
  <!-- 语言包配置 -->
  <el-config-provider :locale="zhCn">
    <RouterView></RouterView>
  </el-config-provider>
</template>
3.0文章分类下拉组件
<script setup>
import { ref, onMounted } from 'vue'
import { artGetChannelsService } from '@/api/article'

defineProps({
  modelValue: {
    type: [Number, String]
  },
  width: {
    type: String
  }
})
const emit = defineEmits(['update:modelValue'])
// 分类列表
const channelList = ref([])
// 获取分类列表
const getChannelList = async () => {
  const res = await artGetChannelsService()
  channelList.value = res.data.data
}

onMounted(() => {
  getChannelList()
})
</script>

<template>
  <el-select
    :modelValue="modelValue"
    @update:modelValue="emit('update:modelValue', $event)"
    :style="{ width }"
  >
    <el-option
      v-for="item in channelList"
      :key="item.id"
      :label="item.cate_name"
      :value="item.id"
    ></el-option>
  </el-select>
</template>
<template>
  <PageContainer title="文章管理">
    <template #default>
      <!-- 筛选 -->
      <el-form inline>
        <el-form-item label="文章分类:">
          <!--vue2: v-model => :value + @input -->
          <!--vue3: v-model => :modelValue + @update:modelValue -->
          <ChannelSelect v-model="params.cate_id"></ChannelSelect>
        </el-form-item>
      </el-form>
    </template>
  </PageContainer>
</template>
4.0文章列表渲染
<script setup>
import { ref, onMounted } from 'vue'
import ChannelSelect from './component/ChannelSelect.vue'
import { formatTime } from '@/utils/format.js'
import { artGetListService, artDelService } from '@/api/article.js'
const articleList = ref([]) // 文章列表
const total = ref(0) // 总条数
const loading = ref(false) // loading状态

// 请求参数对象
const params = ref({
  pagenum: 1,
  pagesize: 5,
  cate_id: '',
  state: ''
})

// 基于params参数,获取文章列表
const getArticleList = async () => {
  loading.value = true

  const res = await artGetListService(params.value)
  articleList.value = res.data.data
  total.value = res.data.total

  loading.value = false
}

// 每页展示条数变化
const onSizeChange = (size) => {
  // 每页条数变化后回到第一页
  params.value.pagenum = 1
  // 更新每页条数
  params.value.pageSize = size
  getArticleList()
}

// 页码变化
const onCurrentChange = (page) => {
  params.value.pagenum = page
  getArticleList()
}

// 搜索
const onSearch = () => {
  params.value.pagenum = 1
  getArticleList()
}

// 重置搜索
const onReset = () => {
  params.value.pagenum = 1
  params.value.cate_id = ''
  params.value.state = ''
  getArticleList()
}

onMounted(() => {
  getArticleList()
})
</script>

<template>
  <PageContainer title="文章管理">
    <template #default>
      <!-- 表格 -->
      <el-table :data="articleList" v-loading="loading">
        <el-table-column label="文章标题">
          <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">
          <!-- 时间格式化 -->
          <template #default="{ row }">
            {{ formatTime(row.pub_date) }}
          </template>
        </el-table-column>
        <el-table-column label="状态" prop="state"></el-table-column>
      </el-table>

      <!-- 分页 -->
      <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="total"
        @size-change="onSizeChange"
        @current-change="onCurrentChange"
        style="margin-top: 20px; justify-content: flex-end"
      >
      </el-pagination>
    </template>
  </PageContainer>
</template>
import { dayjs } from 'element-plus'

export const formatTime = (time) => dayjs(time).format('YYYY年MM月DD日')
5.0封装抽屉组件
<script setup>
import { ref } from 'vue'
// 控制抽屉显示隐藏
const visibleDrawer = ref(false)

// 组件对外暴露一个方法 open,基于open传来的参数,区分添加还是编辑
// open({})  => 表单无需渲染,说明是添加
// open({ id, ..., ... })  => 表单需要渲染,说明是编辑
// open调用后,可以打开抽屉
const editorRef = ref()
const open = async (row) => {
  visibleDrawer.value = true // 显示抽屉
}

defineExpose({
  open
})
</script>

<template>
  <el-drawer
    v-model="visibleDrawer"
    :title="formModel.id ? '编辑文章' : '添加文章'"
    direction="rtl"
    size="50%"
  >
   测试内容
  </el-drawer>
</template>
6.0添加文章

a.嵌入常规表单

<script setup>
import { ref } from 'vue'
import ChannelSelect from './channelselect.vue'
// 控制抽屉显示隐藏
const visibleDrawer = ref(false)
// 默认数据(用于重置)
const defaultForm = {
  title: '', // 标题
  cate_id: '', // 分类id
  cover_img: '', // 封面图片 file 对象
  content: '', // string 内容
  state: '' // 状态
}

// 准备数据(用于提交)
const formModel = ref({ ...defaultForm })

// open调用后,可以打开抽屉
const editorRef = ref()
const open = async (row) => {
  visibleDrawer.value = true // 显示抽屉

  if (row.id) {
    // 编辑操作
    // 传过来的数据row, 无法直接回显
    // 基于 row.id 发送请求,获取编辑对应的详情数据,进行回显
    console.log('编辑回显', row.id)
  } else {
    // 添加操作
    // 重置form数据,基于默认的数据
    formModel.value = { ...defaultForm } 
    // 这里重置了表单的数据,但是图片上传img地址,富文本编辑器内容 => 需要手动重置
    imgUrl.value = ''
    editorRef.value.setHTML('')
  }
}

defineExpose({
  open
})
</script>

<template>
  <el-drawer
    v-model="visibleDrawer"
    :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 @click="onPublish('已发布')" type="primary">发布</el-button>
        <el-button @click="onPublish('草稿')" type="info">草稿</el-button>
      </el-form-item>
    </el-form>
  </el-drawer>
</template>
<script setup>
const drawer = ref()
// 添加
const addArticle = () => {
  drawer.value.open({})
}

// 编辑
const onEdit = (row) => {
  drawer.value.open(row)
}
</script>

<template>
  <PageContainer title="文章管理">
    <template #extra>
      <el-button type="primary" @click="addArticle">添加文章</el-button>
    </template>
    <template #default>
      <!-- 表格 -->
      <el-table :data="articleList" v-loading="loading">
        <el-table-column label="操作">
          <template #default="{ row }">
            <!-- 编辑按钮 -->
            <el-button
              circle
              plain
              type="primary"
              :icon="Edit"
              @click="onEdit(row)"
            ></el-button>
        </el-table-column>
      </el-table>

      <!-- 添加/编辑抽屉 -->
      <ArticleEdit ref="drawer" @success="onSuccess"></ArticleEdit>
    </template>
  </PageContainer>
</template>

b.嵌入上传文件(本地预览)

文件上传有两种提交方式, 一种是前端本地预览, 一种是文件上传后预览, 本项目采用前端本地预览

有两种方式可以实现本地预览:

方式1:URL.createObjectURL(...) 创建一个本地预览的地址, 最终提交的是原始的file对象

方式2: 生成base64图片实现本地预览, 本项目/个人中心页/更换头像, 可以前去查看

<script setup>
// 图片上传相关逻辑
const imgUrl = ref('')
const onSelectFile = (uploadFile) => {
  // 创建本地预览地址
  imgUrl.value = URL.createObjectURL(uploadFile.raw) 
  // 立刻将图片对象,存入 formModel.value.cover_img 将来用于提交
  formModel.value.cover_img = uploadFile.raw
}
</script>

<template>
  <el-drawer
    v-model="visibleDrawer"
    :title="formModel.id ? '编辑文章' : '添加文章'"
    direction="rtl"
    size="50%"
  >
    <!-- 发表文章表单 -->
    <el-form :model="formModel" ref="formRef" label-width="100px">
      <el-form-item label="文章封面" prop="cover_img">
        <!-- 本项目采用前端本地预览方案 
             只需要做前端的本地预览图片即可,无需在提交前上传图片
             所以需要关闭 element-plus 的自动上传,
             不需要配置 action 等参数
             语法:URL.createObjectURL(...) 创建本地预览的地址,来预览
        -->
        <el-upload
          class="avatar-uploader"
          :show-file-list="false"
          :auto-upload="false"
          :on-change="onSelectFile"
        >
          <img v-if="imgUrl" :src="imgUrl" class="avatar" />
          <el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
        </el-upload>
      </el-form-item>
    </el-form>
  </el-drawer>
</template>

c.嵌入富文本编辑器

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

安装: pnpm add @vueup/vue-quill@latest

<script setup>
// 1.引入编辑器组件和样式
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
</script>

<template>
  <el-drawer>
    <!-- 发表文章表单 -->
    <el-form :model="formModel" ref="formRef" label-width="100px">
      <el-form-item label="文章内容" prop="content">
        <div class="editor">
          <!--  -->
          <!--
             2.嵌入文本编辑器
             content-type="html  设置编辑器的文本格式(必须)
             theme="snow"   设置编辑器主题样式(必须)
          -->
          <quill-editor
            ref="editorRef"
            v-model:content="formModel.content"
            content-type="html"
            theme="snow"
          ></quill-editor>
        </div>
      </el-form-item>
    </el-form>
  </el-drawer>
</template>

<style lang="scss" scoped>
<!-- 3.修改默认样式 -->
.editor {
  width: 100%;
  :deep(.ql-editor) {
    min-height: 200px;
  }
}
</style>

d.完成表单提交

<script setup>
// 控制抽屉显示隐藏
const visibleDrawer = ref(false)

// 默认数据
const defaultForm = {
  title: '', // 标题
  cate_id: '', // 分类id
  cover_img: '', // 封面图片 file 对象
  content: '', // string 内容
  state: '' // 状态
}

// 准备数据
const formModel = ref({ ...defaultForm })

// 提交
const emit = defineEmits(['success'])
const onPublish = async (state) => {
  // 将已发布还是草稿状态,存入 formModel
  formModel.value.state = state

  // 注意:当前接口,需要的参数是 formData对象格式
  // 将普通对象 => 转换成 => formData对象
  const fd = new FormData()
  for (let key in formModel.value) {
    fd.append(key, formModel.value[key])
  }

  // 发请求
  if (formModel.value.id) {
    // 编辑操作
    console.log('编辑操作')
  } else {
    // 添加操作
    await artPublishService(fd)
    ElMessage.success('添加成功')
    visibleDrawer.value = false
    // 通知到父组件,添加成功了
    emit('success', 'add')
  }
}
</script>

<template>
  <el-drawer>
    <!-- 发表文章表单 -->
    <el-form :model="formModel" ref="formRef" label-width="100px">
      <el-form-item>
        <el-button @click="onPublish('已发布')" type="primary">发布</el-button>
        <el-button @click="onPublish('草稿')" type="info">草稿</el-button>
      </el-form-item>
    </el-form>
  </el-drawer>
</template>
<script setup>
const total = ref(0) // 总条数
// 请求参数对象
const params = ref({
  pagenum: 1,
  pagesize: 5,
  cate_id: '',
  state: ''
})

// 添加或者编辑 成功的回调
const onSuccess = (type) => {
  if (type === 'add') {
    // 如果是添加,渲染最后一页, 方便用户看到
    // 计算最大页码值
    const lastPage = Math.ceil((total.value + 1) / params.value.pagesize)
    // 更新成最大页码数,再渲染
    params.value.pagenum = lastPage
  }

  // 更新列表数据
  getArticleList()
}
</script>
7.0编辑文章
<script setup>
import { baseURL } from '@/utils/request'
import axios from 'axios'
// 提交
const emit = defineEmits(['success'])
const onPublish = async (state) => {
  // 发请求
  if (formModel.value.id) {
    // 编辑操作
    await artEditService(fd)
    ElMessage.success('修改成功')
    visibleDrawer.value = false
    emit('success', 'edit')
  } else {
    // 添加操作
   ... ...
  }
}

// open调用后,可以打开抽屉
const editorRef = ref()
const open = async (row) => {
  visibleDrawer.value = true // 显示抽屉

  if (row.id) {
    // 编辑操作
    // 需要基于 row.id 发送请求,获取编辑对应的详情数据,进行回显
    const res = await artGetDetailService(row.id)
    formModel.value = res.data.data
    // 返回的图片地址不完整, 所以需要手动补全, 单独处理回显
    imgUrl.value = baseURL + formModel.value.cover_img
    // 注意:提交给后台,需要的数据格式,是file对象格式
    // 需要将网络图片地址 => 转换成 file对象,存储起来, 将来便于提交
    const file = await imageUrlToFileObject(
      imgUrl.value,
      formModel.value.cover_img
    )
    formModel.value.cover_img = file
  } else {
    // 新增操作
    ... ...
  }
}

// 将网络图片地址转换为 File 对象的函数
async function imageUrlToFileObject(imageUrl, filename) {
  try {
    // 使用 Axios 下载图片数据
    const response = await axios.get(imageUrl, { responseType: 'arraybuffer' })

    // 将下载的数据转换成 Blob 对象
    const blob = new Blob([response.data], {
      type: response.headers['content-type']
    })

    // 创建 File 对象
    const file = new File([blob], filename, {
      type: response.headers['content-type']
    })

    return file
  } catch (error) {
    console.error('Error converting image URL to File object:', error)
    return null
  }
}
</script>

个人中心页

1.0认识AI助手

AI互动的过程中, 容易出现的问题

  1. AI未能理解问题的核心要点
  2. AI的回答过于宽泛或者过于具体
  3. AI提供了错误的信息或观点
  4. AI未能提供有价值的建议或解决方案

通过优化Prompt(提示词), 提高AI问答的准确性

  1. 明确提问: 关键字越准确,回答越精准
  2. 细化需求: 大问题拆分小问题
  3. 添加背景信息: 预设角色/描述场景
  4. 适当引导: 例如/请注意/请使用
  5. 限制范围: 长度/条数/范围

ChatGPT: 科学上网, 官网注册, 会有IP/手机号限制

国产ChatGPT: 谷歌搜索chatgpt免费网站列表

一些推荐: https://github.com/LiLittleCat/awesome-free-chatgpt

Copilot代码生成

安装: 登录github, 适用Copilot, 打开vscode, 搜索安装插件 Copilot

使用: 删除键: 不接受, Tab键: 接受, Ctrl+enter: 查看更多方案

2.0基本资料
<script setup>
import PageContainer from '@/components/PageContainer.vue'
import { ref } from 'vue'
import { useUserStore } from '@/stores'
import { userUpdateInfoService } from '@/api/user'

// 数据回显
// 使用仓库中数据的初始值 (无需响应式) 解构无问题
const {
  user: { email, id, nickname, username },
  getUser
} = useUserStore()

// 表单数据
const form = ref({
  id,
  username,
  nickname,
  email
})

// 校验规则
const rules = ref({
  nickname: [
    { required: true, message: '请输入用户昵称', trigger: 'blur' },
    {
      pattern: /^\S{2,10}/,
      message: '昵称长度在2-10个非空字符',
      trigger: 'blur'
    }
  ],
  email: [
    { required: true, message: '请输入用户邮箱', trigger: 'blur' },
    {
      type: 'email',
      message: '请输入正确的邮箱格式',
      trigger: ['blur', 'change']
    }
  ]
})

// 表单提交
const formRef = ref()
const submitForm = async () => {
  // 等待校验结果(预校验)
  await formRef.value.validate()
  // 提交修改
  await userUpdateInfoService(form.value)
  // 通知 user 模块,进行数据的更新
  getUser()
  // 提示用户
  ElMessage.success('修改成功')
}
</script>

<template>
  <page-container title="基本资料">
    <template #default>
      <!-- 表单部分 -->
      <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
        <el-form-item label="登录名称">
          <el-input v-model="form.username" disabled></el-input>
        </el-form-item>
        <el-form-item label="用户昵称" prop="nickname">
          <el-input v-model="form.nickname"></el-input>
        </el-form-item>
        <el-form-item label="用户邮箱" prop="email">
          <el-input v-model="form.email"></el-input>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="submitForm">提交修改</el-button>
        </el-form-item>
      </el-form>
    </template>
  </page-container>
</template>
3.0更换头像

new FileReader() 创建base64格式的图片, 最终提交的是创建出来的base64图片

<script setup>
import { ref } from 'vue'
import { Plus, Upload } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores'
import { userUpdateAvatarService } from '@/api/user'
const userStore = useUserStore()
const imgUrl = ref(userStore.user.user_pic) //回显头像
const uploadRef = ref() // 上传组件的ref

// 选择图片
const onSelectFile = (uploadFile) => {
  // 基于 FileReader 读取图片做预览
  const reader = new FileReader()
  reader.readAsDataURL(uploadFile.raw)
  // 得到base64格式的图片
  reader.onload = () => {
    imgUrl.value = reader.result
  }
}

// 保存头像
const onUpdateAvatar = async () => {
  // 发送请求更新头像
  await userUpdateAvatarService(imgUrl.value)
  // userStore 重新渲染
  await userStore.getUser()
  // 提示用户
  ElMessage.success('头像更新成功')
}
</script>

<template>
  <page-container title="更换头像">
    <el-upload
      ref="uploadRef"
      :auto-upload="false"
      class="avatar-uploader"
      :show-file-list="false"
      :on-change="onSelectFile"
    >
      <img v-if="imgUrl" :src="imgUrl" class="avatar" />
      <el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
    </el-upload>

    <br />
    <el-button
      @click="uploadRef.$el.querySelector('input').click()"
      type="primary"
      :icon="Plus"
      size="large"
      >选择图片</el-button
    >
    <el-button
      @click="onUpdateAvatar"
      type="success"
      :icon="Upload"
      size="large"
      >上传头像</el-button
    >
  </page-container>
</template>
4.0重置密码

<script setup>
import { ref } from 'vue'
import { userUpdatePasswordService } from '@/api/user'
import { useUserStore } from '@/stores'
import { useRouter } from 'vue-router'
// 表单 ref
const formRef = ref()
// 表单数据
const pwdForm = ref({
  old_pwd: '',
  new_pwd: '',
  re_pwd: ''
})

// 表单校验
const checkDifferent = (rule, value, callback) => {
  // 校验新密码和原密码不能一样
  if (value === pwdForm.value.old_pwd) {
    callback(new Error('新密码不能与原密码一样'))
  } else {
    callback()
  }
}
// 表单校验
const checkSameAsNewPwd = (rule, value, callback) => {
  // 校验确认密码必须和新密码一样
  if (value !== pwdForm.value.new_pwd) {
    callback(new Error('确认密码必须和新密码一样'))
  } else {
    callback()
  }
}
// 表单校验
const rules = ref({
  old_pwd: [
    { required: true, message: '请输入原密码', trigger: 'blur' },
    { min: 6, max: 15, message: '原密码长度在6-15位之间', trigger: 'blur' }
  ],
  new_pwd: [
    { required: true, message: '请输入新密码', trigger: 'blur' },
    { min: 6, max: 15, message: '新密码长度在6-15位之间', trigger: 'blur' },
    { validator: checkDifferent, trigger: 'blur' }
  ],
  re_pwd: [
    { required: true, message: '请再次输入新密码', trigger: 'blur' },
    { min: 6, max: 15, message: '确认密码长度在6-15位之间', trigger: 'blur' },
    { validator: checkSameAsNewPwd, trigger: 'blur' }
  ]
})

// 表单提交
const userStore = useUserStore()
const router = useRouter()
const submitForm = async () => {
  await formRef.value.validate() // 表单预校验
  await userUpdatePasswordService(pwdForm.value)
  ElMessage.success('密码修改成功')

  // 密码修改成功后,退出重新登录
  // 清空本地存储的 token 和 个人信息
  userStore.setToken('')
  userStore.setUser({})
  // 拦截登录
  router.push('/login')
}

// 表单重置
const resetForm = () => {
  formRef.value.resetFields()
}
</script>

<template>
  <page-container title="修改密码">
    <el-row>
      <el-col :span="12">
        <el-form
          ref="formRef"
          :model="pwdForm"
          :rules="rules"
          label-width="100px"
        >
          <el-form-item label="原密码" prop="old_pwd">
            <el-input v-model="pwdForm.old_pwd" show-password></el-input>
          </el-form-item>
          <el-form-item label="新密码" prop="new_pwd">
            <el-input v-model="pwdForm.new_pwd" show-password></el-input>
          </el-form-item>
          <el-form-item label="确认密码" prop="re_pwd">
            <el-input v-model="pwdForm.re_pwd" show-password></el-input>
          </el-form-item>
          <el-form-item>
            <el-button type="primary" @click="submitForm">修改密码</el-button>
            <el-button @click="resetForm">重置</el-button>
          </el-form-item>
        </el-form></el-col
      >
    </el-row>
  </page-container>
</template>