完成一键登录和支付功能
This commit is contained in:
@@ -61,7 +61,6 @@ const HIDE_TABBAR_PAGES = [
|
||||
function getActiveIndexFromRoute() {
|
||||
const routePath = getCurrentRoutePath()
|
||||
const index = getTabIndexByRoute(routePath)
|
||||
console.log('从路由获取索引:', routePath, '->', index)
|
||||
return index >= 0 ? index : 0
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* 登录弹窗组件使用示例
|
||||
*
|
||||
* 该组件提供了统一的登录拦截功能,可以在任何页面使用
|
||||
*/
|
||||
|
||||
## 方式一:在页面中直接使用LoginModal组件
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<view>
|
||||
<!-- 其他内容 -->
|
||||
|
||||
<!-- 引入登录弹窗 -->
|
||||
<LoginModal
|
||||
ref="loginModalRef"
|
||||
v-model="showLoginModal"
|
||||
title="登录后享受更多服务"
|
||||
subtitle="登录即表示同意相关协议"
|
||||
:show-close="true"
|
||||
:mask-closable="true"
|
||||
@loginSuccess="onLoginSuccess"
|
||||
@loginError="onLoginError"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { registerLoginModal } from '@/common/hooks/useLoginGuard.js'
|
||||
import LoginModal from '@/components/global/LoginModal.vue'
|
||||
|
||||
const showLoginModal = ref(false)
|
||||
const loginModalRef = ref(null)
|
||||
|
||||
// 注册到全局
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
if (loginModalRef.value) {
|
||||
registerLoginModal(loginModalRef.value)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 显示登录弹窗
|
||||
function showLogin() {
|
||||
showLoginModal.value = true
|
||||
}
|
||||
|
||||
// 登录成功回调
|
||||
function onLoginSuccess(result) {
|
||||
console.log('登录成功', result)
|
||||
// 刷新页面数据等
|
||||
}
|
||||
|
||||
// 登录失败回调
|
||||
function onLoginError(error) {
|
||||
console.log('登录失败', error)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 方式二:使用 useLoginGuard Hook
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { useLoginGuard } from '@/common/hooks/useLoginGuard.js'
|
||||
|
||||
const { requireLogin, checkLogin } = useLoginGuard()
|
||||
|
||||
// 示例1:需要登录才能执行的操作
|
||||
async function handleBookCourse() {
|
||||
// 检查是否已登录
|
||||
if (!checkLogin()) {
|
||||
return
|
||||
}
|
||||
// 执行预约操作...
|
||||
}
|
||||
|
||||
// 示例2:弹窗式登录(推荐)
|
||||
async function handlePurchase() {
|
||||
const loggedIn = await requireLogin({
|
||||
title: '请先登录',
|
||||
subtitle: '登录后即可购买会员卡',
|
||||
onSuccess: (user) => {
|
||||
console.log('登录成功,用户信息:', user)
|
||||
}
|
||||
})
|
||||
|
||||
if (loggedIn) {
|
||||
// 执行购买操作
|
||||
await purchaseCard()
|
||||
}
|
||||
}
|
||||
|
||||
// 示例3:安全执行函数
|
||||
async function handleCheckIn() {
|
||||
const result = await requireLogin().safeExecute(async () => {
|
||||
// 只有登录后才能执行这里的代码
|
||||
return await checkInApi()
|
||||
})
|
||||
|
||||
if (result) {
|
||||
uni.showToast({ title: '签到成功' })
|
||||
}
|
||||
}
|
||||
|
||||
// 示例4:包装API调用
|
||||
async function handleGetCoupons() {
|
||||
const { callLoggedInApi } = useLoginGuard()
|
||||
|
||||
try {
|
||||
const coupons = await callLoggedInApi(getCouponsApi, [], {
|
||||
title: '领取优惠券'
|
||||
})
|
||||
console.log('优惠券列表:', coupons)
|
||||
} catch (e) {
|
||||
if (e.message === '用户未登录') {
|
||||
// 用户未登录,弹窗已显示
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 方式三:在组件中使用登录守卫Mixin
|
||||
|
||||
```vue
|
||||
<script>
|
||||
import { loginGuardMixin } from '@/common/hooks/useLoginGuard.js'
|
||||
|
||||
export default {
|
||||
mixins: [loginGuardMixin()],
|
||||
|
||||
methods: {
|
||||
async onQuickAction(type) {
|
||||
// 自动检查登录状态
|
||||
if (!await this.ensureLogin()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 已登录,执行操作
|
||||
if (type === 'book') {
|
||||
this.goToBooking()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 登录弹窗组件Props说明
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| modelValue | Boolean | false | 控制显示/隐藏 |
|
||||
| title | String | '登录后享受更多服务' | 弹窗标题 |
|
||||
| subtitle | String | '登录即表示同意相关协议' | 弹窗副标题 |
|
||||
| showClose | Boolean | true | 是否显示关闭按钮 |
|
||||
| modalStyle | String | 'center' | 弹窗样式:'center'居中,'bottom'底部 |
|
||||
| maskClosable | Boolean | true | 点击遮罩是否关闭 |
|
||||
| onLoginSuccess | Function | null | 登录成功回调 |
|
||||
| onLoginError | Function | null | 登录失败回调 |
|
||||
|
||||
## 登录弹窗组件Events
|
||||
|
||||
| 事件名 | 说明 | 回调参数 |
|
||||
|--------|------|----------|
|
||||
| update:modelValue | 显示状态变化 | Boolean |
|
||||
| loginSuccess | 登录成功 | Object: 登录结果 |
|
||||
| loginError | 登录失败 | Error: 错误对象 |
|
||||
| close | 弹窗关闭 | - |
|
||||
|
||||
## 登录弹窗组件暴露的方法
|
||||
|
||||
| 方法名 | 说明 | 参数 |
|
||||
|--------|------|------|
|
||||
| show() | 显示弹窗 | - |
|
||||
| hide() | 隐藏弹窗 | - |
|
||||
| switchCard(card) | 切换登录方式 | 'phone'一键登录 / 'sms'验证码登录 |
|
||||
|
||||
## 全局登录拦截示例
|
||||
|
||||
在 `App.vue` 中注册全局登录弹窗:
|
||||
|
||||
```vue
|
||||
<script>
|
||||
import LoginModal from '@/components/global/LoginModal.vue'
|
||||
import { registerLoginModal } from '@/common/hooks/useLoginGuard.js'
|
||||
|
||||
export default {
|
||||
components: { LoginModal },
|
||||
onLaunch() {
|
||||
// 注册全局登录弹窗
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.globalLoginModal) {
|
||||
registerLoginModal(this.$refs.globalLoginModal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view id="app">
|
||||
<router-view />
|
||||
<LoginModal ref="globalLoginModal" />
|
||||
</view>
|
||||
</template>
|
||||
```
|
||||
|
||||
## API路径配置
|
||||
|
||||
登录弹窗组件使用的API路径定义在 `@/api/main.js`:
|
||||
|
||||
```javascript
|
||||
// 一键登录
|
||||
export function phoneLogin(data) {
|
||||
return request.post('/auth/phone/login', data)
|
||||
}
|
||||
|
||||
// 短信验证码登录
|
||||
export function smsLogin(data) {
|
||||
return request.post('/auth/phone/sms/login', data)
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
export function sendSmsCode(phone) {
|
||||
return request.post('/auth/phone/sms/send', { phone })
|
||||
}
|
||||
```
|
||||
|
||||
如需修改API路径,请编辑 `main.js` 文件中的相应函数。
|
||||
@@ -0,0 +1,691 @@
|
||||
<template>
|
||||
<!-- 登录弹窗组件 -->
|
||||
<view class="login-modal-mask" v-if="visible" @tap="handleMaskTap">
|
||||
<view class="login-modal" :class="{ 'login-modal--center': modalStyle === 'center' }" @tap.stop>
|
||||
<!-- 关闭按钮 -->
|
||||
<view class="login-modal__close" v-if="showClose" @tap="handleClose">
|
||||
<text class="icon-close">×</text>
|
||||
</view>
|
||||
|
||||
<!-- 弹窗内容 -->
|
||||
<view class="login-modal__content">
|
||||
<!-- 头部 -->
|
||||
<view class="login-modal__header">
|
||||
<view class="logo-wrapper">
|
||||
<image class="logo-image" src="/static/logo.png" mode="aspectFit" />
|
||||
<text class="app-name">活氧舱</text>
|
||||
</view>
|
||||
<text class="modal-title">{{ title }}</text>
|
||||
<text class="modal-subtitle">{{ subtitle }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 一键登录卡片 -->
|
||||
<view class="login-card" :class="{ active: activeCard === 'phone' }">
|
||||
<view class="card-body">
|
||||
<text class="card-title">本机号码登录</text>
|
||||
<text class="auth-tip">认证服务由中国移动提供</text>
|
||||
|
||||
<button class="login-btn primary" :class="{ disabled: isLoading, loading: isLoading }"
|
||||
@tap="handlePhoneLogin" :disabled="isLoading">
|
||||
<text>{{ isLoading ? '登录中...' : '一键登录' }}</text>
|
||||
</button>
|
||||
|
||||
<button class="login-btn switch-btn" @tap="switchToSms">
|
||||
<text>短信验证码登录</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 短信验证码登录卡片 -->
|
||||
<view class="login-card sms-card" :class="{ active: activeCard === 'sms' }">
|
||||
<view class="card-body">
|
||||
<text class="card-title">验证码登录</text>
|
||||
<text class="auth-tip">认证服务由中国移动提供</text>
|
||||
|
||||
<view class="form-group">
|
||||
<view class="input-wrapper" :class="{ focused: isPhoneFocused }">
|
||||
<input v-model="smsPhone" type="number" placeholder="请输入手机号" maxlength="11"
|
||||
class="phone-input" confirm-type="next" @focus="isPhoneFocused = true"
|
||||
@blur="isPhoneFocused = false" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<view class="input-wrapper code-input" :class="{ focused: isCodeFocused }">
|
||||
<input v-model="smsCode" type="number" placeholder="请输入验证码" maxlength="6"
|
||||
class="phone-input" confirm-type="done" @focus="isCodeFocused = true"
|
||||
@blur="isCodeFocused = false" />
|
||||
<button class="send-code-btn" :class="{ disabled: countdown > 0 || isLoading }"
|
||||
@tap="handleSendCode" :disabled="countdown > 0 || isLoading">
|
||||
<text>{{ countdown > 0 ? `${countdown}秒后重发` : '发送验证码' }}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button class="login-btn primary" :class="{ disabled: !canSmsSubmit, loading: isLoading }"
|
||||
@tap="handleSmsLogin" :disabled="!canSmsSubmit || isLoading">
|
||||
<text>{{ isLoading ? '登录中...' : '登录' }}</text>
|
||||
</button>
|
||||
|
||||
<button class="login-btn switch-btn" @tap="switchToPhone">
|
||||
<text>一键登录</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 协议 -->
|
||||
<view class="agreement-text">
|
||||
<checkbox-group @change="onAgreementChange">
|
||||
<checkbox :checked="agreed" value="agreed" color="#FF8C42" />
|
||||
</checkbox-group>
|
||||
<text>登录即表示同意</text>
|
||||
<text class="link" @tap="showAgreement">《用户协议》</text>
|
||||
<text>和</text>
|
||||
<text class="link" @tap="showPrivacy">《隐私政策》</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Toast提示 -->
|
||||
<view class="custom-toast" :class="{ show: toastShow }">
|
||||
<text class="toast-text">{{ toastMessage }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { oneClickLogin, loginWithPhone, sendCode } from '@/api/main.js'
|
||||
|
||||
// Props定义
|
||||
const props = defineProps({
|
||||
// 是否显示弹窗
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 弹窗标题
|
||||
title: {
|
||||
type: String,
|
||||
default: '登录后享受更多服务'
|
||||
},
|
||||
// 弹窗副标题
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: '登录即表示同意相关协议'
|
||||
},
|
||||
// 是否显示关闭按钮
|
||||
showClose: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 弹窗样式: 'center'居中, 'bottom'底部
|
||||
modalStyle: {
|
||||
type: String,
|
||||
default: 'center'
|
||||
},
|
||||
// 是否点击遮罩层关闭
|
||||
maskClosable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 登录成功回调
|
||||
onLoginSuccess: {
|
||||
type: Function,
|
||||
default: null
|
||||
},
|
||||
// 登录失败回调
|
||||
onLoginError: {
|
||||
type: Function,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
// Emits定义
|
||||
const emit = defineEmits(['update:modelValue', 'loginSuccess', 'loginError', 'close'])
|
||||
|
||||
// 响应式状态
|
||||
const visible = ref(false)
|
||||
const activeCard = ref('phone') // 'phone'一键登录, 'sms'验证码登录
|
||||
const isLoading = ref(false)
|
||||
const isPhoneFocused = ref(false)
|
||||
const isCodeFocused = ref(false)
|
||||
const smsPhone = ref('')
|
||||
const smsCode = ref('')
|
||||
const countdown = ref(0)
|
||||
const agreed = ref(false)
|
||||
const toastShow = ref(false)
|
||||
const toastMessage = ref('')
|
||||
|
||||
// 计算属性
|
||||
const canSmsSubmit = computed(() => {
|
||||
return smsPhone.value.length === 11 && smsCode.value.length === 6 && agreed.value
|
||||
})
|
||||
|
||||
// 监听modelValue变化
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
visible.value = newVal
|
||||
if (newVal) {
|
||||
// 打开弹窗时重置状态
|
||||
resetState()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 重置状态
|
||||
function resetState() {
|
||||
activeCard.value = 'phone'
|
||||
isLoading.value = false
|
||||
smsPhone.value = ''
|
||||
smsCode.value = ''
|
||||
countdown.value = 0
|
||||
agreed.value = false
|
||||
}
|
||||
|
||||
// 显示Toast
|
||||
function showToast(message, duration = 2000) {
|
||||
toastMessage.value = message
|
||||
toastShow.value = true
|
||||
setTimeout(() => {
|
||||
toastShow.value = false
|
||||
}, duration)
|
||||
}
|
||||
|
||||
// 显示用户协议
|
||||
function showAgreement() {
|
||||
uni.showModal({
|
||||
title: '用户协议',
|
||||
content: '这里是用户协议内容...',
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
|
||||
// 显示隐私政策
|
||||
function showPrivacy() {
|
||||
uni.showModal({
|
||||
title: '隐私政策',
|
||||
content: '这里隐私政策内容...',
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
|
||||
// 切换到短信登录
|
||||
function switchToSms() {
|
||||
activeCard.value = 'sms'
|
||||
}
|
||||
|
||||
// 切换到一键登录
|
||||
function switchToPhone() {
|
||||
activeCard.value = 'phone'
|
||||
}
|
||||
|
||||
// 协议勾选变化
|
||||
function onAgreementChange(e) {
|
||||
agreed.value = e.detail.value.includes('agreed')
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
async function handleSendCode() {
|
||||
if (countdown.value > 0 || isLoading.value) return
|
||||
|
||||
if (!smsPhone.value || smsPhone.value.length !== 11) {
|
||||
showToast('请输入正确的手机号')
|
||||
return
|
||||
}
|
||||
|
||||
if (!agreed.value) {
|
||||
showToast('请先同意用户协议')
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
await sendCode({ phone: smsPhone.value })
|
||||
showToast('验证码已发送')
|
||||
countdown.value = 60
|
||||
const timer = setInterval(() => {
|
||||
countdown.value--
|
||||
if (countdown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, 1000)
|
||||
} catch (e) {
|
||||
console.error('发送验证码失败:', e)
|
||||
showToast(e.message || '发送验证码失败')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 一键登录
|
||||
async function handlePhoneLogin() {
|
||||
if (isLoading.value) return
|
||||
|
||||
if (!agreed.value) {
|
||||
showToast('请先同意用户协议')
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// #ifdef APP-PLUS
|
||||
// App环境下使用一键登录
|
||||
await new Promise((resolve, reject) => {
|
||||
uni.login({
|
||||
provider: 'univerify',
|
||||
univerifyStyle: {
|
||||
fullScreen: false,
|
||||
backgroundColor: '#ffffff',
|
||||
icon: {
|
||||
path: '/static/logo.png'
|
||||
},
|
||||
closeIcon: {
|
||||
path: '/static/close.png'
|
||||
},
|
||||
phoneNum: {
|
||||
color: '#333333',
|
||||
fontSize: '18px'
|
||||
},
|
||||
slogan: {
|
||||
color: '#999999',
|
||||
fontSize: '12px'
|
||||
},
|
||||
authButton: {
|
||||
normalColor: '#FF8C42',
|
||||
highlightColor: '#FF7A2C',
|
||||
disabledColor: '#CCCCCC',
|
||||
textColor: '#FFFFFF',
|
||||
title: '本机号码一键登录'
|
||||
},
|
||||
otherLoginButton: {
|
||||
visible: false
|
||||
},
|
||||
protocols: []
|
||||
},
|
||||
success: async (res) => {
|
||||
try {
|
||||
// 调用后端API进行一键登录
|
||||
const result = await oneClickLogin({
|
||||
accessToken: res.authResult.access_token,
|
||||
openid: res.authResult.openid
|
||||
})
|
||||
|
||||
if (result.code === 200 || result.data) {
|
||||
handleLoginSuccess(result)
|
||||
resolve(result)
|
||||
} else {
|
||||
reject(new Error(result.message || '登录失败'))
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('一键登录失败:', err)
|
||||
reject(new Error(err.errMsg || '一键登录失败'))
|
||||
}
|
||||
})
|
||||
})
|
||||
// #endif
|
||||
|
||||
// #ifndef APP-PLUS
|
||||
// 非App环境(小程序、H5等)提示使用其他方式
|
||||
uni.showToast({
|
||||
title: '请使用短信验证码登录',
|
||||
icon: 'none'
|
||||
})
|
||||
activeCard.value = 'sms'
|
||||
// #endif
|
||||
|
||||
} catch (e) {
|
||||
console.error('一键登录失败:', e)
|
||||
showToast(e.message || '一键登录失败')
|
||||
if (props.onLoginError) {
|
||||
props.onLoginError(e)
|
||||
}
|
||||
emit('loginError', e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 短信验证码登录
|
||||
async function handleSmsLogin() {
|
||||
if (!canSmsSubmit.value || isLoading.value) return
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const result = await loginWithPhone({
|
||||
phone: smsPhone.value,
|
||||
code: smsCode.value
|
||||
})
|
||||
|
||||
handleLoginSuccess(result)
|
||||
|
||||
} catch (e) {
|
||||
console.error('短信登录失败:', e)
|
||||
showToast(e.message || '登录失败')
|
||||
if (props.onLoginError) {
|
||||
props.onLoginError(e)
|
||||
}
|
||||
emit('loginError', e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理登录成功
|
||||
function handleLoginSuccess(result) {
|
||||
console.log('登录成功:', result)
|
||||
// 保存token和用户信息到本地缓存
|
||||
if (result.data?.token) {
|
||||
uni.setStorageSync('token', result.data.token)
|
||||
}
|
||||
// 直接缓存登录响应数据
|
||||
if (result.data) {
|
||||
uni.setStorageSync('loginMemberInfo', result.data)
|
||||
}
|
||||
|
||||
showToast('登录成功')
|
||||
|
||||
if (props.onLoginSuccess) {
|
||||
props.onLoginSuccess(result)
|
||||
}
|
||||
emit('loginSuccess', result)
|
||||
|
||||
uni.$emit('loginModal:success', result)
|
||||
|
||||
setTimeout(() => {
|
||||
handleClose()
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
function handleClose() {
|
||||
visible.value = false
|
||||
emit('update:modelValue', false)
|
||||
emit('close')
|
||||
|
||||
uni.$emit('loginModal:close')
|
||||
}
|
||||
|
||||
// 点击遮罩层
|
||||
function handleMaskTap() {
|
||||
if (props.maskClosable) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
// 对外暴露的方法
|
||||
defineExpose({
|
||||
// 显示弹窗
|
||||
show() {
|
||||
visible.value = true
|
||||
emit('update:modelValue', true)
|
||||
},
|
||||
// 隐藏弹窗
|
||||
hide() {
|
||||
handleClose()
|
||||
},
|
||||
// 切换登录方式
|
||||
switchCard(card) {
|
||||
activeCard.value = card
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-modal-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-modal {
|
||||
position: relative;
|
||||
width: 90%;
|
||||
max-width: 650rpx;
|
||||
background: #fff;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.15);
|
||||
|
||||
&--center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.login-modal__close {
|
||||
position: absolute;
|
||||
top: 20rpx;
|
||||
right: 20rpx;
|
||||
width: 50rpx;
|
||||
height: 50rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
|
||||
.icon-close {
|
||||
font-size: 48rpx;
|
||||
color: #999;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.login-modal__content {
|
||||
width: 100%;
|
||||
padding: 40rpx 50rpx 50rpx;
|
||||
}
|
||||
|
||||
.login-modal__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.logo-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
|
||||
.logo-image {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
display: none;
|
||||
|
||||
&.active {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.sms-card {
|
||||
display: none;
|
||||
|
||||
&.active {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-tip {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
background: #F5F7FA;
|
||||
border-radius: 16rpx;
|
||||
border: 2rpx solid transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24rpx;
|
||||
box-sizing: border-box;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.focused {
|
||||
border-color: #FF8C42;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.phone-input {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.code-input {
|
||||
padding-right: 0;
|
||||
|
||||
.phone-input {
|
||||
width: 55%;
|
||||
}
|
||||
}
|
||||
|
||||
.send-code-btn {
|
||||
width: 40%;
|
||||
height: 64rpx;
|
||||
line-height: 64rpx;
|
||||
background: #FF8C42;
|
||||
color: #fff;
|
||||
font-size: 24rpx;
|
||||
border-radius: 8rpx;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
&.disabled {
|
||||
background: #ccc;
|
||||
}
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
border-radius: 44rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
&.primary {
|
||||
background: linear-gradient(135deg, #FF8C42 0%, #FF6B2C 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: #ccc;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.loading {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&.switch-btn {
|
||||
background: transparent;
|
||||
color: #FF8C42;
|
||||
border: 2rpx solid #FF8C42;
|
||||
}
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.agreement-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8rpx;
|
||||
margin-top: 24rpx;
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
|
||||
.link {
|
||||
color: #FF8C42;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-toast {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
padding: 24rpx 48rpx;
|
||||
border-radius: 12rpx;
|
||||
font-size: 28rpx;
|
||||
z-index: 99999;
|
||||
opacity: 0;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.show {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<view
|
||||
class="verify-code-input"
|
||||
@tap="focusInput"
|
||||
:style="{
|
||||
'--vci-active-color': activeColor,
|
||||
'--vci-filled-color': filledColor,
|
||||
'--vci-border-color': borderColor
|
||||
}"
|
||||
>
|
||||
<view class="vci-title" v-if="title">
|
||||
<text class="vci-title__text">{{ title }}</text>
|
||||
</view>
|
||||
<view class="vci-desc" v-if="desc">
|
||||
<text class="vci-desc__text">{{ desc }}</text>
|
||||
</view>
|
||||
|
||||
<view class="vci-code-box">
|
||||
<view
|
||||
v-for="(item, index) in codeLength"
|
||||
:key="index"
|
||||
class="vci-code-item"
|
||||
:class="{
|
||||
'vci-code-item--active': isFocused && currentIndex === index,
|
||||
'vci-code-item--filled': code.length > index,
|
||||
'vci-code-item--underline': type === 'underline',
|
||||
'vci-code-item--box': type === 'box'
|
||||
}"
|
||||
>
|
||||
<text v-if="code[index] && !mask" class="vci-code-text">{{ code[index] }}</text>
|
||||
<view v-else-if="code[index] && mask" class="vci-code-dot"></view>
|
||||
<view v-if="isFocused && currentIndex === index && code.length === index" class="vci-code-cursor"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="vci-tips" v-if="errorMsg">
|
||||
<text class="vci-tips__text">{{ errorMsg }}</text>
|
||||
</view>
|
||||
|
||||
<input
|
||||
ref="inputRef"
|
||||
class="vci-hidden-input"
|
||||
type="number"
|
||||
:maxlength="codeLength"
|
||||
:value="code"
|
||||
:focus="isFocused"
|
||||
:adjust-position="false"
|
||||
@input="onInput"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
desc: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
length: {
|
||||
type: Number,
|
||||
default: 6
|
||||
},
|
||||
mask: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'box'
|
||||
},
|
||||
errorMsg: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
autoFocus: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
activeColor: {
|
||||
type: String,
|
||||
default: '#1677FF'
|
||||
},
|
||||
filledColor: {
|
||||
type: String,
|
||||
default: '#1A202C'
|
||||
},
|
||||
borderColor: {
|
||||
type: String,
|
||||
default: '#E2E8F0'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'complete', 'focus', 'blur'])
|
||||
|
||||
const code = ref(props.modelValue)
|
||||
const isFocused = ref(false)
|
||||
const currentIndex = ref(0)
|
||||
const inputRef = ref(null)
|
||||
|
||||
const codeLength = computed(() => props.length)
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
code.value = val
|
||||
currentIndex.value = Math.min(val.length, codeLength.value - 1)
|
||||
})
|
||||
|
||||
function focusInput() {
|
||||
isFocused.value = true
|
||||
}
|
||||
|
||||
function onInput(e) {
|
||||
let val = e.detail.value.replace(/\D/g, '').slice(0, codeLength.value)
|
||||
code.value = val
|
||||
currentIndex.value = Math.min(val.length, codeLength.value - 1)
|
||||
emit('update:modelValue', val)
|
||||
if (val.length === codeLength.value) {
|
||||
emit('complete', val)
|
||||
}
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
isFocused.value = true
|
||||
currentIndex.value = code.value.length
|
||||
emit('focus')
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
isFocused.value = false
|
||||
emit('blur')
|
||||
}
|
||||
|
||||
function clear() {
|
||||
code.value = ''
|
||||
currentIndex.value = 0
|
||||
emit('update:modelValue', '')
|
||||
}
|
||||
|
||||
function setFocus(val) {
|
||||
isFocused.value = val
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
clear,
|
||||
focusInput,
|
||||
setFocus
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.verify-code-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vci-title {
|
||||
margin-bottom: 8px;
|
||||
|
||||
&__text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1A202C;
|
||||
}
|
||||
}
|
||||
|
||||
.vci-desc {
|
||||
margin-bottom: 32px;
|
||||
|
||||
&__text {
|
||||
font-size: 13px;
|
||||
color: #718096;
|
||||
}
|
||||
}
|
||||
|
||||
.vci-code-box {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.vci-code-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&--box {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid var(--vci-border-color, #E2E8F0);
|
||||
border-radius: 8px;
|
||||
|
||||
&.vci-code-item--active {
|
||||
border-color: var(--vci-active-color, #1677FF);
|
||||
}
|
||||
|
||||
&.vci-code-item--filled {
|
||||
border-color: var(--vci-filled-color, #1A202C);
|
||||
}
|
||||
}
|
||||
|
||||
&--underline {
|
||||
width: 40px;
|
||||
height: 50px;
|
||||
border-bottom: 2px solid var(--vci-border-color, #E2E8F0);
|
||||
|
||||
&.vci-code-item--active {
|
||||
border-bottom-color: var(--vci-active-color, #FF8C42);
|
||||
}
|
||||
|
||||
&.vci-code-item--filled {
|
||||
border-bottom-color: var(--vci-filled-color, #1A202C);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.vci-code-text {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--vci-filled-color, #1A202C);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.vci-code-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--vci-filled-color, #1A202C);
|
||||
}
|
||||
|
||||
.vci-code-cursor {
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 24px;
|
||||
background: var(--vci-active-color, #1677FF);
|
||||
animation: vci-blink 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes vci-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.vci-tips {
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&__text {
|
||||
font-size: 13px;
|
||||
color: #E53E3E;
|
||||
}
|
||||
}
|
||||
|
||||
.vci-hidden-input {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,138 +1,271 @@
|
||||
<template>
|
||||
<view class="member-card-section">
|
||||
<view class="member-card-section__inner">
|
||||
<view class="member-card-section__head">
|
||||
<view class="member-card-section__head-inner">
|
||||
<text class="member-card-section__title">
|
||||
我的会员卡
|
||||
</text>
|
||||
<view
|
||||
class="member-card-section__link"
|
||||
hover-class="mi-tap--hover"
|
||||
:hover-stay-time="150"
|
||||
@tap="$emit('view-all')"
|
||||
>
|
||||
<text
|
||||
class="member-card-section__link-text"
|
||||
>
|
||||
查看全部
|
||||
</text>
|
||||
<image class="member-card-section__link-arrow" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/chevronright12.png" mode="aspectFit" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="member-card-section__inner">
|
||||
<view class="member-card-section__head">
|
||||
<view class="member-card-section__head-inner">
|
||||
<text class="member-card-section__title">
|
||||
我的会员卡
|
||||
</text>
|
||||
<view
|
||||
class="member-card-preview"
|
||||
hover-class="mi-tap-card--hover"
|
||||
:hover-stay-time="150"
|
||||
@tap="$emit('view-all')"
|
||||
class="member-card-section__count"
|
||||
>
|
||||
<view class="member-card-preview__inner">
|
||||
<view class="member-card-preview__head">
|
||||
<view class="member-card-preview__head-inner">
|
||||
<view
|
||||
class="member-card-preview__type-row"
|
||||
>
|
||||
<view
|
||||
class="member-card-preview__icon-wrap"
|
||||
>
|
||||
<view
|
||||
class="member-card-preview__icon-border"
|
||||
>
|
||||
<view
|
||||
class="member-card-preview__icon-bg"
|
||||
></view>
|
||||
<view
|
||||
class="member-card-preview__icon-stroke"
|
||||
></view>
|
||||
</view>
|
||||
<image class="member-card-preview__icon-line" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/Line_2_468.png" mode="aspectFill" />
|
||||
</view>
|
||||
<text
|
||||
class="member-card-preview__name"
|
||||
>
|
||||
{{ cardInfo.name }}
|
||||
</text>
|
||||
</view>
|
||||
<view
|
||||
class="member-card-preview__tag"
|
||||
>
|
||||
<text class="member-card-preview__tag-text">
|
||||
{{ cardInfo.detailTag || '详情' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="member-card-preview__expire">
|
||||
{{ cardInfo.expireDate }}
|
||||
</text>
|
||||
<view class="member-card-preview__footer">
|
||||
<view class="member-card-preview__footer-inner">
|
||||
<view
|
||||
class="member-card-preview__days"
|
||||
>
|
||||
<text
|
||||
class="member-card-preview__days-num"
|
||||
>
|
||||
{{ cardInfo.remainingDays }}
|
||||
</text>
|
||||
<text
|
||||
class="member-card-preview__days-unit"
|
||||
>
|
||||
天剩余
|
||||
</text>
|
||||
</view>
|
||||
<view
|
||||
class="member-card-preview__renew"
|
||||
hover-class="mi-tap-btn--hover"
|
||||
:hover-stay-time="150"
|
||||
@tap.stop="$emit('renew')"
|
||||
>
|
||||
<text
|
||||
class="member-card-preview__renew-text"
|
||||
>
|
||||
续费
|
||||
</text>
|
||||
</view>
|
||||
<view
|
||||
class="member-card-preview__purchase"
|
||||
hover-class="mi-tap-btn--hover"
|
||||
:hover-stay-time="150"
|
||||
@tap.stop="$emit('purchase')"
|
||||
>
|
||||
<text
|
||||
class="member-card-preview__purchase-text"
|
||||
>
|
||||
购买新卡
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="member-card-tip">
|
||||
<view class="member-card-tip__inner">
|
||||
<view class="member-card-tip__content">
|
||||
<image class="member-card-tip__icon" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/clock1.png" mode="aspectFit" />
|
||||
<text
|
||||
class="member-card-tip__text"
|
||||
>
|
||||
{{ cardInfo.tip }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="member-card-tip__border"></view>
|
||||
<text class="member-card-section__count-num">{{ cardInfo.activeCount || 0 }}</text>
|
||||
<text class="member-card-section__count-text">张有效</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
class="member-card-preview"
|
||||
hover-class="mi-tap-card--hover"
|
||||
:hover-stay-time="150"
|
||||
@tap="$emit('view-all')"
|
||||
>
|
||||
<view class="member-card-preview__inner">
|
||||
<view class="member-card-preview__head">
|
||||
<view class="member-card-preview__head-inner">
|
||||
<view
|
||||
class="member-card-preview__type-row"
|
||||
>
|
||||
<view
|
||||
class="member-card-preview__icon-wrap"
|
||||
>
|
||||
<view
|
||||
class="member-card-preview__icon-border"
|
||||
>
|
||||
<view
|
||||
class="member-card-preview__icon-bg"
|
||||
></view>
|
||||
<view
|
||||
class="member-card-preview__icon-stroke"
|
||||
></view>
|
||||
</view>
|
||||
<image class="member-card-preview__icon-line" src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/Line_2_468.png" mode="aspectFill" />
|
||||
</view>
|
||||
<text
|
||||
class="member-card-preview__name"
|
||||
>
|
||||
{{ cardInfo.hasActiveCard ? cardInfo.name : '暂无会员卡' }}
|
||||
</text>
|
||||
</view>
|
||||
<view
|
||||
class="member-card-preview__tag"
|
||||
:class="getTagClass()"
|
||||
v-if="cardInfo.hasActiveCard"
|
||||
>
|
||||
<text class="member-card-preview__tag-text">
|
||||
{{ getTagText() }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="member-card-preview__expire" v-if="cardInfo.hasActiveCard && cardInfo.expireDate">
|
||||
有效期至 {{ cardInfo.expireDate }}
|
||||
</text>
|
||||
<view class="member-card-preview__footer">
|
||||
<view class="member-card-preview__footer-inner">
|
||||
<view
|
||||
class="member-card-preview__days"
|
||||
v-if="cardInfo.hasActiveCard"
|
||||
>
|
||||
<text
|
||||
class="member-card-preview__days-num"
|
||||
:class="getDaysClass()"
|
||||
>
|
||||
{{ getDisplayDays() }}
|
||||
</text>
|
||||
<text
|
||||
class="member-card-preview__days-unit"
|
||||
>
|
||||
{{ getDaysUnit() }}
|
||||
</text>
|
||||
</view>
|
||||
<view
|
||||
class="member-card-preview__purchase"
|
||||
hover-class="mi-tap-btn--hover"
|
||||
:hover-stay-time="150"
|
||||
@tap.stop="$emit('purchase')"
|
||||
>
|
||||
<text
|
||||
class="member-card-preview__purchase-text"
|
||||
>
|
||||
购买新卡
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
cardInfo: { type: Object, required: true }
|
||||
import { reactive, onMounted } from 'vue'
|
||||
import { getPrimaryMemberCard, getMyMemberCardsWithStatus } from '@/api/main.js'
|
||||
import { getMemberId } from '@/utils/request.js'
|
||||
|
||||
defineEmits(['view-all', 'purchase'])
|
||||
|
||||
const cardInfo = reactive({
|
||||
name: '',
|
||||
type: '',
|
||||
remainingTimes: 0,
|
||||
remainingDays: 0,
|
||||
expireDate: '',
|
||||
activeCount: 0,
|
||||
isExpiring: false,
|
||||
isExpired: false,
|
||||
isUsedUp: false,
|
||||
hasActiveCard: false
|
||||
})
|
||||
|
||||
defineEmits(['view-all', 'renew', 'purchase'])
|
||||
let isLoading = false
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
const date = new Date(dateStr)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
} catch (e) {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
// 加载会员卡数据
|
||||
async function loadMemberCard() {
|
||||
if (isLoading) {
|
||||
console.log('[MemberInfoMemberCard] 正在加载中,跳过重复请求')
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
try {
|
||||
// 使用统一的 getMemberId 方法获取会员ID
|
||||
const memberId = getMemberId()
|
||||
console.log('[MemberInfoMemberCard] memberId:', memberId)
|
||||
|
||||
if (!memberId) {
|
||||
console.log('[MemberInfoMemberCard] 未登录,无会员ID')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[MemberInfoMemberCard] 加载会员卡数据, memberId:', memberId)
|
||||
|
||||
// 并行获取主要会员卡和有效卡列表
|
||||
const [primaryCardRes, activeCardsRes] = await Promise.all([
|
||||
getPrimaryMemberCard(memberId),
|
||||
getMyMemberCardsWithStatus(memberId, 'active')
|
||||
])
|
||||
|
||||
console.log('[MemberInfoMemberCard] getPrimaryMemberCard 返回:', JSON.stringify(primaryCardRes, null, 2))
|
||||
console.log('[MemberInfoMemberCard] getMyMemberCardsWithStatus(active) 返回:', JSON.stringify(activeCardsRes, null, 2))
|
||||
|
||||
// 解析主要会员卡数据
|
||||
let displayCard = null
|
||||
if (primaryCardRes?.data) {
|
||||
displayCard = primaryCardRes.data
|
||||
} else if (primaryCardRes?.value) {
|
||||
displayCard = primaryCardRes.value
|
||||
} else if (primaryCardRes && typeof primaryCardRes === 'object') {
|
||||
displayCard = primaryCardRes
|
||||
}
|
||||
|
||||
// 计算有效会员卡数量(过滤储值卡)
|
||||
let activeCount = 0
|
||||
let allActiveCards = []
|
||||
if (activeCardsRes?.value) {
|
||||
allActiveCards = activeCardsRes.value
|
||||
} else if (Array.isArray(activeCardsRes)) {
|
||||
allActiveCards = activeCardsRes
|
||||
} else if (activeCardsRes?.data) {
|
||||
allActiveCards = Array.isArray(activeCardsRes.data) ? activeCardsRes.data : (activeCardsRes.data.value || [])
|
||||
}
|
||||
// 过滤储值卡
|
||||
allActiveCards = allActiveCards.filter(card => card.memberCardType !== 'STORED_VALUE_CARD')
|
||||
activeCount = allActiveCards.length
|
||||
|
||||
// 计算卡片状态
|
||||
let diffDays = 0
|
||||
let isExpiring = false
|
||||
let isExpired = false
|
||||
let isUsedUp = false
|
||||
let displayDays = 0
|
||||
|
||||
if (displayCard) {
|
||||
if (displayCard.status === 'USED_UP') {
|
||||
isUsedUp = true
|
||||
displayDays = displayCard.remainingTimes || 0
|
||||
} else if (displayCard.expireTime) {
|
||||
const expireTime = new Date(displayCard.expireTime)
|
||||
const now = new Date()
|
||||
diffDays = Math.ceil((expireTime - now) / (1000 * 60 * 60 * 24))
|
||||
isExpiring = diffDays > 0 && diffDays <= 3
|
||||
isExpired = diffDays <= 0
|
||||
displayDays = Math.abs(diffDays)
|
||||
}
|
||||
}
|
||||
console.log(displayCard)
|
||||
|
||||
// 更新数据
|
||||
cardInfo.name = displayCard?.memberCardName || '暂无会员卡'
|
||||
cardInfo.type = displayCard?.memberCardType || ''
|
||||
cardInfo.remainingTimes = displayCard?.remainingTimes || displayCard?.remainingAmount || 0
|
||||
cardInfo.remainingDays = displayDays
|
||||
cardInfo.expireDate = displayCard?.expireTime ? formatDate(displayCard.expireTime) : ''
|
||||
cardInfo.activeCount = activeCount
|
||||
cardInfo.isExpiring = isExpiring
|
||||
cardInfo.isExpired = isExpired
|
||||
cardInfo.isUsedUp = isUsedUp
|
||||
cardInfo.hasActiveCard = activeCount > 0
|
||||
|
||||
console.log('[MemberInfoMemberCard] 更新后的 cardInfo:', JSON.stringify(cardInfo, null, 2))
|
||||
} catch (e) {
|
||||
console.error('[MemberInfoMemberCard] 加载会员卡数据失败:', e)
|
||||
cardInfo.hasActiveCard = false
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(() => {
|
||||
console.log('[MemberInfoMemberCard] 组件已挂载,开始加载数据')
|
||||
loadMemberCard()
|
||||
})
|
||||
|
||||
function getTagClass() {
|
||||
if (cardInfo.isExpiring) return 'member-card-preview__tag--expiring'
|
||||
if (cardInfo.isExpired) return 'member-card-preview__tag--expired'
|
||||
if (cardInfo.isUsedUp) return 'member-card-preview__tag--expired'
|
||||
return 'member-card-preview__tag--active'
|
||||
}
|
||||
|
||||
function getTagText() {
|
||||
if (cardInfo.isUsedUp) return '已用完'
|
||||
if (cardInfo.isExpired) return '已失效'
|
||||
if (cardInfo.isExpiring) return '临期'
|
||||
return '有效'
|
||||
}
|
||||
|
||||
function getDaysClass() {
|
||||
if (cardInfo.isExpiring) return 'member-card-preview__days-num--expiring'
|
||||
if (cardInfo.isExpired) return 'member-card-preview__days-num--expired'
|
||||
if (cardInfo.isUsedUp) return 'member-card-preview__days-num--expired'
|
||||
return ''
|
||||
}
|
||||
|
||||
function getDisplayDays() {
|
||||
if (cardInfo.isUsedUp) return cardInfo.remainingTimes || 0
|
||||
return cardInfo.remainingDays || 0
|
||||
}
|
||||
|
||||
function getDaysUnit() {
|
||||
if (cardInfo.isUsedUp) return '次剩余'
|
||||
return '天剩余'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user