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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user