695 lines
17 KiB
Vue
695 lines
17 KiB
Vue
<template>
|
||
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||
<CustomNavBar title="安排课程" show-back />
|
||
|
||
<view v-if="detail" class="who-bar">
|
||
<view class="who-avatar">
|
||
<image v-if="detail.user.avatarUrl" class="avatar-img" :src="detail.user.avatarUrl" mode="aspectFill" />
|
||
<view v-else class="avatar-fallback">
|
||
<text class="avatar-letter">{{ (detail.user.nickname || '?').slice(0, 1) }}</text>
|
||
</view>
|
||
</view>
|
||
<view class="who-copy">
|
||
<text class="who-kicker">为学员安排</text>
|
||
<text class="who-name">{{ detail.user.nickname || '未知用户' }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="usableMemberships.length" class="card-switch">
|
||
<scroll-view scroll-x class="card-scroll" :show-scrollbar="false">
|
||
<view class="card-track">
|
||
<view
|
||
v-for="card in usableMemberships"
|
||
:key="card.id"
|
||
class="card-pill"
|
||
:class="{ 'card-pill--on': selectedMembershipId === card.id }"
|
||
@tap="selectedMembershipId = card.id"
|
||
>
|
||
<text class="card-pill-name">{{ card.cardType.name }}</text>
|
||
<text class="card-pill-meta">
|
||
{{ card.remainingTimes == null ? '月卡不扣次' : `剩 ${card.remainingTimes} 次` }}
|
||
</text>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
|
||
<view v-else class="ban-banner">
|
||
<text class="ban-text">该会员没有可扣课的有效卡,请先开卡</text>
|
||
</view>
|
||
|
||
<DateSelector v-model="selectedDate" variant="booking" @select="onDateSelect" />
|
||
<TimePeriodFilter v-model="selectedPeriod" variant="booking" />
|
||
|
||
<view v-if="slotsLoading" class="slot-skeleton">
|
||
<view v-for="i in 4" :key="i" class="slot-skel" />
|
||
</view>
|
||
|
||
<view v-else-if="filteredSlots.length === 0" class="empty-slots">
|
||
<text class="empty-title">这天还没有可安排的课表</text>
|
||
<text class="empty-sub">可以去排课管理发布,或在下方加开一个时段</text>
|
||
<view class="ghost-link" @tap="goSchedule">
|
||
<text class="ghost-link-text">前往排课管理</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-else class="slot-list">
|
||
<view
|
||
v-for="slot in filteredSlots"
|
||
:key="slot.startTime + slot.endTime + (slot.id || 'draft')"
|
||
class="slot-row"
|
||
:class="{ 'slot-row--disabled': !canPickSlot(slot) }"
|
||
@tap="onPickSlot(slot)"
|
||
>
|
||
<view class="slot-time-col">
|
||
<text class="slot-time">{{ slot.startTime.slice(0, 5) }}</text>
|
||
<text class="slot-end">{{ slot.endTime.slice(0, 5) }}</text>
|
||
</view>
|
||
<view class="slot-body">
|
||
<text class="slot-title">{{ slotLabel(slot) }}</text>
|
||
<text class="slot-cap">{{ slot.bookedCount }}/{{ slot.capacity }} 人</text>
|
||
</view>
|
||
<text class="slot-action">{{ slotActionLabel(slot) }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="custom-block">
|
||
<text class="custom-title">加开此时段</text>
|
||
<view class="custom-row">
|
||
<picker mode="time" :value="customStart" @change="onCustomStartChange">
|
||
<view class="custom-picker">
|
||
<text class="custom-picker-text">{{ customStart }} – {{ customEnd }}</text>
|
||
<text class="custom-picker-arrow">选择开始时间</text>
|
||
</view>
|
||
</picker>
|
||
<view
|
||
class="custom-btn"
|
||
:class="{ 'custom-btn--disabled': !canArrange }"
|
||
@tap="onCustomArrange"
|
||
>
|
||
<text class="custom-btn-text">加开并安排</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="confirmVisible && pendingSlot" class="mask" @tap="confirmVisible = false">
|
||
<view class="sheet" @tap.stop>
|
||
<text class="sheet-kicker">立即确认</text>
|
||
<text class="sheet-title">安排给 {{ detail?.user.nickname || '该会员' }}</text>
|
||
<view class="sheet-lines">
|
||
<view class="sheet-line">
|
||
<text class="sheet-label">时间</text>
|
||
<text class="sheet-value">{{ pendingSlot.date }} {{ pendingSlot.startTime.slice(0, 5) }}–{{ pendingSlot.endTime.slice(0, 5) }}</text>
|
||
</view>
|
||
<view class="sheet-line">
|
||
<text class="sheet-label">扣卡</text>
|
||
<text class="sheet-value">{{ selectedMembership?.cardType.name }}</text>
|
||
</view>
|
||
</view>
|
||
<text class="sheet-note">{{ deductHint }}</text>
|
||
<view class="sheet-actions">
|
||
<view class="sheet-cancel" @tap="confirmVisible = false">
|
||
<text class="sheet-cancel-text">取消</text>
|
||
</view>
|
||
<view class="sheet-ok" :class="{ 'sheet-ok--disabled': arranging }" @tap="confirmArrange">
|
||
<text class="sheet-ok-text">{{ arranging ? '安排中...' : '确认安排' }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import { onLoad } from '@dcloudio/uni-app'
|
||
import type {
|
||
AdminMemberDetail,
|
||
ScheduleSlotPreview,
|
||
} from '@mp-pilates/shared'
|
||
import { MembershipStatus, TIME_PERIODS, TimeSlotStatus, TimeSlotSource } from '@mp-pilates/shared'
|
||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||
import DateSelector from '../../components/DateSelector.vue'
|
||
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
|
||
import { getSystemLayout } from '../../utils/system'
|
||
import { formatDate, isSlotPast } from '../../utils/format'
|
||
import { getErrorMessage } from '../../utils/auth'
|
||
import { useAdminStore } from '../../stores/admin'
|
||
|
||
type PeriodKey = keyof typeof TIME_PERIODS | null
|
||
|
||
const adminStore = useAdminStore()
|
||
const navBarHeight = ref('64px')
|
||
const userId = ref('')
|
||
const detail = ref<AdminMemberDetail | null>(null)
|
||
const selectedMembershipId = ref('')
|
||
const selectedDate = ref(formatDate(new Date()))
|
||
const selectedPeriod = ref<PeriodKey>(null)
|
||
const slots = ref<ScheduleSlotPreview[]>([])
|
||
const slotsLoading = ref(false)
|
||
const customStart = ref('10:00')
|
||
const confirmVisible = ref(false)
|
||
const pendingSlot = ref<ScheduleSlotPreview | null>(null)
|
||
const arranging = ref(false)
|
||
|
||
const usableMemberships = computed(() =>
|
||
(detail.value?.memberships ?? []).filter((item) =>
|
||
item.status === MembershipStatus.ACTIVE
|
||
&& (item.remainingTimes === null || item.remainingTimes > 0)
|
||
&& new Date(item.expireDate) > new Date(),
|
||
),
|
||
)
|
||
|
||
const selectedMembership = computed(
|
||
() => usableMemberships.value.find((item) => item.id === selectedMembershipId.value) ?? null,
|
||
)
|
||
|
||
const canArrange = computed(() => Boolean(selectedMembership.value))
|
||
|
||
const isDurationCard = computed(() => selectedMembership.value?.remainingTimes == null)
|
||
|
||
const deductHint = computed(() => {
|
||
if (isDurationCard.value) {
|
||
return '将立即确认该课,月卡不扣次,会员无需再确认。'
|
||
}
|
||
return '将立即确认该课并扣除 1 次,会员无需再确认。'
|
||
})
|
||
|
||
const filteredSlots = computed(() => {
|
||
if (!selectedPeriod.value) return slots.value
|
||
const period = TIME_PERIODS[selectedPeriod.value]
|
||
return slots.value.filter((slot) => slot.startTime >= period.start && slot.startTime < period.end)
|
||
})
|
||
|
||
const customEnd = computed(() => addHour(customStart.value))
|
||
|
||
function addHour(time: string): string {
|
||
const [hours, minutes] = time.split(':').map(Number)
|
||
const total = hours * 60 + (minutes || 0) + 60
|
||
const nextHours = Math.floor(total / 60) % 24
|
||
const nextMinutes = total % 60
|
||
return `${String(nextHours).padStart(2, '0')}:${String(nextMinutes).padStart(2, '0')}`
|
||
}
|
||
|
||
function canPickSlot(slot: ScheduleSlotPreview): boolean {
|
||
if (!canArrange.value) return false
|
||
if (isSlotPast(slot.date, slot.startTime)) return false
|
||
if (slot.status === TimeSlotStatus.CLOSED) return false
|
||
if (slot.isPublished && (slot.status === TimeSlotStatus.FULL || slot.bookedCount >= slot.capacity)) {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
function slotLabel(slot: ScheduleSlotPreview): string {
|
||
if (!slot.isPublished || !slot.id) return '未发布时段'
|
||
if (slot.status === TimeSlotStatus.CLOSED) return '已关闭'
|
||
if (slot.status === TimeSlotStatus.FULL || slot.bookedCount >= slot.capacity) return '已满员'
|
||
if (isSlotPast(slot.date, slot.startTime)) return '已过点'
|
||
return '可安排'
|
||
}
|
||
|
||
function slotActionLabel(slot: ScheduleSlotPreview): string {
|
||
if (!slot.isPublished || !slot.id) return '加开'
|
||
if (!canPickSlot(slot)) return '—'
|
||
return '安排'
|
||
}
|
||
|
||
async function loadSlots(date: string) {
|
||
slotsLoading.value = true
|
||
try {
|
||
slots.value = await adminStore.previewScheduleByDate(date)
|
||
} catch (err: unknown) {
|
||
slots.value = []
|
||
uni.showToast({ title: getErrorMessage(err, '课表加载失败'), icon: 'none' })
|
||
} finally {
|
||
slotsLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadPage() {
|
||
detail.value = await adminStore.fetchMemberDetail(userId.value)
|
||
const first = usableMemberships.value[0]
|
||
selectedMembershipId.value = first?.id ?? ''
|
||
await loadSlots(selectedDate.value)
|
||
}
|
||
|
||
function onDateSelect(date: string) {
|
||
selectedDate.value = date
|
||
loadSlots(date)
|
||
}
|
||
|
||
function onCustomStartChange(e: { detail: { value: string } }) {
|
||
customStart.value = e.detail.value
|
||
}
|
||
|
||
function goSchedule() {
|
||
uni.navigateTo({ url: '/pages/admin/schedule' })
|
||
}
|
||
|
||
function onPickSlot(slot: ScheduleSlotPreview) {
|
||
if (!canPickSlot(slot)) return
|
||
pendingSlot.value = slot
|
||
confirmVisible.value = true
|
||
}
|
||
|
||
function onCustomArrange() {
|
||
if (!canArrange.value) {
|
||
uni.showToast({ title: '请先开通有效会员卡', icon: 'none' })
|
||
return
|
||
}
|
||
if (customEnd.value <= customStart.value) {
|
||
uni.showToast({ title: '结束时间必须晚于开始时间', icon: 'none' })
|
||
return
|
||
}
|
||
if (isSlotPast(selectedDate.value, customStart.value)) {
|
||
uni.showToast({ title: '不能安排已经过去的时间', icon: 'none' })
|
||
return
|
||
}
|
||
pendingSlot.value = {
|
||
id: null,
|
||
date: selectedDate.value,
|
||
startTime: customStart.value,
|
||
endTime: customEnd.value,
|
||
capacity: 1,
|
||
bookedCount: 0,
|
||
status: TimeSlotStatus.OPEN,
|
||
source: TimeSlotSource.MANUAL,
|
||
templateId: null,
|
||
isPublished: false,
|
||
}
|
||
confirmVisible.value = true
|
||
}
|
||
|
||
async function confirmArrange() {
|
||
const slot = pendingSlot.value
|
||
const membership = selectedMembership.value
|
||
if (!slot || !membership || arranging.value) return
|
||
arranging.value = true
|
||
try {
|
||
let timeSlotId = slot.id
|
||
await adminStore.arrangeMemberBooking({
|
||
userId: userId.value,
|
||
membershipId: membership.id,
|
||
...(timeSlotId ? { timeSlotId } : {}),
|
||
date: slot.date,
|
||
startTime: slot.startTime.slice(0, 5),
|
||
endTime: slot.endTime.slice(0, 5),
|
||
capacity: slot.capacity || 1,
|
||
})
|
||
confirmVisible.value = false
|
||
uni.showToast({ title: '已安排并确认', icon: 'success' })
|
||
setTimeout(() => uni.navigateBack(), 500)
|
||
} catch (err: unknown) {
|
||
uni.showToast({ title: getErrorMessage(err, '安排失败'), icon: 'none' })
|
||
} finally {
|
||
arranging.value = false
|
||
}
|
||
}
|
||
|
||
onLoad((query) => {
|
||
userId.value = String(query?.userId || '')
|
||
})
|
||
|
||
onMounted(async () => {
|
||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||
try {
|
||
await loadPage()
|
||
} catch (err: unknown) {
|
||
uni.showToast({ title: getErrorMessage(err, '加载失败'), icon: 'none' })
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.page {
|
||
min-height: 100vh;
|
||
background: $bg-page;
|
||
padding-bottom: 80rpx;
|
||
}
|
||
|
||
.who-bar {
|
||
margin: 16rpx 24rpx 0;
|
||
padding: 20rpx;
|
||
border-radius: 20rpx;
|
||
background: linear-gradient(135deg, #3c3228, #5a4a3a);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 18rpx;
|
||
}
|
||
|
||
.who-avatar {
|
||
width: 72rpx;
|
||
height: 72rpx;
|
||
border-radius: 18rpx;
|
||
overflow: hidden;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.avatar-img { width: 100%; height: 100%; }
|
||
|
||
.avatar-fallback {
|
||
width: 100%;
|
||
height: 100%;
|
||
background: $accent-color;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.avatar-letter {
|
||
color: #fff8f0;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.who-copy {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4rpx;
|
||
}
|
||
|
||
.who-kicker {
|
||
font-size: 18rpx;
|
||
letter-spacing: 3rpx;
|
||
color: rgba(255, 248, 240, 0.55);
|
||
}
|
||
|
||
.who-name {
|
||
font-size: 32rpx;
|
||
font-weight: 700;
|
||
color: #fff8f0;
|
||
}
|
||
|
||
.card-switch {
|
||
padding: 16rpx 0 4rpx;
|
||
}
|
||
|
||
.card-scroll { white-space: nowrap; }
|
||
|
||
.card-track {
|
||
display: inline-flex;
|
||
gap: 12rpx;
|
||
padding: 0 24rpx;
|
||
}
|
||
|
||
.card-pill {
|
||
padding: 14rpx 22rpx;
|
||
border-radius: 16rpx;
|
||
background: #fff;
|
||
border: 2rpx solid rgba(180, 160, 130, 0.18);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4rpx;
|
||
}
|
||
|
||
.card-pill--on {
|
||
border-color: $brand-color;
|
||
background: #3c3228;
|
||
}
|
||
|
||
.card-pill-name {
|
||
font-size: 24rpx;
|
||
font-weight: 700;
|
||
color: $text-primary;
|
||
}
|
||
|
||
.card-pill--on .card-pill-name,
|
||
.card-pill--on .card-pill-meta {
|
||
color: #fff8f0;
|
||
}
|
||
|
||
.card-pill-meta {
|
||
font-size: 20rpx;
|
||
color: $text-hint;
|
||
}
|
||
|
||
.ban-banner {
|
||
margin: 16rpx 24rpx;
|
||
padding: 20rpx;
|
||
border-radius: 16rpx;
|
||
background: rgba($error-color, 0.1);
|
||
}
|
||
|
||
.ban-text {
|
||
font-size: 24rpx;
|
||
color: $error-color;
|
||
}
|
||
|
||
.slot-skeleton { padding: 16rpx 24rpx; }
|
||
|
||
.slot-skel {
|
||
height: 112rpx;
|
||
border-radius: 18rpx;
|
||
margin-bottom: 12rpx;
|
||
background: linear-gradient(90deg, #efe8df 25%, #f7f2ea 50%, #efe8df 75%);
|
||
background-size: 400% 100%;
|
||
animation: shimmer 1.4s infinite;
|
||
}
|
||
|
||
.empty-slots {
|
||
padding: 48rpx 32rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 10rpx;
|
||
}
|
||
|
||
.empty-title {
|
||
font-size: 28rpx;
|
||
font-weight: 700;
|
||
color: $text-primary;
|
||
}
|
||
|
||
.empty-sub {
|
||
font-size: 24rpx;
|
||
color: $text-hint;
|
||
text-align: center;
|
||
}
|
||
|
||
.ghost-link {
|
||
margin-top: 8rpx;
|
||
padding: 10rpx 24rpx;
|
||
border-radius: 999rpx;
|
||
border: 1rpx solid $brand-color;
|
||
}
|
||
|
||
.ghost-link-text {
|
||
font-size: 22rpx;
|
||
color: $brand-color;
|
||
}
|
||
|
||
.slot-list {
|
||
padding: 16rpx 24rpx 8rpx;
|
||
}
|
||
|
||
.slot-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 20rpx;
|
||
background: #fff;
|
||
border-radius: 18rpx;
|
||
padding: 22rpx 20rpx;
|
||
margin-bottom: 12rpx;
|
||
border: 1rpx solid rgba(180, 160, 130, 0.12);
|
||
}
|
||
|
||
.slot-row--disabled {
|
||
opacity: 0.45;
|
||
}
|
||
|
||
.slot-time-col {
|
||
width: 120rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.slot-time {
|
||
font-size: 34rpx;
|
||
font-weight: 800;
|
||
color: $text-primary;
|
||
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||
}
|
||
|
||
.slot-end {
|
||
font-size: 22rpx;
|
||
color: $text-hint;
|
||
}
|
||
|
||
.slot-body {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4rpx;
|
||
}
|
||
|
||
.slot-title {
|
||
font-size: 26rpx;
|
||
font-weight: 600;
|
||
color: $text-primary;
|
||
}
|
||
|
||
.slot-cap {
|
||
font-size: 22rpx;
|
||
color: $text-hint;
|
||
}
|
||
|
||
.slot-action {
|
||
font-size: 24rpx;
|
||
font-weight: 700;
|
||
color: $accent-color;
|
||
}
|
||
|
||
.custom-block {
|
||
margin: 12rpx 24rpx 40rpx;
|
||
padding: 24rpx;
|
||
background: #fff;
|
||
border-radius: 20rpx;
|
||
border: 1rpx dashed rgba(180, 160, 130, 0.28);
|
||
}
|
||
|
||
.custom-title {
|
||
display: block;
|
||
font-size: 22rpx;
|
||
letter-spacing: 3rpx;
|
||
color: $text-hint;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.custom-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
.custom-picker {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4rpx;
|
||
}
|
||
|
||
.custom-picker-text {
|
||
font-size: 30rpx;
|
||
font-weight: 700;
|
||
color: $text-primary;
|
||
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||
}
|
||
|
||
.custom-picker-arrow {
|
||
font-size: 20rpx;
|
||
color: $text-hint;
|
||
}
|
||
|
||
.custom-btn {
|
||
padding: 16rpx 22rpx;
|
||
border-radius: 14rpx;
|
||
background: $brand-color;
|
||
}
|
||
|
||
.custom-btn--disabled { opacity: 0.4; }
|
||
|
||
.custom-btn-text {
|
||
font-size: 24rpx;
|
||
font-weight: 700;
|
||
color: #fff8f0;
|
||
}
|
||
|
||
.mask {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(44, 36, 28, 0.45);
|
||
display: flex;
|
||
align-items: flex-end;
|
||
z-index: 20;
|
||
}
|
||
|
||
.sheet {
|
||
width: 100%;
|
||
background: #fff8f0;
|
||
border-radius: 28rpx 28rpx 0 0;
|
||
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
|
||
}
|
||
|
||
.sheet-kicker {
|
||
font-size: 20rpx;
|
||
letter-spacing: 4rpx;
|
||
color: $text-hint;
|
||
}
|
||
|
||
.sheet-title {
|
||
display: block;
|
||
margin-top: 8rpx;
|
||
font-size: 36rpx;
|
||
font-weight: 700;
|
||
color: $text-primary;
|
||
font-family: 'Songti SC', Georgia, serif;
|
||
}
|
||
|
||
.sheet-lines {
|
||
margin: 28rpx 0 16rpx;
|
||
}
|
||
|
||
.sheet-line {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
padding: 14rpx 0;
|
||
border-bottom: 1rpx solid rgba(180, 160, 130, 0.14);
|
||
}
|
||
|
||
.sheet-label {
|
||
font-size: 24rpx;
|
||
color: $text-hint;
|
||
}
|
||
|
||
.sheet-value {
|
||
font-size: 26rpx;
|
||
color: $text-primary;
|
||
}
|
||
|
||
.sheet-note {
|
||
display: block;
|
||
font-size: 24rpx;
|
||
color: $text-secondary;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.sheet-actions {
|
||
display: flex;
|
||
gap: 16rpx;
|
||
margin-top: 28rpx;
|
||
}
|
||
|
||
.sheet-cancel,
|
||
.sheet-ok {
|
||
flex: 1;
|
||
height: 84rpx;
|
||
border-radius: 16rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.sheet-cancel {
|
||
background: #fff;
|
||
border: 2rpx solid $brand-color;
|
||
}
|
||
|
||
.sheet-ok {
|
||
background: $brand-color;
|
||
}
|
||
|
||
.sheet-ok--disabled { opacity: 0.5; }
|
||
|
||
.sheet-cancel-text {
|
||
font-size: 28rpx;
|
||
font-weight: 700;
|
||
color: $brand-color;
|
||
}
|
||
|
||
.sheet-ok-text {
|
||
font-size: 28rpx;
|
||
font-weight: 700;
|
||
color: #fff8f0;
|
||
}
|
||
</style>
|