feat: improve loading and student import

dev-QQq
chenyuan 2 months ago
parent 61a5790149
commit ad91bc0f9b

@ -1,181 +1,352 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="renderer" content="webkit"> <meta name="renderer" content="webkit">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<title>电商互联网产品开发实训系统</title> <title>电商互联网产品开发实训系统</title>
<!--[if lt IE 11]><script>window.location.href='/html/ie.html';</script><![endif]--> <!--[if lt IE 11]><script>window.location.href='/html/ie.html';</script><![endif]-->
<style> <style>
html, :root {
body { --ink: #06101f;
width: 100%; --panel: rgba(8, 30, 52, 0.82);
height: 100%; --line: rgba(80, 177, 214, 0.28);
margin: 0; --cyan: #53d6ff;
} --mint: #8ff7d1;
--gold: #ffd978;
#loading-wrapper { --text: #f3fbff;
width: 100vw; --muted: #9fc1d8;
height: 100vh; }
background-color: #080915;
html,
} body {
width: 100%;
height: 100%;
margin: 0;
.loader { overflow: hidden;
left: 50%; }
top: 50%;
margin-left: -30px; #loading-wrapper {
margin-top: -30px; position: relative;
animation: stars-rotate-reverse 2s ease infinite; display: grid;
position: relative; place-items: center;
width: 60px; width: 100vw;
height: 60px; height: 100vh;
border-radius: 50%; color: var(--text);
display: inline-block; font-family: "Microsoft YaHei", "PingFang SC", "Source Han Sans CN", sans-serif;
vertical-align: middle; background:
} linear-gradient(rgba(92, 192, 255, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(92, 192, 255, 0.08) 1px, transparent 1px),
.text{ radial-gradient(circle at 18% 18%, rgba(83, 214, 255, 0.14), transparent 28%),
text-align: center; radial-gradient(circle at 82% 72%, rgba(143, 247, 209, 0.1), transparent 30%),
position: relative; linear-gradient(135deg, #071426 0%, #06101f 50%, #091827 100%);
top: 50%; background-size: 52px 52px, 52px 52px, auto, auto, auto;
color: #fff; }
}
#loading-wrapper::before,
.loader-star { #loading-wrapper::after {
position: absolute; position: absolute;
} inset: 0;
content: "";
.loader-star polygon { pointer-events: none;
fill: #d500f9 !important; }
}
#loading-wrapper::before {
.loader .star1 { background:
top: 0px; linear-gradient(120deg, transparent 0 22%, rgba(83, 214, 255, 0.14) 22.3%, transparent 22.8% 62%, rgba(255, 217, 120, 0.12) 62.3%, transparent 62.8%),
right: -7px; linear-gradient(0deg, rgba(0, 0, 0, 0.24), transparent 36%, rgba(0, 0, 0, 0.2));
} opacity: 0.75;
}
.loader .star2 {
right: 9px; #loading-wrapper::after {
top: -12px; border: 1px solid rgba(83, 214, 255, 0.16);
position: absolute; box-shadow: inset 0 0 90px rgba(0, 0, 0, 0.42);
transform: scale(0.8); }
animation: stars-catch 2s 0.1s ease infinite;
} .loading-shell {
.loader .star3 { position: relative;
right: 27px; z-index: 1;
top: -11px; display: grid;
position: absolute; grid-template-columns: 154px minmax(320px, 520px);
transform: scale(0.7); gap: 30px;
animation: stars-catch 2s 0.15s ease infinite; align-items: center;
} max-width: min(820px, calc(100vw - 40px));
padding: 34px 38px;
.loader .star4 { border: 1px solid var(--line);
right: 41px; border-radius: 8px;
top: -2px; background:
position: absolute; linear-gradient(145deg, rgba(14, 42, 68, 0.92), rgba(6, 20, 38, 0.9)),
transform: scale(0.6); var(--panel);
animation: stars-catch 2s 0.2s ease infinite; box-shadow: 0 24px 70px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.06);
} animation: shell-in 0.7s ease both;
}
.loading-mark {
.loader .star5 { position: relative;
right: 47px; width: 154px;
top: 10px; height: 154px;
position: absolute; overflow: hidden;
transform: scale(0.5); border: 1px solid rgba(83, 214, 255, 0.28);
-webkit-animation: stars-catch 2s 0.25s ease infinite; border-radius: 8px;
animation: stars-catch 2s 0.25s ease infinite; background:
} linear-gradient(90deg, transparent 23px, rgba(83, 214, 255, 0.11) 24px, transparent 25px),
linear-gradient(0deg, transparent 23px, rgba(83, 214, 255, 0.1) 24px, transparent 25px),
rgba(5, 22, 39, 0.9);
.loader .star6 { background-size: 25px 25px;
right: 47px; }
top: 21px;
position: absolute; .learning-card {
transform: scale(0.4); position: absolute;
animation: stars-catch 2s 0.3s ease infinite; left: 27px;
} top: 34px;
width: 100px;
@keyframes stars-catch { height: 72px;
75% { border: 1px solid rgba(143, 247, 209, 0.36);
top: -2px; border-radius: 6px;
right: -11px; background: linear-gradient(180deg, rgba(143, 247, 209, 0.18), rgba(83, 214, 255, 0.08));
opacity: 0; box-shadow: 0 10px 28px rgba(83, 214, 255, 0.12);
} animation: card-float 2.8s ease-in-out infinite;
} }
@keyframes stars-rotate-reverse { .learning-card::before,
0% { .learning-card::after {
transform: rotate(0deg); position: absolute;
} content: "";
}
100% {
transform: rotate(360deg); .learning-card::before {
} left: 15px;
} top: 18px;
</style> width: 70px;
</head> height: 5px;
border-radius: 99px;
<body> background: var(--mint);
<div id="app"> box-shadow: 0 15px 0 rgba(211, 239, 255, 0.7), 0 30px 0 rgba(211, 239, 255, 0.42);
<div id="loading-wrapper"> }
<div class="loader">
<svg class="loader-star star1" version="1.1" xmlns="http://www.w3.org/2000/svg" .learning-card::after {
xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="35px" height="35px" right: -18px;
viewBox="0 0 23.172 23.346" xml:space="preserve"> bottom: -18px;
<polygon width: 48px;
points="11.586,0 8.864,8.9 0,8.9 7.193,14.447 4.471,23.346 11.586,17.84 18.739,23.346 16.77,14.985 23.172,8.9 14.306,8.9"> height: 48px;
</polygon> border: 2px solid var(--gold);
</svg> border-left-color: transparent;
<svg class="loader-star star2" version="1.1" xmlns="http://www.w3.org/2000/svg" border-radius: 50%;
xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="35px" height="35px" animation: spin 1.4s linear infinite;
viewBox="0 0 23.172 23.346" xml:space="preserve"> }
<polygon
points="11.586,0 8.864,8.9 0,8.9 7.193,14.447 4.471,23.346 11.586,17.84 18.739,23.346 16.77,14.985 23.172,8.9 14.306,8.9"> .pulse-node {
</polygon> position: absolute;
</svg> left: 22px;
<svg class="loader-star star3" version="1.1" xmlns="http://www.w3.org/2000/svg" bottom: 22px;
xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="35px" height="35px" width: 10px;
viewBox="0 0 23.172 23.346" xml:space="preserve"> height: 10px;
<polygon border-radius: 50%;
points="11.586,0 8.864,8.9 0,8.9 7.193,14.447 4.471,23.346 11.586,17.84 18.739,23.346 16.77,14.985 23.172,8.9 14.306,8.9"> background: var(--cyan);
</polygon> box-shadow: 28px -8px 0 var(--gold), 58px 4px 0 var(--mint), 86px -18px 0 rgba(83, 214, 255, 0.72);
</svg> }
<svg class="loader-star star4" version="1.1" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="35px" height="35px" .pulse-node::before {
viewBox="0 0 23.172 23.346" xml:space="preserve"> position: absolute;
<polygon left: -2px;
points="11.586,0 8.864,8.9 0,8.9 7.193,14.447 4.471,23.346 11.586,17.84 18.739,23.346 16.77,14.985 23.172,8.9 14.306,8.9"> top: -2px;
</polygon> width: 112px;
</svg> height: 36px;
<svg class="loader-star star5" version="1.1" xmlns="http://www.w3.org/2000/svg" content: "";
xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="35px" height="35px" border-top: 1px solid rgba(83, 214, 255, 0.34);
viewBox="0 0 23.172 23.346" xml:space="preserve"> border-right: 1px solid rgba(143, 247, 209, 0.26);
<polygon transform: skewX(-24deg);
points="11.586,0 8.864,8.9 0,8.9 7.193,14.447 4.471,23.346 11.586,17.84 18.739,23.346 16.77,14.985 23.172,8.9 14.306,8.9"> }
</polygon>
</svg> .loading-copy {
<svg class="loader-star star6" version="1.1" xmlns="http://www.w3.org/2000/svg" min-width: 0;
xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="35px" height="35px" }
viewBox="0 0 23.172 23.346" xml:space="preserve">
<polygon .loading-eyebrow {
points="11.586,0 8.864,8.9 0,8.9 7.193,14.447 4.471,23.346 11.586,17.84 18.739,23.346 16.77,14.985 23.172,8.9 14.306,8.9"> display: inline-flex;
</polygon> align-items: center;
</svg> gap: 8px;
</div> padding: 5px 10px;
<div class="text"> border: 1px solid rgba(83, 214, 255, 0.32);
玩命加载中…… border-radius: 999px;
</div> color: var(--mint);
</div> font-size: 12px;
</div> font-weight: 700;
<script type="module" src="/src/main.js"></script> background: rgba(83, 214, 255, 0.08);
</body> }
</html> .loading-eyebrow::before {
width: 7px;
height: 7px;
content: "";
border-radius: 50%;
background: var(--mint);
box-shadow: 0 0 14px var(--mint);
}
.loading-title {
margin: 16px 0 8px;
color: var(--text);
font-size: 30px;
font-weight: 800;
line-height: 1.2;
}
.loading-subtitle {
margin: 0;
color: var(--muted);
font-size: 14px;
line-height: 1.8;
}
.progress-track {
position: relative;
height: 8px;
margin: 26px 0 18px;
overflow: hidden;
border-radius: 999px;
background: rgba(143, 190, 214, 0.16);
}
.progress-track::before {
position: absolute;
inset: 0;
width: 44%;
content: "";
border-radius: inherit;
background: linear-gradient(90deg, var(--cyan), var(--mint), var(--gold));
animation: progress 1.8s ease-in-out infinite;
}
.loading-steps {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.loading-step {
padding: 10px 12px;
border: 1px solid rgba(83, 214, 255, 0.18);
border-radius: 8px;
color: #c8e5f5;
font-size: 13px;
background: rgba(3, 19, 34, 0.52);
}
.loading-step span {
display: block;
margin-bottom: 4px;
color: var(--mint);
font-size: 12px;
font-weight: 700;
}
@keyframes shell-in {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes card-float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes progress {
0% {
transform: translateX(-110%);
}
55% {
transform: translateX(82%);
}
100% {
transform: translateX(240%);
}
}
@media (max-width: 720px) {
.loading-shell {
grid-template-columns: 1fr;
gap: 22px;
padding: 26px;
}
.loading-mark {
width: 132px;
height: 132px;
}
.learning-card {
left: 16px;
top: 26px;
}
.loading-title {
font-size: 24px;
}
.loading-steps {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
}
}
</style>
</head>
<body>
<div id="app">
<div id="loading-wrapper">
<div class="loading-shell" role="status" aria-live="polite">
<div class="loading-mark" aria-hidden="true">
<div class="learning-card"></div>
<div class="pulse-node"></div>
</div>
<div class="loading-copy">
<div class="loading-eyebrow">Teaching Lab System</div>
<h1 class="loading-title">正在初始化教学环境</h1>
<p class="loading-subtitle">正在同步课程资源、实训数据与学习进度,请稍候。</p>
<div class="progress-track" aria-hidden="true"></div>
<div class="loading-steps" aria-hidden="true">
<div class="loading-step"><span>01</span>课程资源</div>
<div class="loading-step"><span>02</span>实训任务</div>
<div class="loading-step"><span>03</span>学习数据</div>
</div>
</div>
</div>
</div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

@ -155,6 +155,15 @@ export function addAdminStudent(data) {
}); });
} }
export function importAdminStudents(data) {
return request({
url: "/api/school-admin/students/import",
method: "post",
data,
headers: { "Content-Type": "multipart/form-data" },
});
}
export function updateAdminStudent(id, data) { export function updateAdminStudent(id, data) {
return request({ return request({
url: `/api/school-admin/students/${id}`, url: `/api/school-admin/students/${id}`,

@ -15,6 +15,9 @@
<el-button type="primary" @click="load"></el-button> <el-button type="primary" @click="load"></el-button>
<el-button @click="reset"></el-button> <el-button @click="reset"></el-button>
<el-button type="success" @click="openCreate"></el-button> <el-button type="success" @click="openCreate"></el-button>
<el-button type="warning" @click="downloadStudentTemplate"></el-button>
<el-button type="primary" :loading="importing" @click="triggerImport">Excel</el-button>
<input ref="importInputRef" type="file" accept=".xls,.xlsx" class="excel-import-input" @change="handleImportFile" />
</div> </div>
<el-table v-loading="loading" :data="rows" border> <el-table v-loading="loading" :data="rows" border>
@ -54,7 +57,11 @@
<el-option v-for="klass in formClasses" :key="klass.schoolClassId" :label="klass.className" :value="klass.schoolClassId" /> <el-option v-for="klass in formClasses" :key="klass.schoolClassId" :label="klass.className" :value="klass.schoolClassId" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="性别"><el-input v-model="form.sex" /></el-form-item> <el-form-item label="性别">
<el-select v-model="form.sex" placeholder="请选择性别" style="width: 100%">
<el-option v-for="option in sexOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
<el-form-item label="手机"><el-input v-model="form.phone" /></el-form-item> <el-form-item label="手机"><el-input v-model="form.phone" /></el-form-item>
<el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item> <el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item>
</el-form> </el-form>
@ -69,23 +76,38 @@
<script setup> <script setup>
import "@/views/schoolAdmin/admin-theme.scss"; import "@/views/schoolAdmin/admin-theme.scss";
import * as XLSX from "xlsx";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { addAdminStudent, deleteAdminStudent, getAdminClasses, getAdminFaculties, getAdminMajors, getAdminStudents, updateAdminStudent } from "@/api/schoolAdmin"; import { addAdminStudent, deleteAdminStudent, getAdminClasses, getAdminFaculties, getAdminMajors, getAdminStudents, importAdminStudents, updateAdminStudent } from "@/api/schoolAdmin";
const query = reactive({ index: 1, size: 10, name: "", username: "", schoolClassId: "" }); const query = reactive({ index: 1, size: 10, name: "", username: "", schoolClassId: "" });
const rows = ref([]); const rows = ref([]);
const total = ref(0); const total = ref(0);
const loading = ref(false); const loading = ref(false);
const importing = ref(false);
const dialogVisible = ref(false); const dialogVisible = ref(false);
const importInputRef = ref(null);
const form = ref({}); const form = ref({});
const faculties = ref([]); const faculties = ref([]);
const majors = ref([]); const majors = ref([]);
const classes = ref([]); const classes = ref([]);
const studentTemplateHeaders = ["姓名", "学号", "性别", "所属院系", "所属专业", "所属班级", "手机", "邮箱"];
const sexOptions = [
{ label: "男", value: "男" },
{ label: "女", value: "女" },
];
const className = (id) => classes.value.find((klass) => klass.schoolClassId === id)?.className || ""; const className = (id) => classes.value.find((klass) => klass.schoolClassId === id)?.className || "";
const classMajorId = (id) => classes.value.find((klass) => klass.schoolClassId === id)?.schoolMajorId || ""; const classMajorId = (id) => classes.value.find((klass) => klass.schoolClassId === id)?.schoolMajorId || "";
const majorFacultyId = (id) => majors.value.find((major) => major.schoolMajorId === id)?.schoolFacultyId || ""; const majorFacultyId = (id) => majors.value.find((major) => major.schoolMajorId === id)?.schoolFacultyId || "";
const formMajors = computed(() => majors.value.filter((major) => major.schoolFacultyId === form.value.schoolFacultyId)); const formMajors = computed(() => majors.value.filter((major) => major.schoolFacultyId === form.value.schoolFacultyId));
const formClasses = computed(() => classes.value.filter((klass) => klass.schoolMajorId === form.value.schoolMajorId)); const formClasses = computed(() => classes.value.filter((klass) => klass.schoolMajorId === form.value.schoolMajorId));
const trimValue = (value) => String(value ?? "").trim();
const normalizeSex = (sex) => {
const value = trimValue(sex);
if (value === "1" || value === "男" || value === "男性") return "男";
if (value === "2" || value === "女" || value === "女性") return "女";
return "";
};
const loadFaculties = async () => { const loadFaculties = async () => {
const res = await getAdminFaculties({ index: 1, size: 1000 }); const res = await getAdminFaculties({ index: 1, size: 1000 });
@ -103,6 +125,79 @@ const loadClasses = async () => {
const res = await getAdminClasses({ index: 1, size: 1000 }); const res = await getAdminClasses({ index: 1, size: 1000 });
classes.value = (res.data?.list || []).map((row) => row.schoolClass || row); classes.value = (res.data?.list || []).map((row) => row.schoolClass || row);
}; };
const ensureOrgData = async () => {
await Promise.all([loadFaculties(), loadMajors(), loadClasses()]);
};
const orgRows = () =>
classes.value.map((klass) => {
const major = majors.value.find((item) => item.schoolMajorId === klass.schoolMajorId) || {};
const faculty = faculties.value.find((item) => item.schoolFacultyId === major.schoolFacultyId) || {};
return {
所属院系: faculty.schoolFacultyName || "",
所属专业: major.schoolMajorName || "",
所属班级: klass.className || "",
};
});
const firstOrgRow = () => orgRows().find((row) => row.所属院系 && row.所属专业 && row.所属班级) || { 所属院系: "示例院系", 所属专业: "示例专业", 所属班级: "示例班级" };
const downloadStudentTemplate = async () => {
await ensureOrgData();
const sampleOrg = firstOrgRow();
const templateRows = [
studentTemplateHeaders,
["张三", "20260001", "男", sampleOrg.所属院系, sampleOrg.所属专业, sampleOrg.所属班级, "13800000000", "zhangsan@example.com"],
];
const templateSheet = XLSX.utils.aoa_to_sheet(templateRows);
templateSheet["!cols"] = [{ wch: 14 }, { wch: 18 }, { wch: 10 }, { wch: 18 }, { wch: 18 }, { wch: 18 }, { wch: 18 }, { wch: 28 }];
templateSheet["!autofilter"] = { ref: "A1:H1" };
const orgSheet = XLSX.utils.json_to_sheet(orgRows().length ? orgRows() : [{ 所属院系: "请先维护院系", 所属专业: "请先维护专业", 所属班级: "请先维护班级" }]);
orgSheet["!cols"] = [{ wch: 22 }, { wch: 22 }, { wch: 22 }];
const guideSheet = XLSX.utils.aoa_to_sheet([
["填写说明"],
["1. 请在“学生导入模板”中填写学生信息,第一行表头不要修改。"],
["2. 必填列:姓名、学号、所属院系、所属专业、所属班级。"],
["3. 性别请填写“男”或“女”,也兼容 1=男、2=女。"],
["4. 所属院系、所属专业、所属班级请填写名称,不要填写数据 ID。"],
["5. 可在“基础数据”表中查看当前学校可用的院系/专业/班级组合。"],
]);
guideSheet["!cols"] = [{ wch: 80 }];
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, templateSheet, "学生导入模板");
XLSX.utils.book_append_sheet(workbook, orgSheet, "基础数据");
XLSX.utils.book_append_sheet(workbook, guideSheet, "填写说明");
XLSX.writeFile(workbook, "学生导入模板.xlsx");
};
const triggerImport = () => {
importInputRef.value?.click();
};
const handleImportFile = async (event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!/\.(xls|xlsx)$/i.test(file.name)) {
ElMessage.warning("请选择xls或xlsx格式文件");
return;
}
importing.value = true;
try {
await ElMessageBox.confirm("确认导入所选学生Excel文件", "Excel导入", { type: "warning" });
const formData = new FormData();
formData.append("file", file);
const res = await importAdminStudents(formData);
ElMessage.success(res.msg || "导入成功");
load();
} catch (error) {
const errors = error?.response?.data?.data;
if (Array.isArray(errors) && errors.length) {
await ElMessageBox.alert(errors.slice(0, 20).join("\n"), `导入校验失败(共 ${errors.length} 条)`, { type: "warning" });
}
} finally {
importing.value = false;
}
};
const load = async () => { const load = async () => {
loading.value = true; loading.value = true;
try { try {
@ -121,13 +216,14 @@ const reset = () => {
load(); load();
}; };
const openCreate = () => { const openCreate = () => {
form.value = { schoolFacultyId: "", schoolMajorId: "", schoolClassId: "" }; form.value = { schoolFacultyId: "", schoolMajorId: "", schoolClassId: "", sex: "" };
dialogVisible.value = true; dialogVisible.value = true;
}; };
const openEdit = (row) => { const openEdit = (row) => {
const schoolMajorId = row.schoolMajorId || classMajorId(row.schoolClassId); const schoolMajorId = row.schoolMajorId || classMajorId(row.schoolClassId);
form.value = { form.value = {
...row, ...row,
sex: normalizeSex(row.sex),
schoolMajorId, schoolMajorId,
schoolFacultyId: row.schoolFacultyId || majorFacultyId(schoolMajorId), schoolFacultyId: row.schoolFacultyId || majorFacultyId(schoolMajorId),
}; };
@ -164,3 +260,9 @@ onMounted(() => {
load(); load();
}); });
</script> </script>
<style scoped>
.excel-import-input {
display: none;
}
</style>

Loading…
Cancel
Save