Files
medical-mall/pages/user/login.uvue
2026-02-26 17:49:19 +08:00

658 lines
17 KiB
Plaintext
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="page" :style="cssVars">
<!-- Header仅保留左侧Logo -->
<view class="header">
<view class="header-left">
<image :src="logoUrl" mode="aspectFit" class="logo" />
</view>
</view>
<!-- Main -->
<view class="main">
<view class="card">
<!-- Left扫码区>=768显示 -->
<view class="left">
<text class="left-title">APP 扫码登录</text>
<view class="left-hint">
<text class="hint-text">打开 APP 扫一扫</text>
<text class="hint-link" @click="handleTutorial">查看教程</text>
</view>
<view class="qr-wrap">
<view class="qr">
<view class="qr-placeholder">
<text class="qr-text">二维码占位</text>
<text class="qr-sub">220×220</text>
</view>
</view>
</view>
</view>
<!-- Divider -->
<view class="divider"></view>
<!-- Right表单区 -->
<view class="right">
<view class="right-inner">
<!-- Tabs -->
<view class="tabs">
<view class="tab" :class="{ active: loginType === 0 }" @click="loginType = 0">
<text class="tab-text">密码登录</text>
<view v-if="loginType === 0" class="tab-line"></view>
</view>
<view class="tab" :class="{ active: loginType === 1 }" @click="loginType = 1">
<text class="tab-text">短信登录</text>
<view v-if="loginType === 1" class="tab-line"></view>
</view>
</view>
<!-- Form -->
<view class="form">
<template v-if="loginType === 0">
<view class="field">
<input
class="input"
type="text"
placeholder="账号名/手机号/邮箱"
:value="account"
@input="(e: any) => account = e.detail.value"
/>
</view>
<view class="field">
<input
class="input"
type="password"
placeholder="密码"
:value="password"
@input="(e: any) => password = e.detail.value"
/>
</view>
</template>
<template v-else>
<view class="field">
<input
class="input"
type="text"
placeholder="输入手机号码"
maxlength="11"
:value="account"
@input="(e: any) => account = e.detail.value"
/>
</view>
<view class="field code-row">
<input
class="input code-input"
type="text"
placeholder="填写验证码"
maxlength="6"
:value="captcha"
@input="(e: any) => captcha = e.detail.value"
/>
<view class="code-btn" :class="{ disabled: codeDisabled }" @click="getCode">
<text class="code-text">{{ codeText }}</text>
</view>
</view>
</template>
<!-- Button -->
<view class="btn" :class="{ disabled: isLoading }" @click="handleLogin">
<text class="btn-text">登录</text>
</view>
<!-- Actions一行横排 -->
<view class="actions">
<view class="action-item" @click="handleWechatLogin">
<view class="dot wechat"></view>
<text class="action-text">微信登录</text>
</view>
<text class="sep">|</text>
<view class="action-item" @click="handleQQLogin">
<view class="dot qq"></view>
<text class="action-text">QQ登录</text>
</view>
<text class="sep">|</text>
<text class="action-link" @click="handleForgotPassword">忘记密码</text>
<text class="sep">|</text>
<text class="action-link" @click="navigateToRegister">立即注册</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- Footer -->
<view class="footer">
<text class="footer-text">Copyright ©2024 Mall. All Rights Reserved</text>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
import { IS_TEST_MODE } from '@/ak/config.uts'
import { getCurrentUser, logout, setIsLoggedIn, setUserProfile } from '@/utils/store.uts'
import type { UserProfile } from '@/pages/user/types.uts'
const cssVars = {
'--bg': '#f5f6f8',
'--card': '#ffffff',
'--brand': '#e1251b',
'--text': '#333333',
'--muted': '#666666',
'--muted2': '#999999',
'--border': '#eeeeee',
'--inputbg': '#f6f7f9',
'--shadow': '0 2px 12px rgba(0,0,0,0.06)'
}
const logoUrl = ref<string>('/static/logo.png')
const loginType = ref<number>(0)
const account = ref<string>('')
const password = ref<string>('')
const captcha = ref<string>('')
const isLoading = ref<boolean>(false)
const codeDisabled = ref<boolean>(false)
const codeText = ref<string>('获取验证码')
let codeTimer: number | null = null
const codeCountdown = ref<number>(0)
onMounted(() => {
try {
if (IS_TEST_MODE) return
const sessionInfo = supa.getSession()
if (sessionInfo != null && sessionInfo.user != null) {
const pages = getCurrentPages() as any[]
const currentPage = pages.length > 0 ? pages[pages.length - 1] : null
const opts = currentPage?.options as any
const redirect = opts?.redirect as string | null
if (redirect != null && redirect.length > 0) {
uni.redirectTo({ url: decodeURIComponent(redirect) })
} else {
uni.reLaunch({ url: '/pages/mall/admin/homePage/index' })
}
}
} catch (e) {
console.error('检查登录状态失败:', e)
}
})
const validateAccount = (): boolean => {
if (account.value.trim() === '') {
uni.showToast({ title: '请填写账号', icon: 'none' })
return false
}
if (loginType.value === 1) {
if (!/^1[3-9]\d{9}$/.test(account.value)) {
uni.showToast({ title: '请输入正确的手机号码', icon: 'none' })
return false
}
}
return true
}
const validatePassword = (): boolean => {
if (password.value.trim() === '') {
uni.showToast({ title: '请填写密码', icon: 'none' })
return false
}
if (password.value.length < 6) {
uni.showToast({ title: '密码长度不能少于6位', icon: 'none' })
return false
}
return true
}
const validateCaptcha = (): boolean => {
if (captcha.value.trim() === '') {
uni.showToast({ title: '请填写验证码', icon: 'none' })
return false
}
if (!/^\d{6}$/.test(captcha.value)) {
uni.showToast({ title: '请输入正确的验证码', icon: 'none' })
return false
}
return true
}
const getCode = async () => {
if (codeDisabled.value) return
if (!validateAccount()) return
uni.showToast({ title: '验证码已发送', icon: 'success' })
codeDisabled.value = true
codeCountdown.value = 60
codeText.value = `${codeCountdown.value}秒后重试`
codeTimer = setInterval(() => {
codeCountdown.value--
if (codeCountdown.value > 0) {
codeText.value = `${codeCountdown.value}秒后重试`
} else {
codeDisabled.value = false
codeText.value = '获取验证码'
if (codeTimer != null) {
clearInterval(codeTimer)
codeTimer = null
}
}
}, 1000) as unknown as number
}
const handleLogin = async () => {
if (!validateAccount()) return
// 特殊账号处理admin/admin 直接跳转
if (account.value === 'admin' && password.value === 'admin') {
setIsLoggedIn(true)
const adminProfile = {
id: 'admin',
username: 'Admin',
email: 'admin@mall.com',
gender: 'unknown',
birthday: '',
height_cm: 0,
weight_kg: 0,
bio: 'Administrator',
avatar_url: '/static/logo.png',
preferred_language: 'zh-CN',
role: 'admin',
school_id: '',
grade_id: '',
class_id: ''
} as UserProfile
setUserProfile(adminProfile)
uni.showToast({ title: '管理员登录成功', icon: 'success' })
setTimeout(() => {
uni.reLaunch({ url: '/pages/mall/admin/homePage/index' })
}, 500)
return
}
if (loginType.value === 0) {
if (!validatePassword()) return
} else {
if (!validateCaptcha()) return
}
isLoading.value = true
try {
logout()
if (loginType.value === 0) {
const isEmail = account.value.includes('@')
if (isEmail) {
// 邮箱 + 密码登录Supabase Auth
const result = await supa.signIn(account.value.trim(), password.value)
console.log('signIn result:', result)
// 检查登录是否失败
if (result.user == null) {
// 检查是否是邮箱未确认的错误
const rawData = result.raw as UTSJSONObject
const errorMsg = rawData?.getString('msg') ?? ''
const errorCode = rawData?.getString('error_code') ?? ''
if (errorMsg.includes('email') && errorMsg.includes('confirm') ||
errorCode === 'email_not_confirmed' ||
errorMsg.includes('邮箱') && errorMsg.includes('确认')) {
throw new Error('邮箱未确认,请先检查邮箱并点击确认链接')
} else if (errorMsg.includes('Invalid login credentials') ||
errorCode === 'invalid_credentials') {
throw new Error('邮箱或密码错误')
} else {
throw new Error(errorMsg || '登录失败,请重试')
}
}
} else {
uni.showToast({ title: '手机号密码登录功能开发中', icon: 'none' })
return
}
} else {
uni.showToast({ title: '手机验证码登录功能开发中', icon: 'none' })
return
}
// 尝试获取/补全用户资料,但失败时不再阻塞登录
try {
const profile = await getCurrentUser()
console.log('current user profile:', profile)
} catch (e) {
console.error('获取用户信息失败(忽略,不阻塞登录):', e)
}
// 显式保存用户ID到本地存储确保页面刷新或重启后 SupabaseService 能恢复身份
const currentSession = supa.getSession()
if (currentSession.user != null) {
const uid = currentSession.user?.getString('id')
if (uid != null) {
uni.setStorageSync('user_id', uid)
console.log('用户ID已保存到本地存储:', uid)
}
}
uni.showToast({ title: '登录成功', icon: 'success' })
if (!IS_TEST_MODE) {
setTimeout(() => {
const pages = getCurrentPages() as any[]
const currentPage = pages.length > 0 ? pages[pages.length - 1] : null
const opts = currentPage?.options as any
const redirect = opts?.redirect as string | null
if (redirect != null && redirect.length > 0) {
uni.redirectTo({ url: decodeURIComponent(redirect) })
} else {
uni.reLaunch({ url: '/pages/mall/admin/homePage/index' })
}
}, 500)
}
} catch (err) {
console.error('登录错误:', err)
let msg = '登录失败,请重试'
if (err != null && typeof err === 'object') {
const e = err as Error
if (e.message != null && e.message.trim() !== '') msg = e.message
}
uni.showToast({ title: msg, icon: 'none' })
} finally {
isLoading.value = false
}
}
const navigateToRegister = () => {
const pages = getCurrentPages() as any[]
const currentPage = pages.length > 0 ? pages[pages.length - 1] : null
const opts = currentPage?.options as any
const redirect = opts?.redirect as string | null
if (redirect != null && redirect.length > 0) {
uni.navigateTo({
url: `/pages/user/register?redirect=${redirect}`
})
} else {
uni.navigateTo({
url: '/pages/user/register'
})
}
}
const handleTutorial = () => uni.showToast({ title: '扫码教程开发中', icon: 'none' })
const handleForgotPassword = () => uni.showToast({ title: '忘记密码开发中', icon: 'none' })
const handleWechatLogin = () => uni.showToast({ title: '微信登录开发中', icon: 'none' })
const handleQQLogin = () => uni.showToast({ title: 'QQ登录开发中', icon: 'none' })
</script>
<style scoped>
/* Base */
.page{
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg);
}
/* Header */
.header{
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
padding: 30px 72px;
}
.logo{
width: 300px;
height: 80px;
}
/* Main */
.main{
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 28px 18px;
}
/* Card */
.card{
width: min(980px, 92vw);
min-height: 460px;
background: var(--card);
border-radius: 16px;
box-shadow: var(--shadow);
padding: 40px;
display: flex;
flex-direction: row;
gap: 32px;
}
/* Left */
.left{
flex: 0 0 52%;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding-left: 18px;
}
.left-title{
font-size: 18px;
font-weight: 600;
color: var(--text);
margin-bottom: 10px;
}
.left-hint{
display: flex;
flex-direction: row;
align-items: center;
gap: 14px;
margin-bottom: 18px;
}
.hint-text{ font-size: 13px; color: var(--muted); }
.hint-link{ font-size: 13px; color: var(--brand); }
.qr-wrap{
width: 100%;
display: flex;
flex-direction: row;
justify-content: flex-start;
}
.qr{
width: 240px;
height: 240px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.qr-placeholder{
width: 220px;
height: 220px;
border: 1px solid #e6e6e6;
border-radius: 8px;
background: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
}
.qr-text{ font-size: 14px; color: var(--muted); }
.qr-sub{ font-size: 12px; color: var(--muted2); }
/* Divider */
.divider{
width: 1px;
background: var(--border);
flex-shrink: 0;
}
/* Right */
.right{
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.right-inner{
width: 360px; /* 京东右侧“窄列”观感 */
max-width: 100%;
margin-left: auto; /* 靠右 */
}
/* Tabs */
.tabs{
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 24px;
margin-bottom: 18px;
}
.tab{
position: relative;
padding: 8px 2px;
}
.tab-text{
font-size: 16px;
color: var(--muted);
}
.tab.active .tab-text{
color: var(--brand);
font-weight: 600;
}
.tab-line{
position: absolute;
left: 0;
right: 0;
bottom: -6px;
height: 2px;
background: var(--brand);
border-radius: 2px;
}
/* Form */
.form{ margin-top: 10px; }
.field{ margin-bottom: 14px; }
.input{
width: 100%;
height: 44px;
border-radius: 10px;
background: var(--inputbg);
padding: 0 14px;
font-size: 14px;
color: var(--text);
box-sizing: border-box;
}
/* Code row */
.code-row{
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
}
.code-input{ flex: 1; }
.code-btn{
height: 44px;
padding: 0 12px;
border-radius: 10px;
background: #fff;
border: 1px solid #eee;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.code-btn.disabled{ opacity: 0.5; }
.code-text{ font-size: 13px; color: var(--brand); }
/* Button */
.btn{
margin-top: 16px;
height: 46px;
border-radius: 10px;
background: rgba(225, 37, 27, 0.45);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.btn.disabled{ background: #d9d9d9; }
.btn-text{
color: #fff;
font-size: 16px;
font-weight: 600;
}
/* Actions一行横排 */
.actions{
margin-top: 16px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 12px;
flex-wrap: nowrap;
}
.action-item{
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.dot{
width: 16px;
height: 16px;
border-radius: 50%;
}
.dot.wechat{ background: #19be6b; }
.dot.qq{ background: #2d8cf0; }
.action-text{ font-size: 13px; color: var(--muted); }
.action-link{ font-size: 13px; color: var(--muted); }
.sep{ font-size: 13px; color: #e0e0e0; }
/* Footer */
.footer{
padding: 18px 0 28px;
display: flex;
flex-direction: row;
justify-content: center;
}
.footer-text{ font-size: 12px; color: var(--muted2); }
/* ===== 自适应:断点全部用 px避免 rpx 在宽屏放大) ===== */
@media screen and (max-width: 1024px){
.header{ padding: 24px 20px; }
.logo{ width: 240px; height: 68px; }
.card{ width: 92vw; padding: 28px; gap: 22px; }
.right-inner{ width: 360px; }
}
@media screen and (max-width: 768px){
.card{ flex-direction: column; min-height: auto; }
.left{ display: none; }
.divider{ display: none; }
.right-inner{ width: 100%; margin-left: 0; }
.actions{ flex-wrap: wrap; }
}
@media screen and (max-width: 520px){
.sep{ display: none; }
}
</style>