提交 dcb2c886 作者: 廖在望

feat: 优化登录和注册页面逻辑。

上级 2a2d7e73
<script setup lang="ts">
/**
* 滑动验证组件
* 用法:<SlideVerify v-model:show="showSlide" @success="onVerifySuccess" />
*/
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
(e: 'update:show', val: boolean): void
(e: 'success'): void
(e: 'close'): void
}>()
const TRACK_WIDTH = 560 // rpx,滑道宽度(与样式保持一致)
const BTN_WIDTH = 96 // rpx,滑块宽度
const maxRpx = TRACK_WIDTH - BTN_WIDTH // 最大可拖动距离(rpx)
const state = reactive({
translateX: 0, // 当前滑块位移(px,运行时换算)
isDragging: false,
startX: 0,
maxPx: 0, // 运行时根据屏幕宽度换算
verified: false,
text: '按住滑块,拖动到最右侧',
})
// 将 rpx 换算为 px
function rpxToPx(rpx: number): number {
const screenWidth = uni.getSystemInfoSync().windowWidth
return (rpx / 750) * screenWidth
}
watch(() => props.show, (val) => {
if (val) {
reset()
state.maxPx = rpxToPx(maxRpx)
}
})
function reset() {
state.translateX = 0
state.isDragging = false
state.verified = false
state.text = '按住滑块,拖动到最右侧'
}
function onTouchStart(e: any) {
if (state.verified) return
state.isDragging = true
state.startX = e.touches[0].clientX
}
function onTouchMove(e: any) {
if (!state.isDragging || state.verified) return
const dx = e.touches[0].clientX - state.startX
state.translateX = Math.max(0, Math.min(dx, state.maxPx))
}
function onTouchEnd() {
if (!state.isDragging) return
state.isDragging = false
// 距终点 10px 以内视为验证通过
if (state.translateX >= state.maxPx - rpxToPx(10)) {
state.translateX = state.maxPx
state.verified = true
state.text = '验证通过'
setTimeout(() => {
emit('success')
close()
}, 600)
} else {
// 未拖到底,回弹
state.translateX = 0
}
}
function close() {
emit('update:show', false)
emit('close')
reset()
}
</script>
<template>
<view class="sv-mask" v-if="show" @click.self="close">
<view class="sv-panel">
<view class="sv-title">安全验证</view>
<view class="sv-desc">{{ state.text }}</view>
<!-- 滑道 -->
<view class="sv-track">
<!-- 已划过的高亮区 -->
<view class="sv-fill" :style="{ width: state.translateX + 'px' }" />
<!-- 滑块 -->
<view
class="sv-btn"
:class="{ success: state.verified }"
:style="{ transform: `translateX(${state.translateX}px)` }"
@touchstart.prevent="onTouchStart"
@touchmove.prevent="onTouchMove"
@touchend.prevent="onTouchEnd"
>
<text class="sv-arrow" v-if="!state.verified">&#8594;</text>
<text class="sv-check" v-else">&#10003;</text>
</view>
</view>
<view class="sv-close" @click="close">
<text>取消</text>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
.sv-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.sv-panel {
width: 640rpx;
background: #fff;
border-radius: 24rpx;
padding: 48rpx 40rpx 40rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.16);
}
.sv-title {
font-size: 34rpx;
font-weight: 700;
color: #222;
margin-bottom: 12rpx;
}
.sv-desc {
font-size: 26rpx;
color: #888;
margin-bottom: 40rpx;
}
.sv-track {
position: relative;
width: 560rpx;
height: 96rpx;
background: #f0f0f0;
border-radius: 48rpx;
overflow: hidden;
}
.sv-fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: linear-gradient(90deg, #b8e8c0, #5db66f);
border-radius: 48rpx 0 0 48rpx;
transition: width 0.05s;
}
.sv-btn {
position: absolute;
left: 0;
top: 0;
width: 96rpx;
height: 96rpx;
background: #fff;
border-radius: 48rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.18);
display: flex;
align-items: center;
justify-content: center;
transition: background 0.3s;
z-index: 2;
&.success {
background: #5db66f;
}
}
.sv-arrow {
font-size: 40rpx;
color: #5db66f;
font-weight: 700;
}
.sv-check {
font-size: 40rpx;
color: #fff;
font-weight: 700;
}
.sv-close {
margin-top: 32rpx;
text {
font-size: 28rpx;
color: #aaa;
}
}
</style>
<script setup lang="ts">
import { useUserStore } from '@/store/modules/user'
import { closeSplashscreenAndChechUpgrade } from '@/utils/upgrade'
import { isDevMode } from '@/utils/env'
import Link from '@/utils/const/link'
import SlideVerify from '@/components/slide-verify/SlideVerify.vue'
import * as API from '/@/api/model/userInfo'
import { Message } from '@/common'
const userStore = useUserStore()
onShow(async () => {
// 恢复倒计时:计算实际剩余秒数,重启定时器
if (model.countdown > 0 && countdownEndTime > 0) {
const remaining = Math.max(0, Math.ceil((countdownEndTime - Date.now()) / 1000))
if (remaining > 0) {
model.countdown = remaining
startCountdownTimer()
} else {
model.countdown = 0
countdownEndTime = 0
}
}
const token = userStore.getToken
if (token) {
model.isLogin = true
......@@ -24,17 +35,18 @@
}
})
const defaultText = '湘农数智农服'
const readConfirmShow = ref<boolean>(false)
const form = ref()
const showSlide = ref(false)
const slideVerifyRef = ref()
const showSlideVerify = ref(false)
const slideType = ref<'sms' | 'login'>('login')
const canLogin = computed(() => {
return !!model.form.data.username && !!model.form.data.code
})
const model = reactive({
isLogin: false,
loading: false,
text: defaultText,
countdown: 0,
form: {
phoneRules: [
......@@ -44,7 +56,7 @@
msg: ['请输入手机号', '请输入正确的手机号'],
},
],
codeLoginRules: [
loginRules: [
{
name: 'username',
rule: ['required', 'isMobile'],
......@@ -55,20 +67,6 @@
rule: ['required'],
msg: ['请输入验证码'],
},
{
name: 'read',
validator: [
{
msg: '请阅读并同意服务协议和隐私政策',
method: (value: boolean) => {
if (!value) {
readConfirmShow.value = true
}
return value
},
},
],
},
],
data: {
username: '',
......@@ -79,18 +77,18 @@
})
let countdownTimer: any = null
if (isDevMode()) {
model.form.data.username = 'admin'
model.form.data.read = true
}
let countdownEndTime = 0
function login() {
form?.value.validator(model.form.data, model.form.codeLoginRules).then(async (res: { isPassed: boolean }) => {
form?.value.validator(model.form.data, model.form.loginRules).then(async (res: { isPassed: boolean }) => {
if (res.isPassed) {
// 登录前先做滑动验证 (Requirement 7)
slideType.value = 'login'
showSlide.value = true
if (!model.form.data.read) {
readConfirmShow.value = true
return
}
doLogin()
} else {
Message.toast(res.errorMsg)
}
})
}
......@@ -98,20 +96,19 @@
function smsCode() {
form?.value.validator(model.form.data, model.form.phoneRules).then(async (res: { isPassed: boolean }) => {
if (res.isPassed) {
if (model.countdown > 0)
return
slideType.value = 'sms'
showSlide.value = true
if (model.countdown > 0) return
showSlideVerify.value = true
slideVerifyRef.value.reset()
} else {
Message.toast(res.errorMsg)
}
})
}
function onSlideSuccess() {
if (slideType.value === 'sms') {
// 立即开启倒计时(乐观更新),接口报错也不停止,防止恶意连点
showSlideVerify.value = false
startCountdown()
// 发送验证码
const params = {
mobile: model.form.data.username,
smsmode: '1',
......@@ -121,18 +118,12 @@ return
Message.toast('验证码已发送')
})
.catch((err) => {
// 即使接口返回 code: 130 等错误,也不干扰已启动的倒计时
console.error('短信发送业务异常:', err)
})
} else {
// 执行登录
doLogin()
}
}
function doLogin() {
model.loading = true
// 验证码登录
const params = {
mobile: model.form.data.username,
captcha: model.form.data.code,
......@@ -155,16 +146,23 @@ return
}
function startCountdown() {
if (countdownTimer)
clearInterval(countdownTimer)
if (countdownTimer) clearInterval(countdownTimer)
model.countdown = 60
countdownEndTime = Date.now() + 60 * 1000
startCountdownTimer()
}
function startCountdownTimer() {
if (countdownTimer) clearInterval(countdownTimer)
countdownTimer = setInterval(() => {
model.countdown--
if (model.countdown <= 0) {
const remaining = Math.max(0, Math.ceil((countdownEndTime - Date.now()) / 1000))
model.countdown = remaining
if (remaining <= 0) {
clearInterval(countdownTimer)
countdownTimer = null
countdownEndTime = 0
}
}, 1000)
}, 300)
}
function goHome() {
......@@ -189,60 +187,47 @@ clearInterval(countdownTimer)
}
})
function onReadConfirm(val: any) {
if (val.index === 0) {
model.form.data.read = false
} else {
function onReadConfirm() {
model.form.data.read = true
readConfirmShow.value = false
login()
}
function closeAgreeModal() {
readConfirmShow.value = false
}
</script>
<template>
<view class="login-page">
<!-- 顶部设计:数智化农场全景感 -->
<view class="header-visual">
<image class="header-scene" src="/static/images/login/farm_base_bg.png" mode="aspectFill" />
<view class="header-overlay" />
<image class="header-scene" src="/static/images/login/farm_base_bg.png" mode="aspectFill"></image>
<view class="header-overlay"></view>
<!-- 顶部装饰元素:漂浮的气象/农业设备(体现数智感) -->
<image class="float-icon device-1" src="/static/images/nongchang/device1.png" mode="aspectFit" />
<image class="float-icon device-2" src="/static/images/nongchang/device2.png" mode="aspectFit" />
<image class="float-icon device-1" src="/static/images/nongchang/device1.png" mode="aspectFit"></image>
<image class="float-icon device-2" src="/static/images/nongchang/device3.png" mode="aspectFit"></image>
<!-- 顶部状态栏占位与操作 -->
<view class="top-nav-bar">
<view class="close-btn" @click="goHome">
<fui-icon name="close" :size="48" color="#fff" />
</view>
</view>
<!-- 品牌标识与数智概览 -->
<view class="brand-hero">
<view class="logo-box">
<image class="logo-img" src="/static/logo.png" mode="aspectFit" />
<image class="logo-img" src="/static/logo.png" mode="aspectFit"></image>
</view>
<view class="brand-info">
<text class="app-title">湘农数智农服</text>
<view class="app-slogan">
<image src="/static/images/weather/100.svg" class="weather-icon" />
<text>智慧生产 · 农务管家</text>
</view>
</view>
</view>
</view>
<!-- 登录卡片:磨砂玻璃质感 -->
<view class="main-card-container">
<view class="aesthetic-card">
<fui-form ref="form" top="0" :padding="['0', '0']" background="transparent">
<fui-form ref="form" top="0" :padding="['0', '0']" background="transparent" :show="false">
<view class="card-header">
<text class="welcome-text">您好,欢迎登录</text>
<view class="green-line" />
<view class="green-line"></view>
</view>
<!-- 输入区域 -->
<view class="input-fields">
<view class="field-item">
<text class="field-label">手机号</text>
......@@ -251,11 +236,13 @@ clearInterval(countdownTimer)
:padding="['24rpx', '0']"
placeholder="请输入手机号"
backgroundColor="transparent"
placeholderStyle="font-size:16px;"
borderColor="#F0F0F0"
borderBottom
height="110rpx"
size="34"
/>
maxlength="11"
></fui-input>
</view>
<view class="field-item">
......@@ -265,13 +252,15 @@ clearInterval(countdownTimer)
v-model="model.form.data.code"
:padding="['24rpx', '0']"
placeholder="请输入验证码"
placeholderStyle="font-size:16px;"
backgroundColor="transparent"
borderColor="#F0F0F0"
borderBottom
height="110rpx"
size="34"
maxlength="6"
autocomplete="off"
/>
></fui-input>
<view class="sms-trigger" :class="{ inactive: model.countdown > 0 }" @click="smsCode">
{{ model.countdown > 0 ? `${model.countdown}s后重发` : '获取验证码' }}
</view>
......@@ -279,41 +268,43 @@ clearInterval(countdownTimer)
</view>
</view>
<!-- 登录决策 -->
<view class="action-footer">
<view :class="['btn-wrapper', { disabled: !canLogin }]">
<fui-button
text="开启农务数字化"
text="验证并登录"
background="linear-gradient(90deg, #5DB66F 0%, #4CAF50 100%)"
radius="100rpx"
height="110rpx"
height="80rpx"
@click="login"
:loading="model.loading"
:disabled="model.loading"
size="36"
:disabled="!canLogin || model.loading"
color="#ffffff"
disabled-color="#888888"
disabled-background="#f5f5f5"
size="30"
shadow
/>
></fui-button>
</view>
<view class="secondary-links">
<view class="reg-btn" @click="goRegister">新用户注册</view>
<view class="reg-btn" @click="goRegister">还没有账号?立即注册</view>
</view>
</view>
<!-- 隐私合规 -->
<view class="policy-wrapper">
<fui-checkbox-group>
<fui-label class="policy-label">
<view class="checkbox-align">
<fui-checkbox
color="#5DB66F"
:scale="0.7"
:scaleRatio="0.7"
:checked="model.form.data.read"
@change="(e) => (model.form.data.read = e.checked)"
/>
></fui-checkbox>
</view>
<view class="policy-text">
同意<text class="link" @click.stop="Link.to(Link.services, '服务协议')"
>《服务协议》</text
><text class="link" @click.stop="Link.to(Link.privacy, '隐私政策')"
>《隐私政策》</text
>
已阅读并同意<text class="link" @click.stop="Link.to(Link.services, '服务协议')">《服务协议》</text>与<text class="link" @click.stop="Link.to(Link.privacy, '隐私政策')">《隐私政策》</text>
,同意运营商对本人提供的手机号码进行验证。
</view>
</fui-label>
</fui-checkbox-group>
......@@ -322,45 +313,64 @@ clearInterval(countdownTimer)
</view>
</view>
<!-- 装饰底部:泥土与天空的呼应 -->
<view class="footer-aesthetic" />
<!-- 功能弹窗 -->
<fui-modal
:show="readConfirmShow"
title="服务协议及隐私保护"
:buttons="[
{ text: '不同意', plain: true },
{ text: '同意', plain: false, color: '#fff' },
]"
@click="onReadConfirm"
>
<view class="modal-notice">
点击同意即表示您已阅读并理解
<text class="link-inline" @click.stop="Link.to(Link.services, '服务协议')">《服务协议》</text>
<text class="link-inline" @click.stop="Link.to(Link.privacy, '隐私政策')">《隐私政策》</text>
<view class="footer-aesthetic"></view>
<view class="agree-mask" v-if="readConfirmShow" @click.self="closeAgreeModal">
<view class="agree-panel">
<view class="agree-gradient-header"></view>
<view class="agree-close" @click="closeAgreeModal">
<text></text>
</view>
<view class="agree-title-wrap">
<view class="agree-title">服务协议及隐私保护</view>
<view class="agree-title-line"></view>
</view>
<view class="agree-content">
<view class="agree-text">
为了更好的保障您的合法权益,请您阅读并同意
<text class="agree-link" @click.stop="Link.to(Link.services, '用户协议')">《用户协议》</text>
<text class="agree-link" @click.stop="Link.to(Link.privacy, '隐私协议')">《隐私协议》</text>
</view>
<view class="agree-btn" @click="onReadConfirm">同意并继续</view>
</view>
</view>
</view>
</fui-modal>
<SlideVerify v-model:show="showSlide" @success="onSlideSuccess" />
<view class="slide-mask" v-if="showSlideVerify" @click.self="showSlideVerify = false">
<view class="slide-panel">
<view class="slide-gradient-header"></view>
<view class="slide-close" @click="showSlideVerify = false">
<text></text>
</view>
<view class="slide-title-wrap">
<view class="slide-title">安全验证</view>
<view class="slide-title-line"></view>
</view>
<view class="slide-content">
<view class="slide-desc">按住滑块,拖动到最右侧</view>
<fui-slide-verify
ref="slideVerifyRef"
width="540"
height="80"
slider-width="80"
background="#f5f7f5"
active-bg-color="#5DB66F"
pass-color="#5DB66F"
arrow-color="#5DB66F"
line-color="#5DB66F"
color="#999"
active-color="#fff"
@success="onSlideSuccess"
></fui-slide-verify>
</view>
</view>
</view>
</view>
</template>
<style lang="less" scoped>
@keyframes float-slow {
0%,
100% {
transform: translateY(0) rotate(15deg);
}
50% {
transform: translateY(-20rpx) rotate(10deg);
}
}
.login-page {
min-height: 100vh;
background-color: #f7faf8;
......@@ -369,9 +379,8 @@ clearInterval(countdownTimer)
overflow-x: hidden;
}
/* 顶部视觉层:农业生产全景 */
.header-visual {
height: 600rpx;
height: 550rpx;
position: relative;
padding-top: var(--status-bar-height);
overflow: hidden;
......@@ -382,7 +391,7 @@ clearInterval(countdownTimer)
height: 100%;
top: 0;
left: 0;
filter: brightness(0.8);
filter: brightness(0.9);
}
.header-overlay {
......@@ -391,134 +400,134 @@ clearInterval(countdownTimer)
height: 100%;
top: 0;
left: 0;
background: linear-gradient(180deg, rgb(93 182 111 / 40%) 0%, rgb(76 175 80 / 80%) 100%);
background: linear-gradient(180deg, rgba(93, 182, 111, 0.3) 0%, rgba(76, 175, 80, 0.7) 100%);
}
/* 漂浮装饰:增强数智感 */
.float-icon {
position: absolute;
opacity: 0.6;
filter: drop-shadow(0 4rpx 10rpx rgb(0 0 0 / 20%));
opacity: 0.7;
filter: drop-shadow(0 4rpx 10rpx rgba(0,0,0,0.15));
&.device-1 {
width: 120rpx;
height: 120rpx;
right: -20rpx;
top: 150rpx;
transform: rotate(15deg);
width: 130rpx;
height: 130rpx;
right: 20rpx;
top: 180rpx;
transform: rotate(10deg);
animation: float-slow 4s ease-in-out infinite;
}
&.device-2 {
width: 100rpx;
height: 100rpx;
left: 40rpx;
bottom: 120rpx;
transform: rotate(-10deg);
width: 110rpx;
height: 110rpx;
left: 30rpx;
bottom: 100rpx;
animation: float-slow 3s ease-in-out infinite alternate;
}
}
.top-nav-bar {
position: relative;
z-index: 10;
display: flex;
justify-content: flex-start;
align-items: center;
padding: 20rpx 40rpx;
}
.brand-hero {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
margin-top: 50rpx;
margin-top: 80rpx;
.logo-box {
width: 160rpx;
height: 160rpx;
width: 140rpx;
height: 140rpx;
background: #fff;
border-radius: 44rpx;
border-radius: 40rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 16rpx 32rpx rgb(0 0 0 / 15%);
box-shadow: 0 12rpx 24rpx rgba(0,0,0,0.1);
.logo-img {
width: 100rpx;
height: 100rpx;
width: 90rpx;
height: 90rpx;
}
}
.brand-info {
margin-top: 30rpx;
margin-top: 24rpx;
text-align: center;
color: #fff;
.app-title {
font-size: 48rpx;
font-size: 44rpx;
font-weight: 800;
letter-spacing: 4rpx;
text-shadow: 0 4rpx 8rpx rgb(0 0 0 / 20%);
letter-spacing: 2rpx;
background: linear-gradient(90deg, #fff 0%, #fff 40%, rgba(255,255,255,0.3) 50%, #fff 60%, #fff 100%);
background-size: 300% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: shine-text 3s ease-in-out infinite;
}
.app-slogan {
margin-top: 12rpx;
font-size: 26rpx;
opacity: 0.9;
margin-top: 8rpx;
font-size: 24rpx;
opacity: 0.95;
display: flex;
align-items: center;
justify-content: center;
.weather-icon {
width: 40rpx;
height: 40rpx;
margin-right: 12rpx;
width: 36rpx;
height: 36rpx;
margin-right: 10rpx;
}
}
.app-slogan text {
background: linear-gradient(90deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.9) 40%, rgba(255,255,255,0.4) 50%, rgba(255,255,255,0.9) 60%, rgba(255,255,255,0.9) 100%);
background-size: 300% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: shine-text 3s ease-in-out infinite 0.5s;
}
}
}
}
/* 登录卡片层 */
.main-card-container {
padding: 0 40rpx;
margin-top: -100rpx;
margin-top: -80rpx;
position: relative;
z-index: 20;
}
.aesthetic-card {
background: #fff;
border-radius: 56rpx;
padding: 50rpx 50rpx 40rpx;
box-shadow: 0 20rpx 60rpx rgb(0 0 0 / 8%);
background: #ffffff;
border-radius: 50rpx;
padding: 70rpx 50rpx 60rpx;
box-shadow: 0 15rpx 50rpx rgba(0, 0, 0, 0.06);
.card-header {
margin-bottom: 36rpx;
margin-bottom: 50rpx;
.welcome-text {
font-size: 36rpx;
font-size: 38rpx;
font-weight: bold;
color: #333;
}
.green-line {
width: 60rpx;
height: 8rpx;
background: #5db66f;
width: 50rpx;
height: 7rpx;
background: #5DB66F;
border-radius: 4rpx;
margin-top: 12rpx;
margin-top: 14rpx;
}
}
.field-item {
margin-bottom: 30rpx;
margin-bottom: 40rpx;
.field-label {
font-size: 24rpx;
font-size: 26rpx;
color: #999;
margin-left: 4rpx;
}
......@@ -530,9 +539,9 @@ clearInterval(countdownTimer)
.sms-trigger {
white-space: nowrap;
font-size: 26rpx;
color: #5db66f;
padding: 12rpx 24rpx;
font-size: 27rpx;
color: #5DB66F;
padding: 14rpx 28rpx;
background: #f1f9f2;
border-radius: 50rpx;
margin-left: 20rpx;
......@@ -548,64 +557,277 @@ clearInterval(countdownTimer)
}
.action-footer {
margin-top: 50rpx;
margin-top: 60rpx;
.btn-wrapper {
transition: opacity 0.3s;
}
.secondary-links {
margin-top: 28rpx;
margin-top: 40rpx;
text-align: center;
.auto-hint {
font-size: 24rpx;
color: #ccc;
margin-bottom: 24rpx;
}
.reg-btn {
font-size: 30rpx;
color: #666;
font-weight: 500;
display: inline-block;
padding: 10rpx 40rpx;
border: 1rpx solid #eee;
border-radius: 40rpx;
font-size: 28rpx;
color: #aaa;
}
}
}
.policy-wrapper {
margin-top: 50rpx;
margin-top: 60rpx;
.policy-label {
display: flex;
align-items: flex-start;
justify-content: center;
}
.checkbox-align {
line-height: 1;
display: flex;
align-items: center;
flex-shrink: 0;
margin-top: 4rpx;
}
.policy-text {
font-size: 24rpx;
color: #bbb;
margin-left: 12rpx;
line-height: 1.4;
flex: 1;
line-height: 1.6;
word-break: break-all;
}
.link {
color: #5db66f;
color: #5DB66F;
font-weight: 500;
}
}
.modal-notice {
.footer-aesthetic {
flex: 1;
background: linear-gradient(180deg, #f7faf8 0%, #edf3ef 100%);
}
.agree-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.agree-panel {
width: 640rpx;
background: #ffffff;
border-radius: 50rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 15rpx 50rpx rgba(0, 0, 0, 0.08);
position: relative;
overflow: hidden;
}
.agree-gradient-header {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 180rpx;
background: linear-gradient(180deg, #4CAF50 0%, #5DB66F 40%, rgba(93, 182, 111, 0.15) 85%, transparent 100%);
border-radius: 50rpx 50rpx 0 0;
}
.agree-close {
position: absolute;
top: 20rpx;
right: 24rpx;
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.25);
z-index: 2;
text {
font-size: 32rpx;
color: #ffffff;
line-height: 1;
}
}
.agree-title-wrap {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 48rpx;
margin-bottom: 20rpx;
position: relative;
z-index: 1;
}
.agree-title {
font-size: 36rpx;
font-weight: 700;
color: #ffffff;
margin-bottom: 10rpx;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.12);
}
.agree-title-line {
width: 48rpx;
height: 6rpx;
background: rgba(255, 255, 255, 0.7);
border-radius: 3rpx;
}
.agree-content {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: 36rpx 50rpx 48rpx;
position: relative;
z-index: 1;
}
.agree-text {
font-size: 28rpx;
color: #666;
line-height: 1.8;
text-align: center;
margin-bottom: 44rpx;
}
.link-inline {
color: #5db66f;
font-weight: bold;
.agree-link {
color: #5DB66F;
font-weight: 600;
}
.agree-btn {
width: 100%;
height: 90rpx;
background: linear-gradient(90deg, #5DB66F 0%, #4CAF50 100%);
border-radius: 100rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
color: #ffffff;
font-weight: 600;
box-shadow: 0 6rpx 20rpx rgba(93, 182, 111, 0.3);
}
.footer-aesthetic {
flex: 1;
background: linear-gradient(180deg, #f7faf8 0%, #edf3ef 100%);
.slide-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.slide-panel {
width: 640rpx;
background: #ffffff;
border-radius: 50rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 15rpx 50rpx rgba(0, 0, 0, 0.08);
position: relative;
overflow: hidden;
}
.slide-gradient-header {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 180rpx;
background: linear-gradient(180deg, #4CAF50 0%, #5DB66F 40%, rgba(93, 182, 111, 0.15) 85%, transparent 100%);
border-radius: 50rpx 50rpx 0 0;
}
.slide-close {
position: absolute;
top: 20rpx;
right: 24rpx;
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.25);
z-index: 2;
text {
font-size: 32rpx;
color: #ffffff;
line-height: 1;
}
}
.slide-title-wrap {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 48rpx;
margin-bottom: 20rpx;
position: relative;
z-index: 1;
}
.slide-title {
font-size: 36rpx;
font-weight: 700;
color: #ffffff;
margin-bottom: 10rpx;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.12);
}
.slide-title-line {
width: 48rpx;
height: 6rpx;
background: rgba(255, 255, 255, 0.7);
border-radius: 3rpx;
}
.slide-content {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: 36rpx 50rpx 48rpx;
position: relative;
z-index: 1;
}
.slide-desc {
font-size: 26rpx;
color: #999;
margin-bottom: 36rpx;
letter-spacing: 0.5rpx;
}
@keyframes float-slow {
0%, 100% { transform: translateY(0) rotate(5deg); }
50% { transform: translateY(-15rpx) rotate(-5deg); }
}
@keyframes shine-text {
0% { background-position: 0% 0; }
30% { background-position: 0% 0; }
60% { background-position: 100% 0; }
100% { background-position: 100% 0; }
}
</style>
<script setup lang="ts">
import { useUserStore } from '@/store/modules/user'
import * as API from '/@/api/model/userInfo'
import SlideVerify from '@/components/slide-verify/SlideVerify.vue'
import Link from '@/utils/const/link'
import { Message } from '@/common'
const defaultText = '湘农数智农服'
const userStore = useUserStore()
const form = ref()
const slideVerifyRef = ref()
const showSlide = ref(false)
const slideType = ref<'sms' | 'register'>('register')
const slideAction = ref<'sms' | 'submit'>('sms')
const isForgotPwd = ref(false)
onLoad((options) => {
if (options?.type === 'forgot') {
isForgotPwd.value = true
}
})
const model = reactive({
loading: false,
countdown: 0,
......@@ -18,14 +30,25 @@
},
{
name: 'code',
rule: ['required'],
msg: ['请输入验证码'],
rule: ['required', 'minLength:6', 'maxLength:6'],
msg: ['请输入验证码', '验证码为6位数字', '验证码为6位数字'],
},
{
name: 'password',
rule: ['required', 'minLength:6'],
msg: ['请输入密码', '密码长度不能少于6位'],
},
{
name: 'confirmPassword',
rule: ['required', 'minLength:6'],
msg: ['请确认密码', '密码长度不能少于6位'],
validator: [
{
msg: '两次输入的密码不一致',
method: (value: string) => value === model.form.data.password,
},
],
},
],
rulesPhone: [
{
......@@ -37,6 +60,7 @@
data: {
phone: '',
password: '',
confirmPassword: '',
code: '',
read: false,
},
......@@ -45,7 +69,7 @@
let countdownTimer: any = null
function register() {
function submit() {
if (!model.form.data.read) {
Message.toast('请阅读并同意服务协议及隐私政策')
return
......@@ -53,13 +77,16 @@
form?.value.validator(model.form.data, model.form.rules).then(async (res: { isPassed: boolean }) => {
if (res.isPassed) {
slideType.value = 'register'
slideAction.value = 'submit'
showSlide.value = true
slideVerifyRef.value.reset()
} else {
Message.toast(res.errorMsg)
}
})
}
function doRegister() {
function doSubmit() {
const params = {
phone: model.form.data.phone,
password: model.form.data.password,
......@@ -67,15 +94,22 @@
}
model.loading = true
API.sysRegister(params)
.then(async (body) => {
if (body) {
Message.toast(`注册成功, 请登录~`)
setTimeout(() => {
goLogin()
}, 1500)
if (body?.token) {
userStore.setToken(body.token)
const user = await API.getUserInfo()
userStore.setUserInfo(user)
Message.toast(isForgotPwd.value ? '密码重置成功' : '注册成功,欢迎加入~')
goHome()
} else {
Message.toast(body?.message || (isForgotPwd.value ? '密码重置失败' : '注册失败'))
}
})
.catch((err) => {
Message.toast(err?.message || (isForgotPwd.value ? '密码重置失败,请稍后重试' : '注册失败,请稍后重试'))
})
.finally(() => {
model.loading = false
})
......@@ -85,30 +119,33 @@
form?.value.validator(model.form.data, model.form.rulesPhone).then(async (res: { isPassed: boolean }) => {
if (res.isPassed) {
if (model.countdown > 0) return
slideType.value = 'sms'
slideAction.value = 'sms'
showSlide.value = true
slideVerifyRef.value.reset()
} else {
Message.toast(res.errorMsg)
}
})
}
function onSlideSuccess() {
if (slideType.value === 'sms') {
// 开启乐观倒计时,不论接口成败,60s内不准重发
showSlide.value = false
if (slideAction.value === 'submit') {
doSubmit()
} else {
startCountdown()
const params = {
mobile: model.form.data.phone,
smsmode: 2, // 2-注册
smsmode: isForgotPwd.value ? 3 : 2,
}
API.sysSms(params)
.then(async () => {
Message.toast('验证码已发送')
})
.catch((err) => {
console.error('注册短信发送失败:', err)
console.error('短信发送失败:', err)
})
} else {
doRegister()
}
}
......@@ -155,30 +192,30 @@
<view class="login-page">
<!-- 顶部视觉层:阳光农场与气象设备 -->
<view class="header-visual">
<image class="header-scene" src="/static/images/login/farm_base_bg.png" mode="aspectFill" />
<image class="header-scene" src="/static/images/login/farm_base_bg.png" mode="aspectFill"></image>
<view class="header-overlay"></view>
<!-- 气象站与农业设备:体现数智感 -->
<image class="float-icon device-1" src="/static/images/nongchang/device1.png" mode="aspectFit" />
<image class="float-icon device-2" src="/static/images/nongchang/device3.png" mode="aspectFit" />
<image class="float-icon device-1" src="/static/images/nongchang/device1.png" mode="aspectFit"></image>
<image class="float-icon device-2" src="/static/images/nongchang/device3.png" mode="aspectFit"></image>
<!-- 顶部导航 -->
<view class="top-nav-bar">
<view class="back-btn" @click="goLogin">
<fui-icon name="arrowleft" :size="48" color="#fff" />
<fui-icon name="arrowleft" :size="48" color="#fff"></fui-icon>
</view>
</view>
<!-- 品牌标识 -->
<view class="brand-hero">
<view class="logo-box">
<image class="logo-img" src="/static/logo.png" mode="aspectFit" />
<image class="logo-img" src="/static/logo.png" mode="aspectFit"></image>
</view>
<view class="brand-info">
<text class="app-title">加入数智农服</text>
<text class="app-title">{{ isForgotPwd ? '重置密码' : '加入数智农服' }}</text>
<view class="app-slogan">
<image src="/static/images/weather/100.svg" class="weather-icon" />
<text>开启您的智慧农业之旅</text>
<image src="/static/images/weather/100.svg" class="weather-icon" mode="aspectFit"></image>
<text>{{ isForgotPwd ? '设置新密码,安全便捷' : '开启您的智慧农业之旅' }}</text>
</view>
</view>
</view>
......@@ -189,7 +226,7 @@
<view class="aesthetic-card">
<fui-form ref="form" top="0" :padding="['0', '0']" background="transparent">
<view class="card-header">
<text class="welcome-text">创建新账号</text>
<text class="welcome-text">{{ isForgotPwd ? '重置密码' : '创建新账号' }}</text>
<view class="green-line"></view>
</view>
......@@ -198,15 +235,16 @@
<text class="field-label">手机号</text>
<fui-input
v-model="model.form.data.phone"
:padding="['24rpx', '0']"
:padding="['16rpx', '0']"
placeholder="请输入手机号"
backgroundColor="transparent"
placeholderStyle="font-size:16px;"
borderColor="#F0F0F0"
borderBottom
height="110rpx"
height="80rpx"
size="34"
maxlength="11"
/>
></fui-input>
</view>
<view class="field-item">
......@@ -214,15 +252,17 @@
<view class="code-inner">
<fui-input
v-model="model.form.data.code"
:padding="['24rpx', '0']"
:padding="['16rpx', '0']"
placeholder="请输入验证码"
backgroundColor="transparent"
placeholderStyle="font-size:16px;"
borderColor="#F0F0F0"
borderBottom
height="110rpx"
size="34"
height="80rpx"
size="30"
maxlength="6"
autocomplete="off"
/>
></fui-input>
<view class="sms-trigger" :class="{ inactive: model.countdown > 0 }" @click="smsCode">
{{ model.countdown > 0 ? `${model.countdown}s后重发` : '获取验证码' }}
</view>
......@@ -234,33 +274,56 @@
<fui-input
v-model="model.form.data.password"
type="password"
:padding="['24rpx', '0']"
placeholder="请设置6位以上登录密码"
:padding="['16rpx', '0']"
placeholder="请设置6位以上密码(建议字母+数字)"
backgroundColor="transparent"
placeholderStyle="font-size:16px;"
borderColor="#F0F0F0"
borderBottom
height="80rpx"
size="34"
autocomplete="new-password"
></fui-input>
</view>
<view class="field-item">
<text class="field-label">确认密码</text>
<fui-input
v-model="model.form.data.confirmPassword"
type="password"
:padding="['16rpx', '0']"
placeholder="请再次输入密码"
backgroundColor="transparent"
placeholderStyle="font-size:16px;"
borderColor="#F0F0F0"
borderBottom
height="110rpx"
height="80rpx"
size="34"
autocomplete="new-password"
/>
></fui-input>
</view>
</view>
<view class="action-footer">
<view class="btn-wrapper">
<fui-button
text="立即注册"
:text="isForgotPwd ? '确认重置' : '立即注册'"
background="linear-gradient(90deg, #5DB66F 0%, #4CAF50 100%)"
radius="100rpx"
height="110rpx"
@click="register"
height="80rpx"
@click="submit"
:loading="model.loading"
:disabled="model.loading"
size="36"
color="#ffffff"
disabled-color="#888888"
disabled-background="#f5f5f5"
size="30"
shadow
/>
></fui-button>
</view>
<view class="secondary-links">
<view class="reg-btn" @click="goLogin">已有账号?去登录</view>
<view class="reg-btn" @click="goLogin">{{ isForgotPwd ? '返回登录' : '已有账号?去登录' }}</view>
</view>
</view>
......@@ -271,10 +334,10 @@
<view class="checkbox-align">
<fui-checkbox
color="#5DB66F"
:scale="0.7"
:scaleRatio="0.7"
:checked="model.form.data.read"
@change="(e) => (model.form.data.read = e.checked)"
/>
></fui-checkbox>
</view>
<view class="policy-text">
同意<text class="link" @click.stop="Link.to(Link.services, '服务协议')">《服务协议》</text>与<text class="link" @click.stop="Link.to(Link.privacy, '隐私政策')">《隐私政策》</text>
......@@ -288,7 +351,35 @@
<view class="footer-aesthetic"></view>
<SlideVerify v-model:show="showSlide" @success="onSlideSuccess" />
<view class="slide-mask" v-if="showSlide" @click.self="showSlide = false">
<view class="slide-panel">
<view class="slide-gradient-header"></view>
<view class="slide-close" @click="showSlide = false">
<text></text>
</view>
<view class="slide-title-wrap">
<view class="slide-title">安全验证</view>
<view class="slide-title-line"></view>
</view>
<view class="slide-content">
<view class="slide-desc">按住滑块,拖动到最右侧</view>
<fui-slide-verify
ref="slideVerifyRef"
width="540"
height="80"
slider-width="80"
background="#f5f7f5"
active-bg-color="#5DB66F"
pass-color="#5DB66F"
arrow-color="#5DB66F"
line-color="#5DB66F"
color="#999"
active-color="#fff"
@success="onSlideSuccess"
></fui-slide-verify>
</view>
</view>
</view>
</view>
</template>
......@@ -302,7 +393,7 @@
}
.header-visual {
height: 550rpx;
height: 460rpx;
position: relative;
padding-top: var(--status-bar-height);
overflow: hidden;
......@@ -331,18 +422,18 @@
filter: drop-shadow(0 4rpx 10rpx rgba(0,0,0,0.15));
&.device-1 {
width: 130rpx;
height: 130rpx;
width: 100rpx;
height: 100rpx;
right: 20rpx;
top: 180rpx;
top: 140rpx;
transform: rotate(10deg);
animation: float-slow 4s ease-in-out infinite;
}
&.device-2 {
width: 110rpx;
height: 110rpx;
width: 90rpx;
height: 90rpx;
left: 30rpx;
bottom: 100rpx;
bottom: 80rpx;
animation: float-slow 3s ease-in-out infinite alternate;
}
}
......@@ -359,11 +450,11 @@
display: flex;
flex-direction: column;
align-items: center;
margin-top: 20rpx;
margin-top: 0rpx;
.logo-box {
width: 140rpx;
height: 140rpx;
width: 120rpx;
height: 120rpx;
background: #fff;
border-radius: 40rpx;
display: flex;
......@@ -372,25 +463,25 @@
box-shadow: 0 12rpx 24rpx rgba(0,0,0,0.1);
.logo-img {
width: 90rpx;
height: 90rpx;
width: 76rpx;
height: 76rpx;
}
}
.brand-info {
margin-top: 24rpx;
margin-top: 16rpx;
text-align: center;
color: #fff;
.app-title {
font-size: 44rpx;
font-size: 38rpx;
font-weight: 800;
letter-spacing: 2rpx;
text-shadow: 0 2rpx 4rpx rgba(0,0,0,0.1);
}
.app-slogan {
margin-top: 8rpx;
margin-top: 4rpx;
font-size: 24rpx;
opacity: 0.95;
display: flex;
......@@ -417,33 +508,34 @@
.aesthetic-card {
background: #ffffff;
border-radius: 50rpx;
padding: 70rpx 50rpx 60rpx;
padding: 36rpx 50rpx 30rpx;
box-shadow: 0 15rpx 50rpx rgba(0, 0, 0, 0.06);
.card-header {
margin-bottom: 50rpx;
margin-bottom: 24rpx;
.welcome-text {
font-size: 38rpx;
font-size: 34rpx;
font-weight: bold;
color: #333;
}
.green-line {
width: 50rpx;
height: 7rpx;
width: 44rpx;
height: 6rpx;
background: #5DB66F;
border-radius: 4rpx;
margin-top: 14rpx;
border-radius: 3rpx;
margin-top: 10rpx;
}
}
.field-item {
margin-bottom: 40rpx;
margin-bottom: 20rpx;
.field-label {
font-size: 26rpx;
font-size: 24rpx;
color: #999;
margin-left: 4rpx;
margin-bottom: 2rpx;
}
.code-inner {
......@@ -453,9 +545,9 @@
.sms-trigger {
white-space: nowrap;
font-size: 27rpx;
font-size: 24rpx;
color: #5DB66F;
padding: 14rpx 28rpx;
padding: 10rpx 24rpx;
background: #f1f9f2;
border-radius: 50rpx;
margin-left: 20rpx;
......@@ -471,28 +563,29 @@
}
.action-footer {
margin-top: 70rpx;
margin-top: 40rpx;
.btn-wrapper {
transition: opacity 0.3s;
}
.secondary-links {
margin-top: 40rpx;
margin-top: 38rpx;
text-align: center;
.reg-btn {
font-size: 28rpx;
color: #888;
padding: 10rpx 40rpx;
border: 1rpx solid #f0f0f0;
border-radius: 40rpx;
color: #aaa;
}
}
}
.policy-wrapper {
margin-top: 60rpx;
margin-top: 62rpx;
.policy-label {
display: flex;
align-items: center; /* 垂直居中 */
align-items: flex-start;
justify-content: center;
}
......@@ -500,12 +593,14 @@
line-height: 1;
display: flex;
align-items: center;
flex-shrink: 0;
margin-top: 4rpx;
}
.policy-text {
font-size: 24rpx;
font-size: 22rpx;
color: #bbb;
margin-left: 12rpx;
margin-left: 10rpx;
display: flex;
align-items: center;
}
......@@ -521,6 +616,100 @@
background: linear-gradient(180deg, #f7faf8 0%, #edf3ef 100%);
}
.slide-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.slide-panel {
width: 640rpx;
background: #ffffff;
border-radius: 50rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 15rpx 50rpx rgba(0, 0, 0, 0.08);
position: relative;
overflow: hidden;
}
.slide-gradient-header {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 180rpx;
background: linear-gradient(180deg, #4CAF50 0%, #5DB66F 40%, rgba(93, 182, 111, 0.15) 85%, transparent 100%);
border-radius: 50rpx 50rpx 0 0;
}
.slide-close {
position: absolute;
top: 20rpx;
right: 24rpx;
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.25);
z-index: 2;
text {
font-size: 32rpx;
color: #ffffff;
line-height: 1;
}
}
.slide-title-wrap {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 48rpx;
margin-bottom: 20rpx;
position: relative;
z-index: 1;
}
.slide-title {
font-size: 36rpx;
font-weight: 700;
color: #ffffff;
margin-bottom: 10rpx;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.12);
}
.slide-title-line {
width: 48rpx;
height: 6rpx;
background: rgba(255, 255, 255, 0.7);
border-radius: 3rpx;
}
.slide-content {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: 36rpx 50rpx 48rpx;
position: relative;
z-index: 1;
}
.slide-desc {
font-size: 26rpx;
color: #999;
margin-bottom: 36rpx;
letter-spacing: 0.5rpx;
}
@keyframes float-slow {
0%, 100% { transform: translateY(0) rotate(5deg); }
50% { transform: translateY(-15rpx) rotate(-5deg); }
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论