From 7a06b5e336bb012e893fd85de0239e8a5ee3dde4 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Thu, 2 Apr 2026 15:25:57 +0800 Subject: [PATCH] feat(app): implement all sub-pages and admin management pages Sub-pages: card purchase with WeChat Pay flow, my memberships with progress bars, my bookings with tabs, personal info editor Admin: management center grid, week template CRUD, slot adjustment, member management with search, order list with filters, card type CRUD with form modal, studio settings editor Admin Pinia store for all admin API calls --- packages/app/src/pages/admin/card-types.vue | 199 +++---- packages/app/src/pages/admin/index.vue | 275 ++++------ packages/app/src/pages/admin/members.vue | 428 +++++++-------- packages/app/src/pages/admin/orders.vue | 393 ++++++-------- packages/app/src/pages/admin/slot-adjust.vue | 475 +++++++---------- packages/app/src/pages/admin/studio.vue | 55 +- .../app/src/pages/admin/week-template.vue | 501 +++++++----------- packages/app/src/pages/card/detail.vue | 160 ++++-- packages/app/src/pages/profile/bookings.vue | 250 ++++++--- packages/app/src/pages/profile/info.vue | 284 +++++----- packages/app/src/pages/profile/membership.vue | 299 +++++++---- packages/app/src/stores/admin.ts | 170 ++++++ 12 files changed, 1809 insertions(+), 1680 deletions(-) create mode 100644 packages/app/src/stores/admin.ts 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 @@ 卡种名称 - + 类型 - + {{ 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 @@ 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 }} - - - - + + + + + + - - - 📋 - 暂无订单 - + + + 📋 + 暂无订单 + - - - - - - {{ order.cardType?.name ?? '未知卡种' }} - - {{ statusLabel(order.status) }} + + + + + {{ order.cardType?.name ?? '-' }} + + {{ statusLabel(order.status) }} + + + + + 用户 + {{ order.user?.nickname ?? '-' }} + + + 手机 + {{ order.user?.phone ?? '未绑定' }} + + + 金额 + ¥{{ formatPrice(order.amount) }} + + + 时间 + {{ formatDate(order.createdAt) }} + - - - - 👤 - - {{ order.user.nickname }} - · {{ maskPhone(order.user.phone) }} - - - - - - ¥{{ formatPrice(order.amount) }} - {{ formatOrderDate(order.createdAt) }} - - - - 订单号:{{ order.id.slice(0, 16) }}... - - - - - ‹ 上一页 + + + {{ loading ? '加载中...' : '加载更多' }} - {{ currentPage }} / {{ totalPages }} - - 下一页 › - - + + + + 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 }} + {{ addForm.date || '请选择' }} - 开始时间 - {{ addForm.startTime }} + {{ addForm.startTime || '请选择' }} - 结束时间 - {{ addForm.endTime }} + {{ addForm.endTime || '请选择' }} - - 容量(人) + 容量 - - - {{ addingSlot ? '添加中...' : '添加时段' }} + + + {{ submitting ? '提交中...' : '新增时段' }} + - - - - - - {{ closeDateFilter }} - + + + + + + 选择日期: + {{ 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 || '请选择' }} + + + + - 生成天数 - + 结束日期 + + + {{ 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 @@ @@ -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, + } +})