fix: BI可视化分析改走/bi/同机反代修复跨域丢Cookie弹登录;AI助学/助评前置校验提示中文化;教师端实训管理文案与搜索翻页对齐管理端

master
chenyuan 1 day ago
parent ab6c9cdc7b
commit ae64fb3465

@ -15,6 +15,9 @@ VITE_APP_ENV = 'production'
# 若依管理系统/生产环境 # 若依管理系统/生产环境
VITE_APP_BASE_API = '/' VITE_APP_BASE_API = '/'
VITE_APP_AI_API = 'https://whmnjy.sztzjy.com:8764/' 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 # 是否在打包时开启压缩,支持 gzip 和 brotli
VITE_BUILD_COMPRESS = gzip VITE_BUILD_COMPRESS = gzip

@ -225,5 +225,13 @@ export function getLearningAnalysis(params) {
data: stuData, data: stuData,
// AI 服务使用独立鉴权,不能携带本系统的登录令牌。 // AI 服务使用独立鉴权,不能携带本系统的登录令牌。
headers: { isToken: false } 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"; 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) { export function getAiTrainingEvaluation(taskKey) {
return request({ return request({
url: `/api/student/training-tasks/${taskKey}/ai-evaluation`, url: `/api/student/training-tasks/${taskKey}/ai-evaluation`,
@ -13,6 +25,9 @@ export function requestAiTrainingHelp(taskKey, question, stepContext = "") {
method: "post", method: "post",
data: { question, stepContext }, data: { question, stepContext },
headers: { repeatSubmit: false }, headers: { repeatSubmit: false },
// 后端对这类前置校验返回英文原文(还要落库,不能直接改中文),
// 屏蔽全局错误弹窗,由侧边栏按场景给出中文提示
skipErrorMessage: true,
}); });
} }
@ -21,5 +36,6 @@ export function requestAiTrainingAssessment(taskKey) {
url: `/api/student/training-tasks/${taskKey}/ai-evaluation/assessment`, url: `/api/student/training-tasks/${taskKey}/ai-evaluation/assessment`,
method: "post", method: "post",
headers: { repeatSubmit: false }, headers: { repeatSubmit: false },
skipErrorMessage: true,
}); });
} }

