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:
@@ -88,12 +88,23 @@
|
|||||||
|
|
||||||
<view class="modal-field">
|
<view class="modal-field">
|
||||||
<text class="modal-label">卡种名称</text>
|
<text class="modal-label">卡种名称</text>
|
||||||
<input class="modal-input" v-model="form.name" placeholder="如:10次课套餐" placeholder-style="color:#bbb" />
|
<input
|
||||||
|
class="modal-input"
|
||||||
|
v-model="form.name"
|
||||||
|
placeholder="如:10次课套餐"
|
||||||
|
placeholder-style="color:#bbb"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="modal-field">
|
<view class="modal-field">
|
||||||
<text class="modal-label">类型</text>
|
<text class="modal-label">类型</text>
|
||||||
<picker mode="selector" :range="typeOptions" range-key="label" :value="form.typeIdx" @change="(e: any) => form.typeIdx = Number(e.detail.value)">
|
<picker
|
||||||
|
mode="selector"
|
||||||
|
:range="typeOptions"
|
||||||
|
range-key="label"
|
||||||
|
:value="form.typeIdx"
|
||||||
|
@change="(e: any) => form.typeIdx = Number(e.detail.value)"
|
||||||
|
>
|
||||||
<view class="picker-display">
|
<view class="picker-display">
|
||||||
<text class="picker-text">{{ typeOptions[form.typeIdx].label }}</text>
|
<text class="picker-text">{{ typeOptions[form.typeIdx].label }}</text>
|
||||||
<text class="picker-arrow">›</text>
|
<text class="picker-arrow">›</text>
|
||||||
@@ -103,27 +114,57 @@
|
|||||||
|
|
||||||
<view class="modal-field">
|
<view class="modal-field">
|
||||||
<text class="modal-label">现价(元)</text>
|
<text class="modal-label">现价(元)</text>
|
||||||
<input class="modal-input" type="digit" v-model="form.priceStr" placeholder="如:980" placeholder-style="color:#bbb" />
|
<input
|
||||||
|
class="modal-input"
|
||||||
|
type="digit"
|
||||||
|
v-model="form.priceStr"
|
||||||
|
placeholder="如:980"
|
||||||
|
placeholder-style="color:#bbb"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="modal-field">
|
<view class="modal-field">
|
||||||
<text class="modal-label">原价(元)</text>
|
<text class="modal-label">原价(元)</text>
|
||||||
<input class="modal-input" type="digit" v-model="form.originalPriceStr" placeholder="可选,用于展示划线价" placeholder-style="color:#bbb" />
|
<input
|
||||||
|
class="modal-input"
|
||||||
|
type="digit"
|
||||||
|
v-model="form.originalPriceStr"
|
||||||
|
placeholder="可选,用于展示划线价"
|
||||||
|
placeholder-style="color:#bbb"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="modal-field">
|
<view class="modal-field">
|
||||||
<text class="modal-label">次数</text>
|
<text class="modal-label">次数</text>
|
||||||
<input class="modal-input" type="number" v-model="form.totalTimesStr" placeholder="次卡必填,月卡留空" placeholder-style="color:#bbb" />
|
<input
|
||||||
|
class="modal-input"
|
||||||
|
type="number"
|
||||||
|
v-model="form.totalTimesStr"
|
||||||
|
placeholder="次卡必填,月卡留空"
|
||||||
|
placeholder-style="color:#bbb"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="modal-field">
|
<view class="modal-field">
|
||||||
<text class="modal-label">有效天数</text>
|
<text class="modal-label">有效天数</text>
|
||||||
<input class="modal-input" type="number" v-model="form.durationDaysStr" placeholder="如:90" placeholder-style="color:#bbb" />
|
<input
|
||||||
|
class="modal-input"
|
||||||
|
type="number"
|
||||||
|
v-model="form.durationDaysStr"
|
||||||
|
placeholder="如:90"
|
||||||
|
placeholder-style="color:#bbb"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="modal-field">
|
<view class="modal-field">
|
||||||
<text class="modal-label">排序值</text>
|
<text class="modal-label">排序值</text>
|
||||||
<input class="modal-input" type="number" v-model="form.sortOrderStr" placeholder="数字越小越靠前" placeholder-style="color:#bbb" />
|
<input
|
||||||
|
class="modal-input"
|
||||||
|
type="number"
|
||||||
|
v-model="form.sortOrderStr"
|
||||||
|
placeholder="数字越小越靠前"
|
||||||
|
placeholder-style="color:#bbb"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="modal-field modal-field--last">
|
<view class="modal-field modal-field--last">
|
||||||
@@ -142,7 +183,11 @@
|
|||||||
<view class="modal-cancel" @tap="closeModal">
|
<view class="modal-cancel" @tap="closeModal">
|
||||||
<text class="modal-cancel-text">取消</text>
|
<text class="modal-cancel-text">取消</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="modal-confirm" :class="{ 'modal-confirm--loading': submitting }" @tap="submitForm">
|
<view
|
||||||
|
class="modal-confirm"
|
||||||
|
:class="{ 'modal-confirm--loading': submitting }"
|
||||||
|
@tap="submitForm"
|
||||||
|
>
|
||||||
<text class="modal-confirm-text">{{ submitting ? '保存中...' : '确认' }}</text>
|
<text class="modal-confirm-text">{{ submitting ? '保存中...' : '确认' }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -153,11 +198,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { get, post, put, del } from '../../utils/request'
|
import { useAdminStore } from '../../stores/admin'
|
||||||
import { formatPrice } from '../../utils/format'
|
import { formatPrice } from '../../utils/format'
|
||||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||||
import type { CardType } from '@mp-pilates/shared'
|
import type { CardType } from '@mp-pilates/shared'
|
||||||
|
|
||||||
|
const adminStore = useAdminStore()
|
||||||
|
|
||||||
const cardTypes = ref<CardType[]>([])
|
const cardTypes = ref<CardType[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const showModal = ref(false)
|
const showModal = ref(false)
|
||||||
@@ -184,8 +231,7 @@ const form = ref({
|
|||||||
async function fetchCardTypes() {
|
async function fetchCardTypes() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await get<CardType[]>('/admin/card-types')
|
cardTypes.value = await adminStore.fetchCardTypes()
|
||||||
cardTypes.value = data.sort((a, b) => a.sortOrder - b.sortOrder)
|
|
||||||
} catch {
|
} catch {
|
||||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -266,9 +312,9 @@ async function submitForm() {
|
|||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
if (editTarget.value) {
|
if (editTarget.value) {
|
||||||
await put(`/admin/card-types/${editTarget.value.id}`, payload)
|
await adminStore.updateCardType(editTarget.value.id, payload as any)
|
||||||
} else {
|
} else {
|
||||||
await post('/admin/card-types', payload)
|
await adminStore.createCardType(payload as any)
|
||||||
}
|
}
|
||||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||||
closeModal()
|
closeModal()
|
||||||
@@ -282,7 +328,7 @@ async function submitForm() {
|
|||||||
|
|
||||||
async function toggleActive(ct: CardType) {
|
async function toggleActive(ct: CardType) {
|
||||||
try {
|
try {
|
||||||
await put(`/admin/card-types/${ct.id}`, { isActive: !ct.isActive })
|
await adminStore.updateCardType(ct.id, { isActive: !ct.isActive })
|
||||||
await fetchCardTypes()
|
await fetchCardTypes()
|
||||||
} catch {
|
} catch {
|
||||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||||
@@ -296,7 +342,7 @@ function confirmDelete(ct: CardType) {
|
|||||||
success: async (res) => {
|
success: async (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
try {
|
try {
|
||||||
await del(`/admin/card-types/${ct.id}`)
|
await adminStore.deleteCardType(ct.id)
|
||||||
uni.showToast({ title: '已删除', icon: 'success' })
|
uni.showToast({ title: '已删除', icon: 'success' })
|
||||||
await fetchCardTypes()
|
await fetchCardTypes()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -340,10 +386,7 @@ onMounted(fetchCardTypes)
|
|||||||
padding: 24rpx 24rpx 16rpx;
|
padding: 24rpx 24rpx 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-hint {
|
.toolbar-hint { font-size: 24rpx; color: #999; }
|
||||||
font-size: 24rpx;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-btn {
|
.add-btn {
|
||||||
background: #1a1a2e;
|
background: #1a1a2e;
|
||||||
@@ -351,16 +394,10 @@ onMounted(fetchCardTypes)
|
|||||||
padding: 12rpx 28rpx;
|
padding: 12rpx 28rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-btn-text {
|
.add-btn-text { font-size: 26rpx; font-weight: 600; color: #c9a87c; }
|
||||||
font-size: 26rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #c9a87c;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Skeleton ────────────────────────────── */
|
/* ── Skeleton ────────────────────────────── */
|
||||||
.skeleton-list {
|
.skeleton-list { padding: 0 24rpx; }
|
||||||
padding: 0 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-item {
|
.skeleton-item {
|
||||||
height: 260rpx;
|
height: 260rpx;
|
||||||
@@ -389,20 +426,16 @@ onMounted(fetchCardTypes)
|
|||||||
.empty-text { font-size: 28rpx; color: #bbb; }
|
.empty-text { font-size: 28rpx; color: #bbb; }
|
||||||
|
|
||||||
/* ── Card type list ──────────────────────── */
|
/* ── Card type list ──────────────────────── */
|
||||||
.ct-list {
|
.ct-list { padding: 0 24rpx; }
|
||||||
padding: 0 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ct-card {
|
.ct-card {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-radius: 16rpx;
|
border-radius: 16rpx;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 20rpx;
|
margin-bottom: 20rpx;
|
||||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
|
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.08);
|
||||||
|
|
||||||
&--inactive {
|
&--inactive { opacity: 0.6; }
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ct-header {
|
.ct-header {
|
||||||
@@ -416,29 +449,14 @@ onMounted(fetchCardTypes)
|
|||||||
.header--duration { background: linear-gradient(90deg, #6c3483, #9b59b6); }
|
.header--duration { background: linear-gradient(90deg, #6c3483, #9b59b6); }
|
||||||
.header--trial { background: linear-gradient(90deg, #7d6608, #c9a87c); }
|
.header--trial { background: linear-gradient(90deg, #7d6608, #c9a87c); }
|
||||||
|
|
||||||
.ct-type-label {
|
.ct-type-label { font-size: 22rpx; font-weight: 600; color: #ffffff; letter-spacing: 2rpx; }
|
||||||
font-size: 22rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #ffffff;
|
|
||||||
letter-spacing: 2rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ct-status-tag {
|
|
||||||
border-radius: 20rpx;
|
|
||||||
padding: 4rpx 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
.ct-status-tag { border-radius: 20rpx; padding: 4rpx 16rpx; }
|
||||||
.tag--on { background: rgba(255,255,255,0.2); }
|
.tag--on { background: rgba(255,255,255,0.2); }
|
||||||
.tag--off { background: rgba(0,0,0,0.2); }
|
.tag--off { background: rgba(0,0,0,0.2); }
|
||||||
|
.ct-status-text { font-size: 20rpx; color: #ffffff; }
|
||||||
|
|
||||||
.ct-status-text {
|
.ct-body { padding: 24rpx; }
|
||||||
font-size: 20rpx;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ct-body {
|
|
||||||
padding: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ct-name {
|
.ct-name {
|
||||||
font-size: 32rpx;
|
font-size: 32rpx;
|
||||||
@@ -455,11 +473,7 @@ onMounted(fetchCardTypes)
|
|||||||
margin-bottom: 12rpx;
|
margin-bottom: 12rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ct-price {
|
.ct-price { font-size: 40rpx; font-weight: 800; color: #c9a87c; }
|
||||||
font-size: 40rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #c9a87c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ct-original {
|
.ct-original {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
@@ -475,27 +489,11 @@ onMounted(fetchCardTypes)
|
|||||||
margin-bottom: 16rpx;
|
margin-bottom: 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ct-meta {
|
.ct-meta { display: flex; gap: 24rpx; }
|
||||||
display: flex;
|
|
||||||
gap: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-item {
|
.meta-item { display: flex; align-items: baseline; gap: 4rpx; }
|
||||||
display: flex;
|
.meta-value { font-size: 28rpx; font-weight: 700; color: #1a1a2e; }
|
||||||
align-items: baseline;
|
.meta-label { font-size: 22rpx; color: #999; }
|
||||||
gap: 4rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-value {
|
|
||||||
font-size: 28rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1a1a2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-label {
|
|
||||||
font-size: 22rpx;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Actions ─────────────────────────────── */
|
/* ── Actions ─────────────────────────────── */
|
||||||
.ct-actions {
|
.ct-actions {
|
||||||
@@ -511,17 +509,12 @@ onMounted(fetchCardTypes)
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-right: 1rpx solid #f5f5f5;
|
border-right: 1rpx solid #f5f5f5;
|
||||||
|
|
||||||
&:last-child {
|
&:last-child { border-right: none; }
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ct-action-text {
|
.ct-action-text { font-size: 26rpx; font-weight: 600; }
|
||||||
font-size: 26rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-btn .ct-action-text { color: #1a1a2e; }
|
.edit-btn .ct-action-text { color: #1a1a2e; }
|
||||||
.toggle-on .ct-action-text { color: #27ae60; }
|
.toggle-on .ct-action-text { color: #27ae60; }
|
||||||
.toggle-off .ct-action-text { color: #e67e22; }
|
.toggle-off .ct-action-text { color: #e67e22; }
|
||||||
.delete-btn .ct-action-text { color: #c0392b; }
|
.delete-btn .ct-action-text { color: #c0392b; }
|
||||||
@@ -530,7 +523,7 @@ onMounted(fetchCardTypes)
|
|||||||
.modal-mask {
|
.modal-mask {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0,0,0,0.5);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
@@ -560,32 +553,14 @@ onMounted(fetchCardTypes)
|
|||||||
border-bottom: 1rpx solid #f5f5f5;
|
border-bottom: 1rpx solid #f5f5f5;
|
||||||
gap: 16rpx;
|
gap: 16rpx;
|
||||||
|
|
||||||
&--last {
|
&--last { border-bottom: none; align-items: flex-start; }
|
||||||
border-bottom: none;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-label {
|
.modal-label { font-size: 26rpx; color: #555; width: 140rpx; flex-shrink: 0; }
|
||||||
font-size: 26rpx;
|
|
||||||
color: #555;
|
|
||||||
width: 140rpx;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-input {
|
.modal-input { flex: 1; text-align: right; font-size: 26rpx; color: #222; }
|
||||||
flex: 1;
|
|
||||||
text-align: right;
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #222;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-display {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
.picker-display { display: flex; align-items: center; gap: 8rpx; }
|
||||||
.picker-text { font-size: 26rpx; color: #222; }
|
.picker-text { font-size: 26rpx; color: #222; }
|
||||||
.picker-arrow { font-size: 26rpx; color: #bbb; }
|
.picker-arrow { font-size: 26rpx; color: #bbb; }
|
||||||
|
|
||||||
@@ -627,9 +602,5 @@ onMounted(fetchCardTypes)
|
|||||||
&--loading { opacity: 0.6; }
|
&--loading { opacity: 0.6; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-confirm-text {
|
.modal-confirm-text { font-size: 28rpx; font-weight: 700; color: #c9a87c; }
|
||||||
font-size: 28rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #c9a87c;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,38 +1,38 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="admin-page">
|
<view class="page">
|
||||||
<!-- Header -->
|
|
||||||
<view class="admin-header">
|
|
||||||
<view class="header-top">
|
|
||||||
<text class="header-title">管理中心</text>
|
|
||||||
<view class="admin-badge">
|
|
||||||
<text class="admin-badge-text">管理员</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<text class="header-sub">欢迎回来,{{ userStore.user?.nickname }}</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Stats row -->
|
<!-- Stats row -->
|
||||||
<view class="stats-row">
|
<view class="stats-row">
|
||||||
<view v-for="stat in stats" :key="stat.label" class="stat-cell">
|
<view v-if="statsLoading" class="stats-shimmer-wrap">
|
||||||
<view v-if="loadingStats" class="stat-skeleton" />
|
<view v-for="i in 3" :key="i" class="stats-shimmer" />
|
||||||
<template v-else>
|
|
||||||
<text class="stat-value">{{ stat.value }}</text>
|
|
||||||
<text class="stat-label">{{ stat.label }}</text>
|
|
||||||
</template>
|
|
||||||
</view>
|
</view>
|
||||||
|
<template v-else>
|
||||||
|
<view class="stat-item">
|
||||||
|
<text class="stat-value">{{ stats.todayBookings }}</text>
|
||||||
|
<text class="stat-label">今日预约</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-divider" />
|
||||||
|
<view class="stat-item">
|
||||||
|
<text class="stat-value">{{ stats.totalOrders }}</text>
|
||||||
|
<text class="stat-label">总订单</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-divider" />
|
||||||
|
<view class="stat-item">
|
||||||
|
<text class="stat-value">{{ stats.totalBookings }}</text>
|
||||||
|
<text class="stat-label">总预约</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Nav grid -->
|
<!-- Nav grid -->
|
||||||
<view class="grid">
|
<view class="nav-grid">
|
||||||
<view
|
<view
|
||||||
v-for="item in navItems"
|
v-for="item in navItems"
|
||||||
:key="item.path"
|
:key="item.path"
|
||||||
class="grid-item"
|
class="nav-item"
|
||||||
@tap="navigate(item.path)"
|
@tap="navigate(item.path)"
|
||||||
>
|
>
|
||||||
<text class="grid-icon">{{ item.icon }}</text>
|
<text class="nav-icon">{{ item.icon }}</text>
|
||||||
<text class="grid-label">{{ item.label }}</text>
|
<text class="nav-label">{{ item.label }}</text>
|
||||||
<text class="grid-desc">{{ item.desc }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -40,153 +40,71 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useAdminStore } from '../../stores/admin'
|
||||||
import { get } from '../../utils/request'
|
import type { AdminStats } from '../../stores/admin'
|
||||||
import type { PaginatedData, OrderWithDetails, BookingWithDetails } from '@mp-pilates/shared'
|
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const adminStore = useAdminStore()
|
||||||
const loadingStats = ref(true)
|
|
||||||
|
|
||||||
interface Stat {
|
const statsLoading = ref(false)
|
||||||
label: string
|
const stats = ref<AdminStats>({ todayBookings: 0, totalOrders: 0, totalBookings: 0 })
|
||||||
value: string | number
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = ref<Stat[]>([
|
|
||||||
{ label: '今日预约', value: '-' },
|
|
||||||
{ label: '总订单', value: '-' },
|
|
||||||
{ label: '总预约', value: '-' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: '/pages/admin/week-template', icon: '📅', label: '排课设置', desc: '管理周课模板' },
|
{ icon: '📅', label: '排课设置', path: '/pages/admin/week-template' },
|
||||||
{ path: '/pages/admin/slot-adjust', icon: '🗓️', label: '时段调整', desc: '手动添加/关闭时段' },
|
{ icon: '🔧', label: '临时调整', path: '/pages/admin/slot-adjust' },
|
||||||
{ path: '/pages/admin/members', icon: '👥', label: '会员管理', desc: '查看会员活跃度' },
|
{ icon: '👥', label: '会员管理', path: '/pages/admin/members' },
|
||||||
{ path: '/pages/admin/orders', icon: '📋', label: '订单管理', desc: '查看购卡订单' },
|
{ icon: '📋', label: '订单管理', path: '/pages/admin/orders' },
|
||||||
{ path: '/pages/admin/card-types', icon: '💳', label: '卡种管理', desc: '配置会员卡套餐' },
|
{ icon: '💳', label: '卡种管理', path: '/pages/admin/card-types' },
|
||||||
{ path: '/pages/admin/studio', icon: '🏢', label: '工作室设置', desc: '基本信息配置' },
|
{ icon: '🏢', label: '工作室设置', path: '/pages/admin/studio' },
|
||||||
]
|
]
|
||||||
|
|
||||||
async function loadStats() {
|
|
||||||
loadingStats.value = true
|
|
||||||
try {
|
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
|
||||||
const [bookingsRes, ordersRes] = await Promise.all([
|
|
||||||
get<PaginatedData<BookingWithDetails>>('/admin/bookings?page=1&limit=1'),
|
|
||||||
get<PaginatedData<OrderWithDetails>>('/admin/orders?page=1&limit=1'),
|
|
||||||
])
|
|
||||||
|
|
||||||
// Today's bookings — fetch with date filter
|
|
||||||
const todayRes = await get<PaginatedData<BookingWithDetails>>(
|
|
||||||
`/admin/bookings?page=1&limit=1&date=${today}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
stats.value = [
|
|
||||||
{ label: '今日预约', value: todayRes.total ?? 0 },
|
|
||||||
{ label: '总订单', value: ordersRes.total ?? 0 },
|
|
||||||
{ label: '总预约', value: bookingsRes.total ?? 0 },
|
|
||||||
]
|
|
||||||
} catch {
|
|
||||||
stats.value = [
|
|
||||||
{ label: '今日预约', value: '--' },
|
|
||||||
{ label: '总订单', value: '--' },
|
|
||||||
{ label: '总预约', value: '--' },
|
|
||||||
]
|
|
||||||
} finally {
|
|
||||||
loadingStats.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigate(path: string) {
|
function navigate(path: string) {
|
||||||
uni.navigateTo({ url: path })
|
uni.navigateTo({ url: path })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
statsLoading.value = true
|
||||||
|
try {
|
||||||
|
stats.value = await adminStore.fetchDashboardStats()
|
||||||
|
} catch {
|
||||||
|
// fail silently — stats are non-critical
|
||||||
|
} finally {
|
||||||
|
statsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(loadStats)
|
onMounted(loadStats)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.admin-page {
|
.page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f5f3f0;
|
background: #1a1a2e;
|
||||||
|
padding-bottom: 60rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Header ─────────────────────────────────────── */
|
/* ── Stats row ───────────────────────────── */
|
||||||
.admin-header {
|
|
||||||
background: linear-gradient(135deg, #1a1a2e, #2d2d5e);
|
|
||||||
padding: 80rpx 32rpx 48rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-top {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16rpx;
|
|
||||||
margin-bottom: 12rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-title {
|
|
||||||
font-size: 40rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-badge {
|
|
||||||
background: #c9a87c;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
padding: 4rpx 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-badge-text {
|
|
||||||
font-size: 20rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1a1a2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-sub {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: rgba(255, 255, 255, 0.65);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Stats row ───────────────────────────────────── */
|
|
||||||
.stats-row {
|
.stats-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
margin: -24rpx 24rpx 0;
|
|
||||||
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-cell {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 36rpx 0;
|
justify-content: space-around;
|
||||||
border-right: 1rpx solid #f0f0f0;
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
margin: 24rpx 24rpx 32rpx;
|
||||||
&:last-child {
|
border-radius: 20rpx;
|
||||||
border-right: none;
|
padding: 32rpx 16rpx;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stats-shimmer-wrap {
|
||||||
font-size: 44rpx;
|
display: flex;
|
||||||
font-weight: 800;
|
width: 100%;
|
||||||
color: #1a1a2e;
|
justify-content: space-around;
|
||||||
line-height: 1;
|
align-items: center;
|
||||||
margin-bottom: 8rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stats-shimmer {
|
||||||
font-size: 22rpx;
|
width: 120rpx;
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-skeleton {
|
|
||||||
width: 80rpx;
|
|
||||||
height: 60rpx;
|
height: 60rpx;
|
||||||
border-radius: 8rpx;
|
border-radius: 12rpx;
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
background: linear-gradient(90deg, rgba(255,255,255,0.08) 25%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.08) 75%);
|
||||||
background-size: 400% 100%;
|
background-size: 400% 100%;
|
||||||
animation: shimmer 1.4s infinite;
|
animation: shimmer 1.4s infinite;
|
||||||
}
|
}
|
||||||
@@ -196,42 +114,63 @@ onMounted(loadStats)
|
|||||||
100% { background-position: -100% 0; }
|
100% { background-position: -100% 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Nav grid ────────────────────────────────────── */
|
.stat-item {
|
||||||
.grid {
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 44rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #c9a87c;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-divider {
|
||||||
|
width: 1rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Nav grid ────────────────────────────── */
|
||||||
|
.nav-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 20rpx;
|
gap: 20rpx;
|
||||||
margin: 32rpx 24rpx 40rpx;
|
padding: 0 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-item {
|
.nav-item {
|
||||||
background: #ffffff;
|
background: rgba(255, 255, 255, 0.06);
|
||||||
border-radius: 16rpx;
|
border-radius: 20rpx;
|
||||||
padding: 36rpx 28rpx;
|
padding: 40rpx 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8rpx;
|
align-items: center;
|
||||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
gap: 16rpx;
|
||||||
|
border: 1rpx solid rgba(201, 168, 124, 0.15);
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
opacity: 0.8;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-icon {
|
.nav-icon {
|
||||||
font-size: 52rpx;
|
font-size: 56rpx;
|
||||||
margin-bottom: 4rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-label {
|
.nav-label {
|
||||||
font-size: 30rpx;
|
font-size: 28rpx;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: #1a1a2e;
|
color: #ffffff;
|
||||||
}
|
letter-spacing: 1rpx;
|
||||||
|
|
||||||
.grid-desc {
|
|
||||||
font-size: 22rpx;
|
|
||||||
color: #999;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,177 +1,168 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page">
|
<view class="page">
|
||||||
<!-- Search / filter bar -->
|
<!-- Search bar -->
|
||||||
<view class="filter-bar">
|
<view class="filter-bar">
|
||||||
<input
|
<input
|
||||||
class="search-input"
|
class="search-input"
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
placeholder="搜索昵称或手机号"
|
placeholder="搜索昵称或手机号"
|
||||||
placeholder-style="color:#bbb"
|
placeholder-style="color:#bbb"
|
||||||
@input="onSearch"
|
@confirm="onSearch"
|
||||||
|
confirm-type="search"
|
||||||
/>
|
/>
|
||||||
|
<view class="search-btn" @tap="onSearch">
|
||||||
|
<text class="search-btn-text">搜索</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Stats summary -->
|
<!-- Stats row -->
|
||||||
<view class="stats-row">
|
<view class="stats-row">
|
||||||
<view class="stat-cell">
|
<view class="stat-item">
|
||||||
<text class="stat-value">{{ totalMembers }}</text>
|
<text class="stat-value">{{ total }}</text>
|
||||||
<text class="stat-label">活跃会员</text>
|
<text class="stat-label">总会员</text>
|
||||||
</view>
|
|
||||||
<view class="stat-cell">
|
|
||||||
<text class="stat-value">{{ totalBookings }}</text>
|
|
||||||
<text class="stat-label">总预约次数</text>
|
|
||||||
</view>
|
|
||||||
<view class="stat-cell">
|
|
||||||
<text class="stat-value">{{ confirmedBookings }}</text>
|
|
||||||
<text class="stat-label">待上课</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Loading skeleton -->
|
<!-- Loading skeleton -->
|
||||||
<view v-if="loading" class="skeleton-list">
|
<view v-if="loading && !members.length" class="skeleton-list">
|
||||||
<view v-for="i in 6" :key="i" class="skeleton-item" />
|
<view v-for="i in 5" :key="i" class="skeleton-item" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Empty -->
|
<!-- Empty -->
|
||||||
<view v-else-if="!filteredMembers.length" class="empty-state">
|
<view v-else-if="!loading && !members.length" class="empty-state">
|
||||||
<text class="empty-icon">👥</text>
|
<text class="empty-icon">👥</text>
|
||||||
<text class="empty-text">{{ searchQuery ? '未找到匹配会员' : '暂无预约记录' }}</text>
|
<text class="empty-text">暂无会员数据</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Member list -->
|
<!-- Member list -->
|
||||||
<view v-else class="member-list">
|
<view v-else class="member-list">
|
||||||
<view
|
<view
|
||||||
v-for="member in filteredMembers"
|
v-for="m in members"
|
||||||
:key="member.userId"
|
:key="m.userId"
|
||||||
class="member-card"
|
class="member-row"
|
||||||
|
@tap="openDetail(m)"
|
||||||
>
|
>
|
||||||
<view class="member-avatar">
|
<view class="member-avatar">
|
||||||
<text class="member-avatar-text">{{ member.nickname.slice(0, 1).toUpperCase() }}</text>
|
<image v-if="m.avatarUrl" class="avatar-img" :src="m.avatarUrl" mode="aspectFill" />
|
||||||
|
<view v-else class="avatar-placeholder">
|
||||||
|
<text class="avatar-text">{{ (m.nickname || '?').slice(0, 1) }}</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="member-info">
|
<view class="member-info">
|
||||||
<text class="member-name">{{ member.nickname }}</text>
|
<text class="member-name">{{ m.nickname || '未知用户' }}</text>
|
||||||
<text v-if="member.phone" class="member-phone">{{ maskPhone(member.phone) }}</text>
|
<text class="member-phone">{{ m.phone || '未绑定手机' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="member-stats">
|
<view class="member-stats">
|
||||||
<view class="member-stat">
|
<text class="member-stat-value">{{ m.totalBookings }}</text>
|
||||||
<text class="member-stat-value">{{ member.totalBookings }}</text>
|
<text class="member-stat-label">次预约</text>
|
||||||
<text class="member-stat-label">次预约</text>
|
|
||||||
</view>
|
|
||||||
<view class="member-stat">
|
|
||||||
<text class="member-stat-value confirmed-count">{{ member.confirmedBookings }}</text>
|
|
||||||
<text class="member-stat-label">待上课</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
<text class="member-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Load more -->
|
<!-- Load more -->
|
||||||
<view v-if="hasMore && !loading" class="load-more" @tap="loadMore">
|
<view v-if="hasMore" class="load-more" @tap="loadMore">
|
||||||
<text class="load-more-text">加载更多</text>
|
<text class="load-more-text">{{ loading ? '加载中...' : '加载更多' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="loadingMore" class="load-more">
|
|
||||||
<text class="load-more-text">加载中...</text>
|
<!-- Detail modal -->
|
||||||
|
<view v-if="showDetail && detailMember" class="modal-mask" @tap.self="showDetail = false">
|
||||||
|
<view class="modal">
|
||||||
|
<view class="detail-header">
|
||||||
|
<view class="detail-avatar">
|
||||||
|
<image v-if="detailMember.avatarUrl" class="avatar-img" :src="detailMember.avatarUrl" mode="aspectFill" />
|
||||||
|
<view v-else class="avatar-placeholder avatar-placeholder--lg">
|
||||||
|
<text class="avatar-text avatar-text--lg">{{ (detailMember.nickname || '?').slice(0, 1) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="detail-name">{{ detailMember.nickname || '未知用户' }}</text>
|
||||||
|
<text class="detail-phone">{{ detailMember.phone || '未绑定手机' }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="detail-stats">
|
||||||
|
<view class="detail-stat">
|
||||||
|
<text class="detail-stat-value">{{ detailMember.totalBookings }}</text>
|
||||||
|
<text class="detail-stat-label">总预约</text>
|
||||||
|
</view>
|
||||||
|
<view class="detail-stat">
|
||||||
|
<text class="detail-stat-value">{{ detailMember.completedBookings }}</text>
|
||||||
|
<text class="detail-stat-label">已完成</text>
|
||||||
|
</view>
|
||||||
|
<view class="detail-stat">
|
||||||
|
<text class="detail-stat-value">{{ detailMember.cancelledBookings }}</text>
|
||||||
|
<text class="detail-stat-label">已取消</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="modal-close" @tap="showDetail = false">
|
||||||
|
<text class="modal-close-text">关闭</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { get } from '../../utils/request'
|
import { useAdminStore } from '../../stores/admin'
|
||||||
import { BookingStatus } from '@mp-pilates/shared'
|
import type { MemberSummary } from '../../stores/admin'
|
||||||
import type { BookingWithDetails, PaginatedData } from '@mp-pilates/shared'
|
|
||||||
|
|
||||||
interface MemberSummary {
|
const adminStore = useAdminStore()
|
||||||
userId: string
|
|
||||||
nickname: string
|
|
||||||
phone?: string
|
|
||||||
totalBookings: number
|
|
||||||
confirmedBookings: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const allBookings = ref<BookingWithDetails[]>([])
|
const members = ref<MemberSummary[]>([])
|
||||||
const page = ref(1)
|
|
||||||
const limit = 50
|
|
||||||
const hasMore = ref(true)
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const loadingMore = ref(false)
|
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const total = ref(0)
|
||||||
|
const hasMore = ref(false)
|
||||||
|
const showDetail = ref(false)
|
||||||
|
const detailMember = ref<MemberSummary | null>(null)
|
||||||
|
|
||||||
const members = computed<MemberSummary[]>(() => {
|
const LIMIT = 20
|
||||||
const map = new Map<string, MemberSummary>()
|
|
||||||
for (const b of allBookings.value) {
|
|
||||||
const userId = b.userId
|
|
||||||
if (!userId) continue
|
|
||||||
if (!map.has(userId)) {
|
|
||||||
map.set(userId, {
|
|
||||||
userId,
|
|
||||||
nickname: userId.slice(0, 8),
|
|
||||||
totalBookings: 0,
|
|
||||||
confirmedBookings: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const m = map.get(userId)!
|
|
||||||
m.totalBookings++
|
|
||||||
if (b.status === BookingStatus.CONFIRMED) {
|
|
||||||
m.confirmedBookings++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Array.from(map.values()).sort((a, b) => b.totalBookings - a.totalBookings)
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredMembers = computed(() => {
|
async function loadMembers(reset = false) {
|
||||||
if (!searchQuery.value.trim()) return members.value
|
if (loading.value) return
|
||||||
const q = searchQuery.value.toLowerCase()
|
if (reset) {
|
||||||
return members.value.filter(
|
|
||||||
(m) =>
|
|
||||||
m.nickname.toLowerCase().includes(q) ||
|
|
||||||
(m.phone && m.phone.includes(q)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const totalMembers = computed(() => members.value.length)
|
|
||||||
const totalBookings = computed(() => members.value.reduce((s, m) => s + m.totalBookings, 0))
|
|
||||||
const confirmedBookings = computed(() => members.value.reduce((s, m) => s + m.confirmedBookings, 0))
|
|
||||||
|
|
||||||
async function fetchBookings(isLoadMore = false) {
|
|
||||||
if (isLoadMore) {
|
|
||||||
loadingMore.value = true
|
|
||||||
} else {
|
|
||||||
loading.value = true
|
|
||||||
allBookings.value = []
|
|
||||||
page.value = 1
|
page.value = 1
|
||||||
hasMore.value = true
|
members.value = []
|
||||||
}
|
}
|
||||||
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await get<PaginatedData<BookingWithDetails>>(
|
const result = await adminStore.fetchMembers({
|
||||||
`/admin/bookings?page=${page.value}&limit=${limit}`,
|
page: page.value,
|
||||||
)
|
limit: LIMIT,
|
||||||
allBookings.value = [...allBookings.value, ...(data.items ?? [])]
|
search: searchQuery.value.trim() || undefined,
|
||||||
hasMore.value = allBookings.value.length < data.total
|
})
|
||||||
page.value++
|
if (reset) {
|
||||||
|
members.value = [...result.items]
|
||||||
|
} else {
|
||||||
|
members.value.push(...result.items)
|
||||||
|
}
|
||||||
|
total.value = result.total
|
||||||
|
hasMore.value = members.value.length < result.total
|
||||||
} catch {
|
} catch {
|
||||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
loadingMore.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMore() {
|
|
||||||
if (loadingMore.value || !hasMore.value) return
|
|
||||||
await fetchBookings(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSearch() {
|
function onSearch() {
|
||||||
// Reactive filtering via computed — no action needed
|
loadMembers(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function maskPhone(phone: string): string {
|
function loadMore() {
|
||||||
return phone.slice(0, 3) + '****' + phone.slice(-4)
|
if (!hasMore.value || loading.value) return
|
||||||
|
page.value++
|
||||||
|
loadMembers(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => fetchBookings())
|
function openDetail(m: MemberSummary) {
|
||||||
|
detailMember.value = m
|
||||||
|
showDetail.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => loadMembers(true))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -183,62 +174,49 @@ onMounted(() => fetchBookings())
|
|||||||
|
|
||||||
/* ── Filter bar ──────────────────────────── */
|
/* ── Filter bar ──────────────────────────── */
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
padding: 20rpx 24rpx;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 24rpx;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-bottom: 1rpx solid #f0f0f0;
|
border-bottom: 1rpx solid #eee;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
height: 72rpx;
|
||||||
background: #f5f3f0;
|
background: #f5f3f0;
|
||||||
border-radius: 32rpx;
|
border-radius: 36rpx;
|
||||||
padding: 16rpx 28rpx;
|
padding: 0 28rpx;
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #222;
|
color: #333;
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-btn {
|
||||||
|
background: #1a1a2e;
|
||||||
|
border-radius: 36rpx;
|
||||||
|
padding: 16rpx 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn-text { font-size: 26rpx; font-weight: 600; color: #c9a87c; }
|
||||||
|
|
||||||
/* ── Stats row ───────────────────────────── */
|
/* ── Stats row ───────────────────────────── */
|
||||||
.stats-row {
|
.stats-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: #ffffff;
|
padding: 24rpx 28rpx 16rpx;
|
||||||
border-bottom: 1rpx solid #f0f0f0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-cell {
|
.stat-item { display: flex; align-items: baseline; gap: 8rpx; }
|
||||||
flex: 1;
|
.stat-value { font-size: 36rpx; font-weight: 800; color: #c9a87c; }
|
||||||
display: flex;
|
.stat-label { font-size: 24rpx; color: #999; }
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 28rpx 0;
|
|
||||||
border-right: 1rpx solid #f0f0f0;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 40rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #1a1a2e;
|
|
||||||
line-height: 1;
|
|
||||||
margin-bottom: 6rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 20rpx;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Skeleton ────────────────────────────── */
|
/* ── Skeleton ────────────────────────────── */
|
||||||
.skeleton-list {
|
.skeleton-list { padding: 0 24rpx; }
|
||||||
padding: 16rpx 24rpx 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-item {
|
.skeleton-item {
|
||||||
height: 120rpx;
|
height: 100rpx;
|
||||||
border-radius: 12rpx;
|
border-radius: 16rpx;
|
||||||
margin-bottom: 12rpx;
|
margin-bottom: 16rpx;
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||||
background-size: 400% 100%;
|
background-size: 400% 100%;
|
||||||
animation: shimmer 1.4s infinite;
|
animation: shimmer 1.4s infinite;
|
||||||
@@ -262,93 +240,127 @@ onMounted(() => fetchBookings())
|
|||||||
.empty-text { font-size: 28rpx; color: #bbb; }
|
.empty-text { font-size: 28rpx; color: #bbb; }
|
||||||
|
|
||||||
/* ── Member list ─────────────────────────── */
|
/* ── Member list ─────────────────────────── */
|
||||||
.member-list {
|
.member-list { padding: 0 24rpx; padding-top: 8rpx; }
|
||||||
padding: 16rpx 24rpx 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-card {
|
.member-row {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-radius: 12rpx;
|
border-radius: 16rpx;
|
||||||
padding: 24rpx;
|
padding: 24rpx;
|
||||||
margin-bottom: 12rpx;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20rpx;
|
gap: 20rpx;
|
||||||
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.06);
|
margin-bottom: 16rpx;
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-avatar {
|
.member-avatar {
|
||||||
width: 80rpx;
|
width: 80rpx;
|
||||||
height: 80rpx;
|
height: 80rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, #1a1a2e, #2d2d5e);
|
overflow: hidden;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-avatar-text {
|
.avatar-img { width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
.avatar-placeholder {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #1a1a2e;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-placeholder--lg {
|
||||||
|
width: 120rpx;
|
||||||
|
height: 120rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-text {
|
||||||
font-size: 32rpx;
|
font-size: 32rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #c9a87c;
|
color: #c9a87c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-info {
|
.avatar-text--lg { font-size: 48rpx; }
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-name {
|
.member-info { flex: 1; display: flex; flex-direction: column; gap: 8rpx; }
|
||||||
font-size: 28rpx;
|
.member-name { font-size: 28rpx; font-weight: 600; color: #1a1a2e; }
|
||||||
font-weight: 600;
|
.member-phone { font-size: 22rpx; color: #999; }
|
||||||
color: #1a1a2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-phone {
|
.member-stats { display: flex; flex-direction: column; align-items: flex-end; gap: 4rpx; }
|
||||||
font-size: 22rpx;
|
.member-stat-value { font-size: 32rpx; font-weight: 700; color: #c9a87c; }
|
||||||
color: #999;
|
.member-stat-label { font-size: 20rpx; color: #bbb; }
|
||||||
}
|
|
||||||
|
|
||||||
.member-stats {
|
.member-arrow { font-size: 36rpx; color: #ccc; }
|
||||||
display: flex;
|
|
||||||
gap: 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-stat {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-stat-value {
|
|
||||||
font-size: 30rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1a1a2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmed-count {
|
|
||||||
color: #27ae60;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-stat-label {
|
|
||||||
font-size: 20rpx;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Load more ───────────────────────────── */
|
/* ── Load more ───────────────────────────── */
|
||||||
.load-more {
|
.load-more {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-text { font-size: 26rpx; color: #c9a87c; }
|
||||||
|
|
||||||
|
/* ── Detail modal ────────────────────────── */
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: 100%;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 24rpx 24rpx 0 0;
|
||||||
|
padding: 48rpx 32rpx 60rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-bottom: 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-avatar {
|
||||||
|
width: 120rpx;
|
||||||
|
height: 120rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-name { font-size: 32rpx; font-weight: 700; color: #1a1a2e; }
|
||||||
|
.detail-phone { font-size: 26rpx; color: #888; }
|
||||||
|
|
||||||
|
.detail-stats {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
background: #f5f3f0;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 28rpx;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-stat { display: flex; flex-direction: column; align-items: center; gap: 8rpx; }
|
||||||
|
.detail-stat-value { font-size: 40rpx; font-weight: 800; color: #c9a87c; }
|
||||||
|
.detail-stat-label { font-size: 22rpx; color: #999; }
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
width: 100%;
|
||||||
|
height: 88rpx;
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-radius: 44rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 28rpx 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.load-more-text {
|
.modal-close-text { font-size: 28rpx; color: #555; }
|
||||||
font-size: 26rpx;
|
|
||||||
color: #c9a87c;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,214 +5,215 @@
|
|||||||
<view class="filter-row">
|
<view class="filter-row">
|
||||||
<view
|
<view
|
||||||
v-for="f in filters"
|
v-for="f in filters"
|
||||||
:key="f.key"
|
:key="f.value"
|
||||||
class="filter-chip"
|
class="filter-chip"
|
||||||
:class="{ 'filter-chip--active': statusFilter === f.key }"
|
:class="{ 'filter-chip--active': activeFilter === f.value }"
|
||||||
@tap="selectFilter(f.key)"
|
@tap="selectFilter(f.value)"
|
||||||
>
|
>
|
||||||
<text class="filter-chip-text">{{ f.label }}</text>
|
<text class="filter-chip-text">{{ f.label }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
<!-- Loading skeleton -->
|
<!-- Pull-to-refresh wrapper -->
|
||||||
<view v-if="loading" class="skeleton-list">
|
<scroll-view
|
||||||
<view v-for="i in 5" :key="i" class="skeleton-item" />
|
scroll-y
|
||||||
</view>
|
class="list-scroll"
|
||||||
|
:refresher-enabled="true"
|
||||||
|
:refresher-triggered="refreshing"
|
||||||
|
@refresherrefresh="onRefresh"
|
||||||
|
>
|
||||||
|
<!-- Loading skeleton -->
|
||||||
|
<view v-if="loading && !orders.length" class="skeleton-list">
|
||||||
|
<view v-for="i in 5" :key="i" class="skeleton-item" />
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- Empty -->
|
<!-- Empty -->
|
||||||
<view v-else-if="!orders.length" class="empty-state">
|
<view v-else-if="!loading && !orders.length" class="empty-state">
|
||||||
<text class="empty-icon">📋</text>
|
<text class="empty-icon">📋</text>
|
||||||
<text class="empty-text">暂无订单</text>
|
<text class="empty-text">暂无订单</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Order list -->
|
<!-- Order list -->
|
||||||
<view v-else class="order-list">
|
<view v-else class="order-list">
|
||||||
<view
|
<view v-for="order in orders" :key="order.id" class="order-card">
|
||||||
v-for="order in orders"
|
<view class="order-header">
|
||||||
:key="order.id"
|
<text class="order-card-name">{{ order.cardType?.name ?? '-' }}</text>
|
||||||
class="order-card"
|
<view class="order-status-badge" :class="statusBadgeClass(order.status)">
|
||||||
>
|
<text class="order-status-text">{{ statusLabel(order.status) }}</text>
|
||||||
<!-- Header: card name + status badge -->
|
</view>
|
||||||
<view class="order-header">
|
</view>
|
||||||
<text class="order-card-name">{{ order.cardType?.name ?? '未知卡种' }}</text>
|
<view class="order-body">
|
||||||
<view class="status-badge" :class="statusBadgeClass(order.status)">
|
<view class="order-row">
|
||||||
<text class="status-badge-text">{{ statusLabel(order.status) }}</text>
|
<text class="order-row-label">用户</text>
|
||||||
|
<text class="order-row-value">{{ order.user?.nickname ?? '-' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="order-row">
|
||||||
|
<text class="order-row-label">手机</text>
|
||||||
|
<text class="order-row-value">{{ order.user?.phone ?? '未绑定' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="order-row">
|
||||||
|
<text class="order-row-label">金额</text>
|
||||||
|
<text class="order-row-value order-price">¥{{ formatPrice(order.amount) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="order-row">
|
||||||
|
<text class="order-row-label">时间</text>
|
||||||
|
<text class="order-row-value">{{ formatDate(order.createdAt) }}</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- User info -->
|
|
||||||
<view v-if="order.user" class="order-user">
|
|
||||||
<text class="order-user-icon">👤</text>
|
|
||||||
<text class="order-user-text">
|
|
||||||
{{ order.user.nickname }}
|
|
||||||
<text v-if="order.user.phone"> · {{ maskPhone(order.user.phone) }}</text>
|
|
||||||
</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Amount + date row -->
|
|
||||||
<view class="order-footer">
|
|
||||||
<text class="order-amount">¥{{ formatPrice(order.amount) }}</text>
|
|
||||||
<text class="order-date">{{ formatOrderDate(order.createdAt) }}</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Order id -->
|
|
||||||
<text class="order-id">订单号:{{ order.id.slice(0, 16) }}...</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Load more -->
|
||||||
<view v-if="totalPages > 1" class="pagination">
|
<view v-if="hasMore" class="load-more" @tap="loadMore">
|
||||||
<view
|
<text class="load-more-text">{{ loading ? '加载中...' : '加载更多' }}</text>
|
||||||
class="page-btn"
|
|
||||||
:class="{ 'page-btn--disabled': currentPage === 1 }"
|
|
||||||
@tap="goPage(currentPage - 1)"
|
|
||||||
>
|
|
||||||
<text class="page-btn-text">‹ 上一页</text>
|
|
||||||
</view>
|
</view>
|
||||||
<text class="page-info">{{ currentPage }} / {{ totalPages }}</text>
|
|
||||||
<view
|
<!-- Bottom spacer -->
|
||||||
class="page-btn"
|
<view style="height: 40rpx;" />
|
||||||
:class="{ 'page-btn--disabled': currentPage === totalPages }"
|
</scroll-view>
|
||||||
@tap="goPage(currentPage + 1)"
|
|
||||||
>
|
|
||||||
<text class="page-btn-text">下一页 ›</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { get } from '../../utils/request'
|
import { useAdminStore } from '../../stores/admin'
|
||||||
import { formatPrice } from '../../utils/format'
|
import { formatPrice, formatDate } from '../../utils/format'
|
||||||
import type { OrderWithDetails, PaginatedData } from '@mp-pilates/shared'
|
import { OrderStatus } from '@mp-pilates/shared'
|
||||||
|
import type { OrderWithDetails } from '@mp-pilates/shared'
|
||||||
|
|
||||||
|
const adminStore = useAdminStore()
|
||||||
|
|
||||||
const filters = [
|
const filters = [
|
||||||
{ key: '', label: '全部' },
|
{ label: '全部', value: '' },
|
||||||
{ key: 'PAID', label: '已支付' },
|
{ label: '已支付', value: OrderStatus.PAID },
|
||||||
{ key: 'PENDING', label: '待支付' },
|
{ label: '待支付', value: OrderStatus.PENDING },
|
||||||
{ key: 'REFUNDED', label: '已退款' },
|
{ label: '已退款', value: OrderStatus.REFUNDED },
|
||||||
{ key: 'CANCELLED', label: '已取消' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const statusFilter = ref('')
|
const activeFilter = ref('')
|
||||||
const orders = ref<OrderWithDetails[]>([])
|
const orders = ref<OrderWithDetails[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const currentPage = ref(1)
|
const refreshing = ref(false)
|
||||||
const total = ref(0)
|
const page = ref(1)
|
||||||
const limit = 10
|
const hasMore = ref(false)
|
||||||
|
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit)))
|
const LIMIT = 20
|
||||||
|
|
||||||
async function fetchOrders() {
|
function statusLabel(s: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
[OrderStatus.PAID]: '已支付',
|
||||||
|
[OrderStatus.PENDING]: '待支付',
|
||||||
|
[OrderStatus.REFUNDED]: '已退款',
|
||||||
|
}
|
||||||
|
return map[s] ?? s
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadgeClass(s: string) {
|
||||||
|
if (s === OrderStatus.PAID) return 'badge--paid'
|
||||||
|
if (s === OrderStatus.PENDING) return 'badge--pending'
|
||||||
|
if (s === OrderStatus.REFUNDED) return 'badge--refunded'
|
||||||
|
return 'badge--default'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOrders(reset = false) {
|
||||||
|
if (loading.value) return
|
||||||
|
if (reset) {
|
||||||
|
page.value = 1
|
||||||
|
orders.value = []
|
||||||
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const statusParam = statusFilter.value ? `&status=${statusFilter.value}` : ''
|
const result = await adminStore.fetchAdminOrders({
|
||||||
const data = await get<PaginatedData<OrderWithDetails>>(
|
page: page.value,
|
||||||
`/admin/orders?page=${currentPage.value}&limit=${limit}${statusParam}`,
|
limit: LIMIT,
|
||||||
)
|
status: activeFilter.value || undefined,
|
||||||
orders.value = [...(data.items ?? [])]
|
})
|
||||||
total.value = data.total ?? 0
|
if (reset) {
|
||||||
|
orders.value = [...result.items]
|
||||||
|
} else {
|
||||||
|
orders.value.push(...result.items)
|
||||||
|
}
|
||||||
|
hasMore.value = orders.value.length < result.total
|
||||||
} catch {
|
} catch {
|
||||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||||
orders.value = []
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
refreshing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectFilter(key: string) {
|
function selectFilter(value: string) {
|
||||||
statusFilter.value = key
|
activeFilter.value = value
|
||||||
currentPage.value = 1
|
loadOrders(true)
|
||||||
fetchOrders()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function goPage(p: number) {
|
async function onRefresh() {
|
||||||
if (p < 1 || p > totalPages.value) return
|
refreshing.value = true
|
||||||
currentPage.value = p
|
await loadOrders(true)
|
||||||
fetchOrders()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusLabel(status: string): string {
|
function loadMore() {
|
||||||
const map: Record<string, string> = {
|
if (!hasMore.value || loading.value) return
|
||||||
PAID: '已支付',
|
page.value++
|
||||||
PENDING: '待支付',
|
loadOrders(false)
|
||||||
REFUNDED: '已退款',
|
|
||||||
CANCELLED: '已取消',
|
|
||||||
}
|
|
||||||
return map[status] ?? status
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusBadgeClass(status: string): string {
|
onMounted(() => loadOrders(true))
|
||||||
if (status === 'PAID') return 'badge--paid'
|
|
||||||
if (status === 'PENDING') return 'badge--pending'
|
|
||||||
if (status === 'REFUNDED') return 'badge--refunded'
|
|
||||||
if (status === 'CANCELLED') return 'badge--cancelled'
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function maskPhone(phone: string): string {
|
|
||||||
return phone.slice(0, 3) + '****' + phone.slice(-4)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatOrderDate(iso: string): string {
|
|
||||||
const d = new Date(iso)
|
|
||||||
return `${d.getMonth() + 1}月${d.getDate()}日 ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(fetchOrders)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
background: #f5f3f0;
|
background: #f5f3f0;
|
||||||
padding-bottom: 40rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Filter scroll ───────────────────────── */
|
/* ── Filter scroll ───────────────────────── */
|
||||||
.filter-scroll {
|
.filter-scroll {
|
||||||
|
flex-shrink: 0;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-bottom: 1rpx solid #f0f0f0;
|
border-bottom: 1rpx solid #eee;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-row {
|
.filter-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
align-items: center;
|
||||||
gap: 12rpx;
|
|
||||||
padding: 16rpx 24rpx;
|
padding: 16rpx 24rpx;
|
||||||
width: max-content;
|
gap: 16rpx;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-chip {
|
.filter-chip {
|
||||||
padding: 12rpx 28rpx;
|
display: inline-flex;
|
||||||
border-radius: 32rpx;
|
align-items: center;
|
||||||
|
height: 60rpx;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
border-radius: 30rpx;
|
||||||
background: #f0f0f0;
|
background: #f0f0f0;
|
||||||
|
flex-shrink: 0;
|
||||||
&--active {
|
|
||||||
background: #1a1a2e;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-chip-text {
|
.filter-chip--active {
|
||||||
font-size: 26rpx;
|
background: #1a1a2e;
|
||||||
color: #555;
|
}
|
||||||
|
|
||||||
.filter-chip--active & {
|
.filter-chip-text { font-size: 26rpx; color: #888; }
|
||||||
color: #c9a87c;
|
.filter-chip--active .filter-chip-text { color: #c9a87c; font-weight: 600; }
|
||||||
font-weight: 600;
|
|
||||||
}
|
/* ── List scroll ─────────────────────────── */
|
||||||
|
.list-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Skeleton ────────────────────────────── */
|
/* ── Skeleton ────────────────────────────── */
|
||||||
.skeleton-list {
|
.skeleton-list { padding: 24rpx; }
|
||||||
padding: 16rpx 24rpx 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-item {
|
.skeleton-item {
|
||||||
height: 180rpx;
|
height: 180rpx;
|
||||||
border-radius: 12rpx;
|
border-radius: 16rpx;
|
||||||
margin-bottom: 16rpx;
|
margin-bottom: 16rpx;
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||||
background-size: 400% 100%;
|
background-size: 400% 100%;
|
||||||
@@ -229,7 +230,7 @@ onMounted(fetchOrders)
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 100rpx 0;
|
padding: 120rpx 0;
|
||||||
gap: 20rpx;
|
gap: 20rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,113 +238,57 @@ onMounted(fetchOrders)
|
|||||||
.empty-text { font-size: 28rpx; color: #bbb; }
|
.empty-text { font-size: 28rpx; color: #bbb; }
|
||||||
|
|
||||||
/* ── Order list ──────────────────────────── */
|
/* ── Order list ──────────────────────────── */
|
||||||
.order-list {
|
.order-list { padding: 16rpx 24rpx 0; }
|
||||||
padding: 16rpx 24rpx 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-card {
|
.order-card {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-radius: 16rpx;
|
border-radius: 16rpx;
|
||||||
padding: 28rpx;
|
overflow: hidden;
|
||||||
margin-bottom: 16rpx;
|
margin-bottom: 20rpx;
|
||||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-header {
|
.order-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 16rpx;
|
padding: 20rpx 24rpx;
|
||||||
|
border-bottom: 1rpx solid #f5f5f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-card-name {
|
.order-card-name { font-size: 28rpx; font-weight: 600; color: #1a1a2e; }
|
||||||
font-size: 30rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1a1a2e;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
.order-status-badge {
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
padding: 6rpx 16rpx;
|
padding: 6rpx 20rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge-text {
|
.badge--paid { background: rgba(39,174,96,0.1); }
|
||||||
font-size: 22rpx;
|
.badge--paid .order-status-text { font-size: 22rpx; color: #27ae60; }
|
||||||
font-weight: 600;
|
.badge--pending { background: rgba(230,126,34,0.1); }
|
||||||
}
|
.badge--pending .order-status-text { font-size: 22rpx; color: #e67e22; }
|
||||||
|
.badge--refunded { background: rgba(0,0,0,0.06); }
|
||||||
|
.badge--refunded .order-status-text { font-size: 22rpx; color: #999; }
|
||||||
|
.badge--default .order-status-text { font-size: 22rpx; color: #888; }
|
||||||
|
|
||||||
.badge--paid { background: #d4edda; .status-badge-text { color: #155724; } }
|
.order-body { padding: 16rpx 24rpx; }
|
||||||
.badge--pending { background: #fff3cd; .status-badge-text { color: #856404; } }
|
|
||||||
.badge--refunded { background: #cce5ff; .status-badge-text { color: #004085; } }
|
|
||||||
.badge--cancelled { background: #f8d7da; .status-badge-text { color: #721c24; } }
|
|
||||||
|
|
||||||
.order-user {
|
.order-row {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8rpx;
|
|
||||||
margin-bottom: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-user-icon { font-size: 24rpx; }
|
|
||||||
|
|
||||||
.order-user-text {
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-footer {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 12rpx;
|
padding: 10rpx 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-amount {
|
.order-row-label { font-size: 24rpx; color: #999; }
|
||||||
font-size: 36rpx;
|
.order-row-value { font-size: 26rpx; color: #333; }
|
||||||
font-weight: 800;
|
.order-price { font-size: 28rpx; font-weight: 700; color: #c9a87c; }
|
||||||
color: #c9a87c;
|
|
||||||
|
/* ── Load more ───────────────────────────── */
|
||||||
|
.load-more {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-date {
|
.load-more-text { font-size: 26rpx; color: #c9a87c; }
|
||||||
font-size: 22rpx;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-id {
|
|
||||||
font-size: 20rpx;
|
|
||||||
color: #bbb;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Pagination ──────────────────────────── */
|
|
||||||
.pagination {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 32rpx;
|
|
||||||
padding: 32rpx 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-btn {
|
|
||||||
padding: 12rpx 32rpx;
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 32rpx;
|
|
||||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
|
|
||||||
|
|
||||||
&--disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-btn-text {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #1a1a2e;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-info {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #555;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,107 +3,98 @@
|
|||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<view class="tabs">
|
<view class="tabs">
|
||||||
<view
|
<view
|
||||||
v-for="tab in tabs"
|
v-for="(tab, i) in tabs"
|
||||||
:key="tab.key"
|
:key="i"
|
||||||
class="tab"
|
class="tab"
|
||||||
:class="{ 'tab--active': activeTab === tab.key }"
|
:class="{ 'tab--active': activeTab === i }"
|
||||||
@tap="activeTab = tab.key"
|
@tap="activeTab = i"
|
||||||
>
|
>
|
||||||
<text class="tab-text">{{ tab.label }}</text>
|
<text class="tab-text">{{ tab }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- ── Tab: Manual add ───────────────────── -->
|
<!-- ① Add slot -->
|
||||||
<view v-if="activeTab === 'add'" class="section">
|
<view v-if="activeTab === 0" class="panel">
|
||||||
<text class="section-title">手动新增时段</text>
|
|
||||||
|
|
||||||
<view class="form-card">
|
<view class="form-card">
|
||||||
<view class="form-row">
|
<view class="form-row">
|
||||||
<text class="form-label">日期</text>
|
<text class="form-label">日期</text>
|
||||||
<picker mode="date" :value="addForm.date" @change="(e: any) => addForm.date = e.detail.value">
|
<picker mode="date" :value="addForm.date" @change="(e: any) => addForm.date = e.detail.value">
|
||||||
<view class="picker-display">
|
<view class="picker-display">
|
||||||
<text class="picker-text">{{ addForm.date }}</text>
|
<text class="picker-text">{{ addForm.date || '请选择' }}</text>
|
||||||
<text class="picker-arrow">›</text>
|
<text class="picker-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
</picker>
|
</picker>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="form-row">
|
<view class="form-row">
|
||||||
<text class="form-label">开始时间</text>
|
<text class="form-label">开始时间</text>
|
||||||
<picker mode="time" :value="addForm.startTime" @change="(e: any) => addForm.startTime = e.detail.value">
|
<picker mode="time" :value="addForm.startTime" @change="(e: any) => addForm.startTime = e.detail.value">
|
||||||
<view class="picker-display">
|
<view class="picker-display">
|
||||||
<text class="picker-text">{{ addForm.startTime }}</text>
|
<text class="picker-text">{{ addForm.startTime || '请选择' }}</text>
|
||||||
<text class="picker-arrow">›</text>
|
<text class="picker-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
</picker>
|
</picker>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="form-row">
|
<view class="form-row">
|
||||||
<text class="form-label">结束时间</text>
|
<text class="form-label">结束时间</text>
|
||||||
<picker mode="time" :value="addForm.endTime" @change="(e: any) => addForm.endTime = e.detail.value">
|
<picker mode="time" :value="addForm.endTime" @change="(e: any) => addForm.endTime = e.detail.value">
|
||||||
<view class="picker-display">
|
<view class="picker-display">
|
||||||
<text class="picker-text">{{ addForm.endTime }}</text>
|
<text class="picker-text">{{ addForm.endTime || '请选择' }}</text>
|
||||||
<text class="picker-arrow">›</text>
|
<text class="picker-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
</picker>
|
</picker>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="form-row form-row--last">
|
<view class="form-row form-row--last">
|
||||||
<text class="form-label">容量(人)</text>
|
<text class="form-label">容量</text>
|
||||||
<input
|
<input
|
||||||
class="form-input"
|
class="form-input"
|
||||||
type="number"
|
type="number"
|
||||||
v-model="addForm.capacityStr"
|
v-model="addForm.capacityStr"
|
||||||
placeholder="默认10"
|
placeholder="如:10"
|
||||||
placeholder-style="color:#bbb"
|
placeholder-style="color:#bbb"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="action-wrap">
|
||||||
<view
|
<view class="action-btn" :class="{ 'action-btn--loading': submitting }" @tap="submitAddSlot">
|
||||||
class="action-btn primary-btn"
|
<text class="action-btn-text">{{ submitting ? '提交中...' : '新增时段' }}</text>
|
||||||
:class="{ 'primary-btn--loading': addingSlot }"
|
</view>
|
||||||
@tap="handleAddSlot"
|
|
||||||
>
|
|
||||||
<text class="primary-btn-text">{{ addingSlot ? '添加中...' : '添加时段' }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- ── Tab: Close slots ──────────────────── -->
|
<!-- ② Close slot -->
|
||||||
<view v-else-if="activeTab === 'close'" class="section">
|
<view v-else-if="activeTab === 1" class="panel">
|
||||||
<view class="search-row">
|
<view class="date-picker-row">
|
||||||
<picker mode="date" :value="closeDateFilter" @change="(e: any) => { closeDateFilter = e.detail.value; fetchSlotsForClose() }">
|
<picker mode="date" :value="closeDate" @change="(e: any) => { closeDate = e.detail.value; loadSlotsForClose() }">
|
||||||
<view class="date-filter">
|
<view class="picker-display">
|
||||||
<text class="date-filter-text">{{ closeDateFilter }}</text>
|
<text class="picker-label">选择日期:</text>
|
||||||
<text class="date-filter-arrow">›</text>
|
<text class="picker-text">{{ closeDate }}</text>
|
||||||
|
<text class="picker-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
</picker>
|
</picker>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="loadingClose" class="skeleton-list">
|
<view v-if="slotsLoading" class="skeleton-list">
|
||||||
<view v-for="i in 3" :key="i" class="skeleton-item" />
|
<view v-for="i in 4" :key="i" class="skeleton-item" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-else-if="!closeSlots.length" class="empty-state">
|
<view v-else-if="!daySlots.length" class="empty-state">
|
||||||
<text class="empty-icon">🗓️</text>
|
<text class="empty-icon">📭</text>
|
||||||
<text class="empty-text">该日暂无时段</text>
|
<text class="empty-text">该日暂无时段</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-else class="slot-list">
|
<view v-else class="slot-list">
|
||||||
<view
|
<view v-for="slot in daySlots" :key="slot.id" class="slot-row">
|
||||||
v-for="slot in closeSlots"
|
|
||||||
:key="slot.id"
|
|
||||||
class="slot-card"
|
|
||||||
:class="{ 'slot-card--closed': slot.status === 'CLOSED' }"
|
|
||||||
>
|
|
||||||
<view class="slot-info">
|
<view class="slot-info">
|
||||||
<text class="slot-time">{{ slot.startTime.slice(0, 5) }}–{{ slot.endTime.slice(0, 5) }}</text>
|
<text class="slot-time">{{ slot.startTime }} – {{ slot.endTime }}</text>
|
||||||
<text class="slot-cap">容量 {{ slot.capacity }} · 已预约 {{ slot.bookedCount }}</text>
|
<view class="slot-badge" :class="slotBadgeClass(slot.status)">
|
||||||
|
<text class="slot-badge-text">{{ slot.status }}</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<text class="slot-count">{{ slot.bookedCount }}/{{ slot.capacity }}</text>
|
||||||
<view
|
<view
|
||||||
v-if="slot.status !== 'CLOSED'"
|
v-if="slot.status !== 'CLOSED'"
|
||||||
class="close-btn"
|
class="close-btn"
|
||||||
@tap="confirmClose(slot)"
|
@tap="closeSlot(slot)"
|
||||||
>
|
>
|
||||||
<text class="close-btn-text">关闭</text>
|
<text class="close-btn-text">关闭</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -114,114 +105,107 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- ── Tab: Generate ─────────────────────── -->
|
<!-- ③ Batch generate -->
|
||||||
<view v-else-if="activeTab === 'generate'" class="section">
|
<view v-else class="panel">
|
||||||
<text class="section-title">按模板生成时段</text>
|
|
||||||
<text class="section-sub">将依据当前排课模板,生成未来指定天数的课程时段(已存在的时段不会重复生成)。</text>
|
|
||||||
|
|
||||||
<view class="form-card">
|
<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">
|
<view class="form-row form-row--last">
|
||||||
<text class="form-label">生成天数</text>
|
<text class="form-label">结束日期</text>
|
||||||
<input
|
<picker mode="date" :value="genForm.endDate" @change="(e: any) => genForm.endDate = e.detail.value">
|
||||||
class="form-input"
|
<view class="picker-display">
|
||||||
type="number"
|
<text class="picker-text">{{ genForm.endDate || '请选择' }}</text>
|
||||||
v-model="generateDaysStr"
|
<text class="picker-arrow">›</text>
|
||||||
placeholder="如:14"
|
</view>
|
||||||
placeholder-style="color:#bbb"
|
</picker>
|
||||||
/>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<text class="gen-hint">将根据排课模板,自动生成所选日期范围内的时段</text>
|
||||||
<view
|
<view class="action-wrap">
|
||||||
class="action-btn primary-btn"
|
<view class="action-btn" :class="{ 'action-btn--loading': submitting }" @tap="submitGenerate">
|
||||||
:class="{ 'primary-btn--loading': generating }"
|
<text class="action-btn-text">{{ submitting ? '生成中...' : '批量生成' }}</text>
|
||||||
@tap="handleGenerate"
|
</view>
|
||||||
>
|
|
||||||
<text class="primary-btn-text">{{ generating ? '生成中...' : '生成时段' }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { get, post, put } from '../../utils/request'
|
import { useAdminStore } from '../../stores/admin'
|
||||||
|
import { formatDate } from '../../utils/format'
|
||||||
import type { TimeSlot } from '@mp-pilates/shared'
|
import type { TimeSlot } from '@mp-pilates/shared'
|
||||||
|
|
||||||
const tabs = [
|
const adminStore = useAdminStore()
|
||||||
{ key: 'add', label: '新增时段' },
|
|
||||||
{ key: 'close', label: '关闭时段' },
|
|
||||||
{ key: 'generate', label: '批量生成' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const activeTab = ref<string>('add')
|
const tabs = ['新增时段', '关闭时段', '批量生成']
|
||||||
|
const activeTab = ref(0)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const slotsLoading = ref(false)
|
||||||
|
|
||||||
// ── Add slot form ─────────────────────────────────
|
// ── Add slot form ────────────────────────────────────────────────
|
||||||
const todayStr = new Date().toISOString().slice(0, 10)
|
|
||||||
const addForm = ref({
|
const addForm = ref({
|
||||||
date: todayStr,
|
date: formatDate(new Date()),
|
||||||
startTime: '09:00',
|
startTime: '09:00',
|
||||||
endTime: '10:00',
|
endTime: '10:00',
|
||||||
capacityStr: '10',
|
capacityStr: '10',
|
||||||
})
|
})
|
||||||
const addingSlot = ref(false)
|
|
||||||
|
|
||||||
async function handleAddSlot() {
|
async function submitAddSlot() {
|
||||||
if (addingSlot.value) return
|
if (submitting.value) return
|
||||||
const capacity = parseInt(addForm.value.capacityStr, 10)
|
|
||||||
if (!addForm.value.date || !addForm.value.startTime || !addForm.value.endTime) {
|
if (!addForm.value.date || !addForm.value.startTime || !addForm.value.endTime) {
|
||||||
uni.showToast({ title: '请完整填写信息', icon: 'none' })
|
uni.showToast({ title: '请填写完整信息', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const capacity = parseInt(addForm.value.capacityStr, 10)
|
||||||
addingSlot.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
await post('/admin/time-slot/manual', {
|
await adminStore.createManualSlot({
|
||||||
date: addForm.value.date,
|
date: addForm.value.date,
|
||||||
startTime: addForm.value.startTime,
|
startTime: addForm.value.startTime,
|
||||||
endTime: addForm.value.endTime,
|
endTime: addForm.value.endTime,
|
||||||
capacity: isNaN(capacity) ? undefined : capacity,
|
capacity: isNaN(capacity) ? undefined : capacity,
|
||||||
})
|
})
|
||||||
uni.showToast({ title: '时段已添加', icon: 'success' })
|
uni.showToast({ title: '新增成功', icon: 'success' })
|
||||||
addForm.value = { date: todayStr, startTime: '09:00', endTime: '10:00', capacityStr: '10' }
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
uni.showToast({ title: e?.message ?? '添加失败', icon: 'none' })
|
uni.showToast({ title: e?.message ?? '新增失败', icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
addingSlot.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Close slots ────────────────────────────────────
|
// ── Close slot ────────────────────────────────────────────────────
|
||||||
interface SlotRow extends TimeSlot {
|
const closeDate = ref(formatDate(new Date()))
|
||||||
bookedCount: number
|
const daySlots = ref<TimeSlot[]>([])
|
||||||
}
|
|
||||||
|
|
||||||
const closeDateFilter = ref(todayStr)
|
async function loadSlotsForClose() {
|
||||||
const closeSlots = ref<SlotRow[]>([])
|
slotsLoading.value = true
|
||||||
const loadingClose = ref(false)
|
|
||||||
|
|
||||||
async function fetchSlotsForClose() {
|
|
||||||
loadingClose.value = true
|
|
||||||
try {
|
try {
|
||||||
const data = await get<SlotRow[]>(`/admin/time-slots?date=${closeDateFilter.value}`)
|
daySlots.value = await adminStore.fetchSlotsByDate(closeDate.value)
|
||||||
closeSlots.value = data
|
|
||||||
} catch {
|
} catch {
|
||||||
closeSlots.value = []
|
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
loadingClose.value = false
|
slotsLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmClose(slot: SlotRow) {
|
async function closeSlot(slot: TimeSlot) {
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '关闭时段',
|
title: '确认关闭',
|
||||||
content: `确认关闭 ${slot.startTime.slice(0, 5)}–${slot.endTime.slice(0, 5)} 的时段?`,
|
content: `关闭 ${slot.startTime}–${slot.endTime} 时段?`,
|
||||||
success: async (res) => {
|
success: async (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
try {
|
try {
|
||||||
await put(`/admin/time-slot/${slot.id}/close`, {})
|
await adminStore.closeSlot(slot.id)
|
||||||
uni.showToast({ title: '已关闭', icon: 'success' })
|
uni.showToast({ title: '已关闭', icon: 'success' })
|
||||||
await fetchSlotsForClose()
|
await loadSlotsForClose()
|
||||||
} catch {
|
} catch {
|
||||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||||
}
|
}
|
||||||
@@ -230,44 +214,48 @@ function confirmClose(slot: SlotRow) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Generate slots ─────────────────────────────────
|
function slotBadgeClass(status: string) {
|
||||||
const generateDaysStr = ref('14')
|
if (status === 'OPEN') return 'badge--open'
|
||||||
const generating = ref(false)
|
if (status === 'FULL') return 'badge--full'
|
||||||
|
return 'badge--closed'
|
||||||
|
}
|
||||||
|
|
||||||
async function handleGenerate() {
|
// ── Batch generate ────────────────────────────────────────────────
|
||||||
if (generating.value) return
|
const genForm = ref({
|
||||||
const days = parseInt(generateDaysStr.value, 10)
|
startDate: formatDate(new Date()),
|
||||||
if (isNaN(days) || days < 1 || days > 90) {
|
endDate: formatDate(new Date(Date.now() + 7 * 86400000)),
|
||||||
uni.showToast({ title: '请输入 1–90 天', icon: 'none' })
|
})
|
||||||
|
|
||||||
|
async function submitGenerate() {
|
||||||
|
if (submitting.value) return
|
||||||
|
if (!genForm.value.startDate || !genForm.value.endDate) {
|
||||||
|
uni.showToast({ title: '请选择日期范围', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
generating.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
await post('/admin/generate-slots', { days })
|
const result = await adminStore.generateSlots(genForm.value.startDate, genForm.value.endDate)
|
||||||
uni.showToast({ title: '生成成功', icon: 'success' })
|
uni.showToast({ title: `生成 ${result.count} 个时段`, icon: 'success' })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
uni.showToast({ title: e?.message ?? '生成失败', icon: 'none' })
|
uni.showToast({ title: e?.message ?? '生成失败', icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
generating.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
fetchSlotsForClose()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f5f3f0;
|
background: #f5f3f0;
|
||||||
|
padding-bottom: 40rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Tabs ────────────────────────────────── */
|
/* ── Tabs ────────────────────────────────── */
|
||||||
.tabs {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-bottom: 1rpx solid #f0f0f0;
|
border-bottom: 1rpx solid #eee;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
@@ -276,57 +264,34 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
.tab-text {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: #999;
|
color: #999;
|
||||||
|
font-weight: 500;
|
||||||
.tab--active & {
|
|
||||||
color: #1a1a2e;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Section ─────────────────────────────── */
|
.tab--active .tab-text {
|
||||||
.section {
|
|
||||||
padding: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-size: 30rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1a1a2e;
|
color: #1a1a2e;
|
||||||
display: block;
|
font-weight: 700;
|
||||||
margin-bottom: 8rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-sub {
|
.tab--active {
|
||||||
font-size: 24rpx;
|
border-bottom: 4rpx solid #c9a87c;
|
||||||
color: #999;
|
}
|
||||||
line-height: 1.6;
|
|
||||||
display: block;
|
/* ── Panel ───────────────────────────────── */
|
||||||
margin-bottom: 24rpx;
|
.panel {
|
||||||
|
padding: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Form card ───────────────────────────── */
|
/* ── Form card ───────────────────────────── */
|
||||||
.form-card {
|
.form-card {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-radius: 16rpx;
|
border-radius: 20rpx;
|
||||||
overflow: hidden;
|
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;
|
margin-bottom: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,99 +302,34 @@ onMounted(() => {
|
|||||||
padding: 28rpx 28rpx;
|
padding: 28rpx 28rpx;
|
||||||
border-bottom: 1rpx solid #f5f5f5;
|
border-bottom: 1rpx solid #f5f5f5;
|
||||||
|
|
||||||
&--last {
|
&--last { border-bottom: none; }
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-label {
|
.form-label { font-size: 28rpx; color: #555; width: 160rpx; flex-shrink: 0; }
|
||||||
font-size: 28rpx;
|
|
||||||
color: #555;
|
|
||||||
width: 160rpx;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-display {
|
.form-input { flex: 1; text-align: right; font-size: 28rpx; color: #222; background: transparent; }
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-text {
|
.picker-display { display: flex; align-items: center; gap: 8rpx; }
|
||||||
font-size: 28rpx;
|
.picker-label { font-size: 28rpx; color: #555; }
|
||||||
color: #222;
|
.picker-text { font-size: 28rpx; color: #222; }
|
||||||
}
|
.picker-arrow { font-size: 26rpx; color: #bbb; }
|
||||||
|
|
||||||
.picker-arrow {
|
/* ── Date picker row ─────────────────────── */
|
||||||
font-size: 28rpx;
|
.date-picker-row {
|
||||||
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;
|
background: #ffffff;
|
||||||
border-radius: 32rpx;
|
border-radius: 16rpx;
|
||||||
padding: 12rpx 24rpx;
|
padding: 24rpx 28rpx;
|
||||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
|
margin-bottom: 24rpx;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-filter-text {
|
/* ── Skeleton ────────────────────────────── */
|
||||||
font-size: 26rpx;
|
.skeleton-list { }
|
||||||
color: #1a1a2e;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-filter-arrow {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #bbb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-list {
|
|
||||||
margin-top: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-item {
|
.skeleton-item {
|
||||||
height: 100rpx;
|
height: 88rpx;
|
||||||
border-radius: 12rpx;
|
border-radius: 12rpx;
|
||||||
margin-bottom: 12rpx;
|
margin-bottom: 16rpx;
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||||
background-size: 400% 100%;
|
background-size: 400% 100%;
|
||||||
animation: shimmer 1.4s infinite;
|
animation: shimmer 1.4s infinite;
|
||||||
@@ -440,73 +340,88 @@ onMounted(() => {
|
|||||||
100% { background-position: -100% 0; }
|
100% { background-position: -100% 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Empty ───────────────────────────────── */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 80rpx 0;
|
padding: 80rpx 0;
|
||||||
gap: 20rpx;
|
gap: 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-icon { font-size: 80rpx; }
|
.empty-icon { font-size: 64rpx; }
|
||||||
.empty-text { font-size: 28rpx; color: #bbb; }
|
.empty-text { font-size: 28rpx; color: #bbb; }
|
||||||
|
|
||||||
.slot-list {
|
/* ── Slot list ───────────────────────────── */
|
||||||
margin-top: 8rpx;
|
.slot-list { }
|
||||||
}
|
|
||||||
|
|
||||||
.slot-card {
|
.slot-row {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-radius: 12rpx;
|
border-radius: 12rpx;
|
||||||
padding: 24rpx 28rpx;
|
padding: 24rpx;
|
||||||
margin-bottom: 12rpx;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 16rpx;
|
||||||
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.06);
|
margin-bottom: 16rpx;
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
|
||||||
&--closed {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.slot-info {
|
.slot-info { flex: 1; display: flex; align-items: center; gap: 12rpx; }
|
||||||
display: flex;
|
.slot-time { font-size: 28rpx; font-weight: 600; color: #1a1a2e; }
|
||||||
flex-direction: column;
|
|
||||||
gap: 6rpx;
|
.slot-badge {
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slot-time {
|
.badge--open { background: rgba(39,174,96,0.1); }
|
||||||
font-size: 30rpx;
|
.badge--open .slot-badge-text { font-size: 20rpx; color: #27ae60; }
|
||||||
font-weight: 700;
|
.badge--full { background: rgba(230,126,34,0.1); }
|
||||||
color: #1a1a2e;
|
.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 {
|
.slot-count { font-size: 24rpx; color: #888; }
|
||||||
font-size: 22rpx;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn {
|
.close-btn {
|
||||||
background: #fde8e8;
|
background: rgba(192,57,43,0.1);
|
||||||
border-radius: 8rpx;
|
border-radius: 20rpx;
|
||||||
padding: 12rpx 28rpx;
|
padding: 8rpx 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn-text {
|
.close-btn-text { font-size: 26rpx; color: #c0392b; font-weight: 600; }
|
||||||
font-size: 26rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #c0392b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.closed-tag {
|
.closed-tag {
|
||||||
background: #f0f0f0;
|
background: rgba(0,0,0,0.06);
|
||||||
border-radius: 8rpx;
|
border-radius: 20rpx;
|
||||||
padding: 12rpx 28rpx;
|
padding: 8rpx 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.closed-tag-text {
|
.closed-tag-text { font-size: 26rpx; color: #bbb; }
|
||||||
font-size: 26rpx;
|
|
||||||
|
/* ── Generate hint ───────────────────────── */
|
||||||
|
.gen-hint {
|
||||||
|
font-size: 24rpx;
|
||||||
color: #999;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Form card -->
|
<!-- Basic info card -->
|
||||||
<view class="form-card">
|
<view class="form-card">
|
||||||
<text class="form-card-title">基本信息</text>
|
<text class="form-card-title">基本信息</text>
|
||||||
|
|
||||||
@@ -150,10 +150,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { get, put } from '../../utils/request'
|
import { useAdminStore } from '../../stores/admin'
|
||||||
import type { StudioConfig } from '@mp-pilates/shared'
|
|
||||||
|
const adminStore = useAdminStore()
|
||||||
|
|
||||||
// Form state
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
name: '',
|
name: '',
|
||||||
address: '',
|
address: '',
|
||||||
@@ -183,7 +183,7 @@ const bannerStyle = computed(() => {
|
|||||||
async function fetchStudioInfo() {
|
async function fetchStudioInfo() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await get<StudioConfig>('/studio/info')
|
const data = await adminStore.fetchStudioConfig()
|
||||||
const initial = {
|
const initial = {
|
||||||
name: data.name ?? '',
|
name: data.name ?? '',
|
||||||
address: data.address ?? '',
|
address: data.address ?? '',
|
||||||
@@ -228,7 +228,7 @@ async function handleSave() {
|
|||||||
if (!isNaN(lat)) payload.latitude = lat
|
if (!isNaN(lat)) payload.latitude = lat
|
||||||
if (!isNaN(lng)) payload.longitude = lng
|
if (!isNaN(lng)) payload.longitude = lng
|
||||||
|
|
||||||
await put('/admin/studio/info', payload)
|
await adminStore.saveStudioConfig(payload as any)
|
||||||
original.value = { ...form.value }
|
original.value = { ...form.value }
|
||||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -249,10 +249,7 @@ onMounted(fetchStudioInfo)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Skeleton ────────────────────────────── */
|
/* ── Skeleton ────────────────────────────── */
|
||||||
.skeleton-page {
|
.skeleton-page { padding: 0 24rpx; padding-top: 280rpx; }
|
||||||
padding: 0 24rpx;
|
|
||||||
padding-top: 280rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-section {
|
.skeleton-section {
|
||||||
height: 200rpx;
|
height: 200rpx;
|
||||||
@@ -277,7 +274,7 @@ onMounted(fetchStudioInfo)
|
|||||||
.banner-overlay {
|
.banner-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.35);
|
background: rgba(0,0,0,0.35);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -290,13 +287,10 @@ onMounted(fetchStudioInfo)
|
|||||||
height: 96rpx;
|
height: 96rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 4rpx solid rgba(255, 255, 255, 0.4);
|
border: 4rpx solid rgba(255,255,255,0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-logo {
|
.banner-logo { width: 96rpx; height: 96rpx; }
|
||||||
width: 96rpx;
|
|
||||||
height: 96rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-logo-placeholder {
|
.banner-logo-placeholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -307,17 +301,9 @@ onMounted(fetchStudioInfo)
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-logo-text {
|
.banner-logo-text { font-size: 40rpx; font-weight: 700; color: #1a1a2e; }
|
||||||
font-size: 40rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1a1a2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-name {
|
.banner-name { font-size: 32rpx; font-weight: 700; color: #ffffff; }
|
||||||
font-size: 32rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Form card ───────────────────────────── */
|
/* ── Form card ───────────────────────────── */
|
||||||
.form-card {
|
.form-card {
|
||||||
@@ -325,7 +311,7 @@ onMounted(fetchStudioInfo)
|
|||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
margin: 24rpx 24rpx 0;
|
margin: 24rpx 24rpx 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-card-title {
|
.form-card-title {
|
||||||
@@ -345,9 +331,7 @@ onMounted(fetchStudioInfo)
|
|||||||
padding: 28rpx;
|
padding: 28rpx;
|
||||||
border-bottom: 1rpx solid #f5f5f5;
|
border-bottom: 1rpx solid #f5f5f5;
|
||||||
|
|
||||||
&--last {
|
&--last { border-bottom: none; }
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-label {
|
.form-label {
|
||||||
@@ -365,10 +349,7 @@ onMounted(fetchStudioInfo)
|
|||||||
margin-top: 4rpx;
|
margin-top: 4rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label-group {
|
.label-group { width: 240rpx; flex-shrink: 0; }
|
||||||
width: 240rpx;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input {
|
.form-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -385,9 +366,7 @@ onMounted(fetchStudioInfo)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Save button ─────────────────────────── */
|
/* ── Save button ─────────────────────────── */
|
||||||
.save-wrap {
|
.save-wrap { padding: 40rpx 24rpx; }
|
||||||
padding: 40rpx 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-btn {
|
.save-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -397,7 +376,7 @@ onMounted(fetchStudioInfo)
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 4rpx 20rpx rgba(26, 26, 46, 0.3);
|
box-shadow: 0 4rpx 20rpx rgba(26,26,46,0.3);
|
||||||
|
|
||||||
&:active { opacity: 0.85; }
|
&:active { opacity: 0.85; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,86 +1,83 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page">
|
<view class="page">
|
||||||
<!-- Top toolbar -->
|
<!-- Toolbar -->
|
||||||
<view class="toolbar">
|
<view class="toolbar">
|
||||||
<text class="toolbar-hint">共 {{ templates.length }} 条模板</text>
|
<text class="toolbar-hint">共 {{ templates.length }} 条模板</text>
|
||||||
<view class="add-btn" @tap="openAdd">
|
<view class="add-btn" @tap="openAdd">
|
||||||
<text class="add-btn-text">+ 新增</text>
|
<text class="add-btn-text">+ 新增时段</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Loading skeleton -->
|
<!-- Loading skeleton -->
|
||||||
<view v-if="loading" class="skeleton-list">
|
<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>
|
</view>
|
||||||
|
|
||||||
<!-- Empty -->
|
<!-- Empty -->
|
||||||
<view v-else-if="!templates.length" class="empty-state">
|
<view v-else-if="!templates.length" class="empty-state">
|
||||||
<text class="empty-icon">📅</text>
|
<text class="empty-icon">📅</text>
|
||||||
<text class="empty-text">暂无排课模板,点击右上角新增</text>
|
<text class="empty-text">暂无模板,点击右上角新增</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Template list grouped by weekday -->
|
<!-- Template list grouped by weekday -->
|
||||||
<template v-else>
|
<view v-else>
|
||||||
<view v-for="day in weekDays" :key="day.value" class="day-group">
|
<view v-for="(group, day) in grouped" :key="day" class="day-group">
|
||||||
<view class="day-header">
|
<view class="day-header">
|
||||||
<text class="day-label">{{ day.label }}</text>
|
<text class="day-label">{{ WEEKDAY_LABELS[Number(day)] }}</text>
|
||||||
<text class="day-count">{{ dayTemplates(day.value).length }} 节</text>
|
<text class="day-count">{{ group.length }} 个时段</text>
|
||||||
</view>
|
|
||||||
<view v-if="!dayTemplates(day.value).length" class="day-empty">
|
|
||||||
<text class="day-empty-text">该天无课</text>
|
|
||||||
</view>
|
</view>
|
||||||
<view
|
<view
|
||||||
v-for="tpl in dayTemplates(day.value)"
|
v-for="tpl in group"
|
||||||
:key="tpl.id"
|
:key="tpl.id ?? tpl._key"
|
||||||
class="tpl-card"
|
class="tpl-row"
|
||||||
:class="{ 'tpl-card--inactive': !tpl.isActive }"
|
:class="{ 'tpl-row--inactive': !tpl.isActive }"
|
||||||
>
|
>
|
||||||
<view class="tpl-main">
|
<view class="tpl-time">
|
||||||
<view class="tpl-time-block">
|
<text class="tpl-time-text">{{ tpl.startTime }} – {{ tpl.endTime }}</text>
|
||||||
<text class="tpl-time">{{ tpl.startTime.slice(0, 5) }}–{{ tpl.endTime.slice(0, 5) }}</text>
|
<text class="tpl-capacity">{{ tpl.capacity }} 人</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>
|
</view>
|
||||||
<view class="tpl-actions">
|
<view class="tpl-actions">
|
||||||
<view class="action-btn edit-btn" @tap="openEdit(tpl)">
|
|
||||||
<text class="action-btn-text">编辑</text>
|
|
||||||
</view>
|
|
||||||
<view
|
<view
|
||||||
class="action-btn toggle-btn"
|
class="tpl-toggle"
|
||||||
:class="tpl.isActive ? 'toggle-btn--off' : 'toggle-btn--on'"
|
:class="tpl.isActive ? 'toggle--on' : 'toggle--off'"
|
||||||
@tap="toggleActive(tpl)"
|
@tap="toggleTemplate(tpl)"
|
||||||
>
|
>
|
||||||
<text class="action-btn-text">{{ tpl.isActive ? '停用' : '启用' }}</text>
|
<text class="tpl-toggle-text">{{ tpl.isActive ? '启用' : '停用' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="action-btn delete-btn" @tap="confirmDelete(tpl)">
|
<view class="tpl-edit" @tap="openEdit(tpl)">
|
||||||
<text class="action-btn-text">删除</text>
|
<text class="tpl-edit-text">编辑</text>
|
||||||
|
</view>
|
||||||
|
<view class="tpl-delete" @tap="deleteTemplate(tpl)">
|
||||||
|
<text class="tpl-delete-text">删除</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</view>
|
||||||
|
|
||||||
<!-- Save all button -->
|
<!-- Save bar -->
|
||||||
<view v-if="dirty" class="save-bar">
|
<view v-if="isDirty" class="save-bar">
|
||||||
<view class="save-bar-btn" :class="{ 'save-bar-btn--loading': saving }" @tap="saveAll">
|
<view class="save-btn" :class="{ 'save-btn--loading': saving }" @tap="handleSave">
|
||||||
<text class="save-bar-text">{{ saving ? '保存中...' : '保存全部更改' }}</text>
|
<text class="save-btn-text">{{ saving ? '保存中...' : '保存全部更改' }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Add / Edit modal -->
|
<!-- Add / Edit modal -->
|
||||||
<view v-if="showModal" class="modal-mask" @tap.self="closeModal">
|
<view v-if="showModal" class="modal-mask" @tap.self="closeModal">
|
||||||
<view class="modal">
|
<view class="modal">
|
||||||
<text class="modal-title">{{ editTarget ? '编辑模板' : '新增模板' }}</text>
|
<text class="modal-title">{{ editTarget ? '编辑时段' : '新增时段' }}</text>
|
||||||
|
|
||||||
<view class="modal-field">
|
<view class="modal-field">
|
||||||
<text class="modal-label">星期</text>
|
<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">
|
<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>
|
<text class="picker-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
</picker>
|
</picker>
|
||||||
@@ -88,9 +85,13 @@
|
|||||||
|
|
||||||
<view class="modal-field">
|
<view class="modal-field">
|
||||||
<text class="modal-label">开始时间</text>
|
<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">
|
<view class="picker-display">
|
||||||
<text class="picker-text">{{ form.startTime }}</text>
|
<text class="picker-text">{{ form.startTime || '请选择' }}</text>
|
||||||
<text class="picker-arrow">›</text>
|
<text class="picker-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
</picker>
|
</picker>
|
||||||
@@ -98,16 +99,20 @@
|
|||||||
|
|
||||||
<view class="modal-field">
|
<view class="modal-field">
|
||||||
<text class="modal-label">结束时间</text>
|
<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">
|
<view class="picker-display">
|
||||||
<text class="picker-text">{{ form.endTime }}</text>
|
<text class="picker-text">{{ form.endTime || '请选择' }}</text>
|
||||||
<text class="picker-arrow">›</text>
|
<text class="picker-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
</picker>
|
</picker>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="modal-field">
|
<view class="modal-field modal-field--last">
|
||||||
<text class="modal-label">容量(人)</text>
|
<text class="modal-label">容量</text>
|
||||||
<input
|
<input
|
||||||
class="modal-input"
|
class="modal-input"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -121,8 +126,8 @@
|
|||||||
<view class="modal-cancel" @tap="closeModal">
|
<view class="modal-cancel" @tap="closeModal">
|
||||||
<text class="modal-cancel-text">取消</text>
|
<text class="modal-cancel-text">取消</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="modal-confirm" :class="{ 'modal-confirm--loading': submitting }" @tap="submitForm">
|
<view class="modal-confirm" @tap="submitForm">
|
||||||
<text class="modal-confirm-text">{{ submitting ? '保存中...' : '确认' }}</text>
|
<text class="modal-confirm-text">确认</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -132,43 +137,54 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { get, put } from '../../utils/request'
|
import { useAdminStore } from '../../stores/admin'
|
||||||
import type { WeekTemplate, WeekTemplateInput } from '@mp-pilates/shared'
|
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 loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const dirty = ref(false)
|
const isDirty = ref(false)
|
||||||
const showModal = ref(false)
|
const showModal = ref(false)
|
||||||
const submitting = ref(false)
|
const editTarget = ref<LocalTemplate | null>(null)
|
||||||
const editTarget = ref<WeekTemplate | null>(null)
|
|
||||||
|
|
||||||
const weekDays = [
|
const templates = ref<LocalTemplate[]>([])
|
||||||
{ label: '周一', value: 1 },
|
|
||||||
{ label: '周二', value: 2 },
|
const dayOptions = [1, 2, 3, 4, 5, 6, 7].map((d) => ({ label: WEEKDAY_LABELS[d], value: d }))
|
||||||
{ label: '周三', value: 3 },
|
|
||||||
{ label: '周四', value: 4 },
|
|
||||||
{ label: '周五', value: 5 },
|
|
||||||
{ label: '周六', value: 6 },
|
|
||||||
{ label: '周日', value: 0 },
|
|
||||||
]
|
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
dayOfWeek: 0,
|
dayIdx: 0,
|
||||||
startTime: '09:00',
|
startTime: '09:00',
|
||||||
endTime: '10:00',
|
endTime: '10:00',
|
||||||
capacityStr: '10',
|
capacityStr: '10',
|
||||||
})
|
})
|
||||||
|
|
||||||
function dayTemplates(dayVal: number) {
|
const grouped = computed(() => {
|
||||||
return templates.value.filter((t) => t.dayOfWeek === dayVal)
|
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() {
|
async function fetchTemplates() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await get<WeekTemplate[]>('/admin/week-template')
|
templates.value = await adminStore.fetchWeekTemplates()
|
||||||
templates.value = data
|
isDirty.value = false
|
||||||
} catch {
|
} catch {
|
||||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -178,16 +194,17 @@ async function fetchTemplates() {
|
|||||||
|
|
||||||
function openAdd() {
|
function openAdd() {
|
||||||
editTarget.value = null
|
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
|
showModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(tpl: WeekTemplate) {
|
function openEdit(tpl: LocalTemplate) {
|
||||||
editTarget.value = tpl
|
editTarget.value = tpl
|
||||||
|
const dayIdx = dayOptions.findIndex((d) => d.value === tpl.dayOfWeek)
|
||||||
form.value = {
|
form.value = {
|
||||||
dayOfWeek: weekDays.findIndex((d) => d.value === tpl.dayOfWeek),
|
dayIdx: dayIdx >= 0 ? dayIdx : 0,
|
||||||
startTime: tpl.startTime.slice(0, 5),
|
startTime: tpl.startTime,
|
||||||
endTime: tpl.endTime.slice(0, 5),
|
endTime: tpl.endTime,
|
||||||
capacityStr: String(tpl.capacity),
|
capacityStr: String(tpl.capacity),
|
||||||
}
|
}
|
||||||
showModal.value = true
|
showModal.value = true
|
||||||
@@ -198,87 +215,77 @@ function closeModal() {
|
|||||||
editTarget.value = null
|
editTarget.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDayChange(e: any) {
|
function submitForm() {
|
||||||
form.value.dayOfWeek = Number(e.detail.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitForm() {
|
|
||||||
const capacity = parseInt(form.value.capacityStr, 10)
|
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) {
|
if (isNaN(capacity) || capacity < 1) {
|
||||||
uni.showToast({ title: '请输入有效容量', icon: 'none' })
|
uni.showToast({ title: '请填写有效容量', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const dayVal = weekDays[form.value.dayOfWeek].value
|
const day = dayOptions[form.value.dayIdx].value
|
||||||
|
|
||||||
if (editTarget.value) {
|
if (editTarget.value) {
|
||||||
// Update in local list
|
const tpl = editTarget.value
|
||||||
const idx = templates.value.findIndex((t) => t.id === editTarget.value!.id)
|
tpl.dayOfWeek = day
|
||||||
if (idx !== -1) {
|
tpl.startTime = form.value.startTime
|
||||||
templates.value[idx] = {
|
tpl.endTime = form.value.endTime
|
||||||
...templates.value[idx],
|
tpl.capacity = capacity
|
||||||
dayOfWeek: dayVal,
|
|
||||||
startTime: form.value.startTime,
|
|
||||||
endTime: form.value.endTime,
|
|
||||||
capacity,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Add locally with a temp id
|
|
||||||
templates.value.push({
|
templates.value.push({
|
||||||
id: `tmp_${Date.now()}`,
|
_key: String(Date.now()),
|
||||||
dayOfWeek: dayVal,
|
dayOfWeek: day,
|
||||||
startTime: form.value.startTime,
|
startTime: form.value.startTime,
|
||||||
endTime: form.value.endTime,
|
endTime: form.value.endTime,
|
||||||
capacity,
|
capacity,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
createdAt: new Date().toISOString(),
|
})
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
} as unknown as WeekTemplate)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dirty.value = true
|
isDirty.value = true
|
||||||
closeModal()
|
closeModal()
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleActive(tpl: WeekTemplate) {
|
function toggleTemplate(tpl: LocalTemplate) {
|
||||||
const idx = templates.value.findIndex((t) => t.id === tpl.id)
|
tpl.isActive = !tpl.isActive
|
||||||
if (idx !== -1) {
|
isDirty.value = true
|
||||||
templates.value[idx] = { ...templates.value[idx], isActive: !templates.value[idx].isActive }
|
|
||||||
dirty.value = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete(tpl: WeekTemplate) {
|
function deleteTemplate(tpl: LocalTemplate) {
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '确认删除',
|
title: '确认删除',
|
||||||
content: `删除 ${weekDays.find((d) => d.value === tpl.dayOfWeek)?.label} ${tpl.startTime.slice(0, 5)} 的模板?`,
|
content: '删除该时段模板?',
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
templates.value = templates.value.filter((t) => t.id !== tpl.id)
|
const idx = templates.value.indexOf(tpl)
|
||||||
dirty.value = true
|
if (idx >= 0) templates.value.splice(idx, 1)
|
||||||
|
isDirty.value = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveAll() {
|
async function handleSave() {
|
||||||
if (saving.value) return
|
if (saving.value) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const payload: WeekTemplateInput[] = templates.value.map((t) => ({
|
const payload = templates.value.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
dayOfWeek: t.dayOfWeek,
|
dayOfWeek: t.dayOfWeek,
|
||||||
startTime: t.startTime,
|
startTime: t.startTime,
|
||||||
endTime: t.endTime,
|
endTime: t.endTime,
|
||||||
capacity: t.capacity,
|
capacity: t.capacity,
|
||||||
isActive: t.isActive,
|
isActive: t.isActive,
|
||||||
}))
|
}))
|
||||||
await put('/admin/week-template', { templates: payload })
|
await adminStore.saveWeekTemplates(payload as any)
|
||||||
dirty.value = false
|
isDirty.value = false
|
||||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||||
await fetchTemplates()
|
await fetchTemplates()
|
||||||
} catch {
|
} catch (e: any) {
|
||||||
uni.showToast({ title: '保存失败,请重试', icon: 'none' })
|
uni.showToast({ title: e?.message ?? '保存失败', icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
@@ -291,10 +298,10 @@ onMounted(fetchTemplates)
|
|||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f5f3f0;
|
background: #f5f3f0;
|
||||||
padding-bottom: 160rpx;
|
padding-bottom: 120rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Toolbar ────────────────────────────── */
|
/* ── Toolbar ─────────────────────────────── */
|
||||||
.toolbar {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -302,30 +309,21 @@ onMounted(fetchTemplates)
|
|||||||
padding: 24rpx 24rpx 16rpx;
|
padding: 24rpx 24rpx 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-hint {
|
.toolbar-hint { font-size: 24rpx; color: #999; }
|
||||||
font-size: 24rpx;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-btn {
|
.add-btn {
|
||||||
background: #1a1a2e;
|
background: #1a1a2e;
|
||||||
border-radius: 32rpx;
|
border-radius: 32rpx;
|
||||||
padding: 12rpx 32rpx;
|
padding: 12rpx 28rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-btn-text {
|
.add-btn-text { font-size: 26rpx; font-weight: 600; color: #c9a87c; }
|
||||||
font-size: 26rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #c9a87c;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Skeleton ───────────────────────────── */
|
/* ── Skeleton ────────────────────────────── */
|
||||||
.skeleton-list {
|
.skeleton-list { padding: 0 24rpx; }
|
||||||
padding: 0 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-item {
|
.skeleton-item {
|
||||||
height: 120rpx;
|
height: 80rpx;
|
||||||
border-radius: 12rpx;
|
border-radius: 12rpx;
|
||||||
margin-bottom: 16rpx;
|
margin-bottom: 16rpx;
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||||
@@ -338,189 +336,99 @@ onMounted(fetchTemplates)
|
|||||||
100% { background-position: -100% 0; }
|
100% { background-position: -100% 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Empty ──────────────────────────────── */
|
/* ── Empty ───────────────────────────────── */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 120rpx 0;
|
padding: 100rpx 0;
|
||||||
gap: 20rpx;
|
gap: 20rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-icon {
|
.empty-icon { font-size: 80rpx; }
|
||||||
font-size: 80rpx;
|
.empty-text { font-size: 28rpx; color: #bbb; }
|
||||||
}
|
|
||||||
|
|
||||||
.empty-text {
|
/* ── Day group ───────────────────────────── */
|
||||||
font-size: 28rpx;
|
.day-group { margin: 0 24rpx 24rpx; }
|
||||||
color: #bbb;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Day group ──────────────────────────── */
|
|
||||||
.day-group {
|
|
||||||
margin: 0 24rpx 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.day-header {
|
.day-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 16rpx 0 12rpx;
|
padding: 16rpx 8rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-label {
|
.day-label { font-size: 28rpx; font-weight: 700; color: #1a1a2e; }
|
||||||
font-size: 28rpx;
|
.day-count { font-size: 22rpx; color: #999; }
|
||||||
font-weight: 700;
|
|
||||||
color: #1a1a2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.day-count {
|
/* ── Template row ────────────────────────── */
|
||||||
font-size: 22rpx;
|
.tpl-row {
|
||||||
color: #c9a87c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.day-empty {
|
|
||||||
padding: 20rpx 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.day-empty-text {
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Template card ──────────────────────── */
|
|
||||||
.tpl-card {
|
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-radius: 12rpx;
|
border-radius: 12rpx;
|
||||||
padding: 24rpx;
|
padding: 20rpx 24rpx;
|
||||||
margin-bottom: 12rpx;
|
|
||||||
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.06);
|
|
||||||
|
|
||||||
&--inactive {
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tpl-main {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
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 {
|
.tpl-time { display: flex; flex-direction: column; gap: 6rpx; }
|
||||||
display: flex;
|
.tpl-time-text { font-size: 28rpx; font-weight: 600; color: #1a1a2e; }
|
||||||
align-items: center;
|
.tpl-capacity { font-size: 22rpx; color: #888; }
|
||||||
gap: 12rpx;
|
|
||||||
|
.tpl-actions { display: flex; gap: 12rpx; }
|
||||||
|
|
||||||
|
.tpl-toggle,
|
||||||
|
.tpl-edit,
|
||||||
|
.tpl-delete {
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 8rpx 20rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tpl-time {
|
.toggle--on { background: rgba(39,174,96,0.12); }
|
||||||
font-size: 32rpx;
|
.toggle--on .tpl-toggle-text { font-size: 24rpx; color: #27ae60; }
|
||||||
font-weight: 700;
|
.toggle--off { background: rgba(230,126,34,0.12); }
|
||||||
color: #1a1a2e;
|
.toggle--off .tpl-toggle-text { font-size: 24rpx; color: #e67e22; }
|
||||||
}
|
|
||||||
|
|
||||||
.tpl-status-dot {
|
.tpl-edit { background: rgba(26,26,46,0.08); }
|
||||||
width: 14rpx;
|
.tpl-edit-text { font-size: 24rpx; color: #1a1a2e; }
|
||||||
height: 14rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot--active { background: #27ae60; }
|
.tpl-delete { background: rgba(192,57,43,0.08); }
|
||||||
.dot--inactive { background: #ccc; }
|
.tpl-delete-text { font-size: 24rpx; color: #c0392b; }
|
||||||
|
|
||||||
.tpl-meta {
|
/* ── Save bar ────────────────────────────── */
|
||||||
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;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
padding: 24rpx;
|
padding: 20rpx 24rpx 48rpx;
|
||||||
background: #ffffff;
|
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%;
|
width: 100%;
|
||||||
height: 88rpx;
|
height: 96rpx;
|
||||||
|
border-radius: 48rpx;
|
||||||
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
|
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
|
||||||
border-radius: 44rpx;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
&--loading {
|
&--loading { opacity: 0.6; }
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-bar-text {
|
.save-btn-text { font-size: 30rpx; font-weight: 700; color: #c9a87c; }
|
||||||
font-size: 30rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #c9a87c;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Modal ──────────────────────────────── */
|
/* ── Modal ───────────────────────────────── */
|
||||||
.modal-mask {
|
.modal-mask {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0,0,0,0.5);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
@@ -538,53 +446,31 @@ onMounted(fetchTemplates)
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1a1a2e;
|
color: #1a1a2e;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 32rpx;
|
margin-bottom: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-field {
|
.modal-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 28rpx 0;
|
padding: 24rpx 0;
|
||||||
border-bottom: 1rpx solid #f5f5f5;
|
border-bottom: 1rpx solid #f5f5f5;
|
||||||
|
|
||||||
|
&--last { border-bottom: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-label {
|
.modal-label { font-size: 26rpx; color: #555; width: 140rpx; flex-shrink: 0; }
|
||||||
font-size: 28rpx;
|
|
||||||
color: #555;
|
|
||||||
width: 160rpx;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-display {
|
.modal-input { flex: 1; text-align: right; font-size: 26rpx; color: #222; }
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-text {
|
.picker-display { display: flex; align-items: center; gap: 8rpx; }
|
||||||
font-size: 28rpx;
|
.picker-text { font-size: 26rpx; color: #222; }
|
||||||
color: #222;
|
.picker-arrow { font-size: 26rpx; color: #bbb; }
|
||||||
}
|
|
||||||
|
|
||||||
.picker-arrow {
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: #bbb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-input {
|
|
||||||
flex: 1;
|
|
||||||
text-align: right;
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: #222;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-actions {
|
.modal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16rpx;
|
gap: 16rpx;
|
||||||
margin-top: 40rpx;
|
margin-top: 32rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-cancel {
|
.modal-cancel {
|
||||||
@@ -597,10 +483,7 @@ onMounted(fetchTemplates)
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-cancel-text {
|
.modal-cancel-text { font-size: 28rpx; color: #555; }
|
||||||
font-size: 28rpx;
|
|
||||||
color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-confirm {
|
.modal-confirm {
|
||||||
flex: 2;
|
flex: 2;
|
||||||
@@ -610,15 +493,7 @@ onMounted(fetchTemplates)
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
&--loading {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-confirm-text {
|
.modal-confirm-text { font-size: 28rpx; font-weight: 700; color: #c9a87c; }
|
||||||
font-size: 28rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #c9a87c;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -23,12 +23,17 @@
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Hero section -->
|
<!-- Hero section -->
|
||||||
<view class="card-hero" :class="heroClass">
|
<view class="card-hero" :class="heroClass">
|
||||||
|
<!-- Decorative circles -->
|
||||||
|
<view class="hero-deco hero-deco--1" />
|
||||||
|
<view class="hero-deco hero-deco--2" />
|
||||||
|
|
||||||
<view class="hero-badge">
|
<view class="hero-badge">
|
||||||
<text class="hero-badge-text">{{ typeLabel }}</text>
|
<text class="hero-badge-text">{{ typeLabel }}</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="hero-name">{{ card.name }}</text>
|
<text class="hero-name">{{ card.name }}</text>
|
||||||
<view class="hero-price-row">
|
<view class="hero-price-row">
|
||||||
<text class="hero-price">¥{{ formatPrice(card.price) }}</text>
|
<text class="hero-currency">¥</text>
|
||||||
|
<text class="hero-price">{{ formatPrice(card.price) }}</text>
|
||||||
<text
|
<text
|
||||||
v-if="card.originalPrice && card.originalPrice > card.price"
|
v-if="card.originalPrice && card.originalPrice > card.price"
|
||||||
class="hero-original"
|
class="hero-original"
|
||||||
@@ -60,28 +65,38 @@
|
|||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<view v-if="card.description" class="desc-card">
|
<view v-if="card.description" class="desc-card">
|
||||||
<text class="desc-title">课程说明</text>
|
<view class="section-header">
|
||||||
|
<view class="section-dot" />
|
||||||
|
<text class="section-title">课程说明</text>
|
||||||
|
</view>
|
||||||
<text class="desc-content">{{ card.description }}</text>
|
<text class="desc-content">{{ card.description }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Features list -->
|
<!-- Features list -->
|
||||||
<view class="features-card">
|
<view class="features-card">
|
||||||
<text class="features-title">购买须知</text>
|
<view class="section-header">
|
||||||
|
<view class="section-dot" />
|
||||||
|
<text class="section-title">购买须知</text>
|
||||||
|
</view>
|
||||||
<view class="feature-item">
|
<view class="feature-item">
|
||||||
<text class="feature-dot">•</text>
|
<text class="feature-dot">•</text>
|
||||||
<text class="feature-text">购买后立即生效,有效期 {{ card.durationDays }} 天</text>
|
<text class="feature-text">购买后立即生效,有效期 {{ card.durationDays }} 天</text>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="card.totalTimes" class="feature-item">
|
<view v-if="card.totalTimes" class="feature-item">
|
||||||
<text class="feature-dot">•</text>
|
<text class="feature-dot">•</text>
|
||||||
<text class="feature-text">共 {{ card.totalTimes }} 次课时,可灵活安排</text>
|
<text class="feature-text">共 {{ card.totalTimes }} 次课时,可灵活安排上课时间</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="!card.totalTimes" class="feature-item">
|
||||||
|
<text class="feature-dot">•</text>
|
||||||
|
<text class="feature-text">有效期内可无限次预约课程</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="feature-item">
|
<view class="feature-item">
|
||||||
<text class="feature-dot">•</text>
|
<text class="feature-dot">•</text>
|
||||||
<text class="feature-text">每次预约扣除 1 次课时</text>
|
<text class="feature-text">每次预约扣除 1 次课时(次卡)</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="feature-item">
|
<view class="feature-item">
|
||||||
<text class="feature-dot">•</text>
|
<text class="feature-dot">•</text>
|
||||||
<text class="feature-text">到期或课时用完后自动失效</text>
|
<text class="feature-text">到期或课时用完后自动失效,不可退款</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="feature-item">
|
<view class="feature-item">
|
||||||
<text class="feature-dot">•</text>
|
<text class="feature-dot">•</text>
|
||||||
@@ -118,8 +133,9 @@ import { useUserStore } from '../../stores/user'
|
|||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
// ─── Route param ──────────────────────────────────────────
|
// ─── Route params ──────────────────────────────────────────
|
||||||
const cardId = ref<string>('')
|
const cardId = ref<string>('')
|
||||||
|
const isTrial = ref(false)
|
||||||
|
|
||||||
// ─── State ────────────────────────────────────────────────
|
// ─── State ────────────────────────────────────────────────
|
||||||
const card = ref<CardType | null>(null)
|
const card = ref<CardType | null>(null)
|
||||||
@@ -147,20 +163,29 @@ const heroClass = computed(() => {
|
|||||||
const unitPrice = computed(() => {
|
const unitPrice = computed(() => {
|
||||||
if (!card.value) return '-'
|
if (!card.value) return '-'
|
||||||
if (card.value.totalTimes) {
|
if (card.value.totalTimes) {
|
||||||
const price = card.value.price / card.value.totalTimes
|
const pricePerTime = card.value.price / card.value.totalTimes
|
||||||
return `¥${(price / 100).toFixed(0)}`
|
return `¥${(pricePerTime / 100).toFixed(0)}`
|
||||||
}
|
}
|
||||||
const price = card.value.price / card.value.durationDays
|
const pricePerDay = card.value.price / card.value.durationDays
|
||||||
return `¥${(price / 100).toFixed(0)}`
|
return `¥${(pricePerDay / 100).toFixed(0)}`
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── Data loading ─────────────────────────────────────────
|
// ─── Data loading ─────────────────────────────────────────
|
||||||
async function loadCard() {
|
async function loadCard() {
|
||||||
if (!cardId.value) return
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const types = await get<CardType[]>('/membership/card-types')
|
const types = await get<CardType[]>('/membership/card-types')
|
||||||
card.value = types.find((c) => c.id === cardId.value) ?? null
|
const activeTypes = types.filter((c) => c.isActive)
|
||||||
|
|
||||||
|
if (isTrial.value) {
|
||||||
|
// Auto-find the trial card type
|
||||||
|
card.value = activeTypes.find((c) => c.type === CardTypeCategory.TRIAL) ?? null
|
||||||
|
if (card.value) {
|
||||||
|
cardId.value = card.value.id
|
||||||
|
}
|
||||||
|
} else if (cardId.value) {
|
||||||
|
card.value = activeTypes.find((c) => c.id === cardId.value) ?? null
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
card.value = null
|
card.value = null
|
||||||
} finally {
|
} finally {
|
||||||
@@ -229,13 +254,11 @@ async function doPurchase() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Payment succeeded
|
// Payment succeeded — refresh memberships then navigate
|
||||||
uni.showToast({ title: '购买成功!', icon: 'success' })
|
uni.showToast({ title: '购买成功!', icon: 'success' })
|
||||||
// Refresh memberships in background
|
|
||||||
await userStore.fetchMemberships()
|
await userStore.fetchMemberships()
|
||||||
// Navigate back after a moment
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
uni.navigateBack()
|
uni.navigateTo({ url: '/pages/profile/membership' })
|
||||||
}, 1500)
|
}, 1500)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
uni.hideLoading()
|
uni.hideLoading()
|
||||||
@@ -250,11 +273,11 @@ async function doPurchase() {
|
|||||||
|
|
||||||
// ─── Lifecycle ────────────────────────────────────────────
|
// ─── Lifecycle ────────────────────────────────────────────
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Get id from page options
|
|
||||||
const pages = getCurrentPages()
|
const pages = getCurrentPages()
|
||||||
const current = pages[pages.length - 1]
|
const current = pages[pages.length - 1]
|
||||||
const options = (current as { options?: Record<string, string> }).options ?? {}
|
const options = (current as { options?: Record<string, string> }).options ?? {}
|
||||||
cardId.value = options.id ?? ''
|
cardId.value = options.id ?? ''
|
||||||
|
isTrial.value = options.trial === '1'
|
||||||
loadCard()
|
loadCard()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -272,7 +295,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-header {
|
.skeleton-header {
|
||||||
height: 360rpx;
|
height: 380rpx;
|
||||||
background: linear-gradient(90deg, #e8e8e8 25%, #d8d8d8 50%, #e8e8e8 75%);
|
background: linear-gradient(90deg, #e8e8e8 25%, #d8d8d8 50%, #e8e8e8 75%);
|
||||||
background-size: 400% 100%;
|
background-size: 400% 100%;
|
||||||
animation: shimmer 1.4s infinite;
|
animation: shimmer 1.4s infinite;
|
||||||
@@ -335,10 +358,12 @@ onMounted(() => {
|
|||||||
|
|
||||||
/* ── Hero ────────────────────────────────────────────── */
|
/* ── Hero ────────────────────────────────────────────── */
|
||||||
.card-hero {
|
.card-hero {
|
||||||
padding: 60rpx 32rpx 52rpx;
|
padding: 64rpx 36rpx 56rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16rpx;
|
gap: 18rpx;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
&.hero--times {
|
&.hero--times {
|
||||||
background: linear-gradient(135deg, #1a1a2e 0%, #2d2d5e 100%);
|
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 {
|
.hero-badge {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
padding: 8rpx 20rpx;
|
padding: 8rpx 22rpx;
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
background: rgba(255, 255, 255, 0.18);
|
background: rgba(255, 255, 255, 0.18);
|
||||||
border: 1rpx solid rgba(255, 255, 255, 0.3);
|
border: 1rpx solid rgba(255, 255, 255, 0.3);
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-badge-text {
|
.hero-badge-text {
|
||||||
@@ -369,28 +417,39 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hero-name {
|
.hero-name {
|
||||||
font-size: 44rpx;
|
font-size: 48rpx;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
letter-spacing: 1rpx;
|
letter-spacing: 1rpx;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-price-row {
|
.hero-price-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
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 {
|
.hero-price {
|
||||||
font-size: 56rpx;
|
font-size: 64rpx;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-original {
|
.hero-original {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: rgba(255, 255, 255, 0.55);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
|
margin-left: 8rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Detail section ──────────────────────────────────── */
|
/* ── Detail section ──────────────────────────────────── */
|
||||||
@@ -401,6 +460,29 @@ onMounted(() => {
|
|||||||
gap: 20rpx;
|
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 grid card ──────────────────────────────────── */
|
||||||
.info-card {
|
.info-card {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
@@ -447,21 +529,12 @@ onMounted(() => {
|
|||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
padding: 28rpx 24rpx;
|
padding: 28rpx 24rpx;
|
||||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
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 {
|
.desc-content {
|
||||||
font-size: 26rpx;
|
font-size: 27rpx;
|
||||||
color: #666;
|
color: #666;
|
||||||
line-height: 1.7;
|
line-height: 1.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Features card ───────────────────────────────────── */
|
/* ── Features card ───────────────────────────────────── */
|
||||||
@@ -472,13 +545,6 @@ onMounted(() => {
|
|||||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.features-title {
|
|
||||||
font-size: 28rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1a1a1a;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-item {
|
.feature-item {
|
||||||
@@ -486,19 +552,20 @@ onMounted(() => {
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 12rpx;
|
gap: 12rpx;
|
||||||
|
padding: 6rpx 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-dot {
|
.feature-dot {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #c9a87c;
|
color: #c9a87c;
|
||||||
line-height: 1.6;
|
line-height: 1.65;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-text {
|
.feature-text {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #555;
|
color: #555;
|
||||||
line-height: 1.6;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Bottom action bar ───────────────────────────────── */
|
/* ── Bottom action bar ───────────────────────────────── */
|
||||||
@@ -542,6 +609,7 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(26, 26, 46, 0.3);
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="bookings-page">
|
<view class="bookings-page">
|
||||||
<!-- Tab filter -->
|
<!-- Tab bar -->
|
||||||
<view class="tab-bar">
|
<view class="tab-bar">
|
||||||
<view
|
<view
|
||||||
v-for="tab in tabs"
|
v-for="tab in tabs"
|
||||||
@@ -10,26 +10,30 @@
|
|||||||
@tap="selectTab(tab.key)"
|
@tap="selectTab(tab.key)"
|
||||||
>
|
>
|
||||||
<text class="tab-label">{{ tab.label }}</text>
|
<text class="tab-label">{{ tab.label }}</text>
|
||||||
|
<view v-if="tab.key === 'upcoming' && upcomingCount > 0" class="tab-badge">
|
||||||
|
<text class="tab-badge-text">{{ upcomingCount }}</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Upcoming tab content -->
|
||||||
<scroll-view
|
<scroll-view
|
||||||
|
v-show="activeTab === 'upcoming'"
|
||||||
class="scroll"
|
class="scroll"
|
||||||
scroll-y
|
scroll-y
|
||||||
refresher-enabled
|
refresher-enabled
|
||||||
:refresher-triggered="refreshing"
|
:refresher-triggered="refreshingUpcoming"
|
||||||
@refresherrefresh="onRefresh"
|
@refresherrefresh="onRefreshUpcoming"
|
||||||
>
|
>
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<view v-if="bookingStore.loadingBookings && !refreshing" class="loading-wrap">
|
<view v-if="bookingStore.loadingBookings && !refreshingUpcoming" class="loading-wrap">
|
||||||
<view v-for="i in 3" :key="i" class="skeleton-card" />
|
<view v-for="i in 3" :key="i" class="skeleton-card" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Empty -->
|
<!-- Empty -->
|
||||||
<view v-else-if="filteredBookings.length === 0" class="empty-wrap">
|
<view v-else-if="upcomingBookings.length === 0" class="empty-wrap">
|
||||||
<text class="empty-icon">📅</text>
|
<text class="empty-icon">📅</text>
|
||||||
<text class="empty-title">暂无预约记录</text>
|
<text class="empty-title">暂无即将上课的预约</text>
|
||||||
<text class="empty-sub">去预约一节课吧</text>
|
<text class="empty-sub">去预约一节课吧</text>
|
||||||
<view class="empty-btn" @tap="goBooking">
|
<view class="empty-btn" @tap="goBooking">
|
||||||
<text class="empty-btn-text">去预约</text>
|
<text class="empty-btn-text">去预约</text>
|
||||||
@@ -39,43 +43,81 @@
|
|||||||
<!-- Booking list -->
|
<!-- Booking list -->
|
||||||
<view v-else class="list">
|
<view v-else class="list">
|
||||||
<view
|
<view
|
||||||
v-for="booking in filteredBookings"
|
v-for="booking in upcomingBookings"
|
||||||
:key="booking.id"
|
:key="booking.id"
|
||||||
class="booking-card"
|
class="booking-card"
|
||||||
>
|
>
|
||||||
<!-- Date header stripe -->
|
<view class="booking-stripe stripe--confirmed" />
|
||||||
<view class="booking-stripe" :class="stripeClass(booking.status)" />
|
|
||||||
|
|
||||||
<!-- Card content -->
|
|
||||||
<view class="booking-content">
|
<view class="booking-content">
|
||||||
<view class="booking-main">
|
<view class="booking-main">
|
||||||
<!-- Date + time -->
|
|
||||||
<view class="booking-datetime">
|
<view class="booking-datetime">
|
||||||
<text class="booking-date">{{ formatDateDisplay(booking.timeSlot.date) }}</text>
|
<text class="booking-date">{{ formatDateDisplay(booking.timeSlot.date) }}</text>
|
||||||
<text class="booking-time">
|
<text class="booking-time">
|
||||||
{{ booking.timeSlot.startTime.slice(0, 5) }} - {{ booking.timeSlot.endTime.slice(0, 5) }}
|
{{ booking.timeSlot.startTime.slice(0, 5) }} – {{ booking.timeSlot.endTime.slice(0, 5) }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="status-badge badge--confirmed">
|
||||||
|
<text class="status-text">已预约</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="booking-meta">
|
||||||
|
<text class="meta-text">💳 {{ booking.membership.cardType.name }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="cancel-row">
|
||||||
|
<view class="cancel-btn" @tap="handleCancel(booking)">
|
||||||
|
<text class="cancel-text">取消预约</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- Status badge -->
|
<view class="scroll-bottom-spacer" />
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- History tab content -->
|
||||||
|
<scroll-view
|
||||||
|
v-show="activeTab === 'history'"
|
||||||
|
class="scroll"
|
||||||
|
scroll-y
|
||||||
|
refresher-enabled
|
||||||
|
:refresher-triggered="refreshingHistory"
|
||||||
|
@refresherrefresh="onRefreshHistory"
|
||||||
|
>
|
||||||
|
<!-- Loading -->
|
||||||
|
<view v-if="bookingStore.loadingBookings && !refreshingHistory" class="loading-wrap">
|
||||||
|
<view v-for="i in 3" :key="i" class="skeleton-card" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Empty -->
|
||||||
|
<view v-else-if="historyBookings.length === 0" class="empty-wrap">
|
||||||
|
<text class="empty-icon">📋</text>
|
||||||
|
<text class="empty-title">暂无历史记录</text>
|
||||||
|
<text class="empty-sub">已完成或取消的课程将显示在这里</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Booking list -->
|
||||||
|
<view v-else class="list">
|
||||||
|
<view
|
||||||
|
v-for="booking in historyBookings"
|
||||||
|
:key="booking.id"
|
||||||
|
class="booking-card"
|
||||||
|
>
|
||||||
|
<view class="booking-stripe" :class="stripeClass(booking.status)" />
|
||||||
|
<view class="booking-content">
|
||||||
|
<view class="booking-main">
|
||||||
|
<view class="booking-datetime">
|
||||||
|
<text class="booking-date">{{ formatDateDisplay(booking.timeSlot.date) }}</text>
|
||||||
|
<text class="booking-time">
|
||||||
|
{{ booking.timeSlot.startTime.slice(0, 5) }} – {{ booking.timeSlot.endTime.slice(0, 5) }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
<view class="status-badge" :class="statusBadgeClass(booking.status)">
|
<view class="status-badge" :class="statusBadgeClass(booking.status)">
|
||||||
<text class="status-text">{{ statusLabel(booking.status) }}</text>
|
<text class="status-text">{{ statusLabel(booking.status) }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Membership used -->
|
|
||||||
<view class="booking-meta">
|
<view class="booking-meta">
|
||||||
<text class="meta-label">💳 {{ booking.membership.cardType.name }}</text>
|
<text class="meta-text">💳 {{ booking.membership.cardType.name }}</text>
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Cancel button for confirmed upcoming bookings -->
|
|
||||||
<view
|
|
||||||
v-if="booking.status === BookingStatus.CONFIRMED && isUpcoming(booking.timeSlot.date)"
|
|
||||||
class="cancel-row"
|
|
||||||
>
|
|
||||||
<view class="cancel-btn" @tap="handleCancel(booking)">
|
|
||||||
<text class="cancel-text">取消预约</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -91,44 +133,58 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import type { BookingWithDetails } from '@mp-pilates/shared'
|
import type { BookingWithDetails } from '@mp-pilates/shared'
|
||||||
import { BookingStatus } from '@mp-pilates/shared'
|
import { BookingStatus } from '@mp-pilates/shared'
|
||||||
import { useBookingStore } from '../../stores/booking'
|
import { useBookingStore } from '../../stores/booking'
|
||||||
import { formatDate } from '../../utils/format'
|
import { formatDate, getWeekdayLabel } from '../../utils/format'
|
||||||
|
|
||||||
const bookingStore = useBookingStore()
|
const bookingStore = useBookingStore()
|
||||||
|
|
||||||
// ─── Tab state ────────────────────────────────────────────
|
// ─── Tab state ────────────────────────────────────────────
|
||||||
type TabKey = 'upcoming' | 'all'
|
type TabKey = 'upcoming' | 'history'
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: 'upcoming' as TabKey, label: '即将上课' },
|
{ key: 'upcoming' as TabKey, label: '即将上课' },
|
||||||
{ key: 'all' as TabKey, label: '全部记录' },
|
{ key: 'history' as TabKey, label: '历史记录' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const activeTab = ref<TabKey>('upcoming')
|
const activeTab = ref<TabKey>('upcoming')
|
||||||
const refreshing = ref(false)
|
const refreshingUpcoming = ref(false)
|
||||||
|
const refreshingHistory = ref(false)
|
||||||
|
|
||||||
// ─── Filtered bookings ────────────────────────────────────
|
// ─── Filtered bookings ────────────────────────────────────
|
||||||
const filteredBookings = computed<BookingWithDetails[]>(() => {
|
const today = computed(() => formatDate(new Date()))
|
||||||
|
|
||||||
|
const upcomingBookings = computed<BookingWithDetails[]>(() => {
|
||||||
const all = bookingStore.myBookings as BookingWithDetails[]
|
const all = bookingStore.myBookings as BookingWithDetails[]
|
||||||
if (activeTab.value === 'upcoming') {
|
return all
|
||||||
const today = formatDate(new Date())
|
.filter(
|
||||||
return all.filter(
|
(b) => b.status === BookingStatus.CONFIRMED && b.timeSlot.date >= today.value,
|
||||||
(b) => b.status === BookingStatus.CONFIRMED && b.timeSlot.date >= today,
|
)
|
||||||
).sort((a, b) => a.timeSlot.date.localeCompare(b.timeSlot.date))
|
.sort((a, b) => {
|
||||||
}
|
if (a.timeSlot.date !== b.timeSlot.date) {
|
||||||
return [...all].sort((a, b) => {
|
return a.timeSlot.date.localeCompare(b.timeSlot.date)
|
||||||
// Most recent first
|
}
|
||||||
if (b.timeSlot.date !== a.timeSlot.date) {
|
return a.timeSlot.startTime.localeCompare(b.timeSlot.startTime)
|
||||||
return b.timeSlot.date.localeCompare(a.timeSlot.date)
|
})
|
||||||
}
|
|
||||||
return b.timeSlot.startTime.localeCompare(a.timeSlot.startTime)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────
|
const historyBookings = computed<BookingWithDetails[]>(() => {
|
||||||
function isUpcoming(date: string): boolean {
|
const all = bookingStore.myBookings as BookingWithDetails[]
|
||||||
return date >= formatDate(new Date())
|
return all
|
||||||
}
|
.filter(
|
||||||
|
(b) =>
|
||||||
|
b.status !== BookingStatus.CONFIRMED ||
|
||||||
|
b.timeSlot.date < today.value,
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (b.timeSlot.date !== a.timeSlot.date) {
|
||||||
|
return b.timeSlot.date.localeCompare(a.timeSlot.date)
|
||||||
|
}
|
||||||
|
return b.timeSlot.startTime.localeCompare(a.timeSlot.startTime)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const upcomingCount = computed(() => upcomingBookings.value.length)
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────
|
||||||
function statusLabel(status: BookingStatus): string {
|
function statusLabel(status: BookingStatus): string {
|
||||||
const map: Record<BookingStatus, string> = {
|
const map: Record<BookingStatus, string> = {
|
||||||
[BookingStatus.CONFIRMED]: '已预约',
|
[BookingStatus.CONFIRMED]: '已预约',
|
||||||
@@ -164,8 +220,7 @@ function formatDateDisplay(dateStr: string): string {
|
|||||||
const d = new Date(dateStr)
|
const d = new Date(dateStr)
|
||||||
const month = d.getMonth() + 1
|
const month = d.getMonth() + 1
|
||||||
const day = d.getDate()
|
const day = d.getDate()
|
||||||
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
const weekday = getWeekdayLabel(d)
|
||||||
const weekday = weekdays[d.getDay()]
|
|
||||||
return `${month}月${day}日 ${weekday}`
|
return `${month}月${day}日 ${weekday}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,10 +229,16 @@ function selectTab(key: TabKey) {
|
|||||||
activeTab.value = key
|
activeTab.value = key
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onRefresh() {
|
async function onRefreshUpcoming() {
|
||||||
refreshing.value = true
|
refreshingUpcoming.value = true
|
||||||
await bookingStore.fetchMyBookings()
|
await bookingStore.fetchMyBookings()
|
||||||
refreshing.value = false
|
refreshingUpcoming.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRefreshHistory() {
|
||||||
|
refreshingHistory.value = true
|
||||||
|
await bookingStore.fetchMyBookings()
|
||||||
|
refreshingHistory.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function goBooking() {
|
function goBooking() {
|
||||||
@@ -185,25 +246,27 @@ function goBooking() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleCancel(booking: BookingWithDetails) {
|
async function handleCancel(booking: BookingWithDetails) {
|
||||||
|
const dateLabel = formatDateDisplay(booking.timeSlot.date)
|
||||||
|
const timeLabel = booking.timeSlot.startTime.slice(0, 5)
|
||||||
|
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '取消预约',
|
title: '取消预约',
|
||||||
content: `确定要取消 ${formatDateDisplay(booking.timeSlot.date)} ${booking.timeSlot.startTime.slice(0, 5)} 的课程吗?`,
|
content: `确定要取消 ${dateLabel} ${timeLabel} 的课程吗?`,
|
||||||
confirmText: '确定取消',
|
confirmText: '确定取消',
|
||||||
confirmColor: '#ef4444',
|
confirmColor: '#ef4444',
|
||||||
cancelText: '再想想',
|
cancelText: '再想想',
|
||||||
success: async (res) => {
|
success: async (res) => {
|
||||||
if (res.confirm) {
|
if (!res.confirm) return
|
||||||
uni.showLoading({ title: '取消中...' })
|
uni.showLoading({ title: '取消中...' })
|
||||||
try {
|
try {
|
||||||
await bookingStore.cancelBooking(booking.id)
|
await bookingStore.cancelBooking(booking.id)
|
||||||
uni.hideLoading()
|
uni.hideLoading()
|
||||||
uni.showToast({ title: '已取消预约', icon: 'success' })
|
uni.showToast({ title: '已取消预约', icon: 'success' })
|
||||||
await bookingStore.fetchMyBookings()
|
await bookingStore.fetchMyBookings()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
uni.hideLoading()
|
uni.hideLoading()
|
||||||
const msg = err instanceof Error ? err.message : '取消失败,请重试'
|
const msg = err instanceof Error ? err.message : '取消失败,请重试'
|
||||||
uni.showToast({ title: msg, icon: 'none' })
|
uni.showToast({ title: msg, icon: 'none' })
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -227,16 +290,16 @@ onMounted(() => bookingStore.fetchMyBookings())
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-bottom: 1rpx solid #f0ece8;
|
border-bottom: 1rpx solid #f0ece8;
|
||||||
position: sticky;
|
flex-shrink: 0;
|
||||||
top: 0;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-item {
|
.tab-item {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 8rpx;
|
||||||
padding: 28rpx 0;
|
padding: 28rpx 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
@@ -252,7 +315,7 @@ onMounted(() => bookingStore.fetchMyBookings())
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
width: 40rpx;
|
width: 48rpx;
|
||||||
height: 4rpx;
|
height: 4rpx;
|
||||||
background: #c9a87c;
|
background: #c9a87c;
|
||||||
border-radius: 2rpx;
|
border-radius: 2rpx;
|
||||||
@@ -266,9 +329,27 @@ onMounted(() => bookingStore.fetchMyBookings())
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tab-badge {
|
||||||
|
min-width: 32rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #ef4444;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-badge-text {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Scroll ──────────────────────────────────────────── */
|
/* ── Scroll ──────────────────────────────────────────── */
|
||||||
.scroll {
|
.scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
height: calc(100vh - 88rpx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Loading ─────────────────────────────────────────── */
|
/* ── Loading ─────────────────────────────────────────── */
|
||||||
@@ -348,14 +429,15 @@ onMounted(() => bookingStore.fetchMyBookings())
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Colored left stripe */
|
||||||
.booking-stripe {
|
.booking-stripe {
|
||||||
width: 8rpx;
|
width: 8rpx;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
&--confirmed { background: #c9a87c; }
|
&.stripe--confirmed { background: #c9a87c; }
|
||||||
&--completed { background: #4caf50; }
|
&.stripe--completed { background: #4caf50; }
|
||||||
&--cancelled { background: #e0e0e0; }
|
&.stripe--cancelled { background: #e0e0e0; }
|
||||||
&--noshow { background: #ef4444; }
|
&.stripe--noshow { background: #ef4444; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.booking-content {
|
.booking-content {
|
||||||
@@ -390,6 +472,7 @@ onMounted(() => bookingStore.fetchMyBookings())
|
|||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Status badge */
|
||||||
.status-badge {
|
.status-badge {
|
||||||
padding: 8rpx 18rpx;
|
padding: 8rpx 18rpx;
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
@@ -411,14 +494,15 @@ onMounted(() => bookingStore.fetchMyBookings())
|
|||||||
.badge--noshow & { color: #ef4444; }
|
.badge--noshow & { color: #ef4444; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Meta info */
|
||||||
.booking-meta {
|
.booking-meta {
|
||||||
.meta-label {
|
.meta-text {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
color: #999;
|
color: #999;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Cancel row ──────────────────────────────────────── */
|
/* Cancel row */
|
||||||
.cancel-row {
|
.cancel-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -426,13 +510,19 @@ onMounted(() => bookingStore.fetchMyBookings())
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cancel-btn {
|
.cancel-btn {
|
||||||
padding: 8rpx 24rpx;
|
padding: 10rpx 24rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
border: 1rpx solid #ef444430;
|
||||||
|
background: #fef0f0;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.cancel-text {
|
.cancel-text {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
text-decoration: underline;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,48 +2,65 @@
|
|||||||
<view class="info-page">
|
<view class="info-page">
|
||||||
<!-- Avatar section -->
|
<!-- Avatar section -->
|
||||||
<view class="avatar-section">
|
<view class="avatar-section">
|
||||||
<view class="avatar-wrap" @tap="chooseAvatar">
|
<view class="avatar-wrap">
|
||||||
<image
|
<image
|
||||||
v-if="form.avatarUrl"
|
v-if="avatarUrl"
|
||||||
class="avatar"
|
class="avatar"
|
||||||
:src="form.avatarUrl"
|
:src="avatarUrl"
|
||||||
mode="aspectFill"
|
mode="aspectFill"
|
||||||
/>
|
/>
|
||||||
<view v-else class="avatar-placeholder">
|
<view v-else class="avatar-placeholder">
|
||||||
<text class="avatar-placeholder-text">{{ nicknameInitial }}</text>
|
<text class="avatar-placeholder-text">{{ nicknameInitial }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="avatar-edit-badge">
|
|
||||||
<text class="avatar-edit-icon">📷</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
<text class="avatar-hint">点击更换头像</text>
|
<text class="avatar-name">{{ form.nickname || '未设置昵称' }}</text>
|
||||||
|
<text class="avatar-hint">微信头像</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Form -->
|
<!-- Form fields -->
|
||||||
<view class="form-card">
|
<view class="form-card">
|
||||||
<!-- Nickname -->
|
<!-- Nickname (editable) -->
|
||||||
<view class="form-row">
|
<view class="form-row">
|
||||||
<text class="form-label">昵称</text>
|
<text class="form-label">昵称</text>
|
||||||
<input
|
<input
|
||||||
class="form-input"
|
class="form-input"
|
||||||
v-model="form.nickname"
|
v-model="form.nickname"
|
||||||
placeholder="请输入昵称"
|
placeholder="请输入昵称"
|
||||||
placeholder-style="color: #bbb"
|
placeholder-style="color: #ccc"
|
||||||
maxlength="20"
|
maxlength="20"
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
/>
|
/>
|
||||||
|
<text class="form-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Phone (read-only) -->
|
<!-- Phone -->
|
||||||
<view class="form-row form-row--readonly">
|
<view class="form-row form-row--last">
|
||||||
<text class="form-label">手机号</text>
|
<text class="form-label">手机号</text>
|
||||||
<text class="form-value">{{ phoneDisplay }}</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Member since (read-only) -->
|
<!-- Phone set: display masked -->
|
||||||
<view class="form-row form-row--readonly">
|
<text v-if="hasPhone" class="form-value">{{ phoneDisplay }}</text>
|
||||||
<text class="form-label">注册时间</text>
|
|
||||||
<text class="form-value">{{ joinDateDisplay }}</text>
|
<!-- Phone not set: bind button -->
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="bind-phone-btn"
|
||||||
|
open-type="getPhoneNumber"
|
||||||
|
@getphonenumber="handleGetPhone"
|
||||||
|
>
|
||||||
|
<text class="bind-phone-text">绑定手机号</text>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Read-only info card -->
|
||||||
|
<view class="info-card">
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-label">注册时间</text>
|
||||||
|
<text class="info-value">{{ joinDateDisplay }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row info-row--last">
|
||||||
|
<text class="info-label">会员卡数量</text>
|
||||||
|
<text class="info-value">{{ activeMembershipCount }} 张有效</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -51,7 +68,7 @@
|
|||||||
<view class="save-wrap">
|
<view class="save-wrap">
|
||||||
<view
|
<view
|
||||||
class="save-btn"
|
class="save-btn"
|
||||||
:class="{ 'save-btn--loading': saving, 'save-btn--disabled': !isDirty }"
|
:class="{ 'save-btn--loading': saving, 'save-btn--disabled': !isDirty || saving }"
|
||||||
@tap="handleSave"
|
@tap="handleSave"
|
||||||
>
|
>
|
||||||
<text class="save-btn-text">{{ saving ? '保存中...' : '保存修改' }}</text>
|
<text class="save-btn-text">{{ saving ? '保存中...' : '保存修改' }}</text>
|
||||||
@@ -63,39 +80,34 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
|
import { wxBindPhone } from '../../utils/auth'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
// ─── Form state ───────────────────────────────────────────
|
// ─── Form state ───────────────────────────────────────────
|
||||||
const form = ref({
|
const form = ref({
|
||||||
nickname: '',
|
nickname: '',
|
||||||
avatarUrl: '',
|
|
||||||
})
|
})
|
||||||
|
const originalNickname = ref('')
|
||||||
const originalForm = ref({
|
|
||||||
nickname: '',
|
|
||||||
avatarUrl: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
||||||
// ─── Computed ─────────────────────────────────────────────
|
// ─── Computed ─────────────────────────────────────────────
|
||||||
const isDirty = computed(
|
const isDirty = computed(() => form.value.nickname.trim() !== originalNickname.value)
|
||||||
() =>
|
|
||||||
form.value.nickname !== originalForm.value.nickname ||
|
const avatarUrl = computed(() => userStore.user?.avatarUrl ?? '')
|
||||||
form.value.avatarUrl !== originalForm.value.avatarUrl,
|
|
||||||
)
|
|
||||||
|
|
||||||
const nicknameInitial = computed(() => {
|
const nicknameInitial = computed(() => {
|
||||||
const nick = form.value.nickname || '?'
|
const nick = form.value.nickname || '?'
|
||||||
return nick.slice(0, 1).toUpperCase()
|
return nick.slice(0, 1).toUpperCase()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hasPhone = computed(() => !!userStore.user?.phone)
|
||||||
|
|
||||||
const phoneDisplay = computed(() => {
|
const phoneDisplay = computed(() => {
|
||||||
const phone = userStore.user?.phone
|
const phone = userStore.user?.phone
|
||||||
if (!phone) return '未绑定'
|
if (!phone) return '未绑定'
|
||||||
// Mask middle digits: 138****1234
|
// Mask middle 4 digits: 138****1234
|
||||||
return phone.slice(0, 3) + '****' + phone.slice(-4)
|
return `${phone.slice(0, 3)}****${phone.slice(-4)}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const joinDateDisplay = computed(() => {
|
const joinDateDisplay = computed(() => {
|
||||||
@@ -105,53 +117,35 @@ const joinDateDisplay = computed(() => {
|
|||||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`
|
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── Avatar picker ────────────────────────────────────────
|
const activeMembershipCount = computed(
|
||||||
function chooseAvatar() {
|
() => userStore.user?.activeMembershipCount ?? userStore.activeMemberships.length,
|
||||||
uni.chooseImage({
|
)
|
||||||
count: 1,
|
|
||||||
sizeType: ['compressed'],
|
|
||||||
sourceType: ['album', 'camera'],
|
|
||||||
success: (res) => {
|
|
||||||
const tempPath = res.tempFilePaths[0]
|
|
||||||
// Upload to server
|
|
||||||
uploadAvatar(tempPath)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadAvatar(tempPath: string) {
|
// ─── Phone binding ────────────────────────────────────────
|
||||||
uni.showLoading({ title: '上传中...' })
|
async function handleGetPhone(e: {
|
||||||
const token = uni.getStorageSync('token') as string
|
detail: { encryptedData: string; iv: string; errMsg: string }
|
||||||
|
}) {
|
||||||
uni.uploadFile({
|
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
|
||||||
url: 'http://localhost:3000/api/user/avatar',
|
// User denied or cancelled
|
||||||
filePath: tempPath,
|
return
|
||||||
name: 'file',
|
}
|
||||||
header: {
|
uni.showLoading({ title: '绑定中...' })
|
||||||
Authorization: `Bearer ${token}`,
|
try {
|
||||||
},
|
const updated = await wxBindPhone(e as Parameters<typeof wxBindPhone>[0])
|
||||||
success: (res) => {
|
// Refresh store with updated profile
|
||||||
uni.hideLoading()
|
await userStore.fetchProfile()
|
||||||
try {
|
uni.hideLoading()
|
||||||
interface UploadResponse {
|
uni.showToast({ title: '手机号绑定成功', icon: 'success' })
|
||||||
success: boolean
|
// Sync nickname from updated profile
|
||||||
data: { url: string }
|
if (updated.nickname) {
|
||||||
}
|
form.value = { nickname: updated.nickname }
|
||||||
const result = JSON.parse(res.data) as UploadResponse
|
originalNickname.value = updated.nickname
|
||||||
if (result.success && result.data?.url) {
|
}
|
||||||
form.value = { ...form.value, avatarUrl: result.data.url }
|
} catch (err: unknown) {
|
||||||
} else {
|
uni.hideLoading()
|
||||||
throw new Error('上传失败')
|
const msg = err instanceof Error ? err.message : '绑定失败,请重试'
|
||||||
}
|
uni.showToast({ title: msg, icon: 'none' })
|
||||||
} catch {
|
}
|
||||||
uni.showToast({ title: '头像上传失败', icon: 'none' })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fail: () => {
|
|
||||||
uni.hideLoading()
|
|
||||||
uni.showToast({ title: '头像上传失败', icon: 'none' })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Save ─────────────────────────────────────────────────
|
// ─── Save ─────────────────────────────────────────────────
|
||||||
@@ -163,15 +157,16 @@ async function handleSave() {
|
|||||||
uni.showToast({ title: '昵称不能为空', icon: 'none' })
|
uni.showToast({ title: '昵称不能为空', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (nickname.length > 20) {
|
||||||
|
uni.showToast({ title: '昵称最多 20 个字', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
await userStore.updateProfile({
|
await userStore.updateProfile({ nickname })
|
||||||
nickname,
|
originalNickname.value = nickname
|
||||||
avatarUrl: form.value.avatarUrl || undefined,
|
form.value = { nickname }
|
||||||
})
|
|
||||||
// Update original to reflect saved state
|
|
||||||
originalForm.value = { ...form.value }
|
|
||||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : '保存失败,请重试'
|
const msg = err instanceof Error ? err.message : '保存失败,请重试'
|
||||||
@@ -183,15 +178,10 @@ async function handleSave() {
|
|||||||
|
|
||||||
// ─── Lifecycle ────────────────────────────────────────────
|
// ─── Lifecycle ────────────────────────────────────────────
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Ensure we have fresh profile data
|
|
||||||
await userStore.fetchProfile()
|
await userStore.fetchProfile()
|
||||||
if (userStore.user) {
|
if (userStore.user) {
|
||||||
const initial = {
|
form.value = { nickname: userStore.user.nickname }
|
||||||
nickname: userStore.user.nickname,
|
originalNickname.value = userStore.user.nickname
|
||||||
avatarUrl: userStore.user.avatarUrl ?? '',
|
|
||||||
}
|
|
||||||
form.value = { ...initial }
|
|
||||||
originalForm.value = { ...initial }
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -207,15 +197,17 @@ onMounted(async () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 60rpx 0 48rpx;
|
padding: 56rpx 0 40rpx;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
margin-bottom: 24rpx;
|
margin-bottom: 24rpx;
|
||||||
|
border-bottom: 1rpx solid #f0ece8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-wrap {
|
.avatar-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 160rpx;
|
width: 160rpx;
|
||||||
height: 160rpx;
|
height: 160rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
@@ -236,32 +228,20 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.avatar-placeholder-text {
|
.avatar-placeholder-text {
|
||||||
font-size: 60rpx;
|
font-size: 64rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-edit-badge {
|
.avatar-name {
|
||||||
position: absolute;
|
font-size: 34rpx;
|
||||||
bottom: 4rpx;
|
font-weight: 700;
|
||||||
right: 4rpx;
|
color: #1a1a1a;
|
||||||
width: 48rpx;
|
margin-bottom: 6rpx;
|
||||||
height: 48rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.15);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-edit-icon {
|
|
||||||
font-size: 26rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-hint {
|
.avatar-hint {
|
||||||
margin-top: 16rpx;
|
font-size: 22rpx;
|
||||||
font-size: 24rpx;
|
|
||||||
color: #bbb;
|
color: #bbb;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,7 +249,7 @@ onMounted(async () => {
|
|||||||
.form-card {
|
.form-card {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
margin: 0 24rpx;
|
margin: 0 24rpx 20rpx;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
@@ -280,14 +260,11 @@ onMounted(async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 32rpx 28rpx;
|
padding: 32rpx 28rpx;
|
||||||
border-bottom: 1rpx solid #f5f5f5;
|
border-bottom: 1rpx solid #f5f5f5;
|
||||||
|
min-height: 100rpx;
|
||||||
|
|
||||||
&:last-child {
|
&--last {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--readonly {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-label {
|
.form-label {
|
||||||
@@ -304,6 +281,7 @@ onMounted(async () => {
|
|||||||
color: #222;
|
color: #222;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
min-height: 44rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-value {
|
.form-value {
|
||||||
@@ -313,9 +291,74 @@ onMounted(async () => {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-arrow {
|
||||||
|
font-size: 36rpx;
|
||||||
|
color: #ccc;
|
||||||
|
margin-left: 8rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bind phone button (styled, not default wx button) */
|
||||||
|
.bind-phone-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
line-height: normal;
|
||||||
|
|
||||||
|
/* reset uni button default styles */
|
||||||
|
&::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bind-phone-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #c9a87c;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Read-only info card ──────────────────────────────── */
|
||||||
|
.info-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
margin: 0 24rpx 32rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 28rpx 28rpx;
|
||||||
|
border-bottom: 1rpx solid #f5f5f5;
|
||||||
|
|
||||||
|
&--last {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #555;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Save button ─────────────────────────────────────── */
|
/* ── Save button ─────────────────────────────────────── */
|
||||||
.save-wrap {
|
.save-wrap {
|
||||||
padding: 40rpx 24rpx;
|
padding: 8rpx 24rpx 48rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-btn {
|
.save-btn {
|
||||||
@@ -327,6 +370,7 @@ onMounted(async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 4rpx 20rpx rgba(26, 26, 46, 0.3);
|
box-shadow: 0 4rpx 20rpx rgba(26, 26, 46, 0.3);
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
@@ -334,7 +378,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
&--loading,
|
&--loading,
|
||||||
&--disabled {
|
&--disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.45;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<view v-else-if="memberships.length === 0" class="empty-wrap">
|
<view v-else-if="allMemberships.length === 0" class="empty-wrap">
|
||||||
<text class="empty-icon">💳</text>
|
<text class="empty-icon">💳</text>
|
||||||
<text class="empty-title">暂无会员卡</text>
|
<text class="empty-title">暂无会员卡</text>
|
||||||
<text class="empty-sub">购买会员卡后即可预约课程</text>
|
<text class="empty-sub">购买会员卡后即可预约课程</text>
|
||||||
@@ -26,27 +26,59 @@
|
|||||||
<!-- Membership list -->
|
<!-- Membership list -->
|
||||||
<view v-else class="list">
|
<view v-else class="list">
|
||||||
<!-- Active cards -->
|
<!-- Active cards -->
|
||||||
<view v-if="activeMemberships.length > 0">
|
<view v-if="activeMemberships.length > 0" class="group-section">
|
||||||
<text class="group-title">有效会员卡</text>
|
<view class="group-header">
|
||||||
|
<view class="group-dot group-dot--active" />
|
||||||
|
<text class="group-title">有效会员卡</text>
|
||||||
|
<text class="group-count">{{ activeMemberships.length }} 张</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view
|
<view
|
||||||
v-for="m in activeMemberships"
|
v-for="m in activeMemberships"
|
||||||
:key="m.id"
|
:key="m.id"
|
||||||
class="card-item card-item--active"
|
class="card-item"
|
||||||
>
|
>
|
||||||
<view class="card-top" :class="cardTopClass(m)">
|
<!-- Colored left border strip -->
|
||||||
<view>
|
<view class="card-strip" :class="stripClass(m.cardType.type)" />
|
||||||
|
|
||||||
|
<!-- Card header (colored gradient) -->
|
||||||
|
<view class="card-header" :class="headerClass(m.cardType.type)">
|
||||||
|
<view class="card-header-left">
|
||||||
<text class="card-name">{{ m.cardType.name }}</text>
|
<text class="card-name">{{ m.cardType.name }}</text>
|
||||||
<text class="card-type-tag">{{ typeLabel(m.cardType.type) }}</text>
|
<view class="card-type-badge">
|
||||||
|
<text class="card-type-badge-text">{{ typeLabel(m.cardType.type) }}</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="card-badge card-badge--active">
|
<view class="status-badge status-badge--active">
|
||||||
<text class="badge-text">有效</text>
|
<text class="status-badge-text">有效</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- Card body -->
|
||||||
<view class="card-body">
|
<view class="card-body">
|
||||||
<view class="info-row" v-if="m.remainingTimes !== null">
|
<!-- Times card: remaining times + progress -->
|
||||||
<text class="info-label">剩余课时</text>
|
<template v-if="m.remainingTimes !== null">
|
||||||
<text class="info-value info-value--highlight">{{ m.remainingTimes }} 次</text>
|
<view class="highlight-row">
|
||||||
</view>
|
<text class="highlight-label">剩余课时</text>
|
||||||
|
<text class="highlight-value">
|
||||||
|
<text class="highlight-number">{{ m.remainingTimes }}</text>
|
||||||
|
<text class="highlight-unit"> 次</text>
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="m.cardType.totalTimes" class="progress-wrap">
|
||||||
|
<view class="progress-bar">
|
||||||
|
<view
|
||||||
|
class="progress-fill"
|
||||||
|
:style="{ width: progressWidth(m) }"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<text class="progress-label">
|
||||||
|
已使用 {{ usedTimes(m) }} / {{ m.cardType.totalTimes }} 次
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Duration card: expiry -->
|
||||||
<view class="info-row">
|
<view class="info-row">
|
||||||
<text class="info-label">有效期至</text>
|
<text class="info-label">有效期至</text>
|
||||||
<text class="info-value">{{ m.expireDate.slice(0, 10) }}</text>
|
<text class="info-value">{{ m.expireDate.slice(0, 10) }}</text>
|
||||||
@@ -56,40 +88,36 @@
|
|||||||
<text class="info-value">{{ m.startDate.slice(0, 10) }}</text>
|
<text class="info-value">{{ m.startDate.slice(0, 10) }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<!-- Progress bar for time-based cards -->
|
|
||||||
<view v-if="m.remainingTimes !== null && m.cardType.totalTimes" class="progress-wrap">
|
|
||||||
<view class="progress-bar">
|
|
||||||
<view
|
|
||||||
class="progress-fill"
|
|
||||||
:style="{ width: progressWidth(m) }"
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
<text class="progress-label">
|
|
||||||
已使用 {{ m.cardType.totalTimes - m.remainingTimes }}/{{ m.cardType.totalTimes }} 次
|
|
||||||
</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Expired / used up cards -->
|
<!-- Expired / used up cards -->
|
||||||
<view v-if="inactiveMemberships.length > 0" class="inactive-section">
|
<view v-if="inactiveMemberships.length > 0" class="group-section">
|
||||||
<text class="group-title">历史记录</text>
|
<view class="group-header">
|
||||||
|
<view class="group-dot group-dot--inactive" />
|
||||||
|
<text class="group-title">历史记录</text>
|
||||||
|
<text class="group-count">{{ inactiveMemberships.length }} 张</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view
|
<view
|
||||||
v-for="m in inactiveMemberships"
|
v-for="m in inactiveMemberships"
|
||||||
:key="m.id"
|
:key="m.id"
|
||||||
class="card-item card-item--inactive"
|
class="card-item card-item--inactive"
|
||||||
>
|
>
|
||||||
<view class="card-top card-top--inactive">
|
<view class="card-strip card-strip--inactive" />
|
||||||
<view>
|
<view class="card-header card-header--inactive">
|
||||||
|
<view class="card-header-left">
|
||||||
<text class="card-name card-name--dim">{{ m.cardType.name }}</text>
|
<text class="card-name card-name--dim">{{ m.cardType.name }}</text>
|
||||||
<text class="card-type-tag card-type-tag--dim">{{ typeLabel(m.cardType.type) }}</text>
|
<view class="card-type-badge card-type-badge--dim">
|
||||||
|
<text class="card-type-badge-text">{{ typeLabel(m.cardType.type) }}</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="card-badge" :class="statusBadgeClass(m.status)">
|
<view class="status-badge" :class="statusBadgeClass(m.status)">
|
||||||
<text class="badge-text">{{ statusLabel(m.status) }}</text>
|
<text class="status-badge-text">{{ statusLabel(m.status) }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="card-body">
|
<view class="card-body">
|
||||||
<view class="info-row" v-if="m.remainingTimes !== null">
|
<view v-if="m.remainingTimes !== null" class="info-row">
|
||||||
<text class="info-label">剩余课时</text>
|
<text class="info-label">剩余课时</text>
|
||||||
<text class="info-value">{{ m.remainingTimes }} 次</text>
|
<text class="info-value">{{ m.remainingTimes }} 次</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -107,7 +135,8 @@
|
|||||||
|
|
||||||
<!-- Buy more FAB -->
|
<!-- Buy more FAB -->
|
||||||
<view class="fab" @tap="goStore">
|
<view class="fab" @tap="goStore">
|
||||||
<text class="fab-text">+ 购买会员卡</text>
|
<text class="fab-icon">+</text>
|
||||||
|
<text class="fab-text">购买会员卡</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -116,20 +145,23 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import type { MembershipWithCardType } from '@mp-pilates/shared'
|
import type { MembershipWithCardType } from '@mp-pilates/shared'
|
||||||
import { MembershipStatus, CardTypeCategory } 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 ────────────────────────────────────────────────
|
// ─── State ────────────────────────────────────────────────
|
||||||
const memberships = ref<MembershipWithCardType[]>([])
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const refreshing = ref(false)
|
const refreshing = ref(false)
|
||||||
|
|
||||||
// ─── Computed ─────────────────────────────────────────────
|
// ─── Computed from store ───────────────────────────────────
|
||||||
|
const allMemberships = computed(() => userStore.memberships as MembershipWithCardType[])
|
||||||
|
|
||||||
const activeMemberships = computed(() =>
|
const activeMemberships = computed(() =>
|
||||||
memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
|
allMemberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
|
||||||
)
|
)
|
||||||
|
|
||||||
const inactiveMemberships = computed(() =>
|
const inactiveMemberships = computed(() =>
|
||||||
memberships.value.filter((m) => m.status !== MembershipStatus.ACTIVE),
|
allMemberships.value.filter((m) => m.status !== MembershipStatus.ACTIVE),
|
||||||
)
|
)
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────
|
||||||
@@ -152,15 +184,21 @@ function statusLabel(status: MembershipStatus): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function statusBadgeClass(status: MembershipStatus): string {
|
function statusBadgeClass(status: MembershipStatus): string {
|
||||||
if (status === MembershipStatus.EXPIRED) return 'card-badge--expired'
|
if (status === MembershipStatus.EXPIRED) return 'status-badge--expired'
|
||||||
if (status === MembershipStatus.USED_UP) return 'card-badge--used'
|
if (status === MembershipStatus.USED_UP) return 'status-badge--used'
|
||||||
return ''
|
return 'status-badge--expired'
|
||||||
}
|
}
|
||||||
|
|
||||||
function cardTopClass(m: MembershipWithCardType): string {
|
function stripClass(type: CardTypeCategory): string {
|
||||||
if (m.cardType.type === CardTypeCategory.TRIAL) return 'card-top--trial'
|
if (type === CardTypeCategory.TRIAL) return 'card-strip--trial'
|
||||||
if (m.cardType.type === CardTypeCategory.DURATION) return 'card-top--duration'
|
if (type === CardTypeCategory.DURATION) return 'card-strip--duration'
|
||||||
return 'card-top--times'
|
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 {
|
function progressWidth(m: MembershipWithCardType): string {
|
||||||
@@ -169,11 +207,16 @@ function progressWidth(m: MembershipWithCardType): string {
|
|||||||
return `${Math.max(0, Math.min(100, pct))}%`
|
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 ─────────────────────────────────────────
|
// ─── Data loading ─────────────────────────────────────────
|
||||||
async function loadMemberships() {
|
async function loadMemberships() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
memberships.value = await get<MembershipWithCardType[]>('/membership/my')
|
await userStore.fetchMemberships()
|
||||||
} catch {
|
} catch {
|
||||||
uni.showToast({ title: '加载失败,请下拉刷新', icon: 'none' })
|
uni.showToast({ title: '加载失败,请下拉刷新', icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -183,13 +226,11 @@ async function loadMemberships() {
|
|||||||
|
|
||||||
async function onRefresh() {
|
async function onRefresh() {
|
||||||
refreshing.value = true
|
refreshing.value = true
|
||||||
await loadMemberships()
|
await userStore.fetchMemberships()
|
||||||
refreshing.value = false
|
refreshing.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function goStore() {
|
function goStore() {
|
||||||
uni.navigateBack({ delta: 10 })
|
|
||||||
// Navigate to store tab
|
|
||||||
uni.switchTab({ url: '/pages/home/index' })
|
uni.switchTab({ url: '/pages/home/index' })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +257,7 @@ onMounted(loadMemberships)
|
|||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-card {
|
.skeleton-card {
|
||||||
height: 200rpx;
|
height: 220rpx;
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||||
background-size: 400% 100%;
|
background-size: 400% 100%;
|
||||||
@@ -255,9 +296,10 @@ onMounted(loadMemberships)
|
|||||||
|
|
||||||
.empty-btn {
|
.empty-btn {
|
||||||
margin-top: 12rpx;
|
margin-top: 12rpx;
|
||||||
padding: 20rpx 56rpx;
|
padding: 22rpx 60rpx;
|
||||||
border-radius: 44rpx;
|
border-radius: 44rpx;
|
||||||
background: #c9a87c;
|
background: #c9a87c;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(201, 168, 124, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-btn-text {
|
.empty-btn-text {
|
||||||
@@ -269,17 +311,41 @@ onMounted(loadMemberships)
|
|||||||
/* ── List ────────────────────────────────────────────── */
|
/* ── List ────────────────────────────────────────────── */
|
||||||
.list {
|
.list {
|
||||||
padding: 24rpx 24rpx 0;
|
padding: 24rpx 24rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Group section ───────────────────────────────────── */
|
||||||
|
.group-section {
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
gap: 8rpx;
|
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 {
|
.group-title {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #999;
|
color: #555;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
padding: 8rpx 4rpx 12rpx;
|
flex: 1;
|
||||||
display: block;
|
}
|
||||||
|
|
||||||
|
.group-count {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #bbb;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Card item ───────────────────────────────────────── */
|
/* ── Card item ───────────────────────────────────────── */
|
||||||
@@ -288,15 +354,28 @@ onMounted(loadMemberships)
|
|||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 16rpx;
|
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 {
|
&--inactive {
|
||||||
opacity: 0.75;
|
opacity: 0.72;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-top {
|
/* Colored left border strip */
|
||||||
padding: 24rpx 28rpx;
|
.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;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -308,46 +387,88 @@ onMounted(loadMemberships)
|
|||||||
&--inactive { background: #888; }
|
&--inactive { background: #888; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-header-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.card-name {
|
.card-name {
|
||||||
font-size: 32rpx;
|
font-size: 32rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
display: block;
|
|
||||||
margin-bottom: 6rpx;
|
|
||||||
|
|
||||||
&--dim { color: #ddd; }
|
&--dim { color: #ddd; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-type-tag {
|
.card-type-badge {
|
||||||
font-size: 20rpx;
|
align-self: flex-start;
|
||||||
color: rgba(255, 255, 255, 0.7);
|
padding: 4rpx 14rpx;
|
||||||
font-weight: 400;
|
border-radius: 12rpx;
|
||||||
display: block;
|
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;
|
padding: 8rpx 20rpx;
|
||||||
border-radius: 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); }
|
&--expired { background: rgba(0, 0, 0, 0.2); }
|
||||||
&--used { background: rgba(0, 0, 0, 0.2); }
|
&--used { background: rgba(0, 0, 0, 0.2); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-text {
|
.status-badge-text {
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Card body */
|
||||||
.card-body {
|
.card-body {
|
||||||
padding: 20rpx 28rpx;
|
padding: 20rpx 28rpx 24rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.info-row {
|
||||||
@@ -366,20 +487,14 @@ onMounted(loadMemberships)
|
|||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #333;
|
color: #333;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
&--highlight {
|
|
||||||
color: #c9a87c;
|
|
||||||
font-size: 30rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Progress bar ────────────────────────────────────── */
|
/* ── Progress bar ────────────────────────────────────── */
|
||||||
.progress-wrap {
|
.progress-wrap {
|
||||||
padding: 0 28rpx 20rpx;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8rpx;
|
gap: 8rpx;
|
||||||
|
margin-bottom: 6rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
@@ -402,11 +517,6 @@ onMounted(loadMemberships)
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Inactive section ────────────────────────────────── */
|
|
||||||
.inactive-section {
|
|
||||||
margin-top: 8rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── FAB ─────────────────────────────────────────────── */
|
/* ── FAB ─────────────────────────────────────────────── */
|
||||||
.fab {
|
.fab {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -417,12 +527,23 @@ onMounted(loadMemberships)
|
|||||||
padding: 22rpx 36rpx;
|
padding: 22rpx 36rpx;
|
||||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.2);
|
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.2);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fab-icon {
|
||||||
|
font-size: 36rpx;
|
||||||
|
color: #c9a87c;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.fab-text {
|
.fab-text {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -432,6 +553,6 @@ onMounted(loadMemberships)
|
|||||||
|
|
||||||
/* ── Spacer ──────────────────────────────────────────── */
|
/* ── Spacer ──────────────────────────────────────────── */
|
||||||
.scroll-bottom-spacer {
|
.scroll-bottom-spacer {
|
||||||
height: 100rpx;
|
height: 120rpx;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
170
packages/app/src/stores/admin.ts
Normal file
170
packages/app/src/stores/admin.ts
Normal file
@@ -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<WeekTemplate[]>([])
|
||||||
|
|
||||||
|
async function fetchWeekTemplates(): Promise<WeekTemplate[]> {
|
||||||
|
const data = await get<WeekTemplate[]>('/admin/week-template')
|
||||||
|
weekTemplates.value = data
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveWeekTemplates(templates: WeekTemplateInput[]): Promise<WeekTemplate[]> {
|
||||||
|
const data = await put<WeekTemplate[]>('/admin/week-template', templates)
|
||||||
|
weekTemplates.value = data
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Card types ───────────────────────────────────────────────────
|
||||||
|
const cardTypes = ref<CardType[]>([])
|
||||||
|
|
||||||
|
async function fetchCardTypes(): Promise<CardType[]> {
|
||||||
|
const data = await get<CardType[]>('/admin/card-types')
|
||||||
|
cardTypes.value = [...data].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
return cardTypes.value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCardType(dto: CreateCardTypeDto): Promise<CardType> {
|
||||||
|
const data = await post<CardType>('/admin/card-types', dto)
|
||||||
|
await fetchCardTypes()
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateCardType(id: string, dto: UpdateCardTypeDto): Promise<CardType> {
|
||||||
|
const data = await put<CardType>(`/admin/card-types/${id}`, dto)
|
||||||
|
await fetchCardTypes()
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCardType(id: string): Promise<void> {
|
||||||
|
await del(`/admin/card-types/${id}`)
|
||||||
|
await fetchCardTypes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Studio config ────────────────────────────────────────────────
|
||||||
|
const studioConfig = ref<StudioConfig | null>(null)
|
||||||
|
|
||||||
|
async function fetchStudioConfig(): Promise<StudioConfig> {
|
||||||
|
const data = await get<StudioConfig>('/studio/info')
|
||||||
|
studioConfig.value = data
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveStudioConfig(dto: UpdateStudioConfigDto): Promise<StudioConfig> {
|
||||||
|
const data = await put<StudioConfig>('/admin/studio/info', dto)
|
||||||
|
studioConfig.value = data
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Orders ───────────────────────────────────────────────────────
|
||||||
|
async function fetchAdminOrders(params: {
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
status?: string
|
||||||
|
}): Promise<PaginatedData<OrderWithDetails>> {
|
||||||
|
return get<PaginatedData<OrderWithDetails>>('/admin/orders', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bookings ─────────────────────────────────────────────────────
|
||||||
|
async function fetchAdminBookings(params?: {
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
userId?: string
|
||||||
|
}): Promise<PaginatedData<any>> {
|
||||||
|
return get<PaginatedData<any>>('/admin/bookings', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Members ──────────────────────────────────────────────────────
|
||||||
|
async function fetchMembers(params?: {
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
search?: string
|
||||||
|
}): Promise<PaginatedData<MemberSummary>> {
|
||||||
|
return get<PaginatedData<MemberSummary>>('/admin/members', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Time slots ───────────────────────────────────────────────────
|
||||||
|
async function fetchSlotsByDate(date: string): Promise<TimeSlot[]> {
|
||||||
|
return get<TimeSlot[]>('/admin/time-slots', { date })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createManualSlot(dto: CreateManualSlotDto): Promise<TimeSlot> {
|
||||||
|
return post<TimeSlot>('/admin/time-slot/manual', dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeSlot(id: string): Promise<TimeSlot> {
|
||||||
|
return put<TimeSlot>(`/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<AdminStats> {
|
||||||
|
return get<AdminStats>('/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,
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user