Ver código fonte

后台登录增加指纹设备识别 手机号验证码登录 优化封装接口等

dengshaobo 3 semanas atrás
pai
commit
a4708b364b

+ 1 - 1
.env.production

@@ -1,3 +1,3 @@
-VITE_API_BASE_URL = "https://www.huijiaoyan.com/admin/"
+VITE_API_BASE_URL = "https://gthjy.k12100.net/admin/"
 # 门户跳转域名(生产环境)
 # 门户跳转域名(生产环境)
 VITE_PORTAL_BASE_URL = "https://www.k12100.com"
 VITE_PORTAL_BASE_URL = "https://www.k12100.com"

+ 12 - 0
package-lock.json

@@ -9,6 +9,7 @@
       "version": "0.0.0",
       "version": "0.0.0",
       "dependencies": {
       "dependencies": {
         "@element-plus/icons-vue": "^2.3.1",
         "@element-plus/icons-vue": "^2.3.1",
+        "@fingerprintjs/fingerprintjs": "^5.2.0",
         "axios": "^1.9.0",
         "axios": "^1.9.0",
         "echarts": "^6.0.0",
         "echarts": "^6.0.0",
         "element-plus": "^2.9.11",
         "element-plus": "^2.9.11",
@@ -488,6 +489,12 @@
         "node": ">=18"
         "node": ">=18"
       }
       }
     },
     },
+    "node_modules/@fingerprintjs/fingerprintjs": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmmirror.com/@fingerprintjs/fingerprintjs/-/fingerprintjs-5.2.0.tgz",
+      "integrity": "sha512-j+2nInkwCQNTJcNhOjvkGM/nLRTuGJTC6xai4quqvUpjob2ssrGwBZjS7k55nOmKvge7qvJT2nS3i/IRvQSTQA==",
+      "license": "MIT"
+    },
     "node_modules/@floating-ui/core": {
     "node_modules/@floating-ui/core": {
       "version": "1.7.0",
       "version": "1.7.0",
       "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.0.tgz",
       "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.0.tgz",
@@ -2717,6 +2724,11 @@
       "dev": true,
       "dev": true,
       "optional": true
       "optional": true
     },
     },