@ -57,7 +57,8 @@ export const getUserRank = (params) => {
}) })
} }
export const getCurrentExperimentReport = () => { 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) => { export const updateVisitCount = (params) => {

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

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

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

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

@ -100,7 +100,7 @@
font-weight: var(--admin-fw-bolder); font-weight: var(--admin-fw-bolder);
letter-spacing: 0; letter-spacing: 0;
color: transparent; color: transparent;
background: linear-gradient(135deg, #ffffff, #2563eb); background: linear-gradient(135deg, #1f2937, #2563eb);
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
} }
@ -152,7 +152,7 @@
padding: 14px 16px; padding: 14px 16px;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 18px; border-radius: 18px;
background: rgba(0, 35, 55, 0.44); background: #ffffff;
} }
.school-admin-workbench .toolbar-left, .school-admin-workbench .toolbar-left,
@ -502,7 +502,7 @@
overflow: hidden; overflow: hidden;
border-radius: 18px; border-radius: 18px;
border-color: #e5e7eb; border-color: #e5e7eb;
background: rgba(3, 24, 36, 0.72); background: #ffffff;
color: #374151; color: #374151;
font-family: var(--admin-font-base); font-family: var(--admin-font-base);
font-size: var(--admin-fs-body); font-size: var(--admin-fs-body);
@ -531,7 +531,7 @@
.school-admin-workbench .el-table tr, .school-admin-workbench .el-table tr,
.school-admin-workbench .el-table td.el-table__cell { .school-admin-workbench .el-table td.el-table__cell {
background: rgba(3, 24, 36, 0.58); background: #ffffff;
border-color: #e5e7eb; border-color: #e5e7eb;
font-family: var(--admin-font-base); font-family: var(--admin-font-base);
font-size: var(--admin-fs-body-sm); font-size: var(--admin-fs-body-sm);
@ -548,7 +548,7 @@
} }
.school-admin-workbench .el-table .el-loading-mask { .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); backdrop-filter: blur(1px);
} }
@ -567,7 +567,7 @@
.school-admin-workbench .el-pagination { .school-admin-workbench .el-pagination {
--el-pagination-bg-color: transparent; --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; --el-pagination-hover-color: #2563eb;
color: #374151; color: #374151;
font-family: var(--admin-font-base); font-family: var(--admin-font-base);
@ -580,6 +580,33 @@
font-size: var(--admin-fs-body-sm); 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__total,
.school-admin-workbench .el-pagination__jump { .school-admin-workbench .el-pagination__jump {
font-family: var(--admin-font-base); font-family: var(--admin-font-base);
@ -592,7 +619,7 @@
padding: 18px; padding: 18px;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 22px; border-radius: 22px;
background: rgba(0, 20, 35, 0.62); background: #ffffff;
} }
.school-admin-workbench .el-form-item__label { .school-admin-workbench .el-form-item__label {

@ -1,10 +1,5 @@
<template> <template>
<div class="school-admin-workbench"> <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"> <section class="admin-panel">
<div class="toolbar"> <div class="toolbar">
<div class="toolbar-left"> <div class="toolbar-left">
@ -23,7 +18,7 @@
<el-button @click="reset"> <el-button @click="reset">
<el-icon><Refresh /></el-icon><span>重置</span> <el-icon><Refresh /></el-icon><span>重置</span>
</el-button> </el-button>
<el-button type="success" @click="openCreate"> <el-button type="primary" @click="openCreate">
<el-icon><Plus /></el-icon><span>新增</span> <el-icon><Plus /></el-icon><span>新增</span>
</el-button> </el-button>
</div> </div>

@ -1,10 +1,5 @@
<template> <template>
<div class="school-admin-workbench"> <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"> <section class="admin-panel">
<div class="toolbar"> <div class="toolbar">
<el-input v-model="query.schoolFacultyName" clearable placeholder="院系名称" class="filter-input" @keyup.enter="load" /> <el-input v-model="query.schoolFacultyName" clearable placeholder="院系名称" class="filter-input" @keyup.enter="load" />

@ -1,10 +1,5 @@
<template> <template>
<div class="school-admin-workbench"> <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"> <section class="admin-panel">
<div class="toolbar"> <div class="toolbar">
<el-input v-model="query.schoolMajorName" clearable placeholder="专业名称" class="filter-input" @keyup.enter="load" /> <el-input v-model="query.schoolMajorName" clearable placeholder="专业名称" class="filter-input" @keyup.enter="load" />

@ -1,10 +1,5 @@
<template> <template>
<div class="school-admin-workbench"> <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"> <section class="admin-panel">
<el-form :model="form" label-width="110px" class="admin-form"> <el-form :model="form" label-width="110px" class="admin-form">
<el-row :gutter="18"> <el-row :gutter="18">

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

@ -1,10 +1,5 @@
<template> <template>
<div class="school-admin-workbench"> <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"> <section class="admin-panel">
<div class="toolbar"> <div class="toolbar">
<div class="toolbar-left"> <div class="toolbar-left">
@ -22,7 +17,7 @@
<el-button @click="reset"> <el-button @click="reset">
<el-icon><Refresh /></el-icon><span>重置</span> <el-icon><Refresh /></el-icon><span>重置</span>
</el-button> </el-button>
<el-button type="success" @click="openCreate"> <el-button type="primary" @click="openCreate">
<el-icon><Plus /></el-icon><span>新增</span> <el-icon><Plus /></el-icon><span>新增</span>
</el-button> </el-button>
</div> </div>
@ -54,16 +49,6 @@
<el-form :model="form" label-width="86px"> <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.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-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-form-item label="性别">
<el-select v-model="form.sex" placeholder="请选择性别" style="width: 100%"> <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-option v-for="option in sexOptions" :key="option.value" :label="option.label" :value="option.value" />
@ -88,7 +73,7 @@
<script setup> <script setup>
import "@/views/schoolAdmin/admin-theme.scss"; import "@/views/schoolAdmin/admin-theme.scss";
import { ElMessage, ElMessageBox } from "element-plus"; 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"; import { Search, Refresh, Plus, Edit, Delete, Close, Check } from "@element-plus/icons-vue";
const query = reactive({ index: 1, size: 10, name: "", username: "" }); const query = reactive({ index: 1, size: 10, name: "", username: "" });
@ -97,14 +82,10 @@ const total = ref(0);
const loading = ref(false); const loading = ref(false);
const dialogVisible = ref(false); const dialogVisible = ref(false);
const form = ref({}); const form = ref({});
const faculties = ref([]);
const majors = ref([]);
const sexOptions = [ const sexOptions = [
{ label: "男", value: "男" }, { label: "男", value: "男" },
{ 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 normalizeSex = (sex) => {
const value = String(sex ?? "").trim(); const value = String(sex ?? "").trim();
if (value === "1" || value === "男" || value === "男性") return "男"; if (value === "1" || value === "男" || value === "男性") return "男";
@ -113,19 +94,6 @@ const normalizeSex = (sex) => {
}; };
const sexText = (sex) => 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 () => { const load = async () => {
loading.value = true; loading.value = true;
try { try {
@ -143,16 +111,13 @@ const reset = () => {
load(); load();
}; };
const openCreate = () => { const openCreate = () => {
form.value = { schoolFacultyId: "", schoolMajorId: "", sex: "" }; form.value = { sex: "" };
dialogVisible.value = true; dialogVisible.value = true;
}; };
const openEdit = (row) => { 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; dialogVisible.value = true;
}; };
const handleFacultyChange = () => {
form.value.schoolMajorId = "";
};
const submit = async () => { const submit = async () => {
if (form.value.userId) { if (form.value.userId) {
await updateAdminTeacher(form.value.userId, form.value); await updateAdminTeacher(form.value.userId, form.value);
@ -171,8 +136,6 @@ const remove = async (row) => {
}; };
onMounted(() => { onMounted(() => {
loadFaculties();
loadMajors();
load(); load();
}); });
</script> </script>

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

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

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

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

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

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

@ -9,7 +9,11 @@ const page = fs.readFileSync(
assert(/const downloadTemplate = async \(\) =>/.test(page)); assert(/const downloadTemplate = async \(\) =>/.test(page));
assert(/await import\("xlsx"\)/.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\.utils\.book_append_sheet\(workbook, templateSheet, "教师导入模板"\)/.test(page));
assert(/XLSX\.writeFile\(workbook, "教师账号导入模板\.xlsx"\)/.test(page)); assert(/XLSX\.writeFile\(workbook, "教师账号导入模板\.xlsx"\)/.test(page));
assert(!/\/file\/教师信息表格\.xlsx/.test(page)); assert(!/\/file\/教师信息表格\.xlsx/.test(page));

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

Loading…
Cancel
Save