## Features ### Admin Schedule Page (`packages/app/src/pages/admin/schedule.vue`) - Interactive date-based slot editor for managing daily schedules - Real-time slot editing: start/end times, capacity adjustments - Slot deletion with conflict warnings when bookings exist - Add new slots with modal dialog - Live booking status display (booked count, people names) - Publish/Save changes with sync feedback - Revert unsaved changes with confirmation - Skeleton loading states and empty state handling - Responsive design with optimized mobile UX ### Backend Enhancements - **New DTO** (`PublishDaySlotsDto`): Structured slot publishing with validation - Date string validation - Slot array with existing slot IDs for updates - Time and capacity validation per slot - **Schedule Preview API** (`getSchedulePreview`): - Check for existing published slots - Fallback to active WeekTemplates for unpublished dates - Unified response format with isPublished flag - **Publish Slots API** (`publishDaySlots`): - Atomic transaction for consistency - Update existing slots with new times/capacity - Create new slots from template data - Delete unpublished slots or set to CLOSED if bookings exist - Prevent capacity reduction below existing bookings - Returns all published slots for feedback ### State Management - Enhanced admin store with schedule state - Support for pending/unsaved slot changes - Optimistic UI updates with server sync ### Documentation - Comprehensive scheduling system architecture docs - Quick reference for admin workflows - Flow diagrams and state transitions - Implementation guide for future maintenance ## Breaking Changes None ## Testing Recommendations - Create slots for future dates via schedule editor - Verify booking prevention for locked/full slots - Test capacity adjustments with existing bookings - Confirm template-based schedule generation - Verify transaction rollback on publish failures Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
177 lines
4.1 KiB
Vue
177 lines
4.1 KiB
Vue
<template>
|
|
<view class="page">
|
|
<!-- Stats row -->
|
|
<view class="stats-row">
|
|
<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="nav-grid">
|
|
<view
|
|
v-for="item in navItems"
|
|
:key="item.path"
|
|
class="nav-item"
|
|
@tap="navigate(item.path)"
|
|
>
|
|
<text class="nav-icon">{{ item.icon }}</text>
|
|
<text class="nav-label">{{ item.label }}</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted } from 'vue'
|
|
import { useAdminStore } from '../../stores/admin'
|
|
import type { AdminStats } from '../../stores/admin'
|
|
|
|
const adminStore = useAdminStore()
|
|
|
|
const statsLoading = ref(false)
|
|
const stats = ref<AdminStats>({ todayBookings: 0, totalOrders: 0, totalBookings: 0 })
|
|
|
|
const navItems = [
|
|
{ icon: '📅', label: '排课管理', path: '/pages/admin/schedule' },
|
|
{ icon: '📋', label: '排课模板', path: '/pages/admin/week-template' },
|
|
{ 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' },
|
|
]
|
|
|
|
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>
|
|
.page {
|
|
min-height: 100vh;
|
|
background: #1a1a2e;
|
|
padding-bottom: 60rpx;
|
|
}
|
|
|
|
/* ── Stats row ───────────────────────────── */
|
|
.stats-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-around;
|
|
background: rgba(255, 255, 255, 0.06);
|
|
margin: 24rpx 24rpx 32rpx;
|
|
border-radius: 20rpx;
|
|
padding: 32rpx 16rpx;
|
|
}
|
|
|
|
.stats-shimmer-wrap {
|
|
display: flex;
|
|
width: 100%;
|
|
justify-content: space-around;
|
|
align-items: center;
|
|
}
|
|
|
|
.stats-shimmer {
|
|
width: 120rpx;
|
|
height: 60rpx;
|
|
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;
|
|
}
|
|
|
|
@keyframes shimmer {
|
|
0% { background-position: 100% 0; }
|
|
100% { background-position: -100% 0; }
|
|
}
|
|
|
|
.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;
|
|
padding: 0 24rpx;
|
|
}
|
|
|
|
.nav-item {
|
|
background: rgba(255, 255, 255, 0.06);
|
|
border-radius: 20rpx;
|
|
padding: 40rpx 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 16rpx;
|
|
border: 1rpx solid rgba(201, 168, 124, 0.15);
|
|
|
|
&:active {
|
|
opacity: 0.7;
|
|
}
|
|
}
|
|
|
|
.nav-icon {
|
|
font-size: 56rpx;
|
|
}
|
|
|
|
.nav-label {
|
|
font-size: 28rpx;
|
|
font-weight: 600;
|
|
color: #ffffff;
|
|
letter-spacing: 1rpx;
|
|
}
|
|
</style>
|