1003 lines
29 KiB
Vue
1003 lines
29 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import http, { apiErrorMessage } from '../api/http'
|
||
import { useAuthStore } from '../stores/auth'
|
||
|
||
interface TermOption {
|
||
id: string
|
||
name: string
|
||
startDate: string
|
||
endDate: string
|
||
isCurrent: boolean
|
||
hasPublishedTimetable: boolean
|
||
}
|
||
|
||
interface CampusOption {
|
||
id: string
|
||
code: string
|
||
name: string
|
||
}
|
||
|
||
interface BuildingOption {
|
||
id: string
|
||
code: string
|
||
name: string
|
||
campusId: string
|
||
}
|
||
|
||
interface ClassroomOption {
|
||
id: string
|
||
code: string
|
||
name: string
|
||
buildingId: string
|
||
capacity: number
|
||
roomType: string
|
||
}
|
||
|
||
interface AvailableClassroom extends ClassroomOption {
|
||
buildingName: string
|
||
campusId: string
|
||
campusName: string
|
||
}
|
||
|
||
interface TimeSlot {
|
||
periodNumber: number
|
||
name: string
|
||
startTime: string
|
||
endTime: string
|
||
}
|
||
|
||
interface Applicant {
|
||
id: string
|
||
displayName: string
|
||
collegeId: string
|
||
collegeName: string
|
||
}
|
||
|
||
interface Reservation {
|
||
id: string
|
||
applicantUserId: string
|
||
applicantName: string
|
||
applicantCollegeId: string
|
||
applicantCollegeName: string
|
||
academicTermId: string
|
||
academicTermName: string
|
||
classroomId: string
|
||
classroomCode: string
|
||
classroomName: string
|
||
buildingName: string
|
||
campusName: string
|
||
reservationDate: string
|
||
startPeriod: number
|
||
periodCount: number
|
||
attendeeCount: number
|
||
purpose: string
|
||
contactPhone: string
|
||
notes?: string
|
||
status: 'Submitted' | 'Approved' | 'Rejected' | 'Cancelled'
|
||
reviewComment?: string
|
||
reviewedAt?: string
|
||
cancelledAt?: string
|
||
createdAt: string
|
||
}
|
||
|
||
interface PagedResponse {
|
||
items: Reservation[]
|
||
total: number
|
||
page: number
|
||
pageSize: number
|
||
}
|
||
|
||
const auth = useAuthStore()
|
||
const isCollegeAdmin = computed(() =>
|
||
auth.user?.roles.includes('CollegeAdmin') ?? false,
|
||
)
|
||
const activeTab = ref<'mine' | 'review'>('mine')
|
||
const loading = ref(false)
|
||
const submitting = ref(false)
|
||
const availabilityLoading = ref(false)
|
||
const applicant = ref<Applicant | null>(null)
|
||
const applicantProblem = ref('')
|
||
const terms = ref<TermOption[]>([])
|
||
const campuses = ref<CampusOption[]>([])
|
||
const buildings = ref<BuildingOption[]>([])
|
||
const classrooms = ref<ClassroomOption[]>([])
|
||
const timeSlots = ref<TimeSlot[]>([])
|
||
const availableRooms = ref<AvailableClassroom[]>([])
|
||
const availabilityChecked = ref(false)
|
||
const mine = ref<Reservation[]>([])
|
||
const mineTotal = ref(0)
|
||
const minePage = ref(1)
|
||
const reviewItems = ref<Reservation[]>([])
|
||
const reviewTotal = ref(0)
|
||
const reviewPage = ref(1)
|
||
const pageSize = 12
|
||
const reviewStatus = ref<Reservation['status'] | ''>('Submitted')
|
||
|
||
const form = reactive({
|
||
academicTermId: '',
|
||
reservationDate: '',
|
||
campusId: '',
|
||
buildingId: '',
|
||
classroomId: '',
|
||
startPeriod: 1,
|
||
periodCount: 2,
|
||
attendeeCount: 1,
|
||
purpose: '',
|
||
contactPhone: '',
|
||
notes: '',
|
||
})
|
||
|
||
const selectedTerm = computed(() =>
|
||
terms.value.find((term) => term.id === form.academicTermId),
|
||
)
|
||
const filteredBuildings = computed(() =>
|
||
buildings.value.filter((building) =>
|
||
!form.campusId || building.campusId === form.campusId,
|
||
),
|
||
)
|
||
const availablePeriodCounts = computed(() => {
|
||
const enabled = new Set(timeSlots.value.map((slot) => slot.periodNumber))
|
||
const counts: number[] = []
|
||
for (let count = 1; count <= 6; count += 1) {
|
||
if (!enabled.has(form.startPeriod + count - 1)) break
|
||
counts.push(count)
|
||
}
|
||
return counts
|
||
})
|
||
|
||
const statusMeta: Record<Reservation['status'], { label: string; type: 'warning' | 'success' | 'danger' | 'info' }> = {
|
||
Submitted: { label: '待学院审核', type: 'warning' },
|
||
Approved: { label: '已批准', type: 'success' },
|
||
Rejected: { label: '已驳回', type: 'danger' },
|
||
Cancelled: { label: '已取消', type: 'info' },
|
||
}
|
||
|
||
function statusInfo(status: unknown) {
|
||
return statusMeta[status as Reservation['status']]
|
||
?? { label: '未知状态', type: 'info' as const }
|
||
}
|
||
|
||
function todayText() {
|
||
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||
timeZone: 'Asia/Shanghai',
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
})
|
||
return formatter.format(new Date())
|
||
}
|
||
|
||
function initialReservationDate(term?: TermOption) {
|
||
const today = todayText()
|
||
if (!term) return today
|
||
if (today < term.startDate) return term.startDate
|
||
if (today > term.endDate) return ''
|
||
return today
|
||
}
|
||
|
||
function invalidateAvailability() {
|
||
availabilityChecked.value = false
|
||
availableRooms.value = []
|
||
form.classroomId = ''
|
||
}
|
||
|
||
async function loadOptions(termId?: string) {
|
||
const { data } = await http.get('/classroom-reservations/options', {
|
||
params: termId ? { academicTermId: termId } : {},
|
||
})
|
||
applicant.value = data.applicant
|
||
applicantProblem.value = data.applicantProblem ?? ''
|
||
terms.value = data.terms
|
||
campuses.value = data.campuses
|
||
buildings.value = data.buildings
|
||
classrooms.value = data.classrooms
|
||
timeSlots.value = data.timeSlots
|
||
const nextTermId = termId || data.selectedTermId || ''
|
||
form.academicTermId = nextTermId
|
||
if (!timeSlots.value.some((slot) => slot.periodNumber === form.startPeriod)) {
|
||
form.startPeriod = timeSlots.value[0]?.periodNumber ?? 1
|
||
}
|
||
if (!availablePeriodCounts.value.includes(form.periodCount)) {
|
||
form.periodCount = availablePeriodCounts.value[0] ?? 1
|
||
}
|
||
if (!form.reservationDate ||
|
||
form.reservationDate < (selectedTerm.value?.startDate ?? '') ||
|
||
form.reservationDate > (selectedTerm.value?.endDate ?? '9999-12-31')) {
|
||
form.reservationDate = initialReservationDate(selectedTerm.value)
|
||
}
|
||
invalidateAvailability()
|
||
}
|
||
|
||
async function changeTerm(termId: string) {
|
||
try {
|
||
await loadOptions(termId)
|
||
} catch (error) {
|
||
ElMessage.error(apiErrorMessage(error))
|
||
}
|
||
}
|
||
|
||
function changeCampus() {
|
||
if (form.buildingId &&
|
||
!filteredBuildings.value.some((building) => building.id === form.buildingId)) {
|
||
form.buildingId = ''
|
||
}
|
||
invalidateAvailability()
|
||
}
|
||
|
||
function isDateDisabled(value: Date) {
|
||
const term = selectedTerm.value
|
||
if (!term) return true
|
||
const date = [
|
||
value.getFullYear(),
|
||
String(value.getMonth() + 1).padStart(2, '0'),
|
||
String(value.getDate()).padStart(2, '0'),
|
||
].join('-')
|
||
return date < todayText() || date < term.startDate || date > term.endDate
|
||
}
|
||
|
||
async function queryAvailability() {
|
||
if (!form.academicTermId || !form.reservationDate) {
|
||
ElMessage.warning('请先选择学期和预约日期。')
|
||
return
|
||
}
|
||
availabilityLoading.value = true
|
||
try {
|
||
const { data } = await http.get('/classroom-reservations/availability', {
|
||
params: {
|
||
academicTermId: form.academicTermId,
|
||
reservationDate: form.reservationDate,
|
||
startPeriod: form.startPeriod,
|
||
periodCount: form.periodCount,
|
||
campusId: form.campusId || undefined,
|
||
buildingId: form.buildingId || undefined,
|
||
attendeeCount: form.attendeeCount,
|
||
},
|
||
})
|
||
availableRooms.value = data.items
|
||
availabilityChecked.value = true
|
||
if (!availableRooms.value.some((room) => room.id === form.classroomId)) {
|
||
form.classroomId = ''
|
||
}
|
||
if (availableRooms.value.length === 0) {
|
||
ElMessage.warning('当前条件下没有可预约教室,请调整日期、节次或人数。')
|
||
}
|
||
} catch (error) {
|
||
availabilityChecked.value = false
|
||
ElMessage.error(apiErrorMessage(error))
|
||
} finally {
|
||
availabilityLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function submitReservation() {
|
||
if (!applicant.value) {
|
||
ElMessage.error(applicantProblem.value || '当前账号没有可用的学院归属。')
|
||
return
|
||
}
|
||
if (!availabilityChecked.value || !form.classroomId) {
|
||
ElMessage.warning('请先查询可用教室并选择教室。')
|
||
return
|
||
}
|
||
if (!form.purpose.trim() || !form.contactPhone.trim()) {
|
||
ElMessage.warning('请填写借用用途和联系电话。')
|
||
return
|
||
}
|
||
submitting.value = true
|
||
try {
|
||
await http.post('/classroom-reservations', {
|
||
academicTermId: form.academicTermId,
|
||
classroomId: form.classroomId,
|
||
reservationDate: form.reservationDate,
|
||
startPeriod: form.startPeriod,
|
||
periodCount: form.periodCount,
|
||
attendeeCount: form.attendeeCount,
|
||
purpose: form.purpose.trim(),
|
||
contactPhone: form.contactPhone.trim(),
|
||
notes: form.notes.trim() || null,
|
||
})
|
||
ElMessage.success('申请已提交,将由您所在学院审核。')
|
||
form.purpose = ''
|
||
form.notes = ''
|
||
invalidateAvailability()
|
||
activeTab.value = 'mine'
|
||
minePage.value = 1
|
||
await loadMine()
|
||
} catch (error) {
|
||
ElMessage.error(apiErrorMessage(error))
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
async function loadMine() {
|
||
loading.value = true
|
||
try {
|
||
const { data } = await http.get<PagedResponse>('/classroom-reservations/mine', {
|
||
params: { page: minePage.value, pageSize },
|
||
})
|
||
mine.value = data.items
|
||
mineTotal.value = data.total
|
||
} catch (error) {
|
||
ElMessage.error(apiErrorMessage(error))
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadReview() {
|
||
if (!isCollegeAdmin.value) return
|
||
loading.value = true
|
||
try {
|
||
const { data } = await http.get<PagedResponse>('/classroom-reservations/review', {
|
||
params: {
|
||
status: reviewStatus.value || undefined,
|
||
page: reviewPage.value,
|
||
pageSize,
|
||
},
|
||
})
|
||
reviewItems.value = data.items
|
||
reviewTotal.value = data.total
|
||
} catch (error) {
|
||
ElMessage.error(apiErrorMessage(error))
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function changeTab(name: string | number) {
|
||
activeTab.value = name as 'mine' | 'review'
|
||
if (activeTab.value === 'review') await loadReview()
|
||
}
|
||
|
||
async function cancelReservation(item: Reservation | Record<string, any>) {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
item.status === 'Approved'
|
||
? '该预约已经批准,取消后教室将立即释放。确认取消吗?'
|
||
: '确认撤回这条待审核申请吗?',
|
||
'取消教室预约',
|
||
{ type: 'warning', confirmButtonText: '确认取消', cancelButtonText: '暂不取消' },
|
||
)
|
||
await http.post(`/classroom-reservations/${item.id}/cancel`)
|
||
ElMessage.success('预约已取消。')
|
||
await loadMine()
|
||
} catch (error) {
|
||
if (error === 'cancel' || error === 'close') return
|
||
ElMessage.error(apiErrorMessage(error))
|
||
}
|
||
}
|
||
|
||
async function approveReservation(item: Reservation | Record<string, any>) {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`确认批准 ${item.applicantName} 对 ${item.buildingName} ${item.classroomName} 的借用申请吗?系统会再次检查占用冲突。`,
|
||
'批准教室借用',
|
||
{ type: 'success', confirmButtonText: '批准', cancelButtonText: '返回' },
|
||
)
|
||
await http.post(`/classroom-reservations/${item.id}/approve`, { comment: null })
|
||
ElMessage.success('申请已批准。')
|
||
await loadReview()
|
||
} catch (error) {
|
||
if (error === 'cancel' || error === 'close') return
|
||
ElMessage.error(apiErrorMessage(error))
|
||
}
|
||
}
|
||
|
||
async function rejectReservation(item: Reservation | Record<string, any>) {
|
||
try {
|
||
const result = await ElMessageBox.prompt(
|
||
'请填写驳回原因,申请人将在消息中心收到该意见。',
|
||
'驳回教室借用',
|
||
{
|
||
inputType: 'textarea',
|
||
inputPlaceholder: '请输入具体原因',
|
||
inputValidator: (value) => value.trim().length > 0 || '驳回原因不能为空',
|
||
confirmButtonText: '确认驳回',
|
||
cancelButtonText: '返回',
|
||
type: 'warning',
|
||
},
|
||
)
|
||
await http.post(`/classroom-reservations/${item.id}/reject`, {
|
||
comment: result.value.trim(),
|
||
})
|
||
ElMessage.success('申请已驳回。')
|
||
await loadReview()
|
||
} catch (error) {
|
||
if (error === 'cancel' || error === 'close') return
|
||
ElMessage.error(apiErrorMessage(error))
|
||
}
|
||
}
|
||
|
||
function periodText(item: Reservation | Record<string, any>) {
|
||
const end = item.startPeriod + item.periodCount - 1
|
||
return item.periodCount === 1
|
||
? `第 ${item.startPeriod} 节`
|
||
: `第 ${item.startPeriod}—${end} 节`
|
||
}
|
||
|
||
function roomLabel(room: AvailableClassroom) {
|
||
return `${room.campusName} · ${room.buildingName} · ${room.name}(${room.capacity} 人)`
|
||
}
|
||
|
||
function formatDateTime(value?: string) {
|
||
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||
}
|
||
|
||
onMounted(async () => {
|
||
try {
|
||
await Promise.all([loadOptions(), loadMine()])
|
||
} catch (error) {
|
||
ElMessage.error(apiErrorMessage(error))
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<section class="reservation-page">
|
||
<header class="page-heading">
|
||
<div>
|
||
<p class="eyebrow">TEACHING SPACE</p>
|
||
<h2>教室借用与预约</h2>
|
||
<p>选择正式课表之外的空闲时段提交申请,由申请人所在学院审核。</p>
|
||
</div>
|
||
<el-tag v-if="applicant" type="success" effect="plain" size="large">
|
||
审核学院:{{ applicant.collegeName }}
|
||
</el-tag>
|
||
</header>
|
||
|
||
<el-alert
|
||
v-if="applicantProblem"
|
||
:title="applicantProblem"
|
||
type="warning"
|
||
show-icon
|
||
:closable="false"
|
||
class="scope-alert"
|
||
/>
|
||
|
||
<el-card class="application-card" shadow="never">
|
||
<template #header>
|
||
<div class="card-title">
|
||
<div>
|
||
<h3>提交借用申请</h3>
|
||
<p>系统会避让正式课程、已发布考试及已批准的教室预约。</p>
|
||
</div>
|
||
<span v-if="applicant">{{ applicant.displayName }} · {{ applicant.collegeName }}</span>
|
||
</div>
|
||
</template>
|
||
|
||
<el-form label-position="top" :disabled="!applicant">
|
||
<div class="form-grid">
|
||
<el-form-item label="学期" required>
|
||
<el-select
|
||
v-model="form.academicTermId"
|
||
placeholder="选择学期"
|
||
@change="changeTerm"
|
||
>
|
||
<el-option
|
||
v-for="term in terms"
|
||
:key="term.id"
|
||
:value="term.id"
|
||
:label="`${term.name}${term.hasPublishedTimetable ? '' : '(课表未发布)'}`"
|
||
:disabled="!term.hasPublishedTimetable"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="预约日期" required>
|
||
<el-date-picker
|
||
v-model="form.reservationDate"
|
||
type="date"
|
||
value-format="YYYY-MM-DD"
|
||
format="YYYY-MM-DD"
|
||
placeholder="选择日期"
|
||
:disabled-date="isDateDisabled"
|
||
@change="invalidateAvailability"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="开始节次" required>
|
||
<el-select v-model="form.startPeriod" @change="invalidateAvailability">
|
||
<el-option
|
||
v-for="slot in timeSlots"
|
||
:key="slot.periodNumber"
|
||
:value="slot.periodNumber"
|
||
:label="`${slot.name}${slot.startTime ? `(${slot.startTime}—${slot.endTime})` : ''}`"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="连续节数" required>
|
||
<el-select v-model="form.periodCount" @change="invalidateAvailability">
|
||
<el-option
|
||
v-for="count in availablePeriodCounts"
|
||
:key="count"
|
||
:value="count"
|
||
:label="`${count} 节`"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="预计人数" required>
|
||
<el-input-number
|
||
v-model="form.attendeeCount"
|
||
:min="1"
|
||
:max="10000"
|
||
controls-position="right"
|
||
@change="invalidateAvailability"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="校区">
|
||
<el-select
|
||
v-model="form.campusId"
|
||
clearable
|
||
placeholder="全部校区"
|
||
@change="changeCampus"
|
||
>
|
||
<el-option
|
||
v-for="campus in campuses"
|
||
:key="campus.id"
|
||
:value="campus.id"
|
||
:label="campus.name"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="教学楼">
|
||
<el-select
|
||
v-model="form.buildingId"
|
||
clearable
|
||
placeholder="全部教学楼"
|
||
@change="invalidateAvailability"
|
||
>
|
||
<el-option
|
||
v-for="building in filteredBuildings"
|
||
:key="building.id"
|
||
:value="building.id"
|
||
:label="building.name"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="可用教室" required>
|
||
<div class="room-picker">
|
||
<el-select
|
||
v-model="form.classroomId"
|
||
filterable
|
||
:disabled="!availabilityChecked"
|
||
:placeholder="availabilityChecked ? '选择可用教室' : '请先查询可用教室'"
|
||
>
|
||
<el-option
|
||
v-for="room in availableRooms"
|
||
:key="room.id"
|
||
:value="room.id"
|
||
:label="roomLabel(room)"
|
||
/>
|
||
</el-select>
|
||
<el-button
|
||
:loading="availabilityLoading"
|
||
type="primary"
|
||
plain
|
||
@click="queryAvailability"
|
||
>
|
||
查询可用教室
|
||
</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="借用用途" required class="span-2">
|
||
<el-input
|
||
v-model="form.purpose"
|
||
maxlength="200"
|
||
show-word-limit
|
||
placeholder="例如:学院学术讲座、班级主题活动"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="联系电话" required>
|
||
<el-input
|
||
v-model="form.contactPhone"
|
||
maxlength="30"
|
||
placeholder="便于审核人联系"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="补充说明" class="span-2">
|
||
<el-input
|
||
v-model="form.notes"
|
||
type="textarea"
|
||
:rows="3"
|
||
maxlength="500"
|
||
show-word-limit
|
||
placeholder="可填写设备、布置或其他需求"
|
||
/>
|
||
</el-form-item>
|
||
</div>
|
||
<div class="submit-row">
|
||
<p v-if="availabilityChecked">
|
||
已找到 <b>{{ availableRooms.length }}</b> 间符合条件的空闲教室
|
||
</p>
|
||
<span v-else>日期、节次、人数变化后需重新查询可用教室。</span>
|
||
<el-button
|
||
type="primary"
|
||
size="large"
|
||
:loading="submitting"
|
||
@click="submitReservation"
|
||
>
|
||
提交学院审核
|
||
</el-button>
|
||
</div>
|
||
</el-form>
|
||
</el-card>
|
||
|
||
<el-card class="records-card" shadow="never">
|
||
<el-tabs v-model="activeTab" @tab-change="changeTab">
|
||
<el-tab-pane label="我的申请" name="mine">
|
||
<div v-loading="loading">
|
||
<el-table :data="mine" class="desktop-table">
|
||
<el-table-column label="日期与节次" min-width="150">
|
||
<template #default="{ row }">
|
||
<b>{{ row.reservationDate }}</b>
|
||
<small>{{ periodText(row) }}</small>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="教室" min-width="190">
|
||
<template #default="{ row }">
|
||
<b>{{ row.buildingName }} · {{ row.classroomName }}</b>
|
||
<small>{{ row.campusName }} · {{ row.attendeeCount }} 人</small>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="purpose" label="用途" min-width="180" show-overflow-tooltip />
|
||
<el-table-column label="状态" width="120">
|
||
<template #default="{ row }">
|
||
<el-tag :type="statusInfo(row.status).type">
|
||
{{ statusInfo(row.status).label }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="审核意见" min-width="170">
|
||
<template #default="{ row }">{{ row.reviewComment || '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="90" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button
|
||
v-if="['Submitted', 'Approved'].includes(row.status)"
|
||
link
|
||
type="danger"
|
||
@click="cancelReservation(row)"
|
||
>
|
||
取消
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<div class="mobile-records">
|
||
<article v-for="item in mine" :key="item.id" class="record-item">
|
||
<header>
|
||
<div>
|
||
<b>{{ item.reservationDate }} · {{ periodText(item) }}</b>
|
||
<span>{{ item.buildingName }} {{ item.classroomName }}</span>
|
||
</div>
|
||
<el-tag :type="statusInfo(item.status).type">
|
||
{{ statusInfo(item.status).label }}
|
||
</el-tag>
|
||
</header>
|
||
<p>{{ item.purpose }}</p>
|
||
<small>{{ item.attendeeCount }} 人 · {{ item.contactPhone }}</small>
|
||
<blockquote v-if="item.reviewComment">{{ item.reviewComment }}</blockquote>
|
||
<el-button
|
||
v-if="['Submitted', 'Approved'].includes(item.status)"
|
||
type="danger"
|
||
plain
|
||
@click="cancelReservation(item)"
|
||
>
|
||
取消预约
|
||
</el-button>
|
||
</article>
|
||
</div>
|
||
<el-empty v-if="mine.length === 0" description="还没有教室借用申请" />
|
||
<el-pagination
|
||
v-if="mineTotal > pageSize"
|
||
v-model:current-page="minePage"
|
||
:page-size="pageSize"
|
||
:total="mineTotal"
|
||
layout="prev, pager, next"
|
||
@current-change="loadMine"
|
||
/>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane v-if="isCollegeAdmin" label="本院审核" name="review">
|
||
<div class="review-toolbar">
|
||
<div>
|
||
<h3>{{ applicant?.collegeName || auth.user?.displayName }}审核队列</h3>
|
||
<p>这里只显示申请人归属本学院的教室借用申请。</p>
|
||
</div>
|
||
<el-select
|
||
v-model="reviewStatus"
|
||
style="width: 150px"
|
||
@change="reviewPage = 1; loadReview()"
|
||
>
|
||
<el-option label="待审核" value="Submitted" />
|
||
<el-option label="已批准" value="Approved" />
|
||
<el-option label="已驳回" value="Rejected" />
|
||
<el-option label="已取消" value="Cancelled" />
|
||
<el-option label="全部状态" value="" />
|
||
</el-select>
|
||
</div>
|
||
<div v-loading="loading">
|
||
<el-table :data="reviewItems" class="desktop-table">
|
||
<el-table-column label="申请人" min-width="140">
|
||
<template #default="{ row }">
|
||
<b>{{ row.applicantName }}</b>
|
||
<small>{{ row.applicantCollegeName }}</small>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="日期与教室" min-width="220">
|
||
<template #default="{ row }">
|
||
<b>{{ row.reservationDate }} · {{ periodText(row) }}</b>
|
||
<small>{{ row.campusName }} · {{ row.buildingName }} {{ row.classroomName }}</small>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="用途与人数" min-width="220">
|
||
<template #default="{ row }">
|
||
<span>{{ row.purpose }}</span>
|
||
<small>{{ row.attendeeCount }} 人 · {{ row.contactPhone }}</small>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="提交时间" width="170">
|
||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="120">
|
||
<template #default="{ row }">
|
||
<el-tag :type="statusInfo(row.status).type">
|
||
{{ statusInfo(row.status).label }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="150" fixed="right">
|
||
<template #default="{ row }">
|
||
<template v-if="row.status === 'Submitted'">
|
||
<el-button link type="success" @click="approveReservation(row)">批准</el-button>
|
||
<el-button link type="danger" @click="rejectReservation(row)">驳回</el-button>
|
||
</template>
|
||
<span v-else>{{ row.reviewComment || '—' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<div class="mobile-records">
|
||
<article v-for="item in reviewItems" :key="item.id" class="record-item">
|
||
<header>
|
||
<div>
|
||
<b>{{ item.applicantName }} · {{ item.reservationDate }}</b>
|
||
<span>{{ periodText(item) }} · {{ item.buildingName }} {{ item.classroomName }}</span>
|
||
</div>
|
||
<el-tag :type="statusInfo(item.status).type">
|
||
{{ statusInfo(item.status).label }}
|
||
</el-tag>
|
||
</header>
|
||
<p>{{ item.purpose }}</p>
|
||
<small>{{ item.attendeeCount }} 人 · {{ item.contactPhone }}</small>
|
||
<div v-if="item.status === 'Submitted'" class="review-actions">
|
||
<el-button type="success" @click="approveReservation(item)">批准</el-button>
|
||
<el-button type="danger" plain @click="rejectReservation(item)">驳回</el-button>
|
||
</div>
|
||
<blockquote v-else-if="item.reviewComment">{{ item.reviewComment }}</blockquote>
|
||
</article>
|
||
</div>
|
||
<el-empty v-if="reviewItems.length === 0" description="当前筛选下没有申请" />
|
||
<el-pagination
|
||
v-if="reviewTotal > pageSize"
|
||
v-model:current-page="reviewPage"
|
||
:page-size="pageSize"
|
||
:total="reviewTotal"
|
||
layout="prev, pager, next"
|
||
@current-change="loadReview"
|
||
/>
|
||
</div>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</el-card>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.reservation-page {
|
||
display: grid;
|
||
gap: 20px;
|
||
}
|
||
|
||
.page-heading,
|
||
.card-title,
|
||
.submit-row,
|
||
.review-toolbar,
|
||
.record-item header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
}
|
||
|
||
.page-heading h2,
|
||
.card-title h3,
|
||
.review-toolbar h3 {
|
||
margin: 0;
|
||
color: #183b56;
|
||
}
|
||
|
||
.page-heading p,
|
||
.card-title p,
|
||
.review-toolbar p {
|
||
margin: 5px 0 0;
|
||
color: #6c7f90;
|
||
}
|
||
|
||
.eyebrow {
|
||
color: #17867c !important;
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
letter-spacing: .16em;
|
||
}
|
||
|
||
.scope-alert {
|
||
border: 1px solid #f3d7a5;
|
||
}
|
||
|
||
.application-card,
|
||
.records-card {
|
||
border: 1px solid #dfe9ec;
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.card-title > span {
|
||
color: #17867c;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.form-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 0 16px;
|
||
}
|
||
|
||
.span-2 {
|
||
grid-column: span 2;
|
||
}
|
||
|
||
.room-picker {
|
||
display: flex;
|
||
width: 100%;
|
||
gap: 8px;
|
||
}
|
||
|
||
.room-picker .el-select {
|
||
flex: 1;
|
||
}
|
||
|
||
.submit-row {
|
||
padding-top: 10px;
|
||
border-top: 1px dashed #dfe9ec;
|
||
}
|
||
|
||
.submit-row p,
|
||
.submit-row span {
|
||
margin: 0;
|
||
color: #6c7f90;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.submit-row b {
|
||
color: #17867c;
|
||
}
|
||
|
||
.desktop-table b,
|
||
.desktop-table span,
|
||
.desktop-table small {
|
||
display: block;
|
||
}
|
||
|
||
.desktop-table small {
|
||
margin-top: 4px;
|
||
color: #7a8a99;
|
||
}
|
||
|
||
.review-toolbar {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.mobile-records {
|
||
display: none;
|
||
}
|
||
|
||
.record-item {
|
||
align-items: stretch;
|
||
flex-direction: column;
|
||
padding: 15px;
|
||
border: 1px solid #dfe9ec;
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
}
|
||
|
||
.record-item + .record-item {
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.record-item header {
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.record-item header b,
|
||
.record-item header span,
|
||
.record-item small {
|
||
display: block;
|
||
}
|
||
|
||
.record-item header span,
|
||
.record-item small {
|
||
margin-top: 4px;
|
||
color: #738596;
|
||
}
|
||
|
||
.record-item p {
|
||
margin: 12px 0 2px;
|
||
color: #29495f;
|
||
}
|
||
|
||
.record-item blockquote {
|
||
margin: 10px 0 0;
|
||
padding: 9px 12px;
|
||
border-left: 3px solid #e4a74e;
|
||
background: #fff9ef;
|
||
color: #805d2c;
|
||
}
|
||
|
||
.review-actions {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 8px;
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.el-pagination {
|
||
justify-content: flex-end;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
@media (max-width: 1100px) {
|
||
.form-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
@media (max-width: 720px) {
|
||
.page-heading,
|
||
.card-title,
|
||
.submit-row,
|
||
.review-toolbar {
|
||
align-items: flex-start;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.form-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.span-2 {
|
||
grid-column: span 1;
|
||
}
|
||
|
||
.room-picker {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.submit-row .el-button,
|
||
.review-toolbar .el-select {
|
||
width: 100% !important;
|
||
}
|
||
|
||
.desktop-table {
|
||
display: none;
|
||
}
|
||
|
||
.mobile-records {
|
||
display: block;
|
||
}
|
||
|
||
.el-pagination {
|
||
justify-content: center;
|
||
}
|
||
}
|
||
</style>
|