feat: 优化排课管理

This commit is contained in:
richarjiang
2026-04-15 23:25:09 +08:00
parent 6ab16f508a
commit 4dacd908a6
14 changed files with 71 additions and 765 deletions

View File

@@ -75,12 +75,6 @@
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{
"path": "pages/admin/week-template",
"style": {
"navigationStyle": "custom"
}
},
{ {
"path": "pages/admin/slot-adjust", "path": "pages/admin/slot-adjust",
"style": { "style": {

View File

@@ -63,21 +63,6 @@
<text class="arrow-text"></text> <text class="arrow-text"></text>
</view> </view>
</view> </view>
<view class="list-item" @tap="navigate('/pages/admin/week-template')">
<view class="item-left">
<view class="item-icon-wrap icon--template">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">排课模板</text>
<text class="item-desc">设置每周课程模板</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text>
</view>
</view>
</view> </view>
<!-- Section header: 会员与订单 --> <!-- Section header: 会员与订单 -->

View File

@@ -15,7 +15,7 @@
<view v-else-if="editableSlots.length === 0" class="empty-state"> <view v-else-if="editableSlots.length === 0" class="empty-state">
<text class="empty-icon">📭</text> <text class="empty-icon">📭</text>
<text class="empty-text">当日暂无排课</text> <text class="empty-text">当日暂无排课</text>
<text class="empty-sub">无模板匹配请手动添加时段或先配置排课模板</text> <text class="empty-sub">当日暂无默认时段请点击下方按钮手动添加</text>
</view> </view>
<!-- Slot list --> <!-- Slot list -->
@@ -404,7 +404,7 @@ function slotBadgeClass(slot: EditableSlot): string {
function slotBadgeText(slot: EditableSlot): string { function slotBadgeText(slot: EditableSlot): string {
if (slot.isNew) return '新增' if (slot.isNew) return '新增'
if (slot.isPublished) return '已发布' if (slot.isPublished) return '已发布'
return '来自模板' return '默认时段'
} }
// ── Lifecycle ───────────────────────────────────────────── // ── Lifecycle ─────────────────────────────────────────────

View File

@@ -128,7 +128,7 @@
</picker> </picker>
</view> </view>
</view> </view>
<text class="gen-hint">根据排课模板自动生成所选日期范围内的时段</text> <text class="gen-hint">按默认时间表每天 8:00-22:00每小时一节自动生成所选日期范围内的时段</text>
<view class="action-wrap"> <view class="action-wrap">
<view class="action-btn" :class="{ 'action-btn--loading': submitting }" @tap="submitGenerate"> <view class="action-btn" :class="{ 'action-btn--loading': submitting }" @tap="submitGenerate">
<text class="action-btn-text">{{ submitting ? '生成中...' : '批量生成' }}</text> <text class="action-btn-text">{{ submitting ? '生成中...' : '批量生成' }}</text>

View File

@@ -1,528 +0,0 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="排课模板" show-back />
<!-- Toolbar -->
<view class="toolbar">
<text class="toolbar-hint"> {{ templates.length }} 条模板</text>
<view class="add-btn" @tap="openAdd">
<text class="add-btn-text"> 新增时段</text>
</view>
</view>
<!-- Loading skeleton -->
<view v-if="loading" class="skeleton-list">
<view v-for="i in 5" :key="i" class="skeleton-item" />
</view>
<!-- Empty -->
<view v-else-if="!templates.length" class="empty-state">
<text class="empty-icon">📅</text>
<text class="empty-text">暂无模板点击右上角新增</text>
</view>
<!-- Template list grouped by weekday -->
<view v-else>
<view v-for="(group, day) in grouped" :key="day" class="day-group">
<view class="day-header">
<text class="day-label">{{ WEEKDAY_LABELS[Number(day)] }}</text>
<text class="day-count">{{ group.length }} 个时段</text>
</view>
<view
v-for="tpl in group"
:key="tpl.id ?? tpl._key"
class="tpl-row"
:class="{ 'tpl-row--inactive': !tpl.isActive }"
>
<view class="tpl-time">
<text class="tpl-time-text">{{ tpl.startTime }} {{ tpl.endTime }}</text>
<text class="tpl-capacity">{{ tpl.capacity }} </text>
</view>
<view class="tpl-actions">
<view
class="tpl-toggle"
:class="tpl.isActive ? 'toggle--on' : 'toggle--off'"
@tap="toggleTemplate(tpl)"
>
<text class="tpl-toggle-text">{{ tpl.isActive ? '启用' : '停用' }}</text>
</view>
<view class="tpl-edit" @tap="openEdit(tpl)">
<text class="tpl-edit-text">编辑</text>
</view>
<view class="tpl-delete" @tap="deleteTemplate(tpl)">
<text class="tpl-delete-text">删除</text>
</view>
</view>
</view>
</view>
</view>
<!-- Save bar -->
<view v-if="isDirty" class="save-bar">
<view class="save-btn" :class="{ 'save-btn--loading': saving }" @tap="handleSave">
<text class="save-btn-text">{{ saving ? '保存中...' : '保存全部更改' }}</text>
</view>
</view>
<!-- Add / Edit modal -->
<view v-if="showModal" class="modal-mask" @tap.self="closeModal">
<view class="modal">
<text class="modal-title">{{ editTarget ? '编辑时段' : '新增时段' }}</text>
<view class="modal-field">
<text class="modal-label">星期</text>
<picker
mode="selector"
:range="dayOptions"
range-key="label"
:value="form.dayIdx"
@change="(e: any) => form.dayIdx = Number(e.detail.value)"
>
<view class="picker-display">
<text class="picker-text">{{ dayOptions[form.dayIdx].label }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="modal-field">
<text class="modal-label">开始时间</text>
<picker
mode="time"
:value="form.startTime"
@change="(e: any) => form.startTime = e.detail.value"
>
<view class="picker-display">
<text class="picker-text">{{ form.startTime || '请选择' }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="modal-field">
<text class="modal-label">结束时间</text>
<picker
mode="time"
:value="form.endTime"
@change="(e: any) => form.endTime = e.detail.value"
>
<view class="picker-display">
<text class="picker-text">{{ form.endTime || '请选择' }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="modal-field modal-field--last">
<text class="modal-label">容量</text>
<input
class="modal-input"
type="number"
v-model="form.capacityStr"
placeholder="如10"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-actions">
<view class="modal-cancel" @tap="closeModal">
<text class="modal-cancel-text">取消</text>
</view>
<view class="modal-confirm" @tap="submitForm">
<text class="modal-confirm-text">确认</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin'
import { WEEKDAY_LABELS } from '@mp-pilates/shared'
import type { WeekTemplate } from '@mp-pilates/shared'
type LocalTemplate = Partial<WeekTemplate> & {
_key?: string
dayOfWeek: number
startTime: string
endTime: string
capacity: number
isActive: boolean
}
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
const loading = ref(false)
const saving = ref(false)
const isDirty = ref(false)
const showModal = ref(false)
const editTarget = ref<LocalTemplate | null>(null)
const templates = ref<LocalTemplate[]>([])
const dayOptions = [1, 2, 3, 4, 5, 6, 7].map((d) => ({ label: WEEKDAY_LABELS[d], value: d }))
const form = ref({
dayIdx: 0,
startTime: '08:00',
endTime: '09:00',
capacityStr: '1',
})
const grouped = computed(() => {
const map: Record<number, LocalTemplate[]> = {}
for (const tpl of templates.value) {
if (!map[tpl.dayOfWeek]) map[tpl.dayOfWeek] = []
map[tpl.dayOfWeek].push(tpl)
}
// Sort by day
return Object.fromEntries(
Object.entries(map).sort(([a], [b]) => Number(a) - Number(b)),
)
})
/** 生成默认模板周一到周日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 {
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 {
loading.value = false
}
}
function openAdd() {
editTarget.value = null
form.value = { dayIdx: 0, startTime: '08:00', endTime: '09:00', capacityStr: '1' }
showModal.value = true
}
function openEdit(tpl: LocalTemplate) {
editTarget.value = tpl
const dayIdx = dayOptions.findIndex((d) => d.value === tpl.dayOfWeek)
form.value = {
dayIdx: dayIdx >= 0 ? dayIdx : 0,
startTime: tpl.startTime,
endTime: tpl.endTime,
capacityStr: String(tpl.capacity),
}
showModal.value = true
}
function closeModal() {
showModal.value = false
editTarget.value = null
}
function submitForm() {
const capacity = parseInt(form.value.capacityStr, 10)
if (!form.value.startTime || !form.value.endTime) {
uni.showToast({ title: '请填写时间', icon: 'none' })
return
}
if (isNaN(capacity) || capacity < 1) {
uni.showToast({ title: '请填写有效容量', icon: 'none' })
return
}
const day = dayOptions[form.value.dayIdx].value
if (editTarget.value) {
const tpl = editTarget.value
tpl.dayOfWeek = day
tpl.startTime = form.value.startTime
tpl.endTime = form.value.endTime
tpl.capacity = capacity
} else {
templates.value.push({
_key: String(Date.now()),
dayOfWeek: day,
startTime: form.value.startTime,
endTime: form.value.endTime,
capacity,
isActive: true,
})
}
isDirty.value = true
closeModal()
}
function toggleTemplate(tpl: LocalTemplate) {
tpl.isActive = !tpl.isActive
isDirty.value = true
}
function deleteTemplate(tpl: LocalTemplate) {
uni.showModal({
title: '确认删除',
content: '删除该时段模板?',
success: (res) => {
if (res.confirm) {
const idx = templates.value.indexOf(tpl)
if (idx >= 0) templates.value.splice(idx, 1)
isDirty.value = true
}
},
})
}
async function handleSave() {
if (saving.value) return
saving.value = true
try {
const payload = templates.value.map((t) => ({
id: t.id,
dayOfWeek: t.dayOfWeek,
startTime: t.startTime,
endTime: t.endTime,
capacity: t.capacity,
isActive: t.isActive,
}))
await adminStore.saveWeekTemplates(payload as any)
isDirty.value = false
uni.showToast({ title: '保存成功', icon: 'success' })
await fetchTemplates()
} catch (e: any) {
uni.showToast({ title: e?.message ?? '保存失败', icon: 'none' })
} finally {
saving.value = false
}
}
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
fetchTemplates()
})
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f3f0;
padding-bottom: 120rpx;
}
/* ── Toolbar ─────────────────────────────── */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 24rpx 16rpx;
}
.toolbar-hint { font-size: 24rpx; color: #999; }
.add-btn {
background: #1a1a2e;
border-radius: 32rpx;
padding: 12rpx 28rpx;
}
.add-btn-text { font-size: 26rpx; font-weight: 600; color: $primary-dark; }
/* ── Skeleton ────────────────────────────── */
.skeleton-list { padding: 0 24rpx; }
.skeleton-item {
height: 80rpx;
border-radius: 12rpx;
margin-bottom: 16rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
/* ── Empty ───────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 100rpx 0;
gap: 20rpx;
}
.empty-icon { font-size: 80rpx; }
.empty-text { font-size: 28rpx; color: #bbb; }
/* ── Day group ───────────────────────────── */
.day-group { margin: 0 24rpx 24rpx; }
.day-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 8rpx;
}
.day-label { font-size: 28rpx; font-weight: 700; color: #1a1a2e; }
.day-count { font-size: 22rpx; color: #999; }
/* ── Template row ────────────────────────── */
.tpl-row {
background: #ffffff;
border-radius: 12rpx;
padding: 20rpx 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
&--inactive { opacity: 0.5; }
}
.tpl-time { display: flex; flex-direction: column; gap: 6rpx; }
.tpl-time-text { font-size: 28rpx; font-weight: 600; color: #1a1a2e; }
.tpl-capacity { font-size: 22rpx; color: #888; }
.tpl-actions { display: flex; gap: 12rpx; }
.tpl-toggle,
.tpl-edit,
.tpl-delete {
border-radius: 20rpx;
padding: 8rpx 20rpx;
}
.toggle--on { background: rgba(39,174,96,0.12); }
.toggle--on .tpl-toggle-text { font-size: 24rpx; color: #27ae60; }
.toggle--off { background: rgba(230,126,34,0.12); }
.toggle--off .tpl-toggle-text { font-size: 24rpx; color: #e67e22; }
.tpl-edit { background: rgba(26,26,46,0.08); }
.tpl-edit-text { font-size: 24rpx; color: #1a1a2e; }
.tpl-delete { background: rgba(192,57,43,0.08); }
.tpl-delete-text { font-size: 24rpx; color: #c0392b; }
/* ── Save bar ────────────────────────────── */
.save-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);
}
.save-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; }
}
.save-btn-text { font-size: 30rpx; font-weight: 700; color: $primary-dark; }
/* ── Modal ───────────────────────────────── */
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: flex-end;
z-index: 100;
}
.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-arrow { font-size: 26rpx; color: #bbb; }
.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: $primary-dark; }
</style>

View File

@@ -2,8 +2,6 @@ import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { get, post, put, del } from '../utils/request' import { get, post, put, del } from '../utils/request'
import type { import type {
WeekTemplate,
WeekTemplateInput,
CardType, CardType,
CreateCardTypeDto, CreateCardTypeDto,
UpdateCardTypeDto, UpdateCardTypeDto,
@@ -86,21 +84,6 @@ export interface UserMembership {
} }
export const useAdminStore = defineStore('admin', () => { export const useAdminStore = defineStore('admin', () => {
// ── Week templates ───────────────────────────────────────────────
const weekTemplates = ref<WeekTemplate[]>([])
async function fetchWeekTemplates(): Promise<WeekTemplate[]> {
const data = await get<WeekTemplate[]>('/admin/week-template')
weekTemplates.value = data
return data
}
async function saveWeekTemplates(templates: WeekTemplateInput[]): Promise<WeekTemplate[]> {
const data = await put<WeekTemplate[]>('/admin/week-template', { templates })
weekTemplates.value = data
return data
}
// ── Card types ─────────────────────────────────────────────────── // ── Card types ───────────────────────────────────────────────────
const cardTypes = ref<CardType[]>([]) const cardTypes = ref<CardType[]>([])
@@ -273,14 +256,10 @@ export const useAdminStore = defineStore('admin', () => {
return { return {
// State // State
weekTemplates,
cardTypes, cardTypes,
studioConfig, studioConfig,
schedulePreview, schedulePreview,
scheduleLoading, scheduleLoading,
// Week templates
fetchWeekTemplates,
saveWeekTemplates,
// Card types // Card types
fetchCardTypes, fetchCardTypes,
createCardType, createCardType,

View File

@@ -6,40 +6,14 @@ import {
TimeSlotSource, TimeSlotSource,
MembershipStatus, MembershipStatus,
BookingStatus, BookingStatus,
getDefaultTimeSlots,
} from '@mp-pilates/shared' } from '@mp-pilates/shared'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Return a Date whose JS getDay() maps to the given ISO weekday (1=Mon…7=Sun) */
function dateForIsoWeekday(isoWeekday: number): Date {
const base = new Date('2026-04-06T00:00:00Z') // Monday
const d = new Date(base)
d.setDate(base.getDate() + (isoWeekday - 1))
return d
}
const makeTemplate = (overrides: Record<string, unknown> = {}) => ({
id: 'tpl-1',
dayOfWeek: 1,
startTime: '09:00',
endTime: '10:00',
capacity: 1,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
})
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mock PrismaService // Mock PrismaService
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const mockPrisma = { const mockPrisma = {
weekTemplate: {
findMany: jest.fn(),
},
timeSlot: { timeSlot: {
createMany: jest.fn(), createMany: jest.fn(),
updateMany: jest.fn(), updateMany: jest.fn(),
@@ -77,26 +51,12 @@ describe('SlotGeneratorService', () => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
describe('generateSlots', () => { describe('generateSlots', () => {
it('returns 0 when there are no active templates', async () => { it('creates slots for every day using the default schedule (14 slots per day)', async () => {
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([]) const defaultSlots = getDefaultTimeSlots()
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: defaultSlots.length * 7 })
const count = await service.generateSlots(7) const count = await service.generateSlots(7)
expect(count).toBe(0)
expect(mockPrisma.timeSlot.createMany).not.toHaveBeenCalled()
})
it('creates correct number of slots from templates', async () => {
// 2 templates, both for Monday (ISO 1)
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
makeTemplate({ id: 'tpl-1', dayOfWeek: 1, startTime: '09:00', endTime: '10:00' }),
makeTemplate({ id: 'tpl-2', dayOfWeek: 1, startTime: '10:00', endTime: '11:00' }),
])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 2 })
// Use 7 days — will hit exactly one Monday
const count = await service.generateSlots(7)
expect(mockPrisma.timeSlot.createMany).toHaveBeenCalledTimes(1) expect(mockPrisma.timeSlot.createMany).toHaveBeenCalledTimes(1)
const { data, skipDuplicates } = const { data, skipDuplicates } =
mockPrisma.timeSlot.createMany.mock.calls[0][0] as { mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
@@ -104,23 +64,30 @@ describe('SlotGeneratorService', () => {
skipDuplicates: boolean skipDuplicates: boolean
} }
expect(skipDuplicates).toBe(true) expect(skipDuplicates).toBe(true)
// Both templates should appear in the batch (may include more days) // 7 days × 14 slots per day = 98
const mondaySlots = ( expect(data).toHaveLength(defaultSlots.length * 7)
data as Array<{ startTime: string; source: TimeSlotSource }> expect(count).toBe(defaultSlots.length * 7)
).filter( })
(s) => s.startTime === '09:00' || s.startTime === '10:00',
) it('creates 14 slots per day (08:00-22:00 hourly)', async () => {
expect(mondaySlots.length).toBeGreaterThanOrEqual(2) mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 14 })
expect(count).toBe(2)
await service.generateSlots(1)
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: Array<{ startTime: string; endTime: string }>
}
expect(data).toHaveLength(14)
expect(data[0].startTime).toBe('08:00')
expect(data[0].endTime).toBe('09:00')
expect(data[13].startTime).toBe('21:00')
expect(data[13].endTime).toBe('22:00')
}) })
it('passes skipDuplicates: true to handle existing date+time combinations', async () => { it('passes skipDuplicates: true to handle existing date+time combinations', async () => {
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
makeTemplate({ dayOfWeek: 1 }),
])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 0 }) mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 0 })
await service.generateSlots(7) await service.generateSlots(1)
const call = mockPrisma.timeSlot.createMany.mock.calls[0][0] as { const call = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
skipDuplicates: boolean skipDuplicates: boolean
@@ -128,54 +95,17 @@ describe('SlotGeneratorService', () => {
expect(call.skipDuplicates).toBe(true) expect(call.skipDuplicates).toBe(true)
}) })
it('maps Sunday (JS getDay()=0) to ISO weekday 7', async () => { it('sets source to TEMPLATE for all generated slots', async () => {
// Template for Sunday (ISO 7) mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 14 })
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
makeTemplate({ id: 'tpl-sun', dayOfWeek: 7, startTime: '08:00', endTime: '09:00' }),
])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 1 })
// 7 days will cover exactly one Sunday await service.generateSlots(1)
const count = await service.generateSlots(7)
expect(mockPrisma.timeSlot.createMany).toHaveBeenCalledTimes(1)
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: Array<{ startTime: string; source: TimeSlotSource }>
}
const sundaySlots = data.filter((s) => s.startTime === '08:00')
expect(sundaySlots.length).toBeGreaterThanOrEqual(1)
expect(sundaySlots[0].source).toBe(TimeSlotSource.TEMPLATE)
expect(count).toBe(1)
})
it('maps Monday (JS getDay()=1) to ISO weekday 1', async () => {
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
makeTemplate({ id: 'tpl-mon', dayOfWeek: 1, startTime: '07:00', endTime: '08:00' }),
])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 1 })
await service.generateSlots(7)
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as { const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: Array<{ startTime: string }> data: Array<{ source: TimeSlotSource }>
} }
const mondaySlots = data.filter((s) => s.startTime === '07:00') for (const slot of data) {
expect(mondaySlots.length).toBeGreaterThanOrEqual(1) expect(slot.source).toBe(TimeSlotSource.TEMPLATE)
})
it('attaches templateId from the matching template', async () => {
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
makeTemplate({ id: 'tpl-xyz', dayOfWeek: 2 }),
])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 1 })
await service.generateSlots(7)
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: Array<{ templateId: string }>
} }
const tuesdaySlots = data.filter((s) => s.templateId === 'tpl-xyz')
expect(tuesdaySlots.length).toBeGreaterThanOrEqual(1)
}) })
}) })

