Implement requested Jiaowu workflow updates
This commit is contained in:
1 parent
2257ce7674
commit
3450d882cf
20 files changed
+16416
-7
No files matched your search
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, ArrowRight, Plus } from '@element-plus/icons-vue'
|
||||
import http from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { academicTermLabel, defaultAcademicTermId } from '../utils/academicTerms'
|
||||
interface CalendarEvent { id: string; academicTermId: string; title: string; kind: number | string; startsOn: string; endsOn?: string; reminderDays: number; audienceRoles: string[]; description?: string; isEnabled: boolean }
|
||||
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))
|
||||
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 } }
|
||||
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>
|
||||
<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>
|
||||
<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%}}
|
||||
</style>
|
||||
@@ -22,6 +22,7 @@ import http from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import StudentDashboardView from './StudentDashboardView.vue'
|
||||
import TeacherDashboardView from './TeacherDashboardView.vue'
|
||||
import AcademicCalendarReminder from '../components/AcademicCalendarReminder.vue'
|
||||
|
||||
interface DashboardData {
|
||||
audience: {
|
||||
@@ -440,6 +441,7 @@ onMounted(async () => {
|
||||
</el-result>
|
||||
|
||||
<template v-else-if="data">
|
||||
<AcademicCalendarReminder />
|
||||
<section class="overview-hero">
|
||||
<div class="overview-heading">
|
||||
<div class="scope-line">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ArrowRight, Bell, Calendar, Clock, Location, Reading } from '@element-p
|
||||
import { useRouter } from 'vue-router'
|
||||
import http from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import AcademicCalendarReminder from '../components/AcademicCalendarReminder.vue'
|
||||
|
||||
interface TimetableSlot {
|
||||
periodNumber: number
|
||||
@@ -353,6 +354,7 @@ onMounted(loadOverview)
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="student-overview">
|
||||
<AcademicCalendarReminder />
|
||||
<section class="student-overview-hero">
|
||||
<div class="student-hero-copy">
|
||||
<span class="section-kicker">我的学习安排</span>
|
||||
|
||||
Loaded 3 of 20 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user