用户头像裁切上传功能

逻辑代码及思维过程

  1. 在项目中安装croppers
csharp 复制代码
$ yarn add cropperjs

  1. 分析思路,首先需要在navbar组件中添加一个更新头像的下拉选项

添加代码-代码位置(src/layout/components/navbar.vue)-dropdown组件中

xml 复制代码
 <!-- 更新头像 -->
          <a target="_blank" @click.prevent="updateStaffPhoto">
            <el-dropdown-item>更新头像</el-dropdown-item>
          </a>
  • 添加一个变量控制弹层的显示
javascript 复制代码
export default {
  data() {
    return {
      ...
      showCropperDialog: false // 控制编辑图像的弹层
    }
  }
}
  • 实现一个updateStaffPhoto方法-调用时,将弹层显示
javascript 复制代码
 updateStaffPhoto() {
      this.showCropperDialog = true
 },
  • 放置一个弹层,底部使用插槽放置两个按钮
xml 复制代码
  <el-dialog title="更新头像" :visible.sync="showCropperDialog">
      <!-- 底部按钮显示区域 -->
      <template #footer>
        <el-row type="flex" justify="end">
          <el-button size="mini" >取消</el-button>
          <el-button size="mini" type="primary">确认并上传</el-button>
        </el-row>
      </template>
    </el-dialog>
  1. 设置裁剪图区域 和 预览图区域的页面结构和样式

对应代码

xml 复制代码
 <el-dialog title="更新头像" :visible.sync="showCropperDialog">
   <!-- 图片显示区域 -->
      <el-row class="img_box_row">
        <el-col :span="14" class="img_box_left">
          <img
            ref="imgStaffPhoto"
            class="image_staffphoto"
            :src="selectImg"
            alt=""
          >
        </el-col>
        <el-col :span="10" class="img_box_right">
          <div class="img-preview" />
        </el-col>
      </el-row>
   ...
</el-dialog>

布局样式和预览图样式

css 复制代码
 .img_box_row {
     height: 300px;
    .img_box_left {
      height: 100%;
      background: #eee;
      background-size: 24px 24px;
      background-position: 0 0,12px 12px;
      background-image:linear-gradient(45deg,rgba(0,0,0,.25) 25%,transparent 0,transparent 75%,rgba(0,0,0,.25) 0),linear-gradient(45deg,rgba(0,0,0,.25) 25%,transparent 0,transparent 75%,rgba(0,0,0,.25) 0);
      .image_staffphoto {
             display: block;
             max-width: 80%;
             height: 300px;
       }
    }
    .img_box_right {
      height: 150px;
      .img-preview {
         background-color: #fff;
         text-align: center;
         width: 200px;
         height: 200px;
         border: 1px solid #ccc;
         margin-left: 20px;
         overflow: hidden;
      }
    }
  }

上面css中设置背景图片主要是设置在没有图片情况下 Cropper是不会被初始化的,所以设置一个默认的背景,如下图

  1. 打开弹出层时,将头像给到一个图片变量,并初始化Cropper

弹出层时,我们需要将用户原来的头像设置给用于显示弹层图片的变量,如果该变量有值,则初始化Cropper

  • 定义selectImg为响应式数据
javascript 复制代码
export default {
  data() {
    return {
      ...
      selectImg: ''
    }
  }
}
  • 将selectImg绑定给裁剪区域的图片
csharp 复制代码
 <img ref="imgStaffPhoto" class="image_staffphoto"  :src="selectImg"  alt="" >
  • 弹出层时,设置头像,并初始化Cropper
kotlin 复制代码
 updateStaffPhoto() {
      this.showCropperDialog = true
      this.selectImg = this.avatar
      this.$nextTick(() => {
        this.selectImg && this.initCropper()
      })
    },
    initCropper() {
      this.staffCropper = new Cropper(this.$refs.imgStaffPhoto, {
        aspectRatio: 1,
        viewMode: 0,
        preview: '.img-preview'
      })
    },

我们从计算属性中取到了avatar头像赋值给selectImg,然后用了nextTick,因为更新完头像之后,要保证头像的值更新到dom必须在上一次更新完成后执行, 然后我们将Cropper的实例给到this.staffCropper,以后就可以使用它来进行各种的控制啦

完成以上步骤,如果用户有头像,就可以进入编辑模式啦

  1. 布局下方操作按钮
  • 定义一个计算属性iconEnable-用来控制操作按钮的可用性

