Files
zyt/admin/src/views/account/login.vue
T

472 lines
16 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 flex flex-col">
<div class="flex-1 flex items-center justify-center">
<div class="login-card flex rounded-md overflow-hidden">
<div class="flex-1 h-full hidden md:inline-block">
<image-contain :src="config.login_image" :width="400" height="100%" />
</div>
<div
class="login-form bg-body flex flex-col justify-center px-10 py-10 md:w-[420px] w-[380px] flex-none mx-auto"
>
<div class="text-center text-3xl font-medium mb-8">{{ config.web_name }}</div>
<!-- 企业微信自动授权中 -->
<div v-if="wxWorkAutoLogin || iamLoading" class="text-center py-10">
<el-icon class="is-loading mb-4" :size="40" color="var(--el-color-primary)">
<Loading />
</el-icon>
<div class="text-gray-500">{{ iamLoading ? '统一账号登录中...' : '企业微信授权登录中...' }}</div>
</div>
<template v-else>
<!-- 登录方式切换标签 -->
<div v-if="wxWorkEnabled" class="flex justify-center mb-6">
<el-radio-group v-model="loginMode" size="large">
<el-radio-button value="account">账号登录</el-radio-button>
<el-radio-button value="wxwork">企业微信</el-radio-button>
</el-radio-group>
</div>
<!-- 账号密码登录 -->
<template v-if="loginMode === 'account'">
<el-form ref="formRef" :model="formData" size="large" :rules="rules">
<el-form-item prop="account">
<el-input
v-model="formData.account"
placeholder="请输入账号"
@keyup.enter="handleEnter"
>
<template #prepend>
<icon name="el-icon-User" size="16" />
</template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
ref="passwordRef"
v-model="formData.password"
show-password
placeholder="请输入密码"
@keyup.enter="handleLogin"
>
<template #prepend>
<icon name="el-icon-Lock" size="16" />
</template>
</el-input>
</el-form-item>
</el-form>
<div class="mb-5">
<el-checkbox v-model="remAccount" label="记住账号"></el-checkbox>
</div>
<el-button type="primary" size="large" :loading="isLock" @click="lockLogin">
登录
</el-button>
</template>
<!-- 企业微信扫码登录非企业微信内浏览器 -->
<template v-if="loginMode === 'wxwork'">
<div class="wxwork-qrcode-wrap">
<div v-if="wxWorkLoading" class="text-center py-10">
<el-icon class="is-loading" :size="32" color="var(--el-color-primary)">
<Loading />
</el-icon>
<div class="mt-2 text-gray-400 text-sm">加载企业微信扫码...</div>
</div>
<div v-else id="wxwork_qrcode_container" class="wxwork-qrcode"></div>
</div>
<div class="text-center text-sm text-gray-400 mt-4">
请使用企业微信扫描二维码登录
</div>
</template>
<section v-if="iamEnabled" class="iam-login-alternative" aria-label="其他登录方式">
<div class="iam-login-divider" aria-hidden="true">其他登录方式</div>
<el-button class="iam-login-entry" size="large" @click="handleIamLogin">
<span class="iam-login-entry__content">
<icon name="local-icon-anquan" size="22" />
<span>统一账号快捷登录</span>
</span>
<span class="iam-login-entry__arrow" aria-hidden="true">
<icon name="el-icon-ArrowRight" size="16" />
</span>
</el-button>
<p class="iam-login-hint">使用统一身份平台账号登录</p>
</section>
</template>
</div>
</div>
</div>
<layout-footer />
</div>
</template>
<script lang="ts" setup>
import type { FormInstance, InputInstance } from 'element-plus'
import { computed, nextTick, onMounted, ref, shallowRef, watch } from 'vue'
import { Loading } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { ACCOUNT_KEY } from '@/enums/cacheEnums'
import { PageEnum } from '@/enums/pageEnum'
import { useLockFn } from '@/hooks/useLockFn'
import LayoutFooter from '@/layout/components/footer.vue'
import useAppStore from '@/stores/modules/app'
import useUserStore from '@/stores/modules/user'
import cache from '@/utils/cache'
import { getIamConfig, getWorkWechatConfig } from '@/api/user'
const passwordRef = shallowRef<InputInstance>()
const formRef = shallowRef<FormInstance>()
const appStore = useAppStore()
const userStore = useUserStore()
const route = useRoute()
const router = useRouter()
const remAccount = ref(false)
const config = computed(() => appStore.config)
const formData = reactive({
account: '',
password: ''
})
const rules = {
account: [{ required: true, message: '请输入账号', trigger: ['blur'] }],
password: [{ required: true, message: '请输入密码', trigger: ['blur'] }]
}
// 统一账号登录是可选入口,不取代账号密码或企业微信登录。
const iamEnabled = ref(false)
const iamLoginUrl = ref('')
const iamLoading = ref(false)
let iamCallbackHandled = false
const loadIamConfig = async () => {
try {
const result = await getIamConfig()
const url = new URL(result?.loginUrl || '', window.location.origin)
if (result?.enabled === true && url.protocol === 'https:') {
iamLoginUrl.value = url.href
iamEnabled.value = true
}
} catch {
// IAM 不可用时,原有登录入口保持可用。
}
}
const handleIamLogin = () => {
if (iamEnabled.value && iamLoginUrl.value) {
window.location.assign(iamLoginUrl.value)
}
}
const handleIamCallback = async (ticket: string | null, error: string | null) => {
if (iamCallbackHandled) return
iamCallbackHandled = true
// 在任何 await 和兑换前移除票据,刷新不会重复兑换;保留其他 query/hash。
const cleanUrl = new URL(window.location.href)
cleanUrl.searchParams.delete('iam_ticket')
cleanUrl.searchParams.delete('iam_error')
// 同一回调不得随后被识别为企业微信授权。
cleanUrl.searchParams.delete('code')
cleanUrl.searchParams.delete('state')
window.history.replaceState(window.history.state, '', cleanUrl.pathname + cleanUrl.search + cleanUrl.hash)
if (error || !ticket) {
ElMessage.error(error || '统一账号登录凭证无效,请重新登录')
return
}
iamLoading.value = true
try {
const result = await userStore.iamLogin(ticket)
if (result.is_paw === 0) {
await router.push('/change-password')
return
}
if (result.need_bind_work_wechat) {
await router.push('/bind-work-wechat')
return
}
redirectAfterLogin()
} catch (error: any) {
ElMessage.error(error?.msg || error?.message || '统一账号登录失败,请重新登录或使用账号密码')
loginMode.value = 'account'
} finally {
iamLoading.value = false
}
}
// 企业微信相关
const loginMode = ref<'account' | 'wxwork'>('account')
const wxWorkEnabled = ref(false)
const wxWorkConfig = ref<{ corp_id: string; agent_id: string }>({ corp_id: '', agent_id: '' })
const wxWorkAutoLogin = ref(false)
const wxWorkLoading = ref(false)
const isInWxWork = () => /wxwork/i.test(navigator.userAgent)
const getRedirectUri = () => {
return window.location.origin + window.location.pathname
}
const handleEnter = () => {
if (!formData.password) {
return passwordRef.value?.focus()
}
handleLogin()
}
const handleLogin = async () => {
await formRef.value?.validate()
cache.set(ACCOUNT_KEY, {
remember: remAccount.value,
account: remAccount.value ? formData.account : ''
})
const result: any = await userStore.login(formData)
// 检查是否需要修改密码
if (result.is_paw === 0) {
router.push('/change-password')
return
}
if (result.need_bind_work_wechat) {
router.push('/bind-work-wechat')
return
}
redirectAfterLogin()
}
const { isLock, lockFn: lockLogin } = useLockFn(handleLogin)
const redirectAfterLogin = () => {
const { query: { redirect } } = route
const path = typeof redirect === 'string' ? redirect : PageEnum.INDEX
router.push(path)
}
// 企业微信 code 登录
const handleWxWorkCodeLogin = async (code: string) => {
wxWorkAutoLogin.value = true
try {
const result: any = await userStore.workWechatLogin(code)
// 检查是否需要修改密码
if (result.is_paw === 0) {
router.push('/change-password')
return
}
if (result.need_bind_work_wechat) {
router.push('/bind-work-wechat')
return
}
redirectAfterLogin()
} catch (error: any) {
console.error('企业微信登录失败:', error)
ElMessage.error(error?.msg || error?.message || '企业微信登录失败,请使用账号密码登录')
wxWorkAutoLogin.value = false
loginMode.value = 'account'
}
}
// 加载企业微信扫码 JS SDK 并渲染二维码
const renderWxWorkQRCode = async () => {
if (!wxWorkConfig.value.corp_id) return
wxWorkLoading.value = true
// 动态加载企业微信扫码 JS
if (!(window as any).WwLogin) {
await new Promise<void>((resolve, reject) => {
const script = document.createElement('script')
script.src = 'https://wwcdn.weixin.qq.com/node/wework/wwopen/js/wwLogin-1.2.7.js'
script.onload = () => resolve()
script.onerror = () => reject(new Error('加载企业微信JS失败'))
document.head.appendChild(script)
})
}
await nextTick()
wxWorkLoading.value = false
await nextTick()
const redirectUri = encodeURIComponent(getRedirectUri())
new (window as any).WwLogin({
id: 'wxwork_qrcode_container',
appid: wxWorkConfig.value.corp_id,
agentid: wxWorkConfig.value.agent_id,
redirect_uri: redirectUri,
lang: 'zh',
state: 'admin_login',
href: '',
self_redirect: false,
})
}
// 切换到企业微信扫码时自动渲染
watch(loginMode, (val) => {
if (val === 'wxwork') {
nextTick(() => renderWxWorkQRCode())
}
})
onMounted(async () => {
// 恢复记住账号
const value = cache.get(ACCOUNT_KEY)
if (value?.remember) {
remAccount.value = value.remember
formData.account = value.account
}
// 检查 URL 中是否有企业微信回调 code
const urlParams = new URLSearchParams(window.location.search)
void loadIamConfig()
const hasIamCallback = urlParams.has('iam_ticket') || urlParams.has('iam_error')
if (hasIamCallback) {
await handleIamCallback(urlParams.get('iam_ticket'), urlParams.get('iam_error'))
}
const wxCode = hasIamCallback ? null : urlParams.get('code')
const wxState = urlParams.get('state')
if (wxCode && wxState === 'admin_login') {
// 清除 URL 中的 code 参数(避免刷新重复使用)
const cleanUrl = window.location.pathname + (route.query.redirect ? `?redirect=${route.query.redirect}` : '')
window.history.replaceState({}, '', cleanUrl)
handleWxWorkCodeLogin(wxCode)
return
}
// 获取企业微信配置
try {
const res = await getWorkWechatConfig()
if (res?.enabled) {
wxWorkEnabled.value = true
wxWorkConfig.value = { corp_id: res.corp_id, agent_id: res.agent_id }
// 在企业微信内:自动跳转 OAuth 授权
if (isInWxWork() && !hasIamCallback) {
const redirectUri = encodeURIComponent(getRedirectUri())
const authUrl =
`https://open.weixin.qq.com/connect/oauth2/authorize` +
`?appid=${res.corp_id}` +
`&redirect_uri=${redirectUri}` +
`&response_type=code` +
`&scope=snsapi_privateinfo` +
`&agentid=${res.agent_id}` +
`&state=admin_login` +
`#wechat_redirect`
window.location.href = authUrl
wxWorkAutoLogin.value = true
return
}
}
} catch (e) {
console.warn('获取企业微信配置失败:', e)
}
})
</script>
<style lang="scss" scoped>
.iam-login-alternative {
margin-top: 28px;
}
.iam-login-divider {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 18px;
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 20px;
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background: var(--el-border-color-light);
}
}
.iam-login-entry.el-button {
--el-button-bg-color: var(--el-color-primary-light-9);
--el-button-text-color: var(--el-color-primary);
--el-button-border-color: var(--el-color-primary-light-5);
--el-button-hover-bg-color: var(--el-color-primary-light-8);
--el-button-hover-text-color: var(--el-color-primary);
--el-button-hover-border-color: var(--el-color-primary);
--el-button-active-bg-color: var(--el-color-primary-light-8);
--el-button-active-border-color: var(--el-color-primary);
position: relative;
width: 100%;
min-height: 48px;
height: auto;
margin: 0;
padding: 12px 34px;
border-radius: 8px;
font-weight: 600;
font-size: 14px;
transition: background-color 150ms ease, border-color 150ms ease;
&:focus-visible {
outline: 2px solid var(--el-color-primary);
outline-offset: 3px;
}
}
.iam-login-entry__content {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.iam-login-entry__arrow {
position: absolute;
right: 14px;
display: inline-flex;
align-items: center;
}
.iam-login-hint {
margin: 14px 0 0;
text-align: center;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 20px;
}
@media (prefers-reduced-motion: reduce) {
.iam-login-entry.el-button {
transition: none;
}
}
.login {
background-image: url('./images/login_bg.png');
@apply min-h-screen bg-no-repeat bg-center bg-cover;
.login-card {
height: auto;
min-height: 400px;
}
}
.wxwork-qrcode-wrap {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.wxwork-qrcode {
width: 340px;
height: 400px;
overflow: hidden;
:deep(iframe) {
width: 340px !important;
height: 400px !important;
border: none;
}
}
</style>