成绩+点名
This commit is contained in:
Vendored
+1
@@ -20,6 +20,7 @@ declare module 'vue' {
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDivider: typeof import('element-plus/es')['ElDivider']
|
||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
|
||||
@@ -106,6 +106,8 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
|
||||
),
|
||||
...whenVisible(isStudent.value || isTeacher.value, { path: '/my-timetable', label: isTeacher.value ? '我的授课课表' : '我的课表' }),
|
||||
...whenVisible(isStudent.value, { path: '/free-classrooms', label: '空闲教室' }),
|
||||
...whenVisible(isTeacher.value || isTeachingAdmin.value, { path: '/teacher-attendance', label: '教学点名' }),
|
||||
...whenVisible(isTeacher.value, { path: '/teacher-roster', label: '选课名单' }),
|
||||
...whenVisible(!isStudent.value, {
|
||||
path: '/class-timetable',
|
||||
label: isTimetableManager.value ? '课表查询中心' : '班级课表查询',
|
||||
|
||||
@@ -149,6 +149,18 @@ const router = createRouter({
|
||||
component: () => import('../views/TimetableView.vue'),
|
||||
meta: { teacherView: true },
|
||||
},
|
||||
{
|
||||
path: 'teacher-roster',
|
||||
name: 'teacher-roster',
|
||||
component: () => import('../views/TeacherRosterView.vue'),
|
||||
meta: { roles: ['Teacher'] },
|
||||
},
|
||||
{
|
||||
path: 'teacher-attendance',
|
||||
name: 'teacher-attendance',
|
||||
component: () => import('../views/TeacherAttendanceView.vue'),
|
||||
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher'] },
|
||||
},
|
||||
{
|
||||
path: 'free-classrooms',
|
||||
name: 'free-classrooms',
|
||||
|
||||
+126
-41
@@ -2,6 +2,7 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import {
|
||||
Check,
|
||||
Delete,
|
||||
DocumentChecked,
|
||||
EditPen,
|
||||
Plus,
|
||||
@@ -28,11 +29,15 @@ const termId = ref<string | undefined>()
|
||||
const status = ref<string | undefined>()
|
||||
const createForm = reactive({
|
||||
regularWeight: 30,
|
||||
midtermWeight: 0,
|
||||
finalWeight: 70,
|
||||
items: [] as { name: string; weight: number }[],
|
||||
})
|
||||
const newItemName = ref('')
|
||||
const newItemWeight = ref(0)
|
||||
const returnComment = ref('')
|
||||
|
||||
const presetItems = ['实验', '实习', '课程设计', '作业', '课堂表现', '期中']
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
Draft: '录入中',
|
||||
Submitted: '待审核',
|
||||
@@ -46,6 +51,10 @@ const examStatusLabels: Record<string, string> = {
|
||||
Deferred: '缓考',
|
||||
Exempt: '免修',
|
||||
}
|
||||
const itemsWeight = computed(() =>
|
||||
createForm.items.reduce((sum, item) => sum + item.weight, 0))
|
||||
const weightSum = computed(() =>
|
||||
createForm.regularWeight + createForm.finalWeight + itemsWeight.value)
|
||||
const completedCount = computed(() =>
|
||||
detail.value?.records.filter((record: any) =>
|
||||
record.totalScore != null || record.examStatus !== 'Normal').length ?? 0)
|
||||
@@ -69,8 +78,7 @@ const transcriptGpa = computed(() => {
|
||||
const credits = numeric.reduce((sum: number, record: any) => sum + Number(record.credits), 0)
|
||||
if (!credits) return '—'
|
||||
const points = numeric.reduce(
|
||||
(sum: number, record: any) => sum + Number(record.gradePoint) * Number(record.credits),
|
||||
0,
|
||||
(sum: number, record: any) => sum + Number(record.gradePoint) * Number(record.credits), 0,
|
||||
)
|
||||
return (points / credits).toFixed(2)
|
||||
})
|
||||
@@ -79,6 +87,29 @@ const earnedCredits = computed(() =>
|
||||
.filter((record: any) => Number(record.totalScore) >= 60)
|
||||
.reduce((sum: number, record: any) => sum + Number(record.credits), 0))
|
||||
|
||||
function addItem() {
|
||||
const name = newItemName.value.trim() || '自定义分项'
|
||||
if (createForm.items.some(item => item.name === name)) {
|
||||
ElMessage.warning('分项名称不能重复。')
|
||||
return
|
||||
}
|
||||
createForm.items.push({ name, weight: newItemWeight.value || 0 })
|
||||
newItemName.value = ''
|
||||
newItemWeight.value = 0
|
||||
}
|
||||
|
||||
function addPresetItem(name: string) {
|
||||
if (createForm.items.some(item => item.name === name)) {
|
||||
ElMessage.warning('该分项已存在。')
|
||||
return
|
||||
}
|
||||
createForm.items.push({ name, weight: 0 })
|
||||
}
|
||||
|
||||
function removeItem(index: number) {
|
||||
createForm.items.splice(index, 1)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -127,23 +158,25 @@ async function selectTask(task: any) {
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(createForm, {
|
||||
regularWeight: 30,
|
||||
midtermWeight: 0,
|
||||
finalWeight: 70,
|
||||
})
|
||||
createForm.regularWeight = 30
|
||||
createForm.finalWeight = 70
|
||||
createForm.items = []
|
||||
newItemName.value = ''
|
||||
newItemWeight.value = 0
|
||||
createDialog.value = true
|
||||
}
|
||||
|
||||
async function createSheet() {
|
||||
if (createForm.regularWeight + createForm.midtermWeight + createForm.finalWeight !== 100) {
|
||||
ElMessage.warning('三个成绩分项的比例必须合计 100%。')
|
||||
if (weightSum.value !== 100) {
|
||||
ElMessage.warning(`平时、期末与所有分项的比例必须合计 100%(当前 ${weightSum.value}%)。`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await http.post('/grades/sheets', {
|
||||
teachingTaskId: selectedTask.value.id,
|
||||
...createForm,
|
||||
regularWeight: createForm.regularWeight,
|
||||
finalWeight: createForm.finalWeight,
|
||||
items: createForm.items.map(item => ({ name: item.name, weight: item.weight })),
|
||||
})
|
||||
createDialog.value = false
|
||||
ElMessage.success('成绩登记册已建立')
|
||||
@@ -159,8 +192,11 @@ async function saveRecords() {
|
||||
records: detail.value.records.map((record: any) => ({
|
||||
id: record.id,
|
||||
regularScore: record.regularScore,
|
||||
midtermScore: record.midtermScore,
|
||||
finalScore: record.finalScore,
|
||||
itemScores: record.itemScores?.map((itemScore: any) => ({
|
||||
gradeItemId: itemScore.gradeItemId,
|
||||
score: itemScore.score,
|
||||
})),
|
||||
examStatus: record.examStatus,
|
||||
notes: record.notes,
|
||||
})),
|
||||
@@ -241,6 +277,10 @@ function scoreClass(score: number | null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
function itemScore(record: any, gradeItemId: string) {
|
||||
return record.itemScores?.find((itemScore: any) => itemScore.gradeItemId === gradeItemId)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
terms.value = (await http.get('/base-data/terms')).data
|
||||
@@ -259,7 +299,7 @@ onMounted(async () => {
|
||||
<span class="section-kicker">ACADEMIC RECORD</span>
|
||||
<h2>{{ isStudent ? '学业成绩单' : '成绩管理' }}</h2>
|
||||
<p v-if="isStudent">查看学校已正式发布的课程成绩、学分与绩点。</p>
|
||||
<p v-else>从教师登记、学院复核到校级发布,保留每张成绩单的明确状态。</p>
|
||||
<p v-else>从教师登记、学院复核到校级发布,支持自定义平时、实验、实习等分项。</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||
</section>
|
||||
@@ -366,7 +406,7 @@ onMounted(async () => {
|
||||
<div><span>及格率</span><b>{{ passRate }}</b></div>
|
||||
<p>
|
||||
平时 {{ detail.regularWeight }}%
|
||||
<template v-if="detail.midtermWeight"> · 期中 {{ detail.midtermWeight }}%</template>
|
||||
<template v-if="detail.items?.length">{{ detail.items.map((item: any) => ` · ${item.name} ${item.weight}%`).join('') }}</template>
|
||||
· 期末 {{ detail.finalWeight }}%
|
||||
</p>
|
||||
</section>
|
||||
@@ -389,58 +429,58 @@ onMounted(async () => {
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="`平时 ${detail.regularWeight}%`" width="125">
|
||||
<el-table-column :label="`平时 ${detail.regularWeight}%`" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-if="detail.canEdit && row.examStatus === 'Normal'"
|
||||
v-model="row.regularScore"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:controls="false"
|
||||
:min="0" :max="100" :controls="false" size="small"
|
||||
/>
|
||||
<span v-else>{{ row.regularScore ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="detail.midtermWeight" :label="`期中 ${detail.midtermWeight}%`" width="125">
|
||||
<el-table-column
|
||||
v-for="item in detail.items"
|
||||
:key="item.id"
|
||||
:label="`${item.name} ${item.weight}%`"
|
||||
width="110"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-if="detail.canEdit && row.examStatus === 'Normal'"
|
||||
v-model="row.midtermScore"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:controls="false"
|
||||
:model-value="itemScore(row, item.id)?.score"
|
||||
@update:model-value="(val: number | undefined) => { const found = itemScore(row, item.id); if (found) found.score = val }"
|
||||
:min="0" :max="100" :controls="false" size="small"
|
||||
/>
|
||||
<span v-else>{{ row.midtermScore ?? '—' }}</span>
|
||||
<span v-else>{{ itemScore(row, item.id)?.score ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="`期末 ${detail.finalWeight}%`" width="125">
|
||||
<el-table-column :label="`期末 ${detail.finalWeight}%`" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-if="detail.canEdit && row.examStatus === 'Normal'"
|
||||
v-model="row.finalScore"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:controls="false"
|
||||
:min="0" :max="100" :controls="false" size="small"
|
||||
/>
|
||||
<span v-else>{{ row.finalScore ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="总评" width="90">
|
||||
<el-table-column label="总评" width="80">
|
||||
<template #default="{ row }">
|
||||
<b class="total-score" :class="scoreClass(row.totalScore)">{{ row.totalScore ?? '—' }}</b>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="考试状态" width="125">
|
||||
<el-table-column label="考试状态" width="115">
|
||||
<template #default="{ row }">
|
||||
<el-select v-if="detail.canEdit" v-model="row.examStatus">
|
||||
<el-select v-if="detail.canEdit" v-model="row.examStatus" size="small">
|
||||
<el-option v-for="(label, value) in examStatusLabels" :key="value" :label="label" :value="value" />
|
||||
</el-select>
|
||||
<span v-else>{{ examStatusLabels[row.examStatus] }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="150">
|
||||
<el-table-column label="备注" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input v-if="detail.canEdit" v-model="row.notes" maxlength="300" />
|
||||
<el-input v-if="detail.canEdit" v-model="row.notes" maxlength="300" size="small" />
|
||||
<span v-else>{{ row.notes || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -463,24 +503,54 @@ onMounted(async () => {
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<el-dialog v-model="createDialog" title="建立成绩登记册" width="560px">
|
||||
<el-alert title="成绩构成比例合计必须为 100%" type="info" :closable="false" />
|
||||
<el-dialog v-model="createDialog" title="建立成绩登记册" width="620px">
|
||||
<el-alert title="平时、期末与所有分项的比例必须合计 100%" type="info" :closable="false" />
|
||||
<el-form label-position="top" class="grade-weight-form">
|
||||
<div class="form-grid three">
|
||||
<el-form-item label="平时成绩">
|
||||
<el-form-item label="平时成绩 %">
|
||||
<el-input-number v-model="createForm.regularWeight" :min="0" :max="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="期中成绩">
|
||||
<el-input-number v-model="createForm.midtermWeight" :min="0" :max="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="期末成绩">
|
||||
<el-form-item label="期末成绩 %">
|
||||
<el-input-number v-model="createForm.finalWeight" :min="0" :max="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="当前合计">
|
||||
<div class="weight-sum" :class="{ invalid: weightSum !== 100 }">
|
||||
<b>{{ weightSum }}%</b>
|
||||
<span v-if="weightSum !== 100">需为 100%</span>
|
||||
<span v-else class="valid">✓</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<el-divider>成绩分项(实验、实习、课程设计等)</el-divider>
|
||||
|
||||
<div class="preset-items">
|
||||
<span>快速添加:</span>
|
||||
<el-button
|
||||
v-for="name in presetItems" :key="name"
|
||||
size="small" @click="addPresetItem(name)"
|
||||
>{{ name }}</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="createForm.items.length" class="item-list">
|
||||
<div v-for="(item, index) in createForm.items" :key="index" class="item-row">
|
||||
<span>{{ item.name }}</span>
|
||||
<el-input-number v-model="item.weight" :min="0" :max="100" size="small" />
|
||||
<span>%</span>
|
||||
<el-button :icon="Delete" link type="danger" @click="removeItem(index)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="add-item-row">
|
||||
<el-input v-model="newItemName" placeholder="自定义名称" size="small" style="width:160px" />
|
||||
<el-input-number v-model="newItemWeight" :min="0" :max="100" size="small" />
|
||||
<span>%</span>
|
||||
<el-button size="small" :icon="Plus" @click="addItem">添加</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="createSheet">建立登记册</el-button>
|
||||
<el-button type="primary" :disabled="weightSum !== 100" @click="createSheet">建立登记册</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -497,3 +567,18 @@ onMounted(async () => {
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grade-weight-form { margin-top: 16px; }
|
||||
.weight-sum { display: flex; align-items: baseline; gap: 8px; padding: 12px; border-radius: 6px; background: #f5f7fa; }
|
||||
.weight-sum b { font: 700 22px/1 Consolas, monospace; color: var(--indigo); }
|
||||
.weight-sum.invalid b { color: #b34e48; }
|
||||
.weight-sum span { font-size: 11px; color: var(--muted); }
|
||||
.weight-sum .valid { color: #2d8975; font-weight: 700; }
|
||||
.preset-items { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.preset-items > span { font-size: 11px; color: var(--muted); }
|
||||
.item-list { display: grid; gap: 6px; margin-bottom: 12px; }
|
||||
.item-row { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: #eef5f0; border-radius: 4px; }
|
||||
.item-row > span:first-child { flex: 1; font-size: 13px; font-weight: 650; }
|
||||
.add-item-row { display: flex; align-items: center; gap: 8px; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { Check, Download, Plus, Refresh, Upload } from '@element-plus/icons-vue'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { downloadApiFile, importExcel } from '../api/excel'
|
||||
|
||||
const loading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const terms = ref<any[]>([])
|
||||
const tasks = ref<any[]>([])
|
||||
const sheets = ref<any[]>([])
|
||||
const selectedTask = ref<any>(null)
|
||||
const selectedSheet = ref<any>(null)
|
||||
const sheetDetail = ref<any>(null)
|
||||
const createDialog = ref(false)
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const termId = ref<string>()
|
||||
const createForm = ref({ name: '', attendanceDate: '' })
|
||||
|
||||
const statusLabels: Record<number, string> = {
|
||||
1: '出勤', 2: '缺勤', 3: '迟到', 4: '请假', 5: '免修',
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
loading.value = true
|
||||
try {
|
||||
tasks.value = (await http.get('/attendance/my-tasks', {
|
||||
params: { academicTermId: termId.value || undefined },
|
||||
})).data
|
||||
const preferred = tasks.value.find((item: any) => item.id === selectedTask.value?.id)
|
||||
?? tasks.value[0]
|
||||
if (preferred) await selectTask(preferred)
|
||||
else {
|
||||
selectedTask.value = null
|
||||
sheets.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function selectTask(task: any) {
|
||||
selectedTask.value = task
|
||||
selectedSheet.value = null
|
||||
sheetDetail.value = null
|
||||
try {
|
||||
sheets.value = (await http.get('/attendance/sheets', {
|
||||
params: { teachingTaskId: task.id },
|
||||
})).data
|
||||
} catch (error) {
|
||||
sheets.value = []
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function selectSheet(sheet: any) {
|
||||
selectedSheet.value = sheet
|
||||
detailLoading.value = true
|
||||
try {
|
||||
sheetDetail.value = (await http.get(`/attendance/sheets/${sheet.id}`)).data
|
||||
} catch (error) {
|
||||
sheetDetail.value = null
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
createForm.value = {
|
||||
name: '',
|
||||
attendanceDate: new Date().toISOString().slice(0, 10),
|
||||
}
|
||||
createDialog.value = true
|
||||
}
|
||||
|
||||
async function createSheet() {
|
||||
if (!createForm.value.name.trim()) {
|
||||
ElMessage.warning('请填写考勤表名称。')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await http.post('/attendance/sheets', {
|
||||
teachingTaskId: selectedTask.value.id,
|
||||
name: createForm.value.name,
|
||||
attendanceDate: new Date(createForm.value.attendanceDate).toISOString(),
|
||||
})
|
||||
createDialog.value = false
|
||||
ElMessage.success('考勤表已建立')
|
||||
await selectTask(selectedTask.value)
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRecords() {
|
||||
if (!sheetDetail.value) return
|
||||
try {
|
||||
await http.put(`/attendance/sheets/${sheetDetail.value.sheet.id}/records`, {
|
||||
records: sheetDetail.value.sheet.records.map((record: any) => ({
|
||||
studentId: record.studentId,
|
||||
status: record.status,
|
||||
notes: record.notes,
|
||||
})),
|
||||
})
|
||||
ElMessage.success('考勤记录已保存')
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function submitSheet() {
|
||||
try {
|
||||
await http.post(`/attendance/sheets/${sheetDetail.value.sheet.id}/submit`)
|
||||
ElMessage.success('考勤表已提交')
|
||||
await selectTask(selectedTask.value)
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSheet(sheet: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除考勤表"${sheet.name}"吗?`, '删除考勤表', {
|
||||
type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消',
|
||||
})
|
||||
await http.delete(`/attendance/sheets/${sheet.id}`)
|
||||
ElMessage.success('考勤表已删除')
|
||||
await selectTask(selectedTask.value)
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
function chooseImportFile() { fileInput.value?.click() }
|
||||
|
||||
async function handleImport(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file || !sheetDetail.value) return
|
||||
try {
|
||||
const { data } = await importExcel(
|
||||
`/attendance/sheets/${sheetDetail.value.sheet.id}/import`,
|
||||
file,
|
||||
)
|
||||
ElMessage.success(`已从 Excel 更新 ${data.updated} 条考勤记录`)
|
||||
await selectSheet(selectedSheet.value)
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function exportSheet() {
|
||||
if (!selectedSheet.value) return
|
||||
try {
|
||||
await downloadApiFile(
|
||||
`/attendance/sheets/${selectedSheet.value.id}/export.xlsx`,
|
||||
`考勤表-${selectedSheet.value.name}.xlsx`,
|
||||
)
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
function batchStatus(status: number) {
|
||||
if (!sheetDetail.value?.sheet?.records) return
|
||||
sheetDetail.value.sheet.records.forEach((record: any) => {
|
||||
record.status = status
|
||||
})
|
||||
}
|
||||
|
||||
function countStatus(status: number) {
|
||||
return sheetDetail.value?.sheet?.records?.filter((r: any) => r.status === status).length ?? 0
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
terms.value = (await http.get('/base-data/terms')).data
|
||||
termId.value = terms.value.find((item: any) => item.isCurrent)?.id
|
||||
await loadTasks()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack attendance-page">
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<span class="section-kicker">ATTENDANCE</span>
|
||||
<h2>教学点名</h2>
|
||||
<p>按教学班创建考勤表,在线点名或从 Excel 导入考勤结果。</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" @click="loadTasks">刷新</el-button>
|
||||
</section>
|
||||
|
||||
<section class="attendance-toolbar">
|
||||
<el-select v-model="termId" clearable placeholder="全部学期" @change="loadTasks">
|
||||
<el-option v-for="term in terms" :key="term.id" :label="term.name" :value="term.id" />
|
||||
</el-select>
|
||||
<span>共 {{ tasks.length }} 个教学班</span>
|
||||
</section>
|
||||
|
||||
<section class="attendance-workspace" v-loading="loading">
|
||||
<aside class="attendance-task-list">
|
||||
<button
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
type="button"
|
||||
:class="{ active: selectedTask?.id === task.id }"
|
||||
@click="selectTask(task)"
|
||||
>
|
||||
<span>{{ task.courseCode }} · {{ task.taskNumber }}</span>
|
||||
<b>{{ task.courseName }}</b>
|
||||
<small>{{ task.termName }} · {{ task.studentCount }} 名学生 · {{ task.sheetCount }} 张考勤表</small>
|
||||
</button>
|
||||
<el-empty v-if="!tasks.length" description="没有可点名的教学班" />
|
||||
</aside>
|
||||
|
||||
<main class="attendance-detail">
|
||||
<template v-if="!selectedTask">
|
||||
<el-empty description="请选择一个教学班" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<header class="attendance-subhead">
|
||||
<div>
|
||||
<strong>{{ selectedTask.courseName }}</strong>
|
||||
<span>{{ selectedTask.taskNumber }} · {{ selectedTask.studentCount }} 名学生</span>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">新建考勤表</el-button>
|
||||
</header>
|
||||
<div class="attendance-sheet-list">
|
||||
<button
|
||||
v-for="sheet in sheets"
|
||||
:key="sheet.id"
|
||||
type="button"
|
||||
:class="{ active: selectedSheet?.id === sheet.id }"
|
||||
@click="selectSheet(sheet)"
|
||||
>
|
||||
<div>
|
||||
<b>{{ sheet.name }}</b>
|
||||
<span>{{ new Date(sheet.attendanceDate).toLocaleDateString('zh-CN') }}</span>
|
||||
</div>
|
||||
<div class="sheet-stats">
|
||||
<span class="present">出勤 {{ sheet.presentCount }}</span>
|
||||
<span class="absent" v-if="sheet.absentCount">缺勤 {{ sheet.absentCount }}</span>
|
||||
<span class="late" v-if="sheet.lateCount">迟到 {{ sheet.lateCount }}</span>
|
||||
<span class="leave" v-if="sheet.leaveCount">请假 {{ sheet.leaveCount }}</span>
|
||||
</div>
|
||||
<i>{{ sheet.status === 2 ? '已提交' : '草稿' }}</i>
|
||||
<el-button
|
||||
v-if="sheet.status === 1"
|
||||
link type="danger" size="small"
|
||||
@click.stop="removeSheet(sheet)"
|
||||
>删除</el-button>
|
||||
</button>
|
||||
<el-empty v-if="!sheets.length" description="尚未创建考勤表,点击右上方按钮新建" />
|
||||
</div>
|
||||
</template>
|
||||
</main>
|
||||
|
||||
<aside v-if="sheetDetail" class="attendance-panel" v-loading="detailLoading">
|
||||
<header>
|
||||
<div>
|
||||
<strong>{{ sheetDetail.sheet.name }}</strong>
|
||||
<span>{{ sheetDetail.sheet.courseCode }} · {{ sheetDetail.sheet.taskNumber }}</span>
|
||||
<small>{{ new Date(sheetDetail.sheet.attendanceDate).toLocaleDateString('zh-CN') }} · {{ sheetDetail.sheet.status === 2 ? '已提交' : '草稿' }}</small>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<el-button
|
||||
v-if="sheetDetail.canEdit"
|
||||
size="small" :icon="Upload"
|
||||
@click="chooseImportFile"
|
||||
>导入 Excel</el-button>
|
||||
<input ref="fileInput" class="visually-hidden" type="file" accept=".xlsx" @change="handleImport" />
|
||||
<el-button size="small" :icon="Download" @click="exportSheet">导出 Excel</el-button>
|
||||
<el-button v-if="sheetDetail.canEdit" type="primary" size="small" :icon="Check" @click="saveRecords">保存</el-button>
|
||||
<el-button v-if="sheetDetail.canEdit" type="success" size="small" @click="submitSheet">提交</el-button>
|
||||
</div>
|
||||
</header>
|
||||
<div v-if="sheetDetail.canEdit" class="batch-row">
|
||||
<span>批量设置:</span>
|
||||
<el-button size="small" @click="batchStatus(1)">全部出勤</el-button>
|
||||
<el-button size="small" @click="batchStatus(2)">全部缺勤</el-button>
|
||||
<el-button size="small" @click="batchStatus(3)">全部迟到</el-button>
|
||||
<small>出勤 {{ countStatus(1) }} · 缺勤 {{ countStatus(2) }} · 迟到 {{ countStatus(3) }} · 请假 {{ countStatus(4) }} · 免修 {{ countStatus(5) }}</small>
|
||||
</div>
|
||||
<el-table :data="sheetDetail.sheet.records" class="data-table" size="small">
|
||||
<el-table-column label="学号" width="120">
|
||||
<template #default="{ row }"><span class="registry-number">{{ row.studentNumber }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="姓名" min-width="80" />
|
||||
<el-table-column label="考勤" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-if="sheetDetail.canEdit"
|
||||
v-model="row.status" size="small"
|
||||
>
|
||||
<el-option v-for="(label, val) in statusLabels" :key="val" :label="label" :value="Number(val)" />
|
||||
</el-select>
|
||||
<span v-else>{{ statusLabels[row.status] }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-input v-if="sheetDetail.canEdit" v-model="row.notes" size="small" maxlength="300" />
|
||||
<span v-else>{{ row.notes || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="createDialog" title="新建考勤表" width="500px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="考勤名称" required>
|
||||
<el-input v-model="createForm.name" placeholder="如:第3周课堂点名" maxlength="120" />
|
||||
</el-form-item>
|
||||
<el-form-item label="考勤日期" required>
|
||||
<el-date-picker v-model="createForm.attendanceDate" value-format="YYYY-MM-DD" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="createSheet">建立考勤表</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.attendance-toolbar {
|
||||
min-height: 56px; padding: 10px 16px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
border: 1px solid var(--line); background: #fbfcfd;
|
||||
}
|
||||
.attendance-toolbar .el-select { width: 260px; }
|
||||
.attendance-toolbar > span { color: var(--muted); font-size: 12px; }
|
||||
.attendance-workspace { display: grid; grid-template-columns: 260px minmax(0, 1fr) 420px; min-height: 500px; border: 1px solid var(--line); background: white; }
|
||||
.attendance-task-list { border-right: 1px solid var(--line); overflow-y: auto; max-height: 650px; }
|
||||
.attendance-task-list button {
|
||||
display: grid; gap: 3px; width: 100%; padding: 12px 14px; border: none; border-bottom: 1px solid #edf0f4;
|
||||
background: none; cursor: pointer; text-align: left; transition: background .15s;
|
||||
}
|
||||
.attendance-task-list button:hover { background: #f5f7fa; }
|
||||
.attendance-task-list button.active { background: #e9f3f5; border-left: 3px solid #176b87; }
|
||||
.attendance-task-list button > span { color: var(--teal); font: 700 10px/1.2 Consolas, monospace; }
|
||||
.attendance-task-list button > b { font-size: 13px; }
|
||||
.attendance-task-list button > small { color: var(--muted); font-size: 10px; }
|
||||
.attendance-detail { display: flex; flex-direction: column; border-right: 1px solid var(--line); }
|
||||
.attendance-subhead {
|
||||
padding: 14px 18px; display: flex; align-items: center; justify-content: space-between;
|
||||
border-bottom: 1px solid var(--line); background: #f8fafb;
|
||||
}
|
||||
.attendance-subhead strong { font-size: 15px; }
|
||||
.attendance-subhead span { color: var(--muted); font-size: 11px; display: block; }
|
||||
.attendance-sheet-list { flex: 1; overflow-y: auto; }
|
||||
.attendance-sheet-list > button {
|
||||
width: 100%; padding: 12px 16px; border: none; border-bottom: 1px solid #edf0f4;
|
||||
background: none; cursor: pointer; text-align: left; display: grid; gap: 6px; transition: background .15s;
|
||||
}
|
||||
.attendance-sheet-list > button:hover { background: #f5f7fa; }
|
||||
.attendance-sheet-list > button.active { background: #eef5f0; border-left: 3px solid #2d8975; }
|
||||
.attendance-sheet-list > button > div { display: flex; align-items: baseline; justify-content: space-between; }
|
||||
.attendance-sheet-list > button b { font-size: 14px; }
|
||||
.attendance-sheet-list > button span { color: var(--muted); font-size: 11px; }
|
||||
.attendance-sheet-list > button i { color: var(--indigo); font-size: 10px; font-weight: 700; font-style: normal; }
|
||||
.sheet-stats { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.sheet-stats span { font-size: 10px !important; font-weight: 700; }
|
||||
.sheet-stats .present { color: #2d8975; }
|
||||
.sheet-stats .absent { color: #b34e48; }
|
||||
.sheet-stats .late { color: #c78724; }
|
||||
.sheet-stats .leave { color: #79579a; }
|
||||
.attendance-panel { overflow-y: auto; max-height: 650px; }
|
||||
.attendance-panel > header {
|
||||
padding: 14px 16px; border-bottom: 1px solid var(--line); background: #f8fafb;
|
||||
}
|
||||
.attendance-panel > header strong { font-size: 15px; display: block; }
|
||||
.attendance-panel > header span { color: var(--muted); font-size: 10px; }
|
||||
.attendance-panel > header small { color: var(--indigo); font-size: 10px; display: block; }
|
||||
.panel-actions { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
|
||||
.batch-row { padding: 8px 14px; display: flex; align-items: center; gap: 6px; border-bottom: 1px solid #edf0f4; background: #fff; }
|
||||
.batch-row > span { font-size: 11px; color: var(--muted); }
|
||||
.batch-row > small { margin-left: auto; font-size: 10px; color: var(--muted); }
|
||||
@media (max-width: 1100px) {
|
||||
.attendance-workspace { grid-template-columns: 1fr; }
|
||||
.attendance-task-list { max-height: 200px; }
|
||||
.attendance-sheet-list { max-height: 200px; }
|
||||
.attendance-panel { max-height: 400px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { Download, Refresh } from '@element-plus/icons-vue'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { downloadApiFile } from '../api/excel'
|
||||
|
||||
const loading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const terms = ref<any[]>([])
|
||||
const offerings = ref<any[]>([])
|
||||
const selectedOffering = ref<any>(null)
|
||||
const roster = ref<any>(null)
|
||||
const termId = ref<string>()
|
||||
|
||||
async function loadOfferings() {
|
||||
loading.value = true
|
||||
try {
|
||||
offerings.value = (await http.get('/course-selections/my-offerings', {
|
||||
params: { academicTermId: termId.value || undefined },
|
||||
})).data
|
||||
const preferred = offerings.value.find((item: any) => item.id === selectedOffering.value?.id)
|
||||
?? offerings.value[0]
|
||||
if (preferred) await selectOffering(preferred)
|
||||
else {
|
||||
selectedOffering.value = null
|
||||
roster.value = null
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function selectOffering(offering: any) {
|
||||
selectedOffering.value = offering
|
||||
detailLoading.value = true
|
||||
try {
|
||||
roster.value = (await http.get(`/course-selections/offerings/${offering.id}/roster`)).data
|
||||
} catch (error) {
|
||||
roster.value = null
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function exportRoster() {
|
||||
if (!selectedOffering.value) return
|
||||
try {
|
||||
await downloadApiFile(
|
||||
`/course-selections/offerings/${selectedOffering.value.id}/roster/export.xlsx`,
|
||||
`选课名单-${selectedOffering.value.taskNumber}.xlsx`,
|
||||
)
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
terms.value = (await http.get('/base-data/terms')).data
|
||||
termId.value = terms.value.find((item: any) => item.isCurrent)?.id
|
||||
await loadOfferings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack roster-page">
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<span class="section-kicker">ENROLLMENT ROSTER</span>
|
||||
<h2>选课名单</h2>
|
||||
<p>查看本人授课班级的选课学生名单,支持 Excel 导出。</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" @click="loadOfferings">刷新</el-button>
|
||||
</section>
|
||||
|
||||
<section class="roster-toolbar">
|
||||
<el-select v-model="termId" clearable placeholder="全部学期" @change="loadOfferings">
|
||||
<el-option v-for="term in terms" :key="term.id" :label="term.name" :value="term.id" />
|
||||
</el-select>
|
||||
<span>共 {{ offerings.length }} 个教学班</span>
|
||||
</section>
|
||||
|
||||
<section class="roster-workspace" v-loading="loading">
|
||||
<aside class="roster-task-list">
|
||||
<button
|
||||
v-for="offering in offerings"
|
||||
:key="offering.id"
|
||||
type="button"
|
||||
:class="{ active: selectedOffering?.id === offering.id }"
|
||||
@click="selectOffering(offering)"
|
||||
>
|
||||
<span>{{ offering.courseCode }} · {{ offering.taskNumber }}</span>
|
||||
<b>{{ offering.courseName }}</b>
|
||||
<small>{{ offering.termName }} · {{ offering.roundName }}</small>
|
||||
<i>{{ offering.enrolledCount }} / {{ offering.capacity }} 人</i>
|
||||
</button>
|
||||
<el-empty v-if="!offerings.length" description="当前学期没有选课教学班" />
|
||||
</aside>
|
||||
|
||||
<main class="roster-detail" v-loading="detailLoading">
|
||||
<el-empty v-if="!selectedOffering" description="请选择一个教学班" />
|
||||
<template v-else-if="roster">
|
||||
<header class="roster-head">
|
||||
<div>
|
||||
<span>{{ roster.courseCode }} · {{ roster.taskNumber }}</span>
|
||||
<h3>{{ roster.courseName }}选课名单</h3>
|
||||
<p>已选 {{ roster.enrolledCount }} 人 / 容量 {{ roster.capacity }} 人</p>
|
||||
</div>
|
||||
<el-button :icon="Download" @click="exportRoster">导出 Excel</el-button>
|
||||
</header>
|
||||
<el-table :data="roster.students" class="data-table">
|
||||
<el-table-column label="学号" width="135">
|
||||
<template #default="{ row }"><span class="registry-number">{{ row.studentNumber }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="姓名" min-width="100" />
|
||||
<el-table-column prop="className" label="班级" min-width="130" />
|
||||
<el-table-column prop="majorName" label="专业" min-width="150" />
|
||||
<el-table-column label="选课时间" width="170">
|
||||
<template #default="{ row }">{{ new Date(row.enrolledAt).toLocaleString('zh-CN') }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</main>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.roster-toolbar {
|
||||
min-height: 56px; padding: 10px 16px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
border: 1px solid var(--line); background: #fbfcfd;
|
||||
}
|
||||
.roster-toolbar .el-select { width: 260px; }
|
||||
.roster-toolbar > span { color: var(--muted); font-size: 12px; }
|
||||
.roster-workspace { display: grid; grid-template-columns: 280px minmax(0, 1fr); min-height: 420px; border: 1px solid var(--line); background: white; }
|
||||
.roster-task-list { border-right: 1px solid var(--line); overflow-y: auto; max-height: 600px; }
|
||||
.roster-task-list button {
|
||||
display: grid; gap: 3px; width: 100%; padding: 14px 16px; border: none; border-bottom: 1px solid #edf0f4;
|
||||
background: none; cursor: pointer; text-align: left; transition: background .15s;
|
||||
}
|
||||
.roster-task-list button:hover { background: #f5f7fa; }
|
||||
.roster-task-list button.active { background: #e9f3f5; border-left: 3px solid #176b87; }
|
||||
.roster-task-list button > span { color: var(--teal); font: 700 10px/1.2 Consolas, monospace; }
|
||||
.roster-task-list button > b { font-size: 14px; }
|
||||
.roster-task-list button > small { color: var(--muted); font-size: 11px; }
|
||||
.roster-task-list button > i { color: var(--indigo); font-size: 10px; font-weight: 700; }
|
||||
.roster-detail { min-height: 420px; }
|
||||
.roster-head {
|
||||
padding: 18px 22px; display: flex; align-items: center; justify-content: space-between; gap: 16px;
|
||||
border-bottom: 1px solid var(--line); background: #f8fafb;
|
||||
}
|
||||
.roster-head h3 { margin: 5px 0 4px; font-size: 18px; }
|
||||
.roster-head p { color: var(--muted); font-size: 12px; margin: 0; }
|
||||
.roster-head span { color: var(--teal); font-size: 10px; font-weight: 700; }
|
||||
@media (max-width: 760px) {
|
||||
.roster-workspace { grid-template-columns: 1fr; }
|
||||
.roster-task-list { max-height: 220px; border-right: none; border-bottom: 1px solid var(--line); }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user