Files
Academic-Affairs-System/web/src/views/CourseAdjustmentsView.vue
T
biss c91d826169 学年学期页面新增“设为当前”“归档”“撤销归档”,当前学期不能归档、删除或直接取消;已归档学期需先撤销才能重新设为当前。[BaseDataController.cs (line 286)](/E:/jiaowu/src/Jiaowu.Api/Controllers/BaseDataController.cs:286)
切换后,其他学期自动变为历史态;已归档学期进一步弱化。所有主要学期选择器统一默认当前,并显示“当前 / 历史 / 已归档”。[academicTerms.ts (line 1)](/E:/jiaowu/web/src/utils/academicTerms.ts:1)
桌面使用分层表格,390px 手机端改为独立学期卡片,操作不会再挤压表格。[BaseDataView.vue (line 211)](/E:/jiaowu/web/src/views/BaseDataView.vue:211)
归档定义为“历史显示状态”,不是成绩冻结:补考成绩仍可录入并回写,已发布成绩仍可走成绩更正审批。
已加入 SQLite 开发迁移、MySQL 正式迁移和切换/归档/撤销测试。[20260726143000_AcademicTermArchiving.cs (line 1)](/E:/jiaowu/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726143000_AcademicTermArchiving.cs:1)
2026-07-26 11:14:05 +08:00

