Files
medical-mall/pages/user/login.uvue

1016 lines
30 KiB
Plaintext
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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="账号名/手机号/邮箱"
v-model="account"
autocomplete="off"
/>
</view>
<view class="field password-field">
<input
class="input"
:type="isPasswordVisible ? 'text' : 'password'"
placeholder="密码"
v-model="password"
autocomplete="new-password"
/>
<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"
v-model="account"
autocomplete="off"
/>
</view>
<view class="field code-row">
<input
class="input code-input"
type="text"
placeholder="填写验证码"
maxlength="6"
v-model="captcha"
/>
<view class="code-btn" :class="codeDisabled ? 'disabled' : ''" @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 { onLoad } from '@dcloudio/uni-app'
import supa from '@/components/supadb/aksupainstance.uts'
import { IS_TEST_MODE, PUSH_SERVER_URL } from '@/ak/config.uts'
import { getCurrentUser, logout, setIsLoggedIn, setUserProfile } from '@/utils/store.uts'
import { checkMerchantAccount, clearAuth, goMerchantHome, isMerchantRole, requireMerchantAuth, setMerchantInfo } from '@/utils/merchantAuth.uts'
import { clearDeliveryAuth, goDeliveryHome, requireDeliveryAuth, saveDeliverySession } from '@/utils/deliveryAuth.uts'
import { loginDelivery } from '@/services/deliveryService.uts'
import type { UserProfile } from '@/types/mall-types.uts'
import { AkReq } from '@/uni_modules/ak-req/index.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)
// ─────────────────────────────────────────────
// 默认账号密码(唯一来源,修改只改这里)
// 必须在 account/password ref 之前声明,否则 ref 初始化时无法引用
// ─────────────────────────────────────────────
// const TEST_ACCOUNT = 'test@mall.com' // ← 旧账号(已停用)
// const TEST_PASSWORD = 'Hf2152111' // ← 旧密码(已停用)
const TEST_ACCOUNT = 'test19@163.com'
const TEST_PASSWORD = 'huang123456'
// ✅ account/password 直接以常量作初始值,上线/刷新立即生效,不再依赖 onMounted 延迟赋值
const account = ref<string>('')
const password = ref<string>('')
const captcha = ref<string>('')
const isPasswordVisible = ref<boolean>(false)
const isLoading = ref<boolean>(false)
const redirectPath = ref<string>('')
const loginMode = ref<string>('consumer')
const getOptionText = (opts: UTSJSONObject, key: string): string => {
try {
const value = opts.getString(key)
if (value != null && value !== '') {
return value
}
} catch (e) {}
try {
const rawValue = opts[key]
if (rawValue != null) {
return '' + rawValue
}
} catch (e) {}
return ''
}
const isMerchantMode = (): boolean => {
return loginMode.value === 'merchant'
}
const isDeliveryMode = (): boolean => {
return loginMode.value === 'delivery'
}
const goTargetHome = (): void => {
if (isDeliveryMode()) {
goDeliveryHome()
} else if (isMerchantMode()) {
goMerchantHome()
} else {
goConsumerHome()
}
}
onLoad((opts) => {
if (opts != null) {
const optsObj = opts as UTSJSONObject
redirectPath.value = getOptionText(optsObj, 'redirect')
const parsedMode = getOptionText(optsObj, 'mode')
if (parsedMode === 'merchant') {
loginMode.value = 'merchant'
uni.setNavigationBarTitle({ title: '商家登录' })
} else if (parsedMode === 'delivery') {
loginMode.value = 'delivery'
uni.setNavigationBarTitle({ title: '服务人员登录' })
} else {
loginMode.value = 'consumer'
uni.setNavigationBarTitle({ title: '登录' })
}
}
})
type ParseRoleInput = UTSJSONObject | Array<UTSJSONObject> | string | null
const checkUserAccess = async (uid: string, rawEmail: string): Promise<UTSJSONObject | null> => {
const email = rawEmail.trim().toLowerCase()
const parseRoleData = (dataArray: ParseRoleInput): UTSJSONObject | null => {
if (Array.isArray(dataArray)) {
const arr = dataArray as Array<UTSJSONObject>
if (arr.length > 0) {
const obj = arr[0]
const role = obj.getString('role') ?? ''
const id = obj.getString('id') ?? ''
const allowed = isMerchantMode() ? (role === 'merchant') : (role === 'consumer' || role === 'customer')
if (allowed && id !== '') {
return { id, role } as UTSJSONObject
}
}
}
return null
}
try {
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
res = await supa.from('ak_users').select('id, role').eq('id', uid).execute()
parsed = parseRoleData(res.data)
if (parsed != null) return parsed
if (email !== '') {
res = await supa.from('ak_users').select('id, role').eq('email', email).execute()
parsed = parseRoleData(res.data)
if (parsed != null) return parsed
}
throw new Error('NOT_REGISTERED')
} catch (e) {
if (e instanceof Error && e.message === 'NOT_REGISTERED') {
if (isMerchantMode()) {
throw new Error('该账户不是商家账号,或数据库中不存在该账户')
}
throw new Error('该账户不是消费者,或数据库中不存在该账户')
}
if (isMerchantMode()) {
throw new Error('商家身份校验失败,请联系管理员检查用户数据')
}
throw new Error('消费者身份校验失败,请联系管理员检查用户数据')
}
}
const codeDisabled = ref<boolean>(false)
const codeText = ref<string>('获取验证码')
const codeTimer = ref<number>(0)
const codeCountdown = ref<number>(0)
const goConsumerHome = (): void => {
uni.reLaunch({ url: '/pages/main/index' })
}
const isTabBarPage = (url: string): boolean => {
return url === '/pages/main/index'
|| url === '/pages/main/category'
|| url === '/pages/main/messages'
|| url === '/pages/main/cart'
|| url === '/pages/main/profile'
|| url === '/pages/mall/delivery/home/index'
|| url === '/pages/mall/delivery/orders/index'
|| url === '/pages/mall/delivery/messages/index'
|| url === '/pages/mall/delivery/profile/index'
}
const navigateToRedirect = (redirect: string): void => {
const target = decodeURIComponent(redirect) ?? ''
if (target === '') {
goTargetHome()
return
}
if (isTabBarPage(target)) {
uni.switchTab({ url: target })
return
}
uni.redirectTo({ url: target })
}
const redirectAfterMount = (redirect: string): void => {
setTimeout(() => {
if (redirect !== '') {
navigateToRedirect(redirect)
} else {
goTargetHome()
}
}, 80)
}
const checkLoginStatus = async (): Promise<void> => {
try {
const sessionInfo = supa.getSession()
const storedId = uni.getStorageSync('user_id')
const hasSession = sessionInfo != null && sessionInfo.user != null
const hasStorage = storedId != null && (storedId as string) !== ''
if (isDeliveryMode()) {
const authResult = await requireDeliveryAuth({ redirectOnFail: false, toastOnFail: false })
if (!authResult.ok) {
return
}
const redirect = redirectPath.value
redirectAfterMount(redirect)
return
}
if (hasSession && hasStorage) {
if (isMerchantMode()) {
const authResult = await requireMerchantAuth({ redirectOnFail: false, toastOnFail: false })
if (!authResult.ok) {
return
}
}
const redirect = redirectPath.value
console.log('检测到已有会话, 执行重定向...')
redirectAfterMount(redirect)
}
} catch (e) {
console.error('检查登录状态失败:', e)
}
}
onMounted(() => {
// ── 调试日志(开发阶段保留,上线前可删除)──
console.log('[Login] ▶ onMounted 开始')
console.log('[Login] 📝 form 初始值:', {
account: account.value,
password: account.value === TEST_ACCOUNT ? '(TEST_PASSWORD 默认)' : '(ref 已被其他逻辑覆盖)'
})
// 检查是否存在旧版本 loginn.uvue 遗留的 rememberEmail 缓存
try {
const storedEmail = uni.getStorageSync('rememberEmail') as string | null
console.log('[Login] 📦 读取 storage[rememberEmail]:', storedEmail ?? '(无)')
if (storedEmail != null && storedEmail.trim() !== '') {
// 如果存在之前的记住密码缓存,创建账号使用缓存,密码空留用户手动输入
console.log('[Login] 发现 rememberEmail 缓存,使用缓存账号:', storedEmail)
account.value = storedEmail
password.value = '' // 密码不缓存,须用户手动输入
} else {
console.log('[Login] 无缓存,使用默认账号密码(已由 ref 初始化)')
// account 和 password 已在 ref 声明时直接用 TEST_ACCOUNT/TEST_PASSWORD 初始化,无需重复赋值
}
} catch (e) {
console.error('[Login] ⚠️ 读取 rememberEmail 失败(已忽略):', e)
}
console.log('[Login] 📝 onMounted 完成,最终 form 値:', {
account: account.value,
isDefault: account.value === TEST_ACCOUNT
})
if (isDeliveryMode()) {
account.value = ''
password.value = ''
} else if (isMerchantMode()) {
account.value = TEST_ACCOUNT
password.value = TEST_PASSWORD
}
checkLoginStatus()
})
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.value = setInterval(() => {
codeCountdown.value--
if (codeCountdown.value > 0) {
codeText.value = `${codeCountdown.value}秒后重试`
} else {
codeDisabled.value = false
codeText.value = '获取验证码'
if (codeTimer.value != 0) {
clearInterval(codeTimer.value)
codeTimer.value = 0
}
}
}, 1000) as number
}
const handleLogin = async () => {
if (!validateAccount()) return
if (isDeliveryMode()) {
if (loginType.value !== 0) {
uni.showToast({ title: '服务人员端暂不支持短信登录', icon: 'none' })
return
}
if (!validatePassword()) return
if (!account.value.includes('@')) {
uni.showToast({ title: '请使用真实邮箱账号登录', icon: 'none' })
return
}
isLoading.value = true
try {
clearAuth()
clearDeliveryAuth()
const result = await loginDelivery({ account: account.value.trim(), password: password.value.trim() })
saveDeliverySession(result.token, result.userInfo, result.deliveryInfo)
const authResult = await requireDeliveryAuth({ redirectOnFail: false, toastOnFail: false })
if (!authResult.ok) {
clearDeliveryAuth()
throw new Error(authResult.message != '' ? authResult.message : '服务人员登录失败')
}
uni.$emit('authChanged', { loggedIn: true })
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => {
const redirect = redirectPath.value
if (redirect !== '') {
navigateToRedirect(redirect)
} else {
goTargetHome()
}
}, 300)
} catch (err) {
let msg = '登录失败,请重试'
try {
const e = err as Error
if (e.message != null && e.message.trim() !== '') msg = e.message
} catch (e2) {}
uni.showToast({ title: msg, icon: 'none' })
} finally {
isLoading.value = false
}
return
}
// 特殊账号处理:仅在测试模式下保留直登逻辑,生产环境强制校验角色
if (!isMerchantMode() && 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: isMerchantMode() ? 'admin' : 'consumer',
school_id: '',
grade_id: '',
class_id: ''
} as UserProfile
setUserProfile(adminProfile)
// uni.setStorageSync('merchant_id', 'admin') // mock removed
uni.$emit('authChanged', { loggedIn: true })
uni.showToast({ title: '管理员登录成功', icon: 'success' })
setTimeout(() => {
goTargetHome()
}, 500)
return
}
if (loginType.value === 0) {
if (!validatePassword()) return
} else {
if (!validateCaptcha()) return
}
isLoading.value = true
try {
clearAuth()
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') ||
errorMsg.includes('credentials') ||
errorMsg.includes('invalid')) {
throw new Error('用户名或密码错误')
} else {
throw new Error(errorMsg != '' ? errorMsg : '登录失败,请重试')
}
}
// 2) 按当前登录模式校验消费者或商家身份
const sessionUser = result.user
let sessionUid = sessionUser?.getString('id') ?? ''
const accessData = await checkUserAccess(sessionUid, account.value)
if (accessData == null) {
await supa.signOut()
clearAuth()
throw new Error(isMerchantMode() ? '该账户无商家端权限' : '该账户无消费者权限')
}
if (!isMerchantMode()) {
uni.removeStorageSync('merchant_id')
uni.removeStorageSync('merchant_info')
}
} else {
uni.showToast({ title: '手机号密码登录功能开发中', icon: 'none' })
return
}
} else {
uni.showToast({ title: '手机验证码登录功能开发中', icon: 'none' })
return
}
// 更新 store 中的用户资料
let profile: UserProfile | null = null
try {
profile = await getCurrentUser()
console.log('fetch profile success:', profile)
} catch (e) {
console.error('获取用户信息失败(忽略):', e)
}
if (isMerchantMode()) {
if (profile == null || !isMerchantRole(profile)) {
clearAuth()
throw new Error('当前账号不是商家账号')
}
const merchantInfo = await checkMerchantAccount(profile)
if (merchantInfo == null) {
clearAuth()
throw new Error('当前账号未绑定商家信息')
}
if (merchantInfo.status === 0) {
clearAuth()
throw new Error('当前商家信息待审核')
}
if (merchantInfo.status != null && merchantInfo.status !== 1) {
clearAuth()
throw new Error('当前商家状态异常,暂不可登录')
}
setMerchantInfo(merchantInfo)
}
// 已移除对 shared storage user_id 的依赖
const currentSession = supa.getSession()
uni.$emit('authChanged', { loggedIn: true })
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 redirect = redirectPath.value
if (redirect !== '') {
navigateToRedirect(redirect)
} else {
goTargetHome()
}
}, 500)
} catch (err) {
console.error('登录错误:', err)
let msg = '登录失败,请重试'
// UTS 不支持 typeof 检查,直接尝试转换
try {
const e = err as Error
if (e.message != null && e.message.trim() !== '') msg = e.message
} catch (e2) {
// 忽略转换错误,使用默认消息
}
uni.showToast({ title: msg, icon: 'none' })
} finally {
isLoading.value = false
}
}
const navigateToRegister = (): void => {
let url = '/pages/user/register'
if (isDeliveryMode()) {
url = '/pages/user/register?mode=delivery'
} else if (isMerchantMode()) {
url = '/pages/user/register?mode=merchant'
}
uni.navigateTo({
url
})
}
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; */ /* UVUE 不支持 vh */
flex: 1;
display: flex;
flex-direction: column;
background-color: #f5f6f8; /* UVUE 暂不支持 cssVars 在 style 标签中的变量引用 */
}
/* Header */
.header{
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
padding: 30px 40px; /* 调整边距 */
}
.logo{
width: 240px;
height: 64px;
}
/* Main */
.main{
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 20px 10px;
}
/* Card */
.card{
/* width: min(980px, 92vw); UVUE 不支持 min/vw */
/* min-height: 460px; */
width: 90%;
background-color: #ffffff;
border-radius: 16px;
/* box-shadow: var(--shadow); */
padding: 30px;
display: flex;
flex-direction: column; /* App端改为列式布局兼容性更好或者用 row 需注意 */
/* gap: 32px; UVUE 不支持 gap */
}
/* Left - 暂隐藏或简化 */
/* .left{ display: none; } */
.left{
/* flex: 0 0 52%; UVUE flex 简写支持不全,建议用 flex-grow/basis */
flex-direction: column;
align-items: flex-start;
justify-content: center;
/* padding-left: 18px; */
display: none; /* 移动端 App 暂时隐藏扫码区 */
}
.left-title{
font-size: 18px;
font-weight: 700; /* 600 -> 700 */
color: #333333;
margin-bottom: 10px;
}
.left-hint{
display: flex;
flex-direction: row;
align-items: center;
/* gap: 14px; */
margin-bottom: 18px;
}
/* 替代 gap */
.hint-text{ font-size: 13px; color: #666666; margin-right: 14px; }
.hint-link{ font-size: 13px; color: #e1251b; }
.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-color: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
/* gap: 8px; */
}
/* 替代 gap */
.qr-text{ font-size: 14px; color: #666666; margin-bottom: 8px; }
.qr-sub{ font-size: 12px; color: #999999; }
/* Divider */
.divider{
display: none; /* 移动端隐藏分割线 */
}
/* Right */
.right{
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.right-inner{
/* width: 360px; */
/* max-width: 100%; UVUE 不支持百分比 max-width */
width: 100%;
margin-left: auto;
}
/* Tabs */
.tabs{
display: flex;
flex-direction: row;
align-items: center;
justify-content: center; /* 移动端居中 */
/* gap: 24px; */
margin-bottom: 18px;
}
.tab{
position: relative;
padding: 8px 12px; /* 增加内边距替代 gap */
margin: 0 12px;
}
.tab-text{
font-size: 16px;
color: #666666;
}
.tab.active .tab-text{
color: #e1251b;
font-weight: 700; /* 600 -> 700 */
}
.tab-line{
position: absolute;
left: 0;
right: 0;
bottom: -6px;
height: 2px;
background-color: #e1251b;
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-color: #f6f7f9;
padding: 0 14px;
font-size: 14px;
color: #333333;
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; margin-right: 10px; }
.code-btn{
height: 44px;
padding: 0 12px;
border-radius: 10px;
background-color: #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: #e1251b; }
/* Button */
.btn{
margin-top: 16px;
height: 46px;
border-radius: 10px;
background-color: rgba(225, 37, 27, 0.45); /* 注意 rgba 兼容性,建议用 hex 或 view opacity */
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.btn.disabled{ background-color: #d9d9d9; }
.btn-text{
color: #fff;
font-size: 16px;
font-weight: 700; /* 600 -> 700 */
}
/* Actions一行横排 */
.actions{
margin-top: 16px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
/* gap: 12px; */
flex-wrap: wrap; /* 允许换行 */
}
.action-item{
display: flex;
flex-direction: row;
align-items: center;
/* gap: 8px; */
margin: 0 6px;
}
.dot{
width: 16px;
height: 16px;
border-radius: 8px; /* 50% -> 8px (一半) */
margin-right: 8px;
}
.dot.wechat{ background-color: #19be6b; }
.dot.qq{ background-color: #2d8cf0; }
.action-text{ font-size: 13px; color: #666666; }
.action-link{ font-size: 13px; color: #666666; margin: 0 6px; }
.sep{ font-size: 13px; color: #e0e0e0; margin: 0 6px; }
/* Footer */
.footer{
padding: 18px 0 28px;
display: flex;
flex-direction: row;
justify-content: center;
}
.footer-text{ font-size: 12px; color: #999999; }
/* ===== 自适应:移除复杂 Media Query使用简单流式布局 ===== */
/*
@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>