feat(app): implement all sub-pages and admin management pages

Sub-pages: card purchase with WeChat Pay flow, my memberships with
progress bars, my bookings with tabs, personal info editor
Admin: management center grid, week template CRUD, slot adjustment,
member management with search, order list with filters, card type
CRUD with form modal, studio settings editor
Admin Pinia store for all admin API calls
This commit is contained in:
richarjiang
2026-04-02 15:25:57 +08:00
parent 3a29aca0db
commit 7a06b5e336
12 changed files with 1809 additions and 1680 deletions

View File

@@ -88,12 +88,23 @@
<view class="modal-field"> <view class="modal-field">
<text class="modal-label">卡种名称</text> <text class="modal-label">卡种名称</text>
<input class="modal-input" v-model="form.name" placeholder="如10次课套餐" placeholder-style="color:#bbb" /> <input
class="modal-input"
v-model="form.name"
placeholder="如10次课套餐"
placeholder-style="color:#bbb"
/>
</view> </view>
<view class="modal-field"> <view class="modal-field">
<text class="modal-label">类型</text> <text class="modal-label">类型</text>
<picker mode="selector" :range="typeOptions" range-key="label" :value="form.typeIdx" @change="(e: any) => form.typeIdx = Number(e.detail.value)"> <picker
mode="selector"
:range="typeOptions"
range-key="label"
:value="form.typeIdx"
@change="(e: any) => form.typeIdx = Number(e.detail.value)"
>
<view class="picker-display"> <view class="picker-display">
<text class="picker-text">{{ typeOptions[form.typeIdx].label }}</text> <text class="picker-text">{{ typeOptions[form.typeIdx].label }}</text>
<text class="picker-arrow"></text> <text class="picker-arrow"></text>
@@ -103,27 +114,57 @@
<view class="modal-field"> <view class="modal-field">
<text class="modal-label">现价</text> <text class="modal-label">现价</text>
<input class="modal-input" type="digit" v-model="form.priceStr" placeholder="如980" placeholder-style="color:#bbb" /> <input
class="modal-input"
type="digit"
v-model="form.priceStr"
placeholder="如980"
placeholder-style="color:#bbb"
/>
</view> </view>
<view class="modal-field"> <view class="modal-field">
<text class="modal-label">原价</text> <text class="modal-label">原价</text>
<input class="modal-input" type="digit" v-model="form.originalPriceStr" placeholder="可选,用于展示划线价" placeholder-style="color:#bbb" /> <input
class="modal-input"
type="digit"
v-model="form.originalPriceStr"
placeholder="可选,用于展示划线价"
placeholder-style="color:#bbb"
/>
</view> </view>
<view class="modal-field"> <view class="modal-field">
<text class="modal-label">次数</text> <text class="modal-label">次数</text>
<input class="modal-input" type="number" v-model="form.totalTimesStr" placeholder="次卡必填,月卡留空" placeholder-style="color:#bbb" /> <input
class="modal-input"
type="number"
v-model="form.totalTimesStr"
placeholder="次卡必填,月卡留空"
placeholder-style="color:#bbb"
/>
</view> </view>
<view class="modal-field"> <view class="modal-field">
<text class="modal-label">有效天数</text> <text class="modal-label">有效天数</text>
<input class="modal-input" type="number" v-model="form.durationDaysStr" placeholder="如90" placeholder-style="color:#bbb" /> <input
class="modal-input"
type="number"
v-model="form.durationDaysStr"
placeholder="如90"
placeholder-style="color:#bbb"
/>
</view> </view>
<view class="modal-field"> <view class="modal-field">
<text class="modal-label">排序值</text> <text class="modal-label">排序值</text>
<input class="modal-input" type="number" v-model="form.sortOrderStr" placeholder="数字越小越靠前" placeholder-style="color:#bbb" /> <input
class="modal-input"
type="number"
v-model="form.sortOrderStr"
placeholder="数字越小越靠前"
placeholder-style="color:#bbb"
/>
</view> </view>
<view class="modal-field modal-field--last"> <view class="modal-field modal-field--last">
@@ -142,7 +183,11 @@
<view class="modal-cancel" @tap="closeModal"> <view class="modal-cancel" @tap="closeModal">
<text class="modal-cancel-text">取消</text> <text class="modal-cancel-text">取消</text>
</view> </view>
<view class="modal-confirm" :class="{ 'modal-confirm--loading': submitting }" @tap="submitForm"> <view
class="modal-confirm"
:class="{ 'modal-confirm--loading': submitting }"
@tap="submitForm"
>
<text class="modal-confirm-text">{{ submitting ? '保存中...' : '确认' }}</text> <text class="modal-confirm-text">{{ submitting ? '保存中...' : '确认' }}</text>
</view> </view>
</view> </view>
@@ -153,11 +198,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { get, post, put, del } from '../../utils/request' import { useAdminStore } from '../../stores/admin'
import { formatPrice } from '../../utils/format' import { formatPrice } from '../../utils/format'
import { CardTypeCategory } from '@mp-pilates/shared' import { CardTypeCategory } from '@mp-pilates/shared'
import type { CardType } from '@mp-pilates/shared' import type { CardType } from '@mp-pilates/shared'
const adminStore = useAdminStore()
const cardTypes = ref<CardType[]>([]) const cardTypes = ref<CardType[]>([])
const loading = ref(false) const loading = ref(false)
const showModal = ref(false) const showModal = ref(false)
@@ -184,8 +231,7 @@ const form = ref({
async function fetchCardTypes() { async function fetchCardTypes() {
loading.value = true loading.value = true
try { try {
const data = await get<CardType[]>('/admin/card-types') cardTypes.value = await adminStore.fetchCardTypes()
cardTypes.value = data.sort((a, b) => a.sortOrder - b.sortOrder)
} catch { } catch {
uni.showToast({ title: '加载失败', icon: 'none' }) uni.showToast({ title: '加载失败', icon: 'none' })
} finally { } finally {
@@ -266,9 +312,9 @@ async function submitForm() {
submitting.value = true submitting.value = true
try { try {
if (editTarget.value) { if (editTarget.value) {
await put(`/admin/card-types/${editTarget.value.id}`, payload) await adminStore.updateCardType(editTarget.value.id, payload as any)
} else { } else {
await post('/admin/card-types', payload) await adminStore.createCardType(payload as any)
} }
uni.showToast({ title: '保存成功', icon: 'success' }) uni.showToast({ title: '保存成功', icon: 'success' })
closeModal() closeModal()
@@ -282,7 +328,7 @@ async function submitForm() {
async function toggleActive(ct: CardType) { async function toggleActive(ct: CardType) {
try { try {
await put(`/admin/card-types/${ct.id}`, { isActive: !ct.isActive }) await adminStore.updateCardType(ct.id, { isActive: !ct.isActive })
await fetchCardTypes() await fetchCardTypes()
} catch { } catch {
uni.showToast({ title: '操作失败', icon: 'none' }) uni.showToast({ title: '操作失败', icon: 'none' })
@@ -296,7 +342,7 @@ function confirmDelete(ct: CardType) {
success: async (res) => { success: async (res) => {
if (res.confirm) { if (res.confirm) {
try { try {
await del(`/admin/card-types/${ct.id}`) await adminStore.deleteCardType(ct.id)
uni.showToast({ title: '已删除', icon: 'success' }) uni.showToast({ title: '已删除', icon: 'success' })
await fetchCardTypes() await fetchCardTypes()
} catch { } catch {
@@ -340,10 +386,7 @@ onMounted(fetchCardTypes)
padding: 24rpx 24rpx 16rpx; padding: 24rpx 24rpx 16rpx;
} }
.toolbar-hint { .toolbar-hint { font-size: 24rpx; color: #999; }
font-size: 24rpx;
color: #999;
}
.add-btn { .add-btn {
background: #1a1a2e; background: #1a1a2e;
@@ -351,16 +394,10 @@ onMounted(fetchCardTypes)
padding: 12rpx 28rpx; padding: 12rpx 28rpx;
} }
.add-btn-text { .add-btn-text { font-size: 26rpx; font-weight: 600; color: #c9a87c; }
font-size: 26rpx;
font-weight: 600;
color: #c9a87c;
}
/* ── Skeleton ────────────────────────────── */ /* ── Skeleton ────────────────────────────── */
.skeleton-list { .skeleton-list { padding: 0 24rpx; }
padding: 0 24rpx;
}
.skeleton-item { .skeleton-item {
height: 260rpx; height: 260rpx;
@@ -389,20 +426,16 @@ onMounted(fetchCardTypes)
.empty-text { font-size: 28rpx; color: #bbb; } .empty-text { font-size: 28rpx; color: #bbb; }
/* ── Card type list ──────────────────────── */ /* ── Card type list ──────────────────────── */
.ct-list { .ct-list { padding: 0 24rpx; }
padding: 0 24rpx;
}
.ct-card { .ct-card {
background: #ffffff; background: #ffffff;
border-radius: 16rpx; border-radius: 16rpx;
overflow: hidden; overflow: hidden;
margin-bottom: 20rpx; margin-bottom: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08); box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.08);
&--inactive { &--inactive { opacity: 0.6; }
opacity: 0.6;
}
} }
.ct-header { .ct-header {
@@ -416,29 +449,14 @@ onMounted(fetchCardTypes)
.header--duration { background: linear-gradient(90deg, #6c3483, #9b59b6); } .header--duration { background: linear-gradient(90deg, #6c3483, #9b59b6); }
.header--trial { background: linear-gradient(90deg, #7d6608, #c9a87c); } .header--trial { background: linear-gradient(90deg, #7d6608, #c9a87c); }
.ct-type-label { .ct-type-label { font-size: 22rpx; font-weight: 600; color: #ffffff; letter-spacing: 2rpx; }
font-size: 22rpx;
font-weight: 600;
color: #ffffff;
letter-spacing: 2rpx;
}
.ct-status-tag {
border-radius: 20rpx;
padding: 4rpx 16rpx;
}
.ct-status-tag { border-radius: 20rpx; padding: 4rpx 16rpx; }
.tag--on { background: rgba(255,255,255,0.2); } .tag--on { background: rgba(255,255,255,0.2); }
.tag--off { background: rgba(0,0,0,0.2); } .tag--off { background: rgba(0,0,0,0.2); }
.ct-status-text { font-size: 20rpx; color: #ffffff; }
.ct-status-text { .ct-body { padding: 24rpx; }
font-size: 20rpx;
color: #ffffff;
}
.ct-body {
padding: 24rpx;
}
.ct-name { .ct-name {
font-size: 32rpx; font-size: 32rpx;
@@ -455,11 +473,7 @@ onMounted(fetchCardTypes)
margin-bottom: 12rpx; margin-bottom: 12rpx;
} }
.ct-price { .ct-price { font-size: 40rpx; font-weight: 800; color: #c9a87c; }
font-size: 40rpx;
font-weight: 800;
color: #c9a87c;
}
.ct-original { .ct-original {
font-size: 24rpx; font-size: 24rpx;
@@ -475,27 +489,11 @@ onMounted(fetchCardTypes)
margin-bottom: 16rpx; margin-bottom: 16rpx;
} }
.ct-meta { .ct-meta { display: flex; gap: 24rpx; }
display: flex;
gap: 24rpx;
}
.meta-item { .meta-item { display: flex; align-items: baseline; gap: 4rpx; }
display: flex; .meta-value { font-size: 28rpx; font-weight: 700; color: #1a1a2e; }
align-items: baseline; .meta-label { font-size: 22rpx; color: #999; }
gap: 4rpx;
}
.meta-value {
font-size: 28rpx;
font-weight: 700;
color: #1a1a2e;
}
.meta-label {
font-size: 22rpx;
color: #999;
}
/* ── Actions ─────────────────────────────── */ /* ── Actions ─────────────────────────────── */
.ct-actions { .ct-actions {
@@ -511,15 +509,10 @@ onMounted(fetchCardTypes)
justify-content: center; justify-content: center;
border-right: 1rpx solid #f5f5f5; border-right: 1rpx solid #f5f5f5;
&:last-child { &:last-child { border-right: none; }
border-right: none;
}
} }
.ct-action-text { .ct-action-text { font-size: 26rpx; font-weight: 600; }
font-size: 26rpx;
font-weight: 600;
}
.edit-btn .ct-action-text { color: #1a1a2e; } .edit-btn .ct-action-text { color: #1a1a2e; }
.toggle-on .ct-action-text { color: #27ae60; } .toggle-on .ct-action-text { color: #27ae60; }
@@ -530,7 +523,7 @@ onMounted(fetchCardTypes)
.modal-mask { .modal-mask {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.5); background: rgba(0,0,0,0.5);
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
z-index: 100; z-index: 100;
@@ -560,32 +553,14 @@ onMounted(fetchCardTypes)
border-bottom: 1rpx solid #f5f5f5; border-bottom: 1rpx solid #f5f5f5;
gap: 16rpx; gap: 16rpx;
&--last { &--last { border-bottom: none; align-items: flex-start; }
border-bottom: none;
align-items: flex-start;
}
} }
.modal-label { .modal-label { font-size: 26rpx; color: #555; width: 140rpx; flex-shrink: 0; }
font-size: 26rpx;
color: #555;
width: 140rpx;
flex-shrink: 0;
}
.modal-input { .modal-input { flex: 1; text-align: right; font-size: 26rpx; color: #222; }
flex: 1;
text-align: right;
font-size: 26rpx;
color: #222;
}
.picker-display {
display: flex;
align-items: center;
gap: 8rpx;
}
.picker-display { display: flex; align-items: center; gap: 8rpx; }
.picker-text { font-size: 26rpx; color: #222; } .picker-text { font-size: 26rpx; color: #222; }
.picker-arrow { font-size: 26rpx; color: #bbb; } .picker-arrow { font-size: 26rpx; color: #bbb; }
@@ -627,9 +602,5 @@ onMounted(fetchCardTypes)
&--loading { opacity: 0.6; } &--loading { opacity: 0.6; }
} }
.modal-confirm-text { .modal-confirm-text { font-size: 28rpx; font-weight: 700; color: #c9a87c; }
font-size: 28rpx;
font-weight: 700;
color: #c9a87c;
}
</style> </style>

View File

@@ -1,38 +1,38 @@
<template> <template>
<view class="admin-page"> <view class="page">
<!-- Header -->
<view class="admin-header">
<view class="header-top">
<text class="header-title">管理中心</text>
<view class="admin-badge">
<text class="admin-badge-text">管理员</text>
</view>
</view>
<text class="header-sub">欢迎回来{{ userStore.user?.nickname }}</text>
</view>
<!-- Stats row --> <!-- Stats row -->
<view class="stats-row"> <view class="stats-row">
<view v-for="stat in stats" :key="stat.label" class="stat-cell"> <view v-if="statsLoading" class="stats-shimmer-wrap">
<view v-if="loadingStats" class="stat-skeleton" /> <view v-for="i in 3" :key="i" class="stats-shimmer" />
<template v-else>
<text class="stat-value">{{ stat.value }}</text>
<text class="stat-label">{{ stat.label }}</text>
</template>
</view> </view>
<template v-else>
<view class="stat-item">
<text class="stat-value">{{ stats.todayBookings }}</text>
<text class="stat-label">今日预约</text>
</view>
<view class="stat-divider" />
<view class="stat-item">
<text class="stat-value">{{ stats.totalOrders }}</text>
<text class="stat-label">总订单</text>
</view>
<view class="stat-divider" />
<view class="stat-item">
<text class="stat-value">{{ stats.totalBookings }}</text>
<text class="stat-label">总预约</text>
</view>
</template>
</view> </view>
<!-- Nav grid --> <!-- Nav grid -->
<view class="grid"> <view class="nav-grid">
<view <view
v-for="item in navItems" v-for="item in navItems"
:key="item.path" :key="item.path"
class="grid-item" class="nav-item"
@tap="navigate(item.path)" @tap="navigate(item.path)"
> >
<text class="grid-icon">{{ item.icon }}</text> <text class="nav-icon">{{ item.icon }}</text>
<text class="grid-label">{{ item.label }}</text> <text class="nav-label">{{ item.label }}</text>
<text class="grid-desc">{{ item.desc }}</text>
</view> </view>
</view> </view>
</view> </view>
@@ -40,153 +40,71 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useUserStore } from '../../stores/user' import { useAdminStore } from '../../stores/admin'
import { get } from '../../utils/request' import type { AdminStats } from '../../stores/admin'
import type { PaginatedData, OrderWithDetails, BookingWithDetails } from '@mp-pilates/shared'
const userStore = useUserStore() const adminStore = useAdminStore()
const loadingStats = ref(true)
interface Stat { const statsLoading = ref(false)
label: string const stats = ref<AdminStats>({ todayBookings: 0, totalOrders: 0, totalBookings: 0 })
value: string | number
}
const stats = ref<Stat[]>([
{ label: '今日预约', value: '-' },
{ label: '总订单', value: '-' },
{ label: '总预约', value: '-' },
])
const navItems = [ const navItems = [
{ path: '/pages/admin/week-template', icon: '📅', label: '排课设置', desc: '管理周课模板' }, { icon: '📅', label: '排课设置', path: '/pages/admin/week-template' },
{ path: '/pages/admin/slot-adjust', icon: '🗓️', label: '时调整', desc: '手动添加/关闭时段' }, { icon: '🔧', label: '时调整', path: '/pages/admin/slot-adjust' },
{ path: '/pages/admin/members', icon: '👥', label: '会员管理', desc: '查看会员活跃度' }, { icon: '👥', label: '会员管理', path: '/pages/admin/members' },
{ path: '/pages/admin/orders', icon: '📋', label: '订单管理', desc: '查看购卡订单' }, { icon: '📋', label: '订单管理', path: '/pages/admin/orders' },
{ path: '/pages/admin/card-types', icon: '💳', label: '卡种管理', desc: '配置会员卡套餐' }, { icon: '💳', label: '卡种管理', path: '/pages/admin/card-types' },
{ path: '/pages/admin/studio', icon: '🏢', label: '工作室设置', desc: '基本信息配置' }, { icon: '🏢', label: '工作室设置', path: '/pages/admin/studio' },
] ]
async function loadStats() {
loadingStats.value = true
try {
const today = new Date().toISOString().slice(0, 10)
const [bookingsRes, ordersRes] = await Promise.all([
get<PaginatedData<BookingWithDetails>>('/admin/bookings?page=1&limit=1'),
get<PaginatedData<OrderWithDetails>>('/admin/orders?page=1&limit=1'),
])
// Today's bookings — fetch with date filter
const todayRes = await get<PaginatedData<BookingWithDetails>>(
`/admin/bookings?page=1&limit=1&date=${today}`,
)
stats.value = [
{ label: '今日预约', value: todayRes.total ?? 0 },
{ label: '总订单', value: ordersRes.total ?? 0 },
{ label: '总预约', value: bookingsRes.total ?? 0 },
]
} catch {
stats.value = [
{ label: '今日预约', value: '--' },
{ label: '总订单', value: '--' },
{ label: '总预约', value: '--' },
]
} finally {
loadingStats.value = false
}
}
function navigate(path: string) { function navigate(path: string) {
uni.navigateTo({ url: path }) uni.navigateTo({ url: path })
} }
async function loadStats() {
statsLoading.value = true
try {
stats.value = await adminStore.fetchDashboardStats()
} catch {
// fail silently — stats are non-critical
} finally {
statsLoading.value = false
}
}
onMounted(loadStats) onMounted(loadStats)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.admin-page { .page {
min-height: 100vh; min-height: 100vh;
background: #f5f3f0; background: #1a1a2e;
padding-bottom: 60rpx;
} }
/* ── Header ─────────────────────────────────────── */ /* ── Stats row ───────────────────────────── */
.admin-header {
background: linear-gradient(135deg, #1a1a2e, #2d2d5e);
padding: 80rpx 32rpx 48rpx;
}
.header-top {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 12rpx;
}
.header-title {
font-size: 40rpx;
font-weight: 700;
color: #ffffff;
}
.admin-badge {
background: #c9a87c;
border-radius: 20rpx;
padding: 4rpx 16rpx;
}
.admin-badge-text {
font-size: 20rpx;
font-weight: 600;
color: #1a1a2e;
}
.header-sub {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.65);
}
/* ── Stats row ───────────────────────────────────── */
.stats-row { .stats-row {
display: flex; display: flex;
background: #ffffff;
border-radius: 20rpx;
margin: -24rpx 24rpx 0;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.stat-cell {
flex: 1;
display: flex;
flex-direction: column;
align-items: center; align-items: center;
padding: 36rpx 0; justify-content: space-around;
border-right: 1rpx solid #f0f0f0; background: rgba(255, 255, 255, 0.06);
margin: 24rpx 24rpx 32rpx;
&:last-child { border-radius: 20rpx;
border-right: none; padding: 32rpx 16rpx;
}
} }
.stat-value { .stats-shimmer-wrap {
font-size: 44rpx; display: flex;
font-weight: 800; width: 100%;
color: #1a1a2e; justify-content: space-around;
line-height: 1; align-items: center;
margin-bottom: 8rpx;
} }
.stat-label { .stats-shimmer {
font-size: 22rpx; width: 120rpx;
color: #999;
}
.stat-skeleton {
width: 80rpx;
height: 60rpx; height: 60rpx;
border-radius: 8rpx; border-radius: 12rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background: linear-gradient(90deg, rgba(255,255,255,0.08) 25%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.08) 75%);
background-size: 400% 100%; background-size: 400% 100%;
animation: shimmer 1.4s infinite; animation: shimmer 1.4s infinite;
} }
@@ -196,42 +114,63 @@ onMounted(loadStats)
100% { background-position: -100% 0; } 100% { background-position: -100% 0; }
} }
/* ── Nav grid ────────────────────────────────────── */ .stat-item {
.grid { display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
flex: 1;
}
.stat-value {
font-size: 44rpx;
font-weight: 800;
color: #c9a87c;
line-height: 1;
}
.stat-label {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.5);
}
.stat-divider {
width: 1rpx;
height: 60rpx;
background: rgba(255, 255, 255, 0.12);
}
/* ── Nav grid ────────────────────────────── */
.nav-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 20rpx; gap: 20rpx;
margin: 32rpx 24rpx 40rpx; padding: 0 24rpx;
} }
.grid-item { .nav-item {
background: #ffffff; background: rgba(255, 255, 255, 0.06);
border-radius: 16rpx; border-radius: 20rpx;
padding: 36rpx 28rpx; padding: 40rpx 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8rpx; align-items: center;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06); gap: 16rpx;
border: 1rpx solid rgba(201, 168, 124, 0.15);
&:active { &:active {
opacity: 0.8; opacity: 0.7;
} }
} }
.grid-icon { .nav-icon {
font-size: 52rpx; font-size: 56rpx;
margin-bottom: 4rpx;
} }
.grid-label { .nav-label {
font-size: 30rpx; font-size: 28rpx;
font-weight: 700; font-weight: 600;
color: #1a1a2e; color: #ffffff;
} letter-spacing: 1rpx;
.grid-desc {
font-size: 22rpx;
color: #999;
line-height: 1.4;
} }
</style> </style>

View File

@@ -1,177 +1,168 @@
<template> <template>
<view class="page"> <view class="page">
<!-- Search / filter bar --> <!-- Search bar -->
<view class="filter-bar"> <view class="filter-bar">
<input <input
class="search-input" class="search-input"
v-model="searchQuery" v-model="searchQuery"
placeholder="搜索昵称或手机号" placeholder="搜索昵称或手机号"
placeholder-style="color:#bbb" placeholder-style="color:#bbb"
@input="onSearch" @confirm="onSearch"
confirm-type="search"
/> />
<view class="search-btn" @tap="onSearch">
<text class="search-btn-text">搜索</text>
</view>
</view> </view>
<!-- Stats summary --> <!-- Stats row -->
<view class="stats-row"> <view class="stats-row">
<view class="stat-cell"> <view class="stat-item">
<text class="stat-value">{{ totalMembers }}</text> <text class="stat-value">{{ total }}</text>
<text class="stat-label">活跃会员</text> <text class="stat-label">会员</text>
</view>
<view class="stat-cell">
<text class="stat-value">{{ totalBookings }}</text>
<text class="stat-label">总预约次数</text>
</view>
<view class="stat-cell">
<text class="stat-value">{{ confirmedBookings }}</text>
<text class="stat-label">待上课</text>
</view> </view>
</view> </view>
<!-- Loading skeleton --> <!-- Loading skeleton -->
<view v-if="loading" class="skeleton-list"> <view v-if="loading && !members.length" class="skeleton-list">
<view v-for="i in 6" :key="i" class="skeleton-item" /> <view v-for="i in 5" :key="i" class="skeleton-item" />
</view> </view>
<!-- Empty --> <!-- Empty -->
<view v-else-if="!filteredMembers.length" class="empty-state"> <view v-else-if="!loading && !members.length" class="empty-state">
<text class="empty-icon">👥</text> <text class="empty-icon">👥</text>
<text class="empty-text">{{ searchQuery ? '未找到匹配会员' : '暂无预约记录' }}</text> <text class="empty-text">暂无会员数据</text>
</view> </view>
<!-- Member list --> <!-- Member list -->
<view v-else class="member-list"> <view v-else class="member-list">
<view <view
v-for="member in filteredMembers" v-for="m in members"
:key="member.userId" :key="m.userId"
class="member-card" class="member-row"
@tap="openDetail(m)"
> >
<view class="member-avatar"> <view class="member-avatar">
<text class="member-avatar-text">{{ member.nickname.slice(0, 1).toUpperCase() }}</text> <image v-if="m.avatarUrl" class="avatar-img" :src="m.avatarUrl" mode="aspectFill" />
<view v-else class="avatar-placeholder">
<text class="avatar-text">{{ (m.nickname || '?').slice(0, 1) }}</text>
</view>
</view> </view>
<view class="member-info"> <view class="member-info">
<text class="member-name">{{ member.nickname }}</text> <text class="member-name">{{ m.nickname || '未知用户' }}</text>
<text v-if="member.phone" class="member-phone">{{ maskPhone(member.phone) }}</text> <text class="member-phone">{{ m.phone || '未绑定手机' }}</text>
</view> </view>
<view class="member-stats"> <view class="member-stats">
<view class="member-stat"> <text class="member-stat-value">{{ m.totalBookings }}</text>
<text class="member-stat-value">{{ member.totalBookings }}</text>
<text class="member-stat-label">次预约</text> <text class="member-stat-label">次预约</text>
</view> </view>
<view class="member-stat"> <text class="member-arrow"></text>
<text class="member-stat-value confirmed-count">{{ member.confirmedBookings }}</text>
<text class="member-stat-label">待上课</text>
</view>
</view>
</view> </view>
</view> </view>
<!-- Load more --> <!-- Load more -->
<view v-if="hasMore && !loading" class="load-more" @tap="loadMore"> <view v-if="hasMore" class="load-more" @tap="loadMore">
<text class="load-more-text">加载更多</text> <text class="load-more-text">{{ loading ? '加载中...' : '加载更多' }}</text>
</view>
<!-- 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 v-if="loadingMore" class="load-more">
<text class="load-more-text">加载中...</text>
</view> </view>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { get } from '../../utils/request' import { useAdminStore } from '../../stores/admin'
import { BookingStatus } from '@mp-pilates/shared' import type { MemberSummary } from '../../stores/admin'
import type { BookingWithDetails, PaginatedData } from '@mp-pilates/shared'
interface MemberSummary { const adminStore = useAdminStore()
userId: string
nickname: string
phone?: string
totalBookings: number
confirmedBookings: number
}
const allBookings = ref<BookingWithDetails[]>([]) const members = ref<MemberSummary[]>([])
const page = ref(1)
const limit = 50
const hasMore = ref(true)
const loading = ref(false) const loading = ref(false)
const loadingMore = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
const page = ref(1)
const total = ref(0)
const hasMore = ref(false)
const showDetail = ref(false)
const detailMember = ref<MemberSummary | null>(null)
const members = computed<MemberSummary[]>(() => { const LIMIT = 20
const map = new Map<string, MemberSummary>()
for (const b of allBookings.value) {
const userId = b.userId
if (!userId) continue
if (!map.has(userId)) {
map.set(userId, {
userId,
nickname: userId.slice(0, 8),
totalBookings: 0,
confirmedBookings: 0,
})
}
const m = map.get(userId)!
m.totalBookings++
if (b.status === BookingStatus.CONFIRMED) {
m.confirmedBookings++
}
}
return Array.from(map.values()).sort((a, b) => b.totalBookings - a.totalBookings)
})
const filteredMembers = computed(() => { async function loadMembers(reset = false) {
if (!searchQuery.value.trim()) return members.value if (loading.value) return
const q = searchQuery.value.toLowerCase() if (reset) {
return members.value.filter(
(m) =>
m.nickname.toLowerCase().includes(q) ||
(m.phone && m.phone.includes(q)),
)
})
const totalMembers = computed(() => members.value.length)
const totalBookings = computed(() => members.value.reduce((s, m) => s + m.totalBookings, 0))
const confirmedBookings = computed(() => members.value.reduce((s, m) => s + m.confirmedBookings, 0))
async function fetchBookings(isLoadMore = false) {
if (isLoadMore) {
loadingMore.value = true
} else {
loading.value = true
allBookings.value = []
page.value = 1 page.value = 1
hasMore.value = true members.value = []
} }
loading.value = true
try { try {
const data = await get<PaginatedData<BookingWithDetails>>( const result = await adminStore.fetchMembers({
`/admin/bookings?page=${page.value}&limit=${limit}`, page: page.value,
) limit: LIMIT,
allBookings.value = [...allBookings.value, ...(data.items ?? [])] search: searchQuery.value.trim() || undefined,
hasMore.value = allBookings.value.length < data.total })
page.value++ if (reset) {
members.value = [...result.items]
} else {
members.value.push(...result.items)
}
total.value = result.total
hasMore.value = members.value.length < result.total
} catch { } catch {
uni.showToast({ title: '加载失败', icon: 'none' }) uni.showToast({ title: '加载失败', icon: 'none' })
} finally { } finally {
loading.value = false loading.value = false
loadingMore.value = false
} }
} }
async function loadMore() {
if (loadingMore.value || !hasMore.value) return
await fetchBookings(true)
}
function onSearch() { function onSearch() {
// Reactive filtering via computed — no action needed loadMembers(true)
} }
function maskPhone(phone: string): string { function loadMore() {
return phone.slice(0, 3) + '****' + phone.slice(-4) if (!hasMore.value || loading.value) return
page.value++
loadMembers(false)
} }
onMounted(() => fetchBookings()) function openDetail(m: MemberSummary) {
detailMember.value = m
showDetail.value = true
}
onMounted(() => loadMembers(true))
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -183,62 +174,49 @@ onMounted(() => fetchBookings())
/* ── Filter bar ──────────────────────────── */ /* ── Filter bar ──────────────────────────── */
.filter-bar { .filter-bar {
padding: 20rpx 24rpx; display: flex;
align-items: center;
gap: 16rpx;
padding: 24rpx;
background: #ffffff; background: #ffffff;
border-bottom: 1rpx solid #f0f0f0; border-bottom: 1rpx solid #eee;
} }
.search-input { .search-input {
flex: 1;
height: 72rpx;
background: #f5f3f0; background: #f5f3f0;
border-radius: 32rpx; border-radius: 36rpx;
padding: 16rpx 28rpx; padding: 0 28rpx;
font-size: 26rpx; font-size: 26rpx;
color: #222; color: #333;
width: 100%;
} }
.search-btn {
background: #1a1a2e;
border-radius: 36rpx;
padding: 16rpx 32rpx;
}
.search-btn-text { font-size: 26rpx; font-weight: 600; color: #c9a87c; }
/* ── Stats row ───────────────────────────── */ /* ── Stats row ───────────────────────────── */
.stats-row { .stats-row {
display: flex; display: flex;
background: #ffffff; padding: 24rpx 28rpx 16rpx;
border-bottom: 1rpx solid #f0f0f0;
} }
.stat-cell { .stat-item { display: flex; align-items: baseline; gap: 8rpx; }
flex: 1; .stat-value { font-size: 36rpx; font-weight: 800; color: #c9a87c; }
display: flex; .stat-label { font-size: 24rpx; color: #999; }
flex-direction: column;
align-items: center;
padding: 28rpx 0;
border-right: 1rpx solid #f0f0f0;
&:last-child {
border-right: none;
}
}
.stat-value {
font-size: 40rpx;
font-weight: 800;
color: #1a1a2e;
line-height: 1;
margin-bottom: 6rpx;
}
.stat-label {
font-size: 20rpx;
color: #999;
}
/* ── Skeleton ────────────────────────────── */ /* ── Skeleton ────────────────────────────── */
.skeleton-list { .skeleton-list { padding: 0 24rpx; }
padding: 16rpx 24rpx 0;
}
.skeleton-item { .skeleton-item {
height: 120rpx; height: 100rpx;
border-radius: 12rpx; border-radius: 16rpx;
margin-bottom: 12rpx; margin-bottom: 16rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 400% 100%; background-size: 400% 100%;
animation: shimmer 1.4s infinite; animation: shimmer 1.4s infinite;
@@ -262,93 +240,127 @@ onMounted(() => fetchBookings())
.empty-text { font-size: 28rpx; color: #bbb; } .empty-text { font-size: 28rpx; color: #bbb; }
/* ── Member list ─────────────────────────── */ /* ── Member list ─────────────────────────── */
.member-list { .member-list { padding: 0 24rpx; padding-top: 8rpx; }
padding: 16rpx 24rpx 0;
}
.member-card { .member-row {
background: #ffffff; background: #ffffff;
border-radius: 12rpx; border-radius: 16rpx;
padding: 24rpx; padding: 24rpx;
margin-bottom: 12rpx;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 20rpx; gap: 20rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.06); margin-bottom: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
} }
.member-avatar { .member-avatar {
width: 80rpx; width: 80rpx;
height: 80rpx; height: 80rpx;
border-radius: 50%; border-radius: 50%;
background: linear-gradient(135deg, #1a1a2e, #2d2d5e); overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0; flex-shrink: 0;
} }
.member-avatar-text { .avatar-img { width: 100%; height: 100%; }
.avatar-placeholder {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: #1a1a2e;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-placeholder--lg {
width: 120rpx;
height: 120rpx;
}
.avatar-text {
font-size: 32rpx; font-size: 32rpx;
font-weight: 700; font-weight: 700;
color: #c9a87c; color: #c9a87c;
} }
.member-info { .avatar-text--lg { font-size: 48rpx; }
flex: 1;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.member-name { .member-info { flex: 1; display: flex; flex-direction: column; gap: 8rpx; }
font-size: 28rpx; .member-name { font-size: 28rpx; font-weight: 600; color: #1a1a2e; }
font-weight: 600; .member-phone { font-size: 22rpx; color: #999; }
color: #1a1a2e;
}
.member-phone { .member-stats { display: flex; flex-direction: column; align-items: flex-end; gap: 4rpx; }
font-size: 22rpx; .member-stat-value { font-size: 32rpx; font-weight: 700; color: #c9a87c; }
color: #999; .member-stat-label { font-size: 20rpx; color: #bbb; }
}
.member-stats { .member-arrow { font-size: 36rpx; color: #ccc; }
display: flex;
gap: 20rpx;
}
.member-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
}
.member-stat-value {
font-size: 30rpx;
font-weight: 700;
color: #1a1a2e;
}
.confirmed-count {
color: #27ae60;
}
.member-stat-label {
font-size: 20rpx;
color: #999;
}
/* ── Load more ───────────────────────────── */ /* ── Load more ───────────────────────────── */
.load-more { .load-more {
text-align: center;
padding: 32rpx;
}
.load-more-text { font-size: 26rpx; color: #c9a87c; }
/* ── Detail modal ────────────────────────── */
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: flex-end;
z-index: 100;
}
.modal {
width: 100%;
background: #ffffff;
border-radius: 24rpx 24rpx 0 0;
padding: 48rpx 32rpx 60rpx;
}
.detail-header {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
margin-bottom: 40rpx;
}
.detail-avatar {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
overflow: hidden;
}
.detail-name { font-size: 32rpx; font-weight: 700; color: #1a1a2e; }
.detail-phone { font-size: 26rpx; color: #888; }
.detail-stats {
display: flex;
justify-content: space-around;
background: #f5f3f0;
border-radius: 16rpx;
padding: 28rpx;
margin-bottom: 32rpx;
}
.detail-stat { display: flex; flex-direction: column; align-items: center; gap: 8rpx; }
.detail-stat-value { font-size: 40rpx; font-weight: 800; color: #c9a87c; }
.detail-stat-label { font-size: 22rpx; color: #999; }
.modal-close {
width: 100%;
height: 88rpx;
background: #f0f0f0;
border-radius: 44rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 28rpx 0;
} }
.load-more-text { .modal-close-text { font-size: 28rpx; color: #555; }
font-size: 26rpx;
color: #c9a87c;
}
</style> </style>

View File

@@ -5,214 +5,215 @@
<view class="filter-row"> <view class="filter-row">
<view <view
v-for="f in filters" v-for="f in filters"
:key="f.key" :key="f.value"
class="filter-chip" class="filter-chip"
:class="{ 'filter-chip--active': statusFilter === f.key }" :class="{ 'filter-chip--active': activeFilter === f.value }"
@tap="selectFilter(f.key)" @tap="selectFilter(f.value)"
> >
<text class="filter-chip-text">{{ f.label }}</text> <text class="filter-chip-text">{{ f.label }}</text>
</view> </view>
</view> </view>
</scroll-view> </scroll-view>
<!-- Pull-to-refresh wrapper -->
<scroll-view
scroll-y
class="list-scroll"
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
>
<!-- Loading skeleton --> <!-- Loading skeleton -->
<view v-if="loading" class="skeleton-list"> <view v-if="loading && !orders.length" class="skeleton-list">
<view v-for="i in 5" :key="i" class="skeleton-item" /> <view v-for="i in 5" :key="i" class="skeleton-item" />
</view> </view>
<!-- Empty --> <!-- Empty -->
<view v-else-if="!orders.length" class="empty-state"> <view v-else-if="!loading && !orders.length" class="empty-state">
<text class="empty-icon">📋</text> <text class="empty-icon">📋</text>
<text class="empty-text">暂无订单</text> <text class="empty-text">暂无订单</text>
</view> </view>
<!-- Order list --> <!-- Order list -->
<view v-else class="order-list"> <view v-else class="order-list">
<view <view v-for="order in orders" :key="order.id" class="order-card">
v-for="order in orders"
:key="order.id"
class="order-card"
>
<!-- Header: card name + status badge -->
<view class="order-header"> <view class="order-header">
<text class="order-card-name">{{ order.cardType?.name ?? '未知卡种' }}</text> <text class="order-card-name">{{ order.cardType?.name ?? '-' }}</text>
<view class="status-badge" :class="statusBadgeClass(order.status)"> <view class="order-status-badge" :class="statusBadgeClass(order.status)">
<text class="status-badge-text">{{ statusLabel(order.status) }}</text> <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> </view>
</view> </view>
<!-- User info --> <!-- Load more -->
<view v-if="order.user" class="order-user"> <view v-if="hasMore" class="load-more" @tap="loadMore">
<text class="order-user-icon">👤</text> <text class="load-more-text">{{ loading ? '加载中...' : '加载更多' }}</text>
<text class="order-user-text">
{{ order.user.nickname }}
<text v-if="order.user.phone"> · {{ maskPhone(order.user.phone) }}</text>
</text>
</view> </view>
<!-- Amount + date row --> <!-- Bottom spacer -->
<view class="order-footer"> <view style="height: 40rpx;" />
<text class="order-amount">¥{{ formatPrice(order.amount) }}</text> </scroll-view>
<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>
</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>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { get } from '../../utils/request' import { useAdminStore } from '../../stores/admin'
import { formatPrice } from '../../utils/format' import { formatPrice, formatDate } from '../../utils/format'
import type { OrderWithDetails, PaginatedData } from '@mp-pilates/shared' import { OrderStatus } from '@mp-pilates/shared'
import type { OrderWithDetails } from '@mp-pilates/shared'
const adminStore = useAdminStore()
const filters = [ const filters = [
{ key: '', label: '全部' }, { label: '全部', value: '' },
{ key: 'PAID', label: '已支付' }, { label: '已支付', value: OrderStatus.PAID },
{ key: 'PENDING', label: '待支付' }, { label: '待支付', value: OrderStatus.PENDING },
{ key: 'REFUNDED', label: '已退款' }, { label: '已退款', value: OrderStatus.REFUNDED },
{ key: 'CANCELLED', label: '已取消' },
] ]
const statusFilter = ref('') const activeFilter = ref('')
const orders = ref<OrderWithDetails[]>([]) const orders = ref<OrderWithDetails[]>([])
const loading = ref(false) const loading = ref(false)
const currentPage = ref(1) const refreshing = ref(false)
const total = ref(0) const page = ref(1)
const limit = 10 const hasMore = ref(false)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit))) const LIMIT = 20
async function fetchOrders() { function statusLabel(s: string) {
const map: Record<string, string> = {
[OrderStatus.PAID]: '已支付',
[OrderStatus.PENDING]: '待支付',
[OrderStatus.REFUNDED]: '已退款',
}
return map[s] ?? s
}
function statusBadgeClass(s: string) {
if (s === OrderStatus.PAID) return 'badge--paid'
if (s === OrderStatus.PENDING) return 'badge--pending'
if (s === OrderStatus.REFUNDED) return 'badge--refunded'
return 'badge--default'
}
async function loadOrders(reset = false) {
if (loading.value) return
if (reset) {
page.value = 1
orders.value = []
}
loading.value = true loading.value = true
try { try {
const statusParam = statusFilter.value ? `&status=${statusFilter.value}` : '' const result = await adminStore.fetchAdminOrders({
const data = await get<PaginatedData<OrderWithDetails>>( page: page.value,
`/admin/orders?page=${currentPage.value}&limit=${limit}${statusParam}`, limit: LIMIT,
) status: activeFilter.value || undefined,
orders.value = [...(data.items ?? [])] })
total.value = data.total ?? 0 if (reset) {
orders.value = [...result.items]
} else {
orders.value.push(...result.items)
}
hasMore.value = orders.value.length < result.total
} catch { } catch {
uni.showToast({ title: '加载失败', icon: 'none' }) uni.showToast({ title: '加载失败', icon: 'none' })
orders.value = []
} finally { } finally {
loading.value = false loading.value = false
refreshing.value = false
} }
} }
function selectFilter(key: string) { function selectFilter(value: string) {
statusFilter.value = key activeFilter.value = value
currentPage.value = 1 loadOrders(true)
fetchOrders()
} }
function goPage(p: number) { async function onRefresh() {
if (p < 1 || p > totalPages.value) return refreshing.value = true
currentPage.value = p await loadOrders(true)
fetchOrders()
} }
function statusLabel(status: string): string { function loadMore() {
const map: Record<string, string> = { if (!hasMore.value || loading.value) return
PAID: '已支付', page.value++
PENDING: '待支付', loadOrders(false)
REFUNDED: '已退款',
CANCELLED: '已取消',
}
return map[status] ?? status
} }
function statusBadgeClass(status: string): string { onMounted(() => loadOrders(true))
if (status === 'PAID') return 'badge--paid'
if (status === 'PENDING') return 'badge--pending'
if (status === 'REFUNDED') return 'badge--refunded'
if (status === 'CANCELLED') return 'badge--cancelled'
return ''
}
function maskPhone(phone: string): string {
return phone.slice(0, 3) + '****' + phone.slice(-4)
}
function formatOrderDate(iso: string): string {
const d = new Date(iso)
return `${d.getMonth() + 1}${d.getDate()}${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
onMounted(fetchOrders)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.page { .page {
min-height: 100vh; height: 100vh;
display: flex;
flex-direction: column;
background: #f5f3f0; background: #f5f3f0;
padding-bottom: 40rpx;
} }
/* ── Filter scroll ───────────────────────── */ /* ── Filter scroll ───────────────────────── */
.filter-scroll { .filter-scroll {
flex-shrink: 0;
background: #ffffff; background: #ffffff;
border-bottom: 1rpx solid #f0f0f0; border-bottom: 1rpx solid #eee;
} }
.filter-row { .filter-row {
display: flex; display: flex;
flex-direction: row; align-items: center;
gap: 12rpx;
padding: 16rpx 24rpx; padding: 16rpx 24rpx;
width: max-content; gap: 16rpx;
white-space: nowrap;
} }
.filter-chip { .filter-chip {
padding: 12rpx 28rpx; display: inline-flex;
border-radius: 32rpx; align-items: center;
height: 60rpx;
padding: 0 28rpx;
border-radius: 30rpx;
background: #f0f0f0; background: #f0f0f0;
flex-shrink: 0;
&--active {
background: #1a1a2e;
}
} }
.filter-chip-text { .filter-chip--active {
font-size: 26rpx; background: #1a1a2e;
color: #555; }
.filter-chip--active & { .filter-chip-text { font-size: 26rpx; color: #888; }
color: #c9a87c; .filter-chip--active .filter-chip-text { color: #c9a87c; font-weight: 600; }
font-weight: 600;
} /* ── List scroll ─────────────────────────── */
.list-scroll {
flex: 1;
overflow: hidden;
} }
/* ── Skeleton ────────────────────────────── */ /* ── Skeleton ────────────────────────────── */
.skeleton-list { .skeleton-list { padding: 24rpx; }
padding: 16rpx 24rpx 0;
}
.skeleton-item { .skeleton-item {
height: 180rpx; height: 180rpx;
border-radius: 12rpx; border-radius: 16rpx;
margin-bottom: 16rpx; margin-bottom: 16rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 400% 100%; background-size: 400% 100%;
@@ -229,7 +230,7 @@ onMounted(fetchOrders)
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding: 100rpx 0; padding: 120rpx 0;
gap: 20rpx; gap: 20rpx;
} }
@@ -237,113 +238,57 @@ onMounted(fetchOrders)
.empty-text { font-size: 28rpx; color: #bbb; } .empty-text { font-size: 28rpx; color: #bbb; }
/* ── Order list ──────────────────────────── */ /* ── Order list ──────────────────────────── */
.order-list { .order-list { padding: 16rpx 24rpx 0; }
padding: 16rpx 24rpx 0;
}
.order-card { .order-card {
background: #ffffff; background: #ffffff;
border-radius: 16rpx; border-radius: 16rpx;
padding: 28rpx; overflow: hidden;
margin-bottom: 16rpx; margin-bottom: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06); box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
} }
.order-header { .order-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 16rpx; padding: 20rpx 24rpx;
border-bottom: 1rpx solid #f5f5f5;
} }
.order-card-name { .order-card-name { font-size: 28rpx; font-weight: 600; color: #1a1a2e; }
font-size: 30rpx;
font-weight: 700;
color: #1a1a2e;
flex: 1;
}
.status-badge { .order-status-badge {
border-radius: 20rpx; border-radius: 20rpx;
padding: 6rpx 16rpx; padding: 6rpx 20rpx;
} }
.status-badge-text { .badge--paid { background: rgba(39,174,96,0.1); }
font-size: 22rpx; .badge--paid .order-status-text { font-size: 22rpx; color: #27ae60; }
font-weight: 600; .badge--pending { background: rgba(230,126,34,0.1); }
} .badge--pending .order-status-text { font-size: 22rpx; color: #e67e22; }
.badge--refunded { background: rgba(0,0,0,0.06); }
.badge--refunded .order-status-text { font-size: 22rpx; color: #999; }
.badge--default .order-status-text { font-size: 22rpx; color: #888; }
.badge--paid { background: #d4edda; .status-badge-text { color: #155724; } } .order-body { padding: 16rpx 24rpx; }
.badge--pending { background: #fff3cd; .status-badge-text { color: #856404; } }
.badge--refunded { background: #cce5ff; .status-badge-text { color: #004085; } }
.badge--cancelled { background: #f8d7da; .status-badge-text { color: #721c24; } }
.order-user { .order-row {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 16rpx;
}
.order-user-icon { font-size: 24rpx; }
.order-user-text {
font-size: 24rpx;
color: #555;
}
.order-footer {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 12rpx; padding: 10rpx 0;
} }
.order-amount { .order-row-label { font-size: 24rpx; color: #999; }
font-size: 36rpx; .order-row-value { font-size: 26rpx; color: #333; }
font-weight: 800; .order-price { font-size: 28rpx; font-weight: 700; color: #c9a87c; }
color: #c9a87c;
/* ── Load more ───────────────────────────── */
.load-more {
text-align: center;
padding: 32rpx;
} }
.order-date { .load-more-text { font-size: 26rpx; color: #c9a87c; }
font-size: 22rpx;
color: #999;
}
.order-id {
font-size: 20rpx;
color: #bbb;
display: block;
}
/* ── Pagination ──────────────────────────── */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 32rpx;
padding: 32rpx 0;
}
.page-btn {
padding: 12rpx 32rpx;
background: #ffffff;
border-radius: 32rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
&--disabled {
opacity: 0.4;
}
}
.page-btn-text {
font-size: 26rpx;
color: #1a1a2e;
font-weight: 600;
}
.page-info {
font-size: 26rpx;
color: #555;
}
</style> </style>

View File

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

View File

@@ -21,7 +21,7 @@
</view> </view>
</view> </view>
<!-- Form card --> <!-- Basic info card -->
<view class="form-card"> <view class="form-card">
<text class="form-card-title">基本信息</text> <text class="form-card-title">基本信息</text>
@@ -150,10 +150,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { get, put } from '../../utils/request' import { useAdminStore } from '../../stores/admin'
import type { StudioConfig } from '@mp-pilates/shared'
const adminStore = useAdminStore()
// Form state
const form = ref({ const form = ref({
name: '', name: '',
address: '', address: '',
@@ -183,7 +183,7 @@ const bannerStyle = computed(() => {
async function fetchStudioInfo() { async function fetchStudioInfo() {
loading.value = true loading.value = true
try { try {
const data = await get<StudioConfig>('/studio/info') const data = await adminStore.fetchStudioConfig()
const initial = { const initial = {
name: data.name ?? '', name: data.name ?? '',
address: data.address ?? '', address: data.address ?? '',
@@ -228,7 +228,7 @@ async function handleSave() {
if (!isNaN(lat)) payload.latitude = lat if (!isNaN(lat)) payload.latitude = lat
if (!isNaN(lng)) payload.longitude = lng if (!isNaN(lng)) payload.longitude = lng
await put('/admin/studio/info', payload) await adminStore.saveStudioConfig(payload as any)
original.value = { ...form.value } original.value = { ...form.value }
uni.showToast({ title: '保存成功', icon: 'success' }) uni.showToast({ title: '保存成功', icon: 'success' })
} catch (e: any) { } catch (e: any) {
@@ -249,10 +249,7 @@ onMounted(fetchStudioInfo)
} }
/* ── Skeleton ────────────────────────────── */ /* ── Skeleton ────────────────────────────── */
.skeleton-page { .skeleton-page { padding: 0 24rpx; padding-top: 280rpx; }
padding: 0 24rpx;
padding-top: 280rpx;
}
.skeleton-section { .skeleton-section {
height: 200rpx; height: 200rpx;
@@ -277,7 +274,7 @@ onMounted(fetchStudioInfo)
.banner-overlay { .banner-overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.35); background: rgba(0,0,0,0.35);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -290,13 +287,10 @@ onMounted(fetchStudioInfo)
height: 96rpx; height: 96rpx;
border-radius: 50%; border-radius: 50%;
overflow: hidden; overflow: hidden;
border: 4rpx solid rgba(255, 255, 255, 0.4); border: 4rpx solid rgba(255,255,255,0.4);
} }
.banner-logo { .banner-logo { width: 96rpx; height: 96rpx; }
width: 96rpx;
height: 96rpx;
}
.banner-logo-placeholder { .banner-logo-placeholder {
width: 100%; width: 100%;
@@ -307,17 +301,9 @@ onMounted(fetchStudioInfo)
justify-content: center; justify-content: center;
} }
.banner-logo-text { .banner-logo-text { font-size: 40rpx; font-weight: 700; color: #1a1a2e; }
font-size: 40rpx;
font-weight: 700;
color: #1a1a2e;
}
.banner-name { .banner-name { font-size: 32rpx; font-weight: 700; color: #ffffff; }
font-size: 32rpx;
font-weight: 700;
color: #ffffff;
}
/* ── Form card ───────────────────────────── */ /* ── Form card ───────────────────────────── */
.form-card { .form-card {
@@ -325,7 +311,7 @@ onMounted(fetchStudioInfo)
border-radius: 20rpx; border-radius: 20rpx;
margin: 24rpx 24rpx 0; margin: 24rpx 24rpx 0;
overflow: hidden; overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05); box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05);
} }
.form-card-title { .form-card-title {
@@ -345,9 +331,7 @@ onMounted(fetchStudioInfo)
padding: 28rpx; padding: 28rpx;
border-bottom: 1rpx solid #f5f5f5; border-bottom: 1rpx solid #f5f5f5;
&--last { &--last { border-bottom: none; }
border-bottom: none;
}
} }
.form-label { .form-label {
@@ -365,10 +349,7 @@ onMounted(fetchStudioInfo)
margin-top: 4rpx; margin-top: 4rpx;
} }
.label-group { .label-group { width: 240rpx; flex-shrink: 0; }
width: 240rpx;
flex-shrink: 0;
}
.form-input { .form-input {
flex: 1; flex: 1;
@@ -385,9 +366,7 @@ onMounted(fetchStudioInfo)
} }
/* ── Save button ─────────────────────────── */ /* ── Save button ─────────────────────────── */
.save-wrap { .save-wrap { padding: 40rpx 24rpx; }
padding: 40rpx 24rpx;
}
.save-btn { .save-btn {
width: 100%; width: 100%;
@@ -397,7 +376,7 @@ onMounted(fetchStudioInfo)
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 4rpx 20rpx rgba(26, 26, 46, 0.3); box-shadow: 0 4rpx 20rpx rgba(26,26,46,0.3);
&:active { opacity: 0.85; } &:active { opacity: 0.85; }

View File

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

View File

@@ -23,12 +23,17 @@
<template v-else> <template v-else>
<!-- Hero section --> <!-- Hero section -->
<view class="card-hero" :class="heroClass"> <view class="card-hero" :class="heroClass">
<!-- Decorative circles -->
<view class="hero-deco hero-deco--1" />
<view class="hero-deco hero-deco--2" />
<view class="hero-badge"> <view class="hero-badge">
<text class="hero-badge-text">{{ typeLabel }}</text> <text class="hero-badge-text">{{ typeLabel }}</text>
</view> </view>
<text class="hero-name">{{ card.name }}</text> <text class="hero-name">{{ card.name }}</text>
<view class="hero-price-row"> <view class="hero-price-row">
<text class="hero-price">¥{{ formatPrice(card.price) }}</text> <text class="hero-currency">¥</text>
<text class="hero-price">{{ formatPrice(card.price) }}</text>
<text <text
v-if="card.originalPrice && card.originalPrice > card.price" v-if="card.originalPrice && card.originalPrice > card.price"
class="hero-original" class="hero-original"
@@ -60,28 +65,38 @@
<!-- Description --> <!-- Description -->
<view v-if="card.description" class="desc-card"> <view v-if="card.description" class="desc-card">
<text class="desc-title">课程说明</text> <view class="section-header">
<view class="section-dot" />
<text class="section-title">课程说明</text>
</view>
<text class="desc-content">{{ card.description }}</text> <text class="desc-content">{{ card.description }}</text>
</view> </view>
<!-- Features list --> <!-- Features list -->
<view class="features-card"> <view class="features-card">
<text class="features-title">购买须知</text> <view class="section-header">
<view class="section-dot" />
<text class="section-title">购买须知</text>
</view>
<view class="feature-item"> <view class="feature-item">
<text class="feature-dot"></text> <text class="feature-dot"></text>
<text class="feature-text">购买后立即生效有效期 {{ card.durationDays }} </text> <text class="feature-text">购买后立即生效有效期 {{ card.durationDays }} </text>
</view> </view>
<view v-if="card.totalTimes" class="feature-item"> <view v-if="card.totalTimes" class="feature-item">
<text class="feature-dot"></text> <text class="feature-dot"></text>
<text class="feature-text"> {{ card.totalTimes }} 次课时可灵活安排</text> <text class="feature-text"> {{ card.totalTimes }} 次课时可灵活安排上课时间</text>
</view>
<view v-if="!card.totalTimes" class="feature-item">
<text class="feature-dot"></text>
<text class="feature-text">有效期内可无限次预约课程</text>
</view> </view>
<view class="feature-item"> <view class="feature-item">
<text class="feature-dot"></text> <text class="feature-dot"></text>
<text class="feature-text">每次预约扣除 1 次课时</text> <text class="feature-text">每次预约扣除 1 次课时次卡</text>
</view> </view>
<view class="feature-item"> <view class="feature-item">
<text class="feature-dot"></text> <text class="feature-dot"></text>
<text class="feature-text">到期或课时用完后自动失效</text> <text class="feature-text">到期或课时用完后自动失效不可退款</text>
</view> </view>
<view class="feature-item"> <view class="feature-item">
<text class="feature-dot"></text> <text class="feature-dot"></text>
@@ -118,8 +133,9 @@ import { useUserStore } from '../../stores/user'
const userStore = useUserStore() const userStore = useUserStore()
// ─── Route param ────────────────────────────────────────── // ─── Route params ──────────────────────────────────────────
const cardId = ref<string>('') const cardId = ref<string>('')
const isTrial = ref(false)
// ─── State ──────────────────────────────────────────────── // ─── State ────────────────────────────────────────────────
const card = ref<CardType | null>(null) const card = ref<CardType | null>(null)
@@ -147,20 +163,29 @@ const heroClass = computed(() => {
const unitPrice = computed(() => { const unitPrice = computed(() => {
if (!card.value) return '-' if (!card.value) return '-'
if (card.value.totalTimes) { if (card.value.totalTimes) {
const price = card.value.price / card.value.totalTimes const pricePerTime = card.value.price / card.value.totalTimes
return `¥${(price / 100).toFixed(0)}` return `¥${(pricePerTime / 100).toFixed(0)}`
} }
const price = card.value.price / card.value.durationDays const pricePerDay = card.value.price / card.value.durationDays
return `¥${(price / 100).toFixed(0)}` return `¥${(pricePerDay / 100).toFixed(0)}`
}) })
// ─── Data loading ───────────────────────────────────────── // ─── Data loading ─────────────────────────────────────────
async function loadCard() { async function loadCard() {
if (!cardId.value) return
loading.value = true loading.value = true
try { try {
const types = await get<CardType[]>('/membership/card-types') const types = await get<CardType[]>('/membership/card-types')
card.value = types.find((c) => c.id === cardId.value) ?? null const activeTypes = types.filter((c) => c.isActive)
if (isTrial.value) {
// Auto-find the trial card type
card.value = activeTypes.find((c) => c.type === CardTypeCategory.TRIAL) ?? null
if (card.value) {
cardId.value = card.value.id
}
} else if (cardId.value) {
card.value = activeTypes.find((c) => c.id === cardId.value) ?? null
}
} catch { } catch {
card.value = null card.value = null
} finally { } finally {
@@ -229,13 +254,11 @@ async function doPurchase() {
}) })
}) })
// Payment succeeded // Payment succeeded — refresh memberships then navigate
uni.showToast({ title: '购买成功!', icon: 'success' }) uni.showToast({ title: '购买成功!', icon: 'success' })
// Refresh memberships in background
await userStore.fetchMemberships() await userStore.fetchMemberships()
// Navigate back after a moment
setTimeout(() => { setTimeout(() => {
uni.navigateBack() uni.navigateTo({ url: '/pages/profile/membership' })
}, 1500) }, 1500)
} catch (err: unknown) { } catch (err: unknown) {
uni.hideLoading() uni.hideLoading()
@@ -250,11 +273,11 @@ async function doPurchase() {
// ─── Lifecycle ──────────────────────────────────────────── // ─── Lifecycle ────────────────────────────────────────────
onMounted(() => { onMounted(() => {
// Get id from page options
const pages = getCurrentPages() const pages = getCurrentPages()
const current = pages[pages.length - 1] const current = pages[pages.length - 1]
const options = (current as { options?: Record<string, string> }).options ?? {} const options = (current as { options?: Record<string, string> }).options ?? {}
cardId.value = options.id ?? '' cardId.value = options.id ?? ''
isTrial.value = options.trial === '1'
loadCard() loadCard()
}) })
</script> </script>
@@ -272,7 +295,7 @@ onMounted(() => {
} }
.skeleton-header { .skeleton-header {
height: 360rpx; height: 380rpx;
background: linear-gradient(90deg, #e8e8e8 25%, #d8d8d8 50%, #e8e8e8 75%); background: linear-gradient(90deg, #e8e8e8 25%, #d8d8d8 50%, #e8e8e8 75%);
background-size: 400% 100%; background-size: 400% 100%;
animation: shimmer 1.4s infinite; animation: shimmer 1.4s infinite;
@@ -335,10 +358,12 @@ onMounted(() => {
/* ── Hero ────────────────────────────────────────────── */ /* ── Hero ────────────────────────────────────────────── */
.card-hero { .card-hero {
padding: 60rpx 32rpx 52rpx; padding: 64rpx 36rpx 56rpx;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16rpx; gap: 18rpx;
position: relative;
overflow: hidden;
&.hero--times { &.hero--times {
background: linear-gradient(135deg, #1a1a2e 0%, #2d2d5e 100%); background: linear-gradient(135deg, #1a1a2e 0%, #2d2d5e 100%);
@@ -353,12 +378,35 @@ onMounted(() => {
} }
} }
/* Decorative background circles */
.hero-deco {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.05);
pointer-events: none;
&--1 {
width: 320rpx;
height: 320rpx;
top: -80rpx;
right: -60rpx;
}
&--2 {
width: 200rpx;
height: 200rpx;
bottom: -40rpx;
left: 20rpx;
}
}
.hero-badge { .hero-badge {
align-self: flex-start; align-self: flex-start;
padding: 8rpx 20rpx; padding: 8rpx 22rpx;
border-radius: 20rpx; border-radius: 20rpx;
background: rgba(255, 255, 255, 0.18); background: rgba(255, 255, 255, 0.18);
border: 1rpx solid rgba(255, 255, 255, 0.3); border: 1rpx solid rgba(255, 255, 255, 0.3);
z-index: 1;
} }
.hero-badge-text { .hero-badge-text {
@@ -369,28 +417,39 @@ onMounted(() => {
} }
.hero-name { .hero-name {
font-size: 44rpx; font-size: 48rpx;
font-weight: 800; font-weight: 800;
color: #fff; color: #fff;
letter-spacing: 1rpx; letter-spacing: 1rpx;
z-index: 1;
} }
.hero-price-row { .hero-price-row {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 16rpx; gap: 8rpx;
z-index: 1;
}
.hero-currency {
font-size: 28rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
line-height: 1;
} }
.hero-price { .hero-price {
font-size: 56rpx; font-size: 64rpx;
font-weight: 800; font-weight: 800;
color: #fff; color: #fff;
line-height: 1;
} }
.hero-original { .hero-original {
font-size: 28rpx; font-size: 28rpx;
color: rgba(255, 255, 255, 0.55); color: rgba(255, 255, 255, 0.5);
text-decoration: line-through; text-decoration: line-through;
margin-left: 8rpx;
} }
/* ── Detail section ──────────────────────────────────── */ /* ── Detail section ──────────────────────────────────── */
@@ -401,6 +460,29 @@ onMounted(() => {
gap: 20rpx; gap: 20rpx;
} }
/* ── Section header ──────────────────────────────────── */
.section-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 12rpx;
margin-bottom: 16rpx;
}
.section-dot {
width: 6rpx;
height: 28rpx;
border-radius: 3rpx;
background: #c9a87c;
flex-shrink: 0;
}
.section-title {
font-size: 30rpx;
font-weight: 700;
color: #1a1a1a;
}
/* ── Info grid card ──────────────────────────────────── */ /* ── Info grid card ──────────────────────────────────── */
.info-card { .info-card {
background: #fff; background: #fff;
@@ -447,21 +529,12 @@ onMounted(() => {
border-radius: 20rpx; border-radius: 20rpx;
padding: 28rpx 24rpx; padding: 28rpx 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05); box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
gap: 16rpx;
}
.desc-title {
font-size: 28rpx;
font-weight: 700;
color: #1a1a1a;
} }
.desc-content { .desc-content {
font-size: 26rpx; font-size: 27rpx;
color: #666; color: #666;
line-height: 1.7; line-height: 1.75;
} }
/* ── Features card ───────────────────────────────────── */ /* ── Features card ───────────────────────────────────── */
@@ -472,13 +545,6 @@ onMounted(() => {
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05); box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16rpx;
}
.features-title {
font-size: 28rpx;
font-weight: 700;
color: #1a1a1a;
} }
.feature-item { .feature-item {
@@ -486,19 +552,20 @@ onMounted(() => {
flex-direction: row; flex-direction: row;
align-items: flex-start; align-items: flex-start;
gap: 12rpx; gap: 12rpx;
padding: 6rpx 0;
} }
.feature-dot { .feature-dot {
font-size: 26rpx; font-size: 26rpx;
color: #c9a87c; color: #c9a87c;
line-height: 1.6; line-height: 1.65;
flex-shrink: 0; flex-shrink: 0;
} }
.feature-text { .feature-text {
font-size: 26rpx; font-size: 26rpx;
color: #555; color: #555;
line-height: 1.6; line-height: 1.65;
} }
/* ── Bottom action bar ───────────────────────────────── */ /* ── Bottom action bar ───────────────────────────────── */
@@ -542,6 +609,7 @@ onMounted(() => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 4rpx 16rpx rgba(26, 26, 46, 0.3);
&:active { &:active {
opacity: 0.85; opacity: 0.85;

View File

@@ -1,6 +1,6 @@
<template> <template>
<view class="bookings-page"> <view class="bookings-page">
<!-- Tab filter --> <!-- Tab bar -->
<view class="tab-bar"> <view class="tab-bar">
<view <view
v-for="tab in tabs" v-for="tab in tabs"
@@ -10,26 +10,30 @@
@tap="selectTab(tab.key)" @tap="selectTab(tab.key)"
> >
<text class="tab-label">{{ tab.label }}</text> <text class="tab-label">{{ tab.label }}</text>
<view v-if="tab.key === 'upcoming' && upcomingCount > 0" class="tab-badge">
<text class="tab-badge-text">{{ upcomingCount }}</text>
</view>
</view> </view>
</view> </view>
<!-- Content --> <!-- Upcoming tab content -->
<scroll-view <scroll-view
v-show="activeTab === 'upcoming'"
class="scroll" class="scroll"
scroll-y scroll-y
refresher-enabled refresher-enabled
:refresher-triggered="refreshing" :refresher-triggered="refreshingUpcoming"
@refresherrefresh="onRefresh" @refresherrefresh="onRefreshUpcoming"
> >
<!-- Loading --> <!-- Loading -->
<view v-if="bookingStore.loadingBookings && !refreshing" class="loading-wrap"> <view v-if="bookingStore.loadingBookings && !refreshingUpcoming" class="loading-wrap">
<view v-for="i in 3" :key="i" class="skeleton-card" /> <view v-for="i in 3" :key="i" class="skeleton-card" />
</view> </view>
<!-- Empty --> <!-- Empty -->
<view v-else-if="filteredBookings.length === 0" class="empty-wrap"> <view v-else-if="upcomingBookings.length === 0" class="empty-wrap">
<text class="empty-icon">📅</text> <text class="empty-icon">📅</text>
<text class="empty-title">暂无预约记录</text> <text class="empty-title">暂无即将上课的预约</text>
<text class="empty-sub">去预约一节课吧</text> <text class="empty-sub">去预约一节课吧</text>
<view class="empty-btn" @tap="goBooking"> <view class="empty-btn" @tap="goBooking">
<text class="empty-btn-text">去预约</text> <text class="empty-btn-text">去预约</text>
@@ -39,43 +43,81 @@
<!-- Booking list --> <!-- Booking list -->
<view v-else class="list"> <view v-else class="list">
<view <view
v-for="booking in filteredBookings" v-for="booking in upcomingBookings"
:key="booking.id" :key="booking.id"
class="booking-card" class="booking-card"
> >
<!-- Date header stripe --> <view class="booking-stripe stripe--confirmed" />
<view class="booking-stripe" :class="stripeClass(booking.status)" />
<!-- Card content -->
<view class="booking-content"> <view class="booking-content">
<view class="booking-main"> <view class="booking-main">
<!-- Date + time -->
<view class="booking-datetime"> <view class="booking-datetime">
<text class="booking-date">{{ formatDateDisplay(booking.timeSlot.date) }}</text> <text class="booking-date">{{ formatDateDisplay(booking.timeSlot.date) }}</text>
<text class="booking-time"> <text class="booking-time">
{{ booking.timeSlot.startTime.slice(0, 5) }} - {{ booking.timeSlot.endTime.slice(0, 5) }} {{ booking.timeSlot.startTime.slice(0, 5) }} {{ booking.timeSlot.endTime.slice(0, 5) }}
</text> </text>
</view> </view>
<view class="status-badge badge--confirmed">
<text class="status-text">已预约</text>
</view>
</view>
<view class="booking-meta">
<text class="meta-text">💳 {{ booking.membership.cardType.name }}</text>
</view>
<view class="cancel-row">
<view class="cancel-btn" @tap="handleCancel(booking)">
<text class="cancel-text">取消预约</text>
</view>
</view>
</view>
</view>
</view>
<!-- Status badge --> <view class="scroll-bottom-spacer" />
</scroll-view>
<!-- History tab content -->
<scroll-view
v-show="activeTab === 'history'"
class="scroll"
scroll-y
refresher-enabled
:refresher-triggered="refreshingHistory"
@refresherrefresh="onRefreshHistory"
>
<!-- Loading -->
<view v-if="bookingStore.loadingBookings && !refreshingHistory" class="loading-wrap">
<view v-for="i in 3" :key="i" class="skeleton-card" />
</view>
<!-- Empty -->
<view v-else-if="historyBookings.length === 0" class="empty-wrap">
<text class="empty-icon">📋</text>
<text class="empty-title">暂无历史记录</text>
<text class="empty-sub">已完成或取消的课程将显示在这里</text>
</view>
<!-- Booking list -->
<view v-else class="list">
<view
v-for="booking in historyBookings"
:key="booking.id"
class="booking-card"
>
<view class="booking-stripe" :class="stripeClass(booking.status)" />
<view class="booking-content">
<view class="booking-main">
<view class="booking-datetime">
<text class="booking-date">{{ formatDateDisplay(booking.timeSlot.date) }}</text>
<text class="booking-time">
{{ booking.timeSlot.startTime.slice(0, 5) }} {{ booking.timeSlot.endTime.slice(0, 5) }}
</text>
</view>
<view class="status-badge" :class="statusBadgeClass(booking.status)"> <view class="status-badge" :class="statusBadgeClass(booking.status)">
<text class="status-text">{{ statusLabel(booking.status) }}</text> <text class="status-text">{{ statusLabel(booking.status) }}</text>
</view> </view>
</view> </view>
<!-- Membership used -->
<view class="booking-meta"> <view class="booking-meta">
<text class="meta-label">💳 {{ booking.membership.cardType.name }}</text> <text class="meta-text">💳 {{ booking.membership.cardType.name }}</text>
</view>
<!-- Cancel button for confirmed upcoming bookings -->
<view
v-if="booking.status === BookingStatus.CONFIRMED && isUpcoming(booking.timeSlot.date)"
class="cancel-row"
>
<view class="cancel-btn" @tap="handleCancel(booking)">
<text class="cancel-text">取消预约</text>
</view>
</view> </view>
</view> </view>
</view> </view>
@@ -91,32 +133,48 @@ import { ref, computed, onMounted } from 'vue'
import type { BookingWithDetails } from '@mp-pilates/shared' import type { BookingWithDetails } from '@mp-pilates/shared'
import { BookingStatus } from '@mp-pilates/shared' import { BookingStatus } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking' import { useBookingStore } from '../../stores/booking'
import { formatDate } from '../../utils/format' import { formatDate, getWeekdayLabel } from '../../utils/format'
const bookingStore = useBookingStore() const bookingStore = useBookingStore()
// ─── Tab state ──────────────────────────────────────────── // ─── Tab state ────────────────────────────────────────────
type TabKey = 'upcoming' | 'all' type TabKey = 'upcoming' | 'history'
const tabs = [ const tabs = [
{ key: 'upcoming' as TabKey, label: '即将上课' }, { key: 'upcoming' as TabKey, label: '即将上课' },
{ key: 'all' as TabKey, label: '全部记录' }, { key: 'history' as TabKey, label: '历史记录' },
] ]
const activeTab = ref<TabKey>('upcoming') const activeTab = ref<TabKey>('upcoming')
const refreshing = ref(false) const refreshingUpcoming = ref(false)
const refreshingHistory = ref(false)
// ─── Filtered bookings ──────────────────────────────────── // ─── Filtered bookings ────────────────────────────────────
const filteredBookings = computed<BookingWithDetails[]>(() => { const today = computed(() => formatDate(new Date()))
const upcomingBookings = computed<BookingWithDetails[]>(() => {
const all = bookingStore.myBookings as BookingWithDetails[] const all = bookingStore.myBookings as BookingWithDetails[]
if (activeTab.value === 'upcoming') { return all
const today = formatDate(new Date()) .filter(
return all.filter( (b) => b.status === BookingStatus.CONFIRMED && b.timeSlot.date >= today.value,
(b) => b.status === BookingStatus.CONFIRMED && b.timeSlot.date >= today, )
).sort((a, b) => a.timeSlot.date.localeCompare(b.timeSlot.date)) .sort((a, b) => {
if (a.timeSlot.date !== b.timeSlot.date) {
return a.timeSlot.date.localeCompare(b.timeSlot.date)
} }
return [...all].sort((a, b) => { return a.timeSlot.startTime.localeCompare(b.timeSlot.startTime)
// Most recent first })
})
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) { if (b.timeSlot.date !== a.timeSlot.date) {
return b.timeSlot.date.localeCompare(a.timeSlot.date) return b.timeSlot.date.localeCompare(a.timeSlot.date)
} }
@@ -124,11 +182,9 @@ const filteredBookings = computed<BookingWithDetails[]>(() => {
}) })
}) })
// ─── Helpers ────────────────────────────────────────────── const upcomingCount = computed(() => upcomingBookings.value.length)
function isUpcoming(date: string): boolean {
return date >= formatDate(new Date())
}
// ─── Helpers ──────────────────────────────────────────────
function statusLabel(status: BookingStatus): string { function statusLabel(status: BookingStatus): string {
const map: Record<BookingStatus, string> = { const map: Record<BookingStatus, string> = {
[BookingStatus.CONFIRMED]: '已预约', [BookingStatus.CONFIRMED]: '已预约',
@@ -164,8 +220,7 @@ function formatDateDisplay(dateStr: string): string {
const d = new Date(dateStr) const d = new Date(dateStr)
const month = d.getMonth() + 1 const month = d.getMonth() + 1
const day = d.getDate() const day = d.getDate()
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] const weekday = getWeekdayLabel(d)
const weekday = weekdays[d.getDay()]
return `${month}${day}${weekday}` return `${month}${day}${weekday}`
} }
@@ -174,10 +229,16 @@ function selectTab(key: TabKey) {
activeTab.value = key activeTab.value = key
} }
async function onRefresh() { async function onRefreshUpcoming() {
refreshing.value = true refreshingUpcoming.value = true
await bookingStore.fetchMyBookings() await bookingStore.fetchMyBookings()
refreshing.value = false refreshingUpcoming.value = false
}
async function onRefreshHistory() {
refreshingHistory.value = true
await bookingStore.fetchMyBookings()
refreshingHistory.value = false
} }
function goBooking() { function goBooking() {
@@ -185,14 +246,17 @@ function goBooking() {
} }
async function handleCancel(booking: BookingWithDetails) { async function handleCancel(booking: BookingWithDetails) {
const dateLabel = formatDateDisplay(booking.timeSlot.date)
const timeLabel = booking.timeSlot.startTime.slice(0, 5)
uni.showModal({ uni.showModal({
title: '取消预约', title: '取消预约',
content: `确定要取消 ${formatDateDisplay(booking.timeSlot.date)} ${booking.timeSlot.startTime.slice(0, 5)} 的课程吗?`, content: `确定要取消 ${dateLabel} ${timeLabel} 的课程吗?`,
confirmText: '确定取消', confirmText: '确定取消',
confirmColor: '#ef4444', confirmColor: '#ef4444',
cancelText: '再想想', cancelText: '再想想',
success: async (res) => { success: async (res) => {
if (res.confirm) { if (!res.confirm) return
uni.showLoading({ title: '取消中...' }) uni.showLoading({ title: '取消中...' })
try { try {
await bookingStore.cancelBooking(booking.id) await bookingStore.cancelBooking(booking.id)
@@ -204,7 +268,6 @@ async function handleCancel(booking: BookingWithDetails) {
const msg = err instanceof Error ? err.message : '取消失败,请重试' const msg = err instanceof Error ? err.message : '取消失败,请重试'
uni.showToast({ title: msg, icon: 'none' }) uni.showToast({ title: msg, icon: 'none' })
} }
}
}, },
}) })
} }
@@ -227,16 +290,16 @@ onMounted(() => bookingStore.fetchMyBookings())
flex-direction: row; flex-direction: row;
background: #fff; background: #fff;
border-bottom: 1rpx solid #f0ece8; border-bottom: 1rpx solid #f0ece8;
position: sticky; flex-shrink: 0;
top: 0;
z-index: 10;
} }
.tab-item { .tab-item {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: row;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8rpx;
padding: 28rpx 0; padding: 28rpx 0;
position: relative; position: relative;
@@ -252,7 +315,7 @@ onMounted(() => bookingStore.fetchMyBookings())
bottom: 0; bottom: 0;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
width: 40rpx; width: 48rpx;
height: 4rpx; height: 4rpx;
background: #c9a87c; background: #c9a87c;
border-radius: 2rpx; border-radius: 2rpx;
@@ -266,9 +329,27 @@ onMounted(() => bookingStore.fetchMyBookings())
font-weight: 400; font-weight: 400;
} }
.tab-badge {
min-width: 32rpx;
height: 32rpx;
border-radius: 16rpx;
background: #ef4444;
display: flex;
align-items: center;
justify-content: center;
padding: 0 8rpx;
}
.tab-badge-text {
font-size: 20rpx;
color: #fff;
font-weight: 600;
}
/* ── Scroll ──────────────────────────────────────────── */ /* ── Scroll ──────────────────────────────────────────── */
.scroll { .scroll {
flex: 1; flex: 1;
height: calc(100vh - 88rpx);
} }
/* ── Loading ─────────────────────────────────────────── */ /* ── Loading ─────────────────────────────────────────── */
@@ -348,14 +429,15 @@ onMounted(() => bookingStore.fetchMyBookings())
flex-direction: row; flex-direction: row;
} }
/* Colored left stripe */
.booking-stripe { .booking-stripe {
width: 8rpx; width: 8rpx;
flex-shrink: 0; flex-shrink: 0;
&--confirmed { background: #c9a87c; } &.stripe--confirmed { background: #c9a87c; }
&--completed { background: #4caf50; } &.stripe--completed { background: #4caf50; }
&--cancelled { background: #e0e0e0; } &.stripe--cancelled { background: #e0e0e0; }
&--noshow { background: #ef4444; } &.stripe--noshow { background: #ef4444; }
} }
.booking-content { .booking-content {
@@ -390,6 +472,7 @@ onMounted(() => bookingStore.fetchMyBookings())
color: #888; color: #888;
} }
/* Status badge */
.status-badge { .status-badge {
padding: 8rpx 18rpx; padding: 8rpx 18rpx;
border-radius: 20rpx; border-radius: 20rpx;
@@ -411,14 +494,15 @@ onMounted(() => bookingStore.fetchMyBookings())
.badge--noshow & { color: #ef4444; } .badge--noshow & { color: #ef4444; }
} }
/* Meta info */
.booking-meta { .booking-meta {
.meta-label { .meta-text {
font-size: 24rpx; font-size: 24rpx;
color: #999; color: #999;
} }
} }
/* ── Cancel row ──────────────────────────────────────── */ /* Cancel row */
.cancel-row { .cancel-row {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@@ -426,13 +510,19 @@ onMounted(() => bookingStore.fetchMyBookings())
} }
.cancel-btn { .cancel-btn {
padding: 8rpx 24rpx; padding: 10rpx 24rpx;
border-radius: 24rpx;
border: 1rpx solid #ef444430;
background: #fef0f0;
&:active {
opacity: 0.75;
}
} }
.cancel-text { .cancel-text {
font-size: 24rpx; font-size: 24rpx;
color: #ef4444; color: #ef4444;
text-decoration: underline;
font-weight: 500; font-weight: 500;
} }

View File

@@ -2,48 +2,65 @@
<view class="info-page"> <view class="info-page">
<!-- Avatar section --> <!-- Avatar section -->
<view class="avatar-section"> <view class="avatar-section">
<view class="avatar-wrap" @tap="chooseAvatar"> <view class="avatar-wrap">
<image <image
v-if="form.avatarUrl" v-if="avatarUrl"
class="avatar" class="avatar"
:src="form.avatarUrl" :src="avatarUrl"
mode="aspectFill" mode="aspectFill"
/> />
<view v-else class="avatar-placeholder"> <view v-else class="avatar-placeholder">
<text class="avatar-placeholder-text">{{ nicknameInitial }}</text> <text class="avatar-placeholder-text">{{ nicknameInitial }}</text>
</view> </view>
<view class="avatar-edit-badge">
<text class="avatar-edit-icon">📷</text>
</view> </view>
</view> <text class="avatar-name">{{ form.nickname || '未设置昵称' }}</text>
<text class="avatar-hint">点击更换头像</text> <text class="avatar-hint">微信头像</text>
</view> </view>
<!-- Form --> <!-- Form fields -->
<view class="form-card"> <view class="form-card">
<!-- Nickname --> <!-- Nickname (editable) -->
<view class="form-row"> <view class="form-row">
<text class="form-label">昵称</text> <text class="form-label">昵称</text>
<input <input
class="form-input" class="form-input"
v-model="form.nickname" v-model="form.nickname"
placeholder="请输入昵称" placeholder="请输入昵称"
placeholder-style="color: #bbb" placeholder-style="color: #ccc"
maxlength="20" maxlength="20"
:disabled="saving" :disabled="saving"
/> />
<text class="form-arrow"></text>
</view> </view>
<!-- Phone (read-only) --> <!-- Phone -->
<view class="form-row form-row--readonly"> <view class="form-row form-row--last">
<text class="form-label">手机号</text> <text class="form-label">手机号</text>
<text class="form-value">{{ phoneDisplay }}</text>
<!-- 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> </view>
<!-- Member since (read-only) --> <!-- Read-only info card -->
<view class="form-row form-row--readonly"> <view class="info-card">
<text class="form-label">注册时间</text> <view class="info-row">
<text class="form-value">{{ joinDateDisplay }}</text> <text class="info-label">注册时间</text>
<text class="info-value">{{ joinDateDisplay }}</text>
</view>
<view class="info-row info-row--last">
<text class="info-label">会员卡数量</text>
<text class="info-value">{{ activeMembershipCount }} 张有效</text>
</view> </view>
</view> </view>
@@ -51,7 +68,7 @@
<view class="save-wrap"> <view class="save-wrap">
<view <view
class="save-btn" class="save-btn"
:class="{ 'save-btn--loading': saving, 'save-btn--disabled': !isDirty }" :class="{ 'save-btn--loading': saving, 'save-btn--disabled': !isDirty || saving }"
@tap="handleSave" @tap="handleSave"
> >
<text class="save-btn-text">{{ saving ? '保存中...' : '保存修改' }}</text> <text class="save-btn-text">{{ saving ? '保存中...' : '保存修改' }}</text>
@@ -63,39 +80,34 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useUserStore } from '../../stores/user' import { useUserStore } from '../../stores/user'
import { wxBindPhone } from '../../utils/auth'
const userStore = useUserStore() const userStore = useUserStore()
// ─── Form state ─────────────────────────────────────────── // ─── Form state ───────────────────────────────────────────
const form = ref({ const form = ref({
nickname: '', nickname: '',
avatarUrl: '',
}) })
const originalNickname = ref('')
const originalForm = ref({
nickname: '',
avatarUrl: '',
})
const saving = ref(false) const saving = ref(false)
// ─── Computed ───────────────────────────────────────────── // ─── Computed ─────────────────────────────────────────────
const isDirty = computed( const isDirty = computed(() => form.value.nickname.trim() !== originalNickname.value)
() =>
form.value.nickname !== originalForm.value.nickname || const avatarUrl = computed(() => userStore.user?.avatarUrl ?? '')
form.value.avatarUrl !== originalForm.value.avatarUrl,
)
const nicknameInitial = computed(() => { const nicknameInitial = computed(() => {
const nick = form.value.nickname || '?' const nick = form.value.nickname || '?'
return nick.slice(0, 1).toUpperCase() return nick.slice(0, 1).toUpperCase()
}) })
const hasPhone = computed(() => !!userStore.user?.phone)
const phoneDisplay = computed(() => { const phoneDisplay = computed(() => {
const phone = userStore.user?.phone const phone = userStore.user?.phone
if (!phone) return '未绑定' if (!phone) return '未绑定'
// Mask middle digits: 138****1234 // Mask middle 4 digits: 138****1234
return phone.slice(0, 3) + '****' + phone.slice(-4) return `${phone.slice(0, 3)}****${phone.slice(-4)}`
}) })
const joinDateDisplay = computed(() => { const joinDateDisplay = computed(() => {
@@ -105,53 +117,35 @@ const joinDateDisplay = computed(() => {
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}` return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}`
}) })
// ─── Avatar picker ──────────────────────────────────────── const activeMembershipCount = computed(
function chooseAvatar() { () => userStore.user?.activeMembershipCount ?? userStore.activeMemberships.length,
uni.chooseImage({ )
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
const tempPath = res.tempFilePaths[0]
// Upload to server
uploadAvatar(tempPath)
},
})
}
async function uploadAvatar(tempPath: string) { // ─── Phone binding ────────────────────────────────────────
uni.showLoading({ title: '上传中...' }) async function handleGetPhone(e: {
const token = uni.getStorageSync('token') as string detail: { encryptedData: string; iv: string; errMsg: string }
}) {
uni.uploadFile({ if (e.detail.errMsg !== 'getPhoneNumber:ok') {
url: 'http://localhost:3000/api/user/avatar', // User denied or cancelled
filePath: tempPath, return
name: 'file', }
header: { uni.showLoading({ title: '绑定中...' })
Authorization: `Bearer ${token}`,
},
success: (res) => {
uni.hideLoading()
try { try {
interface UploadResponse { const updated = await wxBindPhone(e as Parameters<typeof wxBindPhone>[0])
success: boolean // Refresh store with updated profile
data: { url: string } await userStore.fetchProfile()
}
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.hideLoading()
uni.showToast({ title: '头像上传失败', icon: 'none' }) 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 ───────────────────────────────────────────────── // ─── Save ─────────────────────────────────────────────────
@@ -163,15 +157,16 @@ async function handleSave() {
uni.showToast({ title: '昵称不能为空', icon: 'none' }) uni.showToast({ title: '昵称不能为空', icon: 'none' })
return return
} }
if (nickname.length > 20) {
uni.showToast({ title: '昵称最多 20 个字', icon: 'none' })
return
}
saving.value = true saving.value = true
try { try {
await userStore.updateProfile({ await userStore.updateProfile({ nickname })
nickname, originalNickname.value = nickname
avatarUrl: form.value.avatarUrl || undefined, form.value = { nickname }
})
// Update original to reflect saved state
originalForm.value = { ...form.value }
uni.showToast({ title: '保存成功', icon: 'success' }) uni.showToast({ title: '保存成功', icon: 'success' })
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : '保存失败,请重试' const msg = err instanceof Error ? err.message : '保存失败,请重试'
@@ -183,15 +178,10 @@ async function handleSave() {
// ─── Lifecycle ──────────────────────────────────────────── // ─── Lifecycle ────────────────────────────────────────────
onMounted(async () => { onMounted(async () => {
// Ensure we have fresh profile data
await userStore.fetchProfile() await userStore.fetchProfile()
if (userStore.user) { if (userStore.user) {
const initial = { form.value = { nickname: userStore.user.nickname }
nickname: userStore.user.nickname, originalNickname.value = userStore.user.nickname
avatarUrl: userStore.user.avatarUrl ?? '',
}
form.value = { ...initial }
originalForm.value = { ...initial }
} }
}) })
</script> </script>
@@ -207,15 +197,17 @@ onMounted(async () => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding: 60rpx 0 48rpx; padding: 56rpx 0 40rpx;
background: #fff; background: #fff;
margin-bottom: 24rpx; margin-bottom: 24rpx;
border-bottom: 1rpx solid #f0ece8;
} }
.avatar-wrap { .avatar-wrap {
position: relative; position: relative;
width: 160rpx; width: 160rpx;
height: 160rpx; height: 160rpx;
margin-bottom: 16rpx;
} }
.avatar { .avatar {
@@ -236,32 +228,20 @@ onMounted(async () => {
} }
.avatar-placeholder-text { .avatar-placeholder-text {
font-size: 60rpx; font-size: 64rpx;
font-weight: 700; font-weight: 700;
color: #fff; color: #fff;
} }
.avatar-edit-badge { .avatar-name {
position: absolute; font-size: 34rpx;
bottom: 4rpx; font-weight: 700;
right: 4rpx; color: #1a1a1a;
width: 48rpx; margin-bottom: 6rpx;
height: 48rpx;
border-radius: 50%;
background: #fff;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.15);
display: flex;
align-items: center;
justify-content: center;
}
.avatar-edit-icon {
font-size: 26rpx;
} }
.avatar-hint { .avatar-hint {
margin-top: 16rpx; font-size: 22rpx;
font-size: 24rpx;
color: #bbb; color: #bbb;
} }
@@ -269,7 +249,7 @@ onMounted(async () => {
.form-card { .form-card {
background: #fff; background: #fff;
border-radius: 20rpx; border-radius: 20rpx;
margin: 0 24rpx; margin: 0 24rpx 20rpx;
overflow: hidden; overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05); box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
} }
@@ -280,14 +260,11 @@ onMounted(async () => {
align-items: center; align-items: center;
padding: 32rpx 28rpx; padding: 32rpx 28rpx;
border-bottom: 1rpx solid #f5f5f5; border-bottom: 1rpx solid #f5f5f5;
min-height: 100rpx;
&:last-child { &--last {
border-bottom: none; border-bottom: none;
} }
&--readonly {
opacity: 0.8;
}
} }
.form-label { .form-label {
@@ -304,6 +281,7 @@ onMounted(async () => {
color: #222; color: #222;
text-align: right; text-align: right;
background: transparent; background: transparent;
min-height: 44rpx;
} }
.form-value { .form-value {
@@ -313,9 +291,74 @@ onMounted(async () => {
text-align: right; text-align: right;
} }
.form-arrow {
font-size: 36rpx;
color: #ccc;
margin-left: 8rpx;
line-height: 1;
}
/* Bind phone button (styled, not default wx button) */
.bind-phone-btn {
flex: 1;
display: flex;
justify-content: flex-end;
align-items: center;
background: transparent;
border: none;
padding: 0;
margin: 0;
line-height: normal;
/* reset uni button default styles */
&::after {
border: none;
}
}
.bind-phone-text {
font-size: 26rpx;
color: #c9a87c;
font-weight: 600;
text-decoration: underline;
}
/* ── Read-only info card ──────────────────────────────── */
.info-card {
background: #fff;
border-radius: 20rpx;
margin: 0 24rpx 32rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
.info-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 28rpx 28rpx;
border-bottom: 1rpx solid #f5f5f5;
&--last {
border-bottom: none;
}
}
.info-label {
font-size: 26rpx;
color: #999;
}
.info-value {
font-size: 26rpx;
color: #555;
font-weight: 500;
}
/* ── Save button ─────────────────────────────────────── */ /* ── Save button ─────────────────────────────────────── */
.save-wrap { .save-wrap {
padding: 40rpx 24rpx; padding: 8rpx 24rpx 48rpx;
} }
.save-btn { .save-btn {
@@ -327,6 +370,7 @@ onMounted(async () => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 4rpx 20rpx rgba(26, 26, 46, 0.3); box-shadow: 0 4rpx 20rpx rgba(26, 26, 46, 0.3);
transition: opacity 0.2s;
&:active { &:active {
opacity: 0.85; opacity: 0.85;
@@ -334,7 +378,7 @@ onMounted(async () => {
&--loading, &--loading,
&--disabled { &--disabled {
opacity: 0.5; opacity: 0.45;
box-shadow: none; box-shadow: none;
} }
} }

View File

@@ -14,7 +14,7 @@
</view> </view>
<!-- Empty state --> <!-- Empty state -->
<view v-else-if="memberships.length === 0" class="empty-wrap"> <view v-else-if="allMemberships.length === 0" class="empty-wrap">
<text class="empty-icon">💳</text> <text class="empty-icon">💳</text>
<text class="empty-title">暂无会员卡</text> <text class="empty-title">暂无会员卡</text>
<text class="empty-sub">购买会员卡后即可预约课程</text> <text class="empty-sub">购买会员卡后即可预约课程</text>
@@ -26,27 +26,59 @@
<!-- Membership list --> <!-- Membership list -->
<view v-else class="list"> <view v-else class="list">
<!-- Active cards --> <!-- Active cards -->
<view v-if="activeMemberships.length > 0"> <view v-if="activeMemberships.length > 0" class="group-section">
<view class="group-header">
<view class="group-dot group-dot--active" />
<text class="group-title">有效会员卡</text> <text class="group-title">有效会员卡</text>
<text class="group-count">{{ activeMemberships.length }} </text>
</view>
<view <view
v-for="m in activeMemberships" v-for="m in activeMemberships"
:key="m.id" :key="m.id"
class="card-item card-item--active" class="card-item"
> >
<view class="card-top" :class="cardTopClass(m)"> <!-- Colored left border strip -->
<view> <view class="card-strip" :class="stripClass(m.cardType.type)" />
<!-- Card header (colored gradient) -->
<view class="card-header" :class="headerClass(m.cardType.type)">
<view class="card-header-left">
<text class="card-name">{{ m.cardType.name }}</text> <text class="card-name">{{ m.cardType.name }}</text>
<text class="card-type-tag">{{ typeLabel(m.cardType.type) }}</text> <view class="card-type-badge">
</view> <text class="card-type-badge-text">{{ typeLabel(m.cardType.type) }}</text>
<view class="card-badge card-badge--active">
<text class="badge-text">有效</text>
</view> </view>
</view> </view>
<view class="status-badge status-badge--active">
<text class="status-badge-text">有效</text>
</view>
</view>
<!-- Card body -->
<view class="card-body"> <view class="card-body">
<view class="info-row" v-if="m.remainingTimes !== null"> <!-- Times card: remaining times + progress -->
<text class="info-label">剩余课时</text> <template v-if="m.remainingTimes !== null">
<text class="info-value info-value--highlight">{{ m.remainingTimes }} </text> <view class="highlight-row">
<text class="highlight-label">剩余课时</text>
<text class="highlight-value">
<text class="highlight-number">{{ m.remainingTimes }}</text>
<text class="highlight-unit"> </text>
</text>
</view> </view>
<view v-if="m.cardType.totalTimes" class="progress-wrap">
<view class="progress-bar">
<view
class="progress-fill"
:style="{ width: progressWidth(m) }"
/>
</view>
<text class="progress-label">
已使用 {{ usedTimes(m) }} / {{ m.cardType.totalTimes }}
</text>
</view>
</template>
<!-- Duration card: expiry -->
<view class="info-row"> <view class="info-row">
<text class="info-label">有效期至</text> <text class="info-label">有效期至</text>
<text class="info-value">{{ m.expireDate.slice(0, 10) }}</text> <text class="info-value">{{ m.expireDate.slice(0, 10) }}</text>
@@ -56,40 +88,36 @@
<text class="info-value">{{ m.startDate.slice(0, 10) }}</text> <text class="info-value">{{ m.startDate.slice(0, 10) }}</text>
</view> </view>
</view> </view>
<!-- Progress bar for time-based cards -->
<view v-if="m.remainingTimes !== null && m.cardType.totalTimes" class="progress-wrap">
<view class="progress-bar">
<view
class="progress-fill"
:style="{ width: progressWidth(m) }"
/>
</view>
<text class="progress-label">
已使用 {{ m.cardType.totalTimes - m.remainingTimes }}/{{ m.cardType.totalTimes }}
</text>
</view>
</view> </view>
</view> </view>
<!-- Expired / used up cards --> <!-- Expired / used up cards -->
<view v-if="inactiveMemberships.length > 0" class="inactive-section"> <view v-if="inactiveMemberships.length > 0" class="group-section">
<view class="group-header">
<view class="group-dot group-dot--inactive" />
<text class="group-title">历史记录</text> <text class="group-title">历史记录</text>
<text class="group-count">{{ inactiveMemberships.length }} </text>
</view>
<view <view
v-for="m in inactiveMemberships" v-for="m in inactiveMemberships"
:key="m.id" :key="m.id"
class="card-item card-item--inactive" class="card-item card-item--inactive"
> >
<view class="card-top card-top--inactive"> <view class="card-strip card-strip--inactive" />
<view> <view class="card-header card-header--inactive">
<view class="card-header-left">
<text class="card-name card-name--dim">{{ m.cardType.name }}</text> <text class="card-name card-name--dim">{{ m.cardType.name }}</text>
<text class="card-type-tag card-type-tag--dim">{{ typeLabel(m.cardType.type) }}</text> <view class="card-type-badge card-type-badge--dim">
<text class="card-type-badge-text">{{ typeLabel(m.cardType.type) }}</text>
</view> </view>
<view class="card-badge" :class="statusBadgeClass(m.status)"> </view>
<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> </view>
<view class="card-body"> <view class="card-body">
<view class="info-row" v-if="m.remainingTimes !== null"> <view v-if="m.remainingTimes !== null" class="info-row">
<text class="info-label">剩余课时</text> <text class="info-label">剩余课时</text>
<text class="info-value">{{ m.remainingTimes }} </text> <text class="info-value">{{ m.remainingTimes }} </text>
</view> </view>
@@ -107,7 +135,8 @@
<!-- Buy more FAB --> <!-- Buy more FAB -->
<view class="fab" @tap="goStore"> <view class="fab" @tap="goStore">
<text class="fab-text">+ 购买会员卡</text> <text class="fab-icon">+</text>
<text class="fab-text">购买会员卡</text>
</view> </view>
</view> </view>
</template> </template>
@@ -116,20 +145,23 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import type { MembershipWithCardType } from '@mp-pilates/shared' import type { MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared' import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
import { get } from '../../utils/request' import { useUserStore } from '../../stores/user'
const userStore = useUserStore()
// ─── State ──────────────────────────────────────────────── // ─── State ────────────────────────────────────────────────
const memberships = ref<MembershipWithCardType[]>([])
const loading = ref(false) const loading = ref(false)
const refreshing = ref(false) const refreshing = ref(false)
// ─── Computed ───────────────────────────────────────────── // ─── Computed from store ───────────────────────────────────
const allMemberships = computed(() => userStore.memberships as MembershipWithCardType[])
const activeMemberships = computed(() => const activeMemberships = computed(() =>
memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE), allMemberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
) )
const inactiveMemberships = computed(() => const inactiveMemberships = computed(() =>
memberships.value.filter((m) => m.status !== MembershipStatus.ACTIVE), allMemberships.value.filter((m) => m.status !== MembershipStatus.ACTIVE),
) )
// ─── Helpers ────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────
@@ -152,15 +184,21 @@ function statusLabel(status: MembershipStatus): string {
} }
function statusBadgeClass(status: MembershipStatus): string { function statusBadgeClass(status: MembershipStatus): string {
if (status === MembershipStatus.EXPIRED) return 'card-badge--expired' if (status === MembershipStatus.EXPIRED) return 'status-badge--expired'
if (status === MembershipStatus.USED_UP) return 'card-badge--used' if (status === MembershipStatus.USED_UP) return 'status-badge--used'
return '' return 'status-badge--expired'
} }
function cardTopClass(m: MembershipWithCardType): string { function stripClass(type: CardTypeCategory): string {
if (m.cardType.type === CardTypeCategory.TRIAL) return 'card-top--trial' if (type === CardTypeCategory.TRIAL) return 'card-strip--trial'
if (m.cardType.type === CardTypeCategory.DURATION) return 'card-top--duration' if (type === CardTypeCategory.DURATION) return 'card-strip--duration'
return 'card-top--times' return 'card-strip--times'
}
function headerClass(type: CardTypeCategory): string {
if (type === CardTypeCategory.TRIAL) return 'card-header--trial'
if (type === CardTypeCategory.DURATION) return 'card-header--duration'
return 'card-header--times'
} }
function progressWidth(m: MembershipWithCardType): string { function progressWidth(m: MembershipWithCardType): string {
@@ -169,11 +207,16 @@ function progressWidth(m: MembershipWithCardType): string {
return `${Math.max(0, Math.min(100, pct))}%` return `${Math.max(0, Math.min(100, pct))}%`
} }
function usedTimes(m: MembershipWithCardType): number {
if (m.remainingTimes === null || !m.cardType.totalTimes) return 0
return m.cardType.totalTimes - m.remainingTimes
}
// ─── Data loading ───────────────────────────────────────── // ─── Data loading ─────────────────────────────────────────
async function loadMemberships() { async function loadMemberships() {
loading.value = true loading.value = true
try { try {
memberships.value = await get<MembershipWithCardType[]>('/membership/my') await userStore.fetchMemberships()
} catch { } catch {
uni.showToast({ title: '加载失败,请下拉刷新', icon: 'none' }) uni.showToast({ title: '加载失败,请下拉刷新', icon: 'none' })
} finally { } finally {
@@ -183,13 +226,11 @@ async function loadMemberships() {
async function onRefresh() { async function onRefresh() {
refreshing.value = true refreshing.value = true
await loadMemberships() await userStore.fetchMemberships()
refreshing.value = false refreshing.value = false
} }
function goStore() { function goStore() {
uni.navigateBack({ delta: 10 })
// Navigate to store tab
uni.switchTab({ url: '/pages/home/index' }) uni.switchTab({ url: '/pages/home/index' })
} }
@@ -216,7 +257,7 @@ onMounted(loadMemberships)
} }
.skeleton-card { .skeleton-card {
height: 200rpx; height: 220rpx;
border-radius: 20rpx; border-radius: 20rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%); background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 400% 100%; background-size: 400% 100%;
@@ -255,9 +296,10 @@ onMounted(loadMemberships)
.empty-btn { .empty-btn {
margin-top: 12rpx; margin-top: 12rpx;
padding: 20rpx 56rpx; padding: 22rpx 60rpx;
border-radius: 44rpx; border-radius: 44rpx;
background: #c9a87c; background: #c9a87c;
box-shadow: 0 4rpx 16rpx rgba(201, 168, 124, 0.35);
} }
.empty-btn-text { .empty-btn-text {
@@ -269,17 +311,41 @@ onMounted(loadMemberships)
/* ── List ────────────────────────────────────────────── */ /* ── List ────────────────────────────────────────────── */
.list { .list {
padding: 24rpx 24rpx 0; padding: 24rpx 24rpx 0;
}
/* ── Group section ───────────────────────────────────── */
.group-section {
margin-bottom: 8rpx;
}
.group-header {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
gap: 8rpx; align-items: center;
gap: 10rpx;
padding: 8rpx 4rpx 14rpx;
}
.group-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
flex-shrink: 0;
&--active { background: #4caf50; }
&--inactive { background: #bbb; }
} }
.group-title { .group-title {
font-size: 26rpx; font-size: 26rpx;
color: #999; color: #555;
font-weight: 500; font-weight: 600;
padding: 8rpx 4rpx 12rpx; flex: 1;
display: block; }
.group-count {
font-size: 22rpx;
color: #bbb;
} }
/* ── Card item ───────────────────────────────────────── */ /* ── Card item ───────────────────────────────────────── */
@@ -288,15 +354,28 @@ onMounted(loadMemberships)
border-radius: 20rpx; border-radius: 20rpx;
overflow: hidden; overflow: hidden;
margin-bottom: 16rpx; margin-bottom: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06); box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.07);
display: flex;
flex-direction: column;
&--inactive { &--inactive {
opacity: 0.75; opacity: 0.72;
} }
} }
.card-top { /* Colored left border strip */
padding: 24rpx 28rpx; .card-strip {
height: 6rpx;
&--times { background: linear-gradient(90deg, #1a1a2e, #2d2d5e); }
&--duration { background: linear-gradient(90deg, #6c3483, #9b59b6); }
&--trial { background: linear-gradient(90deg, #7d6608, #c9a87c); }
&--inactive { background: #ccc; }
}
/* Card header gradient area */
.card-header {
padding: 22rpx 28rpx;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
@@ -308,46 +387,88 @@ onMounted(loadMemberships)
&--inactive { background: #888; } &--inactive { background: #888; }
} }
.card-header-left {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.card-name { .card-name {
font-size: 32rpx; font-size: 32rpx;
font-weight: 700; font-weight: 700;
color: #fff; color: #fff;
display: block;
margin-bottom: 6rpx;
&--dim { color: #ddd; } &--dim { color: #ddd; }
} }
.card-type-tag { .card-type-badge {
font-size: 20rpx; align-self: flex-start;
color: rgba(255, 255, 255, 0.7); padding: 4rpx 14rpx;
font-weight: 400; border-radius: 12rpx;
display: block; background: rgba(255, 255, 255, 0.15);
border: 1rpx solid rgba(255, 255, 255, 0.25);
&--dim { color: rgba(255, 255, 255, 0.5); } &--dim {
background: rgba(255, 255, 255, 0.08);
}
} }
.card-badge { .card-type-badge-text {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.85);
font-weight: 500;
}
/* Status badge */
.status-badge {
padding: 8rpx 20rpx; padding: 8rpx 20rpx;
border-radius: 20rpx; border-radius: 20rpx;
border: 1rpx solid rgba(255, 255, 255, 0.4); border: 1rpx solid rgba(255, 255, 255, 0.35);
flex-shrink: 0;
&--active { background: rgba(76, 175, 80, 0.25); } &--active { background: rgba(76, 175, 80, 0.3); }
&--expired { background: rgba(0, 0, 0, 0.2); } &--expired { background: rgba(0, 0, 0, 0.2); }
&--used { background: rgba(0, 0, 0, 0.2); } &--used { background: rgba(0, 0, 0, 0.2); }
} }
.badge-text { .status-badge-text {
font-size: 22rpx; font-size: 22rpx;
color: #fff; color: #fff;
font-weight: 600; font-weight: 600;
} }
/* Card body */
.card-body { .card-body {
padding: 20rpx 28rpx; padding: 20rpx 28rpx 24rpx;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12rpx; gap: 10rpx;
}
.highlight-row {
display: flex;
flex-direction: row;
align-items: baseline;
justify-content: space-between;
margin-bottom: 4rpx;
}
.highlight-label {
font-size: 26rpx;
color: #999;
}
.highlight-number {
font-size: 44rpx;
font-weight: 800;
color: #c9a87c;
line-height: 1;
}
.highlight-unit {
font-size: 22rpx;
color: #c9a87c;
font-weight: 500;
} }
.info-row { .info-row {
@@ -366,20 +487,14 @@ onMounted(loadMemberships)
font-size: 26rpx; font-size: 26rpx;
color: #333; color: #333;
font-weight: 500; font-weight: 500;
&--highlight {
color: #c9a87c;
font-size: 30rpx;
font-weight: 700;
}
} }
/* ── Progress bar ────────────────────────────────────── */ /* ── Progress bar ────────────────────────────────────── */
.progress-wrap { .progress-wrap {
padding: 0 28rpx 20rpx;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8rpx; gap: 8rpx;
margin-bottom: 6rpx;
} }
.progress-bar { .progress-bar {
@@ -402,11 +517,6 @@ onMounted(loadMemberships)
text-align: right; text-align: right;
} }
/* ── Inactive section ────────────────────────────────── */
.inactive-section {
margin-top: 8rpx;
}
/* ── FAB ─────────────────────────────────────────────── */ /* ── FAB ─────────────────────────────────────────────── */
.fab { .fab {
position: fixed; position: fixed;
@@ -417,12 +527,23 @@ onMounted(loadMemberships)
padding: 22rpx 36rpx; padding: 22rpx 36rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.2); box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.2);
z-index: 100; z-index: 100;
display: flex;
flex-direction: row;
align-items: center;
gap: 8rpx;
&:active { &:active {
opacity: 0.85; opacity: 0.85;
} }
} }
.fab-icon {
font-size: 36rpx;
color: #c9a87c;
font-weight: 300;
line-height: 1;
}
.fab-text { .fab-text {
font-size: 28rpx; font-size: 28rpx;
font-weight: 700; font-weight: 700;
@@ -432,6 +553,6 @@ onMounted(loadMemberships)
/* ── Spacer ──────────────────────────────────────────── */ /* ── Spacer ──────────────────────────────────────────── */
.scroll-bottom-spacer { .scroll-bottom-spacer {
height: 100rpx; height: 120rpx;
} }
</style> </style>

View 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,
}
})