You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

221 lines
8.5 KiB
Vue

<template>
<view class="container">
<!-- 月份切换 -->
<view class="month-bar">
<view class="month-btn" @tap="prevMonth"></view>
<view class="month-text">{{ year }}{{ month }}</view>
<view class="month-btn" @tap="nextMonth"></view>
</view>
<!-- -->
<view class="calendar card">
<view class="week-row">
<view class="week-item" v-for="w in weeks" :key="w">{{ w }}</view>
</view>
<view class="date-grid">
<view class="date-item" v-for="(d, idx) in calendarDays" :key="idx"
:class="{ selected: selectedDate === d.date, hasPlan: d.available > 0, disabled: !d.date || d.past }"
@tap="selectDate(d)">
<view class="date-num">{{ d.day }}</view>
<view class="date-dot" v-if="d.available > 0"></view>
</view>
</view>
</view>
<!-- 时间段列表 -->
<view v-if="selectedDate && periods.length > 0" class="periods">
<view class="section-title">{{ selectedDate }} 可预约时段</view>
<view class="period-card card" v-for="p in periods" :key="p.period_id">
<view class="period-header">
<view class="period-left">
<text class="period-time">{{ p.time_range }}</text>
<text class="period-room" v-if="p.room_name">{{ p.room_name }}</text>
</view>
<text class="period-remain" v-if="p.remain_count !== undefined">余 {{ p.remain_count }}</text>
</view>
<!-- 时间段模式 -->
<view v-if="!p.slots">
<view class="slot-item" :class="{ disabled: !p.enable }" @tap="selectSlot(p)">
<text>{{ p.enable ? '可选择' : '已满' }}</text>
</view>
</view>
<!-- 时间点模式 -->
<view v-else class="slot-list">
<view class="slot-item" v-for="s in p.slots" :key="s.id"
:class="{ disabled: !s.enable, selected: selectedPlanId === s.id }"
@tap="s.enable && selectTimePoint(s)">
{{ s.time }}
</view>
</view>
</view>
</view>
<view v-if="selectedDate && periods.length === 0 && !loading" class="empty-state">
<view class="empty-icon">📅</view>
<view>该日期暂无可用号源</view>
</view>
<!-- 底部确认按钮 -->
<view class="bottom-bar" v-if="selectedPlanId">
<view class="btn-primary" @tap="goConfirm"></view>
</view>
</view>
</template>
<script>
import { getPlanCalendar, getAvailablePlans } from '../../services/plan'
export default {
data() {
return {
applicationIds: '',
year: new Date().getFullYear(),
month: new Date().getMonth() + 1,
weeks: ['日', '一', '二', '三', '四', '五', '六'],
calendarDays: [],
calendarData: {},
selectedDate: '',
periods: [],
selectedPlanId: null,
loading: false
}
},
onLoad(options) {
this.applicationIds = options.application_ids || ''
this.generateCalendar()
this.loadCalendar()
},
methods: {
generateCalendar() {
const firstDay = new Date(this.year, this.month - 1, 1)
const lastDay = new Date(this.year, this.month, 0)
const startWeek = firstDay.getDay()
const totalDays = lastDay.getDate()
const today = new Date()
today.setHours(0,0,0,0)
const days = []
for (let i = 0; i < startWeek; i++) { days.push({ day: '', date: '', past: true, available: 0 }) }
for (let d = 1; d <= totalDays; d++) {
const dateStr = `${this.year}-${String(this.month).padStart(2,'0')}-${String(d).padStart(2,'0')}`
const dateObj = new Date(this.year, this.month - 1, d)
days.push({ day: d, date: dateStr, past: dateObj < today, available: 0 })
}
const remaining = 42 - days.length
for (let i = 0; i < remaining; i++) { days.push({ day: '', date: '', past: true, available: 0 }) }
this.calendarDays = days
},
async loadCalendar() {
this.loading = true
try {
const ids = this.applicationIds.split(',').map(Number)
const res = await getPlanCalendar({
exam_application_ids: ids,
channel_id: 3,
year: this.year,
month: this.month
})
const resources = res.data.resources || []
const dataMap = {}
resources.forEach(r => {
(r.slot_data || []).forEach(d => {
if (!dataMap[d.date]) dataMap[d.date] = 0
dataMap[d.date] += d.available_count
})
})
this.calendarData = dataMap
this.calendarDays.forEach(d => {
if (d.date) d.available = dataMap[d.date] || 0
})
} catch (e) {}
this.loading = false
},
prevMonth() {
this.month--
if (this.month < 1) { this.month = 12; this.year-- }
this.selectedDate = ''
this.periods = []
this.generateCalendar()
this.loadCalendar()
},
nextMonth() {
this.month++
if (this.month > 12) { this.month = 1; this.year++ }
this.selectedDate = ''
this.periods = []
this.generateCalendar()
this.loadCalendar()
},
selectDate(d) {
if (!d.date || d.past || d.available <= 0) return
this.selectedDate = d.date
this.selectedPlanId = null
this.loadAvailable()
},
async loadAvailable() {
this.loading = true
try {
const ids = this.applicationIds.split(',').map(Number)
const res = await getAvailablePlans({
exam_application_ids: ids,
channel_id: 3,
start_date: this.selectedDate
})
const resources = res.data.resources || []
this.periods = []
resources.forEach(r => {
if (r.periods) {
r.periods.forEach(p => {
p.room_name = r.room_name
this.periods.push(p)
})
}
})
this.periods.sort((a, b) => a.time_range.localeCompare(b.time_range))
} catch (e) { this.periods = [] }
this.loading = false
},
selectSlot(p) {
if (!p.enable || !p.plan_id) return
this.selectedPlanId = p.plan_id
},
selectTimePoint(s) {
this.selectedPlanId = s.id
},
goConfirm() {
uni.navigateTo({
url: `/pages/appointment/confirm?application_ids=${this.applicationIds}&plan_id=${this.selectedPlanId}&date=${this.selectedDate}`
})
}
}
}
</script>
<style scoped>
.month-bar { display: flex; justify-content: space-between; align-items: center; padding: 20rpx 30rpx; background: #FFF; border-radius: 16rpx; margin-bottom: 20rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
.month-text { font-size: 34rpx; font-weight: 600; color: #333; }
.month-btn { padding: 16rpx 24rpx; font-size: 28rpx; color: #2B7FD2; }
.week-row { display: flex; }
.week-item { flex: 1; text-align: center; font-size: 24rpx; color: #999; padding: 16rpx 0; }
.date-grid { display: flex; flex-wrap: wrap; }
.date-item { width: calc(100% / 7); display: flex; flex-direction: column; align-items: center; padding: 12rpx 0; position: relative; }
.date-num { font-size: 30rpx; color: #333; width: 64rpx; height: 64rpx; display: flex; align-items: center; justify-content: center; border-radius: 50%; }
.date-item.selected .date-num { background: #2B7FD2; color: #FFF; }
.date-item.disabled .date-num { color: #DDD; }
.date-item.hasPlan .date-num { color: #2B7FD2; font-weight: 600; }
.date-dot { width: 10rpx; height: 10rpx; border-radius: 50%; background: #2B7FD2; margin-top: 4rpx; }
.date-item.selected .date-dot { background: #FFF; }
.section-title { font-size: 30rpx; font-weight: 600; color: #333; margin: 24rpx 0 16rpx; }
.period-card { padding: 24rpx 30rpx; }
.period-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16rpx; }
.period-left { display: flex; align-items: center; gap: 12rpx; }
.period-time { font-size: 30rpx; font-weight: 600; color: #333; }
.period-room { font-size: 22rpx; color: #999; background: #F5F5F5; padding: 2rpx 12rpx; border-radius: 6rpx; }
.period-remain { font-size: 24rpx; color: #4CAF50; }
.slot-list { display: flex; flex-wrap: wrap; gap: 16rpx; }
.slot-item { padding: 16rpx 32rpx; background: #E8F4FD; color: #2B7FD2; border-radius: 8rpx; font-size: 26rpx; }
.slot-item.disabled { background: #F5F5F5; color: #CCC; }
.slot-item.selected { background: #2B7FD2; color: #FFF; }
.bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; padding: 20rpx 30rpx; padding-bottom: calc(20rpx + env(safe-area-inset-bottom)); background: #FFF; box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.06); }
</style>