feat: 优化首页、个人中心页面 UI

This commit is contained in:
richarjiang
2026-09-08 14:20:43 +08:00
parent 301f9ae385
commit d1f193e13e
16 changed files with 481 additions and 2829 deletions

View File

@@ -2,7 +2,6 @@
<view class="about-section">
<view class="section-header">
<view>
<text class="section-eyebrow">Teacher Spotlight</text>
<text class="section-title">老师介绍</text>
</view>
<text class="section-more" @tap="goToDetail">查看详情</text>
@@ -12,9 +11,7 @@
<view class="teacher-main">
<view class="cover-wrap">
<image class="teacher-cover" :src="teacher.avatar" mode="aspectFill" />
<view class="cover-badge">
<text class="cover-badge-text">{{ teacher.badges[0] }}</text>
</view>
</view>
<view class="teacher-content">
@@ -22,28 +19,20 @@
<view>
<view class="name-row">
<text class="teacher-name">{{ teacher.name }}</text>
<text class="teacher-tag">{{ teacher.badges[1] }}</text>
</view>
<text class="teacher-title">{{ teacher.title }}</text>
</view>
</view>
<view class="specialty-row">
<text v-for="item in teacher.specialties" :key="item" class="specialty-pill">{{ item }}</text>
<text class="specialty-text">{{ teacher.specialties.join(' · ') }}</text>
</view>
<text class="teacher-intro">{{ teacher.intro }}</text>
</view>
</view>
<view class="credential-box">
<text class="credential-label">认证背景</text>
<view v-for="item in teacher.certifications" :key="item" class="credential-row">
<view class="credential-dot" />
<text class="credential-text">{{ item }}</text>
</view>
</view>
<view class="action-row">
<view class="secondary-btn" @tap.stop="goToDetail">
<text class="secondary-btn-text">查看详情</text>
@@ -71,228 +60,25 @@ function goToBooking() {
</script>
<style lang="scss" scoped>
.about-section {
padding: 20rpx 24rpx 0;
}
.section-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 18rpx;
}
.section-eyebrow {
display: block;
font-size: 20rpx;
letter-spacing: 3rpx;
text-transform: uppercase;
color: #b99b8c;
margin-bottom: 8rpx;
}
.section-title {
font-size: 34rpx;
font-weight: 700;
color: #2f2723;
}
.section-more {
font-size: 24rpx;
color: #c36d52;
padding: 10rpx 0;
}
.teacher-card {
display: flex;
flex-direction: column;
gap: 18rpx;
background:
radial-gradient(circle at top right, rgba(226, 198, 179, 0.42), transparent 32%),
linear-gradient(135deg, #fffdfb 0%, #f8f2ee 46%, #f2e8e2 100%);
border-radius: 30rpx;
padding: 22rpx;
box-shadow: 0 18rpx 38rpx rgba(126, 98, 84, 0.09);
}
.teacher-main {
display: flex;
gap: 22rpx;
align-items: stretch;
}
.cover-wrap {
position: relative;
width: 188rpx;
height: 248rpx;
border-radius: 24rpx;
overflow: hidden;
flex-shrink: 0;
background: #eadfd7;
}
.teacher-cover {
width: 100%;
height: 100%;
}
.cover-badge {
position: absolute;
left: 14rpx;
bottom: 14rpx;
border-radius: 999rpx;
padding: 8rpx 16rpx;
background: rgba(41, 34, 30, 0.74);
backdrop-filter: blur(12rpx);
}
.cover-badge-text {
font-size: 20rpx;
font-weight: 600;
color: #fff7f3;
}
.teacher-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.teacher-heading {
margin-bottom: 14rpx;
}
.name-row {
display: flex;
align-items: center;
gap: 10rpx;
margin-bottom: 8rpx;
}
.teacher-name {
font-size: 38rpx;
line-height: 1;
font-weight: 700;
color: #2e2521;
}
.teacher-tag {
font-size: 18rpx;
line-height: 1;
color: #a85d44;
background: rgba(221, 150, 118, 0.18);
border-radius: 999rpx;
padding: 8rpx 12rpx;
}
.teacher-title {
font-size: 24rpx;
color: #7f6659;
}
.specialty-row {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
margin-bottom: 14rpx;
}
.specialty-pill {
padding: 8rpx 16rpx;
border-radius: 999rpx;
background: rgba(92, 126, 151, 0.12);
color: #5a7a8b;
font-size: 20rpx;
font-weight: 600;
}
.teacher-intro {
font-size: 22rpx;
line-height: 1.7;
color: #5e5048;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.credential-box {
padding: 16rpx 18rpx;
border-radius: 18rpx;
background: rgba(255, 255, 255, 0.72);
border: 1rpx solid rgba(190, 161, 145, 0.22);
}
.credential-label {
display: block;
font-size: 18rpx;
letter-spacing: 2rpx;
color: #b19486;
margin-bottom: 8rpx;
}
.credential-text {
font-size: 20rpx;
line-height: 1.7;
color: #6a5a51;
}
.credential-row {
display: flex;
align-items: flex-start;
gap: 12rpx;
}
.credential-row + .credential-row {
margin-top: 12rpx;
}
.credential-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
margin-top: 12rpx;
flex-shrink: 0;
background: #6d4037;
}
.action-row {
display: flex;
gap: 12rpx;
}
.secondary-btn,
.primary-btn {
height: 72rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
}
.secondary-btn {
width: 148rpx;
border: 1rpx solid rgba(139, 113, 99, 0.24);
background: rgba(255, 255, 255, 0.8);
}
.primary-btn {
flex: 1;
background: linear-gradient(135deg, #ff7654 0%, #ff4d38 100%);
box-shadow: 0 14rpx 24rpx rgba(255, 92, 69, 0.24);
}
.secondary-btn-text {
font-size: 24rpx;
font-weight: 600;
color: #684d40;
}
.primary-btn-text {
font-size: 26rpx;
font-weight: 700;
color: #ffffff;
letter-spacing: 1rpx;
}
.about-section { margin: 36rpx 32rpx 0; }
.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20rpx; }
.section-title { font-size: 30rpx; font-weight: 500; color: #514943; }
.section-more { font-size: 23rpx; color: #8b817b; padding: 8rpx 0; }
.teacher-card { padding: 26rpx; background: #f2ebe5; border-radius: 28rpx; }
.teacher-main { display: flex; align-items: flex-start; gap: 24rpx; }
.cover-wrap { width: 164rpx; height: 230rpx; border-radius: 18rpx; overflow: hidden; background: #e1d4c8; flex-shrink: 0; }
.teacher-cover { width: 100%; height: 100%; }
.teacher-content { flex: 1; min-width: 0; }
.name-row { margin-bottom: 8rpx; }
.teacher-name { font-size: 34rpx; font-weight: 500; color: #514943; }
.teacher-title { font-size: 22rpx; color: #8b817b; line-height: 1.5; }
.specialty-row { margin: 14rpx 0 10rpx; }
.specialty-text { font-size: 21rpx; color: #617d73; line-height: 1.6; }
.teacher-intro { font-size: 22rpx; line-height: 1.7; color: #786b61; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.action-row { display: flex; gap: 16rpx; margin-top: 24rpx; }
.secondary-btn, .primary-btn { flex: 1; height: 68rpx; border-radius: 999rpx; display: flex; align-items: center; justify-content: center; }
.secondary-btn { background: #fbf7f3; }
.primary-btn { background: #6b8276; }
.secondary-btn-text { font-size: 24rpx; color: #78675c; }
.primary-btn-text { font-size: 24rpx; font-weight: 400; color: #fff; }
</style>

View File

@@ -42,9 +42,6 @@
<!-- Single membership -->
<view v-if="memberships.length === 1" class="card-single">
<view class="card-item selected">
<view class="card-icon-wrap">
<text class="card-icon">💳</text>
</view>
<view class="card-info">
<text class="card-name">{{ memberships[0].cardType.name }}</text>
<text class="card-remain" v-if="memberships[0].remainingTimes !== null">
@@ -61,7 +58,7 @@
</view>
<!-- Multiple memberships picker -->
<view v-else-if="memberships.length > 1" class="card-picker-wrap">
<scroll-view v-else-if="memberships.length > 1" class="card-picker-wrap" scroll-y>
<view
v-for="m in memberships"
:key="m.id"
@@ -69,9 +66,6 @@
:class="{ selected: selectedMembershipId === m.id }"
@tap="selectedMembershipId = m.id"
>
<view class="card-icon-wrap">
<text class="card-icon">💳</text>
</view>
<view class="card-info">
<text class="card-name">{{ m.cardType.name }}</text>
<text class="card-remain" v-if="m.remainingTimes !== null">
@@ -85,7 +79,7 @@
<text class="check-icon"></text>
</view>
</view>
</view>
</scroll-view>
<!-- No memberships fallback (should not normally appear) -->
<view v-else class="no-card-tip">
@@ -110,6 +104,8 @@
<button
class="btn-confirm"
:class="{ disabled: !selectedMembershipId }"
:disabled="!selectedMembershipId || requestingSubscribe"
:loading="requestingSubscribe"
@tap="handleConfirm"
>
<text class="btn-confirm-text">确认预约</text>
@@ -143,8 +139,8 @@ const requestingSubscribe = ref(false)
watch(
[() => props.visible, () => props.memberships],
([visible, memberships]) => {
if (visible && memberships.length > 0) {
selectedMembershipId.value = memberships[0].id
if (visible) {
selectedMembershipId.value = memberships[0]?.id ?? ''
}
},
{ immediate: true },
@@ -187,261 +183,45 @@ function handleMaskTap() {
</script>
<style lang="scss" scoped>
.popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 1000;
display: flex;
align-items: flex-end;
justify-content: center;
}
.popup-panel {
width: 100%;
background: #fff;
border-radius: 32rpx 32rpx 0 0;
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
gap: 0;
}
.popup-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 32rpx;
}
.popup-title {
font-size: 34rpx;
font-weight: 700;
color: #1a1a1a;
}
.close-btn {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
background: $primary-selected-bg;
border-radius: 50%;
}
.close-icon {
font-size: 24rpx;
color: #999;
}
/* Info rows */
.info-section {
display: flex;
flex-direction: column;
gap: 20rpx;
margin-bottom: 28rpx;
}
.info-row {
display: flex;
flex-direction: row;
align-items: center;
}
.info-label {
font-size: 28rpx;
color: #999;
width: 80rpx;
flex-shrink: 0;
}
.info-value {
font-size: 28rpx;
color: #222;
font-weight: 500;
}
.divider {
height: 1rpx;
background: $primary-border;
margin: 8rpx 0 28rpx;
}
/* Card selection */
.card-section {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-bottom: 24rpx;
}
.section-label-row {
margin-bottom: 8rpx;
}
.section-label {
font-size: 28rpx;
color: #666;
font-weight: 500;
}
.card-single,
.card-picker-wrap {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.popup-mask { position: fixed; inset: 0; background: rgba(56, 48, 42, 0.32); z-index: 1000; display: flex; align-items: flex-end; justify-content: center; }
.popup-panel { width: 100%; box-sizing: border-box; background: #fbf9f6; border-radius: 36rpx 36rpx 0 0; padding: 28rpx 32rpx calc(24rpx + env(safe-area-inset-bottom)); }
.popup-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24rpx; }
.popup-title { font-size: 32rpx; font-weight: 500; color: #514943; }
.close-btn { width: 60rpx; height: 60rpx; display: flex; align-items: center; justify-content: center; }
.close-icon { font-size: 26rpx; color: #8b817b; }
.info-section { padding: 24rpx; background: #f1e8e1; border-radius: 24rpx; display: flex; flex-direction: column; gap: 14rpx; }
.info-row { display: flex; align-items: center; gap: 20rpx; }
.info-label { font-size: 24rpx; color: #8b7b70; width: 64rpx; flex-shrink: 0; }
.info-value { font-size: 26rpx; color: #6f5c50; font-weight: 400; }
.divider { height: 24rpx; }
.card-section { display: flex; flex-direction: column; gap: 16rpx; }
.section-label { font-size: 26rpx; color: #514943; font-weight: 500; }
.card-picker-wrap { height: 250rpx; }
.card-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 24rpx 20rpx;
border-radius: 16rpx;
border: 2rpx solid $primary-border;
background: $primary-bg;
gap: 20rpx;
transition: border-color 0.15s, background 0.15s;
&.selected {
border-color: $primary-dark;
background: $primary-selected-bg;
}
display: flex; align-items: center; padding: 22rpx 24rpx; border-radius: 24rpx;
border: 2rpx solid #eee8e3; background: #fff; gap: 20rpx; box-sizing: border-box;
&.selected { border-color: #a5b8ab; background: #f0f4ee; }
}
.card-icon-wrap {
width: 60rpx;
height: 60rpx;
border-radius: 14rpx;
background: linear-gradient(135deg, $primary-color, $primary-dark);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.card-icon {
font-size: 32rpx;
}
.card-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.card-name {
font-size: 28rpx;
font-weight: 600;
color: #222;
}
.card-remain {
font-size: 22rpx;
color: #999;
}
.check-mark {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
background: $primary-dark;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.check-icon {
font-size: 24rpx;
color: #fff;
font-weight: 700;
}
.no-card-tip {
padding: 24rpx;
text-align: center;
}
.no-card-text {
font-size: 26rpx;
color: #bbb;
}
/* Deduction tip */
.deduction-tip {
background: $primary-selected-bg;
border-radius: 12rpx;
padding: 16rpx 20rpx;
margin-bottom: 28rpx;
}
.deduction-text {
font-size: 24rpx;
color: $primary-dark;
line-height: 1.5;
}
/* Action buttons */
.action-row {
display: flex;
flex-direction: row;
gap: 20rpx;
margin-top: 8rpx;
}
.btn-outline {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
border: 2rpx solid $primary-border;
display: flex;
align-items: center;
justify-content: center;
&:active {
background: #f5f5f5;
}
}
.btn-outline-text {
font-size: 30rpx;
color: #666;
font-weight: 500;
}
.card-picker-wrap .card-item { margin-bottom: 12rpx; }
.card-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
.card-name { font-size: 26rpx; font-weight: 400; color: #514943; overflow-wrap: anywhere; }
.card-remain { font-size: 22rpx; color: #8b817b; }
.check-mark { width: 36rpx; height: 36rpx; border-radius: 50%; background: #6b8276; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.check-icon { font-size: 22rpx; color: #fff; }
.no-card-tip { padding: 24rpx; text-align: center; }
.no-card-text { font-size: 24rpx; color: #8b817b; }
.deduction-tip { padding: 18rpx 4rpx; }
.deduction-text { font-size: 22rpx; color: #8b817b; line-height: 1.6; }
.action-row { display: flex; gap: 20rpx; margin-top: 12rpx; }
.btn-outline { flex: 1; height: 88rpx; border-radius: 999rpx; background: #f0eae4; display: flex; align-items: center; justify-content: center; }
.btn-outline-text { font-size: 28rpx; color: #78675c; font-weight: 400; }
.btn-confirm {
flex: 2;
height: 88rpx;
border-radius: 44rpx;
background: linear-gradient(135deg, $primary-color, $primary-dark);
display: flex;
align-items: center;
justify-content: center;
&:active {
opacity: 0.85;
}
&.disabled {
background: $primary-border;
}
}
.btn-confirm-text {
font-size: 30rpx;
color: #fff;
font-weight: 600;
letter-spacing: 1rpx;
.disabled & {
color: #bbb;
}
flex: 2; height: 88rpx; padding: 0; margin: 0; border: none; line-height: 1;
border-radius: 999rpx; background: #6b8276;
display: flex; align-items: center; justify-content: center;
&::after { border: none; }
&:active { background: #597264; }
&.disabled { background: #d9dfd8; }
}
.btn-confirm-text { font-size: 28rpx; color: #fff; font-weight: 500; .disabled & { color: #677467; } }
</style>

View File

@@ -3,7 +3,7 @@
<!-- Section header -->
<view class="section-header">
<text class="section-title">会员卡</text>
<text class="section-action" @tap="goToAllCards">全部</text>
<text class="section-action" @tap="goToAllCards">全部 </text>
</view>
<!-- Loading skeleton -->
@@ -39,8 +39,7 @@
mode="aspectFill"
/>
<template v-else>
<view class="cover-deco cover-deco--1" />
<view class="cover-deco cover-deco--2" />
<text class="cover-label">{{ card.type === CardTypeCategory.TRIAL ? '体验' : card.type === CardTypeCategory.DURATION ? '期限卡' : '次卡' }}</text>
</template>
</view>
@@ -146,272 +145,36 @@ function goToAllCards() {
</script>
<style lang="scss" scoped>
.card-shop {
background: #ffffff;
margin: 16rpx 24rpx 0;
padding-bottom: 20rpx;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
/* ── Section header ── */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx 32rpx 20rpx;
}
.section-title {
font-size: 36rpx;
font-weight: 700;
color: #222;
}
.section-action {
font-size: 26rpx;
color: #999;
padding: 8rpx 24rpx;
border: 1rpx solid #ddd;
border-radius: 24rpx;
}
/* ── Card list ── */
.card-list {
padding: 0 32rpx;
}
.card-row {
display: flex;
align-items: center;
gap: 20rpx;
padding: 16rpx 0;
border-bottom: 1rpx solid rgba($brand-color, 0.08);
&:last-child {
border-bottom: none;
}
}
/* ══════════════════════════════════════════════════════════
CARD COVER — Clean minimal design
══════════════════════════════════════════════════════════ */
.card-cover {
width: 200rpx;
height: 130rpx;
border-radius: 16rpx;
overflow: hidden;
flex-shrink: 0;
position: relative;
}
.card-cover-img {
width: 100%;
height: 100%;
}
/* Decorative circles */
.cover-deco {
position: absolute;
border-radius: 50%;
pointer-events: none;
&--1 {
width: 100rpx;
height: 100rpx;
top: -30rpx;
right: -20rpx;
background: rgba(255, 255, 255, 0.4);
}
&--2 {
width: 70rpx;
height: 70rpx;
bottom: -20rpx;
left: -10rpx;
background: rgba(255, 255, 255, 0.25);
}
}
/* Card cover backgrounds */
.cover--times {
background: linear-gradient(135deg, #E8D5C4 0%, #D4BFA8 100%);
}
.cover--duration {
background: linear-gradient(135deg, #D8C8DC 0%, #C4AECB 100%);
}
.cover--trial {
background: linear-gradient(135deg, #C8D8D2 0%, #A9C4BC 100%);
}
/* ── Card info — matches card-cover height ── */
.card-info {
flex: 1;
min-width: 0;
height: 130rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.info-top {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4rpx;
}
.info-bottom {
display: flex;
align-items: baseline;
gap: 16rpx;
}
.card-name-row {
display: flex;
align-items: center;
gap: 10rpx;
min-width: 0;
}
.card-name {
min-width: 0;
font-size: 30rpx;
font-weight: 700;
color: $text-primary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0.5rpx;
line-height: 1.2;
}
.renew-tag {
flex-shrink: 0;
font-size: 18rpx;
font-weight: 700;
color: #5f7a6e;
background: rgba(143, 168, 154, 0.2);
padding: 2rpx 10rpx;
border-radius: 8rpx;
line-height: 1.4;
letter-spacing: 1rpx;
}
.card-validity {
font-size: 23rpx;
color: $text-secondary;
line-height: 1.2;
}
.card-times {
display: flex;
align-items: baseline;
gap: 4rpx;
}
.card-times-value {
font-size: 34rpx;
font-weight: 800;
color: $brand-color;
line-height: 1;
}
.card-times-unit {
font-size: 20rpx;
color: $text-secondary;
font-weight: 500;
}
.price-row {
display: flex;
align-items: baseline;
gap: 6rpx;
}
.price-current {
font-size: 32rpx;
font-weight: 800;
color: $brand-color;
line-height: 1;
}
.price-original {
font-size: 20rpx;
color: $text-hint;
text-decoration: line-through;
}
/* Arrow */
.card-arrow {
font-size: 44rpx;
color: $text-hint;
flex-shrink: 0;
transform: scaleX(0.5);
transform-origin: center;
}
/* ── Skeleton ── */
.skeleton-row {
display: flex;
align-items: center;
gap: 20rpx;
padding: 20rpx 0;
border-bottom: 1rpx solid rgba($brand-color, 0.08);
}
.skeleton-card-cover {
width: 240rpx;
height: 130rpx;
border-radius: 16rpx;
flex-shrink: 0;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
.skeleton-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.skeleton-line {
height: 24rpx;
border-radius: 6rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
&--title {
width: 60%;
height: 30rpx;
}
&--sub {
width: 40%;
}
&--price {
width: 45%;
height: 36rpx;
}
}
/* ── Empty state ── */
.empty-state {
padding: 80rpx;
display: flex;
align-items: center;
justify-content: center;
}
.empty-text {
font-size: 28rpx;
color: $text-hint;
}
.card-shop { margin: 36rpx 32rpx 0; }
.section-header { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; margin-bottom: 20rpx; }
.section-title { font-size: 30rpx; font-weight: 500; color: #514943; }
.section-action { font-size: 23rpx; color: #8b817b; padding: 8rpx 0; }
.card-list { padding: 0 24rpx; border-radius: 28rpx; background: #fff; border: 1rpx solid #eee8e3; }
.card-row { display: flex; align-items: center; gap: 20rpx; padding: 26rpx 0; border-bottom: 1rpx solid #f0ebe6; &:last-child { border: none; } }
.card-cover { width: 132rpx; height: 148rpx; border-radius: 16rpx; overflow: hidden; flex-shrink: 0; display: flex; align-items: center; justify-content: center; background: #f0e6de; }
.card-cover-img { width: 100%; height: 100%; }
.cover--times { background: #f0e6de; }
.cover--duration { background: #e9ede5; }
.cover--trial { background: #f2e4e0; }
.cover-label { font-family: 'Songti SC', 'STSong', serif; font-size: 28rpx; color: #857062; letter-spacing: 3rpx; }
.card-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 14rpx; }
.info-top { display: flex; flex-direction: column; gap: 8rpx; }
.card-name-row { display: flex; align-items: baseline; gap: 8rpx; }
.card-name { min-width: 0; font-size: 27rpx; font-weight: 400; color: #514943; line-height: 1.5; overflow-wrap: anywhere; }
.renew-tag { flex-shrink: 0; font-size: 18rpx; color: #617d73; background: #edf2ec; padding: 3rpx 8rpx; border-radius: 6rpx; }
.card-validity { font-size: 21rpx; color: #8b817b; }
.info-bottom { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8rpx 16rpx; }
.card-times { display: flex; align-items: baseline; gap: 4rpx; }
.card-times-value { font-size: 24rpx; color: #6f655e; }
.card-times-unit { font-size: 20rpx; color: #8b817b; }
.price-row { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8rpx; }
.price-current { font-size: 30rpx; font-weight: 500; color: #8b6c5b; font-variant-numeric: tabular-nums; }
.price-original { font-size: 20rpx; color: #a59b93; text-decoration: line-through; }
.card-arrow { font-size: 32rpx; color: #b2a79e; flex-shrink: 0; }
.skeleton-card-cover { width: 132rpx; height: 148rpx; border-radius: 16rpx; flex-shrink: 0; }
.skeleton-info { flex: 1; display: flex; flex-direction: column; gap: 16rpx; }
.skeleton-line { height: 24rpx; width: 70%; border-radius: 8rpx; &--sub { width: 50%; } }
.skeleton-line, .skeleton-card-cover { background: linear-gradient(90deg, #f0eae5 25%, #faf7f3 50%, #f0eae5 75%); background-size: 400% 100%; animation: shimmer 1.4s infinite; }
.empty-state { padding: 48rpx 24rpx; border-radius: 24rpx; background: #f3efea; text-align: center; }
.empty-text { font-size: 24rpx; color: #8b817b; }
</style>

View File

@@ -14,7 +14,7 @@
>
<text class="weekday">{{ item.isToday ? '今天' : item.weekday }}</text>
<text class="day">{{ getDayNumber(item.date) }}</text>
<text class="month">{{ getMonthNumber(item.date) }}</text>
<text v-if="variant !== 'soft' || getDayNumber(item.date) === '1'" class="month">{{ getMonthNumber(item.date) }}</text>
</view>
</view>
</scroll-view>
@@ -28,7 +28,7 @@ import { getDateRange } from '../utils/format'
interface Props {
modelValue: string
variant?: 'default' | 'booking'
variant?: 'default' | 'booking' | 'soft'
}
const props = defineProps<Props>()
@@ -168,4 +168,24 @@ const variant = computed(() => props.variant ?? 'default')
}
}
}
.date-selector.date-selector--soft {
padding: 8rpx 0 24rpx;
background: #fbf9f6;
border-bottom: none;
.track { padding: 0 32rpx; gap: 12rpx; }
.date-item {
min-width: 0; width: 86rpx; height: 116rpx; padding: 0;
box-sizing: border-box; border-radius: 24rpx;
background: transparent; gap: 12rpx;
.weekday { font-size: 22rpx; color: #8b817b; }
.day { font-size: 34rpx; font-weight: 400; color: #514943; }
&.active {
background: #eee3dc;
.weekday, .day { color: #705b4f; }
.day { font-weight: 500; }
}
&.today:not(.active) .weekday { color: #617d73; font-weight: 400; }
}
}
</style>

View File

@@ -3,11 +3,8 @@
<!-- Section header -->
<view class="section-header">
<view class="header-left">
<view class="flash-icon-wrap">
<view class="flash-icon-clock" />
</view>
<text class="section-title">限时秒杀</text>
<view v-if="hasOngoing" class="live-dot" />
<text v-if="hasOngoing" class="live-note">进行中</text>
</view>
</view>
@@ -155,284 +152,31 @@ onUnmounted(() => {
</script>
<style lang="scss" scoped>
.flash-sale-section {
background: #fff;
margin: 16rpx 24rpx 0;
padding-bottom: 24rpx;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
/* ── Section header ── */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 32rpx 16rpx;
}
.header-left {
display: flex;
align-items: center;
gap: 12rpx;
}
.flash-icon-wrap {
width: 44rpx;
height: 44rpx;
border-radius: 12rpx;
background: linear-gradient(135deg, #D4A59A, #C08B7E);
display: flex;
align-items: center;
justify-content: center;
}
/* CSS-drawn clock icon */
.flash-icon-clock {
width: 24rpx;
height: 24rpx;
border: 3rpx solid #fff;
border-radius: 50%;
position: relative;
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 2rpx;
height: 9rpx;
background: #fff;
transform-origin: bottom center;
transform: translate(-50%, -100%) rotate(0deg);
border-radius: 1rpx;
}
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 2rpx;
height: 7rpx;
background: #fff;
transform-origin: bottom center;
transform: translate(-50%, -100%) rotate(90deg);
border-radius: 1rpx;
}
}
.section-title {
font-size: 36rpx;
font-weight: 700;
color: #222;
}
.live-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #C08B7E;
animation: pulse 1.5s ease infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.8); }
}
/* ── Horizontal scroll ── */
.flash-scroll {
padding-left: 32rpx;
white-space: nowrap;
}
.flash-card-list {
display: inline-flex;
gap: 20rpx;
padding-right: 32rpx;
}
/* ── Flash card ── */
.flash-card {
width: 340rpx;
border-radius: 20rpx;
overflow: hidden;
flex-shrink: 0;
display: inline-flex;
flex-direction: column;
box-shadow: 0 4rpx 20rpx rgba(192, 139, 126, 0.18);
white-space: normal;
}
.card--ongoing { box-shadow: 0 6rpx 28rpx rgba(192, 139, 126, 0.28); }
.card--upcoming { opacity: 0.95; }
.card--soldout { opacity: 0.7; }
.card--ended { opacity: 0.5; }
/* Card top gradient — warm blush tones */
.card-top {
padding: 20rpx 20rpx 16rpx;
background: linear-gradient(135deg, #D4A59A 0%, #C9948A 40%, #B5836E 100%);
display: flex;
flex-direction: column;
gap: 10rpx;
}
.card--upcoming .card-top {
background: linear-gradient(135deg, #8FA89A 0%, #7BA5A0 100%);
}
.card--soldout .card-top,
.card--ended .card-top {
background: linear-gradient(135deg, #C4BAB0, #AEA49A);
}
/* Phase badge */
.phase-badge {
align-self: flex-start;
padding: 4rpx 14rpx;
border-radius: 12rpx;
}
.badge--ongoing { background: rgba(255, 255, 255, 0.3); }
.badge--upcoming { background: rgba(255, 255, 255, 0.25); }
.badge--inactive { background: rgba(0, 0, 0, 0.1); }
.phase-badge-text {
font-size: 20rpx;
font-weight: 600;
color: #fff;
}
/* Countdown */
.countdown-row {
display: flex;
align-items: center;
gap: 8rpx;
}
.countdown-label {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.85);
}
.countdown-blocks {
display: flex;
align-items: center;
gap: 4rpx;
}
.cd-block {
background: rgba(255, 255, 255, 0.25);
color: #fff;
font-size: 22rpx;
font-weight: 700;
padding: 4rpx 8rpx;
border-radius: 6rpx;
font-family: 'DIN Alternate', monospace;
min-width: 36rpx;
text-align: center;
backdrop-filter: blur(4px);
}
.cd-sep {
color: rgba(255, 255, 255, 0.75);
font-size: 20rpx;
font-weight: 700;
}
/* Card body */
.card-body {
padding: 20rpx;
background: #fff;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.card-title {
font-size: 28rpx;
font-weight: 700;
color: $text-primary;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-type-name {
font-size: 22rpx;
color: $text-hint;
}
/* Price area */
.price-area {
display: flex;
align-items: baseline;
gap: 12rpx;
margin-top: 4rpx;
}
.flash-price-row {
display: flex;
align-items: baseline;
}
.flash-currency {
font-size: 22rpx;
font-weight: 700;
color: #B5725E;
}
.flash-price {
font-size: 40rpx;
font-weight: 800;
color: #B5725E;
line-height: 1;
}
.original-price {
font-size: 22rpx;
color: #ccc;
text-decoration: line-through;
}
/* Stock area */
.stock-area {
display: flex;
flex-direction: column;
gap: 6rpx;
margin-top: 8rpx;
}
.stock-bar {
height: 10rpx;
background: #f5f0ed;
border-radius: 5rpx;
overflow: hidden;
}
.stock-fill {
height: 100%;
background: linear-gradient(90deg, #D4A59A, #C08B7E);
border-radius: 5rpx;
transition: width 0.3s;
&--hot {
animation: stockPulse 2s ease infinite;
}
}
@keyframes stockPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.stock-text {
font-size: 20rpx;
color: $text-hint;
}
.flash-sale-section { margin: 36rpx 32rpx 0; }
.section-header { margin-bottom: 20rpx; }
.header-left { display: flex; align-items: baseline; justify-content: space-between; gap: 16rpx; }
.section-title { font-size: 30rpx; font-weight: 500; color: #514943; }
.live-note { font-size: 22rpx; color: #9b7b66; }
.flash-scroll { width: 100%; white-space: nowrap; }
.flash-card-list { display: inline-flex; gap: 20rpx; }
.flash-card { width: 400rpx; border-radius: 28rpx; overflow: hidden; border: 1rpx solid #e7ded5; flex-shrink: 0; display: inline-flex; flex-direction: column; white-space: normal; background: #fff; }
.card-top { padding: 20rpx 24rpx; background: #f1e6de; display: flex; flex-direction: column; gap: 12rpx; }
.card--upcoming .card-top { background: #eaf0e8; }
.card--soldout .card-top, .card--ended .card-top { background: #eeeae5; }
.phase-badge-text { font-size: 22rpx; color: #7c6656; }
.countdown-row, .countdown-blocks { display: flex; align-items: baseline; gap: 8rpx; }
.countdown-label { font-size: 20rpx; color: #8b817b; }
.cd-block, .cd-sep { font-size: 24rpx; color: #7c6656; font-variant-numeric: tabular-nums; }
.card-body { padding: 24rpx; display: flex; flex-direction: column; gap: 10rpx; }
.card-title { font-size: 28rpx; font-weight: 500; color: #514943; line-height: 1.5; overflow-wrap: anywhere; }
.card-type-name { font-size: 22rpx; color: #8b817b; }
.price-area { display: flex; align-items: baseline; flex-wrap: wrap; gap: 12rpx; margin-top: 8rpx; }
.flash-price-row { display: flex; align-items: baseline; gap: 4rpx; }
.flash-currency { font-size: 22rpx; color: #8b6c5b; }
.flash-price { font-size: 38rpx; font-weight: 500; color: #8b6c5b; }
.original-price { font-size: 21rpx; color: #a59b93; text-decoration: line-through; }
.stock-area { margin-top: 12rpx; display: flex; flex-direction: column; gap: 10rpx; }
.stock-bar { height: 6rpx; background: #f3efea; border-radius: 6rpx; overflow: hidden; }
.stock-fill { height: 100%; border-radius: 6rpx; background: #bea28e; }
.stock-text { font-size: 20rpx; color: #8b817b; }
</style>

View File

@@ -2,7 +2,6 @@
<view class="practice">
<view class="practice__heading">
<view>
<text class="practice__eyebrow">YOUR PRACTICE</text>
<view class="practice__title">练习足迹<text class="practice__period">最近 30 </text></view>
</view>
<view class="practice__summary"><text class="practice__number">{{ activity ? total : '—' }}</text><text>节课</text></view>
@@ -55,10 +54,10 @@ const cells = computed(() => activity.value?.days ?? Array.from({ length: 30 },
const total = computed(() => cells.value.reduce((sum, day) => sum + day.count, 0))
const activeDays = computed(() => cells.value.filter(day => day.count > 0).length)
const detail = computed(() => {
if (!activity.value) return '每一次练习,都在积蓄力量'
if (!activity.value) return '正在整理练习记录'
const day = activity.value.days.find(item => item.date === selected.value)
if (day) return `${formatDate(day.date)} · ${day.count ? `已完成 ${day.count} 节课` : '暂无已完成课程'}`
return total.value ? `已练习 ${activeDays.value} 天 · 点选查看` : '从下一次练习,点亮第一格'
return total.value ? `已练习 ${activeDays.value} 天 · 点选查看` : '近 30 天暂无已完成课程'
})
function formatDate(date: string) {
return `${Number(date.slice(5, 7))}${Number(date.slice(8, 10))}`
@@ -89,21 +88,19 @@ onUnmounted(() => { disposed = true })
<style scoped lang="scss">
.practice {
margin: 28rpx $spacing-lg 0;
margin: 24rpx 32rpx 0;
padding: 28rpx;
border: 1rpx solid #e2eae8;
border: 1rpx solid #eee8e3;
border-radius: 28rpx;
background: linear-gradient(135deg, #fffefd, #f5f9f8);
box-shadow: 0 8rpx 28rpx rgba(56, 88, 88, 0.035);
color: #355b60;
background: #fff;
color: #514943;
&__heading, &__range, &__footer, &__legend { display: flex; align-items: center; justify-content: space-between; }
&__eyebrow { font-family: Georgia, serif; font-size: 17rpx; letter-spacing: 3rpx; color: #75908f; }
&__title { margin-top: 8rpx; font-size: 30rpx; font-weight: 600; letter-spacing: 2rpx; }
&__period { margin-left: 16rpx; font-size: 21rpx; font-weight: 400; letter-spacing: 0; color: #788b88; }
&__summary { display: flex; align-items: baseline; gap: 8rpx; font-size: 21rpx; color: #788b88; }
&__number { font-family: Georgia, serif; font-size: 56rpx; line-height: 1; color: #355b60; font-variant-numeric: tabular-nums; }
&__range { margin: 24rpx 0 12rpx; font-size: 20rpx; color: #788b88; }
&__title { font-size: 28rpx; font-weight: 500; }
&__period { margin-left: 16rpx; font-size: 21rpx; font-weight: 400; letter-spacing: 0; color: #8b817b; }
&__summary { display: flex; align-items: baseline; gap: 8rpx; font-size: 21rpx; color: #8b817b; }
&__number { font-size: 44rpx; font-weight: 400; line-height: 1; color: #617d73; font-variant-numeric: tabular-nums; }
&__range { margin: 24rpx 0 12rpx; font-size: 20rpx; color: #8b817b; }
&__grid { display: grid; grid-template-columns: repeat(10, minmax(0, 1fr)); gap: 9rpx; }
&__cell {
width: 100%; height: 44rpx; min-width: 0; margin: 0; padding: 0;
@@ -111,19 +108,19 @@ onUnmounted(() => { disposed = true })
border-radius: 8rpx; border: 2rpx solid transparent; box-sizing: border-box;
font-size: 19rpx; line-height: 1; font-variant-numeric: tabular-nums;
&::after { border: none; }
&--0 { background: #e9eeeb; color: #768780; }
&--1 { background: #b8d4ce; color: #355b60; }
&--2 { background: #77a9a2; color: #193f43; }
&--3 { background: #3e7575; color: #ffffff; }
&--today { border-bottom-color: #355b60; }
&--selected { box-shadow: 0 0 0 3rpx #fff, 0 0 0 5rpx #527f80; }
&--0 { background: #edf0e9; color: #7c8676; }
&--1 { background: #ccd9c6; color: #516d50; }
&--2 { background: #9cb594; color: #304b36; }
&--3 { background: #637f5f; color: #ffffff; }
&--today { border-bottom-color: #516d50; }
&--selected { box-shadow: 0 0 0 3rpx #fff, 0 0 0 5rpx #637f5f; }
}
&__grid--loading { opacity: 0.5; }
&__footer { margin-top: 22rpx; gap: 12rpx; flex-wrap: wrap; }
&__detail { font-size: 21rpx; color: #627c77; }
&__legend { gap: 5rpx; font-size: 18rpx; color: #788b88; }
&__detail { font-size: 21rpx; color: #788477; }
&__legend { gap: 5rpx; font-size: 18rpx; color: #8b817b; }
&__swatch { width: 13rpx; height: 13rpx; border-radius: 3rpx; }
&__state { min-height: 200rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20rpx; font-size: 24rpx; color: #788b88; }
&__retry { margin: 0; padding: 0 24rpx; line-height: 56rpx; font-size: 24rpx; color: #355b60; background: #e9eeeb; border-radius: 28rpx; &::after { border: none; } }
&__state { min-height: 200rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20rpx; font-size: 24rpx; color: #8b817b; }
&__retry { margin: 0; padding: 0 24rpx; line-height: 56rpx; font-size: 24rpx; color: #516d50; background: #edf0e9; border-radius: 28rpx; &::after { border: none; } }
}
</style>

View File

@@ -1,32 +1,29 @@
<template>
<view class="profile-menu">
<template v-for="item in menuItems" :key="item.key">
<!-- Separator -->
<view v-if="item.type === 'separator'" class="profile-menu__separator" />
<!-- Menu item -->
<view
v-else
class="profile-menu__item"
:class="{ 'profile-menu__item--admin': item.isAdmin }"
hover-class="profile-menu__item--hover"
hover-stay-time="150"
@tap="handleTap(item)"
>
<view
class="profile-menu__icon-wrap"
:class="[
`profile-menu__icon-wrap--${item.key}`,
{ 'profile-menu__icon-wrap--admin': item.isAdmin },
]"
/>
<text class="profile-menu__title" :class="{ 'profile-menu__title--admin': item.isAdmin }">
{{ item.title }}
</text>
<text v-if="item.badge" class="profile-menu__badge">{{ item.badge }}</text>
<text class="profile-menu__arrow"></text>
<view class="profile-menu__shortcuts">
<view v-for="item in primaryItems" :key="item.key" class="profile-menu__shortcut"
hover-class="profile-menu__item--hover" @tap="handleTap(item)">
<view class="profile-menu__shortcut-head">
<view class="profile-menu__icon" :class="'profile-menu__icon--' + item.key" />
<text class="profile-menu__arrow"></text>
</view>
<text class="profile-menu__shortcut-title">{{ item.title }}</text>
<text class="profile-menu__shortcut-note">{{ !requireAuth ? '登录后查看' : item.key === 'membership' ? `${activeMembershipCount || 0} 张可用` : `${upcomingBookingCount || 0} 节待上` }}</text>
</view>
</template>
</view>
<slot />
<view class="profile-menu__links">
<template v-for="item in secondaryItems" :key="item.key">
<view v-if="item.type === 'separator'" class="profile-menu__separator" />
<view v-else class="profile-menu__item" :class="{ 'profile-menu__item--admin': item.isAdmin }"
hover-class="profile-menu__item--hover" hover-stay-time="150" @tap="handleTap(item)">
<text class="profile-menu__title">{{ item.title }}</text>
<text class="profile-menu__arrow"></text>
</view>
</template>
</view>
</view>
</template>
@@ -135,6 +132,9 @@ const menuItems = computed<MenuItem[]>(() => {
return items
})
const primaryItems = computed(() => menuItems.value.filter(item => item.key === 'membership' || item.key === 'bookings'))
const secondaryItems = computed(() => menuItems.value.filter(item => item.key !== 'membership' && item.key !== 'bookings'))
function handleTap(item: MenuItem) {
if (item.requireAuth && !props.requireAuth) {
emit('require-login')
@@ -150,269 +150,20 @@ function handleTap(item: MenuItem) {
<style lang="scss" scoped>
.profile-menu {
background: $bg-card;
border-radius: $radius-lg;
margin: $spacing-lg $spacing-lg 0;
overflow: hidden;
&__separator {
height: $spacing-sm;
background: $bg-page;
}
&__item {
display: flex;
align-items: center;
padding: $spacing-md $spacing-lg;
border-bottom: 1rpx solid $border-color;
background: $bg-card;
transition: background 0.15s;
&:last-child {
border-bottom: none;
}
&--hover {
background: #f5f5f5;
}
&--admin {
background: rgba($accent-color, 0.04);
}
}
&__icon-wrap {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: $spacing-md;
position: relative;
background: rgba($brand-color, 0.06);
// ─── Pure CSS Icons ────────────────────────────────
// 会员卡 — 圆角矩形卡片 + 横线
&--membership {
background: rgba($accent-color, 0.10);
&::before {
content: '';
width: 26rpx;
height: 18rpx;
border: 2.5rpx solid $accent-color;
border-radius: 4rpx;
box-sizing: border-box;
}
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, 1rpx);
width: 16rpx;
height: 0;
border-top: 2.5rpx solid $accent-color;
}
}
// 预约 — 日历(矩形 + 顶部两个小竖线)
&--bookings {
background: rgba($brand-color, 0.06);
&::before {
content: '';
width: 24rpx;
height: 22rpx;
border: 2.5rpx solid $brand-color;
border-radius: 4rpx;
border-top-width: 5rpx;
box-sizing: border-box;
}
&::after {
content: '';
position: absolute;
top: 14rpx;
left: 50%;
transform: translateX(-50%);
width: 10rpx;
height: 0;
border-top: 2.5rpx solid $brand-color;
// 用 box-shadow 模拟两个竖线
box-shadow:
-4rpx -7rpx 0 0 $brand-color,
4rpx -7rpx 0 0 $brand-color;
}
}
&--teaching-schedule {
background: rgba(93, 140, 138, 0.12);
&::before {
content: '';
width: 24rpx;
height: 22rpx;
border: 2.5rpx solid #476d72;
border-radius: 6rpx;
box-sizing: border-box;
}
&::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 12rpx;
height: 12rpx;
transform: translate(-30%, -18%) rotate(45deg);
border-top: 2.5rpx solid #476d72;
border-left: 2.5rpx solid #476d72;
}
}
&--invite {
background: rgba(255, 122, 69, 0.12);
&::before {
content: '';
width: 20rpx;
height: 20rpx;
border: 2.5rpx solid #ff7a45;
border-radius: 50%;
position: absolute;
top: 12rpx;
left: 14rpx;
box-sizing: border-box;
box-shadow: 16rpx 8rpx 0 -2rpx rgba(255, 122, 69, 0.95);
}
&::after {
content: '';
position: absolute;
width: 22rpx;
height: 12rpx;
border: 2.5rpx solid #ff7a45;
border-top: none;
border-radius: 0 0 14rpx 14rpx;
left: 17rpx;
bottom: 13rpx;
box-sizing: border-box;
}
}
// 个人信息 — 人形(圆 + 肩弧)
&--info {
background: rgba($brand-color, 0.06);
&::before {
content: '';
width: 12rpx;
height: 12rpx;
border: 2.5rpx solid $brand-color;
border-radius: 50%;
position: absolute;
top: 16rpx;
left: 50%;
transform: translateX(-50%);
box-sizing: border-box;
}
&::after {
content: '';
width: 22rpx;
height: 10rpx;
border: 2.5rpx solid $brand-color;
border-bottom: none;
border-radius: 12rpx 12rpx 0 0;
position: absolute;
bottom: 13rpx;
left: 50%;
transform: translateX(-50%);
box-sizing: border-box;
}
}
// 清除缓存 — 旋转的刷新箭头(圆弧)
&--clear {
background: rgba($text-hint, 0.08);
&::before {
content: '';
width: 20rpx;
height: 20rpx;
border: 2.5rpx solid $text-secondary;
border-radius: 50%;
border-right-color: transparent;
box-sizing: border-box;
}
&::after {
content: '';
position: absolute;
top: 14rpx;
right: 15rpx;
width: 0;
height: 0;
border-left: 5rpx solid $text-secondary;
border-top: 4rpx solid transparent;
border-bottom: 4rpx solid transparent;
}
}
// 管理中心 — 齿轮(圆 + 四个刻度)
&--admin {
background: rgba($accent-color, 0.12);
&::before {
content: '';
width: 14rpx;
height: 14rpx;
border: 2.5rpx solid $accent-color;
border-radius: 50%;
box-sizing: border-box;
}
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 24rpx;
height: 24rpx;
transform: translate(-50%, -50%);
// 四条刻度线用 box-shadow 实现
background:
linear-gradient($accent-color, $accent-color) center top / 2.5rpx 5rpx no-repeat,
linear-gradient($accent-color, $accent-color) center bottom / 2.5rpx 5rpx no-repeat,
linear-gradient($accent-color, $accent-color) left center / 5rpx 2.5rpx no-repeat,
linear-gradient($accent-color, $accent-color) right center / 5rpx 2.5rpx no-repeat;
}
}
}
&__title {
flex: 1;
font-size: 30rpx;
color: $text-primary;
&--admin {
color: $accent-color;
font-weight: 500;
}
}
&__badge {
font-size: 22rpx;
line-height: 1;
font-weight: 600;
color: #8f6759;
background: linear-gradient(135deg, rgba(255, 248, 244, 0.98), rgba(241, 228, 220, 0.96));
border-radius: 999rpx;
padding: 9rpx 18rpx;
margin-right: $spacing-sm;
border: 1rpx solid rgba(192, 154, 137, 0.16);
box-shadow:
inset 0 1rpx 0 rgba(255, 255, 255, 0.92),
0 6rpx 16rpx rgba(143, 103, 89, 0.12);
}
&__arrow {
font-size: 36rpx;
color: $text-hint;
line-height: 1;
transform: scaleX(0.6);
transform-origin: center;
}
&__shortcuts { display: flex; gap: 20rpx; margin: 24rpx 32rpx 0; }
&__shortcut { flex: 1; min-width: 0; padding: 24rpx; box-sizing: border-box; border-radius: 26rpx; background: #fff; border: 1rpx solid #eee8e3; }
&__shortcut-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16rpx; }
&__shortcut-title { display: block; font-size: 27rpx; font-weight: 500; color: #514943; }
&__shortcut-note { display: block; font-size: 22rpx; color: #8b817b; margin-top: 8rpx; }
&__icon { width: 36rpx; height: 30rpx; position: relative; box-sizing: border-box; border: 2rpx solid #9b8878; border-radius: 6rpx; }
&__icon--membership::after { content: ''; position: absolute; top: 8rpx; left: 0; right: 0; border-top: 2rpx solid #9b8878; }
&__icon--bookings { border-color: #829a8b; border-top-width: 7rpx; &::after { content: ''; position: absolute; left: 8rpx; top: 8rpx; width: 12rpx; border-top: 2rpx solid #829a8b; } }
&__links { margin: 24rpx 32rpx 0; border-radius: 26rpx; background: #fff; overflow: hidden; }
&__item { display: flex; align-items: center; gap: 20rpx; min-height: 96rpx; padding: 0 28rpx; box-sizing: border-box; border-bottom: 1rpx solid #f2ede8; &:last-child { border-bottom: none; } }
&__item--hover { background: #f4f1ec; }
&__item--admin { background: #eff3ed; .profile-menu__title { color: #617d73; } }
&__title { flex: 1; font-size: 27rpx; font-weight: 400; color: #6f655e; }
&__arrow { font-size: 30rpx; color: #b2a79e; line-height: 1; }
&__separator { height: 12rpx; background: #fbf9f6; }
}
</style>

View File

@@ -2,7 +2,6 @@
<view class="quick-entry">
<!-- Not logged in -->
<view v-if="!userStore.loggedIn" class="entry-pill pill-login" @tap="handleLogin">
<view class="pill-dot dot-login" />
<text class="pill-label">欢迎来到工作室</text>
<view class="pill-action action-login">
<text class="pill-action-text">微信登录</text>
@@ -15,7 +14,7 @@
class="entry-pill pill-trial"
@tap="handleTrialEntry"
>
<view class="pill-tag tag-trial">体验</view>
<text class="pill-label">首次体验专属课程</text>
<view class="pill-action action-trial">
<text class="pill-action-text">预约体验课</text>
@@ -25,10 +24,9 @@
<!-- Has valid active card -->
<template v-else-if="userStore.hasValidMembership">
<view class="entry-pill pill-active" @tap="handleBooking">
<view class="pill-dot dot-active" />
<text class="pill-label pill-label-active">{{ activeMembershipLabel }}</text>
<text class="pill-label pill-label-active">{{ activeMembershipLabel }}</text>
<view class="pill-action action-book">
<text class="pill-action-text">约课</text>
<text class="pill-action-text">约课</text>
</view>
</view>
@@ -45,8 +43,7 @@
class="entry-pill pill-expired"
@tap="handleRenew"
>
<view class="pill-dot dot-expired" />
<text class="pill-label">会员卡已到期</text>
<text class="pill-label">暂无可用会员卡</text>
<view class="pill-action action-renew">
<text class="pill-action-text">续卡</text>
</view>
@@ -130,160 +127,15 @@ const renewStripText = computed(() => {
</script>
<style lang="scss" scoped>
.quick-entry {
padding: 20rpx 24rpx 0;
}
/* ── Pill base ── */
.entry-pill {
display: flex;
align-items: center;
height: 80rpx;
border-radius: 40rpx;
padding: 0 8rpx 0 24rpx;
gap: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
/* ── Pill variants ── */
.pill-login {
background: #1a1a2e;
}
.pill-trial {
background: linear-gradient(135deg, #2d2d5e 0%, #4a3f7a 100%);
}
.pill-active {
background: #ffffff;
border: 1rpx solid rgba(0, 0, 0, 0.06);
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.05);
}
.pill-expired {
background: #f5f5f5;
border: 1rpx solid rgba(0, 0, 0, 0.04);
box-shadow: none;
}
/* ── Status dot ── */
.pill-dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
flex-shrink: 0;
}
.dot-login {
background: $primary-color;
box-shadow: 0 0 8rpx rgba($primary-color, 0.6);
}
.dot-active {
background: #34c759;
box-shadow: 0 0 8rpx rgba(52, 199, 89, 0.5);
}
.dot-expired {
background: #aaa;
}
/* ── Tag (trial only) ── */
.pill-tag {
font-size: 20rpx;
font-weight: 700;
padding: 4rpx 14rpx;
border-radius: 20rpx;
flex-shrink: 0;
letter-spacing: 1rpx;
}
.tag-trial {
background: rgba(255, 215, 0, 0.25);
color: #ffd700;
}
/* ── Label text ── */
.pill-label {
flex: 1;
font-size: 26rpx;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1;
}
.pill-label-active {
color: #333;
}
.pill-expired .pill-label {
color: #888;
}
/* ── Action button ── */
.pill-action {
flex-shrink: 0;
height: 60rpx;
padding: 0 28rpx;
border-radius: 30rpx;
display: flex;
align-items: center;
justify-content: center;
}
.pill-action-text {
font-size: 24rpx;
font-weight: 600;
white-space: nowrap;
line-height: 1;
}
.action-login {
background: $primary-color;
.pill-action-text { color: #1a1a2e; }
}
.action-trial {
background: rgba(255, 215, 0, 0.2);
.pill-action-text { color: #ffd700; }
}
.action-book {
background: #1a1a2e;
.pill-action-text { color: #fff; }
}
.action-renew {
background: #e8efe9;
.pill-action-text { color: #5f7a6e; }
}
/* ── Renew strip (running low) ── */
.renew-strip {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
margin-top: 12rpx;
padding: 14rpx 24rpx;
background: linear-gradient(135deg, #8fa89a, #a8c0b4);
border-radius: 24rpx;
}
.renew-strip-text {
font-size: 22rpx;
font-weight: 500;
color: #fffcfa;
letter-spacing: 0.5rpx;
}
.renew-strip-arrow {
font-size: 28rpx;
font-weight: 700;
color: rgba(255, 252, 250, 0.82);
line-height: 1;
}
.quick-entry { margin: 24rpx 32rpx 0; }
.entry-pill { display: flex; align-items: center; min-height: 120rpx; box-sizing: border-box; padding: 24rpx; gap: 20rpx; border-radius: 28rpx; background: #f1e8e1; }
.pill-active { background: #edf2ec; }
.pill-expired { background: #f0ece7; }
.pill-label { flex: 1; min-width: 0; font-size: 26rpx; line-height: 1.6; color: #6f5c50; overflow-wrap: anywhere; }
.pill-label-active { color: #526b5d; }
.pill-action { flex-shrink: 0; min-height: 68rpx; padding: 0 24rpx; border-radius: 999rpx; display: flex; align-items: center; justify-content: center; background: #6b8276; }
.pill-action-text { font-size: 24rpx; font-weight: 400; color: #fff; white-space: nowrap; }
.renew-strip { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; padding: 18rpx 12rpx 0; }
.renew-strip-text { font-size: 22rpx; color: #8b817b; line-height: 1.5; }
.renew-strip-arrow { font-size: 28rpx; color: #8b817b; }
</style>

View File

@@ -1,94 +1,29 @@
<template>
<view class="slot-card-wrapper" :class="[`status-${statusClass}`]" @tap="emit('cardTap', timeSlot)">
<!-- Ticket background image -->
<image
class="ticket-bg"
src="/static/courseBg.png"
mode="aspectFill"
/>
<!-- Card content overlay -->
<view class="ticket-content">
<!-- Top section: Time row (like flight ticket) -->
<view class="ticket-top">
<!-- Left: Start time -->
<view class="time-block">
<text class="time-main">{{ startTimeDisplay }}</text>
<text class="time-label">{{ timeSlot.date }}</text>
</view>
<!-- Center: Duration + icon -->
<view class="duration-block">
<view class="duration-line">
<view class="line-dot" />
<view class="line-dash" />
<view class="duration-icon">
<text class="icon-text"></text>
</view>
<view class="line-dash" />
<view class="line-dot" />
</view>
<text class="duration-text">{{ durationMin }}分钟</text>
</view>
<!-- Right: End time -->
<view class="time-block time-block--right">
<text class="time-main">{{ endTimeDisplay }}</text>
<view class="capacity-tag" :class="capacityClass">
<text class="capacity-text">{{ capacityLabel }}</text>
</view>
</view>
<view class="slot-card" :class="`slot-card--${statusClass}`" @tap="emit('cardTap', timeSlot)">
<view class="slot-heading">
<view class="slot-time">
<text class="time-start">{{ startTimeDisplay }}</text>
<text class="time-end"> {{ endTimeDisplay }}</text>
</view>
<!-- Dashed tear-off line -->
<view class="tear-line" />
<!-- Bottom section: Course name + Action -->
<view class="ticket-bottom">
<view class="course-info">
<text class="course-name">普拉提私教</text>
</view>
<!-- Action area -->
<view class="action-area">
<!-- Expired -->
<template v-if="isPast && !timeSlot.isBookedByMe">
<view class="action-badge badge-expired">
<text>已过期</text>
</view>
</template>
<!-- OPEN + not booked -->
<template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && !timeSlot.isBookedByMe">
<view class="action-btn btn-book" @tap.stop="emit('book', timeSlot)">
<text>预约</text>
</view>
</template>
<!-- OPEN + booked by me -->
<template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && timeSlot.isBookedByMe">
<view class="action-badge badge-booked">
<text>{{ myBookingLabel }}</text>
</view>
<view class="cancel-link" @tap.stop="emit('cancel', timeSlot)">
<text>取消</text>
</view>
</template>
<!-- FULL -->
<template v-else-if="timeSlot.status === TimeSlotStatus.FULL">
<view class="action-badge badge-full">
<text>已约满</text>
</view>
</template>
<!-- CLOSED -->
<template v-else>
<view class="action-badge badge-closed">
<text>已关闭</text>
</view>
</template>
</view>
<text class="slot-duration">{{ durationMin }} 分钟</text>
</view>
<view class="slot-body">
<view class="course-info">
<text class="course-name">普拉提私教</text>
<text class="slot-status">{{ capacityLabel }}</text>
</view>
<view class="action-area">
<template v-if="timeSlot.isBookedByMe">
<text class="booked-label">已预约</text>
<view v-if="timeSlot.status === TimeSlotStatus.OPEN" class="cancel-link" @tap.stop="emit('cancel', timeSlot)">
<text>取消预约</text>
</view>
</template>
<button v-else-if="!isPast && timeSlot.status === TimeSlotStatus.OPEN && timeSlot.bookedCount < timeSlot.capacity"
class="book-button" hover-class="book-button--pressed" @tap.stop="emit('book', timeSlot)">
预约
</button>
<text v-else class="unavailable-label">{{ isPast ? '已结束' : timeSlot.status === TimeSlotStatus.CLOSED ? '未开放' : '已约满' }}</text>
</view>
</view>
</view>
@@ -122,24 +57,17 @@ const durationMin = computed(() => {
const myBookingLabel = computed(() => (
props.timeSlot.myBookingStatus === BookingStatus.PENDING_CONFIRMATION
? '已预约待确认'
? '待老师确认'
: '已预约'
))
const capacityLabel = computed(() => {
const { bookedCount, capacity, status } = props.timeSlot
if (status === TimeSlotStatus.CLOSED) return '已关闭'
if (status === TimeSlotStatus.FULL) return '已约满'
const remaining = capacity - bookedCount
return `剩余${remaining}`
})
const capacityClass = computed(() => {
const { bookedCount, capacity, status } = props.timeSlot
if (status === TimeSlotStatus.CLOSED) return 'cap-closed'
if (status === TimeSlotStatus.FULL) return 'cap-full'
if (bookedCount >= capacity * 0.8) return 'cap-almost'
return 'cap-open'
const { bookedCount, capacity, status, isBookedByMe } = props.timeSlot
if (isBookedByMe) return myBookingLabel.value
if (isPast.value) return '已结束'
if (status === TimeSlotStatus.CLOSED) return '暂未开放'
if (status === TimeSlotStatus.FULL || bookedCount >= capacity) return '名额已满'
return `还可约 ${Math.max(0, capacity - bookedCount)}`
})
const statusClass = computed(() => {
@@ -154,276 +82,39 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
</script>
<style lang="scss" scoped>
/* ─── Wrapper ─── */
.slot-card-wrapper {
position: relative;
margin: 0 24rpx 20rpx;
min-height: 220rpx;
transition: all 0.2s ease;
filter: drop-shadow(0 16rpx 28rpx rgba(120, 91, 79, 0.08));
&:active {
transform: scale(0.98);
}
/* Status-based opacity */
&.status-expired {
opacity: 0.55;
}
&.status-full,
&.status-closed {
opacity: 0.75;
}
.slot-card {
margin: 0 32rpx 20rpx;
padding: 28rpx;
border: 1rpx solid #eee8e3;
border-radius: 28rpx;
background: #fff;
color: #514943;
}
/* ─── Ticket background image ─── */
.ticket-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
.slot-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 16rpx; }
.slot-time { display: flex; align-items: baseline; gap: 12rpx; font-variant-numeric: tabular-nums; }
.time-start { font-size: 40rpx; font-weight: 500; line-height: 1.2; }
.time-end { font-size: 28rpx; color: #8b817b; }
.slot-duration { flex-shrink: 0; font-size: 22rpx; color: #8b817b; }
.slot-body { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; margin-top: 24rpx; }
.course-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
.course-name { font-size: 26rpx; font-weight: 400; }
.slot-status { font-size: 22rpx; color: #617d73; }
.action-area { display: flex; flex-direction: column; align-items: flex-end; gap: 2rpx; flex-shrink: 0; }
.book-button {
display: flex; align-items: center; justify-content: center;
min-width: 136rpx; height: 72rpx; margin: 0; padding: 0 30rpx;
border: none; border-radius: 999rpx; background: #6b8276;
font-size: 26rpx; font-weight: 400; line-height: 1; color: #fff;
&::after { border: none; }
&--pressed { background: #597264; }
}
/* ─── Content overlay ─── */
.ticket-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
padding: 28rpx 40rpx 24rpx;
}
/* ═══ Top section: Time row ═══ */
.ticket-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.time-block {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 100rpx;
&--right {
align-items: flex-end;
}
}
.time-main {
font-size: 40rpx;
font-weight: 800;
color: #3a2e2a;
line-height: 1;
letter-spacing: 1rpx;
}
.time-label {
margin-top: 8rpx;
font-size: 22rpx;
color: #a18a82;
font-weight: 500;
}
/* Duration center block */
.duration-block {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
padding: 0 16rpx;
}
.duration-line {
display: flex;
align-items: center;
width: 100%;
margin-top: 8rpx;
}
.line-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: #ccb7ae;
flex-shrink: 0;
}
.line-dash {
flex: 1;
height: 2rpx;
background: repeating-linear-gradient(
to right,
#dccbc2 0,
#dccbc2 8rpx,
transparent 8rpx,
transparent 16rpx
);
}
.duration-icon {
flex-shrink: 0;
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: rgba(185, 143, 125, 0.12);
display: flex;
align-items: center;
justify-content: center;
margin: 0 8rpx;
}
.icon-text {
font-size: 22rpx;
}
.duration-text {
margin-top: 6rpx;
font-size: 22rpx;
color: #a18a82;
font-weight: 500;
}
/* Capacity tag */
.capacity-tag {
margin-top: 8rpx;
padding: 4rpx 12rpx;
border-radius: 6rpx;
font-size: 20rpx;
&.cap-open {
background: rgba(101, 163, 126, 0.12);
.capacity-text {
color: #5d9472;
}
}
&.cap-almost {
background: rgba(214, 161, 92, 0.14);
.capacity-text {
color: #b98543;
}
}
&.cap-full {
background: rgba(216, 91, 87, 0.12);
.capacity-text {
color: #c96763;
}
}
&.cap-closed {
background: rgba(111, 96, 91, 0.08);
.capacity-text {
color: #9d8f89;
}
}
}
.capacity-text {
font-size: 20rpx;
font-weight: 600;
}
/* ═══ Tear-off dashed line ═══ */
.tear-line {
margin: 20rpx -40rpx 16rpx;
height: 2rpx;
background: repeating-linear-gradient(
to right,
#e0dcd6 0,
#e0dcd6 10rpx,
transparent 10rpx,
transparent 20rpx
);
opacity: 0.6;
}
/* ═══ Bottom section ═══ */
.ticket-bottom {
display: flex;
align-items: center;
justify-content: space-between;
}
.course-info {
flex: 1;
}
.course-name {
font-size: 30rpx;
font-weight: 700;
color: #3a2e2a;
letter-spacing: 1rpx;
}
/* ─── Action area ─── */
.action-area {
display: flex;
align-items: center;
gap: 12rpx;
flex-shrink: 0;
}
.action-btn,
.action-badge {
padding: 10rpx 24rpx;
border-radius: 20rpx;
font-size: 26rpx;
font-weight: 600;
white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
}
.btn-book {
background: linear-gradient(135deg, #d5b9ab 0%, #b98f7d 100%);
color: #fffaf7;
box-shadow: 0 8rpx 18rpx rgba(143, 103, 89, 0.24);
min-width: 120rpx;
height: 60rpx;
transition: all 0.15s;
&:active {
opacity: 0.85;
transform: scale(0.96);
}
}
.badge-booked {
background: linear-gradient(135deg, rgba(247, 240, 235, 0.96), rgba(236, 225, 217, 0.98));
color: #8f6759;
}
.badge-expired {
background: rgba(111, 96, 91, 0.08);
color: #9d8f89;
}
.badge-full {
background: rgba(216, 91, 87, 0.12);
color: #c96763;
}
.badge-closed {
background: rgba(111, 96, 91, 0.08);
color: #b5a8a1;
}
.cancel-link {
font-size: 22rpx;
color: #c96763;
font-weight: 500;
text-decoration: underline;
text-decoration-color: rgba(201, 103, 99, 0.28);
.booked-label { font-size: 24rpx; color: #617d73; }
.cancel-link { padding: 12rpx 0 8rpx 20rpx; font-size: 21rpx; color: #8b817b; }
.unavailable-label { padding: 14rpx 24rpx; border-radius: 999rpx; background: #f3f0ed; color: #8b817b; font-size: 23rpx; }
.slot-card--booked { background: #f2f5f0; border-color: #e2e9df; }
.slot-card--expired, .slot-card--closed, .slot-card--full {
background: #f7f5f2;
.time-start, .course-name { color: #8b817b; }
.slot-status { color: #948a82; }
}
</style>

View File

@@ -1,9 +1,9 @@
<template>
<view class="studio-info">
<text class="section-title">来工作室坐坐</text>
<!-- Address + Chat row -->
<view class="location-row">
<view class="location-left" @tap="handleAddressTap">
<view class="location-icon" />
<view class="location-content">
<text class="location-label">场馆地址</text>
<text class="location-text">
@@ -12,7 +12,7 @@
</view>
</view>
<button class="chat-btn" open-type="contact">
<view class="chat-icon" />
<text>联系老师</text>
</button>
</view>
@@ -27,7 +27,6 @@
@tap="previewPhoto(idx)"
>
<image class="gallery-image" :src="photo" mode="aspectFill" />
<view class="gallery-overlay" />
</view>
</view>
</scroll-view>
@@ -88,179 +87,15 @@ function copyAddress() {
</script>
<style lang="scss" scoped>
.studio-info {
margin: 16rpx 24rpx 0;
background: #ffffff;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
/* ── Location row ── */
.location-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 32rpx 24rpx;
gap: 24rpx;
}
.location-left {
display: flex;
align-items: center;
gap: 12rpx;
flex: 1;
min-width: 0;
}
.location-content {
flex: 1;
min-width: 0;
}
.location-label {
display: block;
font-size: 22rpx;
color: #b39a92;
letter-spacing: 2rpx;
margin-bottom: 6rpx;
}
.location-text {
font-size: 26rpx;
color: #5f5955;
line-height: 1.6;
word-break: break-word;
}
/* ── Gallery ── */
.gallery-block {
padding: 6rpx 0 28rpx;
}
.gallery-scroll {
width: 100%;
}
.gallery-track {
display: flex;
gap: 14rpx;
padding: 0 32rpx;
width: max-content;
}
.gallery-item {
position: relative;
width: 192rpx;
height: 108rpx;
border-radius: 14rpx;
overflow: hidden;
flex-shrink: 0;
background: linear-gradient(135deg, #eadfd8 0%, #d5c0b4 100%);
box-shadow: 0 8rpx 18rpx rgba(124, 95, 82, 0.1);
}
.gallery-image {
width: 100%;
height: 100%;
}
.gallery-overlay {
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(38, 28, 24, 0.2) 100%),
linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, transparent 48%);
}
/* ── Icons ── */
.location-icon {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba($brand-color, 0.06);
position: relative;
flex-shrink: 0;
// 定位图标 — 圆头 + 尖尾
&::before {
content: '';
position: absolute;
top: 14rpx;
left: 50%;
transform: translateX(-50%);
width: 18rpx;
height: 18rpx;
border: 2.5rpx solid $brand-color;
border-radius: 50% 50% 50% 0;
transform: translateX(-50%) rotate(-45deg);
box-sizing: border-box;
}
// 中心白点
&::after {
content: '';
position: absolute;
top: 21rpx;
left: 50%;
transform: translateX(-50%);
width: 6rpx;
height: 6rpx;
background: $brand-color;
border-radius: 50%;
box-sizing: border-box;
}
}
.chat-btn {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: rgba($brand-color, 0.06);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
margin: 0;
border: none;
}
.chat-btn::after {
border: none;
}
.chat-icon {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba($brand-color, 0.06);
position: relative;
// 消息气泡
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 26rpx;
height: 20rpx;
border: 2.5rpx solid $brand-color;
border-radius: 6rpx;
box-sizing: border-box;
}
// 气泡尾巴
&::after {
content: '';
position: absolute;
bottom: 12rpx;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 5rpx solid transparent;
border-right: 5rpx solid transparent;
border-top: 7rpx solid $brand-color;
}
}
.studio-info { margin: 36rpx 32rpx 0; }
.section-title { display: block; margin-bottom: 20rpx; font-size: 30rpx; font-weight: 500; color: #514943; }
.location-row { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; margin-bottom: 20rpx; }
.location-left, .location-content { flex: 1; min-width: 0; }
.location-label { display: block; font-size: 21rpx; color: #8b817b; margin-bottom: 6rpx; }
.location-text { font-size: 24rpx; color: #6f655e; line-height: 1.7; overflow-wrap: anywhere; }
.chat-btn { flex-shrink: 0; margin: 0; padding: 0 20rpx; line-height: 64rpx; font-size: 23rpx; border: none; border-radius: 999rpx; background: #f0eae4; color: #78675c; &::after { border: none; } }
.gallery-scroll { width: 100%; }
.gallery-track { display: inline-flex; gap: 16rpx; }
.gallery-item { width: 264rpx; height: 180rpx; border-radius: 20rpx; overflow: hidden; flex-shrink: 0; background: #e9e1d8; }
.gallery-image { width: 100%; height: 100%; }
</style>

View File

@@ -25,7 +25,7 @@ interface Tab {
interface Props {
modelValue: PeriodKey
variant?: 'default' | 'booking'
variant?: 'default' | 'booking' | 'soft'
}
const props = defineProps<Props>()
@@ -117,4 +117,18 @@ function handleChange(key: PeriodKey) {
}
}
}
.time-period-filter.time-period-filter--soft {
margin: 0 32rpx 24rpx; padding: 6rpx;
border: none; border-radius: 999rpx; background: #f0ece7;
.tab-item {
padding: 14rpx 0; border-radius: 999rpx;
.tab-label { font-size: 24rpx; color: #8b817b; }
&.active {
background: #fff;
.tab-label { color: #617d73; font-weight: 500; }
&::after { display: none; }
}
}
}
</style>

View File

@@ -25,7 +25,6 @@
<view class="details-col">
<text class="card-name">{{ booking.membership.cardType.name }}</text>
<view class="time-row">
<text class="time-icon">🕐</text>
<text class="time-text">
{{ formatTime(booking.timeSlot.startTime) }} {{ formatTime(booking.timeSlot.endTime) }}
</text>
@@ -108,132 +107,25 @@ function goToBookingDetail(id: string) {
</script>
<style lang="scss" scoped>
.upcoming-section {
margin: 24rpx 24rpx 0;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.section-title {
font-size: 32rpx;
font-weight: 700;
color: #1a1a2e;
}
.section-more {
font-size: 26rpx;
color: $primary-dark;
}
.booking-card {
background: #ffffff;
border-radius: 16rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08);
padding: 28rpx 28rpx;
display: flex;
align-items: center;
gap: 24rpx;
margin-bottom: 16rpx;
}
.date-col {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
width: 88rpx;
}
.date-day {
font-size: 52rpx;
font-weight: 800;
color: #1a1a2e;
line-height: 1;
}
.date-month {
font-size: 22rpx;
color: $primary-dark;
margin-top: 4rpx;
}
.date-weekday {
font-size: 22rpx;
color: #999;
margin-top: 4rpx;
}
.booking-divider {
width: 2rpx;
height: 80rpx;
background: #f0f0f0;
flex-shrink: 0;
}
.details-col {
flex: 1;
}
.card-name {
display: block;
font-size: 30rpx;
font-weight: 600;
color: #1a1a2e;
margin-bottom: 10rpx;
}
.time-row {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 10rpx;
}
.time-icon {
font-size: 24rpx;
}
.time-text {
font-size: 26rpx;
color: #555;
}
.status-row {
display: flex;
align-items: center;
gap: 8rpx;
}
.status-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
}
.dot--pending { background: #f39c12; }
.dot--confirmed { background: #27ae60; }
.dot--completed { background: #3498db; }
.dot--cancelled { background: #e74c3c; }
.dot--default { background: #999; }
.status-text {
font-size: 24rpx;
color: #999;
}
.text--pending { color: #f39c12; }
.text--confirmed { color: #27ae60; }
.text--completed { color: #3498db; }
.text--cancelled { color: #e74c3c; }
.booking-arrow {
font-size: 36rpx;
color: #ccc;
flex-shrink: 0;
}
.upcoming-section { margin: 36rpx 32rpx 0; }
.section-header { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; margin-bottom: 20rpx; }
.section-title { font-size: 30rpx; font-weight: 500; color: #514943; }
.section-more { font-size: 23rpx; color: #8b817b; padding: 8rpx 0; }
.booking-card { display: flex; align-items: center; gap: 24rpx; margin-bottom: 16rpx; padding: 26rpx; border-radius: 28rpx; background: #fff; border: 1rpx solid #eee8e3; }
.date-col { display: flex; flex-direction: column; align-items: center; gap: 4rpx; width: 72rpx; flex-shrink: 0; }
.date-day { font-size: 44rpx; font-weight: 400; line-height: 1.1; color: #617d73; }
.date-month, .date-weekday { font-size: 20rpx; color: #8b817b; }
.booking-divider { width: 1rpx; height: 80rpx; background: #eee8e3; flex-shrink: 0; }
.details-col { flex: 1; min-width: 0; }
.card-name { display: block; font-size: 26rpx; color: #514943; font-weight: 400; line-height: 1.5; margin-bottom: 8rpx; overflow-wrap: anywhere; }
.time-row { margin-bottom: 8rpx; }
.time-text { font-size: 24rpx; color: #6f655e; font-variant-numeric: tabular-nums; }
.status-row { display: flex; align-items: center; gap: 8rpx; }
.status-dot { width: 8rpx; height: 8rpx; border-radius: 50%; background: #a5b8ab; }
.status-text { font-size: 21rpx; color: #617d73; }
.dot--pending { background: #b8a18b; }
.text--pending { color: #957c65; }
.dot--cancelled { background: #b69589; }
.text--cancelled { color: #9c7a6e; }
.booking-arrow { font-size: 32rpx; color: #b2a79e; flex-shrink: 0; }
</style>

View File

@@ -1,15 +1,14 @@
<template>
<view class="user-card">
<!-- Header: gradient background, padded to sit below nav bar -->
<view class="user-card__header" :style="{ paddingTop: (navBarHeight ?? 0) + 'px' }">
<view class="user-card__header">
<!-- Not logged in state -->
<view v-if="!loggedIn" class="user-card__guest">
<view class="user-card__avatar-wrap">
<image class="user-card__avatar-img" src="/static/default-avatar.jpg" mode="aspectFill" />
</view>
<view class="user-card__guest-info">
<text class="user-card__guest-title">Hi欢迎来到普拉提</text>
<text class="user-card__guest-sub">登录后查看个人数据</text>
<text class="user-card__guest-title">欢迎来到工作室</text>
<text class="user-card__guest-sub">登录后查看会员卡与练习记录</text>
</view>
<button class="user-card__login-btn" :loading="loading" @tap="handleLogin">
微信登录
@@ -17,7 +16,7 @@
</view>
<!-- Logged in + profile loaded -->
<view v-else-if="loggedIn && hasProfile" class="user-card__user">
<view v-else-if="loggedIn && hasProfile" class="user-card__user" @tap="emit('edit')">
<view class="user-card__avatar-wrap">
<image
class="user-card__avatar-img"
@@ -29,15 +28,11 @@
<view class="user-card__info">
<view class="user-card__name-row">
<text class="user-card__nickname">{{ user!.nickname }}</text>
<view v-if="hasMembership" class="user-card__member-badge">
<view class="user-card__member-icon">
<text class="user-card__member-letter">C</text>
</view>
<text class="user-card__member-label">CLUB</text>
</view>
<text v-if="hasMembership" class="user-card__member-label">会员</text>
</view>
<text v-if="maskedPhone" class="user-card__phone">{{ maskedPhone }}</text>
<text class="user-card__phone">{{ maskedPhone || '完善个人资料' }}</text>
</view>
<text class="user-card__edit">资料 </text>
</view>
<!-- Logged in but profile still loading -->
@@ -57,18 +52,18 @@
<!-- Stats row: shown only when profile is loaded -->
<view v-if="loggedIn && hasProfile" class="user-card__stats">
<view class="user-card__stat-item">
<text class="user-card__stat-value">{{ stats?.totalBookings ?? 0 }}</text>
<text class="user-card__stat-label">总训练()</text>
<text class="user-card__stat-value">{{ stats?.totalBookings ?? '—' }}</text>
<text class="user-card__stat-label">累计上课 · </text>
</view>
<view class="user-card__stat-divider" />
<view class="user-card__stat-item">
<text class="user-card__stat-value">{{ stats?.monthBookings ?? 0 }}</text>
<text class="user-card__stat-label">本月()</text>
<text class="user-card__stat-value">{{ stats?.monthBookings ?? '—' }}</text>
<text class="user-card__stat-label">本月上课 · </text>
</view>
<view class="user-card__stat-divider" />
<view class="user-card__stat-item">
<text class="user-card__stat-value">{{ remainingSessions }}</text>
<text class="user-card__stat-label">剩余课时()</text>
<text class="user-card__stat-label">剩余课时 · </text>
</view>
</view>
</view>
@@ -86,12 +81,11 @@ const props = defineProps<{
stats: UserStatsResponse | null
memberships?: readonly MembershipWithCardType[]
loading?: boolean
/** Height of the custom nav bar in px, so header content starts below it */
navBarHeight?: number
}>()
const emit = defineEmits<{
(e: 'login'): void
(e: 'edit'): void
}>()
const avatarFailed = ref(false)
@@ -151,221 +145,28 @@ function handleLogin() {
<style lang="scss" scoped>
.user-card {
background: linear-gradient(160deg, #E1F4FA 0%, $primary-color 50%, $primary-dark 100%);
border-radius: 0 0 40rpx 40rpx;
overflow: hidden;
&__header {
padding: $spacing-lg $spacing-lg $spacing-lg;
}
// ── Guest state ──
&__guest {
display: flex;
align-items: center;
gap: $spacing-md;
}
&__guest-info {
flex: 1;
display: flex;
flex-direction: column;
gap: $spacing-xs;
}
&__guest-title {
font-size: 32rpx;
font-weight: 600;
color: #ffffff;
}
&__guest-sub {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.65);
}
&__login-btn {
flex-shrink: 0;
background: rgba(255, 255, 255, 0.2);
color: #ffffff;
font-size: 26rpx;
font-weight: 500;
border: none;
border-radius: $radius-lg;
padding: 0 $spacing-md;
height: 64rpx;
line-height: 64rpx;
min-width: 160rpx;
&::after {
border: none;
}
}
// ── Logged-in user ──
&__user {
display: flex;
align-items: center;
gap: $spacing-md;
}
&__avatar-wrap {
position: relative;
flex-shrink: 0;
width: 120rpx;
height: 120rpx;
}
&__avatar-img {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
border: 4rpx solid rgba(255, 255, 255, 0.4);
}
&__info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
}
&__name-row {
display: flex;
align-items: center;
gap: $spacing-sm;
}
&__nickname {
font-size: 36rpx;
font-weight: 600;
color: #ffffff;
}
&__member-badge {
display: inline-flex;
align-items: center;
gap: 10rpx;
padding: 6rpx 14rpx 6rpx 8rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, rgba(255, 249, 245, 0.98), rgba(242, 229, 221, 0.96));
border: 1rpx solid rgba(192, 154, 137, 0.18);
box-shadow:
inset 0 1rpx 0 rgba(255, 255, 255, 0.9),
0 8rpx 20rpx rgba(143, 103, 89, 0.14);
}
&__member-icon {
width: 34rpx;
height: 34rpx;
border-radius: 50%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle at 30% 30%, #fffdfb 0%, #f2e2d8 40%, #c79d89 100%);
box-shadow:
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.76),
0 3rpx 8rpx rgba(143, 103, 89, 0.16);
&::after {
content: '';
position: absolute;
inset: 3rpx;
border-radius: 50%;
border: 1.5rpx solid rgba(143, 103, 89, 0.28);
}
}
&__member-letter {
position: relative;
z-index: 1;
font-size: 20rpx;
line-height: 1;
font-weight: 800;
color: #8f6759;
letter-spacing: 1rpx;
}
&__member-label {
font-size: 20rpx;
line-height: 1;
font-weight: 700;
color: #8f6759;
letter-spacing: 2rpx;
}
&__phone {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.75);
}
// ── Loading state ──
&__loading {
display: flex;
align-items: center;
gap: $spacing-md;
}
&__avatar-skeleton {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
}
&__nickname-skeleton {
width: 160rpx;
height: 36rpx;
border-radius: $radius-sm;
background: rgba(255, 255, 255, 0.3);
}
&__phone-skeleton {
width: 120rpx;
height: 26rpx;
border-radius: $radius-sm;
background: rgba(255, 255, 255, 0.2);
margin-top: 8rpx;
}
// ── Stats row ──
&__stats {
display: flex;
align-items: stretch;
background: rgba(255, 255, 255, 0.15);
margin: 0 $spacing-lg $spacing-lg;
border-radius: $radius-lg;
padding: $spacing-md 0;
backdrop-filter: blur(10rpx);
}
&__stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-xs 0;
}
&__stat-divider {
width: 1rpx;
background: rgba(255, 255, 255, 0.3);
margin: $spacing-xs 0;
}
&__stat-value {
font-size: 44rpx;
font-weight: 700;
color: #ffffff;
line-height: 1;
}
&__stat-label {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.8);
margin-top: 6rpx;
}
margin: 20rpx 32rpx 0; padding: 28rpx; background: #f2e9e3; border-radius: 32rpx;
&__user, &__loading { display: flex; align-items: center; gap: 20rpx; }
&__avatar-wrap { width: 108rpx; height: 108rpx; flex-shrink: 0; }
&__avatar-img { width: 100%; height: 100%; box-sizing: border-box; border-radius: 50%; border: 5rpx solid #fcf8f4; }
&__info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10rpx; }
&__name-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10rpx; }
&__nickname { font-size: 36rpx; font-weight: 500; line-height: 1.4; color: #514943; overflow-wrap: anywhere; }
&__member-label { flex-shrink: 0; font-size: 19rpx; color: #8b6c5b; background: #fbf5ef; padding: 4rpx 12rpx; border-radius: 999rpx; }
&__phone { font-size: 24rpx; color: #8b7b70; }
&__edit { flex-shrink: 0; font-size: 22rpx; color: #8b7b70; padding: 16rpx 0 16rpx 8rpx; }
&__stats { display: flex; align-items: stretch; margin-top: 28rpx; padding-top: 26rpx; border-top: 1rpx solid #e3d7ce; }
&__stat-item { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 10rpx; }
&__stat-value { font-size: 40rpx; font-weight: 400; color: #6f5c50; line-height: 1.15; font-variant-numeric: tabular-nums; }
&__stat-label { font-size: 20rpx; color: #8b7b70; }
&__stat-divider { width: 1rpx; margin: 6rpx 0; background: #e3d7ce; }
&__guest { display: flex; align-items: center; flex-wrap: wrap; gap: 20rpx; }
&__guest-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10rpx; }
&__guest-title { font-size: 32rpx; font-weight: 500; color: #514943; }
&__guest-sub { font-size: 23rpx; color: #8b7b70; line-height: 1.6; }
&__login-btn { width: 100%; height: 76rpx; line-height: 76rpx; margin: 4rpx 0 0; padding: 0; border: none; border-radius: 999rpx; background: #6b8276; color: #fff; font-size: 26rpx; font-weight: 400; &::after { border: none; } }
&__avatar-skeleton { width: 108rpx; height: 108rpx; border-radius: 50%; background: #e6d9ce; }
&__nickname-skeleton { width: 150rpx; height: 32rpx; border-radius: 8rpx; background: #e6d9ce; }
&__phone-skeleton { width: 180rpx; height: 22rpx; border-radius: 6rpx; background: #e6d9ce; }
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<view class="booking-page">
<view class="booking-page" :style="{ height: pageHeight }">
<!-- Status bar spacing -->
<view class="status-bar" :style="{ height: statusBarHeight }" />
@@ -10,15 +10,18 @@
<!-- Date & period filters -->
<view class="filter-header">
<DateSelector v-model="selectedDate" variant="booking" @select="onDateSelect" />
<TimePeriodFilter v-model="selectedPeriod" variant="booking" @change="onPeriodChange" />
<view class="calendar-heading">
<text class="calendar-month">{{ selectedMonthLabel }}</text>
<text class="calendar-hint">选择上课日期</text>
</view>
<DateSelector v-model="selectedDate" variant="soft" @select="onDateSelect" />
<TimePeriodFilter v-model="selectedPeriod" variant="soft" @change="onPeriodChange" />
</view>
<!-- Slot list -->
<scroll-view
class="slot-scroll"
scroll-y
:style="{ height: scrollHeight }"
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
@@ -37,12 +40,7 @@
<!-- Empty state -->
<view v-else-if="filteredSlots.length === 0" class="empty-wrap">
<view class="empty-illustration">
<view class="empty-circle outer" />
<view class="empty-circle inner" />
<view class="empty-dot" />
</view>
<text class="empty-text">当日暂无可约时段</text>
<text class="empty-text">这个时段还没有课程</text>
<text class="empty-sub">请选择其他日期或时段查看</text>
</view>
@@ -50,9 +48,8 @@
<view v-else class="slot-list">
<!-- Date summary -->
<view class="date-summary">
<text class="date-summary-text">
{{ filteredSlots.length }} 可选时段
</text>
<text class="date-summary-title">{{ selectedDayLabel }}</text>
<text class="date-summary-text">{{ filteredSlots.length }} 个时段</text>
</view>
<SlotCard
@@ -82,7 +79,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { onResize, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
import { BookingStatus, TIME_PERIODS } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking'
@@ -126,28 +123,26 @@ onShareTimeline(() => {
// ─── Layout ───────────────────────────────────────────────
const statusBarHeight = ref('20px')
const scrollHeight = ref('500px')
// Heights of static elements above scroll-view (in rpx, converted to px)
const PAGE_HEADER_RPX = 88 // title bar height
const FILTER_HEADER_RPX = 240 // DateSelector + TimePeriodFilter
const TABBAR_RPX = 100
const pageHeight = ref('100vh')
function updateLayout() {
const { statusBarHeight: statusBarPx, windowWidth } = getSystemLayout()
const ratio = windowWidth / 750
statusBarHeight.value = `${statusBarPx}px`
const headerPx = Math.round(PAGE_HEADER_RPX * ratio)
const filterPx = Math.round(FILTER_HEADER_RPX * ratio)
const tabbarPx = Math.round(TABBAR_RPX * ratio)
// scroll-view fills remaining space: window - statusBar - pageHeader - filters - tabbar
const { windowHeight } = uni.getWindowInfo()
const remaining = windowHeight - statusBarPx - headerPx - filterPx - tabbarPx
scrollHeight.value = `${remaining}px`
statusBarHeight.value = `${getSystemLayout().statusBarHeight}px`
// The mini-program window already excludes its native tab bar. Flex layout
// gives the remaining height to the list as the filters change size.
pageHeight.value = `${uni.getWindowInfo().windowHeight}px`
}
updateLayout()
onResize(updateLayout)
const selectedMonthLabel = computed(() => {
const [year, month] = selectedDate.value.split('-')
return `${year}${Number(month)}`
})
const selectedDayLabel = computed(() => {
const [, month, day] = selectedDate.value.split('-')
return `${Number(month)}${Number(day)} 日的课程`
})
// ─── Filtered slots ───────────────────────────────────────
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
@@ -310,216 +305,33 @@ onMounted(async () => {
</script>
<style lang="scss" scoped>
.booking-page {
height: 100vh;
background:
radial-gradient(circle at top, rgba(255, 232, 218, 0.36), transparent 34%),
linear-gradient(180deg, #fbf7f3 0%, #f6efea 100%);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Status bar ───────────────────────────────────── */
.status-bar {
flex-shrink: 0;
background: #fcfaf8;
}
/* ── Page header ──────────────────────────────────── */
.page-header {
flex-shrink: 0;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
background: #fcfaf8;
}
.page-title {
font-size: 34rpx;
font-weight: 700;
color: #3a2e2a;
letter-spacing: 1rpx;
}
/* ── Filter header ────────────────────────────────── */
.filter-header {
flex-shrink: 0;
background: rgba(252, 250, 248, 0.96);
box-shadow: 0 12rpx 30rpx rgba(120, 91, 79, 0.06);
}
/* ── Scroll container ──────────────────────────────── */
.slot-scroll {
flex: 1;
width: 100%;
box-sizing: border-box;
}
/* ── Slot list ─────────────────────────────────────── */
.slot-list {
display: flex;
flex-direction: column;
padding: 28rpx 0 0;
}
/* ── Date summary ──────────────────────────────────── */
.date-summary {
padding: 0 24rpx 16rpx;
}
.date-summary-text {
font-size: 24rpx;
color: #9d8b83;
font-weight: 500;
}
/* ── Loading skeleton ──────────────────────────────── */
.loading-wrap {
display: flex;
flex-direction: column;
gap: 24rpx;
padding: 28rpx 24rpx;
}
.skeleton-card {
height: 220rpx;
border-radius: 26rpx;
background: rgba(255, 255, 255, 0.88);
display: flex;
flex-direction: row;
align-items: center;
padding: 28rpx 48rpx;
gap: 20rpx;
margin: 0 24rpx;
box-shadow: 0 16rpx 36rpx rgba(120, 91, 79, 0.08);
}
.skeleton-time {
width: 90rpx;
height: 80rpx;
border-radius: 12rpx;
background: linear-gradient(90deg, rgba(236, 225, 217, 0.9) 25%, rgba(248, 240, 233, 0.98) 50%, rgba(236, 225, 217, 0.9) 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
flex-shrink: 0;
}
.skeleton-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.skeleton-title {
width: 60%;
height: 28rpx;
border-radius: 8rpx;
background: linear-gradient(90deg, rgba(236, 225, 217, 0.9) 25%, rgba(248, 240, 233, 0.98) 50%, rgba(236, 225, 217, 0.9) 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
.skeleton-sub {
width: 40%;
height: 20rpx;
border-radius: 6rpx;
background: linear-gradient(90deg, rgba(236, 225, 217, 0.9) 25%, rgba(248, 240, 233, 0.98) 50%, rgba(236, 225, 217, 0.9) 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
.skeleton-btn {
width: 100rpx;
height: 60rpx;
border-radius: 20rpx;
background: linear-gradient(90deg, rgba(236, 225, 217, 0.9) 25%, rgba(248, 240, 233, 0.98) 50%, rgba(236, 225, 217, 0.9) 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
flex-shrink: 0;
}
/* ── Empty state ───────────────────────────────────── */
.empty-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 40rpx 80rpx;
gap: 0;
}
/* Zen-inspired geometric illustration */
.empty-illustration {
position: relative;
width: 200rpx;
height: 200rpx;
margin-bottom: 56rpx;
}
.empty-circle {
position: absolute;
border-radius: 50%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
&.outer {
width: 180rpx;
height: 180rpx;
border: 2rpx solid rgba(192, 154, 137, 0.18);
animation: breathe 3s ease-in-out infinite;
}
&.inner {
width: 120rpx;
height: 120rpx;
background: linear-gradient(135deg, #f6e9e1 0%, #ddc1b4 50%, #b98f7d 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: #a87d6c;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: pulse 2s ease-in-out infinite;
}
.empty-text {
font-size: 32rpx;
color: #6f605b;
font-weight: 600;
letter-spacing: 2rpx;
margin-bottom: 16rpx;
}
.empty-sub {
font-size: 26rpx;
color: #a18a82;
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 ─────────────────────────────────── */
.scroll-bottom-spacer {
height: 48rpx;
.booking-page { height: 100vh; background: #fbf9f6; display: flex; flex-direction: column; overflow: hidden; }
.status-bar { flex-shrink: 0; }
.page-header { flex-shrink: 0; height: 88rpx; display: flex; align-items: center; justify-content: center; }
.page-title { font-size: 34rpx; font-weight: 500; color: #514943; }
.filter-header { flex-shrink: 0; border-bottom: 1rpx solid #eee8e3; }
.calendar-heading { display: flex; align-items: baseline; justify-content: space-between; padding: 24rpx 32rpx 16rpx; gap: 16rpx; }
.calendar-month { font-size: 28rpx; font-weight: 500; color: #514943; }
.calendar-hint { font-size: 22rpx; color: #8b817b; }
.slot-scroll { flex: 1; height: 0; min-height: 0; width: 100%; box-sizing: border-box; }
.slot-list { display: flex; flex-direction: column; padding-top: 28rpx; }
.date-summary { display: flex; justify-content: space-between; align-items: baseline; gap: 16rpx; padding: 0 32rpx 20rpx; }
.date-summary-title { font-size: 25rpx; color: #6f655e; }
.date-summary-text { font-size: 22rpx; color: #8b817b; }
.loading-wrap { display: flex; flex-direction: column; gap: 20rpx; padding: 28rpx 32rpx; }
.skeleton-card { height: 200rpx; box-sizing: border-box; border-radius: 28rpx; background: #fff; display: flex; align-items: center; padding: 28rpx; gap: 20rpx; }
.skeleton-time, .skeleton-title, .skeleton-sub, .skeleton-btn {
border-radius: 10rpx;
background: linear-gradient(90deg, #f0eae5 25%, #faf7f3 50%, #f0eae5 75%);
background-size: 400% 100%; animation: shimmer 1.4s infinite;
}
.skeleton-time { width: 90rpx; height: 70rpx; flex-shrink: 0; }
.skeleton-body { flex: 1; display: flex; flex-direction: column; gap: 12rpx; }
.skeleton-title { width: 80%; height: 28rpx; }
.skeleton-sub { width: 60%; height: 20rpx; }
.skeleton-btn { width: 110rpx; height: 60rpx; border-radius: 999rpx; flex-shrink: 0; }
.empty-wrap { margin: 32rpx; padding: 64rpx 28rpx; border-radius: 28rpx; background: #f3efea; display: flex; flex-direction: column; align-items: center; gap: 14rpx; }
.empty-text { font-size: 28rpx; color: #6f655e; font-weight: 400; }
.empty-sub { font-size: 23rpx; color: #8b817b; text-align: center; line-height: 1.6; }
.scroll-bottom-spacer { height: 28rpx; }
</style>

View File

@@ -1,52 +1,22 @@
<template>
<view class="home-page">
<!-- Brand Banner fixed background layer -->
<view class="home-page" :style="{ height: pageHeight }">
<view class="banner-fixed">
<BrandBanner :studio-info="studioStore.studioInfo" />
</view>
<!-- Pull-to-refresh wrapper scrollable foreground -->
<scroll-view
class="page-scroll"
scroll-y
:scroll-top="scrollTop"
:scroll-with-animation="true"
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="handleRefresh"
@refresherrestore="refreshing = false"
>
<!-- Transparent spacer to reveal Banner behind -->
<scroll-view class="page-scroll" scroll-y :scroll-into-view="scrollTarget"
scroll-with-animation refresher-enabled :refresher-triggered="refreshing"
@refresherrefresh="handleRefresh" @refresherrestore="refreshing = false">
<view class="banner-spacer" />
<!-- Floating card with rounded top corners -->
<view class="floating-card">
<!-- Drag indicator -->
<view class="card-handle">
<view class="card-handle-bar" />
</view>
<!-- Studio Info (photo strip + address/phone) -->
<StudioInfo :studio-info="studioStore.studioInfo" />
<!-- Quick Entry (login / trial / book / renew) -->
<view class="card-handle"><view class="card-handle-bar" /></view>
<QuickEntry @scroll-to-card-shop="scrollToCardShop" />
<!-- Upcoming Bookings -->
<UpcomingBooking />
<!-- .5 Flash Sale Section -->
<StudioInfo :studio-info="studioStore.studioInfo" />
<FlashSaleSection ref="flashSaleRef" />
<!-- Card Shop (vertical list) -->
<view :id="cardShopAnchorId">
<CardShop ref="cardShopRef" />
</view>
<!-- About (teacher + studio gallery) -->
<AboutSection />
<!-- Bottom padding for tab bar -->
<view class="bottom-padding" />
</view>
</scroll-view>
@@ -55,7 +25,7 @@
<script setup lang="ts">
import { ref, nextTick, onUnmounted } from 'vue'
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { onShow, onResize, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import BrandBanner from '../../components/BrandBanner.vue'
import StudioInfo from '../../components/StudioInfo.vue'
@@ -94,7 +64,9 @@ const refreshing = ref(false)
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
const flashSaleRef = ref<InstanceType<typeof FlashSaleSection> | null>(null)
const cardShopAnchorId = 'card-shop-anchor'
const scrollTop = ref(0)
const scrollTarget = ref('')
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
onResize(() => { pageHeight.value = `${uni.getWindowInfo().windowHeight}px` })
const pendingScrollToCardShop = ref(false)
// Listen for cross-page scroll request (e.g. from booking page "去购买")
@@ -132,8 +104,10 @@ async function refreshData() {
await Promise.allSettled(tasks)
// Also refresh card shop and flash sales
cardShopRef.value?.fetchCardTypes()
flashSaleRef.value?.fetchFlashSales()
await Promise.allSettled([
cardShopRef.value?.fetchCardTypes(),
flashSaleRef.value?.fetchFlashSales(),
])
}
async function handleRefresh() {
@@ -145,78 +119,30 @@ async function handleRefresh() {
}
}
function scrollToCardShop() {
// Reset first so setting the same value still triggers scroll
scrollTop.value = 0
nextTick(() => {
uni.createSelectorQuery()
.select(`#${cardShopAnchorId}`)
.boundingClientRect()
.selectViewport()
.scrollOffset((res) => {
if (res) {
scrollTop.value = (res as UniApp.NodeInfo).scrollTop ?? 0
}
})
.exec()
})
async function scrollToCardShop() {
scrollTarget.value = ''
await nextTick()
scrollTarget.value = cardShopAnchorId
}
</script>
<style lang="scss" scoped>
.home-page {
position: relative;
height: 100vh;
background: #FAF8F5;
}
/* Banner fixed behind everything */
.banner-fixed {
position: fixed;
top: 0;
left: 0;
width: 100%;
z-index: 0;
}
/* Scroll layer sits above banner */
.page-scroll {
position: relative;
z-index: 1;
flex: 1;
height: 100vh;
}
/* Transparent spacer lets banner peek through */
.banner-spacer {
height: 420rpx;
}
/* Floating card that overlaps the banner */
.home-page { position: relative; height: 100vh; background: #fbf9f6; }
.banner-fixed { position: fixed; top: 0; left: 0; width: 100%; z-index: 0; }
.page-scroll { position: relative; z-index: 1; height: 100%; }
.banner-spacer { height: 420rpx; }
.floating-card {
position: relative;
background: #FAF8F5;
border-radius: 32rpx 32rpx 0 0;
min-height: 100vh;
padding-top: 12rpx;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
}
/* Small drag indicator at top of card */
.card-handle {
display: flex;
justify-content: center;
padding: 16rpx 0 8rpx;
}
.card-handle-bar {
width: 64rpx;
height: 8rpx;
border-radius: 4rpx;
background: rgba(0, 0, 0, 0.1);
}
.bottom-padding {
height: 120rpx;
border-radius: 32rpx 32rpx 0 0;
border-top: 1rpx solid rgba(255, 255, 255, 0.7);
background: linear-gradient(180deg, rgba(251, 249, 246, 0.86), #fbf9f6 260rpx);
-webkit-backdrop-filter: blur(16px);
backdrop-filter: blur(16px);
box-shadow: 0 -4rpx 20rpx rgba(66, 54, 45, 0.06);
}
.card-handle { display: flex; justify-content: center; padding: 16rpx 0 8rpx; }
.card-handle-bar { width: 64rpx; height: 8rpx; border-radius: 4rpx; background: rgba(112, 94, 79, 0.18); }
.bottom-padding { height: 48rpx; }
</style>

View File

@@ -1,13 +1,12 @@
<template>
<view class="profile-page">
<!-- Custom nav bar (transparent, blends with UserCard gradient) -->
<CustomNavBar title="我的" transparent />
<view class="profile-page" :style="{ paddingTop: navBarHeight + 'px' }">
<view class="profile-page__nav" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="profile-page__nav-title">我的</view>
</view>
<!-- User card -->
<UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :stats="stats" :memberships="memberships"
:loading="loginLoading" :nav-bar-height="navBarHeight" @login="handleLogin" />
<PracticeActivityCard v-if="loggedIn" :key="userStore.token" :refresh-key="activityRefreshKey" />
:loading="loginLoading" @login="handleLogin" @edit="goToInfo" />
<!-- Menu section: always visible -->
<ProfileMenu
@@ -18,7 +17,9 @@
:invite-share-eligible="!!user?.inviteShareEligible"
@clear-cache="handleClearCache"
@require-login="handleLogin"
/>
>
<PracticeActivityCard v-if="loggedIn" :key="userStore.token" :refresh-key="activityRefreshKey" />
</ProfileMenu>
<!-- Logout button: only when logged in -->
<view v-if="loggedIn" class="profile-page__logout-wrap">
@@ -38,7 +39,6 @@ import { getErrorMessage } from '../../utils/auth'
import PracticeActivityCard from '../../components/PracticeActivityCard.vue'
import UserCard from '../../components/UserCard.vue'
import ProfileMenu from '../../components/ProfileMenu.vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
const userStore = useUserStore()
const bookingStore = useBookingStore()
@@ -47,7 +47,8 @@ const { upcomingBookings } = storeToRefs(bookingStore)
const activityRefreshKey = ref(0)
const loginLoading = ref(false)
const navBarHeight = ref(64)
const navBarHeight = ref(getSystemLayout().navBarHeight)
const statusBarHeight = getSystemLayout().statusBarHeight
const activeMembershipCount = computed(
() => user.value?.activeMembershipCount ?? userStore.activeMemberships.length,
@@ -107,12 +108,16 @@ async function handleLogin() {
}
}
function goToInfo() {
uni.navigateTo({ url: '/pages/profile/info' })
}
function handleLogout() {
uni.showModal({
title: '退出登录',
content: '确定要退出登录吗?',
confirmText: '退出',
confirmColor: '#ff4d4f',
confirmColor: '#9c7a6e',
success(res) {
if (res.confirm) {
userStore.logout()
@@ -138,27 +143,10 @@ function handleClearCache() {
<style lang="scss" scoped>
.profile-page {
min-height: 100vh;
&__logout-wrap {
margin: $spacing-xl $spacing-lg $spacing-xl;
}
&__logout-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: $bg-card;
color: $error-color;
font-size: 30rpx;
font-weight: 500;
border: none;
border-radius: $radius-lg;
text-align: center;
&::after {
border: none;
}
}
min-height: 100vh; box-sizing: border-box; background: #fbf9f6; padding-bottom: 36rpx;
&__nav { position: fixed; top: 0; left: 0; right: 0; z-index: 101; background: #fbf9f6; }
&__nav-title { height: 88rpx; display: flex; align-items: center; justify-content: center; font-size: 34rpx; font-weight: 500; color: #514943; }
&__logout-wrap { margin: 28rpx 32rpx 0; }
&__logout-btn { width: 100%; height: 80rpx; line-height: 80rpx; padding: 0; margin: 0; border: none; border-radius: 24rpx; background: #f2ede7; color: #91766a; font-size: 25rpx; font-weight: 400; &::after { border: none; } }
}
</style>