perf: 完善新用户引导

This commit is contained in:
richarjiang
2026-04-06 15:50:22 +08:00
parent 66d47ec162
commit f8268cb6f6
15 changed files with 483 additions and 119 deletions

View File

@@ -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>

View File

@@ -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;

View File

@@ -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 ─────────────────────────────────── */

View File

@@ -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' })
} }

View File

@@ -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">&#x1F9D8;</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">&#x1F4CB;</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 {

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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,

View File

@@ -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> {

View File

@@ -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
}

View File

@@ -0,0 +1,4 @@
export const ROUTES = {
PROFILE_INFO: '/pages/profile/info',
PROFILE_INFO_FIRST_LOGIN: '/pages/profile/info?from=login',
} as const

View File

@@ -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,
}) })
}) })
}) })

View File

@@ -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,

View File

@@ -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