Files
yunzerwebsiteallinone/uniapp/pages/login/login.vue
T
2026-07-15 22:38:33 +08:00

633 lines
14 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="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>