fix:考勤管理调整、学生信息增加人脸录入

master
zhoulexin 2 weeks ago
parent fb6a791e64
commit 70ee94f39e

106
package-lock.json generated

@ -15,7 +15,8 @@
"pinia": "^2.1.7", "pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.3", "pinia-plugin-persistedstate": "^3.2.3",
"vue": "^3.4.21", "vue": "^3.4.21",
"vue-router": "^4.3.0" "vue-router": "^4.3.0",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",
@ -1367,6 +1368,15 @@
"vue": "^3.5.0" "vue": "^3.5.0"
} }
}, },
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
@ -1416,6 +1426,19 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz", "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz",
@ -1432,6 +1455,15 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/combined-stream": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
@ -1444,6 +1476,18 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
@ -1681,6 +1725,15 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
@ -2125,6 +2178,18 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
@ -2261,6 +2326,45 @@
"vue": "^3.5.0" "vue": "^3.5.0"
} }
}, },
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/zrender": { "node_modules/zrender": {
"version": "5.6.1", "version": "5.6.1",
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz", "resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz",

@ -16,7 +16,8 @@
"pinia": "^2.1.7", "pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.3", "pinia-plugin-persistedstate": "^3.2.3",
"vue": "^3.4.21", "vue": "^3.4.21",
"vue-router": "^4.3.0" "vue-router": "^4.3.0",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",

@ -2,5 +2,10 @@ import request from '@/utils/request'
/** 分页查询考勤记录 */ /** 分页查询考勤记录 */
export const getRecordPage = (params) => { export const getRecordPage = (params) => {
return request.get('/attendance/record/page', { params }) return request.get('/attendance/task/page', { params })
}
/** 分页查询考勤详情 */
export const getDetailPage = (params) => {
return request.get('/attendance/detail/page', { params })
} }

