feat: 完善运动记录展示、体测导出与列表布局

AI运动站展示成绩值及课目单位,保留未知课目编号的可识别名称;AI阳光跑列表隐藏有效里程和有效用时列。

体测数据记录增加年级-班级级联筛选,800/1000 米成绩按分秒展示;导出时带上当前筛选条件并下载完整 Excel 数据。

公共管理列表与报表列表根据筛选区域和视口动态调整表格高度,使分页保持可见;体测汇总页两个标签单独采用弹性布局,弹窗表格不受影响。

登录表单支持回车聚焦密码与提交,并增加提交中的防重复处理。
This commit is contained in:
ltx 2026-10-13 14:28:24 +08:00
parent 4e77cfeed2
commit 765b7c545c
9 changed files with 260 additions and 73 deletions

View File

@ -164,6 +164,13 @@ export function GetSportsTestResultPageList(a, b, c, d, e, f, g, h) {
return http.get(`/api/SportsTestResult/GetSportsTestCategoryPageList?TeacherId=${a}&GradeId=${b}&ClassId=${c}&StudentNo=${d}&Sex=${e}&CategoryId=${f}&PageIndex=${g}&PageSize=${h}`)
}
export function ExportSummaryOfGradesTestRecords(params) {
return http.get('/api/SportsTestResult/ExportSummaryOfGrades', null, false, {
params,
responseType: 'blob'
})
}
export function AddSportsTestResult(params) {
return http.post(`/api/SportsTestResult/Add`, params)
}

View File

