perf: 完善新用户引导
This commit is contained in:
@@ -114,10 +114,7 @@ async function handleLogin() {
|
|||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await userStore.login()
|
await userStore.loginWithSetup()
|
||||||
await userStore.fetchMemberships()
|
|
||||||
// 登录成功后跳转到个人中心,让用户完善信息
|
|
||||||
uni.navigateTo({ url: '/pages/profile/info' })
|
|
||||||
} catch {
|
} catch {
|
||||||
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -338,14 +335,36 @@ const lowestRemainingTimes = computed(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Warning triangle */
|
/* 闪电 icon — 更有能量感 */
|
||||||
.icon-warning {
|
.icon-warning {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 0;
|
width: 22rpx;
|
||||||
height: 0;
|
height: 22rpx;
|
||||||
border-left: 12rpx solid transparent;
|
|
||||||
border-right: 12rpx solid transparent;
|
&::before {
|
||||||
border-bottom: 20rpx solid #e8a87c;
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) rotate(-15deg);
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 8rpx solid transparent;
|
||||||
|
border-right: 8rpx solid transparent;
|
||||||
|
border-bottom: 18rpx solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2rpx;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 10rpx;
|
||||||
|
height: 3rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
border-radius: 2rpx;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-text {
|
.entry-text {
|
||||||
@@ -430,42 +449,83 @@ const lowestRemainingTimes = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.low-badge {
|
.low-badge {
|
||||||
background: #e74c3c;
|
background: linear-gradient(135deg, #FF6B35 0%, #FF8E53 100%);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(255, 107, 53, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Renew tip bar */
|
/* Renew tip bar — 充满能量的激励配色 */
|
||||||
.renew-tip {
|
.renew-tip {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12rpx;
|
gap: 16rpx;
|
||||||
margin-top: 16rpx;
|
margin-top: 16rpx;
|
||||||
padding: 20rpx 24rpx;
|
padding: 22rpx 28rpx;
|
||||||
background: #fff8f0;
|
background: linear-gradient(135deg, #FF6B35 0%, #FF8E53 60%, #FFAA5C 100%);
|
||||||
border-radius: 12rpx;
|
border-radius: 16rpx;
|
||||||
border: 1rpx solid rgba(240, 180, 100, 0.3);
|
box-shadow: 0 6rpx 20rpx rgba(255, 107, 53, 0.3);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
/* 右上角光晕装饰 */
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -20rpx;
|
||||||
|
right: -20rpx;
|
||||||
|
width: 120rpx;
|
||||||
|
height: 120rpx;
|
||||||
|
background: radial-gradient(circle, rgba(255, 255, 255, 0.25) 0%, transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部微光 */
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 40%;
|
||||||
|
background: linear-gradient(to top, rgba(0, 0, 0, 0.08) 0%, transparent 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.renew-tip-icon {
|
.renew-tip-icon {
|
||||||
width: 36rpx;
|
width: 52rpx;
|
||||||
height: 36rpx;
|
height: 52rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
backdrop-filter: blur(4rpx);
|
||||||
}
|
}
|
||||||
|
|
||||||
.renew-tip-text {
|
.renew-tip-text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 24rpx;
|
font-size: 26rpx;
|
||||||
color: #a0622a;
|
color: #ffffff;
|
||||||
line-height: 1.4;
|
line-height: 1.5;
|
||||||
|
font-weight: 500;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
text-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.renew-tip-action {
|
.renew-tip-action {
|
||||||
font-size: 24rpx;
|
font-size: 26rpx;
|
||||||
color: $primary-dark;
|
color: #ffffff;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
/* 箭头增强感 */
|
||||||
|
text-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="slot-card" :class="{ 'slot-card--booked': timeSlot.isBookedByMe }">
|
<view class="slot-card" :class="{ 'slot-card--booked': timeSlot.isBookedByMe, 'slot-card--past': isPast && !timeSlot.isBookedByMe }">
|
||||||
<!-- Booked accent bar -->
|
<!-- Booked accent bar -->
|
||||||
<view v-if="timeSlot.isBookedByMe" class="booked-bar" />
|
<view v-if="timeSlot.isBookedByMe" class="booked-bar" />
|
||||||
|
|
||||||
@@ -27,8 +27,14 @@
|
|||||||
|
|
||||||
<!-- Right: Action -->
|
<!-- Right: Action -->
|
||||||
<view class="slot-action">
|
<view class="slot-action">
|
||||||
|
<template v-if="isPast && !timeSlot.isBookedByMe">
|
||||||
|
<view class="badge-expired">
|
||||||
|
<text class="badge-expired-text">已过期</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- OPEN + not booked -->
|
<!-- OPEN + not booked -->
|
||||||
<template v-if="timeSlot.status === TimeSlotStatus.OPEN && !timeSlot.isBookedByMe">
|
<template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && !timeSlot.isBookedByMe">
|
||||||
<view class="btn btn-book" @tap.stop="emit('book', timeSlot)">
|
<view class="btn btn-book" @tap.stop="emit('book', timeSlot)">
|
||||||
<text class="btn-text">预约</text>
|
<text class="btn-text">预约</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -68,6 +74,7 @@
|
|||||||
import type { TimeSlotWithBookingStatus } from '@mp-pilates/shared'
|
import type { TimeSlotWithBookingStatus } from '@mp-pilates/shared'
|
||||||
import { TimeSlotStatus } from '@mp-pilates/shared'
|
import { TimeSlotStatus } from '@mp-pilates/shared'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { isSlotPast } from '../utils/format'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
timeSlot: TimeSlotWithBookingStatus
|
timeSlot: TimeSlotWithBookingStatus
|
||||||
@@ -100,6 +107,8 @@ const capacityClass = computed(() => {
|
|||||||
if (bookedCount >= capacity * 0.8) return 'cap-almost'
|
if (bookedCount >= capacity * 0.8) return 'cap-almost'
|
||||||
return 'cap-open'
|
return 'cap-open'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.startTime))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -119,6 +128,19 @@ const capacityClass = computed(() => {
|
|||||||
background: #f0f7fb;
|
background: #f0f7fb;
|
||||||
box-shadow: 0 4rpx 24rpx rgba($primary-dark, 0.12);
|
box-shadow: 0 4rpx 24rpx rgba($primary-dark, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--past {
|
||||||
|
background: #f8f8f8;
|
||||||
|
opacity: 0.7;
|
||||||
|
|
||||||
|
.slot-start {
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-title {
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.booked-bar {
|
.booked-bar {
|
||||||
@@ -308,6 +330,22 @@ const capacityClass = computed(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge-expired {
|
||||||
|
height: 52rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-radius: 26rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.badge-expired-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.btn-cancel {
|
.btn-cancel {
|
||||||
padding: 4rpx 8rpx;
|
padding: 4rpx 8rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -37,8 +37,10 @@
|
|||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<view v-else-if="filteredSlots.length === 0" class="empty-wrap">
|
<view v-else-if="filteredSlots.length === 0" class="empty-wrap">
|
||||||
<view class="empty-icon-circle">
|
<view class="empty-illustration">
|
||||||
<text class="empty-icon-text">📅</text>
|
<view class="empty-circle outer" />
|
||||||
|
<view class="empty-circle inner" />
|
||||||
|
<view class="empty-dot" />
|
||||||
</view>
|
</view>
|
||||||
<text class="empty-text">当日暂无可约时段</text>
|
<text class="empty-text">当日暂无可约时段</text>
|
||||||
<text class="empty-sub">请选择其他日期或时段查看</text>
|
<text class="empty-sub">请选择其他日期或时段查看</text>
|
||||||
@@ -189,10 +191,10 @@ async function onBookTap(slot: TimeSlotWithBookingStatus) {
|
|||||||
success: async (res) => {
|
success: async (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
try {
|
try {
|
||||||
await userStore.login()
|
const { isNewUser } = await userStore.loginWithSetup()
|
||||||
await userStore.fetchMemberships()
|
if (!isNewUser) {
|
||||||
// Retry booking flow after login
|
onBookTap(slot)
|
||||||
onBookTap(slot)
|
}
|
||||||
} catch {
|
} catch {
|
||||||
uni.showToast({ title: '登录失败', icon: 'none' })
|
uni.showToast({ title: '登录失败', icon: 'none' })
|
||||||
}
|
}
|
||||||
@@ -413,34 +415,75 @@ onMounted(async () => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 140rpx 40rpx;
|
padding: 120rpx 40rpx 80rpx;
|
||||||
gap: 16rpx;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-icon-circle {
|
/* Zen-inspired geometric illustration */
|
||||||
width: 140rpx;
|
.empty-illustration {
|
||||||
height: 140rpx;
|
position: relative;
|
||||||
|
width: 200rpx;
|
||||||
|
height: 200rpx;
|
||||||
|
margin-bottom: 56rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-circle {
|
||||||
|
position: absolute;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: $primary-border;
|
top: 50%;
|
||||||
display: flex;
|
left: 50%;
|
||||||
align-items: center;
|
transform: translate(-50%, -50%);
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 16rpx;
|
&.outer {
|
||||||
|
width: 180rpx;
|
||||||
|
height: 180rpx;
|
||||||
|
border: 2rpx solid $primary-border;
|
||||||
|
animation: breathe 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.inner {
|
||||||
|
width: 120rpx;
|
||||||
|
height: 120rpx;
|
||||||
|
background: linear-gradient(135deg, $primary-light 0%, $primary-color 50%, $primary-dark 100%);
|
||||||
|
opacity: 0.6;
|
||||||
|
animation: breathe 3s ease-in-out infinite 0.5s;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-icon-text {
|
.empty-dot {
|
||||||
font-size: 56rpx;
|
position: absolute;
|
||||||
|
width: 16rpx;
|
||||||
|
height: 16rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $primary-dark;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-text {
|
.empty-text {
|
||||||
font-size: 30rpx;
|
font-size: 32rpx;
|
||||||
color: #666;
|
color: $primary-dark;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-sub {
|
.empty-sub {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #bbb;
|
color: $primary-color;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes breathe {
|
||||||
|
0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.6; }
|
||||||
|
50% { transform: translate(-50%, -50%) scale(1.05); opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||||||
|
50% { opacity: 0.5; transform: translate(-50%, -50%) scale(0.8); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Bottom spacer ─────────────────────────────────── */
|
/* ── Bottom spacer ─────────────────────────────────── */
|
||||||
|
|||||||
@@ -299,8 +299,10 @@ async function handleBuy() {
|
|||||||
success: async (res) => {
|
success: async (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
try {
|
try {
|
||||||
await userStore.login()
|
const { isNewUser } = await userStore.loginWithSetup()
|
||||||
handleBuy()
|
if (!isNewUser) {
|
||||||
|
handleBuy()
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
uni.showToast({ title: '登录失败', icon: 'none' })
|
uni.showToast({ title: '登录失败', icon: 'none' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,9 @@
|
|||||||
<!-- Empty -->
|
<!-- Empty -->
|
||||||
<view v-else-if="upcomingBookings.length === 0" class="empty-wrap">
|
<view v-else-if="upcomingBookings.length === 0" class="empty-wrap">
|
||||||
<view class="empty-illustration">
|
<view class="empty-illustration">
|
||||||
<text class="empty-icon">🧘</text>
|
<view class="empty-circle outer" />
|
||||||
|
<view class="empty-circle inner" />
|
||||||
|
<view class="empty-dot" />
|
||||||
</view>
|
</view>
|
||||||
<text class="empty-title">暂无即将上课的预约</text>
|
<text class="empty-title">暂无即将上课的预约</text>
|
||||||
<text class="empty-sub">开始预约你的普拉提课程吧</text>
|
<text class="empty-sub">开始预约你的普拉提课程吧</text>
|
||||||
@@ -117,7 +119,9 @@
|
|||||||
<!-- Empty -->
|
<!-- Empty -->
|
||||||
<view v-else-if="historyBookings.length === 0" class="empty-wrap">
|
<view v-else-if="historyBookings.length === 0" class="empty-wrap">
|
||||||
<view class="empty-illustration">
|
<view class="empty-illustration">
|
||||||
<text class="empty-icon">📋</text>
|
<view class="empty-circle outer" />
|
||||||
|
<view class="empty-circle inner" />
|
||||||
|
<view class="empty-dot" />
|
||||||
</view>
|
</view>
|
||||||
<text class="empty-title">暂无历史记录</text>
|
<text class="empty-title">暂无历史记录</text>
|
||||||
<text class="empty-sub">已完成或取消的课程将显示在这里</text>
|
<text class="empty-sub">已完成或取消的课程将显示在这里</text>
|
||||||
@@ -481,33 +485,73 @@ onMounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 120rpx 40rpx;
|
padding: 120rpx 40rpx;
|
||||||
gap: 16rpx;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-illustration {
|
.empty-illustration {
|
||||||
width: 160rpx;
|
position: relative;
|
||||||
height: 160rpx;
|
width: 200rpx;
|
||||||
border-radius: 80rpx;
|
height: 200rpx;
|
||||||
background: #faf6f1;
|
margin-bottom: 56rpx;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 16rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-icon {
|
.empty-circle {
|
||||||
font-size: 72rpx;
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
|
||||||
|
&.outer {
|
||||||
|
width: 180rpx;
|
||||||
|
height: 180rpx;
|
||||||
|
border: 2rpx solid $primary-border;
|
||||||
|
animation: breathe 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.inner {
|
||||||
|
width: 120rpx;
|
||||||
|
height: 120rpx;
|
||||||
|
background: linear-gradient(135deg, $primary-light 0%, $primary-color 50%, $primary-dark 100%);
|
||||||
|
opacity: 0.6;
|
||||||
|
animation: breathe 3s ease-in-out infinite 0.5s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-dot {
|
||||||
|
position: absolute;
|
||||||
|
width: 16rpx;
|
||||||
|
height: 16rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $primary-dark;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-title {
|
.empty-title {
|
||||||
font-size: 32rpx;
|
font-size: 32rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #333;
|
color: $primary-dark;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-sub {
|
.empty-sub {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #999;
|
color: $primary-color;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes breathe {
|
||||||
|
0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.6; }
|
||||||
|
50% { transform: translate(-50%, -50%) scale(1.05); opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||||||
|
50% { opacity: 0.5; transform: translate(-50%, -50%) scale(0.8); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-btn {
|
.empty-btn {
|
||||||
|
|||||||
@@ -81,12 +81,10 @@ async function handleLogin() {
|
|||||||
if (loginLoading.value) return
|
if (loginLoading.value) return
|
||||||
loginLoading.value = true
|
loginLoading.value = true
|
||||||
try {
|
try {
|
||||||
await userStore.login()
|
const { isNewUser } = await userStore.loginWithSetup()
|
||||||
await Promise.all([
|
if (!isNewUser) {
|
||||||
userStore.fetchProfile(),
|
await userStore.fetchStats()
|
||||||
userStore.fetchStats(),
|
}
|
||||||
userStore.fetchMemberships(),
|
|
||||||
])
|
|
||||||
} catch {
|
} catch {
|
||||||
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="info-page" :style="{ paddingTop: navBarHeight }">
|
<view class="info-page" :style="{ paddingTop: navBarHeight }">
|
||||||
<CustomNavBar title="个人信息" show-back />
|
<CustomNavBar
|
||||||
|
:title="isFromLogin ? '完善个人信息' : '个人信息'"
|
||||||
|
:show-back="!isFromLogin"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- First-login welcome banner -->
|
||||||
|
<view v-if="isFromLogin" class="welcome-banner">
|
||||||
|
<view class="welcome-content">
|
||||||
|
<view class="welcome-text">
|
||||||
|
<text class="welcome-title">欢迎加入</text>
|
||||||
|
<text class="welcome-desc">设置你的头像和昵称,让大家认识你</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- Avatar section -->
|
<!-- Avatar section -->
|
||||||
<view class="avatar-section">
|
<view class="avatar-section" :class="{ 'avatar-section--welcome': isFromLogin }">
|
||||||
<button class="avatar-btn" open-type="chooseAvatar" @chooseavatar="handleChooseAvatar">
|
<button class="avatar-btn" open-type="chooseAvatar" @chooseavatar="handleChooseAvatar">
|
||||||
<view class="avatar-wrap">
|
<view class="avatar-wrap">
|
||||||
<image
|
<image
|
||||||
@@ -14,10 +28,14 @@
|
|||||||
<view v-else class="avatar-placeholder">
|
<view v-else class="avatar-placeholder">
|
||||||
<text class="avatar-placeholder-text">{{ nicknameInitial }}</text>
|
<text class="avatar-placeholder-text">{{ nicknameInitial }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
<!-- Upload hint overlay -->
|
||||||
|
<view class="avatar-overlay">
|
||||||
|
<text class="avatar-overlay-text">点击更换</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</button>
|
</button>
|
||||||
<text class="avatar-name">{{ form.nickname || '未设置昵称' }}</text>
|
<text class="avatar-name">{{ form.nickname || '未设置昵称' }}</text>
|
||||||
<text class="avatar-hint">微信头像</text>
|
<text class="avatar-hint">点击头像选择微信头像</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Form fields -->
|
<!-- Form fields -->
|
||||||
@@ -37,8 +55,8 @@
|
|||||||
<text class="form-arrow">›</text>
|
<text class="form-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Phone -->
|
<!-- Phone (hide in first-login mode) -->
|
||||||
<view class="form-row form-row--last">
|
<view v-if="!isFromLogin" class="form-row form-row--last">
|
||||||
<text class="form-label">手机号</text>
|
<text class="form-label">手机号</text>
|
||||||
|
|
||||||
<!-- Phone set: display masked -->
|
<!-- Phone set: display masked -->
|
||||||
@@ -56,8 +74,8 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Read-only info card -->
|
<!-- Read-only info card (hide in first-login mode) -->
|
||||||
<view class="info-card">
|
<view v-if="!isFromLogin" class="info-card">
|
||||||
<view class="info-row">
|
<view class="info-row">
|
||||||
<text class="info-label">注册时间</text>
|
<text class="info-label">注册时间</text>
|
||||||
<text class="info-value">{{ joinDateDisplay }}</text>
|
<text class="info-value">{{ joinDateDisplay }}</text>
|
||||||
@@ -72,24 +90,40 @@
|
|||||||
<view class="save-wrap">
|
<view class="save-wrap">
|
||||||
<view
|
<view
|
||||||
class="save-btn"
|
class="save-btn"
|
||||||
:class="{ 'save-btn--loading': saving, 'save-btn--disabled': !isDirty || saving }"
|
:class="{
|
||||||
|
'save-btn--loading': saving,
|
||||||
|
'save-btn--disabled': !isFromLogin && (!isDirty || saving),
|
||||||
|
}"
|
||||||
@tap="handleSave"
|
@tap="handleSave"
|
||||||
>
|
>
|
||||||
<text class="save-btn-text">{{ saving ? '保存中...' : '保存修改' }}</text>
|
<text class="save-btn-text">
|
||||||
|
{{ saving ? '保存中...' : isFromLogin ? '保存并进入' : '保存修改' }}
|
||||||
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
<text v-if="isFromLogin" class="skip-text" @tap="handleSkip">稍后再说</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
import { wxBindPhone } from '../../utils/auth'
|
import { wxBindPhone } from '../../utils/auth'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
|
||||||
|
const TOAST_DISPLAY_MS = 1200
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
// ─── Route params ────────────────────────────────────────
|
||||||
|
const isFromLogin = ref(false)
|
||||||
|
|
||||||
|
onLoad((query) => {
|
||||||
|
isFromLogin.value = query?.from === 'login'
|
||||||
|
})
|
||||||
|
|
||||||
// ─── Nav bar height ──────────────────────────────────────
|
// ─── Nav bar height ──────────────────────────────────────
|
||||||
const navBarHeight = ref('64px')
|
const navBarHeight = ref('64px')
|
||||||
|
|
||||||
@@ -189,7 +223,10 @@ async function handleGetPhone(e: {
|
|||||||
|
|
||||||
// ─── Save ─────────────────────────────────────────────────
|
// ─── Save ─────────────────────────────────────────────────
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!isDirty.value || saving.value) return
|
if (saving.value) return
|
||||||
|
|
||||||
|
// In first-login mode, allow saving even if not dirty (user may just want to proceed)
|
||||||
|
if (!isFromLogin.value && !isDirty.value) return
|
||||||
|
|
||||||
const nickname = form.value.nickname.trim()
|
const nickname = form.value.nickname.trim()
|
||||||
if (!nickname) {
|
if (!nickname) {
|
||||||
@@ -206,7 +243,15 @@ async function handleSave() {
|
|||||||
await userStore.updateProfile({ nickname })
|
await userStore.updateProfile({ nickname })
|
||||||
originalNickname.value = nickname
|
originalNickname.value = nickname
|
||||||
form.value = { nickname }
|
form.value = { nickname }
|
||||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
|
||||||
|
if (isFromLogin.value) {
|
||||||
|
uni.showToast({ title: '欢迎加入!', icon: 'success' })
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.navigateBack()
|
||||||
|
}, TOAST_DISPLAY_MS)
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : '保存失败,请重试'
|
const msg = err instanceof Error ? err.message : '保存失败,请重试'
|
||||||
uni.showToast({ title: msg, icon: 'none' })
|
uni.showToast({ title: msg, icon: 'none' })
|
||||||
@@ -215,10 +260,27 @@ async function handleSave() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Skip (first-login only) ─────────────────────────────
|
||||||
|
function handleSkip() {
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认跳过?',
|
||||||
|
content: '完善头像和昵称可以让教练和伙伴更容易认识你',
|
||||||
|
confirmText: '去完善',
|
||||||
|
cancelText: '跳过',
|
||||||
|
success(res) {
|
||||||
|
if (!res.confirm) {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Lifecycle ────────────────────────────────────────────
|
// ─── Lifecycle ────────────────────────────────────────────
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
await userStore.fetchProfile()
|
if (!isFromLogin.value) {
|
||||||
|
await userStore.fetchProfile()
|
||||||
|
}
|
||||||
if (userStore.user) {
|
if (userStore.user) {
|
||||||
form.value = { nickname: userStore.user.nickname }
|
form.value = { nickname: userStore.user.nickname }
|
||||||
originalNickname.value = userStore.user.nickname
|
originalNickname.value = userStore.user.nickname
|
||||||
@@ -229,7 +291,52 @@ onMounted(async () => {
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.info-page {
|
.info-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f5f3f0;
|
background: $bg-page;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Welcome banner (first-login) ───────────────────── */
|
||||||
|
.welcome-banner {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 $spacing-lg $spacing-md;
|
||||||
|
padding: 36rpx 32rpx;
|
||||||
|
background: linear-gradient(135deg, $brand-color 0%, lighten($brand-color, 12%) 100%);
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -40rpx;
|
||||||
|
right: -20rpx;
|
||||||
|
width: 180rpx;
|
||||||
|
height: 180rpx;
|
||||||
|
background: radial-gradient(circle, rgba(255, 255, 255, 0.15) 0%, transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-title {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-desc {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Avatar section ──────────────────────────────────── */
|
/* ── Avatar section ──────────────────────────────────── */
|
||||||
@@ -238,9 +345,13 @@ onMounted(async () => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 56rpx 0 40rpx;
|
padding: 56rpx 0 40rpx;
|
||||||
background: #fff;
|
background: $bg-card;
|
||||||
margin-bottom: 24rpx;
|
margin-bottom: $spacing-md;
|
||||||
border-bottom: 1rpx solid #f0ece8;
|
border-bottom: 1rpx solid $border-color;
|
||||||
|
|
||||||
|
&--welcome {
|
||||||
|
padding-top: 40rpx;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-btn {
|
.avatar-btn {
|
||||||
@@ -265,14 +376,14 @@ onMounted(async () => {
|
|||||||
width: 160rpx;
|
width: 160rpx;
|
||||||
height: 160rpx;
|
height: 160rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 4rpx solid #f0f0f0;
|
border: 4rpx solid $border-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-placeholder {
|
.avatar-placeholder {
|
||||||
width: 160rpx;
|
width: 160rpx;
|
||||||
height: 160rpx;
|
height: 160rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, $primary-dark, $primary-color);
|
background: linear-gradient(135deg, $brand-color, $accent-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -284,25 +395,45 @@ onMounted(async () => {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar-overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 52rpx;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
border-radius: 0 0 80rpx 80rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-overlay-text {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.avatar-name {
|
.avatar-name {
|
||||||
font-size: 34rpx;
|
font-size: 34rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1a1a1a;
|
color: $text-primary;
|
||||||
margin-bottom: 6rpx;
|
margin-bottom: 6rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-hint {
|
.avatar-hint {
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: #bbb;
|
color: $text-hint;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Form card ───────────────────────────────────────── */
|
/* ── Form card ───────────────────────────────────────── */
|
||||||
.form-card {
|
.form-card {
|
||||||
background: #fff;
|
background: $bg-card;
|
||||||
border-radius: 20rpx;
|
border-radius: $radius-lg;
|
||||||
margin: 0 24rpx 20rpx;
|
margin: 0 $spacing-lg $spacing-md;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-row {
|
.form-row {
|
||||||
@@ -310,7 +441,7 @@ onMounted(async () => {
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 32rpx 28rpx;
|
padding: 32rpx 28rpx;
|
||||||
border-bottom: 1rpx solid #f5f5f5;
|
border-bottom: 1rpx solid rgba($border-color, 0.5);
|
||||||
min-height: 100rpx;
|
min-height: 100rpx;
|
||||||
|
|
||||||
&--last {
|
&--last {
|
||||||
@@ -320,7 +451,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.form-label {
|
.form-label {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: #555;
|
color: $text-secondary;
|
||||||
width: 120rpx;
|
width: 120rpx;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -329,7 +460,7 @@ onMounted(async () => {
|
|||||||
.form-input {
|
.form-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: #222;
|
color: $text-primary;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
min-height: 44rpx;
|
min-height: 44rpx;
|
||||||
@@ -338,13 +469,13 @@ onMounted(async () => {
|
|||||||
.form-value {
|
.form-value {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: #888;
|
color: $text-hint;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-arrow {
|
.form-arrow {
|
||||||
font-size: 36rpx;
|
font-size: 36rpx;
|
||||||
color: #ccc;
|
color: $text-hint;
|
||||||
margin-left: 8rpx;
|
margin-left: 8rpx;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
@@ -369,18 +500,18 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.bind-phone-text {
|
.bind-phone-text {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: $primary-dark;
|
color: $accent-color;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Read-only info card ──────────────────────────────── */
|
/* ── Read-only info card ──────────────────────────────── */
|
||||||
.info-card {
|
.info-card {
|
||||||
background: #fff;
|
background: $bg-card;
|
||||||
border-radius: 20rpx;
|
border-radius: $radius-lg;
|
||||||
margin: 0 24rpx 32rpx;
|
margin: 0 $spacing-lg $spacing-lg;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-row {
|
.info-row {
|
||||||
@@ -389,7 +520,7 @@ onMounted(async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 28rpx 28rpx;
|
padding: 28rpx 28rpx;
|
||||||
border-bottom: 1rpx solid #f5f5f5;
|
border-bottom: 1rpx solid rgba($border-color, 0.5);
|
||||||
|
|
||||||
&--last {
|
&--last {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
@@ -398,33 +529,38 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.info-label {
|
.info-label {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #999;
|
color: $text-hint;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-value {
|
.info-value {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #555;
|
color: $text-secondary;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Save button ─────────────────────────────────────── */
|
/* ── Save button ─────────────────────────────────────── */
|
||||||
.save-wrap {
|
.save-wrap {
|
||||||
padding: 8rpx 24rpx 48rpx;
|
padding: 8rpx $spacing-lg 48rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-btn {
|
.save-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 96rpx;
|
height: 96rpx;
|
||||||
border-radius: 48rpx;
|
border-radius: 48rpx;
|
||||||
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
|
background: linear-gradient(135deg, $brand-color, lighten($brand-color, 8%));
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 4rpx 20rpx rgba(26, 26, 46, 0.3);
|
box-shadow: 0 8rpx 24rpx rgba($brand-color, 0.25);
|
||||||
transition: opacity 0.2s;
|
transition: all 0.25s ease;
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
opacity: 0.85;
|
transform: scale(0.98);
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba($brand-color, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
&--loading,
|
&--loading,
|
||||||
@@ -437,7 +573,17 @@ onMounted(async () => {
|
|||||||
.save-btn-text {
|
.save-btn-text {
|
||||||
font-size: 32rpx;
|
font-size: 32rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: $primary-dark;
|
color: #ffffff;
|
||||||
letter-spacing: 2rpx;
|
letter-spacing: 2rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skip-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $text-hint;
|
||||||
|
padding: 8rpx 24rpx;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
import { UserRole, MembershipStatus } from '@mp-pilates/shared'
|
import { UserRole, MembershipStatus } from '@mp-pilates/shared'
|
||||||
import { wxLogin, isLoggedIn, logout as authLogout } from '../utils/auth'
|
import { wxLogin, isLoggedIn, logout as authLogout } from '../utils/auth'
|
||||||
import { get, put } from '../utils/request'
|
import { get, put } from '../utils/request'
|
||||||
|
import { ROUTES } from '../utils/routes'
|
||||||
|
|
||||||
export const useUserStore = defineStore('user', () => {
|
export const useUserStore = defineStore('user', () => {
|
||||||
// State
|
// State
|
||||||
@@ -33,13 +34,27 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
const result = await wxLogin()
|
const result = await wxLogin()
|
||||||
token.value = result.token
|
token.value = result.token
|
||||||
user.value = result.user
|
user.value = result.user
|
||||||
return result.user
|
return { user: result.user, isNewUser: result.isNewUser }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Login failed:', err)
|
console.error('Login failed:', err)
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login, redirect new users to complete profile, and fetch post-login data.
|
||||||
|
* Returns isNewUser so callers know whether navigation was intercepted.
|
||||||
|
*/
|
||||||
|
async function loginWithSetup(): Promise<{ isNewUser: boolean }> {
|
||||||
|
const { isNewUser } = await login()
|
||||||
|
if (isNewUser) {
|
||||||
|
uni.navigateTo({ url: ROUTES.PROFILE_INFO_FIRST_LOGIN })
|
||||||
|
return { isNewUser: true }
|
||||||
|
}
|
||||||
|
await Promise.all([fetchProfile(), fetchMemberships()])
|
||||||
|
return { isNewUser: false }
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchProfile() {
|
async function fetchProfile() {
|
||||||
if (!isLoggedIn()) return
|
if (!isLoggedIn()) return
|
||||||
try {
|
try {
|
||||||
@@ -99,6 +114,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
activeMemberships,
|
activeMemberships,
|
||||||
hasValidMembership,
|
hasValidMembership,
|
||||||
login,
|
login,
|
||||||
|
loginWithSetup,
|
||||||
fetchProfile,
|
fetchProfile,
|
||||||
fetchStats,
|
fetchStats,
|
||||||
fetchMemberships,
|
fetchMemberships,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { UserProfileResponse } from '@mp-pilates/shared'
|
|||||||
interface LoginResponse {
|
interface LoginResponse {
|
||||||
readonly token: string
|
readonly token: string
|
||||||
readonly user: UserProfileResponse
|
readonly user: UserProfileResponse
|
||||||
|
readonly isNewUser: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function wxLogin(): Promise<LoginResponse> {
|
export async function wxLogin(): Promise<LoginResponse> {
|
||||||
|
|||||||
@@ -64,3 +64,9 @@ export function getCardCoverClass(type: CardTypeCategory): string {
|
|||||||
if (type === CardTypeCategory.DURATION) return 'cover--duration'
|
if (type === CardTypeCategory.DURATION) return 'cover--duration'
|
||||||
return 'cover--times'
|
return 'cover--times'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 判断课程时段是否已过期(当前时间 > 课程开始时间) */
|
||||||
|
export function isSlotPast(date: string, startTime: string): boolean {
|
||||||
|
const slotDateTime = new Date(`${date}T${startTime}:00`)
|
||||||
|
return new Date() > slotDateTime
|
||||||
|
}
|
||||||
|
|||||||
4
packages/app/src/utils/routes.ts
Normal file
4
packages/app/src/utils/routes.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const ROUTES = {
|
||||||
|
PROFILE_INFO: '/pages/profile/info',
|
||||||
|
PROFILE_INFO_FIRST_LOGIN: '/pages/profile/info?from=login',
|
||||||
|
} as const
|
||||||
@@ -94,6 +94,7 @@ describe('AuthService', () => {
|
|||||||
data: { openid: OPENID, nickname: TEST_NICKNAME },
|
data: { openid: OPENID, nickname: TEST_NICKNAME },
|
||||||
})
|
})
|
||||||
expect(result.user).toEqual(mockUser)
|
expect(result.user).toEqual(mockUser)
|
||||||
|
expect(result.isNewUser).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('creates user with unionid when present', async () => {
|
it('creates user with unionid when present', async () => {
|
||||||
@@ -123,6 +124,7 @@ describe('AuthService', () => {
|
|||||||
})
|
})
|
||||||
expect(mockPrismaService.user.create).not.toHaveBeenCalled()
|
expect(mockPrismaService.user.create).not.toHaveBeenCalled()
|
||||||
expect(result.user).toEqual(mockUser)
|
expect(result.user).toEqual(mockUser)
|
||||||
|
expect(result.isNewUser).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns a valid JWT token', async () => {
|
it('returns a valid JWT token', async () => {
|
||||||
@@ -145,6 +147,7 @@ describe('AuthService', () => {
|
|||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
token: JWT_TOKEN,
|
token: JWT_TOKEN,
|
||||||
user: mockUser,
|
user: mockUser,
|
||||||
|
isNewUser: false,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class AuthController {
|
|||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async login(@Body() loginDto: LoginDto): Promise<{ token: string; user: User }> {
|
async login(@Body() loginDto: LoginDto): Promise<{ token: string; user: User; isNewUser: boolean }> {
|
||||||
return this.authService.login(
|
return this.authService.login(
|
||||||
loginDto.code,
|
loginDto.code,
|
||||||
loginDto.nickname,
|
loginDto.nickname,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { WechatService } from './wechat.service'
|
|||||||
export interface LoginResult {
|
export interface LoginResult {
|
||||||
token: string
|
token: string
|
||||||
user: User
|
user: User
|
||||||
|
isNewUser: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JwtPayload {
|
export interface JwtPayload {
|
||||||
@@ -69,6 +70,8 @@ export class AuthService {
|
|||||||
where: { openid },
|
where: { openid },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isNewUser = existingUser === null
|
||||||
|
|
||||||
const user =
|
const user =
|
||||||
existingUser ??
|
existingUser ??
|
||||||
(await this.prisma.user.create({
|
(await this.prisma.user.create({
|
||||||
@@ -89,7 +92,7 @@ export class AuthService {
|
|||||||
sessionKeyStore.set(updated.id, sessionKey)
|
sessionKeyStore.set(updated.id, sessionKey)
|
||||||
const payload: JwtPayload = { sub: updated.id, role: updated.role as UserRole }
|
const payload: JwtPayload = { sub: updated.id, role: updated.role as UserRole }
|
||||||
const token = this.jwtService.sign(payload)
|
const token = this.jwtService.sign(payload)
|
||||||
return { token, user: updated }
|
return { token, user: updated, isNewUser: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionKeyStore.set(user.id, sessionKey)
|
sessionKeyStore.set(user.id, sessionKey)
|
||||||
@@ -97,7 +100,7 @@ export class AuthService {
|
|||||||
const payload: JwtPayload = { sub: user.id, role: user.role as UserRole }
|
const payload: JwtPayload = { sub: user.id, role: user.role as UserRole }
|
||||||
const token = this.jwtService.sign(payload)
|
const token = this.jwtService.sign(payload)
|
||||||
|
|
||||||
return { token, user }
|
return { token, user, isNewUser }
|
||||||
}
|
}
|
||||||
|
|
||||||
async bindPhone(
|
async bindPhone(
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user