新增锁定屏幕功能

This commit is contained in:
RuoYi
2026-03-20 21:44:35 +08:00
parent 6a8b086109
commit 030ba0dc28
7 changed files with 445 additions and 1 deletions

View File

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

View File

@@ -50,7 +50,10 @@
</router-link>
<el-dropdown-item command="setLayout" v-if="settingsStore.showSettings">
<span>布局设置</span>
</el-dropdown-item>
</el-dropdown-item>
<el-dropdown-item command="lockScreen">
<span>锁定屏幕</span>
</el-dropdown-item>
<el-dropdown-item divided command="logout">
<span>退出登录</span>
</el-dropdown-item>
@@ -75,11 +78,15 @@ import RuoYiGit from '@/components/RuoYi/Git'
import RuoYiDoc from '@/components/RuoYi/Doc'
import useAppStore from '@/store/modules/app'
import useUserStore from '@/store/modules/user'
import useLockStore from '@/store/modules/lock'
import useSettingsStore from '@/store/modules/settings'
import HeaderNotice from './HeaderNotice'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const userStore = useUserStore()
const lockStore = useLockStore()
const settingsStore = useSettingsStore()
function toggleSideBar() {
@@ -91,6 +98,9 @@ function handleCommand(command) {
case "setLayout":
setLayout()
break
case "lockScreen":
lockScreen()
break
case "logout":
logout()
break
@@ -116,6 +126,12 @@ function setLayout() {
emits('setLayout')
}
function lockScreen() {
const currentPath = route.fullPath
lockStore.lockScreen(currentPath)
router.push('/lock')
}
async function toggleTheme(event) {
const x = event?.clientX || window.innerWidth / 2
const y = event?.clientY || window.innerHeight / 2

View File

@@ -6,6 +6,7 @@ import { getToken } from '@/utils/auth'
import { isHttp, isPathMatch } from '@/utils/validate'
import { isRelogin } from '@/utils/request'
import useUserStore from '@/store/modules/user'
import useLockStore from '@/store/modules/lock'
import useSettingsStore from '@/store/modules/settings'
import usePermissionStore from '@/store/modules/permission'
@@ -21,12 +22,20 @@ router.beforeEach((to, from, next) => {
NProgress.start()
if (getToken()) {
to.meta.title && useSettingsStore().setTitle(to.meta.title)
const isLock = useLockStore().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') {
alert(isLock)
next({ path: '/' })
NProgress.done()
} else {
if (useUserStore().roles.length === 0) {
isRelogin.show = true

View File

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

27
src/store/modules/lock.js Normal file
View File

@@ -0,0 +1,27 @@
const LOCK_KEY = 'screen-lock'
const LOCK_PATH_KEY = 'screen-lock-path'
export const useLockStore = defineStore('lock', {
state: () => ({
isLock: JSON.parse(localStorage.getItem(LOCK_KEY) || 'false'),
lockPath: localStorage.getItem(LOCK_PATH_KEY) || '/index'
}),
actions: {
// 锁定屏幕,同时记录当前路径
lockScreen(currentPath) {
this.lockPath = currentPath || '/index'
localStorage.setItem(LOCK_PATH_KEY, this.lockPath)
this.isLock = true
localStorage.setItem(LOCK_KEY, 'true')
},
// 解锁屏幕,清除路径
unlockScreen() {
this.isLock = false
localStorage.setItem(LOCK_KEY, 'false')
this.lockPath = '/index'
localStorage.setItem(LOCK_PATH_KEY, '/index')
}
}
})
export default useLockStore

View File

@@ -3,6 +3,7 @@ import { ElMessageBox, } from 'element-plus'
import { login, logout, getInfo } from '@/api/login'
import { getToken, setToken, removeToken } from '@/utils/auth'
import { isHttp, isEmpty } from "@/utils/validate"
import useLockStore from '@/store/modules/lock'
import defAva from '@/assets/images/profile.jpg'
const useUserStore = defineStore(
@@ -28,6 +29,7 @@ const useUserStore = defineStore(
login(username, password, code, uuid).then(res => {
setToken(res.token)
this.token = res.token
useLockStore().unlockScreen()
resolve()
}).catch(error => {
reject(error)

375
src/views/lock.vue Normal file
View 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="userStore.avatar" class="lock-avatar" @error="onAvatarError" />
<div class="lock-icon">🔒</div>
</div>
<div class="lock-username">{{ userStore.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="javascript:;" @click="goLogin">退出重新登录</a>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import useUserStore from '@/store/modules/user'
import useLockStore from '@/store/modules/lock'
import { unlockScreen } from '@/api/login'
import defAva from '@/assets/images/profile.jpg'
const router = useRouter()
const userStore = useUserStore()
const lockStore = useLockStore()
const password = ref('')
const loading = ref(false)
const errorMsg = ref('')
const isShaking = ref(false)
const currentTime = ref('')
const currentDate = ref('')
const passwordInput = ref(null)
const particleCanvas = ref(null)
let timer = null
let animationId = null
let particles = []
const onAvatarError = (e) => {
e.target.src = defAva
}
const startClock = () => {
const update = () => {
const now = new Date()
const pad = n => String(n).padStart(2, '0')
currentTime.value = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
const days = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
currentDate.value = `${now.getFullYear()}${now.getMonth() + 1}${now.getDate()}${days[now.getDay()]}`
}
update()
timer = setInterval(update, 1000)
}
const handleUnlock = async () => {
if (!password.value) {
showError('请输入密码')
return
}
loading.value = true
errorMsg.value = ''
try {
await unlockScreen(password.value)
const lockPath = lockStore.lockPath
lockStore.unlockScreen()
router.replace(lockPath)
} catch (err) {
const msg = err.message || err.toString()
showError(msg)
password.value = ''
nextTick(() => passwordInput.value?.focus())
} finally {
loading.value = false
}
}
const showError = (msg) => {
errorMsg.value = msg
isShaking.value = true
setTimeout(() => { isShaking.value = false }, 600)
}
const goLogin = () => {
lockStore.unlockScreen()
userStore.logOut().then(() => {
router.push('/login')
})
}
const initParticles = () => {
const canvas = particleCanvas.value
if (!canvas) return
const ctx = canvas.getContext('2d')
const resize = () => {
canvas.width = window.innerWidth
canvas.height = window.innerHeight
}
resize()
window.addEventListener('resize', resize)
particles = Array.from({ length: 80 }, () => ({
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)
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 < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const a = particles[i], b = 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()
}
}
}
animationId = requestAnimationFrame(draw)
}
draw()
}
onMounted(() => {
startClock()
initParticles()
nextTick(() => passwordInput.value?.focus())
})
onBeforeUnmount(() => {
clearInterval(timer)
cancelAnimationFrame(animationId)
})
</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>