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
This commit is contained in:
richarjiang
2026-04-02 15:25:57 +08:00
parent 3a29aca0db
commit 7a06b5e336
12 changed files with 1809 additions and 1680 deletions

View File

@@ -3,107 +3,98 @@
<!-- Tabs -->
<view class="tabs">
<view
v-for="tab in tabs"
:key="tab.key"
v-for="(tab, i) in tabs"
:key="i"
class="tab"
:class="{ 'tab--active': activeTab === tab.key }"
@tap="activeTab = tab.key"
:class="{ 'tab--active': activeTab === i }"
@tap="activeTab = i"
>
<text class="tab-text">{{ tab.label }}</text>
<text class="tab-text">{{ tab }}</text>
</view>
</view>
<!-- Tab: Manual add -->
<view v-if="activeTab === 'add'" class="section">
<text class="section-title">手动新增时段</text>
<!-- Add slot -->
<view v-if="activeTab === 0" class="panel">
<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-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-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-text">{{ addForm.endTime || '请选择' }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="form-row form-row--last">
<text class="form-label">容量</text>
<text class="form-label">容量</text>
<input
class="form-input"
type="number"
v-model="addForm.capacityStr"
placeholder="默认10"
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 class="action-wrap">
<view class="action-btn" :class="{ 'action-btn--loading': submitting }" @tap="submitAddSlot">
<text class="action-btn-text">{{ submitting ? '提交中...' : '新增时段' }}</text>
</view>
</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>
<!-- Close slot -->
<view v-else-if="activeTab === 1" class="panel">
<view class="date-picker-row">
<picker mode="date" :value="closeDate" @change="(e: any) => { closeDate = e.detail.value; loadSlotsForClose() }">
<view class="picker-display">
<text class="picker-label">选择日期</text>
<text class="picker-text">{{ closeDate }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view v-if="loadingClose" class="skeleton-list">
<view v-for="i in 3" :key="i" class="skeleton-item" />
<view v-if="slotsLoading" class="skeleton-list">
<view v-for="i in 4" :key="i" class="skeleton-item" />
</view>
<view v-else-if="!closeSlots.length" class="empty-state">
<text class="empty-icon">🗓</text>
<view v-else-if="!daySlots.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 v-for="slot in daySlots" :key="slot.id" class="slot-row">
<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>
<text class="slot-time">{{ slot.startTime }} {{ slot.endTime }}</text>
<view class="slot-badge" :class="slotBadgeClass(slot.status)">
<text class="slot-badge-text">{{ slot.status }}</text>
</view>
</view>
<text class="slot-count">{{ slot.bookedCount }}/{{ slot.capacity }}</text>
<view
v-if="slot.status !== 'CLOSED'"
class="close-btn"
@tap="confirmClose(slot)"
@tap="closeSlot(slot)"
>
<text class="close-btn-text">关闭</text>
</view>
@@ -114,114 +105,107 @@
</view>
</view>
<!-- Tab: Generate -->
<view v-else-if="activeTab === 'generate'" class="section">
<text class="section-title">按模板生成时段</text>
<text class="section-sub">将依据当前排课模板生成未来指定天数的课程时段已存在的时段不会重复生成</text>
<!-- Batch generate -->
<view v-else class="panel">
<view class="form-card">
<view class="form-row">
<text class="form-label">开始日期</text>
<picker mode="date" :value="genForm.startDate" @change="(e: any) => genForm.startDate = e.detail.value">
<view class="picker-display">
<text class="picker-text">{{ genForm.startDate || '请选择' }}</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="generateDaysStr"
placeholder="如14"
placeholder-style="color:#bbb"
/>
<text class="form-label">结束日期</text>
<picker mode="date" :value="genForm.endDate" @change="(e: any) => genForm.endDate = e.detail.value">
<view class="picker-display">
<text class="picker-text">{{ genForm.endDate || '请选择' }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
</view>
<view
class="action-btn primary-btn"
:class="{ 'primary-btn--loading': generating }"
@tap="handleGenerate"
>
<text class="primary-btn-text">{{ generating ? '生成中...' : '生成时段' }}</text>
<text class="gen-hint">将根据排课模板自动生成所选日期范围内的时段</text>
<view class="action-wrap">
<view class="action-btn" :class="{ 'action-btn--loading': submitting }" @tap="submitGenerate">
<text class="action-btn-text">{{ submitting ? '生成中...' : '批量生成' }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { get, post, put } from '../../utils/request'
import { ref } from 'vue'
import { useAdminStore } from '../../stores/admin'
import { formatDate } from '../../utils/format'
import type { TimeSlot } from '@mp-pilates/shared'
const tabs = [
{ key: 'add', label: '新增时段' },
{ key: 'close', label: '关闭时段' },
{ key: 'generate', label: '批量生成' },
]
const adminStore = useAdminStore()
const activeTab = ref<string>('add')
const tabs = ['新增时段', '关闭时段', '批量生成']
const activeTab = ref(0)
const submitting = ref(false)
const slotsLoading = ref(false)
// ── Add slot form ─────────────────────────────────
const todayStr = new Date().toISOString().slice(0, 10)
// ── Add slot form ────────────────────────────────────────────────
const addForm = ref({
date: todayStr,
date: formatDate(new Date()),
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)
async function submitAddSlot() {
if (submitting.value) return
if (!addForm.value.date || !addForm.value.startTime || !addForm.value.endTime) {
uni.showToast({ title: '请完整填写信息', icon: 'none' })
uni.showToast({ title: '请填写完整信息', icon: 'none' })
return
}
addingSlot.value = true
const capacity = parseInt(addForm.value.capacityStr, 10)
submitting.value = true
try {
await post('/admin/time-slot/manual', {
await adminStore.createManualSlot({
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' }
uni.showToast({ title: '新增成功', icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e?.message ?? '添加失败', icon: 'none' })
uni.showToast({ title: e?.message ?? '新增失败', icon: 'none' })
} finally {
addingSlot.value = false
submitting.value = false
}
}
// ── Close slots ────────────────────────────────────
interface SlotRow extends TimeSlot {
bookedCount: number
}
// ── Close slot ────────────────────────────────────────────────────
const closeDate = ref(formatDate(new Date()))
const daySlots = ref<TimeSlot[]>([])
const closeDateFilter = ref(todayStr)
const closeSlots = ref<SlotRow[]>([])
const loadingClose = ref(false)
async function fetchSlotsForClose() {
loadingClose.value = true
async function loadSlotsForClose() {
slotsLoading.value = true
try {
const data = await get<SlotRow[]>(`/admin/time-slots?date=${closeDateFilter.value}`)
closeSlots.value = data
daySlots.value = await adminStore.fetchSlotsByDate(closeDate.value)
} catch {
closeSlots.value = []
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loadingClose.value = false
slotsLoading.value = false
}
}
function confirmClose(slot: SlotRow) {
async function closeSlot(slot: TimeSlot) {
uni.showModal({
title: '关闭时段',
content: `确认关闭 ${slot.startTime.slice(0, 5)}${slot.endTime.slice(0, 5)} 时段?`,
title: '确认关闭',
content: `关闭 ${slot.startTime}${slot.endTime} 时段?`,
success: async (res) => {
if (res.confirm) {
try {
await put(`/admin/time-slot/${slot.id}/close`, {})
await adminStore.closeSlot(slot.id)
uni.showToast({ title: '已关闭', icon: 'success' })
await fetchSlotsForClose()
await loadSlotsForClose()
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
}
@@ -230,44 +214,48 @@ function confirmClose(slot: SlotRow) {
})
}
// ── Generate slots ─────────────────────────────────
const generateDaysStr = ref('14')
const generating = ref(false)
function slotBadgeClass(status: string) {
if (status === 'OPEN') return 'badge--open'
if (status === 'FULL') return 'badge--full'
return 'badge--closed'
}
async function handleGenerate() {
if (generating.value) return
const days = parseInt(generateDaysStr.value, 10)
if (isNaN(days) || days < 1 || days > 90) {
uni.showToast({ title: '请输入 190 天', icon: 'none' })
// ── Batch generate ────────────────────────────────────────────────
const genForm = ref({
startDate: formatDate(new Date()),
endDate: formatDate(new Date(Date.now() + 7 * 86400000)),
})
async function submitGenerate() {
if (submitting.value) return
if (!genForm.value.startDate || !genForm.value.endDate) {
uni.showToast({ title: '请选择日期范围', icon: 'none' })
return
}
generating.value = true
submitting.value = true
try {
await post('/admin/generate-slots', { days })
uni.showToast({ title: '生成成功', icon: 'success' })
const result = await adminStore.generateSlots(genForm.value.startDate, genForm.value.endDate)
uni.showToast({ title: `生成 ${result.count} 个时段`, icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e?.message ?? '生成失败', icon: 'none' })
} finally {
generating.value = false
submitting.value = false
}
}
onMounted(() => {
fetchSlotsForClose()
})
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f3f0;
padding-bottom: 40rpx;
}
/* ── Tabs ────────────────────────────────── */
.tabs {
display: flex;
background: #ffffff;
border-bottom: 1rpx solid #f0f0f0;
border-bottom: 1rpx solid #eee;
}
.tab {
@@ -276,57 +264,34 @@ onMounted(() => {
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;
}
font-weight: 500;
}
/* ── Section ─────────────────────────────── */
.section {
padding: 24rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 700;
.tab--active .tab-text {
color: #1a1a2e;
display: block;
margin-bottom: 8rpx;
font-weight: 700;
}
.section-sub {
font-size: 24rpx;
color: #999;
line-height: 1.6;
display: block;
margin-bottom: 24rpx;
.tab--active {
border-bottom: 4rpx solid #c9a87c;
}
/* ── Panel ───────────────────────────────── */
.panel {
padding: 24rpx;
}
/* ── Form card ───────────────────────────── */
.form-card {
background: #ffffff;
border-radius: 16rpx;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05);
margin-bottom: 24rpx;
}
@@ -337,99 +302,34 @@ onMounted(() => {
padding: 28rpx 28rpx;
border-bottom: 1rpx solid #f5f5f5;
&--last {
border-bottom: none;
}
&--last { border-bottom: none; }
}
.form-label {
font-size: 28rpx;
color: #555;
width: 160rpx;
flex-shrink: 0;
}
.form-label { font-size: 28rpx; color: #555; width: 160rpx; flex-shrink: 0; }
.picker-display {
display: flex;
align-items: center;
gap: 8rpx;
}
.form-input { flex: 1; text-align: right; font-size: 28rpx; color: #222; background: transparent; }
.picker-text {
font-size: 28rpx;
color: #222;
}
.picker-display { display: flex; align-items: center; gap: 8rpx; }
.picker-label { font-size: 28rpx; color: #555; }
.picker-text { font-size: 28rpx; color: #222; }
.picker-arrow { font-size: 26rpx; color: #bbb; }
.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;
/* ── Date picker row ─────────────────────── */
.date-picker-row {
background: #ffffff;
border-radius: 32rpx;
padding: 12rpx 24rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
border-radius: 16rpx;
padding: 24rpx 28rpx;
margin-bottom: 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05);
}
.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 ────────────────────────────── */
.skeleton-list { }
.skeleton-item {
height: 100rpx;
height: 88rpx;
border-radius: 12rpx;
margin-bottom: 12rpx;
margin-bottom: 16rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
@@ -440,73 +340,88 @@ onMounted(() => {
100% { background-position: -100% 0; }
}
/* ── Empty ───────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 80rpx 0;
gap: 20rpx;
gap: 16rpx;
}
.empty-icon { font-size: 80rpx; }
.empty-icon { font-size: 64rpx; }
.empty-text { font-size: 28rpx; color: #bbb; }
.slot-list {
margin-top: 8rpx;
}
/* ── Slot list ───────────────────────────── */
.slot-list { }
.slot-card {
.slot-row {
background: #ffffff;
border-radius: 12rpx;
padding: 24rpx 28rpx;
margin-bottom: 12rpx;
padding: 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.06);
&--closed {
opacity: 0.5;
}
gap: 16rpx;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
}
.slot-info {
display: flex;
flex-direction: column;
gap: 6rpx;
.slot-info { flex: 1; display: flex; align-items: center; gap: 12rpx; }
.slot-time { font-size: 28rpx; font-weight: 600; color: #1a1a2e; }
.slot-badge {
border-radius: 16rpx;
padding: 4rpx 16rpx;
}
.slot-time {
font-size: 30rpx;
font-weight: 700;
color: #1a1a2e;
}
.badge--open { background: rgba(39,174,96,0.1); }
.badge--open .slot-badge-text { font-size: 20rpx; color: #27ae60; }
.badge--full { background: rgba(230,126,34,0.1); }
.badge--full .slot-badge-text { font-size: 20rpx; color: #e67e22; }
.badge--closed { background: rgba(0,0,0,0.06); }
.badge--closed .slot-badge-text { font-size: 20rpx; color: #999; }
.slot-cap {
font-size: 22rpx;
color: #999;
}
.slot-count { font-size: 24rpx; color: #888; }
.close-btn {
background: #fde8e8;
border-radius: 8rpx;
padding: 12rpx 28rpx;
background: rgba(192,57,43,0.1);
border-radius: 20rpx;
padding: 8rpx 24rpx;
}
.close-btn-text {
font-size: 26rpx;
font-weight: 600;
color: #c0392b;
}
.close-btn-text { font-size: 26rpx; color: #c0392b; font-weight: 600; }
.closed-tag {
background: #f0f0f0;
border-radius: 8rpx;
padding: 12rpx 28rpx;
background: rgba(0,0,0,0.06);
border-radius: 20rpx;
padding: 8rpx 24rpx;
}
.closed-tag-text {
font-size: 26rpx;
.closed-tag-text { font-size: 26rpx; color: #bbb; }
/* ── Generate hint ───────────────────────── */
.gen-hint {
font-size: 24rpx;
color: #999;
line-height: 1.6;
display: block;
padding: 0 8rpx 24rpx;
}
/* ── Action button ───────────────────────── */
.action-wrap { }
.action-btn {
width: 100%;
height: 96rpx;
border-radius: 48rpx;
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
display: flex;
align-items: center;
justify-content: center;
&--loading { opacity: 0.6; }
}
.action-btn-text { font-size: 30rpx; font-weight: 700; color: #c9a87c; }
</style>