From 88c2e4979a53621db8b00b8e11a7c499febcd2da Mon Sep 17 00:00:00 2001
From: RuoYi <yzz_ivy@163.com>
Date: Sat, 21 Mar 2026 23:29:22 +0800
Subject: [PATCH] 新增锁定屏幕功能
---
ruoyi-ui/src/permission.js | 7
ruoyi-auth/src/main/java/com/ruoyi/auth/controller/TokenController.java | 11 +
ruoyi-ui/src/store/modules/user.js | 2
ruoyi-ui/src/views/lock.vue | 375 +++++++++++++++++++++++++++++++++++++++++
ruoyi-auth/src/main/java/com/ruoyi/auth/form/UnLockBody.java | 24 ++
ruoyi-auth/src/main/java/com/ruoyi/auth/service/SysLoginService.java | 29 +++
ruoyi-ui/src/api/login.js | 9 +
ruoyi-ui/src/store/index.js | 2
ruoyi-ui/src/store/modules/lock.js | 34 +++
ruoyi-ui/src/router/index.js | 6
ruoyi-ui/src/store/getters.js | 2
ruoyi-ui/src/layout/components/Navbar.vue | 14 +
12 files changed, 510 insertions(+), 5 deletions(-)
diff --git a/ruoyi-auth/src/main/java/com/ruoyi/auth/controller/TokenController.java b/ruoyi-auth/src/main/java/com/ruoyi/auth/controller/TokenController.java
index c21dee5..beb8615 100644
--- a/ruoyi-auth/src/main/java/com/ruoyi/auth/controller/TokenController.java
+++ b/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();
+ }
}
diff --git a/ruoyi-auth/src/main/java/com/ruoyi/auth/form/UnLockBody.java b/ruoyi-auth/src/main/java/com/ruoyi/auth/form/UnLockBody.java
new file mode 100644
index 0000000..7421e77
--- /dev/null
+++ b/ruoyi-auth/src/main/java/com/ruoyi/auth/form/UnLockBody.java
@@ -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;
+ }
+}
diff --git a/ruoyi-auth/src/main/java/com/ruoyi/auth/service/SysLoginService.java b/ruoyi-auth/src/main/java/com/ruoyi/auth/service/SysLoginService.java
index 9ba6046..907ffdf 100644
--- a/ruoyi-auth/src/main/java/com/ruoyi/auth/service/SysLoginService.java
+++ b/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)
diff --git a/ruoyi-ui/src/api/login.js b/ruoyi-ui/src/api/login.js
index 50d8623..5d282dc 100644
--- a/ruoyi-ui/src/api/login.js
+++ b/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({
diff --git a/ruoyi-ui/src/layout/components/Navbar.vue b/ruoyi-ui/src/layout/components/Navbar.vue
index f64e554..35cf728 100644
--- a/ruoyi-ui/src/layout/components/Navbar.vue
+++ b/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 {
diff --git a/ruoyi-ui/src/permission.js b/ruoyi-ui/src/permission.js
index 7b0d1ed..1dac250 100644
--- a/ruoyi-ui/src/permission.js
+++ b/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
diff --git a/ruoyi-ui/src/router/index.js b/ruoyi-ui/src/router/index.js
index 958ca4b..f4e884a 100644
--- a/ruoyi-ui/src/router/index.js
+++ b/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,
diff --git a/ruoyi-ui/src/store/getters.js b/ruoyi-ui/src/store/getters.js
index 04e9fe3..5545bb1 100644
--- a/ruoyi-ui/src/store/getters.js
+++ b/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,
diff --git a/ruoyi-ui/src/store/index.js b/ruoyi-ui/src/store/index.js
index 2ee6e43..293f5d1 100644
--- a/ruoyi-ui/src/store/index.js
+++ b/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,
diff --git a/ruoyi-ui/src/store/modules/lock.js b/ruoyi-ui/src/store/modules/lock.js
new file mode 100644
index 0000000..14e1bd0
--- /dev/null
+++ b/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
diff --git a/ruoyi-ui/src/store/modules/user.js b/ruoyi-ui/src/store/modules/user.js
index 1fff758..18ccf1c 100644
--- a/ruoyi-ui/src/store/modules/user.js
+++ b/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)
diff --git a/ruoyi-ui/src/views/lock.vue b/ruoyi-ui/src/views/lock.vue
new file mode 100644
index 0000000..d6abf08
--- /dev/null
+++ b/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.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>
--
Gitblit v1.9.3