只有当selectImg有值时,才可以让按钮可用

javascript 复制代码
computed: {
  ...
  iconEnable() {
      return !!this.selectImg
  },
}
  • 结构代码
ini 复制代码
      <el-row type="flex" style="height: 60px; width: 440px" align="middle">
        <el-col :span="10">
          <el-tooltip content="上传图片">
            <el-upload
              :show-file-list="false"
              :auto-upload="false"
              action=""
              accept="image/png, image/jpeg"
            >
              <el-button
                type="primary"
                size="mini"
                icon="el-icon-upload"
                class="tool-icon"
                circle
              />
            </el-upload>
          </el-tooltip>
        </el-col>
        <el-col>
          <el-row type="flex" justify="space-around">
            <el-tooltip content="重置">
              <el-button
                type="primary"
                size="mini"
                icon="el-icon-refresh"
                class="tool-icon"
                circle
                :disabled="!iconEnable"
                @click="reset"
              />
            </el-tooltip>
            <el-tooltip content="逆时针旋转">
              <el-button
                type="primary"
                size="mini"
                icon="el-icon-refresh-left"
                class="tool-icon"
                circle
                :disabled="!iconEnable"
                @click="rotateRetroGrade"
              />
            </el-tooltip>
            <el-tooltip content="顺时针旋转">
              <el-button
                type="primary"
                size="mini"
                icon="el-icon-refresh-right"
                class="tool-icon"
                circle
                :disabled="!iconEnable"
                @click="rotateForward"
              />
            </el-tooltip>
            <el-tooltip content="水平翻转">
              <el-button
                type="primary"
                size="mini"
                icon="el-icon-sort"
                class="tool-icon"
                circle
                :disabled="!iconEnable"
                style="transform: rotate(90deg);"
                @click="scaleX"
              />
            </el-tooltip>
            <el-tooltip content="垂直翻转">
              <el-button
                type="primary"
                size="mini"
                icon="el-icon-sort"
                class="tool-icon"
                circle
                :disabled="!iconEnable"
                @click="scaleY"
              />
            </el-tooltip>
            <el-tooltip content="放大">
              <el-button
                type="primary"
                size="mini"
                icon="el-icon-zoom-in"
                class="tool-icon"
                circle
                :disabled="!iconEnable"
                @click="scaleMax"
              />
            </el-tooltip>
            <el-tooltip content="缩小">
              <el-button
                type="primary"
                size="mini"
                icon="el-icon-zoom-out"
                class="tool-icon"
                circle
                :disabled="!iconEnable"
                @click="scaleMin"
              />
            </el-tooltip>
          </el-row>
        </el-col>
      </el-row>

实现重置-正向旋转-逆向旋转-水平镜像-垂直镜像-放大-缩小等功能(类似前面的demo)

kotlin 复制代码
    // 重置
    reset() {
      this.staffCropper?.reset()
    },
    // 正向旋转
    rotateForward() {
      this.staffCropper?.rotate(45)
    },
    // 逆向旋转
    rotateRetroGrade() {
      this.staffCropper?.rotate(-45)
    },
    // 水平翻转
    scaleX() {
      this.scaleXValue = this.scaleXValue === -1 ? 1 : -1
      this.staffCropper?.scaleX(this.scaleXValue)
    },
    // 垂直翻转
    scaleY() {
      this.scaleYValue = this.scaleYValue === -1 ? 1 : -1
      this.staffCropper?.scaleY(this.scaleYValue)
    },
    // 放大
    scaleMax() {
      this.staffCropper?.zoom(0.1)
    },
    // 缩小
    scaleMin() {
      this.zoom -= 0.1
      this.staffCropper?.zoom(-0.1)
    },

到现在我们就实现了头像的裁剪的基本功能啦,接下来我们处理下选择新图片的情况

  1. 绑定el-upload的on-change属性-⚠️: el-upload的on-change是属性不是事件,但是这个属性是函数类型
ini 复制代码
  <el-upload
              :on-change="selectFile"
              :show-file-list="false"
              :auto-upload="false"
              action=""
              accept="image/png, image/jpeg"
            >
              <el-button
                type="primary"
                size="mini"
                icon="el-icon-upload"
                class="tool-icon"
                circle
              />
  </el-upload>
  • 实现selectFile方法
