diff --git a/packages/app/src/pages/admin/card-types.vue b/packages/app/src/pages/admin/card-types.vue
index b0b3b6b..e79d7e3 100644
--- a/packages/app/src/pages/admin/card-types.vue
+++ b/packages/app/src/pages/admin/card-types.vue
@@ -88,12 +88,23 @@
卡种名称
-
+
类型
- form.typeIdx = Number(e.detail.value)">
+ form.typeIdx = Number(e.detail.value)"
+ >
{{ typeOptions[form.typeIdx].label }}
›
@@ -103,27 +114,57 @@
现价(元)
-
+
原价(元)
-
+
次数
-
+
有效天数
-
+
排序值
-
+
@@ -142,7 +183,11 @@
取消
-
+
{{ submitting ? '保存中...' : '确认' }}
@@ -153,11 +198,13 @@
diff --git a/packages/app/src/pages/admin/members.vue b/packages/app/src/pages/admin/members.vue
index 102486c..70d0003 100644
--- a/packages/app/src/pages/admin/members.vue
+++ b/packages/app/src/pages/admin/members.vue
@@ -1,177 +1,168 @@
-
+
+
+ 搜索
+
-
+
-
- {{ totalMembers }}
- 活跃会员
-
-
- {{ totalBookings }}
- 总预约次数
-
-
- {{ confirmedBookings }}
- 待上课
+
+ {{ total }}
+ 总会员
-
-
+
+
-
+
👥
- {{ searchQuery ? '未找到匹配会员' : '暂无预约记录' }}
+ 暂无会员数据
- {{ member.nickname.slice(0, 1).toUpperCase() }}
+
+
+ {{ (m.nickname || '?').slice(0, 1) }}
+
- {{ member.nickname }}
- {{ maskPhone(member.phone) }}
+ {{ m.nickname || '未知用户' }}
+ {{ m.phone || '未绑定手机' }}
-
- {{ member.totalBookings }}
- 次预约
-
-
- {{ member.confirmedBookings }}
- 待上课
-
+ {{ m.totalBookings }}
+ 次预约
+ ›
-
- 加载更多
+
+ {{ loading ? '加载中...' : '加载更多' }}
-
- 加载中...
+
+
+
+
+
+
+
+
+ {{ detailMember.totalBookings }}
+ 总预约
+
+
+ {{ detailMember.completedBookings }}
+ 已完成
+
+
+ {{ detailMember.cancelledBookings }}
+ 已取消
+
+
+
+
+ 关闭
+
+
diff --git a/packages/app/src/pages/admin/orders.vue b/packages/app/src/pages/admin/orders.vue
index 98213af..fec82a3 100644
--- a/packages/app/src/pages/admin/orders.vue
+++ b/packages/app/src/pages/admin/orders.vue
@@ -5,214 +5,215 @@
{{ f.label }}
-
-
-
-
+
+
+
+
+
+
-
-
- 📋
- 暂无订单
-
+
+
+ 📋
+ 暂无订单
+
-
-
-
-
-
diff --git a/packages/app/src/pages/admin/slot-adjust.vue b/packages/app/src/pages/admin/slot-adjust.vue
index 9426c32..93fc2d1 100644
--- a/packages/app/src/pages/admin/slot-adjust.vue
+++ b/packages/app/src/pages/admin/slot-adjust.vue
@@ -3,107 +3,98 @@
- {{ tab.label }}
+ {{ tab }}
-
-
- 手动新增时段
-
+
+
日期
addForm.date = e.detail.value">
- {{ addForm.date }}
+ {{ addForm.date || '请选择' }}
›
-
开始时间
addForm.startTime = e.detail.value">
- {{ addForm.startTime }}
+ {{ addForm.startTime || '请选择' }}
›
-
结束时间
addForm.endTime = e.detail.value">
- {{ addForm.endTime }}
+ {{ addForm.endTime || '请选择' }}
›
-
- 容量(人)
+ 容量
-
-
- {{ addingSlot ? '添加中...' : '添加时段' }}
+
+
+ {{ submitting ? '提交中...' : '新增时段' }}
+
-
-
-
- { closeDateFilter = e.detail.value; fetchSlotsForClose() }">
-
- {{ closeDateFilter }}
- ›
+
+
+
+ { closeDate = e.detail.value; loadSlotsForClose() }">
+
+ 选择日期:
+ {{ closeDate }}
+ ›
-
-
+
+
-
- 🗓️
+
+ 📭
该日暂无时段
-
+
- {{ slot.startTime.slice(0, 5) }}–{{ slot.endTime.slice(0, 5) }}
- 容量 {{ slot.capacity }} · 已预约 {{ slot.bookedCount }}
+ {{ slot.startTime }} – {{ slot.endTime }}
+
+ {{ slot.status }}
+
+ {{ slot.bookedCount }}/{{ slot.capacity }}
关闭
@@ -114,114 +105,107 @@
-
-
- 按模板生成时段
- 将依据当前排课模板,生成未来指定天数的课程时段(已存在的时段不会重复生成)。
-
+
+
+
+ 开始日期
+ genForm.startDate = e.detail.value">
+
+ {{ genForm.startDate || '请选择' }}
+ ›
+
+
+
- 生成天数
-
+ 结束日期
+ genForm.endDate = e.detail.value">
+
+ {{ genForm.endDate || '请选择' }}
+ ›
+
+
-
-
- {{ generating ? '生成中...' : '生成时段' }}
+ 将根据排课模板,自动生成所选日期范围内的时段
+
+
+ {{ submitting ? '生成中...' : '批量生成' }}
+
diff --git a/packages/app/src/pages/admin/studio.vue b/packages/app/src/pages/admin/studio.vue
index 7af2f4c..c7c9f30 100644
--- a/packages/app/src/pages/admin/studio.vue
+++ b/packages/app/src/pages/admin/studio.vue
@@ -21,7 +21,7 @@
-
+
基本信息
@@ -150,10 +150,10 @@
@@ -272,7 +295,7 @@ onMounted(() => {
}
.skeleton-header {
- height: 360rpx;
+ height: 380rpx;
background: linear-gradient(90deg, #e8e8e8 25%, #d8d8d8 50%, #e8e8e8 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
@@ -335,10 +358,12 @@ onMounted(() => {
/* ── Hero ────────────────────────────────────────────── */
.card-hero {
- padding: 60rpx 32rpx 52rpx;
+ padding: 64rpx 36rpx 56rpx;
display: flex;
flex-direction: column;
- gap: 16rpx;
+ gap: 18rpx;
+ position: relative;
+ overflow: hidden;
&.hero--times {
background: linear-gradient(135deg, #1a1a2e 0%, #2d2d5e 100%);
@@ -353,12 +378,35 @@ onMounted(() => {
}
}
+/* Decorative background circles */
+.hero-deco {
+ position: absolute;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.05);
+ pointer-events: none;
+
+ &--1 {
+ width: 320rpx;
+ height: 320rpx;
+ top: -80rpx;
+ right: -60rpx;
+ }
+
+ &--2 {
+ width: 200rpx;
+ height: 200rpx;
+ bottom: -40rpx;
+ left: 20rpx;
+ }
+}
+
.hero-badge {
align-self: flex-start;
- padding: 8rpx 20rpx;
+ padding: 8rpx 22rpx;
border-radius: 20rpx;
background: rgba(255, 255, 255, 0.18);
border: 1rpx solid rgba(255, 255, 255, 0.3);
+ z-index: 1;
}
.hero-badge-text {
@@ -369,28 +417,39 @@ onMounted(() => {
}
.hero-name {
- font-size: 44rpx;
+ font-size: 48rpx;
font-weight: 800;
color: #fff;
letter-spacing: 1rpx;
+ z-index: 1;
}
.hero-price-row {
display: flex;
align-items: baseline;
- gap: 16rpx;
+ gap: 8rpx;
+ z-index: 1;
+}
+
+.hero-currency {
+ font-size: 28rpx;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.85);
+ line-height: 1;
}
.hero-price {
- font-size: 56rpx;
+ font-size: 64rpx;
font-weight: 800;
color: #fff;
+ line-height: 1;
}
.hero-original {
font-size: 28rpx;
- color: rgba(255, 255, 255, 0.55);
+ color: rgba(255, 255, 255, 0.5);
text-decoration: line-through;
+ margin-left: 8rpx;
}
/* ── Detail section ──────────────────────────────────── */
@@ -401,6 +460,29 @@ onMounted(() => {
gap: 20rpx;
}
+/* ── Section header ──────────────────────────────────── */
+.section-header {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 12rpx;
+ margin-bottom: 16rpx;
+}
+
+.section-dot {
+ width: 6rpx;
+ height: 28rpx;
+ border-radius: 3rpx;
+ background: #c9a87c;
+ flex-shrink: 0;
+}
+
+.section-title {
+ font-size: 30rpx;
+ font-weight: 700;
+ color: #1a1a1a;
+}
+
/* ── Info grid card ──────────────────────────────────── */
.info-card {
background: #fff;
@@ -447,21 +529,12 @@ onMounted(() => {
border-radius: 20rpx;
padding: 28rpx 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
- display: flex;
- flex-direction: column;
- gap: 16rpx;
-}
-
-.desc-title {
- font-size: 28rpx;
- font-weight: 700;
- color: #1a1a1a;
}
.desc-content {
- font-size: 26rpx;
+ font-size: 27rpx;
color: #666;
- line-height: 1.7;
+ line-height: 1.75;
}
/* ── Features card ───────────────────────────────────── */
@@ -472,13 +545,6 @@ onMounted(() => {
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
- gap: 16rpx;
-}
-
-.features-title {
- font-size: 28rpx;
- font-weight: 700;
- color: #1a1a1a;
}
.feature-item {
@@ -486,19 +552,20 @@ onMounted(() => {
flex-direction: row;
align-items: flex-start;
gap: 12rpx;
+ padding: 6rpx 0;
}
.feature-dot {
font-size: 26rpx;
color: #c9a87c;
- line-height: 1.6;
+ line-height: 1.65;
flex-shrink: 0;
}
.feature-text {
font-size: 26rpx;
color: #555;
- line-height: 1.6;
+ line-height: 1.65;
}
/* ── Bottom action bar ───────────────────────────────── */
@@ -542,6 +609,7 @@ onMounted(() => {
display: flex;
align-items: center;
justify-content: center;
+ box-shadow: 0 4rpx 16rpx rgba(26, 26, 46, 0.3);
&:active {
opacity: 0.85;
diff --git a/packages/app/src/pages/profile/bookings.vue b/packages/app/src/pages/profile/bookings.vue
index f5d3b63..aba51b1 100644
--- a/packages/app/src/pages/profile/bookings.vue
+++ b/packages/app/src/pages/profile/bookings.vue
@@ -1,6 +1,6 @@
-
+
{{ tab.label }}
+
+ {{ upcomingCount }}
+
-
+
-
+
-
+
📅
- 暂无预约记录
+ 暂无即将上课的预约
去预约一节课吧
去预约
@@ -39,43 +43,81 @@
-
-
-
-
+
-
{{ formatDateDisplay(booking.timeSlot.date) }}
- {{ booking.timeSlot.startTime.slice(0, 5) }} - {{ booking.timeSlot.endTime.slice(0, 5) }}
+ {{ booking.timeSlot.startTime.slice(0, 5) }} – {{ booking.timeSlot.endTime.slice(0, 5) }}
+
+ 已预约
+
+
+
+ 💳 {{ booking.membership.cardType.name }}
+
+
+
+ 取消预约
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+ 📋
+ 暂无历史记录
+ 已完成或取消的课程将显示在这里
+
+
+
+
+
+
+
+
+
+ {{ formatDateDisplay(booking.timeSlot.date) }}
+
+ {{ booking.timeSlot.startTime.slice(0, 5) }} – {{ booking.timeSlot.endTime.slice(0, 5) }}
+
+
{{ statusLabel(booking.status) }}
-
-
- 💳 {{ booking.membership.cardType.name }}
-
-
-
-
-
- 取消预约
-
+ 💳 {{ booking.membership.cardType.name }}
@@ -91,44 +133,58 @@ import { ref, computed, onMounted } from 'vue'
import type { BookingWithDetails } from '@mp-pilates/shared'
import { BookingStatus } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking'
-import { formatDate } from '../../utils/format'
+import { formatDate, getWeekdayLabel } from '../../utils/format'
const bookingStore = useBookingStore()
// ─── Tab state ────────────────────────────────────────────
-type TabKey = 'upcoming' | 'all'
+type TabKey = 'upcoming' | 'history'
const tabs = [
{ key: 'upcoming' as TabKey, label: '即将上课' },
- { key: 'all' as TabKey, label: '全部记录' },
+ { key: 'history' as TabKey, label: '历史记录' },
]
const activeTab = ref('upcoming')
-const refreshing = ref(false)
+const refreshingUpcoming = ref(false)
+const refreshingHistory = ref(false)
// ─── Filtered bookings ────────────────────────────────────
-const filteredBookings = computed(() => {
+const today = computed(() => formatDate(new Date()))
+
+const upcomingBookings = computed(() => {
const all = bookingStore.myBookings as BookingWithDetails[]
- if (activeTab.value === 'upcoming') {
- const today = formatDate(new Date())
- return all.filter(
- (b) => b.status === BookingStatus.CONFIRMED && b.timeSlot.date >= today,
- ).sort((a, b) => a.timeSlot.date.localeCompare(b.timeSlot.date))
- }
- return [...all].sort((a, b) => {
- // Most recent first
- if (b.timeSlot.date !== a.timeSlot.date) {
- return b.timeSlot.date.localeCompare(a.timeSlot.date)
- }
- return b.timeSlot.startTime.localeCompare(a.timeSlot.startTime)
- })
+ return all
+ .filter(
+ (b) => b.status === BookingStatus.CONFIRMED && b.timeSlot.date >= today.value,
+ )
+ .sort((a, b) => {
+ if (a.timeSlot.date !== b.timeSlot.date) {
+ return a.timeSlot.date.localeCompare(b.timeSlot.date)
+ }
+ return a.timeSlot.startTime.localeCompare(b.timeSlot.startTime)
+ })
})
-// ─── Helpers ──────────────────────────────────────────────
-function isUpcoming(date: string): boolean {
- return date >= formatDate(new Date())
-}
+const historyBookings = computed(() => {
+ const all = bookingStore.myBookings as BookingWithDetails[]
+ return all
+ .filter(
+ (b) =>
+ b.status !== BookingStatus.CONFIRMED ||
+ b.timeSlot.date < today.value,
+ )
+ .sort((a, b) => {
+ if (b.timeSlot.date !== a.timeSlot.date) {
+ return b.timeSlot.date.localeCompare(a.timeSlot.date)
+ }
+ return b.timeSlot.startTime.localeCompare(a.timeSlot.startTime)
+ })
+})
+const upcomingCount = computed(() => upcomingBookings.value.length)
+
+// ─── Helpers ──────────────────────────────────────────────
function statusLabel(status: BookingStatus): string {
const map: Record = {
[BookingStatus.CONFIRMED]: '已预约',
@@ -164,8 +220,7 @@ function formatDateDisplay(dateStr: string): string {
const d = new Date(dateStr)
const month = d.getMonth() + 1
const day = d.getDate()
- const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
- const weekday = weekdays[d.getDay()]
+ const weekday = getWeekdayLabel(d)
return `${month}月${day}日 ${weekday}`
}
@@ -174,10 +229,16 @@ function selectTab(key: TabKey) {
activeTab.value = key
}
-async function onRefresh() {
- refreshing.value = true
+async function onRefreshUpcoming() {
+ refreshingUpcoming.value = true
await bookingStore.fetchMyBookings()
- refreshing.value = false
+ refreshingUpcoming.value = false
+}
+
+async function onRefreshHistory() {
+ refreshingHistory.value = true
+ await bookingStore.fetchMyBookings()
+ refreshingHistory.value = false
}
function goBooking() {
@@ -185,25 +246,27 @@ function goBooking() {
}
async function handleCancel(booking: BookingWithDetails) {
+ const dateLabel = formatDateDisplay(booking.timeSlot.date)
+ const timeLabel = booking.timeSlot.startTime.slice(0, 5)
+
uni.showModal({
title: '取消预约',
- content: `确定要取消 ${formatDateDisplay(booking.timeSlot.date)} ${booking.timeSlot.startTime.slice(0, 5)} 的课程吗?`,
+ content: `确定要取消 ${dateLabel} ${timeLabel} 的课程吗?`,
confirmText: '确定取消',
confirmColor: '#ef4444',
cancelText: '再想想',
success: async (res) => {
- if (res.confirm) {
- uni.showLoading({ title: '取消中...' })
- try {
- await bookingStore.cancelBooking(booking.id)
- uni.hideLoading()
- uni.showToast({ title: '已取消预约', icon: 'success' })
- await bookingStore.fetchMyBookings()
- } catch (err: unknown) {
- uni.hideLoading()
- const msg = err instanceof Error ? err.message : '取消失败,请重试'
- uni.showToast({ title: msg, icon: 'none' })
- }
+ if (!res.confirm) return
+ uni.showLoading({ title: '取消中...' })
+ try {
+ await bookingStore.cancelBooking(booking.id)
+ uni.hideLoading()
+ uni.showToast({ title: '已取消预约', icon: 'success' })
+ await bookingStore.fetchMyBookings()
+ } catch (err: unknown) {
+ uni.hideLoading()
+ const msg = err instanceof Error ? err.message : '取消失败,请重试'
+ uni.showToast({ title: msg, icon: 'none' })
}
},
})
@@ -227,16 +290,16 @@ onMounted(() => bookingStore.fetchMyBookings())
flex-direction: row;
background: #fff;
border-bottom: 1rpx solid #f0ece8;
- position: sticky;
- top: 0;
- z-index: 10;
+ flex-shrink: 0;
}
.tab-item {
flex: 1;
display: flex;
+ flex-direction: row;
align-items: center;
justify-content: center;
+ gap: 8rpx;
padding: 28rpx 0;
position: relative;
@@ -252,7 +315,7 @@ onMounted(() => bookingStore.fetchMyBookings())
bottom: 0;
left: 50%;
transform: translateX(-50%);
- width: 40rpx;
+ width: 48rpx;
height: 4rpx;
background: #c9a87c;
border-radius: 2rpx;
@@ -266,9 +329,27 @@ onMounted(() => bookingStore.fetchMyBookings())
font-weight: 400;
}
+.tab-badge {
+ min-width: 32rpx;
+ height: 32rpx;
+ border-radius: 16rpx;
+ background: #ef4444;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 8rpx;
+}
+
+.tab-badge-text {
+ font-size: 20rpx;
+ color: #fff;
+ font-weight: 600;
+}
+
/* ── Scroll ──────────────────────────────────────────── */
.scroll {
flex: 1;
+ height: calc(100vh - 88rpx);
}
/* ── Loading ─────────────────────────────────────────── */
@@ -348,14 +429,15 @@ onMounted(() => bookingStore.fetchMyBookings())
flex-direction: row;
}
+/* Colored left stripe */
.booking-stripe {
width: 8rpx;
flex-shrink: 0;
- &--confirmed { background: #c9a87c; }
- &--completed { background: #4caf50; }
- &--cancelled { background: #e0e0e0; }
- &--noshow { background: #ef4444; }
+ &.stripe--confirmed { background: #c9a87c; }
+ &.stripe--completed { background: #4caf50; }
+ &.stripe--cancelled { background: #e0e0e0; }
+ &.stripe--noshow { background: #ef4444; }
}
.booking-content {
@@ -390,6 +472,7 @@ onMounted(() => bookingStore.fetchMyBookings())
color: #888;
}
+/* Status badge */
.status-badge {
padding: 8rpx 18rpx;
border-radius: 20rpx;
@@ -411,14 +494,15 @@ onMounted(() => bookingStore.fetchMyBookings())
.badge--noshow & { color: #ef4444; }
}
+/* Meta info */
.booking-meta {
- .meta-label {
+ .meta-text {
font-size: 24rpx;
color: #999;
}
}
-/* ── Cancel row ──────────────────────────────────────── */
+/* Cancel row */
.cancel-row {
display: flex;
justify-content: flex-end;
@@ -426,13 +510,19 @@ onMounted(() => bookingStore.fetchMyBookings())
}
.cancel-btn {
- padding: 8rpx 24rpx;
+ padding: 10rpx 24rpx;
+ border-radius: 24rpx;
+ border: 1rpx solid #ef444430;
+ background: #fef0f0;
+
+ &:active {
+ opacity: 0.75;
+ }
}
.cancel-text {
font-size: 24rpx;
color: #ef4444;
- text-decoration: underline;
font-weight: 500;
}
diff --git a/packages/app/src/pages/profile/info.vue b/packages/app/src/pages/profile/info.vue
index eba2570..521531b 100644
--- a/packages/app/src/pages/profile/info.vue
+++ b/packages/app/src/pages/profile/info.vue
@@ -2,48 +2,65 @@
-
+
{{ nicknameInitial }}
-
- 📷
-
- 点击更换头像
+ {{ form.nickname || '未设置昵称' }}
+ 微信头像
-
+
-
+
昵称
+ ›
-
-
+
+
手机号
- {{ phoneDisplay }}
-
-
-
- 注册时间
- {{ joinDateDisplay }}
+
+ {{ phoneDisplay }}
+
+
+
+
+
+
+
+
+
+ 注册时间
+ {{ joinDateDisplay }}
+
+
+ 会员卡数量
+ {{ activeMembershipCount }} 张有效
@@ -51,7 +68,7 @@
{{ saving ? '保存中...' : '保存修改' }}
@@ -63,39 +80,34 @@
@@ -207,15 +197,17 @@ onMounted(async () => {
display: flex;
flex-direction: column;
align-items: center;
- padding: 60rpx 0 48rpx;
+ padding: 56rpx 0 40rpx;
background: #fff;
margin-bottom: 24rpx;
+ border-bottom: 1rpx solid #f0ece8;
}
.avatar-wrap {
position: relative;
width: 160rpx;
height: 160rpx;
+ margin-bottom: 16rpx;
}
.avatar {
@@ -236,32 +228,20 @@ onMounted(async () => {
}
.avatar-placeholder-text {
- font-size: 60rpx;
+ font-size: 64rpx;
font-weight: 700;
color: #fff;
}
-.avatar-edit-badge {
- position: absolute;
- bottom: 4rpx;
- right: 4rpx;
- width: 48rpx;
- height: 48rpx;
- border-radius: 50%;
- background: #fff;
- box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.15);
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.avatar-edit-icon {
- font-size: 26rpx;
+.avatar-name {
+ font-size: 34rpx;
+ font-weight: 700;
+ color: #1a1a1a;
+ margin-bottom: 6rpx;
}
.avatar-hint {
- margin-top: 16rpx;
- font-size: 24rpx;
+ font-size: 22rpx;
color: #bbb;
}
@@ -269,7 +249,7 @@ onMounted(async () => {
.form-card {
background: #fff;
border-radius: 20rpx;
- margin: 0 24rpx;
+ margin: 0 24rpx 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
@@ -280,14 +260,11 @@ onMounted(async () => {
align-items: center;
padding: 32rpx 28rpx;
border-bottom: 1rpx solid #f5f5f5;
+ min-height: 100rpx;
- &:last-child {
+ &--last {
border-bottom: none;
}
-
- &--readonly {
- opacity: 0.8;
- }
}
.form-label {
@@ -304,6 +281,7 @@ onMounted(async () => {
color: #222;
text-align: right;
background: transparent;
+ min-height: 44rpx;
}
.form-value {
@@ -313,9 +291,74 @@ onMounted(async () => {
text-align: right;
}
+.form-arrow {
+ font-size: 36rpx;
+ color: #ccc;
+ margin-left: 8rpx;
+ line-height: 1;
+}
+
+/* Bind phone button (styled, not default wx button) */
+.bind-phone-btn {
+ flex: 1;
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ background: transparent;
+ border: none;
+ padding: 0;
+ margin: 0;
+ line-height: normal;
+
+ /* reset uni button default styles */
+ &::after {
+ border: none;
+ }
+}
+
+.bind-phone-text {
+ font-size: 26rpx;
+ color: #c9a87c;
+ font-weight: 600;
+ text-decoration: underline;
+}
+
+/* ── Read-only info card ──────────────────────────────── */
+.info-card {
+ background: #fff;
+ border-radius: 20rpx;
+ margin: 0 24rpx 32rpx;
+ overflow: hidden;
+ box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
+}
+
+.info-row {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ padding: 28rpx 28rpx;
+ border-bottom: 1rpx solid #f5f5f5;
+
+ &--last {
+ border-bottom: none;
+ }
+}
+
+.info-label {
+ font-size: 26rpx;
+ color: #999;
+}
+
+.info-value {
+ font-size: 26rpx;
+ color: #555;
+ font-weight: 500;
+}
+
/* ── Save button ─────────────────────────────────────── */
.save-wrap {
- padding: 40rpx 24rpx;
+ padding: 8rpx 24rpx 48rpx;
}
.save-btn {
@@ -327,6 +370,7 @@ onMounted(async () => {
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 20rpx rgba(26, 26, 46, 0.3);
+ transition: opacity 0.2s;
&:active {
opacity: 0.85;
@@ -334,7 +378,7 @@ onMounted(async () => {
&--loading,
&--disabled {
- opacity: 0.5;
+ opacity: 0.45;
box-shadow: none;
}
}
diff --git a/packages/app/src/pages/profile/membership.vue b/packages/app/src/pages/profile/membership.vue
index 38233c6..c7478b1 100644
--- a/packages/app/src/pages/profile/membership.vue
+++ b/packages/app/src/pages/profile/membership.vue
@@ -14,7 +14,7 @@
-
+
💳
暂无会员卡
购买会员卡后即可预约课程
@@ -26,27 +26,59 @@
-
- 有效会员卡
+
+
+
-
-
+
+
+
+
+
-
- 历史记录
+
+
+
-
-
+
+
@@ -116,20 +145,23 @@
import { ref, computed, onMounted } from 'vue'
import type { MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
-import { get } from '../../utils/request'
+import { useUserStore } from '../../stores/user'
+
+const userStore = useUserStore()
// ─── State ────────────────────────────────────────────────
-const memberships = ref([])
const loading = ref(false)
const refreshing = ref(false)
-// ─── Computed ─────────────────────────────────────────────
+// ─── Computed from store ───────────────────────────────────
+const allMemberships = computed(() => userStore.memberships as MembershipWithCardType[])
+
const activeMemberships = computed(() =>
- memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
+ allMemberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
)
const inactiveMemberships = computed(() =>
- memberships.value.filter((m) => m.status !== MembershipStatus.ACTIVE),
+ allMemberships.value.filter((m) => m.status !== MembershipStatus.ACTIVE),
)
// ─── Helpers ──────────────────────────────────────────────
@@ -152,15 +184,21 @@ function statusLabel(status: MembershipStatus): string {
}
function statusBadgeClass(status: MembershipStatus): string {
- if (status === MembershipStatus.EXPIRED) return 'card-badge--expired'
- if (status === MembershipStatus.USED_UP) return 'card-badge--used'
- return ''
+ if (status === MembershipStatus.EXPIRED) return 'status-badge--expired'
+ if (status === MembershipStatus.USED_UP) return 'status-badge--used'
+ return 'status-badge--expired'
}
-function cardTopClass(m: MembershipWithCardType): string {
- if (m.cardType.type === CardTypeCategory.TRIAL) return 'card-top--trial'
- if (m.cardType.type === CardTypeCategory.DURATION) return 'card-top--duration'
- return 'card-top--times'
+function stripClass(type: CardTypeCategory): string {
+ if (type === CardTypeCategory.TRIAL) return 'card-strip--trial'
+ if (type === CardTypeCategory.DURATION) return 'card-strip--duration'
+ return 'card-strip--times'
+}
+
+function headerClass(type: CardTypeCategory): string {
+ if (type === CardTypeCategory.TRIAL) return 'card-header--trial'
+ if (type === CardTypeCategory.DURATION) return 'card-header--duration'
+ return 'card-header--times'
}
function progressWidth(m: MembershipWithCardType): string {
@@ -169,11 +207,16 @@ function progressWidth(m: MembershipWithCardType): string {
return `${Math.max(0, Math.min(100, pct))}%`
}
+function usedTimes(m: MembershipWithCardType): number {
+ if (m.remainingTimes === null || !m.cardType.totalTimes) return 0
+ return m.cardType.totalTimes - m.remainingTimes
+}
+
// ─── Data loading ─────────────────────────────────────────
async function loadMemberships() {
loading.value = true
try {
- memberships.value = await get('/membership/my')
+ await userStore.fetchMemberships()
} catch {
uni.showToast({ title: '加载失败,请下拉刷新', icon: 'none' })
} finally {
@@ -183,13 +226,11 @@ async function loadMemberships() {
async function onRefresh() {
refreshing.value = true
- await loadMemberships()
+ await userStore.fetchMemberships()
refreshing.value = false
}
function goStore() {
- uni.navigateBack({ delta: 10 })
- // Navigate to store tab
uni.switchTab({ url: '/pages/home/index' })
}
@@ -216,7 +257,7 @@ onMounted(loadMemberships)
}
.skeleton-card {
- height: 200rpx;
+ height: 220rpx;
border-radius: 20rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 400% 100%;
@@ -255,9 +296,10 @@ onMounted(loadMemberships)
.empty-btn {
margin-top: 12rpx;
- padding: 20rpx 56rpx;
+ padding: 22rpx 60rpx;
border-radius: 44rpx;
background: #c9a87c;
+ box-shadow: 0 4rpx 16rpx rgba(201, 168, 124, 0.35);
}
.empty-btn-text {
@@ -269,17 +311,41 @@ onMounted(loadMemberships)
/* ── List ────────────────────────────────────────────── */
.list {
padding: 24rpx 24rpx 0;
+}
+
+/* ── Group section ───────────────────────────────────── */
+.group-section {
+ margin-bottom: 8rpx;
+}
+
+.group-header {
display: flex;
- flex-direction: column;
- gap: 8rpx;
+ flex-direction: row;
+ align-items: center;
+ gap: 10rpx;
+ padding: 8rpx 4rpx 14rpx;
+}
+
+.group-dot {
+ width: 12rpx;
+ height: 12rpx;
+ border-radius: 50%;
+ flex-shrink: 0;
+
+ &--active { background: #4caf50; }
+ &--inactive { background: #bbb; }
}
.group-title {
font-size: 26rpx;
- color: #999;
- font-weight: 500;
- padding: 8rpx 4rpx 12rpx;
- display: block;
+ color: #555;
+ font-weight: 600;
+ flex: 1;
+}
+
+.group-count {
+ font-size: 22rpx;
+ color: #bbb;
}
/* ── Card item ───────────────────────────────────────── */
@@ -288,15 +354,28 @@ onMounted(loadMemberships)
border-radius: 20rpx;
overflow: hidden;
margin-bottom: 16rpx;
- box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
+ box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.07);
+ display: flex;
+ flex-direction: column;
&--inactive {
- opacity: 0.75;
+ opacity: 0.72;
}
}
-.card-top {
- padding: 24rpx 28rpx;
+/* Colored left border strip */
+.card-strip {
+ height: 6rpx;
+
+ &--times { background: linear-gradient(90deg, #1a1a2e, #2d2d5e); }
+ &--duration { background: linear-gradient(90deg, #6c3483, #9b59b6); }
+ &--trial { background: linear-gradient(90deg, #7d6608, #c9a87c); }
+ &--inactive { background: #ccc; }
+}
+
+/* Card header gradient area */
+.card-header {
+ padding: 22rpx 28rpx;
display: flex;
flex-direction: row;
align-items: center;
@@ -308,46 +387,88 @@ onMounted(loadMemberships)
&--inactive { background: #888; }
}
+.card-header-left {
+ display: flex;
+ flex-direction: column;
+ gap: 8rpx;
+}
+
.card-name {
font-size: 32rpx;
font-weight: 700;
color: #fff;
- display: block;
- margin-bottom: 6rpx;
&--dim { color: #ddd; }
}
-.card-type-tag {
- font-size: 20rpx;
- color: rgba(255, 255, 255, 0.7);
- font-weight: 400;
- display: block;
+.card-type-badge {
+ align-self: flex-start;
+ padding: 4rpx 14rpx;
+ border-radius: 12rpx;
+ background: rgba(255, 255, 255, 0.15);
+ border: 1rpx solid rgba(255, 255, 255, 0.25);
- &--dim { color: rgba(255, 255, 255, 0.5); }
+ &--dim {
+ background: rgba(255, 255, 255, 0.08);
+ }
}
-.card-badge {
+.card-type-badge-text {
+ font-size: 20rpx;
+ color: rgba(255, 255, 255, 0.85);
+ font-weight: 500;
+}
+
+/* Status badge */
+.status-badge {
padding: 8rpx 20rpx;
border-radius: 20rpx;
- border: 1rpx solid rgba(255, 255, 255, 0.4);
+ border: 1rpx solid rgba(255, 255, 255, 0.35);
+ flex-shrink: 0;
- &--active { background: rgba(76, 175, 80, 0.25); }
+ &--active { background: rgba(76, 175, 80, 0.3); }
&--expired { background: rgba(0, 0, 0, 0.2); }
&--used { background: rgba(0, 0, 0, 0.2); }
}
-.badge-text {
+.status-badge-text {
font-size: 22rpx;
color: #fff;
font-weight: 600;
}
+/* Card body */
.card-body {
- padding: 20rpx 28rpx;
+ padding: 20rpx 28rpx 24rpx;
display: flex;
flex-direction: column;
- gap: 12rpx;
+ gap: 10rpx;
+}
+
+.highlight-row {
+ display: flex;
+ flex-direction: row;
+ align-items: baseline;
+ justify-content: space-between;
+ margin-bottom: 4rpx;
+}
+
+.highlight-label {
+ font-size: 26rpx;
+ color: #999;
+}
+
+.highlight-number {
+ font-size: 44rpx;
+ font-weight: 800;
+ color: #c9a87c;
+ line-height: 1;
+}
+
+.highlight-unit {
+ font-size: 22rpx;
+ color: #c9a87c;
+ font-weight: 500;
}
.info-row {
@@ -366,20 +487,14 @@ onMounted(loadMemberships)
font-size: 26rpx;
color: #333;
font-weight: 500;
-
- &--highlight {
- color: #c9a87c;
- font-size: 30rpx;
- font-weight: 700;
- }
}
/* ── Progress bar ────────────────────────────────────── */
.progress-wrap {
- padding: 0 28rpx 20rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
+ margin-bottom: 6rpx;
}
.progress-bar {
@@ -402,11 +517,6 @@ onMounted(loadMemberships)
text-align: right;
}
-/* ── Inactive section ────────────────────────────────── */
-.inactive-section {
- margin-top: 8rpx;
-}
-
/* ── FAB ─────────────────────────────────────────────── */
.fab {
position: fixed;
@@ -417,12 +527,23 @@ onMounted(loadMemberships)
padding: 22rpx 36rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.2);
z-index: 100;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 8rpx;
&:active {
opacity: 0.85;
}
}
+.fab-icon {
+ font-size: 36rpx;
+ color: #c9a87c;
+ font-weight: 300;
+ line-height: 1;
+}
+
.fab-text {
font-size: 28rpx;
font-weight: 700;
@@ -432,6 +553,6 @@ onMounted(loadMemberships)
/* ── Spacer ──────────────────────────────────────────── */
.scroll-bottom-spacer {
- height: 100rpx;
+ height: 120rpx;
}
diff --git a/packages/app/src/stores/admin.ts b/packages/app/src/stores/admin.ts
new file mode 100644
index 0000000..248b1ce
--- /dev/null
+++ b/packages/app/src/stores/admin.ts
@@ -0,0 +1,170 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import { get, post, put, del } from '../utils/request'
+import type {
+ WeekTemplate,
+ WeekTemplateInput,
+ CardType,
+ CreateCardTypeDto,
+ UpdateCardTypeDto,
+ StudioConfig,
+ UpdateStudioConfigDto,
+ OrderWithDetails,
+ TimeSlot,
+ CreateManualSlotDto,
+ PaginatedData,
+} from '@mp-pilates/shared'
+
+export interface AdminStats {
+ todayBookings: number
+ totalOrders: number
+ totalBookings: number
+}
+
+export interface MemberSummary {
+ userId: string
+ nickname: string
+ phone: string | null
+ avatarUrl: string | null
+ totalBookings: number
+ completedBookings: number
+ cancelledBookings: number
+}
+
+export const useAdminStore = defineStore('admin', () => {
+ // ── Week templates ───────────────────────────────────────────────
+ const weekTemplates = ref([])
+
+ async function fetchWeekTemplates(): Promise {
+ const data = await get('/admin/week-template')
+ weekTemplates.value = data
+ return data
+ }
+
+ async function saveWeekTemplates(templates: WeekTemplateInput[]): Promise {
+ const data = await put('/admin/week-template', templates)
+ weekTemplates.value = data
+ return data
+ }
+
+ // ── Card types ───────────────────────────────────────────────────
+ const cardTypes = ref([])
+
+ async function fetchCardTypes(): Promise {
+ const data = await get('/admin/card-types')
+ cardTypes.value = [...data].sort((a, b) => a.sortOrder - b.sortOrder)
+ return cardTypes.value
+ }
+
+ async function createCardType(dto: CreateCardTypeDto): Promise {
+ const data = await post('/admin/card-types', dto)
+ await fetchCardTypes()
+ return data
+ }
+
+ async function updateCardType(id: string, dto: UpdateCardTypeDto): Promise {
+ const data = await put(`/admin/card-types/${id}`, dto)
+ await fetchCardTypes()
+ return data
+ }
+
+ async function deleteCardType(id: string): Promise {
+ await del(`/admin/card-types/${id}`)
+ await fetchCardTypes()
+ }
+
+ // ── Studio config ────────────────────────────────────────────────
+ const studioConfig = ref(null)
+
+ async function fetchStudioConfig(): Promise {
+ const data = await get('/studio/info')
+ studioConfig.value = data
+ return data
+ }
+
+ async function saveStudioConfig(dto: UpdateStudioConfigDto): Promise {
+ const data = await put('/admin/studio/info', dto)
+ studioConfig.value = data
+ return data
+ }
+
+ // ── Orders ───────────────────────────────────────────────────────
+ async function fetchAdminOrders(params: {
+ page?: number
+ limit?: number
+ status?: string
+ }): Promise> {
+ return get>('/admin/orders', params)
+ }
+
+ // ── Bookings ─────────────────────────────────────────────────────
+ async function fetchAdminBookings(params?: {
+ page?: number
+ limit?: number
+ userId?: string
+ }): Promise> {
+ return get>('/admin/bookings', params)
+ }
+
+ // ── Members ──────────────────────────────────────────────────────
+ async function fetchMembers(params?: {
+ page?: number
+ limit?: number
+ search?: string
+ }): Promise> {
+ return get>('/admin/members', params)
+ }
+
+ // ── Time slots ───────────────────────────────────────────────────
+ async function fetchSlotsByDate(date: string): Promise {
+ return get('/admin/time-slots', { date })
+ }
+
+ async function createManualSlot(dto: CreateManualSlotDto): Promise {
+ return post('/admin/time-slot/manual', dto)
+ }
+
+ async function closeSlot(id: string): Promise {
+ return put(`/admin/time-slot/${id}/close`, {})
+ }
+
+ async function generateSlots(startDate: string, endDate: string): Promise<{ count: number }> {
+ return post<{ count: number }>('/admin/generate-slots', { startDate, endDate })
+ }
+
+ // ── Dashboard stats ──────────────────────────────────────────────
+ async function fetchDashboardStats(): Promise {
+ return get('/admin/stats')
+ }
+
+ return {
+ // State
+ weekTemplates,
+ cardTypes,
+ studioConfig,
+ // Week templates
+ fetchWeekTemplates,
+ saveWeekTemplates,
+ // Card types
+ fetchCardTypes,
+ createCardType,
+ updateCardType,
+ deleteCardType,
+ // Studio
+ fetchStudioConfig,
+ saveStudioConfig,
+ // Orders
+ fetchAdminOrders,
+ // Bookings
+ fetchAdminBookings,
+ // Members
+ fetchMembers,
+ // Time slots
+ fetchSlotsByDate,
+ createManualSlot,
+ closeSlot,
+ generateSlots,
+ // Stats
+ fetchDashboardStats,
+ }
+})