fix(server): preserve admin 'rest day' intent across cron runs

publishDaySlots used to DELETE the TimeSlot rows of a day that an admin
just 'cleared', which made the nightly slot-generation cron resurrect
the default schedule the next morning — a real production bug where
classes would reappear on a teacher's rest day.

Changes:
- publishDaySlots now CLOSEs (never DELETEs) orphaned rows, and materializes
  the default template for days with no rows so an empty publish still
  records intent. update on existing rows preserves their current status,
  so re-publishing the page cannot silently reopen a CLOSED rest day.
- SlotGeneratorService.generateSlots pre-fetches dates that already have any
  TimeSlot row and skips them, so the cron no longer overwrites admin-touched
  days even when admin only set status without changing times.
- SlotGeneratorService.prunePastClosedSlots (new) deletes past CLOSED slots
  with no bookings, scheduled nightly at 02:35, so the 'no DELETE' rule
  above does not leak rows forever.
- schedule.vue surfaces the CLOSED state with a grey '已关闭' badge and
  card style.

Tests: 12 new specs across time-slot.service.spec.ts (publishDaySlots
behaviour) and slot-generator.service.spec.ts (skip touched dates +
prune conditions). Full suite: 315 / 315 passing.
This commit is contained in:
richarjiang
2026-09-10 22:32:32 +08:00
parent 9af558286b
commit 806f3ee770
6 changed files with 480 additions and 22 deletions

View File

@@ -160,6 +160,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { ScheduleSlotPreview } from '@mp-pilates/shared'
import { TimeSlotStatus } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from './stores/admin'
@@ -181,6 +182,7 @@ interface EditableSlot {
endTime: string
capacity: number
bookedCount: number
status: TimeSlotStatus
isPublished: boolean
isNew: boolean
isRemoved: boolean
@@ -222,6 +224,7 @@ function mapPreviewToEditable(previews: readonly ScheduleSlotPreview[]): Editabl
endTime: p.endTime,
capacity: p.capacity,
bookedCount: p.bookedCount,
status: (p.status ?? TimeSlotStatus.OPEN) as TimeSlotStatus,
isPublished: p.isPublished,
isNew: false,
isRemoved: false,
@@ -323,6 +326,7 @@ function submitAdd() {
endTime: addForm.value.endTime,
capacity,
bookedCount: 0,
status: TimeSlotStatus.OPEN,
isPublished: false,
isNew: true,
isRemoved: false,
@@ -396,18 +400,21 @@ async function doPublish(slots: readonly EditableSlot[]) {
// ── Style helpers ─────────────────────────────────────────
function slotCardClass(slot: EditableSlot): string {
if (slot.status === TimeSlotStatus.CLOSED) return 'slot-card--closed'
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.status === TimeSlotStatus.CLOSED) return 'badge--closed'
if (slot.isNew) return 'badge--new'
if (slot.isPublished) return 'badge--published'
return 'badge--template'
}
function slotBadgeText(slot: EditableSlot): string {
if (slot.status === TimeSlotStatus.CLOSED) return '已关闭'
if (slot.isNew) return '新增'
if (slot.isPublished) return '已发布'
return '默认时段'
@@ -495,6 +502,12 @@ onMounted(() => {
border-color: #3498db;
background: rgba(52, 152, 219, 0.04);
}
&--closed {
opacity: 0.55;
background: #fafafa;
border-color: #e5e5e5;
}
}
/* ── Slot header ─────────────────────────── */
@@ -516,6 +529,8 @@ onMounted(() => {
.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; }
.badge--closed { background: rgba(0, 0, 0, 0.06); }
.badge--closed .slot-badge-text { font-size: 22rpx; color: #888; font-weight: 600; }
.booked-info { }
.booked-text { font-size: 22rpx; color: #e67e22; }