feat(app): implement home, booking, and profile pages
Home: brand banner, studio info swiper, smart quick entries based on membership status, upcoming bookings, card shop horizontal scroll Booking: 7-day date selector, time period filter, slot cards with status, booking confirm popup with membership picker Profile: user card with login, training stats, menu with admin entry 8 reusable components: BrandBanner, StudioInfo, QuickEntry, UpcomingBooking, CardShop, DateSelector, SlotCard, BookingConfirmPopup, TimePeriodFilter, UserCard, TrainingStats, ProfileMenu
This commit is contained in:
@@ -1,15 +1,512 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="placeholder">
|
||||
<text>时段调整 - 待实现</text>
|
||||
<!-- Tabs -->
|
||||
<view class="tabs">
|
||||
<view
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="tab"
|
||||
:class="{ 'tab--active': activeTab === tab.key }"
|
||||
@tap="activeTab = tab.key"
|
||||
>
|
||||
<text class="tab-text">{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ── Tab: Manual add ───────────────────── -->
|
||||
<view v-if="activeTab === 'add'" class="section">
|
||||
<text class="section-title">手动新增时段</text>
|
||||
|
||||
<view class="form-card">
|
||||
<view class="form-row">
|
||||
<text class="form-label">日期</text>
|
||||
<picker mode="date" :value="addForm.date" @change="(e: any) => addForm.date = e.detail.value">
|
||||
<view class="picker-display">
|
||||
<text class="picker-text">{{ addForm.date }}</text>
|
||||
<text class="picker-arrow">›</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="form-row">
|
||||
<text class="form-label">开始时间</text>
|
||||
<picker mode="time" :value="addForm.startTime" @change="(e: any) => addForm.startTime = e.detail.value">
|
||||
<view class="picker-display">
|
||||
<text class="picker-text">{{ addForm.startTime }}</text>
|
||||
<text class="picker-arrow">›</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="form-row">
|
||||
<text class="form-label">结束时间</text>
|
||||
<picker mode="time" :value="addForm.endTime" @change="(e: any) => addForm.endTime = e.detail.value">
|
||||
<view class="picker-display">
|
||||
<text class="picker-text">{{ addForm.endTime }}</text>
|
||||
<text class="picker-arrow">›</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="form-row form-row--last">
|
||||
<text class="form-label">容量(人)</text>
|
||||
<input
|
||||
class="form-input"
|
||||
type="number"
|
||||
v-model="addForm.capacityStr"
|
||||
placeholder="默认10"
|
||||
placeholder-style="color:#bbb"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="action-btn primary-btn"
|
||||
:class="{ 'primary-btn--loading': addingSlot }"
|
||||
@tap="handleAddSlot"
|
||||
>
|
||||
<text class="primary-btn-text">{{ addingSlot ? '添加中...' : '添加时段' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ── Tab: Close slots ──────────────────── -->
|
||||
<view v-else-if="activeTab === 'close'" class="section">
|
||||
<view class="search-row">
|
||||
<picker mode="date" :value="closeDateFilter" @change="(e: any) => { closeDateFilter = e.detail.value; fetchSlotsForClose() }">
|
||||
<view class="date-filter">
|
||||
<text class="date-filter-text">{{ closeDateFilter }}</text>
|
||||
<text class="date-filter-arrow">›</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view v-if="loadingClose" class="skeleton-list">
|
||||
<view v-for="i in 3" :key="i" class="skeleton-item" />
|
||||
</view>
|
||||
|
||||
<view v-else-if="!closeSlots.length" class="empty-state">
|
||||
<text class="empty-icon">🗓️</text>
|
||||
<text class="empty-text">该日暂无时段</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="slot-list">
|
||||
<view
|
||||
v-for="slot in closeSlots"
|
||||
:key="slot.id"
|
||||
class="slot-card"
|
||||
:class="{ 'slot-card--closed': slot.status === 'CLOSED' }"
|
||||
>
|
||||
<view class="slot-info">
|
||||
<text class="slot-time">{{ slot.startTime.slice(0, 5) }}–{{ slot.endTime.slice(0, 5) }}</text>
|
||||
<text class="slot-cap">容量 {{ slot.capacity }} · 已预约 {{ slot.bookedCount }}</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="slot.status !== 'CLOSED'"
|
||||
class="close-btn"
|
||||
@tap="confirmClose(slot)"
|
||||
>
|
||||
<text class="close-btn-text">关闭</text>
|
||||
</view>
|
||||
<view v-else class="closed-tag">
|
||||
<text class="closed-tag-text">已关闭</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ── Tab: Generate ─────────────────────── -->
|
||||
<view v-else-if="activeTab === 'generate'" class="section">
|
||||
<text class="section-title">按模板生成时段</text>
|
||||
<text class="section-sub">将依据当前排课模板,生成未来指定天数的课程时段(已存在的时段不会重复生成)。</text>
|
||||
|
||||
<view class="form-card">
|
||||
<view class="form-row form-row--last">
|
||||
<text class="form-label">生成天数</text>
|
||||
<input
|
||||
class="form-input"
|
||||
type="number"
|
||||
v-model="generateDaysStr"
|
||||
placeholder="如:14"
|
||||
placeholder-style="color:#bbb"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="action-btn primary-btn"
|
||||
:class="{ 'primary-btn--loading': generating }"
|
||||
@tap="handleGenerate"
|
||||
>
|
||||
<text class="primary-btn-text">{{ generating ? '生成中...' : '生成时段' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post, put } from '../../utils/request'
|
||||
import type { TimeSlot } from '@mp-pilates/shared'
|
||||
|
||||
const tabs = [
|
||||
{ key: 'add', label: '新增时段' },
|
||||
{ key: 'close', label: '关闭时段' },
|
||||
{ key: 'generate', label: '批量生成' },
|
||||
]
|
||||
|
||||
const activeTab = ref<string>('add')
|
||||
|
||||
// ── Add slot form ─────────────────────────────────
|
||||
const todayStr = new Date().toISOString().slice(0, 10)
|
||||
const addForm = ref({
|
||||
date: todayStr,
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
capacityStr: '10',
|
||||
})
|
||||
const addingSlot = ref(false)
|
||||
|
||||
async function handleAddSlot() {
|
||||
if (addingSlot.value) return
|
||||
const capacity = parseInt(addForm.value.capacityStr, 10)
|
||||
if (!addForm.value.date || !addForm.value.startTime || !addForm.value.endTime) {
|
||||
uni.showToast({ title: '请完整填写信息', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
addingSlot.value = true
|
||||
try {
|
||||
await post('/admin/time-slot/manual', {
|
||||
date: addForm.value.date,
|
||||
startTime: addForm.value.startTime,
|
||||
endTime: addForm.value.endTime,
|
||||
capacity: isNaN(capacity) ? undefined : capacity,
|
||||
})
|
||||
uni.showToast({ title: '时段已添加', icon: 'success' })
|
||||
addForm.value = { date: todayStr, startTime: '09:00', endTime: '10:00', capacityStr: '10' }
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e?.message ?? '添加失败', icon: 'none' })
|
||||
} finally {
|
||||
addingSlot.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Close slots ────────────────────────────────────
|
||||
interface SlotRow extends TimeSlot {
|
||||
bookedCount: number
|
||||
}
|
||||
|
||||
const closeDateFilter = ref(todayStr)
|
||||
const closeSlots = ref<SlotRow[]>([])
|
||||
const loadingClose = ref(false)
|
||||
|
||||
async function fetchSlotsForClose() {
|
||||
loadingClose.value = true
|
||||
try {
|
||||
const data = await get<SlotRow[]>(`/admin/time-slots?date=${closeDateFilter.value}`)
|
||||
closeSlots.value = data
|
||||
} catch {
|
||||
closeSlots.value = []
|
||||
} finally {
|
||||
loadingClose.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmClose(slot: SlotRow) {
|
||||
uni.showModal({
|
||||
title: '关闭时段',
|
||||
content: `确认关闭 ${slot.startTime.slice(0, 5)}–${slot.endTime.slice(0, 5)} 的时段?`,
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await put(`/admin/time-slot/${slot.id}/close`, {})
|
||||
uni.showToast({ title: '已关闭', icon: 'success' })
|
||||
await fetchSlotsForClose()
|
||||
} catch {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── Generate slots ─────────────────────────────────
|
||||
const generateDaysStr = ref('14')
|
||||
const generating = ref(false)
|
||||
|
||||
async function handleGenerate() {
|
||||
if (generating.value) return
|
||||
const days = parseInt(generateDaysStr.value, 10)
|
||||
if (isNaN(days) || days < 1 || days > 90) {
|
||||
uni.showToast({ title: '请输入 1–90 天', icon: 'none' })
|
||||
return
|
||||
}
|
||||
generating.value = true
|
||||
try {
|
||||
await post('/admin/generate-slots', { days })
|
||||
uni.showToast({ title: '生成成功', icon: 'success' })
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e?.message ?? '生成失败', icon: 'none' })
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchSlotsForClose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page { min-height: 100vh; background: #f5f5f5; }
|
||||
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #f5f3f0;
|
||||
}
|
||||
|
||||
/* ── Tabs ────────────────────────────────── */
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: #ffffff;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 28rpx 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
|
||||
&--active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 20%;
|
||||
right: 20%;
|
||||
height: 4rpx;
|
||||
background: #1a1a2e;
|
||||
border-radius: 2rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
|
||||
.tab--active & {
|
||||
color: #1a1a2e;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Section ─────────────────────────────── */
|
||||
.section {
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
display: block;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.section-sub {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
line-height: 1.6;
|
||||
display: block;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
/* ── Form card ───────────────────────────── */
|
||||
.form-card {
|
||||
background: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 28rpx 28rpx;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
|
||||
&--last {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 28rpx;
|
||||
color: #555;
|
||||
width: 160rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.picker-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.picker-text {
|
||||
font-size: 28rpx;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.picker-arrow {
|
||||
font-size: 28rpx;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: 28rpx;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
/* ── Buttons ─────────────────────────────── */
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
border-radius: 44rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
|
||||
|
||||
&--loading {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.primary-btn-text {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #c9a87c;
|
||||
}
|
||||
|
||||
/* ── Close tab ───────────────────────────── */
|
||||
.search-row {
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.date-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
background: #ffffff;
|
||||
border-radius: 32rpx;
|
||||
padding: 12rpx 24rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.date-filter-text {
|
||||
font-size: 26rpx;
|
||||
color: #1a1a2e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.date-filter-arrow {
|
||||
font-size: 26rpx;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.skeleton-list {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.skeleton-item {
|
||||
height: 100rpx;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 12rpx;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 100% 0; }
|
||||
100% { background-position: -100% 0; }
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 80rpx 0;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.empty-icon { font-size: 80rpx; }
|
||||
.empty-text { font-size: 28rpx; color: #bbb; }
|
||||
|
||||
.slot-list {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.slot-card {
|
||||
background: #ffffff;
|
||||
border-radius: 12rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
margin-bottom: 12rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.06);
|
||||
|
||||
&--closed {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.slot-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.slot-time {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.slot-cap {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: #fde8e8;
|
||||
border-radius: 8rpx;
|
||||
padding: 12rpx 28rpx;
|
||||
}
|
||||
|
||||
.close-btn-text {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.closed-tag {
|
||||
background: #f0f0f0;
|
||||
border-radius: 8rpx;
|
||||
padding: 12rpx 28rpx;
|
||||
}
|
||||
|
||||
.closed-tag-text {
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user