Sfoglia il codice sorgente

feat✨: 登录功能添加

gitboyzcf 2 settimane fa
parent
commit
81fe1c35e6

+ 3 - 1
package.json

@@ -12,6 +12,7 @@
   "dependencies": {
     "@ffmpeg/ffmpeg": "^0.12.10",
     "@ffmpeg/util": "^0.12.1",
+    "@fingerprintjs/fingerprintjs": "^4.6.2",
     "@iconify-json/ion": "^1.2.0",
     "@rollup/plugin-alias": "^5.1.0",
     "@vant/touch-emulator": "^1.4.0",
@@ -27,6 +28,7 @@
     "mitt": "^3.0.1",
     "mp4box": "0.5.2",
     "native-file-system-adapter": "^3.0.1",
+    "node-forge": "^1.3.1",
     "pinia": "^2.2.2",
     "screenfull": "^6.0.2",
     "swiper": "^10.3.1",
@@ -45,7 +47,7 @@
     "zx-calendar": "^0.7.3"
   },
   "devDependencies": {
-    "@iconify/json": "^2.2.244",
+    "@iconify/json": "^2.2.388",
     "@iconify/vue": "^4.1.2",
     "@rushstack/eslint-patch": "^1.10.4",
     "@unocss/preset-rem-to-px": "^0.61.9",

+ 24 - 5
pnpm-lock.yaml

@@ -17,6 +17,9 @@ importers:
       '@ffmpeg/util':
         specifier: ^0.12.1
         version: 0.12.2
+      '@fingerprintjs/fingerprintjs':
+        specifier: ^4.6.2
+        version: 4.6.2
       '@iconify-json/ion':
         specifier: ^1.2.0
         version: 1.2.2
@@ -62,6 +65,9 @@ importers:
       native-file-system-adapter:
         specifier: ^3.0.1
         version: 3.0.1
+      node-forge:
+        specifier: ^1.3.1
+        version: 1.3.1
       pinia:
         specifier: ^2.2.2
         version: 2.3.1(vue@3.5.13)
@@ -112,8 +118,8 @@ importers:
         version: 0.7.3
     devDependencies:
       '@iconify/json':
-        specifier: ^2.2.244
-        version: 2.2.328
+        specifier: ^2.2.388
+        version: 2.2.388
       '@iconify/vue':
         specifier: ^4.1.2
         version: 4.3.0(vue@3.5.13)
@@ -541,6 +547,9 @@ packages:
     resolution: {integrity: sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==}
     engines: {node: '>=18.x'}
 
+  '@fingerprintjs/fingerprintjs@4.6.2':
+    resolution: {integrity: sha512-g8mXuqcFKbgH2CZKwPfVtsUJDHyvcgIABQI7Y0tzWEFXpGxJaXuAuzlifT2oTakjDBLTK4Gaa9/5PERDhqUjtw==}
+
   '@humanfs/core@0.19.1':
     resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
     engines: {node: '>=18.18.0'}
@@ -564,8 +573,8 @@ packages:
   '@iconify-json/ion@1.2.2':
     resolution: {integrity: sha512-GysHYDAyy3K4R2Xxsab3I0ZFmQA4oorfIkwX0g6xfZJNVZcFyAlA4pa1jAJC9Bk3cLiWUtnN8ZyWs6cP3nxlnw==}
 
-  '@iconify/json@2.2.328':
-    resolution: {integrity: sha512-hdkS6oE+U3tfR2onc1QcHZh+2hkKHjLEkagtdQHDZnx/OS2sFIDc6/XoEh+2BtuzDXfyANgGV/Kpd6oy0OBvqQ==}
+  '@iconify/json@2.2.388':
+    resolution: {integrity: sha512-sNwZo4oDovAA7IT1XHfJiEP4fLialEFYALrGVQAblbflkICEJFnK6rUK0sHhvkkX2HUTNTMywOX+LG9AGVN3Pw==}
 
   '@iconify/types@2.0.0':
     resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
@@ -3152,6 +3161,10 @@ packages:
       encoding:
         optional: true
 
+  node-forge@1.3.1:
+    resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==}
+    engines: {node: '>= 6.13.0'}
+
   node-releases@2.0.19:
     resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
 
