2 Commits

Author SHA1 Message Date
richarjiang
58c6d2092a fix(app): 修复课程管理待确认按钮文字颜色隐形问题
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 14:41:25 +08:00
richarjiang
b72357030d fix(server): 修复排课发布唯一约束冲突并更正默认时段模板
发布时按日期+起止时间匹配已有时段,避免幽灵预览重复 create;默认课表改为上午整点与下午 13:30 起每小时至 21:30。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 14:26:43 +08:00
7 changed files with 179 additions and 28 deletions

View File

@@ -111,7 +111,7 @@
<text class="action-btn-text action-btn-text--ghost">婉拒</text> <text class="action-btn-text action-btn-text--ghost">婉拒</text>
</view> </view>
<view class="action-btn action-btn--primary" @tap.stop="handleConfirm(booking)"> <view class="action-btn action-btn--primary" @tap.stop="handleConfirm(booking)">
<text class="action-btn-text">确认预约</text> <text class="action-btn-text action-btn-text--light">确认预约</text>
</view> </view>
</view> </view>
@@ -982,11 +982,19 @@ $cream: #efe4cf; // base cream tint
&--primary { &--primary {
background: $sage; background: $sage;
border-color: $sage; border-color: $sage;
.action-btn-text {
color: #fff;
}
} }
&--confirm { &--confirm {
background: $ink; background: $ink;
border-color: $ink; border-color: $ink;
.action-btn-text {
color: #fff;
}
} }
&--ghost { &--ghost {

View File

@@ -72,21 +72,25 @@ describe('SlotGeneratorService', () => {
expect(count).toBe(defaultSlots.length * 7) expect(count).toBe(defaultSlots.length * 7)
}) })
it('creates 13 slots per day (08:00-09:00, then 09:30-21:30 hourly)', async () => { it('creates 12 slots per day (morning on the hour, afternoon from 13:30)', async () => {
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 13 }) mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 12 })
await service.generateSlots(1) await service.generateSlots(1)
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as { const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: Array<{ startTime: string; endTime: string }> data: Array<{ startTime: string; endTime: string }>
} }
expect(data).toHaveLength(13) expect(data).toHaveLength(12)
expect(data[0].startTime).toBe('08:00') expect(data[0].startTime).toBe('08:00')
expect(data[0].endTime).toBe('09:00') expect(data[0].endTime).toBe('09:00')
expect(data[1].startTime).toBe('09:30') expect(data[1].startTime).toBe('09:00')
expect(data[1].endTime).toBe('10:30') expect(data[1].endTime).toBe('10:00')
expect(data[12].startTime).toBe('20:30') expect(data[3].startTime).toBe('11:00')
expect(data[12].endTime).toBe('21:30') expect(data[3].endTime).toBe('12:00')
expect(data[4].startTime).toBe('13:30')
expect(data[4].endTime).toBe('14:30')
expect(data[11].startTime).toBe('20:30')
expect(data[11].endTime).toBe('21:30')
}) })
it('passes skipDuplicates: true to handle existing date+time combinations', async () => { it('passes skipDuplicates: true to handle existing date+time combinations', async () => {
@@ -101,7 +105,7 @@ describe('SlotGeneratorService', () => {
}) })
it('sets source to TEMPLATE for all generated slots', async () => { it('sets source to TEMPLATE for all generated slots', async () => {
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 13 }) mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 12 })
await service.generateSlots(1) await service.generateSlots(1)

View File

@@ -7,6 +7,7 @@ import {
TimeSlotSource, TimeSlotSource,
BookingStatus, BookingStatus,
DEFAULT_SLOT_CAPACITY, DEFAULT_SLOT_CAPACITY,
getDefaultTimeSlots,
} from '@mp-pilates/shared' } from '@mp-pilates/shared'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -508,6 +509,102 @@ describe('TimeSlotService', () => {
expect(createData.capacity).toBe(4) expect(createData.capacity).toBe(4)
}) })
it('updates materialized template rows when ghost preview is published without ids', async () => {
const defaultSlots = getDefaultTimeSlots()
const materialized = defaultSlots.map((slot, index) =>
makeSlot({
id: `slot-${index}`,
startTime: slot.startTime,
endTime: slot.endTime,
source: TimeSlotSource.TEMPLATE,
}),
)
mockPrisma.timeSlot.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce(materialized)
.mockResolvedValueOnce(materialized)
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: defaultSlots.length })
mockPrisma.timeSlot.update.mockImplementation(async (args: { where: { id: string } }) => {
const row = materialized.find((s) => s.id === args.where.id)!
return { ...row }
})
await service.publishDaySlots({
date,
slots: defaultSlots.map((slot) => ({
startTime: slot.startTime,
endTime: slot.endTime,
capacity: DEFAULT_SLOT_CAPACITY,
})),
})
expect(mockPrisma.timeSlot.createMany).toHaveBeenCalledTimes(1)
expect(mockPrisma.timeSlot.create).not.toHaveBeenCalled()
expect(mockPrisma.timeSlot.update).toHaveBeenCalledTimes(defaultSlots.length)
})
it('reopens an existing row by time when admin re-adds a slot without existingSlotId', async () => {
const existing = [
makeSlot({
id: 'slot-open',
startTime: '09:30',
endTime: '10:30',
status: TimeSlotStatus.OPEN,
}),
]
mockPrisma.timeSlot.findMany
.mockResolvedValueOnce(existing)
.mockResolvedValueOnce(existing)
mockPrisma.timeSlot.update.mockResolvedValueOnce({
...existing[0],
status: TimeSlotStatus.OPEN,
source: TimeSlotSource.MANUAL,
capacity: 3,
})
await service.publishDaySlots({
date,
slots: [{ startTime: '09:30', endTime: '10:30', capacity: 3 }],
})
expect(mockPrisma.timeSlot.create).not.toHaveBeenCalled()
const updateData = (mockPrisma.timeSlot.update.mock.calls[0][0] as {
data: { status: string; source: string; capacity: number }
}).data
expect(updateData.status).toBe(TimeSlotStatus.OPEN)
expect(updateData.source).toBe(TimeSlotSource.MANUAL)
expect(updateData.capacity).toBe(3)
})
it('reopens a CLOSED row when admin publishes the same time window as a new slot', async () => {
const existing = [
makeSlot({
id: 'slot-closed',
startTime: '11:00',
endTime: '12:00',
status: TimeSlotStatus.CLOSED,
}),
]
mockPrisma.timeSlot.findMany
.mockResolvedValueOnce(existing)
.mockResolvedValueOnce(existing)
mockPrisma.timeSlot.update.mockResolvedValueOnce({
...existing[0],
status: TimeSlotStatus.OPEN,
})
await service.publishDaySlots({
date,
slots: [{ startTime: '11:00', endTime: '12:00', capacity: 2 }],
})
expect(mockPrisma.timeSlot.create).not.toHaveBeenCalled()
const updateData = (mockPrisma.timeSlot.update.mock.calls[0][0] as {
data: { status: string }
}).data
expect(updateData.status).toBe(TimeSlotStatus.OPEN)
})
it('returns the final state of the day ordered by startTime', async () => { it('returns the final state of the day ordered by startTime', async () => {
const finalState = [ const finalState = [
makeSlot({ id: 'a', startTime: '09:00', endTime: '10:00' }), makeSlot({ id: 'a', startTime: '09:00', endTime: '10:00' }),

View File

@@ -37,7 +37,7 @@ export class SlotGeneratorService {
/** /**
* Generate time slots for the next `daysAhead` days based on the fixed * Generate time slots for the next `daysAhead` days based on the fixed
* default schedule (Mon-Sun: 08:00-09:00, then 09:30-21:30 hourly). * default schedule (morning on the hour 08:0012:00, afternoon from 13:30 hourly to 21:30).
* *
* Behaviour: * Behaviour:
* - Days that already have any TimeSlot row (any status, including CLOSED) * - Days that already have any TimeSlot row (any status, including CLOSED)

View File

@@ -18,6 +18,10 @@ export class TimeSlotService {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 23, 59, 59, 999)) return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 23, 59, 59, 999))
} }
private slotTimeKey(startTime: string, endTime: string): string {
return `${startTime}\0${endTime}`
}
private mapToWithBookingStatus( private mapToWithBookingStatus(
slot: { slot: {
id: string id: string
@@ -276,6 +280,9 @@ export class TimeSlotService {
} }
const existingMap = new Map(existing.map((s) => [s.id, s])) const existingMap = new Map(existing.map((s) => [s.id, s]))
const existingByTime = new Map(
existing.map((s) => [this.slotTimeKey(s.startTime, s.endTime), s]),
)
const keptIds = new Set<string>() const keptIds = new Set<string>()
const results: Array<{ const results: Array<{
@@ -291,29 +298,44 @@ export class TimeSlotService {
// 3. Process each slot in the request. // 3. Process each slot in the request.
for (const item of dto.slots) { for (const item of dto.slots) {
let resolvedId: string | null = null
if (item.existingSlotId && existingMap.has(item.existingSlotId)) { if (item.existingSlotId && existingMap.has(item.existingSlotId)) {
// Update existing slot. Never reduce capacity below bookedCount. resolvedId = item.existingSlotId
const existingSlot = existingMap.get(item.existingSlotId)! } else {
const byTime = existingByTime.get(this.slotTimeKey(item.startTime, item.endTime))
if (byTime) {
resolvedId = byTime.id
}
}
if (resolvedId) {
const existingSlot = existingMap.get(resolvedId)!
const safeCapacity = Math.max(item.capacity, existingSlot.bookedCount) const safeCapacity = Math.max(item.capacity, existingSlot.bookedCount)
const matchedByExplicitId =
Boolean(item.existingSlotId) && item.existingSlotId === resolvedId
const updated = await tx.timeSlot.update({ const updated = await tx.timeSlot.update({
where: { id: item.existingSlotId }, where: { id: resolvedId },
data: { data: {
startTime: item.startTime, startTime: item.startTime,
endTime: item.endTime, endTime: item.endTime,
capacity: safeCapacity, capacity: safeCapacity,
// Existing status is preserved: CLOSED rows stay CLOSED, so an // Rows referenced by id keep status (CLOSED stays CLOSED on re-publish).
// admin's "rest day" intent cannot be undone by a no-op // Rows matched only by time (ghost preview, re-added slot) are published OPEN.
// re-publish. Reopening must go through a dedicated endpoint. ...(!matchedByExplicitId
? {
status: TimeSlotStatus.OPEN,
source: TimeSlotSource.MANUAL,
}
: {}),
}, },
}) })
keptIds.add(item.existingSlotId) keptIds.add(resolvedId)
results.push(updated) results.push(updated)
} else { } else {
// Create a new slot.
const created = await tx.timeSlot.create({ const created = await tx.timeSlot.create({
data: { data: {
date: parsedDate, date: this.toDateOfDay(parsedDate),
startTime: item.startTime, startTime: item.startTime,
endTime: item.endTime, endTime: item.endTime,
capacity: item.capacity, capacity: item.capacity,
@@ -321,6 +343,9 @@ export class TimeSlotService {
status: TimeSlotStatus.OPEN, status: TimeSlotStatus.OPEN,
}, },
}) })
keptIds.add(created.id)
existingMap.set(created.id, created)
existingByTime.set(this.slotTimeKey(created.startTime, created.endTime), created)
results.push(created) results.push(created)
} }
} }

View File

@@ -15,11 +15,15 @@ export const DEFAULT_SLOT_CAPACITY = 1
/** 自动生成时段的天数范围 */ /** 自动生成时段的天数范围 */
export const SLOT_GENERATION_DAYS = 14 export const SLOT_GENERATION_DAYS = 14
/** 默认排课时间表:第一节 08:00-09:00之后从 09:30 起每小时一节至 21:30 */ /** 默认排课:上午整点 08:0012:0089、910、1011、1112下午 13:30 起每小时至 21:30 结束 */
export const DEFAULT_SCHEDULE_START_TIME = '08:00' export const DEFAULT_SCHEDULE_START_TIME = '08:00'
export const DEFAULT_SCHEDULE_END_TIME = '21:30' export const DEFAULT_SCHEDULE_END_TIME = '21:30'
/** 第一节课后的半点课表起点 */ /** 上午时段结束时刻(不含):最后一节为 11:0012:00 */
export const DEFAULT_SCHEDULE_OFFSET_START_TIME = '09:30' export const DEFAULT_SCHEDULE_MORNING_END_TIME = '12:00'
/** 下午时段起点13:3014:30 起,之后每小时一节 */
export const DEFAULT_SCHEDULE_AFTERNOON_START_TIME = '13:30'
/** @deprecated 使用 DEFAULT_SCHEDULE_AFTERNOON_START_TIME */
export const DEFAULT_SCHEDULE_OFFSET_START_TIME = DEFAULT_SCHEDULE_AFTERNOON_START_TIME
/** 排课时间选择步长:仅整点与半点 */ /** 排课时间选择步长:仅整点与半点 */
export const SCHEDULE_MINUTE_STEP = 30 export const SCHEDULE_MINUTE_STEP = 30
@@ -37,14 +41,13 @@ function formatMinutesToTime(totalMinutes: number): string {
/** 生成默认时段列表 (startTime, endTime) */ /** 生成默认时段列表 (startTime, endTime) */
export function getDefaultTimeSlots(): ReadonlyArray<{ readonly startTime: string; readonly endTime: string }> { export function getDefaultTimeSlots(): ReadonlyArray<{ readonly startTime: string; readonly endTime: string }> {
const duration = 60 const duration = 60
const slots: Array<{ startTime: string; endTime: string }> = [ const slots: Array<{ startTime: string; endTime: string }> = []
{ startTime: DEFAULT_SCHEDULE_START_TIME, endTime: '09:00' }, const morningEnd = parseTimeToMinutes(DEFAULT_SCHEDULE_MORNING_END_TIME)
] const dayEnd = parseTimeToMinutes(DEFAULT_SCHEDULE_END_TIME)
const endMinutes = parseTimeToMinutes(DEFAULT_SCHEDULE_END_TIME)
for ( for (
let t = parseTimeToMinutes(DEFAULT_SCHEDULE_OFFSET_START_TIME); let t = parseTimeToMinutes(DEFAULT_SCHEDULE_START_TIME);
t + duration <= endMinutes; t + duration <= morningEnd;
t += duration t += duration
) { ) {
slots.push({ slots.push({
@@ -52,6 +55,18 @@ export function getDefaultTimeSlots(): ReadonlyArray<{ readonly startTime: strin
endTime: formatMinutesToTime(t + duration), endTime: formatMinutesToTime(t + duration),
}) })
} }
for (
let t = parseTimeToMinutes(DEFAULT_SCHEDULE_AFTERNOON_START_TIME);
t + duration <= dayEnd;
t += duration
) {
slots.push({
startTime: formatMinutesToTime(t),
endTime: formatMinutesToTime(t + duration),
})
}
return slots return slots
} }

View File

@@ -38,6 +38,8 @@ export {
SLOT_GENERATION_DAYS, SLOT_GENERATION_DAYS,
DEFAULT_SCHEDULE_START_TIME, DEFAULT_SCHEDULE_START_TIME,
DEFAULT_SCHEDULE_END_TIME, DEFAULT_SCHEDULE_END_TIME,
DEFAULT_SCHEDULE_MORNING_END_TIME,
DEFAULT_SCHEDULE_AFTERNOON_START_TIME,
DEFAULT_SCHEDULE_OFFSET_START_TIME, DEFAULT_SCHEDULE_OFFSET_START_TIME,
SCHEDULE_MINUTE_STEP, SCHEDULE_MINUTE_STEP,
getDefaultTimeSlots, getDefaultTimeSlots,