Files
yunzerwebsiteallinone/uniapp/pages/login/login.vue
T
2026-10-03 23:46:55 +08:00

927 lines
21 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>
<view class="login-page">
<!-- 品牌区 -->
<view class="brand">
<view class="brand-logo">
<FaIcon name="shield-halved" color="#ffffff" :size="20" />
</view>
<text class="brand-name">云泽统一认证</text>
<text class="brand-desc">一次登录,全平台通行</text>
</view>
<view class="card">
<!-- ============ 选择企业(一人多企业) ============ -->
<block v-if="step === 'tenant'">
<view class="panel-head">
<text class="panel-title">选择企业</text>
<text class="panel-sub">该账号已加入多家企业,请选择要进入的空间</text>
</view>
<view v-if="errorMsg" class="error-box">
<text class="error-text">{{ errorMsg }}</text>
</view>
<view class="tenant-list">
<view
v-for="item in tenants"
:key="item.tid"
class="tenant-item"
:class="{ disabled: submitting }"
@tap="chooseTenant(item)"
>
<view class="tenant-avatar">
<FaIcon name="building" color="#3973ff" :size="18" />
</view>
<view class="tenant-info">
<text class="tenant-name">{{ item.tenant_name || `企业${item.tid}` }}</text>
<text class="tenant-meta">{{ item.name ? `${item.name} · ` : '' }}企业ID {{ item.tid }}</text>
</view>
<FaIcon name="chevron-right" color="#c8cfdf" :size="14" />
</view>
</view>
<view class="back-row" @tap="backToAccount">
<text class="link">不是我的账号?切换账号</text>
</view>
</block>
<!-- ============ 账号密码登录 ============ -->
<block v-else>
<view class="panel-head">
<text class="panel-title">欢迎登录</text>
<text class="panel-sub">请输入您的账号与密码以继续</text>
</view>
<view v-if="errorMsg" class="error-box">
<text class="error-text">{{ errorMsg }}</text>
</view>
<view class="field">
<text class="field-label">账号</text>
<view class="input-wrap">
<input
v-model="account"
class="field-input"
type="text"
placeholder="手机号 / 邮箱 / 企业账号"
placeholder-class="field-placeholder"
/>
</view>
</view>
<view class="field">
<text class="field-label">密码</text>
<view class="input-wrap">
<input
v-model="password"
class="field-input"
:password="!showPassword"
placeholder="请输入密码"
placeholder-class="field-placeholder"
@confirm="handleLogin"
/>
<view class="eye-btn" @tap="showPassword = !showPassword">
<FaIcon
:name="showPassword ? 'eye-slash' : 'eye'"
color="#a7b1c7"
:size="16"
/>
</view>
</view>
</view>
<view class="row-extra">
<view class="remember" @tap="rememberMe = !rememberMe">
<view class="checkbox" :class="{ on: rememberMe }" />
<text class="remember-text">记住账号</text>
</view>
<text class="link" @tap="goForget">忘记密码</text>
</view>
<view
class="btn"
:class="{ disabled: submitting }"
@tap="handleLogin"
>
<text>{{ submitting ? '登录中...' : '登 录' }}</text>
</view>
<view v-if="!agreed && showAgreementTip" class="agree-tip">
<FaIcon name="circle-info" color="#f79009" :size="12" />
<text class="agree-tip-text">请先阅读并勾选同意《用户协议》和《隐私政策》</text>
</view>
<!-- 微信小程序一键登录 -->
<block v-if="showMiniLogin">
<view class="divider">
<view class="divider-line" />
<text class="divider-text">其他登录方式</text>
<view class="divider-line" />
</view>
<!-- #ifdef MP-WEIXIN -->
<button
class="btn-wechat"
open-type="getPhoneNumber"
:class="{ disabled: submitting }"
@getphonenumber="onGetPhoneNumber"
>
<FaIcon name="weixin" type="brands" color="#ffffff" :size="18" />
<text>微信手机号一键登录</text>
</button>
<!-- #endif -->
<view class="tip-row">
<text class="tip-text">首次使用将自动绑定当前微信手机号对应的云泽账号</text>
</view>
</block>
<view class="back-row">
<text class="tip-text">还没有账号?</text>
<text class="link" @tap="goRegister">立即注册</text>
</view>
</block>
</view>
<view class="agreement" @tap="toggleAgree">
<view class="agree-dot" :class="{ on: agreed }" />
<text class="agree-text">
登录即表示同意
<text class="agree-link" @tap.stop="showAgreement">《用户协议》</text>
和
<text class="agree-link" @tap.stop="showAgreement">《隐私政策》</text>
</text>
</view>
<view class="foot-tip">
<FaIcon name="lock" color="#a3acbf" :size="11" />
<text class="foot-tip-text">统一身份认证 · 一次登录,全平台通行</text>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import {
loginUAC,
switchTenant,
fetchUserInfo,
getAuthVerifyConfig,
getMiniappLoginConfig,
loginByMiniapp
} from '@/api/auth.js'
import {
loginSuccess,
isLoggedIn,
getRememberLogin,
saveRememberLogin,
clearRememberLogin,
setAuthTokens,
setTenants,
setTid,
setTenantName,
resolveTenantName,
clearPendingAuth,
getToken
} from '@/utils/auth.js'
import { showGeetest4 } from '@/utils/geetest.js'
import FaIcon from '@/components/FaIcon.vue'
const step = ref('password')
const account = ref('')
const password = ref('')
const showPassword = ref(false)
/** 用户协议必须主动勾选,默认不勾选(此前默认 true 导致未勾选也能登录) */
const agreed = ref(false)
const rememberMe = ref(false)
const submitting = ref(false)
const showAgreementTip = ref(false)
const errorMsg = ref('')
const tenants = ref([])
const verifyCfg = ref({ needGeetest: false, captchaId: '' })
/** 仅微信小程序端且服务端已开通时展示「一键登录」入口 */
const showMiniLogin = ref(false)
onMounted(async () => {
if (isLoggedIn()) {
uni.reLaunch({ url: '/pages/nav/nav' })
return
}
const remembered = getRememberLogin()
if (remembered.rememberMe) {
rememberMe.value = true
account.value = remembered.account
}
loadVerifyConfig()
loadMiniLoginConfig()
})
/** 拉取统一认证的登录验证配置(极验开关与 captcha_id) */
async function loadVerifyConfig() {
try {
const data = await getAuthVerifyConfig()
verifyCfg.value = {
needGeetest: !!data?.needGeetest,
captchaId: data?.captchaId || ''
}
} catch {
verifyCfg.value = { needGeetest: false, captchaId: '' }
}
}
/** 小程序一键登录是否已开通(未开通则隐藏入口) */
async function loadMiniLoginConfig() {
// #ifdef MP-WEIXIN
try {
const data = await getMiniappLoginConfig()
showMiniLogin.value = !!data?.enabled
} catch {
showMiniLogin.value = false
}
// #endif
}
/* ==================== 登录主流程 ==================== */
async function handleLogin() {
if (submitting.value) return
// 未勾选协议:弹窗确认,确认后自动勾选并继续登录
if (!agreed.value) {
const ok = await confirmAgreement()
if (!ok) {
showAgreementTip.value = true
return
}
}
const acc = account.value.trim()
if (!acc) {
uni.showToast({ title: '请输入账号', icon: 'none' })
return
}
if (!password.value) {
uni.showToast({ title: '请输入密码', icon: 'none' })
return
}
submitting.value = true
errorMsg.value = ''
try {
let captcha = null
if (verifyCfg.value.needGeetest) {
if (!verifyCfg.value.captchaId) {
errorMsg.value = '人机验证未配置,请联系管理员'
return
}
captcha = await showGeetest4(verifyCfg.value.captchaId)
}
const data = await loginUAC(Object.assign({ account: acc, password: password.value }, captcha || {}))
saveRememberAccount(acc)
await handleAuthResult(data, acc)
} catch (err) {
showError(err)
} finally {
submitting.value = false
}
}
/**
* 统一处理 /auth/login 与 /auth/wechat/miniapp/login 的返回:
* 两者的令牌与待选企业标记都在顶层,结构一致。
*/
async function handleAuthResult(data, fallbackAccount = '') {
if (!data || data.code !== 200) {
errorMsg.value = (data && data.msg) || '登录失败'
return
}
// 一人多企业:先落待选令牌,再让用户选企业
if (data.need_choose_tenant) {
setAuthTokens(data.tokens || { access_token: data.access_token, sid: data.sid })
tenants.value = data.tenants || []
if (!tenants.value.length) {
clearAuthState()
errorMsg.value = '该账号未加入任何企业,请联系管理员'
return
}
step.value = 'tenant'
return
}
const list = data.tenants || []
const tid = list.length ? list[0].tid : 0
await finishLogin(data.tokens || {}, list, tid, fallbackAccount)
}
function chooseTenant(item) {
if (submitting.value) return
submitting.value = true
errorMsg.value = ''
uni.showLoading({ title: '进入中...', mask: true })
switchTenant({ tid: item.tid })
.then(async (res) => {
// request 已解包 data:{ tokens, identity }
if (!res || !res.tokens) {
errorMsg.value = (res && res.msg) || '进入企业失败'
return
}
await finishLogin(res.tokens, tenants.value, item.tid, account.value.trim())
})
.catch((err) => showError(err))
.finally(() => {
uni.hideLoading()
submitting.value = false
})
}
function backToAccount() {
clearAuthState()
step.value = 'password'
errorMsg.value = ''
}
/** 写入登录态并跳转首页 */
async function finishLogin(tokens, tenantList, tid, fallbackAccount = '') {
setAuthTokens(tokens || {})
const list = Array.isArray(tenantList) ? tenantList : []
setTenants(list)
// /auth/login 不返回身份信息,统一用 userinfo 补全(含 id / group_id)
let profile = null
try {
profile = await fetchUserInfo()
} catch {
profile = null
}
const currentTid = tid || (profile && profile.tid) || 0
const name = (profile && (profile.name || profile.nickname || profile.account)) || ''
setTid(currentTid)
setTenantName(resolveTenantName(profile || {}, currentTid))
loginSuccess({
token: getToken(),
user: {
id: (profile && (profile.id || profile.sub)) || 0,
account: (profile && (profile.account || profile.mobile)) || fallbackAccount,
name,
nickname: (profile && (profile.nickname || profile.name)) || name || fallbackAccount,
avatar: (profile && profile.avatar) || '',
phone: (profile && profile.mobile) || '',
group_id: (profile && profile.group_id) || '',
tid: currentTid,
tenant_name: resolveTenantName(profile || {}, currentTid)
}
})
uni.hideLoading()
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => {
uni.reLaunch({ url: '/pages/nav/nav' })
}, 400)
}
/* ==================== 微信小程序一键登录 ==================== */
/** 取 uni.login 的临时登录凭证 */
function uniLoginCode() {
return new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: (res) => {
if (res && res.code) resolve(res.code)
else reject(new Error('未获取到微信登录凭证'))
},
fail: () => reject(new Error('获取微信登录凭证失败'))
})
})
}
// #ifdef MP-WEIXIN
/** getPhoneNumber 授权回调:动态 code + uni.login code 一起换登录态 */
async function onGetPhoneNumber(e) {
if (submitting.value) return
const detail = (e && e.detail) || {}
if (detail.errMsg && detail.errMsg.indexOf('fail') !== -1) {
if (detail.errMsg.indexOf('deny') !== -1) {
uni.showToast({ title: '你已取消手机号授权', icon: 'none' })
}
return
}
if (!detail.code) {
uni.showToast({ title: '未获取到手机号凭证,请重试', icon: 'none' })
return
}
if (!agreed.value) {
const ok = await confirmAgreement()
if (!ok) {
showAgreementTip.value = true
return
}
}
submitting.value = true
errorMsg.value = ''
try {
const code = await uniLoginCode()
const data = await loginByMiniapp({ code, phoneCode: detail.code })
await handleAuthResult(data)
} catch (err) {
handleMiniError(err)
} finally {
submitting.value = false
}
}
/** 按后端返回的 reason 给出差异化引导 */
function handleMiniError(err) {
const reason = err && err.response && err.response.reason
if (reason === 'identity_not_found') {
uni.showModal({
title: '尚未绑定云泽账号',
content: '当前微信手机号未注册云泽账号,请先使用账号密码登录或注册后再使用一键登录。',
showCancel: false,
confirmText: '去注册',
success: (res) => {
if (res.confirm) goRegister()
}
})
return
}
if (reason === 'not_configured') {
showMiniLogin.value = false
}
showError(err)
}
// #endif
/* ==================== 工具 ==================== */
/** 只记住账号,绝不保存密码 */
function saveRememberAccount(acc) {
if (rememberMe.value) {
saveRememberLogin({ tenantName: '', account: acc })
} else {
clearRememberLogin()
}
}
/** 清除待选企业阶段的临时令牌,避免半登录态残留 */
function clearAuthState() {
clearPendingAuth()
}
function showError(err) {
errorMsg.value = (err && err.message) || '登录失败,请稍后重试'
}
function goRegister() {
uni.navigateTo({ url: '/pages/login/register' })
}
/** 勾选/取消用户协议:勾选后自动收起未勾选提示 */
function toggleAgree() {
agreed.value = !agreed.value
if (agreed.value) showAgreementTip.value = false
}
/** 协议占位:正式接入条款页后改为跳转条款页 */
function showAgreement() {
uni.showToast({ title: '条款内容待接入', icon: 'none' })
}
/**
* 未勾选协议时的二次确认弹窗。
* 点「同意并登录」→ 自动勾选协议并返回 true,调用方继续登录;
* 点「再看看」→ 返回 false,停在当前页并高亮提示。
* @returns {Promise<boolean>}
*/
function confirmAgreement() {
return new Promise((resolve) => {
uni.showModal({
title: '请确认用户协议',
content: '登录即表示您已阅读并同意《用户协议》与《隐私政策》,是否同意并继续登录?',
confirmText: '同意并登录',
cancelText: '再看看',
confirmColor: '#3973ff',
success: (res) => {
if (res.confirm) {
agreed.value = true
showAgreementTip.value = false
resolve(true)
} else {
resolve(false)
}
},
fail: () => resolve(false)
})
})
}
function goForget() {
uni.navigateTo({ url: '/pages/login/forget' })
}
</script>
<style lang="scss" scoped>
@import '@/src/styles/page-common.scss';
.login-page {
min-height: 100vh;
background: linear-gradient(160deg, #1b3b8f 0%, #2f6fe4 52%, #52a8ff 100%);
padding: 0 32rpx calc(env(safe-area-inset-bottom, 0px) + 40rpx);
box-sizing: border-box;
}
/* ---------- 品牌区 ---------- */
.brand {
display: flex;
flex-direction: column;
align-items: center;
padding-top: calc(env(safe-area-inset-top, 0px) + 96rpx);
padding-bottom: 56rpx;
}
.brand-logo {
width: 88rpx;
height: 88rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.18);
border: 1rpx solid rgba(255, 255, 255, 0.3);
display: flex;
align-items: center;
justify-content: center;
}
.brand-name {
margin-top: 20rpx;
font-size: 34rpx;
font-weight: 600;
letter-spacing: 1rpx;
color: #ffffff;
}
.brand-desc {
margin-top: 8rpx;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.76);
}
/* ---------- 表单卡片 ---------- */
.card {
background: $color-card;
border-radius: 24rpx;
padding: 44rpx 36rpx 36rpx;
box-shadow: 0 18rpx 50rpx rgba(37, 96, 169, 0.14);
}
.panel-head {
margin-bottom: 32rpx;
}
.panel-title {
display: block;
font-size: 40rpx;
font-weight: 600;
color: $color-text;
}
.panel-sub {
display: block;
margin-top: 10rpx;
font-size: 26rpx;
color: $color-text-secondary;
}
.error-box {
background: #fff2f0;
border: 1rpx solid #ffd4cf;
border-radius: 12rpx;
padding: 18rpx 20rpx;
margin-bottom: 24rpx;
}
.error-text {
font-size: 24rpx;
color: #cf1322;
line-height: 1.6;
}
.field {
margin-bottom: 28rpx;
}
.field-label {
display: block;
font-size: 26rpx;
color: $color-text-secondary;
margin-bottom: 14rpx;
font-weight: 500;
}
.input-wrap {
position: relative;
display: flex;
align-items: center;
background: #fbfcff;
border: 1rpx solid $color-border;
border-radius: $radius-md;
padding: 0 20rpx;
}
.field-input {
flex: 1;
min-width: 0;
height: 88rpx;
font-size: 28rpx;
color: $color-text;
}
.field-placeholder {
color: #adb6c9;
font-size: 28rpx;
}
.eye-btn {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.row-extra {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32rpx;
}
.remember {
display: flex;
align-items: center;
gap: 10rpx;
}
/* 记住账号勾选框:与协议勾选框同款对号,仅配色相反 */
.checkbox {
position: relative;
width: 32rpx;
height: 32rpx;
border-radius: 8rpx;
border: 2rpx solid $color-border;
background: $color-card;
flex-shrink: 0;
&.on {
background: $color-primary;
border-color: $color-primary;
}
&.on::after {
content: '';
position: absolute;
left: 50%;
top: 48%;
width: 7rpx;
height: 14rpx;
margin-left: -4rpx;
transform: translateY(-50%) rotate(45deg);
border-right: 3rpx solid #ffffff;
border-bottom: 3rpx solid #ffffff;
}
}
.remember-text {
font-size: 26rpx;
color: $color-text-secondary;
}
.btn {
height: 92rpx;
border-radius: $radius-md;
background: linear-gradient(135deg, #3973ff 0%, #52a8ff 100%);
color: #ffffff;
font-size: 32rpx;
font-weight: 500;
letter-spacing: 4rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 20rpx rgba(57, 115, 255, 0.28);
&.disabled {
opacity: 0.72;
}
}
.btn-wechat {
margin-top: 0;
height: 88rpx;
border-radius: $radius-md;
background: #07c160;
color: #ffffff;
font-size: 28rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
border: none;
padding: 0;
&::after {
border: none;
}
&.disabled {
opacity: 0.72;
}
}
.divider {
display: flex;
align-items: center;
gap: 16rpx;
margin: 40rpx 0 24rpx;
}
.divider-line {
flex: 1;
height: 1rpx;
background: $color-border;
}
.divider-text {
font-size: 24rpx;
color: #a3acbf;
}
.tip-row {
margin-top: 16rpx;
display: flex;
justify-content: center;
}
.tip-text {
font-size: 22rpx;
color: $color-text-muted;
line-height: 1.6;
}
.back-row {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
margin-top: 32rpx;
}
.link {
font-size: 26rpx;
color: #3973ff;
}
/* ---------- 企业选择 ---------- */
.tenant-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.tenant-item {
display: flex;
align-items: center;
gap: 20rpx;
padding: 24rpx 24rpx;
border: 1rpx solid $color-border;
border-radius: $radius-md;
background: #fbfcff;
&:active {
border-color: #3973ff;
background: #f5f9ff;
}
&.disabled {
opacity: 0.6;
}
}
.tenant-avatar {
width: 72rpx;
height: 72rpx;
flex-shrink: 0;
border-radius: 16rpx;
background: rgba(57, 115, 255, 0.12);
display: flex;
align-items: center;
justify-content: center;
}
.tenant-info {
flex: 1;
min-width: 0;
}
.tenant-name {
display: block;
font-size: 30rpx;
font-weight: 500;
color: $color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tenant-meta {
display: block;
margin-top: 6rpx;
font-size: 22rpx;
color: $color-text-muted;
}
/* ---------- 协议与页脚 ---------- */
.agreement {
display: flex;
align-items: flex-start;
gap: 12rpx;
margin-top: 32rpx;
padding: 0 8rpx;
}
/* 协议勾选框:对号用 CSS 绘制(不依赖字体图标,任何端渲染一致) */
.agree-dot {
position: relative;
width: 34rpx;
height: 34rpx;
border-radius: 8rpx;
border: 2rpx solid rgba(255, 255, 255, 0.75);
background: rgba(255, 255, 255, 0.14);
flex-shrink: 0;
margin-top: 2rpx;
&.on {
background: #ffffff;
border-color: #ffffff;
}
/* 对号:两条边 + 旋转 45°,比图标字体更清晰 */
&.on::after {
content: '';
position: absolute;
left: 50%;
top: 48%;
width: 8rpx;
height: 16rpx;
margin-left: -5rpx;
transform: translateY(-50%) rotate(45deg);
border-right: 3rpx solid #1b3b8f;
border-bottom: 3rpx solid #1b3b8f;
}
}
.agree-text {
flex: 1;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.78);
line-height: 1.7;
}
.agree-link {
color: #ffffff;
}
/* 未勾选协议时的行内提示 */
.agree-tip {
display: flex;
align-items: center;
gap: 8rpx;
margin-top: 16rpx;
padding: 12rpx 18rpx;
border-radius: $radius-sm;
background: rgba(247, 144, 9, 0.16);
}
.agree-tip-text {
font-size: 22rpx;
color: #ffd591;
line-height: 1.5;
}
.foot-tip {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
margin-top: 40rpx;
}
.foot-tip-text {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.62);
}
</style>