成绩+点名

This commit is contained in:
2026-07-25 14:08:50 +08:00 Unverified
parent 7800951aee
commit 36049af692
16 changed files with 1809 additions and 98 deletions
+391
View File
@@ -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>