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

master
zhoulexin 2 weeks ago
parent fb6a791e64
commit 70ee94f39e

106
package-lock.json generated

@ -15,7 +15,8 @@
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.3",
"vue": "^3.4.21",
"vue-router": "^4.3.0"
"vue-router": "^4.3.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
@ -1367,6 +1368,15 @@
"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": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
@ -1416,6 +1426,19 @@
"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": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz",
@ -1432,6 +1455,15 @@
"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": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
@ -1444,6 +1476,18 @@
"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": {
"version": "3.2.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
@ -1681,6 +1725,15 @@
"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": {
"version": "2.3.3",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
@ -2125,6 +2178,18 @@
"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": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
@ -2261,6 +2326,45 @@
"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": {
"version": "5.6.1",
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz",

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

@ -2,5 +2,10 @@ import request from '@/utils/request'
/** 分页查询考勤记录 */
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 = () => {
return request.get('/dashboard/ranking')
export const getRanking = (params) => {
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-collapsed-width: 64px;
$navbar-height: 56px;
$navbar-height: 90px;
//
$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) {
config.headers.Authorization = `Bearer ${store.token}`
}
// FormData让浏览器自动设置带 boundary 的 Content-Type
if (config.data instanceof FormData) {
delete config.headers['Content-Type']
}
return config
},
(error) => Promise.reject(error)

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

@ -3,7 +3,7 @@
:model-value="props.modelValue"
@update:model-value="emit('update:modelValue', $event)"
:title="`${props.detailData.courseName} - 考勤详情`"
width="720px"
width="1010px"
destroy-on-close
>
<div class="detail-header">
@ -13,24 +13,52 @@
</div>
<el-tabs v-model="detailTab">
<el-tab-pane label="未签到学生" name="absent">
<el-table :data="absentStudents" stripe max-height="300">
<el-tab-pane label="考勤详情" name="detail">
<!-- 搜索区 -->
<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 prop="studentId" label="学号" width="120" />
<el-table-column prop="name" label="姓名" width="100" />
<el-table-column prop="className" label="班级" min-width="140" />
<el-table-column prop="reason" label="原因" width="100">
<el-table-column prop="studentNo" label="学号" width="120" />
<el-table-column prop="studentName" label="姓名" width="100" />
<el-table-column prop="attDate" label="考勤日期" width="120" />
<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 }">
<el-tag size="small" :type="row.reason === '请假' ? 'warning' : 'danger'">
{{ row.reason }}
<el-tag size="small" :type="statusTagType(row.attStatus)">
{{ statusLabel(row.attStatus) }}
</el-tag>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="出勤统计" name="stats">
<div ref="detailChartRef" style="height: 300px"></div>
<el-pagination
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-tabs>
@ -42,9 +70,9 @@
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import * as echarts from 'echarts'
import { getDetailPage } from '@/api/attendance'
const props = defineProps({
modelValue: { type: Boolean, default: false },
@ -53,45 +81,122 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue'])
const detailTab = ref('absent')
const detailChartRef = ref(null)
const absentStudents = ref([
{ studentId: '2021001', name: '张三', className: '计算机2021-1班', reason: '缺勤' },
{ studentId: '2021015', name: '李四', className: '计算机2021-1班', reason: '请假' },
{ studentId: '2021020', name: '王五', className: '计算机2021-1班', reason: '缺勤' }
])
const initDetailChart = () => {
if (!detailChartRef.value) return
const chart = echarts.init(detailChartRef.value)
chart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: ['出勤', '缺勤', '请假'], bottom: 0 },
grid: { top: 20, right: 20, bottom: 40, left: 40 },
xAxis: { type: 'category', data: ['5/26', '5/27', '5/28', '5/29', '5/30', '5/31', '6/01'] },
yAxis: { type: 'value' },
series: [
{ name: '出勤', type: 'bar', stack: 'total', data: [40, 42, 43, 41, 44, 43, 44], color: '#52c41a' },
{ name: '缺勤', type: 'bar', stack: 'total', data: [3, 1, 0, 2, 1, 0, 1], color: '#f5222d' },
{ name: '请假', type: 'bar', stack: 'total', data: [2, 2, 2, 2, 0, 2, 0], color: '#faad14' }
]
})
const detailTab = ref('detail')
const loading = ref(false)
const detailList = ref([])
const total = ref(0)
const searchForm = reactive({
studentName: '',
attStatus: null
})
const pagination = reactive({
current: 1,
pageSize: 10
})
const statusMap = {
0: { label: '未签到', type: 'warning' },
1: { label: '正常', type: 'success' },
2: { label: '迟到', type: '' },
3: { label: '缺勤', type: 'danger' },
4: { label: '早退', type: '' },
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('批量导出成功')
// tab
const batchExport = () => {
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) => {
if (val) {
detailTab.value = 'absent'
detailTab.value = 'detail'
pagination.current = 1
fetchDetailList()
}
})
// tab
watch(detailTab, (val) => {
if (val === 'stats') {
nextTick(() => initDetailChart())
}
//
watch(() => pagination.current, () => fetchDetailList())
watch(() => pagination.pageSize, () => {
pagination.current = 1
fetchDetailList()
})
</script>
@ -106,4 +211,11 @@ watch(detailTab, (val) => {
font-size: 13px;
color: #525252;
}
.search-bar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
}
</style>

@ -78,7 +78,7 @@
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { getRecordPage } from '@/api/attendance'
import { getRecordPage, getDetailPage } from '@/api/attendance'
import { useNameMaps } from '@/composables/useNameMaps'
defineEmits(['showDetail'])
@ -100,7 +100,8 @@ const mapRecord = (item) => ({
total: item.totalCount,
actual: item.actualCount,
absentCount: item.absentCount,
absentRate: item.absentRate ?? 0
absentRate: item.absentRate ?? 0,
id: item.id
})
const tableData = ref([])
@ -160,7 +161,36 @@ const handleReset = () => {
pagination.current = 1
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>
<style lang="scss" scoped>

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

@ -55,6 +55,7 @@
</div>
<el-table :data="recentRecords" stripe>
<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="time" label="上课时间" width="320" sortable align="center">
<template #default="{ row }">
@ -70,12 +71,7 @@
</el-tag>
</template>
</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-tab-pane>
@ -149,7 +145,7 @@ const recentRecords = ref([])
const fetchRecentRecords = async () => {
try {
const res = await getRecordPage({ current: 1, size: 10 })
const res = await getRecordPage({ current: 1, size: 10, taskStatus: 2 })
const { records } = res.data
if (Array.isArray(records)) {
recentRecords.value = records.map(mapRecord)

@ -82,6 +82,9 @@
<el-form-item label="结束周" prop="endWeek">
<el-input-number v-model="addForm.endWeek" :min="1" :max="20" style="width: 100%" />
</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-input v-model="addForm.semester" placeholder="请输入学期2024-2025-1" style="width: 100%" />
</el-form-item>
@ -109,6 +112,7 @@
<el-descriptions-item label="结束节次">{{ detailData.endSection }}</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.firstClassTime }}</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="状态">
@ -271,7 +275,8 @@ const addRules = {
startSection: [{ required: true, message: '请输入开始节次', trigger: 'blur' }],
endSection: [{ 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 = () => ({
@ -284,6 +289,7 @@ const defaultForm = () => ({
endSection: 2,
startWeek: 1,
endWeek: 16,
firstClassTime: '',
semester: '',
status: 1
})
@ -315,6 +321,7 @@ const showEditDialog = (row) => {
endSection: row.endSection,
startWeek: row.startWeek,
endWeek: row.endWeek,
firstClassTime: row.firstClassTime || '',
semester: row.semester || '',
status: row.status
}
@ -339,6 +346,7 @@ const saveSchedule = async () => {
endSection: addForm.value.endSection,
startWeek: addForm.value.startWeek,
endWeek: addForm.value.endWeek,
firstClassTime: addForm.value.firstClassTime,
semester: addForm.value.semester,
status: addForm.value.status
}

@ -2,7 +2,7 @@
<div class="page-container fade-in-up">
<div class="page-header">
<h2 class="page-title">学生信息</h2>
<p class="page-subtitle">管理学生信息支持批量导入与编辑</p>
<p class="page-subtitle">管理学生信息支持新增与编辑</p>
</div>
<div class="filter-bar">
@ -20,6 +20,13 @@
<div class="data-table-card">
<el-table :data="students" stripe v-loading="loading" @selection-change="handleSelectionChange">
<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="name" label="姓名" width="100" />
<el-table-column prop="gender" label="性别" width="70" align="center">
@ -56,12 +63,18 @@
</div>
<!-- 添加/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="editing ? '编辑学生信息' : '添加学生'" width="520px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="80px" label-position="left">
<el-form-item label="学号" required>
<el-dialog v-model="dialogVisible" :title="editing ? '编辑学生信息' : '添加学生'" width="640px" destroy-on-close top="3vh">
<div class="dialog-scroll-body">
<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-form-item>
<el-form-item label="姓名" required>
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="性别">
@ -70,7 +83,7 @@
<el-radio :value="0"></el-radio>
</el-radio-group>
</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-option v-for="c in classList" :key="c.id" :label="c.className" :value="c.id" />
</el-select>
@ -84,18 +97,72 @@
<el-radio :value="0">离校</el-radio>
</el-radio-group>
</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>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="saveStudent"></el-button>
</template>
</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>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
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'
const searchKey = ref('')
@ -111,9 +178,32 @@ const selectedRows = ref([])
const hasSelected = ref(false)
const classList = ref([])
//
const frontImages = ref([])
const leftImages = ref([])
const rightImages = ref([])
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 = {
studentNo: [
{ required: true, message: '请输入学号', trigger: 'blur' }
],
name: [
{ required: true, message: '请输入姓名', trigger: 'blur' }
],
classId: [
{ required: true, message: '请选择班级', trigger: 'change' }
],
phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
]
@ -202,6 +292,9 @@ const handleSelectionChange = (rows) => {
const showAddDialog = () => {
editing.value = false
form.value = { id: null, studentNo: '', name: '', gender: 1, classId: '', phone: '', status: 1 }
frontImages.value = []
leftImages.value = []
rightImages.value = []
dialogVisible.value = true
formRef.value?.clearValidate()
}
@ -215,8 +308,12 @@ const editStudent = (row) => {
gender: row.gender,
classId: row.classId,
phone: row.phone || '',
status: row.status
status: row.status,
faceImage: row.faceImage || ''
}
frontImages.value = []
leftImages.value = []
rightImages.value = []
dialogVisible.value = true
formRef.value?.clearValidate()
}
@ -225,21 +322,37 @@ const saveStudent = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitting.value = true
const payload = {
studentNo: form.value.studentNo,
name: form.value.name,
gender: form.value.gender,
classId: form.value.classId,
phone: form.value.phone,
status: form.value.status
//
if (!editing.value && frontImages.value.length + leftImages.value.length + rightImages.value.length === 0) {
ElMessage.warning('请至少上传一张学生照片')
return
}
submitting.value = true
try {
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)
} 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 ? '编辑成功' : '添加成功')
dialogVisible.value = false
@ -290,5 +403,41 @@ const batchDelete = () => {
border-radius: 8px;
padding: 20px;
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>

Loading…
Cancel
Save