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:
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -49,10 +49,16 @@
|
||||
"navigationBarTitleText": "管理中心"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/admin/schedule",
|
||||
"style": {
|
||||
"navigationBarTitleText": "排课管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/admin/week-template",
|
||||
"style": {
|
||||
"navigationBarTitleText": "排课设置"
|
||||
"navigationBarTitleText": "排课模板"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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' },
|
||||
|
||||
755
packages/app/src/pages/admin/schedule.vue
Normal file
755
packages/app/src/pages/admin/schedule.vue
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
37
packages/server/src/time-slot/dto/publish-day-slots.dto.ts
Normal file
37
packages/server/src/time-slot/dto/publish-day-slots.dto.ts
Normal 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[]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -35,6 +35,9 @@ export type {
|
||||
TimeSlot,
|
||||
TimeSlotWithBookingStatus,
|
||||
CreateManualSlotDto,
|
||||
ScheduleSlotPreview,
|
||||
PublishDaySlotItem,
|
||||
PublishDaySlotsDto,
|
||||
Booking,
|
||||
BookingWithDetails,
|
||||
CreateBookingDto,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user