|
|
@@ -31,10 +31,37 @@
|
|
|
<div class="form-container">
|
|
|
<div class="form-header">
|
|
|
<h2>欢迎登录</h2>
|
|
|
- <p>请输入您的账号信息</p>
|
|
|
+ <p>微信扫一扫,授权手机号后自动登录</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="qr-login">
|
|
|
+ <div class="qr-frame">
|
|
|
+ <img v-if="qrImage" class="qr-image" :src="qrImage" alt="登录二维码" />
|
|
|
+ <div v-else class="qr-placeholder">{{ qrStatus === 'creating' ? '生成中…' : '暂无二维码' }}</div>
|
|
|
+ </div>
|
|
|
+ <p class="qr-status" :class="{ error: qrStatus === 'failed' || qrStatus === 'expired' }">
|
|
|
+ {{ qrStatusText }}
|
|
|
+ </p>
|
|
|
+ <el-button :loading="qrLoading" @click="refreshQr">
|
|
|
+ <el-icon><Refresh /></el-icon>
|
|
|
+ 刷新二维码
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="password-toggle" @click="showPasswordLogin = !showPasswordLogin">
|
|
|
+ <span class="divider-line"></span>
|
|
|
+ <button type="button" class="toggle-btn">
|
|
|
+ <el-icon>
|
|
|
+ <ArrowDown v-if="!showPasswordLogin" />
|
|
|
+ <ArrowUp v-else />
|
|
|
+ </el-icon>
|
|
|
+ 或使用账号密码登录
|
|
|
+ </button>
|
|
|
+ <span class="divider-line"></span>
|
|
|
</div>
|
|
|
|
|
|
<el-form
|
|
|
+ v-show="showPasswordLogin"
|
|
|
ref="formRef"
|
|
|
:model="formData"
|
|
|
:rules="rules"
|
|
|
@@ -70,6 +97,7 @@
|
|
|
class="login-btn"
|
|
|
@click="handleSubmit"
|
|
|
>
|
|
|
+ <el-icon><Check /></el-icon>
|
|
|
登 录
|
|
|
</el-button>
|
|
|
</el-form-item>
|
|
|
@@ -85,12 +113,12 @@
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
import { useUserStore } from '@/store/modules/user'
|
|
|
- import { fetchLogin } from '@/api/auth'
|
|
|
+ import { fetchLogin, fetchWxQrCreate, fetchWxQrStatus } from '@/api/auth'
|
|
|
import { resetRouteInitState } from '@/router/guards/beforeEach'
|
|
|
import { resolveSafeRedirectTarget } from '@/utils/router'
|
|
|
import { ElNotification, type FormInstance, type FormRules } from 'element-plus'
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
- import { User, Lock } from '@element-plus/icons-vue'
|
|
|
+ import { User, Lock, Refresh, Check, ArrowDown, ArrowUp } from '@element-plus/icons-vue'
|
|
|
|
|
|
defineOptions({ name: 'Login' })
|
|
|
|
|
|
@@ -101,6 +129,7 @@
|
|
|
const route = useRoute()
|
|
|
|
|
|
const formRef = ref<FormInstance>()
|
|
|
+ const showPasswordLogin = ref(false)
|
|
|
|
|
|
const formData = reactive({
|
|
|
username: '',
|
|
|
@@ -113,6 +142,144 @@
|
|
|
}
|
|
|
|
|
|
const loading = ref(false)
|
|
|
+ const qrLoading = ref(false)
|
|
|
+ const qrImage = ref('')
|
|
|
+ const sessionId = ref('')
|
|
|
+ const qrStatus = ref<'creating' | 'pending' | 'scanned' | 'failed' | 'expired' | 'success'>(
|
|
|
+ 'creating'
|
|
|
+ )
|
|
|
+ const qrMessage = ref('')
|
|
|
+ let pollTimer: ReturnType<typeof setInterval> | null = null
|
|
|
+ let loginHandled = false
|
|
|
+
|
|
|
+ const qrStatusText = computed(() => {
|
|
|
+ switch (qrStatus.value) {
|
|
|
+ case 'creating':
|
|
|
+ return '正在生成二维码…'
|
|
|
+ case 'pending':
|
|
|
+ return '请使用微信扫一扫'
|
|
|
+ case 'scanned':
|
|
|
+ return '已扫码,请在手机上确认授权'
|
|
|
+ case 'failed':
|
|
|
+ return qrMessage.value || '扫码登录失败,请刷新后重试'
|
|
|
+ case 'expired':
|
|
|
+ return qrMessage.value || '二维码已过期,请刷新后重扫'
|
|
|
+ case 'success':
|
|
|
+ return '登录成功,正在跳转…'
|
|
|
+ default:
|
|
|
+ return ''
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ const stopPoll = () => {
|
|
|
+ if (pollTimer) {
|
|
|
+ clearInterval(pollTimer)
|
|
|
+ pollTimer = null
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const finishLogin = async (payload: {
|
|
|
+ token: string
|
|
|
+ refreshToken?: string
|
|
|
+ userInfo: {
|
|
|
+ id: string
|
|
|
+ username: string
|
|
|
+ realName: string
|
|
|
+ phone: string
|
|
|
+ role: string
|
|
|
+ roleNames?: string[]
|
|
|
+ regionId: string
|
|
|
+ regionName: string
|
|
|
+ avatarUrl?: string | null
|
|
|
+ }
|
|
|
+ }) => {
|
|
|
+ userStore.setToken(payload.token, payload.refreshToken)
|
|
|
+ userStore.setLoginStatus(true)
|
|
|
+ userStore.setUserInfo(payload.userInfo)
|
|
|
+
|
|
|
+ ElNotification({
|
|
|
+ title: '登录成功',
|
|
|
+ type: 'success',
|
|
|
+ duration: 2500,
|
|
|
+ message: `欢迎回来,${payload.userInfo.realName || payload.userInfo.username}!`
|
|
|
+ })
|
|
|
+
|
|
|
+ resetRouteInitState()
|
|
|
+ const target = resolveSafeRedirectTarget(route.query.redirect)
|
|
|
+ await router.push(target)
|
|
|
+ }
|
|
|
+
|
|
|
+ const pollStatus = async () => {
|
|
|
+ if (!sessionId.value || loginHandled) return
|
|
|
+
|
|
|
+ try {
|
|
|
+ const data = await fetchWxQrStatus(sessionId.value)
|
|
|
+ const status = (data?.status || '') as typeof qrStatus.value
|
|
|
+ if (!status) return
|
|
|
+
|
|
|
+ if (status === 'pending' || status === 'scanned') {
|
|
|
+ qrStatus.value = status
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (status === 'success') {
|
|
|
+ stopPoll()
|
|
|
+ if (loginHandled) return
|
|
|
+ if (!data.token || !data.userInfo) {
|
|
|
+ qrStatus.value = 'failed'
|
|
|
+ qrMessage.value = '登录失败:未收到令牌'
|
|
|
+ return
|
|
|
+ }
|
|
|
+ loginHandled = true
|
|
|
+ qrStatus.value = 'success'
|
|
|
+ await finishLogin({
|
|
|
+ token: data.token,
|
|
|
+ refreshToken: data.refreshToken,
|
|
|
+ userInfo: data.userInfo
|
|
|
+ })
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (status === 'failed' || status === 'expired') {
|
|
|
+ stopPoll()
|
|
|
+ qrStatus.value = status
|
|
|
+ qrMessage.value = data.message || ''
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ // 轮询失败不打断;保留上次状态
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const startPoll = () => {
|
|
|
+ stopPoll()
|
|
|
+ pollTimer = setInterval(() => {
|
|
|
+ void pollStatus()
|
|
|
+ }, 1500)
|
|
|
+ }
|
|
|
+
|
|
|
+ const refreshQr = async () => {
|
|
|
+ stopPoll()
|
|
|
+ loginHandled = false
|
|
|
+ qrLoading.value = true
|
|
|
+ qrStatus.value = 'creating'
|
|
|
+ qrMessage.value = ''
|
|
|
+ qrImage.value = ''
|
|
|
+ sessionId.value = ''
|
|
|
+
|
|
|
+ try {
|
|
|
+ const data = await fetchWxQrCreate()
|
|
|
+ sessionId.value = data.sessionId
|
|
|
+ qrImage.value = data.qrImageBase64
|
|
|
+ qrStatus.value = 'pending'
|
|
|
+ startPoll()
|
|
|
+ } catch (error: any) {
|
|
|
+ qrStatus.value = 'failed'
|
|
|
+ qrMessage.value = error?.message || '二维码生成失败,请稍后重试'
|
|
|
+ ElMessage.error(qrMessage.value)
|
|
|
+ } finally {
|
|
|
+ qrLoading.value = false
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
// 登录
|
|
|
const handleSubmit = async () => {
|
|
|
@@ -133,28 +300,22 @@
|
|
|
throw new Error('登录失败:未收到令牌')
|
|
|
}
|
|
|
|
|
|
- // 存储 token 和登录状态
|
|
|
- userStore.setToken(token, refreshToken)
|
|
|
- userStore.setLoginStatus(true)
|
|
|
- userStore.setUserInfo(userInfo)
|
|
|
-
|
|
|
- ElNotification({
|
|
|
- title: '登录成功',
|
|
|
- type: 'success',
|
|
|
- duration: 2500,
|
|
|
- message: `欢迎回来,${userInfo.realName || userInfo.username}!`
|
|
|
- })
|
|
|
-
|
|
|
- // 避免 redirect 指回登录页(如登出时误写入 /auth/login)
|
|
|
- resetRouteInitState()
|
|
|
- const target = resolveSafeRedirectTarget(route.query.redirect)
|
|
|
- await router.push(target)
|
|
|
+ stopPoll()
|
|
|
+ await finishLogin({ token, refreshToken, userInfo })
|
|
|
} catch (error: any) {
|
|
|
ElMessage.error(error?.message || '登录失败,请稍后重试')
|
|
|
} finally {
|
|
|
loading.value = false
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ onMounted(() => {
|
|
|
+ void refreshQr()
|
|
|
+ })
|
|
|
+
|
|
|
+ onUnmounted(() => {
|
|
|
+ stopPoll()
|
|
|
+ })
|
|
|
</script>
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
@@ -167,7 +328,7 @@
|
|
|
|
|
|
.login-left {
|
|
|
flex: 1;
|
|
|
- background: linear-gradient(135deg, #047857 0%, #065f46 100%);
|
|
|
+ background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
|
@@ -254,7 +415,7 @@
|
|
|
}
|
|
|
|
|
|
.form-header {
|
|
|
- margin-bottom: 40px;
|
|
|
+ margin-bottom: 28px;
|
|
|
text-align: center;
|
|
|
|
|
|
h2 {
|
|
|
@@ -271,7 +432,83 @@
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ .qr-login {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .qr-frame {
|
|
|
+ width: 200px;
|
|
|
+ height: 200px;
|
|
|
+ border: 1px solid #e5e7eb;
|
|
|
+ border-radius: 10px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ overflow: hidden;
|
|
|
+ background: #fff;
|
|
|
+ }
|
|
|
+
|
|
|
+ .qr-image {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ object-fit: contain;
|
|
|
+ }
|
|
|
+
|
|
|
+ .qr-placeholder {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #94a3b8;
|
|
|
+ }
|
|
|
+
|
|
|
+ .qr-status {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #64748b;
|
|
|
+ text-align: center;
|
|
|
+ min-height: 20px;
|
|
|
+
|
|
|
+ &.error {
|
|
|
+ color: #dc2626;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .password-toggle {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ margin: 24px 0 8px;
|
|
|
+ cursor: pointer;
|
|
|
+ }
|
|
|
+
|
|
|
+ .divider-line {
|
|
|
+ flex: 1;
|
|
|
+ height: 1px;
|
|
|
+ background: #e5e7eb;
|
|
|
+ }
|
|
|
+
|
|
|
+ .toggle-btn {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+ border: none;
|
|
|
+ background: transparent;
|
|
|
+ color: #64748b;
|
|
|
+ font-size: 13px;
|
|
|
+ cursor: pointer;
|
|
|
+ padding: 0;
|
|
|
+ white-space: nowrap;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ color: #10b981;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
.login-form {
|
|
|
+ margin-top: 16px;
|
|
|
+
|
|
|
:deep(.el-form-item) {
|
|
|
margin-bottom: 24px;
|
|
|
}
|
|
|
@@ -291,7 +528,7 @@
|
|
|
height: 48px;
|
|
|
font-size: 16px;
|
|
|
border-radius: 8px;
|
|
|
- background: linear-gradient(135deg, #047857 0%, #065f46 100%);
|
|
|
+ background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
|
|
border: none;
|
|
|
|
|
|
&:hover {
|