633 lines
14 KiB
Vue
633 lines
14 KiB
Vue
<template>
|
||
<view class="login-page">
|
||
<view class="page-inner">
|
||
<view class="header">
|
||
<text class="title">欢迎回来</text>
|
||
<text class="subtitle">登录云泽,开启你的旅程</text>
|
||
</view>
|
||
|
||
<view class="tabs">
|
||
<view
|
||
class="tab"
|
||
:class="{ active: loginType === 'phone' }"
|
||
@tap="loginType = 'phone'"
|
||
>手机登录</view>
|
||
<view
|
||
class="tab"
|
||
:class="{ active: loginType === 'account' }"
|
||
@tap="loginType = 'account'"
|
||
>账号登录</view>
|
||
</view>
|
||
|
||
<view class="form-card">
|
||
<view class="form">
|
||
<view class="field">
|
||
<u-input
|
||
v-model="tenantName"
|
||
placeholder="请输入租户名称"
|
||
border="none"
|
||
color="#303133"
|
||
:customStyle="inputStyle"
|
||
:placeholderStyle="placeholderStyle"
|
||
/>
|
||
</view>
|
||
|
||
<template v-if="loginType === 'phone'">
|
||
<view class="field">
|
||
<u-input
|
||
v-model="phone"
|
||
placeholder="请输入手机号"
|
||
type="number"
|
||
maxlength="11"
|
||
border="none"
|
||
color="#303133"
|
||
:customStyle="inputStyle"
|
||
:placeholderStyle="placeholderStyle"
|
||
>
|
||
<template #prefix>
|
||
<text class="prefix">+86</text>
|
||
</template>
|
||
</u-input>
|
||
</view>
|
||
<view class="field field-row">
|
||
<u-input
|
||
v-model="smsCode"
|
||
placeholder="请输入验证码"
|
||
type="number"
|
||
maxlength="6"
|
||
border="none"
|
||
color="#303133"
|
||
:customStyle="inputStyle"
|
||
:placeholderStyle="placeholderStyle"
|
||
/>
|
||
<text
|
||
class="code-link"
|
||
:class="{ disabled: countdown > 0 || sendingCode }"
|
||
@tap="sendPhoneCode"
|
||
>{{ countdown > 0 ? `${countdown}s 后重发` : (sendingCode ? '发送中' : '获取验证码') }}</text>
|
||
</view>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<view class="field">
|
||
<u-input
|
||
v-model="username"
|
||
placeholder="请输入账号"
|
||
border="none"
|
||
color="#303133"
|
||
:customStyle="inputStyle"
|
||
:placeholderStyle="placeholderStyle"
|
||
/>
|
||
</view>
|
||
<view class="field">
|
||
<u-input
|
||
v-model="password"
|
||
placeholder="请输入密码"
|
||
type="password"
|
||
border="none"
|
||
color="#303133"
|
||
:customStyle="inputStyle"
|
||
:placeholderStyle="placeholderStyle"
|
||
/>
|
||
</view>
|
||
<!-- 后台开启短信/邮箱登录校验时展示 -->
|
||
<view v-if="needLoginCode" class="field field-row">
|
||
<u-input
|
||
v-model="verifyCode"
|
||
placeholder="请输入登录验证码"
|
||
type="number"
|
||
maxlength="8"
|
||
border="none"
|
||
color="#303133"
|
||
:customStyle="inputStyle"
|
||
:placeholderStyle="placeholderStyle"
|
||
/>
|
||
<text
|
||
class="code-link"
|
||
:class="{ disabled: countdown > 0 || sendingCode }"
|
||
@tap="sendAccountVerifyCode"
|
||
>{{ countdown > 0 ? `${countdown}s 后重发` : (sendingCode ? '发送中' : '获取验证码') }}</text>
|
||
</view>
|
||
<view v-if="verifyHint" class="verify-hint">
|
||
<text>{{ verifyHint }}</text>
|
||
</view>
|
||
<view class="remember-row" @tap="rememberMe = !rememberMe">
|
||
<view class="remember-dot" :class="{ on: rememberMe }">
|
||
<text v-if="rememberMe" class="remember-check">✓</text>
|
||
</view>
|
||
<text class="remember-text">记住我</text>
|
||
</view>
|
||
</template>
|
||
|
||
<view class="btn-primary" :class="{ disabled: submitting }" @tap="handleLogin">
|
||
{{ submitting ? '登录中...' : '登 录' }}
|
||
</view>
|
||
|
||
<view class="link-row">
|
||
<text class="link" @tap="goRegister">注册账号</text>
|
||
<text class="link" @tap="goForget">忘记密码</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="agreement" @tap="agreed = !agreed">
|
||
<view class="agree-dot" :class="{ on: agreed }">
|
||
<text v-if="agreed" class="agree-check">✓</text>
|
||
</view>
|
||
<text class="agree-text">
|
||
登录即表示同意
|
||
<text class="agree-link">《用户协议》</text>
|
||
和
|
||
<text class="agree-link">《隐私政策》</text>
|
||
</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||
import {
|
||
login,
|
||
loginBySms,
|
||
sendLoginCode,
|
||
fetchVerifyConfig
|
||
} from '@/api/auth.js'
|
||
import {
|
||
loginSuccess,
|
||
isLoggedIn,
|
||
setTenantName,
|
||
getTenantName,
|
||
getRememberLogin,
|
||
saveRememberLogin,
|
||
clearRememberLogin
|
||
} from '@/utils/auth.js'
|
||
import { showGeetest4 } from '@/utils/geetest.js'
|
||
|
||
const loginType = ref('account')
|
||
const tenantName = ref('')
|
||
const phone = ref('')
|
||
const smsCode = ref('')
|
||
const username = ref('')
|
||
const password = ref('')
|
||
const verifyCode = ref('')
|
||
const agreed = ref(true)
|
||
const rememberMe = ref(false)
|
||
const countdown = ref(0)
|
||
const sendingCode = ref(false)
|
||
const submitting = ref(false)
|
||
const openVerify = ref(false)
|
||
const verifyType = ref('')
|
||
let timer = null
|
||
|
||
const inputStyle = {
|
||
backgroundColor: 'transparent',
|
||
padding: '0 8rpx',
|
||
height: '96rpx',
|
||
fontSize: '28rpx'
|
||
}
|
||
const placeholderStyle = 'color: #909399; font-size: 28rpx'
|
||
|
||
const needLoginCode = computed(() => {
|
||
return openVerify.value && (verifyType.value === 'sms' || verifyType.value === 'email')
|
||
})
|
||
|
||
const needGeetest = computed(() => {
|
||
return loginType.value === 'account' && !needLoginCode.value
|
||
})
|
||
|
||
const verifyHint = computed(() => {
|
||
if (!openVerify.value) return ''
|
||
if (verifyType.value === 'sms') return '已开启短信验证,请先获取验证码'
|
||
if (verifyType.value === 'email') return '已开启邮箱验证,请先获取验证码'
|
||
return ''
|
||
})
|
||
|
||
onMounted(async () => {
|
||
if (isLoggedIn()) {
|
||
uni.reLaunch({ url: '/pages/dashboard/dashboard' })
|
||
return
|
||
}
|
||
const remembered = getRememberLogin()
|
||
if (remembered.rememberMe) {
|
||
rememberMe.value = true
|
||
tenantName.value = remembered.tenantName
|
||
username.value = remembered.account
|
||
password.value = remembered.password
|
||
} else {
|
||
tenantName.value = getTenantName()
|
||
}
|
||
const cfg = await fetchVerifyConfig()
|
||
openVerify.value = cfg.openVerify
|
||
verifyType.value = cfg.verifyType
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (timer) clearInterval(timer)
|
||
})
|
||
|
||
function startCountdown() {
|
||
countdown.value = 60
|
||
if (timer) clearInterval(timer)
|
||
timer = setInterval(() => {
|
||
countdown.value--
|
||
if (countdown.value <= 0) {
|
||
clearInterval(timer)
|
||
timer = null
|
||
}
|
||
}, 1000)
|
||
}
|
||
|
||
async function sendPhoneCode() {
|
||
if (countdown.value > 0 || sendingCode.value) return
|
||
if (!tenantName.value.trim()) {
|
||
uni.showToast({ title: '请输入租户名称', icon: 'none' })
|
||
return
|
||
}
|
||
if (!/^1\d{10}$/.test(phone.value)) {
|
||
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
|
||
return
|
||
}
|
||
sendingCode.value = true
|
||
try {
|
||
await sendLoginCode({
|
||
tenant_name: tenantName.value.trim(),
|
||
account: phone.value.trim(),
|
||
channel: 'sms'
|
||
})
|
||
uni.showToast({ title: '验证码已发送', icon: 'success' })
|
||
startCountdown()
|
||
} catch {
|
||
// toast 已在 request 中处理
|
||
} finally {
|
||
sendingCode.value = false
|
||
}
|
||
}
|
||
|
||
async function sendAccountVerifyCode() {
|
||
if (countdown.value > 0 || sendingCode.value) return
|
||
if (!tenantName.value.trim()) {
|
||
uni.showToast({ title: '请输入租户名称', icon: 'none' })
|
||
return
|
||
}
|
||
if (!username.value.trim()) {
|
||
uni.showToast({ title: '请输入账号', icon: 'none' })
|
||
return
|
||
}
|
||
const channel = verifyType.value === 'email' ? 'email' : 'sms'
|
||
sendingCode.value = true
|
||
try {
|
||
await sendLoginCode({
|
||
tenant_name: tenantName.value.trim(),
|
||
account: username.value.trim(),
|
||
channel
|
||
})
|
||
uni.showToast({ title: '验证码已发送', icon: 'success' })
|
||
startCountdown()
|
||
} catch {
|
||
// handled
|
||
} finally {
|
||
sendingCode.value = false
|
||
}
|
||
}
|
||
|
||
function navigateAfterLogin() {
|
||
uni.showToast({ title: '登录成功', icon: 'success' })
|
||
setTimeout(() => {
|
||
uni.reLaunch({ url: '/pages/dashboard/dashboard' })
|
||
}, 400)
|
||
}
|
||
|
||
async function submitAccountLogin(geetestResult = null) {
|
||
if (!username.value.trim()) {
|
||
uni.showToast({ title: '请输入账号', icon: 'none' })
|
||
return false
|
||
}
|
||
if (!password.value.trim()) {
|
||
uni.showToast({ title: '请输入密码', icon: 'none' })
|
||
return false
|
||
}
|
||
if (needLoginCode.value && !verifyCode.value.trim()) {
|
||
uni.showToast({ title: '请输入登录验证码', icon: 'none' })
|
||
return false
|
||
}
|
||
|
||
const payload = {
|
||
tenant_name: tenantName.value.trim(),
|
||
account: username.value.trim(),
|
||
password: password.value
|
||
}
|
||
if (needLoginCode.value) {
|
||
payload.code = verifyCode.value.trim()
|
||
}
|
||
if (geetestResult) {
|
||
Object.assign(payload, geetestResult)
|
||
}
|
||
|
||
const data = await login(payload)
|
||
setTenantName(tenantName.value.trim())
|
||
loginSuccess({
|
||
token: data.token,
|
||
user: data.user
|
||
})
|
||
if (rememberMe.value) {
|
||
saveRememberLogin({
|
||
tenantName: tenantName.value.trim(),
|
||
account: username.value.trim(),
|
||
password: password.value
|
||
})
|
||
} else {
|
||
clearRememberLogin()
|
||
}
|
||
return true
|
||
}
|
||
|
||
async function handleLogin() {
|
||
if (submitting.value) return
|
||
if (!agreed.value) {
|
||
uni.showToast({ title: '请先同意用户协议', icon: 'none' })
|
||
return
|
||
}
|
||
if (!tenantName.value.trim()) {
|
||
uni.showToast({ title: '请输入租户名称', icon: 'none' })
|
||
return
|
||
}
|
||
|
||
submitting.value = true
|
||
try {
|
||
if (loginType.value === 'phone') {
|
||
if (!/^1\d{10}$/.test(phone.value)) {
|
||
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
|
||
return
|
||
}
|
||
if (!smsCode.value || smsCode.value.length < 4) {
|
||
uni.showToast({ title: '请输入验证码', icon: 'none' })
|
||
return
|
||
}
|
||
const data = await loginBySms({
|
||
tenant_name: tenantName.value.trim(),
|
||
phone: phone.value.trim(),
|
||
code: smsCode.value.trim()
|
||
})
|
||
setTenantName(tenantName.value.trim())
|
||
loginSuccess({
|
||
token: data.token,
|
||
user: data.user || { phone: phone.value, nickname: '用户' + phone.value.slice(-4) }
|
||
})
|
||
navigateAfterLogin()
|
||
return
|
||
}
|
||
|
||
if (needGeetest.value) {
|
||
if (!username.value.trim()) {
|
||
uni.showToast({ title: '请输入账号', icon: 'none' })
|
||
return
|
||
}
|
||
if (!password.value.trim()) {
|
||
uni.showToast({ title: '请输入密码', icon: 'none' })
|
||
return
|
||
}
|
||
try {
|
||
const geetestResult = await showGeetest4()
|
||
const ok = await submitAccountLogin(geetestResult)
|
||
if (ok) navigateAfterLogin()
|
||
} catch (err) {
|
||
const msg = err?.message || '人机验证失败'
|
||
if (msg !== '人机验证未通过') {
|
||
uni.showToast({ title: msg, icon: 'none' })
|
||
}
|
||
}
|
||
return
|
||
}
|
||
|
||
const ok = await submitAccountLogin()
|
||
if (ok) navigateAfterLogin()
|
||
} catch {
|
||
// toast 已处理
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
function goRegister() {
|
||
uni.navigateTo({ url: '/pages/login/register' })
|
||
}
|
||
|
||
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: $color-bg-page;
|
||
}
|
||
|
||
.page-inner {
|
||
padding: 0 48rpx;
|
||
padding-top: calc(var(--status-bar-height, 44px) + 80rpx);
|
||
}
|
||
|
||
.header {
|
||
margin-bottom: 64rpx;
|
||
}
|
||
|
||
.title {
|
||
display: block;
|
||
font-size: 48rpx;
|
||
font-weight: 600;
|
||
color: $color-text;
|
||
}
|
||
|
||
.subtitle {
|
||
display: block;
|
||
font-size: 28rpx;
|
||
color: $color-text-muted;
|
||
margin-top: 12rpx;
|
||
}
|
||
|
||
.tabs {
|
||
display: flex;
|
||
background: $color-card;
|
||
border-radius: $radius-md;
|
||
padding: 6rpx;
|
||
margin-bottom: 32rpx;
|
||
box-shadow: $shadow-card;
|
||
}
|
||
|
||
.tab {
|
||
flex: 1;
|
||
text-align: center;
|
||
font-size: 28rpx;
|
||
color: $color-text-secondary;
|
||
padding: 18rpx 0;
|
||
border-radius: $radius-sm;
|
||
|
||
&.active {
|
||
color: $color-primary;
|
||
font-weight: 600;
|
||
background: $color-primary-bg;
|
||
}
|
||
}
|
||
|
||
.form-card {
|
||
@include card;
|
||
padding: 32rpx 28rpx;
|
||
}
|
||
|
||
.form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 24rpx;
|
||
}
|
||
|
||
.field {
|
||
background: $color-bg-page;
|
||
border-radius: $radius-md;
|
||
padding: 0 24rpx;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.field-row {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.prefix {
|
||
font-size: 28rpx;
|
||
color: $color-text;
|
||
padding-right: 20rpx;
|
||
margin-right: 20rpx;
|
||
border-right: 1rpx solid $color-border;
|
||
}
|
||
|
||
.code-link {
|
||
flex-shrink: 0;
|
||
font-size: 26rpx;
|
||
color: $color-primary;
|
||
padding-left: 16rpx;
|
||
white-space: nowrap;
|
||
|
||
&.disabled {
|
||
color: $color-text-muted;
|
||
}
|
||
}
|
||
|
||
.verify-hint {
|
||
font-size: 22rpx;
|
||
color: $color-text-muted;
|
||
line-height: 1.5;
|
||
padding: 0 4rpx;
|
||
}
|
||
|
||
.remember-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
padding: 4rpx;
|
||
}
|
||
|
||
.remember-dot {
|
||
width: 32rpx;
|
||
height: 32rpx;
|
||
border-radius: 50%;
|
||
border: 1rpx solid $color-border;
|
||
background: $color-card;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
|
||
&.on {
|
||
background: $color-primary;
|
||
border-color: $color-primary;
|
||
}
|
||
}
|
||
|
||
.remember-check {
|
||
font-size: 18rpx;
|
||
color: #fff;
|
||
}
|
||
|
||
.remember-text {
|
||
font-size: 26rpx;
|
||
color: $color-text-secondary;
|
||
}
|
||
|
||
.btn-primary {
|
||
height: 96rpx;
|
||
background: $color-primary;
|
||
border-radius: $radius-md;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 32rpx;
|
||
font-weight: 600;
|
||
color: #fff;
|
||
margin-top: 8rpx;
|
||
|
||
&:active {
|
||
background: $color-primary-dark;
|
||
}
|
||
|
||
&.disabled {
|
||
opacity: 0.7;
|
||
}
|
||
}
|
||
|
||
.link-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
padding: 8rpx 4rpx 0;
|
||
}
|
||
|
||
.link {
|
||
font-size: 26rpx;
|
||
color: $color-primary;
|
||
}
|
||
|
||
.agreement {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 12rpx;
|
||
margin-top: 32rpx;
|
||
padding-bottom: 60rpx;
|
||
}
|
||
|
||
.agree-dot {
|
||
width: 32rpx;
|
||
height: 32rpx;
|
||
border-radius: 50%;
|
||
border: 1rpx solid $color-border;
|
||
background: $color-card;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
margin-top: 2rpx;
|
||
|
||
&.on {
|
||
background: $color-primary;
|
||
border-color: $color-primary;
|
||
}
|
||
}
|
||
|
||
.agree-check {
|
||
font-size: 18rpx;
|
||
color: #fff;
|
||
}
|
||
|
||
.agree-text {
|
||
font-size: 24rpx;
|
||
color: $color-text-muted;
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.agree-link {
|
||
color: $color-primary;
|
||
}
|
||
</style>
|