kotlin 复制代码
 selectFile(file) {
      if (file) {
        if (file.raw.size > 1024 * 1024) {
          this.$message.warning('头像不能大于1M')
          return
        }
        this.staffCropper?.destroy() // 销毁图片
        const reader = new FileReader()
        reader.readAsDataURL(file.raw) // 读取图片
        reader.onloadend = () => {
          this.selectImg = reader.result 
          this.$nextTick(() => {
            this.selectImg && this.initCropper() // 重新初始化Cropper
          })
        }
      }
    },

上述方法首先判断了文件的大小,然后销毁了原来的Cropper实例,然后将File读取为base64,最终将base64设置给selectImg,有了新的selectImg重新进行Cropper

  1. 实现更新头像的功能

此时要获取裁剪后的头像,将它转成base64(类似前面demo),然后调用接口更新到服务器,成功之后,再将新的头像同步更新到Vuex中的头像

  • 封装更新头像的接口
php 复制代码
/** *
 *
 * 更新用户头像
 * ***/
export function updateStaffPhoto(data) {
  return request({
    url: '/sys/updateStaffPhoto',
    method: 'put',
    data
  })
}
kotlin 复制代码
 import { updateStaffPhoto } from '@/api/user'
   
    // 获取图片的canvas - 并转化成base64
    async bentStaffSubmit() {
      const canvas = this.staffCropper?.getCroppedCanvas()
      const staffPhoto = canvas.toDataURL('image/png') // 转化成base64的png图片格式
      const byteLength = (staffPhoto.length * 3) / 4 / 1024 / 1024 // 设置base64不能超过1M
      if (byteLength > 1) {
        return this.$message.warning('编辑过的图片大小不能超过1M')
      }
      await updateStaffPhoto({
        staffPhoto
      })
      // 更新vuex数据
      this.$store.commit('user/setUserInfo', { ...this.$store.state.user.userInfo, staffPhoto })
      this.staffCropper?.destroy() // 销毁Cropper实例
      this.showCropperDialog = false
    },
  • 关闭弹层的方法
javascript 复制代码
    btnCancelImg() {
      this.staffCropper?.destroy() // 销毁Cropper实例
      this.showCropperDialog = false
    },

到现在为止,我们就实现了整个的裁剪头像的功能,现在附上所有代码