@ -1,11 +1,26 @@
<template>
<div class="commonManage">
<div ref="containerRef" class="commonManage" :class="{ 'fitted-list': fitsViewport }">
<slot></slot>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { useViewportTableFit } from '@/utils/useViewportTableFit';
const containerRef = ref<HTMLElement | null>(null);
const { fitsViewport } = useViewportTableFit(containerRef, ':scope > .cont');
</script>
<style lang="less">
.commonManage.fitted-list > .cont {
height: auto !important;
min-height: 0 !important;
}
.commonManage.fitted-list > .cont > .el-table {
height: var(--fitted-table-height) !important;
max-height: var(--fitted-table-height) !important;
}
</style>
<style lang="less" scoped>
.commonManage {
// width: calc(100% - 32px);

View File

@ -1,5 +1,5 @@
<template>
<div class="commonTable">
<div ref="containerRef" class="commonTable" :class="{ 'fitted-table': fitsViewport }">
<el-table v-loading="Boolean(propsTableNeed.loading) || paginationLoading" element-loading-text="数据加载中..."
element-loading-background="rgba(255, 255, 255, 0.76)" :data="propsChannel" highlight-current-row stripe
style="width: calc(100% - 40px) ;height: calc(100vh - 330px); margin:0 20px ;" fit="true"
@ -29,8 +29,11 @@
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useTablePaginationLoading } from '@/utils/tablePaginationLoading'
import { useViewportTableFit } from '@/utils/useViewportTableFit'
const containerRef = ref<HTMLElement | null>(null)
const { fitsViewport } = useViewportTableFit(containerRef)
const props = defineProps<{
tableNeed: any,
operationPermissions?: Record<string, boolean>
@ -96,6 +99,13 @@ function goUp(row) {
}
</script>
<style lang="less">
.commonTable.fitted-table > .el-table {
height: var(--fitted-table-height) !important;
max-height: var(--fitted-table-height) !important;
}
</style>
<style lang="less" scoped>
.commonTable {

View File

@ -0,0 +1,59 @@
import { nextTick, onBeforeUnmount, onMounted, onUpdated, ref, type Ref } from 'vue'
export function useViewportTableFit(rootRef: Ref<HTMLElement | null>, contentSelector?: string) {
const fitsViewport = ref(false)
let frame = 0
let observer: ResizeObserver | undefined
let observedHeader: Element | null = null
function fit() {
const root = rootRef.value
if (!root?.closest('#vol-main') || root.closest('.el-dialog, .el-drawer')) {
fitsViewport.value = false
return
}
const content = contentSelector ? root?.querySelector<HTMLElement>(contentSelector) : root
const table = content?.querySelector<HTMLElement>(':scope > .el-table')
const pagination = content?.querySelector<HTMLElement>(':scope > .el-pagination')
fitsViewport.value = !!(table && pagination)
if (!root || !content || !table || !pagination) return
const header = contentSelector ? content.previousElementSibling : root.previousElementSibling
if (header !== observedHeader) {
observer?.disconnect()
if (header) observer?.observe(header)
observedHeader = header
}
const scrollView = root.closest('.el-scrollbar__view')
const scrollTop = scrollView?.parentElement?.scrollTop || 0
const bottom = document.querySelector('#vol-main')?.getBoundingClientRect().bottom || window.innerHeight
const paginationStyle = getComputedStyle(pagination)
const contentStyle = getComputedStyle(content)
const reserved = pagination.getBoundingClientRect().height
+ (parseFloat(paginationStyle.marginTop) || 0) + (parseFloat(paginationStyle.marginBottom) || 0)
+ (parseFloat(contentStyle.paddingBottom) || 0) + 16
const height = Math.max(180, Math.floor(bottom - table.getBoundingClientRect().top - scrollTop - reserved))
root.style.setProperty('--fitted-table-height', `${height}px`)
}
function scheduleFit() {
cancelAnimationFrame(frame)
frame = requestAnimationFrame(fit)
}
onMounted(async () => {
observer = new ResizeObserver(scheduleFit)
window.addEventListener('resize', scheduleFit)
await nextTick()
scheduleFit()
})
onUpdated(scheduleFit)
onBeforeUnmount(() => {
window.removeEventListener('resize', scheduleFit)
observer?.disconnect()
cancelAnimationFrame(frame)
})
return { fitsViewport }
}

View File

@ -56,10 +56,13 @@
<el-table-column fixed prop="ind" label="序号" width="60" />
<el-table-column prop="studentName" label="姓名" />
<el-table-column prop="sex" label="性别" />
<el-table-column prop="className" label="班级" />
<el-table-column prop="projectName" label="项目" />
<el-table-column prop="startTime" label="运动开始时间" />
<el-table-column prop="endTime" label="运动结束时间" />
<el-table-column prop="className" label="班级" min-width="120" />
<el-table-column prop="projectName" label="项目" min-width="150" />
<el-table-column prop="score" label="成绩值" min-width="95">
<template #default="{ row }">{{ formatScore(row) }}</template>
</el-table-column>
<el-table-column prop="startTime" label="运动开始时间" min-width="170" />
<el-table-column prop="endTime" label="运动结束时间" min-width="170" />
</el-table>
<el-pagination
class="pagination"
@ -124,6 +127,7 @@
});
const projectList = [
{ label: "待确认课目(编号2)", value: "待确认课目(编号2)" },
{ label: "引体向上", value: "引体向上" },
{ label: "立定跳远", value: "立定跳远" },
{ label: "跳绳", value: "跳绳" },
@ -143,6 +147,18 @@
{ label: "体重系数(BMI)", value: "体重系数(BMI)" },
{ label: "肺活量", value: "肺活量" },
];
const scoreUnits: Record<number, string> = {
3: "次", 7: "米", 8: "次", 9: "次", 10: "次", 11: "次",
23: "厘米", 24: "次", 25: "次", 26: "次", 27: "次",
28: "次", 29: "次", 30: "次", 31: "次", 32: "次",
1001: "kg/m²", 1002: "毫升"
};
function formatScore(row: { score?: number | null; courseId?: number }) {
if (row.score === null || row.score === undefined) return "-";
return `${row.score}${scoreUnits[row.courseId ?? 0] ?? "(单位待确认)"}`;
}
const data = ref({
classList: [] as any[],
tableNeed: {

View File

@ -19,6 +19,12 @@
:value="item.id" />
</el-select>
</div>
<div v-if="props.embeddedInSummary" class="one grade-class-filter">
<p class="desctipt">年级-班级:</p>
<el-cascader v-model="gradeClassSelection" class="m-2" :options="gradeClassOptions"
:props="{ checkStrictly: true }" clearable filterable placeholder="全部"
@change="onGradeClassChange" />
</div>
<!-- <div class="one">
<p class="desctipt">年级:</p>
<el-select v-model="data.GradeId" class="m-2" clearable>
@ -46,6 +52,8 @@
<p v-for="(item, index) in toolbarButtons" :key="index" :class="item.className"
@click="btnClick(item.engName)">
{{ item.chinName }}</p>
<el-button v-if="props.embeddedInSummary" class="Export" :icon="Download"
:loading="exporting" @click="exportData">导出成绩</el-button>
</div>
</div>
<div class="cont" :class="{ 'summary-cont': props.embeddedInSummary }">
@ -56,26 +64,30 @@
: { width: 'calc(100% - 40px)', minHeight: '420px', height: 'calc(100vh - 430px)', margin: '0 20px' }" fit="true"
:header-cell-style="{ 'text-align': 'center', 'font-size': '12px', 'color': '#3F404A', padding: '3px', background: '#F8F8FA', height: '50px' }"
:cell-style="getCellStyles" :row-style="{ height: '0px' }">
<el-table-column fixed prop="ind" label="序号" />
<el-table-column fixed prop="ind" label="序号" width="120"/>
<!-- <el-table-column prop="teacherId" label="老师" width="100" /> -->
<!-- <el-table-column prop="gradeId" label="年级" width="120" /> -->
<!-- <el-table-column prop="categoryId" label="测试类别" width="120" /> -->
<el-table-column prop="gradeNameAndClassName" label="年级-班级" width="160" />
<el-table-column prop="studentName" label="学生姓名" width="120" />
<el-table-column prop="studentName" label="学生姓名" width="160" />
<el-table-column prop="studentNo" label="学生学号" width="120" />
<el-table-column prop="sex" label="性别" width="80" />
<el-table-column prop="categoryName" label="测试类别" width="120" />
<el-table-column prop="value" label="测试结果" width="120" />
<el-table-column prop="motionDuration" label="运动时长/分钟" width="120" />
<el-table-column prop="consumption" label="运动消耗" width="120" />
<el-table-column prop="strength" label="运动强度" width="120" />
<el-table-column prop="categoryName" label="测试类别" width="160" />
<el-table-column prop="value" label="测试结果" width="120">
<template #default="{ row }">
{{ props.embeddedInSummary ? formatTestResult(row) : row.value }}
</template>
</el-table-column>
<el-table-column v-if="!props.embeddedInSummary" prop="motionDuration" label="运动时长/分钟" width="120" />
<el-table-column v-if="!props.embeddedInSummary" prop="consumption" label="运动消耗" width="120" />
<el-table-column v-if="!props.embeddedInSummary" prop="strength" label="运动强度" width="120" />
<el-table-column prop="score" label="得分" width="120" />
<el-table-column prop="rank" label="等级" width="120" />
<el-table-column prop="classRank" label="班级排名" width="120" />
<el-table-column prop="scoreTime" label="测试时间" width="160" />
<!-- <el-table-column prop="remarks" label="备注" /> -->
<el-table-column v-if="showRowOperate" label="操作" width="140" fixed="right">
<el-table-column v-if="showRowOperate" label="操作" fixed="right">
<template #default="{ row }">
<div class="operate">
<p class="edit" @click="editTable(row)">编辑</p>
@ -145,6 +157,7 @@ import * as _api from '@/api/grade';
import eventBus from '@/eventBus.js'; // 导入事件总线
import { ElMessage } from 'element-plus';
import axios from 'axios';
import { Download } from '@element-plus/icons-vue';
const props = withDefaults(defineProps<{
embeddedInSummary?: boolean
}>(), {
@ -204,6 +217,13 @@ handelData.value.sort((a, b) => {
});
const toolbarButtons = computed(() => handelData.value.filter((item) => item.engName !== 'Export' && item.engName !== 'Import' && item.engName !== 'Update' && item.engName !== 'Delete'))
const showRowOperate = computed(() => true)
const gradeClassSelection = ref<number[]>([])
const gradeClassOptions = computed(() => data.value.GradeData.map((grade: any) => ({
value: grade.id,
label: grade.gradeName,
children: (grade.class || []).map((cls: any) => ({ value: cls.id, label: cls.className }))
})))
const exporting = ref(false)
const ruleForm = ref({
studentNo: '',
categoryEnum: '',
@ -419,7 +439,7 @@ function btnClick(val) {
console.log(val, 'vallll')
switch (val) {
case 'Search':
// data.value.tableNeed.refresh = false
data.value.tableNeed.pageIndex = 1
initApi(data.value)
break
case 'Add':
@ -462,10 +482,36 @@ onMounted(() => {
getGrade()
getCategoryList()
})
function onGradeClassChange(selection: number[] | null) {
data.value.GradeId = selection?.[0] ?? 0
data.value.ClassId = selection?.[1] ?? 0
}
function getQueryParams() {
return {
TeacherId: data.value.TeacherId || 0,
GradeId: data.value.GradeId || 0,
ClassId: data.value.ClassId || 0,
StudentNo: data.value.StudentNo || '',
Sex: data.value.Sex || 0,
CategoryId: data.value.CategoryId || 0
}
}
function formatTestResult(row: any) {
if (Number(row.categoryValue) !== 10 && Number(row.categoryValue) !== 11) return row.value
const value = Number(row.value)
if (!Number.isFinite(value)) return '-'
// Historical imported values above this range are milliseconds; normal results are seconds.
const totalSeconds = Math.round(Math.max(0, value >= 10000 ? value / 1000 : value))
return `${Math.floor(totalSeconds / 60)}分${String(totalSeconds % 60).padStart(2, '0')}秒`
}
//列表接口
async function initApi(val) {
try {
const res = await _api.GetSportsTestResultPageList(val.TeacherId, val.GradeId, val.ClassId, val.StudentNo, val.Sex, val.CategoryId, val.tableNeed.pageIndex, val.tableNeed.pageSize);
const filters = getQueryParams()
const res = await _api.GetSportsTestResultPageList(filters.TeacherId, filters.GradeId, filters.ClassId,
filters.StudentNo, filters.Sex, filters.CategoryId, val.tableNeed.pageIndex, val.tableNeed.pageSize);
if (res.code == 200) {
val.tableNeed.tableData = res.data.datas;
val.tableNeed.tableData.map((i, index) => {
@ -595,27 +641,25 @@ async function DeleteList(val) {
console.error('Error occurred while calling API:', error);
}
}
function exportData(val) {
axios({
method: 'get',
url: `/api/SportsTestResult/Export?TeacherId=${val.TeacherId}&GradeId=${val.GradeId}&ClassId=${val.ClassId}&StudentNo=${val.StudentNo}&Sex=${val.Sex}&CategoryId=${val.CategoryId}&PageIndex=${val.tableNeed.pageIndex}&PageSize=${val.tableNeed.pageSize}`,
// data: data,
responseType: 'blob'
})
.then((res) => {
const blob = new Blob([res.data])
const fileName = `体测数据.xlsx`
if ('download' in document.createElement('a')) {
const elink = document.createElement('a')
elink.download = fileName
elink.style.display = 'none'
elink.href = URL.createObjectURL(blob)
document.body.appendChild(elink)
elink.click()
URL.revokeObjectURL(elink.href)
document.body.removeChild(elink)
}
})
async function exportData() {
if (exporting.value) return
exporting.value = true
try {
const blob = await _api.ExportSummaryOfGradesTestRecords(getQueryParams())
if (!(blob instanceof Blob) || blob.type.includes('json')) throw new Error('导出失败')
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `体测数据记录${new Date().toISOString().slice(0, 10)}.xlsx`
document.body.appendChild(link)
link.click()
link.remove()
setTimeout(() => URL.revokeObjectURL(url), 1000)
} catch (error) {
ElMessage.error('导出失败,请重试')
} finally {
exporting.value = false
}
}
//导入
@ -916,6 +960,9 @@ async function AddList() {
border-radius: 0;
box-shadow: none;
overflow: visible;
height: calc(100dvh - 180px);
display: flex;
flex-direction: column;
.head {
width: calc(100% - 32px) !important;
@ -957,6 +1004,22 @@ async function AddList() {
margin-left: 0;
}
}
.one.grade-class-filter {
width: 320px !important;
max-width: 100%;
p {
width: 104px;
white-space: nowrap;
}
.m-2 {
flex: 1;
width: auto;
min-width: 0;
}
}
}
.right.btn {
@ -990,10 +1053,18 @@ async function AddList() {
margin: 0 auto;
padding: 0 0 16px;
box-sizing: border-box;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.summary-table-card {
width: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid #e7eef8;
border-radius: 10px;
@ -1005,6 +1076,9 @@ async function AddList() {
}
:deep(.el-table) {
flex: 1;
min-height: 0 !important;
height: 0 !important;
width: 100% !important;
margin: 0 !important;
border-radius: 0;
@ -1016,6 +1090,7 @@ async function AddList() {
}
:deep(.el-pagination) {
flex-shrink: 0;
margin: 14px 18px 18px !important;
}
}

View File

@ -29,21 +29,24 @@
<div class="card-subtitle">智慧运动平台</div>
<el-form ref="ruleFormRef" :model="loginAllData.loginData.ruleForm"
:rules="loginAllData.loginData.rules" class="login-form">
:rules="loginAllData.loginData.rules" class="login-form"
@submit.prevent="submitForm(ruleFormRef)">
<el-form-item prop="userName">
<el-input v-model="loginAllData.loginData.ruleForm.userName" placeholder="请输入用户名" clearable
autocomplete="off" :prefix-icon="UserFilled">
autocomplete="username" :prefix-icon="UserFilled" @keydown.enter.prevent="focusPassword">
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input v-model="loginAllData.loginData.ruleForm.password" type="password"
placeholder="请输入密码" clearable autocomplete="off" show-password :prefix-icon="Lock">
<el-input ref="passwordInputRef" v-model="loginAllData.loginData.ruleForm.password"
type="password" placeholder="请输入密码" clearable autocomplete="current-password"
show-password :prefix-icon="Lock">
</el-input>
</el-form-item>
<button class="submit-btn" :class="{ 'is-loading': loginLoading }" type="submit"
:disabled="loginLoading">
<span>{{ loginLoading ? '登录中' : '登录' }}</span>
</button>
</el-form>
<div class="submit-btn" :class="{ 'is-loading': loginLoading }" @click="submitForm(ruleFormRef)">
<span>{{ loginLoading ? '登录中' : '登录' }}</span>
</div>
<!-- <p class="card-footer">加油,同学!运动让你更棒 ⭐</p> -->
</div>
</div>
@ -168,6 +171,7 @@ const imageUrls = [
const preloadedImages = ref({});
let router = useRouter();
const ruleFormRef = ref()
const passwordInputRef = ref()
const registerRef = ref()
const ModifyPwdRef1 = ref()
let appContext = getCurrentInstance().appContext;
@ -289,12 +293,7 @@ const cancle = () => {
loginAllData.value.ModifyPwd.ModifyPwdDialog = false
resetForm(ModifyPwdRef1.value)
}
const loginPress = (e) => {
console.log(e, 'eeeee')
if (e.keyCode == 13) {
submitForm(ruleFormRef.value)
}
};
const focusPassword = () => passwordInputRef.value?.focus()
const getPasswordStrengthPercentage = (password) => {
const result = zxcvbn(password);
return (result.score / 4) * 100;
@ -381,18 +380,19 @@ let loginAllData = ref({
}
})
//点击登录
const submitForm = (formEl) => {
if (loginLoading.value) return
let submitPending = false
const submitForm = async (formEl) => {
if (submitPending || loginLoading.value) return
if (!formEl) return
formEl.validate((valid) => {
console.log(valid, 'valid')
submitPending = true
try {
const valid = await formEl.validate().catch(() => false)
if (valid) {
loginApi(loginAllData.value.loginData.ruleForm)
} else {
console.log('error submit!')
return false
await loginApi(loginAllData.value.loginData.ruleForm)
}
})
} finally {
submitPending = false
}
}
//清空输入框内容
const resetForm = (formEl) => {
@ -476,7 +476,6 @@ onMounted(() => {
// handleScreenAuto()
preloadImages()
store.commit('clearUserInfo', '');
window.addEventListener('keydown', handleKeyDown)
})
const handleScreenAuto = () => {
const designWidth = 1920;
@ -507,13 +506,6 @@ const handleScreenAuto = () => {
document.getElementById('login').style.marginLeft = `${offsetX}px`;
document.getElementById('login').style.marginTop = `${offsetY}px`;
};
//按下回车
const handleKeyDown = (event) => {
const keyCode = event.keyCode || event.which
if (keyCode == 13) {
submitForm(ruleFormRef.value)
}
}
function encryptAES(encryptString, encryptKey) {
let key = CryptoJS.enc.Utf8.parse(encryptKey.substring(0, 16));
let iv = CryptoJS.enc.Utf8.parse("\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F");
@ -932,6 +924,9 @@ async function ModifyPwdApi(val) {
.submit-btn {
width: 100%;
height: 54px;
border: 0;
padding: 0;
font-family: inherit;
background: linear-gradient(135deg, #00aaff 0%, #005ce6 100%);
border-radius: 14px;
display: flex;
@ -968,6 +963,11 @@ async function ModifyPwdApi(val) {
box-shadow: 0 6px 18px rgba(0, 120, 255, 0.28);
transform: none;
}
&:focus-visible {
outline: 3px solid #005ce6;
outline-offset: 3px;
}
}
.card-footer {

View File

@ -491,13 +491,14 @@ onMounted(() => {
.summary-score-manage {
width: 100%;
min-height: calc(100vh - 166px);
height: calc(100dvh - 180px);
min-height: 0;
}
.sport-hall-report {
width: 100%;
min-height: calc(100vh - 166px);
min-height: 0;
margin: 0;
display: flex;
flex-direction: column;
@ -743,11 +744,16 @@ onMounted(() => {
padding-bottom: 0;
box-shadow: 0 8px 22px rgba(29, 68, 130, 0.06);
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
overflow: hidden;
:deep(.el-table) {
height: calc(100vh - 370px) !important;
flex: 1;
min-height: 0;
height: 0 !important;
border: 1px solid #e7eef8;
border-radius: 10px;
overflow: hidden;
@ -786,6 +792,7 @@ onMounted(() => {
.pagination-wrap {
display: flex;
flex-shrink: 0;
// justify-content: flex-start;
justify-content: flex-end;
align-items: center;

View File

@ -33,8 +33,6 @@
<el-table-column prop="studentName" label="姓名" min-width="115" />
<el-table-column prop="sexName" label="性别" width="75" />
<el-table-column prop="className" label="班级" min-width="150" />
<el-table-column prop="effectiveDistance" label="有效里程" min-width="110" />
<el-table-column prop="effectiveTime" label="有效用时" min-width="110" />
<el-table-column prop="totalDistance" label="总里程" min-width="105" />
<el-table-column prop="totalTime" label="总用时" min-width="105" />
<el-table-column prop="startTime" label="开始时间" min-width="160" />