421 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Bell, Check, Plus, Refresh } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore()
const isManager = computed(() =>
auth.user?.roles.some(r => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(r)) ?? false)
const isTeacher = computed(() => auth.user?.roles.includes('Teacher') && !isManager.value)
const adjustments = ref<any[]>([])
const pendingReviews = ref<any[]>([])
const notifications = ref<any[]>([])
const unreadCount = ref(0)
const loading = ref(false)
const reviewLoading = ref(false)
const dialog = ref(false)
const rejectDialog = ref(false)
const currentAdj = ref<any>(null)
const rejectComment = ref('')
const terms = ref<any[]>([])
const tasks = ref<any[]>([])
const termId = ref<string>()
const tab = ref(isManager.value ? 'reviews' : 'mine')
const form = reactive({
teachingTaskId: undefined as string | undefined,
type: 'Reschedule' as string,
reason: '',
submit: true,
targetDate: '' as string,
dayOfWeek: undefined as number | undefined,
startPeriod: undefined as number | undefined,
periodCount: 2,
classroomId: undefined as string | undefined,
substituteTeacherId: undefined as string | undefined,
cancelWeek: undefined as number | undefined,
cancelDate: '' as string,
})
const typeLabels: Record<string, string> = { Reschedule: '调课', Cancel: '停课', Makeup: '补课', Substitute: '代课' }
const statusLabels: Record<string, string> = { Draft: '草稿', Submitted: '待审核', Approved: '已通过', Rejected: '已退回' }
const statusColors: Record<string, 'info' | 'warning' | 'success' | 'danger'> = { Draft: 'info', Submitted: 'warning', Approved: 'success', Rejected: 'danger' }
function periodOptions() {
return Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: `第 ${i + 1} 节` }))
}
async function load() {
loading.value = true
try {
if (isTeacher.value || (!isManager.value)) {
adjustments.value = (await http.get('/course-adjustments/mine', {
params: { academicTermId: termId.value },
})).data
}
if (isManager.value) {
pendingReviews.value = (await http.get('/course-adjustments/pending-reviews', {
params: { academicTermId: termId.value },
})).data
}
await loadNotifications()
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
finally { loading.value = false }
}
async function loadNotifications() {
try {
const { data } = await http.get('/notifications', { params: { pageSize: 50 } })
notifications.value = data.items
unreadCount.value = data.unreadCount
} catch (_) { /* notifications are optional */ }
}
function openCreate() {
Object.assign(form, {
teachingTaskId: undefined,
type: 'Reschedule',
reason: '',
submit: true,
targetDate: '',
dayOfWeek: undefined,
startPeriod: undefined,
periodCount: 2,
classroomId: undefined,
substituteTeacherId: undefined,
cancelWeek: undefined,
cancelDate: '',
})
dialog.value = true
}
async function submit() {
if (!form.teachingTaskId) { ElMessage.warning('请选择教学班'); return }
if (!form.reason.trim()) { ElMessage.warning('请填写申请原因'); return }
try {
await http.post('/course-adjustments', {
...form,
dayOfWeek: form.dayOfWeek || null,
startPeriod: form.startPeriod || null,
classroomId: form.classroomId || null,
substituteTeacherId: form.substituteTeacherId || null,
cancelWeek: form.cancelWeek || null,
targetDate: form.targetDate || null,
cancelDate: form.cancelDate || null,
})
dialog.value = false
ElMessage.success('申请已提交')
await load()
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
async function doSubmit(adj: any) {
try {
await http.post(`/course-adjustments/${adj.id}/submit`)
ElMessage.success('已提交审核')
await load()
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
async function doApprove(adj: any) {
try {
await http.post(`/course-adjustments/${adj.id}/approve`)
ElMessage.success('已通过')
await load()
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
function openReject(adj: any) {
currentAdj.value = adj
rejectComment.value = ''
rejectDialog.value = true
}
async function doReject() {
if (!currentAdj.value) return
try {
await http.post(`/course-adjustments/${currentAdj.value.id}/reject`, {
comment: rejectComment.value,
})
rejectDialog.value = false
ElMessage.success('已退回')
await load()
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
async function markRead(n: any) {
if (n.isRead) return
try {
await http.post(`/notifications/${n.id}/read`)
n.isRead = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
} catch (_) {}
}
async function markAllRead() {
try {
await http.post('/notifications/read-all')
notifications.value.forEach(n => n.isRead = true)
unreadCount.value = 0
} catch (_) {}
}
onMounted(async () => {
try {
const [termRes, taskRes] = await Promise.all([
http.get('/base-data/terms'),
isTeacher.value || !isManager.value
? http.get('/teaching-tasks', { params: { page: 1, pageSize: 100 } })
: Promise.resolve({ data: { items: [] } }),
])
terms.value = termRes.data
tasks.value = taskRes.data.items
termId.value = defaultAcademicTermId(terms.value)
} catch (_) {}
await load()
})
function showTypeFields(type: string) {
return type === 'Reschedule' || type === 'Makeup'
}
function showDate(type: string) { return type === 'Makeup' }
function showSub(type: string) { return type === 'Substitute' }
function showCancel(type: string) { return type === 'Cancel' }
</script>
<template>
<div class="page-stack adj-page">
<section class="page-intro">
<div>
<span class="section-kicker">COURSE ADJUSTMENT</span>
<h2>调停课管理</h2>
<p>教师提交调课停课补课代课申请学院审核后生效并通知相关人员</p>
</div>
<div style="display:flex;gap:8px;align-items:center">
<el-badge :value="unreadCount" :hidden="!unreadCount">
<el-button :icon="Bell" @click="tab = 'notifications'">消息</el-button>
</el-badge>
<el-button v-if="isTeacher" type="primary" :icon="Plus" @click="openCreate">提交申请</el-button>
<el-button :icon="Refresh" @click="load">刷新</el-button>
</div>
</section>
<section class="adj-toolbar">
<el-select v-model="termId" clearable placeholder="全部学期" @change="load">
<el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" />
</el-select>
<el-segmented v-model="tab" :options="[
...(isTeacher ? [{ label: '我的申请', value: 'mine' }] : []),
...(isManager ? [{ label: '待审核', value: 'reviews' }] : []),
{ label: '消息通知', value: 'notifications' },
]" />
</section>
<!-- My applications -->
<section v-if="tab === 'mine'" v-loading="loading" class="adj-list">
<article v-for="adj in adjustments" :key="adj.id" class="adj-card" :class="adj.status.toLowerCase()">
<header>
<div>
<span>{{ adj.courseCode }} · {{ adj.taskNumber }}</span>
<h3>{{ adj.courseName }}</h3>
<p>{{ adj.teacherNames.join('、') }} · {{ adj.collegeName }}</p>
</div>
<div class="adj-tags">
<el-tag :type="adj.type === 'Reschedule' ? 'primary' : adj.type === 'Cancel' ? 'danger' : adj.type === 'Makeup' ? 'success' : 'warning'" size="small">
{{ typeLabels[adj.type] }}
</el-tag>
<el-tag :type="statusColors[adj.status]" size="small">{{ statusLabels[adj.status] }}</el-tag>
</div>
</header>
<div class="adj-body">
<p v-if="adj.reason"><b>原因</b>{{ adj.reason }}</p>
<div class="adj-details">
<span v-if="adj.targetDate"><b>日期</b>{{ adj.targetDate }}</span>
<span v-if="adj.dayOfWeek"><b>星期</b>{{ ['','一','二','三','四','五','六','日'][adj.dayOfWeek] }}</span>
<span v-if="adj.startPeriod"><b>节次</b>{{ adj.startPeriod }}-{{ adj.startPeriod + adj.periodCount - 1 }}</span>
<span v-if="adj.classroomName"><b>教室</b>{{ adj.buildingName }} {{ adj.classroomName }}</span>
<span v-if="adj.substituteTeacherName"><b>代课教师</b>{{ adj.substituteTeacherName }}</span>
<span v-if="adj.cancelWeek"><b>停课周次</b>{{ adj.cancelWeek }}</span>
<span v-if="adj.cancelDate"><b>停课日期</b>{{ adj.cancelDate }}</span>
</div>
<el-alert v-if="adj.reviewComment" :title="`审核意见:${adj.reviewComment}`" type="warning" :closable="false" show-icon />
</div>
<footer v-if="adj.status === 'Draft'">
<el-button size="small" type="primary" @click="doSubmit(adj)">提交审核</el-button>
</footer>
</article>
<el-empty v-if="!adjustments.length" description="暂无调停课申请" />
</section>
<!-- Pending reviews -->
<section v-if="tab === 'reviews'" v-loading="reviewLoading" class="adj-list">
<article v-for="adj in pendingReviews" :key="adj.id" class="adj-card review">
<header>
<div>
<span>{{ adj.courseCode }} · {{ adj.taskNumber }}</span>
<h3>{{ adj.courseName }}</h3>
<p>{{ adj.teacherNames.join('、') }} · {{ adj.collegeName }}</p>
</div>
<el-tag :type="adj.type === 'Reschedule' ? 'primary' : adj.type === 'Cancel' ? 'danger' : adj.type === 'Makeup' ? 'success' : 'warning'" size="small">
{{ typeLabels[adj.type] }}
</el-tag>
</header>
<div class="adj-body">
<p><b>原因</b>{{ adj.reason }}</p>
<div class="adj-details">
<span v-if="adj.targetDate"><b>日期</b>{{ adj.targetDate }}</span>
<span v-if="adj.dayOfWeek"><b>星期</b>{{ ['','一','二','三','四','五','六','日'][adj.dayOfWeek] }}</span>
<span v-if="adj.startPeriod"><b>节次</b>{{ adj.startPeriod }}-{{ adj.startPeriod + adj.periodCount - 1 }}</span>
<span v-if="adj.classroomName"><b>教室</b>{{ adj.buildingName }} {{ adj.classroomName }}</span>
<span v-if="adj.substituteTeacherName"><b>代课教师</b>{{ adj.substituteTeacherName }}</span>
<span v-if="adj.cancelWeek"><b>停课周次</b>{{ adj.cancelWeek }}</span>
<span v-if="adj.cancelDate"><b>停课日期</b>{{ adj.cancelDate }}</span>
</div>
<p class="adj-meta"><b>提交时间</b>{{ new Date(adj.submittedAt).toLocaleString('zh-CN') }}</p>
</div>
<footer>
<el-button size="small" type="danger" plain @click="openReject(adj)">退回</el-button>
<el-button size="small" type="success" :icon="Check" @click="doApprove(adj)">通过</el-button>
</footer>
</article>
<el-empty v-if="!pendingReviews.length" description="暂无待审核申请" />
</section>
<!-- Notifications -->
<section v-if="tab === 'notifications'" class="adj-list">
<div v-if="unreadCount" class="notif-actions">
<el-button size="small" text @click="markAllRead">全部标为已读</el-button>
</div>
<article v-for="n in notifications" :key="n.id" class="notif-card" :class="{ unread: !n.isRead }" @click="markRead(n)">
<div class="notif-dot" v-if="!n.isRead" />
<div>
<b>{{ n.title }}</b>
<p>{{ n.content }}</p>
<small>{{ new Date(n.createdAt).toLocaleString('zh-CN') }}</small>
</div>
</article>
<el-empty v-if="!notifications.length" description="暂无消息通知" />
</section>
<!-- Create dialog -->
<el-dialog v-model="dialog" title="提交调停课申请" width="680px" top="5vh">
<el-form label-position="top">
<el-form-item label="教学班" required>
<el-select v-model="form.teachingTaskId" filterable placeholder="选择教学班">
<el-option v-for="t in tasks" :key="t.id" :label="`${t.taskNumber} · ${t.courseName}`" :value="t.id" />
</el-select>
</el-form-item>
<el-form-item label="申请类型" required>
<el-segmented v-model="form.type" :options="[
{ label: '调课', value: 'Reschedule' },
{ label: '停课', value: 'Cancel' },
{ label: '补课', value: 'Makeup' },
{ label: '代课', value: 'Substitute' },
]" />
</el-form-item>
<template v-if="showDate(form.type)">
<el-form-item label="补课日期" required>
<el-date-picker v-model="form.targetDate" type="date" value-format="YYYY-MM-DD" placeholder="选择补课日期" />
</el-form-item>
</template>
<template v-if="showTypeFields(form.type)">
<div class="form-grid three">
<el-form-item label="星期">
<el-select v-model="form.dayOfWeek" placeholder="选择">
<el-option v-for="d in 7" :key="d" :label="['','周一','周二','周三','周四','周五','周六','周日'][d]" :value="d" />
</el-select>
</el-form-item>
<el-form-item label="起始节次">
<el-select v-model="form.startPeriod">
<el-option v-for="o in periodOptions()" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
<el-form-item label="持续节数">
<el-input-number v-model="form.periodCount" :min="1" :max="6" />
</el-form-item>
</div>
<el-form-item label="教室(可选)">
<el-select v-model="form.classroomId" clearable filterable placeholder="留空不调整教室">
</el-select>
</el-form-item>
</template>
<template v-if="showSub(form.type)">
<el-form-item label="代课教师" required>
<el-select v-model="form.substituteTeacherId" filterable placeholder="选择代课教师">
</el-select>
</el-form-item>
</template>
<template v-if="showCancel(form.type)">
<div class="form-grid two">
<el-form-item label="停课周次">
<el-input-number v-model="form.cancelWeek" :min="1" :max="20" placeholder="第几周" />
</el-form-item>
<el-form-item label="停课日期">
<el-date-picker v-model="form.cancelDate" type="date" value-format="YYYY-MM-DD" placeholder="或选择日期" />
</el-form-item>
</div>
</template>
<el-form-item label="申请原因" required>
<el-input v-model="form.reason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请详细说明调停课原因" />
</el-form-item>
<el-form-item>
<el-checkbox v-model="form.submit">提交后直接送审</el-checkbox>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialog = false">取消</el-button>
<el-button type="primary" @click="submit">提交申请</el-button>
</template>
</el-dialog>
<!-- Reject dialog -->
<el-dialog v-model="rejectDialog" title="退回申请" width="500px">
<el-form label-position="top">
<el-form-item label="退回意见">
<el-input v-model="rejectComment" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请说明退回原因" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rejectDialog = false">取消</el-button>
<el-button type="danger" @click="doReject">确认退回</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.adj-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.adj-list { display: grid; gap: 12px; }
.adj-card { background: #fff; border: 1px solid #e4e7ed; border-radius: 8px; padding: 16px 20px; }
.adj-card header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
.adj-card header h3 { font-size: 15px; margin: 4px 0; }
.adj-card header p { font-size: 12px; color: var(--muted); margin: 0; }
.adj-card header span { font-size: 11px; color: var(--muted); text-transform: uppercase; }
.adj-tags { display: flex; gap: 6px; flex-shrink: 0; }
.adj-body { margin: 12px 0; font-size: 13px; }
.adj-details { display: flex; flex-wrap: wrap; gap: 4px 16px; color: var(--muted); font-size: 12px; margin-top: 8px; }
.adj-meta { font-size: 11px; color: var(--muted); margin-top: 8px; }
.adj-card footer { display: flex; justify-content: flex-end; gap: 8px; border-top: 1px solid #ebeef5; padding-top: 12px; }
.adj-card.review { border-left: 4px solid #e6a23c; }
.adj-card.draft { border-left: 4px solid #909399; }
.adj-card.submitted { border-left: 4px solid #e6a23c; }
.adj-card.approved { border-left: 4px solid #67c23a; }
.adj-card.rejected { border-left: 4px solid #f56c6c; }
.notif-card { display: flex; align-items: flex-start; gap: 12px; padding: 14px 18px; background: #fff; border: 1px solid #e4e7ed; border-radius: 8px; cursor: pointer; }
.notif-card.unread { background: #ecf5ff; border-color: #b3d8ff; }
.notif-dot { width: 8px; height: 8px; border-radius: 50%; background: #409eff; flex-shrink: 0; margin-top: 6px; }
.notif-card b { font-size: 14px; display: block; margin-bottom: 4px; }
.notif-card p { font-size: 13px; color: #606266; margin: 0; }
.notif-card small { font-size: 11px; color: var(--muted); }
.notif-actions { display: flex; justify-content: flex-end; margin-bottom: 8px; }
</style>