xml 复制代码
<template>
  <div class="navbar">
    <hamburger
      :is-active="sidebar.opened"
      class="hamburger-container"
      @toggleClick="toggleSideBar"
    />

    <breadcrumb class="breadcrumb-container" />
    <div class="right-menu">
      <el-dropdown class="avatar-container" trigger="click">
        <div class="avatar-wrapper">
          <!-- 头像 -->
          <img v-if="avatar" :src="avatar" class="user-avatar">
          <span v-else class="username">{{ name?.charAt(0) }}</span>
          <!-- 用户名称 -->
          <span class="name">{{ name }}</span>
          <!-- 图标 -->
          <!-- <i class="el-icon-setting" /> -->
        </div>
        <el-dropdown-menu slot="dropdown" class="user-dropdown">
          <router-link to="/">
            <el-dropdown-item> 首页 </el-dropdown-item>
          </router-link>
          <a
            target="_blank"
            href="https://github.com/PanJiaChen/vue-admin-template/"
          >
            <el-dropdown-item>项目地址</el-dropdown-item>
          </a>
          <!-- prevent阻止默认事件 -->
          <a target="_blank" @click.prevent="updatePassword">
            <el-dropdown-item>修改密码</el-dropdown-item>
          </a>
          <!-- 更新头像 -->
          <a target="_blank" @click.prevent="updateStaffPhoto">
            <el-dropdown-item>更新头像</el-dropdown-item>
          </a>
          <!-- native事件修饰符 -->
          <!-- 注册组件的根元素的原生事件 -->
          <el-dropdown-item @click.native="logout">
            <span style="display: block">登出</span>
          </el-dropdown-item>
        </el-dropdown-menu>
      </el-dropdown>

    </div>
    <!-- 放置dialog -->
    <!-- sync- 可以接收子组件传过来的事件和值 -->
    <el-dialog
      width="500px"
      title="修改密码"
      :visible.sync="showDialog"
      @close="btnCancel"
    >
      <!-- 放置表单 -->
      <el-form
        ref="passForm"
        label-width="120px"
        :model="passForm"
        :rules="rules"
      >
        <el-form-item label="旧密码" prop="oldPassword">
          <el-input v-model="passForm.oldPassword" show-password size="small" />
        </el-form-item>
        <el-form-item label="新密码" prop="newPassword">
          <el-input v-model="passForm.newPassword" show-password size="small" />
        </el-form-item>
        <el-form-item label="重复密码" prop="confirmPassword">
          <el-input
            v-model="passForm.confirmPassword"
            show-password
            size="small"
          />
        </el-form-item>
        <el-form-item>
          <el-button
            size="mini"
            type="primary"
            @click="btnOK"
          >确认修改</el-button>
          <el-button size="mini" @click="btnCancel">取消</el-button>
        </el-form-item>
      </el-form>
    </el-dialog>
    <el-dialog title="更新头像" :visible.sync="showCropperDialog" @close="btnCancelImg">
      <!-- 图片显示区域 -->
      <el-row class="img_box_row">
        <el-col :span="14" class="img_box_left">
          <img
            ref="imgStaffPhoto"
            class="image_staffphoto"
            :src="selectImg"
            alt=""
          >
        </el-col>
        <el-col :span="10" class="img_box_right">
          <div class="img-preview" />
        </el-col>
      </el-row>
      <!-- 图表区域 -->
      <el-row type="flex" style="height: 60px; width: 440px" align="middle">
        <el-col :span="10">
          <el-tooltip content="上传图片">
            <el-upload
              :on-change="selectFile"
              :show-file-list="false"
              :auto-upload="false"
              action=""
              accept="image/png, image/jpeg"
            >
              <el-button
                type="primary"
                size="mini"
                icon="el-icon-upload"
                class="tool-icon"
                circle
              />
            </el-upload>
          </el-tooltip>
        </el-col>
        <el-col>
          <el-row type="flex" justify="space-around">
            <el-tooltip content="重置">
              <el-button
                type="primary"
                size="mini"
                icon="el-icon-refresh"
                class="tool-icon"
                circle
                :disabled="!iconEnable"
                @click="reset"
              />
            </el-tooltip>
            <el-tooltip content="逆时针旋转">
              <el-button
                type="primary"
                size="mini"
                icon="el-icon-refresh-left"
                class="tool-icon"
                circle
                :disabled="!iconEnable"
                @click="rotateRetroGrade"
              />
            </el-tooltip>
            <el-tooltip content="顺时针旋转">
              <el-button
                type="primary"
                size="mini"
                icon="el-icon-refresh-right"
                class="tool-icon"
                circle
                :disabled="!iconEnable"
                @click="rotateForward"
              />
            </el-tooltip>
            <el-tooltip content="水平翻转">
              <el-button
                type="primary"
                size="mini"
                icon="el-icon-sort"
                class="tool-icon"
                circle
                :disabled="!iconEnable"
                style="transform: rotate(90deg);"
                @click="scaleX"
              />
            </el-tooltip>
            <el-tooltip content="垂直翻转">
              <el-button
                type="primary"
                size="mini"
                icon="el-icon-sort"
                class="tool-icon"
                circle
                :disabled="!iconEnable"
                @click="scaleY"
              />
            </el-tooltip>
            <el-tooltip content="放大">
              <el-button
                type="primary"
                size="mini"
                icon="el-icon-zoom-in"
                class="tool-icon"
                circle
                :disabled="!iconEnable"
                @click="scaleMax"
              />
            </el-tooltip>
            <el-tooltip content="缩小">
              <el-button
                type="primary"
                size="mini"
                icon="el-icon-zoom-out"
                class="tool-icon"
                circle
                :disabled="!iconEnable"
                @click="scaleMin"
              />
            </el-tooltip>
          </el-row>
        </el-col>
      </el-row>
      <template #footer>
        <el-row type="flex" justify="end">
          <el-button size="mini" @click="btnCancelImg">取消</el-button>
          <el-button size="mini" type="primary" @click="bentStaffSubmit">确认并上传</el-button>

        </el-row>
      </template>
    </el-dialog>
  </div>
</template>