@ -11,6 +11,6 @@ export const getTrend = () => {
} }
/** 获取各班级出勤率排名 */ /** 获取各班级出勤率排名 */
export const getRanking = () => { export const getRanking = (params) => {
return request.get('/dashboard/ranking') return request.get('/dashboard/ranking', { params })
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@ -58,7 +58,7 @@ $font-family-mono: 'SF Mono', 'Monaco', 'Consolas', 'Courier New', monospace;
// //
$sidebar-width: 240px; $sidebar-width: 240px;
$sidebar-collapsed-width: 64px; $sidebar-collapsed-width: 64px;
$navbar-height: 56px; $navbar-height: 90px;
// //
$transition-fast: 0.15s ease; $transition-fast: 0.15s ease;

@ -0,0 +1,2 @@
const fileHttp = 'https://shipllm.ngsk.tech:7001/storage'
export default fileHttp

@ -17,6 +17,10 @@ request.interceptors.request.use(
if (store.token) { if (store.token) {
config.headers.Authorization = `Bearer ${store.token}` config.headers.Authorization = `Bearer ${store.token}`
} }
// FormData让浏览器自动设置带 boundary 的 Content-Type
if (config.data instanceof FormData) {
delete config.headers['Content-Type']
}
return config return config
}, },
(error) => Promise.reject(error) (error) => Promise.reject(error)

@ -20,7 +20,7 @@
<div class="chart-card"> <div class="chart-card">
<div class="chart-header"> <div class="chart-header">
<h3>行为分布占比</h3> <h3>行为分布占比</h3>
<el-tag size="small" type="success">今日</el-tag> <!-- <el-tag size="small" type="success">今日</el-tag> -->
</div> </div>
<div ref="pieChartRef" class="chart-body"></div> <div ref="pieChartRef" class="chart-body"></div>
<div class="behavior-legend"> <div class="behavior-legend">

@ -3,7 +3,7 @@
:model-value="props.modelValue" :model-value="props.modelValue"
@update:model-value="emit('update:modelValue', $event)" @update:model-value="emit('update:modelValue', $event)"
:title="`${props.detailData.courseName} - 考勤详情`" :title="`${props.detailData.courseName} - 考勤详情`"
width="720px" width="1010px"
destroy-on-close destroy-on-close
> >
<div class="detail-header"> <div class="detail-header">
@ -13,24 +13,52 @@
</div> </div>
<el-tabs v-model="detailTab"> <el-tabs v-model="detailTab">
<el-tab-pane label="未签到学生" name="absent"> <el-tab-pane label="考勤详情" name="detail">
<el-table :data="absentStudents" stripe max-height="300"> <!-- 搜索区 -->
<div class="search-bar">
<el-input v-model="searchForm.studentName" placeholder="学生姓名" clearable size="default" style="width: 180px" @clear="handleSearch" />
<el-select v-model="searchForm.attStatus" placeholder="考勤状态" clearable size="default" style="width: 140px" @change="handleSearch">
<el-option label="未签到" :value="0" />
<el-option label="正常" :value="1" />
<el-option label="迟到" :value="2" />
<el-option label="缺勤" :value="3" />
<el-option label="早退" :value="4" />
<el-option label="请假" :value="5" />
</el-select>
<el-button type="primary" size="default" @click="handleSearch"></el-button>
<el-button size="default" @click="handleReset"></el-button>
</div>
<el-table :data="detailList" stripe v-loading="loading" max-height="380" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="45" /> <el-table-column type="selection" width="45" />
<el-table-column prop="studentId" label="学号" width="120" /> <el-table-column prop="studentNo" label="学号" width="120" />
<el-table-column prop="name" label="姓名" width="100" /> <el-table-column prop="studentName" label="姓名" width="100" />
<el-table-column prop="className" label="班级" min-width="140" /> <el-table-column prop="attDate" label="考勤日期" width="120" />
<el-table-column prop="reason" label="原因" width="100"> <el-table-column prop="checkInTime" label="签到时间" width="170" />
<el-table-column prop="checkOutTime" label="签退时间" width="170" />
<el-table-column prop="faceSimilarity" label="人脸相似度" width="110" align="center">
<template #default="{ row }">
<span>{{ (row.faceSimilarity * 100).toFixed(1) }}%</span>
</template>
</el-table-column>
<el-table-column prop="attStatus" label="考勤状态" width="100" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag size="small" :type="row.reason === '请假' ? 'warning' : 'danger'"> <el-tag size="small" :type="statusTagType(row.attStatus)">
{{ row.reason }} {{ statusLabel(row.attStatus) }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-tab-pane>
<el-tab-pane label="出勤统计" name="stats"> <el-pagination
<div ref="detailChartRef" style="height: 300px"></div> v-model:current-page="pagination.current"
v-model:page-size="pagination.pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
background
style="margin-top: 16px; justify-content: flex-end"
/>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
@ -42,9 +70,9 @@
</template> </template>
<script setup> <script setup>
import { ref, watch, nextTick } from 'vue' import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import * as echarts from 'echarts' import { getDetailPage } from '@/api/attendance'
const props = defineProps({ const props = defineProps({
modelValue: { type: Boolean, default: false }, modelValue: { type: Boolean, default: false },
@ -53,45 +81,122 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const detailTab = ref('absent') const detailTab = ref('detail')
const detailChartRef = ref(null) const loading = ref(false)
const detailList = ref([])
const absentStudents = ref([ const total = ref(0)
{ studentId: '2021001', name: '张三', className: '计算机2021-1班', reason: '缺勤' },
{ studentId: '2021015', name: '李四', className: '计算机2021-1班', reason: '请假' }, const searchForm = reactive({
{ studentId: '2021020', name: '王五', className: '计算机2021-1班', reason: '缺勤' } studentName: '',
]) attStatus: null
})
const initDetailChart = () => {
if (!detailChartRef.value) return const pagination = reactive({
const chart = echarts.init(detailChartRef.value) current: 1,
chart.setOption({ pageSize: 10
tooltip: { trigger: 'axis' }, })
legend: { data: ['出勤', '缺勤', '请假'], bottom: 0 },
grid: { top: 20, right: 20, bottom: 40, left: 40 }, const statusMap = {
xAxis: { type: 'category', data: ['5/26', '5/27', '5/28', '5/29', '5/30', '5/31', '6/01'] }, 0: { label: '未签到', type: 'warning' },
yAxis: { type: 'value' }, 1: { label: '正常', type: 'success' },
series: [ 2: { label: '迟到', type: '' },
{ name: '出勤', type: 'bar', stack: 'total', data: [40, 42, 43, 41, 44, 43, 44], color: '#52c41a' }, 3: { label: '缺勤', type: 'danger' },
{ name: '缺勤', type: 'bar', stack: 'total', data: [3, 1, 0, 2, 1, 0, 1], color: '#f5222d' }, 4: { label: '早退', type: '' },
{ name: '请假', type: 'bar', stack: 'total', data: [2, 2, 2, 2, 0, 2, 0], color: '#faad14' } 5: { label: '请假', type: 'warning' }
] }
})
const statusLabel = (val) => statusMap[val]?.label || '未知'
const statusTagType = (val) => statusMap[val]?.type || 'info'
//
const selectedRows = ref([])
const handleSelectionChange = (rows) => {
selectedRows.value = rows
}
const fetchDetailList = async () => {
loading.value = true
try {
const params = {
current: pagination.current,
size: pagination.pageSize,
taskId: props.detailData.id
}
if (searchForm.studentName) params.studentName = searchForm.studentName
if (searchForm.attStatus !== null && searchForm.attStatus !== '') params.attStatus = searchForm.attStatus
const res = await getDetailPage(params)
if (res?.code === 200 && res.data) {
detailList.value = res.data.records || []
total.value = res.data.total || 0
}
} catch {
detailList.value = []
total.value = 0
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.current = 1
fetchDetailList()
}
const handleReset = () => {
searchForm.studentName = ''
searchForm.attStatus = null
pagination.current = 1
fetchDetailList()
} }
const batchExport = () => ElMessage.success('批量导出成功') const batchExport = () => {
// tab const list = selectedRows.value.length > 0 ? selectedRows.value : detailList.value
if (list.length === 0) {
ElMessage.warning('没有数据可导出')
return
}
// CSV
const headers = ['学号', '姓名', '考勤日期', '签到时间', '签退时间', '人脸相似度', '考勤状态']
const rows = list.map(row => [
row.studentNo,
row.studentName,
row.attDate,
row.checkInTime,
row.checkOutTime,
row.faceSimilarity ? (row.faceSimilarity * 100).toFixed(1) + '%' : '',
statusLabel(row.attStatus)
])
// CSV
const csvContent = [headers, ...rows]
.map(r => r.map(cell => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(','))
.join('\n')
const BOM = '\uFEFF'
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `考勤详情_${props.detailData.courseName || ''}_${new Date().toLocaleDateString()}.csv`
a.click()
URL.revokeObjectURL(url)
ElMessage.success(`已导出 ${list.length} 条记录`)
}
//
watch(() => props.modelValue, (val) => { watch(() => props.modelValue, (val) => {
if (val) { if (val) {
detailTab.value = 'absent' detailTab.value = 'detail'
pagination.current = 1
fetchDetailList()
} }
}) })
// tab //
watch(detailTab, (val) => { watch(() => pagination.current, () => fetchDetailList())
if (val === 'stats') { watch(() => pagination.pageSize, () => {
nextTick(() => initDetailChart()) pagination.current = 1
} fetchDetailList()
}) })
</script> </script>
@ -106,4 +211,11 @@ watch(detailTab, (val) => {
font-size: 13px; font-size: 13px;
color: #525252; color: #525252;
} }
.search-bar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
}
</style> </style>

@ -78,7 +78,7 @@
import { ref, reactive, computed, onMounted, watch } from 'vue' import { ref, reactive, computed, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Search } from '@element-plus/icons-vue' import { Search } from '@element-plus/icons-vue'
import { getRecordPage } from '@/api/attendance' import { getRecordPage, getDetailPage } from '@/api/attendance'
import { useNameMaps } from '@/composables/useNameMaps' import { useNameMaps } from '@/composables/useNameMaps'
defineEmits(['showDetail']) defineEmits(['showDetail'])
@ -100,7 +100,8 @@ const mapRecord = (item) => ({
total: item.totalCount, total: item.totalCount,
actual: item.actualCount, actual: item.actualCount,
absentCount: item.absentCount, absentCount: item.absentCount,
absentRate: item.absentRate ?? 0 absentRate: item.absentRate ?? 0,
id: item.id
}) })
const tableData = ref([]) const tableData = ref([])
@ -160,7 +161,36 @@ const handleReset = () => {
pagination.current = 1 pagination.current = 1
fetchData() fetchData()
} }
const handleExport = (row) => ElMessage.success(`正在导出 ${row.courseName} 考勤明细...`) const handleExport = async () => {
try {
const res = await getDetailPage({ current: 1, size: 9999 })
const list = res?.data?.records || []
if (list.length === 0) {
ElMessage.warning('暂无考勤详情数据')
return
}
const statusLabels = { 0: '未签到', 1: '正常', 2: '迟到', 3: '缺勤', 4: '早退', 5: '请假' }
const headers = ['学号', '姓名', '考勤日期', '签到时间', '签退时间', '人脸相似度', '考勤状态']
const rows = list.map(r => [
r.studentNo, r.studentName, r.attDate, r.checkInTime, r.checkOutTime,
r.faceSimilarity ? (r.faceSimilarity * 100).toFixed(1) + '%' : '',
statusLabels[r.attStatus] || '未知'
])
const csv = [headers, ...rows]
.map(r => r.map(c => `"${String(c ?? '').replace(/"/g, '""')}"`).join(','))
.join('\n')
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `考勤明细_${new Date().toLocaleDateString()}.csv`
a.click()
URL.revokeObjectURL(a.href)
ElMessage.success(`已导出 ${list.length} 条记录`)
} catch {
ElMessage.error('导出失败')
}
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

@ -5,46 +5,73 @@
</div> </div>
<div v-if="classRank && classRank.length > 0" class="class-rank"> <div v-if="classRank && classRank.length > 0" class="class-rank">
<div v-for="(item, index) in classRank" :key="index" class="rank-item"> <div v-for="(item, index) in classRank" :key="index" class="rank-item">
<span class="rank-num" :class="{ top: index < 3 }">{{ index + 1 }}</span> <span class="rank-num" :class="{ top: index < 3 }">{{ pageOffset + index + 1 }}</span>
<span class="rank-name">{{ item.className }}</span> <span class="rank-name">{{ item.className }}</span>
<div class="rank-bar-wrap"> <div class="rank-bar-wrap">
<div <div
class="rank-bar" class="rank-bar"
:style="{ width: item.rate + '%', background: index < 3 ? '#52c41a' : '#1890ff' }" :style="{ width: item.attendanceRate + '%', background: index < 3 ? '#52c41a' : '#1890ff' }"
></div> ></div>
</div> </div>
<span class="rank-value">{{ item.rate }}%</span> <span class="rank-value">{{ item.attendanceRate }}%</span>
</div> </div>
</div> </div>
<div v-else class="no-data"> <div v-else class="no-data">
<el-empty description="暂无数据"></el-empty> <el-empty description="暂无数据"></el-empty>
</div> </div>
<el-pagination
v-if="total > 0"
v-model:current-page="current"
v-model:page-size="size"
:total="total"
layout="prev, pager, next"
background
small
style="margin-top: 16px; justify-content: center"
/>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import { getRanking } from '@/api/dashboard' import { getRanking } from '@/api/dashboard'
const classRank = ref([]) const classRank = ref([])
const current = ref(1)
const size = ref(8)
const total = ref(0)
const pages = ref(0)
const pageOffset = computed(() => (current.value - 1) * size.value)
const fetchRanking = async () => { const fetchRanking = async () => {
try { try {
const res = await getRanking() const res = await getRanking({ current: current.value, size: size.value })
const data = res.data const data = res.data
if (Array.isArray(data) && data.length > 0) { if (data && data.records && data.records.length > 0) {
classRank.value = data classRank.value = data.records
total.value = data.total ?? 0
pages.value = data.pages ?? 0
} else { } else {
classRank.value = [] classRank.value = []
total.value = 0
} }
} catch { } catch {
classRank.value = [] classRank.value = []
total.value = 0
} }
} }
onMounted(() => { onMounted(() => {
fetchRanking() fetchRanking()
}) })
watch(() => current.value, () => fetchRanking())
watch(() => size.value, () => {
current.value = 1
fetchRanking()
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -131,4 +158,11 @@ onMounted(() => {
text-align: right; text-align: right;
flex-shrink: 0; flex-shrink: 0;
} }
.no-data {
min-height: 100px;
display: flex;
align-items: center;
justify-content: center;
}
</style> </style>

@ -55,6 +55,7 @@
</div> </div>
<el-table :data="recentRecords" stripe> <el-table :data="recentRecords" stripe>
<el-table-column prop="courseName" label="课程名称" min-width="150" /> <el-table-column prop="courseName" label="课程名称" min-width="150" />
<el-table-column prop="teacher" label="授课教师" width="100" align="center" />
<el-table-column prop="classroom" label="教室" width="120" align="center" /> <el-table-column prop="classroom" label="教室" width="120" align="center" />
<el-table-column prop="time" label="上课时间" width="320" sortable align="center"> <el-table-column prop="time" label="上课时间" width="320" sortable align="center">
<template #default="{ row }"> <template #default="{ row }">
@ -70,12 +71,7 @@
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="140" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="showDetail(row)"></el-button>
<el-button link size="small" @click="handleExport(row)"></el-button>
</template>
</el-table-column>
</el-table> </el-table>
</el-tab-pane> </el-tab-pane>
@ -149,7 +145,7 @@ const recentRecords = ref([])
const fetchRecentRecords = async () => { const fetchRecentRecords = async () => {
try { try {
const res = await getRecordPage({ current: 1, size: 10 }) const res = await getRecordPage({ current: 1, size: 10, taskStatus: 2 })
const { records } = res.data const { records } = res.data
if (Array.isArray(records)) { if (Array.isArray(records)) {
recentRecords.value = records.map(mapRecord) recentRecords.value = records.map(mapRecord)

@ -82,6 +82,9 @@
<el-form-item label="结束周" prop="endWeek"> <el-form-item label="结束周" prop="endWeek">
<el-input-number v-model="addForm.endWeek" :min="1" :max="20" style="width: 100%" /> <el-input-number v-model="addForm.endWeek" :min="1" :max="20" style="width: 100%" />
</el-form-item> </el-form-item>
<el-form-item label="上课时间" prop="firstClassTime">
<el-date-picker v-model="addForm.firstClassTime" type="datetime" placeholder="请选择上课时间" value-format="YYYY-MM-DD HH:mm" style="width: 100%" />
</el-form-item>
<el-form-item label="学期" prop="semester"> <el-form-item label="学期" prop="semester">
<el-input v-model="addForm.semester" placeholder="请输入学期2024-2025-1" style="width: 100%" /> <el-input v-model="addForm.semester" placeholder="请输入学期2024-2025-1" style="width: 100%" />
</el-form-item> </el-form-item>
@ -109,6 +112,7 @@
<el-descriptions-item label="结束节次">{{ detailData.endSection }}</el-descriptions-item> <el-descriptions-item label="结束节次">{{ detailData.endSection }}</el-descriptions-item>
<el-descriptions-item label="开始周">{{ detailData.startWeek }}</el-descriptions-item> <el-descriptions-item label="开始周">{{ detailData.startWeek }}</el-descriptions-item>
<el-descriptions-item label="结束周">{{ detailData.endWeek }}</el-descriptions-item> <el-descriptions-item label="结束周">{{ detailData.endWeek }}</el-descriptions-item>
<el-descriptions-item label="上课时间">{{ detailData.firstClassTime }}</el-descriptions-item>
<el-descriptions-item label="教学楼">{{ getBuildingName(detailData.classroomId) }}</el-descriptions-item> <el-descriptions-item label="教学楼">{{ getBuildingName(detailData.classroomId) }}</el-descriptions-item>
<el-descriptions-item label="教室">{{ getRoomName(detailData.classroomId) }}</el-descriptions-item> <el-descriptions-item label="教室">{{ getRoomName(detailData.classroomId) }}</el-descriptions-item>
<el-descriptions-item label="状态"> <el-descriptions-item label="状态">
@ -271,7 +275,8 @@ const addRules = {
startSection: [{ required: true, message: '请输入开始节次', trigger: 'blur' }], startSection: [{ required: true, message: '请输入开始节次', trigger: 'blur' }],
endSection: [{ required: true, message: '请输入结束节次', trigger: 'blur' }], endSection: [{ required: true, message: '请输入结束节次', trigger: 'blur' }],
startWeek: [{ required: true, message: '请输入开始周', trigger: 'blur' }], startWeek: [{ required: true, message: '请输入开始周', trigger: 'blur' }],
endWeek: [{ required: true, message: '请输入结束周', trigger: 'blur' }] endWeek: [{ required: true, message: '请输入结束周', trigger: 'blur' }],
firstClassTime: [{ required: true, message: '请选择上课时间', trigger: 'change' }]
} }
const defaultForm = () => ({ const defaultForm = () => ({
@ -284,6 +289,7 @@ const defaultForm = () => ({
endSection: 2, endSection: 2,
startWeek: 1, startWeek: 1,
endWeek: 16, endWeek: 16,
firstClassTime: '',
semester: '', semester: '',
status: 1 status: 1
}) })
@ -315,6 +321,7 @@ const showEditDialog = (row) => {
endSection: row.endSection, endSection: row.endSection,
startWeek: row.startWeek, startWeek: row.startWeek,
endWeek: row.endWeek, endWeek: row.endWeek,
firstClassTime: row.firstClassTime || '',
semester: row.semester || '', semester: row.semester || '',
status: row.status status: row.status
} }
@ -339,6 +346,7 @@ const saveSchedule = async () => {
endSection: addForm.value.endSection, endSection: addForm.value.endSection,
startWeek: addForm.value.startWeek, startWeek: addForm.value.startWeek,
endWeek: addForm.value.endWeek, endWeek: addForm.value.endWeek,
firstClassTime: addForm.value.firstClassTime,
semester: addForm.value.semester, semester: addForm.value.semester,
status: addForm.value.status status: addForm.value.status
} }

@ -2,7 +2,7 @@
<div class="page-container fade-in-up"> <div class="page-container fade-in-up">
<div class="page-header"> <div class="page-header">
<h2 class="page-title">学生信息</h2> <h2 class="page-title">学生信息</h2>
<p class="page-subtitle">管理学生信息支持批量导入与编辑</p> <p class="page-subtitle">管理学生信息支持新增与编辑</p>
</div> </div>
<div class="filter-bar"> <div class="filter-bar">
@ -20,6 +20,13 @@
<div class="data-table-card"> <div class="data-table-card">
<el-table :data="students" stripe v-loading="loading" @selection-change="handleSelectionChange"> <el-table :data="students" stripe v-loading="loading" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="45" /> <el-table-column type="selection" width="45" />
<el-table-column label="头像" width="80" align="center">
<template #default="{ row }">
<el-avatar :src="row.faceImage ? fileHttp + '/' + row.faceImage : ''" :size="40">
{{ row.name?.charAt(0) }}
</el-avatar>
</template>
</el-table-column>
<el-table-column prop="studentNo" label="学号" width="120" /> <el-table-column prop="studentNo" label="学号" width="120" />
<el-table-column prop="name" label="姓名" width="100" /> <el-table-column prop="name" label="姓名" width="100" />
<el-table-column prop="gender" label="性别" width="70" align="center"> <el-table-column prop="gender" label="性别" width="70" align="center">
@ -56,12 +63,18 @@
</div> </div>
<!-- 添加/编辑弹窗 --> <!-- 添加/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="editing ? '编辑学生信息' : '添加学生'" width="520px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="editing ? '编辑学生信息' : '添加学生'" width="640px" destroy-on-close top="3vh">
<el-form ref="formRef" :model="form" :rules="formRules" label-width="80px" label-position="left"> <div class="dialog-scroll-body">
<el-form-item label="学号" required> <div v-if="editing" class="edit-avatar">
<el-avatar :src="form.faceImage ? fileHttp + '/' + form.faceImage : ''" :size="72">
{{ form.name?.charAt(0) }}
</el-avatar>
</div>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="90px" label-position="left">
<el-form-item label="学号" prop="studentNo">
<el-input v-model="form.studentNo" placeholder="请输入学号" /> <el-input v-model="form.studentNo" placeholder="请输入学号" />
</el-form-item> </el-form-item>
<el-form-item label="姓名" required> <el-form-item label="姓名" prop="name">
<el-input v-model="form.name" placeholder="请输入姓名" /> <el-input v-model="form.name" placeholder="请输入姓名" />
</el-form-item> </el-form-item>
<el-form-item label="性别"> <el-form-item label="性别">
@ -70,7 +83,7 @@
<el-radio :value="0"></el-radio> <el-radio :value="0"></el-radio>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item label="班级" required> <el-form-item label="班级" prop="classId">
<el-select v-model="form.classId" placeholder="请选择班级" style="width: 100%"> <el-select v-model="form.classId" placeholder="请选择班级" style="width: 100%">
<el-option v-for="c in classList" :key="c.id" :label="c.className" :value="c.id" /> <el-option v-for="c in classList" :key="c.id" :label="c.className" :value="c.id" />
</el-select> </el-select>
@ -84,18 +97,72 @@
<el-radio :value="0">离校</el-radio> <el-radio :value="0">离校</el-radio>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
</el-form> <el-form-item v-if="!editing" label="正脸照片" required>
<el-upload
v-model:file-list="frontImages"
list-type="picture-card"
:auto-upload="false"
accept="image/*"
multiple
:on-preview="handlePreview"
>
<el-icon><Plus /></el-icon>
</el-upload>
</el-form-item>
<el-form-item v-if="!editing" label="左脸照片" required>
<el-upload
v-model:file-list="leftImages"
list-type="picture-card"
:auto-upload="false"
accept="image/*"
multiple
:on-preview="handlePreview"
>
<el-icon><Plus /></el-icon>
</el-upload>
</el-form-item>
<el-form-item v-if="!editing" label="右脸照片" required>
<el-upload
v-model:file-list="rightImages"
list-type="picture-card"
:auto-upload="false"
accept="image/*"
multiple
:on-preview="handlePreview"
>
<el-icon><Plus /></el-icon>
</el-upload>
</el-form-item>
<div v-if="!editing" class="upload-notice">
<el-alert
type="warning"
:closable="false"
show-icon
title="请谨慎选择照片,提交后不支持在线修改已上传的图片"
/>
</div>
</el-form>
</div>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="saveStudent"></el-button> <el-button type="primary" :loading="submitting" @click="saveStudent"></el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 图片预览弹窗 -->
<el-dialog v-model="previewVisible" title="图片预览" width="600px" destroy-on-close>
<div class="preview-body">
<el-image :src="previewUrl" fit="contain" style="width: 100%; max-height: 70vh" />
</div>
</el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, watch, onMounted } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, Edit, Delete } from '@element-plus/icons-vue'
import fileHttp from '@/utils/fileHttp'
import { getStudentPage, getClassList, addStudent, updateStudent, deleteStudent } from '@/api/info' import { getStudentPage, getClassList, addStudent, updateStudent, deleteStudent } from '@/api/info'
const searchKey = ref('') const searchKey = ref('')
@ -111,9 +178,32 @@ const selectedRows = ref([])
const hasSelected = ref(false) const hasSelected = ref(false)
const classList = ref([]) const classList = ref([])
//
const frontImages = ref([])
const leftImages = ref([])
const rightImages = ref([])
const formRef = ref(null) const formRef = ref(null)
//
const previewVisible = ref(false)
const previewUrl = ref('')
const handlePreview = (file) => {
previewUrl.value = file.url || URL.createObjectURL(file.raw)
previewVisible.value = true
}
const formRules = { const formRules = {
studentNo: [
{ required: true, message: '请输入学号', trigger: 'blur' }
],
name: [
{ required: true, message: '请输入姓名', trigger: 'blur' }
],
classId: [
{ required: true, message: '请选择班级', trigger: 'change' }
],
phone: [ phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' } { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
] ]
@ -202,6 +292,9 @@ const handleSelectionChange = (rows) => {
const showAddDialog = () => { const showAddDialog = () => {
editing.value = false editing.value = false
form.value = { id: null, studentNo: '', name: '', gender: 1, classId: '', phone: '', status: 1 } form.value = { id: null, studentNo: '', name: '', gender: 1, classId: '', phone: '', status: 1 }
frontImages.value = []
leftImages.value = []
rightImages.value = []
dialogVisible.value = true dialogVisible.value = true
formRef.value?.clearValidate() formRef.value?.clearValidate()
} }
@ -215,8 +308,12 @@ const editStudent = (row) => {
gender: row.gender, gender: row.gender,
classId: row.classId, classId: row.classId,
phone: row.phone || '', phone: row.phone || '',
status: row.status status: row.status,
faceImage: row.faceImage || ''
} }
frontImages.value = []
leftImages.value = []
rightImages.value = []
dialogVisible.value = true dialogVisible.value = true
formRef.value?.clearValidate() formRef.value?.clearValidate()
} }
@ -225,21 +322,37 @@ const saveStudent = async () => {
const valid = await formRef.value?.validate().catch(() => false) const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return if (!valid) return
submitting.value = true //
const payload = { if (!editing.value && frontImages.value.length + leftImages.value.length + rightImages.value.length === 0) {
studentNo: form.value.studentNo, ElMessage.warning('请至少上传一张学生照片')
name: form.value.name, return
gender: form.value.gender,
classId: form.value.classId,
phone: form.value.phone,
status: form.value.status
} }
submitting.value = true
try { try {
if (editing.value) { if (editing.value) {
payload.id = form.value.id const payload = {
id: form.value.id,
studentNo: form.value.studentNo,
name: form.value.name,
gender: form.value.gender,
classId: form.value.classId,
phone: form.value.phone,
status: form.value.status
}
await updateStudent(payload) await updateStudent(payload)
} else { } else {
await addStudent(payload) const fd = new FormData()
fd.append('studentNo', form.value.studentNo)
fd.append('name', form.value.name)
fd.append('gender', form.value.gender)
fd.append('classId', form.value.classId || '')
fd.append('phone', form.value.phone || '')
fd.append('status', form.value.status)
frontImages.value.forEach(f => fd.append('frontImage', f.raw))
leftImages.value.forEach(f => fd.append('leftImage', f.raw))
rightImages.value.forEach(f => fd.append('rightImage', f.raw))
await addStudent(fd)
} }
ElMessage.success(editing.value ? '编辑成功' : '添加成功') ElMessage.success(editing.value ? '编辑成功' : '添加成功')
dialogVisible.value = false dialogVisible.value = false
@ -290,5 +403,41 @@ const batchDelete = () => {
border-radius: 8px; border-radius: 8px;
padding: 20px; padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
:deep(.el-avatar) {
font-size: 18px;
}
}
.edit-avatar {
display: flex;
justify-content: center;
margin-bottom: 16px;
:deep(.el-avatar) {
font-size: 30px;
}
}
.dialog-scroll-body {
max-height: calc(85vh - 160px);
overflow-y: auto;
padding-right: 4px;
:deep(.el-upload--picture-card) {
width: 90px;
height: 90px;
line-height: 90px;
}
:deep(.el-upload-list--picture-card .el-upload-list__item) {
width: 90px;
height: 90px;
}
}
.upload-notice {
margin-top: -8px;
margin-bottom: 12px;
} }
</style> </style>

Loading…
Cancel
Save