Compare commits

...

7 Commits

Author SHA1 Message Date
qinzhenpen 71ac33be70 style: synchronize sidebar toggle behavior and improve menu visibility
- Updated sidebar styles to ensure the collapse toggle remains visible across all student and teacher roles, removing conditional rendering.
- Enhanced specificity of styles to synchronize the toggle arrow and menu visibility with the sidebar's expanded/collapsed state.
- Added logic to force the menu to display correctly when the sidebar is expanded, addressing previous inconsistencies.
8 hours ago
qinzhenpen 5bf2ba612d Merge remote-tracking branch 'origin/master' into dev-QQq 9 hours ago
qinzhenpen e1da3bd6a2 style: update sidebar styles for student layout in collapsed state
- Added specific styles for the student layout sidebar when collapsed, ensuring it reduces to 64px width.
- Addressed specificity issues with existing styles to prevent layout conflicts in non-mobile views.
9 hours ago
chenyuan ae64fb3465 fix: BI可视化分析改走/bi/同机反代修复跨域丢Cookie弹登录;AI助学/助评前置校验提示中文化;教师端实训管理文案与搜索翻页对齐管理端 10 hours ago
chenyuan ab6c9cdc7b merge: sync dev-QQq into master 2 days ago
chenyuan d8c4e40bef fix: 大数据表格列改用tableColumns并为content列增加宽度与溢出提示 3 days ago
chenyuan 4419a3cfd1 merge: sync dev-QQq into master 4 days ago

@ -15,6 +15,9 @@ VITE_APP_ENV = 'production'
# 若依管理系统/生产环境
VITE_APP_BASE_API = '/'
VITE_APP_AI_API = 'https://whmnjy.sztzjy.com:8764/'
VITE_APP_BI_URL = 'https://dshlw.sztzjy.com:196'
# BI 可视化分析:走本机 Nginx 的 /bi/ 同机反代,保持与主系统同源,
# 登录 Cookie(Admin-Token)才能随 iframe 请求带给 BI,避免 BI 弹自己的登录页。
# 若部署机可直接用 *.sztzjy.com 域名 + HTTPS 访问,也可改回绝对地址 https://dshlw.sztzjy.com:196
VITE_APP_BI_URL = '/bi/'
# 是否在打包时开启压缩,支持 gzip 和 brotli
VITE_BUILD_COMPRESS = gzip

@ -225,5 +225,13 @@ export function getLearningAnalysis(params) {
data: stuData,
// AI 服务使用独立鉴权,不能携带本系统的登录令牌。
headers: { isToken: false }
}).then((res) => {
// AI 服务端点故障时返回 HTTP 200 + "Error: ..." 纯文本,这里统一转成失败,
// 由调用方回退到可用的 AI 对话通道(api/kimi4)。
const text = typeof res === 'string' ? res.trim() : String(res?.data ?? '').trim()
if (/^error\b/i.test(text)) {
throw new Error(text)
}
return res
})
}

@ -1,5 +1,17 @@
import request from "@/utils/request";
/**
* 后端 AI 助学/助评的前置校验消息是英文(该文案要落库,不能直接改中文),
* 前端按场景转成中文引导;未识别的消息回退到调用方给定的兜底文案。
*/
export function friendlyAiEvaluationMessage(error, fallback) {
const raw = String(error?.response?.data?.msg || error?.message || "");
if (raw.includes("at least one training step")) {
return "请先在实训页填写并保存至少一个步骤的内容,再使用 AI 助学/助评。";
}
return fallback;
}
export function getAiTrainingEvaluation(taskKey) {
return request({
url: `/api/student/training-tasks/${taskKey}/ai-evaluation`,
@ -13,6 +25,9 @@ export function requestAiTrainingHelp(taskKey, question, stepContext = "") {
method: "post",
data: { question, stepContext },
headers: { repeatSubmit: false },
// 后端对这类前置校验返回英文原文(还要落库,不能直接改中文),
// 屏蔽全局错误弹窗,由侧边栏按场景给出中文提示
skipErrorMessage: true,
});
}
@ -21,5 +36,6 @@ export function requestAiTrainingAssessment(taskKey) {
url: `/api/student/training-tasks/${taskKey}/ai-evaluation/assessment`,
method: "post",
headers: { repeatSubmit: false },
skipErrorMessage: true,
});
}

