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

@@ -1,86 +1,83 @@
<template>
<view class="page">
<!-- Top toolbar -->
<!-- Toolbar -->
<view class="toolbar">
<text class="toolbar-hint"> {{ templates.length }} 条模板</text>
<view class="add-btn" @tap="openAdd">
<text class="add-btn-text"> 新增</text>
<text class="add-btn-text"> 新增时段</text>
</view>
</view>
<!-- Loading skeleton -->
<view v-if="loading" class="skeleton-list">
<view v-for="i in 4" :key="i" class="skeleton-item" />
<view v-for="i in 5" :key="i" class="skeleton-item" />
</view>
<!-- Empty -->
<view v-else-if="!templates.length" class="empty-state">
<text class="empty-icon">📅</text>
<text class="empty-text">暂无排课模板点击右上角新增</text>
<text class="empty-text">暂无模板点击右上角新增</text>
</view>
<!-- Template list grouped by weekday -->
<template v-else>
<view v-for="day in weekDays" :key="day.value" class="day-group">
<view v-else>
<view v-for="(group, day) in grouped" :key="day" class="day-group">
<view class="day-header">
<text class="day-label">{{ day.label }}</text>
<text class="day-count">{{ dayTemplates(day.value).length }} </text>
</view>
<view v-if="!dayTemplates(day.value).length" class="day-empty">
<text class="day-empty-text">该天无课</text>
<text class="day-label">{{ WEEKDAY_LABELS[Number(day)] }}</text>
<text class="day-count">{{ group.length }} 个时段</text>
</view>
<view
v-for="tpl in dayTemplates(day.value)"
:key="tpl.id"
class="tpl-card"
:class="{ 'tpl-card--inactive': !tpl.isActive }"
v-for="tpl in group"
:key="tpl.id ?? tpl._key"
class="tpl-row"
:class="{ 'tpl-row--inactive': !tpl.isActive }"
>
<view class="tpl-main">
<view class="tpl-time-block">
<text class="tpl-time">{{ tpl.startTime.slice(0, 5) }}{{ tpl.endTime.slice(0, 5) }}</text>
<view class="tpl-status-dot" :class="tpl.isActive ? 'dot--active' : 'dot--inactive'" />
</view>
<view class="tpl-meta">
<text class="tpl-capacity">容量 {{ tpl.capacity }} </text>
<text class="tpl-active-label">{{ tpl.isActive ? '启用中' : '已停用' }}</text>
</view>
<view class="tpl-time">
<text class="tpl-time-text">{{ tpl.startTime }} {{ tpl.endTime }}</text>
<text class="tpl-capacity">{{ tpl.capacity }} </text>
</view>
<view class="tpl-actions">
<view class="action-btn edit-btn" @tap="openEdit(tpl)">
<text class="action-btn-text">编辑</text>
</view>
<view
class="action-btn toggle-btn"
:class="tpl.isActive ? 'toggle-btn--off' : 'toggle-btn--on'"
@tap="toggleActive(tpl)"
class="tpl-toggle"
:class="tpl.isActive ? 'toggle--on' : 'toggle--off'"
@tap="toggleTemplate(tpl)"
>
<text class="action-btn-text">{{ tpl.isActive ? '用' : '用' }}</text>
<text class="tpl-toggle-text">{{ tpl.isActive ? '用' : '用' }}</text>
</view>
<view class="action-btn delete-btn" @tap="confirmDelete(tpl)">
<text class="action-btn-text">删除</text>
<view class="tpl-edit" @tap="openEdit(tpl)">
<text class="tpl-edit-text">编辑</text>
</view>
<view class="tpl-delete" @tap="deleteTemplate(tpl)">
<text class="tpl-delete-text">删除</text>
</view>
</view>
</view>
</view>
</template>
</view>
<!-- Save all button -->
<view v-if="dirty" class="save-bar">
<view class="save-bar-btn" :class="{ 'save-bar-btn--loading': saving }" @tap="saveAll">
<text class="save-bar-text">{{ saving ? '保存中...' : '保存全部更改' }}</text>
<!-- Save bar -->
<view v-if="isDirty" class="save-bar">
<view class="save-btn" :class="{ 'save-btn--loading': saving }" @tap="handleSave">
<text class="save-btn-text">{{ saving ? '保存中...' : '保存全部更改' }}</text>
</view>
</view>
<!-- Add / Edit modal -->
<view v-if="showModal" class="modal-mask" @tap.self="closeModal">
<view class="modal">
<text class="modal-title">{{ editTarget ? '编辑模板' : '新增模板' }}</text>
<text class="modal-title">{{ editTarget ? '编辑时段' : '新增时段' }}</text>
<view class="modal-field">
<text class="modal-label">星期</text>
<picker mode="selector" :range="weekDays" range-key="label" :value="form.dayOfWeek" @change="onDayChange">
<picker
mode="selector"
:range="dayOptions"
range-key="label"
:value="form.dayIdx"
@change="(e: any) => form.dayIdx = Number(e.detail.value)"
>
<view class="picker-display">
<text class="picker-text">{{ weekDays[form.dayOfWeek].label }}</text>
<text class="picker-text">{{ dayOptions[form.dayIdx].label }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
@@ -88,9 +85,13 @@
<view class="modal-field">
<text class="modal-label">开始时间</text>
<picker mode="time" :value="form.startTime" @change="(e: any) => form.startTime = e.detail.value">
<picker
mode="time"
:value="form.startTime"
@change="(e: any) => form.startTime = e.detail.value"
>
<view class="picker-display">
<text class="picker-text">{{ form.startTime }}</text>
<text class="picker-text">{{ form.startTime || '请选择' }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
@@ -98,16 +99,20 @@
<view class="modal-field">
<text class="modal-label">结束时间</text>
<picker mode="time" :value="form.endTime" @change="(e: any) => form.endTime = e.detail.value">
<picker
mode="time"
:value="form.endTime"
@change="(e: any) => form.endTime = e.detail.value"
>
<view class="picker-display">
<text class="picker-text">{{ form.endTime }}</text>
<text class="picker-text">{{ form.endTime || '请选择' }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="modal-field">
<text class="modal-label">容量</text>
<view class="modal-field modal-field--last">
<text class="modal-label">容量</text>
<input
class="modal-input"
type="number"
@@ -121,8 +126,8 @@
<view class="modal-cancel" @tap="closeModal">
<text class="modal-cancel-text">取消</text>
</view>
<view class="modal-confirm" :class="{ 'modal-confirm--loading': submitting }" @tap="submitForm">
<text class="modal-confirm-text">{{ submitting ? '保存中...' : '确认' }}</text>
<view class="modal-confirm" @tap="submitForm">
<text class="modal-confirm-text">确认</text>
</view>
</view>
</view>
@@ -132,43 +137,54 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { get, put } from '../../utils/request'
import type { WeekTemplate, WeekTemplateInput } from '@mp-pilates/shared'
import { useAdminStore } from '../../stores/admin'
import { WEEKDAY_LABELS } from '@mp-pilates/shared'
import type { WeekTemplate } from '@mp-pilates/shared'
const templates = ref<WeekTemplate[]>([])
type LocalTemplate = Partial<WeekTemplate> & {
_key?: string
dayOfWeek: number
startTime: string
endTime: string
capacity: number
isActive: boolean
}
const adminStore = useAdminStore()
const loading = ref(false)
const saving = ref(false)
const dirty = ref(false)
const isDirty = ref(false)
const showModal = ref(false)
const submitting = ref(false)
const editTarget = ref<WeekTemplate | null>(null)
const editTarget = ref<LocalTemplate | null>(null)
const weekDays = [
{ label: '周一', value: 1 },
{ label: '周二', value: 2 },
{ label: '周三', value: 3 },
{ label: '周四', value: 4 },
{ label: '周五', value: 5 },
{ label: '周六', value: 6 },
{ label: '周日', value: 0 },
]
const templates = ref<LocalTemplate[]>([])
const dayOptions = [1, 2, 3, 4, 5, 6, 7].map((d) => ({ label: WEEKDAY_LABELS[d], value: d }))
const form = ref({
dayOfWeek: 0,
dayIdx: 0,
startTime: '09:00',
endTime: '10:00',
capacityStr: '10',
})
function dayTemplates(dayVal: number) {
return templates.value.filter((t) => t.dayOfWeek === dayVal)
}
const grouped = computed(() => {
const map: Record<number, LocalTemplate[]> = {}
for (const tpl of templates.value) {
if (!map[tpl.dayOfWeek]) map[tpl.dayOfWeek] = []
map[tpl.dayOfWeek].push(tpl)
}
// Sort by day
return Object.fromEntries(
Object.entries(map).sort(([a], [b]) => Number(a) - Number(b)),
)
})
async function fetchTemplates() {
loading.value = true
try {
const data = await get<WeekTemplate[]>('/admin/week-template')
templates.value = data
templates.value = await adminStore.fetchWeekTemplates()
isDirty.value = false
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
@@ -178,16 +194,17 @@ async function fetchTemplates() {
function openAdd() {
editTarget.value = null
form.value = { dayOfWeek: 0, startTime: '09:00', endTime: '10:00', capacityStr: '10' }
form.value = { dayIdx: 0, startTime: '09:00', endTime: '10:00', capacityStr: '10' }
showModal.value = true
}
function openEdit(tpl: WeekTemplate) {
function openEdit(tpl: LocalTemplate) {
editTarget.value = tpl
const dayIdx = dayOptions.findIndex((d) => d.value === tpl.dayOfWeek)
form.value = {
dayOfWeek: weekDays.findIndex((d) => d.value === tpl.dayOfWeek),
startTime: tpl.startTime.slice(0, 5),
endTime: tpl.endTime.slice(0, 5),
dayIdx: dayIdx >= 0 ? dayIdx : 0,
startTime: tpl.startTime,
endTime: tpl.endTime,
capacityStr: String(tpl.capacity),
}
showModal.value = true
@@ -198,87 +215,77 @@ function closeModal() {
editTarget.value = null
}
function onDayChange(e: any) {
form.value.dayOfWeek = Number(e.detail.value)
}
async function submitForm() {
function submitForm() {
const capacity = parseInt(form.value.capacityStr, 10)
if (!form.value.startTime || !form.value.endTime) {
uni.showToast({ title: '请填写时间', icon: 'none' })
return
}
if (isNaN(capacity) || capacity < 1) {
uni.showToast({ title: '请输入有效容量', icon: 'none' })
uni.showToast({ title: '请填写有效容量', icon: 'none' })
return
}
const dayVal = weekDays[form.value.dayOfWeek].value
const day = dayOptions[form.value.dayIdx].value
if (editTarget.value) {
// Update in local list
const idx = templates.value.findIndex((t) => t.id === editTarget.value!.id)
if (idx !== -1) {
templates.value[idx] = {
...templates.value[idx],
dayOfWeek: dayVal,
startTime: form.value.startTime,
endTime: form.value.endTime,
capacity,
}
}
const tpl = editTarget.value
tpl.dayOfWeek = day
tpl.startTime = form.value.startTime
tpl.endTime = form.value.endTime
tpl.capacity = capacity
} else {
// Add locally with a temp id
templates.value.push({
id: `tmp_${Date.now()}`,
dayOfWeek: dayVal,
_key: String(Date.now()),
dayOfWeek: day,
startTime: form.value.startTime,
endTime: form.value.endTime,
capacity,
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as WeekTemplate)
})
}
dirty.value = true
isDirty.value = true
closeModal()
}
function toggleActive(tpl: WeekTemplate) {
const idx = templates.value.findIndex((t) => t.id === tpl.id)
if (idx !== -1) {
templates.value[idx] = { ...templates.value[idx], isActive: !templates.value[idx].isActive }
dirty.value = true
}
function toggleTemplate(tpl: LocalTemplate) {
tpl.isActive = !tpl.isActive
isDirty.value = true
}
function confirmDelete(tpl: WeekTemplate) {
function deleteTemplate(tpl: LocalTemplate) {
uni.showModal({
title: '确认删除',
content: `删除 ${weekDays.find((d) => d.value === tpl.dayOfWeek)?.label} ${tpl.startTime.slice(0, 5)} 的模板?`,
content: '删除该时段模板?',
success: (res) => {
if (res.confirm) {
templates.value = templates.value.filter((t) => t.id !== tpl.id)
dirty.value = true
const idx = templates.value.indexOf(tpl)
if (idx >= 0) templates.value.splice(idx, 1)
isDirty.value = true
}
},
})
}
async function saveAll() {
async function handleSave() {
if (saving.value) return
saving.value = true
try {
const payload: WeekTemplateInput[] = templates.value.map((t) => ({
const payload = templates.value.map((t) => ({
id: t.id,
dayOfWeek: t.dayOfWeek,
startTime: t.startTime,
endTime: t.endTime,
capacity: t.capacity,
isActive: t.isActive,
}))
await put('/admin/week-template', { templates: payload })
dirty.value = false
await adminStore.saveWeekTemplates(payload as any)
isDirty.value = false
uni.showToast({ title: '保存成功', icon: 'success' })
await fetchTemplates()
} catch {
uni.showToast({ title: '保存失败,请重试', icon: 'none' })
} catch (e: any) {
uni.showToast({ title: e?.message ?? '保存失败', icon: 'none' })
} finally {
saving.value = false
}
@@ -291,10 +298,10 @@ onMounted(fetchTemplates)
.page {
min-height: 100vh;
background: #f5f3f0;
padding-bottom: 160rpx;
padding-bottom: 120rpx;
}
/* ── Toolbar ────────────────────────────── */
/* ── Toolbar ────────────────────────────── */
.toolbar {
display: flex;
align-items: center;
@@ -302,30 +309,21 @@ onMounted(fetchTemplates)
padding: 24rpx 24rpx 16rpx;
}
.toolbar-hint {
font-size: 24rpx;
color: #999;
}
.toolbar-hint { font-size: 24rpx; color: #999; }
.add-btn {
background: #1a1a2e;
border-radius: 32rpx;
padding: 12rpx 32rpx;
padding: 12rpx 28rpx;
}
.add-btn-text {
font-size: 26rpx;
font-weight: 600;
color: #c9a87c;
}
.add-btn-text { font-size: 26rpx; font-weight: 600; color: #c9a87c; }
/* ── Skeleton ───────────────────────────── */
.skeleton-list {
padding: 0 24rpx;
}
/* ── Skeleton ───────────────────────────── */
.skeleton-list { padding: 0 24rpx; }
.skeleton-item {
height: 120rpx;
height: 80rpx;
border-radius: 12rpx;
margin-bottom: 16rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
@@ -338,189 +336,99 @@ onMounted(fetchTemplates)
100% { background-position: -100% 0; }
}
/* ── Empty ──────────────────────────────── */
/* ── Empty ──────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0;
padding: 100rpx 0;
gap: 20rpx;
}
.empty-icon {
font-size: 80rpx;
}
.empty-icon { font-size: 80rpx; }
.empty-text { font-size: 28rpx; color: #bbb; }
.empty-text {
font-size: 28rpx;
color: #bbb;
}
/* ── Day group ──────────────────────────── */
.day-group {
margin: 0 24rpx 24rpx;
}
/* ── Day group ───────────────────────────── */
.day-group { margin: 0 24rpx 24rpx; }
.day-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 0 12rpx;
padding: 16rpx 8rpx;
}
.day-label {
font-size: 28rpx;
font-weight: 700;
color: #1a1a2e;
}
.day-label { font-size: 28rpx; font-weight: 700; color: #1a1a2e; }
.day-count { font-size: 22rpx; color: #999; }
.day-count {
font-size: 22rpx;
color: #c9a87c;
}
.day-empty {
padding: 20rpx 0;
}
.day-empty-text {
font-size: 24rpx;
color: #ccc;
}
/* ── Template card ──────────────────────── */
.tpl-card {
/* ── Template row ────────────────────────── */
.tpl-row {
background: #ffffff;
border-radius: 12rpx;
padding: 24rpx;
margin-bottom: 12rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.06);
&--inactive {
opacity: 0.55;
}
}
.tpl-main {
padding: 20rpx 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
margin-bottom: 12rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
&--inactive { opacity: 0.5; }
}
.tpl-time-block {
display: flex;
align-items: center;
gap: 12rpx;
.tpl-time { display: flex; flex-direction: column; gap: 6rpx; }
.tpl-time-text { font-size: 28rpx; font-weight: 600; color: #1a1a2e; }
.tpl-capacity { font-size: 22rpx; color: #888; }
.tpl-actions { display: flex; gap: 12rpx; }
.tpl-toggle,
.tpl-edit,
.tpl-delete {
border-radius: 20rpx;
padding: 8rpx 20rpx;
}
.tpl-time {
font-size: 32rpx;
font-weight: 700;
color: #1a1a2e;
}
.toggle--on { background: rgba(39,174,96,0.12); }
.toggle--on .tpl-toggle-text { font-size: 24rpx; color: #27ae60; }
.toggle--off { background: rgba(230,126,34,0.12); }
.toggle--off .tpl-toggle-text { font-size: 24rpx; color: #e67e22; }
.tpl-status-dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
}
.tpl-edit { background: rgba(26,26,46,0.08); }
.tpl-edit-text { font-size: 24rpx; color: #1a1a2e; }
.dot--active { background: #27ae60; }
.dot--inactive { background: #ccc; }
.tpl-delete { background: rgba(192,57,43,0.08); }
.tpl-delete-text { font-size: 24rpx; color: #c0392b; }
.tpl-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4rpx;
}
.tpl-capacity {
font-size: 24rpx;
color: #555;
}
.tpl-active-label {
font-size: 22rpx;
color: #999;
}
.tpl-actions {
display: flex;
gap: 12rpx;
}
.action-btn {
flex: 1;
padding: 12rpx 0;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
}
.action-btn-text {
font-size: 24rpx;
font-weight: 600;
}
.edit-btn {
background: #f0f0f0;
.action-btn-text { color: #1a1a2e; }
}
.toggle-btn--off {
background: #fff3cd;
.action-btn-text { color: #a07000; }
}
.toggle-btn--on {
background: #d4edda;
.action-btn-text { color: #155724; }
}
.delete-btn {
background: #fde8e8;
.action-btn-text { color: #c0392b; }
}
/* ── Save bar ───────────────────────────── */
/* ── Save bar ────────────────────────────── */
.save-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 24rpx;
padding: 20rpx 24rpx 48rpx;
background: #ffffff;
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.08);
box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.08);
}
.save-bar-btn {
.save-btn {
width: 100%;
height: 88rpx;
height: 96rpx;
border-radius: 48rpx;
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
&--loading {
opacity: 0.6;
}
&--loading { opacity: 0.6; }
}
.save-bar-text {
font-size: 30rpx;
font-weight: 700;
color: #c9a87c;
}
.save-btn-text { font-size: 30rpx; font-weight: 700; color: #c9a87c; }
/* ── Modal ──────────────────────────────── */
/* ── Modal ──────────────────────────────── */
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
background: rgba(0,0,0,0.5);
display: flex;
align-items: flex-end;
z-index: 100;
@@ -538,53 +446,31 @@ onMounted(fetchTemplates)
font-weight: 700;
color: #1a1a2e;
display: block;
margin-bottom: 32rpx;
margin-bottom: 24rpx;
}
.modal-field {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 0;
padding: 24rpx 0;
border-bottom: 1rpx solid #f5f5f5;
&--last { border-bottom: none; }
}
.modal-label {
font-size: 28rpx;
color: #555;
width: 160rpx;
flex-shrink: 0;
}
.modal-label { font-size: 26rpx; color: #555; width: 140rpx; flex-shrink: 0; }
.picker-display {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8rpx;
}
.modal-input { flex: 1; text-align: right; font-size: 26rpx; color: #222; }
.picker-text {
font-size: 28rpx;
color: #222;
}
.picker-arrow {
font-size: 28rpx;
color: #bbb;
}
.modal-input {
flex: 1;
text-align: right;
font-size: 28rpx;
color: #222;
}
.picker-display { display: flex; align-items: center; gap: 8rpx; }
.picker-text { font-size: 26rpx; color: #222; }
.picker-arrow { font-size: 26rpx; color: #bbb; }
.modal-actions {
display: flex;
gap: 16rpx;
margin-top: 40rpx;
margin-top: 32rpx;
}
.modal-cancel {
@@ -597,10 +483,7 @@ onMounted(fetchTemplates)
justify-content: center;
}
.modal-cancel-text {
font-size: 28rpx;
color: #555;
}
.modal-cancel-text { font-size: 28rpx; color: #555; }
.modal-confirm {
flex: 2;
@@ -610,15 +493,7 @@ onMounted(fetchTemplates)
display: flex;
align-items: center;
justify-content: center;
&--loading {
opacity: 0.6;
}
}
.modal-confirm-text {
font-size: 28rpx;
font-weight: 700;
color: #c9a87c;
}
.modal-confirm-text { font-size: 28rpx; font-weight: 700; color: #c9a87c; }
</style>