WuYihui 1 жил өмнө
parent
commit
e2fd349070

+ 2 - 0
package.json

@@ -33,6 +33,7 @@
     "@zxcvbn-ts/core": "^3.0.0",
     "animate.css": "^4.1.1",
     "axios": "^1.4.0",
+    "dayjs": "^1.11.7",
     "echarts": "^5.4.2",
     "echarts-wordcloud": "^2.1.0",
     "element-plus": "2.3.4",
@@ -42,6 +43,7 @@
     "mockjs": "^1.1.0",
     "nprogress": "^0.2.0",
     "pinia": "^2.0.36",
+    "pinia-plugin-persist": "^1.0.0",
     "qrcode": "^1.5.3",
     "qs": "^6.11.1",
     "url": "^0.11.0",

+ 42 - 0
src/components/UserInfo/src/UserInfo.vue

@@ -7,6 +7,14 @@ import { useRouter } from 'vue-router'
 import { loginOutApi } from '@/api/login'
 import { useDesign } from '@/hooks/web/useDesign'
 import { useTagsViewStore } from '@/store/modules/tagsView'
+import LockDialog from './components/LockDialog.vue'
+import { ref, computed } from 'vue'
+import LockPage from './components/LockPage.vue'
+import { useLockStore } from '@/store/modules/lock'
+
+const lockStore = useLockStore()
+
+const getIsLock = computed(() => lockStore.getLockInfo?.isLock ?? false)
 
 const tagsViewStore = useTagsViewStore()
 
@@ -38,6 +46,13 @@ const loginOut = () => {
     .catch(() => {})
 }
 
+const dialogVisible = ref<boolean>(false)
+
+// 锁定屏幕
+const lockScreen = () => {
+  dialogVisible.value = true
+}
+
 const toDocument = () => {
   window.open('https://element-plus-admin-doc.cn/')
 }