<script>
import { mapGetters } from 'vuex'
import Breadcrumb from '@/components/Breadcrumb'
import Hamburger from '@/components/Hamburger'
import { updatePassword, updateStaffPhoto, getUserSelfMessage, delUserSelfMessage, updateMessageState } from '@/api/user'
import Cropper from 'cropperjs'
import 'cropperjs/dist/cropper.css'
export default {
  components: {
    Breadcrumb,
    Hamburger
  },
  data() {
    return {
      showDialog: false, // 控制弹层的显示和隐藏
      showCropperDialog: false, // 控制编辑图像的弹层
      selectImg: '', // 选择的图片
      passForm: {
        oldPassword: '', // 旧密码
        newPassword: '', // 新密码
        confirmPassword: '' // 确认密码字段
      },
      rules: {
        oldPassword: [
          { required: true, message: '旧密码不能为空', trigger: 'blur' }
        ], // 旧密码
        newPassword: [
          { required: true, message: '新密码不能为空', trigger: 'blur' },
          {
            trigger: 'blur',
            min: 6,
            max: 16,
            message: '新密码的长度为6-16位之间'
          }
        ], // 新密码
        confirmPassword: [
          { required: true, message: '重复密码不能为空', trigger: 'blur' },
          {
            trigger: 'blur',
            validator: (rule, value, callback) => {
              // value
              if (this.passForm.newPassword === value) {
                callback()
              } else {
                callback(new Error('重复密码和新密码输入不一致'))
              }
            }
          }
        ] // 确认密码字段
      }
    }
  },
  computed: {
    // 引入头像和用户名称
    ...mapGetters(['sidebar', 'avatar', 'name']),
    iconEnable() {
      return !!this.selectImg
    }
  },

  methods: {
    btnCancelImg() {
      this.staffCropper?.destroy()
      this.showCropperDialog = false
    },
    selectFile(file) {
      if (file) {
        if (file.raw.size > 1024 * 1024) {
          this.$message.warning('头像不能大于1M')
          return
        }
        this.staffCropper?.destroy()
        const reader = new FileReader()
        reader.readAsDataURL(file.raw)
        reader.onloadend = () => {
          this.selectImg = reader.result
          this.$nextTick(() => {
            this.selectImg && this.initCropper()
          })
        }
      }
    },
    updateStaffPhoto() {
      this.showCropperDialog = true
      this.selectImg = this.avatar
      this.$nextTick(() => {
        this.selectImg && this.initCropper()
      })
    },
    initCropper() {
      this.staffCropper = new Cropper(this.$refs.imgStaffPhoto, {
        aspectRatio: 1,
        viewMode: 0,
        preview: '.img-preview'
      })
    },

    // 获取图片的canvas - 并转化成base64
    async bentStaffSubmit() {
      const canvas = this.staffCropper?.getCroppedCanvas()
      const staffPhoto = canvas.toDataURL('image/png') // 转化成base64的png图片格式
      const byteLength = (staffPhoto.length * 3) / 4 / 1024 / 1024 // 设置base64不能超过1M
      if (byteLength > 1) {
        return this.$message.warning('编辑过的图片大小不能超过1M')
      }
      await updateStaffPhoto({
        staffPhoto
      })
      // 更新vuex数据
      this.$store.commit('user/setUserInfo', { ...this.$store.state.user.userInfo, staffPhoto })
      this.staffCropper?.destroy()
      this.showCropperDialog = false
    },
    // 重置
    reset() {
      this.staffCropper?.reset()
    },
    // 水平翻转
    scaleX() {
      this.scaleXValue = this.scaleXValue === -1 ? 1 : -1
      this.staffCropper?.scaleX(this.scaleXValue)
    },
    // 垂直翻转
    scaleY() {
      this.scaleYValue = this.scaleYValue === -1 ? 1 : -1
      this.staffCropper?.scaleY(this.scaleYValue)
    },
    // 放大
    scaleMax() {
      this.staffCropper?.zoom(0.1)
    },
    // 缩小
    scaleMin() {
      this.zoom -= 0.1
      this.staffCropper?.zoom(-0.1)
    },
    // 正向旋转
    rotateForward() {
      this.staffCropper?.rotate(45)
    },
    // 逆向旋转
    rotateRetroGrade() {
      this.staffCropper?.rotate(-45)
    },
    updatePassword() {
      // 弹出层
      this.showDialog = true // 显示弹层
    },
    toggleSideBar() {
      this.$store.dispatch('app/toggleSideBar')
    },
    async logout() {
      // 调用退出登录的action
      await this.$store.dispatch('user/logout')
      this.$router.push('/login')
    },
    // 确定
    btnOK() {
      this.$refs.passForm.validate(async(isOK) => {
        if (isOK) {
          // 调用接口
          await updatePassword(this.passForm)
          this.$message.success('修改密码成功')
          this.btnCancel()
        }
      })
    },
    // 取消
    btnCancel() {
      this.$refs.passForm.resetFields() // 重置表单
      // 关闭弹层
      this.showDialog = false
    }
  }
}
</script>

