Files
yunzerwebsiteallinone/uniapp/pages/login/login.vue
T
2026-11-11 16:58:30 +08:00

1296 lines
31 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="deco deco-a" />
<view class="deco deco-b" />
<!-- 顶部自绘导航(账号密码 / 选择企业视图) -->
<view v-if="step !== 'main'" class="nav-bar" :style="{ paddingTop: navPadTop }">
<view class="nav-back" :class="{ invisible: !canGoBack }" @tap="onNavBack">
<FaIcon name="chevron-left" color="#1e293b" :size="20" />
</view>
<text class="nav-title">{{ navTitle }}</text>
<view class="nav-side" />
</view>
<!-- 品牌区 + 表单卡片 -->
<view class="login-body">
<!-- 品牌区 -->
<view class="brand" :class="{ compact: step !== 'main' }" :style="{ paddingTop: brandPadTop }">
<view class="brand-logo">
<FaIcon name="shield-halved" color="#ffffff" :size="22" />
</view>
<text class="brand-name">温新云统一认证</text>
<text class="brand-desc">一次登录,全平台通行</text>
</view>
<view class="card">
<!-- ============ 主视图:微信一键登录 ============ -->
<block v-if="step === 'main'">
<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>
<!-- #ifdef MP-WEIXIN -->
<!-- 微信端且服务端已开通:一键登录主 CTA -->
<block v-if="showMiniLogin">
<button
class="btn-wechat"
open-type="getPhoneNumber"
:class="{ disabled: submitting }"
@getphonenumber="onGetPhoneNumber"
>
<FaIcon name="weixin" type="brands" color="#ffffff" :size="20" />
<text class="btn-wechat-text">{{ submitting ? '登录中...' : '微信手机号一键登录' }}</text>
</button>
<view class="tip-row">
<text class="tip-text">首次使用请先用账号密码登录一次,自动完成微信绑定后即可一键登录</text>
</view>
</block>
<view v-else class="loading-box">
<text class="tip-text">正在加载登录方式…</text>
</view>
<!-- #endif -->
<!-- 弱化入口:切换到独立的账号密码登录视图 -->
<view class="alt-entry" @tap="goAccount">
<view class="alt-line" />
<text class="alt-text">使用账号密码登录</text>
<FaIcon name="chevron-right" color="#94a3b8" :size="12" />
<view class="alt-line" />
</view>
<!-- 返回门户:不登录也能先用免登录功能 -->
<view class="back-portal" @tap="goPortal">
<FaIcon name="chevron-left" color="#94a3b8" :size="12" />
<text class="back-portal-text">返回门户,先使用免登录功能</text>
</view>
</block>
<!-- ============ 账号密码登录(独立视图) ============ -->
<block v-else-if="step === 'account'">
<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">
<FaIcon name="user" color="#94a3b8" :size="16" />
<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">
<FaIcon name="lock" color="#94a3b8" :size="16" />
<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>
</view>
<!-- 返回门户:不登录也能先用免登录功能 -->
<view class="back-portal" @tap="goPortal">
<FaIcon name="chevron-left" color="#94a3b8" :size="12" />
<text class="back-portal-text">返回门户,先使用免登录功能</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="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>
</block>
<!-- ============ 协议(企业选择视图无需) ============ -->
<block v-if="step !== 'tenant'">
<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 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>
<view class="foot-tip">
<FaIcon name="shield-halved" color="#94a3b8" :size="12" />
<text class="foot-tip-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 { getGuestToken, clearGuestToken } from '@/utils/guest.js'
import { guestMerge } from '@/api/guest.js'
import FaIcon from '@/components/FaIcon.vue'
/** 运行端检测: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
/**
* 视图状态:
* main 微信一键登录主入口(小程序端默认视图)
* account 账号密码登录(独立视图,由主视图弱化入口进入)
* tenant 选择企业(一人多企业)
*/
const step = ref(platform.value === 'mp-weixin' ? 'main' : 'account')
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)
/** 顶部导航标题 */
const navTitle = computed(() => {
if (step.value === 'tenant') return '选择企业'
return '账号密码登录'
})
/**
* 状态栏高度(px)。
* 小程序端 env(safe-area-inset-top) 在无刘海的安卓机型返回 0,
* 自绘导航会被状态栏压住,因此这里取系统信息里的真实状态栏高度。
*/
const statusBarHeight = ref(0)
try {
const info =
typeof uni.getWindowInfo === 'function' ? uni.getWindowInfo() : uni.getSystemInfoSync()
statusBarHeight.value = (info && info.statusBarHeight) || 0
} catch (e) {
statusBarHeight.value = 0
}
/** 自绘导航的上内边距:状态栏高度(导航本体固定 88rpx) */
const navPadTop = computed(() => `${statusBarHeight.value}px`)
/** 品牌区上内边距:主视图需让开状态栏,紧凑视图(账号/企业)由导航栏已占位 */
const brandPadTop = computed(() => {
if (step.value !== 'main') return '32rpx'
return `${statusBarHeight.value + uni.upx2px(88)}px`
})
/** 是否显示可返回的上级箭头(账号视图无论能否退回主视图都可见:退不了就回门户) */
const canGoBack = computed(() => {
if (step.value === 'tenant') return true
if (step.value === 'account') return true
return false
})
/** 切换到账号密码登录视图 */
function goAccount() {
if (submitting.value) return
step.value = 'account'
errorMsg.value = ''
}
/** 返回微信一键登录主视图 */
function goMain() {
if (submitting.value) return
if (!showMiniLogin.value) return
step.value = 'main'
errorMsg.value = ''
}
/** 顶部导航返回:登录内部层级优先逐级退;无可退上级时返回门户(免登录页) */
function onNavBack() {
if (step.value === 'tenant') {
backToAccount()
return
}
if (step.value === 'account' && showMiniLogin.value) {
goMain()
return
}
// 无登录内部上级 → 返回门户,继续用免登录功能
goPortal()
}
/**
* 返回门户首页(nav)。
* 多数从 nav 点「需登录」模块进来,直接 navigateBack 回上一页(保留游客宫格);
* 若是被 401 兑回登录页等无栈场景,回落到 reLaunch nav 以游客态重载。
*/
function goPortal() {
if (submitting.value) return
const pages = (typeof getCurrentPages === 'function' && getCurrentPages()) || []
if (pages.length > 1) uni.navigateBack()
else uni.reLaunch({ url: '/pages/nav/nav' })
}
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() {
if (platform.value !== 'mp-weixin') return
try {
const data = await getMiniappLoginConfig()
showMiniLogin.value = !!data?.enabled
} catch {
showMiniLogin.value = false
}
// 服务端未开通一键登录:直接落到账号密码视图
if (!showMiniLogin.value && step.value === 'main') {
step.value = 'account'
}
}
/* ==================== 登录主流程 ==================== */
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 = 'account'
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()
// 游客数据转正:持游客令牌(手机号授权)时静默并入账号;
// 失败不阻断跳转(令牌保留,下次登录自动重试;令牌失效则由 401 自动清理)
if (getGuestToken()) {
try {
await guestMerge()
clearGuestToken()
} catch (e) {
console.warn('[login] guest merge failed:', e)
}
}
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
}
// 手机号授权码可能不可用;仍继续尝试按已绑定的 openid 登录
const phoneCode = detail.code && errMsg.indexOf('fail') === -1 ? detail.code : ''
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: '知道了',
confirmText: '账号登录',
confirmColor: '#2563eb',
success: (res) => {
if (res.confirm) goAccount()
}
})
return
}
if (reason === 'not_configured') {
showMiniLogin.value = false
step.value = 'account'
}
showError(err)
}
// #endif
/* ==================== 工具 ==================== */
/** 只记住账号,绝不保存密码 */
function saveRememberAccount(acc) {
if (rememberMe.value) {
saveRememberLogin({ tenantName: '', account: acc })
} else {
clearRememberLogin()
}
}
/** 清除待选企业阶段的临时令牌,避免半登录态残留 */
function clearAuthState() {
clearPendingAuth()
}
function showError(err) {
const raw = (err && err.message) || ''
// 微信未配置 request 合法域名:原始报错是英文,给出可操作的中文提示
if (/url not in domain list/i.test(raw)) {
errorMsg.value =
'当前小程序未配置服务器域名,请在微信公众平台「开发管理 - 开发设置 - 服务器域名」中添加 api.yunzer.cn 为 request 合法域名'
return
}
errorMsg.value = raw || '登录失败,请稍后重试'
}
/** 勾选/取消用户协议:勾选后自动收起未勾选提示 */
function toggleAgree() {
agreed.value = !agreed.value
if (agreed.value) showAgreementTip.value = false
}
function openLegal(path, title) {
uni.navigateTo({ url: `/pages/legal/document?path=${encodeURIComponent(path)}&title=${encodeURIComponent(title)}` })
}
/**
* 未勾选协议时的二次确认弹窗。
* 点「同意登录」→ 自动勾选协议并返回 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 {
position: relative;
min-height: 100vh;
box-sizing: border-box;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, #eef4ff 0%, #ffffff 38%, #f7f9fc 100%);
padding: 0 32rpx calc(env(safe-area-inset-bottom, 0px) + 36rpx);
overflow: hidden;
}
/* 主体(品牌 + 卡片):自动上下外边距实现垂直居中,内容超高时不裁切 */
.login-body {
margin: auto 0;
}
/* 背景光斑:纯装饰,径向渐变让边缘自然消隐 */
.deco {
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.deco-a {
width: 560rpx;
height: 560rpx;
top: -260rpx;
right: -240rpx;
background: radial-gradient(circle, rgba(96, 165, 250, 0.26) 0%, rgba(96, 165, 250, 0) 70%);
}
.deco-b {
width: 520rpx;
height: 520rpx;
bottom: -260rpx;
left: -240rpx;
background: radial-gradient(circle, rgba(37, 99, 235, 0.14) 0%, rgba(37, 99, 235, 0) 70%);
}
/* ---------- 顶部自绘导航 ---------- */
.nav-bar {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
/* 高度不含状态栏内边距:padding-top 由脚本按真实状态栏高度注入 */
box-sizing: content-box;
height: 88rpx;
margin-top: 8rpx;
}
.nav-back {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: flex-start;
flex-shrink: 0;
&.invisible {
opacity: 0;
}
}
.nav-side {
width: 64rpx;
flex-shrink: 0;
}
.nav-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 32rpx;
font-weight: 600;
color: $color-text;
}
/* ---------- 品牌区 ---------- */
.brand {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
/* 上内边距由脚本按状态栏高度注入,这里给无脚本时的兜底值 */
padding-top: 88rpx;
padding-bottom: 48rpx;
&.compact {
padding-bottom: 36rpx;
.brand-logo {
width: 76rpx;
height: 76rpx;
border-radius: 22rpx;
}
.brand-name {
margin-top: 16rpx;
font-size: 32rpx;
}
.brand-desc {
font-size: 22rpx;
}
}
}
.brand-logo {
width: 96rpx;
height: 96rpx;
border-radius: 28rpx;
background: linear-gradient(135deg, #60a5fa 0%, #2563eb 100%);
box-shadow: 0 14rpx 30rpx rgba(37, 99, 235, 0.28);
display: flex;
align-items: center;
justify-content: center;
}
.brand-name {
margin-top: 22rpx;
font-size: 38rpx;
font-weight: 600;
letter-spacing: 2rpx;
color: $color-text;
}
.brand-desc {
margin-top: 10rpx;
font-size: 24rpx;
color: $color-text-secondary;
}
/* ---------- 表单卡片 ---------- */
.card {
position: relative;
z-index: 2;
background: $color-card;
border: 1rpx solid rgba(226, 232, 240, 0.9);
border-radius: $radius-lg;
padding: 44rpx 36rpx 32rpx;
box-shadow: 0 2rpx 8rpx rgba(15, 23, 42, 0.04), 0 18rpx 44rpx rgba(37, 99, 235, 0.1);
}
.panel-head {
margin-bottom: 32rpx;
}
.panel-title {
display: block;
font-size: 42rpx;
font-weight: 700;
letter-spacing: 1rpx;
color: #183b63;
}
.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;
}
.loading-box {
display: flex;
align-items: center;
justify-content: center;
height: 96rpx;
}
.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;
gap: 14rpx;
background: #f8fafc;
border: 1rpx solid $color-border;
border-radius: $radius-md;
padding: 0 20rpx;
box-shadow: inset 0 2rpx 8rpx rgba(104, 158, 202, 0.05);
}
.field-input {
flex: 1;
min-width: 0;
height: 92rpx;
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;
}
}
/* 主 CTA:微信一键登录(品牌蓝,弱化微信绿以保持蓝白统一) */
.btn-wechat {
margin-top: 0;
height: 96rpx;
border-radius: 20rpx;
background: linear-gradient(135deg, #2563eb 0%, #4f8ef7 100%);
color: #ffffff;
font-size: 32rpx;
font-weight: 500;
letter-spacing: 2rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
border: none;
padding: 0;
box-shadow: 0 12rpx 26rpx rgba(37, 99, 235, 0.32);
&::after {
border: none;
}
&.disabled {
opacity: 0.72;
}
}
.btn-wechat-text {
font-size: 32rpx;
color: #ffffff;
}
.tip-row {
margin-top: 18rpx;
display: flex;
justify-content: center;
}
.tip-text {
font-size: 22rpx;
color: $color-text-muted;
line-height: 1.6;
}
/* 弱化入口:切换账号密码登录 */
.alt-entry {
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
margin-top: 44rpx;
}
.alt-line {
width: 80rpx;
height: 1rpx;
background: $color-border;
}
.alt-text {
font-size: 26rpx;
color: $color-text-muted;
}
/* 弱化入口:切换账号密码登录 */
.alt-entry {
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
margin-top: 44rpx;
}
.alt-line {
width: 80rpx;
height: 1rpx;
background: $color-border;
}
.alt-text {
font-size: 26rpx;
color: $color-text-muted;
}
.back-row {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
margin-top: 32rpx;
}
/* 返回门户:不登录也能先浏览免登录模块 */
.back-portal {
display: flex;
align-items: center;
justify-content: center;
gap: 6rpx;
margin-top: 28rpx;
padding: 8rpx 0;
}
.back-portal:active {
opacity: 0.6;
}
.back-portal-text {
font-size: 24rpx;
color: $color-text-muted;
}
.link {
font-size: 26rpx;
color: $color-primary;
}
/* ---------- 企业选择 ---------- */
.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 {
position: relative;
z-index: 1;
display: flex;
align-items: flex-start;
gap: 12rpx;
margin-top: 36rpx;
padding: 0 4rpx;
}
/* 协议勾选框:对号用 CSS 绘制(不依赖字体图标,任何端渲染一致) */
.agree-dot {
position: relative;
width: 34rpx;
height: 34rpx;
border-radius: 8rpx;
border: 2rpx solid #cbd5e1;
background: $color-card;
flex-shrink: 0;
margin-top: 2rpx;
&.on {
background: $color-primary;
border-color: $color-primary;
}
/* 对号:两条边 + 旋转 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 #ffffff;
border-bottom: 3rpx solid #ffffff;
}
}
.agree-text {
flex: 1;
font-size: 22rpx;
color: $color-text-secondary;
line-height: 1.7;
}
.agree-link {
color: $color-primary;
}
/* 未勾选协议时的行内提示 */
.agree-tip {
display: flex;
align-items: center;
gap: 8rpx;
margin-top: 16rpx;
padding: 12rpx 18rpx;
border-radius: $radius-sm;
background: #fff7e6;
border: 1rpx solid #ffe0a3;
}
.agree-tip-text {
font-size: 22rpx;
color: #b45309;
line-height: 1.5;
}
/* ---------- 页脚 ---------- */
.foot-tip {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
margin-top: 40rpx;
}
.foot-tip-text {
font-size: 22rpx;
color: $color-text-muted;
}
</style>