+    "@fingerprintjs/fingerprintjs": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmmirror.com/@fingerprintjs/fingerprintjs/-/fingerprintjs-5.2.0.tgz",
+      "integrity": "sha512-j+2nInkwCQNTJcNhOjvkGM/nLRTuGJTC6xai4quqvUpjob2ssrGwBZjS7k55nOmKvge7qvJT2nS3i/IRvQSTQA=="
+    },
     "@floating-ui/core": {
     "@floating-ui/core": {
       "version": "1.7.0",
       "version": "1.7.0",
       "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.0.tgz",
       "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.0.tgz",

+ 4 - 3
package.json

@@ -11,14 +11,15 @@
   },
   },
   "dependencies": {
   "dependencies": {
     "@element-plus/icons-vue": "^2.3.1",
     "@element-plus/icons-vue": "^2.3.1",
+    "@fingerprintjs/fingerprintjs": "^5.2.0",
     "axios": "^1.9.0",
     "axios": "^1.9.0",
+    "echarts": "^6.0.0",
     "element-plus": "^2.9.11",
     "element-plus": "^2.9.11",
+    "lodash": "^4.17.21",
     "particles.js": "^2.0.0",
     "particles.js": "^2.0.0",
     "vue": "^3.5.13",
     "vue": "^3.5.13",
     "vue-router": "^4.5.1",
     "vue-router": "^4.5.1",
-    "vuex": "^4.1.0",
-    "lodash": "^4.17.21",
-    "echarts": "^6.0.0"
+    "vuex": "^4.1.0"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@types/node": "^22.15.26",
     "@types/node": "^22.15.26",

+ 36 - 2
src/api/login.ts

@@ -10,10 +10,44 @@ export const getUserInfo = () => {
   return http.get('/adminApi/v1/sys/users/info')
   return http.get('/adminApi/v1/sys/users/info')
 }
 }
 // 退出登录
 // 退出登录
-export const loginOut = (userId: any) => {
-  return http.post(`/adminApi/v1/sys/users/${userId}/kickOut`)
+export const loginOut = () => {
+  return http.post(`/adminApi/v1/login/admin_logout`)
 }
 }
 // 修改密码
 // 修改密码
 export const changePassWord = (data: any) => {
 export const changePassWord = (data: any) => {
   return http.post('/adminApi/v1/sys/users/updatePassword', data)
   return http.post('/adminApi/v1/sys/users/updatePassword', data)
 }
 }
+
+
+//后台新版登录  
+
+//新版登录
+export const loginSignIn = (data: any) => {
+  return http.post('/adminApi/v1/login/admin_sign_in',data)
+}
+
+//验证滑块是否正确 返回手机号(业务码 601/602/400 由调用方自行分支,跳过统一错误处理)
+export const verifySlider = (data: any) => {
+  return http.post('/adminApi/v1/login/admin_validate_slider', data, { skipErrorToast: true })
+}
+
+
+//重新获取滑块数据
+export const getSlider = (data: any) => {
+  return http.post('/adminApi/v1/login/admin_re_obtain_slider',data)
+}
+
+//退出
+export const loginOutNew = () => {
+  return http.post('/adminApi/v1/login/admin_logout')
+}
+
+//使用验证码登录
+export const loginInByCode = (data: any) => {
+  return http.post('/adminApi/v1/login/admin_log_in_by_code',data)
+}
+
+//绑定设备码
+export const bindDeviceCode = (data: any) => {
+  return http.post('/adminApi/v1/login/admin_bind_device_finger',data)
+}

+ 507 - 0
src/components/SlideCode.vue

@@ -0,0 +1,507 @@
+<template>
+  <transition name="zoom-center" appear>
+    <div v-if="visible" class="slide_code_modal" @click.self="HandleConfirm">
+        <div  class="slide_code_content">
+          <!-- <div class="modal_header">
+            <h3>{{ title }}</h3>
+            <i class="el-icon-close close_btn" @click="HandleConfirm"></i>
+          </div> -->
+          
+          <div class="modal_body">
+            <slot>
+              <div class="slider_container">
+                <div class="slider_text">
+                  请拖动滑块完成拼图
+                </div>
+                <div class="container_bg" :style="{ backgroundImage:  sliderBgUrl ? 'url(' + sliderBgUrl + ')' : '' }">
+                  <div class="slider_bg"  :style="{ left: sliderPositionX + 'px',top:sliderPositionY+'px', backgroundImage:  sliderImgUrl ? 'url(' + sliderImgUrl + ')' : '' }">
+
+                  </div>
+                  <!-- 验证成功的样式 -->
+                  <transition name="slide-up">
+                    <div v-if="isSuccess" class="slider_sucess">
+                      {{sliderTime }}秒的速度超过{{sliderPrent}}%的用户
+                    </div>
+                    <div v-else-if="isFailed" class="slider_failed">
+                      验证失败,请重新拖动滑块尝试
+                    </div>
+                  </transition>
+                </div>
+                <div class="slider_track" ref="trackRef">
+                  
+                  <div  class="slider_button"  :style="{ left: sliderPositionX + 'px' }"  @mousedown="startSlide"  @touchstart="startSlide">
+                    <i class="el-icon-d-arrow-right"></i>
+                  </div>
+                 
+                </div>
+              </div>
+            </slot>
+          </div>
+        </div>
+    </div>
+  </transition>
+</template>
+
+<script setup lang="ts">
+import { ref, watch, onBeforeUnmount } from 'vue'
+import { ElMessage } from 'element-plus'
+import { verifySlider } from '@/api/login'
+
+/* ------------------------------------------------------------------ */
+/*  Props / Emits                                                      */
+/* ------------------------------------------------------------------ */
+
+interface Props {
+  /** v-model 绑定值:控制弹窗显示 / 隐藏 */
+  modelValue?: boolean
+  /** 图片唯一凭证 */
+  sliderUUID?: string
+  /** 背景图片地址 */
+  sliderBgUrl?: string
+  /** 滑块图片地址 */
+  sliderImgUrl?: string
+  /** 滑块 y 轴位置 */
+  sliderPositionY?: number | string
+  /** 标题 */
+  title?: string
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  modelValue: false,
+  sliderUUID: '',
+  sliderBgUrl: '',
+  sliderImgUrl: '',
+  sliderPositionY: 0,
+  title: '安全验证',
+})
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: boolean): void
+  (e: 'close', verified: number, phone: string): void
+  (e: 'refresh'): void
+}>()
+
+/* ------------------------------------------------------------------ */
+/*  响应式状态                                                          */
+/* ------------------------------------------------------------------ */
+
+const visible = ref<boolean>(false)
+const trackRef = ref<HTMLElement | null>(null) // 滑块轨道元素
+
+const sliderPositionX = ref<number>(0) // 滑块的 x 轴位置
+const sliderWidth = ref<number>(0)
+const sliderTime = ref<number>(0) // 滑动耗时(秒)
+const sliderPrent = ref<number | string>('98') // 百分比
+const startTime = ref<number>(0) // 开始滑动的时间戳
+const sliderVerified = ref<number>(0) // 验证状态:0 未开始,1 成功,-1 失败
+const sliderPhone = ref<string>('') // 验证成功后后端返回的手机号
+const isSliding = ref<boolean>(false) // 是否开始滑动
+const isSuccess = ref<boolean>(false) // 是否验证成功
+const isFailed = ref<boolean>(false) // 是否验证失败
+const startX = ref<number>(0) // 鼠标相对于轨道的初始偏移
+const sliderText = ref<string>('按住滑块拖动')
+
+/* ------------------------------------------------------------------ */
+/*  方法                                                                */
+/* ------------------------------------------------------------------ */
+
+/** 从鼠标 / 触摸事件中取出 clientX */
+const getClientX = (event: MouseEvent | TouchEvent): number => {
+  if (event.type.includes('mouse')) {
+    return (event as MouseEvent).clientX
+  }
+  return (event as TouchEvent).touches[0].clientX
+}
+
+/** 重置滑块位置 */
+const ResetSlider = (): void => {
+  sliderPositionX.value = 0
+  sliderVerified.value = 0
+  sliderWidth.value = 0
+  sliderText.value = '按住滑块拖动'
+  isSuccess.value = false
+  isFailed.value = false
+}
+
+/** 关闭弹窗 */
+const HandleConfirm = (): void => {
+  visible.value = false
+  emit('update:modelValue', false)
+  emit('close', sliderVerified.value, sliderPhone.value)
+}
+
+/** 验证滑动验证码位置是否正确 */
+const VerifySliderCaptcha = (): void => {
+  const param = {
+    sliderUUID: props.sliderUUID, // 图片唯一凭证
+    x: Math.round(sliderPositionX.value), // 滑块位置
+  }
+
+  verifySlider(param)
+    .then((res: any) => {
+      console.log('验证滑动验证码返回结果', res)
+
+      if (res.code === 200) {
+        // 验证成功
+        isSuccess.value = true
+        sliderVerified.value = 1
+        sliderPhone.value = res.data
+        setTimeout(() => {
+          HandleConfirm() // 关闭弹窗
+        }, 1000)
+      } else if (res.code === 601) {
+        // 验证通过,上次验证码仍在有效期
+        sliderVerified.value = 1
+        isSuccess.value = true
+        sliderPhone.value = res.data
+        ElMessage.warning('滑块验证通过,您上次的验证码还在有效期,请输入上次发送的验证码进行登录!')
+        setTimeout(() => {
+          HandleConfirm() // 关闭弹窗
+        }, 1000)
+      } else if (res.code === 602) {
+        // 滑动验证码过期,重新获取
+        ElMessage.error('滑块验证码已过期,已为您重新获取!')
+        emit('refresh') // 重新获取验证码
+      } else if (res.code === 400) {
+        // 验证失败
+        isFailed.value = true
+        sliderVerified.value = -1
+        sliderPositionX.value = 0
+        emit('refresh') // 重新获取验证码
+      } else {
+        // 验证失败
+        isFailed.value = true
+        sliderVerified.value = -1
+        sliderPositionX.value = 0
+      }
+
+      // 延迟 2 秒关闭失败层
+      setTimeout(() => {
+        isFailed.value = false
+      }, 2000)
+    })
+    .catch((err: unknown) => {
+      // 业务码(601/602/400)已由 skipErrorToast 放行进入 then;
+      // 此处仅剩网络错误/超时等请求级异常
+      console.error('验证滑动验证码请求异常:', err)
+    })
+}
+
+/** 滑动过程中 */
+const onSlide = (event: MouseEvent | TouchEvent): void => {
+  if (!isSliding.value || isSuccess.value) return
+
+  const track = trackRef.value
+  if (!track) return
+  const rect = track.getBoundingClientRect()
+
+  // 计算当前鼠标距离轨道左边缘的距离
+  const currentClientX = getClientX(event)
+  const currentRelativeX = currentClientX - rect.left
+
+  // 新的滑块位置 = 当前鼠标相对位置 - 初始点击时的相对偏移
+  // 这样能保证滑块跟随鼠标移动,且不会发生跳变
+  let newPixelPosition = currentRelativeX - startX.value
+
+  // 计算最大可移动距离并限制范围
+  const maxPosition = 300 - 50
+  newPixelPosition = Math.max(0, Math.min(maxPosition, newPixelPosition))
+
+  sliderPositionX.value = newPixelPosition
+}
+
+/** 结束滑动 */
+const stopSlide = (): void => {
+  if (!isSliding.value) return
+  // 计算滑动耗时
+  const endTime = Date.now()
+  sliderTime.value = parseFloat(((endTime - startTime.value) / 1000).toFixed(2))
+
+  console.log('滑动耗时:', sliderTime.value, '秒')
+  // 根据滑动时间计算百分比
+  if (sliderTime.value <= 1) {
+    sliderPrent.value = 99
+  } else if (sliderTime.value <= 2) {
+    sliderPrent.value = 98
+  } else if (sliderTime.value <= 3) {
+    sliderPrent.value = 97
+  } else {
+    sliderPrent.value = Math.max(0, 96 - Math.floor(sliderTime.value - 3))
+  }
+  isSliding.value = false
+  document.removeEventListener('mousemove', onSlide)
+  document.removeEventListener('mouseup', stopSlide)
+  document.removeEventListener('touchmove', onSlide)
+  document.removeEventListener('touchend', stopSlide)
+  // 松开鼠标后,发送请求验证滑块位置
+  VerifySliderCaptcha()
+}
+
+/** 开始滑动事件 */
+const startSlide = (event: MouseEvent | TouchEvent): void => {
+  event.preventDefault()
+  event.stopPropagation()
+  isSliding.value = true // 是否滑动
+
+  // 记录开始时间
+  startTime.value = Date.now()
+
+  const track = trackRef.value
+  if (!track) return
+  const rect = track.getBoundingClientRect()
+
+  // 计算鼠标点击位置距离轨道左边缘的距离
+  const clientX = getClientX(event)
+  startX.value = clientX - rect.left - sliderPositionX.value
+
+  document.addEventListener('mousemove', onSlide)
+  document.addEventListener('mouseup', stopSlide)
+  document.addEventListener('touchmove', onSlide, { passive: false })
+  document.addEventListener('touchend', stopSlide)
+}
+
+/* ------------------------------------------------------------------ */
+/*  侦听 & 生命周期                                                     */
+/* ------------------------------------------------------------------ */
+
+// 同步 v-model 值到内部 visible,并重置滑块
+watch(
+  () => props.modelValue,
+  (newVal) => {
+    visible.value = newVal
+    ResetSlider() // 重置滑块位置
+  },
+  { immediate: true },
+)
+
+// 组件卸载前移除全局事件监听(原 Vue2 的 beforeDestroy)
+onBeforeUnmount(() => {
+  document.removeEventListener('mousemove', onSlide)
+  document.removeEventListener('mouseup', stopSlide)
+  document.removeEventListener('touchmove', onSlide)
+  document.removeEventListener('touchend', stopSlide)
+})
+</script>
+
+<style scoped lang="scss">
+.slide_code_modal {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  // background-color: rgba(0, 0, 0, 0.5);//不要遮罩层
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 9999;
+}
+
+.slide_code_content {
+  background: #fff;
+  border-radius: 8px;
+  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
+  min-width: 300px;
+  max-width: 600px;
+}
+
+.modal_header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 16px 20px;
+  border-bottom: 1px solid #e4e7ed;
+  
+  h3 {
+    margin: 0;
+    font-size: 16px;
+    color: #303133;
+  }
+  
+  .close_btn {
+    cursor: pointer;
+    font-size: 18px;
+    color: #909399;
+    transition: color 0.3s;
+    
+    &:hover {
+      color: #f56c6c;
+    }
+  }
+}
+
+.modal_body {
+  padding: 20px;
+  min-height: 100px;
+}
+
+.modal_footer {
+  padding: 12px 20px;
+  border-top: 1px solid #e4e7ed;
+  text-align: right;
+  
+  .el-button {
+    margin-left: 10px;
+  }
+}
+
+.slider_container {
+
+  .slider_text
+  {
+    text-align: center;
+    height: 36px;
+    font-size: 16px;
+  }
+  //背景的图片
+  .container_bg
+  {
+    width: 300px;
+    height: 180px;
+    background-size: 100% 100%;
+    background-position: 0 0;
+    background-repeat: no-repeat;
+    position: relative;
+    overflow: hidden;
+    .slider_bg
+    {
+      width: 50px;
+      height: 50px;
+      background-position: 0 0;
+      background-repeat: no-repeat;
+      background-size: 100% 100%;
+      position: absolute;
+      left: 0;
+      top: 35px;
+      border-radius: 5px;
+      border:1px solid red;
+    }
+
+    .slider_sucess
+    {
+      width: 100%;
+      height: 40px;
+      background-color: #2BC644;
+      position: absolute;
+      left: 0;
+      bottom: 0;
+      z-index: 88;
+      line-height: 40px;
+      color:#fff;
+      font-size: 12px;
+      text-align: center;
+    }
+
+    .slider_failed
+    {
+      width: 100%;
+      height: 40px;
+      background-color: #ff5d39;
+      position: absolute;
+      left: 0;
+      bottom: 0;
+      z-index: 88;
+      line-height: 40px;
+      color:#fff;
+      font-size: 12px;
+      text-align: center;
+    }
+  }
+}
+
+.slider_track {
+  position: relative;
+  height: 36px;
+  background-color: #e4e7ed;
+  border-radius: 20px;
+  overflow: hidden;
+  user-select: none;
+  cursor: pointer;
+  margin-top: 20px;
+}
+
+.slider_button {
+  position: absolute;
+  left: 0;
+  top: 0;
+  width: 50px;
+  height: 36px;
+  background: #fff;
+  border: 2px solid #409eff;
+  border-radius: 25px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: grab;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+
+  
+  i {
+    color: #409eff;
+    font-size: 16px;
+  }
+  
+  &:active {
+    cursor: grabbing;
+  }
+}
+
+// 从底部升起的动画
+.slide-up-enter-active {
+  animation: slideUpIn 0.5s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.slide-up-leave-active {
+  animation: slideUpOut 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+@keyframes slideUpIn {
+  from {
+    transform: translateY(100%);
+    opacity: 0;
+  }
+  to {
+    transform: translateY(0);
+    opacity: 1;
+  }
+}
+
+@keyframes slideUpOut {
+  from {
+    transform: translateY(0);
+    opacity: 1;
+  }
+  to {
+    transform: translateY(100%);
+    opacity: 0;
+  }
+}
+
+.zoom-center-enter-active {
+  transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.zoom-center-leave-active {
+  transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1);
+}
+.zoom-center-enter,
+.zoom-center-leave-to {
+  opacity: 0;
+  transform: scale(0.5);
+}
+
+.zoom-center-enter-to {
+  opacity: 1;
+  transform: scale(1);
+}
+
+.zoom-center-leave {
+  opacity: 1;
+  transform: scale(1);
+}
+
+.zoom-center-leave-to {
+  opacity: 0;
+  transform: scale(0.5);
+}
+</style>

+ 224 - 0
src/utils/deviceFingerprint.ts

@@ -0,0 +1,224 @@
+// src/utils/deviceFingerprint.ts
+import FingerprintJS from '@fingerprintjs/fingerprintjs'
+import type { Agent, GetResult, UnknownComponents } from '@fingerprintjs/fingerprintjs'
+
+/* ------------------------------------------------------------------ */
+/*  类型定义                                                            */
+/* ------------------------------------------------------------------ */
+
+/** 本地缓存数据结构 */
+interface FingerprintCache {
+  fingerprint: string
+  timestamp: number
+}
+
+/** 设备指纹详情(调试用) */
+export interface FingerprintDetails {
+  visitorId: string
+  confidence: number
+  components: string[]
+  componentValues: UnknownComponents
+  cached: boolean
+}
+
+/* ------------------------------------------------------------------ */
+/*  常量                                                                */
+/* ------------------------------------------------------------------ */
+
+/** localStorage 缓存 key */
+const CACHE_KEY = 'stable_device_fingerprint'
+
+/** 缓存过期时间:30 天(毫秒) */
+const CACHE_EXPIRY = 30 * 24 * 60 * 60 * 1000
+
+/**
+ * 稳定组件参考列表(与域名无关)
+ *
+ * 注意:FingerprintJS v5 的 load() 不支持 components 过滤选项,
+ * 库内部会使用全部内置熵源并自动处理稳定性。
+ * 此列表仅作为文档参考,记录期望使用的稳定组件。
+ *
+ * 排除的组件:plugins、cookies、webgl、audio 等可能因域名/环境变化的因素
+ */
+export const STABLE_COMPONENTS: readonly string[] = [
+  'userAgent',           // 浏览器 UA
+  'languages',           // 语言设置
+  'colorDepth',          // 颜色深度
+  'resolution',          // 屏幕分辨率
+  'hardwareConcurrency', // CPU 核心数
+  'deviceMemory',        // 设备内存
+  'timezone',            // 时区偏移
+  'sessionStorage',      // 是否支持 sessionStorage
+  'localStorage',        // 是否支持 localStorage
+  'indexedDB',           // 是否支持 indexedDB
+  'openDatabase',        // 是否支持 Web SQL
+  'cpuClass',            // CPU 类别
+  'platform',            // 操作系统平台
+  'touchSupport',        // 触摸支持
+  'canvas',              // Canvas 指纹
+] as const
+
+/* ------------------------------------------------------------------ */
+/*  内部状态                                                            */
+/* ------------------------------------------------------------------ */
+
+/** FingerprintJS Agent 单例 Promise */
+let fpPromise: Promise<Agent> | null = null
+
+/**
+ * 获取 FingerprintJS Agent 单例(懒加载)
+ */
+function getAgent(): Promise<Agent> {
+  if (!fpPromise) {
+    fpPromise = FingerprintJS.load({ monitoring: false })
+  }
+  return fpPromise
+}
+
+/* ------------------------------------------------------------------ */
+/*  缓存读写                                                            */
+/* ------------------------------------------------------------------ */
+
+/**
+ * 从本地存储获取未过期的缓存指纹
+ */
+function getCachedFingerprint(): string | null {
+  try {
+    const cached = localStorage.getItem(CACHE_KEY)
+    if (!cached) return null
+
+    const { fingerprint, timestamp } = JSON.parse(cached) as FingerprintCache
+
+    // 检查是否过期
+    if (Date.now() - timestamp > CACHE_EXPIRY) {
+      console.log('⏰ 设备指纹缓存已过期(超过30天)')
+      localStorage.removeItem(CACHE_KEY)
+      return null
+    }
+
+    return fingerprint
+  } catch (error) {
+    console.error('读取缓存指纹失败:', error)
+    return null
+  }
+}
+
+/**
+ * 获取过期的缓存指纹(作为最后的备选)
+ */
+function getExpiredCachedFingerprint(): string | null {
+  try {
+    const cached = localStorage.getItem(CACHE_KEY)
+    if (!cached) return null
+
+    const { fingerprint } = JSON.parse(cached) as FingerprintCache
+    console.warn('⚠️ 返回过期的缓存指纹')
+    return fingerprint
+  } catch {
+    return null
+  }
+}
+
+/**
+ * 缓存设备指纹到本地存储
+ */
+function cacheFingerprint(fingerprint: string): void {
+  try {
+    const data: FingerprintCache = {
+      fingerprint,
+      timestamp: Date.now(),
+    }
+    localStorage.setItem(CACHE_KEY, JSON.stringify(data))
+    console.log('💾 设备指纹已缓存(有效期30天)')
+  } catch (error) {
+    console.error('缓存设备指纹失败:', error)
+  }
+}
+
+/* ------------------------------------------------------------------ */
+/*  公开 API                                                            */
+/* ------------------------------------------------------------------ */
+
+/**
+ * 获取稳定的设备指纹(唯一推荐方法)
+ *
+ * 核心策略:
+ * 1. 优先使用本地缓存的指纹(确保同一设备指纹绝对一致)
+ * 2. 缓存过期后,使用 FingerprintJS 重新生成并缓存
+ * 3. FingerprintJS v5 内部使用全部稳定熵源,自动排除域名相关因素
+ *
+ * @returns 稳定的设备指纹(32 位字符串)
+ */
+export async function getStableDeviceFingerprint(): Promise<string> {
+  try {
+    // 1. 尝试从缓存读取(最关键的一步)
+    const cached = getCachedFingerprint()
+    if (cached) {
+      console.log('✅ 使用缓存的设备指纹:', cached)
+      return cached
+    }
+
+    // 2. 缓存不存在或已过期,使用 FingerprintJS 生成新指纹
+    console.log('🔄 缓存不存在或已过期,生成新的设备指纹...')
+
+    const fp: Agent = await getAgent()
+    const result: GetResult = await fp.get()
+
+    const fingerprint: string = result.visitorId
+
+    // 3. 立即缓存新生成的指纹
+    cacheFingerprint(fingerprint)
+
+    console.log('✅ 新生成的设备指纹:', fingerprint)
+    console.log('📊 置信度评分:', result.confidence?.score)
+    console.log('🔧 使用的组件:', Object.keys(result.components))
+
+    return fingerprint
+  } catch (error) {
+    console.error('❌ 获取设备指纹失败:', error)
+
+    // 如果出错,尝试返回过期的缓存(比生成新的更稳定)
+    const expiredCache = getExpiredCachedFingerprint()
+    if (expiredCache) {
+      console.warn('⚠️ 使用过期的缓存指纹(可能不是最新的):', expiredCache)
+      return expiredCache
+    }
+
+    // 如果连过期缓存都没有,抛出错误让调用方处理
+    throw new Error('无法获取设备指纹,请检查浏览器是否支持 localStorage')
+  }
+}
+
+/**
+ * 清除缓存的设备指纹
+ * 用于测试或强制重新生成指纹
+ */
+export function clearCachedFingerprint(): void {
+  try {
+    localStorage.removeItem(CACHE_KEY)
+    console.log('🗑️ 已清除设备指纹缓存')
+  } catch (error) {
+    console.error('清除缓存失败:', error)
+  }
+}
+
+/**
+ * 获取设备指纹的详细信息(用于调试)
+ */
+export async function getFingerprintDetails(): Promise<FingerprintDetails | null> {
+  try {
+    const fp: Agent = await getAgent()
+    const result: GetResult = await fp.get()
+
+    return {
+      visitorId: result.visitorId,
+      confidence: result.confidence?.score ?? 0,
+      components: Object.keys(result.components),
+      componentValues: result.components as UnknownComponents,
+      cached: !!getCachedFingerprint(),
+    }
+  } catch (error) {
+    console.error('获取指纹详情失败:', error)
+    return null
+  }
+}

+ 21 - 9
src/utils/request.ts

@@ -1,7 +1,13 @@
 // src/utils/request.ts
 // src/utils/request.ts
 import axios from 'axios'
 import axios from 'axios'
 import { ElMessage } from 'element-plus'
 import { ElMessage } from 'element-plus'
-import type { AxiosInstance, AxiosResponse } from 'axios'
+import type { AxiosInstance, AxiosResponse, AxiosRequestConfig } from 'axios'
+
+/** 扩展请求配置 */
+export interface RequestConfig extends AxiosRequestConfig {
+  /** 为 true 时:响应拦截器对非 200 业务码不弹错误提示、不 reject,原样返回响应由调用方自行判断(如滑块验证码 601/602/400) */
+  skipErrorToast?: boolean
+}
 
 
 // 创建 axios 实例
 // 创建 axios 实例
 const service: AxiosInstance = axios.create({
 const service: AxiosInstance = axios.create({
@@ -33,6 +39,12 @@ service.interceptors.response.use(
     const res = response.data
     const res = response.data
 
 
     if (res.code !== 200) {
     if (res.code !== 200) {
+      // 启用 skipErrorToast 的接口(如滑块验证码 601/602/400):
+      // 不弹错误提示、不 reject,原样返回响应由调用方按业务码分支
+      if ((response.config as RequestConfig).skipErrorToast) {
+        return res
+      }
+
       ElMessage({
       ElMessage({
         message: res.msg || '请求失败',
         message: res.msg || '请求失败',
         type: 'error',
         type: 'error',
@@ -63,17 +75,17 @@ service.interceptors.response.use(
 
 
 // 常用请求方法封装
 // 常用请求方法封装
 export const http = {
 export const http = {
-  get<T = any>(url: string, params?: any): Promise<T> {
-    return service.get(url, { params })
+  get<T = any>(url: string, params?: any, config?: RequestConfig): Promise<T> {
+    return service.get(url, { params, ...config })
   },
   },
-  post<T = any>(url: string, data?: any): Promise<T> {
-    return service.post(url, data)
+  post<T = any>(url: string, data?: any, config?: RequestConfig): Promise<T> {
+    return service.post(url, data, config)
   },
   },
-  put<T = any>(url: string, data?: any): Promise<T> {
-    return service.put(url, data)
+  put<T = any>(url: string, data?: any, config?: RequestConfig): Promise<T> {
+    return service.put(url, data, config)
   },
   },
-  delete<T = any>(url: string, data?: any): Promise<T> {
-    return service.delete(url, { data })
+  delete<T = any>(url: string, data?: any, config?: RequestConfig): Promise<T> {
+    return service.delete(url, { data, ...config })
   }
   }
 }
 }
 
 

+ 393 - 201
src/views/login/login.vue

@@ -16,44 +16,91 @@
 
 
         </div>
         </div>
         <div class="login_info_message">后台管理系统登录</div>
         <div class="login_info_message">后台管理系统登录</div>
-        <div class="login_info_input">
-          <el-form ref="userInfoForm" @submit.prevent="SubmitLogin">
-            <el-form-item prop="userName">
-              <el-input v-model="userName" placeholder="请输入账号" clearable @keyup.enter="SubmitLogin">
-                <template #prefix>
-                  <i class="iconfont icon_zhanghao" />
-                </template>
-              </el-input>
-            </el-form-item>
-            <el-form-item prop="passWord">
-              <el-input v-model="passWord" type="password" show-password placeholder="请输入密码" @keyup.enter="SubmitLogin">
-                <template #prefix>
-                  <i class="iconfont icon_mima" />
-                </template>
-              </el-input>
-              <!-- <el-input v-model="passWord" placeholder="请输入密码" :type="!showPass ? 'password' : 'text'">
-                <template #prefix>
-                  <i class="iconfont icon_mima" />
-                </template>
-                <template #suffix>
-                  <span @click.stop="showPass = !showPass">
-                    <i v-if="showPass" class="iconfont icon_xianshimima"></i>
-                    <i v-else class="iconfont icon_buxianshimima"></i>
-                  </span>
-                </template>
-              </el-input> -->
-            </el-form-item>
-          </el-form>
-        </div>
-        <div class="login_info_item">
-          <div class="item_left">
-            <el-checkbox v-model="remeberPassWord">记住密码</el-checkbox>
+        <div v-if="tabActive == 1">
+          <div class="login_info_message module_tab">
+            <div class="tab_item">
+              账号登录
+            </div>
+          </div>
+          <div class="login_info_input">
+            <el-form ref="userInfoForm" @submit.prevent="SubmitLogin">
+              <el-form-item prop="userName">
+                <el-input v-model="userName" placeholder="请输入账号" clearable @keyup.enter="SubmitLogin">
+                  <template #prefix>
+                    <i class="iconfont icon_zhanghao" />
+                  </template>
+                </el-input>
+              </el-form-item>
+              <el-form-item prop="passWord">
+                <el-input v-model="passWord" type="password" show-password placeholder="请输入密码" @keyup.enter="SubmitLogin">
+                  <template #prefix>
+                    <i class="iconfont icon_mima" />
+                  </template>
+                </el-input>
+                <!-- <el-input v-model="passWord" placeholder="请输入密码" :type="!showPass ? 'password' : 'text'">
+                  <template #prefix>
+                    <i class="iconfont icon_mima" />
+                  </template>
+                  <template #suffix>
+                    <span @click.stop="showPass = !showPass">
+                      <i v-if="showPass" class="iconfont icon_xianshimima"></i>
+                      <i v-else class="iconfont icon_buxianshimima"></i>
+                    </span>
+                  </template>
+                </el-input> -->
+              </el-form-item>
+            </el-form>
+          </div>
+          <div class="login_info_item">
+            <div class="item_left">
+              <el-checkbox v-model="remeberPassWord">记住密码</el-checkbox>
+            </div>
+            <div class="item_right">忘记密码</div>
+          </div>
+          <div class="login_info_button">
+            <el-button  style="width:100%;" @click="SubmitLogin()" :loading="loadingLogin">登 录</el-button>
           </div>
           </div>
-          <div class="item_right">忘记密码</div>
         </div>
         </div>
-        <div class="login_info_button">
-          <el-button  style="width:100%;" @click="SubmitLogin()" :loading="loadingLogin">登 录</el-button>
+        <div v-if="tabActive == 6">
+          <div class="login_info_message module_tab">
+            <div class="tab_item">
+              手机验证
+            </div>
+          </div>
+          <div class="login_info_input">
+            <el-form>
+              <el-form-item prop="userName">
+                <el-input v-model="sliderPhone" readonly placeholder="请输入手机号" maxlength="11">
+                  <template #prefix>
+                    <i class="iconfont icon_shoujihao" />
+                  </template>
+                </el-input>
+              </el-form-item>
+              <el-form-item prop="passWord">
+                <el-input v-model="sliderCode" clearable placeholder="请输入验证码" maxlength="6"
+                  @input="HandleSliderCodeInput">
+                  <template #prefix>
+                    <i class="iconfont icon_yanzhengma" />
+                  </template>
+                  <template #suffix>
+                    <span :class="isCount ? 'code_gray' : 'get_code'" @click="GetBindPhoneCode">{{ codeText }}</span>
+                  </template>
+                </el-input>
+
+              </el-form-item>
+            </el-form>
+          </div>
+          <div class="login_info_item">
+            <div>
+
+              验证码5分钟内有效,请注意查收短信。
+            </div>
+          </div>
+          <div class="login_info_button">
+            <el-button style="width:100%;" @click="SubmitCodeLogin()" :loading="loadingBind">登 录</el-button>
+          </div>
         </div>
         </div>
+       
         <div class="login_other_type">
         <div class="login_other_type">
           <div class="other_login_line">
           <div class="other_login_line">
             <span class="line"></span>
             <span class="line"></span>
@@ -77,165 +124,306 @@
         </div>
         </div>
       </div>
       </div>
     </div>
     </div>
-    
+    <SlideCode v-model="showSlideCode" title="安全验证" :sliderUUID="sliderUUID" :sliderBgUrl="sliderBgUrl"
+      :sliderPositionY="sliderPositionY" :sliderImgUrl="sliderImgUrl" @close="CloseSlideCode"
+      @refresh="ObtainSliderImg" />
   </div>
   </div>
 </template>
 </template>
-<script setup>
-import { ref, onMounted } from 'vue'
-// import particles from 'particles.js'
-import { login, getUserInfo } from '@/api/login'
-import { ElMessage } from 'element-plus' // 
+<script setup lang="ts">
+import { ref, onMounted, onBeforeUnmount } from 'vue'
+import { loginSignIn, getUserInfo, bindDeviceCode, getSlider,loginInByCode } from '@/api/login'
+import { getStableDeviceFingerprint } from '@/utils/deviceFingerprint'
+import SlideCode from '@/components/SlideCode.vue' // 滑动验证码组件
+import { ElMessage, ElMessageBox } from 'element-plus'
 import { useRouter } from 'vue-router'
 import { useRouter } from 'vue-router'
 
 
+/** 菜单项类型 */
+interface MenuItem {
+  route: string
+  [key: string]: unknown
+}
 
 
-const router = useRouter() // 获取路由实例
-// 响应式数据
-const userName = ref('')
-const passWord = ref('')
-const remeberPassWord = ref(false)
-const loadingLogin = ref(false)
-const showPass = ref(false)
-// 粒子动画初始化
-const InitParticles = () => {
-  const particlesConfig = {
-    particles: {
-      number: {
-        value: 60,
-        density: {
-          enable: true,
-          value_area: 800
-        }
-      },
-      color: {
-        value: '#ffffff'
-      },
-      shape: {
-        type: ['circle'],
-        stroke: {
-          width: 0,
-          color: '#000000'
-        },
-        polygon: {
-          nb_sides: 5
-        },
-        image: {
-          src: 'img/github.svg',
-          width: 100,
-          height: 100
-        }
-      },
-      opacity: {
-        value: 0.5,
-        random: false,
-        anim: {
-          enable: false,
-          speed: 1,
-          opacity_min: 0.1,
-          sync: false
-        }
-      },
-      size: {
-        value: 3,
-        random: false,
-        anim: {
-          enable: true,
-          speed: 40,
-          size_min: 0.1,
-          sync: false
-        }
-      },
-      line_linked: {
-        enable: true,
-        distance: 150,
-        color: '#ffffff',
-        opacity: 0.4,
-        width: 1
-      },
-      move: {
-        enable: true,
-        speed: 6,
-        direction: 'none',
-        random: false,
-        straight: false,
-        out_mode: 'out',
-        bounce: false,
-        attract: {
-          enable: false,
-          rotateX: 600,
-          rotateY: 1200
-        }
-      }
-    },
-    interactivity: {
-      detect_on: 'canvas',
-      events: {
-        onhover: {
-          enable: true,
-          mode: 'grab'
-        },
-        onclick: {
-          enable: true,
-          mode: 'push'
-        },
-        resize: true
-      },
-      modes: {
-        grab: {
-          distance: 140,
-          line_linked: {
-            opacity: 1
-          }
-        },
-        bubble: {
-          distance: 400,
-          size: 40,
-          duration: 2,
-          opacity: 8,
-          speed: 3
-        },
-        repulse: {
-          distance: 200,
-          duration: 0.4
-        },
-        push: {
-          particles_nb: 4
-        },
-        remove: {
-          particles_nb: 2
-        }
-      }
-    },
-    retina_detect: true
-  }
+const router = useRouter()
 
 
-  particlesJS('particles', particlesConfig, () => {
-    console.log('粒子动画特效加载成功')
-  })
-}
+// 响应式数据
+const userName = ref<string>('')
+const passWord = ref<string>('')
+const remeberPassWord = ref<boolean>(false)
+const loadingLogin = ref<boolean>(false)
 
 
+const tabActive = ref<number>(1)// 登录类型 1:账号登录 6:手机验证
+/** 设备指纹(用于登录设备识别) */
+const deviceFingerprint = ref<string>('')
+const menuList = ref<MenuItem[]>([])
+const showSlideCode = ref<boolean>(false)
+const sliderUUID = ref<string>('')
+const sliderBgUrl = ref<string>('')
+const sliderPositionY = ref<number>(0)
+const sliderImgUrl = ref<string>('')
+/** 滑动验证状态:0=未开始,1=验证成功,-1=验证失败 */
+const sliderVerified = ref<number>(0)
+/** 滑块验证成功后后端返回的手机号 */
+const sliderPhone = ref<string>('')
+const sliderCode = ref<string>('')
+const loadingBind = ref<boolean>(false)
+const isCount = ref<boolean>(false) // 验证码倒计时状态
+const codeText = ref<string>('获取验证码') // 验证码倒计时文本
+// 页面加载时预获取设备指纹 + 恢复记住的密码
 onMounted(() => {
 onMounted(() => {
-//   InitParticles()
+  // 预加载设备指纹(非阻塞,登录时若未完成会再次等待)
+  getStableDeviceFingerprint()
+    .then((fp) => {
+      deviceFingerprint.value = fp
+      console.log('📱 当前设备指纹:', fp)
+    })
+    .catch((error) => {
+      console.error('获取设备指纹失败:', error)
+    })
+
+  // 恢复记住的密码
   const storedUsername = localStorage.getItem('userName')
   const storedUsername = localStorage.getItem('userName')
   const storedPassword = localStorage.getItem('passWord')
   const storedPassword = localStorage.getItem('passWord')
   const storedRememberMe = localStorage.getItem('rememberMe')
   const storedRememberMe = localStorage.getItem('rememberMe')
 
 
   if (storedRememberMe === 'true') {
   if (storedRememberMe === 'true') {
-    userName.value = storedUsername
-    passWord.value = storedPassword
+    userName.value = storedUsername || ''
+    passWord.value = storedPassword || ''
     remeberPassWord.value = true
     remeberPassWord.value = true
   }
   }
 })
 })
 
 
-// 清空输入框
-const ClearInput = () => {
-  userName.value = ''
+
+/**
+ * 关闭滑动验证码弹窗(SlideCode @close 事件)
+ * @param verified    验证状态:1=成功,-1=失败,0=未开始
+ * @param phoneNumber 验证成功后后端返回的手机号
+ */
+const CloseSlideCode = (verified: number, phoneNumber: string): void => {
+  console.log('关闭滑动验证码弹窗', verified)
+  console.log('验证状态', verified)
+  sliderVerified.value = verified
+  console.log('验证手机号', phoneNumber)
+  sliderPhone.value = phoneNumber || ''
+
+  if (verified === 1) {
+    // 验证成功
+    // TODO: 旧项目此处根据 tabActive 区分「账号登录 / 手机号验证 / 绑定手机」并调用 StartCountdown() 启动短信倒计时。
+    //       当前登录页为纯账号密码登录,暂无多标签与短信验证码 UI,
+    //       待补充对应流程后,在此处接入验证成功后的登录 / 发短信逻辑。
+    console.log('验证成功,手机号为', phoneNumber)
+    tabActive.value = 6 // 切换到手机号验证标签
+    StartCountdown()
+  }
+
+  showSlideCode.value = false
+}
+
+/** 倒计时定时器句柄 */
+let countdownTimer: ReturnType<typeof setInterval> | null = null
+/** 倒计时剩余秒数 */
+let count = 300
+
+/** 启动全局倒计时定时器 */
+const StartCountdown = (): void => {
+  // 先清除已存在的定时器
+  if (countdownTimer) {
+    clearInterval(countdownTimer)
+    countdownTimer = null
+  }
+
+  isCount.value = true
+  count = 300
+  codeText.value = '300 秒'
+  countdownTimer = setInterval(() => {
+    count--
+    codeText.value = `${count} 秒`
+    if (count <= 0) {
+      StopCountdown()
+    }
+  }, 1000)
+}
+
+/** 停止全局倒计时定时器 */
+const StopCountdown = (): void => {
+  if (countdownTimer) {
+    clearInterval(countdownTimer)
+    countdownTimer = null
+  }
+  isCount.value = false
+  count = 300
+  codeText.value = '重新获取'
+}
+
+// 组件卸载时清理倒计时定时器,避免泄漏
+onBeforeUnmount(() => {
+  StopCountdown()
+})
+
+/** 重新获取滑动验证码(SlideCode @refresh 事件) */
+const ObtainSliderImg = (): void => {
+  const params = {
+    sliderUUID: sliderUUID.value, // 图片唯一凭证
+  }
+  getSlider(params).then((res: any) => {
+    console.log('重新获取滑动验证码结果', res)
+    if (res.code === 200) {
+      sliderUUID.value = res.data.sliderUUID
+      sliderBgUrl.value = res.data.backgroundBase64
+      sliderImgUrl.value = res.data.blockBase64
+      sliderPositionY.value = res.data.y // 验证码的 y 坐标
+    } else {
+      ElMessage.error(res.msg)
+    }
+  }).catch((err: unknown) => {
+    // 非 200 由拦截器统一弹错并 reject,此处仅兜底避免未处理的 rejection
+    console.error('重新获取滑动验证码异常:', err)
+  })
+}
+
+/** 验证码输入回调(TODO: 如需输满 6 位自动提交可在此处理) */
+const HandleSliderCodeInput = (): void => {
+  // TODO: 预留,验证码输入变化回调
+}
+
+/** 获取手机验证码(TODO: 接入发送短信验证码接口并启动倒计时) */
+const GetBindPhoneCode = (): void => {
+  // TODO: 调用发送验证码接口,成功后启动倒计时并更新 codeText / isCount
+  console.log('TODO: 获取手机验证码')
+}
+
+/**
+ * 密码加密(TODO: 内容待补充,接入旧项目加密逻辑)
+ * @param text 明文密码
+ * @returns 密文(当前原样返回明文,仅占位)
+ */
+const encrypt = (text: string): string => {
+  // TODO: 补充加密实现
+  return text
+}
+
+/** 保存设备指纹到信任设备(TODO: 内容待补充,可调用 bindDeviceCode) */
+const SaveUserDeviceFinge = (): void => {
+  // TODO: 补充信任设备指纹保存逻辑
+  let params={
+    username: userName.value,// 用户名
+    deviceFinger: deviceFingerprint.value,// 设备指纹
+  };
+  bindDeviceCode(params).then((res: any) => {
+    console.log('绑定设备指纹结果', res)
+  }).catch(
+    (err: unknown) => {
+      console.error('绑定设备指纹失败:', err)
+    },
+  )
 }
 }
- const menuList = ref([])
-// 登录提交
-const SubmitLogin = () => {
-  if(!userName.value || !passWord.value) {
+
+/**
+ * 登录成功后的统一处理(各登录方式成功后统一调用)
+ * 流程:存 token/菜单/权限 → (可选)信任设备确认 → 获取用户信息 → 跳转主页
+ * @param data           登录接口返回的数据
+ * @param askTrustDevice 是否提示加入信任设备(仅手机号验证码登录传 true)
+ */
+const HandleSuccessfulLogin = async (data: any, askTrustDevice = false): Promise<void> => {
+  // 1. 保存 token、菜单、权限
+  localStorage.setItem('token', data.tokenValue)
+  menuList.value = data.pcMenuVOS
+  localStorage.setItem('menuList', JSON.stringify(data.pcMenuVOS))
+  localStorage.setItem('permissions', JSON.stringify(data.permissions))
+
+  // 2. 仅手机号验证码登录成功后,弹窗提示是否加入30天免验证登录信任设备
+  //    确定则保存设备指纹,取消则跳过;两者都不阻塞后续登录流程
+  if (askTrustDevice) {
+    await ElMessageBox.confirm('是否加入30天免验证登录信任设备?', '提示', {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'warning',
+    }).then(() => {
+      // 确定按钮点击事件
+      SaveUserDeviceFinge() // 保存设备指纹
+    }).catch(() => {
+      // 取消按钮点击事件:不加入信任设备
+    })
+  }
+
+  // 3. 获取用户信息
+  const userInfoRes: any = await getUserInfo()
+  console.log('获取用户信息返回', userInfoRes)
+  if (userInfoRes && userInfoRes.code === 200) {
+    localStorage.setItem('userInfo', JSON.stringify(userInfoRes.data))
+    ElMessage.success('登录成功')
+    // 4. 跳转到主页
+    const firstRoute = menuList.value[0]?.route
+    if (firstRoute) {
+      router.push(firstRoute)
+    } else {
+      ElMessage.error('菜单为空,无法跳转主页')
+    }
+  }
+}
+
+/** 验证码登录提交 */
+const SubmitCodeLogin = (): void => {
+  if (!isCount.value) {
+    // 倒计时未开启
+    if (codeText.value === '获取验证码') {
+      ElMessage.error('请先获取验证码!')
+      return
+    }
+    if (showSlideCode.value) {
+      ElMessage.error('请先验证滑块位置')
+      return
+    }
+  }
+  if (sliderCode.value === '') {
+    ElMessage.error('请输入验证码')
     return
     return
   }
   }
+
+  const param = {
+    phoneCode: sliderCode.value.trim(), // 手机验证码
+    sliderUUID: sliderUUID.value, // 验证码唯一id
+    deviceType: 'PC',
+  }
+
+  loadingBind.value = true
+  loginInByCode(param).then((res: any) => {
+    console.log('手机验证码登录返回结果', res)
+    loadingBind.value = false
+    if (res.code === 200) {
+      // 清空验证码并走登录成功统一处理(验证码登录需提示加入信任设备)
+      sliderCode.value = ''
+      HandleSuccessfulLogin(res.data, true).catch((err: unknown) => {
+        console.error('处理登录成功失败:', err)
+      })
+    } else {
+      // 登录失败
+      ElMessage.error(res.msg)
+    }
+  }).catch((err: unknown) => {
+    loadingBind.value = false
+    console.error('验证码登录请求异常:', err)
+  })
+}
+
+/**
+ * 登录提交
+ * 流程:获取设备指纹 → 携带指纹登录 → 绑定设备 → 获取用户信息 → 跳转
+ */
+const SubmitLogin = async (): Promise<void> => {
+  if (!userName.value || !passWord.value) {
+    return
+  }
+
+  // 确保设备指纹已获取(若 onMounted 预加载未完成则在此等待)
+  if (!deviceFingerprint.value) {
+    try {
+      deviceFingerprint.value = await getStableDeviceFingerprint()
+    } catch (error) {
+      console.error('登录时获取设备指纹失败:', error)
+    }
+  }
+
+  // 记住密码处理
   if (remeberPassWord.value) {
   if (remeberPassWord.value) {
     localStorage.setItem('userName', userName.value)
     localStorage.setItem('userName', userName.value)
     localStorage.setItem('passWord', passWord.value)
     localStorage.setItem('passWord', passWord.value)
@@ -245,40 +433,44 @@ const SubmitLogin = () => {
     localStorage.removeItem('passWord')
     localStorage.removeItem('passWord')
     localStorage.setItem('rememberMe', 'false')
     localStorage.setItem('rememberMe', 'false')
   }
   }
+
   const params = {
   const params = {
-    username: userName.value.trim(),
-    password: passWord.value.trim()
+    username: userName.value.trim(), // 账号
+    password: passWord.value.trim(), // 密码
+    deviceFinger: deviceFingerprint.value, // 设备指纹
   }
   }
+
   loadingLogin.value = true
   loadingLogin.value = true
-  login(params).then((res) => {
-      loadingLogin.value = false
-      if (res.code === 200) {
-        console.log("登录成功返回",res)
-        localStorage.setItem('token', res.data.tokenValue)
-        menuList.value = res.data.pcMenuVOS
-        localStorage.setItem('menuList', JSON.stringify(res.data.pcMenuVOS))
-        localStorage.setItem('permissions', JSON.stringify(res.data.permissions))
-        // 登录成功后调用获取用户信息接口
-        return getUserInfo(res.data.token)
+  try {
+    // 1. 登录(携带设备指纹)
+    const res: any = await loginSignIn(params)
+    loadingLogin.value = false
+
+    if (res.code === 200) {
+      console.log('登录成功返回', res)
+
+      //先判断是否有token  有token 直接登录成功
+      if (res.data.tokenValue) {
+        // 登录成功后的统一处理(账号密码登录不提示信任设备)
+        await HandleSuccessfulLogin(res.data)
+      } else {
+        // 无 token:需先通过滑块验证,弹出滑动验证码
+        showSlideCode.value = true
+        sliderUUID.value = res.data.sliderUUID
+        sliderBgUrl.value = res.data.backgroundBase64
+        sliderImgUrl.value = res.data.blockBase64
+        sliderPositionY.value = res.data.y // 验证码的 y 坐标
       }
       }
-  }).then((userInfoRes)=>{
-    console.log("获取用户信息返回",userInfoRes)
-    if (userInfoRes && userInfoRes.code === 200) {
-      // 存储用户信息到 localStorage 或 Pinia
-      localStorage.setItem('userInfo', JSON.stringify(userInfoRes.data))
-      ElMessage.success('登录成功');
-      // 跳转到主页
-      router.push(menuList.value[0].route)
     }
     }
-  }).catch((err) => {
-      loadingLogin.value = false
-
-      console.error(err)
-  })
+    // 非 200 由 request.ts 拦截器统一弹错并 reject,由下方 catch 处理
+  } catch (err) {
+    loadingLogin.value = false
+    console.error(err)
+  }
 }
 }
 
 
-// 去往官网
-const GoWebsite = () => {
+/** 去往官网 */
+const GoWebsite = (): void => {
   window.open('https://www.k12100.com/website')
   window.open('https://www.k12100.com/website')
 }
 }
 </script>
 </script>

+ 1 - 1
vite.config.ts

@@ -33,7 +33,7 @@ export default defineConfig({
       // },
       // },
       '/adminApi':{
       '/adminApi':{
         target:'https://admin.k12100.net/admin/adminApi',//测试
         target:'https://admin.k12100.net/admin/adminApi',//测试
-        // target:'https://admin.k12100.com/admin/adminApi',//线上
+        // target:'https://gthjy.k12100.net/admin/adminApi',//线上
         // target:'http://192.168.1.61:47002/adminApi',
         // target:'http://192.168.1.61:47002/adminApi',
         changeOrigin:true,
         changeOrigin:true,
         rewrite:path => path.replace(/^\/adminApi/, '')
         rewrite:path => path.replace(/^\/adminApi/, '')