Compare commits

...

19 Commits

Author SHA1 Message Date
qinzhenpen 39113aa091 fix(task-review): 实训任务下拉首次加载按实训项目联动过滤
现象:进入任务评阅页、默认选中教学班后,「实训项目」已显示某项目,
但「实训任务」下拉列出该教学班全部任务;手动重选一次实训项目才恢复。

根因:首次进入/切换班级时的首个请求不带 projectName,后端在 projectName 为空时
不做项目过滤、返回全量任务;normalizeReview 随后用 selectedTask 回填了
filters.projectName,但没有因此重新请求,任务下拉仍停留在全量数据上。

修法:loadReview 记录本次请求是否带了项目;若未带且响应后回填出了 projectName,
则按该项目补查一次 getTaskReview。常规分支只替换 review.tasks 与 projects,
不整体 normalize —— selectedTask 的对象引用被预览 watcher 监听,整体替换会白白
重建一次右侧学生页预览 iframe(预览页会真实发起接口请求)。补查结果不含当前
taskId 时整体刷新兜底;结果为空时保留首份响应。补查请求已带 projectName,
不会再触发补查,幂等无死循环。

影响面:仅首次进入/切换班级多一次接口调用,其余选择路径请求次数不变。
1 day ago
qinzhenpen 0e237e5cdb Merge branch 'dev-QQq' of http://118.31.7.2:3000/chenyuan1/dianshang-qianduan into dev-QQq 1 day ago
qinzhenpen 511abe73b9 fix(task-review): 评阅预览改用最新学生页并修复新版样式门控失配
1. previewMap 三处指向已废弃的旧页面,改为 router 实际引用的最新页面:
   industry-demand-analysis      -> demand/industry-demand-analysis.vue
   competitive-environment-analysis -> demand/competitive-environment-analysis.vue
   market-opportunity-selection  -> product/market-opportunity-selection.vue
   并在文件头补充契约注释(必须与 router 一致,不得指向废弃页面)。

2. 修复评阅预览"看起来仍在用废弃页面"的根因,两重原因叠加:
   a) /task-review-preview 是脱离 Layout 的独立路由,DOM 里没有 .studentLayout
      与 section.app-main,导致学生页新版皮肤的门控选择器
      `#app .studentLayout section.app-main:has(.xxx-page) …` 集体失配,
      页面静默回落全局旧版样式(胶囊满宽、步骤说明小字重新出现、按钮配色不一致)。
      模板外层补两级空壳祖先容器,只补壳、不渲染顶栏侧栏。
   b) 预览 iframe 宽约 1000px,命中 student-training.scss 的
      @media (max-width: 1100px):`#app .student-training-shell .step-tab
      { width: 100% }`(特异性 1,2,0)压过预览自身 :deep() 覆盖(0,4,0),
      步骤胶囊被撑成整行、只剩第一个可见并出现横向滚动条。
      新增非 scoped 样式用更高特异性复位宽度,并压掉布局级背景与内边距。

实测(真实 Chrome,1000px 视口,与预览 iframe 同宽):
修复前 胶囊宽 952px x4、导航区 scrollWidth 3874/952(溢出);
修复后 214/169/199/200px、952/952(无溢出),与学生端外观一致。
1 day ago
qinzhenpen 5f01ee20cb style: adjust media query breakpoint for student training styles
- Changed the media query breakpoint from 1100px to 800px to improve responsiveness of the student training shell on narrower screens.
- Ensured that the AI floating panel remains fixed and adapts its width appropriately on smaller devices.
1 day ago
qinzhenpen 71ac33be70 style: synchronize sidebar toggle behavior and improve menu visibility
- Updated sidebar styles to ensure the collapse toggle remains visible across all student and teacher roles, removing conditional rendering.
- Enhanced specificity of styles to synchronize the toggle arrow and menu visibility with the sidebar's expanded/collapsed state.
- Added logic to force the menu to display correctly when the sidebar is expanded, addressing previous inconsistencies.
1 day ago
qinzhenpen 5bf2ba612d Merge remote-tracking branch 'origin/master' into dev-QQq 1 day ago
qinzhenpen e1da3bd6a2 style: update sidebar styles for student layout in collapsed state
- Added specific styles for the student layout sidebar when collapsed, ensuring it reduces to 64px width.
- Addressed specificity issues with existing styles to prevent layout conflicts in non-mobile views.
1 day ago
chenyuan ae64fb3465 fix: BI可视化分析改走/bi/同机反代修复跨域丢Cookie弹登录;AI助学/助评前置校验提示中文化;教师端实训管理文案与搜索翻页对齐管理端 1 day ago
chenyuan ab6c9cdc7b merge: sync dev-QQq into master 3 days ago
qinzhenpen c3b1e99267 Merge branch 'dev-QQq' of http://118.31.7.2:3000/chenyuan1/dianshang-qianduan into dev-QQq 3 days ago
qinzhenpen 4b20ea3d99 refactor: 统一项目名称为互联网产品开发实训系统并优化代码格式
- 替换所有文件中的项目名称“电商互联网产品开发实训系统”为“互联网产品开发实训系统”
- 优化多个文件的代码换行格式,提升可读
- 停用教师端和班级管理中院系/专业相关的旧逻辑
- 设计登录页ui
3 days ago
chenyuan d8c4e40bef fix: 大数据表格列改用tableColumns并为content列增加宽度与溢出提示 5 days ago
chenyuan 4419a3cfd1 merge: sync dev-QQq into master 5 days ago
qinzhenpen 89d08986cf feat: 重设计 AI 学习助手悬浮按钮并添加滚动隐藏与扇形展开交互 5 days ago
chenyuan d4d7a1c033 merge: sync dev-QQq into master 6 days ago
qinzhenpen e175e3e4f9 修改考试时长最大值限制问题 6 days ago
chenyuan a8425d74bd merge: sync dev-QQq into master 6 days ago
qinzhenpen 623771ec6b Merge remote-tracking branch 'origin/fix/exam-question-access-material-excel-delete-draft' into dev-QQq 6 days ago
qinzhenpen 2b640c32f5 feat(考试系统): 增加题目来源筛选、侧边栏变量、题目排序及分页功能 6 days ago

@ -7,7 +7,7 @@
# @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'

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

@ -1,5 +1,5 @@
# 页面标题
VITE_APP_TITLE = 电商互联网产品开发实训系统
VITE_APP_TITLE = 互联网产品开发实训系统
# 生产环境配置
VITE_APP_ENV = 'staging'
@ -8,4 +8,4 @@ VITE_APP_ENV = 'staging'
VITE_APP_BASE_API = '/stage-api'
# 是否在打包时开启压缩,支持 gzip 和 brotli
VITE_BUILD_COMPRESS = gzip
VITE_BUILD_COMPRESS = gzip

1
.gitignore vendored

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

@ -0,0 +1,17 @@
# 2026-10-11
## AI 学习助手悬浮按钮重设计
- 文件:`src/views/components/TrainingAiSidebar.vue`(收起态 `.ai-fab`)
- 原状:50px 纯图标圆形蓝球(MagicStick 图标),不够显眼
- 新设计:3D 黏土风 SVG 小助手(渐变头部、深蓝面罩发光眼、天线顶珠、星光点缀)+ 文字胶囊按钮(主标题"AI 学习助手" + 副标语"学习有疑问?随时问我")
- 动效:头像上下浮动(bob)、呼吸光环(pulse)、悬停上浮 + 小助手摆动(wiggle);含 prefers-reduced-motion 降级
- 尺寸:头像 44px,胶囊整体约 56px 高,固定右下角不遮挡内容;纯 SVG 内联无图片资源
### 追加:遮挡修复
- 用户反馈悬浮胶囊会遮挡底部「保存/Step 01-04/下一步」导航栏(step-navigation 为普通文档流,滚到底部即被压住)
- 方案:滚动方向感知自动隐藏 —— `.ai-fab` 增加 `is-hidden` 状态;window scroll 监听(rAF 节流,阈值 6px),向下滚动且 scrollY>40 时 translateY(150%)+opacity:0+pointer-events:none,向上滚动滑出;含 reduced-motion 兼容
### 追加:扇形展开交互
- 用户要求默认只展示头像,悬停时像扇子一样展开文字
- 实现:待机态按钮为 52px 圆形(padding 4px + 头像 44px),透明背景 + 圆形投影;渐变胶囊底色移到 `.ai-fab::before` 用 opacity 淡入(渐变本身不可 transition);文字用 max-width 0→180px + margin-left 0→10px + translateX(-10px)→0 展开,悬停/:focus-visible 均触发;滚动隐藏逻辑保留
- 调速:展开放慢至 0.55s cubic-bezier(0.22,1,0.36,1) ease-out(文字 opacity 延迟 0.08s),收合 0.32s ease-in 稍快回收;利用"transition 定义在目标态"机制区分展开/收合速度