@@ -4749,6 +4762,10 @@ snapshots:
 
   '@ffmpeg/util@0.12.2': {}
 
+  '@fingerprintjs/fingerprintjs@4.6.2':
+    dependencies:
+      tslib: 2.8.1
+
   '@humanfs/core@0.19.1': {}
 
   '@humanfs/node@0.16.6':
@@ -4766,7 +4783,7 @@ snapshots:
     dependencies:
       '@iconify/types': 2.0.0
 
-  '@iconify/json@2.2.328':
+  '@iconify/json@2.2.388':
     dependencies:
       '@iconify/types': 2.0.0
       pathe: 1.1.2
@@ -7515,6 +7532,8 @@ snapshots:
       whatwg-url: 5.0.0
     optional: true
 
+  node-forge@1.3.1: {}
+
   node-releases@2.0.19: {}
 
   nopt@5.0.0:

+ 15 - 1
src/api/index.js

@@ -4,7 +4,21 @@
  * @Date 2023-08-17 16:43
  * @E-mail boyzcf@qq.com
  */
-import { modulesHandle } from '@/utils'
+/**
+ * 模块话方式处理 默认处理 modules文件夹下的所有js文件 内容以export default导出的文件
+ * @param { 模块内容集合 } moduleContext
+ * @returns modules集合
+ */
+const modulesHandle = (moduleContext = {}) => {
+  if (!Object.keys(moduleContext).length) return;
+  const modules = {};
+  Object.keys(moduleContext).forEach((v) => {
+    for (let key in moduleContext[v].default) {
+      modules[key] = moduleContext[v].default[key];
+    }
+  });
+  return modules;
+};
 
 const apis = modulesHandle(import.meta.glob('./modules/**/*.js', { eager: true }))
 export const useRequest = () => {

+ 27 - 0
src/api/modules/login.js

@@ -0,0 +1,27 @@
+import { request } from "@/api/request.js";
+
+export default {
+  // 获取公钥和盐
+  API_AUTH_KEY_GET(params = {}) {
+    return request({
+      url: '/authentication/key',
+      method: 'get',
+      params
+    })
+  },
+  // token续签
+  API_RENEWAL_PUT(params = {}) {
+    return request({
+      url: '/authentication/Renewal',
+      method: 'put',
+    })
+  },
+  // 登录
+  API_LOGIN_POST(data = {}) {
+    return request({
+      url: '/authentication/logon',
+      method: 'post',
+      data
+    })
+  },
+}

+ 56 - 0
src/api/modules/user.js

@@ -0,0 +1,56 @@
+import { request } from "@/api/request.js";
+import storage from '@/utils/storage'
+export default {
+  // 获取所有用户列表
+  API_PL_USER_LIST_GET(params = {}) {
+    return request({
+      url: '/authentication/UserList',
+      method: 'get',
+      params
+    })
+  },
+  // 权限列表
+  API_PL_GET(params = {}) {
+    return request({
+      url: '/User/Privileges',
+      method: 'get',
+      params
+    })
+  },
+  // 修改用户权限
+  API_UPD_PL_GET(params = {}, data = {}) {
+    return request({
+      url: '/User/Privileges',
+      method: 'put',
+      params,
+      data
+    })
+  },
+  // 可进行设置的权限列表
+  API_PLL_GET(params = {}) {
+    return request({
+      url: '/User/PrivilegesList',
+      method: 'get',
+      params
+    })
+  },
+  // 根据用户Id 获取权限列表
+  API_PLF_ID_GET(params = {}) {
+    return request({
+      url: '/User/PrivilegesFromId',
+      method: 'get',
+      params
+    })
+  },
+  // 修改密码
+  API_CHANGE_PASSWORD_GET(data = {}) {
+    return request({
+      url: '/authentication/ChangePassword',
+      method: 'put',
+      data,
+      headers: {
+        Token: storage.local.get('t')
+      }
+    })
+  },
+}

+ 7 - 2
src/assets/scss/reset.scss

@@ -15,6 +15,11 @@
   --van-radio-disabled-background: #cccccc94;
   --van-notice-bar-icon-size: 16px;
   --van-notice-bar-font-size: 18px;
+  --van-calendar-month-mark-color: rgba(242, 243, 245, 0.2);
+  --animate-duration: 0.5s;
+}
+.van-dialog {
+  top: 30%;
 }
 .van-popup,
 .van-overlay {
@@ -192,8 +197,8 @@
   --n-box-shadow: inset 0 0 0 1px #0080ff;
   --n-box-shadow-active: inset 0 0 0 1px #0080ff;
   --n-box-shadow-disabled: inset 0 0 0 1px #0080ff;
-  --n-box-shadow-focus: inset 0 0 0 1px #0080ff,
-    0 0 0 2px rgba(24, 160, 88, 0.2) !important;
+  --n-box-shadow-focus:
+    inset 0 0 0 1px #0080ff, 0 0 0 2px rgba(24, 160, 88, 0.2) !important;
   --n-box-shadow-hover: inset 0 0 0 1px #0080ff;
   .n-radio__label {
     color: #8ac5ff;

+ 14 - 6
src/components/Icon.vue

@@ -54,7 +54,7 @@ import PhMagnifyingGlassPlusBold from "~icons/ph/magnifying-glass-plus-bold";
 import FluentToolbox20Regular from "~icons/fluent/toolbox-20-regular";
 import MaterialSymbolsLightLanguage from "~icons/material-symbols-light/language";
 import MingcuteBatteryLine from "~icons/heroicons/battery-0-solid";
-import SolarHomeOutline from "~icons/solar/home-outline";
+import StreamlineFlexHome2Remix from "~icons/streamline-flex/home-2-remix";
 import SolarClipboardLinear from "~icons/solar/clipboard-linear";
 import MingcutePerformanceLine from "~icons/mingcute/performance-line";
 import MingcuteStorageLine from "~icons/mingcute/storage-line";
@@ -73,10 +73,12 @@ import HeroiconsWrenchScrewdriver from "~icons/heroicons/wrench-screwdriver";
 import MdiVirtualPrivateNetwork from "~icons/mdi/virtual-private-network";
 import MaterialSymbolsNetworkManage from "~icons/material-symbols/network-manage";
 import TablerTimezone from "~icons/tabler/timezone";
-import MaterialSymbolsFlagOutlineRounded from '~icons/material-symbols/flag-outline-rounded';
-import LetsIconsTransferLeft from '~icons/lets-icons/transfer-left';
-import OuiFullScreen from '~icons/solar/full-screen-square-linear';
-import SolarRefreshSquareOutline from '~icons/solar/refresh-square-outline';
+import MaterialSymbolsFlagOutlineRounded from "~icons/material-symbols/flag-outline-rounded";
+import LetsIconsTransferLeft from "~icons/lets-icons/transfer-left";
+import OuiFullScreen from "~icons/solar/full-screen-square-linear";
+import SolarRefreshSquareOutline from "~icons/solar/refresh-square-outline";
+import TablerLogout from "~icons/tabler/logout";
+import IconParkOutlineSettingWeb from "~icons/icon-park-outline/setting-web";
 
 const props = defineProps({
   icon: {
@@ -245,7 +247,7 @@ const init = () => {
       iconCom.value = MingcuteBatteryLine;
       break;
     case "solar:home-outline":
-      iconCom.value = SolarHomeOutline;
+      iconCom.value = StreamlineFlexHome2Remix;
       break;
     case "solar:clipboard-linear":
       iconCom.value = SolarClipboardLinear;
@@ -313,6 +315,12 @@ const init = () => {
     case "solar:refresh-square-outline":
       iconCom.value = SolarRefreshSquareOutline;
       break;
+    case "tabler:logout":
+      iconCom.value = TablerLogout;
+      break;
+    case "icon-park-outline:setting-web":
+      iconCom.value = IconParkOutlineSettingWeb;
+      break;
     default:
       console.error(`${props.icon}需要到 src/components/Icon.vue 中配置改图标`);
       break;

+ 18 - 2
src/router/index.js

@@ -1,6 +1,7 @@
-import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'
-import { usePageTitle, urlParamsLogin } from './helper'
+import { createRouter, createWebHashHistory } from 'vue-router'
+import { usePageTitle } from './helper'
 import Index from '@/views/Home/Index.vue'
+import storage from '@/utils/storage'
 
 const whiteList = ['/login'] // 白名单
 const router = createRouter({
@@ -22,6 +23,14 @@ const router = createRouter({
         title: '全景布控球'
       }
     },
+    {
+      path: '/login',
+      name: 'Login',
+      component: () => import('@/views/system/Login/index.vue'),
+      meta: {
+        title: '登录'
+      }
+    },
     {
       path: '/:pathMatch(.*)*',
       component: () => import('@/views/system/404/404.vue'),
@@ -34,6 +43,13 @@ const router = createRouter({
 
 router.beforeEach((to, from, next) => {
   usePageTitle(to)
+  // 登录拦截
+  // if (!storage.local.get('t')) {
+  //   if (whiteList.includes(to.path)) {
+  //     return next()
+  //   }
+  //   return next('/login')
+  // }
   next()
 })
 

+ 2 - 2
src/stores/modules/system.js

@@ -72,6 +72,7 @@ export const useSystemStore = defineStore("systemStore", {
         State: 0,
       },
     ], // 预制点位
+    token: storage.local.get('t') || '', // token
   }),
   getters: {
     ipF() {
@@ -112,10 +113,9 @@ export const useSystemStore = defineStore("systemStore", {
     },
     // 退出登录
     async loginOut(router) {
-      clearInterval(this.renewalTimer);
       storage.session.clear();
       storage.local.clear();
-      router.replace("/login");
+      router.replace({ name: "Login" });
     },
     // 重置全景
     async resetQj(worker) {

+ 65 - 18
src/utils/index.js

@@ -1,7 +1,8 @@
 import { showSaveFilePicker } from "native-file-system-adapter";
 import mitt from "mitt";
-import dayjs from "dayjs";
-import { showSuccessToast, showFailToast } from "vant";
+import FingerprintJS from '@fingerprintjs/fingerprintjs'
+import forge from 'node-forge'
+import { showSuccessToast, showFailToast, showConfirmDialog } from "vant";
 /**
  * 获取资源路径
  * @param {相对路径} relativePath
@@ -13,21 +14,6 @@ const getStaticResource = (relativePath) => {
   return new URL(`../${relativePath}`, import.meta.url);
 };
 
-/**
- * 模块话方式处理 默认处理 modules文件夹下的所有js文件 内容以export default导出的文件
- * @param { 模块内容集合 } moduleContext
- * @returns modules集合
- */
-const modulesHandle = (moduleContext = {}) => {
-  if (!Object.keys(moduleContext).length) return;
-  const modules = {};
-  Object.keys(moduleContext).forEach((v) => {
-    for (let key in moduleContext[v].default) {
-      modules[key] = moduleContext[v].default[key];
-    }
-  });
-  return modules;
-};
 
 /**
  *
@@ -138,9 +124,67 @@ function decimalToDMS(decimal, isLatitude) {
 const msgS = (t) => showSuccessToast(t);
 const msgE = (t) => showFailToast(t);
 
+/**
+ *
+ * @param {标题} String
+ * @param {内容} String
+ * @param {确认回调} () => {}
+ * @param {取消回调} () => {}
+ * @param {其他配置} { }
+ */
+const dialogFn = (t, m, confirm, cancel, config) => {
+  showConfirmDialog({
+    title: t,
+    message: m,
+    ...config,
+  })
+    .then(confirm)
+    .catch(cancel);
+};
+
+// 指纹
+const getFingerprint = async () => {
+  const fpPromise = FingerprintJS.load()
+  const fp = await fpPromise
+  const result = await fp.get()
+  const components = {
+    languages: result.components.languages,
+    colorDepth: result.components.colorDepth,
+    deviceMemory: result.components.deviceMemory,
+    hardwareConcurrency: result.components.hardwareConcurrency,
+    timezone: result.components.timezone,
+    platform: result.components.platform,
+    vendor: result.components.vendor,
+    vendorFlavors: result.components.vendorFlavors,
+    cookiesEnabled: result.components.cookiesEnabled,
+    colorGamut: result.components.colorGamut,
+    hdr: result.components.hdr,
+    videoCard: result.components.videoCard
+  }
+  return { fingerprint: result.visitorId, components }
+}
+
+// 加密
+const getENCPwd = async (pwd) => {
+  const { API_AUTH_KEY_GET } = useRequest()
+
+  const res = await API_AUTH_KEY_GET()
+  const publicKey = forge.pki.publicKeyFromPem(res.PublicKey)
+  try {
+    const encryptedBytes = publicKey.encrypt(res.hash + pwd, 'RSA-OAEP', {
+      md: forge.md.sha256.create()
+    })
+    // 将加密后的数据转换为Base64字符串
+    const encryptedText = forge.util.encode64(encryptedBytes)
+    return encryptedText
+  } catch (error) {
+    console.error('加密失败', error)
+  }
+}
+
+
 export {
   getStaticResource,
-  modulesHandle,
   $mitt,
   isWap,
   UrlStreamSaverDownload,
@@ -148,4 +192,7 @@ export {
   msgS,
   msgE,
   decimalToDMS,
+  dialogFn,
+  getFingerprint,
+  getENCPwd
 };

+ 15 - 0
src/utils/storage.js

@@ -4,6 +4,7 @@
  * @Date 2023-09-20 13:25
  * @E-mail boyzcf@qq.com
  */
+import Cookies from "js-cookie";
 const PREFIX = import.meta.env.VITE_APP_PREFIX
 
 const storage = {
@@ -40,6 +41,20 @@ const storage = {
     clear: () => {
       sessionStorage.clear()
     }
+  },
+  cookie: {
+    has: (key) => {
+      return !!Cookies.get(`${PREFIX}${key}`)
+    },
+    get: (key) => {
+      return Cookies.get(`${PREFIX}${key}`)
+    },
+    set: (key, value, options = {}) => {
+      Cookies.set(`${PREFIX}${key}`, value, { ...options })
+    },
+    remove: (key, options = {}) => {
+      Cookies.remove(`${PREFIX}${key}`, { ...options })
+    }
   }
 }
 

+ 24 - 1
src/views/Home/components/joystick.vue

@@ -3,6 +3,12 @@
     <div class="joystick-wrapper w-400px">
       <template v-if="type === 'joystick'">
         <div class="joystick-inner flex gap-4 mb-4">
+          <div
+            class="w-full h-90px text-7 line-height-45px text-center bg-#093F9D border-rd-2 flex flex-justify-around flex-items-center transition duration-200 ease-in-out active:bg-blue-500 focus:outline-none"
+            @click="logout"
+          >
+            <Icon icon="tabler:logout" width="40" height="40" />
+          </div>
           <div
             class="w-full h-90px text-7 line-height-45px text-center bg-#093F9D border-rd-2 flex flex-justify-around flex-items-center transition duration-200 ease-in-out active:bg-blue-500 focus:outline-none"
             @click="toggle"
@@ -263,6 +269,7 @@ import { useFullscreen } from "@vueuse/core";
 import { getStaticResource, isWap } from "@/utils";
 import { useOutsideSystemStore } from "@/stores/modules/system";
 import { msg } from "@/utils";
+import { showConfirmDialog } from "vant";
 
 defineProps({
   type: {
@@ -277,6 +284,7 @@ const {
   API_LY_B_RELOGIN_POST,
   API_PRESET_POINTS_EXEC_POST,
 } = useRequest();
+const router = useRouter();
 const directions = ["lt", "t", "tr", "l", "hr", "r", "lb", "b", "br"];
 const sliderValue = ref(3);
 
@@ -334,6 +342,21 @@ const reconnect = (err, data, api) => {
     api(data);
   }
 };
+
+const logout = () => {
+  showConfirmDialog({
+    title: "退出登录",
+    message: "是否确认退出登录?",
+    confirmButtonText: "确认",
+    cancelButtonText: "取消",
+  })
+    .then(() => {
+      useSystem.loginOut(router);
+    })
+    .catch(() => {
+      // on cancel
+    });
+};
 const load = () => {
   location.reload();
 };
@@ -474,7 +497,7 @@ const setPZTmove = (direction, isKey) => {
 
 const toSelectedPoint = async (item) => {
   if (!item.value) {
-    msg('error', '暂未设置该预制点')
+    msg("error", "暂未设置该预制点");
     return;
   }
   API_PRESET_POINTS_EXEC_POST({

+ 303 - 0
src/views/system/Login/components/bg.vue

@@ -0,0 +1,303 @@
+<template>
+  <div class="bg h-full w-full">
+    <!-- 背景装饰元素 -->
+    <div class="bg-element"></div>
+    <div class="bg-element"></div>
+
+    <!-- 光效 -->
+    <div class="light-effect" style="top: 20%; left: 20%"></div>
+    <div class="light-effect" style="bottom: 10%; right: 20%"></div>
+
+    <div class="flex justify-center items-center h-full">
+      <div class="camera-container !scale-150 glass animate__animated animate__fadeInDown">
+        <div
+          class="camera-set animate__animated animate__fadeIn animate__delay-1s animate__slow"
+        >
+          <slot name="header"></slot>
+
+          <div class="dome-camera">
+            <div class="dome-lens">
+              <div class="dome-lens-inner"></div>
+            </div>
+            <div class="indicator"></div>
+          </div>
+          <!-- 
+          <div class="stand"></div>
+  
+          <div class="panoramic-camera">
+            <div class="compound-lens"></div>
+            <div class="compound-lens"></div>
+            <div class="compound-lens"></div>
+            <div class="compound-lens"></div>
+            <div class="indicator"></div>
+          </div> -->
+        </div>
+        <slot></slot>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup></script>
+
+<style lang="scss" scoped>
+:deep(.van-field__label) {
+  display: none;
+}
+:deep(.van-cell-group) {
+  margin: 0;
+  padding: 0 0;
+  background: var(--van-cell-background);
+
+  .van-field__error-message {
+    position: absolute;
+  }
+  .van-cell {
+    padding-bottom: 24px;
+  }
+}
+
+/* Liquid glass effect class */
+.glass {
+  position: relative;
+  background: rgba(18, 41, 100, 0.15);
+  backdrop-filter: blur(5px) saturate(180%);
+  border: 0.0625rem solid rgba(18, 41, 100, 0.8);
+  border-radius: 2rem;
+  padding: 1.25rem;
+  box-shadow:
+    0 8px 32px rgba(31, 38, 135, 0.2),
+    inset 0 4px 20px rgba(18, 41, 100, 0.3);
+}
+
+.glass::after {
+  content: "";
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background: rgba(255, 255, 255, 0.1);
+  border-radius: 2rem;
+  backdrop-filter: blur(1px);
+  box-shadow:
+    inset -10px -8px 0px -11px rgba(255, 255, 255, 0.6),
+    inset 0px -9px 0px -8px rgba(255, 255, 255, 1);
+  opacity: 0.6;
+  z-index: -1;
+  filter: blur(1px) drop-shadow(10px 4px 6px black) brightness(115%);
+  pointer-events: none;
+}
+
+/* 背景装饰元素 */
+.bg-element {
+  position: absolute;
+  border-radius: 50%;
+  background: rgba(23, 42, 69, 0.6);
+  z-index: -1;
+}
+
+.bg-element:nth-child(1) {
+  width: 300px;
+  height: 300px;
+  top: -150px;
+  left: -150px;
+  background: linear-gradient(45deg, #112240, #0a192f);
+  box-shadow: 0 0 100px rgba(100, 255, 218, 0.1);
+}
+
+.bg-element:nth-child(2) {
+  width: 500px;
+  height: 500px;
+  bottom: -250px;
+  right: -250px;
+  background: linear-gradient(45deg, #0a192f, #112240);
+  box-shadow: 0 0 150px rgba(100, 255, 218, 0.1);
+}
+
+/* 主容器 */
+.camera-container {
+  display: flex;
+  align-items: center;
+  gap: 40px;
+  padding: 15px;
+}
+
+/* 相机组合 */
+.camera-set {
+  position: relative;
+  width: 500px;
+  height: 400px;
+}
+
+/* 全景复眼相机 */
+.panoramic-camera {
+  position: absolute;
+  bottom: 50px;
+  left: 50%;
+  transform: translateX(-50%);
+  width: 100%;
+  max-width: 600px;
+  height: 150px;
+  background: linear-gradient(to bottom, #0c1e3e, #07162c);
+  border-radius: 20px;
+  box-shadow:
+    0 10px 30px rgba(0, 0, 0, 0.6),
+    inset 0 0 0 1px rgba(100, 255, 218, 0.1);
+  display: flex;
+  justify-content: space-around;
+  align-items: center;
+  padding: 0 20px;
+}
+
+/* 复眼镜头 */
+.compound-lens {
+  width: 80px;
+  height: 80px;
+  background: linear-gradient(135deg, #07162c, #0c1e3e);
+  border-radius: 50%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  box-shadow: 0 0 0 2px rgba(23, 42, 69, 0.8);
+  position: relative;
+}
+
+.compound-lens::before {
+  content: "";
+  position: absolute;
+  width: 60px;
+  height: 60px;
+  background: radial-gradient(circle at 30% 30%, #122d4f, #0a1a32);
+  border-radius: 50%;
+  box-shadow: 0 0 15px rgba(58, 97, 134, 0.6);
+}
+
+.compound-lens::after {
+  content: "";
+  position: absolute;
+  width: 40px;
+  height: 40px;
+  background: radial-gradient(circle at 40% 40%, #1e3a5f, #0c203f);
+  border-radius: 50%;
+}
+
+/* 球型相机 */
+.dome-camera {
+  position: absolute;
+  top: 50px;
+  left: 50%;
+  transform: translateX(-50%);
+  width: 200px;
+  height: 200px;
+  background: linear-gradient(135deg, #0c1e3e, #07162c);
+  border-radius: 50%;
+  box-shadow:
+    0 10px 30px rgba(0, 0, 0, 0.6),
+    inset 0 0 0 1px rgba(100, 255, 218, 0.1);
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  transform-style: preserve-3d;
+  transition: transform 1.5s ease;
+}
+
+.dome-lens {
+  width: 100px;
+  height: 100px;
+  background: radial-gradient(circle at 30% 30%, #122d4f, #0a1a32);
+  border-radius: 50%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  transform: translateZ(20px);
+  animation: pulse 1.5s infinite alternate ease-in-out;
+}
+
+.dome-lens-inner {
+  width: 60px;
+  height: 60px;
+  background: radial-gradient(circle at 40% 40%, #1e3a5f, #0c203f);
+  border-radius: 50%;
+}
+
+/* 支架 */
+.stand {
+  position: absolute;
+  top: 170px;
+  left: 50%;
+  transform: translateX(-50%);
+  width: 20px;
+  height: 130px;
+  background: linear-gradient(to bottom, #07162c, #0c1e3e);
+}
+
+/* 指示灯 */
+.indicator {
+  position: absolute;
+  width: 8px;
+  height: 8px;
+  background: #64ffda;
+  border-radius: 50%;
+  box-shadow: 0 0 10px #64ffda;
+}
+
+.panoramic-camera .indicator {
+  top: 15px;
+  right: 20px;
+}
+
+.dome-camera .indicator {
+  bottom: 20px;
+  right: 20px;
+}
+
+@keyframes pulse {
+  0% {
+    box-shadow: 0 0 20px rgba(58, 97, 134, 0.2);
+  }
+  100% {
+    box-shadow: 0 0 20px rgba(58, 97, 134, 0.8);
+  }
+}
+
+/* 光效 */
+.light-effect {
+  position: absolute;
+  width: 300px;
+  height: 300px;
+  background: radial-gradient(
+    circle,
+    rgba(100, 255, 218, 0.1) 0%,
+    transparent 70%
+  );
+  border-radius: 50%;
+  z-index: -1;
+}
+
+/* 控制面板 */
+.control-panel {
+  display: flex;
+  justify-content: center;
+  gap: 20px;
+  margin-top: 40px;
+  flex-wrap: wrap;
+}
+
+.control-btn {
+  background: rgba(23, 42, 69, 0.5);
+  border: 1px solid rgba(100, 255, 218, 0.3);
+  color: #64ffda;
+  padding: 12px 24px;
+  border-radius: 30px;
+  font-size: 1rem;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  backdrop-filter: blur(5px);
+}
+
+.control-btn:hover {
+  background: rgba(100, 255, 218, 0.1);
+  box-shadow: 0 0 15px rgba(100, 255, 218, 0.3);
+}
+</style>

+ 130 - 0
src/views/system/Login/index.vue

@@ -0,0 +1,130 @@
+<template>
+  <div class="login-container relative h-full overflow-hidden">
+    <Bg>
+      <template #header>
+        <div class="login-h w-full absolute left-0 top-0 flex justify-between">
+          <h2
+            class="text-4xl tracking-2 font-[yb-font] text-#8ac5ff opacity-10"
+          >
+            全景布控球
+          </h2>
+          <h2
+            class="text-4xl tracking-2 font-[yb-font] text-#8ac5ff opacity-10"
+          >
+            欢迎回来
+          </h2>
+        </div>
+      </template>
+      <div
+        class="flex flex-col items-center absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-14"
+      >
+        <van-form @submit="onSubmit" class="login-form w-80">
+          <van-cell-group inset>
+            <van-field
+              v-model="form.user"
+              name="user"
+              label="用户名"
+              placeholder="用户名"
+              :rules="[{ required: true, message: '请填写用户名' }]"
+              :disabled="loading"
+            />
+            <van-field
+              v-model="form.password"
+              type="password"
+              name="password"
+              label="密码"
+              placeholder="密码"
+              :rules="[{ required: true, message: '请填写密码' }]"
+              :disabled="loading"
+            />
+          </van-cell-group>
+          <div class="my-4">
+            <van-checkbox
+              class="text-12px"
+              v-model="checked"
+              :disabled="loading"
+            >
+              <!-- 我已阅读并同意<span class="text-blue">《用户使用条款》</span
+              >和<span class="text-blue">《隐私政策》</span> -->
+              记住密码
+            </van-checkbox>
+          </div>
+          <van-button
+            block
+            type="primary"
+            native-type="submit"
+            :loading="loading"
+            loading-text="登录中,请等待..."
+          >
+            登录
+          </van-button>
+        </van-form>
+      </div>
+    </Bg>
+  </div>
+</template>
+
+<script setup>
+import { getFingerprint, getENCPwd } from "@/utils";
+import Bg from "./components/bg.vue";
+import storage from "@/utils/storage";
+import { msgE } from "@/utils";
+import { useOutsideSystemStore } from "@/stores/modules/system.js";
+
+const useSystem = useOutsideSystemStore();
+const { API_LOGIN_POST } = useRequest();
+const router = useRouter();
+
+const form = ref({
+  user: "",
+  password: "",
+  // key: ''
+});
+const loading = ref(false);
+const checked = ref(true);
+const onSubmit = (values) => {
+  // if (!checked.value) {
+  //   msgE("请阅读并同意用户使用条款和隐私政策");
+  //   return;
+  // }
+  loading.value = true;
+  if (checked.value) {
+    // 记住密码,存7天
+    storage.cookie.set("mp", `${form.value.user}:${form.value.password}`, {
+      expires: 7,
+    });
+  } else {
+    storage.cookie.remove("mp");
+  }
+  getENCPwd(form.value.password).then((pwd) => {
+    getFingerprint().then(({ fingerprint, components }) => {
+      const params = {
+        User: form.value.user,
+        Password: pwd,
+        Fingerprint: fingerprint,
+        components,
+      };
+
+      API_LOGIN_POST(params)
+        .then((res) => {
+          useSystem.token = res.Token;
+          storage.local.set("t", res.Token);
+          loading.value = false;
+          router.replace({ name: "Home" });
+        })
+        .catch(() => {
+          loading.value = false;
+        });
+    });
+  });
+};
+
+onMounted(() => {
+  const mp = storage.cookie.get("mp");
+  if (mp) {
+    const [user, password] = mp.split(":");
+    form.value.user = user;
+    form.value.password = password;
+  }
+});
+</script>