Files
medical-mall/pages/user/login.uvue
2026-03-17 12:17:38 +08:00

708 lines
20 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 { AkReq } from '@/uni_modules/ak-req/index.uts'
import { IS_TEST_MODE, PUSH_SERVER_URL } from '@/ak/config.uts'
import { getCurrentUser, logout, setIsLoggedIn, setUserProfile } from '@/utils/store.uts'
import { UserProfile } from '@/pages/user/types.uts'
import { ensureUserProfile } from '@/utils/sapi.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 {
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
// 注意IS_TEST_MODE 仅在 boot 页禁止自动跳转,在登录页若已有 Session建议还是允许其进入首页
if (redirect != null && redirect.length > 0) {
uni.redirectTo({ url: decodeURIComponent(redirect) })
} else if (!IS_TEST_MODE) {
// 非测试模式下,自动跳转首页
uni.switchTab({ url: '/pages/mall/consumer/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
// 特殊账号处理:仅在测试模式允许本地绕过登录(不推荐生产使用)
if (IS_TEST_MODE && 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.setStorageSync('user_id', 'admin')
uni.showToast({ title: '管理员登录成功(测试模式)', icon: 'success' })
setTimeout(() => {
uni.switchTab({ url: '/pages/mall/consumer/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
}
// 登录成功后强制同步用户资料到 ak_users确保 auth_id 与 role 落表)
try {
const sessionInfo = supa.getSession()
if (sessionInfo?.user != null) {
const syncResult = await ensureUserProfile(sessionInfo.user)
console.log('ensureUserProfile sync result:', syncResult)
}
} catch (e) {
console.error('同步用户资料到 ak_users 失败(不阻塞登录):', e)
}
// 登录成功后按 redirect 参数跳转(守卫放在需要权限的页面入口)
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.switchTab({ url: '/pages/mall/consumer/index' })
}
// 显式保存用户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)
// 获取并上报推送 CID若可用
try {
uni.getPushClientId({
success: async (res: any) => {
const cid = (res && (res.clientid || res.cid)) ? (res.clientid || res.cid) : (typeof res === 'string' ? res : null)
if (cid != null && cid !== '') {
try {
uni.setStorageSync('uni_push2_cid', cid)
} catch (e) {}
try {
const uidStored = uni.getStorageSync('user_id') || null
const currentUid = uidStored || (currentSession.user ? currentSession.user.getString('id') : null)
await AkReq.request({
url: `${PUSH_SERVER_URL}/api/v1/push/register`,
method: 'POST',
data: { cid, platform: 'android', user_id: currentUid },
contentType: 'application/json'
})
console.log('CID 已上报后台:', cid, 'user_id:', currentUid)
} catch (e) {
console.warn('上报 CID 失败:', e)
}
}
},
fail: (err: any) => {
console.warn('获取 Push CID 失败:', err)
}
})
} catch (e) {
console.warn('getPushClientId 调用异常:', e)
}
}
}
uni.showToast({ title: '登录成功', icon: 'success' })
// 登录成功后的跳转不应受 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.switchTab({ url: '/pages/mall/consumer/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>