1046 lines
24 KiB
Vue
1046 lines
24 KiB
Vue
<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="#2563eb" :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>
|
||
|
||
<!-- ============ 登录(小程序端微信/账号密码 Tab 切换;H5/APK 仅账号密码) ============ -->
|
||
<block v-else>
|
||
<view class="panel-head">
|
||
<text class="panel-title">欢迎登录</text>
|
||
<text class="panel-sub">{{ panelSub }}</text>
|
||
</view>
|
||
|
||
<view v-if="errorMsg" class="error-box">
|
||
<text class="error-text">{{ errorMsg }}</text>
|
||
</view>
|
||
|
||
<!-- #ifdef MP-WEIXIN -->
|
||
<!-- 微信端且服务端已开通:微信登录 / 账号密码登录 Tab 切换 -->
|
||
<view v-if="showMiniLogin" class="login-tabs">
|
||
<view
|
||
class="login-tab"
|
||
:class="{ on: activeTab === 'wechat' }"
|
||
@tap="switchTab('wechat')"
|
||
>
|
||
<text class="login-tab-text">微信登录</text>
|
||
<view class="login-tab-bar" />
|
||
</view>
|
||
<view
|
||
class="login-tab"
|
||
:class="{ on: activeTab === 'account' }"
|
||
@tap="switchTab('account')"
|
||
>
|
||
<text class="login-tab-text">账号密码登录</text>
|
||
<view class="login-tab-bar" />
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 微信登录面板 -->
|
||
<block v-if="showMiniLogin && activeTab === 'wechat'">
|
||
<button
|
||
class="btn-wechat"
|
||
open-type="getPhoneNumber"
|
||
:class="{ disabled: submitting }"
|
||
@getphonenumber="onGetPhoneNumber"
|
||
>
|
||
<FaIcon name="weixin" type="brands" color="#ffffff" :size="18" />
|
||
<text>微信手机号一键登录</text>
|
||
</button>
|
||
<view class="tip-row">
|
||
<text class="tip-text">首次使用请先用账号密码登录一次,自动完成微信绑定后即可一键登录</text>
|
||
</view>
|
||
<view class="back-row" @tap="switchTab('account')">
|
||
<text class="link">使用账号密码登录</text>
|
||
</view>
|
||
</block>
|
||
<!-- #endif -->
|
||
|
||
<!-- 账号密码面板(未开通微信登录的端始终展示) -->
|
||
<block v-if="!(showMiniLogin && activeTab === 'wechat')">
|
||
<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 class="back-row">
|
||
<text class="tip-text">还没有账号?</text>
|
||
<text class="link" @tap="goRegister">立即注册</text>
|
||
</view>
|
||
</block>
|
||
|
||
<!-- 未勾选协议提示:两个 Tab 面板共用 -->
|
||
<view v-if="!agreed && showAgreementTip" class="agree-tip">
|
||
<FaIcon name="circle-info" color="#f79009" :size="12" />
|
||
<text class="agree-tip-text">请先阅读并勾选同意《用户协议》和《隐私政策》</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>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import {
|
||
loginUAC,
|
||
switchTenant,
|
||
fetchUserInfo,
|
||
getAuthVerifyConfig,
|
||
getMiniappLoginConfig,
|
||
loginByMiniapp,
|
||
bindMiniapp
|
||
} 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')
|
||
/** 小程序端登录方式 Tab:wechat / account(其他端固定账号密码) */
|
||
const activeTab = ref('wechat')
|
||
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)
|
||
|
||
/** 面板副标题:随 Tab 切换提示文案 */
|
||
const panelSub = computed(() => {
|
||
if (platform.value === 'mp-weixin' && showMiniLogin.value && activeTab.value === 'wechat') {
|
||
return '使用微信一键登录,或切换到账号密码登录'
|
||
}
|
||
return '请输入您的账号与密码以继续'
|
||
})
|
||
|
||
/** 切换登录方式 Tab:清掉上一次残留的错误提示 */
|
||
function switchTab(tab) {
|
||
if (submitting.value) return
|
||
if (activeTab.value === tab) return
|
||
activeTab.value = tab
|
||
errorMsg.value = ''
|
||
}
|
||
|
||
/** 运行端检测:h5 / apk(app-plus) / mp-weixin,决定登录方式优先级 */
|
||
const platform = ref('')
|
||
// #ifdef H5
|
||
platform.value = 'h5'
|
||
// #endif
|
||
// #ifdef APP-PLUS
|
||
platform.value = 'apk'
|
||
// #endif
|
||
// #ifdef MP-WEIXIN
|
||
platform.value = 'mp-weixin'
|
||
// #endif
|
||
|
||
onMounted(async () => {
|
||
if (isLoggedIn()) {
|
||
uni.reLaunch({ url: '/pages/nav/nav' })
|
||
return
|
||
}
|
||
|
||
const remembered = getRememberLogin()
|
||
if (remembered.rememberMe) {
|
||
rememberMe.value = true
|
||
account.value = remembered.account
|
||
// 记住了账号,默认落在账号密码 Tab 更顺手
|
||
activeTab.value = '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() {
|
||
if (platform.value !== 'mp-weixin') return
|
||
try {
|
||
const data = await getMiniappLoginConfig()
|
||
showMiniLogin.value = !!data?.enabled
|
||
} catch {
|
||
showMiniLogin.value = false
|
||
}
|
||
}
|
||
|
||
/* ==================== 登录主流程 ==================== */
|
||
|
||
async function handleLogin() {
|
||
console.log('[login] tap handleLogin', { submitting: submitting.value, agreed: agreed.value })
|
||
if (submitting.value) {
|
||
uni.showToast({ title: '登录处理中,请稍候', icon: 'none' })
|
||
return
|
||
}
|
||
// 未勾选协议:弹窗确认,确认后自动勾选并继续登录
|
||
if (!agreed.value) {
|
||
let ok = false
|
||
try {
|
||
ok = await confirmAgreement()
|
||
} catch {
|
||
ok = false
|
||
}
|
||
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
|
||
// 小程序端无极验 SDK:不弹验证,后端对 yz-miniapp 客户端也跳过校验
|
||
if (verifyCfg.value.needGeetest && platform.value !== 'mp-weixin') {
|
||
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)
|
||
}
|
||
})
|
||
|
||
// 微信端:登录成功后静默绑定 openid,认证中/无手机号能力时一键登录靠它认人
|
||
await silentBindMiniapp()
|
||
|
||
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('获取微信登录凭证失败'))
|
||
})
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 微信端登录成功后静默把当前 openid 绑到该账号。
|
||
* 小程序认证中 / 未开通手机号能力时,一键登录拿不到手机号,
|
||
* 后端按这条绑定认人;绑定失败不阻塞登录。
|
||
*/
|
||
async function silentBindMiniapp() {
|
||
if (platform.value !== 'mp-weixin') return
|
||
try {
|
||
const code = await uniLoginCode()
|
||
await bindMiniapp({ code })
|
||
} catch {
|
||
/* 静默:不影响已完成的登录 */
|
||
}
|
||
}
|
||
|
||
// #ifdef MP-WEIXIN
|
||
/** getPhoneNumber 授权回调:动态 code + uni.login code 一起换登录态 */
|
||
async function onGetPhoneNumber(e) {
|
||
if (submitting.value) return
|
||
const detail = (e && e.detail) || {}
|
||
const errMsg = detail.errMsg || ''
|
||
if (errMsg.indexOf('deny') !== -1) {
|
||
uni.showToast({ title: '你已取消手机号授权', icon: 'none' })
|
||
return
|
||
}
|
||
// 认证中 / 未开通手机号能力:拿不到动态 code 时降级为「按已绑定 openid 登录」
|
||
const phoneCode = detail.code && errMsg.indexOf('fail') === -1 ? detail.code : ''
|
||
if (!phoneCode) {
|
||
uni.showToast({ title: '手机号能力暂不可用,按已绑定账号登录', icon: 'none' })
|
||
}
|
||
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 })
|
||
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: true,
|
||
cancelText: '知道了',
|
||
// 微信小程序 showModal 的 confirmText 上限为 4 个汉字,超限会导致 API 失败、弹窗不显示
|
||
confirmText: '去登录',
|
||
confirmColor: '#2563eb',
|
||
success: (res) => {
|
||
if (res.confirm) switchTab('account')
|
||
}
|
||
})
|
||
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,停在当前页并高亮提示。
|
||
* 兜底:部分端 showModal 既不回调 success 也不回调 fail,会导致 await 永久挂起、
|
||
* 点击登录「没反应」,这里用超时保证一定会返回,绝不阻塞。
|
||
* @returns {Promise<boolean>}
|
||
*/
|
||
function confirmAgreement() {
|
||
return new Promise((resolve) => {
|
||
let settled = false
|
||
let timer = null
|
||
const finish = (val) => {
|
||
if (settled) return
|
||
settled = true
|
||
if (timer) clearTimeout(timer)
|
||
resolve(val)
|
||
}
|
||
timer = setTimeout(() => {
|
||
console.warn('[login] confirmAgreement 超时未回调,按未同意处理')
|
||
finish(false)
|
||
}, 8000)
|
||
uni.showModal({
|
||
title: '请确认用户协议',
|
||
content: '登录即表示您已阅读并同意《用户协议》与《隐私政策》,是否同意并继续登录?',
|
||
confirmText: '同意登录',
|
||
cancelText: '再看看',
|
||
confirmColor: '#2563eb',
|
||
success: (res) => {
|
||
if (res.confirm) {
|
||
agreed.value = true
|
||
showAgreementTip.value = false
|
||
finish(true)
|
||
} else {
|
||
finish(false)
|
||
}
|
||
},
|
||
fail: () => finish(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, #1e3a8a 0%, #2563eb 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, #2563eb 0%, #60a5fa 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;
|
||
}
|
||
}
|
||
|
||
/* 登录方式 Tab:微信登录 / 账号密码登录 */
|
||
.login-tabs {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 48rpx;
|
||
margin-bottom: 36rpx;
|
||
border-bottom: 1rpx solid $color-border;
|
||
}
|
||
|
||
.login-tab {
|
||
position: relative;
|
||
padding: 0 4rpx 20rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
|
||
.login-tab-text {
|
||
font-size: 28rpx;
|
||
color: $color-text-secondary;
|
||
}
|
||
|
||
.login-tab.on .login-tab-text {
|
||
font-size: 30rpx;
|
||
font-weight: 600;
|
||
color: #2563eb;
|
||
}
|
||
|
||
.login-tab-bar {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
width: 0;
|
||
height: 6rpx;
|
||
border-radius: 6rpx;
|
||
background: #2563eb;
|
||
transition: width 0.2s ease;
|
||
}
|
||
|
||
.login-tab.on .login-tab-bar {
|
||
width: 56rpx;
|
||
}
|
||
|
||
.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: #2563eb;
|
||
}
|
||
|
||
/* ---------- 企业选择 ---------- */
|
||
.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: #2563eb;
|
||
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 #1e3a8a;
|
||
border-bottom: 3rpx solid #1e3a8a;
|
||
}
|
||
}
|
||
|
||
.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>
|