View File

@@ -6,14 +6,10 @@ import {
BookingStatus, BookingStatus,
SLOT_GENERATION_DAYS, SLOT_GENERATION_DAYS,
DEFAULT_SLOT_CAPACITY, DEFAULT_SLOT_CAPACITY,
getDefaultTimeSlots,
} from '@mp-pilates/shared' } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service' import { PrismaService } from '../prisma/prisma.service'
/** Convert JS getDay() (0=Sun … 6=Sat) to ISO weekday (1=Mon … 7=Sun) */
function toIsoWeekday(jsDay: number): number {
return jsDay === 0 ? 7 : jsDay
}
/** Build a UTC Date for midnight of a local calendar date */ /** Build a UTC Date for midnight of a local calendar date */
function toUtcMidnight(date: Date): Date { function toUtcMidnight(date: Date): Date {
const d = new Date(date) const d = new Date(date)
@@ -28,20 +24,14 @@ export class SlotGeneratorService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
/** /**
* Generate time slots for the next `daysAhead` days based on active * Generate time slots for the next `daysAhead` days based on the fixed
* WeekTemplates. Uses `createMany` with `skipDuplicates` so re-runs are safe. * default schedule (Mon-Sun, 08:00-22:00 hourly).
* Uses `createMany` with `skipDuplicates` so re-runs are safe.
* *
* @returns Number of newly created slots * @returns Number of newly created slots
*/ */
async generateSlots(daysAhead: number = SLOT_GENERATION_DAYS): Promise<number> { async generateSlots(daysAhead: number = SLOT_GENERATION_DAYS): Promise<number> {
const templates = await this.prisma.weekTemplate.findMany({ const defaultSlots = getDefaultTimeSlots()
where: { isActive: true },
})
if (templates.length === 0) {
this.logger.log('No active week templates found skipping slot generation')
return 0
}
const tomorrow = new Date() const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1) tomorrow.setDate(tomorrow.getDate() + 1)
@@ -53,27 +43,19 @@ export class SlotGeneratorService {
endTime: string endTime: string
capacity: number capacity: number
source: TimeSlotSource source: TimeSlotSource
templateId: string
}> = [] }> = []
for (let offset = 0; offset < daysAhead; offset++) { for (let offset = 0; offset < daysAhead; offset++) {
const target = new Date(tomorrow) const target = new Date(tomorrow)
target.setDate(target.getDate() + offset) target.setDate(target.getDate() + offset)
const isoWeekday = toIsoWeekday(target.getDay()) for (const slot of defaultSlots) {
const matchingTemplates = templates.filter(
(t) => t.dayOfWeek === isoWeekday,
)
for (const template of matchingTemplates) {
slotsToCreate.push({ slotsToCreate.push({
date: toUtcMidnight(target), date: toUtcMidnight(target),
startTime: template.startTime, startTime: slot.startTime,
endTime: template.endTime, endTime: slot.endTime,
capacity: template.capacity ?? DEFAULT_SLOT_CAPACITY, capacity: DEFAULT_SLOT_CAPACITY,
source: TimeSlotSource.TEMPLATE, source: TimeSlotSource.TEMPLATE,
templateId: template.id,
}) })
} }
} }

View File

@@ -19,7 +19,6 @@ import { TimeSlotService } from './time-slot.service'
import { SlotGeneratorService } from './slot-generator.service' import { SlotGeneratorService } from './slot-generator.service'
import { QuerySlotsDto } from './dto/query-slots.dto' import { QuerySlotsDto } from './dto/query-slots.dto'
import { CreateManualSlotDto } from './dto/create-manual-slot.dto' import { CreateManualSlotDto } from './dto/create-manual-slot.dto'
import { UpdateWeekTemplateDto } from './dto/week-template.dto'
import { PublishDaySlotsDto } from './dto/publish-day-slots.dto' import { PublishDaySlotsDto } from './dto/publish-day-slots.dto'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -61,18 +60,6 @@ export class AdminTimeSlotController {
private readonly slotGeneratorService: SlotGeneratorService, private readonly slotGeneratorService: SlotGeneratorService,
) {} ) {}
// Week template management
@Get('week-template')
getWeekTemplates() {
return this.timeSlotService.getWeekTemplates()
}
@Put('week-template')
replaceWeekTemplates(@Body() dto: UpdateWeekTemplateDto) {
return this.timeSlotService.replaceWeekTemplates(dto.templates)
}
// Manual slot management // Manual slot management
@Post('time-slot/manual') @Post('time-slot/manual')

View File

@@ -1,5 +1,5 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common' import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'
import { TimeSlotStatus, BookingStatus, DEFAULT_SLOT_CAPACITY } from '@mp-pilates/shared' import { TimeSlotStatus, BookingStatus, DEFAULT_SLOT_CAPACITY, getDefaultTimeSlots } from '@mp-pilates/shared'
import { TimeSlotSource } from '@mp-pilates/shared' import { TimeSlotSource } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service' import { PrismaService } from '../prisma/prisma.service'
import type { TimeSlotWithBookingStatus, ScheduleSlotPreview } from '@mp-pilates/shared' import type { TimeSlotWithBookingStatus, ScheduleSlotPreview } from '@mp-pilates/shared'
@@ -144,51 +144,12 @@ export class TimeSlotService {
}) })
} }
async getWeekTemplates() {
return this.prisma.weekTemplate.findMany({
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
})
}
async replaceWeekTemplates(
items: Array<{
dayOfWeek: number
startTime: string
endTime: string
capacity?: number
isActive?: boolean
}>,
) {
return this.prisma.$transaction(async (tx) => {
await tx.weekTemplate.deleteMany()
await tx.weekTemplate.createMany({
data: items.map((item) => ({
dayOfWeek: item.dayOfWeek,
startTime: item.startTime,
endTime: item.endTime,
capacity: item.capacity ?? DEFAULT_SLOT_CAPACITY,
isActive: item.isActive ?? true,
})),
})
return tx.weekTemplate.findMany({
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
})
})
}
// ── Schedule preview & publish ────────────────────────────── // ── 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. * Return a schedule preview for a given date.
* If TimeSlot records already exist → return them (isPublished: true). * If TimeSlot records already exist → return them (isPublished: true).
* Otherwise → derive from active WeekTemplates (isPublished: false). * Otherwise → derive from the fixed default schedule (isPublished: false).
*/ */
async getSchedulePreview(date: string): Promise<ScheduleSlotPreview[]> { async getSchedulePreview(date: string): Promise<ScheduleSlotPreview[]> {
const parsedDate = new Date(date) const parsedDate = new Date(date)
@@ -216,23 +177,19 @@ export class TimeSlotService {
})) }))
} }
// 2. No existing slots — derive from WeekTemplate // 2. No existing slots — use fixed default schedule
const isoWeekday = this.toIsoWeekday(parsedDate.getUTCDay()) const defaultSlots = getDefaultTimeSlots()
const templates = await this.prisma.weekTemplate.findMany({
where: { dayOfWeek: isoWeekday, isActive: true },
orderBy: { startTime: 'asc' },
})
return templates.map((tpl) => ({ return defaultSlots.map((slot) => ({
id: null, id: null,
date: date, date: date,
startTime: tpl.startTime, startTime: slot.startTime,
endTime: tpl.endTime, endTime: slot.endTime,
capacity: tpl.capacity, capacity: DEFAULT_SLOT_CAPACITY,
bookedCount: 0, bookedCount: 0,
status: TimeSlotStatus.OPEN, status: TimeSlotStatus.OPEN,
source: TimeSlotSource.TEMPLATE, source: TimeSlotSource.TEMPLATE,
templateId: tpl.id, templateId: null,
isPublished: false, isPublished: false,
})) }))
} }

