Browse Source

手机版登录、修改密码、快捷导航

liuq 2 months ago
parent
commit
b942f5234b

+ 68 - 5
frontend/src/router/index.ts

@@ -30,6 +30,22 @@ const routes: Array<RouteRecordRaw> = [
     name: 'Consent',
     component: () => import('../views/Consent.vue')
   },
+  {
+    path: '/mobile/login',
+    name: 'MobileLogin',
+    component: () => import('../views/mobile/MobileLogin.vue')
+  },
+  {
+    path: '/mobile/reset-password',
+    name: 'MobileResetPassword',
+    component: () => import('../views/mobile/MobileResetPassword.vue')
+  },
+  {
+    path: '/mobile/dashboard',
+    name: 'MobileDashboard',
+    component: () => import('../views/mobile/MobileDashboard.vue'),
+    meta: { requiresAuth: true }
+  },
   {
     path: '/dashboard',
     component: () => import('../views/Dashboard.vue'),
@@ -129,21 +145,68 @@ const router = createRouter({
 router.beforeEach((to, from, next) => {
   const token = localStorage.getItem('token')
 
+  // Detect Mobile Device
+  const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
+  const isMobilePath = to.path.startsWith('/mobile')
+
+  // 1. Mobile Device accessing PC path -> Redirect to Mobile
+  if (isMobile && !isMobilePath) {
+    if (to.path === '/' || to.path === '/login') {
+      next('/mobile/login')
+      return
+    }
+    if (to.path === '/reset-password') {
+      next('/mobile/reset-password')
+      return
+    }
+    // PC Dashboard and children -> Mobile Dashboard
+    if (to.path.startsWith('/dashboard')) {
+      next('/mobile/dashboard')
+      return
+    }
+  }
+
+  // 2. PC Device accessing Mobile path -> Redirect to PC
+  if (!isMobile && isMobilePath) {
+    if (to.path === '/mobile/login') {
+      next('/login')
+      return
+    }
+    if (to.path === '/mobile/reset-password') {
+      next('/reset-password')
+      return
+    }
+    if (to.path === '/mobile/dashboard') {
+      next('/dashboard')
+      return
+    }
+  }
+
   // If user is already logged in and tries to access login page, redirect to dashboard
-  if (token && to.path === '/login') {
-    next('/dashboard')
-    return
+  if (token) {
+    if (to.path === '/login') {
+      next('/dashboard')
+      return
+    }
+    if (to.path === '/mobile/login') {
+      next('/mobile/dashboard')
+      return
+    }
   }
 
   // Public routes
-  const publicRoutes = ['/login', '/register', '/consent', '/reset-password', '/setup']
+  const publicRoutes = ['/login', '/register', '/consent', '/reset-password', '/setup', '/mobile/login', '/mobile/reset-password']
   if (publicRoutes.includes(to.path)) {
     next()
     return
   }
 
   if (to.meta.requiresAuth && !token) {
-    next('/login')
+    if (to.path.startsWith('/mobile')) {
+      next('/mobile/login')
+    } else {
+      next('/login')
+    }
   } else {
     // Check for Admin role if needed
     // Note: We need user info to check role. Since fetchUser is async,

+ 88 - 0
frontend/src/views/mobile/MobileDashboard.vue

@@ -0,0 +1,88 @@
+<template>
+  <div class="mobile-dashboard">
+    <div class="mobile-header">
+      <div class="brand">
+        <img src="/logo.png" alt="Logo" class="logo-img" />
+        <span class="logo-text">统一登录平台</span>
+      </div>
+      <el-button type="danger" link @click="handleLogout">
+        退出
+      </el-button>
+    </div>
+
+    <div class="mobile-content">
+      <PlatformLaunchpad />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useRouter } from 'vue-router'
+import { useAuthStore } from '../../store/auth'
+import PlatformLaunchpad from '../PlatformLaunchpad.vue'
+
+const router = useRouter()
+const authStore = useAuthStore()
+
+const handleLogout = () => {
+  authStore.logout()
+  router.push('/mobile/login')
+}
+</script>
+
+<style scoped>
+.mobile-dashboard {
+  height: 100vh;
+  display: flex;
+  flex-direction: column;
+  background-color: #f0f2f5;
+}
+
+.mobile-header {
+  height: 50px;
+  background-color: #fff;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 0 15px;
+  box-shadow: 0 1px 4px rgba(0,0,0,0.1);
+  z-index: 10;
+}
+
+.brand {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.logo-img {
+  height: 30px;
+}
+
+.logo-text {
+  font-weight: bold;
+  font-size: 16px;
+  color: #333;
+}
+
+.mobile-content {
+  flex: 1;
+  overflow-y: auto;
+  /* PlatformLaunchpad has its own padding, but we might want to adjust */
+}
+
+/* Adjust PlatformLaunchpad styles for mobile if needed */
+:deep(.launchpad-container) {
+  padding: 15px;
+}
+
+:deep(.header) {
+  margin-bottom: 20px;
+}
+
+:deep(.apps-grid) {
+  justify-content: center;
+  gap: 20px;
+}
+</style>
+

+ 192 - 0
frontend/src/views/mobile/MobileLogin.vue

@@ -0,0 +1,192 @@
+<template>
+  <div class="mobile-login-container">
+    <div class="login-header">
+      <img src="/logo.png" alt="Logo" class="logo-img" />
+      <h2>统一登录平台</h2>
+      <div v-if="ssoAppName" class="sso-tag">正在登录 {{ ssoAppName }}</div>
+    </div>
+    
+    <div class="login-content">
+      <el-form :model="loginForm" label-width="0px" class="login-form">
+        <el-form-item>
+          <el-input v-model="loginForm.mobile" placeholder="手机号" prefix-icon="Iphone" size="large" />
+        </el-form-item>
+        <el-form-item>
+          <el-input 
+            v-model="loginForm.password" 
+            type="password" 
+            placeholder="密码" 
+            prefix-icon="Lock" 
+            show-password
+            size="large"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-checkbox v-model="loginForm.remember_me">记住我 (30天免登录)</el-checkbox>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" :loading="loading" @click="handleLogin" style="width: 100%" size="large">
+            登录
+          </el-button>
+        </el-form-item>
+        <div class="login-links">
+          <router-link to="/mobile/reset-password">忘记密码?</router-link>
+        </div>
+      </el-form>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
+import { useAuthStore } from '../../store/auth'
+import { getAppPublicInfo, ssoLogin } from '../../api/public'
+import { ElMessage } from 'element-plus'
+
+const router = useRouter()
+const route = useRoute()
+const authStore = useAuthStore()
+
+const loginForm = reactive({
+  mobile: '',
+  password: '',
+  remember_me: true
+})
+
+const loading = ref(false)
+const ssoAppId = ref('')
+const ssoAppName = ref('')
+
+const handleLogin = async () => {
+  if (!loginForm.mobile || !loginForm.password) {
+    ElMessage.warning('请输入手机号和密码')
+    return
+  }
+  
+  loading.value = true
+  try {
+    if (ssoAppId.value) {
+      // SSO Login Flow
+      const res = await ssoLogin({
+        app_id: ssoAppId.value,
+        username: loginForm.mobile,
+        password: loginForm.password
+      })
+      if (res.data.redirect_url) {
+        ElMessage.success('登录成功,正在跳转...')
+        window.location.href = res.data.redirect_url
+      }
+    } else {
+      // Platform Login Flow
+      await authStore.login({
+        mobile: loginForm.mobile,
+        password: loginForm.password,
+        remember_me: loginForm.remember_me
+      })
+      ElMessage.success('登录成功')
+      router.push('/mobile/dashboard')
+    }
+  } catch (error) {
+    // Error handled in interceptor
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(async () => {
+  const appid = route.query.appid as string || route.query.app_id as string
+  
+  if (appid) {
+    try {
+      const appInfo = await getAppPublicInfo(appid)
+      ssoAppId.value = appid
+      ssoAppName.value = appInfo.data.app_name
+      
+      // Try Auto-Login (SSO)
+      try {
+          const res = await ssoLogin({
+            app_id: appid,
+            username: '',
+            password: ''
+          }, { skipGlobalErrorHandler: true })
+          
+          if (res.data.redirect_url) {
+            ElMessage.success(`检测到已登录,正在跳转到 ${appInfo.data.app_name}...`)
+            window.location.href = res.data.redirect_url
+            return
+          }
+      } catch (e) {
+          // Auto-login failed, user needs to login manually
+          console.log("Auto-login failed, showing login form")
+      }
+    } catch (e) {
+      ElMessage.error('无效的应用ID')
+    }
+  }
+})
+</script>
+
+<style scoped>
+.mobile-login-container {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  min-height: 100vh;
+  background-color: #fff;
+  padding: 0 30px;
+}
+
+.login-header {
+  text-align: center;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 15px;
+  margin-bottom: 40px;
+}
+
+.sso-tag {
+  color: #67C23A;
+  background-color: #f0f9eb;
+  border: 1px solid #e1f3d8;
+  padding: 6px 12px;
+  border-radius: 4px;
+  font-size: 16px;
+  margin-top: 5px;
+}
+
+.logo-img {
+  height: 80px;
+  object-fit: contain;
+}
+
+.login-content {
+  width: 100%;
+}
+
+.login-links {
+  text-align: center;
+  margin-top: 20px;
+  font-size: 14px;
+}
+
+/* Custom Large Input Styles */
+:deep(.el-input--large .el-input__wrapper) {
+  padding: 8px 15px;
+  border-radius: 8px;
+}
+
+:deep(.el-input--large .el-input__inner) {
+  font-size: 18px;
+  font-weight: 500;
+  height: 40px;
+}
+
+:deep(.el-button--large) {
+  height: 48px;
+  font-size: 18px;
+  border-radius: 8px;
+}
+</style>
+

+ 343 - 0
frontend/src/views/mobile/MobileResetPassword.vue

@@ -0,0 +1,343 @@
+<template>
+  <div class="mobile-reset-container">
+    <div class="header">
+      <h2>重置密码</h2>
+    </div>
+
+    <el-steps :active="activeStep" finish-status="success" simple style="margin-bottom: 30px; background: transparent; padding: 10px 0;">
+      <el-step title="验证手机" />
+      <el-step title="设置新密码" />
+    </el-steps>
+
+    <!-- Step 1: Send SMS -->
+    <el-form v-if="activeStep === 0" :model="form" label-width="0" size="large">
+      <el-form-item>
+        <el-input v-model="form.mobile" placeholder="手机号" prefix-icon="Iphone" />
+      </el-form-item>
+      <el-form-item>
+        <div style="display: flex; width: 100%; gap: 10px;">
+          <el-input v-model="form.captcha_code" placeholder="图形验证码" style="flex: 1" />
+          <div class="captcha-img" @click="fetchCaptcha" v-if="captchaImage" style="width: 100px;">
+            <img :src="captchaImage" alt="captcha" />
+          </div>
+        </div>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" style="width: 100%" @click="handleSendSms" :loading="loading">
+          发送验证码
+        </el-button>
+      </el-form-item>
+      <div class="links">
+        <router-link to="/mobile/login">返回登录</router-link>
+      </div>
+    </el-form>
+
+    <!-- Step 2: Reset Password -->
+    <el-form v-if="activeStep === 1" :model="form" :rules="step2Rules" ref="step2FormRef" label-width="0" size="large">
+      <el-form-item>
+         <el-input v-model="form.mobile" disabled prefix-icon="Iphone" />
+      </el-form-item>
+      <el-form-item prop="sms_code">
+        <div style="display: flex; width: 100%; gap: 10px;">
+          <el-input 
+            v-model="form.sms_code" 
+            placeholder="短信验证码" 
+            prefix-icon="Message" 
+            style="flex: 1"
+            :name="dynamicFields.sms"
+            autocomplete="off"
+          />
+          <el-button type="primary" plain :disabled="countdown > 0" @click="openCaptchaDialog">
+            {{ countdown > 0 ? `${countdown}s` : '重新发送' }}
+          </el-button>
+        </div>
+      </el-form-item>
+      <el-form-item prop="new_password">
+        <el-input 
+          v-model="form.new_password" 
+          type="password" 
+          placeholder="新密码" 
+          prefix-icon="Lock" 
+          show-password 
+          :name="dynamicFields.password"
+          autocomplete="new-password"
+        />
+      </el-form-item>
+      <el-form-item prop="confirm_password">
+        <el-input 
+          v-model="form.confirm_password" 
+          type="password" 
+          placeholder="确认新密码" 
+          prefix-icon="Lock" 
+          show-password 
+          autocomplete="new-password"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" style="width: 100%" @click="handleReset" :loading="loading">
+          确认重置
+        </el-button>
+      </el-form-item>
+       <div class="links">
+        <el-button link @click="activeStep = 0">上一步</el-button>
+      </div>
+    </el-form>
+
+    <!-- Resend Captcha Dialog -->
+    <el-dialog v-model="captchaDialogVisible" title="安全验证" width="90%" append-to-body>
+      <el-form :model="captchaForm" label-width="0">
+         <el-form-item>
+          <div style="display: flex; width: 100%; gap: 10px;">
+            <el-input v-model="captchaForm.code" placeholder="图形验证码" style="flex: 1" />
+            <div class="captcha-img" @click="fetchResendCaptcha" v-if="resendCaptchaImage">
+              <img :src="resendCaptchaImage" alt="captcha" />
+            </div>
+          </div>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <span class="dialog-footer">
+          <el-button @click="captchaDialogVisible = false">取消</el-button>
+          <el-button type="primary" @click="handleResendSmsConfirm" :loading="resendLoading">
+            确认发送
+          </el-button>
+        </span>
+      </template>
+    </el-dialog>
+
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted, onUnmounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { getCaptcha, sendSms, resetPassword } from '../../api/public'
+import { ElMessage } from 'element-plus'
+import type { FormInstance, FormRules } from 'element-plus'
+
+const router = useRouter()
+const activeStep = ref(0)
+const loading = ref(false)
+const captchaImage = ref('')
+const step2FormRef = ref<FormInstance>()
+
+// Resend Logic State
+const countdown = ref(0)
+let timer: any = null
+const captchaDialogVisible = ref(false)
+const resendCaptchaImage = ref('')
+const resendCaptchaId = ref('')
+const resendLoading = ref(false)
+const captchaForm = reactive({
+  code: ''
+})
+
+const dynamicFields = reactive({
+  sms: 'sms_code',
+  password: 'new_password'
+})
+
+const form = reactive({
+  mobile: '',
+  captcha_id: '',
+  captcha_code: '',
+  sms_code: '',
+  new_password: '',
+  confirm_password: ''
+})
+
+const validatePass2 = (rule: any, value: any, callback: any) => {
+  if (value === '') {
+    callback(new Error('请再次输入密码'))
+  } else if (value !== form.new_password) {
+    callback(new Error('两次输入密码不一致!'))
+  } else {
+    callback()
+  }
+}
+
+const step2Rules = reactive<FormRules>({
+  sms_code: [{ required: true, message: '请输入验证码', trigger: 'blur' }],
+  new_password: [
+    { required: true, message: '请输入新密码', trigger: 'blur' },
+    { min: 6, message: '密码长度不能少于6位', trigger: 'blur' },
+    { pattern: /^(?=.*[a-zA-Z])(?=.*\d).+$/, message: '密码必须包含字母和数字', trigger: 'blur' }
+  ],
+  confirm_password: [{ validator: validatePass2, trigger: 'blur' }]
+})
+
+const fetchCaptcha = async () => {
+  try {
+    const res = await getCaptcha()
+    captchaImage.value = res.data.image
+    form.captcha_id = res.data.captcha_id
+    form.captcha_code = ''
+  } catch (e) {
+    console.error(e)
+  }
+}
+
+const startCountdown = () => {
+  countdown.value = 60
+  if (timer) clearInterval(timer)
+  timer = setInterval(() => {
+    countdown.value--
+    if (countdown.value <= 0) {
+      clearInterval(timer)
+    }
+  }, 1000)
+}
+
+const openCaptchaDialog = () => {
+  captchaForm.code = ''
+  fetchResendCaptcha()
+  captchaDialogVisible.value = true
+}
+
+const fetchResendCaptcha = async () => {
+   try {
+    const res = await getCaptcha()
+    resendCaptchaImage.value = res.data.image
+    resendCaptchaId.value = res.data.captcha_id
+  } catch (e) {
+    console.error(e)
+  }
+}
+
+const handleResendSmsConfirm = async () => {
+  if (!captchaForm.code) {
+    ElMessage.warning('请输入图形验证码')
+    return
+  }
+  resendLoading.value = true
+  try {
+    await sendSms({
+      mobile: form.mobile,
+      captcha_id: resendCaptchaId.value,
+      captcha_code: captchaForm.code
+    })
+    ElMessage.success('验证码已发送')
+    captchaDialogVisible.value = false
+    startCountdown()
+  } catch (e) {
+    fetchResendCaptcha()
+  } finally {
+    resendLoading.value = false
+  }
+}
+
+const handleSendSms = async () => {
+  if (!form.mobile || !form.captcha_code) {
+    ElMessage.warning('请输入手机号和图形验证码')
+    return
+  }
+  loading.value = true
+  try {
+    await sendSms({
+      mobile: form.mobile,
+      captcha_id: form.captcha_id,
+      captcha_code: form.captcha_code
+    })
+    ElMessage.success('验证码已发送')
+    activeStep.value = 1
+    startCountdown()
+  } catch (e) {
+    fetchCaptcha() // Refresh captcha on fail
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleReset = async () => {
+  if (!step2FormRef.value) return
+  
+  await step2FormRef.value.validate(async (valid) => {
+    if (valid) {
+      loading.value = true
+      try {
+        await resetPassword({
+          mobile: form.mobile,
+          sms_code: form.sms_code,
+          new_password: form.new_password
+        })
+        ElMessage.success('密码重置成功')
+        router.push('/mobile/login')
+      } catch (e) {
+        // handled
+      } finally {
+        loading.value = false
+      }
+    }
+  })
+}
+
+onMounted(() => {
+  fetchCaptcha()
+  const suffix = Math.random().toString(36).slice(2, 8)
+  dynamicFields.sms = `sms_${suffix}`
+  dynamicFields.password = `pwd_${suffix}`
+})
+
+onUnmounted(() => {
+  if (timer) clearInterval(timer)
+})
+</script>
+
+<style scoped>
+.mobile-reset-container {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  min-height: 100vh;
+  background-color: #fff;
+  padding: 0 30px;
+}
+
+.header {
+  text-align: center;
+  margin-bottom: 20px;
+}
+
+.captcha-img {
+  cursor: pointer;
+  height: 40px;
+  display: flex;
+  align-items: center;
+  border-radius: 4px;
+  overflow: hidden;
+  border: 1px solid #dcdfe6;
+}
+.captcha-img img {
+  height: 100%;
+  width: 100%;
+  object-fit: cover;
+}
+
+.links {
+  text-align: center;
+  margin-top: 20px;
+}
+
+/* Custom Large Input Styles - Consistent with Login */
+:deep(.el-input--large .el-input__wrapper) {
+  padding: 8px 15px;
+  border-radius: 8px;
+}
+
+:deep(.el-input--large .el-input__inner) {
+  font-size: 18px;
+  font-weight: 500;
+  height: 40px;
+}
+
+:deep(.el-button--large) {
+  height: 48px;
+  font-size: 18px;
+  border-radius: 8px;
+}
+
+:deep(.el-button--default) {
+   height: 48px; 
+}
+</style>
+