Browse Source

✨ 2023-05-10:feat: 新增锁屏功能

YunaiV 1 year ago
parent
commit
f8580fdf2a

+ 1 - 0
package.json

@@ -55,6 +55,7 @@
     "mitt": "^3.0.1",
     "nprogress": "^0.2.0",
     "pinia": "^2.1.7",
+    "pinia-plugin-persist": "^1.0.0",
     "qrcode": "^1.5.3",
     "qs": "^6.11.2",
     "steady-xml": "^0.1.0",

BIN
src/assets/imgs/avatar.jpg


+ 1 - 1
src/components/Editor/src/Editor.vue

@@ -185,7 +185,7 @@ defineExpose({
     <Toolbar
       :editor="editorRef"
       :editorId="editorId"
-      class="border-0 b-b-1 border-[var(--el-border-color)] border-solid"
+      class="border-0 b-b-1 border-solid border-[var(--tags-view-border-color)]"
     />
     <!-- 编辑器 -->
     <Editor

+ 43 - 2
src/layout/components/UserInfo/src/UserInfo.vue

@@ -5,6 +5,9 @@ import avatarImg from '@/assets/imgs/avatar.gif'
 import { useDesign } from '@/hooks/web/useDesign'
 import { useTagsViewStore } from '@/store/modules/tagsView'
 import { useUserStore } from '@/store/modules/user'
+import LockDialog from './components/LockDialog.vue'
+import LockPage from './components/LockPage.vue'
+import { useLockStore } from '@/store/modules/lock'
 
 defineOptions({ name: 'UserInfo' })
 
@@ -23,6 +26,14 @@ const prefixCls = getPrefixCls('user-info')
 const avatar = computed(() => userStore.user.avatar ?? avatarImg)
 const userName = computed(() => userStore.user.nickname ?? 'Admin')
 
+// 锁定屏幕
+const lockStore = useLockStore()
+const getIsLock = computed(() => lockStore.getLockInfo?.isLock ?? false)
+const dialogVisible = ref<boolean>(false)
+const lockScreen = () => {
+  dialogVisible.value = true
+}
+
 const loginOut = async () => {
   try {
     await ElMessageBox.confirm(t('common.loginOutMessage'), t('common.reminder'), {
@@ -33,8 +44,7 @@ const loginOut = async () => {
     await userStore.loginOut()
     tagsViewStore.delAllViews()
     replace('/login?redirect=/index')
-  }
-  catch { }
+  } catch {}
 }
 const toProfile = async () => {
   push('/user/profile')
@@ -62,6 +72,10 @@ const toDocument = () => {
           <Icon icon="ep:menu" />
           <div @click="toDocument">{{ t('common.document') }}</div>
         </ElDropdownItem>
+        <ElDropdownItem divided>
+          <Icon icon="ep:lock" />
+          <div @click="lockScreen">{{ t('lock.lockScreen') }}</div>
+        </ElDropdownItem>
         <ElDropdownItem divided @click="loginOut">
           <Icon icon="ep:switch-button" />
           <div>{{ t('common.loginOut') }}</div>
@@ -69,4 +83,31 @@ const toDocument = () => {
       </ElDropdownMenu>
     </template>
   </ElDropdown>
+
+  <LockDialog v-if="dialogVisible" v-model="dialogVisible" />
+
+  <teleport to="body">
+    <transition name="fade-bottom" mode="out-in">
+      <LockPage v-if="getIsLock" />
+    </transition>
+  </teleport>
 </template>
+
+<style scoped lang="scss">
+.fade-bottom-enter-active,
+.fade-bottom-leave-active {
+  transition:
+    opacity 0.25s,
+    transform 0.3s;
+}
+
+.fade-bottom-enter-from {
+  opacity: 0;
+  transform: translateY(-10%);
+}
+
+.fade-bottom-leave-to {
+  opacity: 0;
+  transform: translateY(10%);
+}
+</style>

+ 98 - 0
src/layout/components/UserInfo/src/components/LockDialog.vue

@@ -0,0 +1,98 @@
+<script setup lang="ts">
+import { useValidator } from '@/hooks/web/useValidator'
+import { useDesign } from '@/hooks/web/useDesign'
+import { useLockStore } from '@/store/modules/lock'
+import avatarImg from '@/assets/imgs/avatar.gif'
+import { useUserStore } from '@/store/modules/user'
+
+const { getPrefixCls } = useDesign()
+const prefixCls = getPrefixCls('lock-dialog')
+
+const { required } = useValidator()
+
+const { t } = useI18n()
+
+const lockStore = useLockStore()
+
+const props = defineProps({
+  modelValue: {
+    type: Boolean
+  }
+})
+
+const userStore = useUserStore()
+const avatar = computed(() => userStore.user.avatar ?? avatarImg)
+const userName = computed(() => userStore.user.nickname ?? 'Admin')
+
+const emit = defineEmits(['update:modelValue'])
+
+const dialogVisible = computed({
+  get: () => props.modelValue,
+  set: (val) => {
+    console.log('set: ', val)
+    emit('update:modelValue', val)
+  }
+})
+
+const dialogTitle = ref(t('lock.lockScreen'))
+
+const formData = ref({
+  password: undefined
+})
+const formRules = reactive({
+  password: [required()]
+})
+
+const formRef = ref() // 表单 Ref
+const handleLock = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 提交请求
+  dialogVisible.value = false
+  lockStore.setLockInfo({
+    ...formData.value,
+    isLock: true
+  })
+}
+</script>
+
+<template>
+  <Dialog
+    v-model="dialogVisible"
+    width="500px"
+    max-height="170px"
+    :class="prefixCls"
+    :title="dialogTitle"
+  >
+    <div class="flex flex-col items-center">
+      <img :src="avatar" alt="" class="w-70px h-70px rounded-[50%]" />
+      <span class="text-14px my-10px text-[var(--top-header-text-color)]">
+        {{ userName }}
+      </span>
+    </div>
+    <el-form ref="formRef" :model="formData" :rules="formRules" label-width="80px">
+      <el-form-item :label="t('lock.lockPassword')" prop="password">
+        <el-input
+          type="password"
+          v-model="formData.password"
+          :placeholder="'请输入' + t('lock.lockPassword')"
+          clearable
+          show-password
+        />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <ElButton type="primary" @click="handleLock">{{ t('lock.lock') }}</ElButton>
+    </template>
+  </Dialog>
+</template>
+
+<style lang="scss" scoped>
+:global(.v-lock-dialog) {
+  @media (max-width: 767px) {
+    max-width: calc(100vw - 16px);
+  }
+}
+</style>

+ 272 - 0
src/layout/components/UserInfo/src/components/LockPage.vue

@@ -0,0 +1,272 @@
+<script lang="ts" setup>
+import { resetRouter } from '@/router'
+import { useCache } from '@/hooks/web/useCache'
+import { useLockStore } from '@/store/modules/lock'
+import { useNow } from './useNow'
+import { useDesign } from '@/hooks/web/useDesign'
+import { useTagsViewStore } from '@/store/modules/tagsView'
+import { useUserStore } from '@/store/modules/user'
+import avatarImg from '@/assets/imgs/avatar.gif'
+
+const tagsViewStore = useTagsViewStore()
+
+const { wsCache } = useCache()
+
+const { replace } = useRouter()
+
+const userStore = useUserStore()
+
+const password = ref('')
+const loading = ref(false)
+const errMsg = ref(false)
+const showDate = ref(true)
+
+const { getPrefixCls } = useDesign()
+const prefixCls = getPrefixCls('lock-page')
+
+const avatar = computed(() => userStore.user.avatar ?? avatarImg)
+const userName = computed(() => userStore.user.nickname ?? 'Admin')
+
+const lockStore = useLockStore()
+
+const { hour, month, minute, meridiem, year, day, week } = useNow(true)
+
+const { t } = useI18n()
+
+// 解锁
+async function unLock() {
+  if (!password.value) {
+    return
+  }
+  let pwd = password.value
+  try {
+    loading.value = true
+    const res = await lockStore.unLock(pwd)
+    errMsg.value = !res
+  } finally {
+    loading.value = false
+  }
+}
+
+// 返回登录
+async function goLogin() {
+  await userStore.loginOut().catch(() => {})
+  // 登出后清理
+  wsCache.clear()
+  tagsViewStore.delAllViews()
+  resetRouter() // 重置静态路由表
+  lockStore.resetLockInfo()
+  replace('/login')
+}
+
+function handleShowForm(show = false) {
+  showDate.value = show
+}
+</script>
+
+<template>
+  <div
+    :class="prefixCls"
+    class="fixed inset-0 flex h-screen w-screen bg-black items-center justify-center"
+  >
+    <div
+      :class="`${prefixCls}__unlock`"
+      class="absolute top-0 left-1/2 flex pt-5 h-16 items-center justify-center sm:text-md xl:text-xl text-white flex-col cursor-pointer transform translate-x-1/2"
+      @click="handleShowForm(false)"
+      v-show="showDate"
+    >
+      <Icon icon="ep:lock" />
+      <span>{{ t('lock.unlock') }}</span>
+    </div>
+
+    <div class="flex w-screen h-screen justify-center items-center">
+      <div :class="`${prefixCls}__hour`" class="relative mr-5 md:mr-20 w-2/5 h-2/5 md:h-4/5">
+        <span>{{ hour }}</span>
+        <span class="meridiem absolute left-5 top-5 text-md xl:text-xl" v-show="showDate">
+          {{ meridiem }}
+        </span>
+      </div>
+      <div :class="`${prefixCls}__minute w-2/5 h-2/5 md:h-4/5 `">
+        <span> {{ minute }}</span>
+      </div>
+    </div>
+    <transition name="fade-slide">
+      <div :class="`${prefixCls}-entry`" v-show="!showDate">
+        <div :class="`${prefixCls}-entry-content`">
+          <div class="flex flex-col items-center">
+            <img :src="avatar" alt="" class="w-70px h-70px rounded-[50%]" />
+            <span class="text-14px my-10px text-[var(--logo-title-text-color)]">
+              {{ userName }}
+            </span>
+          </div>
+          <ElInput
+            type="password"
+            :placeholder="t('lock.placeholder')"
+            class="enter-x"
+            v-model="password"
+          />
+          <span :class="`text-14px ${prefixCls}-entry__err-msg enter-x`" v-if="errMsg">
+            {{ t('lock.message') }}
+          </span>
+          <div :class="`${prefixCls}-entry__footer enter-x`">
+            <ElButton
+              type="primary"
+              size="small"
+              class="mt-2 mr-2 enter-x"
+              link
+              :disabled="loading"
+              @click="handleShowForm(true)"
+            >
+              {{ t('common.back') }}
+            </ElButton>
+            <ElButton
+              type="primary"
+              size="small"
+              class="mt-2 mr-2 enter-x"
+              link
+              :disabled="loading"
+              @click="goLogin"
+            >
+              {{ t('lock.backToLogin') }}
+            </ElButton>
+            <ElButton
+              type="primary"
+              class="mt-2"
+              size="small"
+              link
+              @click="unLock()"
+              :disabled="loading"
+            >
+              {{ t('lock.entrySystem') }}
+            </ElButton>
+          </div>
+        </div>
+      </div>
+    </transition>
+
+    <div class="absolute bottom-5 w-full text-gray-300 xl:text-xl 2xl:text-3xl text-center enter-y">
+      <div class="text-5xl mb-4 enter-x" v-show="!showDate">
+        {{ hour }}:{{ minute }} <span class="text-3xl">{{ meridiem }}</span>
+      </div>
+      <div class="text-2xl">{{ year }}/{{ month }}/{{ day }} {{ week }}</div>
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+$prefix-cls: '#{$namespace}-lock-page';
+
+// Small screen / tablet
+$screen-sm: 576px;
+
+// Medium screen / desktop
+$screen-md: 768px;
+
+// Large screen / wide desktop
+$screen-lg: 992px;
+
+// Extra large screen / full hd
+$screen-xl: 1200px;
+
+// Extra extra large screen / large desktop
+$screen-2xl: 1600px;
+
+$error-color: #ed6f6f;
+
+.#{$prefix-cls} {
+  z-index: 3000;
+
+  &__unlock {
+    transform: translate(-50%, 0);
+  }
+
+  &__hour,
+  &__minute {
+    display: flex;
+    font-weight: 700;
+    color: #bababa;
+    background-color: #141313;
+    border-radius: 30px;
+    justify-content: center;
+    align-items: center;
+
+    @media screen and (max-width: $screen-md) {
+      span:not(.meridiem) {
+        font-size: 160px;
+      }
+    }
+
+    @media screen and (min-width: $screen-md) {
+      span:not(.meridiem) {
+        font-size: 160px;
+      }
+    }
+
+    @media screen and (max-width: $screen-sm) {
+      span:not(.meridiem) {
+        font-size: 90px;
+      }
+    }
+    @media screen and (min-width: $screen-lg) {
+      span:not(.meridiem) {
+        font-size: 220px;
+      }
+    }
+
+    @media screen and (min-width: $screen-xl) {
+      span:not(.meridiem) {
+        font-size: 260px;
+      }
+    }
+    @media screen and (min-width: $screen-2xl) {
+      span:not(.meridiem) {
+        font-size: 320px;
+      }
+    }
+  }
+
+  &-entry {
+    position: absolute;
+    top: 0;
+    left: 0;
+    display: flex;
+    width: 100%;
+    height: 100%;
+    background-color: rgba(0, 0, 0, 0.5);
+    backdrop-filter: blur(8px);
+    justify-content: center;
+    align-items: center;
+
+    &-content {
+      width: 260px;
+    }
+
+    &__header {
+      text-align: center;
+
+      &-img {
+        width: 70px;
+        margin: 0 auto;
+        border-radius: 50%;
+      }
+
+      &-name {
+        margin-top: 5px;
+        font-weight: 500;
+        color: #bababa;
+      }
+    }
+
+    &__err-msg {
+      display: inline-block;
+      margin-top: 10px;
+      color: $error-color;
+    }
+
+    &__footer {
+      display: flex;
+      justify-content: space-between;
+    }
+  }
+}
+</style>

+ 60 - 0
src/layout/components/UserInfo/src/components/useNow.ts

@@ -0,0 +1,60 @@
+import { dateUtil } from '@/utils/dateUtil'
+import { reactive, toRefs } from 'vue'
+import { tryOnMounted, tryOnUnmounted } from '@vueuse/core'
+
+export function useNow(immediate = true) {
+  let timer: IntervalHandle
+
+  const state = reactive({
+    year: 0,
+    month: 0,
+    week: '',
+    day: 0,
+    hour: '',
+    minute: '',
+    second: 0,
+    meridiem: ''
+  })
+
+  const update = () => {
+    const now = dateUtil()
+
+    const h = now.format('HH')
+    const m = now.format('mm')
+    const s = now.get('s')
+
+    state.year = now.get('y')
+    state.month = now.get('M') + 1
+    state.week = '星期' + ['日', '一', '二', '三', '四', '五', '六'][now.day()]
+    state.day = now.get('date')
+    state.hour = h
+    state.minute = m
+    state.second = s
+
+    state.meridiem = now.format('A')
+  }
+
+  function start() {
+    update()
+    clearInterval(timer)
+    timer = setInterval(() => update(), 1000)
+  }
+
+  function stop() {
+    clearInterval(timer)
+  }
+
+  tryOnMounted(() => {
+    immediate && start()
+  })
+
+  tryOnUnmounted(() => {
+    stop()
+  })
+
+  return {
+    ...toRefs(state),
+    start,
+    stop
+  }
+}

+ 10 - 0
src/locales/en.ts

@@ -56,6 +56,16 @@ export default {
     copySuccess: 'Copy Success',
     copyError: 'Copy Error'
   },
+  lock: {
+    lockScreen: 'Lock screen',
+    lock: 'Lock',
+    lockPassword: 'Lock screen password',
+    unlock: 'Click to unlock',
+    backToLogin: 'Back to login',
+    entrySystem: 'Entry the system',
+    placeholder: 'Please enter the lock screen password',
+    message: 'Lock screen password error'
+  },
   error: {
     noPermission: `Sorry, you don't have permission to access this page.`,
     pageError: 'Sorry, the page you visited does not exist.',

+ 10 - 0
src/locales/zh-CN.ts

@@ -56,6 +56,16 @@ export default {
     copySuccess: '复制成功',
     copyError: '复制失败'
   },
+  lock: {
+    lockScreen: '锁定屏幕',
+    lock: '锁定',
+    lockPassword: '锁屏密码',
+    unlock: '点击解锁',
+    backToLogin: '返回登录',
+    entrySystem: '进入系统',
+    placeholder: '请输入锁屏密码',
+    message: '锁屏密码错误'
+  },
   error: {
     noPermission: `抱歉,您无权访问此页面。`,
     pageError: '抱歉,您访问的页面不存在。',

+ 2 - 0
src/store/index.ts

@@ -1,7 +1,9 @@
 import type { App } from 'vue'
 import { createPinia } from 'pinia'
+import piniaPersist from 'pinia-plugin-persist'
 
 const store = createPinia()
+store.use(piniaPersist)
 
 export const setupStore = (app: App<Element>) => {
   app.use(store)

+ 52 - 0
src/store/modules/lock.ts

@@ -0,0 +1,52 @@
+import { defineStore } from 'pinia'
+import { store } from '@/store'
+
+interface lockInfo {
+  isLock?: boolean
+  password?: string
+}
+
+interface LockState {
+  lockInfo: lockInfo
+}
+
+// TODO 芋艿:【锁屏】这里有报错,后续解决下
+export const useLockStore = defineStore('lock', {
+  state: (): LockState => {
+    return {
+      lockInfo: {
+        // isLock: false, // 是否锁定屏幕
+        // password: '' // 锁屏密码
+      }
+    }
+  },
+  getters: {
+    getLockInfo(): lockInfo {
+      return this.lockInfo
+    }
+  },
+  actions: {
+    setLockInfo(lockInfo: lockInfo) {
+      this.lockInfo = lockInfo
+    },
+    resetLockInfo() {
+      this.lockInfo = {}
+    },
+    unLock(password: string) {
+      if (this.lockInfo?.password === password) {
+        this.resetLockInfo()
+        return true
+      } else {
+        return false
+      }
+    }
+  },
+  persist: {
+    enabled: true,
+    strategies: [{ key: 'lock', storage: localStorage }]
+  }
+})
+
+export const useLockStoreWithOut = () => {
+  return useLockStore(store)
+}

+ 18 - 0
src/utils/dateUtil.ts

@@ -0,0 +1,18 @@
+/**
+ * Independent time operation tool to facilitate subsequent switch to dayjs
+ */
+// TODO 芋艿:【锁屏】可能后面删除掉
+import dayjs from 'dayjs'
+
+const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'
+const DATE_FORMAT = 'YYYY-MM-DD'
+
+export function formatToDateTime(date?: dayjs.ConfigType, format = DATE_TIME_FORMAT): string {
+  return dayjs(date).format(format)
+}
+
+export function formatToDate(date?: dayjs.ConfigType, format = DATE_FORMAT): string {
+  return dayjs(date).format(format)
+}
+
+export const dateUtil = dayjs

+ 3 - 0
types/global.d.ts

@@ -14,6 +14,9 @@ declare global {
 
   type LocaleType = 'zh-CN' | 'en'
 
+  declare type TimeoutHandle = ReturnType<typeof setTimeout>
+  declare type IntervalHandle = ReturnType<typeof setInterval>
+
   type AxiosHeaders =
     | 'application/json'
     | 'application/x-www-form-urlencoded'