@ -0,0 +1,61 @@
# 2026-10-14
## 摸清教师端「任务评阅」页面结构(是否复用学生端组件)
结论:**不是复制页面,而是同一个学生端 SFC 被动态复用**(源码同一份,非副本)。
链路:
- `views/teacherEnd/score/TaskReviewPanel.vue`(左侧班级/任务/学生列表 + 打分)右侧用 `<iframe src="...#/task-review-preview?taskKey=..&reviewPreviewKey=..">`
- 隐藏教师路由 `/task-review-preview`(roles:["teacher"])→ `views/teacherEnd/taskReview/StudentTaskReadonlyPreview.vue`
- 该组件用 `<component :is>` 动态加载 `studentTaskPreviewMap.js`:25 个 taskKey → 真实学生页组件(`@/views/foundation|demand|product|channel|test|assessment/**.vue`);仅 comprehensive-case-training 通过 props 传 previewTaskKey
数据注入(不走 props,走 sessionStorage + API 层拦截):
- `utils/trainingReviewPreview.js`:写 `teacher-task-review-preview:<previewKey>` = {taskKey, teachingClassId, taskConfig, answer}
- `api/studentTrainingAnswer.js#getStudentTrainingAnswer` 命中 preview 直接返回 preview.answer
- `api/trainingTask.js#getTrainingTaskByKey` 返回该班生效 taskConfig(教师覆盖优先,否则管理员案例)
- `utils/request.js` 拦截 POST/PUT/PATCH/DELETE(答案/学习时长/上传)直接 reject「只读模式」
只读保障三层:①点击/按键捕获 guardPreviewInteraction(拦 input/select/contenteditable/按钮,放行步骤导航)②scoped `:deep` 隐藏顶部 chrome、AI 侧栏、底部动作区,表单 pointer-events:none ③网络层拦截写请求
iframe 高度:子页 ResizeObserver + MutationObserver 测量 → postMessage(`teacher-task-review-preview-height`)→ 父页设置高度
风险点:学生页任何类名/结构改动会波及评阅预览(大量 :deep 依赖命名契约);学生页新增写接口需同步加入 `isTrainingReviewPreviewRequest` 名单。
### 追加:覆盖率与差异排查(重要发现)
- taskKey 全覆盖(25 个),不会出现"不支持预览";但**3 个 key 的预览加载的是废弃旧页面文件**(仅 previewMap 引用,router 已无引用):
| taskKey | 学生端实际文件 | 预览加载文件 |
|---|---|---|
| industry-demand-analysis | demand/industry-demand-analysis.vue | demand/index.vue |
| competitive-environment-analysis | demand/competitive-environment-analysis.vue | demand/environment.vue |
| market-opportunity-selection | product/market-opportunity-selection.vue | product/index.vue |
证据:旧页面模板头带 `TrainingTaskToolbar` + `TrainingTaskBrief` + `training-core`;新页面是自定义 `task-header/task-brief-toggle` + `xxx-core`
- 另有 5 个 key 存在"一个 taskKey 对应多个路由页面",previewMap 只映射其一:/demand/user(user.vue 旧6KB) 与 /demand/people;/test/channelas(test/channel.vue 3KB) 与 /test/index;/test/marketing(marketing.vue 旧) 与 /test/promotion;/channel/purchase(purchase.vue) 与 /channel/index;/assessment/index(index.vue 旧16KB)+five/kano/market/traffic 与 /assessment/diagnosis
- 有意差异(只读外壳):隐藏顶部标题/brief/各 toolbar、AI 侧栏与 AI 评测、底部动作区(55% 透明+灰度+不可点)、浮层弹窗;`student-training-shell` 改单列;步骤导航强制横排胶囊(132~220px);表单 pointer-events:none;数据走 sessionStorage 注入(该班生效 taskConfig + 学生 answer);iframe 无应用布局(学生端有顶栏/TagsView/侧边菜单)
- 待办建议:修正 previewMap 三条指向新版文件;评估废弃重复路由(/demand/user、/test/channelas、/test/marketing、/channel/purchase、/assessment/index 等)
### 已执行:previewMap 全部改指最新页面
- `src/views/teacherEnd/taskReview/studentTaskPreviewMap.js`:三条改为 demand/industry-demand-analysis.vue、demand/competitive-environment-analysis.vue、product/market-opportunity-selection.vue;顶部加注释声明"必须与 router 保持一致,不得指向废弃页面"
- `src/views/teacherEnd/taskReview/StudentTaskReadonlyPreview.vue`:补两个类名 —— 隐藏列表加 `.task-action-strip`(否则工具栏被隐藏后残留 10px 空带)、白卡规则加 `.industry-step-card`(行业页步骤容器类名不同)
- 校验:node 脚本交叉核对 = 映射 24 条、文件全部存在、router 全部引用、废弃页面残留引用 0;@vue/compiler-sfc parse + compileStyle 无错误
- 遗留风险(previewMap 换新页后新增):新版页面采用 localStorage 草稿(loadDraft/persistDraft,键含当前用户 id),评阅预览同源会读写教师自己的草稿,理论上可能先闪出教师草稿;涉及 5 个页面(new-product-survey、product-development-factors、product-development-process、industry-demand-analysis、competitive-environment-analysis),可在这些页面用 getTrainingReviewPreviewKey() 判断预览态跳过草稿读写。待用户确认是否执行
### 已修复:评阅预览"看起来还在用废弃页面"的根因(两个叠加原因)
用户反馈截图里步骤导航是一条满宽蓝色大条,误判为废弃页面。实测根因 **不是页面文件错了**,而是样式失配:
1. **学生页"新版皮肤"全部由布局门控选择器承载**:`#app .studentLayout section.app-main:has(.xxx-page) …`(非 scoped 全局块,写在每个实训页底部)。
而 `/task-review-preview` 路由**脱离 Layout**(`component` 直接指向组件,无 Layout 包裹)→ 没有 `.studentLayout`、没有 `section.app-main` → 这些新样式全部失配,页面静默退化到全局旧版样式(步骤说明 `small`「当前步骤」重新出现、胶囊样式丢失、按钮配色不一致)。
`studentLayout` 类由 `layout/index.vue` 的 `classObj` 计算(仅 role 4 挂载);`.app-main` 由 `layout/components/AppMain.vue` 渲染且样式是 scoped。
2. **预览 iframe 宽度约 1000px**(`TaskReviewPanel.vue` 左栏 `minmax(440px,520px)` + 右栏自适应),命中 `student-training.scss` 的 `@media (max-width: 1100px)`:
`.student-training-shell .step-indicator { flex-direction: column }` + `#app .student-training-shell .step-tab { width: 100% !important }`。
后者带 `#app`(ID 级特异性 1,2,0),压过预览自身 `:deep(.step-tab){max-width:220px}`(0,4,0)→ 每个步骤胶囊被撑成整行、只剩第一个可见并出现横向滚动条。
修复(`src/views/teacherEnd/taskReview/StudentTaskReadonlyPreview.vue`,单文件):
- 模板外层补空壳祖先:`<div class="studentLayout student-task-preview-root"><section class="app-main">…`,让门控样式集体生效
- 新增一段非 scoped `<style>`:用 `#app .studentLayout.student-task-preview-root …`(1,3,0/1,4,0)压掉布局级背景与内边距,并复位 `.step-tab/.flow-pill/.flow-step/.training-action` 的 `width: 100%`
- 保留原有兜底:`:deep()` 里 `flex-direction: row`、隐藏 chrome、白卡等规则不变
验证方式(可复用):用本地 `sass` 编译 `index.scss` + 页面非 scoped 样式块 + 预览组件样式块,拼成静态 HTML(`.studentLayout > section.app-main > main.student-task-readonly-preview` + 4 个 `.step-tab`),用本机 Chrome `--headless=new --window-size=1000,700 --screenshot=xxx.png` 出图,页面内探针把 computed style 写进 `<pre>` 一起截进图里。
结果:修复前 tabWidths=952px×4(=容器满宽)、navScroll=3874/952 溢出、small=inline(与用户截图完全一致);修复后 tabWidths=214/169/199/200px 自然宽、navScroll=952/952 不溢出、small=none,与图二学生端一致。
注:Chrome for Windows 为 GUI 子系统,`--dump-dom` 不写 stdout,只能靠 `--screenshot` 落盘取证。
### 学生端菜单/页面全貌(roleId=4)
菜单不是硬编码:`dynamicRoutes` 里大量 `hidden:true`,真正侧边菜单由 `utils/studentTrainingNavigation.js#buildStudentTaskSections()` 按后端「课程分配」动态生成(按 projectName 分组),路径查 `knownTaskPaths`。
静态模块:`/foundation`(3) / `/demand`(8: index、environment、people、user、business-feasibility、consumer-behavior、consumer-scenario、feature-conversion) / `/product`(7: index、price、differentiation、structure、after-sales-service、requirements-doc、hardware-cost) / `/test`(4: index、promotion、channelas、marketing) / `/channel`(3: index、purchase、supplier) / `/assessment`(3: index、diagnosis、optimization) / `/comprehensive` / `/training/task/:pageKey` / `/training-step/:taskKey/:stepId` / `/assistant` / `/ai/learning-situation-*` / `/analysis` / `/report` / `/bi` / `/examination`(index、take/:id、result/:id) / `/student-demo`。
遗留:5 处重复 pageKey(一个 taskKey 对多个路由入口);`demand/index.vue`、`demand/environment.vue`、`product/index.vue` 三个文件已零引用但仍在磁盘(其内部仍保留 `:has(.industry-demand-page)` 等门控块,是现新页的前身)。

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

