校历修改

This commit is contained in:
biss committed 2026-08-18 16:54:40 +08:00
1 parent 3450d882cf
commit f106973d62
2 files changed
+873 -31

No files matched your search

-1
View File
@@ -65,7 +65,6 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
{ {
key: 'overview', key: 'overview',
label: '教务总览', label: '教务总览',
direct: true,
items: [ items: [
{ path: '/dashboard', label: '教务总览' }, { path: '/dashboard', label: '教务总览' },
{ path: '/academic-calendar', label: '学期校历与日程' }, { path: '/academic-calendar', label: '学期校历与日程' },
+873 -30
View File
@@ -1,34 +1,877 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from "vue";
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from "element-plus";
import { ArrowLeft, ArrowRight, Plus } from '@element-plus/icons-vue' import { ArrowLeft, ArrowRight, Plus } from "@element-plus/icons-vue";
import http from '../api/http' import http from "../api/http";
import { useAuthStore } from '../stores/auth' import { useAuthStore } from "../stores/auth";
import { academicTermLabel, defaultAcademicTermId } from '../utils/academicTerms' import {
interface CalendarEvent { id: string; academicTermId: string; title: string; kind: number | string; startsOn: string; endsOn?: string; reminderDays: number; audienceRoles: string[]; description?: string; isEnabled: boolean } academicTermLabel,
const auth = useAuthStore(), terms = ref<any[]>([]), events = ref<CalendarEvent[]>([]), selectedTermId = ref(''), loading = ref(false), dialogVisible = ref(false), editingId = ref<string>(), cursor = ref(new Date(new Date().getFullYear(), new Date().getMonth(), 1)) defaultAcademicTermId,
const canManage = computed(() => auth.user?.roles.some(role => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false) } from "../utils/academicTerms";
const isStudent = computed(() => auth.user?.roles.includes('Student') ?? false) interface CalendarEvent {
const roles = [['SuperAdmin','超级管理员'],['AcademicAdmin','校级教务管理员'],['CollegeAdmin','学院教务管理员'],['Leader','领导'],['Counselor','辅导员'],['Teacher','教师'],['Student','学生'],['MaintenanceAdmin','维修管理员'],['MaintenanceStaff','维修人员']] id: string;
const kinds = [[1,'选课'],[2,'成绩'],[3,'评教'],[4,'考试'],[5,'毕业审核'],[6,'法定节假日'],[99,'其他']], weekdays = ['一','二','三','四','五','六','日'] academicTermId: string;
const form = reactive<any>({ academicTermId:'', title:'', kind:99, startsOn:'', endsOn:'', reminderDays:7, audienceRoles:[], description:'', isEnabled:true }) title: string;
function key(date: Date) { return `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}` } kind: number | string;
function parse(value: string) { const [y,m,d] = value.slice(0,10).split('-').map(Number); return new Date(y,m-1,d) } startsOn: string;
function kindKey(kind: number | string) { return ({'1':'course',courseselection:'course','2':'grade',grade:'grade','3':'evaluation',evaluation:'evaluation','4':'exam',exam:'exam','5':'graduation',graduationaudit:'graduation','6':'holiday',publicholiday:'holiday','99':'other',other:'other'} as Record<string,string>)[String(kind).toLowerCase()] ?? 'other' } endsOn?: string;
function label(kind: number | string) { return ({course:'选课',grade:'成绩',evaluation:'评教',exam:'考试',graduation:'毕业审核',holiday:'法定节假日',other:'其他'} as Record<string,string>)[kindKey(kind)] } reminderDays: number;
function spans(item: CalendarEvent, day: string) { return item.startsOn <= day && (item.endsOn ?? item.startsOn) >= day } audienceRoles: string[];
const today = key(new Date()), monthTitle = computed(() => `${cursor.value.getFullYear()} 年 ${cursor.value.getMonth()+1} 月`) description?: string;
const days = computed(() => { const first = new Date(cursor.value.getFullYear(),cursor.value.getMonth(),1), start = new Date(first); start.setDate(first.getDate()-((first.getDay()+6)%7)); return Array.from({length:42},(_,i) => { const date = new Date(start); date.setDate(start.getDate()+i); const day = key(date); return { date, day, inMonth:date.getMonth()===cursor.value.getMonth(), events:events.value.filter(item=>spans(item,day)) } }) }) isEnabled: boolean;
const monthEvents = computed(() => events.value.filter(item => { const date=parse(item.startsOn); return date.getFullYear()===cursor.value.getFullYear() && date.getMonth()===cursor.value.getMonth() })) }
function shift(amount:number) { cursor.value = new Date(cursor.value.getFullYear(),cursor.value.getMonth()+amount,1) } function goToday() { cursor.value = new Date(new Date().getFullYear(),new Date().getMonth(),1) } interface HolidayPreview {
function due(item:CalendarEvent) { const gap=Math.ceil((parse(item.startsOn).getTime()-parse(today).getTime())/86400000); return gap>=0 && gap<=item.reminderDays } function audience(item:CalendarEvent) { return item.audienceRoles.length ? item.audienceRoles.map(role=>roles.find(x=>x[0]===role)?.[1]??role).join('、') : '全部角色' } name: string;
async function load() { loading.value=true; try { const calendarRequest=http.get(canManage.value?'/academic-calendar/manage':'/academic-calendar',{params:selectedTermId.value?{academicTermId:selectedTermId.value}:undefined}); const examRequest=isStudent.value?http.get('/exams/my-schedule'):Promise.resolve({data:[]}); const [calendar,exams]=await Promise.all([calendarRequest,examRequest]); const examEvents=(exams.data as any[]).map(exam=>({id:`exam-${exam.id}`,academicTermId:selectedTermId.value,title:`考试:${exam.courseName}`,kind:'Exam',startsOn:exam.examDate,reminderDays:7,audienceRoles:['Student'],description:`${exam.startsAt?.slice(11,16)??''}—${exam.endsAt?.slice(11,16)??''} · ${[exam.buildingName,exam.classroomName].filter(Boolean).join(' ')}`,isEnabled:true,source:'ExamSchedule'})); events.value=[...calendar.data,...examEvents] } finally { loading.value=false } } date: string;
function open(item?:CalendarEvent) { editingId.value=item?.id; Object.assign(form,item?{...item,endsOn:item.endsOn??''}:{academicTermId:selectedTermId.value||defaultAcademicTermId(terms.value)||'',title:'',kind:99,startsOn:today,endsOn:'',reminderDays:7,audienceRoles:[],description:'',isEnabled:true}); dialogVisible.value=true } isOffDay: boolean;
async function save() { if(!form.academicTermId||!form.title.trim()||!form.startsOn)return ElMessage.warning('请填写学期、日程名称和开始日期。'); const body={...form,title:form.title.trim(),endsOn:form.endsOn||null,description:form.description?.trim()||null}; if(editingId.value)await http.put(`/academic-calendar/${editingId.value}`,body);else await http.post('/academic-calendar',body); ElMessage.success('校历日程已保存;它只用于展示和提醒。'); dialogVisible.value=false; await load() } }
async function remove(item:CalendarEvent) { await ElMessageBox.confirm(`删除“${item.title}”吗?`,'删除日程',{type:'warning'});await http.delete(`/academic-calendar/${item.id}`);ElMessage.success('日程已删除');await load() } const auth = useAuthStore(),
onMounted(async()=>{terms.value=(await http.get('/base-data/terms')).data;selectedTermId.value=defaultAcademicTermId(terms.value)??'';await load()}) terms = ref<any[]>([]),
events = ref<CalendarEvent[]>([]),
selectedTermId = ref(""),
loading = ref(false),
dialogVisible = ref(false),
holidayDialogVisible = ref(false),
holidayLoading = ref(false),
holidayDays = ref<HolidayPreview[]>([]),
holidayYear = ref(new Date().getFullYear()),
editingId = ref<string>(),
cursor = ref(new Date(new Date().getFullYear(), new Date().getMonth(), 1));
const canManage = computed(
() =>
auth.user?.roles.some((role) =>
["SuperAdmin", "AcademicAdmin"].includes(role),
) ?? false,
);
const isStudent = computed(() => auth.user?.roles.includes("Student") ?? false);
const roles = [
["SuperAdmin", "超级管理员"],
["AcademicAdmin", "校级教务管理员"],
["CollegeAdmin", "学院教务管理员"],
["Leader", "领导"],
["Counselor", "辅导员"],
["Teacher", "教师"],
["Student", "学生"],
["MaintenanceAdmin", "维修管理员"],
["MaintenanceStaff", "维修人员"],
];
const kinds = [
[1, "选课"],
[2, "成绩"],
[3, "评教"],
[4, "考试"],
[5, "毕业审核"],
[6, "法定节假日"],
[99, "其他"],
],
weekdays = ["一", "二", "三", "四", "五", "六", "日"];
const form = reactive<any>({
academicTermId: "",
title: "",
kind: 99,
startsOn: "",
endsOn: "",
reminderDays: 7,
audienceRoles: [],
description: "",
isEnabled: true,
});
function key(date: Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function parse(value: string) {
const [y, m, d] = value.slice(0, 10).split("-").map(Number);
return new Date(y, m - 1, d);
}
function kindKey(kind: number | string) {
return (
(
{
"1": "course",
courseselection: "course",
"2": "grade",
grade: "grade",
"3": "evaluation",
evaluation: "evaluation",
"4": "exam",
exam: "exam",
"5": "graduation",
graduationaudit: "graduation",
"6": "holiday",
publicholiday: "holiday",
"99": "other",
other: "other",
} as Record<string, string>
)[String(kind).toLowerCase()] ?? "other"
);
}
function label(kind: number | string) {
return (
{
course: "选课",
grade: "成绩",
evaluation: "评教",
exam: "考试",
graduation: "毕业审核",
holiday: "法定节假日",
other: "其他",
} as Record<string, string>
)[kindKey(kind)];
}
function spans(item: CalendarEvent, day: string) {
return item.startsOn <= day && (item.endsOn ?? item.startsOn) >= day;
}
const today = key(new Date()),
monthTitle = computed(
() => `${cursor.value.getFullYear()} 年 ${cursor.value.getMonth() + 1} 月`,
);
const days = computed(() => {
const first = new Date(
cursor.value.getFullYear(),
cursor.value.getMonth(),
1,
),
start = new Date(first);
start.setDate(first.getDate() - ((first.getDay() + 6) % 7));
return Array.from({ length: 42 }, (_, i) => {
const date = new Date(start);
date.setDate(start.getDate() + i);
const day = key(date);
return {
date,
day,
inMonth: date.getMonth() === cursor.value.getMonth(),
events: events.value.filter((item) => spans(item, day)),
};
});
});
const monthEvents = computed(() =>
events.value.filter((item) => {
const date = parse(item.startsOn);
return (
date.getFullYear() === cursor.value.getFullYear() &&
date.getMonth() === cursor.value.getMonth()
);
}),
);
function shift(amount: number) {
cursor.value = new Date(
cursor.value.getFullYear(),
cursor.value.getMonth() + amount,
1,
);
}
function goToday() {
cursor.value = new Date(new Date().getFullYear(), new Date().getMonth(), 1);
}
function due(item: CalendarEvent) {
const gap = Math.ceil(
(parse(item.startsOn).getTime() - parse(today).getTime()) / 86400000,
);
return gap >= 0 && gap <= item.reminderDays;
}
function audience(item: CalendarEvent) {
return item.audienceRoles.length
? item.audienceRoles
.map((role) => roles.find((x) => x[0] === role)?.[1] ?? role)
.join("、")
: "全部角色";
}
async function load() {
loading.value = true;
try {
const calendarRequest = http.get(
canManage.value ? "/academic-calendar/manage" : "/academic-calendar",
{
params: selectedTermId.value
? { academicTermId: selectedTermId.value }
: undefined,
},
);
const examRequest = isStudent.value
? http.get("/exams/my-schedule")
: Promise.resolve({ data: [] });
const [calendar, exams] = await Promise.all([calendarRequest, examRequest]);
const examEvents = (exams.data as any[]).map((exam) => ({
id: `exam-${exam.id}`,
academicTermId: selectedTermId.value,
title: `考试:${exam.courseName}`,
kind: "Exam",
startsOn: exam.examDate,
reminderDays: 7,
audienceRoles: ["Student"],
description: `${exam.startsAt?.slice(11, 16) ?? ""}—${exam.endsAt?.slice(11, 16) ?? ""} · ${[exam.buildingName, exam.classroomName].filter(Boolean).join(" ")}`,
isEnabled: true,
source: "ExamSchedule",
}));
events.value = [...calendar.data, ...examEvents];
} finally {
loading.value = false;
}
}
async function previewHolidays() {
holidayLoading.value = true;
try {
const { data } = await http.get(
`/academic-calendar/holidays/${holidayYear.value}`,
);
holidayDays.value = data.days;
} finally {
holidayLoading.value = false;
}
}
async function openHolidaySync() {
holidayDialogVisible.value = true;
await previewHolidays();
}
async function syncHolidays() {
if (!selectedTermId.value)
return ElMessage.warning("请先选择要写入节假日的学期。");
await ElMessageBox.confirm(
`将同步 ${holidayYear.value} 年节假日,并替换该学期已同步的同年度数据。`,
"同步法定节假日",
{ type: "warning", confirmButtonText: "确认同步" },
);
holidayLoading.value = true;
try {
const { data } = await http.put("/academic-calendar/holidays/sync", {
academicTermId: selectedTermId.value,
year: holidayYear.value,
});
ElMessage.success(`已同步 ${data.syncedCount} 条节假日与调休数据。`);
holidayDialogVisible.value = false;
await load();
} finally {
holidayLoading.value = false;
}
}
function open(item?: CalendarEvent) {
editingId.value = item?.id;
Object.assign(
form,
item
? { ...item, endsOn: item.endsOn ?? "" }
: {
academicTermId:
selectedTermId.value || defaultAcademicTermId(terms.value) || "",
title: "",
kind: 99,
startsOn: today,
endsOn: "",
reminderDays: 7,
audienceRoles: [],
description: "",
isEnabled: true,
},
);
dialogVisible.value = true;
}
async function save() {
if (!form.academicTermId || !form.title.trim() || !form.startsOn)
return ElMessage.warning("请填写学期、日程名称和开始日期。");
const body = {
...form,
title: form.title.trim(),
endsOn: form.endsOn || null,
description: form.description?.trim() || null,
};
if (editingId.value)
await http.put(`/academic-calendar/${editingId.value}`, body);
else await http.post("/academic-calendar", body);
ElMessage.success("校历日程已保存;它只用于展示和提醒。");
dialogVisible.value = false;
await load();
}
async function remove(item: CalendarEvent) {
await ElMessageBox.confirm(`删除“${item.title}”吗?`, "删除日程", {
type: "warning",
});
await http.delete(`/academic-calendar/${item.id}`);
ElMessage.success("日程已删除");
await load();
}
onMounted(async () => {
terms.value = (await http.get("/base-data/terms")).data;
selectedTermId.value = defaultAcademicTermId(terms.value) ?? "";
await load();
});
</script> </script>
<template><section class="calendar-page"><header class="calendar-masthead"><div><p>ACADEMIC ALMANAC</p><h1>学期校历</h1><span>教学日程、法定节假日与临期提醒都落在同一张日历上;仅作提醒,不改变任何业务办理规则。</span></div><el-button v-if="canManage" type="primary" :icon="Plus" @click="open()">新增日程</el-button></header><div class="calendar-controls"><el-select v-model="selectedTermId" clearable placeholder="当前学期" @change="load"><el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id"/></el-select><div class="month-switcher"><el-button :icon="ArrowLeft" circle text @click="shift(-1)"/><strong>{{ monthTitle }}</strong><el-button text @click="goToday">今天</el-button><el-button :icon="ArrowRight" circle text @click="shift(1)"/></div><div class="calendar-legend"><span class="holiday">法定节假日</span><span>教学日程</span><span class="due">临期</span></div></div><el-alert v-if="!canManage" title="这里是校历提醒,不表示您有待办,也不会限制任何业务操作。" type="info" :closable="false" show-icon/><div v-loading="loading" class="calendar-layout"><div class="calendar-board"><div class="weekdays"><span v-for="weekday in weekdays" :key="weekday">周{{ weekday }}</span></div><div class="calendar-grid"><article v-for="day in days" :key="day.day" :class="['calendar-day',{muted:!day.inMonth,today:day.day===today,weekend:day.date.getDay()===0||day.date.getDay()===6}]"><div class="day-number">{{ day.date.getDate() }}</div><button v-for="item in day.events.slice(0,3)" :key="item.id" type="button" :class="['calendar-event',kindKey(item.kind),{due:due(item)}]" :title="item.title" @click="canManage&&open(item)"><span>{{ item.title.replace('(法定节假日)','') }}</span></button><span v-if="day.events.length>3" class="more-events">+{{ day.events.length-3 }} 项</span></article></div></div><aside class="month-agenda"><header><span>本月安排</span><strong>{{ monthEvents.length }} 项</strong></header><div v-if="monthEvents.length" class="agenda-items"><article v-for="item in monthEvents" :key="item.id" :class="['agenda-item',kindKey(item.kind)]"><time>{{ item.startsOn.slice(5).replace('-',' / ') }}<template v-if="item.endsOn&&item.endsOn!==item.startsOn">—{{ item.endsOn.slice(5).replace('-',' / ') }}</template></time><div><b>{{ item.title }}</b><p>{{ item.description||label(item.kind) }}</p><small v-if="canManage">面向:{{ audience(item) }}<template v-if="due(item)"> · 临期提醒</template></small></div><div v-if="canManage" class="agenda-actions"><el-button text @click="open(item)">编辑</el-button><el-button text type="danger" @click="remove(item)">删除</el-button></div></article></div><el-empty v-else :image-size="60" description="本月暂无日程"/></aside></div><el-dialog v-model="dialogVisible" :title="editingId?'编辑校历日程':'新增校历日程'" width="620px" destroy-on-close><el-form label-width="98px"><el-form-item label="所属学期" required><el-select v-model="form.academicTermId"><el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id"/></el-select></el-form-item><el-form-item label="日程名称" required><el-input v-model="form.title" maxlength="120"/></el-form-item><el-form-item label="日程类型"><el-select v-model="form.kind"><el-option v-for="kind in kinds" :key="kind[0]" :label="kind[1]" :value="kind[0]"/></el-select></el-form-item><el-form-item label="开始 / 结束" required><el-date-picker v-model="form.startsOn" type="date" value-format="YYYY-MM-DD"/><span class="range-separator">至</span><el-date-picker v-model="form.endsOn" type="date" value-format="YYYY-MM-DD" clearable/></el-form-item><el-form-item label="提前提醒"><el-input-number v-model="form.reminderDays" :min="0" :max="90"/> <span class="form-hint">天(只控制页面临期提示)</span></el-form-item><el-form-item label="面向角色"><el-checkbox-group v-model="form.audienceRoles"><el-checkbox v-for="role in roles" :key="role[0]" :label="role[0]">{{ role[1] }}</el-checkbox></el-checkbox-group><div class="form-hint">不选则向所有角色显示。</div></el-form-item><el-form-item label="说明"><el-input v-model="form.description" type="textarea" :rows="3" maxlength="1000" show-word-limit/></el-form-item><el-form-item label="启用"><el-switch v-model="form.isEnabled"/></el-form-item></el-form><template #footer><el-button @click="dialogVisible=false">取消</el-button><el-button type="primary" @click="save">保存</el-button></template></el-dialog></section></template> <template>
<section class="calendar-page">
<header class="calendar-masthead">
<div>
<p>ACADEMIC ALMANAC</p>
<h1>学期校历</h1>
<span
>教学日程、法定节假日与临期提醒都落在同一张日历上;仅作提醒,不改变任何业务办理规则。</span
>
</div>
<div v-if="canManage" class="calendar-masthead-actions">
<el-button @click="openHolidaySync">同步法定节假日</el-button>
<el-button type="primary" :icon="Plus" @click="open()"
>新增日程</el-button
>
</div>
</header>
<div class="calendar-controls">
<el-select
v-model="selectedTermId"
clearable
placeholder="当前学期"
@change="load"
><el-option
v-for="term in terms"
:key="term.id"
:label="academicTermLabel(term)"
:value="term.id"
/></el-select>
<div class="month-switcher">
<el-button :icon="ArrowLeft" circle text @click="shift(-1)" /><strong>{{
monthTitle
}}</strong
><el-button text @click="goToday">今天</el-button
><el-button :icon="ArrowRight" circle text @click="shift(1)" />
</div>
<div class="calendar-legend">
<span class="holiday">法定节假日</span><span>教学日程</span
><span class="due">临期</span>
</div>
</div>
<el-alert
v-if="!canManage"
title="这里是校历提醒,不表示您有待办,也不会限制任何业务操作。"
type="info"
:closable="false"
show-icon
/>
<div v-loading="loading" class="calendar-layout">
<div class="calendar-board">
<div class="weekdays">
<span v-for="weekday in weekdays" :key="weekday"
>周{{ weekday }}</span
>
</div>
<div class="calendar-grid">
<article
v-for="day in days"
:key="day.day"
:class="[
'calendar-day',
{
muted: !day.inMonth,
today: day.day === today,
weekend: day.date.getDay() === 0 || day.date.getDay() === 6,
},
]"
>
<div class="day-number">{{ day.date.getDate() }}</div>
<button
v-for="item in day.events.slice(0, 3)"
:key="item.id"
type="button"
:class="[
'calendar-event',
kindKey(item.kind),
{ due: due(item) },
]"
:title="item.title"
@click="canManage && open(item)"
>
<span>{{
item.title.replace("(法定节假日)", "")
}}</span></button
><span v-if="day.events.length > 3" class="more-events"
>+{{ day.events.length - 3 }} 项</span
>
</article>
</div>
</div>
<aside class="month-agenda">
<header>
<span>本月安排</span><strong>{{ monthEvents.length }} 项</strong>
</header>
<div v-if="monthEvents.length" class="agenda-items">
<article
v-for="item in monthEvents"
:key="item.id"
:class="['agenda-item', kindKey(item.kind)]"
>
<time
>{{ item.startsOn.slice(5).replace("-", " / ")
}}<template v-if="item.endsOn && item.endsOn !== item.startsOn"
>—{{ item.endsOn.slice(5).replace("-", " / ") }}</template
></time
>
<div>
<b>{{ item.title }}</b>
<p>{{ item.description || label(item.kind) }}</p>
<small v-if="canManage"
>面向:{{ audience(item)
}}<template v-if="due(item)"> · 临期提醒</template></small
>
</div>
<div v-if="canManage" class="agenda-actions">
<el-button text @click="open(item)">编辑</el-button
><el-button text type="danger" @click="remove(item)"
>删除</el-button
>
</div>
</article>
</div>
<el-empty v-else :image-size="60" description="本月暂无日程" />
</aside>
</div>
<el-dialog
v-model="dialogVisible"
:title="editingId ? '编辑校历日程' : '新增校历日程'"
width="620px"
destroy-on-close
><el-form label-width="98px"
><el-form-item label="所属学期" required
><el-select v-model="form.academicTermId"
><el-option
v-for="term in terms"
:key="term.id"
:label="academicTermLabel(term)"
:value="term.id" /></el-select></el-form-item
><el-form-item label="日程名称" required
><el-input v-model="form.title" maxlength="120" /></el-form-item
><el-form-item label="日程类型"
><el-select v-model="form.kind"
><el-option
v-for="kind in kinds"
:key="kind[0]"
:label="kind[1]"
:value="kind[0]" /></el-select></el-form-item
><el-form-item label="开始 / 结束" required
><el-date-picker
v-model="form.startsOn"
type="date"
value-format="YYYY-MM-DD" /><span class="range-separator">至</span
><el-date-picker
v-model="form.endsOn"
type="date"
value-format="YYYY-MM-DD"
clearable /></el-form-item
><el-form-item label="提前提醒"
><el-input-number v-model="form.reminderDays" :min="0" :max="90" />
<span class="form-hint">天(只控制页面临期提示)</span></el-form-item
><el-form-item label="面向角色"
><el-checkbox-group v-model="form.audienceRoles"
><el-checkbox
v-for="role in roles"
:key="role[0]"
:label="role[0]"
>{{ role[1] }}</el-checkbox
></el-checkbox-group
>
<div class="form-hint">不选则向所有角色显示。</div></el-form-item
><el-form-item label="说明"
><el-input
v-model="form.description"
type="textarea"
:rows="3"
maxlength="1000"
show-word-limit /></el-form-item
><el-form-item label="启用"
><el-switch v-model="form.isEnabled" /></el-form-item></el-form
><template #footer
><el-button @click="dialogVisible = false">取消</el-button
><el-button type="primary" @click="save">保存</el-button></template
></el-dialog
><el-dialog
v-model="holidayDialogVisible"
title="同步法定节假日"
width="620px"
destroy-on-close
>
<p class="holiday-sync-intro">
从节假日数据源读取指定年份,写入当前选择的学期。再次同步同一年会替换此前同步的数据。
</p>
<div class="holiday-sync-controls">
<el-input-number
v-model="holidayYear"
:min="2000"
:max="2100"
controls-position="right"
/>
<el-button :loading="holidayLoading" @click="previewHolidays"
>预览数据</el-button
>
</div>
<div v-loading="holidayLoading" class="holiday-preview">
<el-empty
v-if="!holidayDays.length"
:image-size="50"
description="点击预览以加载节假日数据"
/>
<div v-else class="holiday-preview-list">
<span
v-for="item in holidayDays"
:key="`${item.date}-${item.name}`"
:class="{ workday: !item.isOffDay }"
>
<b>{{ item.date }}</b
>{{ item.name }} · {{ item.isOffDay ? "休息" : "调休上班" }}
</span>
</div>
</div>
<template #footer>
<el-button @click="holidayDialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="holidayLoading"
:disabled="!holidayDays.length"
@click="syncHolidays"
>同步到当前学期</el-button
>
</template>
</el-dialog>
</section>
</template>
<style scoped> <style scoped>
.calendar-page{max-width:1280px;margin:auto;padding:10px 0 38px}.calendar-masthead{display:flex;justify-content:space-between;align-items:end;gap:24px;margin:10px 0 22px}.calendar-masthead p{margin:0 0 5px;color:#2a7890;font:700 11px/1 ui-monospace,monospace;letter-spacing:.14em}.calendar-masthead h1{margin:0 0 9px;color:#183e57;font:700 32px/1.1 "STZhongsong","Songti SC",serif}.calendar-masthead span{color:#657b8d;font-size:14px}.calendar-controls{display:flex;align-items:center;gap:18px;margin:14px 0 16px;padding:11px 14px;border-top:2px solid #2b6f83;border-bottom:1px solid #d9e4e8;background:#f8fbfb}.calendar-controls>.el-select{width:238px}.month-switcher{display:flex;align-items:center;gap:8px}.month-switcher strong{min-width:112px;color:#214d63;text-align:center;font:700 18px "STZhongsong","Songti SC",serif}.calendar-legend{display:flex;gap:12px;margin-left:auto;color:#60798a;font-size:12px}.calendar-legend span{display:flex;align-items:center;gap:5px}.calendar-legend span:before{width:8px;height:8px;border-radius:50%;background:#4f91a0;content:""}.calendar-legend .holiday:before{background:#be4b4f}.calendar-legend .due:before{background:#df9c35}.calendar-layout{display:grid;grid-template-columns:minmax(0,1fr) 300px;border:1px solid #dce6e8;background:#fff}.weekdays,.calendar-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr))}.weekdays{border-bottom:1px solid #dce6e8;background:#edf4f5}.weekdays span{padding:9px;text-align:center;color:#456a78;font-size:12px;font-weight:700}.weekdays span:nth-last-child(-n+2){color:#a45a55}.calendar-day{min-height:120px;padding:8px;border-right:1px solid #e4ecee;border-bottom:1px solid #e4ecee;background:#fff}.calendar-day:nth-child(7n){border-right:0}.calendar-day.muted{background:#fafcfc}.calendar-day.weekend:not(.muted){background:#fffafa}.calendar-day.today .day-number{display:grid;place-items:center;width:23px;height:23px;border-radius:50%;background:#22687e;color:#fff}.day-number{margin:0 0 5px;color:#4a6270;font:700 13px/1 ui-monospace,monospace}.muted .day-number{color:#b1bdc3}.calendar-event{display:block;overflow:hidden;width:100%;margin:3px 0;padding:3px 5px;color:#315966;text-overflow:ellipsis;white-space:nowrap;border:0;border-left:2px solid #5d9aac;background:#edf7f8;cursor:pointer;text-align:left;font-size:11px}.calendar-event.holiday{color:#9c3439;border-left-color:#c24348;background:#fff0f0;font-weight:700}.calendar-event.grade{border-left-color:#9170b0;background:#f5f0fb}.calendar-event.exam{border-left-color:#bc783a;background:#fff5e8}.calendar-event.due{box-shadow:inset -3px 0 #df9c35}.more-events{color:#708996;font-size:11px}.month-agenda{border-left:1px solid #dce6e8;background:#f9fbfb}.month-agenda header{display:flex;justify-content:space-between;padding:16px;border-bottom:1px solid #dde8ea;color:#284e61}.month-agenda header span{font-size:13px}.month-agenda header strong{font:700 15px ui-monospace,monospace}.agenda-items{padding:5px 12px}.agenda-item{display:grid;grid-template-columns:68px 1fr;gap:9px;padding:12px 0;border-bottom:1px solid #e2eaec}.agenda-item time{color:#4f8796;font:700 11px/1.45 ui-monospace,monospace}.agenda-item b{display:block;color:#304f5f;font-size:13px}.agenda-item p{margin:4px 0;color:#718691;font-size:12px;line-height:1.5}.agenda-item small{color:#9a7740;font-size:11px}.agenda-item.holiday time{color:#b04a4d}.agenda-actions{grid-column:2;display:flex;gap:3px}.range-separator{padding:0 8px;color:#7b8794}.form-hint{color:#718096;font-size:13px}@media(max-width:980px){.calendar-layout{grid-template-columns:1fr}.month-agenda{border-top:1px solid #dce6e8;border-left:0}.agenda-items{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 15px}}@media(max-width:700px){.calendar-masthead,.calendar-controls{align-items:flex-start;flex-direction:column}.calendar-legend{margin-left:0}.calendar-day{min-height:88px;padding:5px}.calendar-event{padding:2px 3px;font-size:10px}.calendar-event span{display:none}.calendar-event:after{content:"●"}.calendar-event.holiday:after{content:"休"}.weekdays span{padding:7px 2px;font-size:11px}.agenda-items{grid-template-columns:1fr}.calendar-controls>.el-select{width:100%}} .calendar-page {
max-width: 1280px;
margin: auto;
padding: 10px 0 38px;
}
.calendar-masthead {
display: flex;
justify-content: space-between;
align-items: end;
gap: 24px;
margin: 10px 0 22px;
}
.calendar-masthead-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.calendar-masthead p {
margin: 0 0 5px;
color: #2a7890;
font:
700 11px/1 ui-monospace,
monospace;
letter-spacing: 0.14em;
}
.calendar-masthead h1 {
margin: 0 0 9px;
color: #183e57;
font:
700 32px/1.1 "STZhongsong",
"Songti SC",
serif;
}
.calendar-masthead span {
color: #657b8d;
font-size: 14px;
}
.calendar-controls {
display: flex;
align-items: center;
gap: 18px;
margin: 14px 0 16px;
padding: 11px 14px;
border-top: 2px solid #2b6f83;
border-bottom: 1px solid #d9e4e8;
background: #f8fbfb;
}
.calendar-controls > .el-select {
width: 238px;
}
.month-switcher {
display: flex;
align-items: center;
gap: 8px;
}
.month-switcher strong {
min-width: 112px;
color: #214d63;
text-align: center;
font:
700 18px "STZhongsong",
"Songti SC",
serif;
}
.calendar-legend {
display: flex;
gap: 12px;
margin-left: auto;
color: #60798a;
font-size: 12px;
}
.calendar-legend span {
display: flex;
align-items: center;
gap: 5px;
}
.calendar-legend span:before {
width: 8px;
height: 8px;
border-radius: 50%;
background: #4f91a0;
content: "";
}
.calendar-legend .holiday:before {
background: #be4b4f;
}
.calendar-legend .due:before {
background: #df9c35;
}
.calendar-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
border: 1px solid #dce6e8;
background: #fff;
}
.weekdays,
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.weekdays {
border-bottom: 1px solid #dce6e8;
background: #edf4f5;
}
.weekdays span {
padding: 9px;
text-align: center;
color: #456a78;
font-size: 12px;
font-weight: 700;
}
.weekdays span:nth-last-child(-n + 2) {
color: #a45a55;
}
.calendar-day {
min-height: 120px;
padding: 8px;
border-right: 1px solid #e4ecee;
border-bottom: 1px solid #e4ecee;
background: #fff;
}
.calendar-day:nth-child(7n) {
border-right: 0;
}
.calendar-day.muted {
background: #fafcfc;
}
.calendar-day.weekend:not(.muted) {
background: #fffafa;
}
.calendar-day.today .day-number {
display: grid;
place-items: center;
width: 23px;
height: 23px;
border-radius: 50%;
background: #22687e;
color: #fff;
}
.day-number {
margin: 0 0 5px;
color: #4a6270;
font:
700 13px/1 ui-monospace,
monospace;
}
.muted .day-number {
color: #b1bdc3;
}
.calendar-event {
display: block;
overflow: hidden;
width: 100%;
margin: 3px 0;
padding: 3px 5px;
color: #315966;
text-overflow: ellipsis;
white-space: nowrap;
border: 0;
border-left: 2px solid #5d9aac;
background: #edf7f8;
cursor: pointer;
text-align: left;
font-size: 11px;
}
.calendar-event.holiday {
color: #9c3439;
border-left-color: #c24348;
background: #fff0f0;
font-weight: 700;
}
.calendar-event.grade {
border-left-color: #9170b0;
background: #f5f0fb;
}
.calendar-event.exam {
border-left-color: #bc783a;
background: #fff5e8;
}
.calendar-event.due {
box-shadow: inset -3px 0 #df9c35;
}
.more-events {
color: #708996;
font-size: 11px;
}
.month-agenda {
border-left: 1px solid #dce6e8;
background: #f9fbfb;
}
.month-agenda header {
display: flex;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #dde8ea;
color: #284e61;
}
.month-agenda header span {
font-size: 13px;
}
.month-agenda header strong {
font:
700 15px ui-monospace,
monospace;
}
.agenda-items {
padding: 5px 12px;
}
.agenda-item {
display: grid;
grid-template-columns: 68px 1fr;
gap: 9px;
padding: 12px 0;
border-bottom: 1px solid #e2eaec;
}
.agenda-item time {
color: #4f8796;
font:
700 11px/1.45 ui-monospace,
monospace;
}
.agenda-item b {
display: block;
color: #304f5f;
font-size: 13px;
}
.agenda-item p {
margin: 4px 0;
color: #718691;
font-size: 12px;
line-height: 1.5;
}
.agenda-item small {
color: #9a7740;
font-size: 11px;
}
.agenda-item.holiday time {
color: #b04a4d;
}
.agenda-actions {
grid-column: 2;
display: flex;
gap: 3px;
}
.range-separator {
padding: 0 8px;
color: #7b8794;
}
.form-hint {
color: #718096;
font-size: 13px;
}
.holiday-sync-intro {
margin: 0 0 16px;
color: #60798a;
line-height: 1.65;
}
.holiday-sync-controls {
display: flex;
align-items: center;
gap: 10px;
}
.holiday-preview {
min-height: 130px;
margin-top: 14px;
padding: 10px;
border: 1px solid #e1eaed;
background: #fafcfc;
}
.holiday-preview-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.holiday-preview-list span {
padding: 5px 7px;
color: #9c3439;
border-left: 2px solid #c24348;
background: #fff1f1;
font-size: 12px;
}
.holiday-preview-list span.workday {
color: #835d20;
border-left-color: #c08831;
background: #fff7e9;
}
.holiday-preview-list b {
margin-right: 5px;
font:
700 11px ui-monospace,
monospace;
}
@media (max-width: 980px) {
.calendar-layout {
grid-template-columns: 1fr;
}
.month-agenda {
border-top: 1px solid #dce6e8;
border-left: 0;
}
.agenda-items {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 15px;
}
}
@media (max-width: 700px) {
.calendar-masthead,
.calendar-controls {
align-items: flex-start;
flex-direction: column;
}
.calendar-legend {
margin-left: 0;
}
.calendar-day {
min-height: 88px;
padding: 5px;
}
.calendar-event {
padding: 2px 3px;
font-size: 10px;
}
.calendar-event span {
display: none;
}
.calendar-event:after {
content: "●";
}
.calendar-event.holiday:after {
content: "休";
}
.weekdays span {
padding: 7px 2px;
font-size: 11px;
}
.agenda-items {
grid-template-columns: 1fr;
}
.calendar-controls > .el-select {
width: 100%;
}
}
</style> </style>