<style lang="scss" scoped>
.navbar {
  height: 50px;
  overflow: hidden;
  position: relative;
  background: #fff;
  box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);

  .hamburger-container {
    line-height: 46px;
    height: 100%;
    float: left;
    cursor: pointer;
    transition: background 0.3s;
    -webkit-tap-highlight-color: transparent;

    &:hover {
      background: rgba(0, 0, 0, 0.025);
    }
  }

  .breadcrumb-container {
    float: left;
  }
  .right-menu {
    float: right;
    height: 100%;
    line-height: 50px;
    display: flex;
    align-items: center;
    &:focus {
      outline: none;
    }
    .user-message {
      display: flex;
      width: 20px;
      font-size: 23px;
      color: #1e1e1e;
      margin-right: 15px;
    }
    .right-menu-item {
      display: inline-block;
      padding: 0 8px;
      height: 100%;
      font-size: 18px;
      color: #5a5e66;
      vertical-align: text-bottom;

      &.hover-effect {
        cursor: pointer;
        transition: background 0.3s;

        &:hover {
          background: rgba(0, 0, 0, 0.025);
        }
      }
    }

    .avatar-container {
      margin-right: 30px;
      .avatar-wrapper {
        margin-top: 5px;
        position: relative;
        display: flex;
        align-items: center;
        .name {
          //  用户名称距离右侧距离
          margin-right: 40px;
          margin-left: 20px;
          font-size: 16px;
          color: #383c4e;
        }
        .username {
          width: 30px;
          height: 30px;
          text-align: center;
          line-height: 30px;
          border-radius: 50%;
          background: #04c9be;
          color: #fff;
          margin-right: 4px;
        }
        .el-icon-setting {
          font-size: 20px;
        }
        .user-avatar {
          cursor: pointer;
          width: 30px;
          height: 30px;
          border-radius: 50%;
        }

        .el-icon-caret-bottom {
          cursor: pointer;
          position: absolute;
          right: -20px;
          top: 25px;
          font-size: 12px;
        }
      }
    }
  }
  .tool-icon {
    font-size: 16px;
  }

 .img_box_row {
     height: 300px;
    .img_box_left {
      height: 100%;
      background: #eee;
      background-size: 24px 24px;
      background-position: 0 0,12px 12px;
      background-image:linear-gradient(45deg,rgba(0,0,0,.25) 25%,transparent 0,transparent 75%,rgba(0,0,0,.25) 0),linear-gradient(45deg,rgba(0,0,0,.25) 25%,transparent 0,transparent 75%,rgba(0,0,0,.25) 0);
      .image_staffphoto {
             display: block;
             max-width: 80%;
             height: 300px;
       }
    }
    .img_box_right {
      height: 150px;
      .img-preview {
         background-color: #fff;
         text-align: center;
         width: 200px;
         height: 200px;
         border: 1px solid #ccc;
         margin-left: 20px;
         overflow: hidden;
      }
    }
  }
}
</style>
相关推荐
向前看-16 分钟前
验证码机制
前端·后端
燃先生._.1 小时前
Day-03 Vue(生命周期、生命周期钩子八个函数、工程化开发和脚手架、组件化开发、根组件、局部注册和全局注册的步骤)
前端·javascript·vue.js
高山我梦口香糖2 小时前
[react]searchParams转普通对象
开发语言·前端·javascript
m0_748235242 小时前
前端实现获取后端返回的文件流并下载
前端·状态模式
m0_748240253 小时前
前端如何检测用户登录状态是否过期
前端
black^sugar3 小时前
纯前端实现更新检测
开发语言·前端·javascript
寻找沙漠的人4 小时前
前端知识补充—CSS
前端·css
GISer_Jing4 小时前
2025前端面试热门题目——计算机网络篇
前端·计算机网络·面试
m0_748245524 小时前
吉利前端、AI面试
前端·面试·职场和发展