dev-QQq
qinzhenpen 3 days ago
commit 0e237e5cdb

@ -7,7 +7,7 @@
# @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE # @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
### ###
# 页面标题 # 页面标题
VITE_APP_TITLE = 电商互联网产品开发实训系统 VITE_APP_TITLE = 互联网产品开发实训系统
# 开发环境配置 # 开发环境配置
VITE_APP_ENV = 'development' VITE_APP_ENV = 'development'

@ -7,7 +7,7 @@
# @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE # @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
### ###
# 页面标题 # 页面标题
VITE_APP_TITLE = 电商互联网产品开发实训系统 VITE_APP_TITLE = 互联网产品开发实训系统
# 生产环境配置 # 生产环境配置
VITE_APP_ENV = 'production' VITE_APP_ENV = 'production'
@ -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

@ -1,5 +1,5 @@
# 页面标题 # 页面标题
VITE_APP_TITLE = 电商互联网产品开发实训系统 VITE_APP_TITLE = 互联网产品开发实训系统
# 生产环境配置 # 生产环境配置
VITE_APP_ENV = 'staging' VITE_APP_ENV = 'staging'

1
.gitignore vendored

@ -12,6 +12,7 @@ selenium-debug.log
# Editor directories and files # Editor directories and files
.idea .idea
.workbuddy
.cursor/ .cursor/
.vscode .vscode
*.suo *.suo

@ -1,4 +1,4 @@
# 电商互联网产品开发实训系统 - 项目认知 # 互联网产品开发实训系统 - 项目认知
> 本文件为 AI 助手提供项目上下文,每次新对话开始时会读取此文件。 > 本文件为 AI 助手提供项目上下文,每次新对话开始时会读取此文件。
@ -6,7 +6,7 @@
## 1. 项目基本信息 ## 1. 项目基本信息
- **项目名称**:电商互联网产品开发实训系统 - **项目名称**:互联网产品开发实训系统
- **技术栈**:Vue 3 (Composition API) + Vite 3 + Element Plus 2.2.x + Pinia + Vue Router 4 + ECharts 5.4 + SCSS - **技术栈**:Vue 3 (Composition API) + Vite 3 + Element Plus 2.2.x + Pinia + Vue Router 4 + ECharts 5.4 + SCSS
- **基础框架**:RuoYi-Vue 3.8.6(若依前后端分离系统增强版) - **基础框架**:RuoYi-Vue 3.8.6(若依前后端分离系统增强版)
- **项目路径**:`D:\TZ\dianshang-qianduan` 或 `d:\TZ\dianshang-qianduan` - **项目路径**:`D:\TZ\dianshang-qianduan` 或 `d:\TZ\dianshang-qianduan`

@ -1,14 +1,17 @@
<!DOCTYPE html> <!doctype html>
<html> <html>
<head>
<head> <meta charset="utf-8" />
<meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> <meta name="renderer" content="webkit" />
<meta name="renderer" content="webkit"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"> <link rel="icon" href="/favicon.ico" />
<link rel="icon" href="/favicon.ico"> <title>互联网产品开发实训系统</title>
<title>电商互联网产品开发实训系统</title> <!--[if lt IE 11]>
<!--[if lt IE 11]><script>window.location.href='/html/ie.html';</script><![endif]--> <script>
window.location.href = "/html/ie.html";
</script>
<![endif]-->
<style> <style>
:root { :root {
--ink: #06101f; --ink: #06101f;
@ -37,13 +40,13 @@
height: 100vh; height: 100vh;
color: var(--text); color: var(--text);
font-family: "Microsoft YaHei", "PingFang SC", "Source Han Sans CN", sans-serif; font-family: "Microsoft YaHei", "PingFang SC", "Source Han Sans CN", sans-serif;
background: background: linear-gradient(rgba(92, 192, 255, 0.08) 1px, transparent 1px), linear-gradient(90deg, rgba(92, 192, 255, 0.08) 1px, transparent 1px), radial-gradient(circle at 18% 18%, rgba(83, 214, 255, 0.14), transparent 28%), radial-gradient(circle at 82% 72%, rgba(143, 247, 209, 0.1), transparent 30%), linear-gradient(135deg, #071426 0%, #06101f 50%, #091827 100%);
linear-gradient(rgba(92, 192, 255, 0.08) 1px, transparent 1px), background-size:
linear-gradient(90deg, rgba(92, 192, 255, 0.08) 1px, transparent 1px), 52px 52px,
radial-gradient(circle at 18% 18%, rgba(83, 214, 255, 0.14), transparent 28%), 52px 52px,
radial-gradient(circle at 82% 72%, rgba(143, 247, 209, 0.1), transparent 30%), auto,
linear-gradient(135deg, #071426 0%, #06101f 50%, #091827 100%); auto,
background-size: 52px 52px, 52px 52px, auto, auto, auto; auto;
} }
#loading-wrapper::before, #loading-wrapper::before,
@ -55,9 +58,7 @@
} }
#loading-wrapper::before { #loading-wrapper::before {
background: background: linear-gradient(120deg, transparent 0 22%, rgba(83, 214, 255, 0.14) 22.3%, transparent 22.8% 62%, rgba(255, 217, 120, 0.12) 62.3%, transparent 62.8%), linear-gradient(0deg, rgba(0, 0, 0, 0.24), transparent 36%, rgba(0, 0, 0, 0.2));
linear-gradient(120deg, transparent 0 22%, rgba(83, 214, 255, 0.14) 22.3%, transparent 22.8% 62%, rgba(255, 217, 120, 0.12) 62.3%, transparent 62.8%),
linear-gradient(0deg, rgba(0, 0, 0, 0.24), transparent 36%, rgba(0, 0, 0, 0.2));
opacity: 0.75; opacity: 0.75;
} }
@ -77,10 +78,10 @@
padding: 34px 38px; padding: 34px 38px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
background: background: linear-gradient(145deg, rgba(14, 42, 68, 0.92), rgba(6, 20, 38, 0.9)), var(--panel);
linear-gradient(145deg, rgba(14, 42, 68, 0.92), rgba(6, 20, 38, 0.9)), box-shadow:
var(--panel); 0 24px 70px rgba(0, 0, 0, 0.34),
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.06); inset 0 1px 0 rgba(255, 255, 255, 0.06);
animation: shell-in 0.7s ease both; animation: shell-in 0.7s ease both;
} }
@ -91,10 +92,7 @@
overflow: hidden; overflow: hidden;
border: 1px solid rgba(83, 214, 255, 0.28); border: 1px solid rgba(83, 214, 255, 0.28);
border-radius: 8px; border-radius: 8px;
background: background: linear-gradient(90deg, transparent 23px, rgba(83, 214, 255, 0.11) 24px, transparent 25px), linear-gradient(0deg, transparent 23px, rgba(83, 214, 255, 0.1) 24px, transparent 25px), rgba(5, 22, 39, 0.9);
linear-gradient(90deg, transparent 23px, rgba(83, 214, 255, 0.11) 24px, transparent 25px),
linear-gradient(0deg, transparent 23px, rgba(83, 214, 255, 0.1) 24px, transparent 25px),
rgba(5, 22, 39, 0.9);
background-size: 25px 25px; background-size: 25px 25px;
} }
@ -124,7 +122,9 @@
height: 5px; height: 5px;
border-radius: 99px; border-radius: 99px;
background: var(--mint); background: var(--mint);
box-shadow: 0 15px 0 rgba(211, 239, 255, 0.7), 0 30px 0 rgba(211, 239, 255, 0.42); box-shadow:
0 15px 0 rgba(211, 239, 255, 0.7),
0 30px 0 rgba(211, 239, 255, 0.42);
} }
.learning-card::after { .learning-card::after {
@ -146,7 +146,10 @@
height: 10px; height: 10px;
border-radius: 50%; border-radius: 50%;
background: var(--cyan); background: var(--cyan);
box-shadow: 28px -8px 0 var(--gold), 58px 4px 0 var(--mint), 86px -18px 0 rgba(83, 214, 255, 0.72); box-shadow:
28px -8px 0 var(--gold),
58px 4px 0 var(--mint),
86px -18px 0 rgba(83, 214, 255, 0.72);
} }
.pulse-node::before { .pulse-node::before {
@ -322,9 +325,9 @@
} }
} }
</style> </style>
</head> </head>
<body> <body>
<div id="app"> <div id="app">
<div id="loading-wrapper"> <div id="loading-wrapper">
<div class="loading-shell" role="status" aria-live="polite"> <div class="loading-shell" role="status" aria-live="polite">
@ -338,15 +341,23 @@
<p class="loading-subtitle">正在同步课程资源、实训数据与学习进度,请稍候。</p> <p class="loading-subtitle">正在同步课程资源、实训数据与学习进度,请稍候。</p>
<div class="progress-track" aria-hidden="true"></div> <div class="progress-track" aria-hidden="true"></div>
<div class="loading-steps" aria-hidden="true"> <div class="loading-steps" aria-hidden="true">
<div class="loading-step"><span>01</span>课程资源</div> <div class="loading-step">
<div class="loading-step"><span>02</span>实训任务</div> <span>01</span>
<div class="loading-step"><span>03</span>学习数据</div> 课程资源
</div>
<div class="loading-step">
<span>02</span>
实训任务
</div>
<div class="loading-step">
<span>03</span>
学习数据
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>

