Files
medical-mall/pages/user/login.uvue
2026-03-18 17:14:05 +08:00

786 lines
23 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 password-field">
<input
class="input"
:type="isPasswordVisible ? 'text' : 'password'"
placeholder="密码"
:value="password"
@input="(e: any) => password = e.detail.value"
/>
<view class="eye-btn" @click="isPasswordVisible = !isPasswordVisible">
<!-- 睁眼(猴子双手打开)表示可见, 闭眼(猴子捂眼)表示不可见 -->
<text class="eye-icon">{{ isPasswordVisible ? '🙉' : '🙈' }}</text>
</view>
</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'
import { AkReq } from '@/uni_modules/ak-req/index.uts'
import { PUSH_SERVER_URL } from '@/ak/config.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 isPasswordVisible = ref<boolean>(false)
const isLoading = ref<boolean>(false)
/**
* 【核心函数】:登录成功后,多条件校验是否为商家角色
* 优先级: session_uid (auth_id) -> id -> normalized email
*/
const checkAdminOrMerchantAccess = async (uid: string, rawEmail: string) : Promise<UTSJSONObject | null> => {
const email = rawEmail.trim().toLowerCase()
console.log(`🔍 开始校验后台或商家端角色 -> UID: ${uid}, Email: ${email}`)
const parseRoleData = (dataArray: any | null): UTSJSONObject | null => {
if (Array.isArray(dataArray) && dataArray.length > 0) {
const obj = dataArray[0] as UTSJSONObject
const role = obj.getString('role')
const id = obj.getString('id')
console.log('✅ 匹配成功role:', role)
if ((role === 'merchant' || role === 'admin') && id != null) {
return { id, role } as UTSJSONObject
}
}
return null
}
try {
// 1. 尝试按 auth_id 查询
let res = await supa.from('ak_users').select('id, role').eq('auth_id', uid).execute()
let parsed = parseRoleData(res.data)
if (parsed != null) return parsed
// 2. 尝试按 id 查询 (兼容老数据)
res = await supa.from('ak_users').select('id, role').eq('id', uid).execute()
parsed = parseRoleData(res.data)
if (parsed != null) return parsed
// 3. 尝试按 email 兜底查询
if (email !== '') {
res = await supa.from('ak_users').select('id, role').eq('email', email).execute()
const dataArray = res.data
if (Array.isArray(dataArray) && dataArray.length > 1) {
console.error('⚠️ 警告: 按 email 查到多条 ak_users 记录取第一条校验。Email:', email)
}
parsed = parseRoleData(dataArray)
if (parsed != null) return parsed
}
console.error('❌ 未能在 ak_users 中找到该用户的有效角色记录')
throw new Error('NOT_REGISTERED')
} catch (e) {
console.error('❌ 查询角色过程异常:', e)
if (e instanceof Error && e.message === 'NOT_REGISTERED') {
throw new Error('该账户无后台或商家端权限,请联系管理员核对')
}
throw new Error('后台身份校验失败,请联系管理员检查用户数据')
}
}
const codeDisabled = ref<boolean>(false)
const codeText = ref<string>('获取验证码')
let codeTimer: number | null = null
const codeCountdown = ref<number>(0)
onMounted(() => {
try {
// 检查是否已有 Session
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
console.log('检测到已有会话, 执行重定向...')
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
// 特殊账号处理:仅在测试模式下保留直登逻辑,生产环境强制校验角色
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('merchant_id', 'admin') // mock removed
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) {
// 1) 调用 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' ||
errorMsg.includes('Invalid credentials')) {
throw new Error('邮箱或密码错误')
} else {
throw new Error(errorMsg || '登录失败,请重试')
}
}
// 2) 【核心逻辑】:执行商家端角色准入校验
// 优先使用 session user id 来查询数据库里的真实 user 数据兜底校验
const sessionUser = result.user
let sessionUid = sessionUser?.getString('id') ?? ''
const accessData = await checkAdminOrMerchantAccess(sessionUid, account.value)
if (accessData == null) {
await supa.signOut()
logout()
throw new Error('该账户无后台或商家端权限')
}
const currRole = accessData.getString('role')
const currId = accessData.getString('id')
// uni.setStorageSync('adminRole', currRole) // 移除本地缓存依赖,强制按单例会话状态
if (currRole === 'merchant') {
// uni.setStorageSync('merchant_id', currId) // 移除本地缓存依赖,强制按单例会话状态
} else {
uni.removeStorageSync('merchant_id')
}
} else {
uni.showToast({ title: '手机号密码登录功能开发中', icon: 'none' })
return
}
} else {
uni.showToast({ title: '手机验证码登录功能开发中', icon: 'none' })
return
}
// 更新 store 中的用户资料
try {
const profile = await getCurrentUser()
console.log('fetch profile success:', profile)
} catch (e) {
console.error('获取用户信息失败(忽略):', e)
}
// 已移除对 shared storage user_id 的依赖
const currentSession = supa.getSession()
uni.showToast({ title: '登录成功', icon: 'success' })
// 获取并上报推送 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 currentSession = supa.getSession()
const currentUid = uidStored || (currentSession && 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)
}
// 成功跳转逻辑
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; }
.password-field {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
}
.eye-btn {
position: absolute;
right: 14px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
.eye-icon {
font-size: 18px;
}
.input{
width: 100%;
height: 44px;
line-height: 44px;
border-radius: 10px;
background: var(--inputbg);
padding: 0 14px;
font-size: 14px;
color: var(--text);
box-sizing: border-box;
display: flex;
align-items: center;
}
/* 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>