Files
yunzerwebsiteallinone/uniapp/pages/login/login.vue
T
2026-10-31 19:17:37 +08:00

1046 lines
24 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="#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>