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