Browse Source

新增锁定屏幕功能

RuoYi 2 months ago
parent
commit
030ba0dc28

+ 9 - 0
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() {
 export function logout() {
   return request({
   return request({

+ 17 - 1
src/layout/components/Navbar.vue

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

+ 9 - 0
src/permission.js

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

+ 6 - 0
src/router/index.js

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

+ 27 - 0
src/store/modules/lock.js

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

+ 2 - 0
src/store/modules/user.js

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

+ 375 - 0
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="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>