RuoYi
2026-03-21 88c2e4979a53621db8b00b8e11a7c499febcd2da
新增锁定屏幕功能
3 files added
9 files modified
515 ■■■■■ changed files
ruoyi-auth/src/main/java/com/ruoyi/auth/controller/TokenController.java 11 ●●●●● patch | view | raw | blame | history
ruoyi-auth/src/main/java/com/ruoyi/auth/form/UnLockBody.java 24 ●●●●● patch | view | raw | blame | history
ruoyi-auth/src/main/java/com/ruoyi/auth/service/SysLoginService.java 29 ●●●●● patch | view | raw | blame | history
ruoyi-ui/src/api/login.js 9 ●●●●● patch | view | raw | blame | history
ruoyi-ui/src/layout/components/Navbar.vue 14 ●●●●● patch | view | raw | blame | history
ruoyi-ui/src/permission.js 7 ●●●●● patch | view | raw | blame | history
ruoyi-ui/src/router/index.js 6 ●●●●● patch | view | raw | blame | history
ruoyi-ui/src/store/getters.js 2 ●●●●● patch | view | raw | blame | history
ruoyi-ui/src/store/index.js 2 ●●●●● patch | view | raw | blame | history
ruoyi-ui/src/store/modules/lock.js 34 ●●●●● patch | view | raw | blame | history
ruoyi-ui/src/store/modules/user.js 2 ●●●●● patch | view | raw | blame | history
ruoyi-ui/src/views/lock.vue 375 ●●●●● patch | view | raw | blame | history
ruoyi-auth/src/main/java/com/ruoyi/auth/controller/TokenController.java
@@ -8,6 +8,7 @@
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.auth.form.LoginBody;
import com.ruoyi.auth.form.RegisterBody;
import com.ruoyi.auth.form.UnLockBody;
import com.ruoyi.auth.service.SysLoginService;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.utils.JwtUtils;
@@ -75,4 +76,14 @@
        sysLoginService.register(registerBody.getUsername(), registerBody.getPassword());
        return R.ok();
    }
    /**
     * 解锁屏幕
     */
    @PostMapping("/unlockscreen")
    public R<?> unlockScreen(@RequestBody UnLockBody unLockBody)
    {
        sysLoginService.unlock(unLockBody.getPassword());
        return R.ok();
    }
}
ruoyi-auth/src/main/java/com/ruoyi/auth/form/UnLockBody.java
New file
@@ -0,0 +1,24 @@
package com.ruoyi.auth.form;
/**
 * 系统解锁对象
 *
 * @author ruoyi
 */
public class UnLockBody
{
    /**
     * 用户密码
     */
    private String password;
    public String getPassword()
    {
        return password;
    }
    public void setPassword(String password)
    {
        this.password = password;
    }
}
ruoyi-auth/src/main/java/com/ruoyi/auth/service/SysLoginService.java
@@ -113,12 +113,41 @@
        remoteUserService.recordUserLogin(sysUser, SecurityConstants.INNER);
    }
    /**
     * 退出
     */
    public void logout(String loginName)
    {
        recordLogService.recordLogininfor(loginName, Constants.LOGOUT, "退出成功");
    }
    /**
     * 解锁
     */
    public void unlock(String password)
    {
        String username = SecurityUtils.getUsername();
        // 或密码为空 错误
        if (StringUtils.isEmpty(password))
        {
            throw new ServiceException("密码不能为空");
        }
        // 查询用户信息
        R<LoginUser> userResult = remoteUserService.getUserInfo(username, SecurityConstants.INNER);
        if (R.FAIL == userResult.getCode())
        {
            throw new ServiceException(userResult.getMsg());
        }
        SysUser user = userResult.getData().getSysUser();
        if (!SecurityUtils.matchesPassword(password, user.getPassword()))
        {
            throw new ServiceException("密码错误,请重新输入");
        }
    }
    /**
     * 注册
     */
    public void register(String username, String password)
