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