@ -1,352 +1,363 @@
<!DOCTYPE html>
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="renderer" content="webkit" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<link rel="icon" href="/favicon.ico" />
<title>互联网产品开发实训系统</title>
<!--[if lt IE 11]>
<script>
window.location.href = "/html/ie.html";
</script>
<![endif]-->
<style>
:root {
--ink: #06101f;
--panel: rgba(8, 30, 52, 0.82);
--line: rgba(80, 177, 214, 0.28);
--cyan: #53d6ff;
--mint: #8ff7d1;
--gold: #ffd978;
--text: #f3fbff;
--muted: #9fc1d8;
}
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="renderer" content="webkit">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link rel="icon" href="/favicon.ico">
<title>电商互联网产品开发实训系统</title>
<!--[if lt IE 11]><script>window.location.href='/html/ie.html';</script><![endif]-->
<style>
:root {
--ink: #06101f;
--panel: rgba(8, 30, 52, 0.82);
--line: rgba(80, 177, 214, 0.28);
--cyan: #53d6ff;
--mint: #8ff7d1;
--gold: #ffd978;
--text: #f3fbff;
--muted: #9fc1d8;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
}
#loading-wrapper {
position: relative;
display: grid;
place-items: center;
width: 100vw;
height: 100vh;
color: var(--text);
font-family: "Microsoft YaHei", "PingFang SC", "Source Han Sans CN", sans-serif;
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%);
background-size: 52px 52px, 52px 52px, auto, auto, auto;
}
#loading-wrapper::before,
#loading-wrapper::after {
position: absolute;
inset: 0;
content: "";
pointer-events: none;
}
#loading-wrapper::before {
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));
opacity: 0.75;
}
#loading-wrapper::after {
border: 1px solid rgba(83, 214, 255, 0.16);
box-shadow: inset 0 0 90px rgba(0, 0, 0, 0.42);
}
.loading-shell {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 154px minmax(320px, 520px);
gap: 30px;
align-items: center;
max-width: min(820px, calc(100vw - 40px));
padding: 34px 38px;
border: 1px solid var(--line);
border-radius: 8px;
background:
linear-gradient(145deg, rgba(14, 42, 68, 0.92), rgba(6, 20, 38, 0.9)),
var(--panel);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.06);
animation: shell-in 0.7s ease both;
}
.loading-mark {
position: relative;
width: 154px;
height: 154px;
overflow: hidden;
border: 1px solid rgba(83, 214, 255, 0.28);
border-radius: 8px;
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);
background-size: 25px 25px;
}
.learning-card {
position: absolute;
left: 27px;
top: 34px;
width: 100px;
height: 72px;
border: 1px solid rgba(143, 247, 209, 0.36);
border-radius: 6px;
background: linear-gradient(180deg, rgba(143, 247, 209, 0.18), rgba(83, 214, 255, 0.08));
box-shadow: 0 10px 28px rgba(83, 214, 255, 0.12);
animation: card-float 2.8s ease-in-out infinite;
}
.learning-card::before,
.learning-card::after {
position: absolute;
content: "";
}
.learning-card::before {
left: 15px;
top: 18px;
width: 70px;
height: 5px;
border-radius: 99px;
background: var(--mint);
box-shadow: 0 15px 0 rgba(211, 239, 255, 0.7), 0 30px 0 rgba(211, 239, 255, 0.42);
}
.learning-card::after {
right: -18px;
bottom: -18px;
width: 48px;
height: 48px;
border: 2px solid var(--gold);
border-left-color: transparent;
border-radius: 50%;
animation: spin 1.4s linear infinite;
}
.pulse-node {
position: absolute;
left: 22px;
bottom: 22px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--cyan);
box-shadow: 28px -8px 0 var(--gold), 58px 4px 0 var(--mint), 86px -18px 0 rgba(83, 214, 255, 0.72);
}
.pulse-node::before {
position: absolute;
left: -2px;
top: -2px;
width: 112px;
height: 36px;
content: "";
border-top: 1px solid rgba(83, 214, 255, 0.34);
border-right: 1px solid rgba(143, 247, 209, 0.26);
transform: skewX(-24deg);
}
.loading-copy {
min-width: 0;
}
.loading-eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 10px;
border: 1px solid rgba(83, 214, 255, 0.32);
border-radius: 999px;
color: var(--mint);
font-size: 12px;
font-weight: 700;
background: rgba(83, 214, 255, 0.08);
}
.loading-eyebrow::before {
width: 7px;
height: 7px;
content: "";
border-radius: 50%;
background: var(--mint);
box-shadow: 0 0 14px var(--mint);
}
.loading-title {
margin: 16px 0 8px;
color: var(--text);
font-size: 30px;
font-weight: 800;
line-height: 1.2;
}
.loading-subtitle {
margin: 0;
color: var(--muted);
font-size: 14px;
line-height: 1.8;
}
.progress-track {
position: relative;
height: 8px;
margin: 26px 0 18px;
overflow: hidden;
border-radius: 999px;
background: rgba(143, 190, 214, 0.16);
}
.progress-track::before {
position: absolute;
inset: 0;
width: 44%;
content: "";
border-radius: inherit;
background: linear-gradient(90deg, var(--cyan), var(--mint), var(--gold));
animation: progress 1.8s ease-in-out infinite;
}
.loading-steps {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.loading-step {
padding: 10px 12px;
border: 1px solid rgba(83, 214, 255, 0.18);
border-radius: 8px;
color: #c8e5f5;
font-size: 13px;
background: rgba(3, 19, 34, 0.52);
}
.loading-step span {
display: block;
margin-bottom: 4px;
color: var(--mint);
font-size: 12px;
font-weight: 700;
}
@keyframes shell-in {
from {
opacity: 0;
transform: translateY(14px);
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
}
to {
opacity: 1;
transform: translateY(0);
#loading-wrapper {
position: relative;
display: grid;
place-items: center;
width: 100vw;
height: 100vh;
color: var(--text);
font-family: "Microsoft YaHei", "PingFang SC", "Source Han Sans CN", sans-serif;
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%);
background-size:
52px 52px,
52px 52px,
auto,
auto,
auto;
}
}
@keyframes card-float {
0%,
100% {
transform: translateY(0);
#loading-wrapper::before,
#loading-wrapper::after {
position: absolute;
inset: 0;
content: "";
pointer-events: none;
}
50% {
transform: translateY(-8px);
#loading-wrapper::before {
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));
opacity: 0.75;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
#loading-wrapper::after {
border: 1px solid rgba(83, 214, 255, 0.16);
box-shadow: inset 0 0 90px rgba(0, 0, 0, 0.42);
}
}
@keyframes progress {
0% {
transform: translateX(-110%);
.loading-shell {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 154px minmax(320px, 520px);
gap: 30px;
align-items: center;
max-width: min(820px, calc(100vw - 40px));
padding: 34px 38px;
border: 1px solid var(--line);
border-radius: 8px;
background: linear-gradient(145deg, rgba(14, 42, 68, 0.92), rgba(6, 20, 38, 0.9)), var(--panel);
box-shadow:
0 24px 70px rgba(0, 0, 0, 0.34),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
animation: shell-in 0.7s ease both;
}
55% {
transform: translateX(82%);
.loading-mark {
position: relative;
width: 154px;
height: 154px;
overflow: hidden;
border: 1px solid rgba(83, 214, 255, 0.28);
border-radius: 8px;
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);
background-size: 25px 25px;
}
100% {
transform: translateX(240%);
.learning-card {
position: absolute;
left: 27px;
top: 34px;
width: 100px;
height: 72px;
border: 1px solid rgba(143, 247, 209, 0.36);
border-radius: 6px;
background: linear-gradient(180deg, rgba(143, 247, 209, 0.18), rgba(83, 214, 255, 0.08));
box-shadow: 0 10px 28px rgba(83, 214, 255, 0.12);
animation: card-float 2.8s ease-in-out infinite;
}
}
@media (max-width: 720px) {
.loading-shell {
grid-template-columns: 1fr;
gap: 22px;
padding: 26px;
.learning-card::before,
.learning-card::after {
position: absolute;
content: "";
}
.loading-mark {
width: 132px;
height: 132px;
.learning-card::before {
left: 15px;
top: 18px;
width: 70px;
height: 5px;
border-radius: 99px;
background: var(--mint);
box-shadow:
0 15px 0 rgba(211, 239, 255, 0.7),
0 30px 0 rgba(211, 239, 255, 0.42);
}
.learning-card {
left: 16px;
top: 26px;
.learning-card::after {
right: -18px;
bottom: -18px;
width: 48px;
height: 48px;
border: 2px solid var(--gold);
border-left-color: transparent;
border-radius: 50%;
animation: spin 1.4s linear infinite;
}
.pulse-node {
position: absolute;
left: 22px;
bottom: 22px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--cyan);
box-shadow:
28px -8px 0 var(--gold),
58px 4px 0 var(--mint),
86px -18px 0 rgba(83, 214, 255, 0.72);
}
.pulse-node::before {
position: absolute;
left: -2px;
top: -2px;
width: 112px;
height: 36px;
content: "";
border-top: 1px solid rgba(83, 214, 255, 0.34);
border-right: 1px solid rgba(143, 247, 209, 0.26);
transform: skewX(-24deg);
}
.loading-copy {
min-width: 0;
}
.loading-eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 10px;
border: 1px solid rgba(83, 214, 255, 0.32);
border-radius: 999px;
color: var(--mint);
font-size: 12px;
font-weight: 700;
background: rgba(83, 214, 255, 0.08);
}
.loading-eyebrow::before {
width: 7px;
height: 7px;
content: "";
border-radius: 50%;
background: var(--mint);
box-shadow: 0 0 14px var(--mint);
}
.loading-title {
font-size: 24px;
margin: 16px 0 8px;
color: var(--text);
font-size: 30px;
font-weight: 800;
line-height: 1.2;
}
.loading-subtitle {
margin: 0;
color: var(--muted);
font-size: 14px;
line-height: 1.8;
}
.progress-track {
position: relative;
height: 8px;
margin: 26px 0 18px;
overflow: hidden;
border-radius: 999px;
background: rgba(143, 190, 214, 0.16);
}
.progress-track::before {
position: absolute;
inset: 0;
width: 44%;
content: "";
border-radius: inherit;
background: linear-gradient(90deg, var(--cyan), var(--mint), var(--gold));
animation: progress 1.8s ease-in-out infinite;
}
.loading-steps {
grid-template-columns: 1fr;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
.loading-step {
padding: 10px 12px;
border: 1px solid rgba(83, 214, 255, 0.18);
border-radius: 8px;
color: #c8e5f5;
font-size: 13px;
background: rgba(3, 19, 34, 0.52);
}
}
</style>
</head>
<body>
<div id="app">
<div id="loading-wrapper">
<div class="loading-shell" role="status" aria-live="polite">
<div class="loading-mark" aria-hidden="true">
<div class="learning-card"></div>
<div class="pulse-node"></div>
</div>
<div class="loading-copy">
<div class="loading-eyebrow">Teaching Lab System</div>
<h1 class="loading-title">正在初始化教学环境</h1>
<p class="loading-subtitle">正在同步课程资源、实训数据与学习进度,请稍候。</p>
<div class="progress-track" aria-hidden="true"></div>
<div class="loading-steps" aria-hidden="true">
<div class="loading-step"><span>01</span>课程资源</div>
<div class="loading-step"><span>02</span>实训任务</div>
<div class="loading-step"><span>03</span>学习数据</div>
.loading-step span {
display: block;
margin-bottom: 4px;
color: var(--mint);
font-size: 12px;
font-weight: 700;
}
@keyframes shell-in {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes card-float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes progress {
0% {
transform: translateX(-110%);
}
55% {
transform: translateX(82%);
}
100% {
transform: translateX(240%);
}
}
@media (max-width: 720px) {
.loading-shell {
grid-template-columns: 1fr;
gap: 22px;
padding: 26px;
}
.loading-mark {
width: 132px;
height: 132px;
}
.learning-card {
left: 16px;
top: 26px;
}
.loading-title {
font-size: 24px;
}
.loading-steps {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
}
}
</style>
</head>
<body>
<div id="app">
<div id="loading-wrapper">
<div class="loading-shell" role="status" aria-live="polite">
<div class="loading-mark" aria-hidden="true">
<div class="learning-card"></div>
<div class="pulse-node"></div>
</div>
<div class="loading-copy">
<div class="loading-eyebrow">Teaching Lab System</div>
<h1 class="loading-title">正在初始化教学环境</h1>
<p class="loading-subtitle">正在同步课程资源、实训数据与学习进度,请稍候。</p>
<div class="progress-track" aria-hidden="true"></div>
<div class="loading-steps" aria-hidden="true">
<div class="loading-step">
<span>01</span>
课程资源
</div>
<div class="loading-step">
<span>02</span>
实训任务
</div>
<div class="loading-step">
<span>03</span>
学习数据
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
<script type="module" src="/src/main.js"></script>
</body>
</html>

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

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

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

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

@ -1006,6 +1006,47 @@
}
}
/*
* 折叠把手的箭头再挂一条 .hideSidebar 链
*
* 侧栏宽度由 .app-wrapper 上的 .hideSidebar 控制,而箭头此前只由
* .sidebar-container 上的 .is-collapse 控制 —— 两者本该同源,却走了
* 两条独立的 class 链。一旦失步就会出现「宽度已切、箭头不动」的现象。
* 这里把箭头也绑到控制宽度的同一个 class 上,二者再也无法不同步。
* 特异性 (1,4,0) 与基础隐藏规则相同,靠书写顺序在后面取胜。
*/
#app .hideSidebar .sidebar-container .course-sidebar-toggle {
.toggle-arrow--left {
display: none;
}
.toggle-arrow--right {
display: inline-flex;
}
}
/*
* 兜底:展开态下不允许 el-menu 停留在折叠外观
*
* 菜单文字的显隐由 Element Plus 内部的 .el-menu--collapse 决定,它只跟随
* <el-menu :collapse> 这个 prop。若该 prop 与外层状态不同步(失步的根因),
* 就会出现「侧栏已展开,菜单却仍是 64px 图标轨道、文字不显示」。
* 这里以控制宽度的 .hideSidebar 为准做强制纠正:只要侧栏处于展开态,
* 即便 el-menu 仍带着 .el-menu--collapse,也要撑满并恢复文字。
* 特异性 (1,5,0)+ ,足以压过 Element Plus 自身的隐藏规则。
*/
#app .app-wrapper:not(.hideSidebar) .sidebar-container .student-course-menu.el-menu--collapse {
width: 100% !important;
> .el-menu-item > span,
> .el-sub-menu > .el-sub-menu__title > span {
height: auto;
width: auto;
overflow: visible;
visibility: visible;
display: inline-block;
}
}
/*
* 教师端 sidebar 宽度单独缩减 50%(304 → 152px)
* 教师端使用 el-menu 直显式布局,无需学生端的内嵌卡片空间,原 304px 造成大量空白
@ -1013,8 +1054,9 @@
* 同时隐藏教师端唯一的分组标题"教学管理"(标识),仅保留菜单项
*/
#app .teacherLayout {
--teacher-sidebar-width: 180px;
.main-container {
margin-left: 180px !important;
margin-left: var(--teacher-sidebar-width) !important;
}
// 收起后侧边栏仅保留 64px 图标轨道,主内容同步释放展开态的空间。
@ -1023,7 +1065,7 @@
}
.sidebar-container {
width: 180px !important;
width: var(--teacher-sidebar-width) !important;
}
// 隐藏教师端顶部分组标题("教学管理"标识),不影响学生端多分组标题
.student-course-nav > .student-course-heading {
@ -1031,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 的暗色背景统一覆盖为亮色

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

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

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

@ -1,15 +1,60 @@
<template>
<aside :class="['training-ai-sidebar', { 'is-collapsed': sidebarCollapsed }]">
<!-- 收起态:右下角圆形悬浮球 -->
<!-- 收起态:右下角 3D 助手形象 + 文字胶囊悬浮按钮 -->
<button
v-if="sidebarCollapsed"
class="ai-fab"
:class="['ai-fab', { 'is-hidden': fabHidden }]"
type="button"
aria-label="展开 AI 助学与助评"
title="AI 助学助手"
aria-label="展开 AI 学习助手"
title="AI 学习助手"
@click="sidebarCollapsed = false"
>
<el-icon class="ai-fab__icon"><MagicStick /></el-icon>
<span class="ai-fab__mascot" aria-hidden="true">
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="aiFabBody" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#8ab6ff" />
<stop offset="0.55" stop-color="#3b82f6" />
<stop offset="1" stop-color="#1d4ed8" />
</linearGradient>
<linearGradient id="aiFabVisor" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1e3a8a" />
<stop offset="1" stop-color="#152757" />
</linearGradient>
<linearGradient id="aiFabTip" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fde68a" />
<stop offset="1" stop-color="#f59e0b" />
</linearGradient>
</defs>
<!-- 地面投影 -->
<ellipse cx="32" cy="59" rx="15" ry="3" fill="#1e3a8a" opacity="0.2" />
<!-- 天线 + 顶珠 -->
<line x1="32" y1="13" x2="32" y2="7" stroke="#1e40af" stroke-width="2.4" stroke-linecap="round" />
<circle cx="32" cy="5.5" r="3.2" fill="url(#aiFabTip)" />
<!-- 侧耳 -->
<rect x="7" y="28" width="7" height="12" rx="3.5" fill="#1e40af" />
<rect x="50" y="28" width="7" height="12" rx="3.5" fill="#1e40af" />
<!-- 3D 黏土头部 -->
<ellipse cx="32" cy="34" rx="20" ry="19" fill="url(#aiFabBody)" />
<!-- 高光 -->
<ellipse cx="24" cy="21" rx="7.5" ry="3.6" fill="#ffffff" opacity="0.35" transform="rotate(-16 24 21)" />
<!-- 显示器面罩 -->
<rect x="17" y="25" width="30" height="15" rx="7.5" fill="url(#aiFabVisor)" />
<!-- 发光眼睛 -->
<circle cx="26.5" cy="32.5" r="2.7" fill="#7dd3fc" />
<circle cx="37.5" cy="32.5" r="2.7" fill="#7dd3fc" />
<circle cx="27.3" cy="31.7" r="0.9" fill="#ffffff" />
<circle cx="38.3" cy="31.7" r="0.9" fill="#ffffff" />
<!-- 微笑 -->
<path d="M29 36.5 Q32 39 35 36.5" stroke="#7dd3fc" stroke-width="1.6" fill="none" stroke-linecap="round" />
<!-- 星光点缀 -->
<path d="M50 12 C50.7 15.3 53.4 16.3 56 17 C53.4 17.7 50.7 18.7 50 22 C49.3 18.7 46.6 17.7 44 17 C46.6 16.3 49.3 15.3 50 12 Z" fill="#fbbf24" />
</svg>
</span>
<span class="ai-fab__label">
<strong>AI 学习助手</strong>
<em>学习有疑问?随时问我</em>
</span>
</button>
<!-- 展开态 -->
@ -165,6 +210,7 @@ import { getAiTrainingEvaluation, requestAiTrainingAssessment, requestAiTraining
import { normalizeEvaluationReports } from "./trainingAiEvaluationReport";
import { resolveTrainingTaskKey } from "@/views/training/taskKeyMap";
import { buildReferencePrompt } from "./studentTrainingAiPanel";
import { friendlyAiEvaluationMessage as friendlyAiMessage } from "@/api/aiTrainingEvaluation";
const props = defineProps({
studyTip: {
@ -219,6 +265,25 @@ const sidebarCollapsed = ref(true);
const activePanel = ref("study");
const chatStreamRef = ref(null);
const assistInputRef = ref(null);
/* 悬浮按钮滚动方向感知:向下滚动时隐藏(避免遮挡底部导航/内容),向上滚动时滑出 */
const fabHidden = ref(false);
let lastScrollY = 0;
let fabScrollTicking = false;
function handleWindowScroll() {
if (fabScrollTicking) return;
fabScrollTicking = true;
requestAnimationFrame(() => {
fabScrollTicking = false;
const y = window.scrollY || 0;
const delta = y - lastScrollY;
if (Math.abs(delta) < 6) return;
fabHidden.value = delta > 0 && y > 40;
lastScrollY = y;
});
}
onMounted(() => window.addEventListener("scroll", handleWindowScroll, { passive: true }));
onBeforeUnmount(() => window.removeEventListener("scroll", handleWindowScroll));
/** 输入框最大高度(约 6 行):到此停止增高,内容在框内滚动(DeepSeek 式) */
const ASSIST_INPUT_MAX_HEIGHT = 168;
function autoGrowAssist() {
@ -303,7 +368,7 @@ async function requestAssist() {
assistMessage.value = reports.value.helpReport || props.assistText;
assistMessages.value.push({ id: `assistant-${Date.now()}`, role: "assistant", content: assistMessage.value });
} catch (error) {
assistMessages.value.push({ id: `assistant-${Date.now()}`, role: "assistant", content: "AI 助学暂时不可用,请稍后重试。" });
assistMessages.value.push({ id: `assistant-${Date.now()}`, role: "assistant", content: friendlyAiMessage(error, "AI 助学暂时不可用,请稍后重试。") });
} finally {
assistLoading.value = false;
}
@ -328,7 +393,7 @@ async function requestEvaluation() {
internalScore.value = reports.value.score ?? "--";
internalEvalMessage.value = reports.value.assessmentSummary || "AI 评分已生成,请查看评分明细与改进建议。";
} catch (error) {
internalEvalMessage.value = "AI 助评暂时不可用,请稍后重试。";
internalEvalMessage.value = friendlyAiMessage(error, "AI 助评暂时不可用,请稍后重试。");
} finally {
assessmentLoading.value = false;
}
@ -410,29 +475,159 @@ function plainText(value) {
width: auto;
}
/* ===== 收起态:右下角圆形悬浮球 ===== */
/* ===== 收起态:待机仅 3D 头像,悬停像扇子一样展开文字 ===== */
.ai-fab {
position: fixed;
right: 24px;
bottom: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
border: 0;
border-radius: 50%;
padding: 4px;
border: 1px solid rgba(255, 255, 255, 0);
border-radius: 999px;
color: #ffffff;
background: var(--ai-accent);
box-shadow: 0 8px 24px rgba(37, 99, 235, 0.32);
background: transparent;
box-shadow: 0 8px 22px rgba(37, 99, 235, 0.32);
font-family: inherit;
text-align: left;
cursor: pointer;
transition: background 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
transition: transform 0.22s ease, box-shadow 0.22s ease, opacity 0.22s ease,
border-color 0.25s ease, padding 0.28s ease;
}
/* 胶囊底色:默认透明,悬停时淡入,避免渐变背景无法过渡的问题 */
.ai-fab::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 55%, #1d4ed8 100%);
opacity: 0;
transition: opacity 0.2s ease;
}
/* 展开态:悬停 / 键盘聚焦(慢速展开,带 ease-out 回弹感) */
.ai-fab:hover:not(.is-hidden),
.ai-fab:focus-visible:not(.is-hidden) {
padding: 4px 20px 4px 4px;
border-color: rgba(255, 255, 255, 0.55);
box-shadow: 0 14px 30px rgba(37, 99, 235, 0.46);
transition: padding 0.5s cubic-bezier(0.22, 1, 0.36, 1),
border-color 0.4s ease, box-shadow 0.35s ease,
transform 0.22s ease, opacity 0.22s ease;
}
.ai-fab:hover:not(.is-hidden)::before,
.ai-fab:focus-visible:not(.is-hidden)::before {
opacity: 1;
transition: opacity 0.45s ease;
}
/* 向下滚动时自动隐藏:滑出视口底部,完全不遮挡内容 */
.ai-fab.is-hidden {
transform: translateY(150%);
opacity: 0;
pointer-events: none;
}
.ai-fab:hover:not(.is-hidden) {
transform: translateY(-3px);
}
.ai-fab:active { transform: translateY(-1px); }
/* 3D 助手头像:浅色圆底衬托 SVG 形象,缓慢上下浮动 */
.ai-fab__mascot {
position: relative;
z-index: 1;
display: inline-flex;
flex: 0 0 auto;
width: 44px;
height: 44px;
border-radius: 50%;
background: radial-gradient(circle at 30% 25%, #ffffff 0%, #dbeafe 62%, #bfdbfe 100%);
box-shadow: inset 0 -2px 5px rgba(30, 58, 138, 0.18), 0 2px 6px rgba(15, 23, 42, 0.22);
animation: mascot-bob 3.2s ease-in-out infinite;
}
.ai-fab__mascot svg {
display: block;
width: 100%;
height: 100%;
}
/* 呼吸光环:吸引注意但不刺眼 */
.ai-fab__mascot::before {
content: "";
position: absolute;
inset: -4px;
border: 2px solid rgba(147, 197, 253, 0.75);
border-radius: 50%;
animation: mascot-pulse 2.4s ease-out infinite;
}
/* 文字描述:默认折叠在头像后方,悬停时像扇子一样横向展开 */
.ai-fab__label {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 1px;
max-width: 0;
margin-left: 0;
overflow: hidden;
white-space: nowrap;
opacity: 0;
transform: translateX(-10px) scale(0.96);
transform-origin: left center;
/* 收合速度(离开悬停时稍快回收) */
transition: max-width 0.32s ease-in, margin-left 0.32s ease-in, opacity 0.2s ease-in,
transform 0.32s ease-in;
strong {
color: #ffffff;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.5px;
line-height: 18px;
}
em {
color: rgba(255, 255, 255, 0.78);
font-size: 11px;
font-style: normal;
line-height: 15px;
}
}
.ai-fab:hover:not(.is-hidden) .ai-fab__label,
.ai-fab:focus-visible:not(.is-hidden) .ai-fab__label {
max-width: 180px;
margin-left: 10px;
opacity: 1;
transform: translateX(0) scale(1);
/* 展开速度(放慢 + ease-out,扇子徐徐展开) */
transition: max-width 0.55s cubic-bezier(0.22, 1, 0.36, 1),
margin-left 0.55s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.4s ease-out 0.08s,
transform 0.55s cubic-bezier(0.22, 1, 0.36, 1);
}
/* 悬停时小助手挥手式摆动 */
.ai-fab:hover .ai-fab__mascot svg { animation: mascot-wiggle 0.55s ease; }
@keyframes mascot-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); }
}
@keyframes mascot-pulse {
0% { transform: scale(0.85); opacity: 0.9; }
70%, 100% { transform: scale(1.35); opacity: 0; }
}
@keyframes mascot-wiggle {
0%, 100% { transform: rotate(0); }
25% { transform: rotate(-7deg); }
60% { transform: rotate(6deg); }
}
.ai-fab__icon { font-size: 23px; color: #ffffff; }
.ai-fab:hover {
background: var(--ai-accent-deep);
box-shadow: 0 10px 28px rgba(37, 99, 235, 0.4);
transform: translateY(-2px);
@media (prefers-reduced-motion: reduce) {
.ai-fab:not(.is-hidden),
.ai-fab:hover:not(.is-hidden) { transform: none; }
.ai-fab__mascot,
.ai-fab__mascot::before,
.ai-fab:hover .ai-fab__mascot svg { animation: none; }
}
/* ===== 展开态骨架:白底卡片,撑满 fixed 根 ===== */

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

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

@ -6,8 +6,8 @@
</main>
</template>
<script setup>
import { computed, onMounted, ref } from "vue"; import { useRouter } from "vue-router"; import { ElMessage } from "element-plus"; import { Clock, Refresh } from "@element-plus/icons-vue"; import * as examApi from "@/api/exam";
const router=useRouter(),loading=ref(false),exams=ref([]),filter=ref("ALL"); const filters=[{label:"全部",value:"ALL"},{label:"未开始",value:"NOT_STARTED"},{label:"进行中",value:"IN_PROGRESS"},{label:"已结束",value:"ENDED"}]; const statusMeta={NOT_STARTED:{label:"未开始",tone:""},IN_PROGRESS:{label:"进行中",tone:"success"},ENDED:{label:"已结束",tone:"info"}}; const visibleExams=computed(()=>filter.value==="ALL"?exams.value:exams.value.filter((item)=>item.status===filter.value)); onMounted(load);
import { computed, onActivated, onMounted, ref } from "vue"; import { useRouter } from "vue-router"; import { ElMessage } from "element-plus"; import { Clock, Refresh } from "@element-plus/icons-vue"; import * as examApi from "@/api/exam";
const router=useRouter(),loading=ref(false),exams=ref([]),filter=ref("ALL"); const filters=[{label:"全部",value:"ALL"},{label:"未开始",value:"NOT_STARTED"},{label:"进行中",value:"IN_PROGRESS"},{label:"已结束",value:"ENDED"}]; const statusMeta={NOT_STARTED:{label:"未开始",tone:""},IN_PROGRESS:{label:"进行中",tone:"success"},ENDED:{label:"已结束",tone:"info"}}; const visibleExams=computed(()=>filter.value==="ALL"?exams.value:exams.value.filter((item)=>item.status===filter.value)); let examsMounted=false; onMounted(load); onActivated(()=>{if(examsMounted) load(); examsMounted=true;});
async function load(){loading.value=true;try{exams.value=(await examApi.getStudentExams()).data||[]}finally{loading.value=false}} async function enter(exam){try{const token=deviceToken(exam.id);await examApi.enterExam(exam.id,token);router.push(`/examination/take/${exam.id}`)}catch(error){ElMessage.error(error?.message||"进入考试失败")}} function deviceToken(id){const key=`exam-device-${id}`;let value=localStorage.getItem(key);if(!value){value=crypto?.randomUUID?.()||`${Date.now()}-${Math.random().toString(36).slice(2)}`;localStorage.setItem(key,value)}return value} function count(value){return value==="ALL"?exams.value.length:exams.value.filter((item)=>item.status===value).length} function day(value){return value?String(new Date(value).getDate()).padStart(2,"0"):"--"} function month(value){return value?`${new Date(value).getMonth()+1}月`:"—"} function formatTime(value){return value?new Date(value).toLocaleString("zh-CN",{hour12:false}).replaceAll("/","-"):"—"} function durationBetween(row){return row.startTime&&row.endTime?Math.max(0,Math.round((new Date(row.endTime)-new Date(row.startTime))/60000)):0} function countdown(value){const minutes=Math.max(0,Math.ceil((new Date(value)-Date.now())/60000));return minutes<60?`${minutes} 分钟后开始`:`${Math.ceil(minutes/60)} 小时后开始`}
</script>
<style scoped lang="scss">

@ -7,7 +7,7 @@
<script setup>
import { computed,onBeforeUnmount,onMounted,reactive,ref } from "vue"; import { useRoute,useRouter } from "vue-router"; import { ElMessage,ElMessageBox } from "element-plus"; import { Download,Upload } from "@element-plus/icons-vue"; import * as examApi from "@/api/exam";
const route=useRoute(),router=useRouter(),examId=route.params.id,loading=ref(false),paper=ref({questions:[]}),currentId=ref(null),remaining=ref(0),answers=reactive({}),files=reactive({});let ticker; const typeName={SINGLE:"单选题",MULTIPLE:"多选题",TRUE_FALSE:"判断题",SHORT_ANSWER:"简答题"}; const current=computed(()=>paper.value.questions.find(q=>q.id===currentId.value)); const currentIndex=computed(()=>paper.value.questions.findIndex(q=>q.id===currentId.value)); const grouped=computed(()=>Object.keys(typeName).map(type=>({type,items:paper.value.questions.filter(q=>q.type===type)})).filter(g=>g.items.length)); const timerText=computed(()=>`${String(Math.floor(remaining.value/60)).padStart(2,"0")} 分钟 ${String(remaining.value%60).padStart(2,"0")} 秒`); onMounted(load); onBeforeUnmount(()=>clearInterval(ticker));
async function load(){loading.value=true;try{const token=deviceToken();paper.value=(await examApi.getActivePaper(examId,token)).data;paper.value.questions.forEach(q=>{answers[q.id]=q.studentAnswer??(q.type==="MULTIPLE"?[]:"");if(q.answerFileUrl)files[q.id]={name:q.answerFileName,url:q.answerFileUrl}});currentId.value=paper.value.questions[0]?.id;remaining.value=Math.max(0,Math.floor((new Date(paper.value.endTime)-Date.now())/1000));ticker=setInterval(()=>{remaining.value=Math.max(0,remaining.value-1);if(remaining.value===0){clearInterval(ticker);autoSubmit()}},1000)}finally{loading.value=false}} function deviceToken(){return localStorage.getItem(`exam-device-${examId}`)||""} function select(id){currentId.value=id} function move(step){currentId.value=paper.value.questions[currentIndex.value+step]?.id} function isAnswered(q){const answer=answers[q.id];return(Array.isArray(answer)?answer.length:Boolean(String(answer??"").trim()))||Boolean(files[q.id])} function guardCopy(event){if(paper.value.allowCopyQuestion||event.target?.closest("textarea,input"))return;event.preventDefault();ElMessage.warning("该考试不允许复制试题")} function guardContextMenu(event){if(paper.value.allowCopyQuestion||event.target?.closest("textarea,input"))return;event.preventDefault()} function guardPaste(event){if(!paper.value.allowPasteAnswer){event.preventDefault();ElMessage.warning("该考试不允许粘贴答案")}} async function persist(q){await examApi.saveExamAnswer(examId,q.id,{deviceToken:deviceToken(),answer:answers[q.id]})} async function uploadAnswer({file}){const form=new FormData();form.append("file",file);const data=(await examApi.uploadExamAnswerFile(examId,current.value.id,deviceToken(),form)).data;files[current.value.id]={name:data.name,url:data.url};ElMessage.success("作答文件已上传")} async function confirmSubmit(){const unanswered=paper.value.questions.filter(q=>!isAnswered(q)).length;await ElMessageBox.confirm(unanswered?`还有 ${unanswered} 道题未作答,确认交卷?`:"确认提交试卷?","确认交卷",{type:"warning",confirmButtonText:"确认交卷"});await doSubmit()} async function doSubmit(){await examApi.submitExam(examId,deviceToken());ElMessage.success("交卷成功");router.replace("/examination/index")} async function autoSubmit(){try{await doSubmit()}catch{router.replace("/examination/index")}} function formatTime(v){return v?new Date(v).toLocaleString("zh-CN",{hour12:false}).replaceAll("/","-"):"—"}
async function load(){loading.value=true;try{const token=deviceToken();paper.value=(await examApi.getActivePaper(examId,token)).data;const typeOrder={SINGLE:0,MULTIPLE:1,TRUE_FALSE:2,SHORT_ANSWER:3};paper.value.questions.sort((a,b)=>(typeOrder[a.type]??9)-(typeOrder[b.type]??9));paper.value.questions.forEach((q,i)=>{q.order=i+1;answers[q.id]=q.studentAnswer??(q.type==="MULTIPLE"?[]:"");if(q.answerFileUrl)files[q.id]={name:q.answerFileName,url:q.answerFileUrl}});currentId.value=paper.value.questions[0]?.id;remaining.value=Math.max(0,Math.floor((new Date(paper.value.endTime)-Date.now())/1000));ticker=setInterval(()=>{remaining.value=Math.max(0,remaining.value-1);if(remaining.value===0){clearInterval(ticker);autoSubmit()}},1000)}finally{loading.value=false}} function deviceToken(){return localStorage.getItem(`exam-device-${examId}`)||""} function select(id){currentId.value=id} function move(step){currentId.value=paper.value.questions[currentIndex.value+step]?.id} function isAnswered(q){const answer=answers[q.id];return(Array.isArray(answer)?answer.length:Boolean(String(answer??"").trim()))||Boolean(files[q.id])} function guardCopy(event){if(paper.value.allowCopyQuestion||event.target?.closest("textarea,input"))return;event.preventDefault();ElMessage.warning("该考试不允许复制试题")} function guardContextMenu(event){if(paper.value.allowCopyQuestion||event.target?.closest("textarea,input"))return;event.preventDefault()} function guardPaste(event){if(!paper.value.allowPasteAnswer){event.preventDefault();ElMessage.warning("该考试不允许粘贴答案")}} async function persist(q){await examApi.saveExamAnswer(examId,q.id,{deviceToken:deviceToken(),answer:answers[q.id]})} async function uploadAnswer({file}){const form=new FormData();form.append("file",file);const data=(await examApi.uploadExamAnswerFile(examId,current.value.id,deviceToken(),form)).data;files[current.value.id]={name:data.name,url:data.url};ElMessage.success("作答文件已上传")} async function confirmSubmit(){const unanswered=paper.value.questions.filter(q=>!isAnswered(q)).length;await ElMessageBox.confirm(unanswered?`还有 ${unanswered} 道题未作答,确认交卷?`:"确认提交试卷?","确认交卷",{type:"warning",confirmButtonText:"确认交卷"});await doSubmit()} async function doSubmit(){await examApi.submitExam(examId,deviceToken());ElMessage.success("交卷成功");router.replace("/examination/index")} async function autoSubmit(){try{await doSubmit()}catch{router.replace("/examination/index")}} function formatTime(v){return v?new Date(v).toLocaleString("zh-CN",{hour12:false}).replaceAll("/","-"):"—"}
</script>
<style scoped lang="scss">
.take-page{--accent:#2563eb;--border:#e5e7eb;--muted:#6b7280;min-height:calc(100vh - 84px);background:#ffffff;color:#1f2937}.take-head{height:72px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;padding:0 28px;border-bottom:1px solid var(--border);background:#fff}.take-head h1{margin:0;font-size:17px}.take-head p{margin:4px 0 0;color:var(--muted);font-size:12px}.take-head>button{justify-self:end}.timer{display:grid;gap:2px;text-align:center;color:var(--muted);font-size:12px}.timer strong{color:var(--accent);font-size:18px;font-variant-numeric:tabular-nums}.timer.urgent strong{color:#c2413b}.exam-body{display:grid;grid-template-columns:250px minmax(0,1fr);gap:20px;padding:20px 22px}.answer-sheet,.question-panel{border:1px solid var(--border);border-radius:14px;background:#fff}.answer-sheet{align-self:start;padding:16px;position:sticky;top:16px}.answer-sheet h2{margin:0 0 14px;font-size:14px}.sheet-group{margin-bottom:18px}.sheet-group h3{margin:0 0 10px;color:var(--muted);font-size:12px}.sheet-group>div{display:grid;grid-template-columns:repeat(5,1fr);gap:7px}.sheet-group button{aspect-ratio:1;border:1px solid var(--border);border-radius:6px;background:#fff;color:var(--muted);cursor:pointer}.sheet-group button.answered{border-color:#2563eb;color:#2563eb;background:#eff6ff}.sheet-group button.current{outline:2px solid var(--accent);outline-offset:2px}.answer-sheet footer{display:flex;gap:14px;color:var(--muted);font-size:11px}.answer-sheet i{display:inline-block;width:8px;height:8px;margin-right:4px;border:1px solid #d1d5db;border-radius:50%}.answer-sheet i.answered-dot{border-color:#2563eb;background:#2563eb}.question-panel{min-height:610px;padding:22px 28px 80px;position:relative}.question-panel>header{display:flex;justify-content:space-between;color:var(--muted);font-size:13px}.question-panel>header b{color:var(--accent)}.question-panel h2{margin:18px 0;color:#1f2937;font-size:17px;line-height:1.65}.material{display:inline-flex;gap:6px;margin-bottom:16px;color:var(--accent);font-size:12px;text-decoration:none}.choice-list{display:grid;gap:10px;width:100%}.question-panel:deep(.el-radio),.question-panel:deep(.el-checkbox){height:auto;margin:0;padding:13px 16px;border:1px solid var(--border);border-radius:8px;background:#fff}.question-panel:deep(.el-radio:hover),.question-panel:deep(.el-checkbox:hover),.question-panel:deep(.is-checked){border-color:var(--accent);background:#eff6ff}.question-panel:deep(.el-radio__label),.question-panel:deep(.el-checkbox__label){display:flex;gap:12px;color:#1f2937;font-size:13px;line-height:1.55}.judgement{grid-template-columns:1fr 1fr}.question-panel:deep(.el-textarea__inner){border-color:var(--border);border-radius:8px;box-shadow:none}.file-answer{display:flex;align-items:center;gap:12px;margin-top:12px;color:var(--muted);font-size:12px}.file-answer a{color:var(--accent)}.question-actions{position:absolute;right:28px;bottom:24px;left:28px;display:flex;justify-content:space-between}.take-page:deep(.el-button){height:40px;border-radius:10px;font-weight:700}.take-page:deep(.el-button--primary){border-color:var(--accent);background:var(--accent)}.take-page:deep(.el-button--danger.is-plain){border-color:rgba(217,85,76,.45);background:#fff;color:#d9554c}@media(max-width:760px){.take-head{grid-template-columns:1fr auto;height:auto;padding:10px 14px}.timer{grid-column:1/3;margin-top:8px}.exam-body{grid-template-columns:1fr;padding:12px}.answer-sheet{position:static}.question-panel{padding:20px 18px 78px}.question-actions{right:18px;left:18px}}

File diff suppressed because it is too large Load Diff

@ -2,7 +2,8 @@
<main class="question-bank app-container">
<header class="page-head"><div><h1>题库管理</h1><p>维护平台公共题库,供教师组卷使用</p></div><div class="actions"><el-button type="primary" :icon="Plus" @click="openEditor()">添加题目</el-button><el-button type="success" :icon="Upload" :loading="loading" @click="pickImport">批量导入</el-button><el-dropdown trigger="click" @command="downloadTemplate"><el-button type="success" :icon="Download">下载模板<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="word">Word 导入模板</el-dropdown-item><el-dropdown-item command="excel">Excel 导入模板</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></header>
<section class="toolbar"><el-select v-model="query.type" clearable placeholder="题型:请选择" @change="load"><el-option v-for="item in types" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-input v-model="query.keyword" placeholder="请输入题干关键词" clearable @keyup.enter="load" /><el-button type="primary" :icon="Search" @click="load">搜索</el-button><input ref="fileInput" type="file" accept=".docx,.xlsx,.xls" hidden @change="importFile" /></section>
<el-table v-loading="loading" :data="questions" class="data-table"><el-table-column type="index" label="序号" width="78" /><el-table-column label="题干" min-width="420" show-overflow-tooltip><template #default="{ row }"><span>{{ plainHtml(row.stem) }}</span></template></el-table-column><el-table-column label="题型" width="120"><template #default="{ row }"><el-tag>{{ typeName[row.type] }}</el-tag></template></el-table-column><el-table-column label="题目来源" width="140"><template #default><el-tag type="success">平台题库</el-tag></template></el-table-column><el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openEditor(row)">编辑</el-button><el-button link type="primary" @click="openEditor(row)">查看</el-button><el-button link type="danger" @click="remove(row)">删除</el-button></template></el-table-column><template #empty><el-empty description="暂无题目" /></template></el-table>
<el-table v-loading="loading" :data="pagedQuestions" class="data-table"><el-table-column type="index" label="序号" width="78" /><el-table-column label="题干" min-width="420" show-overflow-tooltip><template #default="{ row }"><span>{{ plainHtml(row.stem) }}</span></template></el-table-column><el-table-column label="题型" width="120"><template #default="{ row }"><el-tag>{{ typeName[row.type] }}</el-tag></template></el-table-column><el-table-column label="题目来源" width="140"><template #default><el-tag type="success">平台题库</el-tag></template></el-table-column><el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openEditor(row)">编辑</el-button><el-button link type="primary" @click="openEditor(row)">查看</el-button><el-button link type="danger" @click="remove(row)">删除</el-button></template></el-table-column><template #empty><el-empty description="暂无题目" /></template></el-table>
<div class="pagination-wrap"><el-pagination v-model:current-page="pagination.currentPage" v-model:page-size="pagination.pageSize" :total="pagination.total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" background /></div>
<p class="bank-note">说明:平台题库为系统默认题库,所有教师可见;教师仅可查看平台题目,编辑删除自己新增的题目。</p>
<el-dialog v-model="editorVisible" :title="form.id ? '编辑题目' : '新增题目'" width="min(1180px, 94vw)" top="4vh" class="question-editor-dialog" destroy-on-close>
@ -29,11 +30,11 @@ import * as api from "@/api/exam";
const types = [{ label: "单选题", value: "SINGLE" }, { label: "多选题", value: "MULTIPLE" }, { label: "判断题", value: "TRUE_FALSE" }, { label: "简答题", value: "SHORT_ANSWER" }];
const typeName = Object.fromEntries(types.map((item) => [item.value, item.label]));
const loading = ref(false); const saving = ref(false); const uploading = ref(false); const aiLoading = ref(false); const questions = ref([]); const editorVisible = ref(false); const aiVisible = ref(false); const preview = ref([]); const fileInput = ref();
const query = reactive({ type: "", taskKey: "", keyword: "" }); const form = reactive(emptyQuestion()); const aiForm = reactive({ taskKey: "", type: "SINGLE", count: 5, requirement: "" });
const isChoice = computed(() => ["SINGLE", "MULTIPLE"].includes(form.type)); const answers = computed({ get: () => Array.isArray(form.answer) ? form.answer : [], set: (value) => { form.answer = value; } }); const singleAnswer = computed({ get: () => Array.isArray(form.answer) ? form.answer[0] || "" : "", set: (value) => { form.answer = value ? [value] : []; } });
const query = reactive({ type: "", taskKey: "", keyword: "" }); const pagination = reactive({ currentPage: 1, pageSize: 10, total: 0 }); const form = reactive(emptyQuestion()); const aiForm = reactive({ taskKey: "", type: "SINGLE", count: 5, requirement: "" });
const isChoice = computed(() => ["SINGLE", "MULTIPLE"].includes(form.type)); const pagedQuestions = computed(() => { const start = (pagination.currentPage - 1) * pagination.pageSize; return questions.value.slice(start, start + pagination.pageSize); }); const answers = computed({ get: () => Array.isArray(form.answer) ? form.answer : [], set: (value) => { form.answer = value; } }); const singleAnswer = computed({ get: () => Array.isArray(form.answer) ? form.answer[0] || "" : "", set: (value) => { form.answer = value ? [value] : []; } });
function emptyQuestion() { return { id: null, taskKey: "", type: "SINGLE", stem: "<p></p>", options: ["A", "B", "C", "D"].map((label) => ({ label, content: "<p></p>" })), answer: [], analysis: "<p></p>", attachmentName: "", attachmentUrl: "" }; }
function plainHtml(value) { return String(value || "").replace(/<[^>]+>/g, "").replace(/&nbsp;/g, " "); }
async function load() { loading.value = true; try { questions.value = (await api.getPlatformQuestions(query)).data || []; } finally { loading.value = false; } } async function init() { await load(); } onMounted(init);
async function load() { loading.value = true; try { questions.value = (await api.getPlatformQuestions(query)).data || []; pagination.total = questions.value.length; pagination.currentPage = 1; } finally { loading.value = false; } } async function init() { await load(); } onMounted(init);
function openEditor(row) { Object.assign(form, row ? JSON.parse(JSON.stringify(row)) : emptyQuestion()); if (!Array.isArray(form.answer) && isChoice.value) form.answer = []; editorVisible.value = true; } function openAi() { aiForm.taskKey = ""; preview.value = []; aiVisible.value = true; }
function optionLabel(index) { return String.fromCharCode(65 + index); }
function addOption() { if (form.options.length >= 26) return ElMessage.warning("最多支持 26 个选项"); form.options.push({ label: optionLabel(form.options.length), content: "<p></p>" }); }
@ -53,6 +54,6 @@ function displayAnswer(value) { return Array.isArray(value) ? value.join("、")
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 18px; }
.page-head h1 { margin: 0; font-size: 22px; font-weight: 700; color: #1f2937; }.page-head p { margin: 7px 0 0; color: #6b7280; font-size: 13px; }.actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 14px; margin-bottom: 16px; border: 1px solid #e5e7eb; border-radius: 10px; background: #ffffff; }.toolbar .el-select { width: 210px; }.toolbar .el-input { width: min(300px, 30vw); }
.data-table { background: #ffffff; border-radius: 10px; }.bank-note { margin: 14px auto 0; max-width: 1180px; color: #6b7280; font-size: 13px; line-height: 1.8; text-align: center; }
.data-table { background: #ffffff; border-radius: 10px; }.pagination-wrap { display: flex; justify-content: flex-end; margin-top: 16px; }.bank-note { margin: 14px auto 0; max-width: 1180px; color: #6b7280; font-size: 13px; line-height: 1.8; text-align: center; }
.editor-shell { padding: 4px 8px 12px; color: #1f2937; }.editor-project { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; }.editor-project > span { width: 62px; color: #4b5563; }.editor-project .el-select { width: 420px; }.type-switch { display: flex; align-items: center; gap: 12px; margin: 4px 0 22px; }.type-switch > span { width: 62px; color: #4b5563; font-size: 15px; text-align: right; }.type-select { width: 200px; }.rich-row, .option-block { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 12px; margin-bottom: 22px; align-items: start; }.rich-row > label, .option-block > label, .tf-block > label { padding-top: 10px; color: #4b5563; font-size: 15px; text-align: right; }.option-list { display: grid; gap: 12px; }.option-row { display: grid; grid-template-columns: 56px minmax(0, 1fr) 36px; align-items: start; gap: 10px; }.option-letter { display: flex; flex-direction: column; align-items: center; gap: 6px; padding-top: 12px; }.option-letter .answer-control { margin-right: 0; }.letter-tag { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 50%; background: #eff6ff; color: #2563eb; font-size: 13px; font-weight: 600; border: 1px solid #bfdbfe; }.remove-btn { padding-top: 16px; color: #9ca3af; }.add-option-btn { justify-self: start; margin-top: 4px; }.tf-block { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 12px; margin: 18px 0 30px; align-items: center; }.tf-options { display: flex; gap: 16px; }.tf-radio-group { display: flex; gap: 16px; }.tf-radio { padding: 10px 36px; font-size: 15px; border-radius: 8px; }.material-row { display: flex; align-items: center; gap: 14px; margin: 6px 0 22px 132px; }.material-name { color: #6b7280; }.preview-alert { margin-bottom: 12px; }.preview-item { padding: 10px 4px; border-bottom: 1px solid #e5e7eb; line-height: 1.7; color: #1f2937; }.editor-shell :deep(.ql-container) { min-height: 88px; font-size: 15px; color: #1f2937; }.editor-shell :deep(.ql-editor) { min-height: 74px; color: #1f2937; }.editor-shell :deep(.ql-toolbar) { background: #fff; border-color: #e5e7eb; }.editor-shell :deep(.ql-editor.ql-blank::before) { color: #9ca3af; font-style: normal; }
</style>

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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

@ -29,6 +29,10 @@
<el-select v-model="questionQuery.type" placeholder="全部题型" clearable @change="loadQuestions">
<el-option v-for="item in typeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select v-model="sourceFilter" placeholder="全部来源" clearable @change="resetQuestionPager" style="width: 140px">
<el-option label="平台题库" value="PLATFORM" />
<el-option label="教师自建" value="CUSTOM" />
</el-select>
<!-- 全部项目筛选暂时隐藏 -->
<el-select v-if="false" v-model="questionQuery.taskKey" placeholder="全部项目" clearable @change="loadQuestions" style="width: 190px">
<el-option v-for="item in projectOptions" :key="item.taskKey" :label="`${item.projectName} / ${item.taskName}`" :value="item.taskKey" />
@ -80,10 +84,10 @@
</div>
</template>
</el-table>
<div v-if="questions.length" class="question-pager">
<div v-if="filteredQuestions.length" class="question-pager">
<el-pagination
layout="total, prev, pager, next, sizes, jumper"
:total="questions.length"
:total="filteredQuestions.length"
v-model:current-page="questionPager.page"
v-model:page-size="questionPager.pageSize"
:page-sizes="[10, 20, 50, 100]"
@ -142,11 +146,17 @@ const typeName = Object.fromEntries(typeOptions.map((item) => [item.value, item.
const typeTone = { SINGLE: "", MULTIPLE: "success", TRUE_FALSE: "warning", SHORT_ANSWER: "info" };
const statusMeta = { DRAFT: { label: "未发布", tone: "info" }, NOT_STARTED: { label: "未开始", tone: "" }, IN_PROGRESS: { label: "进行中", tone: "success" }, ENDED: { label: "已结束", tone: "warning" } };
const questionQuery = reactive({ type: "", taskKey: "", keyword: "" });
const sourceFilter = ref(""); // 题目来源前端筛选:PLATFORM=平台题库 / CUSTOM=教师自建(非 PLATFORM)
// 题库列表使用前端分页:后端一次返回全部,前端按页切片
const questionPager = reactive({ page: 1, pageSize: 10 });
const filteredQuestions = computed(() => {
const st = sourceFilter.value;
if (!st) return questions.value;
return questions.value.filter(q => st === "PLATFORM" ? q.sourceType === "PLATFORM" : q.sourceType !== "PLATFORM");
});
const pagedQuestions = computed(() => {
const start = (questionPager.page - 1) * questionPager.pageSize;
return questions.value.slice(start, start + questionPager.pageSize);
return filteredQuestions.value.slice(start, start + questionPager.pageSize);
});
const emptyQuestion = () => ({ id: null, taskKey: "", type: "SINGLE", stem: "", options: [{ label: "A", content: "" }, { label: "B", content: "" }, { label: "C", content: "" }, { label: "D", content: "" }], answer: [], analysis: "", attachmentName: "", attachmentUrl: "" });
const questionForm = reactive(emptyQuestion());
@ -159,6 +169,7 @@ let examsMounted = false; // KeepAlive 缓存页面:再次进入时刷新考
onActivated(() => { if (examsMounted) loadExams(); examsMounted = true; });
async function loadProjects() { try { projectOptions.value = (await examApi.getQuestionProjects()).data || []; } catch (e) { projectOptions.value = []; } }
async function loadQuestions() { loading.value = true; try { questionPager.page = 1; questions.value = (await examApi.getQuestions(questionQuery)).data || []; } finally { loading.value = false; } }
function resetQuestionPager() { questionPager.page = 1; }
async function loadExams() { loading.value = true; try { exams.value = (await examApi.getTeacherExams({ keyword: examKeyword.value })).data || []; } finally { loading.value = false; } }
function openQuestion(row) { questionReadonly.value = row?.sourceType === 'PLATFORM'; Object.assign(questionForm, row ? JSON.parse(JSON.stringify(row)) : emptyQuestion()); if (!Array.isArray(questionForm.answer) && isChoice.value) questionForm.answer = []; questionDialog.value = true; }
function openAi() { router.push('/exam/ai-generate'); }

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

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

@ -202,8 +202,34 @@ async function loadReview() {
if (!filters.value.classId) return;
loading.value = true;
try {
/*
* 首次进入 / 切换班级时,前端还不知道该选哪个实训项目,因此首个请求不带
* projectName;后端在 projectName 为空时不会按项目联动过滤,会把该教学班的
* 全部实训任务都返回回来。normalizeReview 随后用 selectedTask 回填了
* projectName,但任务列表仍停留在"全量"那份数据上,表现为:
* 「实训任务」下拉展示了全部任务,手动重选一次实训项目才恢复正常。
* 这里在回填出 projectName 后按该项目重新查一次,保证两个下拉始终联动一致。
*/
const requestedProject = filters.value.projectName;
const res = await teacherApi.getTaskReview({ ...filters.value });
normalizeReview(res.data);
if (!requestedProject && filters.value.projectName) {
const projectRes = await teacherApi.getTaskReview({ ...filters.value });
const projectTasks = projectRes?.data?.tasks || [];
const currentTaskListed = projectTasks.some((task) => String(task.taskId) === String(filters.value.taskId));
if (currentTaskListed) {
// 常规路径:只替换「实训任务」下拉的选项,不动 selectedTask 与学生列表,
// 避免多触发一次右侧学生页预览重建(预览页会真实发起接口请求)。
review.value = {
...review.value,
projects: projectRes.data.projects || review.value.projects,
tasks: projectTasks,
};
} else if (projectTasks.length) {
// 兜底:过滤后不含当前任务时整体刷新,保证下拉值与选中任务一致。
normalizeReview(projectRes.data);
}
}
} finally {
loading.value = false;
}

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

@ -1,8 +1,23 @@
<template>
<main ref="previewRoot" class="student-task-readonly-preview" @click.capture="guardPreviewInteraction" @keydown.capture="guardPreviewInteraction">
<component v-if="previewComponent" :is="previewComponent" v-bind="previewProps" />
<el-empty v-else :description="emptyDescription" :image-size="112" />
</main>
<!--
教师端任务评阅右侧预览(脱离 Layout 的独立路由,不渲染顶栏/侧栏)。
必须补齐 .studentLayout 与 section.app-main 两级祖先:
学生端实训页的「新版皮肤」全部以
#app .studentLayout section.app-main:has(.xxx-page) …
作为样式门控前缀。本路由没有走 Layout,天然缺少这两级祖先,
会导致这些新样式集体失配,页面静默退化到全局旧版样式
(步骤导航变 100% 宽竖排、步骤说明文字重新出现、动作按钮配色不一致)。
这里只补空壳容器,不引入顶栏、侧栏或 TagsView。
-->
<div class="studentLayout student-task-preview-root">
<section class="app-main">
<main ref="previewRoot" class="student-task-readonly-preview" @click.capture="guardPreviewInteraction" @keydown.capture="guardPreviewInteraction">
<component v-if="previewComponent" :is="previewComponent" v-bind="previewProps" />
<el-empty v-else :description="emptyDescription" :image-size="112" />
</main>
</section>
</div>
</template>
<script setup>
@ -126,6 +141,7 @@ function isStepNavigation(element) {
.student-task-readonly-preview :deep(.workbench-head),
.student-task-readonly-preview :deep(.workbench-head--actions-only),
.student-task-readonly-preview :deep(.workbench-actions),
.student-task-readonly-preview :deep(.task-action-strip),
.student-task-readonly-preview :deep(.training-task-toolbar),
.student-task-readonly-preview :deep(.training-legacy-actions),
.student-task-readonly-preview :deep(.new-product-survey-toolbar),
@ -232,6 +248,7 @@ function isStepNavigation(element) {
/* ===== 6. 步骤内容卡片:保留学生端白卡 + 灰边样式 ===== */
.student-task-readonly-preview :deep(.step-card),
.student-task-readonly-preview :deep(.industry-step-card),
.student-task-readonly-preview :deep(.process-step-card),
.student-task-readonly-preview :deep(.factor-step-card),
.student-task-readonly-preview :deep(.step-content > .step-card),
@ -268,3 +285,49 @@ function isStepNavigation(element) {
.student-task-readonly-preview :deep(.el-popper),
.student-task-readonly-preview :deep(.el-overlay) { display: none !important; }
</style>
<!--
非 scoped:补齐 .studentLayout / section.app-main 空壳祖先之后,
需要压掉布局级背景与内边距;同时复位窄屏媒体查询。
选择器统一带 #app + .student-task-preview-root,确保 ID 级特异性高于
student-training.scss / sidebar.scss 里的
#app .studentLayout .app-main:has(.student-training-shell) …
这类规则,避免预览壳被学生端布局背景与内边距污染。
-->
<style lang="scss">
#app .studentLayout.student-task-preview-root {
min-height: 0 !important;
background: #ffffff !important;
}
#app .studentLayout.student-task-preview-root .app-main,
#app .studentLayout.student-task-preview-root .app-main:has(.student-training-shell) {
height: auto !important;
min-height: 0 !important;
padding: 0 !important;
overflow: visible !important;
background: #ffffff !important;
}
/*
评阅预览的 iframe 宽度约 1000px(左栏 440~520px + 右栏自适应),
会命中 student-training.scss 的 @media (max-width: 1100px):
.student-training-shell .step-indicator { flex-direction: column }
#app .student-training-shell .step-tab { width: 100% }
后者带 #app(ID 级特异性),会压过本组件 :deep() 里的宽度覆盖,
于是预览里的步骤胶囊被撑成整行、只剩第一个可见(横向滚动条)。
预览需要呈现学生端「桌面端」外观,这里用更高特异性把宽度复位。
*/
#app .student-task-preview-root .step-indicator .step-tab,
#app .student-task-preview-root .step-tabs .step-tab,
#app .student-task-preview-root .comprehensive-step-strip .step-tab,
#app .student-task-preview-root .training-intro__steps .step-tab,
#app .student-task-preview-root .step-indicator .flow-pill,
#app .student-task-preview-root .flow-steps .flow-pill,
#app .student-task-preview-root .flow-rail .flow-step,
#app .student-task-preview-root .training-actions .el-button.training-action,
#app .student-task-preview-root .training-actions button.training-action {
width: auto !important;
}
</style>

@ -1,10 +1,18 @@
/*
* 教师端任务评阅预览:taskKey → 学生端页面组件。
*
* 重要:每一条都必须与 src/router/index.js 中学生路由实际引用的页面文件一致,
* 不得指向已废弃的旧版页面(历史上曾误挂 demand、product 目录下的旧版同任务页面,
* 导致评阅预览渲染了学生端根本看不到的旧页面)。
* 新增实训任务或调整学生页文件名时,请同步更新本映射。
*/
export const studentTaskPreviewComponentByKey = {
"new-product-survey": () => import("@/views/foundation/new-product-survey.vue"),
"product-development-factors": () => import("@/views/foundation/product-development-factors.vue"),
"product-development-process": () => import("@/views/foundation/product-development-process.vue"),
"industry-demand-analysis": () => import("@/views/demand/index.vue"),
"competitive-environment-analysis": () => import("@/views/demand/environment.vue"),
"market-opportunity-selection": () => import("@/views/product/index.vue"),
"industry-demand-analysis": () => import("@/views/demand/industry-demand-analysis.vue"),
"competitive-environment-analysis": () => import("@/views/demand/competitive-environment-analysis.vue"),
"market-opportunity-selection": () => import("@/views/product/market-opportunity-selection.vue"),
"business-feasibility": () => import("@/views/demand/business-feasibility.vue"),
"target-user-profile": () => import("@/views/demand/people.vue"),
"consumer-behavior": () => import("@/views/demand/consumer-behavior.vue"),

@ -16,7 +16,7 @@
<div class="online">
<span>教师工号:</span>
<el-input style="width: 14rem" v-model="paramsquery.userName" class="ml10"></el-input>
<el-button @click="getList" class="ml10">
<el-button @click="search" class="ml10">
<el-icon>
<Search />
</el-icon>
@ -143,6 +143,8 @@
@close="onClose">
<el-form :model="formInline" class="demo-form-inline" ref="formInlineref" label-width="100px"
style="width: 80%" :rules="rules">
<!-- 2026-10-12 停用:所属院系 / 所属专业的下拉选择(新增教师不再选择院系与专业)。
原始代码保留备用;恢复时打开下面这段注释,并删掉其后两个只读 el-form-item。
<el-form-item v-if="status || isOrganizationManagementEnabled" label="所属院系:" prop="schoolFacultyId">
<el-select v-model="formInline.schoolFacultyId" placeholder="" clearable @change="getMajorList"
style="width: 20rem" v-if="status">
@ -160,6 +162,14 @@
<el-input v-else v-model="classInfo.value" placeholder="请输入所属专业:" style="width: 20rem"
disabled></el-input>
</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-input v-model="formInline.name" style="width: 20rem" placeholder=""></el-input>
</el-form-item>
@ -236,7 +246,8 @@ const organizationRules = {
schoolMajorId: [{ required: true, message: "请选择专业", trigger: "change" }],
}
const rules = computed(() => ({
...((status.value || isOrganizationManagementEnabled.value) ? organizationRules : {}),
// 院系/专业新增时已停用;仅编辑且展示只读归属时才保留规则
...((!status.value && isOrganizationManagementEnabled.value) ? organizationRules : {}),
name: [{ required: true, message: "请输入教师姓名", trigger: "blur" }],
userName: [{ required: true, message: "请输入教师工号", trigger: "blur" }],
}))
@ -258,6 +269,11 @@ const getList = () => {
schoolTotal.value = res.data.total
})
}
// 搜索回到第一页,避免停留在超出结果范围的页码
const search = () => {
paramsquery.value.index = 1
getList()
}
// 删除院校
const delSchool = (id) => {
indexApi.deleteTeacher({ userId: id.userId, operatorId: currentUserId.value }).then((res) => {
@ -285,7 +301,9 @@ const editSchool = (row) => {
})
}
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
indexApi.addTeacher(formInline.value).then((res) => {
proxy.$message.success("添加成功")
@ -376,14 +394,12 @@ const exportTeacher = () => {
// 导入模板下载
const downloadTemplate = async () => {
const XLSX = await import("xlsx")
// 教师的学校/院系/专业直接继承当前操作用户,模板不再需要组织信息列
const templateSheet = XLSX.utils.aoa_to_sheet([
["院校名称", "院系名称", "专业名称", "教师姓名", "工号", "电话", "邮箱"],
["请填写系统中的院校名称", "请填写系统中的院系名称", "请填写系统中的专业名称", "张老师", "20260001", "13800000000", "teacher@example.com"],
["教师姓名", "工号", "电话", "邮箱"],
["张老师", "20260001", "13800000000", "teacher@example.com"],
])
templateSheet["!cols"] = [
{ wch: 26 },
{ wch: 24 },
{ wch: 24 },
{ wch: 16 },
{ wch: 18 },
{ wch: 18 },
@ -391,8 +407,8 @@ const downloadTemplate = async () => {
]
const guideSheet = XLSX.utils.aoa_to_sheet([
["填写说明"],
["1. 院校、院系和专业名称必须与系统中已维护的名称完全一致。"],
["2. 教师姓名和工号必填,工号只能填写数字且不能重复。"],
["1. 教师姓名和工号必填,工号只能填写数字且不能重复。"],
["2. 导入的教师归入你所在的学校(院系/专业同你的账号)。"],
["3. 电话和邮箱可以留空;填写时必须符合手机号和邮箱格式。"],
["4. 默认密码为 123qwe,导入成功后请提醒教师及时修改密码。"],
])

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

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

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

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

Loading…
Cancel
Save