feat: 登录页

This commit is contained in:
AfyerCu 2025-08-15 21:30:51 +08:00
parent 02f9c4ec4c
commit 03b516afa7
8 changed files with 633 additions and 14 deletions

7
env/.env vendored
View File

@ -8,11 +8,10 @@ VITE_WX_APPID = 'wxa2abb91f64032a2b'
VITE_APP_PUBLIC_BASE=/
# 登录页面
VITE_LOGIN_URL = '/pages/login/index'
# 第一个请求地址
VITE_SERVER_BASEURL = 'https://ukw0y1.laf.run'
VITE_LOGIN_URL = '/pages/auth/index'
VITE_UPLOAD_BASEURL = 'https://ukw0y1.laf.run/upload'
VITE_SERVER_BASEURL = 'http://mapi.xianglexue.com/api/v1'
VITE_UPLOAD_BASEURL = 'http://mapi.xianglexue.com/api/v1'
# h5是否需要配置代理
VITE_APP_PROXY=false

2
env/.env.production vendored
View File

@ -1,6 +1,6 @@
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
NODE_ENV = 'development'
# 是否去除console 和 debugger
VITE_DELETE_CONSOLE = true
VITE_DELETE_CONSOLE = false
# 是否开启sourcemap
VITE_SHOW_SOURCEMAP = false

View File

@ -1,25 +1,91 @@
<script setup lang="ts">
import { onHide, onLaunch, onShow } from '@dcloudio/uni-app'
import { navigateToInterceptor } from '@/router/interceptor'
import { useUserStore } from '@/store/user'
import { tabbarStore } from './tabbar/store'
import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'
const userStore = useUserStore()
//
const publicPages = [
'/pages/auth/splash',
'/pages/auth/index',
]
//
function isPublicPage(path: string) {
return publicPages.some(page => path.includes(page.replace('/pages/', '')))
}
//
function handlePageNavigation(targetPath?: string) {
const isLoggedIn = userStore.isLogin && userStore.accessToken
//
if (!isLoggedIn && targetPath && !isPublicPage(targetPath)) {
console.log('用户未登录,跳转到加载页')
uni.reLaunch({
url: '/pages/auth/index',
})
return
}
// 访
if (isLoggedIn && targetPath && isPublicPage(targetPath)) {
console.log('用户已登录,跳转到首页')
uni.reLaunch({
url: '/pages/index/index',
})
return
}
//
if (targetPath) {
navigateToInterceptor.invoke({ url: targetPath })
}
else {
//
const defaultUrl = isLoggedIn ? '/pages/index/index' : '/pages/auth/splash'
navigateToInterceptor.invoke({ url: defaultUrl })
}
}
onLaunch((options) => {
// h5
// https://github.com/unibest-tech/unibest/issues/192
console.log('App Launch', options)
if (options?.path) {
navigateToInterceptor.invoke({ url: `/${options.path}` })
}
else {
navigateToInterceptor.invoke({ url: '/' })
}
// tabbarIndex
tabbarStore.setAutoCurIdx(options.path)
const targetPath = options?.path ? `/${options.path}` : undefined
// store
setTimeout(() => {
handlePageNavigation(targetPath)
// tabbarIndex
tabbarStore.setAutoCurIdx(options.path)
}, 100)
})
onShow((options) => {
console.log('App Show', options)
//
const isLoggedIn = userStore.isLogin && userStore.accessToken
if (!isLoggedIn) {
//
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const currentRoute = currentPage?.route || ''
if (!isPublicPage(`/${currentRoute}`)) {
console.log('应用显示时检测到未登录,跳转到登录页')
uni.reLaunch({
url: '/pages/auth/index',
})
}
}
})
onHide(() => {
console.log('App Hide')
})

View File

