534 lines
14 KiB
Vue
534 lines
14 KiB
Vue
<template>
|
||
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||
<CustomNavBar title="排课模板" show-back />
|
||
<!-- Toolbar -->
|
||
<view class="toolbar">
|
||
<text class="toolbar-hint">共 {{ templates.length }} 条模板</text>
|
||
<view class="add-btn" @tap="openAdd">
|
||
<text class="add-btn-text">+ 新增时段</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Loading skeleton -->
|
||
<view v-if="loading" class="skeleton-list">
|
||
<view v-for="i in 5" :key="i" class="skeleton-item" />
|
||
</view>
|
||
|
||
<!-- Empty -->
|
||
<view v-else-if="!templates.length" class="empty-state">
|
||
<text class="empty-icon">📅</text>
|
||
<text class="empty-text">暂无模板,点击右上角新增</text>
|
||
</view>
|
||
|
||
<!-- Template list grouped by weekday -->
|
||
<view v-else>
|
||
<view v-for="(group, day) in grouped" :key="day" class="day-group">
|
||
<view class="day-header">
|
||
<text class="day-label">{{ WEEKDAY_LABELS[Number(day)] }}</text>
|
||
<text class="day-count">{{ group.length }} 个时段</text>
|
||
</view>
|
||
<view
|
||
v-for="tpl in group"
|
||
:key="tpl.id ?? tpl._key"
|
||
class="tpl-row"
|
||
:class="{ 'tpl-row--inactive': !tpl.isActive }"
|
||
>
|
||
<view class="tpl-time">
|
||
<text class="tpl-time-text">{{ tpl.startTime }} – {{ tpl.endTime }}</text>
|
||
<text class="tpl-capacity">{{ tpl.capacity }} 人</text>
|
||
</view>
|
||
<view class="tpl-actions">
|
||
<view
|
||
class="tpl-toggle"
|
||
:class="tpl.isActive ? 'toggle--on' : 'toggle--off'"
|
||
@tap="toggleTemplate(tpl)"
|
||
>
|
||
<text class="tpl-toggle-text">{{ tpl.isActive ? '启用' : '停用' }}</text>
|
||
</view>
|
||
<view class="tpl-edit" @tap="openEdit(tpl)">
|
||
<text class="tpl-edit-text">编辑</text>
|
||
</view>
|
||
<view class="tpl-delete" @tap="deleteTemplate(tpl)">
|
||
<text class="tpl-delete-text">删除</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Save bar -->
|
||
<view v-if="isDirty" class="save-bar">
|
||
<view class="save-btn" :class="{ 'save-btn--loading': saving }" @tap="handleSave">
|
||
<text class="save-btn-text">{{ saving ? '保存中...' : '保存全部更改' }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Add / Edit modal -->
|
||
<view v-if="showModal" class="modal-mask" @tap.self="closeModal">
|
||
<view class="modal">
|
||
<text class="modal-title">{{ editTarget ? '编辑时段' : '新增时段' }}</text>
|
||
|
||
<view class="modal-field">
|
||
<text class="modal-label">星期</text>
|
||
<picker
|
||
mode="selector"
|
||
:range="dayOptions"
|
||
range-key="label"
|
||
:value="form.dayIdx"
|
||
@change="(e: any) => form.dayIdx = Number(e.detail.value)"
|
||
>
|
||
<view class="picker-display">
|
||
<text class="picker-text">{{ dayOptions[form.dayIdx].label }}</text>
|
||
<text class="picker-arrow">›</text>
|
||
</view>
|
||
</picker>
|
||
</view>
|
||
|
||
<view class="modal-field">
|
||
<text class="modal-label">开始时间</text>
|
||
<picker
|
||
mode="time"
|
||
:value="form.startTime"
|
||
@change="(e: any) => form.startTime = e.detail.value"
|
||
>
|
||
<view class="picker-display">
|
||
<text class="picker-text">{{ form.startTime || '请选择' }}</text>
|
||
<text class="picker-arrow">›</text>
|
||
</view>
|
||
</picker>
|
||
</view>
|
||
|
||
<view class="modal-field">
|
||
<text class="modal-label">结束时间</text>
|
||
<picker
|
||
mode="time"
|
||
:value="form.endTime"
|
||
@change="(e: any) => form.endTime = e.detail.value"
|
||
>
|
||
<view class="picker-display">
|
||
<text class="picker-text">{{ form.endTime || '请选择' }}</text>
|
||
<text class="picker-arrow">›</text>
|
||
</view>
|
||
</picker>
|
||
</view>
|
||
|
||
<view class="modal-field modal-field--last">
|
||
<text class="modal-label">容量</text>
|
||
<input
|
||
class="modal-input"
|
||
type="number"
|
||
v-model="form.capacityStr"
|
||
placeholder="如:10"
|
||
placeholder-style="color:#bbb"
|
||
/>
|
||
</view>
|
||
|
||
<view class="modal-actions">
|
||
<view class="modal-cancel" @tap="closeModal">
|
||
<text class="modal-cancel-text">取消</text>
|
||
</view>
|
||
<view class="modal-confirm" @tap="submitForm">
|
||
<text class="modal-confirm-text">确认</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||
import { useAdminStore } from '../../stores/admin'
|
||
import { WEEKDAY_LABELS } from '@mp-pilates/shared'
|
||
import type { WeekTemplate } from '@mp-pilates/shared'
|
||
|
||
type LocalTemplate = Partial<WeekTemplate> & {
|
||
_key?: string
|
||
dayOfWeek: number
|
||
startTime: string
|
||
endTime: string
|
||
capacity: number
|
||
isActive: boolean
|
||
}
|
||
|
||
const adminStore = useAdminStore()
|
||
const navBarHeight = ref('64px')
|
||
const loading = ref(false)
|
||
const saving = ref(false)
|
||
const isDirty = ref(false)
|
||
const showModal = ref(false)
|
||
const editTarget = ref<LocalTemplate | null>(null)
|
||
|
||
const templates = ref<LocalTemplate[]>([])
|
||
|
||
const dayOptions = [1, 2, 3, 4, 5, 6, 7].map((d) => ({ label: WEEKDAY_LABELS[d], value: d }))
|
||
|
||
const form = ref({
|
||
dayIdx: 0,
|
||
startTime: '08:00',
|
||
endTime: '09:00',
|
||
capacityStr: '1',
|
||
})
|
||
|
||
const grouped = computed(() => {
|
||
const map: Record<number, LocalTemplate[]> = {}
|
||
for (const tpl of templates.value) {
|
||
if (!map[tpl.dayOfWeek]) map[tpl.dayOfWeek] = []
|
||
map[tpl.dayOfWeek].push(tpl)
|
||
}
|
||
// Sort by day
|
||
return Object.fromEntries(
|
||
Object.entries(map).sort(([a], [b]) => Number(a) - Number(b)),
|
||
)
|
||
})
|
||
|
||
/** 生成默认模板:周一到周日,8:00-22:00 每小时一个时段 */
|
||
function generateDefaultTemplates(): LocalTemplate[] {
|
||
const defaults: LocalTemplate[] = []
|
||
for (let day = 1; day <= 7; day++) {
|
||
for (let hour = 8; hour < 22; hour++) {
|
||
const start = String(hour).padStart(2, '0') + ':00'
|
||
const end = String(hour + 1).padStart(2, '0') + ':00'
|
||
defaults.push({
|
||
_key: `default-${day}-${start}`,
|
||
dayOfWeek: day,
|
||
startTime: start,
|
||
endTime: end,
|
||
capacity: 1,
|
||
isActive: true,
|
||
})
|
||
}
|
||
}
|
||
return defaults
|
||
}
|
||
|
||
async function fetchTemplates() {
|
||
loading.value = true
|
||
try {
|
||
const data = await adminStore.fetchWeekTemplates()
|
||
if (data.length === 0) {
|
||
// No templates yet — pre-fill with defaults
|
||
templates.value = generateDefaultTemplates()
|
||
isDirty.value = true
|
||
} else {
|
||
templates.value = data
|
||
isDirty.value = false
|
||
}
|
||
} catch {
|
||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function openAdd() {
|
||
editTarget.value = null
|
||
form.value = { dayIdx: 0, startTime: '08:00', endTime: '09:00', capacityStr: '1' }
|
||
showModal.value = true
|
||
}
|
||
|
||
function openEdit(tpl: LocalTemplate) {
|
||
editTarget.value = tpl
|
||
const dayIdx = dayOptions.findIndex((d) => d.value === tpl.dayOfWeek)
|
||
form.value = {
|
||
dayIdx: dayIdx >= 0 ? dayIdx : 0,
|
||
startTime: tpl.startTime,
|
||
endTime: tpl.endTime,
|
||
capacityStr: String(tpl.capacity),
|
||
}
|
||
showModal.value = true
|
||
}
|
||
|
||
function closeModal() {
|
||
showModal.value = false
|
||
editTarget.value = null
|
||
}
|
||
|
||
function submitForm() {
|
||
const capacity = parseInt(form.value.capacityStr, 10)
|
||
if (!form.value.startTime || !form.value.endTime) {
|
||
uni.showToast({ title: '请填写时间', icon: 'none' })
|
||
return
|
||
}
|
||
if (isNaN(capacity) || capacity < 1) {
|
||
uni.showToast({ title: '请填写有效容量', icon: 'none' })
|
||
return
|
||
}
|
||
|
||
const day = dayOptions[form.value.dayIdx].value
|
||
|
||
if (editTarget.value) {
|
||
const tpl = editTarget.value
|
||
tpl.dayOfWeek = day
|
||
tpl.startTime = form.value.startTime
|
||
tpl.endTime = form.value.endTime
|
||
tpl.capacity = capacity
|
||
} else {
|
||
templates.value.push({
|
||
_key: String(Date.now()),
|
||
dayOfWeek: day,
|
||
startTime: form.value.startTime,
|
||
endTime: form.value.endTime,
|
||
capacity,
|
||
isActive: true,
|
||
})
|
||
}
|
||
|
||
isDirty.value = true
|
||
closeModal()
|
||
}
|
||
|
||
function toggleTemplate(tpl: LocalTemplate) {
|
||
tpl.isActive = !tpl.isActive
|
||
isDirty.value = true
|
||
}
|
||
|
||
function deleteTemplate(tpl: LocalTemplate) {
|
||
uni.showModal({
|
||
title: '确认删除',
|
||
content: '删除该时段模板?',
|
||
success: (res) => {
|
||
if (res.confirm) {
|
||
const idx = templates.value.indexOf(tpl)
|
||
if (idx >= 0) templates.value.splice(idx, 1)
|
||
isDirty.value = true
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
async function handleSave() {
|
||
if (saving.value) return
|
||
saving.value = true
|
||
try {
|
||
const payload = templates.value.map((t) => ({
|
||
id: t.id,
|
||
dayOfWeek: t.dayOfWeek,
|
||
startTime: t.startTime,
|
||
endTime: t.endTime,
|
||
capacity: t.capacity,
|
||
isActive: t.isActive,
|
||
}))
|
||
await adminStore.saveWeekTemplates(payload as any)
|
||
isDirty.value = false
|
||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||
await fetchTemplates()
|
||
} catch (e: any) {
|
||
uni.showToast({ title: e?.message ?? '保存失败', icon: 'none' })
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
const sys = uni.getSystemInfoSync()
|
||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
||
fetchTemplates()
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.page {
|
||
min-height: 100vh;
|
||
background: #f5f3f0;
|
||
padding-bottom: 120rpx;
|
||
}
|
||
|
||
/* ── Toolbar ─────────────────────────────── */
|
||
.toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 24rpx 24rpx 16rpx;
|
||
}
|
||
|
||
.toolbar-hint { font-size: 24rpx; color: #999; }
|
||
|
||
.add-btn {
|
||
background: #1a1a2e;
|
||
border-radius: 32rpx;
|
||
padding: 12rpx 28rpx;
|
||
}
|
||
|
||
.add-btn-text { font-size: 26rpx; font-weight: 600; color: #c9a87c; }
|
||
|
||
/* ── Skeleton ────────────────────────────── */
|
||
.skeleton-list { padding: 0 24rpx; }
|
||
|
||
.skeleton-item {
|
||
height: 80rpx;
|
||
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;
|
||
}
|
||
|
||
@keyframes shimmer {
|
||
0% { background-position: 100% 0; }
|
||
100% { background-position: -100% 0; }
|
||
}
|
||
|
||
/* ── Empty ───────────────────────────────── */
|
||
.empty-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 100rpx 0;
|
||
gap: 20rpx;
|
||
}
|
||
|
||
.empty-icon { font-size: 80rpx; }
|
||
.empty-text { font-size: 28rpx; color: #bbb; }
|
||
|
||
/* ── Day group ───────────────────────────── */
|
||
.day-group { margin: 0 24rpx 24rpx; }
|
||
|
||
.day-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 16rpx 8rpx;
|
||
}
|
||
|
||
.day-label { font-size: 28rpx; font-weight: 700; color: #1a1a2e; }
|
||
.day-count { font-size: 22rpx; color: #999; }
|
||
|
||
/* ── Template row ────────────────────────── */
|
||
.tpl-row {
|
||
background: #ffffff;
|
||
border-radius: 12rpx;
|
||
padding: 20rpx 24rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 12rpx;
|
||
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
|
||
|
||
&--inactive { opacity: 0.5; }
|
||
}
|
||
|
||
.tpl-time { display: flex; flex-direction: column; gap: 6rpx; }
|
||
.tpl-time-text { font-size: 28rpx; font-weight: 600; color: #1a1a2e; }
|
||
.tpl-capacity { font-size: 22rpx; color: #888; }
|
||
|
||
.tpl-actions { display: flex; gap: 12rpx; }
|
||
|
||
.tpl-toggle,
|
||
.tpl-edit,
|
||
.tpl-delete {
|
||
border-radius: 20rpx;
|
||
padding: 8rpx 20rpx;
|
||
}
|
||
|
||
.toggle--on { background: rgba(39,174,96,0.12); }
|
||
.toggle--on .tpl-toggle-text { font-size: 24rpx; color: #27ae60; }
|
||
.toggle--off { background: rgba(230,126,34,0.12); }
|
||
.toggle--off .tpl-toggle-text { font-size: 24rpx; color: #e67e22; }
|
||
|
||
.tpl-edit { background: rgba(26,26,46,0.08); }
|
||
.tpl-edit-text { font-size: 24rpx; color: #1a1a2e; }
|
||
|
||
.tpl-delete { background: rgba(192,57,43,0.08); }
|
||
.tpl-delete-text { font-size: 24rpx; color: #c0392b; }
|
||
|
||
/* ── Save bar ────────────────────────────── */
|
||
.save-bar {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
padding: 20rpx 24rpx 48rpx;
|
||
background: #ffffff;
|
||
box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.08);
|
||
}
|
||
|
||
.save-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; }
|
||
}
|
||
|
||
.save-btn-text { font-size: 30rpx; font-weight: 700; color: #c9a87c; }
|
||
|
||
/* ── Modal ───────────────────────────────── */
|
||
.modal-mask {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0,0,0,0.5);
|
||
display: flex;
|
||
align-items: flex-end;
|
||
z-index: 100;
|
||
}
|
||
|
||
.modal {
|
||
width: 100%;
|
||
background: #ffffff;
|
||
border-radius: 24rpx 24rpx 0 0;
|
||
padding: 40rpx 32rpx 60rpx;
|
||
}
|
||
|
||
.modal-title {
|
||
font-size: 32rpx;
|
||
font-weight: 700;
|
||
color: #1a1a2e;
|
||
display: block;
|
||
margin-bottom: 24rpx;
|
||
}
|
||
|
||
.modal-field {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 24rpx 0;
|
||
border-bottom: 1rpx solid #f5f5f5;
|
||
|
||
&--last { border-bottom: none; }
|
||
}
|
||
|
||
.modal-label { font-size: 26rpx; color: #555; width: 140rpx; flex-shrink: 0; }
|
||
|
||
.modal-input { flex: 1; text-align: right; font-size: 26rpx; color: #222; }
|
||
|
||
.picker-display { display: flex; align-items: center; gap: 8rpx; }
|
||
.picker-text { font-size: 26rpx; color: #222; }
|
||
.picker-arrow { font-size: 26rpx; color: #bbb; }
|
||
|
||
.modal-actions {
|
||
display: flex;
|
||
gap: 16rpx;
|
||
margin-top: 32rpx;
|
||
}
|
||
|
||
.modal-cancel {
|
||
flex: 1;
|
||
height: 88rpx;
|
||
background: #f0f0f0;
|
||
border-radius: 44rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.modal-cancel-text { font-size: 28rpx; color: #555; }
|
||
|
||
.modal-confirm {
|
||
flex: 2;
|
||
height: 88rpx;
|
||
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
|
||
border-radius: 44rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.modal-confirm-text { font-size: 28rpx; font-weight: 700; color: #c9a87c; }
|
||
</style>
|