ruoyi-ui/src/api/login.js
@@ -25,6 +25,15 @@
  })
}
// 解锁屏幕
export function unlockScreen(password) {
  return request({
    url: '/auth/unlockscreen',
    method: 'post',
    data: { password }
  })
}
// 刷新方法
export function refreshToken() {
  return request({
ruoyi-ui/src/layout/components/Navbar.vue
@@ -40,6 +40,9 @@
          <el-dropdown-item @click.native="setLayout" v-if="setting">
            <span>布局设置</span>
          </el-dropdown-item>
          <el-dropdown-item @click.native="lockScreen">
            <span>锁定屏幕</span>
          </el-dropdown-item>
          <el-dropdown-item divided @click.native="logout">
            <span>退出登录</span>
          </el-dropdown-item>
@@ -106,6 +109,12 @@
    setLayout(event) {
      this.$emit('setLayout')
    },
    lockScreen() {
      const currentPath = this.$route.fullPath
      this.$store.dispatch('lock/lockScreen', currentPath).then(() => {
        this.$router.push('/lock')
      })
    },
    logout() {
      this.$confirm('确定注销并退出系统吗?', '提示', {
        confirmButtonText: '确定',
@@ -171,11 +180,6 @@
    align-items: center;
    overflow: hidden;
    margin-left: 8px;
  }
  .errLog-container {
    display: inline-block;
    vertical-align: top;
  }
  .right-menu {
ruoyi-ui/src/permission.js
@@ -19,12 +19,19 @@
  NProgress.start()
  if (getToken()) {
    to.meta.title && store.dispatch('settings/setTitle', to.meta.title)
    const isLock = store.getters.isLock
    /* has token*/
    if (to.path === '/login') {
      next({ path: '/' })
      NProgress.done()
    } else if (isWhiteList(to.path)) {
      next()
    } else if (isLock && to.path !== '/lock') {
      next({ path: '/lock' })
      NProgress.done()
    } else if (!isLock && to.path === '/lock') {
      next({ path: '/' })
      NProgress.done()
    } else {
      if (store.getters.roles.length === 0) {
        isRelogin.show = true
ruoyi-ui/src/router/index.js
@@ -75,6 +75,12 @@
    ]
  },
  {
    path: '/lock',
    component: () => import('@/views/lock'),
    hidden: true,
    meta: { title: '锁定屏幕' }
  },
  {
    path: '/user',
    component: Layout,
    hidden: true,
ruoyi-ui/src/store/getters.js
@@ -3,6 +3,8 @@
  size: state => state.app.size,
  device: state => state.app.device,
  dict: state => state.dict.dict,
  isLock: state => state.lock.isLock,
  lockPath: state => state.lock.lockPath,
  visitedViews: state => state.tagsView.visitedViews,
  cachedViews: state => state.tagsView.cachedViews,
  token: state => state.user.token,
ruoyi-ui/src/store/index.js
@@ -1,6 +1,7 @@
import Vue from 'vue'
import Vuex from 'vuex'
import app from './modules/app'
import lock from './modules/lock'
import dict from './modules/dict'
import user from './modules/user'
import tagsView from './modules/tagsView'
@@ -13,6 +14,7 @@
const store = new Vuex.Store({
  modules: {
    app,
    lock,
    dict,
    user,
    tagsView,
ruoyi-ui/src/store/modules/lock.js
New file
@@ -0,0 +1,34 @@
const LOCK_KEY = 'screen-lock'
const LOCK_PATH_KEY = 'screen-lock-path'
const lock = {
  namespaced: true,
  state: {
    isLock: JSON.parse(localStorage.getItem(LOCK_KEY) || 'false'),
    lockPath: localStorage.getItem(LOCK_PATH_KEY) || '/index'
  },
  mutations: {
    SET_LOCK(state, status) {
      state.isLock = status
      localStorage.setItem(LOCK_KEY, JSON.stringify(status))
    },
    SET_LOCK_PATH(state, path) {
      state.lockPath = path
      localStorage.setItem(LOCK_PATH_KEY, path)
    }
  },
  actions: {
    // 锁定屏幕,同时记录当前路径
    lockScreen({ commit }, currentPath) {
      commit('SET_LOCK_PATH', currentPath || '/index')
      commit('SET_LOCK', true)
    },
    // 解锁屏幕,清除路径
    unlockScreen({ commit }) {
      commit('SET_LOCK', false)
      commit('SET_LOCK_PATH', '/index')
    }
  }
}
export default lock
ruoyi-ui/src/store/modules/user.js
@@ -1,3 +1,4 @@
import store from '@/store'
import router from '@/router'
import { MessageBox, } from 'element-ui'
import { login, logout, getInfo, refreshToken } from '@/api/login'
@@ -57,6 +58,7 @@
          commit('SET_TOKEN', data.access_token)
          setExpiresIn(data.expires_in)
          commit('SET_EXPIRES_IN', data.expires_in)
          store.dispatch('lock/unlockScreen')
          resolve()
        }).catch(error => {
          reject(error)
ruoyi-ui/src/views/lock.vue
New file
@@ -0,0 +1,375 @@
<template>
  <div class="lock-container">
    <!-- 动态粒子背景 -->
    <canvas ref="particleCanvas" class="particle-bg"></canvas>
    <!-- 时钟 -->
    <div class="lock-time">{{ currentTime }}</div>
    <div class="lock-date">{{ currentDate }}</div>
    <!-- 锁屏卡片 -->
    <div class="lock-card">
      <div class="avatar-wrap">
        <img :src="avatar" class="lock-avatar" @error="onAvatarError" />
        <div class="lock-icon">🔒</div>
      </div>
      <div class="lock-username">{{ nickName }}</div>
      <div class="lock-hint">系统已锁定,请输入密码解锁</div>
      <div class="input-wrap" :class="{ shake: isShaking }">
        <input ref="passwordInput" v-model="password" type="password" placeholder="请输入登录密码" class="lock-input" @keydown.enter="handleUnlock" autocomplete="off" />
        <button class="unlock-btn" @click="handleUnlock" :disabled="loading">
          <span v-if="!loading">→</span>
          <span v-else class="loading-dot">···</span>
        </button>
      </div>
      <div v-if="errorMsg" class="error-msg">{{ errorMsg }}</div>
      <div class="lock-footer">
        <a href="/login" @click.prevent="goLogin">退出重新登录</a>
      </div>
    </div>
  </div>
</template>
<script>
import { mapGetters } from 'vuex'
import { unlockScreen } from '@/api/login'
import defAva from '@/assets/images/profile.jpg'
export default {
  name: 'LockScreen',
  data() {
    return {
      password: '',
      loading: false,
      errorMsg: '',
      isShaking: false,
      currentTime: '',
      currentDate: '',
      timer: null,
      animationId: null,
      particles: []
    }
  },
  computed: {
    ...mapGetters(['avatar', 'nickName'])
  },
  mounted() {
    this.startClock()
    this.initParticles()
    this.$nextTick(() => {
      this.$refs.passwordInput && this.$refs.passwordInput.focus()
    })
  },
  beforeDestroy() {
    clearInterval(this.timer)
    cancelAnimationFrame(this.animationId)
  },
  methods: {
    onAvatarError(e) {
      e.target.src = defAva
    },
    startClock() {
      const update = () => {
        const now = new Date()
        const h = String(now.getHours()).padStart(2, '0')
        const m = String(now.getMinutes()).padStart(2, '0')
        const s = String(now.getSeconds()).padStart(2, '0')
        this.currentTime = `${h}:${m}:${s}`
        const days = ['星期日','星期一','星期二','星期三','星期四','星期五','星期六']
        this.currentDate = `${now.getFullYear()}年${now.getMonth()+1}月${now.getDate()}日 ${days[now.getDay()]}`
      }
      update()
      this.timer = setInterval(update, 1000)
    },
    async handleUnlock() {
      if (!this.password) {
        this.showError('请输入密码')
        return
      }
      this.loading = true
      this.errorMsg = ''
      try {
        await unlockScreen(this.password)
        const lockPath = this.$store.getters.lockPath  // 取锁屏前的路径
        await this.$store.dispatch('lock/unlockScreen')
        this.$router.replace(lockPath)
      } catch (err) {
        const msg = err.message || err.toString()
        this.showError(msg)
        this.password = ''
        this.$refs.passwordInput && this.$refs.passwordInput.focus()
      } finally {
        this.loading = false
      }
    },
    showError(msg) {
      this.errorMsg = msg
      this.isShaking = true
      setTimeout(() => { this.isShaking = false }, 600)
    },
    goLogin() {
      this.$store.dispatch('lock/unlockScreen')
      this.$store.dispatch('LogOut').then(() => {
        this.$router.push('/login')
      })
    },
    // 粒子背景
    initParticles() {
      const canvas = this.$refs.particleCanvas
      if (!canvas) return
      const ctx = canvas.getContext('2d')
      const resize = () => {
        canvas.width = window.innerWidth
        canvas.height = window.innerHeight
      }
      resize()
      window.addEventListener('resize', resize)
      const count = 80
      for (let i = 0; i < count; i++) {
        this.particles.push({
          x: Math.random() * canvas.width,
          y: Math.random() * canvas.height,
          r: Math.random() * 2 + 1,
          dx: (Math.random() - 0.5) * 0.6,
          dy: (Math.random() - 0.5) * 0.6,
          alpha: Math.random() * 0.5 + 0.2
        })
      }
      const draw = () => {
        ctx.clearRect(0, 0, canvas.width, canvas.height)
        this.particles.forEach(p => {
          ctx.beginPath()
          ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)
          ctx.fillStyle = `rgba(255,255,255,${p.alpha})`
          ctx.fill()
          p.x += p.dx
          p.y += p.dy
          if (p.x < 0 || p.x > canvas.width) p.dx *= -1
          if (p.y < 0 || p.y > canvas.height) p.dy *= -1
        })
        // 连线
        for (let i = 0; i < this.particles.length; i++) {
          for (let j = i + 1; j < this.particles.length; j++) {
            const a = this.particles[i], b = this.particles[j]
            const dist = Math.hypot(a.x - b.x, a.y - b.y)
            if (dist < 120) {
              ctx.beginPath()
              ctx.moveTo(a.x, a.y)
              ctx.lineTo(b.x, b.y)
              ctx.strokeStyle = `rgba(255,255,255,${0.15 * (1 - dist / 120)})`
              ctx.lineWidth = 0.5
              ctx.stroke()
            }
          }
        }
        this.animationId = requestAnimationFrame(draw)
      }
      draw()
    }
  }
}
</script>
<style scoped>
.lock-container {
  position: fixed;
  inset: 0;
  background: linear-gradient(135deg, #0f0c29, #302b63, #24243e);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  z-index: 9999;
  font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
  overflow: hidden;
}
.particle-bg {
  position: absolute;
  inset: 0;
  z-index: 0;
}
.lock-time {
  position: relative;
  z-index: 1;
  font-size: 72px;
  font-weight: 200;
  color: #fff;
  letter-spacing: 4px;
  text-shadow: 0 0 40px rgba(255,255,255,0.3);
  margin-bottom: 8px;
  font-variant-numeric: tabular-nums;
}
.lock-date {
  position: relative;
  z-index: 1;
  font-size: 15px;
  color: rgba(255,255,255,0.6);
  margin-bottom: 48px;
  letter-spacing: 2px;
}
.lock-card {
  position: relative;
  z-index: 1;
  background: rgba(255, 255, 255, 0.08);
  backdrop-filter: blur(20px);
  -webkit-backdrop-filter: blur(20px);
  border: 1px solid rgba(255, 255, 255, 0.15);
  border-radius: 24px;
  padding: 40px 48px;
  width: 360px;
  display: flex;
  flex-direction: column;
  align-items: center;
  box-shadow: 0 25px 60px rgba(0,0,0,0.4);
}
.avatar-wrap {
  position: relative;
  margin-bottom: 16px;
}
.lock-avatar {
  width: 80px;
  height: 80px;
  border-radius: 50%;
  border: 3px solid rgba(255,255,255,0.3);
  object-fit: cover;
  display: block;
}
.lock-icon {
  position: absolute;
  bottom: -4px;
  right: -4px;
  background: rgba(255,255,255,0.15);
  border-radius: 50%;
  width: 26px;
  height: 26px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 13px;
  backdrop-filter: blur(8px);
}
.lock-username {
  color: #fff;
  font-size: 18px;
  font-weight: 600;
  margin-bottom: 6px;
  letter-spacing: 1px;
}
.lock-hint {
  color: rgba(255,255,255,0.5);
  font-size: 13px;
  margin-bottom: 28px;
}
.input-wrap {
  width: 100%;
  display: flex;
  align-items: center;
  background: rgba(255,255,255,0.1);
  border: 1px solid rgba(255,255,255,0.2);
  border-radius: 50px;
  padding: 4px 4px 4px 20px;
  transition: border-color 0.3s;
}
.input-wrap:focus-within {
  border-color: rgba(255,255,255,0.6);
  background: rgba(255,255,255,0.13);
}
.input-wrap.shake {
  animation: shake 0.5s ease;
}
@keyframes shake {
  0%, 100% { transform: translateX(0); }
  20% { transform: translateX(-8px); }
  40% { transform: translateX(8px); }
  60% { transform: translateX(-6px); }
  80% { transform: translateX(6px); }
}
.lock-input {
  flex: 1;
  background: transparent;
  border: none;
  outline: none;
  color: #fff;
  font-size: 15px;
  padding: 10px 0;
}
.lock-input::placeholder {
  color: rgba(255,255,255,0.35);
}
.unlock-btn {
  width: 42px;
  height: 42px;
  border-radius: 50%;
  background: linear-gradient(135deg, #667eea, #764ba2);
  border: none;
  color: #fff;
  font-size: 18px;
  cursor: pointer;
  transition: transform 0.2s, opacity 0.2s;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}
.unlock-btn:hover:not(:disabled) {
  transform: scale(1.08);
}
.unlock-btn:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}
.loading-dot {
  font-size: 13px;
  letter-spacing: 1px;
}
.error-msg {
  margin-top: 14px;
  color: #ff7675;
  font-size: 13px;
  text-align: center;
  animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
  from { opacity: 0; transform: translateY(-4px); }
  to   { opacity: 1; transform: translateY(0); }
}
.lock-footer {
  margin-top: 24px;
}
.lock-footer a {
  color: rgba(255,255,255,0.4);
  font-size: 13px;
  text-decoration: none;
  transition: color 0.2s;
}
.lock-footer a:hover {
  color: rgba(255,255,255,0.8);
}
</style>