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.
178 lines
8.3 KiB
Vue
178 lines
8.3 KiB
Vue
<template>
|
|
<view class="container">
|
|
<view class="card">
|
|
<view class="card-title">改约原因</view>
|
|
<textarea class="reason-input" v-model="reason" placeholder="请输入改约原因" maxlength="200" />
|
|
</view>
|
|
|
|
<!-- 复用日历和时间段选择逻辑 -->
|
|
<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">
|
|
<text class="period-time">{{ p.time_range }}</text>
|
|
<text class="period-remain">余 {{ p.remain_count || 0 }}</text>
|
|
</view>
|
|
<view v-if="!p.slots">
|
|
<view class="slot-item" :class="{ disabled: !p.enable, selected: selectedPlanId === p.plan_id }" @tap="p.enable && selectSlot(p)">
|
|
{{ p.enable ? '可选择' : '已满' }}
|
|
</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 class="bottom-bar" v-if="selectedPlanId">
|
|
<view class="btn-primary" @tap="handleReschedule" :class="{ disabled: submitting }">
|
|
{{ submitting ? '提交中...' : '确认改约' }}
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script>
|
|
import { getPlanCalendar, getAvailablePlans } from '../../services/plan'
|
|
import { rescheduleAppointment } from '../../services/appointment'
|
|
|
|
export default {
|
|
data() {
|
|
return {
|
|
appointmentId: '',
|
|
applicationId: '',
|
|
reason: '',
|
|
year: new Date().getFullYear(),
|
|
month: new Date().getMonth() + 1,
|
|
weeks: ['日', '一', '二', '三', '四', '五', '六'],
|
|
calendarDays: [],
|
|
calendarData: {},
|
|
selectedDate: '',
|
|
periods: [],
|
|
selectedPlanId: null,
|
|
submitting: false,
|
|
loading: false
|
|
}
|
|
},
|
|
onLoad(options) {
|
|
this.appointmentId = options.appointment_id || ''
|
|
this.applicationId = options.application_id || ''
|
|
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 res = await getPlanCalendar({ exam_application_ids: [Number(this.applicationId)], 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 res = await getAvailablePlans({ exam_application_ids: [Number(this.applicationId)], channel_id: 3, start_date: this.selectedDate })
|
|
const resources = res.data.resources || []
|
|
this.periods = []
|
|
resources.forEach(r => { if (r.periods) this.periods.push(...r.periods) })
|
|
} 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 },
|
|
async handleReschedule() {
|
|
if (this.submitting) return
|
|
if (!this.reason.trim()) { uni.showToast({ title: '请输入改约原因', icon: 'none' }); return }
|
|
this.submitting = true
|
|
try {
|
|
await rescheduleAppointment({
|
|
appointment_id: Number(this.appointmentId),
|
|
plan_id: Number(this.selectedPlanId),
|
|
cancel_reason: this.reason.trim()
|
|
})
|
|
uni.showToast({ title: '改约成功', icon: 'success' })
|
|
setTimeout(() => { uni.navigateBack({ delta: 2 }) }, 1500)
|
|
} catch (e) {}
|
|
this.submitting = false
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.reason-input { width: 100%; height: 160rpx; background: #F5F7FA; border-radius: 12rpx; padding: 20rpx; font-size: 28rpx; box-sizing: border-box; }
|
|
.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; }
|
|
.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; margin-bottom: 16rpx; }
|
|
.period-time { font-size: 30rpx; font-weight: 600; color: #333; }
|
|
.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); }
|
|
.bottom-bar .btn-primary.disabled { opacity: 0.6; }
|
|
</style>
|