feat(admin): implement full day-by-day schedule editor with live preview

## 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>
This commit is contained in:
richarjiang
2026-04-05 12:18:49 +08:00
parent 9c5dd4a911
commit b6986ba30c
29 changed files with 7810 additions and 19 deletions

View File

@@ -106,6 +106,8 @@ async function handleLogin() {
try {
await userStore.login()
await userStore.fetchMemberships()
// 登录成功后跳转到个人中心,让用户完善信息
uni.navigateTo({ url: '/pages/profile/info' })
} catch {
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
} finally {

View File

@@ -72,7 +72,7 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import type { UserProfileResponse, UserStatsResponse, MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus } from '@mp-pilates/shared'
@@ -91,6 +91,16 @@ const emit = defineEmits<{
const avatarFailed = ref(false)
// 头像 URL 变化时重置加载错误状态,避免新头像因偶发加载失败而被永久隐藏
watch(
() => props.user?.avatarUrl,
(newUrl, oldUrl) => {
if (newUrl && newUrl !== oldUrl) {
avatarFailed.value = false
}
},
)
const avatarSrc = computed(() => {
if (avatarFailed.value || !props.user?.avatarUrl) {
return '/static/default-avatar.png'

View File

@@ -49,10 +49,16 @@
"navigationBarTitleText": "管理中心"
}
},
{
"path": "pages/admin/schedule",
"style": {
"navigationBarTitleText": "排课管理"
}
},
{
"path": "pages/admin/week-template",
"style": {
"navigationBarTitleText": "排课设置"
"navigationBarTitleText": "排课模板"
}
},
{

View File

@@ -49,8 +49,8 @@ const statsLoading = ref(false)
const stats = ref<AdminStats>({ todayBookings: 0, totalOrders: 0, totalBookings: 0 })
const navItems = [
{ icon: '📅', label: '排课设置', path: '/pages/admin/week-template' },
{ icon: '🔧', label: '临时调整', path: '/pages/admin/slot-adjust' },
{ 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' },

View File

@@ -0,0 +1,755 @@
<template>
<view class="page">
<!-- Date selector -->
<view class="sticky-header">
<DateSelector v-model="selectedDate" @select="onDateSelect" />
</view>
<!-- Loading skeleton -->
<view v-if="loading" class="skeleton-list">
<view v-for="i in 4" :key="i" class="skeleton-item" />
</view>
<!-- Empty state -->
<view v-else-if="editableSlots.length === 0" class="empty-state">
<text class="empty-icon">📭</text>
<text class="empty-text">当日暂无排课</text>
<text class="empty-sub">无模板匹配请手动添加时段或先配置排课模板</text>
</view>
<!-- Slot list -->
<view v-else class="slot-list">
<view
v-for="slot in visibleSlots"
:key="slot.key"
class="slot-card"
:class="slotCardClass(slot)"
>
<!-- Status badge -->
<view class="slot-header">
<view class="slot-badge" :class="slotBadgeClass(slot)">
<text class="slot-badge-text">{{ slotBadgeText(slot) }}</text>
</view>
<view v-if="slot.bookedCount > 0" class="booked-info">
<text class="booked-text">{{ slot.bookedCount }} 人已预约</text>
</view>
</view>
<!-- Time display / edit -->
<view class="slot-body">
<view class="time-section">
<picker
mode="time"
:value="slot.startTime"
@change="(e: any) => updateSlotTime(slot, 'startTime', e.detail.value)"
>
<view class="time-display">
<text class="time-text">{{ slot.startTime }}</text>
</view>
</picker>
<text class="time-separator"></text>
<picker
mode="time"
:value="slot.endTime"
@change="(e: any) => updateSlotTime(slot, 'endTime', e.detail.value)"
>
<view class="time-display">
<text class="time-text">{{ slot.endTime }}</text>
</view>
</picker>
</view>
<view class="capacity-section">
<text class="capacity-label">容量</text>
<view class="capacity-control">
<view class="capacity-btn" @tap="adjustCapacity(slot, -1)">
<text class="capacity-btn-text"></text>
</view>
<text class="capacity-value">{{ slot.capacity }}</text>
<view class="capacity-btn" @tap="adjustCapacity(slot, 1)">
<text class="capacity-btn-text">+</text>
</view>
</view>
</view>
<view class="delete-section">
<view
class="delete-btn"
:class="{ 'delete-btn--warn': slot.bookedCount > 0 }"
@tap="removeSlot(slot)"
>
<text class="delete-btn-text"></text>
</view>
</view>
</view>
</view>
</view>
<!-- Add slot button -->
<view class="add-wrap" @tap="openAddModal">
<text class="add-text"> 添加时段</text>
</view>
<!-- Bottom action bar -->
<view class="action-bar">
<view
class="publish-btn"
:class="{ 'publish-btn--loading': publishing }"
@tap="handlePublish"
>
<text class="publish-btn-text">
{{ publishing ? '发布中...' : (hasPublished ? '更新当日排课' : '发布当日排课') }}
</text>
</view>
</view>
<!-- Add slot modal -->
<view v-if="showAddModal" class="modal-mask" @tap="onMaskTap">
<view class="modal" @tap.stop>
<text class="modal-title">添加时段</text>
<view class="modal-field">
<text class="modal-label">开始时间</text>
<picker
mode="time"
:value="addForm.startTime"
@change="onAddStartTimeChange"
>
<view class="picker-display">
<text class="picker-text">{{ addForm.startTime }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="modal-field">
<text class="modal-label">结束时间</text>
<view class="picker-display picker-display--disabled">
<text class="picker-text picker-text--muted">{{ addForm.endTime }}</text>
</view>
</view>
<view class="modal-field modal-field--last">
<text class="modal-label">容量</text>
<input
class="modal-input"
type="number"
v-model="addForm.capacityStr"
placeholder="如1"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-actions">
<view class="modal-cancel" @tap="closeAddModal">
<text class="modal-cancel-text">取消</text>
</view>
<view class="modal-confirm" @tap="submitAdd">
<text class="modal-confirm-text">确认</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { ScheduleSlotPreview } from '@mp-pilates/shared'
import { useAdminStore } from '../../stores/admin'
import { formatDate } from '../../utils/format'
import DateSelector from '../../components/DateSelector.vue'
interface EditableSlot {
readonly key: string
existingSlotId: string | null
startTime: string
endTime: string
capacity: number
bookedCount: number
isPublished: boolean
isNew: boolean
isRemoved: boolean
templateId: string | null
}
const adminStore = useAdminStore()
const selectedDate = ref(formatDate(new Date()))
const loading = ref(false)
const publishing = ref(false)
const showAddModal = ref(false)
const editableSlots = ref<EditableSlot[]>([])
const addForm = ref({
startTime: '09:00',
endTime: '10:00',
capacityStr: '1',
})
// ── Computed ──────────────────────────────────────────────
const visibleSlots = computed(() =>
editableSlots.value.filter((s) => !s.isRemoved),
)
const hasPublished = computed(() =>
editableSlots.value.some((s) => s.isPublished),
)
// ── Data loading ──────────────────────────────────────────
function mapPreviewToEditable(previews: readonly ScheduleSlotPreview[]): EditableSlot[] {
return previews.map((p) => ({
key: p.id ?? `tpl-${p.templateId}-${p.startTime}`,
existingSlotId: p.id,
startTime: p.startTime,
endTime: p.endTime,
capacity: p.capacity,
bookedCount: p.bookedCount,
isPublished: p.isPublished,
isNew: false,
isRemoved: false,
templateId: p.templateId,
}))
}
async function loadPreview(date: string) {
loading.value = true
try {
const previews = await adminStore.fetchSchedulePreview(date)
editableSlots.value = mapPreviewToEditable(previews)
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
editableSlots.value = []
} finally {
loading.value = false
}
}
function onDateSelect(date: string) {
selectedDate.value = date
loadPreview(date)
}
// ── Slot editing ──────────────────────────────────────────
function updateSlotTime(slot: EditableSlot, field: 'startTime' | 'endTime', value: string) {
slot[field] = value
}
function adjustCapacity(slot: EditableSlot, delta: number) {
const minCapacity = Math.max(1, slot.bookedCount)
const newVal = slot.capacity + delta
if (newVal >= minCapacity) {
slot.capacity = newVal
}
}
function removeSlot(slot: EditableSlot) {
if (slot.bookedCount > 0) {
uni.showModal({
title: '该时段有预约',
content: `已有 ${slot.bookedCount} 人预约此时段,移除后该时段将被关闭(已有预约保留)。确认移除?`,
confirmText: '确认移除',
confirmColor: '#c0392b',
success: (res) => {
if (res.confirm) {
slot.isRemoved = true
}
},
})
} else {
slot.isRemoved = true
}
}
// ── Add slot ──────────────────────────────────────────────
/** 将 "HH:mm" 加一小时,最大 23:59 */
function addOneHour(time: string): string {
const [h, m] = time.split(':').map(Number)
const newH = Math.min(h + 1, 23)
// 如果原本就是 23:xx结束时间设为 23:59
if (h >= 23) return '23:59'
return String(newH).padStart(2, '0') + ':' + String(m).padStart(2, '0')
}
function onAddStartTimeChange(e: any) {
const start = e.detail.value as string
addForm.value.startTime = start
addForm.value.endTime = addOneHour(start)
}
function openAddModal() {
addForm.value = { startTime: '09:00', endTime: '10:00', capacityStr: '1' }
showAddModal.value = true
}
function closeAddModal() {
showAddModal.value = false
}
/** 点击遮罩关闭弹窗 — tap.stop 在 modal 上阻止了内部点击冒泡到此 */
function onMaskTap() {
closeAddModal()
}
function submitAdd() {
const capacity = parseInt(addForm.value.capacityStr, 10)
if (!addForm.value.startTime || !addForm.value.endTime) {
uni.showToast({ title: '请选择时间', icon: 'none' })
return
}
if (isNaN(capacity) || capacity < 1) {
uni.showToast({ title: '请填写有效容量', icon: 'none' })
return
}
editableSlots.value.push({
key: `new-${Date.now()}`,
existingSlotId: null,
startTime: addForm.value.startTime,
endTime: addForm.value.endTime,
capacity,
bookedCount: 0,
isPublished: false,
isNew: true,
isRemoved: false,
templateId: null,
})
closeAddModal()
}
// ── Publish ───────────────────────────────────────────────
async function handlePublish() {
if (publishing.value) return
const slotsToPublish = visibleSlots.value
if (slotsToPublish.length === 0) {
uni.showModal({
title: '提示',
content: '当前没有时段,确认清空当日排课?',
success: async (res) => {
if (res.confirm) {
await doPublish([])
}
},
})
return
}
// Validate times
for (const slot of slotsToPublish) {
if (slot.startTime >= slot.endTime) {
uni.showToast({ title: `时段 ${slot.startTime}-${slot.endTime} 时间无效`, icon: 'none' })
return
}
}
uni.showModal({
title: '确认发布',
content: `确认${hasPublished.value ? '更新' : '发布'} ${selectedDate.value} 的排课?共 ${slotsToPublish.length} 个时段`,
success: async (res) => {
if (res.confirm) {
await doPublish(slotsToPublish)
}
},
})
}
async function doPublish(slots: readonly EditableSlot[]) {
publishing.value = true
try {
await adminStore.publishDaySlots({
date: selectedDate.value,
slots: slots.map((s) => ({
existingSlotId: s.existingSlotId ?? undefined,
startTime: s.startTime,
endTime: s.endTime,
capacity: s.capacity,
})),
})
uni.showToast({ title: '发布成功', icon: 'success' })
// Reload to show fresh state
editableSlots.value = mapPreviewToEditable(adminStore.schedulePreview)
} catch (e: unknown) {
const message = e instanceof Error ? e.message : '发布失败'
uni.showToast({ title: message, icon: 'none' })
} finally {
publishing.value = false
}
}
// ── Style helpers ─────────────────────────────────────────
function slotCardClass(slot: EditableSlot): string {
if (slot.isNew) return 'slot-card--new'
if (slot.isPublished) return 'slot-card--published'
return 'slot-card--template'
}
function slotBadgeClass(slot: EditableSlot): string {
if (slot.isNew) return 'badge--new'
if (slot.isPublished) return 'badge--published'
return 'badge--template'
}
function slotBadgeText(slot: EditableSlot): string {
if (slot.isNew) return '新增'
if (slot.isPublished) return '已发布'
return '来自模板'
}
// ── Lifecycle ─────────────────────────────────────────────
onMounted(() => loadPreview(selectedDate.value))
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f3f0;
padding-bottom: 180rpx;
}
/* ── Sticky header ───────────────────────── */
.sticky-header {
position: sticky;
top: 0;
z-index: 100;
background: #fff;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
/* ── Loading skeleton ────────────────────── */
.skeleton-list {
padding: 24rpx;
}
.skeleton-item {
height: 160rpx;
border-radius: 20rpx;
margin-bottom: 20rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
/* ── Empty state ─────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 100rpx 40rpx;
gap: 16rpx;
}
.empty-icon { font-size: 80rpx; }
.empty-text { font-size: 30rpx; color: #666; font-weight: 600; }
.empty-sub { font-size: 24rpx; color: #bbb; text-align: center; }
/* ── Slot list ───────────────────────────── */
.slot-list {
padding: 24rpx 24rpx 0;
display: flex;
flex-direction: column;
gap: 20rpx;
}
/* ── Slot card ───────────────────────────── */
.slot-card {
background: #ffffff;
border-radius: 20rpx;
padding: 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
border: 2rpx solid transparent;
&--published {
border-color: rgba(39, 174, 96, 0.3);
}
&--template {
border-style: dashed;
border-color: #c9a87c;
background: rgba(201, 168, 124, 0.04);
}
&--new {
border-style: dashed;
border-color: #3498db;
background: rgba(52, 152, 219, 0.04);
}
}
/* ── Slot header ─────────────────────────── */
.slot-header {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 16rpx;
}
.slot-badge {
border-radius: 16rpx;
padding: 4rpx 16rpx;
}
.badge--published { background: rgba(39, 174, 96, 0.1); }
.badge--published .slot-badge-text { font-size: 22rpx; color: #27ae60; font-weight: 600; }
.badge--template { background: rgba(201, 168, 124, 0.15); }
.badge--template .slot-badge-text { font-size: 22rpx; color: #b8860b; font-weight: 600; }
.badge--new { background: rgba(52, 152, 219, 0.1); }
.badge--new .slot-badge-text { font-size: 22rpx; color: #3498db; font-weight: 600; }
.booked-info { }
.booked-text { font-size: 22rpx; color: #e67e22; }
/* ── Slot body ───────────────────────────── */
.slot-body {
display: flex;
align-items: center;
gap: 16rpx;
}
.time-section {
display: flex;
align-items: center;
gap: 8rpx;
flex: 1;
}
.time-display {
background: #f7f4f0;
border-radius: 12rpx;
padding: 12rpx 20rpx;
}
.time-text {
font-size: 32rpx;
font-weight: 700;
color: #1a1a2e;
}
.time-separator {
font-size: 28rpx;
color: #999;
}
.capacity-section {
display: flex;
align-items: center;
gap: 8rpx;
}
.capacity-label {
font-size: 22rpx;
color: #888;
}
.capacity-control {
display: flex;
align-items: center;
gap: 4rpx;
}
.capacity-btn {
width: 48rpx;
height: 48rpx;
border-radius: 24rpx;
background: #f0ece8;
display: flex;
align-items: center;
justify-content: center;
&:active { opacity: 0.6; }
}
.capacity-btn-text {
font-size: 28rpx;
font-weight: 700;
color: #1a1a2e;
line-height: 1;
}
.capacity-value {
font-size: 28rpx;
font-weight: 700;
color: #1a1a2e;
min-width: 40rpx;
text-align: center;
}
.delete-section {
margin-left: 8rpx;
}
.delete-btn {
width: 48rpx;
height: 48rpx;
border-radius: 24rpx;
background: rgba(192, 57, 43, 0.08);
display: flex;
align-items: center;
justify-content: center;
&--warn {
background: rgba(192, 57, 43, 0.2);
}
&:active { opacity: 0.6; }
}
.delete-btn-text {
font-size: 24rpx;
color: #c0392b;
font-weight: 700;
}
/* ── Add button ──────────────────────────── */
.add-wrap {
margin: 24rpx;
padding: 24rpx;
border: 2rpx dashed #c9a87c;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
&:active { opacity: 0.6; }
}
.add-text {
font-size: 28rpx;
font-weight: 600;
color: #c9a87c;
}
/* ── Action bar ──────────────────────────── */
.action-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 20rpx 24rpx 48rpx;
background: #ffffff;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.08);
}
.publish-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; }
&:active { opacity: 0.85; }
}
.publish-btn-text {
font-size: 30rpx;
font-weight: 700;
color: #c9a87c;
}
/* ── Modal ───────────────────────────────── */
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
z-index: 200;
}
.modal {
width: 100%;
background: #ffffff;
border-radius: 24rpx 24rpx 0 0;
padding: 40rpx 32rpx 60rpx;
}
.modal-title {
font-size: 32rpx;
font-weight: 700;
color: #1a1a2e;
display: block;
margin-bottom: 24rpx;
}
.modal-field {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 0;
border-bottom: 1rpx solid #f5f5f5;
&--last { border-bottom: none; }
}
.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;
}
.picker-text { font-size: 26rpx; color: #222; }
.picker-text--muted { color: #999; }
.picker-arrow { font-size: 26rpx; color: #bbb; }
.picker-display--disabled { opacity: 0.6; }
.modal-actions {
display: flex;
gap: 16rpx;
margin-top: 32rpx;
}
.modal-cancel {
flex: 1;
height: 88rpx;
background: #f0f0f0;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
}
.modal-cancel-text { font-size: 28rpx; color: #555; }
.modal-confirm {
flex: 2;
height: 88rpx;
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
}
.modal-confirm-text { font-size: 28rpx; font-weight: 700; color: #c9a87c; }
</style>

View File

@@ -163,9 +163,9 @@ const dayOptions = [1, 2, 3, 4, 5, 6, 7].map((d) => ({ label: WEEKDAY_LABELS[d],
const form = ref({
dayIdx: 0,
startTime: '09:00',
endTime: '10:00',
capacityStr: '10',
startTime: '08:00',
endTime: '09:00',
capacityStr: '1',
})
const grouped = computed(() => {
@@ -180,11 +180,38 @@ const grouped = computed(() => {
)
})
/** 生成默认模板周一到周日8:00-22:00 每小时一个时段 */
function generateDefaultTemplates(): LocalTemplate[] {
const defaults: LocalTemplate[] = []
for (let day = 1; day <= 7; day++) {
for (let hour = 8; hour < 22; hour++) {
const start = String(hour).padStart(2, '0') + ':00'
const end = String(hour + 1).padStart(2, '0') + ':00'
defaults.push({
_key: `default-${day}-${start}`,
dayOfWeek: day,
startTime: start,
endTime: end,
capacity: 1,
isActive: true,
})
}
}
return defaults
}
async function fetchTemplates() {
loading.value = true
try {
templates.value = await adminStore.fetchWeekTemplates()
isDirty.value = false
const data = await adminStore.fetchWeekTemplates()
if (data.length === 0) {
// No templates yet — pre-fill with defaults
templates.value = generateDefaultTemplates()
isDirty.value = true
} else {
templates.value = data
isDirty.value = false
}
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
@@ -194,7 +221,7 @@ async function fetchTemplates() {
function openAdd() {
editTarget.value = null
form.value = { dayIdx: 0, startTime: '09:00', endTime: '10:00', capacityStr: '10' }
form.value = { dayIdx: 0, startTime: '08:00', endTime: '09:00', capacityStr: '1' }
showModal.value = true
}

View File

@@ -5,9 +5,9 @@
<button class="avatar-btn" open-type="chooseAvatar" @chooseavatar="handleChooseAvatar">
<view class="avatar-wrap">
<image
v-if="avatarUrl"
v-if="displayAvatarUrl"
class="avatar"
:src="avatarUrl"
:src="displayAvatarUrl"
mode="aspectFill"
/>
<view v-else class="avatar-placeholder">
@@ -125,6 +125,17 @@ const activeMembershipCount = computed(
() => userStore.user?.activeMembershipCount ?? userStore.activeMemberships.length,
)
// ─── Default avatar ───────────────────────────────────────
const defaultAvatarUrl = computed(() => {
const nickname = form.value.nickname || 'user'
// 使用 dicebear 生成基于昵称的随机头像
return `https://api.dicebear.com/7.x/identicon/svg?seed=${encodeURIComponent(nickname)}&backgroundColor=c9a87c,e8c88a`
})
const displayAvatarUrl = computed(() => {
return avatarUrl.value || defaultAvatarUrl.value
})
// ─── Avatar upload ────────────────────────────────────────
async function handleChooseAvatar(e: { detail: { avatarUrl: string } }) {
const { avatarUrl } = e.detail

View File

@@ -13,6 +13,8 @@ import type {
TimeSlot,
CreateManualSlotDto,
PaginatedData,
ScheduleSlotPreview,
PublishDaySlotsDto,
} from '@mp-pilates/shared'
export interface AdminStats {
@@ -42,7 +44,7 @@ export const useAdminStore = defineStore('admin', () => {
}
async function saveWeekTemplates(templates: WeekTemplateInput[]): Promise<WeekTemplate[]> {
const data = await put<WeekTemplate[]>('/admin/week-template', templates)
const data = await put<WeekTemplate[]>('/admin/week-template', { templates })
weekTemplates.value = data
return data
}
@@ -132,6 +134,26 @@ export const useAdminStore = defineStore('admin', () => {
return post<{ count: number }>('/admin/generate-slots', { startDate, endDate })
}
// ── Schedule management ─────────────────────────────────────────
const schedulePreview = ref<ScheduleSlotPreview[]>([])
const scheduleLoading = ref(false)
async function fetchSchedulePreview(date: string): Promise<ScheduleSlotPreview[]> {
scheduleLoading.value = true
try {
const data = await get<ScheduleSlotPreview[]>('/admin/schedule/preview', { date })
schedulePreview.value = data
return data
} finally {
scheduleLoading.value = false
}
}
async function publishDaySlots(dto: PublishDaySlotsDto): Promise<void> {
await post('/admin/schedule/publish', dto as unknown as Record<string, unknown>)
await fetchSchedulePreview(dto.date)
}
// ── Dashboard stats ──────────────────────────────────────────────
async function fetchDashboardStats(): Promise<AdminStats> {
return get<AdminStats>('/admin/stats')
@@ -142,6 +164,8 @@ export const useAdminStore = defineStore('admin', () => {
weekTemplates,
cardTypes,
studioConfig,
schedulePreview,
scheduleLoading,
// Week templates
fetchWeekTemplates,
saveWeekTemplates,
@@ -164,6 +188,9 @@ export const useAdminStore = defineStore('admin', () => {
createManualSlot,
closeSlot,
generateSlots,
// Schedule
fetchSchedulePreview,
publishDaySlots,
// Stats
fetchDashboardStats,
}

View File

@@ -0,0 +1,37 @@
import {
IsString,
IsOptional,
IsInt,
IsArray,
IsDateString,
Min,
ValidateNested,
} from 'class-validator'
import { Type } from 'class-transformer'
export class PublishDaySlotItemDto {
@IsOptional()
@IsString()
readonly existingSlotId?: string
@IsString()
readonly startTime!: string
@IsString()
readonly endTime!: string
@IsInt()
@Min(1)
@Type(() => Number)
readonly capacity!: number
}
export class PublishDaySlotsDto {
@IsDateString()
readonly date!: string
@IsArray()
@ValidateNested({ each: true })
@Type(() => PublishDaySlotItemDto)
readonly slots!: PublishDaySlotItemDto[]
}

View File

@@ -20,6 +20,7 @@ import { SlotGeneratorService } from './slot-generator.service'
import { QuerySlotsDto } from './dto/query-slots.dto'
import { CreateManualSlotDto } from './dto/create-manual-slot.dto'
import { UpdateWeekTemplateDto } from './dto/week-template.dto'
import { PublishDaySlotsDto } from './dto/publish-day-slots.dto'
// ---------------------------------------------------------------------------
// Member endpoints
@@ -89,4 +90,17 @@ export class AdminTimeSlotController {
generateSlots() {
return this.slotGeneratorService.generateSlots()
}
// Schedule preview & publish
@Get('schedule/preview')
getSchedulePreview(@Query('date') date: string) {
return this.timeSlotService.getSchedulePreview(date)
}
@Post('schedule/publish')
@HttpCode(HttpStatus.OK)
publishDaySlots(@Body() dto: PublishDaySlotsDto) {
return this.timeSlotService.publishDaySlots(dto)
}
}

View File

@@ -1,9 +1,10 @@
import { Injectable, NotFoundException } from '@nestjs/common'
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'
import { TimeSlotStatus, BookingStatus, DEFAULT_SLOT_CAPACITY } from '@mp-pilates/shared'
import { TimeSlotSource } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import type { TimeSlotWithBookingStatus } from '@mp-pilates/shared'
import type { TimeSlotWithBookingStatus, ScheduleSlotPreview } from '@mp-pilates/shared'
import type { CreateManualSlotDto } from './dto/create-manual-slot.dto'
import type { PublishDaySlotsDto } from './dto/publish-day-slots.dto'
@Injectable()
export class TimeSlotService {
@@ -125,7 +126,7 @@ export class TimeSlotService {
return this.prisma.$transaction(async (tx) => {
await tx.weekTemplate.deleteMany()
const created = await tx.weekTemplate.createMany({
await tx.weekTemplate.createMany({
data: items.map((item) => ({
dayOfWeek: item.dayOfWeek,
startTime: item.startTime,
@@ -135,7 +136,166 @@ export class TimeSlotService {
})),
})
return created
return tx.weekTemplate.findMany({
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
})
})
}
// ── Schedule preview & publish ──────────────────────────────
/** Convert JS getDay() (0=Sun … 6=Sat) to ISO weekday (1=Mon … 7=Sun) */
private toIsoWeekday(jsDay: number): number {
return jsDay === 0 ? 7 : jsDay
}
/**
* Return a schedule preview for a given date.
* If TimeSlot records already exist → return them (isPublished: true).
* Otherwise → derive from active WeekTemplates (isPublished: false).
*/
async getSchedulePreview(date: string): Promise<ScheduleSlotPreview[]> {
const parsedDate = new Date(date)
const startOfDay = new Date(parsedDate)
startOfDay.setUTCHours(0, 0, 0, 0)
const endOfDay = new Date(parsedDate)
endOfDay.setUTCHours(23, 59, 59, 999)
// 1. Check for existing TimeSlot records (all statuses)
const existingSlots = await this.prisma.timeSlot.findMany({
where: {
date: { gte: startOfDay, lte: endOfDay },
},
orderBy: { startTime: 'asc' },
})
if (existingSlots.length > 0) {
return existingSlots.map((slot) => ({
id: slot.id,
date: date,
startTime: slot.startTime,
endTime: slot.endTime,
capacity: slot.capacity,
bookedCount: slot.bookedCount,
status: slot.status as TimeSlotStatus,
source: slot.source as TimeSlotSource,
templateId: slot.templateId,
isPublished: true,
}))
}
// 2. No existing slots — derive from WeekTemplate
const isoWeekday = this.toIsoWeekday(parsedDate.getUTCDay())
const templates = await this.prisma.weekTemplate.findMany({
where: { dayOfWeek: isoWeekday, isActive: true },
orderBy: { startTime: 'asc' },
})
return templates.map((tpl) => ({
id: null,
date: date,
startTime: tpl.startTime,
endTime: tpl.endTime,
capacity: tpl.capacity,
bookedCount: 0,
status: TimeSlotStatus.OPEN,
source: TimeSlotSource.TEMPLATE,
templateId: tpl.id,
isPublished: false,
}))
}
/**
* Publish (create/update/remove) time slots for a specific date.
* - Slots with existingSlotId → update
* - New slots → create
* - Existing DB slots not referenced → delete (or CLOSE if they have bookings)
*/
async publishDaySlots(dto: PublishDaySlotsDto) {
const parsedDate = new Date(dto.date)
parsedDate.setUTCHours(0, 0, 0, 0)
const startOfDay = new Date(parsedDate)
const endOfDay = new Date(parsedDate)
endOfDay.setUTCHours(23, 59, 59, 999)
return this.prisma.$transaction(async (tx) => {
// 1. Get existing slots for this date
const existing = await tx.timeSlot.findMany({
where: { date: { gte: startOfDay, lte: endOfDay } },
})
const existingMap = new Map(existing.map((s) => [s.id, s]))
const keptIds = new Set<string>()
const results: Array<{
id: string
date: Date
startTime: string
endTime: string
capacity: number
bookedCount: number
status: string
source: string
}> = []
// 2. Process each slot in the request
for (const item of dto.slots) {
if (item.existingSlotId && existingMap.has(item.existingSlotId)) {
// Update existing slot
const existingSlot = existingMap.get(item.existingSlotId)!
const safeCapacity = Math.max(item.capacity, existingSlot.bookedCount)
const updated = await tx.timeSlot.update({
where: { id: item.existingSlotId },
data: {
startTime: item.startTime,
endTime: item.endTime,
capacity: safeCapacity,
},
})
keptIds.add(item.existingSlotId)
results.push(updated)
} else {
// Create new slot
const created = await tx.timeSlot.create({
data: {
date: parsedDate,
startTime: item.startTime,
endTime: item.endTime,
capacity: item.capacity,
source: TimeSlotSource.MANUAL,
status: TimeSlotStatus.OPEN,
},
})
results.push(created)
}
}
// 3. Handle orphaned existing slots (not in request)
for (const slot of existing) {
if (!keptIds.has(slot.id)) {
if (slot.bookedCount > 0) {
// Has bookings → close instead of delete
await tx.timeSlot.update({
where: { id: slot.id },
data: { status: TimeSlotStatus.CLOSED },
})
} else {
await tx.timeSlot.delete({ where: { id: slot.id } })
}
}
}
return results.map((slot) => ({
id: slot.id,
date: slot.date.toISOString().split('T')[0],
startTime: slot.startTime,
endTime: slot.endTime,
capacity: slot.capacity,
bookedCount: slot.bookedCount,
status: slot.status,
source: slot.source,
}))
})
}
}

File diff suppressed because one or more lines are too long

View File

@@ -35,6 +35,9 @@ export type {
TimeSlot,
TimeSlotWithBookingStatus,
CreateManualSlotDto,
ScheduleSlotPreview,
PublishDaySlotItem,
PublishDaySlotsDto,
Booking,
BookingWithDetails,
CreateBookingDto,

View File

@@ -2,7 +2,7 @@ export type { User, UserProfileResponse, UpdateProfileDto, UserStatsResponse } f
export type { CardType, CreateCardTypeDto, UpdateCardTypeDto } from './card-type'
export type { Membership, MembershipWithCardType } from './membership'
export type { WeekTemplate, WeekTemplateInput } from './week-template'
export type { TimeSlot, TimeSlotWithBookingStatus, CreateManualSlotDto } from './time-slot'
export type { TimeSlot, TimeSlotWithBookingStatus, CreateManualSlotDto, ScheduleSlotPreview, PublishDaySlotItem, PublishDaySlotsDto } from './time-slot'
export type { Booking, BookingWithDetails, CreateBookingDto } from './booking'
export type { Order, OrderWithDetails, CreateOrderDto, PaymentParams, CreateOrderResponse } from './order'
export type { StudioConfig, UpdateStudioConfigDto } from './studio'

View File

@@ -27,3 +27,35 @@ export interface CreateManualSlotDto {
readonly endTime: string
readonly capacity?: number
}
/** 排课预览项(已发布的 TimeSlot 或模板派生的预览) */
export interface ScheduleSlotPreview {
/** 已发布则有 ID模板预览为 null */
readonly id: string | null
readonly date: string
readonly startTime: string
readonly endTime: string
readonly capacity: number
readonly bookedCount: number
readonly status: TimeSlotStatus
readonly source: TimeSlotSource
readonly templateId: string | null
/** true = DB 中已有 TimeSlot 记录 */
readonly isPublished: boolean
}
/** 发布某天排课时的单个时段 */
export interface PublishDaySlotItem {
/** 保留/修改已有时段时传入 */
readonly existingSlotId?: string
readonly startTime: string
readonly endTime: string
readonly capacity: number
}
/** 发布某天排课的请求体 */
export interface PublishDaySlotsDto {
/** YYYY-MM-DD */
readonly date: string
readonly slots: readonly PublishDaySlotItem[]
}