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

<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>