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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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