Files
012-kaopeilian/frontend/src/views/login/index.vue
yuliang_guo 662947cd06
Some checks failed
continuous-integration/drone/push Build is failing
feat: 添加钉钉扫码登录功能
- 后端:钉钉 OAuth 认证服务
- 后端:系统设置 API(钉钉配置)
- 前端:登录页钉钉扫码入口
- 前端:系统设置页面
- 数据库迁移脚本
2026-01-29 14:40:00 +08:00

526 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="login-container">
<div class="login-bg">
<div class="bg-shape bg-shape-1"></div>
<div class="bg-shape bg-shape-2"></div>
<div class="bg-shape bg-shape-3"></div>
</div>
<div class="login-card">
<div class="login-header">
<div class="logo">
<el-icon :size="48" color="#667eea">
<Notebook />
</el-icon>
</div>
<h1 class="title">考培练系统</h1>
<p class="subtitle">让学习更高效让进步看得见</p>
</div>
<el-form ref="formRef" :model="loginForm" :rules="rules" class="login-form">
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="请输入用户名"
size="large"
clearable
>
<template #prefix>
<el-icon><User /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
size="large"
show-password
@keyup.enter="handleLogin"
>
<template #prefix>
<el-icon><Lock /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item>
<div class="login-options">
<el-checkbox v-model="loginForm.remember">记住我</el-checkbox>
<el-link type="primary" :underline="false" @click="forgotPassword">
忘记密码
</el-link>
</div>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="login-btn"
:loading="loading"
@click="handleLogin"
>
</el-button>
</el-form-item>
<div class="other-login" v-if="dingtalkConfig.enabled || !isDingtalk">
<el-divider>其他登录方式</el-divider>
<div class="social-icons">
<!-- 钉钉登录按钮仅在启用且非钉钉环境时显示 -->
<div
v-if="dingtalkConfig.enabled && !isDingtalk"
class="social-icon dingtalk-icon"
@click="handleDingtalkLogin"
title="钉钉登录"
>
<svg viewBox="0 0 1024 1024" width="22" height="22">
<path d="M512 0C229.2 0 0 229.2 0 512s229.2 512 512 512 512-229.2 512-512S794.8 0 512 0z m259.3 568.7l-197.8 3.3-59.4 143.1c-3.6 8.6-15.7 7.5-17.9-1.6l-45.2-188.5-241.9-69c-10.8-3.1-10.6-18.4 0.3-21.2l492.3-126c11.3-2.9 21.4 7.6 18 18.7l-77.4 252.3c-2.7 8.8-15.1 10.1-19.7 2.1l-51.3-90.8-90.8 51.3c-8 4.5-17.6-2-15.9-10.8l34.8-188.4-213.7 54.7 213.7 61.1 19.2 80.1 32.6-78.8 240.1-4z" fill="#3296FA"/>
</svg>
</div>
<div class="social-icon" @click="socialLogin('wechat')">
<el-icon :size="20"><ChatDotRound /></el-icon>
</div>
<div class="social-icon" @click="socialLogin('qq')">
<el-icon :size="20"><Connection /></el-icon>
</div>
</div>
</div>
<!-- 钉钉环境中的自动登录提示 -->
<div v-if="isDingtalk && dingtalkLoading" class="dingtalk-loading">
<el-icon class="is-loading" :size="24"><Loading /></el-icon>
<span>正在通过钉钉自动登录...</span>
</div>
<div class="register-link">
还没有账号
<el-link type="primary" :underline="false" @click="goRegister">
立即注册
</el-link>
</div>
</el-form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { login, dingtalkLogin, getDingtalkConfig } from '@/api/auth'
import type { DingtalkConfig } from '@/api/auth'
import { authManager } from '@/utils/auth'
import { isDingtalkEnv, loadDingtalkSDK, getAuthCode, waitDingtalkReady } from '@/utils/dingtalk'
const router = useRouter()
const formRef = ref<FormInstance>()
const loading = ref(false)
// 钉钉相关状态
const isDingtalk = ref(false)
const dingtalkLoading = ref(false)
const dingtalkConfig = reactive<DingtalkConfig>({
enabled: false,
corp_id: null,
agent_id: null
})
// 登录表单
const loginForm = reactive({
username: '',
password: '',
remember: false
})
// 表单验证规则
const rules = reactive<FormRules>({
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于 6 个字符', trigger: 'blur' }
]
})
/**
* 登录
*/
const handleLogin = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
loading.value = true
try {
// 调用真实的登录API
const response = await login({
username: loginForm.username,
password: loginForm.password
})
if (response.code === 200) {
// 使用authManager保存认证信息
authManager.setAccessToken(response.data.token.access_token)
authManager.setRefreshToken(response.data.token.refresh_token)
// 添加缺少的字段
const userInfo = {
...response.data.user,
created_at: response.data.user.created_at || new Date().toISOString(),
updated_at: response.data.user.updated_at || new Date().toISOString()
}
authManager.setCurrentUser(userInfo)
ElMessage.success('登录成功')
// 跳转到用户默认页面或指定的重定向页面
const redirect = new URLSearchParams(window.location.search).get('redirect') || authManager.getDefaultRoute()
router.push(redirect)
} else {
ElMessage.error(response.message || '登录失败')
}
} catch (error: any) {
console.error('登录错误:', error)
ElMessage.error(error.message || '登录失败,请稍后重试')
} finally {
loading.value = false
}
}
})
}
/**
* 忘记密码
*/
const forgotPassword = () => {
ElMessage.info('请联系管理员重置密码')
}
/**
* 社交登录
*/
const socialLogin = (type: string) => {
ElMessage.info(`${type} 登录功能开发中`)
}
/**
* 去注册
*/
const goRegister = () => {
ElMessage.info('注册功能开发中')
}
/**
* 钉钉登录成功处理
*/
const handleDingtalkLoginSuccess = (response: any) => {
// 保存认证信息
authManager.setAccessToken(response.data.token.access_token)
authManager.setRefreshToken(response.data.token.refresh_token)
const userInfo = {
...response.data.user,
created_at: response.data.user.created_at || new Date().toISOString(),
updated_at: response.data.user.updated_at || new Date().toISOString()
}
authManager.setCurrentUser(userInfo)
ElMessage.success('钉钉登录成功')
// 跳转
const redirect = new URLSearchParams(window.location.search).get('redirect') || authManager.getDefaultRoute()
router.push(redirect)
}
/**
* 钉钉免密登录(在钉钉环境中自动触发)
*/
const autoDingtalkLogin = async () => {
if (!dingtalkConfig.corp_id) {
console.warn('钉钉CorpId未配置')
return
}
dingtalkLoading.value = true
try {
// 等待钉钉SDK就绪
await waitDingtalkReady()
// 获取免登授权码
const code = await getAuthCode(dingtalkConfig.corp_id)
// 调用登录API
const response = await dingtalkLogin({ code })
if (response.code === 200) {
handleDingtalkLoginSuccess(response)
} else {
ElMessage.error(response.message || '钉钉登录失败')
}
} catch (error: any) {
console.error('钉钉自动登录失败:', error)
ElMessage.warning('钉钉自动登录失败,请使用账号密码登录')
} finally {
dingtalkLoading.value = false
}
}
/**
* 手动触发钉钉登录(非钉钉环境下点击钉钉登录按钮)
*/
const handleDingtalkLogin = () => {
if (isDingtalk.value) {
// 在钉钉环境中,重新触发自动登录
autoDingtalkLogin()
} else {
// 非钉钉环境,提示用户
ElMessage.info('请在钉钉客户端中打开本应用以使用免密登录')
}
}
/**
* 初始化钉钉登录
*/
const initDingtalkLogin = async () => {
try {
// 获取钉钉配置
const response = await getDingtalkConfig()
if (response.code === 200 && response.data) {
dingtalkConfig.enabled = response.data.enabled
dingtalkConfig.corp_id = response.data.corp_id
dingtalkConfig.agent_id = response.data.agent_id
}
// 检测钉钉环境
isDingtalk.value = isDingtalkEnv()
// 如果在钉钉环境中且钉钉登录已启用,自动触发登录
if (isDingtalk.value && dingtalkConfig.enabled && dingtalkConfig.corp_id) {
autoDingtalkLogin()
}
} catch (error) {
console.error('初始化钉钉登录失败:', error)
}
}
// 页面加载时初始化
onMounted(async () => {
// 尝试加载钉钉SDK
try {
await loadDingtalkSDK()
} catch (e) {
console.log('钉钉SDK加载跳过非必须')
}
// 初始化钉钉登录
initDingtalkLogin()
})
</script>
<style lang="scss" scoped>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
position: relative;
overflow: hidden;
.login-bg {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
.bg-shape {
position: absolute;
border-radius: 50%;
filter: blur(40px);
opacity: 0.6;
animation: float 20s infinite ease-in-out;
&-1 {
width: 400px;
height: 400px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
top: -200px;
right: -100px;
}
&-2 {
width: 300px;
height: 300px;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
bottom: -150px;
left: -100px;
animation-delay: -5s;
}
&-3 {
width: 350px;
height: 350px;
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation-delay: -10s;
}
}
}
.login-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
padding: 48px;
width: 420px;
position: relative;
z-index: 1;
.login-header {
text-align: center;
margin-bottom: 40px;
.logo {
margin-bottom: 16px;
}
.title {
font-size: 28px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.subtitle {
font-size: 14px;
color: #666;
}
}
.login-form {
.login-options {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.login-btn {
width: 100%;
height: 44px;
font-size: 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 8px;
letter-spacing: 2px;
}
.other-login {
margin-top: 32px;
:deep(.el-divider__text) {
color: #999;
font-size: 13px;
}
.social-icons {
display: flex;
justify-content: center;
gap: 24px;
margin-top: 24px;
.social-icon {
width: 44px;
height: 44px;
border: 1px solid #e4e7ed;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
border-color: #667eea;
color: #667eea;
transform: translateY(-2px);
}
&.dingtalk-icon {
&:hover {
border-color: #3296FA;
background-color: rgba(50, 150, 250, 0.1);
}
}
}
}
}
.dingtalk-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
margin-top: 20px;
color: #666;
.el-icon {
margin-bottom: 12px;
color: #3296FA;
}
span {
font-size: 14px;
}
}
.register-link {
text-align: center;
margin-top: 24px;
font-size: 14px;
color: #666;
}
}
}
}
@keyframes float {
0%, 100% {
transform: translateY(0) rotate(0deg);
}
33% {
transform: translateY(-30px) rotate(120deg);
}
66% {
transform: translateY(30px) rotate(240deg);
}
}
// 响应式
@media (max-width: 768px) {
.login-container {
padding: 20px;
.login-card {
width: 100%;
max-width: 400px;
padding: 32px 24px;
}
}
}
</style>