Browse Source

feat: add the ability to lock the screen (#30)

* feat: 锁屏功能

* feat: 锁屏样式调整

* feat: complete the lock-screen screen and support shortcut keys and preference configuration

---------

Co-authored-by: vince <vince292007@gmail.com>
Laychen 8 months ago
parent
commit
06f5d5686d
27 changed files with 482 additions and 48 deletions
  1. 28 6
      apps/web-antd/src/layouts/basic.vue
  2. 1 1
      apps/web-antd/src/router/routes/_essentials.ts
  3. 2 2
      apps/web-antd/src/store/index.ts
  4. 29 13
      apps/web-antd/src/store/modules/app.ts
  5. 2 1
      packages/@core/forward/preferences/src/config.ts
  6. 4 2
      packages/@core/forward/preferences/src/types.ts
  7. 6 0
      packages/@core/forward/preferences/src/use-preferences.ts
  8. 12 1
      packages/@core/locales/src/langs/en-US.json
  9. 12 1
      packages/@core/locales/src/langs/zh-CN.json
  10. 1 1
      packages/@core/shared/design/src/scss-bem/bem.scss
  11. 7 2
      packages/@core/shared/iconify/src/create-icon.ts
  12. 2 0
      packages/@core/shared/iconify/src/material.ts
  13. 4 1
      packages/business/layouts/src/basic/layout.vue
  14. 1 0
      packages/business/layouts/src/widgets/index.ts
  15. 2 0
      packages/business/layouts/src/widgets/lock-screen/index.ts
  16. 106 0
      packages/business/layouts/src/widgets/lock-screen/lock-screen-modal.vue
  17. 170 0
      packages/business/layouts/src/widgets/lock-screen/lock-screen.vue
  18. 9 0
      packages/business/layouts/src/widgets/lock-screen/typings.ts
  19. 4 0
      packages/business/layouts/src/widgets/preferences/blocks/layout/widget.vue
  20. 11 3
      packages/business/layouts/src/widgets/preferences/blocks/shortcut-keys/global.vue
  21. 6 0
      packages/business/layouts/src/widgets/preferences/preferences-sheet.vue
  22. 2 2
      packages/business/layouts/src/widgets/preferences/preferences.vue
  23. 53 4
      packages/business/layouts/src/widgets/user-dropdown/user-dropdown.vue
  24. 2 2
      packages/business/universal-ui/src/authentication/code-login.vue
  25. 2 2
      packages/business/universal-ui/src/authentication/forget-password.vue
  26. 2 2
      packages/business/universal-ui/src/authentication/qrcode-login.vue
  27. 2 2
      packages/business/universal-ui/src/authentication/register.vue

+ 28 - 6
apps/web-antd/src/layouts/basic.vue

@@ -1,11 +1,12 @@
 <script lang="ts" setup>
-import { computed, ref, toRefs } from 'vue';
+import { computed, ref } from 'vue';
 import { useRouter } from 'vue-router';
 
 import { LOGIN_PATH } from '@vben/constants';
 import { IcRoundCreditScore, MdiDriveDocument, MdiGithub } from '@vben/icons';
 import {
   BasicLayout,
+  LockScreen,
   Notification,
   NotificationItem,
   UserDropdown,
@@ -16,7 +17,7 @@ import { preferences } from '@vben-core/preferences';
 
 import { $t } from '#/locales';
 import { resetRoutes } from '#/router';
-import { useAccessStore, useAppStore } from '#/store';
+import { storeToRefs, useAccessStore, useAppStore } from '#/store';
 
 const notifications = ref<NotificationItem[]>([
   {
@@ -85,11 +86,18 @@ const menus = computed(() => [
 
 const appStore = useAppStore();
 const accessStore = useAccessStore();
+
+const { isLockScreen, lockScreenPassword } = storeToRefs(appStore);
 const {
   loading: loginLoading,
   openLoginExpiredModal,
   userInfo,
-} = toRefs(accessStore);
+} = storeToRefs(accessStore);
+
+const avatar = computed(() => {
+  return userInfo.value?.avatar ?? preferences.app.defaultAvatar;
+});
+
 const router = useRouter();
 
 async function handleLogout() {
@@ -105,17 +113,22 @@ function handleNoticeClear() {
 function handleMakeAll() {
   notifications.value.forEach((item) => (item.isRead = true));
 }
+
+function handleLockScreen(password: string) {
+  appStore.lockScreen(password);
+}
 </script>
 
 <template>
   <BasicLayout @clear-preferences-and-logout="handleLogout">
     <template #user-dropdown>
       <UserDropdown
-        :avatar="userInfo?.avatar ?? preferences.app.defaultAvatar"
-        :menus="menus"
+        :avatar
+        :menus
         :text="userInfo?.realName"
         description="ann.vben@gmail.com"
         tag-text="Pro"
+        @lock-screen="handleLockScreen"
         @logout="handleLogout"
       />
     </template>
@@ -127,7 +140,7 @@ function handleMakeAll() {
         @make-all="handleMakeAll"
       />
     </template>
-    <template #dialog>
+    <template #extra>
       <AuthenticationLoginExpiredModal
         v-model:open="openLoginExpiredModal"
         :loading="loginLoading"
@@ -136,5 +149,14 @@ function handleMakeAll() {
         @submit="accessStore.authLogin"
       />
     </template>
+    <template #lock-screen>
+      <LockScreen
+        v-if="isLockScreen"
+        :avatar
+        :cached-password="lockScreenPassword"
+        @to-login="handleLogout"
+        @unlock="appStore.unlockScreen"
+      />
+    </template>
   </BasicLayout>
 </template>

+ 1 - 1
apps/web-antd/src/router/routes/_essentials.ts

@@ -51,7 +51,7 @@ const essentialsRoutes: RouteRecordRaw[] = [
         component: () =>
           import('#/views/_essential/authentication/code-login.vue'),
         meta: {
-          title: $t('page.essentials.code-login'),
+          title: $t('page.essentials.codeLogin'),
         },
       },
       {

+ 2 - 2
apps/web-antd/src/store/index.ts

@@ -2,7 +2,7 @@ import type { InitStoreOptions } from '@vben-core/stores';
 
 import type { App } from 'vue';
 
-import { initStore } from '@vben-core/stores';
+import { initStore, storeToRefs } from '@vben-core/stores';
 
 /**
  * @zh_CN 初始化pinia
@@ -13,7 +13,7 @@ async function setupStore(app: App, options: InitStoreOptions) {
   app.use(pinia);
 }
 
-export { setupStore };
+export { setupStore, storeToRefs };
 
 export { useAccessStore } from './modules/access';
 export { useAppStore } from './modules/app';

+ 29 - 13
apps/web-antd/src/store/modules/app.ts

@@ -4,19 +4,35 @@ import { defineStore } from 'pinia';
 
 import { useAccessStore } from './access';
 
-export const useAppStore = defineStore('app', () => {
-  const accessStore = useAccessStore();
-  const coreTabbarStore = useCoreTabbarStore();
+interface AppState {
+  isLockScreen: boolean;
+  lockScreenPassword?: string;
+}
 
-  /**
-   * 重置所有状态
-   */
-  async function resetAppState() {
-    accessStore.reset();
-    coreTabbarStore.$reset();
-  }
+export const useAppStore = defineStore('app', {
+  actions: {
+    lockScreen(password: string) {
+      this.isLockScreen = true;
+      this.lockScreenPassword = password;
+    },
 
-  return {
-    resetAppState,
-  };
+    resetAppState() {
+      const accessStore = useAccessStore();
+      const coreTabbarStore = useCoreTabbarStore();
+      accessStore.reset();
+      coreTabbarStore.$reset();
+    },
+
+    unlockScreen() {
+      this.isLockScreen = false;
+      this.lockScreenPassword = undefined;
+    },
+  },
+  persist: {
+    paths: ['isLockScreen', 'lockScreenPassword'],
+  },
+  state: (): AppState => ({
+    isLockScreen: false,
+    lockScreenPassword: undefined,
+  }),
 });

+ 2 - 1
packages/@core/forward/preferences/src/config.ts

@@ -3,7 +3,6 @@ import type { Preferences } from './types';
 const defaultPreferences: Preferences = {
   app: {
     accessMode: 'frontend',
-    aiAssistant: true,
     authPageLayout: 'panel-right',
     colorGrayMode: false,
     colorWeakMode: false,
@@ -55,6 +54,7 @@ const defaultPreferences: Preferences = {
   },
   shortcutKeys: {
     enable: true,
+    globalLockScreen: true,
     globalLogout: true,
     globalPreferences: true,
     globalSearch: true,
@@ -95,6 +95,7 @@ const defaultPreferences: Preferences = {
     fullscreen: true,
     globalSearch: true,
     languageToggle: true,
+    lockScreen: true,
     notification: true,
     sidebarToggle: true,
     themeToggle: true,

+ 4 - 2
packages/@core/forward/preferences/src/types.ts

@@ -26,8 +26,6 @@ type AuthPageLayoutType = 'panel-center' | 'panel-left' | 'panel-right';
 interface AppPreferences {
   /** 权限模式 */
   accessMode: AccessModeType;
-  /** 是否开启vben助手 */
-  aiAssistant: boolean;
   /** 登录注册页面布局 */
   authPageLayout: AuthPageLayoutType;
   /** 是否开启灰色模式 */
@@ -136,6 +134,8 @@ interface SidebarPreferences {
 interface ShortcutKeyPreferences {
   /** 是否启用快捷键-全局 */
   enable: boolean;
+  /** 是否启用全局锁屏快捷键 */
+  globalLockScreen: boolean;
   /** 是否启用全局注销快捷键 */
   globalLogout: boolean;
   /** 是否启用全局偏好设置快捷键 */
@@ -194,6 +194,8 @@ interface WidgetPreferences {
   globalSearch: boolean;
   /** 是否启用语言切换部件 */
   languageToggle: boolean;
+  /** 是否开启锁屏功能 */
+  lockScreen: boolean;
   /** 是否显示通知部件 */
   notification: boolean;
   /** 是否显示侧边栏显示/隐藏部件 */

+ 6 - 0
packages/@core/forward/preferences/src/use-preferences.ts

@@ -125,6 +125,11 @@ function usePreferences() {
     return enable && globalLogout;
   });
 
+  const globalLockScreenShortcutKey = computed(() => {
+    const { enable, globalLockScreen } = shortcutKeysPreferences.value;
+    return enable && globalLockScreen;
+  });
+
   /**
    * @zh_CN 是否启用全局偏好设置快捷键
    */
@@ -138,6 +143,7 @@ function usePreferences() {
     authPanelLeft,
     authPanelRight,
     diffPreference,
+    globalLockScreenShortcutKey,
     globalLogoutShortcutKey,
     globalPreferencesShortcutKey,
     globalSearchShortcutKey,

+ 12 - 1
packages/@core/locales/src/langs/en-US.json

@@ -68,6 +68,16 @@
       "noResults": "No Search Results Found",
       "noRecent": "No Search History",
       "recent": "Search History"
+    },
+    "lockScreen": {
+      "title": "Lock Screen",
+      "screenButton": "Locking",
+      "password": "Password",
+      "placeholder": "Please enter password",
+      "unlock": "Click to unlock",
+      "errorPasswordTip": "Password error, please re-enter",
+      "backToLogin": "Back to login",
+      "entry": "Enter the system"
     }
   },
   "authentication": {
@@ -263,7 +273,8 @@
       "languageToggle": "Enable Language Toggle",
       "notification": "Enable Notification",
       "sidebarToggle": "Enable Sidebar Toggle",
-      "aiAssistant": "Enable AI Assistant"
+      "aiAssistant": "Enable AI Assistant",
+      "lockScreen": "Enable Lock Screen"
     }
   }
 }

+ 12 - 1
packages/@core/locales/src/langs/zh-CN.json

@@ -68,6 +68,16 @@
       "noResults": "未找到搜索结果",
       "noRecent": "没有搜索历史",
       "recent": "搜索历史"
+    },
+    "lockScreen": {
+      "title": "锁定屏幕",
+      "screenButton": "锁定",
+      "password": "密码",
+      "placeholder": "请输入锁屏密码",
+      "unlock": "点击解锁",
+      "errorPasswordTip": "密码错误,请重新输入",
+      "backToLogin": "返回登录",
+      "entry": "进入系统"
     }
   },
   "authentication": {
@@ -263,7 +273,8 @@
       "languageToggle": "启用语言切换",
       "notification": "启用通知",
       "sidebarToggle": "启用侧边栏切换",
-      "aiAssistant": "启用 AI 助手"
+      "aiAssistant": "启用 AI 助手",
+      "lockScreen": "启用锁屏"
     }
   }
 }

+ 1 - 1
packages/@core/shared/design/src/scss-bem/bem.scss

@@ -1,4 +1,4 @@
-@forward './constants.scss';
+@forward './constants';
 
 @mixin b($block) {
   $B: $namespace + '-' + $block !global;

+ 7 - 2
packages/@core/shared/iconify/src/create-icon.ts

@@ -1,9 +1,14 @@
-import { h } from 'vue';
+import { defineComponent, h } from 'vue';
 
 import { Icon } from '@iconify/vue';
 
 function createIconifyIcon(icon: string) {
-  return h(Icon, { icon });
+  return defineComponent({
+    name: `SvgIcon-${icon}`,
+    setup(props, { attrs }) {
+      return () => h(Icon, { icon, ...props, ...attrs });
+    },
+  });
 }
 
 export { createIconifyIcon };

+ 2 - 0
packages/@core/shared/iconify/src/material.ts

@@ -82,3 +82,5 @@ export const IcRoundMultipleStop = createIconifyIcon('ic:round-multiple-stop');
 export const IcRoundRefresh = createIconifyIcon('ic:round-refresh');
 
 export const IcRoundCreditScore = createIconifyIcon('ic:round-credit-score');
+
+export const IcRoundLock = createIconifyIcon('ic:round-lock');

+ 4 - 1
packages/business/layouts/src/basic/layout.vue

@@ -279,7 +279,10 @@ function clearPreferencesAndLogout() {
     </template>
 
     <template #extra>
-      <slot name="dialog"></slot>
+      <slot name="extra"></slot>
+      <Transition v-if="preferences.widget.lockScreen" name="slide-up">
+        <slot name="lock-screen"></slot>
+      </Transition>
     </template>
   </VbenAdminLayout>
 </template>

+ 1 - 0
packages/business/layouts/src/widgets/index.ts

@@ -4,6 +4,7 @@ export { default as CozeAssistant } from './coze-assistant.vue';
 export * from './global-search';
 export { default as LanguageToggle } from './language-toggle.vue';
 export { default as AuthenticationLayoutToggle } from './layout-toggle.vue';
+export * from './lock-screen';
 export * from './notification';
 export * from './preferences';
 export * from './theme-toggle';

+ 2 - 0
packages/business/layouts/src/widgets/lock-screen/index.ts

@@ -0,0 +1,2 @@
+export { default as LockScreen } from './lock-screen.vue';
+export { default as LockScreenModal } from './lock-screen-modal.vue';

+ 106 - 0
packages/business/layouts/src/widgets/lock-screen/lock-screen-modal.vue

@@ -0,0 +1,106 @@
+<script setup lang="ts">
+import type { RegisterEmits } from './typings';
+
+import { computed, reactive } from 'vue';
+
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+  VbenAvatar,
+  VbenButton,
+  VbenInputPassword,
+} from '@vben-core/shadcn-ui';
+
+interface Props {
+  avatar?: string;
+  text?: string;
+}
+
+defineOptions({
+  name: 'LockScreenModal',
+});
+withDefaults(defineProps<Props>(), {
+  avatar: '',
+  text: '',
+});
+const emit = defineEmits<{
+  submit: RegisterEmits['submit'];
+}>();
+const formState = reactive({
+  lockScreenPassword: '',
+  submitted: false,
+});
+const openModal = defineModel<boolean>('open');
+const passwordStatus = computed(() => {
+  return formState.submitted && !formState.lockScreenPassword
+    ? 'error'
+    : 'default';
+});
+
+function handleClose() {
+  openModal.value = false;
+}
+
+function handleSubmit() {
+  formState.submitted = true;
+  if (passwordStatus.value !== 'default') {
+    return;
+  }
+  emit('submit', {
+    lockScreenPassword: formState.lockScreenPassword,
+  });
+}
+</script>
+
+<template>
+  <div>
+    <Dialog :open="openModal">
+      <DialogContent
+        class="top-0 h-full w-full -translate-y-0 border-none p-0 shadow-xl sm:top-[20%] sm:h-[unset] sm:w-[600px] sm:rounded-2xl"
+        @close="handleClose"
+      >
+        <DialogDescription />
+        <DialogHeader>
+          <DialogTitle
+            class="border-border flex h-8 items-center px-5 font-normal"
+          >
+            {{ $t('widgets.lockScreen.title') }}
+          </DialogTitle>
+        </DialogHeader>
+        <div
+          class="mb-10 flex w-full flex-col items-center"
+          @keypress.enter.prevent="handleSubmit"
+        >
+          <div class="w-2/3">
+            <div class="ml-2 flex w-full flex-col items-center">
+              <VbenAvatar
+                :src="avatar"
+                class="size-24"
+                dot-class="bottom-0 right-1 border-2 size-4 bg-green-500"
+              />
+              <div class="text-foreground my-6 flex items-center font-medium">
+                {{ text }}
+              </div>
+            </div>
+            <VbenInputPassword
+              v-model="formState.lockScreenPassword"
+              :error-tip="$t('widgets.lockScreen.placeholder')"
+              :label="$t('widgets.lockScreen.password')"
+              :placeholder="$t('widgets.lockScreen.placeholder')"
+              :status="passwordStatus"
+              name="password"
+              required
+              type="password"
+            />
+            <VbenButton class="w-full" @click="handleSubmit">
+              {{ $t('widgets.lockScreen.screenButton') }}
+            </VbenButton>
+          </div>
+        </div>
+      </DialogContent>
+    </Dialog>
+  </div>
+</template>

+ 170 - 0
packages/business/layouts/src/widgets/lock-screen/lock-screen.vue

@@ -0,0 +1,170 @@
+<script setup lang="ts">
+import { computed, reactive, ref, watchEffect } from 'vue';
+
+import { IcRoundLock } from '@vben-core/iconify';
+import { $t } from '@vben-core/locales';
+import {
+  VbenAvatar,
+  VbenButton,
+  VbenInputPassword,
+} from '@vben-core/shadcn-ui';
+
+import { useDateFormat, useNow } from '@vueuse/core';
+
+interface Props {
+  avatar?: string;
+  cachedPassword?: string;
+}
+
+defineOptions({
+  name: 'LockScreen',
+});
+
+const props = withDefaults(defineProps<Props>(), {
+  avatar: '',
+  cachedPassword: undefined,
+});
+
+const emit = defineEmits<{ toLogin: []; unlock: [string] }>();
+
+const now = useNow();
+const year = useDateFormat(now, 'YYYY');
+const month = useDateFormat(now, 'MM');
+const day = useDateFormat(now, 'DD');
+const week = useDateFormat(now, 'dddd');
+const hour = useDateFormat(now, 'HH');
+const meridiem = useDateFormat(now, 'A');
+const minute = useDateFormat(now, 'mm');
+
+const showUnlockForm = ref(false);
+const validPass = ref(true);
+
+const formState = reactive({
+  password: '',
+  submitted: false,
+});
+
+const passwordStatus = computed(() => {
+  if (formState.submitted && !formState.password) {
+    return 'error';
+  }
+
+  if (formState.submitted && !validPass.value) {
+    return 'error';
+  }
+
+  return 'default';
+});
+
+const errorTip = computed(() => {
+  return props.cachedPassword === undefined || !formState.password
+    ? $t('widgets.lockScreen.placeholder')
+    : $t('widgets.lockScreen.errorPasswordTip');
+});
+
+watchEffect(() => {
+  if (!formState.password) {
+    validPass.value = true;
+  }
+});
+
+function handleSubmit() {
+  formState.submitted = true;
+  if (passwordStatus.value !== 'default') {
+    return;
+  }
+  if (props.cachedPassword !== formState.password) {
+    validPass.value = false;
+    return;
+  }
+  emit('unlock', formState.password);
+}
+
+function toggleUnlockForm() {
+  showUnlockForm.value = !showUnlockForm.value;
+}
+</script>
+
+<template>
+  <div class="bg-background fixed z-[2000] size-full">
+    <transition name="slide-left">
+      <div v-show="!showUnlockForm" class="size-full">
+        <div
+          class="flex-col-center text-foreground/80 hover:text-foreground group my-4 cursor-pointer text-xl font-semibold"
+          @click="toggleUnlockForm"
+        >
+          <IcRoundLock
+            class="size-5 transition-all duration-300 group-hover:scale-125"
+          />
+          <span>{{ $t('widgets.lockScreen.unlock') }}</span>
+        </div>
+        <div class="flex h-full justify-center px-[10%]">
+          <div
+            class="bg-accent flex-center relative mb-14 mr-20 h-4/5 w-2/5 flex-auto rounded-3xl text-center text-[260px]"
+          >
+            <span class="absolute left-4 top-4 text-xl font-semibold">{{
+              meridiem
+            }}</span>
+            {{ hour }}
+          </div>
+          <div
+            class="bg-accent flex-center mb-14 h-4/5 w-2/5 flex-auto rounded-3xl text-center text-[260px]"
+          >
+            {{ minute }}
+          </div>
+        </div>
+      </div>
+    </transition>
+
+    <transition name="slide-right">
+      <div
+        v-if="showUnlockForm"
+        class="flex-center size-full"
+        @keypress.enter.prevent="handleSubmit"
+      >
+        <div class="flex-col-center mb-10 w-[300px]">
+          <VbenAvatar :src="avatar" class="enter-x mb-6 size-20" />
+          <div class="items-cente enter-x mb-2 w-full">
+            <VbenInputPassword
+              v-model="formState.password"
+              :autofocus="true"
+              :error-tip="errorTip"
+              :label="$t('widgets.lockScreen.password')"
+              :placeholder="$t('widgets.lockScreen.placeholder')"
+              :status="passwordStatus"
+              name="password"
+              required
+              type="password"
+            />
+          </div>
+          <VbenButton class="enter-x w-full" @click="handleSubmit">
+            {{ $t('widgets.lockScreen.entry') }}
+          </VbenButton>
+          <VbenButton
+            class="enter-x my-2 w-full"
+            variant="ghost"
+            @click="$emit('toLogin')"
+          >
+            {{ $t('widgets.lockScreen.backToLogin') }}
+          </VbenButton>
+          <VbenButton
+            class="enter-x mr-2 w-full"
+            variant="ghost"
+            @click="toggleUnlockForm"
+          >
+            {{ $t('common.back') }}
+          </VbenButton>
+        </div>
+      </div>
+    </transition>
+
+    <div
+      class="enter-y absolute bottom-5 w-full text-center text-gray-300 xl:text-xl 2xl:text-3xl"
+    >
+      <div v-if="showUnlockForm" class="enter-x mb-2 text-3xl">
+        {{ hour }}:{{ minute }} <span class="text-lg">{{ meridiem }}</span>
+      </div>
+      <div class="text-3xl">{{ year }}/{{ month }}/{{ day }} {{ week }}</div>
+    </div>
+  </div>
+</template>

+ 9 - 0
packages/business/layouts/src/widgets/lock-screen/typings.ts

@@ -0,0 +1,9 @@
+interface LockAndRegisterParams {
+  lockScreenPassword: string;
+}
+
+interface RegisterEmits {
+  submit: [LockAndRegisterParams];
+}
+
+export type { LockAndRegisterParams, RegisterEmits };

+ 4 - 0
packages/business/layouts/src/widgets/preferences/blocks/layout/widget.vue

@@ -14,6 +14,7 @@ const widgetNotification = defineModel<boolean>('widgetNotification');
 const widgetThemeToggle = defineModel<boolean>('widgetThemeToggle');
 const widgetAiAssistant = defineModel<boolean>('widgetAiAssistant');
 const widgetSidebarToggle = defineModel<boolean>('widgetSidebarToggle');
+const widgetLockScreen = defineModel<boolean>('widgetLockScreen');
 </script>
 
 <template>
@@ -35,6 +36,9 @@ const widgetSidebarToggle = defineModel<boolean>('widgetSidebarToggle');
   <SwitchItem v-model="widgetAiAssistant">
     {{ $t('preferences.widget.aiAssistant') }}
   </SwitchItem>
+  <SwitchItem v-model="widgetLockScreen">
+    {{ $t('preferences.widget.lockScreen') }}
+  </SwitchItem>
   <SwitchItem v-model="widgetSidebarToggle">
     {{ $t('preferences.widget.sidebarToggle') }}
   </SwitchItem>

+ 11 - 3
packages/business/layouts/src/widgets/preferences/blocks/shortcut-keys/global.vue

@@ -16,6 +16,7 @@ const shortcutKeysGlobalSearch = defineModel<boolean>(
 );
 const shortcutKeysLogout = defineModel<boolean>('shortcutKeysLogout');
 const shortcutKeysPreferences = defineModel<boolean>('shortcutKeysPreferences');
+const shortcutKeysLockScreen = defineModel<boolean>('shortcutKeysLockScreen');
 
 const altView = computed(() => (isWindowsOs() ? 'Alt' : '⌥'));
 </script>
@@ -24,19 +25,26 @@ const altView = computed(() => (isWindowsOs() ? 'Alt' : '⌥'));
   <SwitchItem v-model="shortcutKeysEnable">
     {{ $t('preferences.shortcutKeys.title') }}
   </SwitchItem>
-  <SwitchItem v-if="shortcutKeysEnable" v-model="shortcutKeysGlobalSearch">
+  <SwitchItem
+    v-model="shortcutKeysGlobalSearch"
+    :disabled="!shortcutKeysEnable"
+  >
     {{ $t('preferences.shortcutKeys.search') }}
     <template #shortcut>
       {{ isWindowsOs() ? 'Ctrl' : '⌘' }}
       <kbd> K </kbd>
     </template>
   </SwitchItem>
-  <SwitchItem v-if="shortcutKeysEnable" v-model="shortcutKeysLogout">
+  <SwitchItem v-model="shortcutKeysLogout" :disabled="!shortcutKeysEnable">
     {{ $t('preferences.shortcutKeys.logout') }}
     <template #shortcut> {{ altView }} Q </template>
   </SwitchItem>
-  <SwitchItem v-if="shortcutKeysEnable" v-model="shortcutKeysPreferences">
+  <SwitchItem v-model="shortcutKeysPreferences" :disabled="!shortcutKeysEnable">
     {{ $t('preferences.shortcutKeys.preferences') }}
     <template #shortcut> {{ altView }} , </template>
   </SwitchItem>
+  <SwitchItem v-model="shortcutKeysLockScreen" :disabled="!shortcutKeysEnable">
+    {{ $t('widgets.lockScreen.title') }}
+    <template #shortcut> {{ altView }} L </template>
+  </SwitchItem>
 </template>

+ 6 - 0
packages/business/layouts/src/widgets/preferences/preferences-sheet.vue

@@ -128,6 +128,9 @@ const shortcutKeysGlobalLogout = defineModel<boolean>(
 const shortcutKeysGlobalPreferences = defineModel<boolean>(
   'shortcutKeysGlobalPreferences',
 );
+const shortcutKeysGlobalLockScreen = defineModel<boolean>(
+  'shortcutKeysGlobalLockScreen',
+);
 
 const widgetGlobalSearch = defineModel<boolean>('widgetGlobalSearch');
 const widgetFullscreen = defineModel<boolean>('widgetFullscreen');
@@ -136,6 +139,7 @@ const widgetNotification = defineModel<boolean>('widgetNotification');
 const widgetThemeToggle = defineModel<boolean>('widgetThemeToggle');
 const widgetAiAssistant = defineModel<boolean>('widgetAiAssistant');
 const widgetSidebarToggle = defineModel<boolean>('widgetSidebarToggle');
+const widgetLockScreen = defineModel<boolean>('widgetLockScreen');
 
 const {
   diffPreference,
@@ -355,6 +359,7 @@ async function handleReset() {
                 v-model:widget-fullscreen="widgetFullscreen"
                 v-model:widget-global-search="widgetGlobalSearch"
                 v-model:widget-language-toggle="widgetLanguageToggle"
+                v-model:widget-lock-screen="widgetLockScreen"
                 v-model:widget-notification="widgetNotification"
                 v-model:widget-sidebar-toggle="widgetSidebarToggle"
                 v-model:widget-theme-toggle="widgetThemeToggle"
@@ -384,6 +389,7 @@ async function handleReset() {
               <GlobalShortcutKeys
                 v-model:shortcut-keys-enable="shortcutKeysEnable"
                 v-model:shortcut-keys-global-search="shortcutKeysGlobalSearch"
+                v-model:shortcut-keys-lock-screen="shortcutKeysGlobalLockScreen"
                 v-model:shortcut-keys-logout="shortcutKeysGlobalLogout"
                 v-model:shortcut-keys-preferences="
                   shortcutKeysGlobalPreferences

+ 2 - 2
packages/business/layouts/src/widgets/preferences/preferences.vue

@@ -8,7 +8,7 @@ import Preferences from './preferences-sheet.vue';
 
 /**
  * preferences 转成 vue props
- * preferences.app.aiAssistant=>appAiAssistant
+ * preferences.widget.aiAssistant=>widgetAiAssistant
  */
 const attrs = computed(() => {
   const result: Record<string, any> = {};
@@ -22,7 +22,7 @@ const attrs = computed(() => {
 
 /**
  * preferences 转成 vue listener
- * preferences.app.aiAssistant=>@update:appAiAssistant
+ * preferences.widget.aiAssistant=>@update:widgetAiAssistant
  */
 const listen = computed(() => {
   const result: Record<string, any> = {};

+ 53 - 4
packages/business/layouts/src/widgets/user-dropdown/user-dropdown.vue

@@ -4,7 +4,11 @@ import type { AnyFunction } from '@vben/types';
 import type { Component } from 'vue';
 import { computed, ref } from 'vue';
 
-import { IcRoundLogout, IcRoundSettingsSuggest } from '@vben-core/iconify';
+import {
+  IcRoundLock,
+  IcRoundLogout,
+  IcRoundSettingsSuggest,
+} from '@vben-core/iconify';
 import { $t } from '@vben-core/locales';
 import { preferences, usePreferences } from '@vben-core/preferences';
 import {
@@ -24,6 +28,7 @@ import { isWindowsOs } from '@vben-core/toolkit';
 
 import { useMagicKeys, whenever } from '@vueuse/core';
 
+import { LockScreenModal } from '../lock-screen';
 import { useOpenPreferences } from '../preferences';
 
 interface Props {
@@ -68,12 +73,16 @@ const props = withDefaults(defineProps<Props>(), {
   text: '',
 });
 
-const emit = defineEmits<{ logout: [] }>();
+const emit = defineEmits<{ lockScreen: [string]; logout: [] }>();
 const openPopover = ref(false);
 const openDialog = ref(false);
+const openLock = ref(false);
 
-const { globalLogoutShortcutKey, globalPreferencesShortcutKey } =
-  usePreferences();
+const {
+  globalLockScreenShortcutKey,
+  globalLogoutShortcutKey,
+  globalPreferencesShortcutKey,
+} = usePreferences();
 const { handleOpenPreference } = useOpenPreferences();
 
 const altView = computed(() => (isWindowsOs() ? 'Alt' : '⌥'));
@@ -82,6 +91,10 @@ const enableLogoutShortcutKey = computed(() => {
   return props.enableShortcutKey && globalLogoutShortcutKey.value;
 });
 
+const enableLockScreenShortcutKey = computed(() => {
+  return props.enableShortcutKey && globalLockScreenShortcutKey.value;
+});
+
 const enableShortcutKey = computed(() => {
   return props.enableShortcutKey && preferences.shortcutKeys.enable;
 });
@@ -90,6 +103,18 @@ const enablePreferencesShortcutKey = computed(() => {
   return props.enableShortcutKey && globalPreferencesShortcutKey.value;
 });
 
+function handleOpenLock() {
+  openLock.value = true;
+}
+
+function handleSubmitLock({
+  lockScreenPassword,
+}: {
+  lockScreenPassword: string;
+}) {
+  openLock.value = false;
+  emit('lockScreen', lockScreenPassword);
+}
 function handleLogout() {
   // emit
   openDialog.value = true;
@@ -114,10 +139,23 @@ if (enableShortcutKey.value) {
       handleOpenPreference();
     }
   });
+
+  whenever(keys['Alt+KeyL'], () => {
+    if (enableLockScreenShortcutKey.value) {
+      handleOpenLock();
+    }
+  });
 }
 </script>
 
 <template>
+  <LockScreenModal
+    v-if="preferences.widget.lockScreen"
+    v-model:open="openLock"
+    :avatar="avatar"
+    :text="text"
+    @submit="handleSubmitLock"
+  />
   <VbenAlertDialog
     v-model:open="openDialog"
     :cancel-text="$t('common.cancel')"
@@ -180,6 +218,17 @@ if (enableShortcutKey.value) {
           {{ altView }} ,
         </DropdownMenuShortcut>
       </DropdownMenuItem>
+      <DropdownMenuItem
+        v-if="preferences.widget.lockScreen"
+        class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
+        @click="handleOpenLock"
+      >
+        <IcRoundLock class="mr-2 size-5" />
+        {{ $t('widgets.lockScreen.title') }}
+        <DropdownMenuShortcut v-if="enableLockScreenShortcutKey">
+          {{ altView }} L
+        </DropdownMenuShortcut>
+      </DropdownMenuItem>
       <DropdownMenuSeparator />
       <DropdownMenuItem
         class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"

+ 2 - 2
packages/business/universal-ui/src/authentication/code-login.vue

@@ -82,7 +82,7 @@ function handleSubmit() {
   });
 }
 
-function goLogin() {
+function goToLogin() {
   router.push(props.loginPath);
 }
 
@@ -151,7 +151,7 @@ onBeforeUnmount(() => {
     <VbenButton :loading="loading" class="mt-2 w-full" @click="handleSubmit">
       {{ $t('common.login') }}
     </VbenButton>
-    <VbenButton class="mt-4 w-full" variant="outline" @click="goLogin()">
+    <VbenButton class="mt-4 w-full" variant="outline" @click="goToLogin()">
       {{ $t('common.back') }}
     </VbenButton>
   </div>

+ 2 - 2
packages/business/universal-ui/src/authentication/forget-password.vue

@@ -50,7 +50,7 @@ function handleSubmut() {
   emit('submit', formState.email);
 }
 
-function goLogin() {
+function goToLogin() {
   router.push(props.loginPath);
 }
 </script>
@@ -79,7 +79,7 @@ function goLogin() {
       <VbenButton class="mt-2 w-full" @click="handleSubmut">
         {{ $t('authentication.sendResetLink') }}
       </VbenButton>
-      <VbenButton class="mt-4 w-full" variant="outline" @click="goLogin()">
+      <VbenButton class="mt-4 w-full" variant="outline" @click="goToLogin()">
         {{ $t('common.back') }}
       </VbenButton>
     </div>

+ 2 - 2
packages/business/universal-ui/src/authentication/qrcode-login.vue

@@ -39,7 +39,7 @@ const qrcode = useQRCode(text, {
   margin: 4,
 });
 
-function goLogin() {
+function goToLogin() {
   router.push(props.loginPath);
 }
 </script>
@@ -62,7 +62,7 @@ function goLogin() {
       </p>
     </div>
 
-    <VbenButton class="mt-4 w-full" variant="outline" @click="goLogin()">
+    <VbenButton class="mt-4 w-full" variant="outline" @click="goToLogin()">
       {{ $t('common.back') }}
     </VbenButton>
   </div>

+ 2 - 2
packages/business/universal-ui/src/authentication/register.vue

@@ -78,7 +78,7 @@ function handleSubmit() {
   });
 }
 
-function goLogin() {
+function goToLogin() {
   router.push(props.loginPath);
 }
 </script>
@@ -160,7 +160,7 @@ function goLogin() {
       {{ $t('authentication.alreadyHaveAccount') }}
       <span
         class="text-primary hover:text-primary-hover cursor-pointer text-sm font-normal"
-        @click="goLogin()"
+        @click="goToLogin()"
       >
         {{ $t('authentication.goToLogin') }}
       </span>