This commit is contained in:
2026-07-24 22:18:32 +08:00 Unverified
parent d67a07f23e
commit 27c3944c28
12 changed files with 781 additions and 14 deletions
+2 -1
View File
@@ -53,7 +53,7 @@ async function submit() {
<div class="form-intro">
<span>欢迎回来</span>
<h2>登录教务工作台</h2>
<p>使用学校分配的管理账号继续</p>
<p>学生使用学号教师使用工号登录管理人员使用学校分配的账号</p>
</div>
<label>
<span>账号</span>
@@ -79,6 +79,7 @@ async function submit() {
>
进入工作台
</el-button>
<router-link class="public-timetable-link" to="/timetable">无需登录查询班级课表 </router-link>
<div class="dev-hint">
<b>本地开发账号</b>
<span>admin / Admin@123456</span>
+40 -3
View File
@@ -113,6 +113,7 @@ function resetForm(row?: any) {
phone: '',
email: '',
notes: '',
initialPassword: '',
}, row ?? {})
} else {
const currentYear = new Date().getFullYear()
@@ -128,6 +129,7 @@ function resetForm(row?: any) {
phone: '',
email: '',
notes: '',
initialPassword: '',
}, row ?? {})
}
}
@@ -266,11 +268,22 @@ async function save() {
ElMessage.warning(`请填写${numberLabel.value}和姓名。`)
return
}
if ((!editingId.value || !form.userId) && String(form.initialPassword ?? '').length < 8) {
ElMessage.warning('请设置至少 8 位初始密码,系统会同时创建同号登录账号。')
return
}
try {
const path = `/personnel/${active.value}`
if (editingId.value) await http.put(`${path}/${editingId.value}`, form)
else await http.post(path, form)
ElMessage.success(editingId.value ? '档案已更新' : '档案已建立')
const payload = { ...form }
if (editingId.value && form.userId) {
delete payload.initialPassword
}
if (editingId.value) {
await http.put(`${path}/${editingId.value}`, payload)
} else {
await http.post(path, payload)
}
ElMessage.success(editingId.value ? '档案与登录账号已同步' : '档案和同号登录账号已建立')
dialogVisible.value = false
await load()
} catch (error) {
@@ -407,6 +420,13 @@ watch(active, async () => {
</span>
</template>
</el-table-column>
<el-table-column label="登录账号" width="105">
<template #default="{ row }">
<span class="table-status" :class="{ off: !row.userId }">
{{ row.userId ? '已开通' : '未开通' }}
</span>
</template>
</el-table-column>
<el-table-column label="联系方式" min-width="170">
<template #default="{ row }">{{ row.phone || row.email || '—' }}</template>
</el-table-column>
@@ -501,6 +521,23 @@ watch(active, async () => {
<el-form-item label="电子邮箱"><el-input v-model="form.email" /></el-form-item>
</div>
<el-form-item label="备注"><el-input v-model="form.notes" type="textarea" :rows="3" /></el-form-item>
<el-alert
v-if="!editingId || !form.userId"
type="info"
:closable="false"
:title="editingId
? '该历史档案尚未开通账号,本次保存会以工号或学号补建登录账号。'
: '保存档案后,系统会以工号或学号作为登录账号,并自动分配对应角色。'"
/>
<el-form-item v-if="!editingId || !form.userId" label="初始密码" required>
<el-input
v-model="form.initialPassword"
type="password"
show-password
autocomplete="new-password"
placeholder="至少 8 位,仅用于创建密码哈希"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
+235
View File
@@ -0,0 +1,235 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import http, { apiErrorMessage } from '../api/http'
const route = useRoute()
const isMine = computed(() => route.meta.mine === true)
const isPublic = computed(() => route.meta.public === true)
const loading = ref(false)
const terms = ref<any[]>([])
const classes = ref<any[]>([])
const termId = ref('')
const classId = ref('')
const timetable = ref<any | null>(null)
const weekdays = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
const patternLabels: Record<string, string> = { All: '每周', Odd: '单周', Even: '双周' }
const maxPeriods = computed(() => {
const slotMaximum = Math.max(0, ...((timetable.value?.slots ?? []).map((x: any) => x.periodNumber)))
const entryMaximum = Math.max(
0,
...((timetable.value?.entries ?? []).map((x: any) => x.startPeriod + x.periodCount - 1)),
)
return Math.max(8, slotMaximum, entryMaximum)
})
const slotMap = computed<Map<number, any>>(() =>
new Map<number, any>(
(timetable.value?.slots ?? []).map((item: any) => [item.periodNumber, item]),
),
)
function formatTime(value: string) {
return value?.slice(0, 5) ?? ''
}
function entryStyle(entry: any) {
return {
gridColumn: String(entry.dayOfWeek + 1),
gridRow: `${entry.startPeriod + 1} / span ${entry.periodCount}`,
}
}
function weeks(entry: any) {
const pattern = patternLabels[entry.weekPattern]
return `${entry.startWeek}${entry.endWeek}${pattern === '每周' ? '' : ` · ${pattern}`}`
}
function location(entry: any) {
return [entry.campusName, entry.buildingName, entry.classroomName].filter(Boolean).join(' · ')
}
async function loadOptions() {
const { data } = await http.get('/timetables/options')
terms.value = data.terms
classes.value = data.classes
termId.value = data.terms.find((item: any) => item.isCurrent)?.id
?? data.terms.find((item: any) => item.hasPublishedTimetable)?.id
?? data.terms[0]?.id
?? ''
classId.value = data.classes.find((item: any) => item.hasPublishedTimetable)?.id
?? data.classes[0]?.id
?? ''
}
async function loadTimetable() {
if (!termId.value || (!isMine.value && !classId.value)) return
loading.value = true
try {
const url = isMine.value ? '/timetables/mine' : `/timetables/classes/${classId.value}`
timetable.value = (await http.get(url, {
params: { academicTermId: termId.value },
})).data
} catch (error) {
timetable.value = null
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
watch([termId, classId], loadTimetable)
onMounted(async () => {
try {
await loadOptions()
await loadTimetable()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
})
</script>
<template>
<main :class="['timetable-page', { 'public-timetable': isPublic }]">
<header v-if="isPublic" class="public-header">
<router-link class="public-brand" to="/timetable">明序教务 · 课表查询</router-link>
<router-link class="login-link" to="/login">登录工作台</router-link>
</header>
<section class="timetable-heading">
<div>
<span class="section-kicker">{{ isMine ? 'MY TIMETABLE' : 'CLASS TIMETABLE' }}</span>
<h2>{{ isMine ? '我的课表' : '班级课表查询' }}</h2>
<p v-if="isMine">行政班课程与本人已选课程统一展示仅采用教务处已发布课表</p>
<p v-else>无需登录即可查询各行政班已正式发布的课程安排</p>
</div>
<div class="timetable-filters">
<el-select v-model="termId" filterable placeholder="选择学期">
<el-option
v-for="term in terms"
:key="term.id"
:label="`${term.name}${term.hasPublishedTimetable ? '' : '(未发布)'}`"
:value="term.id"
/>
</el-select>
<el-select
v-if="!isMine"
v-model="classId"
filterable
placeholder="选择行政班"
>
<el-option
v-for="item in classes"
:key="item.id"
:label="`${item.collegeName} · ${item.majorName} · ${item.name}`"
:value="item.id"
/>
</el-select>
</div>
</section>
<section v-loading="loading" class="timetable-sheet">
<div v-if="timetable" class="sheet-meta">
<div>
<strong>{{ timetable.class.name }}</strong>
<span>{{ timetable.class.collegeName }} · {{ timetable.class.majorName }}</span>
</div>
<div v-if="timetable.student">
<strong>{{ timetable.student.name }}</strong>
<span>{{ timetable.student.studentNumber }}</span>
</div>
<div>
<strong>{{ timetable.term.name }}</strong>
<span v-if="timetable.plan">发布于 {{ new Date(timetable.plan.publishedAt).toLocaleString('zh-CN') }}</span>
<span v-else>本学期课表尚未发布</span>
</div>
</div>
<div v-if="timetable?.plan && timetable.entries.length" class="timetable-scroll">
<div
class="week-grid"
:style="{ gridTemplateRows: `48px repeat(${maxPeriods}, 110px)` }"
>
<div class="grid-corner">节次</div>
<div v-for="day in 7" :key="`head-${day}`" class="day-head">{{ weekdays[day] }}</div>
<template v-for="period in maxPeriods" :key="`period-${period}`">
<div class="period-head" :style="{ gridRow: String(period + 1) }">
<strong> {{ period }} </strong>
<span v-if="slotMap.get(period)">
{{ formatTime(slotMap.get(period).startsAt) }}{{ formatTime(slotMap.get(period).endsAt) }}
</span>
</div>
<div
v-for="day in 7"
:key="`cell-${period}-${day}`"
class="grid-cell"
:style="{ gridColumn: String(day + 1), gridRow: String(period + 1) }"
/>
</template>
<article
v-for="entry in timetable.entries"
:key="entry.id"
class="course-block"
:style="entryStyle(entry)"
>
<strong>{{ entry.courseName }}</strong>
<span>{{ entry.teacherNames.join('、') || '教师待定' }}</span>
<span>{{ location(entry) }}</span>
<small>{{ weeks(entry) }} · {{ entry.startPeriod }}{{ entry.startPeriod + entry.periodCount - 1 }} </small>
</article>
</div>
</div>
<el-empty
v-else-if="timetable && !loading"
:description="timetable.plan ? '该课表暂时没有课程安排' : '所选学期尚未发布课表'"
/>
</section>
</main>
</template>
<style scoped>
.timetable-page { display: grid; gap: 20px; }
.public-timetable { min-height: 100vh; padding: 0 32px 40px; background: #f4f7fa; }
.public-header { height: 68px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #dce4eb; }
.public-brand { color: #17324d; font-weight: 750; text-decoration: none; letter-spacing: .02em; }
.login-link { color: #176b87; text-decoration: none; font-weight: 650; }
.timetable-heading { display: flex; align-items: end; justify-content: space-between; gap: 24px; padding: 24px 28px; background: #fff; border: 1px solid #dce4eb; }
.timetable-heading h2 { margin: 5px 0 8px; color: #17324d; font-size: 27px; }
.timetable-heading p { margin: 0; color: #647587; }
.timetable-filters { display: flex; gap: 12px; }
.timetable-filters .el-select { width: 270px; }
.timetable-sheet { min-height: 360px; padding: 22px; background: #fff; border: 1px solid #dce4eb; }
.sheet-meta { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 18px; padding-bottom: 16px; border-bottom: 1px solid #e6ebf0; }
.sheet-meta div { display: grid; gap: 3px; }
.sheet-meta strong { color: #17324d; }
.sheet-meta span { color: #718191; font-size: 13px; }
.timetable-scroll { overflow: auto; }
.week-grid { min-width: 1120px; display: grid; grid-template-columns: 92px repeat(7, minmax(140px, 1fr)); position: relative; }
.grid-corner, .day-head, .period-head { z-index: 2; background: #f3f6f8; border: 1px solid #dce4eb; color: #3f5366; }
.grid-corner, .day-head { display: grid; place-items: center; font-weight: 700; }
.grid-corner { grid-column: 1; grid-row: 1; }
.day-head { grid-row: 1; }
.day-head:nth-of-type(2) { grid-column: 2; }
.day-head:nth-of-type(3) { grid-column: 3; }
.day-head:nth-of-type(4) { grid-column: 4; }
.day-head:nth-of-type(5) { grid-column: 5; }
.day-head:nth-of-type(6) { grid-column: 6; }
.day-head:nth-of-type(7) { grid-column: 7; }
.day-head:nth-of-type(8) { grid-column: 8; }
.period-head { grid-column: 1; display: grid; place-content: center; gap: 4px; text-align: center; }
.period-head span { color: #7c8b98; font-size: 11px; }
.grid-cell { border: 1px solid #e4e9ee; background: #fff; }
.course-block { z-index: 3; margin: 4px; padding: 9px 10px; overflow: hidden; display: flex; flex-direction: column; gap: 4px; border-left: 4px solid #176b87; background: #e9f3f5; color: #24475a; box-shadow: 0 2px 5px rgba(23, 50, 77, .08); }
.course-block strong { color: #123a4b; font-size: 14px; }
.course-block span { font-size: 12px; }
.course-block small { margin-top: auto; color: #5f7885; font-size: 11px; }
@media (max-width: 760px) {
.public-timetable { padding: 0 14px 24px; }
.timetable-heading, .sheet-meta { align-items: stretch; flex-direction: column; }
.timetable-filters { flex-direction: column; }
.timetable-filters .el-select { width: 100%; }
.timetable-sheet { padding: 12px; }
}
</style>