View File

@@ -2,8 +2,10 @@
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"outDir": "dist", "outDir": "dist",
"rootDir": "../..",
"module": "CommonJS", "module": "CommonJS",
"moduleResolution": "node", "moduleResolution": "node",
"ignoreDeprecations": "5.0",
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"target": "ES2021", "target": "ES2021",

File diff suppressed because one or more lines are too long

View File

@@ -15,6 +15,21 @@ export const DEFAULT_SLOT_CAPACITY = 1
/** 自动生成时段的天数范围 */ /** 自动生成时段的天数范围 */
export const SLOT_GENERATION_DAYS = 14 export const SLOT_GENERATION_DAYS = 14
/** 默认排课时间表:每天 08:00-22:00每小时一节课 */
export const DEFAULT_SCHEDULE_START_HOUR = 8
export const DEFAULT_SCHEDULE_END_HOUR = 22
/** 生成默认时段列表 (startTime, endTime) */
export function getDefaultTimeSlots(): ReadonlyArray<{ readonly startTime: string; readonly endTime: string }> {
const slots: Array<{ startTime: string; endTime: string }> = []
for (let h = DEFAULT_SCHEDULE_START_HOUR; h < DEFAULT_SCHEDULE_END_HOUR; h++) {
const startTime = String(h).padStart(2, '0') + ':00'
const endTime = String(h + 1).padStart(2, '0') + ':00'
slots.push({ startTime, endTime })
}
return slots
}
/** 时段筛选区间 */ /** 时段筛选区间 */
export const TIME_PERIODS = { export const TIME_PERIODS = {
MORNING: { label: '上午', start: '06:00', end: '12:00' }, MORNING: { label: '上午', start: '06:00', end: '12:00' },

View File

@@ -18,6 +18,9 @@ export {
DEFAULT_STUDIO_GALLERY_PHOTOS, DEFAULT_STUDIO_GALLERY_PHOTOS,
DEFAULT_SLOT_CAPACITY, DEFAULT_SLOT_CAPACITY,
SLOT_GENERATION_DAYS, SLOT_GENERATION_DAYS,
DEFAULT_SCHEDULE_START_HOUR,
DEFAULT_SCHEDULE_END_HOUR,
getDefaultTimeSlots,
TIME_PERIODS, TIME_PERIODS,
DATE_SELECTOR_DAYS, DATE_SELECTOR_DAYS,
WEEKDAY_LABELS, WEEKDAY_LABELS,