1296 lines
31 KiB
Vue
1296 lines
31 KiB
Vue
<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> |