@@ -59,9 +74,36 @@ const toDocument = () => {
           <div @click="toDocument">{{ t('common.document') }}</div>
         </ElDropdownItem>
         <ElDropdownItem divided>
+          <div @click="lockScreen">{{ t('lock.lockScreen') }}</div>
+        </ElDropdownItem>
+        <ElDropdownItem>
           <div @click="loginOut">{{ t('common.loginOut') }}</div>
         </ElDropdownItem>
       </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="less">
+.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>

+ 102 - 0
src/components/UserInfo/src/components/LockDialog.vue

@@ -0,0 +1,102 @@
+<script setup lang="ts">
+import { useI18n } from '@/hooks/web/useI18n'
+import { ref, unref } from 'vue'
+import { Dialog } from '@/components/Dialog'
+import { Form } from '@/components/Form'
+import { useForm } from '@/hooks/web/useForm'
+import { reactive, computed } from 'vue'
+import { useValidator } from '@/hooks/web/useValidator'
+import { FormSchema } from '@/types/form'
+import { ElButton } from 'element-plus'
+import { useDesign } from '@/hooks/web/useDesign'
+import { useLockStore } from '@/store/modules/lock'
+
+const { getPrefixCls } = useDesign()
+const prefixCls = getPrefixCls('lock-dialog')
+
+const { required } = useValidator()
+
+const { t } = useI18n()
+
+const lockStore = useLockStore()
+
+const props = defineProps({
+  modelValue: {
+    type: Boolean
+  }
+})
+
+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 rules = reactive({
+  password: [required()]
+})
+
+const schema: FormSchema[] = reactive([
+  {
+    label: t('lock.lockPassword'),
+    field: 'password',
+    component: 'Input',
+    componentProps: {
+      type: 'password',
+      showPassword: true
+    }
+  }
+])
+
+const { register, formRef, methods } = useForm({
+  schema
+})
+
+const { getFormData } = methods
+
+const handleLock = () => {
+  unref(formRef)?.validate(async (valid) => {
+    if (valid) {
+      dialogVisible.value = false
+      const formData = await getFormData()
+      lockStore.setLockInfo({
+        isLock: true,
+        ...formData
+      })
+    }
+  })
+}
+</script>
+
+<template>
+  <Dialog
+    v-model="dialogVisible"
+    width="500px"
+    max-height="170px"
+    :class="prefixCls"
+    :title="dialogTitle"
+  >
+    <div class="flex flex-col items-center">
+      <img src="@/assets/imgs/avatar.jpg" alt="" class="w-70px h-70px rounded-[50%]" />
+      <span class="text-14px my-10px text-[var(--top-header-text-color)]">Archer</span>
+    </div>
+    <Form :is-col="false" :rules="rules" @register="register" />
+    <template #footer>
+      <ElButton type="primary" @click="handleLock">{{ t('lock.lock') }}</ElButton>
+    </template>
+  </Dialog>
+</template>
+
+<style lang="less" scoped>
+:global(.v-lock-dialog) {
+  @media (max-width: 767px) {
+    max-width: calc(100vw - 16px);
+  }
+}
+</style>

+ 270 - 0
src/components/UserInfo/src/components/LockPage.vue

@@ -0,0 +1,270 @@
+<script lang="ts" setup>
+import { ref } from 'vue'
+import { ElInput, ElButton } from 'element-plus'
+import { resetRouter } from '@/router'
+import { useRouter } from 'vue-router'
+import { useCache } from '@/hooks/web/useCache'
+import { useLockStore } from '@/store/modules/lock'
+import { useI18n } from '@/hooks/web/useI18n'
+import { useNow } from './useNow'
+import { useDesign } from '@/hooks/web/useDesign'
+import { Icon } from '@/components/Icon'
+import { loginOutApi } from '@/api/login'
+import { useTagsViewStore } from '@/store/modules/tagsView'
+
+const tagsViewStore = useTagsViewStore()
+
+const { wsCache } = useCache()
+
+const { replace } = useRouter()
+
+const password = ref('')
+const loading = ref(false)
+const errMsg = ref(false)
+const showDate = ref(true)
+
+const { getPrefixCls } = useDesign()
+const prefixCls = getPrefixCls('lock-page')
+
+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() {
+  const res = await loginOutApi().catch(() => {})
+  if (res) {
+    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="@/assets/imgs/avatar.jpg" alt="" class="w-70px h-70px rounded-[50%]" />
+            <span class="text-14px my-10px text-[var(--logo-title-text-color)]">Archer</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="less" 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: rgb(0 0 0 / 50%);
+    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/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

@@ -42,6 +42,16 @@ export default {
     delNoData: 'Please select the data to delete',
     delSuccess: 'Deleted successfully'
   },
+  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

@@ -42,6 +42,16 @@ export default {
     delNoData: '请选择需要删除的数据',
     delSuccess: '删除成功'
   },
+  lock: {
+    lockScreen: '锁定屏幕',
+    lock: '锁定',
+    lockPassword: '锁屏密码',
+    unlock: '点击解锁',
+    backToLogin: '返回登录',
+    entrySystem: '进入系统',
+    placeholder: '请输入锁屏密码',
+    message: '锁屏密码错误'
+  },
   error: {
     noPermission: `抱歉,您无权访问此页面。`,
     pageError: '抱歉,您访问的页面不存在。',

+ 3 - 0
src/store/index.ts

@@ -1,8 +1,11 @@
 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)
 }

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

@@ -0,0 +1,51 @@
+import { defineStore } from 'pinia'
+import { store } from '../index'
+
+interface lockInfo {
+  isLock?: boolean
+  password?: string
+}
+
+interface LockState {
+  lockInfo: lockInfo
+}
+
+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)
+}

+ 17 - 0
src/utils/dateUtil.ts

@@ -0,0 +1,17 @@
+/**
+ * Independent time operation tool to facilitate subsequent switch to dayjs
+ */
+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 {
 
   declare type LocaleType = 'zh-CN' | 'en'
 
+  declare type TimeoutHandle = ReturnType<typeof setTimeout>
+  declare type IntervalHandle = ReturnType<typeof setInterval>
+
   declare type AxiosHeaders =
     | 'application/json'
     | 'application/x-www-form-urlencoded'