|
|
@@ -1,4 +1,4 @@
|
|
|
-<!-- 登录页面(样式对齐 CRM 山峡化工登录页) -->
|
|
|
+<!-- 登录页面(样式对齐 CRM 山峡化工登录页;扫码 + 账号密码 Tab) -->
|
|
|
<template>
|
|
|
<div class="login" :class="{ 'bg-loaded': backgroundLoaded }">
|
|
|
<div v-if="!backgroundLoaded" class="bg-placeholder"></div>
|
|
|
@@ -7,61 +7,103 @@
|
|
|
<span class="loading-text">背景加载中...</span>
|
|
|
</div>
|
|
|
|
|
|
- <el-form
|
|
|
- ref="formRef"
|
|
|
- :model="formData"
|
|
|
- :rules="rules"
|
|
|
- class="login-form"
|
|
|
- @keyup.enter="handleSubmit"
|
|
|
- >
|
|
|
+ <div class="login-form">
|
|
|
<div class="title-box">
|
|
|
<img class="login-brand-logo" src="/logo.gif" alt="山峡化工" />
|
|
|
<h3 class="title">{{ pageTitle }}</h3>
|
|
|
</div>
|
|
|
|
|
|
- <el-form-item prop="username">
|
|
|
- <el-input
|
|
|
- v-model.trim="formData.username"
|
|
|
- type="text"
|
|
|
- size="large"
|
|
|
- auto-complete="off"
|
|
|
- placeholder="请输入用户名"
|
|
|
- >
|
|
|
- <template #prefix>
|
|
|
- <el-icon class="input-icon"><User /></el-icon>
|
|
|
- </template>
|
|
|
- </el-input>
|
|
|
- </el-form-item>
|
|
|
-
|
|
|
- <el-form-item prop="password">
|
|
|
- <el-input
|
|
|
- v-model.trim="formData.password"
|
|
|
- type="password"
|
|
|
- size="large"
|
|
|
- auto-complete="off"
|
|
|
- placeholder="请输入密码"
|
|
|
- show-password
|
|
|
- @keyup.enter="handleSubmit"
|
|
|
- >
|
|
|
- <template #prefix>
|
|
|
- <el-icon class="input-icon"><Lock /></el-icon>
|
|
|
- </template>
|
|
|
- </el-input>
|
|
|
- </el-form-item>
|
|
|
-
|
|
|
- <el-form-item style="width: 100%">
|
|
|
- <el-button
|
|
|
- type="primary"
|
|
|
- :loading="loading"
|
|
|
- size="large"
|
|
|
- class="login-button"
|
|
|
- @click.prevent="handleSubmit"
|
|
|
- >
|
|
|
- <span v-if="!loading">登 录</span>
|
|
|
- <span v-else>登录中...</span>
|
|
|
- </el-button>
|
|
|
- </el-form-item>
|
|
|
- </el-form>
|
|
|
+ <p class="login-subtitle">
|
|
|
+ {{
|
|
|
+ loginTab === 'wechat'
|
|
|
+ ? '微信扫一扫,授权手机号后自动登录'
|
|
|
+ : '请输入账号密码登录系统'
|
|
|
+ }}
|
|
|
+ </p>
|
|
|
+
|
|
|
+ <el-tabs
|
|
|
+ v-model="loginTab"
|
|
|
+ class="login-tabs"
|
|
|
+ stretch
|
|
|
+ @tab-change="handleLoginTabChange"
|
|
|
+ >
|
|
|
+ <el-tab-pane label="微信扫码" name="wechat">
|
|
|
+ <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>
|
|
|
+ </el-tab-pane>
|
|
|
+
|
|
|
+ <el-tab-pane label="账号密码" name="password">
|
|
|
+ <el-form
|
|
|
+ ref="formRef"
|
|
|
+ :model="formData"
|
|
|
+ :rules="rules"
|
|
|
+ @keyup.enter="handleSubmit"
|
|
|
+ >
|
|
|
+ <el-form-item prop="username">
|
|
|
+ <el-input
|
|
|
+ v-model.trim="formData.username"
|
|
|
+ type="text"
|
|
|
+ size="large"
|
|
|
+ auto-complete="off"
|
|
|
+ placeholder="请输入用户名"
|
|
|
+ >
|
|
|
+ <template #prefix>
|
|
|
+ <el-icon class="input-icon"><User /></el-icon>
|
|
|
+ </template>
|
|
|
+ </el-input>
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item prop="password">
|
|
|
+ <el-input
|
|
|
+ v-model.trim="formData.password"
|
|
|
+ type="password"
|
|
|
+ size="large"
|
|
|
+ auto-complete="off"
|
|
|
+ placeholder="请输入密码"
|
|
|
+ show-password
|
|
|
+ @keyup.enter="handleSubmit"
|
|
|
+ >
|
|
|
+ <template #prefix>
|
|
|
+ <el-icon class="input-icon"><Lock /></el-icon>
|
|
|
+ </template>
|
|
|
+ </el-input>
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item style="width: 100%">
|
|
|
+ <el-button
|
|
|
+ type="primary"
|
|
|
+ :loading="loading"
|
|
|
+ size="large"
|
|
|
+ class="login-button"
|
|
|
+ @click.prevent="handleSubmit"
|
|
|
+ >
|
|
|
+ <template v-if="!loading">
|
|
|
+ <el-icon><Check /></el-icon>
|
|
|
+ 登 录
|
|
|
+ </template>
|
|
|
+ <span v-else>登录中...</span>
|
|
|
+ </el-button>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ </el-tab-pane>
|
|
|
+ </el-tabs>
|
|
|
+ </div>
|
|
|
|
|
|
<div class="el-login-footer">
|
|
|
<span>Copyright © 2025-{{ currentYear }} 山峡化工有限公司 All Rights Reserved.</span>
|
|
|
@@ -72,12 +114,12 @@
|
|
|
<script setup lang="ts">
|
|
|
import AppConfig from '@/config'
|
|
|
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 { ElNotification, type FormInstance, type FormRules, type TabPaneName } from 'element-plus'
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
- import { User, Lock } from '@element-plus/icons-vue'
|
|
|
+ import { User, Lock, Refresh, Check } from '@element-plus/icons-vue'
|
|
|
|
|
|
defineOptions({ name: 'Login' })
|
|
|
|
|
|
@@ -89,6 +131,7 @@
|
|
|
const route = useRoute()
|
|
|
|
|
|
const formRef = ref<FormInstance>()
|
|
|
+ const loginTab = ref<'wechat' | 'password'>('wechat')
|
|
|
|
|
|
const formData = reactive({
|
|
|
username: '',
|
|
|
@@ -104,6 +147,35 @@
|
|
|
const backgroundLoaded = ref(false)
|
|
|
const loadingProgress = ref(0)
|
|
|
|
|
|
+ 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 preloadBackgroundImage = () => {
|
|
|
const img = new Image()
|
|
|
const backgroundImageUrl = new URL('@/assets/images/login/sx1.jpg', import.meta.url).href
|
|
|
@@ -130,6 +202,130 @@
|
|
|
img.src = backgroundImageUrl
|
|
|
}
|
|
|
|
|
|
+ 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
|
|
|
+ roles?: 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 handleLoginTabChange = (name: TabPaneName) => {
|
|
|
+ if (name === 'password') {
|
|
|
+ stopPoll()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (name === 'wechat') {
|
|
|
+ if (!sessionId.value || qrStatus.value === 'failed' || qrStatus.value === 'expired') {
|
|
|
+ void refreshQr()
|
|
|
+ } else if (qrStatus.value === 'pending' || qrStatus.value === 'scanned') {
|
|
|
+ startPoll()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
const handleSubmit = async () => {
|
|
|
if (!formRef.value) return
|
|
|
|
|
|
@@ -148,20 +344,8 @@
|
|
|
throw new Error('登录失败:未收到令牌')
|
|
|
}
|
|
|
|
|
|
- userStore.setToken(token, refreshToken)
|
|
|
- userStore.setLoginStatus(true)
|
|
|
- userStore.setUserInfo(userInfo)
|
|
|
-
|
|
|
- ElNotification({
|
|
|
- title: '登录成功',
|
|
|
- type: 'success',
|
|
|
- duration: 2500,
|
|
|
- message: `欢迎回来,${userInfo.realName || userInfo.username}!`
|
|
|
- })
|
|
|
-
|
|
|
- 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 {
|
|
|
@@ -171,6 +355,11 @@
|
|
|
|
|
|
onMounted(() => {
|
|
|
preloadBackgroundImage()
|
|
|
+ void refreshQr()
|
|
|
+ })
|
|
|
+
|
|
|
+ onUnmounted(() => {
|
|
|
+ stopPoll()
|
|
|
})
|
|
|
</script>
|
|
|
|
|
|
@@ -264,6 +453,13 @@
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ .login-subtitle {
|
|
|
+ margin: -18px 0 16px;
|
|
|
+ text-align: center;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #64748b;
|
|
|
+ }
|
|
|
+
|
|
|
.login-form {
|
|
|
border-radius: 6px;
|
|
|
background: rgba(255, 255, 255, 0.55);
|
|
|
@@ -313,6 +509,81 @@
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ .login-tabs {
|
|
|
+ :deep(.el-tabs__header) {
|
|
|
+ margin-bottom: 20px;
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(.el-tabs__nav-wrap::after) {
|
|
|
+ height: 1px;
|
|
|
+ background-color: #e5e7eb;
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(.el-tabs__item) {
|
|
|
+ font-size: 15px;
|
|
|
+ color: #64748b;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ color: var(--el-color-primary);
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-active {
|
|
|
+ color: var(--el-color-primary);
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(.el-tabs__active-bar) {
|
|
|
+ background-color: var(--el-color-primary);
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(.el-tabs__content) {
|
|
|
+ overflow: visible;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .qr-login {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+ padding-bottom: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .qr-frame {
|
|
|
+ width: 200px;
|
|
|
+ height: 200px;
|
|
|
+ border: 1px solid #e5e7eb;
|
|
|
+ border-radius: 10px;
|
|
|
+ background: #fff;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ overflow: hidden;
|
|
|
+ }
|
|
|
+
|
|
|
+ .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;
|
|
|
+
|
|
|
+ &.error {
|
|
|
+ color: #dc2626;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
.el-login-footer {
|
|
|
height: 40px;
|
|
|
line-height: 40px;
|
|
|
@@ -332,6 +603,10 @@
|
|
|
color: #fff;
|
|
|
background: linear-gradient(135deg, var(--el-color-primary) 0%, var(--el-color-primary-dark-2) 100%) !important;
|
|
|
border: none;
|
|
|
+
|
|
|
+ .el-icon {
|
|
|
+ margin-right: 6px;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
@media (max-width: 768px) {
|