@ -0,0 +1,287 @@
<script setup lang="ts">
import type { LoginRequest } from '@/service/types'
import { computed, reactive, ref } from 'vue'
import VerificationCode from './VerificationCode.vue'
interface Props {
mode: 'login' | 'reset'
}
interface Emits {
(e: 'login', data: LoginRequest): void
(e: 'reset', data: { phone: string, code: string, password: string }): void
(e: 'switchMode', mode: 'login' | 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
//
const loginForm = reactive<LoginRequest>({
username: '',
password: '',
})
const resetForm = reactive({
phone: '',
code: '',
password: '',
confirmPassword: '',
})
//
const loading = ref(false)
const agreed = ref(false)
//
const isLogin = computed(() => props.mode === 'login')
const isReset = computed(() => props.mode === 'reset')
//
function validateLoginForm() {
if (!loginForm.username) {
uni.showToast({
title: '请输入手机号',
icon: 'none',
})
return false
}
if (!/^1[3-9]\d{9}$/.test(loginForm.username)) {
uni.showToast({
title: '请输入正确的手机号',
icon: 'none',
})
return false
}
if (!loginForm.password) {
uni.showToast({
title: '请输入密码',
icon: 'none',
})
return false
}
if (loginForm.password.length < 6) {
uni.showToast({
title: '密码不能少于6位',
icon: 'none',
})
return false
}
return true
}
function validateResetForm() {
if (!resetForm.phone) {
uni.showToast({
title: '请输入手机号',
icon: 'none',
})
return false
}
if (!/^1[3-9]\d{9}$/.test(resetForm.phone)) {
uni.showToast({
title: '请输入正确的手机号',
icon: 'none',
})
return false
}
if (!resetForm.code) {
uni.showToast({
title: '请输入验证码',
icon: 'none',
})
return false
}
if (!resetForm.password) {
uni.showToast({
title: '请输入新密码',
icon: 'none',
})
return false
}
if (resetForm.password.length < 8) {
uni.showToast({
title: '密码不能少于8位',
icon: 'none',
})
return false
}
if (resetForm.password !== resetForm.confirmPassword) {
uni.showToast({
title: '两次密码输入不一致',
icon: 'none',
})
return false
}
if (!agreed.value) {
uni.showToast({
title: '请先同意用户协议',
icon: 'none',
})
return false
}
return true
}
//
function handleSubmit() {
if (isLogin.value) {
if (validateLoginForm()) {
emit('login', loginForm)
}
}
else {
if (validateResetForm()) {
emit('reset', {
phone: resetForm.phone,
code: resetForm.code,
password: resetForm.password,
})
}
}
}
function handleSwitchMode() {
const newMode = isLogin.value ? 'reset' : 'login'
emit('switchMode', newMode)
}
//
function handleCodeUpdate(code: string) {
resetForm.code = code
}
</script>
<template>
<view class="min-h-40vh w-full p-8 pt-4">
<!-- 表单标题 -->
<view class="mb-4 text-center">
<text class="text-xl text-gray-900 font-bold">{{ isLogin ? '登录' : '重置密码' }}</text>
</view>
<!-- 表单内容 -->
<view class="space-y-4">
<!-- 登录表单 -->
<view v-if="isLogin" class="mb-24 space-y-4">
<!-- 手机号输入 -->
<view class="space-y-2">
<text class="block text-sm text-gray-700 font-medium">手机号</text>
<wd-input
v-model="loginForm.username"
placeholder="请输入手机号"
type="number"
:maxlength="11"
clearable
class="w-full"
/>
</view>
<!-- 密码输入 -->
<view class="space-y-2">
<text class="block text-sm text-gray-700 font-medium">密码</text>
<wd-input
v-model="loginForm.password"
type="text"
show-password
placeholder="请输入密码"
clearable
class="w-full"
/>
</view>
</view>
<!-- 重置密码表单 -->
<view v-else class="space-y-2">
<!-- 手机号输入 -->
<view class="space-y-2">
<text class="block text-sm text-gray-700 font-medium">电话号码</text>
<wd-input
v-model="resetForm.phone"
placeholder="请输入手机号"
type="number"
:maxlength="11"
clearable
class="w-full"
/>
</view>
<!-- 验证码输入 -->
<verification-code
:phone="resetForm.phone"
@update:code="handleCodeUpdate"
/>
<!-- 新密码输入 -->
<view class="space-y-2">
<text class="block text-sm text-gray-700 font-medium">新密码</text>
<wd-input
v-model="resetForm.password"
type="text"
show-password
placeholder="请输入新密码"
clearable
class="w-full"
/>
</view>
<!-- 确认密码输入 -->
<view class="space-y-2">
<text class="block text-sm text-gray-700 font-medium">确认密码</text>
<wd-input
v-model="resetForm.confirmPassword"
type="text"
show-password
placeholder="请再次输入密码"
clearable
class="w-full"
/>
<!-- 密码提示 -->
<text class="block text-0.6rem text-gray-500">
密码应不少于8位至少包含数字字母且不能包含中文
</text>
</view>
</view>
<!-- 用户协议 -->
<view class="flex items-center space-x-1" @click="agreed = !agreed">
<wd-checkbox :model-value="agreed" class="mt-2" />
<text class="text-0.6rem text-gray-600">
我已阅读并同意用户协议隐私政策以及个人信息处理规则
</text>
</view>
</view>
<!-- 表单按钮 -->
<view class="mt-4 space-y-4">
<!-- 提交按钮 -->
<wd-button
type="primary"
size="large"
block
:loading="loading"
class="rounded-lg !h-12"
@click="handleSubmit"
>
{{ isLogin ? '登录' : '重置密码' }}
</wd-button>
<!-- 切换模式 -->
<view class="text-center">
<text class="cursor-pointer text-sm text-blue-600" @click="handleSwitchMode">
{{ isLogin ? '忘记密码?' : '返回登录' }}
</text>
</view>
</view>
</view>
</template>

View File

@ -0,0 +1,117 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { authGetCodeUsingGet } from '@/service'
interface Props {
phone: string
}
interface Emits {
(e: 'update:code', code: string): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
//
const code = ref('')
const disabled = ref(false)
const countdown = ref(0)
const codeText = ref('获取验证码')
//
watch(code, (newCode) => {
emit('update:code', newCode)
})
//
async function sendCode() {
if (!props.phone) {
uni.showToast({
title: '请输入手机号',
icon: 'none',
})
return
}
if (!/^1[3-9]\d{9}$/.test(props.phone)) {
uni.showToast({
title: '请输入正确的手机号',
icon: 'none',
})
return
}
try {
await authGetCodeUsingGet({
params: { phone: props.phone },
})
uni.showToast({
title: '验证码已发送',
icon: 'success',
})
//
startCountdown()
}
catch (error) {
console.error('发送验证码失败:', error)
uni.showToast({
title: '发送失败,请重试',
icon: 'none',
})
}
}
//
function startCountdown() {
disabled.value = true
countdown.value = 60
const timer = setInterval(() => {
countdown.value--
codeText.value = `${countdown.value}s后重发`
if (countdown.value <= 0) {
clearInterval(timer)
disabled.value = false
codeText.value = '获取验证码'
}
}, 1000)
}
//
function handleNoCode() {
uni.showModal({
title: '提示',
content: '如果您收不到验证码,请检查手机号是否正确,或联系客服获取帮助。',
showCancel: false,
})
}
</script>
<template>
<view class="space-y-2">
<text class="block text-sm text-gray-700 font-medium">验证码</text>
<view class="flex space-x-3">
<wd-input
v-model="code"
placeholder="请输入验证码"
type="number"
:maxlength="6"
clearable
class="flex-1"
/>
<wd-button
type="primary"
size="small"
:disabled="disabled"
class="h-10 whitespace-nowrap px-4 text-xs"
@click="sendCode"
>
{{ codeText }}
</wd-button>
</view>
</view>
</template>

View File

@ -68,6 +68,8 @@ const httpInterceptor = {
if (currentToken) {
options.header.Authorization = `Bearer ${currentToken}`
}
return options
},
}

View File

@ -72,6 +72,14 @@
"style": {
"navigationBarTitleText": "Vue Query 请求演示"
}
},
{
"path": "pages/auth/index",
"type": "page",
"name": "auth",
"style": {
"navigationStyle": "custom"
}
}
],
"subPackages": [
@ -89,4 +97,4 @@
]
}
]
}
}

