mirror of
https://github.com/yangjian102621/geekai.git
synced 2026-04-23 11:34:27 +08:00
微信登录验证完成
This commit is contained in:
@@ -1,68 +1,116 @@
|
||||
<template>
|
||||
<div class="login-dialog w-full">
|
||||
<div class="login-box" v-if="login">
|
||||
<el-form :model="data" class="form space-y-5">
|
||||
<div class="block">
|
||||
<el-input placeholder="账号" size="large" v-model="data.username" autocomplete="off">
|
||||
<template #prefix>
|
||||
<el-icon>
|
||||
<Iphone />
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
<custom-tabs v-model="loginActiveName" @tab-click="handleTabClick">
|
||||
<!-- 账号密码登录 -->
|
||||
<custom-tab-pane name="account" width="48">
|
||||
<template #label>
|
||||
<div class="flex items-center justify-center px-3">
|
||||
<i class="iconfont icon-user-fill mr-2"></i>
|
||||
<span>账号登录</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-form :model="data" class="form space-y-5">
|
||||
<div class="block">
|
||||
<el-input placeholder="账号" size="large" v-model="data.username" autocomplete="off">
|
||||
<template #prefix>
|
||||
<el-icon>
|
||||
<Iphone />
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<div class="block">
|
||||
<el-input
|
||||
placeholder="请输入密码(8-16位)"
|
||||
maxlength="16"
|
||||
size="large"
|
||||
v-model="data.password"
|
||||
show-password
|
||||
autocomplete="off"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon>
|
||||
<Lock />
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
<div class="block">
|
||||
<el-input
|
||||
placeholder="请输入密码(8-16位)"
|
||||
maxlength="16"
|
||||
size="large"
|
||||
v-model="data.password"
|
||||
show-password
|
||||
autocomplete="off"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon>
|
||||
<Lock />
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<el-row class="btn-row mt-8" :gutter="20">
|
||||
<el-col :span="24">
|
||||
<button
|
||||
class="w-full h-12 rounded-xl text-base font-medium text-white bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg active:translate-y-0 shadow-md"
|
||||
@click="submitLogin"
|
||||
type="button"
|
||||
>
|
||||
{{ loading ? '登录中...' : '登 录' }}
|
||||
</button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row class="btn-row mt-8" :gutter="20">
|
||||
<el-col :span="24">
|
||||
<button
|
||||
class="w-full h-12 rounded-xl text-base font-medium text-white bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg active:translate-y-0 shadow-md"
|
||||
@click="submitLogin"
|
||||
type="button"
|
||||
>
|
||||
{{ loading ? '登录中...' : '登 录' }}
|
||||
</button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="w-full">
|
||||
<div
|
||||
class="text flex justify-center items-center pt-3 text-sm"
|
||||
style="color: var(--login-text-color)"
|
||||
>
|
||||
还没有账号?
|
||||
<el-button
|
||||
size="small"
|
||||
class="ml-2 rounded-md px-2 py-1 transition-colors duration-200"
|
||||
style="color: var(--login-link-color)"
|
||||
@click="login = false"
|
||||
@mouseenter="$event.target.style.background = 'var(--login-link-hover-bg)'"
|
||||
@mouseleave="$event.target.style.background = 'transparent'"
|
||||
>注册</el-button
|
||||
>
|
||||
<div class="w-full">
|
||||
<div
|
||||
class="text flex justify-center items-center pt-3 text-sm"
|
||||
style="color: var(--login-text-color)"
|
||||
>
|
||||
还没有账号?
|
||||
<el-button
|
||||
size="small"
|
||||
class="ml-2 rounded-md px-2 py-1 transition-colors duration-200"
|
||||
style="color: var(--login-link-color)"
|
||||
@click="login = false"
|
||||
@mouseenter="$event.target.style.background = 'var(--login-link-hover-bg)'"
|
||||
@mouseleave="$event.target.style.background = 'transparent'"
|
||||
>注册</el-button
|
||||
>
|
||||
|
||||
<el-button type="info" class="forget ml-4" size="small" @click="showResetPass = true"
|
||||
>忘记密码?</el-button
|
||||
>
|
||||
<el-button
|
||||
type="info"
|
||||
class="forget ml-4"
|
||||
size="small"
|
||||
@click="showResetPass = true"
|
||||
>忘记密码?</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</custom-tab-pane>
|
||||
|
||||
<!-- 微信登录 -->
|
||||
<custom-tab-pane name="wechat" width="48">
|
||||
<template #label>
|
||||
<div class="flex items-center justify-center px-3">
|
||||
<i class="iconfont icon-wechat mr-2"></i>
|
||||
<span>微信登录</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="wechat-login pt-3">
|
||||
<div class="qr-code-container">
|
||||
<div class="qr-code-wrapper w-[200px] h-[200px] mx-auto" v-loading="qrcodeLoading">
|
||||
<img :src="wechatLoginQRCode" class="qr-frame" v-if="wechatLoginQRCode" />
|
||||
<!-- 二维码过期蒙版 -->
|
||||
<div v-if="qrcodeExpired" class="qr-expired-mask">
|
||||
<div class="expired-content">
|
||||
<i class="iconfont icon-refresh-ccw expired-icon"></i>
|
||||
<p class="expired-text">二维码已过期</p>
|
||||
<button
|
||||
@click="getWechatLoginURL"
|
||||
class="bg-gray-200 text-gray-600 px-2.5 py-1 rounded-md hover:bg-gray-300"
|
||||
>
|
||||
<i class="iconfont icon-refresh text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-center mt-4 text-gray-600 dark:text-gray-400">
|
||||
请使用微信扫描二维码登录
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</custom-tab-pane>
|
||||
</custom-tabs>
|
||||
</div>
|
||||
|
||||
<div class="register-box w-full" v-else>
|
||||
@@ -281,6 +329,8 @@
|
||||
import Captcha from '@/components/Captcha.vue'
|
||||
import ResetPass from '@/components/ResetPass.vue'
|
||||
import SendMsg from '@/components/SendMsg.vue'
|
||||
import CustomTabPane from '@/components/ui/CustomTabPane.vue'
|
||||
import CustomTabs from '@/components/ui/CustomTabs.vue'
|
||||
import { getSystemInfo } from '@/store/cache'
|
||||
import { setUserToken } from '@/store/session'
|
||||
import { useSharedStore } from '@/store/sharedata'
|
||||
@@ -289,8 +339,9 @@ import { arrayContains } from '@/utils/libs'
|
||||
import { validateEmail, validateMobile } from '@/utils/validate'
|
||||
import { Checked, Iphone, Lock, Message } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import QRCode from 'qrcode'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
const props = defineProps({
|
||||
@@ -313,6 +364,7 @@ watch(
|
||||
)
|
||||
|
||||
const login = ref(props.active === 'login')
|
||||
const loginActiveName = ref('account') // 新增:登录标签页激活状态
|
||||
const data = ref({
|
||||
username: import.meta.env.VITE_USER,
|
||||
password: import.meta.env.VITE_PASS,
|
||||
@@ -322,6 +374,15 @@ const data = ref({
|
||||
code: '',
|
||||
invite_code: props.inviteCode,
|
||||
})
|
||||
|
||||
// 微信登录相关变量
|
||||
const wechatLoginQRCode = ref('')
|
||||
const wechatLoginState = ref('')
|
||||
const qrcodeLoading = ref(false)
|
||||
const pollingTimer = ref(null)
|
||||
const qrcodeExpired = ref(false)
|
||||
const qrcodeTimer = ref(null)
|
||||
|
||||
const enableMobile = ref(false)
|
||||
const enableEmail = ref(false)
|
||||
const enableUser = ref(false)
|
||||
@@ -379,6 +440,36 @@ onMounted(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 监听登录标签页切换
|
||||
watch(loginActiveName, (newValue) => {
|
||||
if (newValue === 'wechat') {
|
||||
getWechatLoginURL()
|
||||
} else {
|
||||
// 其他登录方式,清除定时器
|
||||
if (pollingTimer.value) {
|
||||
clearInterval(pollingTimer.value)
|
||||
}
|
||||
if (qrcodeTimer.value) {
|
||||
clearTimeout(qrcodeTimer.value)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const handleTabClick = (tab) => {
|
||||
// CustomTabs组件传递的是tab对象,包含paneName属性
|
||||
if (tab.paneName === 'wechat') {
|
||||
getWechatLoginURL()
|
||||
} else {
|
||||
// 其他登录方式,清除定时器
|
||||
if (pollingTimer.value) {
|
||||
clearInterval(pollingTimer.value)
|
||||
}
|
||||
if (qrcodeTimer.value) {
|
||||
clearTimeout(qrcodeTimer.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const submit = (verifyData) => {
|
||||
if (action.value === 'login') {
|
||||
doLogin(verifyData)
|
||||
@@ -387,6 +478,107 @@ const submit = (verifyData) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取微信登录 URL
|
||||
const getWechatLoginURL = () => {
|
||||
wechatLoginQRCode.value = ''
|
||||
qrcodeLoading.value = true
|
||||
qrcodeExpired.value = false
|
||||
|
||||
// 清除可能存在的旧定时器
|
||||
if (qrcodeTimer.value) {
|
||||
clearTimeout(qrcodeTimer.value)
|
||||
}
|
||||
|
||||
httpGet('/api/user/login/qrcode')
|
||||
.then((res) => {
|
||||
// 生成二维码
|
||||
QRCode.toDataURL(res.data.url, { width: 200, height: 200, margin: 2 }, (error, url) => {
|
||||
if (error) {
|
||||
console.error(error)
|
||||
} else {
|
||||
wechatLoginQRCode.value = url
|
||||
}
|
||||
})
|
||||
wechatLoginState.value = res.data.state
|
||||
// 开始轮询状态
|
||||
startPolling()
|
||||
|
||||
// 设置1分钟后二维码过期
|
||||
qrcodeTimer.value = setTimeout(() => {
|
||||
qrcodeExpired.value = true
|
||||
// 停止轮询
|
||||
if (pollingTimer.value) {
|
||||
clearInterval(pollingTimer.value)
|
||||
}
|
||||
}, 60 * 1000) // 1分钟过期
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('获取微信登录 URL 失败,' + e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
qrcodeLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// 开始轮询
|
||||
const startPolling = () => {
|
||||
// 清除可能存在的旧定时器
|
||||
if (pollingTimer.value) {
|
||||
clearInterval(pollingTimer.value)
|
||||
}
|
||||
|
||||
pollingTimer.value = setInterval(() => {
|
||||
checkLoginStatus()
|
||||
}, 1000) // 每1秒检查一次
|
||||
}
|
||||
|
||||
// 检查登录状态
|
||||
const checkLoginStatus = () => {
|
||||
if (!wechatLoginState.value) return
|
||||
|
||||
httpGet(`/api/user/login/status?state=${wechatLoginState.value}`)
|
||||
.then((res) => {
|
||||
const status = res.data.status
|
||||
|
||||
switch (status) {
|
||||
case 'success':
|
||||
// 登录成功
|
||||
clearInterval(pollingTimer.value)
|
||||
clearTimeout(qrcodeTimer.value)
|
||||
setUserToken(res.data.token)
|
||||
store.setIsLogin(true)
|
||||
ElMessage.success('登录成功!')
|
||||
emits('hide')
|
||||
emits('success')
|
||||
break
|
||||
|
||||
case 'expired':
|
||||
// 二维码过期
|
||||
clearInterval(pollingTimer.value)
|
||||
clearTimeout(qrcodeTimer.value)
|
||||
qrcodeExpired.value = true
|
||||
break
|
||||
|
||||
case 'pending':
|
||||
// 继续轮询
|
||||
break
|
||||
|
||||
default:
|
||||
// 其他错误情况
|
||||
clearInterval(pollingTimer.value)
|
||||
clearTimeout(qrcodeTimer.value)
|
||||
ElMessage.error('登录失败,请重试')
|
||||
break
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
// 发生错误时显示过期状态
|
||||
clearInterval(pollingTimer.value)
|
||||
clearTimeout(qrcodeTimer.value)
|
||||
qrcodeExpired.value = true
|
||||
})
|
||||
}
|
||||
|
||||
// 登录操作
|
||||
const submitLogin = () => {
|
||||
if (!data.value.username) {
|
||||
@@ -512,22 +704,72 @@ const openPrivacy = () => {
|
||||
showPrivacy.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 组件卸载时清除定时器
|
||||
onUnmounted(() => {
|
||||
if (pollingTimer.value) {
|
||||
clearInterval(pollingTimer.value)
|
||||
}
|
||||
if (qrcodeTimer.value) {
|
||||
clearTimeout(qrcodeTimer.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.login-dialog {
|
||||
border-radius: 10px;
|
||||
|
||||
// Dark theme support for Element Plus components
|
||||
:deep(.el-tabs) {
|
||||
.el-tabs__header {
|
||||
.el-tabs__nav-wrap {
|
||||
.el-tabs__nav {
|
||||
.el-tabs__item {
|
||||
color: var(--el-text-color-primary);
|
||||
// 微信登录样式
|
||||
.wechat-login {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 240px;
|
||||
|
||||
&.is-active {
|
||||
color: var(--el-color-primary);
|
||||
.qr-code-container {
|
||||
text-align: center;
|
||||
|
||||
.qr-code-wrapper {
|
||||
display: inline-block;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
.qr-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.qr-expired-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
|
||||
.expired-content {
|
||||
text-align: center;
|
||||
color: white;
|
||||
|
||||
.expired-icon {
|
||||
font-size: 48px;
|
||||
color: #f56565;
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.expired-text {
|
||||
font-size: 16px;
|
||||
margin: 0 0 16px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -535,6 +777,31 @@ const openPrivacy = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// CustomTabs 组件样式优化
|
||||
:deep(.custom-tabs-header) {
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
:deep(.custom-tab-item) {
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.custom-tab-active) {
|
||||
background: var(--el-color-primary);
|
||||
color: white !important;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-input) {
|
||||
.el-input__wrapper {
|
||||
background: var(--el-fill-color-blank);
|
||||
@@ -571,4 +838,30 @@ const openPrivacy = () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 576px) {
|
||||
.login-dialog {
|
||||
.wechat-login {
|
||||
.qr-code-wrapper {
|
||||
width: 240px !important;
|
||||
height: 240px !important;
|
||||
|
||||
.qr-expired-mask {
|
||||
.expired-content {
|
||||
.expired-icon {
|
||||
font-size: 36px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.expired-text {
|
||||
font-size: 14px;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,41 +5,48 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, inject, useSlots } from 'vue'
|
||||
import { computed, inject, useSlots } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
// 支持百分比宽度,如 "30%" 会生成 style="width: 30%"
|
||||
},
|
||||
})
|
||||
|
||||
const slots = useSlots()
|
||||
const slots = useSlots()
|
||||
|
||||
// 从父组件注入当前激活的 tab
|
||||
const currentTab = inject('currentTab', '')
|
||||
// 从父组件注入当前激活的 tab
|
||||
const currentTab = inject('currentTab', '')
|
||||
|
||||
const active = computed(() => {
|
||||
return currentTab.value === props.name
|
||||
})
|
||||
const active = computed(() => {
|
||||
return currentTab.value === props.name
|
||||
})
|
||||
|
||||
// 向父组件提供当前 pane 的信息,优先使用 labelSlot
|
||||
const parentRegisterPane = inject('registerPane', () => {})
|
||||
// 向父组件提供当前 pane 的信息,优先使用 labelSlot
|
||||
const parentRegisterPane = inject('registerPane', () => {})
|
||||
|
||||
// 立即注册,不要等到 onMounted
|
||||
parentRegisterPane({
|
||||
name: props.name,
|
||||
label: props.label || '', // 如果没有传 label 则使用空字符串
|
||||
labelSlot: slots.label,
|
||||
})
|
||||
// 立即注册,不要等到 onMounted
|
||||
parentRegisterPane({
|
||||
name: props.name,
|
||||
label: props.label || '', // 如果没有传 label 则使用空字符串
|
||||
labelSlot: slots.label,
|
||||
width: props.width, // 传递 width 属性
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-tab-pane {
|
||||
width: 100%;
|
||||
}
|
||||
.custom-tab-pane {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
<!--
|
||||
CustomTabs 组件
|
||||
|
||||
使用方法:
|
||||
<custom-tabs v-model="activeTab">
|
||||
<custom-tab-pane name="tab1" label="标签1" width="30%">
|
||||
内容1
|
||||
</custom-tab-pane>
|
||||
<custom-tab-pane name="tab2" label="标签2" width="70%">
|
||||
内容2
|
||||
</custom-tab-pane>
|
||||
</custom-tabs>
|
||||
|
||||
width 属性说明:
|
||||
- width="30%": 标签页宽度为 30%
|
||||
- width="70%": 标签页宽度为 70%
|
||||
- width="50": 标签页宽度为 50%
|
||||
- 不设置 width: 标签页宽度为自适应
|
||||
- 支持任意百分比值或数字值
|
||||
-->
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div
|
||||
@@ -47,7 +67,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex whitespace-nowrap overflow-x-auto scrollbar-hide"
|
||||
class="flex whitespace-nowrap overflow-x-auto scrollbar-hide justify-between"
|
||||
ref="tabsContainer"
|
||||
@scroll="checkScrollPosition"
|
||||
>
|
||||
@@ -56,9 +76,10 @@
|
||||
v-for="(tab, index) in panes"
|
||||
:key="tab.name"
|
||||
:class="{
|
||||
'!text-purple-600 bg-white shadow-sm custom-tab-active': modelValue === tab.name,
|
||||
'!text-purple-600 bg-white shadow-sm custom-tab-active ': modelValue === tab.name,
|
||||
'hover:bg-gray-50': modelValue !== tab.name,
|
||||
}"
|
||||
:style="getWidthStyle(tab.width)"
|
||||
@click="handleTabClick(tab.name, index)"
|
||||
ref="tabItems"
|
||||
>
|
||||
@@ -87,6 +108,23 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'tab-click'])
|
||||
|
||||
// 动态生成宽度样式对象
|
||||
const getWidthStyle = (width) => {
|
||||
if (!width) return {}
|
||||
|
||||
// 如果是百分比格式,直接使用
|
||||
if (width.includes('%')) {
|
||||
return { width: width }
|
||||
}
|
||||
|
||||
// 如果是数字,转换为百分比
|
||||
if (!isNaN(width)) {
|
||||
return { width: `${width}%` }
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
const tabsHeader = ref(null)
|
||||
const tabsContainer = ref(null)
|
||||
const tabItems = ref([])
|
||||
|
||||
Reference in New Issue
Block a user