Files
mp-pilates/packages/app/src/pages/admin/slot-adjust.vue
2026-04-05 21:35:30 +08:00

431 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="时段调整" show-back />
<!-- Tabs -->
<view class="tabs">
<view
v-for="(tab, i) in tabs"
:key="i"
class="tab"
:class="{ 'tab--active': activeTab === i }"
@tap="activeTab = i"
>
<text class="tab-text">{{ tab }}</text>
</view>
</view>
<!-- Add slot -->
<view v-if="activeTab === 0" class="panel">
<view class="form-card">
<view class="form-row">
<text class="form-label">日期</text>
<picker mode="date" :value="addForm.date" @change="(e: any) => addForm.date = e.detail.value">
<view class="picker-display">
<text class="picker-text">{{ addForm.date || '请选择' }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="form-row">
<text class="form-label">开始时间</text>
<picker mode="time" :value="addForm.startTime" @change="(e: any) => addForm.startTime = e.detail.value">
<view class="picker-display">
<text class="picker-text">{{ addForm.startTime || '请选择' }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="form-row">
<text class="form-label">结束时间</text>
<picker mode="time" :value="addForm.endTime" @change="(e: any) => addForm.endTime = e.detail.value">
<view class="picker-display">
<text class="picker-text">{{ addForm.endTime || '请选择' }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="form-row form-row--last">
<text class="form-label">容量</text>
<input
class="form-input"
type="number"
v-model="addForm.capacityStr"
placeholder="如10"
placeholder-style="color:#bbb"
/>
</view>
</view>
<view class="action-wrap">
<view class="action-btn" :class="{ 'action-btn--loading': submitting }" @tap="submitAddSlot">
<text class="action-btn-text">{{ submitting ? '提交中...' : '新增时段' }}</text>
</view>
</view>
</view>
<!-- Close slot -->
<view v-else-if="activeTab === 1" class="panel">
<view class="date-picker-row">
<picker mode="date" :value="closeDate" @change="(e: any) => { closeDate = e.detail.value; loadSlotsForClose() }">
<view class="picker-display">
<text class="picker-label">选择日期</text>
<text class="picker-text">{{ closeDate }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view v-if="slotsLoading" class="skeleton-list">
<view v-for="i in 4" :key="i" class="skeleton-item" />
</view>
<view v-else-if="!daySlots.length" class="empty-state">
<text class="empty-icon">📭</text>
<text class="empty-text">该日暂无时段</text>
</view>
<view v-else class="slot-list">
<view v-for="slot in daySlots" :key="slot.id" class="slot-row">
<view class="slot-info">
<text class="slot-time">{{ slot.startTime }} {{ slot.endTime }}</text>
<view class="slot-badge" :class="slotBadgeClass(slot.status)">
<text class="slot-badge-text">{{ slot.status }}</text>
</view>
</view>
<text class="slot-count">{{ slot.bookedCount }}/{{ slot.capacity }}</text>
<view
v-if="slot.status !== 'CLOSED'"
class="close-btn"
@tap="closeSlot(slot)"
>
<text class="close-btn-text">关闭</text>
</view>
<view v-else class="closed-tag">
<text class="closed-tag-text">已关闭</text>
</view>
</view>
</view>
</view>
<!-- Batch generate -->
<view v-else class="panel">
<view class="form-card">
<view class="form-row">
<text class="form-label">开始日期</text>
<picker mode="date" :value="genForm.startDate" @change="(e: any) => genForm.startDate = e.detail.value">
<view class="picker-display">
<text class="picker-text">{{ genForm.startDate || '请选择' }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="form-row form-row--last">
<text class="form-label">结束日期</text>
<picker mode="date" :value="genForm.endDate" @change="(e: any) => genForm.endDate = e.detail.value">
<view class="picker-display">
<text class="picker-text">{{ genForm.endDate || '请选择' }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
</view>
<text class="gen-hint">将根据排课模板自动生成所选日期范围内的时段</text>
<view class="action-wrap">
<view class="action-btn" :class="{ 'action-btn--loading': submitting }" @tap="submitGenerate">
<text class="action-btn-text">{{ submitting ? '生成中...' : '批量生成' }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin'
import { formatDate } from '../../utils/format'
import type { TimeSlot } from '@mp-pilates/shared'
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
const tabs = ['新增时段', '关闭时段', '批量生成']
const activeTab = ref(0)
const submitting = ref(false)
const slotsLoading = ref(false)
// ── Add slot form ────────────────────────────────────────────────
const addForm = ref({
date: formatDate(new Date()),
startTime: '09:00',
endTime: '10:00',
capacityStr: '10',
})
async function submitAddSlot() {
if (submitting.value) return
if (!addForm.value.date || !addForm.value.startTime || !addForm.value.endTime) {
uni.showToast({ title: '请填写完整信息', icon: 'none' })
return
}
const capacity = parseInt(addForm.value.capacityStr, 10)
submitting.value = true
try {
await adminStore.createManualSlot({
date: addForm.value.date,
startTime: addForm.value.startTime,
endTime: addForm.value.endTime,
capacity: isNaN(capacity) ? undefined : capacity,
})
uni.showToast({ title: '新增成功', icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e?.message ?? '新增失败', icon: 'none' })
} finally {
submitting.value = false
}
}
// ── Close slot ────────────────────────────────────────────────────
const closeDate = ref(formatDate(new Date()))
const daySlots = ref<TimeSlot[]>([])
async function loadSlotsForClose() {
slotsLoading.value = true
try {
daySlots.value = await adminStore.fetchSlotsByDate(closeDate.value)
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
slotsLoading.value = false
}
}
async function closeSlot(slot: TimeSlot) {
uni.showModal({
title: '确认关闭',
content: `关闭 ${slot.startTime}${slot.endTime} 时段?`,
success: async (res) => {
if (res.confirm) {
try {
await adminStore.closeSlot(slot.id)
uni.showToast({ title: '已关闭', icon: 'success' })
await loadSlotsForClose()
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
},
})
}
function slotBadgeClass(status: string) {
if (status === 'OPEN') return 'badge--open'
if (status === 'FULL') return 'badge--full'
return 'badge--closed'
}
// ── Batch generate ────────────────────────────────────────────────
const genForm = ref({
startDate: formatDate(new Date()),
endDate: formatDate(new Date(Date.now() + 7 * 86400000)),
})
async function submitGenerate() {
if (submitting.value) return
if (!genForm.value.startDate || !genForm.value.endDate) {
uni.showToast({ title: '请选择日期范围', icon: 'none' })
return
}
submitting.value = true
try {
const result = await adminStore.generateSlots(genForm.value.startDate, genForm.value.endDate)
uni.showToast({ title: `生成 ${result.count} 个时段`, icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e?.message ?? '生成失败', icon: 'none' })
} finally {
submitting.value = false
}
}
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
})
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f3f0;
padding-bottom: 40rpx;
}
/* ── Tabs ────────────────────────────────── */
.tabs {
display: flex;
background: #ffffff;
border-bottom: 1rpx solid #eee;
}
.tab {
flex: 1;
padding: 28rpx 0;
display: flex;
align-items: center;
justify-content: center;
}
.tab-text {
font-size: 28rpx;
color: #999;
font-weight: 500;
}
.tab--active .tab-text {
color: #1a1a2e;
font-weight: 700;
}
.tab--active {
border-bottom: 4rpx solid $primary-dark;
}
/* ── Panel ───────────────────────────────── */
.panel {
padding: 24rpx;
}
/* ── Form card ───────────────────────────── */
.form-card {
background: #ffffff;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05);
margin-bottom: 24rpx;
}
.form-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 28rpx;
border-bottom: 1rpx solid #f5f5f5;
&--last { border-bottom: none; }
}
.form-label { font-size: 28rpx; color: #555; width: 160rpx; flex-shrink: 0; }
.form-input { flex: 1; text-align: right; font-size: 28rpx; color: #222; background: transparent; }
.picker-display { display: flex; align-items: center; gap: 8rpx; }
.picker-label { font-size: 28rpx; color: #555; }
.picker-text { font-size: 28rpx; color: #222; }
.picker-arrow { font-size: 26rpx; color: #bbb; }
/* ── Date picker row ─────────────────────── */
.date-picker-row {
background: #ffffff;
border-radius: 16rpx;
padding: 24rpx 28rpx;
margin-bottom: 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05);
}
/* ── Skeleton ────────────────────────────── */
.skeleton-list { }
.skeleton-item {
height: 88rpx;
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: 80rpx 0;
gap: 16rpx;
}
.empty-icon { font-size: 64rpx; }
.empty-text { font-size: 28rpx; color: #bbb; }
/* ── Slot list ───────────────────────────── */
.slot-list { }
.slot-row {
background: #ffffff;
border-radius: 12rpx;
padding: 24rpx;
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
}
.slot-info { flex: 1; display: flex; align-items: center; gap: 12rpx; }
.slot-time { font-size: 28rpx; font-weight: 600; color: #1a1a2e; }
.slot-badge {
border-radius: 16rpx;
padding: 4rpx 16rpx;
}
.badge--open { background: rgba(39,174,96,0.1); }
.badge--open .slot-badge-text { font-size: 20rpx; color: #27ae60; }
.badge--full { background: rgba(230,126,34,0.1); }
.badge--full .slot-badge-text { font-size: 20rpx; color: #e67e22; }
.badge--closed { background: rgba(0,0,0,0.06); }
.badge--closed .slot-badge-text { font-size: 20rpx; color: #999; }
.slot-count { font-size: 24rpx; color: #888; }
.close-btn {
background: rgba(192,57,43,0.1);
border-radius: 20rpx;
padding: 8rpx 24rpx;
}
.close-btn-text { font-size: 26rpx; color: #c0392b; font-weight: 600; }
.closed-tag {
background: rgba(0,0,0,0.06);
border-radius: 20rpx;
padding: 8rpx 24rpx;
}
.closed-tag-text { font-size: 26rpx; color: #bbb; }
/* ── Generate hint ───────────────────────── */
.gen-hint {
font-size: 24rpx;
color: #999;
line-height: 1.6;
display: block;
padding: 0 8rpx 24rpx;
}
/* ── Action button ───────────────────────── */
.action-wrap { }
.action-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; }
}
.action-btn-text { font-size: 30rpx; font-weight: 700; color: $primary-dark; }
</style>