140
src/pages/auth/index.vue Normal file
View File

@ -0,0 +1,140 @@
<script setup lang="ts">
import type { LoginRequest } from '@/service/types'
import { ref } from 'vue'
import AuthForm from '@/components/auth/AuthForm.vue'
import { sysUsersResetPasswordUsingPost } from '@/service/xitongyonghu'
import { useUserStore } from '@/store/user'
const userStore = useUserStore()
//
const currentMode = ref<'login' | 'reset'>('login')
const loading = ref(false)
//
async function handleLogin(data: LoginRequest) {
try {
loading.value = true
await userStore.login(data)
uni.showToast({
title: '登录成功',
icon: 'success',
})
//
setTimeout(() => {
uni.reLaunch({
url: '/pages/index/index',
})
}, 1500)
}
catch (error) {
console.error('登录失败:', error)
}
finally {
loading.value = false
}
}
//
async function handleReset(data: { phone: string, code: string, password: string }) {
try {
loading.value = true
// API
await sysUsersResetPasswordUsingPost({
body: {
phone: data.phone,
verify_code: data.code,
new_password: data.password,
confirm_password: data.password,
username: data.phone, // 使
},
})
uni.showToast({
title: '密码重置成功',
icon: 'success',
})
//
setTimeout(() => {
currentMode.value = 'login'
}, 1500)
}
catch (error) {
console.error('重置密码失败:', error)
uni.showToast({
title: '重置失败,请重试',
icon: 'none',
})
}
finally {
loading.value = false
}
}
//
function handleSwitchMode(mode: 'login' | 'reset') {
currentMode.value = mode
}
</script>
<template>
<view class="relative h-screen w-full overflow-hidden from-blue-500 via-purple-500 to-blue-600 bg-gradient-to-br">
<!-- 背景图片 -->
<image
class="absolute inset-0 z-0 h-full w-full opacity-80"
src="https://images.unsplash.com/photo-1557804506-669a67965ba0?ixlib=rb-4.0.3&auto=format&fit=crop&w=1974&q=80"
mode="aspectFill"
/>
<!-- 渐变蒙版 -->
<view class="absolute inset-0 z-1 from-blue-500/90 via-purple-500/80 to-blue-600/90 bg-gradient-to-br" />
<!-- 主要内容 -->
<view class="relative z-2 h-full flex flex-col">
<!-- Logo 区域 -->
<view class="flex flex-1 flex-col items-center justify-center px-8">
<view class="relative mb-4">
<view class="h-12 w-12 flex items-center justify-center rounded-3xl from-red-500 to-red-600 bg-gradient-to-br shadow-xl">
<text class="text-3xl text-white font-bold"></text>
</view>
</view>
<view class="text-center">
<text class="mb-2 block text-4xl text-white font-bold drop-shadow-lg">象乐学</text>
<text class="mb-4 block text-base text-white/90 drop-shadow">教师版</text>
<text class="block text-sm text-white/80 drop-shadow">乐学未来向学而行</text>
</view>
</view>
<!-- 认证表单区域 - 全宽底部 -->
<view class="w-full rounded-t-2xl bg-white shadow-2xl">
<auth-form
:mode="currentMode"
@login="handleLogin"
@reset="handleReset"
@switch-mode="handleSwitchMode"
/>
</view>
</view>
<!-- 装饰元素 -->
<view class="pointer-events-none absolute inset-0 z-1">
<view class="absolute left-1/10 top-1/5 h-20 w-20 animate-pulse rounded-full bg-white/10" />
<view class="animation-delay-2s absolute right-1/6 top-3/5 h-12 w-12 animate-pulse rounded-full bg-white/10" />
<view class="animation-delay-4s absolute left-1/12 top-3/4 h-28 w-28 animate-pulse rounded-full bg-white/10" />
</view>
</view>
</template>
<route lang="json5">
{
"name": "auth",
"style": {
"navigationStyle": "custom",
}
}
</route>