@ -57,7 +57,8 @@ export const getUserRank = (params) => {
})
}
export const getCurrentExperimentReport = () => {
return request({ url: '/api/student/experiment-report/current', method: 'get' })
// 未加入教学班等场景后端返回 400,页面有自己的空态/错误提示,不在拦截器里重复弹原始报错
return request({ url: '/api/student/experiment-report/current', method: 'get', skipErrorMessage: true })
}
// 更新访问次数
export const updateVisitCount = (params) => {

@ -13,5 +13,6 @@ export function getCurrentProjectStudyTime() {
return request({
url: "/api/student/project-study-time/current",
method: "get",
skipErrorMessage: true,
});
}

@ -1006,6 +1006,47 @@
}
}
/*
* 折叠把手的箭头再挂一条 .hideSidebar 链
*
* 侧栏宽度由 .app-wrapper 上的 .hideSidebar 控制,而箭头此前只由
* .sidebar-container 上的 .is-collapse 控制 —— 两者本该同源,却走了
* 两条独立的 class 链。一旦失步就会出现「宽度已切、箭头不动」的现象。
* 这里把箭头也绑到控制宽度的同一个 class 上,二者再也无法不同步。
* 特异性 (1,4,0) 与基础隐藏规则相同,靠书写顺序在后面取胜。
*/
#app .hideSidebar .sidebar-container .course-sidebar-toggle {
.toggle-arrow--left {
display: none;
}
.toggle-arrow--right {
display: inline-flex;
}
}
/*
* 兜底:展开态下不允许 el-menu 停留在折叠外观
*
* 菜单文字的显隐由 Element Plus 内部的 .el-menu--collapse 决定,它只跟随
* <el-menu :collapse> 这个 prop。若该 prop 与外层状态不同步(失步的根因),
* 就会出现「侧栏已展开,菜单却仍是 64px 图标轨道、文字不显示」。
* 这里以控制宽度的 .hideSidebar 为准做强制纠正:只要侧栏处于展开态,
* 即便 el-menu 仍带着 .el-menu--collapse,也要撑满并恢复文字。
* 特异性 (1,5,0)+ ,足以压过 Element Plus 自身的隐藏规则。
*/
#app .app-wrapper:not(.hideSidebar) .sidebar-container .student-course-menu.el-menu--collapse {
width: 100% !important;
> .el-menu-item > span,
> .el-sub-menu > .el-sub-menu__title > span {
height: auto;
width: auto;
overflow: visible;
visibility: visible;
display: inline-block;
}
}
/*
* 教师端 sidebar 宽度单独缩减 50%(304 → 152px)
* 教师端使用 el-menu 直显式布局,无需学生端的内嵌卡片空间,原 304px 造成大量空白
@ -1032,6 +1073,30 @@
}
}
/*
* 学生端收起态(非移动端):侧边栏必须收敛到 64px 图标轨道
*
* 为什么必须单独补这一条:
* RuoYi 原始收起规则 #app .hideSidebar .sidebar-container { width: 54px !important }
* 后加的学生端规则 #app .studentLayout .sidebar-container { width: 304px !important }
* 两者特异性完全相同(1 个 ID + 2 个类),而后者在源码中位置更靠后,
* 按"同特异性、后出现者胜"被后者压掉 —— 收起后容器仍留 304px 宽,
* 但 el-menu 已切到 collapse 态,于是只剩一列图标、菜单文字全部消失。
* 教师端由 #app .teacherLayout.hideSidebar 处理,学生端此前只在实训页
* (:has(.student-training-shell)) 补过,非实训页全部中招,这里补齐。
*
* :not(.mobile) 用来排除移动端:移动端需保留 304px 抽屉宽度 + translate3d 滑出。
*/
#app .studentLayout.hideSidebar:not(.mobile) {
.sidebar-container {
width: 64px !important;
}
.main-container {
margin-left: 64px !important;
}
}
/*
* 学生端 + 教师端实训页背景覆盖
* student-training.scss 中 .student-training-shell 的暗色背景统一覆盖为亮色

@ -10,14 +10,16 @@
<div :class="[{ 'has-logo': showLogo }, { 'student-sidebar-shell': isCourseStyleSidebar }, { 'is-student-nav': isStudentRole }, { 'is-collapse': isCollapse }]" :style="sidebarStyle">
<!-- <logo v-if="showLogo" :collapse="isCollapse" /> -->
<template v-if="isCourseStyleSidebar">
<!-- 右缘折叠把手:贴侧边栏右缘垂直居中,点击收起/展开 -->
<!-- 右缘折叠把手:贴侧边栏右缘垂直居中,点击收起/展开
常驻显示 —— 此前按「教师角色 + 学生实训页路由」做条件渲染,
学生端切到非实训页(首页 / AI 智能助手 / 学情分析 / 考试 / 实验报告 / BI 等)时
按钮会凭空消失,同一角色在不同页面表现不一致,这里去掉条件固定常显 -->
<button
v-if="isTeacherRole || isStudentTrainingWorkspace"
class="course-sidebar-toggle"
type="button"
:aria-label="isCollapse ? '展开导航' : '收起导航'"
:title="isCollapse ? '展开导航' : '收起导航'"
@click="toggleCourseSidebar"
@click.stop="toggleCourseSidebar"
>
<el-icon class="toggle-arrow toggle-arrow--left"><ArrowLeftBold /></el-icon>
<el-icon class="toggle-arrow toggle-arrow--right"><ArrowRightBold /></el-icon>
@ -26,7 +28,7 @@
<!-- 学生端:el-menu / el-sub-menu,分组默认展开、可折叠 -->
<el-scrollbar v-if="isStudentRole" class="student-sidebar-scroll" wrap-class="scrollbar-wrapper">
<el-menu
:key="studentMenuKey"
:key="`${studentMenuKey}-${isCollapse ? 'collapsed' : 'expanded'}`"
class="student-course-menu"
mode="vertical"
:default-active="activeMenu"
@ -339,9 +341,6 @@ const isCollapse = computed(() => !appStore.sidebar.opened);
const isTeacherRole = computed(() => Number(userStore.userInfo.roleId) === 3);
const isStudentRole = computed(() => ![1, 2, 3].includes(Number(userStore.userInfo.roleId)));
const isCourseStyleSidebar = computed(() => isStudentRole.value || isTeacherRole.value);
const isStudentTrainingWorkspace = computed(() => isStudentRole.value && [
"/foundation/", "/demand/", "/product/", "/channel/", "/assessment/", "/test/", "/comprehensive/",
].some((prefix) => route.path.startsWith(prefix)));
function toggleCourseSidebar() {
appStore.toggleSideBar(false);
}

@ -94,31 +94,17 @@ const messages = ref([]);
const goBack = () => {
router.back();
};
// 发送消息
const sendMessage = async () => {
if (!inputMessage.value.trim() || isRequestPending.value) return proxy.$message.warning("请输入内容");
isRequestPending.value = true;
isTypingComplete.value = true; // 重置为初始状态
// 重置状态
// 添加用户消息
messages.value.push({
id: Date.now(),
role: "user",
content: inputMessage.value,
});
// 用户提问后立即滚动到底部
nextTick(() => {
scrollToBottom();
inputMessage.value = "";
});
// 通过 AI 对话通道(api/kimi4)发起流式提问,AI 回复以气泡逐字输出
const openAssistantStream = async (content) => {
abortController = new AbortController();
const signal = abortController.signal;
// 调用后端API
let params = {
const params = {
systemOwner: "电商互联网产品",
userId: userStore.userInfo.userId,
type: "2",
};
isRequestPending.value = true;
isTypingComplete.value = false;
// 将参数对象转换为查询字符串
const queryString = new URLSearchParams(params);
@ -127,14 +113,15 @@ const sendMessage = async () => {
headers: {
"Content-Type": "application/json",
},
body: inputMessage.value.trim(),
body: content,
signal,
});
if (!resp.ok || !resp.body) {
throw new Error(`AI服务响应异常(${resp.status})`);
}
// 添加AI消息占位
let newMessage = reactive({ id: Date.now(), role: "assistant", content: "" });
messages.value.push(newMessage); // 滚动到底部
console.log(messages.value, " messages.value");
const newMessage = reactive({ id: Date.now(), role: "assistant", content: "" });
messages.value.push(newMessage);
// 处理流式响应
const reader = resp.body.getReader();
const decoder = new TextDecoder("utf-8");
@ -146,11 +133,35 @@ const sendMessage = async () => {
await typeText(newMessage, text);
//滚动到最新消息
nextTick(() => {
const scrollEl = scrollArea.value.wrapRef;
scrollEl.scrollTop = scrollEl.scrollHeight;
scrollToBottom();
});
}
};
// 发送消息
const sendMessage = async () => {
if (!inputMessage.value.trim() || isRequestPending.value) return proxy.$message.warning("请输入内容");
// 添加用户消息
messages.value.push({
id: Date.now(),
role: "user",
content: inputMessage.value,
});
// 用户提问后立即滚动到底部
nextTick(() => {
scrollToBottom();
inputMessage.value = "";
});
try {
await openAssistantStream(inputMessage.value.trim());
} catch (error) {
if (error?.name !== "AbortError") {
proxy.$message.error("AI服务暂时不可用,请稍后重试");
}
} finally {
isRequestPending.value = false;
isTypingComplete.value = true;
}
};
const typeText = (message, text) => {
return new Promise((resolve) => {
isTypingComplete.value = false; // 重置状态
@ -217,31 +228,55 @@ const gInterrupt = () => {
// 获取分析
const getAnalysis = async () => {
loading.value = true;
const stuNeed = proxy.$cache.session.getJSON("msg");
const stuData = proxy.$cache.session.getJSON("learningAnalysisData");
try {
const res = await AI.getLearningAnalysis({ stuNeed: proxy.$cache.session.getJSON("msg"), stuData: proxy.$cache.session.getJSON("learningAnalysisData") });
const res = await AI.getLearningAnalysis({ stuNeed, stuData });
const content = typeof res === "string" ? res : res?.data ?? "";
messages.value.push({
id: Date.now(),
role: "assistant",
content: res,
content,
});
} catch (error) {
proxy.$message.error("AI学情分析暂时不可用,请稍后重试");
// 专用学情分析接口不可用时,把同一条分析需求和学情数据交给 AI 对话通道分析
try {
loading.value = false;
await openAssistantStream(`${stuNeed}\n请严格基于以下学情数据(JSON)分析,不要补造数据:\n${JSON.stringify(stuData)}`);
} catch (fallbackError) {
lastAnalysisRequest = null; // 两条通道都失败,允许用户重试同一条分析
if (fallbackError?.name !== "AbortError") {
proxy.$message.error("AI学情分析暂时不可用,请稍后重试");
}
}
} finally {
loading.value = false;
}
};
onMounted(() => {
if (proxy.$cache.session.getJSON("msg") && proxy.$cache.session.getJSON("learningAnalysisData")) {
// 已渲染过的分析请求:页面被 keep-alive 缓存后回流会再次触发入口逻辑,
// 同一条分析请求不重复发起(对话内容本身仍保留在页面上)。
let lastAnalysisRequest = null;
// 入口逻辑:带学情数据则发起分析;带教案请求则作为提问发送
const runEntryFlow = () => {
const stuNeed = proxy.$cache.session.getJSON("msg");
const stuData = proxy.$cache.session.getJSON("learningAnalysisData");
if (stuNeed && stuData) {
if (stuNeed === lastAnalysisRequest && (loading.value || messages.value.length)) return;
lastAnalysisRequest = stuNeed;
getAnalysis();
} else {
if (proxy.$cache.session.getJSON("teachingPlan")) {
inputMessage.value = proxy.$cache.session.getJSON("teachingPlan");
sendMessage();
// 删除我提问的这条
messages.value.splice(0, 1);
}
return;
}
});
if (proxy.$cache.session.getJSON("teachingPlan")) {
inputMessage.value = proxy.$cache.session.getJSON("teachingPlan");
// 用后即清,避免 keep-alive 回流时重复发送
proxy.$cache.session.remove("teachingPlan");
sendMessage();
// 删除我提问的这条
messages.value.splice(0, 1);
}
};
onMounted(runEntryFlow);
onActivated(runEntryFlow);
// 导出方法
const exportData = () => {

@ -63,7 +63,7 @@
</template>
<script setup>
import { getAiTrainingEvaluation, requestAiTrainingAssessment, requestAiTrainingHelp } from "@/api/aiTrainingEvaluation";
import { friendlyAiEvaluationMessage, getAiTrainingEvaluation, requestAiTrainingAssessment, requestAiTrainingHelp } from "@/api/aiTrainingEvaluation";
import { normalizeEvaluationReports, normalizeOperation } from "./trainingAiEvaluationReport";
import { isStudentDemo } from "@/utils/studentDemo";
@ -129,7 +129,7 @@ async function requestHelp() {
await loadEvaluation();
} catch (error) {
setLocalStatus("help", "FAILED");
helpError.value = "AI助学请求失败,可重试。";
helpError.value = friendlyAiEvaluationMessage(error, "AI助学请求失败,可重试。");
} finally {
helpLoading.value = false;
}
@ -155,7 +155,7 @@ async function requestAssessment() {
await loadEvaluation();
} catch (error) {
setLocalStatus("assessment", "FAILED");
assessmentError.value = "AI助评请求失败,可重试。";
assessmentError.value = friendlyAiEvaluationMessage(error, "AI助评请求失败,可重试。");
} finally {
assessmentLoading.value = false;
}

@ -210,6 +210,7 @@ import { getAiTrainingEvaluation, requestAiTrainingAssessment, requestAiTraining
import { normalizeEvaluationReports } from "./trainingAiEvaluationReport";
import { resolveTrainingTaskKey } from "@/views/training/taskKeyMap";
import { buildReferencePrompt } from "./studentTrainingAiPanel";
import { friendlyAiEvaluationMessage as friendlyAiMessage } from "@/api/aiTrainingEvaluation";
const props = defineProps({
studyTip: {
@ -367,7 +368,7 @@ async function requestAssist() {
assistMessage.value = reports.value.helpReport || props.assistText;
assistMessages.value.push({ id: `assistant-${Date.now()}`, role: "assistant", content: assistMessage.value });
} catch (error) {
assistMessages.value.push({ id: `assistant-${Date.now()}`, role: "assistant", content: "AI 助学暂时不可用,请稍后重试。" });
assistMessages.value.push({ id: `assistant-${Date.now()}`, role: "assistant", content: friendlyAiMessage(error, "AI 助学暂时不可用,请稍后重试。") });
} finally {
assistLoading.value = false;
}
@ -392,7 +393,7 @@ async function requestEvaluation() {
internalScore.value = reports.value.score ?? "--";
internalEvalMessage.value = reports.value.assessmentSummary || "AI 评分已生成,请查看评分明细与改进建议。";
} catch (error) {
internalEvalMessage.value = "AI 助评暂时不可用,请稍后重试。";
internalEvalMessage.value = friendlyAiMessage(error, "AI 助评暂时不可用,请稍后重试。");
} finally {
assessmentLoading.value = false;
}

@ -29,9 +29,9 @@
<div class="main-right">
<el-scrollbar height="100%" class="data-scrollbar">
<el-table :data="tableData" class="data-table" :header-cell-style="headerCellStyle">
<el-table-column v-for="column in tableLabel" :key="column" :prop="column" :label="column" align="center" min-width="130">
<el-table-column v-for="column in tableColumns" :key="column.prop" :prop="column.prop" :label="column.label" align="center" :min-width="column.prop === 'content' ? 240 : 130" :show-overflow-tooltip="column.prop === 'content'">
<template #default="{ row }">
{{ formatDate(row[column]) ?? "..." }}
{{ formatDate(row[column.prop]) ?? "..." }}
</template>
</el-table-column>
</el-table>
@ -65,19 +65,9 @@ const nzIndex = ref(0);
const nzData = ref([]);
const zjIndex = ref(-1);
const zjData = ref([]);
const tableLabel = ref([]);
const tableLabel2 = ref([]);
const arrhander = ref([]);
const arrhanderkey = ref([]);
// const arrMap = {
// 客户细分数据表: tableLabel.value,
// 销售预测数据表: tableLabel4.value,
// 购物车数据表: tableLabel5.value,
// 用户评论表: tableLabel3.value,
// };
const tableColumns = ref([]);
const goNZ = (index, item) => {
nzIndex.value = index;
// tableLabel.value = arrMap[item];
n_dataTableQuery.value.tableName = item;
delete n_dataTableQuery.value.selfTableName;
getSurface();
@ -105,22 +95,25 @@ const getSurface = () => {
portraitModel
.getUserTableData(n_dataTableQuery.value)
.then((res) => {
tableLabel.value = Object.keys(res?.data?.list[0]);
tableData.value = res?.data?.list;
console.log(tableData.value, "454545");
datatotal.value = res?.data?.total;
tableLabel2.value = [];
if (tableData.value[0]?.stepOneA) {
arrhander.value = JSON.parse(tableData.value[0].stepOneA);
arrhanderkey.value = Object.keys(tableData.value[0]).slice(2, arrhander.value.length + 2);
arrhander.value.forEach((item, index) => {
tableLabel2.value.push({
prop: arrhanderkey.value[index],
label: item,
});
});
tableLabel.value = arrhanderkey.value;
tableData.value = res?.data?.list || [];
datatotal.value = res?.data?.total || 0;
const firstRow = tableData.value[0] || {};
const properties = Object.keys(firstRow);
let headers = [];
if (firstRow.stepOneA) {
try {
headers = JSON.parse(firstRow.stepOneA);
} catch (error) {
headers = [];
}
}
const dataProperties = headers.length
? properties.slice(2, headers.length + 2)
: properties;
tableColumns.value = dataProperties.map((prop, index) => ({
prop,
label: headers[index] || prop,
}));
setTimeout(() => {
loading.value = false;
}, 1000);

@ -100,7 +100,7 @@
font-weight: var(--admin-fw-bolder);
letter-spacing: 0;
color: transparent;
background: linear-gradient(135deg, #ffffff, #2563eb);
background: linear-gradient(135deg, #1f2937, #2563eb);
-webkit-background-clip: text;
background-clip: text;
}
@ -152,7 +152,7 @@
padding: 14px 16px;
border: 1px solid #e5e7eb;
border-radius: 18px;
background: rgba(0, 35, 55, 0.44);
background: #ffffff;
}
.school-admin-workbench .toolbar-left,
@ -502,7 +502,7 @@
overflow: hidden;
border-radius: 18px;
border-color: #e5e7eb;
background: rgba(3, 24, 36, 0.72);
background: #ffffff;
color: #374151;
font-family: var(--admin-font-base);
font-size: var(--admin-fs-body);
@ -531,7 +531,7 @@
.school-admin-workbench .el-table tr,
.school-admin-workbench .el-table td.el-table__cell {
background: rgba(3, 24, 36, 0.58);
background: #ffffff;
border-color: #e5e7eb;
font-family: var(--admin-font-base);
font-size: var(--admin-fs-body-sm);
@ -548,7 +548,7 @@
}
.school-admin-workbench .el-table .el-loading-mask {
background: rgba(4, 18, 29, 0.72) !important;
background: rgba(249, 250, 251, 0.8) !important;
backdrop-filter: blur(1px);
}
@ -567,7 +567,7 @@
.school-admin-workbench .el-pagination {
--el-pagination-bg-color: transparent;
--el-pagination-button-bg-color: rgba(3, 24, 36, 0.85);
--el-pagination-button-bg-color: #ffffff;
--el-pagination-hover-color: #2563eb;
color: #374151;
font-family: var(--admin-font-base);
@ -580,6 +580,33 @@
font-size: var(--admin-fs-body-sm);
}
/* 覆盖全局分页深色样式(全局规则带 !important,这里必须同样加 !important 才能生效):
学校管理员工作台为浅色主题 */
.school-admin-workbench .pagination-container .el-pagination .el-select .el-input__wrapper,
.school-admin-workbench .el-pagination .el-select .el-input__wrapper {
background: #ffffff !important;
border: 1px solid #d1d5db !important;
box-shadow: none !important;
}
.school-admin-workbench .el-pagination .el-select .el-input__inner {
color: #374151 !important;
}
.school-admin-workbench .el-pagination .btn-prev,
.school-admin-workbench .el-pagination .btn-next,
.school-admin-workbench .el-pagination .el-pager li {
background-color: #ffffff !important;
color: #374151 !important;
border: 1px solid #d1d5db !important;
}
.school-admin-workbench .el-pagination .el-pager li.is-active {
background: #2563eb !important;
color: #ffffff !important;
border-color: #2563eb !important;
}
.school-admin-workbench .el-pagination__total,
.school-admin-workbench .el-pagination__jump {
font-family: var(--admin-font-base);
@ -592,7 +619,7 @@
padding: 18px;
border: 1px solid #e5e7eb;
border-radius: 22px;
background: rgba(0, 20, 35, 0.62);
background: #ffffff;
}
.school-admin-workbench .el-form-item__label {

@ -1,10 +1,5 @@
<template>
<div class="school-admin-workbench">
<section class="admin-hero">
<div class="admin-badge">School Admin · Class</div>
<h1 class="admin-title">班级管理</h1>
<p class="admin-subtitle">维护本校班级与专业关系,班级删除会受学生和任务数据保护。</p>
</section>
<section class="admin-panel">
<div class="toolbar">
<div class="toolbar-left">
@ -23,7 +18,7 @@
<el-button @click="reset">
<el-icon><Refresh /></el-icon><span>重置</span>
</el-button>
<el-button type="success" @click="openCreate">
<el-button type="primary" @click="openCreate">
<el-icon><Plus /></el-icon><span>新增</span>
</el-button>
</div>

@ -1,10 +1,5 @@
<template>
<div class="school-admin-workbench">
<section class="admin-hero">
<div class="admin-badge">School Admin · Faculty</div>
<h1 class="admin-title">院系管理</h1>
<p class="admin-subtitle">按本校组织结构维护院系信息,支撑专业、教师和学生归属。</p>
</section>
<section class="admin-panel">
<div class="toolbar">
<el-input v-model="query.schoolFacultyName" clearable placeholder="院系名称" class="filter-input" @keyup.enter="load" />

@ -1,10 +1,5 @@
<template>
<div class="school-admin-workbench">
<section class="admin-hero">
<div class="admin-badge">School Admin · Major</div>
<h1 class="admin-title">专业管理</h1>
<p class="admin-subtitle">维护本校院系下的专业档案,专业数据会被班级和学生管理联动使用。</p>
</section>
<section class="admin-panel">
<div class="toolbar">
<el-input v-model="query.schoolMajorName" clearable placeholder="专业名称" class="filter-input" @keyup.enter="load" />

@ -1,10 +1,5 @@
<template>
<div class="school-admin-workbench">
<section class="admin-hero">
<div class="admin-badge">School Admin · Organization</div>
<h1 class="admin-title">学校管理</h1>
<p class="admin-subtitle">维护当前学校的基础信息,所有变更只作用于本校范围。</p>
</section>
<section class="admin-panel">
<el-form :model="form" label-width="110px" class="admin-form">
<el-row :gutter="18">

@ -1,10 +1,5 @@
<template>
<div class="school-admin-workbench">
<section class="admin-hero">
<div class="admin-badge">School Admin · Student</div>
<h1 class="admin-title">学生管理</h1>
<p class="admin-subtitle">管理本校学生账号,学生数据按班级归档并限制在当前学校。</p>
</section>
<section class="admin-panel">
<div class="toolbar">
<el-input v-model="query.name" clearable placeholder="学生姓名" class="filter-input" @keyup.enter="load" />
@ -14,8 +9,8 @@
</el-select>
<el-button type="primary" @click="load">查询</el-button>
<el-button @click="reset">重置</el-button>
<el-button type="success" @click="openCreate">新增</el-button>
<el-button type="warning" @click="downloadStudentTemplate">下载模板</el-button>
<el-button type="primary" @click="openCreate">新增</el-button>
<el-button @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>
@ -40,19 +35,9 @@
<el-form :model="form" label-width="86px">
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="学号"><el-input v-model="form.username" :disabled="Boolean(form.userId)" /></el-form-item>
<el-form-item label="所属院系">
<el-select v-model="form.schoolFacultyId" placeholder="请选择院系" style="width: 100%" @change="handleFacultyChange">
<el-option v-for="faculty in faculties" :key="faculty.schoolFacultyId" :label="faculty.schoolFacultyName" :value="faculty.schoolFacultyId" />
</el-select>
</el-form-item>
<el-form-item label="所属专业">
<el-select v-model="form.schoolMajorId" placeholder="请选择专业" style="width: 100%" :disabled="!form.schoolFacultyId" @change="handleMajorChange">
<el-option v-for="major in formMajors" :key="major.schoolMajorId" :label="major.schoolMajorName" :value="major.schoolMajorId" />
</el-select>
</el-form-item>
<el-form-item label="班级">
<el-select v-model="form.schoolClassId" placeholder="请选择班级" style="width: 100%" :disabled="!form.schoolMajorId">
<el-option v-for="klass in formClasses" :key="klass.schoolClassId" :label="klass.className" :value="klass.schoolClassId" />
<el-select v-model="form.schoolClassId" placeholder="请选择班级" style="width: 100%">
<el-option v-for="klass in classes" :key="klass.schoolClassId" :label="klass.className" :value="klass.schoolClassId" />
</el-select>
</el-form-item>
<el-form-item label="性别">
@ -73,7 +58,7 @@
<script setup>
import "@/views/schoolAdmin/admin-theme.scss";
import { ElMessage, ElMessageBox } from "element-plus";
import { addAdminStudent, deleteAdminStudent, getAdminClasses, getAdminFaculties, getAdminMajors, getAdminStudents, importAdminStudents, updateAdminStudent } from "@/api/schoolAdmin";
import { addAdminStudent, deleteAdminStudent, getAdminClasses, getAdminStudents, importAdminStudents, updateAdminStudent } from "@/api/schoolAdmin";
const query = reactive({ index: 1, size: 10, name: "", username: "", schoolClassId: "" });
const rows = ref([]);
@ -83,19 +68,13 @@ const importing = ref(false);
const dialogVisible = ref(false);
const importInputRef = ref(null);
const form = ref({});
const faculties = ref([]);
const majors = ref([]);
const classes = ref([]);
const studentTemplateHeaders = ["姓名", "学号", "性别", "所属院系", "所属专业", "所属班级"];
const studentTemplateHeaders = ["姓名", "学号", "性别", "所属班级"];
const sexOptions = [
{ label: "男", value: "男" },
{ label: "女", value: "女" },
];
const className = (id) => classes.value.find((klass) => klass.schoolClassId === id)?.className || "";
const classMajorId = (id) => classes.value.find((klass) => klass.schoolClassId === id)?.schoolMajorId || "";
const majorFacultyId = (id) => majors.value.find((major) => major.schoolMajorId === id)?.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 trimValue = (value) => String(value ?? "").trim();
const normalizeSex = (sex) => {
const value = trimValue(sex);
@ -104,64 +83,37 @@ const normalizeSex = (sex) => {
return "";
};
const loadFaculties = async () => {
const res = await getAdminFaculties({ index: 1, size: 1000 });
faculties.value = (res.data?.list || []).map((row) => row.schoolFaculty || row);
};
const loadMajors = async () => {
const res = await getAdminMajors({ index: 1, size: 1000 });
majors.value = (res.data?.list || []).map((row) => ({
...(row.schoolMajor || row),
schoolFacultyId: row.schoolFacultyId || row.schoolMajor?.schoolFacultyId,
schoolMajorId: row.schoolMajorId || row.schoolMajor?.schoolMajorId,
}));
};
const loadClasses = async () => {
const res = await getAdminClasses({ index: 1, size: 1000 });
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 () => {
const XLSX = await import("xlsx");
await ensureOrgData();
const sampleOrg = firstOrgRow();
const templateRows = [
await loadClasses();
const classNames = classes.value.map((klass) => klass.className || "").filter(Boolean);
const templateSheet = XLSX.utils.aoa_to_sheet([
studentTemplateHeaders,
["张三", "20260001", "男", sampleOrg.所属院系, sampleOrg.所属专业, sampleOrg.所属班级],
];
const templateSheet = XLSX.utils.aoa_to_sheet(templateRows);
templateSheet["!cols"] = [{ wch: 14 }, { wch: 18 }, { wch: 10 }, { wch: 18 }, { wch: 18 }, { wch: 18 }];
templateSheet["!autofilter"] = { ref: "A1:F1" };
["张三", "20260001", "男", classNames[0] || "行政一班"],
]);
templateSheet["!cols"] = [{ wch: 14 }, { wch: 18 }, { wch: 10 }, { wch: 20 }];
templateSheet["!autofilter"] = { ref: "A1:D1" };
const orgSheet = XLSX.utils.json_to_sheet(orgRows().length ? orgRows() : [{ 所属院系: "请先维护院系", 所属专业: "请先维护专业", 所属班级: "请先维护班级" }]);
orgSheet["!cols"] = [{ wch: 22 }, { wch: 22 }, { wch: 22 }];
const classSheet = XLSX.utils.aoa_to_sheet([["所属班级"], ...classNames.map((name) => [name])]);
classSheet["!cols"] = [{ wch: 22 }];
const guideSheet = XLSX.utils.aoa_to_sheet([
["填写说明"],
["1. 请在“学生导入模板”中填写学生信息,第一行表头不要修改。"],
["2. 必填列:姓名、学号、所属院系、所属专业、所属班级。"],
["3. 性别请填写“男”或“女”,也兼容 1=男、2=女。"],
["4. 所属院系、所属专业、所属班级请填写名称,不要填写数据 ID。"],
["5. 可在“基础数据”表中查看当前学校可用的院系/专业/班级组合。"],
["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, classSheet, "基础数据");
XLSX.utils.book_append_sheet(workbook, guideSheet, "填写说明");
XLSX.writeFile(workbook, "学生导入模板.xlsx");
};
@ -212,26 +164,16 @@ const reset = () => {
load();
};
const openCreate = () => {
form.value = { schoolFacultyId: "", schoolMajorId: "", schoolClassId: "", sex: "" };
form.value = { schoolClassId: "", sex: "" };
dialogVisible.value = true;
};
const openEdit = (row) => {
const schoolMajorId = row.schoolMajorId || classMajorId(row.schoolClassId);
form.value = {
...row,
sex: normalizeSex(row.sex),
schoolMajorId,
schoolFacultyId: row.schoolFacultyId || majorFacultyId(schoolMajorId),
};
dialogVisible.value = true;
};
const handleFacultyChange = () => {
form.value.schoolMajorId = "";
form.value.schoolClassId = "";
};
const handleMajorChange = () => {
form.value.schoolClassId = "";
};
const submit = async () => {
if (form.value.userId) {
await updateAdminStudent(form.value.userId, form.value);
@ -250,8 +192,6 @@ const remove = async (row) => {
};
onMounted(() => {
loadFaculties();
loadMajors();
loadClasses();
load();
});

@ -1,10 +1,5 @@
<template>
<div class="school-admin-workbench">
<section class="admin-hero">
<div class="admin-badge">School Admin · Teacher</div>
<h1 class="admin-title">教师管理</h1>
<p class="admin-subtitle">管理本校教师账号,后端会固定教师角色并限制在当前学校。</p>
</section>
<section class="admin-panel">
<div class="toolbar">
<div class="toolbar-left">
@ -22,7 +17,7 @@
<el-button @click="reset">
<el-icon><Refresh /></el-icon><span>重置</span>
</el-button>
<el-button type="success" @click="openCreate">
<el-button type="primary" @click="openCreate">
<el-icon><Plus /></el-icon><span>新增</span>
</el-button>
</div>
@ -54,16 +49,6 @@
<el-form :model="form" label-width="86px">
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="工号"><el-input v-model="form.username" :disabled="Boolean(form.userId)" /></el-form-item>
<el-form-item label="所属院系">
<el-select v-model="form.schoolFacultyId" placeholder="请选择院系" style="width: 100%" @change="handleFacultyChange">
<el-option v-for="faculty in faculties" :key="faculty.schoolFacultyId" :label="faculty.schoolFacultyName" :value="faculty.schoolFacultyId" />
</el-select>
</el-form-item>
<el-form-item label="所属专业">
<el-select v-model="form.schoolMajorId" placeholder="请选择专业" style="width: 100%" :disabled="!form.schoolFacultyId">
<el-option v-for="major in formMajors" :key="major.schoolMajorId" :label="major.schoolMajorName" :value="major.schoolMajorId" />
</el-select>
</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" />
@ -88,7 +73,7 @@
<script setup>
import "@/views/schoolAdmin/admin-theme.scss";
import { ElMessage, ElMessageBox } from "element-plus";
import { addAdminTeacher, deleteAdminTeacher, getAdminFaculties, getAdminMajors, getAdminTeachers, updateAdminTeacher } from "@/api/schoolAdmin";
import { addAdminTeacher, deleteAdminTeacher, getAdminTeachers, updateAdminTeacher } from "@/api/schoolAdmin";
import { Search, Refresh, Plus, Edit, Delete, Close, Check } from "@element-plus/icons-vue";
const query = reactive({ index: 1, size: 10, name: "", username: "" });
@ -97,14 +82,10 @@ const total = ref(0);
const loading = ref(false);
const dialogVisible = ref(false);
const form = ref({});
const faculties = ref([]);
const majors = ref([]);
const sexOptions = [
{ label: "男", value: "男" },
{ label: "女", value: "女" },
];
const majorFacultyId = (id) => majors.value.find((major) => major.schoolMajorId === id)?.schoolFacultyId || "";
const formMajors = computed(() => majors.value.filter((major) => major.schoolFacultyId === form.value.schoolFacultyId));
const normalizeSex = (sex) => {
const value = String(sex ?? "").trim();
if (value === "1" || value === "男" || value === "男性") return "男";
@ -113,19 +94,6 @@ const normalizeSex = (sex) => {
};
const sexText = (sex) => normalizeSex(sex) || "-";
const loadFaculties = async () => {
const res = await getAdminFaculties({ index: 1, size: 1000 });
faculties.value = (res.data?.list || []).map((row) => row.schoolFaculty || row);
};
const loadMajors = async () => {
const res = await getAdminMajors({ index: 1, size: 1000 });
majors.value = (res.data?.list || []).map((row) => ({
...(row.schoolMajor || row),
schoolFacultyId: row.schoolFacultyId || row.schoolMajor?.schoolFacultyId,
schoolMajorId: row.schoolMajorId || row.schoolMajor?.schoolMajorId,
}));
};
const load = async () => {
loading.value = true;
try {
@ -143,16 +111,13 @@ const reset = () => {
load();
};
const openCreate = () => {
form.value = { schoolFacultyId: "", schoolMajorId: "", sex: "" };
form.value = { sex: "" };
dialogVisible.value = true;
};
const openEdit = (row) => {
form.value = { ...row, schoolFacultyId: row.schoolFacultyId || majorFacultyId(row.schoolMajorId), sex: normalizeSex(row.sex) };
form.value = { ...row, sex: normalizeSex(row.sex) };
dialogVisible.value = true;
};
const handleFacultyChange = () => {
form.value.schoolMajorId = "";
};
const submit = async () => {
if (form.value.userId) {
await updateAdminTeacher(form.value.userId, form.value);
@ -171,8 +136,6 @@ const remove = async (row) => {
};
onMounted(() => {
loadFaculties();
loadMajors();
load();
});
</script>

@ -263,6 +263,13 @@ onMounted(() => {
loadDashboard();
});
// 学生首页被 AppMain.vue 的 <keep-alive> 缓存,再次进入不会触发 onMounted,
// 这里手动调用以保证每次进入都重新请求数据并重渲图表,
// 避免上次图表渲染未完成导致再次进入时因接口未重调而图表不显示。
onActivated(() => {
loadDashboard();
});
onBeforeUnmount(() => {
window.removeEventListener("resize", resizeCharts);
scoreChart?.dispose();

@ -24,7 +24,7 @@
</template>
</el-input>
<div class="query-divider"></div>
<el-button @click="getList">
<el-button @click="search">
<el-icon>
<Search />
</el-icon>
@ -464,6 +464,12 @@ function getList(options = {}) {
})
}
// 查询回到第一页,避免停留在超出结果范围的页码
function search() {
paramsquery.value.index = 1
getList()
}
function handleTabChange() {
paramsquery.value.index = 1
getList()

@ -18,7 +18,7 @@
<div class="query-item">
<span class="query-label">专业名称:</span>
<el-input v-model="paramsquery.schoolMajorName" placeholder="请输入" clearable />
<el-button @click="getList">
<el-button @click="search">
<el-icon>
<Search />
</el-icon>
@ -161,6 +161,11 @@ const getList = () => {
schoolTotal.value = res.data.total;
});
};
// 搜索回到第一页,避免停留在超出结果范围的页码
const search = () => {
paramsquery.value.index = 1;
getList();
};
// 删除院校
const delSchool = (id) => {
indexApi.deleteMajor({ userId: userStore.userInfo.userId, schoolMajorId: id }).then((res) => {

@ -12,7 +12,7 @@
<div class="query-item">
<span class="query-label">院系名称:</span>
<el-input v-model="paramsquery.schoolName" placeholder="请输入院系名称" clearable />
<el-button @click="getList">
<el-button @click="search">
<el-icon>
<Search />
</el-icon>
@ -156,6 +156,11 @@ const getList = () => {
schoolTotal.value = res.data.total;
});
};
// 搜索回到第一页,避免停留在超出结果范围的页码
const search = () => {
paramsquery.value.index = 1;
getList();
};
// 删除院校
const delSchool = (id) => {
indexApi.deleteSchool({ schoolFacultyId: id, userId: userStore.userInfo.userId }).then((res) => {

@ -23,7 +23,7 @@
<div class="query-item">
<span class="query-label">学生学号:</span>
<el-input style="width: 14rem" v-model="paramsquery.userName" placeholder="请输入"></el-input>
<el-button @click="getList" class="ml10">
<el-button @click="search" class="ml10">
<el-icon>
<Search />
</el-icon>
@ -130,23 +130,6 @@
<el-form :model="formInline" class="demo-form-inline" ref="formInlineref" label-width="100px"
style="width: 80%" :rules="studentFormRules">
<template v-if="!isTeacherRosterMode">
<el-form-item label="所属院系:" prop="schoolFacultyId">
<el-select v-model="formInline.schoolFacultyId" placeholder="" clearable @change="getMajorList"
style="width: 20rem" v-if="status">
<el-option v-for="school in facultyList" :key="school.schoolId"
:label="school.schoolFacultyName" :value="school.schoolFacultyId" />
</el-select>
<el-input v-else v-model="classInfo.name" style="width: 20rem" disabled></el-input>
</el-form-item>
<el-form-item label="所属专业:" prop="schoolMajorId">
<el-select v-model="formInline.schoolMajorId" placeholder="" clearable style="width: 20rem"
v-if="status" @change="getClassByMajor">
<el-option v-for="major in majorList" :key="major.schoolMajorId"
:label="major.schoolMajorName" :value="major.schoolMajorId" />
</el-select>
<el-input v-else v-model="classInfo.value" placeholder="请输入所属专业:" style="width: 20rem"
disabled></el-input>
</el-form-item>
<el-form-item label="所属班级:" prop="schoolClassId">
<el-select v-model="formInline.schoolClassId" placeholder="" clearable style="width: 20rem"
v-if="status">
@ -224,9 +207,8 @@ const createEmptyForm = () => ({
userName: "",
name: "",
})
// 院系/专业选项已停用,新增/编辑只需选择本校行政班
const organizationRules = {
schoolFacultyId: [{ required: true, message: "请选择所属院系", trigger: "change" }],
schoolMajorId: [{ required: true, message: "请选择所属专业", trigger: "change" }],
schoolClassId: [{ required: true, message: "请选择所属班级", trigger: "change" }],
}
const studentIdentityRules = {
@ -262,6 +244,11 @@ const getList = () => {
schoolTotal.value = res.data.total
})
}
// 搜索回到第一页,避免停留在超出结果范围的页码
const search = () => {
paramsquery.value.index = 1
getList()
}
// 删除院校
const delSchool = (id) => {
indexApi.deleteStudent({ userId: id.userId, operatorId: currentUserId.value, teachingClassId: resolveRowTeachingClassId(id) }).then((res) => {
@ -270,12 +257,14 @@ const delSchool = (id) => {
})
}
// 新增院校
const addschool = () => {
const addschool = async () => {
formInline.value = createEmptyForm()
classInfo.value = {}
classList1.value = []
dialogVisible.value = true
status.value = true
// 班级下拉不再依赖院系/专业联动,直接列出本校可用的行政班
await getClassList().catch(() => null)
classList1.value = classList.value.filter((item) => canUseAdminClassForStudent(item))
}
const editSchool = (row) => {
console.log(row)
@ -288,12 +277,6 @@ const editSchool = (row) => {
}
dialogVisible.value = true
status.value = false
if (!isTeacherRosterMode.value && row.schoolMajorId) {
indexApi.getFacultyAndMajorName({ schoolMajorId: row.schoolMajorId }).then((res) => {
classInfo.value.name = Object.keys(res.data)[0]
classInfo.value.value = Object.values(res.data)[0]
})
}
}
const add = () => {
formInline.value.schoolId = currentSchoolId.value
@ -306,7 +289,6 @@ const add = () => {
})
return
}
formInline.value.schoolMajorName = majorList.value?.filter((item) => item.schoolMajorId == formInline.value.schoolMajorId)[0].schoolMajorName
formInline.value.schoolClassName = classList1.value?.filter((item) => item.schoolClassId == formInline.value.schoolClassId)[0].className
formInline.value.userId = currentUserId.value
indexApi.addStudent(formInline.value).then((res) => {
@ -357,22 +339,6 @@ const refresh = () => {
getList()
}, 2000)
}
// 查询院系下拉列表
const facultyList = ref([])
const getFacultyList = () => {
indexApi.seleteSchoolFacultyList({ schoolId: userStore.userInfo.schoolId }).then((res) => {
facultyList.value = res.data
})
}
// 专业下拉框
const majorList = ref([])
const getMajorList = () => {
if (!formInline.value.schoolFacultyId) return
paramsquery.value.schoolMajorId = ""
indexApi.getMajorListBySchoolId({ schoolFacultyId: formInline.value.schoolFacultyId }).then((res) => {
majorList.value = res.data
})
}
const uploadStudents = (options) => {
if (isTeacherRosterMode.value) {
const formData = new FormData()
@ -407,25 +373,12 @@ const exportTeacher = () => {
})
}
// 导入模版下载
const buildOwnedAdminClassRows = async () => {
const majorGroups = await Promise.all(
facultyList.value.map((faculty) =>
indexApi
.getMajorListBySchoolId({ schoolFacultyId: faculty.schoolFacultyId })
.then((res) => (res.data || []).map((major) => ({ ...major, schoolFacultyName: faculty.schoolFacultyName })))
.catch(() => []),
),
)
const allMajors = majorGroups.flat()
return classList.value
// 班级下拉与导入模板不再区分院系/专业,仅列出本校行政班名称
const ownedAdminClassNames = () =>
classList.value
.filter((item) => canUseAdminClassForStudent(item))
.map((item) => {
const major = allMajors.find((majorItem) => majorItem.schoolMajorId === item.schoolMajorId) || majorList.value.find((majorItem) => majorItem.schoolMajorId === item.schoolMajorId)
const faculty = facultyList.value.find((facultyItem) => facultyItem.schoolFacultyId === (major?.schoolFacultyId || item.schoolFacultyId))
return [faculty?.schoolFacultyName || item.schoolFacultyName || "", major?.schoolMajorName || item.schoolMajorName || "", item.className || ""]
})
.filter((row) => row.every(Boolean))
}
.map((item) => item.className || "")
.filter(Boolean)
const downloadTemplate = async () => {
const XLSX = await import("xlsx")
@ -441,17 +394,15 @@ const downloadTemplate = async () => {
XLSX.writeFile(workbook, "学生账号导入模板.xlsx")
return
}
const adminClassRows = await buildOwnedAdminClassRows()
const firstAdminClass = adminClassRows[0] || ["电子商务学院", "电子商务", "行政一班"]
const classNames = ownedAdminClassNames()
const templateSheet = XLSX.utils.aoa_to_sheet([
["姓名", "学号", "性别", "所属院系", "所属专业", "所属班级"],
["张三", "20260001", "男", firstAdminClass[0], firstAdminClass[1], firstAdminClass[2]],
["姓名", "学号", "性别", "所属班级"],
["张三", "20260001", "男", classNames[0] || "行政一班"],
])
templateSheet["!cols"] = [{ wch: 14 }, { wch: 18 }, { wch: 10 }, { wch: 24 }, { wch: 24 }, { wch: 20 }]
const dataRows = adminClassRows.length ? adminClassRows : [["暂无可导入行政班", "请先维护专业", "请先创建行政班"]]
const dataSheet = XLSX.utils.aoa_to_sheet([["所属院系", "所属专业", "所属班级"], ...dataRows])
dataSheet["!cols"] = [{ wch: 28 }, { wch: 28 }, { wch: 24 }]
const guideSheet = XLSX.utils.aoa_to_sheet([["填写说明"], ["1. 本模板用于学生账号导入,不要填写任何数据ID。"], ["2. 所属院系、所属专业、所属班级请使用基础数据页中的名称。"], ["3. 只能导入本校行政班,不能导入教学班或其他学校的班级。"], ["4. 性别可填写男、女,也可留空。"], ["5. 任意一行校验失败时,整批导入失败并返回失败原因。"]])
templateSheet["!cols"] = [{ wch: 14 }, { wch: 18 }, { wch: 10 }, { wch: 20 }]
const dataSheet = XLSX.utils.aoa_to_sheet([["所属班级"], ...classNames.map((name) => [name])])
dataSheet["!cols"] = [{ wch: 24 }]
const guideSheet = XLSX.utils.aoa_to_sheet([["填写说明"], ["1. 本模板用于学生账号导入,不要填写任何数据ID。"], ["2. 所属班级请使用基础数据页中的名称。"], ["3. 只能导入本校行政班,不能导入教学班或其他学校的班级。"], ["4. 性别可填写男、女,也可留空。"], ["5. 任意一行校验失败时,整批导入失败并返回失败原因。"]])
guideSheet["!cols"] = [{ wch: 76 }]
const workbook = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(workbook, templateSheet, "学生导入模板")
@ -491,14 +442,6 @@ const getClassList = () => {
})
}
const classList1 = ref([])
// 根据专业查班级
const getClassByMajor = () => {
if (!formInline.value.schoolMajorId) return
formInline.value.schoolClassId = ""
indexApi.getClassListByMajorId({ schoolMajorId: formInline.value.schoolMajorId }).then((res) => {
classList1.value = (res.data || []).filter((item) => canUseAdminClassForStudent(item))
})
}
//初始化密码
const initPassword = (row, status) => {
if (status == 0) {
@ -542,9 +485,6 @@ const initPassword = (row, status) => {
}
onMounted(async () => {
await userStore.ensureSchoolProductConfig().catch(() => null)
if (!isTeacherRosterMode.value) {
getFacultyList()
}
await getClassList()
// 默认展示第一个班级数据:把 select 绑到第一个教学班并立即拉取学生列表
if (!paramsquery.value.schoolClassId && studentClassOptions.value.length) {

@ -16,7 +16,7 @@
<div class="online">
<span>教师工号:</span>
<el-input style="width: 14rem" v-model="paramsquery.userName" class="ml10"></el-input>
<el-button @click="getList" class="ml10">
<el-button @click="search" class="ml10">
<el-icon>
<Search />
</el-icon>
@ -246,7 +246,8 @@ const organizationRules = {
schoolMajorId: [{ required: true, message: "请选择专业", trigger: "change" }],
}
const rules = computed(() => ({
...((status.value || isOrganizationManagementEnabled.value) ? organizationRules : {}),
// 院系/专业新增时已停用;仅编辑且展示只读归属时才保留规则
...((!status.value && isOrganizationManagementEnabled.value) ? organizationRules : {}),
name: [{ required: true, message: "请输入教师姓名", trigger: "blur" }],
userName: [{ required: true, message: "请输入教师工号", trigger: "blur" }],
}))
@ -268,6 +269,11 @@ const getList = () => {
schoolTotal.value = res.data.total
})
}
// 搜索回到第一页,避免停留在超出结果范围的页码
const search = () => {
paramsquery.value.index = 1
getList()
}
// 删除院校
const delSchool = (id) => {
indexApi.deleteTeacher({ userId: id.userId, operatorId: currentUserId.value }).then((res) => {
@ -388,14 +394,12 @@ const exportTeacher = () => {
// 导入模板下载
const downloadTemplate = async () => {
const XLSX = await import("xlsx")
// 教师的学校/院系/专业直接继承当前操作用户,模板不再需要组织信息列
const templateSheet = XLSX.utils.aoa_to_sheet([
["院校名称", "院系名称", "专业名称", "教师姓名", "工号", "电话", "邮箱"],
["请填写系统中的院校名称", "请填写系统中的院系名称", "请填写系统中的专业名称", "张老师", "20260001", "13800000000", "teacher@example.com"],
["教师姓名", "工号", "电话", "邮箱"],
["张老师", "20260001", "13800000000", "teacher@example.com"],
])
templateSheet["!cols"] = [
{ wch: 26 },
{ wch: 24 },
{ wch: 24 },
{ wch: 16 },
{ wch: 18 },
{ wch: 18 },
@ -403,8 +407,8 @@ const downloadTemplate = async () => {
]
const guideSheet = XLSX.utils.aoa_to_sheet([
["填写说明"],
["1. 院校、院系和专业名称必须与系统中已维护的名称完全一致。"],
["2. 教师姓名和工号必填,工号只能填写数字且不能重复。"],
["1. 教师姓名和工号必填,工号只能填写数字且不能重复。"],
["2. 导入的教师归入你所在的学校(院系/专业同你的账号)。"],
["3. 电话和邮箱可以留空;填写时必须符合手机号和邮箱格式。"],
["4. 默认密码为 123qwe,导入成功后请提醒教师及时修改密码。"],
])

@ -26,7 +26,7 @@
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
<el-input v-model.trim="keyword" clearable placeholder="搜索任务名称..." @keyup.enter="queryTasks" />
<el-input v-model.trim="keyword" clearable placeholder="输入实训任务名称搜索..." @keyup.enter="queryTasks" />
<el-button type="primary" class="search-btn" @click="queryTasks">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
@ -79,7 +79,7 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
</svg>
所属项目
所属项目:
</span>
<div class="project-tabs">
<button type="button" :class="['project-tab', { active: !activeProject }]" @click="toggleProject('')">
@ -198,7 +198,7 @@
{{ dialogMode === "view" ? "Training Task Preview" : "Training Task Editor" }}
</span>
<h3>{{ dialogMode === "view" ? "查看实训任务" : "编辑实训任务" }}</h3>
<p>维护教学班实训任务配置,包括任务名称、说明、目标、要求和步骤等内容</p>
<p>维护教学班实训任务配置,学生端将使用本班的实训背景、目标、要求和案例资料等内容</p>
</div>
<button class="drawer-close-button" aria-label="关闭抽屉" @click="dialogVisible = false">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
@ -308,7 +308,7 @@
<span class="section-number">03</span>
<div class="section-info">
<h4>资料与步骤</h4>
<p>上传案例数据、实训指导手册与AI辅助指导手册;步骤数量固定,仅可修改步骤名称</p>
<p>上传案例资料、案例视频、实训指导手册与AI排错指导手册,配置实训步骤</p>
</div>
</div>
<div v-for="slot in materialSlots" :key="slot.key" class="material-row">
@ -327,7 +327,6 @@
</div>
<div class="material-ops">
<el-upload v-if="!isReadonly" action="#" :auto-upload="false" :show-file-list="false"
:accept="slot.accept || undefined"
:on-change="(file) => handleMaterialChange(slot.key, file)">
<el-button type="primary" :loading="uploadingField === slot.key"
class="upload-btn">
@ -426,13 +425,12 @@ const restoring = ref(false)
const uploadingField = ref("")
const choiceEditorVisible = ref(false)
const bankPickerVisible = ref(false)
// 四类资料槽位:key 对应 taskForm 的 `${key}Name` / `${key}Url` 字段
// accept 为空表示不限制文件类型,否则按逗号分隔的扩展名校验
// 四类资料槽位:key 对应 taskForm 的 `${key}Name` / `${key}Url` 字段,不限制文件类型
const materialSlots = [
{ key: "material", label: "案例数据", accept: ".xls,.xlsx" },
{ key: "trainingData", label: "案例数据", accept: ".xls,.xlsx" },
{ key: "material", label: "案例资料" },
{ key: "trainingData", label: "案例视频" },
{ key: "guideManual", label: "实训指导手册" },
{ key: "aiManual", label: "AI辅助指导手册" },
{ key: "aiManual", label: "AI排错指导手册" },
]
const tasks = ref([])
const classList = ref([])
@ -743,19 +741,6 @@ function removeObjective(index) {
async function handleMaterialChange(slotKey, file) {
const rawFile = file?.raw
if (!rawFile) return
const slot = materialSlots.find((item) => item.key === slotKey)
if (slot?.accept) {
const allowedExts = slot.accept
.split(",")
.map((item) => item.trim().toLowerCase())
.filter(Boolean)
const fileName = String(rawFile.name || file?.name || "").toLowerCase()
const isAllowed = allowedExts.some((ext) => fileName.endsWith(ext))
if (!isAllowed) {
proxy?.$modal?.msgError?.(`${slot.label}仅支持 ${allowedExts.join(" / ")} 格式`)
return
}
}
const data = new FormData()
data.append("file", rawFile)
uploadingField.value = slotKey

@ -9,7 +9,11 @@ const page = fs.readFileSync(
assert(/const downloadTemplate = async \(\) =>/.test(page));
assert(/await import\("xlsx"\)/.test(page));
assert(/\["院校名称", "院系名称", "专业名称", "教师姓名", "工号", "电话", "邮箱"\]/.test(page));
// 2026-10-12 起新增教师不再采集院校/院系/专业,归属直接继承当前操作用户,模板同步移除对应列
assert(/\["教师姓名", "工号", "电话", "邮箱"\]/.test(page));
assert(!/院校名称/.test(page));
assert(!/院系名称/.test(page));
assert(!/专业名称/.test(page));
assert(/XLSX\.utils\.book_append_sheet\(workbook, templateSheet, "教师导入模板"\)/.test(page));
assert(/XLSX\.writeFile\(workbook, "教师账号导入模板\.xlsx"\)/.test(page));
assert(!/\/file\/教师信息表格\.xlsx/.test(page));

@ -6,10 +6,40 @@ const root = resolve(import.meta.dirname, "..");
const sidebar = readFileSync(resolve(root, "src/layout/components/Sidebar/index.vue"), "utf8");
const styles = readFileSync(resolve(root, "src/assets/styles/sidebar.scss"), "utf8");
assert.match(sidebar, /teacher-sidebar-toggle/, "教师侧栏应提供收起与展开按钮");
assert.match(sidebar, /course-sidebar-toggle/, "侧栏应提供收起与展开把手(教师端 / 学生端共用)");
assert.match(sidebar, /toggleSideBar\(/, "收起按钮应复用全局侧栏状态");
assert.match(sidebar, /isCollapse/, "收起状态应同步到教师侧栏样式");
assert.match(styles, /&\.hideSidebar/, "教师侧栏收起时应缩小侧栏并回收内容区空间");
assert.match(styles, /\.teacher-sidebar-toggle/, "收起按钮应有教师端专属样式");
assert.match(sidebar, /isCollapse/, "收起状态应同步到侧栏样式");
assert.match(styles, /&\.hideSidebar/, "侧栏收起时应缩小侧栏并回收内容区空间");
assert.match(styles, /\.course-sidebar-toggle/, "收起把手应贴侧栏右缘展示");
console.log("teacher sidebar can collapse to an icon rail");
// 折叠把手必须常驻:不得再按路由条件渲染。
// 旧实现为 v-if="isTeacherRole || isStudentTrainingWorkspace",
// 导致学生端切到非实训页(首页 / AI 智能助手 / 学情分析 / 考试 / 实验报告 / BI 等)时
// 按钮凭空消失,同一角色在不同页面表现不一致。
const toggleIndex = sidebar.indexOf("course-sidebar-toggle");
assert.ok(toggleIndex > -1, "应存在折叠把手");
const buttonStart = sidebar.lastIndexOf("<button", toggleIndex);
const buttonEnd = sidebar.indexOf(">", toggleIndex);
const toggleTag = sidebar.slice(buttonStart, buttonEnd + 1);
assert.doesNotMatch(toggleTag, /v-if/, "折叠把手不得带 v-if 条件渲染,必须常驻显示");
assert.doesNotMatch(sidebar, /isStudentTrainingWorkspace/, "不应残留按路由判断按钮显隐的旧逻辑");
// 收起状态的三条 class 链必须收敛到同一来源,否则会出现
// 「宽度已切、箭头不动 / 菜单文字不显示」的失步现象。
assert.match(
sidebar,
/:key="`\$\{studentMenuKey\}-\$\{isCollapse \? 'collapsed' : 'expanded'\}`"/,
"折叠切换必须改变 el-menu 的 key,强制 Element Plus 重建菜单",
);
assert.match(
styles,
/#app \.hideSidebar \.sidebar-container \.course-sidebar-toggle/,
"箭头切换应同时挂在控制宽度的 .hideSidebar 链上",
);
assert.match(
styles,
/#app \.app-wrapper:not\(\.hideSidebar\)[\s\S]{0,200}el-menu--collapse/,
"展开态必须能强制恢复被 el-menu 折叠外观隐藏的菜单文字",
);
console.log("sidebar collapse toggle stays visible and keeps all states in sync");

@ -16,8 +16,8 @@ assert(page.includes('v-if="isTeacherRosterMode" label="所属教学班:" prop="
assert(page.includes('teachingClassId: [{ required: true, message: "请选择所属教学班", trigger: "change" }]'), "教师维护名单模式必须校验教学班必选");
assert(page.includes('teachingClassOptions'), "教师维护名单模式必须只提供可维护的教学班选项");
assert(
/if \(!isTeacherRosterMode\.value && row\.schoolMajorId\) \{\s*indexApi\.getFacultyAndMajorName/.test(page),
"教师维护名单模式不能请求必填 schoolMajorId 的组织结构接口"
!page.includes("getFacultyAndMajorName"),
"院系/专业停用后,任何模式都不应再请求必填 schoolMajorId 的组织结构接口"
);
assert(page.includes("indexApi.addTeacherRosterStudent(formInline.value)"), "教师维护名单模式新增学生必须使用无组织字段的名单接口");
assert(page.includes("indexApi.updateTeacherRosterStudent(formInline.value)"), "教师维护名单模式编辑学生必须使用无班级归属的名单接口");

Loading…
Cancel
Save