Bläddra i källkod

新增锁定屏幕功能

RuoYi 3 månader sedan
förälder
incheckning
01780ea59c

+ 35 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysIndexController.java

@@ -1,10 +1,17 @@
 package com.ruoyi.web.controller.system;
 
+import java.util.Map;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 import com.ruoyi.common.config.RuoYiConfig;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.core.domain.entity.SysUser;
+import com.ruoyi.common.utils.SecurityUtils;
 import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.system.service.ISysUserService;
 
 /**
  * 首页
@@ -18,6 +25,9 @@ public class SysIndexController
     @Autowired
     private RuoYiConfig ruoyiConfig;
 
+    @Autowired
+    private ISysUserService userService;
+
     /**
      * 访问首页,提示语
      */
@@ -26,4 +36,29 @@ public class SysIndexController
     {
         return StringUtils.format("欢迎使用{}后台管理框架,当前版本:v{},请通过前端地址访问。", ruoyiConfig.getName(), ruoyiConfig.getVersion());
     }
+
+    /**
+     * 解锁屏幕
+     */
+    @PostMapping("/unlockscreen")
+    public AjaxResult unlockScreen(@RequestBody Map<String, String> body)
+    {
+        String password = body.get("password");
+        if (StringUtils.isEmpty(password))
+        {
+            return AjaxResult.error("密码不能为空");
+        }
+        String username = SecurityUtils.getUsername();
+        SysUser user = userService.selectUserByUserName(username);
+        if (user == null)
+        {
+            return AjaxResult.error("服务器超时,请重新登录");
+        }
+        if (!SecurityUtils.matchesPassword(password, user.getPassword()))
+        {
+            return AjaxResult.error("密码错误,请重新输入");
+        }
+
+        return AjaxResult.success("解锁成功");
+    }
 }

+ 9 - 0
ruoyi-ui/src/api/login.js

@@ -39,6 +39,15 @@ export function getInfo() {
   })
 }
 
+// 解锁屏幕
+export function unlockScreen(password) {
+  return request({
+    url: '/unlockscreen',
+    method: 'post',
+    data: { password }
+  })
+}
+
 // 退出方法
 export function logout() {
   return request({

+ 9 - 0
ruoyi-ui/src/layout/components/Navbar.vue

@@ -45,6 +45,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>
@@ -112,6 +115,12 @@ export default {
     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: '确定',

+ 7 - 0
ruoyi-ui/src/permission.js

@@ -19,12 +19,19 @@ router.beforeEach((to, from, next) => {
   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

+ 6 - 0
ruoyi-ui/src/router/index.js

@@ -75,6 +75,12 @@ export const constantRoutes = [
     ]
   },
   {
+    path: '/lock',
+    component: () => import('@/views/lock'),
+    hidden: true,
+    meta: { title: '锁定屏幕' }
+  },
+  {
     path: '/user',
     component: Layout,
     hidden: true,

+ 2 - 0
ruoyi-ui/src/store/getters.js

@@ -3,6 +3,8 @@ const getters = {
   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,

+ 2 - 0
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 @@ Vue.use(Vuex)
 const store = new Vuex.Store({
   modules: {
     app,
+    lock,
     dict,
     user,
     tagsView,

+ 34 - 0
ruoyi-ui/src/store/modules/lock.js

@@ -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

+ 375 - 0
ruoyi-ui/src/views/lock.vue

@@ -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.response && err.response.data && err.response.data.msg) || err.msg || '密码错误,请重新输入'
+        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>