431 lines
13 KiB
Vue
431 lines
13 KiB
Vue
<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>
|