consumer模块完成90%,前端完成supabase对接

This commit is contained in:
2026-02-04 17:21:15 +08:00
parent 8a535e3f38
commit 39aa1b6bec
1335 changed files with 191376 additions and 4 deletions

656
mall/pages/user/login.uvue Normal file
View File

@@ -0,0 +1,656 @@
<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 } from '@/utils/store.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.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
// 特殊账号处理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.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
}
// 尝试获取/补全用户资料,但失败时不再阻塞登录
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.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>