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

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

View File

@@ -1,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>