@ -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,
}); });
} }

@ -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) * 教师端 sidebar 宽度单独缩减 50%(304 → 152px)
* 教师端使用 el-menu 直显式布局,无需学生端的内嵌卡片空间,原 304px 造成大量空白 * 教师端使用 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 的暗色背景统一覆盖为亮色 * student-training.scss 中 .student-training-shell 的暗色背景统一覆盖为亮色

@ -1064,7 +1064,7 @@ body.student-light-mode {
white-space: nowrap !important; white-space: nowrap !important;
} }
@media (max-width: 1100px) { @media (max-width: 800px) {
/* AI 悬浮球/面板在窄屏仍 fixed 悬浮;面板宽度自适应 100vw-32px */ /* AI 悬浮球/面板在窄屏仍 fixed 悬浮;面板宽度自适应 100vw-32px */
.student-training-shell .workbench-actions { .student-training-shell .workbench-actions {

@ -1,7 +1,8 @@
<template> <template>
<div :class="['navbar', { 'teacher-navbar': role === 3 || role === 1, 'student-navbar': role === 4 }]"> <div :class="['navbar', { 'teacher-navbar': role === 3 || role === 1, 'student-navbar': role === 4 }]">
<!-- 背景装饰(学生端已移除,仅平台/学校管理员保留) --> <!-- 背景装饰(学生端已移除,仅平台/学校管理员保留) -->
<svg v-if="role === 1 || role === 2" class="nav-bg-decoration" viewBox="0 0 1920 60" preserveAspectRatio="xMidYMid slice"> <svg v-if="role === 1 || role === 2" class="nav-bg-decoration" viewBox="0 0 1920 60"
preserveAspectRatio="xMidYMid slice">
<defs> <defs>
<linearGradient id="navGrad" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient id="navGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#001529;stop-opacity:0.95" /> <stop offset="0%" style="stop-color:#001529;stop-opacity:0.95" />
@ -47,26 +48,32 @@
<div class="logo-area"> <div class="logo-area">
<svg class="logo-icon" width="28" height="28" viewBox="0 0 24 24" fill="none"> <svg class="logo-icon" width="28" height="28" viewBox="0 0 24 24" fill="none">
<!-- 购物车主体(车身 + 车把)--> <!-- 购物车主体(车身 + 车把)-->
<path d="M3 3h2l2.2 12h10.5l2.3-8H6" stroke="#00aaff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="rgba(0,170,255,0.12)"/> <path d="M3 3h2l2.2 12h10.5l2.3-8H6" stroke="#00aaff" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round" fill="rgba(0,170,255,0.12)" />
<!-- 代码左尖括号 < 叠加在车身 = 产品开发 --> <!-- 代码左尖括号 < 叠加在车身 = 产品开发 -->
<path d="M10 7.5L7.8 10l2.2 2.5" stroke="#00aaff" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"/> <path d="M10 7.5L7.8 10l2.2 2.5" stroke="#00aaff" stroke-width="1.3" stroke-linecap="round"
stroke-linejoin="round" opacity="0.9" />
<!-- 代码右尖括号 > 叠加在车身 = 产品开发 --> <!-- 代码右尖括号 > 叠加在车身 = 产品开发 -->
<path d="M17 7.5L19.2 10l-2.2 2.5" stroke="#00aaff" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"/> <path d="M17 7.5L19.2 10l-2.2 2.5" stroke="#00aaff" stroke-width="1.3" stroke-linecap="round"
stroke-linejoin="round" opacity="0.9" />
<!-- 左车轮 --> <!-- 左车轮 -->
<circle cx="8" cy="18.5" r="1.5" stroke="#00aaff" stroke-width="1.4"/> <circle cx="8" cy="18.5" r="1.5" stroke="#00aaff" stroke-width="1.4" />
<!-- 右车轮 --> <!-- 右车轮 -->
<circle cx="17" cy="18.5" r="1.5" stroke="#00aaff" stroke-width="1.4"/> <circle cx="17" cy="18.5" r="1.5" stroke="#00aaff" stroke-width="1.4" />
</svg> </svg>
<span class="nacber-name" v-if="role !== 3">电商互联网产品开发实训系统</span> <span class="nacber-name" v-if="role !== 3">互联网产品开发实训系统</span>
<span class="nacber-name" v-else>互联网产品开发实训系统</span> <span class="nacber-name" v-else>互联网产品开发实训系统</span>
</div> </div>
</div> </div>
<div :class="['el-ment', { 'platform-nav': role === 1 || role === 2, 'school-admin-nav': role === 2, 'teacher-nav': role === 3 }]"> <div
:class="['el-ment', { 'platform-nav': role === 1 || role === 2, 'school-admin-nav': role === 2, 'teacher-nav': role === 3 }]">
<template v-for="item in roleList" :key="item.name"> <template v-for="item in roleList" :key="item.name">
<div :class="['el-ment-item', { 'el-ment-item-active': isActive(item) }]" @click="go(item)"> <div :class="['el-ment-item', { 'el-ment-item-active': isActive(item) }]" @click="go(item)">
<span class="menu-indicator"></span> <span class="menu-indicator"></span>
<el-icon v-if="role === 4 && item.icon" class="menu-ico"><component :is="item.icon" /></el-icon> <el-icon v-if="role === 4 && item.icon" class="menu-ico">
<component :is="item.icon" />
</el-icon>
<span class="menu-text">{{ item.name }}</span> <span class="menu-text">{{ item.name }}</span>
</div> </div>
</template> </template>
@ -75,13 +82,14 @@
<div class="right-menu"> <div class="right-menu">
<div class="search-box"> <div class="search-box">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</div> </div>
<div class="divider"></div> <div class="divider"></div>
<div class="avatar-container"> <div class="avatar-container">
<el-dropdown @command="handleCommand" class="right-menu-item hover-effect" trigger="click" :popper-class="role === 3 || role === 1 ? 'teacher-dropdown-popper' : role === 4 ? 'student-dropdown-popper' : ''"> <el-dropdown @command="handleCommand" class="right-menu-item hover-effect" trigger="click"
:popper-class="role === 3 || role === 1 ? 'teacher-dropdown-popper' : role === 4 ? 'student-dropdown-popper' : ''">
<div class="avatar-wrapper"> <div class="avatar-wrapper">
<div class="avatar-ring"> <div class="avatar-ring">
<img src="../../assets/images/头像.webp" class="user-avatar" /> <img src="../../assets/images/头像.webp" class="user-avatar" />
@ -99,17 +107,19 @@
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<el-dropdown-menu class="dropdown-menu-custom"> <el-dropdown-menu class="dropdown-menu-custom">
<el-dropdown-item divided command="changePassword" class="dropdown-item"> <el-dropdown-item divided command="changePassword" class="dropdown-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/> stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M7 11V7a5 5 0 0 1 10 0v4"/> <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg> </svg>
<span>修改密码</span> <span>修改密码</span>
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item divided command="logout" class="dropdown-item logout"> <el-dropdown-item divided command="logout" class="dropdown-item logout">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/> stroke-width="2">
<polyline points="16 17 21 12 16 7"/> <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<line x1="21" y1="12" x2="9" y2="12"/> <polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg> </svg>
<span>退出登录</span> <span>退出登录</span>
</el-dropdown-item> </el-dropdown-item>
@ -124,37 +134,37 @@
</template> </template>
<script setup> <script setup>
import { ElMessageBox } from "element-plus"; import { ElMessageBox } from "element-plus"
import { HomeFilled, Reading, Document, TrendCharts, DataLine } from "@element-plus/icons-vue"; import { HomeFilled, Reading, Document, TrendCharts, DataLine } from "@element-plus/icons-vue"
import Breadcrumb from "@/components/Breadcrumb"; import Breadcrumb from "@/components/Breadcrumb"
import TopNav from "@/components/TopNav"; import TopNav from "@/components/TopNav"
import Hamburger from "@/components/Hamburger"; import Hamburger from "@/components/Hamburger"
import Screenfull from "@/components/Screenfull"; import Screenfull from "@/components/Screenfull"
import SizeSelect from "@/components/SizeSelect"; import SizeSelect from "@/components/SizeSelect"
import HeaderSearch from "@/components/HeaderSearch"; import HeaderSearch from "@/components/HeaderSearch"
import RuoYiGit from "@/components/RuoYi/Git"; import RuoYiGit from "@/components/RuoYi/Git"
import RuoYiDoc from "@/components/RuoYi/Doc"; import RuoYiDoc from "@/components/RuoYi/Doc"
import ChangePasswordDialog from "@/views/profile/ChangePasswordDialog.vue"; import ChangePasswordDialog from "@/views/profile/ChangePasswordDialog.vue"
import useAppStore from "@/store/modules/app"; import useAppStore from "@/store/modules/app"
import useUserStore from "@/store/modules/user"; import useUserStore from "@/store/modules/user"
import useSettingsStore from "@/store/modules/settings"; import useSettingsStore from "@/store/modules/settings"
import { getCachedStudentTrainingTasks, loadStudentTrainingTasks } from "@/api/trainingTask"; import { getCachedStudentTrainingTasks, loadStudentTrainingTasks } from "@/api/trainingTask"
import { getComprehensiveTopTask } from "@/utils/studentTrainingNavigation"; import { getComprehensiveTopTask } from "@/utils/studentTrainingNavigation"
const { proxy } = getCurrentInstance(); const { proxy } = getCurrentInstance()
const router = useRouter(); const router = useRouter()
const appStore = useAppStore(); const appStore = useAppStore()
const userStore = useUserStore(); const userStore = useUserStore()
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore()
const role = computed(() => Number(userStore.userInfo.roleId)); const role = computed(() => Number(userStore.userInfo.roleId))
const roleText = computed(() => { const roleText = computed(() => {
const roleMap = { 1: '平台管理员', 2: '学校管理员', 3: '教师', 4: '学生' }; const roleMap = { 1: '平台管理员', 2: '学校管理员', 3: '教师', 4: '学生' }
return roleMap[role.value] || '用户'; return roleMap[role.value] || '用户'
}); })
const studentTopTask = ref(null); const studentTopTask = ref(null)
const changePasswordVisible = ref(false); const changePasswordVisible = ref(false)
const mentList = ref([ const mentList = ref([
{ name: "首页", path: "/index", icon: HomeFilled }, { name: "首页", path: "/index", icon: HomeFilled },
@ -179,23 +189,23 @@ const mentList = ref([
{ name: "学生管理", path: "/student/index", role: 3, teacherMenu: true, activePrefix: "/student" }, { name: "学生管理", path: "/student/index", role: 3, teacherMenu: true, activePrefix: "/student" },
{ name: "任务分配", path: "/task/index", role: 3, teacherMenu: true, activePrefix: "/task" }, { name: "任务分配", path: "/task/index", role: 3, teacherMenu: true, activePrefix: "/task" },
{ name: "成绩中心", path: "/score/index", role: 3, teacherMenu: true, activePrefix: "/score" }, { name: "成绩中心", path: "/score/index", role: 3, teacherMenu: true, activePrefix: "/score" },
]); ])
const activement = ref(mentList.value[0]); const activement = ref(mentList.value[0])
function toggleSideBar() { function toggleSideBar() {
appStore.toggleSideBar(); appStore.toggleSideBar()
} }
function handleCommand(command) { function handleCommand(command) {
switch (command) { switch (command) {
case "changePassword": case "changePassword":
changePasswordVisible.value = true; changePasswordVisible.value = true
break; break
case "logout": case "logout":
logout(); logout()
break; break
default: default:
break; break
} }
} }
@ -213,11 +223,11 @@ function onPasswordChanged() {
) )
.then(() => { .then(() => {
userStore.logOut().then(() => { userStore.logOut().then(() => {
location.href = "/index"; location.href = "/index"
}); })
}) })
.catch(() => {}); .catch(() => { })
}, 200); }, 200)
} }
function logout() { function logout() {
@ -228,71 +238,71 @@ function logout() {
}) })
.then(() => { .then(() => {
userStore.logOut().then(() => { userStore.logOut().then(() => {
location.href = "/index"; location.href = "/index"
}); })
}) })
.catch(() => {}); .catch(() => { })
} }
const go = (item) => { const go = (item) => {
if (router.currentRoute.value.path === item.path) { if (router.currentRoute.value.path === item.path) {
return; return
} }
activement.value = item; activement.value = item
router.push(item.path); router.push(item.path)
}; }
const isActive = (item) => { const isActive = (item) => {
return item.activePrefix ? router.currentRoute.value.path.startsWith(item.activePrefix) : item.path === router.currentRoute.value.path; return item.activePrefix ? router.currentRoute.value.path.startsWith(item.activePrefix) : item.path === router.currentRoute.value.path
}; }
const roleList = computed(() => { const roleList = computed(() => {
const currentRole = role.value; const currentRole = role.value
if (currentRole === 3) { if (currentRole === 3) {
const teacherOrder = ["/index", "/analysis/index", "/ai/teaching-plan"]; const teacherOrder = ["/index", "/analysis/index", "/ai/teaching-plan"]
return teacherOrder.map((path) => mentList.value.find((item) => item.path === path)).filter(Boolean); return teacherOrder.map((path) => mentList.value.find((item) => item.path === path)).filter(Boolean)
} }
const items = mentList.value.filter((item) => { const items = mentList.value.filter((item) => {
if (currentRole === 1) { if (currentRole === 1) {
return item.path === "/index" || item.role === 1; return item.path === "/index" || item.role === 1
} }
if (currentRole === 2) { if (currentRole === 2) {
return item.path === "/index"; return item.path === "/index"
} }
if (!item.role || item.path === "/index" || item.path === "/analysis/index") return true; if (!item.role || item.path === "/index" || item.path === "/analysis/index") return true
return item.role == currentRole; return item.role == currentRole
}); })
if (currentRole !== 4 || !studentTopTask.value) return items; if (currentRole !== 4 || !studentTopTask.value) return items
const homeIndex = items.findIndex((item) => item.path === "/index"); const homeIndex = items.findIndex((item) => item.path === "/index")
return [ return [
...items.slice(0, homeIndex + 1), ...items.slice(0, homeIndex + 1),
{ ...studentTopTask.value, name: studentTopTask.value.title, icon: Reading }, { ...studentTopTask.value, name: studentTopTask.value.title, icon: Reading },
...items.slice(homeIndex + 1), ...items.slice(homeIndex + 1),
]; ]
}); })
async function loadStudentTopTask() { async function loadStudentTopTask() {
if (role.value !== 4) { if (role.value !== 4) {
studentTopTask.value = null; studentTopTask.value = null
return; return
} }
const cachedTasks = getCachedStudentTrainingTasks(); const cachedTasks = getCachedStudentTrainingTasks()
if (cachedTasks.length) { if (cachedTasks.length) {
studentTopTask.value = getComprehensiveTopTask(cachedTasks); studentTopTask.value = getComprehensiveTopTask(cachedTasks)
} }
try { try {
const res = await loadStudentTrainingTasks(); const res = await loadStudentTrainingTasks()
studentTopTask.value = getComprehensiveTopTask(res.data || []); studentTopTask.value = getComprehensiveTopTask(res.data || [])
} catch (error) { } catch (error) {
if (!cachedTasks.length) { if (!cachedTasks.length) {
studentTopTask.value = null; studentTopTask.value = null
} }
} }
} }
onMounted(() => { onMounted(() => {
loadStudentTopTask(); loadStudentTopTask()
}); })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -606,7 +616,9 @@ onMounted(() => {
backdrop-filter: none; backdrop-filter: none;
// 蓝色光点装饰与极简白底不匹配,移除 // 蓝色光点装饰与极简白底不匹配,移除
.nav-bg-decoration { display: none; } .nav-bg-decoration {
display: none;
}
// 品牌区:去渐变去模糊,logo 描边收敛为教育蓝 // 品牌区:去渐变去模糊,logo 描边收敛为教育蓝
// 超管端(role=1)品牌区使用 .navTitle,教师端(role=3)使用 .TnavTitle,统一覆盖 // 超管端(role=1)品牌区使用 .navTitle,教师端(role=3)使用 .TnavTitle,统一覆盖
@ -619,8 +631,14 @@ onMounted(() => {
.logo-icon { .logo-icon {
filter: none; filter: none;
path, circle { stroke: #3b82f6; } path,
path:first-child { fill: rgba(59, 130, 246, 0.1); } circle {
stroke: #3b82f6;
}
path:first-child {
fill: rgba(59, 130, 246, 0.1);
}
} }
span, span,
@ -672,7 +690,9 @@ onMounted(() => {
background: #3b82f6; background: #3b82f6;
} }
&::before { display: none; } &::before {
display: none;
}
} }
// teacher-nav 定位规则保留,胶囊样式全部清除 // teacher-nav 定位规则保留,胶囊样式全部清除
@ -694,7 +714,9 @@ onMounted(() => {
border: 0; border: 0;
box-shadow: none; box-shadow: none;
.menu-indicator { background: #3b82f6; } .menu-indicator {
background: #3b82f6;
}
} }
} }
} }
@ -752,7 +774,9 @@ onMounted(() => {
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;
box-shadow: none; box-shadow: none;
.nav-bg-decoration { display: none; } .nav-bg-decoration {
display: none;
}
// 品牌区:去渐变去发光,logo 描边收敛为教育蓝 // 品牌区:去渐变去发光,logo 描边收敛为教育蓝
.navTitle { .navTitle {
@ -762,10 +786,14 @@ onMounted(() => {
.logo-icon { .logo-icon {
filter: none; filter: none;
path, circle { path,
circle {
stroke: #2563eb; stroke: #2563eb;
} }
path:first-child { fill: rgba(37, 99, 235, 0.1); }
path:first-child {
fill: rgba(37, 99, 235, 0.1);
}
} }
.nacber-name { .nacber-name {
@ -794,7 +822,10 @@ onMounted(() => {
background: #2563eb; background: #2563eb;
} }
.menu-ico { font-size: 15px; color: inherit; } .menu-ico {
font-size: 15px;
color: inherit;
}
&:hover { &:hover {
color: #1f2937; color: #1f2937;
@ -819,7 +850,9 @@ onMounted(() => {
background: #2563eb; background: #2563eb;
} }
&::before { display: none; } &::before {
display: none;
}
} }
} }
@ -841,7 +874,9 @@ onMounted(() => {
} }
} }
.divider { background: #e5e7eb; } .divider {
background: #e5e7eb;
}
.avatar-container .avatar-wrapper { .avatar-container .avatar-wrapper {
background: #ffffff; background: #ffffff;
@ -863,7 +898,9 @@ onMounted(() => {
} }
} }
.dropdown-icon { color: #6b7280; } .dropdown-icon {
color: #6b7280;
}
} }
} }
} }
@ -976,7 +1013,9 @@ onMounted(() => {
@media (max-width: 768px) { @media (max-width: 768px) {
.navbar { .navbar {
.navTitle, .TnavTitle {
.navTitle,
.TnavTitle {
.logo-area { .logo-area {
.nacber-name { .nacber-name {
font-size: 13px; font-size: 13px;
@ -1069,7 +1108,9 @@ onMounted(() => {
/* ===== 学生端用户下拉:白底墨黑 ===== */ /* ===== 学生端用户下拉:白底墨黑 ===== */
.student-dropdown-popper { .student-dropdown-popper {
.el-popper__arrow { display: none !important; } .el-popper__arrow {
display: none !important;
}
.dropdown-panel { .dropdown-panel {
margin: 0 !important; margin: 0 !important;
@ -1094,6 +1135,7 @@ onMounted(() => {
font-weight: 600; font-weight: 600;
letter-spacing: 0; letter-spacing: 0;
} }
.user-role { .user-role {
color: #6b7280 !important; color: #6b7280 !important;
font-size: var(--student-fs-caption); font-size: var(--student-fs-caption);
@ -1141,7 +1183,9 @@ onMounted(() => {
outline: none; outline: none;
transform: none !important; transform: none !important;
svg { color: #1f2937; } svg {
color: #1f2937;
}
} }
&.logout, &.logout,
@ -1150,14 +1194,19 @@ onMounted(() => {
&.logout:focus-visible { &.logout:focus-visible {
color: #d9554c !important; color: #d9554c !important;
svg { color: #d9554c; } svg {
color: #d9554c;
}
} }
&.logout:hover, &.logout:hover,
&.logout:focus, &.logout:focus,
&.logout:focus-visible { &.logout:focus-visible {
background: rgba(217, 85, 76, 0.08) !important; background: rgba(217, 85, 76, 0.08) !important;
svg { color: #c2413a; } svg {
color: #c2413a;
}
} }
} }
} }

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

File diff suppressed because it is too large Load Diff

@ -753,7 +753,7 @@ const onClick = (type, idx) => {
if (!type?.isTemplateBased) { if (!type?.isTemplateBased) {
let arr = ["课程知识点", "核心知识点", "最难的部分", "实务知识概述", "学习建议", "业务现状", "理论研究", "发展趋势"] let arr = ["课程知识点", "核心知识点", "最难的部分", "实务知识概述", "学习建议", "业务现状", "理论研究", "发展趋势"]
if (arr.includes(type.name)) { if (arr.includes(type.name)) {
inputMessage.value = type.com.replace("xxx", "电商互联网产品开发实训系统") inputMessage.value = type.com.replace("xxx", "互联网产品开发实训系统")
} else { } else {
inputMessage.value = type.com inputMessage.value = type.com
} }
@ -908,7 +908,7 @@ onUnmounted(() => {
if (resizeObserver) { if (resizeObserver) {
resizeObserver.disconnect() resizeObserver.disconnect()
} }
}); })
</script> </script>
<style lang="scss"> <style lang="scss">
.index { .index {

@ -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;
} }

@ -7,13 +7,13 @@
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
--> -->
<template> <template>
<div class="content">电商互联网产品开发实训系统</div> <div class="content">互联网产品开发实训系统</div>
</template> </template>
<script setup></script> <script setup></script>
<style lang="scss" scoped> <style lang="scss" scoped>
.content{ .content {
color: #fff; color: #fff;
} }
</style> </style>

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

@ -1,188 +1,728 @@
<template> <template>
<div class="login" v-loading="loadpage"> <div class="login" v-loading="loadpage">
<div class="login-main"> <div class="login-panel">
<!-- 左侧品牌区:科技数据看板 -->
<div class="brand-side">
<div class="grid-bg"></div>
<div class="scan-line"></div>
<div class="brand-deco deco-1"></div>
<div class="brand-deco deco-2"></div>
<div class="brand-deco deco-3"></div>
<div class="brand-content">
<div class="brand-badge anim" :style="{ animationDelay: '0s' }">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z" />
<path d="M3 6h18" />
<path d="M16 10a4 4 0 0 1-8 0" />
</svg>
<span>电商实训平台</span>
</div>
<h1 class="brand-title anim" :style="{ animationDelay: '0.08s' }">互联网产品<br />开发实训系统</h1>
<p class="brand-subtitle anim" :style="{ animationDelay: '0.16s' }">从市场需求到产品上线,完整还原电商产品全生命周期</p>
<!-- 实训全流程 -->
<div class="dash-card anim" :style="{ animationDelay: '0.24s' }">
<div class="dash-head">
<span class="live-dot"></span>
<span class="dash-title">实训全流程</span>
<span class="dash-tag">5 个阶段</span>
</div>
<div class="flow-row">
<template v-for="(step, i) in steps" :key="step.name">
<div class="flow-node" :class="{ active: i === 0 }">
<span class="node-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" v-html="step.icon"></svg>
</span>
<span class="node-label">{{ step.name }}</span>
<span class="node-index">STEP {{ i + 1 }}</span>
</div>
<div v-if="i < steps.length - 1" class="flow-link"></div>
</template>
</div>
<div class="module-grid">
<div v-for="mod in modules" :key="mod.name" class="module-item">
<span class="module-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" v-html="mod.icon"></svg>
</span>
<div>
<b>{{ mod.name }}</b>
<i>{{ mod.desc }}</i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧表单区 -->
<div class="form-side">
<div class="form-wrapper">
<el-form ref="loginRef" :model="loginForm" :rules="loginRules" class="login-form"> <el-form ref="loginRef" :model="loginForm" :rules="loginRules" class="login-form">
<h3 class="title">电商互联网产品开发实训系统</h3> <h3 class="title">欢迎回来</h3>
<p class="subtitle">请登录你的账号,继续今天的实训</p>
<el-form-item prop="username"> <el-form-item prop="username">
<el-input v-model="loginForm.username" type="text" size="large" autocomplete="off" placeholder="账号"> <el-input v-model="loginForm.username" type="text" size="large" autocomplete="off"
<template #prefix><svg-icon icon-class="user" class="el-input__icon input-icon" /></template> placeholder="账号">
<template #prefix><svg-icon icon-class="user"
class="el-input__icon input-icon" /></template>
</el-input> </el-input>
</el-form-item> </el-form-item>
<el-form-item prop="password"> <el-form-item prop="password">
<el-input v-model="loginForm.password" class="password-input" type="password" size="large" autocomplete="new-password" placeholder="密码" @keyup.enter="handleLogin" show-password> <el-input v-model="loginForm.password" class="password-input" type="password" size="large"
<template #prefix><svg-icon icon-class="password" class="el-input__icon input-icon" /></template> autocomplete="new-password" placeholder="密码" @keyup.enter="handleLogin" show-password>
<template #prefix><svg-icon icon-class="password"
class="el-input__icon input-icon" /></template>
</el-input> </el-input>
</el-form-item> </el-form-item>
<el-checkbox v-model="loginForm.rememberMe" style="margin: 0px 0px 25px 0px">记住密码</el-checkbox> <div class="form-options">
<el-checkbox v-model="loginForm.rememberMe">记住密码</el-checkbox>
<router-link v-if="register" class="link-type" :to="'/register'">立即注册</router-link>
</div>
<el-form-item style="width: 100%"> <el-form-item style="width: 100%">
<el-button :loading="loading" size="large" type="primary" style="width: 100%" @click.prevent="handleLogin"> <el-button :loading="loading" size="large" type="primary" class="login-btn"
@click.prevent="handleLogin">
<span v-if="!loading">登 录</span> <span v-if="!loading">登 录</span>
<span v-else>登 录 中...</span> <span v-else>登 录 中...</span>
</el-button> </el-button>
<div style="float: right" v-if="register">
<router-link class="link-type" :to="'/register'">立即注册</router-link>
</div>
</el-form-item> </el-form-item>
</el-form> </el-form>
</div> </div>
<!-- 底部 --> </div>
<div class="el-login-footer" style="display: none">
<span>Copyright © 2018-2023 ruoyi.vip All Rights Reserved.</span>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { getCodeImg } from "@/api/login"; import { getCodeImg } from "@/api/login"
import Cookies from "js-cookie"; import Cookies from "js-cookie"
import { encrypt, decrypt } from "@/utils/jsencrypt"; import { encrypt, decrypt } from "@/utils/jsencrypt"
import useUserStore from "@/store/modules/user"; import useUserStore from "@/store/modules/user"
import { getUserInfo } from "@/utils/auth"; import { getUserInfo } from "@/utils/auth"
const userStore = useUserStore(); const userStore = useUserStore()
const route = useRoute(); const route = useRoute()
const router = useRouter(); const router = useRouter()
const { proxy } = getCurrentInstance(); const { proxy } = getCurrentInstance()
const loginForm = ref({ const loginForm = ref({
username: "", username: "",
password: "", password: "",
rememberMe: false, rememberMe: false,
TOKEN: "", TOKEN: "",
}); })
const loginRules = { const loginRules = {
username: [{ required: true, trigger: "blur", message: "请输入您的账号" }], username: [{ required: true, trigger: "blur", message: "请输入您的账号" }],
password: [{ required: true, trigger: "blur", message: "请输入您的密码" }], password: [{ required: true, trigger: "blur", message: "请输入您的密码" }],
code: [{ required: true, trigger: "change", message: "请输入验证码" }], code: [{ required: true, trigger: "change", message: "请输入验证码" }],
}; }
const loading = ref(false); const loading = ref(false)
const loadpage = ref(false); const loadpage = ref(false)
// 验证码开关 // 验证码开关
const captchaEnabled = ref(true); const captchaEnabled = ref(true)
// 注册开关 // 注册开关
const register = ref(false); const register = ref(false)
const redirect = ref(undefined); const redirect = ref(undefined)
watch( watch(
route, route,
(newRoute) => { (newRoute) => {
if (newRoute.query.token) { if (newRoute.query.token) {
loadpage.value = true; loadpage.value = true
setTimeout(() => { setTimeout(() => {
loginForm.value.TOKEN = newRoute.query.token; loginForm.value.TOKEN = newRoute.query.token
return handleLogin(); return handleLogin()
}, 1000); }, 1000)
} }
}, },
{ immediate: true } { immediate: true }
); )
function handleLogin() { function handleLogin() {
// proxy.$refs.loginRef.validate(valid => { // proxy.$refs.loginRef.validate(valid => {
// if (valid) { // if (valid) {
loading.value = true; loading.value = true
loadpage.value = true; loadpage.value = true
// 勾选了需要记住密码设置在 cookie 中设置记住用户名和密码 // 勾选了需要记住密码设置在 cookie 中设置记住用户名和密码
if (loginForm.value.rememberMe) { if (loginForm.value.rememberMe) {
Cookies.set("username", loginForm.value.username, { expires: 30 }); Cookies.set("username", loginForm.value.username, { expires: 30 })
Cookies.set("password", encrypt(loginForm.value.password), { expires: 30 }); Cookies.set("password", encrypt(loginForm.value.password), { expires: 30 })
Cookies.set("rememberMe", loginForm.value.rememberMe, { expires: 30 }); Cookies.set("rememberMe", loginForm.value.rememberMe, { expires: 30 })
} else { } else {
// 否则移除 // 否则移除
Cookies.remove("username"); Cookies.remove("username")
Cookies.remove("password"); Cookies.remove("password")
Cookies.remove("rememberMe"); Cookies.remove("rememberMe")
} }
//登录 //登录
const params = { const params = {
userName: loginForm.value.username, userName: loginForm.value.username,
passwordEncode: loginForm.value.password !== "" ? encrypt(loginForm.value.password) : loginForm.value.password, passwordEncode: loginForm.value.password !== "" ? encrypt(loginForm.value.password) : loginForm.value.password,
TOKEN: loginForm.value.token, TOKEN: loginForm.value.token,
}; }
if (loginForm.value.TOKEN !== "") { if (loginForm.value.TOKEN !== "") {
params.token = loginForm.value.TOKEN; params.token = loginForm.value.TOKEN
} }
userStore userStore
.login(params) .login(params)
.then((res) => { .then((res) => {
if (params.token) { if (params.token) {
setTimeout(() => { setTimeout(() => {
loading.value = false; loading.value = false
loadpage.value = false; loadpage.value = false
router.push({ path: redirect.value || "/index" }); router.push({ path: redirect.value || "/index" })
}, 2000); }, 2000)
} else { } else {
loading.value = false; loading.value = false
router.push({ path: redirect.value || "/index" }); router.push({ path: redirect.value || "/index" })
} }
}) })
.catch(() => { .catch(() => {
loadpage.value = false; loadpage.value = false
loading.value = false; loading.value = false
}); })
} }
function getCookie() { function getCookie() {
const username = Cookies.get("username"); const username = Cookies.get("username")
const password = Cookies.get("password"); const password = Cookies.get("password")
const rememberMe = Cookies.get("rememberMe"); const rememberMe = Cookies.get("rememberMe")
loginForm.value = { loginForm.value = {
username: username === undefined ? loginForm.value.username : username, username: username === undefined ? loginForm.value.username : username,
password: password === undefined ? loginForm.value.password : decrypt(password), password: password === undefined ? loginForm.value.password : decrypt(password),
rememberMe: rememberMe === undefined ? false : Boolean(rememberMe), rememberMe: rememberMe === undefined ? false : Boolean(rememberMe),
}; }
} }
// getCode(); // getCode();
getCookie(); getCookie()
/* ---------------- 实训流程与模块(真实课程内容,非模拟数据) ---------------- */
const steps = [
{
name: "市场需求分析",
icon: '<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />',
},
{
name: "产品设计",
icon: '<path d="M12 19l7-7 3 3-7 7-3-3z" /><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z" /><path d="M2 2l7.586 7.586" /><circle cx="11" cy="11" r="2" />',
},
{
name: "概念测试",
icon: '<path d="M9 18h6" /><path d="M10 22h4" /><path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.4 1 2.3h6c0-.9.4-1.8 1-2.3A7 7 0 0 0 12 2z" />',
},
{
name: "概念评估",
icon: '<path d="M3 3v18h18" /><path d="m19 9-5 5-4-4-3 3" />',
},
{
name: "渠道运营",
icon: '<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z" /><path d="M3 6h18" /><path d="M16 10a4 4 0 0 1-8 0" />',
},
]
const modules = [
{
name: "市场需求分析",
desc: "洞察用户真实诉求,找准产品定位",
icon: '<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />',
},
{
name: "产品设计",
desc: "从概念到原型,打磨产品细节",
icon: '<path d="M12 19l7-7 3 3-7 7-3-3z" /><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z" /><path d="M2 2l7.586 7.586" /><circle cx="11" cy="11" r="2" />',
},
{
name: "渠道运营",
desc: "多渠道布局,掌握增长方法论",
icon: '<path d="M3 3v18h18" /><path d="m19 9-5 5-4-4-3 3" />',
},
{
name: "概念测试评估",
desc: "数据驱动决策,验证产品价值",
icon: '<path d="M9 18h6" /><path d="M10 22h4" /><path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.4 1 2.3h6c0-.9.4-1.8 1-2.3A7 7 0 0 0 12 2z" />',
},
]
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.login { .login {
height: 100%;
min-height: 100vh;
background: #f4f7fe;
}
.login-panel {
display: flex;
min-height: 100vh;
}
/* ---------- 入场动画 ---------- */
@keyframes riseIn {
from {
opacity: 0;
transform: translateY(22px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.anim {
animation: riseIn 0.7s cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* ---------- 左侧品牌区 ---------- */
.brand-side {
position: relative;
flex: 1 1 55%;
display: flex; display: flex;
justify-content: center;
align-items: center; align-items: center;
height: 100%; overflow: hidden;
background-image: url("@/assets/images/login-background.0dded3b4.webp"); padding: 52px 68px;
background-size: 100% 100%; background: linear-gradient(135deg, #0b1a4d 0%, #16306f 45%, #2447c4 100%);
color: #fff;
} }
.title {
margin: 0px auto 30px auto; /* 流动网格 */
.grid-bg {
position: absolute;
inset: -60px;
background-image:
linear-gradient(rgba(255, 255, 255, 0.055) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.055) 1px, transparent 1px);
background-size: 46px 46px;
animation: gridMove 18s linear infinite;
mask-image: radial-gradient(circle at 30% 40%, #000 0%, transparent 78%);
-webkit-mask-image: radial-gradient(circle at 30% 40%, #000 0%, transparent 78%);
}
@keyframes gridMove {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(46px, 46px);
}
}
/* 扫描线 */
.scan-line {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 180px;
background: linear-gradient(180deg, rgba(125, 211, 252, 0) 0%, rgba(125, 211, 252, 0.16) 60%, rgba(125, 211, 252, 0) 100%);
animation: scan 7s ease-in-out infinite;
pointer-events: none;
}
@keyframes scan {
0% {
transform: translateY(-200px);
opacity: 0;
}
12% {
opacity: 1;
}
88% {
opacity: 1;
}
100% {
transform: translateY(100vh);
opacity: 0;
}
}
.brand-deco {
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.deco-1 {
width: 520px;
height: 520px;
right: -180px;
top: -190px;
background: radial-gradient(circle at 30% 30%, rgba(56, 189, 248, 0.4), rgba(56, 189, 248, 0) 70%);
animation: breathe 9s ease-in-out infinite;
}
.deco-2 {
width: 420px;
height: 420px;
left: -150px;
bottom: -160px;
background: radial-gradient(circle at 60% 40%, rgba(167, 139, 250, 0.34), rgba(167, 139, 250, 0) 70%);
animation: breathe 11s ease-in-out infinite reverse;
}
.deco-3 {
width: 180px;
height: 180px;
right: 10%;
bottom: 10%;
border: 1px solid rgba(125, 211, 252, 0.22);
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(2px);
}
@keyframes breathe {
0%,
100% {
transform: scale(1);
opacity: 0.75;
}
50% {
transform: scale(1.12);
opacity: 1;
}
}
.brand-content {
position: relative;
z-index: 1;
width: 100%;
max-width: 600px;
}
.brand-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 16px;
border-radius: 999px;
background: rgba(125, 211, 252, 0.12);
border: 1px solid rgba(125, 211, 252, 0.28);
font-size: 13px;
letter-spacing: 2px;
svg {
width: 18px;
height: 18px;
}
}
.brand-title {
margin: 24px 0 14px;
font-size: 34px;
line-height: 1.35;
font-weight: 600;
letter-spacing: 1px;
text-shadow: 0 0 34px rgba(125, 211, 252, 0.35);
}
.brand-subtitle {
margin: 0 0 30px;
font-size: 15px;
line-height: 1.8;
color: rgba(255, 255, 255, 0.68);
}
/* ---------- 数据看板卡片 ---------- */
.dash-card {
padding: 20px 22px 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.13);
backdrop-filter: blur(14px);
box-shadow: 0 18px 44px -22px rgba(0, 0, 0, 0.65);
animation: riseIn 0.7s cubic-bezier(0.22, 1, 0.36, 1) both, cardGlow 6s ease-in-out infinite;
}
/* 只做光影呼吸,不碰 transform,避免与入场动画冲突 */
@keyframes cardGlow {
0%,
100% {
box-shadow: 0 18px 44px -22px rgba(0, 0, 0, 0.65);
border-color: rgba(255, 255, 255, 0.13);
}
50% {
box-shadow: 0 18px 44px -22px rgba(0, 0, 0, 0.65), 0 0 34px -8px rgba(125, 211, 252, 0.42);
border-color: rgba(125, 211, 252, 0.32);
}
}
.dash-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 18px;
}
.live-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #4ade80;
box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.6);
animation: livePulse 1.8s ease-out infinite;
}
@keyframes livePulse {
0% {
box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.55);
}
70% {
box-shadow: 0 0 0 9px rgba(74, 222, 128, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(74, 222, 128, 0);
}
}
.dash-title {
font-size: 13.5px;
letter-spacing: 1px;
color: rgba(255, 255, 255, 0.88);
}
.dash-tag {
margin-left: auto;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
letter-spacing: 1px;
color: #7dd3fc;
background: rgba(125, 211, 252, 0.14);
border: 1px solid rgba(125, 211, 252, 0.25);
}
/* 流程节点行 */
.flow-row {
display: flex;
align-items: stretch;
gap: 4px;
padding: 14px 6px 16px;
border-radius: 11px;
background: rgba(9, 20, 56, 0.42);
border: 1px solid rgba(125, 211, 252, 0.14);
margin-bottom: 16px;
}
.flow-node {
flex: none;
display: flex;
flex-direction: column;
align-items: center;
gap: 7px;
width: 76px;
}
.node-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 50%;
color: #7dd3fc;
background: rgba(125, 211, 252, 0.1);
border: 1px solid rgba(125, 211, 252, 0.32);
transition: box-shadow 0.3s ease;
svg {
width: 20px;
height: 20px;
}
}
.flow-node.active .node-icon {
color: #e8f4ff;
background: linear-gradient(135deg, #38bdf8 0%, #6366f1 100%);
border-color: transparent;
animation: nodeGlow 2.4s ease-in-out infinite;
}
@keyframes nodeGlow {
0%,
100% {
box-shadow: 0 0 0 0 rgba(125, 211, 252, 0.45);
}
50% {
box-shadow: 0 0 0 7px rgba(125, 211, 252, 0);
}
}
.node-label {
font-size: 12px;
line-height: 1.3;
color: rgba(255, 255, 255, 0.82);
text-align: center; text-align: center;
color: rgb(48, 50, 52);
font-size: var(--student-fs-h2);
font-weight: 500;
} }
.login-main {
width: 85%; .node-index {
height: 100%; font-size: 10px;
letter-spacing: 1px;
color: rgba(125, 211, 252, 0.6);
}
.flow-node.active .node-label {
color: #ffffff;
font-weight: 600;
}
/* 流动连接线 */
.flow-link {
flex: 1;
align-self: flex-start;
margin-top: 20px;
height: 2px;
border-radius: 2px;
background: repeating-linear-gradient(90deg, rgba(125, 211, 252, 0.65) 0 7px, transparent 7px 14px);
background-size: 14px 2px;
animation: dashFlow 1.1s linear infinite;
}
@keyframes dashFlow {
to {
background-position: 14px 0;
}
}
/* 模块网格 */
.module-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.module-item {
display: flex; display: flex;
align-items: flex-start;
gap: 11px;
padding: 13px 14px;
border-radius: 11px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
transition: background 0.25s ease, border-color 0.25s ease;
&:hover {
background: rgba(125, 211, 252, 0.1);
border-color: rgba(125, 211, 252, 0.3);
}
b {
display: block;
font-size: 14px;
font-weight: 600;
margin-bottom: 3px;
}
i {
font-style: normal;
font-size: 12px;
color: rgba(255, 255, 255, 0.55);
line-height: 1.5;
}
}
.module-icon {
flex: none;
display: inline-flex;
align-items: center; align-items: center;
justify-content: end; justify-content: center;
width: 34px;
height: 34px;
border-radius: 9px;
color: #7dd3fc;
background: rgba(125, 211, 252, 0.12);
border: 1px solid rgba(125, 211, 252, 0.22);
svg {
width: 17px;
height: 17px;
}
} }
.login-form {
border-radius: 6px; /* ---------- 右侧表单区 ---------- */
.form-side {
flex: 1 1 45%;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff; background: #ffffff;
width: 400px; padding: 40px 32px;
padding: 55px 25px 33px 25px; }
.form-wrapper {
width: 100%;
max-width: 390px;
animation: riseIn 0.8s cubic-bezier(0.22, 1, 0.36, 1) 0.15s both;
}
.title {
margin: 0;
color: #1d2129;
font-size: 26px;
font-weight: 600;
}
.subtitle {
margin: 10px 0 34px;
font-size: 14px;
color: #86909c;
}
.login-form {
width: 100%;
.el-form-item {
margin-bottom: 22px;
}
.el-input { .el-input {
height: 40px; height: 46px;
input { input {
height: 40px; height: 46px;
} }
} }
:deep(.el-input__wrapper) { :deep(.el-input__wrapper) {
border-radius: 10px;
background-color: #f5f7fa !important;
box-shadow: 0 0 0 1px #f5f7fa inset;
padding-left: 14px;
transition: box-shadow 0.25s ease, background-color 0.25s ease;
&:hover {
background-color: #f0f2f7 !important;
box-shadow: 0 0 0 1px #dcdfe6 inset;
}
&.is-focus {
background-color: #ffffff !important; background-color: #ffffff !important;
box-shadow: 0 0 0 2px #2b5aed inset, 0 0 0 4px rgba(43, 90, 237, 0.12) !important;
}
} }
:deep(.el-input__inner), :deep(.el-input__inner),
:deep(input) { :deep(input) {
color: #303133; color: #1d2129;
background-color: transparent !important; background-color: transparent !important;
-webkit-text-fill-color: #303133; -webkit-text-fill-color: #1d2129;
} }
:deep(.el-input__inner::placeholder), :deep(.el-input__inner::placeholder),
:deep(input::placeholder) { :deep(input::placeholder) {
color: #b8c0cc; color: #a8abb2;
-webkit-text-fill-color: #b8c0cc; -webkit-text-fill-color: #a8abb2;
opacity: 1; opacity: 1;
} }
@ -194,9 +734,9 @@ getCookie();
:deep(input:-webkit-autofill:hover), :deep(input:-webkit-autofill:hover),
:deep(input:-webkit-autofill:focus), :deep(input:-webkit-autofill:focus),
:deep(input:-webkit-autofill:active) { :deep(input:-webkit-autofill:active) {
caret-color: #303133 !important; caret-color: #1d2129 !important;
-webkit-text-fill-color: #303133 !important; -webkit-text-fill-color: #1d2129 !important;
-webkit-box-shadow: 0 0 0 1000px #ffffff inset !important; -webkit-box-shadow: 0 0 0 1000px #f5f7fa inset !important;
transition: background-color 9999s ease-out 0s !important; transition: background-color 9999s ease-out 0s !important;
} }
@ -204,36 +744,114 @@ getCookie();
height: 39px; height: 39px;
width: 14px; width: 14px;
margin-left: 0px; margin-left: 0px;
color: #86909c;
} }
} }
.login-tip {
font-size: var(--student-fs-body-sm); .form-options {
text-align: center; display: flex;
color: #bfbfbf; align-items: center;
} justify-content: space-between;
.login-code { margin: 0 0 22px;
width: 33%;
height: 40px; .link-type {
float: right; font-size: 13px;
img { color: #2b5aed;
cursor: pointer; text-decoration: none;
vertical-align: middle;
} &:hover {
} text-decoration: underline;
.el-login-footer { }
height: 40px; }
line-height: 40px; }
position: fixed;
bottom: 0; .login-btn {
position: relative;
overflow: hidden;
width: 100%; width: 100%;
text-align: center; height: 46px;
color: #fff; border: none;
font-family: var(--student-font-base); border-radius: 10px;
font-size: var(--student-fs-caption); font-size: 16px;
letter-spacing: 1px; letter-spacing: 6px;
background: linear-gradient(135deg, #1e3fae 0%, #2b5aed 55%, #5b7cf5 100%);
box-shadow: 0 8px 22px -6px rgba(43, 90, 237, 0.55);
transition: transform 0.2s ease, box-shadow 0.2s ease;
&::after {
content: "";
position: absolute;
top: 0;
left: -60%;
width: 40%;
height: 100%;
background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.32) 50%, rgba(255, 255, 255, 0) 100%);
transform: skewX(-20deg);
animation: shine 3.6s ease-in-out infinite;
}
&:hover {
transform: translateY(-1px);
box-shadow: 0 12px 26px -6px rgba(43, 90, 237, 0.6);
}
&:active {
transform: translateY(0);
}
}
@keyframes shine {
0%,
60% {
left: -60%;
}
100% {
left: 130%;
}
}
/* ---------- 响应式 ---------- */
@media (max-width: 1200px) {
.brand-side {
padding: 40px 44px;
}
.brand-title {
font-size: 29px;
}
.flow-node {
width: 62px;
}
.node-label {
font-size: 11px;
}
.module-grid {
grid-template-columns: 1fr;
}
} }
.login-code-img {
height: 40px; @media (max-width: 900px) {
padding-left: 12px; .brand-side {
display: none;
}
.form-side {
flex: 1 1 100%;
}
}
@media (max-width: 480px) {
.form-side {
padding: 24px 18px;
}
.title {
font-size: 23px;
}
} }
</style> </style>

@ -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>

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

@ -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>
@ -157,6 +157,10 @@
<el-form-item label="班级名称" prop="className"> <el-form-item label="班级名称" prop="className">
<el-input v-model="formInline.className" placeholder="请输入班级名称" class="form-control" /> <el-input v-model="formInline.className" placeholder="请输入班级名称" class="form-control" />
</el-form-item> </el-form-item>
<!-- 2026-10-12 停用:新增班级弹窗的「名单创建」(导入名单创建 + 模板下载)。
教学班改为通过「来源行政班」+ 班级名称直接创建。恢复时打开下面注释即可,
并同步启用 importCreateUploadData / beforeImportCreate / uploadImportCreate /
handleImportCreateSuccess / downloadTeachingClassImportTemplate。
<el-form-item v-if="activeClassType === 'TEACHING' && isCreate" label="名单创建"> <el-form-item v-if="activeClassType === 'TEACHING' && isCreate" label="名单创建">
<div class="import-create-actions"> <div class="import-create-actions">
<el-upload action="/api/user/uploadTeachingClassByStudentMembers" :data="importCreateUploadData" <el-upload action="/api/user/uploadTeachingClassByStudentMembers" :data="importCreateUploadData"
@ -177,6 +181,7 @@
</el-button> </el-button>
</div> </div>
</el-form-item> </el-form-item>
-->
</el-form> </el-form>
<template #footer> <template #footer>
<span class="dialog-footers"> <span class="dialog-footers">
@ -362,6 +367,7 @@ const adminClassOptions = computed(() => {
}) })
}) })
/* 2026-10-12 停用:名单创建上传参数(配合弹窗「名单创建」一起下线)
const importCreateUploadData = computed(() => ({ const importCreateUploadData = computed(() => ({
schoolId: currentSchoolId.value, schoolId: currentSchoolId.value,
schoolFacultyId: formInline.value.schoolFacultyId, schoolFacultyId: formInline.value.schoolFacultyId,
@ -370,6 +376,7 @@ const importCreateUploadData = computed(() => ({
operatorId: currentUserId.value, operatorId: currentUserId.value,
sourceClassIds: sourceAdminClassIds.value.join(","), sourceClassIds: sourceAdminClassIds.value.join(","),
})) }))
*/
const memberUploadData = computed(() => ({ const memberUploadData = computed(() => ({
teachingClassId: selectedTeachingClass.value?.schoolClassId || "", teachingClassId: selectedTeachingClass.value?.schoolClassId || "",
@ -457,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()
@ -545,6 +558,7 @@ function submitForm() {
}) })
} }
/* 2026-10-12 停用:名单创建导入(导入名单创建按钮的配套逻辑,仅该按钮使用)
async function beforeImportCreate(file) { async function beforeImportCreate(file) {
if (activeClassType.value !== "TEACHING") { if (activeClassType.value !== "TEACHING") {
proxy.$message.warning("请切换到教学班") proxy.$message.warning("请切换到教学班")
@ -611,6 +625,7 @@ function handleImportCreateSuccess(res) {
getList() getList()
refreshAllClassList() refreshAllClassList()
} }
*/
function openMemberDialog(schoolClass) { function openMemberDialog(schoolClass) {
selectedTeachingClass.value = schoolClass selectedTeachingClass.value = schoolClass
@ -767,6 +782,7 @@ async function downloadMemberImportTemplate() {
XLSX.writeFile(workbook, "教学班成员导入模板.xlsx") XLSX.writeFile(workbook, "教学班成员导入模板.xlsx")
} }
/* 2026-10-12 停用:名单创建模板下载(仅新增班级弹窗「模板下载」按钮使用)
async function downloadTeachingClassImportTemplate() { async function downloadTeachingClassImportTemplate() {
const XLSX = await import("xlsx") const XLSX = await import("xlsx")
const templateSheet = XLSX.utils.aoa_to_sheet([ const templateSheet = XLSX.utils.aoa_to_sheet([
@ -781,6 +797,7 @@ async function downloadTeachingClassImportTemplate() {
XLSX.utils.book_append_sheet(workbook, guideSheet, "填写说明") XLSX.utils.book_append_sheet(workbook, guideSheet, "填写说明")
XLSX.writeFile(workbook, "教学班名单创建模板.xlsx") XLSX.writeFile(workbook, "教学班名单创建模板.xlsx")
} }
*/
function deleteClass(id) { function deleteClass(id) {
ElMessageBox.confirm("确定删除该班级吗?", "提示", { ElMessageBox.confirm("确定删除该班级吗?", "提示", {

@ -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>
@ -143,6 +143,8 @@
@close="onClose"> @close="onClose">
<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="rules"> style="width: 80%" :rules="rules">
<!-- 2026-10-12 停用:所属院系 / 所属专业的下拉选择(新增教师不再选择院系与专业)。
原始代码保留备用;恢复时打开下面这段注释,并删掉其后两个只读 el-form-item。
<el-form-item v-if="status || isOrganizationManagementEnabled" label="所属院系:" prop="schoolFacultyId"> <el-form-item v-if="status || isOrganizationManagementEnabled" label="所属院系:" prop="schoolFacultyId">
<el-select v-model="formInline.schoolFacultyId" placeholder="" clearable @change="getMajorList" <el-select v-model="formInline.schoolFacultyId" placeholder="" clearable @change="getMajorList"
style="width: 20rem" v-if="status"> style="width: 20rem" v-if="status">
@ -160,6 +162,14 @@
<el-input v-else v-model="classInfo.value" placeholder="请输入所属专业:" style="width: 20rem" <el-input v-else v-model="classInfo.value" placeholder="请输入所属专业:" style="width: 20rem"
disabled></el-input> disabled></el-input>
</el-form-item> </el-form-item>
-->
<!-- 新增教师(status=true)不再展示院系/专业;编辑教师(status=false)仍保留只读展示 -->
<el-form-item v-if="!status && isOrganizationManagementEnabled" label="所属院系:" prop="schoolFacultyId">
<el-input v-model="classInfo.name" style="width: 20rem" disabled></el-input>
</el-form-item>
<el-form-item v-if="!status && isOrganizationManagementEnabled" label="所属专业:" prop="schoolMajorId">
<el-input v-model="classInfo.value" style="width: 20rem" disabled></el-input>
</el-form-item>
<el-form-item label="教师姓名:" prop="name"> <el-form-item label="教师姓名:" prop="name">
<el-input v-model="formInline.name" style="width: 20rem" placeholder=""></el-input> <el-input v-model="formInline.name" style="width: 20rem" placeholder=""></el-input>
</el-form-item> </el-form-item>
@ -236,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" }],
})) }))
@ -258,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) => {
@ -285,7 +301,9 @@ const editSchool = (row) => {
}) })
} }
const add = () => { const add = () => {
formInline.value.schoolMajorName = majorList.value?.filter((item) => item.schoolMajorId == formInline.value.schoolMajorId)[0].schoolMajorName // 院系/专业下拉停用后 schoolMajorId 可能为空,取不到专业名时不能抛错
const matchedMajor = (majorList.value || []).find((item) => item.schoolMajorId == formInline.value.schoolMajorId)
if (matchedMajor) formInline.value.schoolMajorName = matchedMajor.schoolMajorName
formInline.value.operatorId = currentUserId.value formInline.value.operatorId = currentUserId.value
indexApi.addTeacher(formInline.value).then((res) => { indexApi.addTeacher(formInline.value).then((res) => {
proxy.$message.success("添加成功") proxy.$message.success("添加成功")
@ -376,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 },
@ -391,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));

@ -6,10 +6,40 @@ const root = resolve(import.meta.dirname, "..");
const sidebar = readFileSync(resolve(root, "src/layout/components/Sidebar/index.vue"), "utf8"); const sidebar = readFileSync(resolve(root, "src/layout/components/Sidebar/index.vue"), "utf8");
const styles = readFileSync(resolve(root, "src/assets/styles/sidebar.scss"), "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, /toggleSideBar\(/, "收起按钮应复用全局侧栏状态");
assert.match(sidebar, /isCollapse/, "收起状态应同步到教师侧栏样式"); assert.match(sidebar, /isCollapse/, "收起状态应同步到侧栏样式");
assert.match(styles, /&\.hideSidebar/, "教师侧栏收起时应缩小侧栏并回收内容区空间"); assert.match(styles, /&\.hideSidebar/, "侧栏收起时应缩小侧栏并回收内容区空间");
assert.match(styles, /\.teacher-sidebar-toggle/, "收起按钮应有教师端专属样式"); 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('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