chore(sync): 同步当前工作区全部变更(学员学号可编辑并留空自动生成等)

This commit is contained in:
buliping committed 2026-11-10 13:34:14 +08:00
1 parent 01e949093a
commit ee629f2f3e
534 files changed
+72978 -2720

No files matched your search

@@ -0,0 +1,278 @@
# 学员个人画像页面「新模块不渲染」诊断报告
- 诊断日期:2026-11-08
- 涉及页面:`src/views/train/portrait/index.vue`(个人画像 / 学员 360)
- 涉及接口:`GET /train/analysis/portrait/student-360/{studentId}`
- 涉及后端:`ruoyi-train` 模块(`StudentPortraitServiceImpl`、`TrainPortraitAnalyticsMapper`)
- 诊断方式:前端源码与编译产物核查、后端运行实例与编译产物时间比对、本地数据库(只读)表结构核对与 SQL 实测
---
## 一、结论摘要
1. **直接原因(P0)**:画像聚合第 9 条 SQL `selectDormContext` 使用了 V1 旧表结构字段(`train_dorm_auth.student_id / plan_id / dorm_id`),而当前库为 V2 结构,实测报错 `Unknown column 'da.dorm_id' in 'field list'`。
2. 该异常在 `TrainAnalysisServiceImpl.getStudent360()` 中被 `catch` 吞掉,仅记录 `log.warn`,导致返回体里 **没有 `portrait` 节点**。
3. 前端所有新增模块包在 `<div v-if="portrait">` 中,`portrait` 为 null 时整块不渲染,页面只剩老 360 内容 —— 即“旧信息仍在、新图表不出现”。
4. **次要原因(P1)**:`build_ui/dist` 为 2026-11-03 构建,产物中不存在任何新画像代码;若访问的是该静态站点,即使后端修好也只会看到旧页面。
5. **非缺陷项(P2)**:即使 `portrait` 正常返回,各图表自身还有数据门槛(雷达需 ≥3 个可用维度等),数据不足时会显示空态文案,属预期降级。
---
## 二、从组件引入到实际渲染的完整链路
```
源码 build_ui/src/views/train/portrait/* ✅ 新代码存在(render 树校验通过)
↓ 构建
dev server :1026(vue-cli-service serve) ✅ 从源码编译,随 HMR 更新
dist/index.html(2026-11-03 构建) ❌ 早于本次改动,产物中搜不到
"个人画像结论 / 个人能力雷达 / portrait-radar"
↓ 路由(sys_menu → train/portrait/index) ✅ 页面可打开(旧内容可见)
↓ 接口 getStudent360
后端 :8096(2026-11-08 22:39:52 启动,class 22:39) ✅ 新代码已加载
↓ StudentPortraitServiceImpl.getPortrait()
第 9 条 SQL selectDormContext ❌ Unknown column 'da.dorm_id'
↓ catch (Exception) 仅 log.warn ❌ 静默降级,portrait = null
↓ JSON 响应无 portrait 字段
前端 <div v-if="portrait"> ❌ 结论 / 三图 / 趋势 / 风险全部不渲染
↓ 各图表自身数据门槛 ⚠️ 数据不足时只显示空态
↓ echarts 容器宽高 ⚠️ 隐藏容器中初始化为 0×0
```
链路断点唯一且确定:**后端画像聚合 SQL 抛错 → 异常被吞 → `portrait` 缺失 → 前端整块 `v-if` 不成立**。
---
## 三、断点定位与证据
### 3.1 前端:所有新模块挂在 `v-if="portrait"` 之下
`src/views/train/portrait/index.vue:99`
```html
<!-- A2. 个人画像看板:全部数据仅属于当前学员本人,不展示任何其他学员信息 -->
<div v-if="portrait" class="portrait-extras">
```
该容器内依次包含:个人画像结论(综合分环、四项结论、标签、口径与数据截止)、三张图表卡(雷达 / 活跃度 / 目标达成)、七维画像明细、周趋势与跨班期、风险预警。`portrait` 为 null 时全部不产出 DOM。
### 3.2 后端:异常被吞,画像节点直接缺失
`RuoYi-Vue/ruoyi-train/.../service/impl/TrainAnalysisServiceImpl.java:510-520`
```java
if (portraitService != null)
{
try
{
vo.setPortrait(portraitService.getPortrait(studentId));
}
catch (Exception e)
{
log.warn("学员画像扩展数据生成失败, studentId={}, reason={}", studentId, e.getMessage());
}
}
```
`StudentPortraitServiceImpl.getPortrait()` 内部按顺序执行:
```java
fillAttendance(raw); fillStudy(raw); fillScores(raw, studentId);
fillExamCompletion(raw); fillDorm(raw, studentId);
fillClassBaseline(raw); fillTrend(raw);
StudentPortraitVo portrait = PortraitCalculator.build(raw);
```
**任一 `fillXxx` 抛错都会使整个画像构建失败**,当前只有 `saveSnapshot` 做了单条容错。
### 3.3 触发异常的具体 SQL
`RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainPortraitAnalyticsMapper.xml:137-150`
```xml
<select id="selectDormContext" resultType="java.util.HashMap">
select da.face_sync_status as face_sync_status,
da.dorm_id as dorm_id,
(select count(1)
from train_accommodation a2
where a2.dorm_id = da.dorm_id
and a2.enrollment_id &lt;&gt; #{enrollmentId}
and a2.accommodation_status &lt;&gt; 2) as dorm_mates,
(select max(a3.accommodation_status)
from train_accommodation a3
where a3.enrollment_id = #{enrollmentId}) as accommodation_status
from train_dorm_auth da
where da.student_id = #{studentId}
and da.plan_id = #{planId}
limit 1
</select>
```
这是 V1 旧结构写法。V2 中门禁授权通过 `accommodation_id` 关联(可参考旧 mapper 的既有正确写法 `TrainAnalysisMapper.xml:395-413`):
```xml
FROM train_accommodation a
JOIN train_enrollment e ON e.enrollment_id = a.enrollment_id
LEFT JOIN train_dormitory_info d ON d.dorm_id = a.dorm_id
LEFT JOIN train_dorm_auth auth ON auth.accommodation_id = a.accommodation_id
```
### 3.4 数据库层实证(本地库,只读)
逐表逐列核对画像 SQL 依赖,结论如下:
| 检查项 | 结果 |
|---|---|
| `train_theory_exam_score`(`is_final`/`plan_course_id`/`enrollment_id` 等) | 存在 |
| `train_student.extract_status`、`card_no`、`source_type`、`id_card_last4` | 存在 |
| `train_study_duration.resource_id`、`sync_time` | 存在 |
| `train_student_checkin.checkin_way`、`manual_flag` | 存在 |
| `train_course.c_duration`、`train_course_resource.c_id` | 存在 |
| `train_student_portrait`(快照表) | 存在 |
| **`train_dorm_auth.student_id / plan_id / dorm_id`** | **不存在** |
`train_dorm_auth` 实际列为:
`auth_id, accommodation_id, credential_type, credential_no, auth_status, retry_count, last_sync_time, last_error, face_sync_status, create_time, update_time`
直接执行该 SQL 的结果:
```
DORM_SQL_FAIL: Unknown column 'da.dorm_id' in 'field list'
```
即:**每打开一次画像页必然失败一次,与学员数据多少无关。**
### 3.5 前端产物陈旧(次要原因)
| 检查项 | 结果 |
|---|---|
| `build_ui/dist/index.html` 修改时间 | 2026-11-03(早于本次改动) |
| `dist` 中是否包含 `个人画像结论` / `个人能力雷达` / `portrait-radar` | 均未命中 |
| 前端 dev server | 存在,端口 1026,`vue-cli-service serve` |
| 端口 80 | 为 WAMP/Apache 默认站点,非本项目前端 |
若浏览器访问的是 `dist` 打包站点,则新组件根本不在产物中,必须重新 `npm run build`。
### 3.6 旧信息为何仍在
- 基本信息、考勤记录/明细、培训履历、成绩、住宿门禁由**另一条链路**(`analysisMapper` 老查询)生成,与 `portrait` 无关,因此不受影响、始终有内容。
- 老代码中“自主学习学时”本就是硬编码:
`TrainAnalysisServiceImpl.java:463-465`
```java
// 5. 自主学习状态 (严格不造假)
vo.setHasStudyData(false);
vo.setStudyStatusDesc("暂无自主学习记录 / 待外部平台数据同步");
```
新卡片读取的是 `portrait.dimensions[study]`;`portrait` 为 null 时新逻辑不执行,于是“旧文案仍在、新指标未出现”。
---
## 四、各组件显示条件
| 组件 / 区块 | 渲染条件 | 不满足时的表现 |
| --- | --- | --- |
| 整个画像看板 | `profile.portrait != null` | 整块消失(当前状态) |
| 综合分环形 + 四项结论 | 同上 | 同上 |
| 标签行 | `visibleTags.length > 0`(已剔除 `advantage` 组) | 不显示标签行 |
| 个人能力雷达 `PortraitRadar` | 维度中 `available === true` 且 `score != null` 的 **≥3 个** | “暂无可用的维度评分” |
| 活跃度环形 `PortraitActivity` | `activity.parts` 中 `included === true` 且 `score != null` 的 **≥1 个** | “暂无活跃度分项数据” |
| 个人目标达成 | `goalItems.length > 0`(学时/课次/考核完成度/出勤率任一有值) | “暂无可对照的计划目标” |
| 周趋势 `PortraitWeekly` | `trend.weekly.length > 0` | “暂无周维度学习与出勤记录” |
| 跨班期对比 `PortraitPeriods` | `trend.periods.length > 0` | “暂无跨班期记录” |
| 风险预警 | `riskRules.length > 0` | 卡片整体不渲染 |
**雷达最易凑不够 3 轴**:无课次→考勤不可用;无学习记录→学习投入不可用;无成绩→考核能力不可用;无课次→进度不可用。此时仅剩“身份与数据质量”“综合画像”,雷达退化为空态。属正常降级,不是渲染故障。
---
## 五、让新图表正常渲染的前提条件
### P0 必须满足(否则一定空白)
1. **修复门禁 SQL**:`selectDormContext` 改为 V2 关联方式,并按 `enrollmentId` 查询。
2. **后端生效**:修改 XML 后需重启 / 触发热加载(当前实例 22:39 启动)。
3. **前端最新**:
- 走 `:1026` dev server 时硬刷新(Ctrl+F5);
- 走 `dist` 静态站点时必须重新 `npm run build`。
### P1 强烈建议
4. **逐条容错**:在 `StudentPortraitServiceImpl` 的每个 `fillXxx` 外层加 try/catch,失败时置空并写入 `meta.warnings`,避免“一条 SQL 坏掉 → 整块画像消失”。
### P2 数据层面(决定“有图”还是“空态”)
5. 至少有 1 条参训记录(`train_enrollment`),否则返回空画像。
6. 有课次(`train_lesson`)→ 考勤/进度维度可用,雷达才可能凑够 3 轴。
7. 有学习时长(`train_study_duration`)→ 学习投入维度、周趋势学时柱有值。
8. 有成绩(`train_theory_exam_score`,`is_final = 1` 且 `del_flag = '0'`)→ 考核维度、考核均分虚线有值。
9. 有多期参训 → 跨班期对比有 ≥2 根柱(单期也可画,仅一根)。
### P3 渲染层面
10. echarts 容器需在可见状态下初始化;若被隐藏 tab / `v-show` 包裹,需重新触发 `resize`(组件已用 `ResizeObserver` 兜底)。
---
## 六、修复建议(未实施,待确认)
### 6.1 门禁 SQL 改为 V2 结构
```xml
<select id="selectDormContext" resultType="java.util.HashMap">
select da.face_sync_status as face_sync_status,
a.dorm_id as dorm_id,
(select count(1)
from train_accommodation a2
where a2.dorm_id = a.dorm_id
and a2.enrollment_id &lt;&gt; a.enrollment_id
and a2.accommodation_status &lt;&gt; 2) as dorm_mates,
a.accommodation_status as accommodation_status
from train_accommodation a
left join train_dorm_auth da on da.accommodation_id = a.accommodation_id
where a.enrollment_id = #{enrollmentId}
limit 1
</select>
```
同步调整:
- `TrainPortraitAnalyticsMapper.selectDormContext(...)` 入参改为 `@Param("enrollmentId") Long enrollmentId`;
- `StudentPortraitServiceImpl.fillDorm(...)` 改为传入 `raw.enrollmentId`。
### 6.2 画像构建逐条容错(建议)
```java
private void safeFill(String name, PortraitRawData raw, Runnable action) {
try { action.run(); }
catch (Exception e) { raw.warnings.add(name + " 数据获取失败,已按数据不足处理"); log.warn(...); }
}
```
调用侧:`safeFill("住宿门禁", raw, () -> fillDorm(raw, raw.enrollmentId));` —— 保证单个维度失败只降级,不隐藏整块画像。
---
## 七、快速自检步骤
1. F12 → Network → `student-360` 响应中搜索 `"portrait"`:
- 无该字段 → 后端断点(本文根因);
- 有但维度全空 → 数据问题,见第五节 P2。
2. 后端日志搜索 `学员画像扩展数据生成失败`,若包含 `Unknown column 'da.dorm_id'`,即本文根因。
3. 观察页面检索框:候选列表版 = 新前端;否则看的是旧产物(`dist`)。
---
## 八、附:本次核查使用的关键路径
| 用途 | 路径 |
| --- | --- |
| 画像页面 | `build_ui/src/views/train/portrait/index.vue` |
| 图表组件 | `build_ui/src/views/train/portrait/components/Portrait*.vue`、`chartMixin.js` |
| 画像接口 | `build_ui/src/api/train/analysis.js`(`getStudent360`) |
| 画像组装 | `RuoYi-Vue/ruoyi-train/.../service/impl/TrainAnalysisServiceImpl.java` |
| 画像计算 | `RuoYi-Vue/ruoyi-train/.../service/impl/StudentPortraitServiceImpl.java`、`PortraitCalculator.java` |
| 画像 SQL | `RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainPortraitAnalyticsMapper.xml` |
| 正确住宿/门禁关联参考 | `RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAnalysisMapper.xml` |
| 前端产物 | `build_ui/dist`(2026-11-03,陈旧) |
+22
View File
@@ -0,0 +1,22 @@
import request from '@/utils/request'
// 图书快照 train_book_snapshot
export function listBookSnapshot(query) {
return request({ url: '/device/booksnapshot/list', method: 'get', params: query })
}
export function getBookSnapshot(id) {
return request({ url: '/device/booksnapshot/' + id, method: 'get' })
}
export function addBookSnapshot(data) {
return request({ url: '/device/booksnapshot', method: 'post', data })
}
export function updateBookSnapshot(data) {
return request({ url: '/device/booksnapshot', method: 'put', data })
}
export function delBookSnapshot(id) {
return request({ url: '/device/booksnapshot/' + id, method: 'delete' })
}
@@ -0,0 +1,26 @@
import request from '@/utils/request'
// 查询报到一体机列表
export function listCheckinTerminal(query) {
return request({ url: '/device/checkinterminal/list', method: 'get', params: query })
}
// 查询报到一体机详情
export function getCheckinTerminal(id) {
return request({ url: '/device/checkinterminal/' + id, method: 'get' })
}
// 新增报到一体机
export function addCheckinTerminal(data) {
return request({ url: '/device/checkinterminal', method: 'post', data })
}
// 修改报到一体机
export function updateCheckinTerminal(data) {
return request({ url: '/device/checkinterminal', method: 'put', data })
}
// 删除报到一体机
export function delCheckinTerminal(id) {
return request({ url: '/device/checkinterminal/' + id, method: 'delete' })
}
+22
View File
@@ -0,0 +1,22 @@
import request from '@/utils/request'
// 读书机设备档案 train_iot_device
export function listIotDevice(query) {
return request({ url: '/device/iotdevice/list', method: 'get', params: query })
}
export function getIotDevice(id) {
return request({ url: '/device/iotdevice/' + id, method: 'get' })
}
export function addIotDevice(data) {
return request({ url: '/device/iotdevice', method: 'post', data })
}
export function updateIotDevice(data) {
return request({ url: '/device/iotdevice', method: 'put', data })
}
export function delIotDevice(id) {
return request({ url: '/device/iotdevice/' + id, method: 'delete' })
}
+50
View File
@@ -0,0 +1,50 @@
import request from '@/utils/request'
// 阅读机 / 听书机 设备侧 IoT 接口(BasePath: /api/iot/v1)
// 说明:登录接口需 HMAC 签名校验(X-Signature / X-Nonce),不走 RuoYi TokenFilter;
// 其余接口需 Authorization: Bearer {ruoyi-accessToken}。具体签名头由 request 拦截器或调用方注入。
// 1. 设备学员登录
export function deviceLogin(data) {
return request({ url: '/api/iot/v1/device/login', method: 'post', data })
}
// 2. 设备注销
export function deviceLogout(data) {
return request({ url: '/api/iot/v1/device/logout', method: 'post', data })
}
// 3. 设备心跳
export function deviceHeartbeat(data) {
return request({ url: '/api/iot/v1/device/heartbeat', method: 'post', data })
}
// 4. 阅读记录单条上报
export function readingRecord(data) {
return request({ url: '/api/iot/v1/reading/record', method: 'post', data })
}
// 5. 阅读记录批量上报(断网补传,单批最多 200 条)
export function readingRecordBatch(data) {
return request({ url: '/api/iot/v1/reading/record/batch', method: 'post', data })
}
// 6. 获取学员阅读汇总与续读进度
export function readingProgress(params) {
return request({ url: '/api/iot/v1/reading/progress', method: 'get', params })
}
// 7. 获取可阅读书籍列表(仅上架)
export function bookList(params) {
return request({ url: '/api/iot/v1/book/list', method: 'get', params })
}
// 8. PDF 书籍下载(支持 Range 断点续传)
export function bookDownload(params) {
return request({ url: '/api/iot/v1/book/download', method: 'get', params, responseType: 'blob' })
}
// 9. 下载完成回执上报
export function bookDownloadAck(data) {
return request({ url: '/api/iot/v1/book/download/ack', method: 'post', data })
}
@@ -0,0 +1,22 @@
import request from '@/utils/request'
// 备选冲突续读进度 train_reading_alternate_progress
export function listReadingAlternateProgress(query) {
return request({ url: '/device/readingalternateprogress/list', method: 'get', params: query })
}
export function getReadingAlternateProgress(id) {
return request({ url: '/device/readingalternateprogress/' + id, method: 'get' })
}
export function addReadingAlternateProgress(data) {
return request({ url: '/device/readingalternateprogress', method: 'post', data })
}
export function updateReadingAlternateProgress(data) {
return request({ url: '/device/readingalternateprogress', method: 'put', data })
}
export function delReadingAlternateProgress(id) {
return request({ url: '/device/readingalternateprogress/' + id, method: 'delete' })
}
@@ -0,0 +1,22 @@
import request from '@/utils/request'
// 学员主续读进度 train_reading_current_progress
export function listReadingCurrentProgress(query) {
return request({ url: '/device/readingcurrentprogress/list', method: 'get', params: query })
}
export function getReadingCurrentProgress(id) {
return request({ url: '/device/readingcurrentprogress/' + id, method: 'get' })
}
export function addReadingCurrentProgress(data) {
return request({ url: '/device/readingcurrentprogress', method: 'post', data })
}
export function updateReadingCurrentProgress(data) {
return request({ url: '/device/readingcurrentprogress', method: 'put', data })
}
export function delReadingCurrentProgress(id) {
return request({ url: '/device/readingcurrentprogress/' + id, method: 'delete' })
}
@@ -0,0 +1,22 @@
import request from '@/utils/request'
// 阅读上报幂等日志 train_reading_duration_log
export function listReadingDurationLog(query) {
return request({ url: '/device/readingdurationlog/list', method: 'get', params: query })
}
export function getReadingDurationLog(id) {
return request({ url: '/device/readingdurationlog/' + id, method: 'get' })
}
export function addReadingDurationLog(data) {
return request({ url: '/device/readingdurationlog', method: 'post', data })
}
export function updateReadingDurationLog(data) {
return request({ url: '/device/readingdurationlog', method: 'put', data })
}
export function delReadingDurationLog(id) {
return request({ url: '/device/readingdurationlog/' + id, method: 'delete' })
}
+22
View File
@@ -0,0 +1,22 @@
import request from '@/utils/request'
// 学员业务会话 train_user_session
export function listUserSession(query) {
return request({ url: '/device/usersession/list', method: 'get', params: query })
}
export function getUserSession(id) {
return request({ url: '/device/usersession/' + id, method: 'get' })
}
export function addUserSession(data) {
return request({ url: '/device/usersession', method: 'post', data })
}
export function updateUserSession(data) {
return request({ url: '/device/usersession', method: 'put', data })
}
export function delUserSession(id) {
return request({ url: '/device/usersession/' + id, method: 'delete' })
}
+9
View File
@@ -76,3 +76,12 @@ export function batchClear(authIds) {
data: authIds
})
}
// 人工对账:将不确定态的授权标记为操作员确认的终态(CONFIRMED/REJECTED)
export function reconcileAuth(authId, outcome, updateBy) {
return request({
url: '/lock/auth/' + authId + '/reconcile',
method: 'post',
params: { outcome, updateBy }
})
}
+35
View File
@@ -41,3 +41,38 @@ export function getStudent360(studentId) {
method: 'get'
})
}
// 获取成绩统计分析总览 KPI(理论 + 实训上报)
export function getScoreSummary(params) {
return request({
url: '/train/analysis/score/summary',
method: 'get',
params: params
})
}
// 获取成绩统计分析图表集合
export function getScoreCharts(params) {
return request({
url: '/train/analysis/score/charts',
method: 'get',
params: params
})
}
// 获取成绩统计指定维度的分组明细(支持图表/表格视图切换)
export function getScoreDimension(params) {
return request({
url: '/train/analysis/score/dimension',
method: 'get',
params: params
})
}
// 获取成绩统计筛选项(培训班 / 课程 / 实训上报平台)
export function getScoreFilterOptions() {
return request({
url: '/train/analysis/score/filters',
method: 'get'
})
}
+8
View File
@@ -17,6 +17,14 @@ export function getClassroom(classRoomId) {
})
}
// 按培训计划查询教室信息列表
export function listClassroomByPlan(planId) {
return request({
url: '/train/classroom/plan/' + planId,
method: 'get'
})
}
// 新增教室信息
export function addClassroom(data) {
return request({
+17
View File
@@ -26,6 +26,23 @@ export function addDormitory(data) {
})
}
// 修改宿舍信息
export function updateDormitory(data) {
return request({
url: '/train/dormitory/' + data.dormId,
method: 'put',
data: data
})
}
// 删除宿舍房间(支持批量,dormIds 为数组)
export function delDormitory(dormIds) {
return request({
url: '/train/dormitory/' + (Array.isArray(dormIds) ? dormIds.join(',') : dormIds),
method: 'delete'
})
}
// 获取楼栋、楼层级联下拉数据 (P0)
export function getDormitoryOptions() {
return request({
+10
View File
@@ -64,3 +64,13 @@ export function confirmReport(enrollmentId, data) {
silentErrorCodes: [40051]
})
}
// 5.2 报到重置(TrainRegisterApiController#resetReport)
// 解绑人脸 → 清除门锁入住授权 → 参训状态重置为未报到(0),宿舍分配保留
export function resetReport(planId, studentId) {
return request({
url: '/api/register/v1/report/reset',
method: 'post',
params: { planId, studentId }
})
}
+35
View File
@@ -0,0 +1,35 @@
import request from '@/utils/request'
// 查询外部实训上报成绩列表
export function listExamReport(query) {
return request({
url: '/train/examReport/list',
method: 'get',
params: query
})
}
// 查询上报成绩详情
export function getExamReport(reportPk) {
return request({
url: '/train/examReport/' + reportPk,
method: 'get'
})
}
// 认领待匹配成绩
export function claimExamReport(data) {
return request({
url: '/train/examReport/claim',
method: 'post',
data: data
})
}
// 删除上报成绩
export function delExamReport(reportPks) {
return request({
url: '/train/examReport/' + reportPks,
method: 'delete'
})
}
+44
View File
@@ -0,0 +1,44 @@
import request from '@/utils/request'
// 查询考试科目列表
export function listExamSubject(query) {
return request({
url: '/train/examSubject/list',
method: 'get',
params: query
})
}
// 查询考试科目详细
export function getExamSubject(subjectId) {
return request({
url: '/train/examSubject/' + subjectId,
method: 'get'
})
}
// 新增考试科目
export function addExamSubject(data) {
return request({
url: '/train/examSubject',
method: 'post',
data: data
})
}
// 修改考试科目
export function updateExamSubject(data) {
return request({
url: '/train/examSubject',
method: 'put',
data: data
})
}
// 删除考试科目
export function delExamSubject(subjectIds) {
return request({
url: '/train/examSubject/' + subjectIds,
method: 'delete'
})
}
+26
View File
@@ -0,0 +1,26 @@
import request from '@/utils/request'
// 历史报到统计汇总(累计报到人次/平均报到率/平均到训耗时/最早最晚到达/到训时间分布)
export function getRegHistorySummary() {
return request({
url: '/train/regdata/history/summary',
method: 'get'
})
}
// 各批次报到率趋势(近 N 个批次)
export function listBatchTrend() {
return request({
url: '/train/regdata/history/batchTrend',
method: 'get'
})
}
// 分页查询各送培单位报到情况(按平均报到率降序)
export function listUnitReport(query) {
return request({
url: '/train/regdata/history/units',
method: 'get',
params: query
})
}
+10
View File
@@ -0,0 +1,10 @@
import request from '@/utils/request'
// 查询智能门锁设备列表(来自 ruoyi-iot 模块的 lock_device 表)
export function listLockDevice(query) {
return request({
url: '/lock/device/list',
method: 'get',
params: query
})
}
+69
View File
@@ -0,0 +1,69 @@
import request from '@/utils/request'
// 查询外部应用列表
export function listOpenApp(query) {
return request({
url: '/train/openApp/list',
method: 'get',
params: query
})
}
// 查询外部应用详细
export function getOpenApp(appId) {
return request({
url: '/train/openApp/' + appId,
method: 'get'
})
}
// 新增外部应用(返回一次性明文密钥)
export function addOpenApp(data) {
return request({
url: '/train/openApp',
method: 'post',
data: data
})
}
// 修改外部应用
export function updateOpenApp(data) {
return request({
url: '/train/openApp',
method: 'put',
data: data
})
}
// 删除外部应用
export function delOpenApp(appIds) {
return request({
url: '/train/openApp/' + appIds,
method: 'delete'
})
}
// 重置应用密钥(返回一次性明文密钥)
export function resetOpenAppSecret(appId) {
return request({
url: '/train/openApp/resetSecret/' + appId,
method: 'post'
})
}
// 查询应用授权范围
export function listOpenAppScope(appId) {
return request({
url: '/train/openApp/scope/' + appId,
method: 'get'
})
}
// 保存应用授权范围(全量覆盖)
export function saveOpenAppScope(appId, scopes) {
return request({
url: '/train/openApp/scope/' + appId,
method: 'put',
data: scopes
})
}
+8
View File
@@ -42,3 +42,11 @@ export function completePlan(planId) {
method: 'post'
})
}
// 删除开班计划
export function delPlan(planId) {
return request({
url: '/train/plans/' + planId,
method: 'delete'
})
}
+46
View File
@@ -0,0 +1,46 @@
import request from '@/utils/request'
// 查询有报到任务的班级(页面顶部班级标签)
export function listReportClasses(query) {
return request({
url: '/train/regdata/classes',
method: 'get',
params: query
})
}
// 查询班级报到统计汇总(应到/已到/未到/报到率/报到时间分布)
export function getReportSummary(planId) {
return request({
url: '/train/regdata/summary',
method: 'get',
params: { planId: planId }
})
}
// 分页查询未报到学员
export function listNotReported(query) {
return request({
url: '/train/regdata/notReported',
method: 'get',
params: query
})
}
// 分页查询报到名册
export function listReportRoster(query) {
return request({
url: '/train/regdata/roster',
method: 'get',
params: query
})
}
// 电话催促(studentIds 为空表示催促该班全部未报到学员)
export function urgeReport(data) {
return request({
url: '/train/regdata/urge',
method: 'post',
data: data
})
}
+37
View File
@@ -14,3 +14,40 @@ export function getReport(reportId) {
method: 'get'
})
}
// 第一步:查询即将开班的班级列表(含课程编码/专业、班主任、开课时间/教室、应到与待报到人数)
export function listRegisterClasses(query) {
return request({
url: '/train/report/classes',
method: 'get',
params: query
})
}
// 第三步:查询可用的报到设备列表(电子班牌/报到终端等)
export function listRegisterDevices(query) {
return request({
url: '/train/report/devices',
method: 'get',
params: query
})
}
// 查看报到任务列表
export function listRegisterTasks(query) {
return request({
url: '/train/report/tasks',
method: 'get',
params: query
})
}
// 启动班级报到:提交所选班级、报到起止时间与报到设备
export function startClassRegister(data) {
return request({
url: '/train/report/start',
method: 'post',
data: data
})
}
+53
View File
@@ -0,0 +1,53 @@
import request from '@/utils/request'
// 查询班级座位列表
export function listClassSeat(query) {
return request({
url: '/train/classseat/list',
method: 'get',
params: query
})
}
// 查询班级座位详细
export function getClassSeat(seatId) {
return request({
url: '/train/classseat/' + seatId,
method: 'get'
})
}
// 新增班级座位
export function addClassSeat(data) {
return request({
url: '/train/classseat',
method: 'post',
data: data
})
}
// 修改班级座位
export function updateClassSeat(data) {
return request({
url: '/train/classseat/' + data.seatId,
method: 'put',
data: data
})
}
// 删除班级座位
export function delClassSeat(seatIds) {
return request({
url: '/train/classseat/' + seatIds,
method: 'delete'
})
}
// 整体保存座位布局(删除 (planId, classroomId) 下原有座位后批量写入)
export function saveClassSeatLayout(data) {
return request({
url: '/train/classseat/layout',
method: 'post',
data: data
})
}
+7
View File
@@ -8,6 +8,13 @@ export function listSite(query) {
params: query
})
}
// 按培训计划查询实训场地列表
export function listSiteByPlan(planId) {
return request({
url: '/train/training-site/plan/' + planId,
method: 'get'
})
}
// 查询实训场地详细
export function getSite(siteId) {
+54
View File
@@ -35,6 +35,14 @@ export function updateStudent(data) {
})
}
// 生成唯一人员编号(AA + 6位数字)
export function genPersonNo() {
return request({
url: '/train/student/gen-person-no',
method: 'get'
})
}
// 查询可关联学员的系统用户候选列表
export function getStudentBindableUsers(keyword) {
return request({
@@ -68,3 +76,49 @@ export function delStudent(sId) {
method: 'delete'
})
}
// 触发特征提取(deviceId 指定目标门锁,取其维护的 algorithm_version)
export function extract(sId, deviceId) {
return request({
url: '/train/student/' + sId + '/extract',
method: 'post',
params: { deviceId: deviceId }
})
}
// 触发特征提取(deviceId 指定目标门锁,取其维护的 algorithm_version)
export function extractStudent(sId, deviceId) {
return request({
url: '/train/student/' + sId + '/extractStudent',
method: 'post',
params: { deviceId: deviceId }
})
}
// 学员批量导入(multipart/form-data,FormData 中的字段名必须是 file)
// 注意:request.js 里把 axios 默认头设置成了 application/json,
// axios 0.30 的 transformRequest 遇到「FormData + JSON 头」会把表单转成 JSON 字符串,
// 导致后端报 "Current request is not a multipart request"。
// 因此这里必须显式声明 multipart/form-data;浏览器会自动补上 boundary。
export function importStudentData(data) {
return request({
url: '/train/student/importData',
method: 'post',
data: data,
// repeatSubmit:false 关闭重复提交拦截:FormData 被 JSON.stringify 后固定是 {},
// 连续两次上传会被误判为重复提交。
headers: { 'Content-Type': 'multipart/form-data', repeatSubmit: false },
timeout: 60000
})
}
// 批量特征提取
export function batchExtract(sIds, deviceId) {
return request({
url: '/train/student/batch/extract',
method: 'post',
params: { deviceId: deviceId },
data: sIds
})
}
@@ -0,0 +1,45 @@
import request from '@/utils/request'
// 批量查询教师当前住宿安排(教师列表页住宿列回显,teacherIds 以逗号分隔)
export function listTeacherAccommodation(teacherIds) {
return request({
url: '/train/teacher/accommodation/list',
method: 'get',
params: {
teacherIds: Array.isArray(teacherIds) ? teacherIds.join(',') : teacherIds
}
})
}
// 获取教师当前住宿安排
export function getTeacherAccommodation(teacherId) {
return request({
url: '/train/teacher/accommodation/' + teacherId,
method: 'get'
})
}
// 获取教师住宿历史记录(含已退宿)
export function getTeacherAccommodationHistory(teacherId) {
return request({
url: '/train/teacher/accommodation/' + teacherId + '/history',
method: 'get'
})
}
// 教师住宿分配 / 调整宿舍(教师住宿与班级无关)
export function assignTeacherAccommodation(data) {
return request({
url: '/train/teacher/accommodation',
method: 'post',
data: data
})
}
// 教师退宿:后端执行门锁退宿撤权 + 人脸库删除(不清空教师头像)
export function checkOutTeacherAccommodation(accommodationId) {
return request({
url: '/train/teacher/accommodation/' + accommodationId + '/checkout',
method: 'post'
})
}
+55
View File
@@ -0,0 +1,55 @@
import request from '@/utils/request'
// 查询班级学员理论考试成绩列表
export function listTheoryExamScore(query) {
return request({
url: '/train/theoryExamScore/list',
method: 'get',
params: query
})
}
// 查询班级学员理论考试成绩详情
export function getTheoryExamScore(theoryScoreId) {
return request({
url: '/train/theoryExamScore/' + theoryScoreId,
method: 'get'
})
}
// 新增班级学员理论考试成绩
export function addTheoryExamScore(data) {
return request({
url: '/train/theoryExamScore',
method: 'post',
data: data
})
}
// 修改班级学员理论考试成绩
export function updateTheoryExamScore(data) {
return request({
url: '/train/theoryExamScore',
method: 'put',
data: data
})
}
// 删除班级学员理论考试成绩
export function delTheoryExamScore(theoryScoreId) {
return request({
url: '/train/theoryExamScore/' + theoryScoreId,
method: 'delete'
})
}
// 批量导入理论考试成绩(multipart:file + planId + planCourseId + importMode)
export function importTheoryExamScore(data) {
return request({
url: '/train/theoryExamScore/importData',
method: 'post',
data: data,
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 120000
})
}
+5
View File
@@ -35,10 +35,15 @@ export function removeLesson(lessonId) {
}
// 4.4 排课冲突检查接口 (UC-10) 【P0】
// 该接口为幂等只读查询:自动预检(选择教师/时间/场地触发)与保存前复核
// 可能在 1s 内以完全相同的入参重复调用,会被 request 拦截器判定为重复提交并
// 抛出“数据正在处理,请勿重复提交”。此处关闭防重拦截(与 importStudentData 同理),
// 保存接口 addLesson/updateLesson 的防重提交能力不受影响。
export function checkLessonConflict(data) {
return request({
url: '/train/lessons/check-conflict',
method: 'post',
headers: { repeatSubmit: false },
data: data
})
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 509 KiB

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

+149
View File
@@ -482,3 +482,152 @@
position: relative;
float: right;
}
/* 顶部静态布局样式 */
.page-topbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.topbar-left {
display: flex;
align-items: baseline;
gap: 12px;
}
.topbar-title {
margin: 0;
font-size: 20px;
font-weight: 700;
color: #1f2d3d;
}
.topbar-sub {
font-size: 13px;
color: #8a94a6;
}
}
.topbar-filter {
display: flex;
align-items: flex-end;
gap: 16px;
flex-wrap: wrap;
background: #fff;
border: 1px solid #eef0f5;
border-radius: 10px;
padding: 16px 20px;
margin-bottom: 16px;
.filter-item {
.filter-label {
font-size: 12px;
color: #8a94a6;
margin-bottom: 6px;
}
}
.filter-keyword {
flex: 1;
min-width: 260px;
}
.filter-buttons {
display: flex;
gap: 8px;
.el-button + .el-button {
margin-left: 0;
}
}
}
/* ==================== 通用详情弹窗 ==================== */
/* 教室 / 宿舍 / 场地等实体详情统一采用与学员详情一致的描述列表风格 */
.detail-dialog {
.el-dialog__body {
padding: 20px 24px 8px;
}
/* 属性名列按名称长度自适应,禁止折行 */
.el-descriptions-item__label {
white-space: nowrap;
word-break: keep-all;
}
.el-descriptions-item__content {
word-break: break-word;
}
}
/* 详情内容区:内容过高时滚动,避免弹窗超出屏幕 */
.detail-dialog-body {
max-height: 62vh;
overflow-y: auto;
padding-right: 4px;
}
/* 详情属性名标签 */
.detail-dialog-label {
display: inline-block;
min-width: 64px;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
/* 详情内分组标题 */
.detail-dialog-section {
margin: 18px 0 8px;
font-size: 14px;
font-weight: 700;
color: #1f2d3d;
}
/* 响应式:小屏下弹窗收窄、属性名允许折行 */
@media (max-width: 900px) {
.detail-dialog {
width: 92% !important;
margin-top: 8vh !important;
}
.detail-dialog .detail-dialog-label {
max-width: none;
white-space: normal;
}
}
@media (max-width: 640px) {
.detail-dialog {
width: 96% !important;
}
.detail-dialog .el-descriptions-item__label {
white-space: normal;
}
.detail-dialog-body {
max-height: 68vh;
}
}
/* 班级详情弹窗(只读):窄屏自适应,避免固定宽度溢出 */
@media (max-width: 1200px) {
.class-detail-dialog {
width: 94% !important;
}
}
@media (max-width: 768px) {
.class-detail-dialog {
width: 96% !important;
margin-top: 4vh !important;
}
.class-detail-dialog .el-descriptions .el-descriptions__table {
table-layout: fixed;
}
}
+4 -4
View File
@@ -9,17 +9,17 @@ $yellow:#FEC171;
$panGreen: #30B08F;
// 默认菜单主题风格
$base-menu-color: #bfcbd9;
$base-menu-color: #e8ecf3;
$base-menu-color-active: #ffffff;
$base-menu-background: #1a1f2e;
$base-menu-background: #2c3e5d;
$base-logo-title-color: #ffffff;
$base-menu-light-color:rgba(0,0,0,.70);
$base-menu-light-background:#ffffff;
$base-logo-light-title-color: #001529;
$base-sub-menu-background: #141824;
$base-sub-menu-hover: rgba(255,255,255,.06);
$base-sub-menu-background: #243352;
$base-sub-menu-hover: rgba(255,255,255,.08);
$base-sidebar-width: 200px;
+2 -2
View File
@@ -26,7 +26,7 @@ module.exports = {
//target: "http://121.196.246.126:38085",
//target: "http://10.1.191.245:8085", //changchun
target: "http://localhost:8090",
target: "http://127.0.0.1:8096",
changeOrigin: true,
pathRewriter: {
'^/api': '/api',
@@ -48,7 +48,7 @@ module.exports = {
//apiUrl: 'http://121.196.246.126:38085/',
apiUrl: 'http://localhost:8090/',
apiUrl: 'http://127.0.0.1:8096/',
//jsonPath: 'D:\\www\\idea-workspace\\2024_kg_v1.1_kj\\build_ui\\public\\files\\',
//jsonPath: '/opt/kg/apps/dist/files/',
+64
View File
@@ -0,0 +1,64 @@
// see http://vuejs-templates.github.io/webpack for documentation.
var path = require('path')
module.exports = {
build: {
env: require('./prod.env'),
index: path.resolve(__dirname, '../dist/index.html'),
assetsRoot: path.resolve(__dirname, '../dist'),
assetsSubDirectory: 'static',
assetsPublicPath: '/',
productionSourceMap: false,
// Gzip off by default as many popular static hosts such as
// Surge or Netlify already gzip all static assets for you.
// Before setting to `true`, make sure to:
// npm install --save-dev compression-webpack-plugin
productionGzip: false,
productionGzipExtensions: ['js', 'css']
},
dev: {
env: require('./dev.env'),
port: 8081,
assetsSubDirectory: 'static',
assetsPublicPath: '/',
proxyTable: {
'/api': {
//target: "http://121.196.246.126:38085",
//target: "http://10.1.191.245:8085", //changchun
target: "http://192.168.1.230:8096",
changeOrigin: true,
pathRewriter: {
'^/api': '/api',
}
}
},
// CSS Sourcemaps off by default because relative paths are "buggy"
// with this option, according to the CSS-Loader README
// (https://github.com/webpack/css-loader#sourcemaps)
// In our experience, they generally work as expected,
// just be aware of this issue when enabling this option.
cssSourceMap: false
},
setting: {
neo4jUrl: 'bolt://localhost:7687/ ',
//neo4jUrl: 'bolt://121.196.246.126:7687/ ',
neo4jUserName: 'neo4j',
neo4jPassword: 'neo4j',
//apiUrl: 'http://121.196.246.126:38085/',
apiUrl: 'http://192.168.1.230:8096/',
//jsonPath: 'D:\\www\\idea-workspace\\2024_kg_v1.1_kj\\build_ui\\public\\files\\',
//jsonPath: '/opt/kg/apps/dist/files/',
jsonPath: '/opt/kg/apps/uploadfiles/',
//dpApiUrl: 'http://121.196.246.126:38084/',
//dpApiUrl: 'http://10.1.191.245:8085/',
dpApiUrl: process.env.VUE_APP_DP_API_URL || '',
dpUsername: process.env.VUE_APP_DP_USERNAME || '',
dpPassword: process.env.VUE_APP_DP_PASSWORD || '',
}
}
+64
View File
@@ -0,0 +1,64 @@
// see http://vuejs-templates.github.io/webpack for documentation.
var path = require('path')
module.exports = {
build: {
env: require('./prod.env'),
index: path.resolve(__dirname, '../dist/index.html'),
assetsRoot: path.resolve(__dirname, '../dist'),
assetsSubDirectory: 'static',
assetsPublicPath: '/',
productionSourceMap: false,
// Gzip off by default as many popular static hosts such as
// Surge or Netlify already gzip all static assets for you.
// Before setting to `true`, make sure to:
// npm install --save-dev compression-webpack-plugin
productionGzip: false,
productionGzipExtensions: ['js', 'css']
},
dev: {
env: require('./dev.env'),
port: 8081,
assetsSubDirectory: 'static',
assetsPublicPath: '/',
proxyTable: {
'/api': {
//target: "http://121.196.246.126:38085",
//target: "http://10.1.191.245:8085", //changchun
target: "http://192.168.106.24:8096",
changeOrigin: true,
pathRewriter: {
'^/api': '/api',
}
}
},
// CSS Sourcemaps off by default because relative paths are "buggy"
// with this option, according to the CSS-Loader README
// (https://github.com/webpack/css-loader#sourcemaps)
// In our experience, they generally work as expected,
// just be aware of this issue when enabling this option.
cssSourceMap: false
},
setting: {
neo4jUrl: 'bolt://localhost:7687/ ',
//neo4jUrl: 'bolt://121.196.246.126:7687/ ',
neo4jUserName: 'neo4j',
neo4jPassword: 'neo4j',
//apiUrl: 'http://121.196.246.126:38085/',
apiUrl: 'http://192.168.106.24:8096/',
//jsonPath: 'D:\\www\\idea-workspace\\2024_kg_v1.1_kj\\build_ui\\public\\files\\',
//jsonPath: '/opt/kg/apps/dist/files/',
jsonPath: '/opt/kg/apps/uploadfiles/',
//dpApiUrl: 'http://121.196.246.126:38084/',
//dpApiUrl: 'http://10.1.191.245:8085/',
dpApiUrl: process.env.VUE_APP_DP_API_URL || '',
dpUsername: process.env.VUE_APP_DP_USERNAME || '',
dpPassword: process.env.VUE_APP_DP_PASSWORD || '',
}
}
+64
View File
@@ -0,0 +1,64 @@
// see http://vuejs-templates.github.io/webpack for documentation.
var path = require('path')
module.exports = {
build: {
env: require('./prod.env'),
index: path.resolve(__dirname, '../dist/index.html'),
assetsRoot: path.resolve(__dirname, '../dist'),
assetsSubDirectory: 'static',
assetsPublicPath: '/',
productionSourceMap: false,
// Gzip off by default as many popular static hosts such as
// Surge or Netlify already gzip all static assets for you.
// Before setting to `true`, make sure to:
// npm install --save-dev compression-webpack-plugin
productionGzip: false,
productionGzipExtensions: ['js', 'css']
},
dev: {
env: require('./dev.env'),
port: 8081,
assetsSubDirectory: 'static',
assetsPublicPath: '/',
proxyTable: {
'/api': {
//target: "http://121.196.246.126:38085",
//target: "http://10.1.191.245:8085", //changchun
target: "http://127.0.0.1:8096",
changeOrigin: true,
pathRewriter: {
'^/api': '/api',
}
}
},
// CSS Sourcemaps off by default because relative paths are "buggy"
// with this option, according to the CSS-Loader README
// (https://github.com/webpack/css-loader#sourcemaps)
// In our experience, they generally work as expected,
// just be aware of this issue when enabling this option.
cssSourceMap: false
},
setting: {
neo4jUrl: 'bolt://localhost:7687/ ',
//neo4jUrl: 'bolt://121.196.246.126:7687/ ',
neo4jUserName: 'neo4j',
neo4jPassword: 'neo4j',
//apiUrl: 'http://121.196.246.126:38085/',
apiUrl: 'http://127.0.0.1:8096/',
//jsonPath: 'D:\\www\\idea-workspace\\2024_kg_v1.1_kj\\build_ui\\public\\files\\',
//jsonPath: '/opt/kg/apps/dist/files/',
jsonPath: '/opt/kg/apps/uploadfiles/',
//dpApiUrl: 'http://121.196.246.126:38084/',
//dpApiUrl: 'http://10.1.191.245:8085/',
dpApiUrl: process.env.VUE_APP_DP_API_URL || '',
dpUsername: process.env.VUE_APP_DP_USERNAME || '',
dpPassword: process.env.VUE_APP_DP_PASSWORD || '',
}
}
+2 -2
View File
@@ -9,7 +9,7 @@
<top-bar id="topbar-container" class="topbar-container" />
</template>
<div class="right-menu">
<template v-if="device!=='mobile'">
<!-- <template v-if="device!=='mobile'">
<search id="header-search" class="right-menu-item" />
<el-tooltip content="源码地址" effect="dark" placement="bottom">
@@ -30,7 +30,7 @@
<header-notice id="header-notice" class="right-menu-item hover-effect" />
</el-tooltip>
</template>
</template>-->
<el-dropdown class="avatar-container right-menu-item hover-effect" trigger="hover">
<div class="avatar-wrapper">
@@ -59,7 +59,7 @@ export default {
position: relative;
height: 50px;
line-height: 50px;
background: #2b2f3a;
background: #243352;
text-align: center;
overflow: hidden;
+2 -2
View File
@@ -7,7 +7,7 @@ module.exports = {
/**
* 侧边栏主题 深色主题theme-dark,浅色主题theme-light
*/
sideTheme: 'theme-light',
sideTheme: 'theme-dark',
/**
* 系统布局配置
@@ -22,7 +22,7 @@ module.exports = {
/**
* 是否显示 tagsView
*/
tagsView: true,
tagsView: false,
/**
* 持久化标签页
+138
View File
@@ -0,0 +1,138 @@
import { listPlanEnrollments } from '@/api/train/enrollment'
/**
* 报到数据统一口径工具(数据源:train_enrollment)。
*
* 后端仅提供 /train/plans/{planId}/enrollments 这一分页接口返回 train_enrollment 行数据
* (EnrollmentItemVO:enrollmentStatus / joinTime / completeTime / deptName ...),
* 报到状态一律以 enrollment_status 为准,不再使用 train_class_report 的报到单事实。
*
* 页面需要跨页统计与本地分页,因此这里负责把分页结果合并为全量数组并做聚合。
*/
/** 放弃(3)不计入应报到人数,与后端 effectiveEnrollmentCondition 口径一致 */
export const EXCLUDED_STATUS = 3
/** 已报到 */
export const REPORTED_STATUS = 1
/** 待报到 */
export const WAITING_STATUS = 0
/**
* 解析后端 yyyy-MM-dd HH:mm:ss 字符串。
* 不使用 new Date(string) 以避免 Safari 对 '-' 分隔的时间串解析失败。
*/
export function parseDateTime(value) {
if (!value) return null
if (value instanceof Date) return value
const matched = String(value).match(/(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?/)
if (!matched) return null
return new Date(
Number(matched[1]),
Number(matched[2]) - 1,
Number(matched[3]),
Number(matched[4]),
Number(matched[5]),
Number(matched[6] || 0)
)
}
/** HH:mm */
export function formatClock(value) {
const date = parseDateTime(value)
if (!date) return ''
const pad = num => (num < 10 ? '0' + num : String(num))
return pad(date.getHours()) + ':' + pad(date.getMinutes())
}
/** 时刻均值(分钟数数组)转 HH:mm */
export function averageClock(minutes) {
if (!minutes || !minutes.length) return ''
const sum = minutes.reduce((acc, item) => acc + (Number(item) || 0), 0)
const avg = Math.round(sum / minutes.length)
const pad = num => (num < 10 ? '0' + num : String(num))
return pad(Math.floor(avg / 60)) + ':' + pad(avg % 60)
}
/** 百分比保留 1 位小数,分母为 0 时返回 0 */
export function rateOf(part, total) {
const denominator = Number(total) || 0
if (denominator <= 0) return 0
return Math.round((Number(part) || 0) * 1000 / denominator) / 10
}
/** 剔除已放弃(3)的参训记录 */
export function filterEffective(rows) {
return (rows || []).filter(row => row && Number(row.enrollmentStatus) !== EXCLUDED_STATUS)
}
/** 按报到时间(join_time)的整点小时汇总人数 */
export function buildHourly(rows) {
const counter = {}
;(rows || []).forEach(row => {
const date = parseDateTime(row && row.joinTime)
if (!date) return
const hour = date.getHours()
counter[hour] = (counter[hour] || 0) + 1
})
return Object.keys(counter)
.map(key => Number(key))
.sort((a, b) => a - b)
.map(hour => ({ hour: (hour < 10 ? '0' + hour : String(hour)) + ':00', count: counter[hour] }))
}
/** 生成报到高峰时段说明 */
export function buildPeakNote(hourly) {
if (!hourly || !hourly.length) return ''
let peak = hourly[0]
hourly.forEach(item => {
if ((item.count || 0) > (peak.count || 0)) peak = item
})
if (!peak.count) return ''
return '报到高峰时段 ' + peak.hour + '(' + peak.count + ' 人)'
}
/**
* 汇总单批次报到情况。
* @param {Array} rows train_enrollment 行数据
* @returns {{expected:number, reported:number, notReported:number, rate:number, hourly:Array, peakNote:string}}
*/
export function summarizeEnrollments(rows) {
const effective = filterEffective(rows)
const reportedRows = effective.filter(row => Number(row.enrollmentStatus) === REPORTED_STATUS)
const waitingRows = effective.filter(row => Number(row.enrollmentStatus) === WAITING_STATUS)
const hourly = buildHourly(reportedRows)
return {
expected: effective.length,
reported: reportedRows.length,
notReported: waitingRows.length,
rate: rateOf(reportedRows.length, effective.length),
hourly,
peakNote: buildPeakNote(hourly)
}
}
/**
* 分批拉取某培训计划的全部参训记录(train_enrollment)。
* 先取第一页拿到 total,再并行补齐剩余页,避免一次性依赖超大 pageSize。
*
* @param {string} planId 培训计划ID
* @param {object} extraQuery 额外查询条件(studentName / enrollmentStatus)
* @param {number} pageSize 每页条数
* @returns {Promise<Array>} 全量参训记录
*/
export function fetchPlanEnrollments(planId, extraQuery, pageSize) {
if (!planId) return Promise.resolve([])
const size = pageSize || 500
const load = pageNum => listPlanEnrollments(planId, { pageNum: pageNum, pageSize: size, ...(extraQuery || {}) })
return load(1).then(res => {
const rows = (res.rows || []).slice()
const total = Number(res.total) || rows.length
const pages = Math.ceil(total / size)
if (pages <= 1) return rows
const rest = []
for (let page = 2; page <= pages; page++) {
rest.push(load(page))
}
return Promise.all(rest).then(list => list.reduce((acc, item) => acc.concat(item.rows || []), rows))
})
}
+779
View File
@@ -0,0 +1,779 @@
<template>
<div class="app-container">
<!-- 顶部静态布局:标题栏 -->
<div class="page-topbar">
<div class="topbar-left">
<h2 class="topbar-title">图书快照</h2>
<span class="topbar-sub">在册 {{ allTotal }} 本</span>
</div>
<div class="topbar-actions">
<el-button icon="el-icon-download" size="small" @click="handleExport">导出图书台账</el-button>
<el-button type="primary" icon="el-icon-plus" size="small" @click="handleAdd">新增图书</el-button>
</div>
</div>
<div class="student-body">
<!-- 左侧:按图书类别筛选(仅高亮切换,不参与查询条件) -->
<div class="class-panel">
<div class="class-header">
<span class="class-title">按图书类别筛选</span>
<span class="class-badge">共 {{ dict.type.train_book_category.length }} 类</span>
</div>
<div class="class-list">
<div :class="['class-item', { active: activeCategory === 'all' }]" @click="selectCategory('all')">
<span class="class-name">全部分类</span>
<span class="class-count">{{ allTotal }}</span>
</div>
<div
v-for="cls in dict.type.train_book_category"
:key="cls.value"
:class="['class-item', { active: activeCategory === cls.value }]"
@click="selectCategory(cls.value)"
>
<span class="class-name" :title="cls.label">{{ cls.label }}</span>
<span class="class-count">{{ categoryCounts[cls.value] || 0 }}</span>
</div>
</div>
</div>
<div class="student-main">
<!-- 顶部静态布局:筛选卡片 -->
<div class="topbar-filter">
<el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true" label-width="80px">
<el-form-item label="书名" prop="bookTitle">
<el-input
v-model="queryParams.bookTitle"
placeholder="请输入书名"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<!-- <el-form-item label="图书分类" prop="bookCategory">
<el-select v-model="queryParams.bookCategory" placeholder="图书分类" clearable>
<el-option
v-for="dict in dict.type.train_book_category"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>-->
<el-form-item label="上架状态" prop="isOnShelf">
<el-select v-model="queryParams.isOnShelf" placeholder="上架状态" clearable>
<el-option label="上架" :value="1" />
<el-option label="下架" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</div>
<el-table v-loading="loading" :data="bookList">
<el-table-column label="ID" align="center" prop="id" width="80" />
<el-table-column label="图书ID" align="center" prop="bookId" min-width="140" show-overflow-tooltip />
<el-table-column label="封皮" align="center" width="90">
<template slot-scope="scope">
<img v-if="getCoverUrl(scope.row.bookCover)" :src="getCoverUrl(scope.row.bookCover)" class="cover-thumb" alt="封皮" />
<span v-else class="cover-thumb-empty">无</span>
</template>
</el-table-column>
<el-table-column label="书名" align="center" prop="bookTitle" min-width="200" show-overflow-tooltip />
<el-table-column label="分类" align="center" prop="bookCategory" width="130">
<template slot-scope="scope">
<dict-tag :options="dict.type.train_book_category" :value="scope.row.bookCategory" />
</template>
</el-table-column>
<el-table-column label="版本" align="center" prop="bookVersion" width="100" />
<el-table-column label="文件大小(字节)" align="center" prop="fileSize" min-width="120" />
<el-table-column label="上架状态" align="center" prop="isOnShelf" width="90">
<template slot-scope="scope">
<el-tag :type="scope.row.isOnShelf === 1 ? 'success' : 'info'">
{{ scope.row.isOnShelf === 1 ? '上架' : '下架' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="发布时间" align="center" prop="publishTime" min-width="150" />
<!-- <el-table-column label="创建时间" align="center" prop="createTime" min-width="150" />-->
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="160">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:bookSnapshot:edit']"
size="mini"
type="text"
icon="el-icon-edit"
@click="handleUpdate(scope.row)"
>修改</el-button>
<el-button
v-hasPermi="['train:bookSnapshot:remove']"
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
</div>
</div>
<!-- 添加或修改图书快照对话框 -->
<el-dialog :title="title" :visible.sync="open" width="620px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="120px">
<el-form-item label="图书ID" prop="bookId">
<el-input v-model="form.bookId" placeholder="图书永久ID,永不复用" maxlength="64" />
</el-form-item>
<el-form-item label="书名" prop="bookTitle">
<el-input v-model="form.bookTitle" placeholder="请输入书名" maxlength="512" />
</el-form-item>
<el-form-item label="图书分类" prop="bookCategory">
<el-select v-model="form.bookCategory" placeholder="请选择图书分类" style="width: 100%">
<el-option
v-for="dict in dict.type.train_book_category"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="版本" prop="bookVersion">
<el-input v-model="form.bookVersion" placeholder="如 BV-03" maxlength="32" />
</el-form-item>
<el-form-item label="简介" prop="bookSummary">
<el-input v-model="form.bookSummary" type="textarea" :rows="3" placeholder="请输入图书简介" />
</el-form-item>
<el-form-item label="文件大小(字节)" prop="fileSize">
<el-input-number v-model="form.fileSize" :min="0" style="width: 100%" />
</el-form-item>
<!-- <el-form-item label="文件MD5" prop="fileMd5">
<el-input v-model="form.fileMd5" placeholder="请输入文件 MD5" maxlength="32" />
</el-form-item>-->
<el-form-item label="图书文件" prop="downloadUrl">
<el-upload
class="upload-demo"
:action="uploadFileUrl"
:headers="uploadHeaders"
:file-list="uploadFileList"
:limit="1"
:before-upload="handleBeforeUpload"
:on-success="handleUploadSuccess"
:on-remove="handleUploadRemove"
:on-exceed="handleUploadExceed"
>
<el-button size="small" type="primary" icon="el-icon-upload">选取文件</el-button>
<div slot="tip" class="el-upload__tip">支持文档/音视频格式,大小不超过 500MB</div>
</el-upload>
</el-form-item>
<el-form-item label="图书封皮" prop="bookCover">
<el-upload
class="cover-uploader"
:action="uploadFileUrl"
:headers="uploadHeaders"
:show-file-list="false"
accept="image/*"
:before-upload="handleCoverBeforeUpload"
:on-success="handleCoverSuccess"
:on-error="handleCoverError"
>
<img v-if="coverPreview" :src="coverPreview" class="cover-image" alt="封皮" />
<div v-else class="cover-empty">
<i class="el-icon-picture-outline" />
<span>点击上传封皮</span>
</div>
</el-upload>
<el-button v-if="form.bookCover" type="text" size="mini" class="cover-remove" @click="removeCover">移除封皮</el-button>
</el-form-item>
<el-form-item label="上架状态" prop="isOnShelf">
<el-radio-group v-model="form.isOnShelf">
<el-radio :label="1">上架</el-radio>
<el-radio :label="0">下架</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="发布时间" prop="publishTime">
<el-date-picker
v-model="form.publishTime"
type="datetime"
placeholder="选择发布时间"
value-format="yyyy-MM-dd HH:mm:ss"
style="width: 100%"
/>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listBookSnapshot, getBookSnapshot, addBookSnapshot, updateBookSnapshot, delBookSnapshot } from '@/api/device/book'
import { getToken } from '@/utils/auth'
/* ---- 前端本地计算文件二进制 MD5(32 位小写 hex,公共域 blueimp-md5 算法,零依赖) ---- */
function md5cycle(x, k) {
let a = x[0], b = x[1], c = x[2], d = x[3]
a = (((b & c) | (~b & d)) + k[0] - 680876936 | 0); a = (a << 7 | a >>> 25)
d = (((a & b) | (~a & c)) + k[1] - 389564586 | 0); d = (d << 12 | d >>> 20)
c = (((d & a) | (~d & b)) + k[2] + 606105819 | 0); c = (c << 17 | c >>> 15)
b = (((c & d) | (~c & a)) + k[3] - 1044525330 | 0); b = (b << 22 | b >>> 10)
a = (((b & c) | (~b & d)) + k[4] - 176418897 | 0); a = (a << 7 | a >>> 25)
d = (((a & b) | (~a & c)) + k[5] + 1200080426 | 0); d = (d << 12 | d >>> 20)
c = (((d & a) | (~d & b)) + k[6] - 1473231341 | 0); c = (c << 17 | c >>> 15)
b = (((c & d) | (~c & a)) + k[7] - 45705983 | 0); b = (b << 22 | b >>> 10)
a = (((b & c) | (~b & d)) + k[8] + 1770035416 | 0); a = (a << 7 | a >>> 25)
d = (((a & b) | (~a & c)) + k[9] - 1958414417 | 0); d = (d << 12 | d >>> 20)
c = (((d & a) | (~d & b)) + k[10] - 42063 | 0); c = (c << 17 | c >>> 15)
b = (((c & d) | (~c & a)) + k[11] - 1990404162 | 0); b = (b << 22 | b >>> 10)
a = (((b & c) | (~b & d)) + k[12] + 1804603682 | 0); a = (a << 7 | a >>> 25)
d = (((a & b) | (~a & c)) + k[13] - 40341101 | 0); d = (d << 12 | d >>> 20)
c = (((d & a) | (~d & b)) + k[14] - 1502002290 | 0); c = (c << 17 | c >>> 15)
b = (((c & d) | (~c & a)) + k[15] + 1236535329 | 0); b = (b << 22 | b >>> 10)
a = (((b & d) | (c & ~d)) + k[1] - 165796510 | 0); a = (a << 5 | a >>> 27)
d = (((a & c) | (b & ~c)) + k[6] - 1069501632 | 0); d = (d << 9 | d >>> 23)
c = (((d & b) | (a & ~b)) + k[11] + 643717713 | 0); c = (c << 14 | c >>> 18)
b = (((c & a) | (d & ~a)) + k[0] - 373897302 | 0); b = (b << 20 | b >>> 12)
a = (((b & d) | (c & ~d)) + k[5] - 701558691 | 0); a = (a << 5 | a >>> 27)
d = (((a & c) | (b & ~c)) + k[10] + 38016083 | 0); d = (d << 9 | d >>> 23)
c = (((d & b) | (a & ~b)) + k[15] - 660478335 | 0); c = (c << 14 | c >>> 18)
b = (((c & a) | (d & ~a)) + k[4] - 405537848 | 0); b = (b << 20 | b >>> 12)
a = (((b & d) | (c & ~d)) + k[9] + 568446438 | 0); a = (a << 5 | a >>> 27)
d = (((a & c) | (b & ~c)) + k[14] - 1019803690 | 0); d = (d << 9 | d >>> 23)
c = (((d & b) | (a & ~b)) + k[3] - 187363961 | 0); c = (c << 14 | c >>> 18)
b = (((c & a) | (d & ~a)) + k[8] + 1163531501 | 0); b = (b << 20 | b >>> 12)
a = (((b & d) | (c & ~d)) + k[13] - 1444681467 | 0); a = (a << 5 | a >>> 27)
d = (((a & c) | (b & ~c)) + k[2] - 51403784 | 0); d = (d << 9 | d >>> 23)
c = (((d & b) | (a & ~b)) + k[7] + 1735328473 | 0); c = (c << 14 | c >>> 18)
b = (((c & a) | (d & ~a)) + k[12] - 1926607734 | 0); b = (b << 20 | b >>> 12)
a = ((b ^ c ^ d) + k[5] - 378558 | 0); a = (a << 4 | a >>> 28)
d = ((a ^ b ^ c) + k[8] - 2022574463 | 0); d = (d << 11 | d >>> 21)
c = ((d ^ a ^ b) + k[11] + 1839030562 | 0); c = (c << 16 | c >>> 16)
b = ((c ^ d ^ a) + k[14] - 35309556 | 0); b = (b << 23 | b >>> 9)
a = ((b ^ c ^ d) + k[1] - 1530992060 | 0); a = (a << 4 | a >>> 28)
d = ((a ^ b ^ c) + k[4] + 1272893353 | 0); d = (d << 11 | d >>> 21)
c = ((d ^ a ^ b) + k[7] - 155497632 | 0); c = (c << 16 | c >>> 16)
b = ((c ^ d ^ a) + k[10] - 1094730640 | 0); b = (b << 23 | b >>> 9)
a = ((b ^ c ^ d) + k[13] + 681279174 | 0); a = (a << 4 | a >>> 28)
d = ((a ^ b ^ c) + k[0] - 358537222 | 0); d = (d << 11 | d >>> 21)
c = ((d ^ a ^ b) + k[3] - 722521979 | 0); c = (c << 16 | c >>> 16)
b = ((c ^ d ^ a) + k[6] + 76029189 | 0); b = (b << 23 | b >>> 9)
a = ((b ^ c ^ d) + k[9] - 640364487 | 0); a = (a << 4 | a >>> 28)
d = ((a ^ b ^ c) + k[12] - 421815835 | 0); d = (d << 11 | d >>> 21)
c = ((d ^ a ^ b) + k[15] + 530742520 | 0); c = (c << 16 | c >>> 16)
b = ((c ^ d ^ a) + k[2] - 995338651 | 0); b = (b << 23 | b >>> 9)
a = ((c ^ (b | ~d)) + k[0] - 198630844 | 0); a = (a << 6 | a >>> 26)
d = ((b ^ (a | ~c)) + k[7] + 1126891415 | 0); d = (d << 10 | d >>> 22)
c = ((a ^ (d | ~b)) + k[14] - 1416354905 | 0); c = (c << 15 | c >>> 17)
b = ((d ^ (c | ~a)) + k[5] - 57434055 | 0); b = (b << 21 | b >>> 11)
a = ((c ^ (b | ~d)) + k[12] + 1700485571 | 0); a = (a << 6 | a >>> 26)
d = ((b ^ (a | ~c)) + k[3] - 1894986606 | 0); d = (d << 10 | d >>> 22)
c = ((a ^ (d | ~b)) + k[10] - 1051523 | 0); c = (c << 15 | c >>> 17)
b = ((d ^ (c | ~a)) + k[1] - 2054922799 | 0); b = (b << 21 | b >>> 11)
a = ((c ^ (b | ~d)) + k[8] + 1873313359 | 0); a = (a << 6 | a >>> 26)
d = ((b ^ (a | ~c)) + k[15] - 30611744 | 0); d = (d << 10 | d >>> 22)
c = ((a ^ (d | ~b)) + k[6] - 1560198380 | 0); c = (c << 15 | c >>> 17)
b = ((d ^ (c | ~a)) + k[13] + 1309151649 | 0); b = (b << 21 | b >>> 11)
a = ((c ^ (b | ~d)) + k[4] - 145523070 | 0); a = (a << 6 | a >>> 26)
d = ((b ^ (a | ~c)) + k[11] - 1120210379 | 0); d = (d << 10 | d >>> 22)
c = ((a ^ (d | ~b)) + k[2] + 718787259 | 0); c = (c << 15 | c >>> 17)
b = ((d ^ (c | ~a)) + k[9] - 343485551 | 0); b = (b << 21 | b >>> 11)
x[0] = (a + x[0] | 0)
x[1] = (b + x[1] | 0)
x[2] = (c + x[2] | 0)
x[3] = (d + x[3] | 0)
}
function md5blkArray(a) {
const blk = []
for (let i = 0; i < 64; i += 4) {
blk[i >> 2] = (a[i] & 0xff) + ((a[i + 1] & 0xff) << 8) + ((a[i + 2] & 0xff) << 16) + ((a[i + 3] & 0xff) << 24)
}
return blk
}
function md5rhex(n) {
const hexChr = '0123456789abcdef'
let s = ''
for (let j = 0; j < 4; j++) {
s += hexChr[(n >> (j * 8 + 4)) & 0x0f] + hexChr[(n >> (j * 8)) & 0x0f]
}
return s
}
function md5hex(x) {
let out = ''
for (let i = 0; i < x.length; i++) out += md5rhex(x[i])
return out
}
/** 计算 Uint8Array 的 MD5,返回 32 位小写 hex */
function md5Bytes(bytes) {
const state = [1732584193, -271733879, -1732584194, 271733878]
let i = 64
for (; i <= bytes.length; i += 64) {
md5cycle(state, md5blkArray(bytes.subarray(i - 64, i)))
}
const tail = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
const rem = bytes.subarray(i - 64)
for (let j = 0; j < rem.length; j++) {
tail[j >> 2] |= (rem[j] & 0xff) << ((j % 4) << 3)
}
tail[rem.length >> 2] |= 0x80 << ((rem.length % 4) << 3)
if (rem.length > 55) {
md5cycle(state, tail)
for (let k = 0; k < 16; k++) tail[k] = 0
}
tail[14] = bytes.length * 8
md5cycle(state, tail)
return md5hex(state)
}
/* 增量 MD5:按二进制流分块读取后逐块累加,避免一次性占用大量内存,且不经过字符串 */
function createMd5Incremental() {
const state = [1732584193, -271733879, -1732584194, 271733878]
let pending = new Uint8Array(0)
let totalLen = 0
function absorb(buffer) {
const data = new Uint8Array(pending.length + buffer.length)
data.set(pending, 0)
data.set(buffer, pending.length)
let i = 0
for (; i + 64 <= data.length; i += 64) {
md5cycle(state, md5blkArray(data.subarray(i, i + 64)))
}
pending = data.subarray(i)
totalLen += buffer.length
}
function finalize() {
const tail = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
for (let j = 0; j < pending.length; j++) {
tail[j >> 2] |= (pending[j] & 0xff) << ((j % 4) << 3)
}
tail[pending.length >> 2] |= 0x80 << ((pending.length % 4) << 3)
if (pending.length > 55) {
md5cycle(state, tail)
for (let k = 0; k < 16; k++) tail[k] = 0
}
tail[14] = totalLen * 8
md5cycle(state, tail)
return md5hex(state)
}
return { absorb, finalize }
}
export default {
name: 'BookSnapshot',
dicts: ['train_book_category'],
data() {
return {
loading: true,
showSearch: true,
// 左侧分类面板当前高亮项
activeCategory: 'all',
// 全部分类总数(忽略分类条件,尊重书名/状态筛选)
allTotal: 0,
// 各分类数量:{ [dictValue]: 数量 }
categoryCounts: {},
total: 0,
bookList: [],
title: '',
open: false,
// 文件上传
uploadFileUrl: process.env.VUE_APP_BASE_API + '/common/upload',
uploadHeaders: { Authorization: 'Bearer ' + getToken() },
uploadFileList: [],
queryParams: {
pageNum: 1,
pageSize: 10,
bookTitle: null,
bookCategory: null,
isOnShelf: null
},
form: {},
rules: {
bookId: [
{ required: true, message: '图书ID不能为空', trigger: 'blur' }
],
bookTitle: [
{ required: true, message: '书名不能为空', trigger: 'blur' }
],
bookCategory: [
{ required: true, message: '图书分类不能为空', trigger: 'change' }
],
bookVersion: [
{ required: true, message: '版本不能为空', trigger: 'blur' }
]
}
}
},
computed: {
/** 封皮预览地址(后端存储相对路径,拼接 baseApi 后由浏览器直接加载) */
coverPreview() {
return this.form.bookCover ? process.env.VUE_APP_BASE_API + this.form.bookCover : ''
}
},
created() {
this.getList()
},
methods: {
getList() {
this.loading = true
listBookSnapshot(this.queryParams).then(response => {
this.bookList = response.rows
this.total = response.total
this.loading = false
this.loadCategoryCounts()
})
},
/** 统计各分类数量与全部总数(忽略分类条件,仅尊重书名/状态筛选) */
loadCategoryCounts() {
const q = { ...this.queryParams, pageNum: 1, pageSize: 10000, bookCategory: null }
listBookSnapshot(q).then(res => {
const rows = res.rows || []
const counts = {}
rows.forEach(row => {
const key = row.bookCategory || ''
counts[key] = (counts[key] || 0) + 1
})
this.categoryCounts = counts
this.allTotal = res.total
})
},
/** 点击左侧分类:过滤右侧列表,'all' 表示全部 */
selectCategory(val) {
this.activeCategory = val
this.queryParams.bookCategory = val === 'all' ? null : val
this.queryParams.pageNum = 1
this.getList()
},
cancel() {
this.open = false
this.reset()
},
reset() {
this.form = {
id: null,
bookId: null,
bookTitle: null,
bookCategory: null,
bookSummary: null,
bookVersion: null,
fileSize: 0,
fileMd5: null,
downloadUrl: null,
bookCover: null,
isOnShelf: 1,
publishTime: null
}
this.resetForm('form')
this.uploadFileList = []
},
handleQuery() {
this.queryParams.pageNum = 1
this.activeCategory = this.queryParams.bookCategory || 'all'
this.getList()
},
resetQuery() {
this.resetForm('queryForm')
this.activeCategory = 'all'
this.handleQuery()
},
handleDelete(row) {
const id = row.id
this.$modal.confirm('是否确认删除图书为"' + row.bookTitle + '"的数据项?').then(function() {
return delBookSnapshot(id)
}).then(() => {
this.getList()
this.$modal.msgSuccess('删除成功')
}).catch(() => {})
},
handleAdd() {
this.reset()
this.open = true
this.title = '添加图书快照'
},
handleUpdate(row) {
this.reset()
getBookSnapshot(row.id).then(response => {
this.form = response.data
this.uploadFileList = this.form.downloadUrl ? [{ name: this.getFileName(this.form.downloadUrl), url: this.form.downloadUrl }] : []
this.open = true
this.title = '修改图书快照'
})
},
submitForm() {
this.$refs['form'].validate(valid => {
if (valid) {
if (this.form.id != null) {
updateBookSnapshot(this.form).then(() => {
this.$modal.msgSuccess('修改成功')
this.open = false
this.getList()
})
} else {
addBookSnapshot(this.form).then(() => {
this.$modal.msgSuccess('新增成功')
this.open = false
this.getList()
})
}
}
})
},
handleExport() {
this.download('train/bookSnapshot/export', {
...this.queryParams
}, `book_snapshot_${new Date().getTime()}.xlsx`)
},
/** 从路径中取文件名 */
getFileName(name) {
if (!name) return ''
return name.lastIndexOf('/') > -1 ? name.slice(name.lastIndexOf('/') + 1) : name
},
/** 拼接封皮访问地址 */
getCoverUrl(bookCover) {
return bookCover ? process.env.VUE_APP_BASE_API + bookCover : ''
},
/** 上传前:校验格式与大小,并记录文件字节数写入 file_size */
handleBeforeUpload(file) {
const fileType = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'mp3', 'wav', 'mp4', 'avi', 'mov', 'mkv', 'flv', 'wmv']
const ext = file.name.split('.').pop().toLowerCase()
if (fileType.indexOf(ext) < 0) {
this.$modal.msgError(`文件格式不正确,请上传${fileType.join('/')}格式文件!`)
return false
}
if (file.size / 1024 / 1024 >= 500) {
this.$modal.msgError('上传文件大小不能超过 500 MB!')
return false
}
// 计算文件大小(字节)写入数据表 file_size 字段
this.form.fileSize = file.size
this.$modal.loading('正在上传文件,请稍候...')
return true
},
/** 上传成功:回填文件路径并计算文件 MD5 */
handleUploadSuccess(res, file) {
this.$modal.closeLoading()
if (res.code === 200) {
this.form.downloadUrl = res.fileName
this.uploadFileList = [{ name: this.getFileName(res.fileName), url: res.fileName }]
this.$modal.msgSuccess('上传成功')
// 本地计算完整二进制 MD5(32 位小写 hex)写入 fileMd5
//this.computeFileMd5_other(file && file.raw)
} else {
this.$modal.msgError(res.msg || '上传失败')
}
},
/**
* 前端本地计算文件完整二进制 MD5(32 位小写 hex)
* 采用二进制流(FileReader.readAsArrayBuffer)分块读取,逐块累加进增量 MD5,
* 全程不经过字符串读取/转换,也不依赖任何外部库。
* @param {File} file 文件原始对象
*/
computeFileMd5_other(file) {
if (!file) return
this.$modal.loading('正在计算文件 MD5...')
const chunkSize = 2 * 1024 * 1024
const chunks = Math.ceil(file.size / chunkSize)
let currentChunk = 0
const hasher = createMd5Incremental()
const fr = new FileReader()
const loadNext = () => {
const start = currentChunk * chunkSize
const end = Math.min(start + chunkSize, file.size)
fr.readAsArrayBuffer(file.slice(start, end))
}
fr.onload = e => {
hasher.absorb(new Uint8Array(e.target.result))
currentChunk++
if (currentChunk < chunks) {
loadNext()
} else {
const md5 = hasher.finalize()
this.form.fileMd5 = md5
this.$modal.closeLoading()
this.$modal.msgSuccess('已生成文件 MD5:' + md5)
}
}
fr.onerror = () => {
this.$modal.closeLoading()
this.$modal.msgError('文件 MD5 计算失败')
}
loadNext()
},
/** 移除文件:清空路径、大小与 MD5 */
handleUploadRemove() {
this.form.downloadUrl = null
this.form.fileSize = 0
this.form.fileMd5 = null
this.uploadFileList = []
},
/** 超出数量限制 */
handleUploadExceed() {
this.$modal.msgError('最多只能上传 1 个文件!')
},
/** 封皮上传前:仅允许图片且不超过 5MB */
handleCoverBeforeUpload(file) {
const isImage = file.type && file.type.indexOf('image/') === 0
if (!isImage) {
this.$modal.msgError('封皮仅支持图片格式!')
return false
}
if (file.size / 1024 / 1024 > 5) {
this.$modal.msgError('封皮图片大小不能超过 5MB!')
return false
}
this.$modal.loading('正在上传封皮...')
return true
},
/** 封皮上传成功:写入 bookCover 字段 */
handleCoverSuccess(res) {
this.$modal.closeLoading()
if (res.code === 200) {
this.form.bookCover = res.fileName
this.$modal.msgSuccess('封皮上传成功')
} else {
this.$modal.msgError(res.msg || '封皮上传失败')
}
},
/** 封皮上传失败 */
handleCoverError() {
this.$modal.closeLoading()
this.$modal.msgError('封皮上传失败')
},
/** 移除封皮 */
removeCover() {
this.form.bookCover = null
}
}
}
</script>
<style scoped lang="scss">
.cover-uploader { display: inline-block; vertical-align: top; }
.cover-uploader .cover-image {
width: 120px;
height: 160px;
object-fit: cover;
border-radius: 6px;
border: 1px solid #e8eaf1;
display: block;
cursor: pointer;
}
.cover-uploader .cover-empty {
width: 120px;
height: 160px;
border: 1px dashed #c0c4cc;
border-radius: 6px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #b0b7c9;
cursor: pointer;
transition: border-color .2s;
}
.cover-uploader .cover-empty:hover { border-color: #2b6bff; color: #2b6bff; }
.cover-uploader .cover-empty i { font-size: 28px; margin-bottom: 6px; }
.cover-remove { margin-left: 12px; vertical-align: bottom; }
.cover-thumb {
width: 56px;
height: 74px;
object-fit: cover;
border-radius: 4px;
border: 1px solid #e8eaf1;
display: inline-block;
}
.cover-thumb-empty { color: #b0b7c9; font-size: 12px; }
/* 标题栏下方左右布局:左类别筛选(约1/4) + 右查询/列表(约3/4) */
.student-body {
display: flex;
align-items: stretch;
gap: 16px;
}
.student-main {
flex: 1;
min-width: 0;
}
.class-panel {
width: 24%;
min-width: 120px;
max-width: 200px;
flex-shrink: 0;
align-self: flex-start;
background: #fff;
border: 1px solid #eef0f5;
border-radius: 10px;
overflow: hidden;
.class-header {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 16px;
border-bottom: 1px solid #f2f4f8;
.class-title {
font-size: 14px;
font-weight: 600;
color: #1f2d3d;
}
.class-badge {
font-size: 12px;
color: #409eff;
background: #ecf5ff;
border-radius: 10px;
padding: 1px 8px;
}
}
.class-list {
max-height: calc(105vh - 260px);
overflow-y: auto;
padding: 8px 0;
}
.class-item {
display: flex;
align-items: center;
height: 36px;
padding: 0 16px;
cursor: pointer;
font-size: 13px;
color: #303133;
transition: background 0.15s;
&:hover {
background: #f5f7fa;
}
&.active {
background: #ecf5ff;
color: #409eff;
.class-count {
color: #409eff;
}
}
.class-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.class-count {
flex-shrink: 0;
font-size: 12px;
color: #a5adba;
}
}
}
</style>
@@ -0,0 +1,378 @@
<template>
<div class="app-container">
<!-- 顶部静态布局:标题栏 -->
<div class="page-topbar">
<div class="topbar-left">
<h2 class="topbar-title">报到一体机管理</h2>
<span class="topbar-sub">在册 {{ total }} 台</span>
</div>
<div class="topbar-actions">
<el-button icon="el-icon-download" size="small" @click="handleExport">导出台账</el-button>
<el-button type="primary" icon="el-icon-plus" size="small" @click="handleAdd">新增一体机</el-button>
</div>
</div>
<!-- 顶部静态布局:筛选卡片 -->
<div class="topbar-filter">
<el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true" label-width="82px">
<el-form-item label="设备名称" prop="terminalName">
<el-input
v-model="queryParams.terminalName"
placeholder="请输入设备名称"
clearable
style="width: 160px"
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="硬件序列号" prop="terminalSn">
<el-input
v-model="queryParams.terminalSn"
placeholder="请输入序列号"
clearable
style="width: 160px"
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="设备状态" prop="terminalStatus">
<el-select v-model="queryParams.terminalStatus" placeholder="设备状态" clearable style="width: 130px">
<el-option label="离线" :value="0" />
<el-option label="在线" :value="1" />
</el-select>
</el-form-item>
<!-- <el-form-item label="绑定机构" prop="bindDeptId">
<el-input
v-model="queryParams.bindDeptId"
placeholder="请输入绑定机构"
clearable
style="width: 160px"
@keyup.enter.native="handleQuery"
/>
</el-form-item>-->
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</div>
<el-table v-loading="loading" :data="terminalList">
<el-table-column label="ID" align="center" prop="id" width="80" />
<el-table-column label="业务ID" align="center" prop="terminalId" min-width="140" show-overflow-tooltip />
<el-table-column label="设备名称" align="center" prop="terminalName" min-width="140" show-overflow-tooltip />
<!-- <el-table-column label="绑定机构" align="center" prop="bindDeptId" min-width="120" show-overflow-tooltip />-->
<el-table-column label="设备状态" align="center" prop="terminalStatus" width="100">
<template slot-scope="scope">
<el-tag :type="scope.row.terminalStatus === 1 ? 'success' : 'danger'">
{{ scope.row.terminalStatus === 1 ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="报到方式" align="center" prop="supportCheckinType" min-width="150" show-overflow-tooltip />
<el-table-column label="启用模块" align="center" prop="supportModules" min-width="160" show-overflow-tooltip />
<el-table-column label="IP地址" align="center" prop="terminalIp" min-width="130" show-overflow-tooltip />
<el-table-column label="最近心跳" align="center" prop="lastHeartbeat" min-width="150" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="160">
<template slot-scope="scope">
<el-button
v-hasPermi="['device:checkinterminal:edit']"
size="mini"
type="text"
icon="el-icon-edit"
@click="handleUpdate(scope.row)"
>修改</el-button>
<el-button
v-hasPermi="['device:checkinterminal:remove']"
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
<!-- 添加或修改报到一体机对话框 -->
<el-dialog :title="title" :visible.sync="open" width="680px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="110px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="业务ID" prop="terminalId">
<el-input v-model="form.terminalId" placeholder="终端业务ID" maxlength="64" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="硬件序列号" prop="terminalSn">
<el-input v-model="form.terminalSn" placeholder="12位字母数字" maxlength="64" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="设备名称" prop="terminalName">
<el-input v-model="form.terminalName" placeholder="如 基地大厅1号报到机" maxlength="100" />
</el-form-item>
</el-col>
<!-- <el-col :span="12">
<el-form-item label="绑定机构" prop="bindDeptId">
<el-input v-model="form.bindDeptId" placeholder="机构/基地ID" maxlength="64" />
</el-form-item>
</el-col>-->
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="安装位置" prop="installLocation">
<el-input v-model="form.installLocation" placeholder="如 1号楼大厅" maxlength="200" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="设备状态" prop="terminalStatus">
<el-radio-group v-model="form.terminalStatus">
<el-radio :label="0">离线</el-radio>
<el-radio :label="1">在线</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="报到方式" prop="supportCheckinType">
<el-select v-model="form.supportCheckinType" multiple placeholder="可多选" style="width: 100%">
<el-option v-for="item in checkinTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="启用模块" prop="supportModules">
<el-select v-model="form.supportModules" multiple placeholder="可多选" style="width: 100%">
<el-option v-for="item in moduleOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="打印支持" prop="supportPrint">
<el-switch v-model="form.supportPrint" active-value="Y" inactive-value="N" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="语音播报" prop="supportBroadcast">
<el-switch v-model="form.supportBroadcast" active-value="Y" inactive-value="N" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="屏幕方向" prop="screenOrientation">
<el-select v-model="form.screenOrientation" placeholder="屏幕方向" style="width: 100%">
<el-option label="竖屏" value="PORTRAIT" />
<el-option label="横屏" value="LANDSCAPE" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="返回首页(秒)" prop="idleReturnSeconds">
<el-input-number v-model="form.idleReturnSeconds" :min="5" :max="3600" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="联系电话" prop="contactPhone">
<el-input v-model="form.contactPhone" placeholder="联系电话" maxlength="30" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="设备IP" prop="terminalIp">
<el-input v-model="form.terminalIp" placeholder="设备IP地址" maxlength="50" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="服务端点" prop="apiEndpoint">
<el-input v-model="form.apiEndpoint" placeholder="人脸/读卡服务端点" maxlength="255" />
</el-form-item>
<el-form-item label="机构名称" prop="orgName">
<el-input v-model="form.orgName" placeholder="顶部机构名称,不填取绑定机构" maxlength="100" />
</el-form-item>
<el-form-item label="欢迎语" prop="welcomeTitle">
<el-input v-model="form.welcomeTitle" placeholder="欢迎语" maxlength="200" />
</el-form-item>
<el-form-item label="帮助文案" prop="helpText">
<el-input v-model="form.helpText" type="textarea" :rows="2" placeholder="使用帮助文案" maxlength="500" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listCheckinTerminal, getCheckinTerminal, addCheckinTerminal, updateCheckinTerminal, delCheckinTerminal } from '@/api/device/checkinTerminal'
export default {
name: 'CheckinTerminal',
data() {
return {
loading: true,
showSearch: true,
total: 0,
terminalList: [],
title: '',
open: false,
queryParams: {
pageNum: 1,
pageSize: 10,
terminalName: null,
terminalSn: null,
terminalStatus: null,
bindDeptId: null
},
form: {},
checkinTypeOptions: [
{ label: '身份证刷卡', value: 'ID_CARD' },
{ label: '人脸识别', value: 'FACE' },
{ label: '学号输入', value: 'STUDENT_NO' }
],
moduleOptions: [
{ label: '开班报到', value: 'CHECKIN' },
{ label: '信息查询', value: 'INQUIRY' },
{ label: '报到数据', value: 'STATS' },
{ label: '系统设置', value: 'SETTINGS' }
],
rules: {
terminalId: [
{ required: true, message: '业务ID不能为空', trigger: 'blur' }
],
terminalSn: [
{ required: true, message: '硬件序列号不能为空', trigger: 'blur' }
],
terminalName: [
{ required: true, message: '设备名称不能为空', trigger: 'blur' }
],
terminalStatus: [
{ required: true, message: '设备状态不能为空', trigger: 'change' }
]
}
}
},
created() {
this.getList()
},
methods: {
getList() {
this.loading = true
listCheckinTerminal(this.queryParams).then(response => {
this.terminalList = response.rows
this.total = response.total
this.loading = false
})
},
cancel() {
this.open = false
this.reset()
},
reset() {
this.form = {
id: null,
terminalId: null,
terminalSn: null,
terminalName: null,
bindDeptId: null,
installLocation: null,
terminalStatus: 0,
supportCheckinType: [],
supportModules: [],
supportPrint: 'N',
supportBroadcast: 'N',
contactPhone: null,
welcomeTitle: null,
orgName: null,
screenOrientation: 'PORTRAIT',
idleReturnSeconds: 30,
helpText: null,
terminalIp: null,
apiEndpoint: null
}
this.resetForm('form')
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.resetForm('queryForm')
this.handleQuery()
},
toArray(val) {
return val ? String(val).split(',').filter(Boolean) : []
},
handleDelete(row) {
const id = row.id
this.$modal.confirm('是否确认删除设备名称为"' + row.terminalName + '"的数据项?').then(function() {
return delCheckinTerminal(id)
}).then(() => {
this.getList()
this.$modal.msgSuccess('删除成功')
}).catch(() => {})
},
handleAdd() {
this.reset()
this.open = true
this.title = '添加报到一体机'
},
handleUpdate(row) {
this.reset()
getCheckinTerminal(row.id).then(response => {
const data = response.data
data.supportCheckinType = this.toArray(data.supportCheckinType)
data.supportModules = this.toArray(data.supportModules)
data.terminalStatus = data.terminalStatus === null ? 0 : Number(data.terminalStatus)
data.idleReturnSeconds = data.idleReturnSeconds === null ? 30 : Number(data.idleReturnSeconds)
this.form = data
this.open = true
this.title = '修改报到一体机'
})
},
submitForm() {
this.$refs['form'].validate(valid => {
if (valid) {
const payload = {
...this.form,
supportCheckinType: (this.form.supportCheckinType || []).join(','),
supportModules: (this.form.supportModules || []).join(',')
}
if (this.form.id != null) {
updateCheckinTerminal(payload).then(() => {
this.$modal.msgSuccess('修改成功')
this.open = false
this.getList()
})
} else {
addCheckinTerminal(payload).then(() => {
this.$modal.msgSuccess('新增成功')
this.open = false
this.getList()
})
}
}
})
},
handleExport() {
this.download('device/checkinterminal/export', {
...this.queryParams
}, `checkin_terminal_${new Date().getTime()}.xlsx`)
}
}
}
</script>
+253
View File
@@ -0,0 +1,253 @@
<template>
<div class="app-container">
<!-- 顶部静态布局:标题栏 -->
<div class="page-topbar">
<div class="topbar-left">
<h2 class="topbar-title">读书机设备档案</h2>
<span class="topbar-sub">在册 {{ total }} 台</span>
</div>
<div class="topbar-actions">
<el-button icon="el-icon-download" size="small" @click="handleExport">导出设备台账</el-button>
<el-button type="primary" icon="el-icon-plus" size="small" @click="handleAdd">新增设备</el-button>
</div>
</div>
<!-- 顶部静态布局:筛选卡片 -->
<div class="topbar-filter">
<el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true" label-width="80px">
<el-form-item label="设备编码" prop="deviceId">
<el-input
v-model="queryParams.deviceId"
placeholder="请输入设备唯一编码"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="设备状态" prop="deviceStatus">
<el-select v-model="queryParams.deviceStatus" placeholder="设备状态" clearable>
<el-option label="正常" :value="1" />
<el-option label="禁用" :value="2" />
<el-option label="恢复出厂" :value="3" />
</el-select>
</el-form-item>
<el-form-item label="绑定机构" prop="bindOrgId">
<el-input
v-model="queryParams.bindOrgId"
placeholder="请输入绑定机构"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</div>
<el-table v-loading="loading" :data="deviceList">
<el-table-column label="ID" align="center" prop="id" width="80" />
<el-table-column label="设备编码" align="center" prop="deviceId" min-width="140" show-overflow-tooltip />
<el-table-column label="设备状态" align="center" prop="deviceStatus" width="100">
<template slot-scope="scope">
<el-tag :type="scope.row.deviceStatus === 1 ? 'success' : (scope.row.deviceStatus === 3 ? 'warning' : 'danger')">
{{ scope.row.deviceStatus === 1 ? '正常' : (scope.row.deviceStatus === 2 ? '禁用' : '恢复出厂') }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="固件版本" align="center" prop="firmwareVersion" min-width="110" show-overflow-tooltip />
<el-table-column label="APP版本" align="center" prop="appVersion" min-width="110" show-overflow-tooltip />
<el-table-column label="绑定机构" align="center" prop="bindOrgId" min-width="120" show-overflow-tooltip />
<el-table-column label="心跳周期(秒)" align="center" prop="heartbeatInterval" width="110" />
<el-table-column label="最近登录" align="center" prop="lastLoginTime" min-width="150" />
<el-table-column label="最近心跳" align="center" prop="lastHeartbeatTime" min-width="150" />
<!-- <el-table-column label="创建时间" align="center" prop="createTime" min-width="150" />-->
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="160">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:iotDevice:edit']"
size="mini"
type="text"
icon="el-icon-edit"
@click="handleUpdate(scope.row)"
>修改</el-button>
<el-button
v-hasPermi="['train:iotDevice:remove']"
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
<!-- 添加或修改读书机设备对话框 -->
<el-dialog :title="title" :visible.sync="open" width="560px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="120px">
<el-form-item label="设备编码" prop="deviceId">
<el-input v-model="form.deviceId" placeholder="请输入设备唯一编码 deviceId" maxlength="64" />
</el-form-item>
<el-form-item label="密钥摘要" prop="deviceSecretDigest">
<el-input v-model="form.deviceSecretDigest" placeholder="deviceSecret SHA256 摘要,不存明文" maxlength="128" />
</el-form-item>
<el-form-item label="设备状态" prop="deviceStatus">
<el-radio-group v-model="form.deviceStatus">
<el-radio :label="1">正常</el-radio>
<el-radio :label="2">禁用</el-radio>
<el-radio :label="3">恢复出厂</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="固件版本" prop="firmwareVersion">
<el-input v-model="form.firmwareVersion" placeholder="请输入固件版本" maxlength="64" />
</el-form-item>
<el-form-item label="APP版本" prop="appVersion">
<el-input v-model="form.appVersion" placeholder="请输入 APP 版本" maxlength="64" />
</el-form-item>
<el-form-item label="绑定机构" prop="bindOrgId">
<el-input v-model="form.bindOrgId" placeholder="请输入绑定机构" maxlength="64" />
</el-form-item>
<el-form-item label="绑定班级" prop="bindClassId">
<el-input v-model="form.bindClassId" placeholder="多班级逗号分隔" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="心跳周期(秒)" prop="heartbeatInterval">
<el-input-number v-model="form.heartbeatInterval" :min="5" :max="3600" style="width: 100%" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listIotDevice, getIotDevice, addIotDevice, updateIotDevice, delIotDevice } from '@/api/device/device'
export default {
name: 'IotDevice',
data() {
return {
loading: true,
showSearch: true,
total: 0,
deviceList: [],
title: '',
open: false,
queryParams: {
pageNum: 1,
pageSize: 10,
deviceId: null,
deviceStatus: null,
bindOrgId: null
},
form: {},
rules: {
deviceId: [
{ required: true, message: '设备编码不能为空', trigger: 'blur' }
],
deviceStatus: [
{ required: true, message: '设备状态不能为空', trigger: 'change' }
],
heartbeatInterval: [
{ required: true, message: '心跳周期不能为空', trigger: 'blur' }
]
}
}
},
created() {
this.getList()
},
methods: {
getList() {
this.loading = true
listIotDevice(this.queryParams).then(response => {
this.deviceList = response.rows
this.total = response.total
this.loading = false
})
},
cancel() {
this.open = false
this.reset()
},
reset() {
this.form = {
id: null,
deviceId: null,
deviceSecretDigest: null,
deviceStatus: 1,
firmwareVersion: null,
appVersion: null,
bindOrgId: null,
bindClassId: null,
heartbeatInterval: 60
}
this.resetForm('form')
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.resetForm('queryForm')
this.handleQuery()
},
handleDelete(row) {
const id = row.id
this.$modal.confirm('是否确认删除设备编码为"' + row.deviceId + '"的数据项?').then(function() {
return delIotDevice(id)
}).then(() => {
this.getList()
this.$modal.msgSuccess('删除成功')
}).catch(() => {})
},
handleAdd() {
this.reset()
this.open = true
this.title = '添加读书机设备'
},
handleUpdate(row) {
this.reset()
getIotDevice(row.id).then(response => {
this.form = response.data
this.open = true
this.title = '修改读书机设备'
})
},
submitForm() {
this.$refs['form'].validate(valid => {
if (valid) {
if (this.form.id != null) {
updateIotDevice(this.form).then(() => {
this.$modal.msgSuccess('修改成功')
this.open = false
this.getList()
})
} else {
addIotDevice(this.form).then(() => {
this.$modal.msgSuccess('新增成功')
this.open = false
this.getList()
})
}
}
})
},
handleExport() {
this.download('train/iotDevice/export', {
...this.queryParams
}, `iot_device_${new Date().getTime()}.xlsx`)
}
}
}
</script>
@@ -0,0 +1,143 @@
<template>
<div class="app-container">
<!-- 顶部静态布局:标题栏 -->
<div class="page-topbar">
<div class="topbar-left">
<h2 class="topbar-title">备选冲突续读进度</h2>
<span class="topbar-sub">共 {{ total }} 条</span>
</div>
<div class="topbar-actions">
<el-button icon="el-icon-download" size="small" @click="handleExport">导出冲突进度</el-button>
</div>
</div>
<!-- 顶部静态布局:筛选卡片 -->
<div class="topbar-filter">
<el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true" label-width="80px">
<el-form-item label="学员ID" prop="userId">
<el-input
v-model="queryParams.userId"
placeholder="请输入学员 userId"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="图书ID" prop="bookId">
<el-input
v-model="queryParams.bookId"
placeholder="请输入图书ID"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="模式" prop="mode">
<el-select v-model="queryParams.mode" placeholder="阅读/听书" clearable>
<el-option label="read 阅读" value="read" />
<el-option label="audio 听书" value="audio" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</div>
<el-table v-loading="loading" :data="alternateList">
<el-table-column label="ID" align="center" prop="id" width="80" />
<el-table-column label="学员ID" align="center" prop="userId" width="100" />
<el-table-column label="图书ID" align="center" prop="bookId" min-width="120" show-overflow-tooltip />
<el-table-column label="模式" align="center" prop="mode" width="90">
<template slot-scope="scope">
<el-tag :type="scope.row.mode === 'audio' ? 'warning' : 'info'">
{{ scope.row.mode === 'audio' ? 'audio 听书' : 'read 阅读' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="版本" align="center" prop="bookVersion" width="90" />
<el-table-column label="页码" align="center" prop="pageNo" width="90" />
<el-table-column label="句子索引" align="center" prop="sentenceIndex" width="100" />
<el-table-column label="进度" align="center" prop="progress" width="90" />
<el-table-column label="来源recordId" align="center" prop="sourceRecordId" min-width="160" show-overflow-tooltip />
<el-table-column label="来源sessionId" align="center" prop="sourceSessionId" min-width="160" show-overflow-tooltip />
<el-table-column label="创建时间" align="center" prop="createTime" min-width="150" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="100">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:readingAlternateProgress:remove']"
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
</div>
</template>
<script>
import { listReadingAlternateProgress, delReadingAlternateProgress } from '@/api/device/readingAlternateProgress'
export default {
name: 'ReadingAlternateProgress',
data() {
return {
loading: true,
showSearch: true,
total: 0,
alternateList: [],
queryParams: {
pageNum: 1,
pageSize: 10,
userId: null,
bookId: null,
mode: null
}
}
},
created() {
this.getList()
},
methods: {
getList() {
this.loading = true
listReadingAlternateProgress(this.queryParams).then(response => {
this.alternateList = response.rows
this.total = response.total
this.loading = false
})
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.resetForm('queryForm')
this.handleQuery()
},
handleDelete(row) {
const id = row.id
this.$modal.confirm('是否确认删除ID为"' + row.id + '"的备选进度数据项?').then(function() {
return delReadingAlternateProgress(id)
}).then(() => {
this.getList()
this.$modal.msgSuccess('删除成功')
}).catch(() => {})
},
handleExport() {
this.download('train/readingAlternateProgress/export', {
...this.queryParams
}, `reading_alternate_progress_${new Date().getTime()}.xlsx`)
}
}
}
</script>
@@ -0,0 +1,143 @@
<template>
<div class="app-container">
<!-- 顶部静态布局:标题栏 -->
<div class="page-topbar">
<div class="topbar-left">
<h2 class="topbar-title">学员主续读进度</h2>
<span class="topbar-sub">共 {{ total }} 条</span>
</div>
<div class="topbar-actions">
<el-button icon="el-icon-download" size="small" @click="handleExport">导出进度数据</el-button>
</div>
</div>
<!-- 顶部静态布局:筛选卡片 -->
<div class="topbar-filter">
<el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true" label-width="80px">
<el-form-item label="学员ID" prop="userId">
<el-input
v-model="queryParams.userId"
placeholder="请输入学员 userId"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="图书ID" prop="bookId">
<el-input
v-model="queryParams.bookId"
placeholder="请输入图书ID"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="模式" prop="mode">
<el-select v-model="queryParams.mode" placeholder="阅读/听书" clearable>
<el-option label="read 阅读" value="read" />
<el-option label="audio 听书" value="audio" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</div>
<el-table v-loading="loading" :data="progressList">
<el-table-column label="ID" align="center" prop="id" width="80" />
<el-table-column label="学员ID" align="center" prop="userId" width="100" />
<el-table-column label="图书ID" align="center" prop="bookId" min-width="120" show-overflow-tooltip />
<el-table-column label="模式" align="center" prop="mode" width="90">
<template slot-scope="scope">
<el-tag :type="scope.row.mode === 'audio' ? 'warning' : 'info'">
{{ scope.row.mode === 'audio' ? 'audio 听书' : 'read 阅读' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="版本" align="center" prop="bookVersion" width="90" />
<el-table-column label="页码" align="center" prop="pageNo" width="90" />
<el-table-column label="句子索引" align="center" prop="sentenceIndex" width="100" />
<el-table-column label="进度" align="center" prop="progress" width="90" />
<el-table-column label="来源recordId" align="center" prop="sourceRecordId" min-width="160" show-overflow-tooltip />
<el-table-column label="来源sessionId" align="center" prop="sourceSessionId" min-width="160" show-overflow-tooltip />
<el-table-column label="更新时间" align="center" prop="updateTime" min-width="150" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="100">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:readingCurrentProgress:remove']"
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
</div>
</template>
<script>
import { listReadingCurrentProgress, delReadingCurrentProgress } from '@/api/device/readingCurrentProgress'
export default {
name: 'ReadingCurrentProgress',
data() {
return {
loading: true,
showSearch: true,
total: 0,
progressList: [],
queryParams: {
pageNum: 1,
pageSize: 10,
userId: null,
bookId: null,
mode: null
}
}
},
created() {
this.getList()
},
methods: {
getList() {
this.loading = true
listReadingCurrentProgress(this.queryParams).then(response => {
this.progressList = response.rows
this.total = response.total
this.loading = false
})
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.resetForm('queryForm')
this.handleQuery()
},
handleDelete(row) {
const id = row.id
this.$modal.confirm('是否确认删除ID为"' + row.id + '"的主进度数据项?').then(function() {
return delReadingCurrentProgress(id)
}).then(() => {
this.getList()
this.$modal.msgSuccess('删除成功')
}).catch(() => {})
},
handleExport() {
this.download('train/readingCurrentProgress/export', {
...this.queryParams
}, `reading_current_progress_${new Date().getTime()}.xlsx`)
}
}
}
</script>
@@ -0,0 +1,160 @@
<template>
<div class="app-container">
<!-- 顶部静态布局:标题栏 -->
<div class="page-topbar">
<div class="topbar-left">
<h2 class="topbar-title">阅读记录</h2>
<span class="topbar-sub">共 {{ total }} 条</span>
</div>
<div class="topbar-actions">
<el-button icon="el-icon-download" size="small" @click="handleExport">导出上报日志</el-button>
</div>
</div>
<!-- 顶部静态布局:筛选卡片 -->
<div class="topbar-filter">
<el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true" label-width="80px">
<el-form-item label="设备编码" prop="deviceId">
<el-input
v-model="queryParams.deviceId"
placeholder="请输入设备编码"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="学员ID" prop="userId">
<el-input
v-model="queryParams.userId"
placeholder="请输入学员 userId"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="模式" prop="mode">
<el-select v-model="queryParams.mode" placeholder="阅读/听书" clearable>
<el-option label="read 阅读" value="read" />
<el-option label="audio 听书" value="audio" />
</el-select>
</el-form-item>
<el-form-item label="上报结果" prop="resultCode">
<el-select v-model="queryParams.resultCode" placeholder="上报结果" clearable>
<el-option label="ACCEPTED" value="ACCEPTED" />
<el-option label="DUPLICATE" value="DUPLICATE" />
<el-option label="CONFLICT_SAVED" value="CONFLICT_SAVED" />
<el-option label="REJECTED" value="REJECTED" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</div>
<el-table v-loading="loading" :data="logList">
<el-table-column label="ID" align="center" prop="id" width="80" />
<el-table-column label="设备编码" align="center" prop="deviceId" min-width="140" show-overflow-tooltip />
<el-table-column label="学员ID" align="center" prop="userId" width="100" />
<el-table-column label="图书ID" align="center" prop="bookId" min-width="120" show-overflow-tooltip />
<el-table-column label="版本" align="center" prop="bookVersion" width="90" />
<el-table-column label="模式" align="center" prop="mode" width="90">
<template slot-scope="scope">
<el-tag :type="scope.row.mode === 'audio' ? 'warning' : 'info'">
{{ scope.row.mode === 'audio' ? 'audio 听书' : 'read 阅读' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="类型" align="center" prop="recordType" width="100" />
<el-table-column label="时长(秒)" align="center" prop="durationSec" width="100" />
<el-table-column label="进度" align="center" prop="progress" width="90" />
<el-table-column label="上报结果" align="center" prop="resultCode" min-width="130">
<template slot-scope="scope">
<el-tag :type="scope.row.resultCode === 'ACCEPTED' ? 'success' : (scope.row.resultCode === 'REJECTED' ? 'danger' : 'warning')">
{{ scope.row.resultCode }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="阅读开始" align="center" prop="startTime" min-width="150" />
<el-table-column label="阅读结束" align="center" prop="endTime" min-width="150" />
<el-table-column label="接收时间" align="center" prop="receiveTime" min-width="150" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="100">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:readingDurationLog:remove']"
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
</div>
</template>
<script>
import { listReadingDurationLog, delReadingDurationLog } from '@/api/device/readingDurationLog'
export default {
name: 'ReadingDurationLog',
data() {
return {
loading: true,
showSearch: true,
total: 0,
logList: [],
queryParams: {
pageNum: 1,
pageSize: 10,
deviceId: null,
userId: null,
mode: null,
resultCode: null
}
}
},
created() {
this.getList()
},
methods: {
getList() {
this.loading = true
listReadingDurationLog(this.queryParams).then(response => {
this.logList = response.rows
this.total = response.total
this.loading = false
})
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.resetForm('queryForm')
this.handleQuery()
},
handleDelete(row) {
const id = row.id
this.$modal.confirm('是否确认删除上报记录ID为"' + row.recordId + '"的数据项?').then(function() {
return delReadingDurationLog(id)
}).then(() => {
this.getList()
this.$modal.msgSuccess('删除成功')
}).catch(() => {})
},
handleExport() {
this.download('train/readingDurationLog/export', {
...this.queryParams
}, `reading_duration_log_${new Date().getTime()}.xlsx`)
}
}
}
</script>
+142
View File
@@ -0,0 +1,142 @@
<template>
<div class="app-container">
<!-- 顶部静态布局:标题栏 -->
<div class="page-topbar">
<div class="topbar-left">
<h2 class="topbar-title">设备登录日志</h2>
<span class="topbar-sub">共 {{ total }} 条</span>
</div>
<div class="topbar-actions">
<el-button icon="el-icon-download" size="small" @click="handleExport">导出会话数据</el-button>
</div>
</div>
<!-- 顶部静态布局:筛选卡片 -->
<div class="topbar-filter">
<el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true" label-width="80px">
<el-form-item label="学员ID" prop="userId">
<el-input
v-model="queryParams.userId"
placeholder="请输入学员 userId"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="设备编码" prop="deviceId">
<el-input
v-model="queryParams.deviceId"
placeholder="请输入设备编码"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="会话状态" prop="sessionStatus">
<el-select v-model="queryParams.sessionStatus" placeholder="会话状态" clearable>
<el-option label="ACTIVE 在线" :value="1" />
<el-option label="REPLACED 被顶替" :value="2" />
<el-option label="LOGOUT 退出" :value="3" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</div>
<el-table v-loading="loading" :data="sessionList">
<el-table-column label="ID" align="center" prop="id" width="80" />
<el-table-column label="学员ID" align="center" prop="userId" width="100" />
<el-table-column label="业务会话ID" align="center" prop="sessionId" min-width="160" show-overflow-tooltip />
<el-table-column label="设备编码" align="center" prop="deviceId" min-width="140" show-overflow-tooltip />
<el-table-column label="会话状态" align="center" prop="sessionStatus" width="120">
<template slot-scope="scope">
<el-tag :type="scope.row.sessionStatus === 1 ? 'success' : (scope.row.sessionStatus === 2 ? 'warning' : 'info')">
{{ scope.row.sessionStatus === 1 ? 'ACTIVE 在线' : (scope.row.sessionStatus === 2 ? 'REPLACED 被顶替' : 'LOGOUT 退出') }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="登录时间" align="center" prop="loginTime" min-width="150" />
<el-table-column label="被顶替时间" align="center" prop="replaceTime" min-width="150" />
<el-table-column label="退出时间" align="center" prop="logoutTime" min-width="150" />
<el-table-column label="RuoYi TokenKey" align="center" prop="ruoyiTokenKey" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="100">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:userSession:remove']"
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
</div>
</template>
<script>
import { listUserSession, delUserSession } from '@/api/device/session'
export default {
name: 'UserSession',
data() {
return {
loading: true,
showSearch: true,
total: 0,
sessionList: [],
queryParams: {
pageNum: 1,
pageSize: 10,
userId: null,
deviceId: null,
sessionStatus: null
}
}
},
created() {
this.getList()
},
methods: {
getList() {
this.loading = true
listUserSession(this.queryParams).then(response => {
this.sessionList = response.rows
this.total = response.total
this.loading = false
})
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.resetForm('queryForm')
this.handleQuery()
},
handleDelete(row) {
const id = row.id
this.$modal.confirm('是否确认删除会话ID为"' + row.sessionId + '"的数据项?').then(function() {
return delUserSession(id)
}).then(() => {
this.getList()
this.$modal.msgSuccess('删除成功')
}).catch(() => {})
},
handleExport() {
this.download('train/userSession/export', {
...this.queryParams
}, `user_session_${new Date().getTime()}.xlsx`)
}
}
}
</script>
+96 -12
View File
@@ -95,7 +95,7 @@
plain
icon="el-icon-edit"
size="mini"
:disabled="single"
:disabled="single || selectedUncertain"
@click="handleUpdate"
v-hasPermi="['lock:auth:edit']"
>修改</el-button>
@@ -192,6 +192,8 @@
size="mini"
type="text"
icon="el-icon-upload2"
:disabled="isUncertain(scope.row.executionState)"
:title="isUncertain(scope.row.executionState) ? '该授权处于待确认/人工核对状态,需先完成对账后才能下发' : ''"
@click="handlePush(scope.row)"
v-hasPermi="['lock:auth:push']"
>下发</el-button>
@@ -200,6 +202,8 @@
size="mini"
type="text"
icon="el-icon-user-solid"
:disabled="isUncertain(scope.row.executionState)"
:title="isUncertain(scope.row.executionState) ? '该授权处于待确认/人工核对状态,需先完成对账后才能清除' : ''"
@click="handleClear(scope.row)"
v-hasPermi="['lock:auth:clear']"
>删除人脸</el-button>
@@ -207,9 +211,19 @@
size="mini"
type="text"
icon="el-icon-edit"
:disabled="isUncertain(scope.row.executionState)"
:title="isUncertain(scope.row.executionState) ? '该授权处于待确认/人工核对状态,需先完成人工对账后才能修改' : ''"
@click="handleUpdate(scope.row)"
v-hasPermi="['lock:auth:edit']"
>修改</el-button>
<el-button
v-if="isUncertain(scope.row.executionState)"
size="mini"
type="text"
icon="el-icon-s-check"
@click="handleReconcile(scope.row)"
v-hasPermi="['lock:auth:edit']"
>人工核对</el-button>
<el-button
size="mini"
type="text"
@@ -343,11 +357,24 @@
<el-button @click="cancel">取 消</el-button>
</div>
</el-dialog>
<!-- 人工核对对话框 -->
<el-dialog title="人工核对授权状态" :visible.sync="reconcileOpen" width="440px" append-to-body>
<div style="margin-bottom:12px;">授权编号:{{ reconcileRow.authNo || reconcileRow.authId }}</div>
<el-radio-group v-model="reconcileOutcome">
<el-radio label="CONFIRMED">确认已生效(设备已成功授权)</el-radio>
<el-radio label="REJECTED" style="margin-top:8px;">确认未生效(设备未授权 / 下发已失败)</el-radio>
</el-radio-group>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitReconcile">确 定</el-button>
<el-button @click="reconcileOpen = false">取 消</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listAuth, getAuth, delAuth, addAuth, updateAuth, pushAuth, batchPush, clearAuth, batchClear } from "@/api/iot/auth";
import { listAuth, getAuth, delAuth, addAuth, updateAuth, pushAuth, batchPush, clearAuth, batchClear, reconcileAuth } from "@/api/iot/auth";
import { listFace, extract } from "@/api/iot/face";
import { listDevice } from "@/api/iot/device";
import { optionselect } from "@/api/iot/group";
@@ -362,6 +389,12 @@ export default {
ids: [],
single: true,
multiple: true,
// 当前单选行是否处于“待确认/人工核对”等不确定态(禁止修改/下发/清除)
selectedUncertain: false,
// 人工对账弹窗
reconcileOpen: false,
reconcileRow: {},
reconcileOutcome: 'CONFIRMED',
showSearch: true,
total: 0,
authList: [],
@@ -575,6 +608,7 @@ export default {
this.ids = selection.map(item => item.authId);
this.single = selection.length != 1;
this.multiple = !selection.length;
this.selectedUncertain = this.single && this.isUncertain(selection[0].executionState);
},
handleAdd() {
this.reset();
@@ -602,7 +636,7 @@ export default {
this.$modal.msgSuccess("授权意图已更新,请查询设备确认状态");
this.open = false;
this.getList();
});
}).catch(err => this.$modal.msgError(this.friendlyError(err)));
} else {
addAuth(this.form).then(response => {
this.$modal.msgSuccess("授权意图已记录,请查询设备确认状态");
@@ -617,7 +651,7 @@ export default {
pushAuth(row.authId).then(response => {
this.$modal.msgSuccess("下发指令已提交");
this.getList();
});
}).catch(err => this.$modal.msgError(this.friendlyError(err)));
},
/**
* 删除设备侧人脸:下发 delete 帧,仅清除门锁上的人脸数据,平台授权记录保留
@@ -629,22 +663,41 @@ export default {
}).then(() => {
this.$modal.msgSuccess("撤权请求已记录,设备清除结果待确认");
this.getList();
}).catch(() => {});
}).catch(err => { if (err !== 'cancel') this.$modal.msgError(this.friendlyError(err)); });
},
handleBatchPush() {
batchPush(this.ids).then(response => {
const safeIds = this.ids.filter(id => {
const row = this.authList.find(x => x.authId === id);
return row && !this.isUncertain(row.executionState);
});
const skipped = this.ids.length - safeIds.length;
if (safeIds.length === 0) {
this.$modal.msgWarning("所选授权均处于待确认/人工核对状态,需先完成对账");
return;
}
if (skipped > 0) this.$modal.msgWarning(skipped + " 条处于待确认/人工核对状态已跳过");
batchPush(safeIds).then(response => {
this.$modal.msgSuccess("批量下发已提交");
this.getList();
});
}).catch(err => this.$modal.msgError(this.friendlyError(err)));
},
handleBatchClear() {
const authIds = this.ids;
this.$modal.confirm('确认清除选中授权在设备侧的数据?').then(function() {
return batchClear(authIds);
const safeIds = this.ids.filter(id => {
const row = this.authList.find(x => x.authId === id);
return row && !this.isUncertain(row.executionState);
});
const skipped = this.ids.length - safeIds.length;
if (safeIds.length === 0) {
this.$modal.msgWarning("所选授权均处于待确认/人工核对状态,需先完成对账");
return;
}
this.$modal.confirm('确认清除选中授权在设备侧的数据?').then(() => {
return batchClear(safeIds);
}).then(() => {
if (skipped > 0) this.$modal.msgWarning(skipped + " 条处于待确认/人工核对状态已跳过");
this.$modal.msgSuccess("清除指令已提交");
this.getList();
}).catch(() => {});
}).catch(err => { if (err !== 'cancel') this.$modal.msgError(this.friendlyError(err)); });
},
handleDelete(row) {
const authIds = row.authId || this.ids;
@@ -653,7 +706,38 @@ export default {
}).then(() => {
this.getList();
this.$modal.msgSuccess("已处理删除请求,未确认撤权的记录将保留");
}).catch(() => {});
}).catch(err => { if (err !== 'cancel') this.$modal.msgError(this.friendlyError(err)); });
},
/** 授权是否处于“待确认/人工核对”等不确定态(后端禁止在此状态下再次授权) */
isUncertain(state) {
return ['DISPATCHING', 'AWAITING_ACK', 'MANUAL_REVIEW'].indexOf(state) !== -1;
},
/** 打开人工核对对话框 */
handleReconcile(row) {
this.reconcileRow = row;
this.reconcileOutcome = 'CONFIRMED';
this.reconcileOpen = true;
},
/** 提交人工核对结果 */
submitReconcile() {
const updateBy = this.$store.state.user.name;
reconcileAuth(this.reconcileRow.authId, this.reconcileOutcome, updateBy).then(() => {
this.$modal.msgSuccess('已记录人工核对结果,可重新修改 / 下发');
this.reconcileOpen = false;
this.getList();
}).catch(err => this.$modal.msgError(this.friendlyError(err)));
},
/** 把后端安全异常转成可操作的中文提示 */
friendlyError(err) {
const msg = (err && (err.msg
|| (err.response && err.response.data && err.response.data.msg))) || '';
if (/reconciliation|manual reconciliation|uncertain/i.test(msg)) {
return '该授权处于“待确认/人工核对”状态,需先完成人工对账后才能修改或重新下发。';
}
if (/provenance|offline reconciliation/i.test(msg)) {
return '授权来源历史不一致,需离线对账后重试。';
}
return msg || '操作失败,请稍后重试';
},
deviceLabel(deviceId, deviceSn) {
const device = this.deviceOptions.find(item => item.deviceId === deviceId);
+5 -5
View File
@@ -23,7 +23,7 @@
<svg-icon slot="prefix" icon-class="password" class="el-input__icon input-icon" />
</el-input>
</el-form-item>
<el-form-item prop="code" v-if="captchaEnabled">
<!-- <el-form-item prop="code" v-if="captchaEnabled">
<el-input
v-model="loginForm.code"
auto-complete="off"
@@ -36,8 +36,8 @@
<div class="login-code">
<img :src="codeUrl" @click="getCode" class="login-code-img"/>
</div>
</el-form-item>
<el-checkbox v-model="loginForm.rememberMe" style="margin:0px 0px 25px 0px;">记住密码</el-checkbox>
</el-form-item>-->
<!-- <el-checkbox v-model="loginForm.rememberMe" style="margin:0px 0px 25px 0px;">记住密码</el-checkbox>-->
<el-form-item style="width:100%;">
<el-button
:loading="loading"
@@ -55,9 +55,9 @@
</el-form-item>
</el-form>
<!-- 底部 -->
<div class="el-login-footer">
<!-- <div class="el-login-footer">
<span>版权所有 中国科学院沈阳计算技术研究所有限公司</span>
</div>
</div>-->
</div>
</template>
+2 -3
View File
@@ -308,10 +308,9 @@ export default {
this.single = selection.length != 1
this.multiple = !selection.length
},
/** 字典数据抽屉显示信息 */
/** 字典数据列表页面(点击字典类型直接进入管理页,可增删改) */
handleViewData(row) {
this.drawerRow = row
this.drawerVisible = true
this.$tab.openPage("字典数据", '/system/dict-data/index/' + row.dictId)
},
/** 字典数据列表页面 */
handleDataList(row) {
+1 -1
View File
@@ -137,7 +137,7 @@ export default {
loading: false,
info: {},
postOptions: [],
roleOptions: []
roleOptions: [],
}
},
computed: {
@@ -15,58 +15,82 @@
<el-row :gutter="16" class="kpi-row" v-loading="loadingOverview">
<el-col :xs="12" :sm="6" :md="3">
<div class="kpi-card">
<div class="kpi-label">学员总数</div>
<div class="kpi-value text-primary">{{ overview.studentTotal || 0 }}</div>
<div class="kpi-sub">主档档案正常</div>
<div class="kpi-icon tint-blue"><i class="el-icon-user" /></div>
<div class="kpi-meta">
<div class="kpi-label">学员总数</div>
<div class="kpi-value">{{ overview.studentTotal || 0 }}</div>
<div class="kpi-sub">主档档案正常</div>
</div>
</div>
</el-col>
<el-col :xs="12" :sm="6" :md="3">
<div class="kpi-card">
<div class="kpi-label">教师总数</div>
<div class="kpi-value text-success">{{ overview.teacherTotal || 0 }}</div>
<div class="kpi-sub">专兼职讲师</div>
<div class="kpi-icon tint-green"><i class="el-icon-s-check" /></div>
<div class="kpi-meta">
<div class="kpi-label">教师总数</div>
<div class="kpi-value">{{ overview.teacherTotal || 0 }}</div>
<div class="kpi-sub">专兼职讲师</div>
</div>
</div>
</el-col>
<el-col :xs="12" :sm="6" :md="3">
<div class="kpi-card">
<div class="kpi-label">课程总数</div>
<div class="kpi-value text-info">{{ overview.courseTotal || 0 }}</div>
<div class="kpi-sub">标准课程库</div>
<div class="kpi-icon tint-purple"><i class="el-icon-notebook-2" /></div>
<div class="kpi-meta">
<div class="kpi-label">课程总数</div>
<div class="kpi-value">{{ overview.courseTotal || 0 }}</div>
<div class="kpi-sub">标准课程库</div>
</div>
</div>
</el-col>
<el-col :xs="12" :sm="6" :md="3">
<div class="kpi-card">
<div class="kpi-label">培训计划</div>
<div class="kpi-value text-warning">{{ overview.planTotal || 0 }}</div>
<div class="kpi-sub">全周期班级数</div>
<div class="kpi-icon tint-orange"><i class="el-icon-date" /></div>
<div class="kpi-meta">
<div class="kpi-label">培训计划</div>
<div class="kpi-value">{{ overview.planTotal || 0 }}</div>
<div class="kpi-sub">全周期班级数</div>
</div>
</div>
</el-col>
<el-col :xs="12" :sm="6" :md="3">
<div class="kpi-card">
<div class="kpi-label">累计排课</div>
<div class="kpi-value">{{ overview.lessonTotal || 0 }}</div>
<div class="kpi-sub">课次课时调度</div>
<div class="kpi-icon tint-cyan"><i class="el-icon-alarm-clock" /></div>
<div class="kpi-meta">
<div class="kpi-label">累计排课</div>
<div class="kpi-value">{{ overview.lessonTotal || 0 }}</div>
<div class="kpi-sub">课次课时调度</div>
</div>
</div>
</el-col>
<el-col :xs="12" :sm="6" :md="3">
<div class="kpi-card">
<div class="kpi-label">累计参训</div>
<div class="kpi-value">{{ overview.enrollmentTotal || 0 }}</div>
<div class="kpi-sub">人次总规模</div>
<div class="kpi-icon tint-indigo"><i class="el-icon-s-data" /></div>
<div class="kpi-meta">
<div class="kpi-label">累计参训</div>
<div class="kpi-value">{{ overview.enrollmentTotal || 0 }}</div>
<div class="kpi-sub">人次总规模</div>
</div>
</div>
</el-col>
<el-col :xs="12" :sm="6" :md="3">
<div class="kpi-card">
<div class="kpi-label">已报到人数</div>
<div class="kpi-value text-success">{{ overview.reportTotal || 0 }}</div>
<div class="kpi-sub">核验确认人次</div>
<div class="kpi-icon tint-green"><i class="el-icon-finished" /></div>
<div class="kpi-meta">
<div class="kpi-label">已报到人数</div>
<div class="kpi-value">{{ overview.reportTotal || 0 }}</div>
<div class="kpi-sub">核验确认人次</div>
</div>
</div>
</el-col>
<el-col :xs="12" :sm="6" :md="3">
<div class="kpi-card">
<div class="kpi-label">当前在宿</div>
<div class="kpi-value text-primary">{{ overview.accommodationCurrent || 0 }}</div>
<div class="kpi-sub">宿舍在住学员</div>
<div class="kpi-icon tint-blue"><i class="el-icon-house" /></div>
<div class="kpi-meta">
<div class="kpi-label">当前在宿</div>
<div class="kpi-value">{{ overview.accommodationCurrent || 0 }}</div>
<div class="kpi-sub">宿舍在住学员</div>
</div>
</div>
</el-col>
</el-row>
@@ -528,8 +552,21 @@ export default {
<style scoped lang="scss">
.dashboard-editor-container {
padding: 16px 20px;
background-color: #f0f2f5;
background-color: #f5f7fa;
min-height: calc(100vh - 84px);
::v-deep .el-card {
border-radius: 10px;
border-color: #eef0f5;
box-shadow: 0 1px 2px rgba(31, 45, 61, 0.04) !important;
}
::v-deep .el-card__header {
border-bottom: 1px solid #f2f4f8;
padding: 14px 20px;
}
::v-deep .el-card__body {
padding: 18px 20px;
}
}
.dashboard-header {
@@ -538,18 +575,19 @@ export default {
align-items: center;
background: #fff;
padding: 16px 20px;
border-radius: 4px;
border-radius: 10px;
margin-bottom: 16px;
border-left: 4px solid #409EFF;
border: 1px solid #eef0f5;
.header-title h2 {
margin: 0 0 4px 0;
font-size: 18px;
color: #303133;
font-size: 17px;
font-weight: 600;
color: #1f2d3d;
}
.header-title .subtitle {
font-size: 13px;
color: #909399;
color: #8a94a6;
}
}
@@ -559,26 +597,62 @@ export default {
.kpi-card {
background: #fff;
border-radius: 4px;
padding: 14px 16px;
text-align: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
border: 1px solid #eef0f5;
border-radius: 10px;
padding: 16px 14px;
display: flex;
align-items: center;
gap: 10px;
transition: box-shadow 0.2s, transform 0.2s;
&:hover {
box-shadow: 0 4px 12px rgba(31, 45, 61, 0.08);
transform: translateY(-2px);
}
.kpi-icon {
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
&.tint-blue { background: #ecf5ff; color: #409eff; }
&.tint-green { background: #f0f9eb; color: #67c23a; }
&.tint-purple { background: #f3f0ff; color: #7c6af2; }
&.tint-orange { background: #fdf6ec; color: #e6a23c; }
&.tint-cyan { background: #e8f8f8; color: #36cbcb; }
&.tint-indigo { background: #eef2ff; color: #5a6fd8; }
}
.kpi-meta {
min-width: 0;
}
.kpi-label {
font-size: 13px;
color: #606266;
margin-bottom: 8px;
font-size: 12px;
color: #8a94a6;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kpi-value {
font-size: 22px;
font-weight: bold;
color: #303133;
font-weight: 700;
color: #1f2d3d;
line-height: 1.2;
margin-bottom: 6px;
}
.kpi-sub {
font-size: 11px;
color: #909399;
color: #a5adba;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
@@ -639,9 +713,9 @@ export default {
flex: 1;
display: flex;
align-items: center;
background: #fafafa;
border: 1px solid #ebeef5;
border-radius: 4px;
background: #fafbfc;
border: 1px solid #eef0f5;
border-radius: 8px;
padding: 8px 12px;
transition: all 0.2s;
&.clickable:hover {
@@ -656,7 +730,7 @@ export default {
.alert-icon {
width: 36px;
height: 36px;
border-radius: 4px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
File diff suppressed because it is too large. Load diff
+17 -3
View File
@@ -1,5 +1,19 @@
<template>
<div class="app-container">
<!-- 顶部静态布局:标题栏 -->
<div class="page-topbar">
<div class="topbar-left">
<h2 class="topbar-title">电子班牌信息</h2>
<span class="topbar-sub">在册 {{ total }} 块</span>
</div>
<div class="topbar-actions">
<el-button icon="el-icon-download" size="small" @click="handleExport">导出电子班牌台账</el-button>
<el-button type="primary" icon="el-icon-plus" size="small" @click="handleAdd">新增电子班牌</el-button>
</div>
</div>
<!-- 顶部静态布局:筛选卡片 -->
<div class="topbar-filter">
<el-form
v-show="showSearch"
ref="queryForm"
@@ -67,8 +81,8 @@
</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
</div>
<!-- <el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
v-hasPermi="['train:board:add']"
@@ -120,7 +134,7 @@
</el-button>
</el-col>
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
</el-row>
</el-row>-->
<el-table
v-loading="loading"
+16 -15
View File
@@ -1,8 +1,8 @@
<template>
<div class="app-container">
<el-tabs v-model="activeTab" type="border-card">
<!-- Tab 1: 课次考勤与点名签到 -->
<el-tab-pane label="课次考勤" name="roster">
<!-- Tab 1: 课程考勤与点名签到 -->
<el-tab-pane label="课程考勤" name="roster">
<el-form :model="rosterQuery" size="small" :inline="true">
<el-form-item label="培训计划">
<el-select
@@ -20,10 +20,10 @@
/>
</el-select>
</el-form-item>
<el-form-item label="课次">
<el-form-item label="课程">
<el-select
v-model="rosterQuery.lessonId"
placeholder="请选择课次"
placeholder="请选择课程"
filterable
:disabled="!rosterQuery.planId"
:loading="lessonSelectLoading"
@@ -50,16 +50,16 @@
</el-form>
<el-alert
title="课次应到学员考勤:展示该课次应到参训学员。已报到学员可点击【签到】录入出勤;课次已开始后未到课学员可点击【标记缺勤】填写原因直接登记缺勤(不会产生签到时间);待报到或已结训学员暂不可签到;已签到学员支持签退与考勤调整。"
title="课程应到学员考勤:展示该课程应到参训学员。已报到学员可点击【签到】录入出勤;课程已开始后未到课学员可点击【标记缺勤】填写原因直接登记缺勤(不会产生签到时间);待报到或已结训学员暂不可签到;已签到学员支持签退与考勤调整。"
type="info"
:closable="false"
show-icon
class="mb8"
/>
<el-table v-loading="rosterLoading" :data="rosterList" empty-text="请选择培训计划与课次以加载应到学员名册">
<el-table v-loading="rosterLoading" :data="rosterList" empty-text="请选择培训计划与课程以加载应到学员名册">
<el-table-column label="学员姓名" prop="studentName" min-width="110" />
<el-table-column label="学员ID/工号" prop="studentId" min-width="120" show-overflow-tooltip />
<!-- <el-table-column label="学员ID/工号" prop="studentId" min-width="120" show-overflow-tooltip />-->
<el-table-column label="性别" prop="gender" align="center" width="70" />
<el-table-column label="所属机构/部门" prop="deptName" min-width="140" show-overflow-tooltip />
<el-table-column label="联系电话" prop="phoneMasked" min-width="120" />
@@ -164,9 +164,10 @@
<el-table v-loading="loading" :data="rows">
<el-table-column label="培训计划" prop="planName" min-width="150" show-overflow-tooltip />
<el-table-column label="班级名称" prop="className" min-width="150" show-overflow-tooltip />
<el-table-column label="课程" prop="courseName" min-width="140" show-overflow-tooltip />
<el-table-column label="学员" prop="studentName" min-width="100" />
<el-table-column label="班牌" min-width="130" show-overflow-tooltip>
<el-table-column label="电子班牌" min-width="130" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.boardName || scope.row.boardSn || "-" }}</template>
</el-table-column>
<el-table-column label="签到方式" align="center" width="100">
@@ -181,9 +182,9 @@
</el-table-column>
<el-table-column label="签到时间" prop="signInTime" width="170" />
<el-table-column label="签退时间" prop="signOutTime" width="170" />
<el-table-column label="人工修正" align="center" width="90">
<!-- <el-table-column label="人工修正" align="center" width="90">
<template slot-scope="scope">{{ scope.row.manualFlag === 1 ? "是" : "否" }}</template>
</el-table-column>
</el-table-column>-->
<el-table-column label="备注/调整说明" prop="remark" min-width="150" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.remark || "-" }}</template>
</el-table-column>
@@ -355,7 +356,7 @@ export default {
{ validator: validateAbsenceRemark, trigger: 'blur' }
]
},
// 课次考勤名册 (Roster) 相关数据
// 课程考勤名册 (Roster) 相关数据
planOptions: [],
lessonOptions: [],
lessonSelectLoading: false,
@@ -453,13 +454,13 @@ export default {
this.rosterList = res.data || []
}).catch(err => {
const feedback = resolveSignInFeedback(err)
this.$modal.msgError(feedback.message || '获取课次考勤名册失败')
this.$modal.msgError(feedback.message || '获取课程考勤名册失败')
}).finally(() => {
this.rosterLoading = false
})
},
handleRosterSignIn(row) {
this.$modal.confirm(`确认对学员【${row.studentName}】执行课次首次签到?`).then(() => {
this.$modal.confirm(`确认对学员【${row.studentName}】执行课程首次签到?`).then(() => {
return signInAttendance({
enrollmentId: row.enrollmentId,
lessonId: this.rosterQuery.lessonId,
@@ -477,7 +478,7 @@ export default {
})
},
handleRosterSignOut(row) {
this.$modal.confirm(`确认对学员【${row.studentName}】执行课次签退?`).then(() => {
this.$modal.confirm(`确认对学员【${row.studentName}】执行课程签退?`).then(() => {
return signOutAttendance({
enrollmentId: row.enrollmentId,
lessonId: row.lessonId || this.rosterQuery.lessonId
@@ -593,7 +594,7 @@ export default {
})
},
handleSignOut(row) {
this.$modal.confirm(`确认对学员【${row.studentName}】执行课次签退?`).then(() => {
this.$modal.confirm(`确认对学员【${row.studentName}】执行课程签退?`).then(() => {
return signOutAttendance({
enrollmentId: row.enrollmentId,
lessonId: row.lessonId
@@ -0,0 +1,671 @@
<template>
<div class="accommodation-panel">
<!-- 检索条 -->
<el-form ref="queryForm" :model="queryParams" size="small" :inline="true" label-width="78px">
<el-form-item label="学员姓名" prop="studentName">
<el-input
v-model="queryParams.studentName"
placeholder="请输入学员姓名"
clearable
style="width: 150px"
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<!-- 需住宿人员列表(数据来源:本班参训名单中住宿需求=需住宿的学员;固定高度内纵向滚动,表头固定) -->
<el-table v-loading="loading" :data="list" border height="420" style="width: 100%">
<el-table-column label="序号" type="index" width="60" align="center" :index="indexMethod" />
<el-table-column label="头像" align="center" width="70">
<template slot-scope="scope">
<el-image
:src="avatarUrl(scope.row.avatar)"
:preview-src-list="[avatarUrl(scope.row.avatar)]"
fit="cover"
class="enroll-avatar"
>
<div slot="error" class="image-slot">
<i class="el-icon-picture-outline" />
</div>
</el-image>
</template>
</el-table-column>
<el-table-column label="姓名" align="center" prop="studentName" min-width="100" show-overflow-tooltip />
<el-table-column label="性别" align="center" width="70">
<template slot-scope="scope">
<span>{{ formatGender(scope.row.gender) }}</span>
</template>
</el-table-column>
<el-table-column label="住宿安排" align="center" min-width="330">
<template slot-scope="scope">
<div class="dorm-select-cell">
<el-select
:value="rowStateOf(scope.row).dormId"
placeholder="宿舍房间号"
filterable
size="mini"
:disabled="!rowStateOf(scope.row).editing"
style="width: 185px"
@change="val => handleDormChange(scope.row, val)"
>
<el-option
v-for="dorm in dormOptions"
:key="dorm.dormId"
:label="dormLabel(dorm)"
:value="dorm.dormId"
:disabled="isDormUnavailable(dorm, rowStateOf(scope.row).dormId)"
/>
</el-select>
<el-select
:value="rowStateOf(scope.row).bedNo"
placeholder="床位"
size="mini"
:disabled="!rowStateOf(scope.row).editing || !rowStateOf(scope.row).dormId"
style="width: 105px"
@change="val => handleBedChange(scope.row, val)"
>
<el-option v-for="bed in bedOptions(scope.row)" :key="bed" :label="bed" :value="bed" />
</el-select>
</div>
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="200" fixed="right">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:accommodation:add', 'train:accommodation:edit']"
size="mini"
type="text"
:icon="rowStateOf(scope.row).editing ? 'el-icon-close' : 'el-icon-office-building'"
:loading="rowStateOf(scope.row).saving"
@click="handleToggleAdjust(scope.row)"
>调整宿舍</el-button>
<el-button
v-hasPermi="['train:accommodation:edit']"
size="mini"
type="text"
icon="el-icon-remove-outline"
style="color: #E6A23C;"
:disabled="!hasAssignment(scope.row) || scope.row.accommodationStatus === 2"
@click="handleCheckOut(scope.row)"
>退宿</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script>
import { listDormitory } from '@/api/train/dormitory'
import { listPlanEnrollments } from '@/api/train/enrollment'
import {
addAccommodation,
transferAccommodation,
getActiveAccommodation,
checkOutAccommodation
} from '@/api/train/accommodation'
import { getPlan } from '@/api/train/plan'
import { addAuth } from '@/api/iot/auth'
import { listDevice } from '@/api/iot/device'
import { extractStudent } from '@/api/train/student'
import { getStudentAvatar } from '@/utils/avatar'
// 门锁授权类型:01 = 人脸(住宿安排后自动下发人脸授权)
const AUTH_TYPE_FACE = '01'
export default {
name: 'AccommodationPanel',
props: {
planId: {
type: [String, Number],
required: true
}
},
data() {
return {
loading: false,
total: 0,
// 本班需住宿学员名单
list: [],
// 宿舍房间选项(宿舍信息表全量,用于回显与选择)
dormOptions: [],
// 每个学员行的编辑态(key = enrollmentId)
rowState: {},
// 本班开班/结课时间(门锁授权默认生效区间)
planPeriod: {
startTime: undefined,
endTime: undefined
},
queryParams: {
pageNum: 1,
pageSize: 10,
studentName: null,
// 只展示需要住宿的学员
accommodationRequired: 1
}
}
},
watch: {
planId: {
immediate: true,
handler(val) {
if (val) {
this.loadPlanPeriod()
this.loadDormOptions().then(() => this.getList())
}
}
}
},
methods: {
/** 表格序号:按当前分页顺序连续编号 */
indexMethod(index) {
const { pageNum = 1, pageSize = 10 } = this.queryParams
return (pageNum - 1) * pageSize + index + 1
},
avatarUrl(avatar) {
return getStudentAvatar(avatar)
},
formatGender(val) {
if (val === '0' || val === 0 || val === '男') return '男'
if (val === '1' || val === 1 || val === '女') return '女'
return '-'
},
/** 行编辑态:未初始化时返回禁用态默认值,避免模板取值报错 */
rowStateOf(row) {
const state = row && this.rowState[row.enrollmentId]
return state || { editing: false, saving: false, dormId: null, bedNo: null }
},
// ===== 数据加载 =====
loadDormOptions() {
// dormStatus 不传,停用房间也纳入选项,保证已分配学员的宿舍能正确回显
return listDormitory({ pageNum: 1, pageSize: 1000 }).then(res => {
this.dormOptions = res.rows || []
}).catch(() => {
this.dormOptions = []
})
},
/** 生效/失效时间默认取本班开班/结课时间 */
loadPlanPeriod() {
if (!this.planId) return
getPlan(this.planId).then(res => {
const plan = (res && res.data) || {}
this.planPeriod.startTime = this.normalizeDateTime(plan.startTime)
this.planPeriod.endTime = this.normalizeDateTime(plan.endTime)
}).catch(() => {})
},
normalizeDateTime(val) {
if (!val) return undefined
let s = String(val).replace('T', ' ')
if (s.length === 10) s = s + ' 00:00:00'
return s
},
getList() {
if (!this.planId) return
this.loading = true
listPlanEnrollments(this.planId, this.queryParams).then(res => {
console.log('res', res)
this.list = res.rows || []
this.total = res.total || 0
this.initRowState()
}).finally(() => {
this.loading = false
})
},
/** 依据当前页数据初始化每行的下拉框状态(默认禁用) */
initRowState() {
const state = {}
this.list.forEach(row => {
const matched = this.matchDorm(row)
state[row.enrollmentId] = {
editing: false,
saving: false,
accommodationId: null,
lockApiBizNo: row.lockApiBizNo ? row.lockApiBizNo : "",
dormId: matched ? matched.dormId : null,
bedNo: row.bedNo || null,
originDormId: matched ? matched.dormId : null,
originBedNo: row.bedNo || null
}
})
this.rowState = state
},
/** 按楼栋+房间号匹配宿舍记录 */
matchDorm(row) {
if (!row || (!row.dormBuilding && !row.dormRoom)) return null
return this.dormOptions.find(d =>
String(d.dormBuilding || '') === String(row.dormBuilding || '') &&
String(d.dormRoom || '') === String(row.dormRoom || '')
) || null
},
/**
* 是否"当前有效住宿":后端仅返回 0(预分配)/1(已入住) 的住宿字段,
* 这里再兜底排除 status=2(已退宿),避免历史记录被当成在住记录。
*/
hasAssignment(row) {
return !!(row && row.accommodationStatus !== 2 && (row.dormBuilding || row.dormRoom))
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.resetForm('queryForm')
this.handleQuery()
},
// ===== 宿舍房间 / 床位下拉 =====
genderLabel(val) {
if (val === 1) return '男'
if (val === 2) return '女'
return '不限'
},
dormLabel(dorm) {
const building = dorm.dormBuilding || ''
const room = dorm.dormRoom || ''
const free = this.freeBeds(dorm)
const capacity = dorm.capacity != null ? dorm.capacity : '-'
return `${building} ${room}(余位:${free}/${capacity},${this.genderLabel(dorm.genderType)})`
},
/** 已分配人数 */
usedBeds(dorm) {
if (dorm.usedCount != null) return Number(dorm.usedCount) || 0
return 0
},
/** 剩余床位数 */
freeBeds(dorm) {
const remaining = dorm.remainingBeds != null
? dorm.remainingBeds
: (dorm.capacity != null ? Number(dorm.capacity) - this.usedBeds(dorm) : 0)
return Number(remaining) || 0
},
/** 房间是否不可选:停用或已满(已选中的房间仍可展示) */
isDormUnavailable(dorm, currentDormId) {
if (String(dorm.dormId) === String(currentDormId)) return false
if (dorm.dormStatus === 0) return true
return this.freeBeds(dorm) <= 0
},
/** 床位选项:按所选房间核定容量生成 */
bedOptions(row) {
const state = this.rowStateOf(row)
const dorm = this.dormOptions.find(d => String(d.dormId) === String(state.dormId))
const capacity = dorm && dorm.capacity != null ? Number(dorm.capacity) : 0
const options = []
for (let i = 1; i <= capacity; i++) {
options.push(`${i}号床`)
}
return options
},
handleDormChange(row, dormId) {
const state = this.rowState[row.enrollmentId]
if (!state || !state.editing) return
this.$set(state, 'dormId', dormId)
// 换房间后床位需重新选择
this.$set(state, 'bedNo', null)
},
handleBedChange(row, bedNo) {
const state = this.rowState[row.enrollmentId]
if (!state || !state.editing) return
this.$set(state, 'bedNo', bedNo)
this.trySave(row)
},
/**
* 房间与床位均已选择后,确认并提交(新增分配 / 调房),成功后自动下发门锁授权。
*
* 调房不再校验目标房间是否绑定门锁(lock_device_sn):住宿关系优先保存,
* 门锁授权统一在保存成功后再由 autoPushAuth 调门锁开放接口 /stay/checkin 下发;
* 授权下发失败不回滚住宿安排,仅记录日志(响应拦截器已提示具体原因)。
*/
trySave(row) {
const state = this.rowState[row.enrollmentId]
console.log("in_trySave_state==============");
console.log(state);
const lockApiBizNo = state.lockApiBizNo;
if (!state || !state.editing || state.saving) return
if (!state.dormId || !state.bedNo) return
const dorm = this.dormOptions.find(d => String(d.dormId) === String(state.dormId)) || {}
const location = `${dorm.dormBuilding || ''} ${dorm.dormRoom || ''} ${state.bedNo}`.trim()
const dormId = state.dormId
this.$modal.confirm(`确认将学员【${row.studentName || ''}】的住宿安排为【${location}】吗?`)
.then(() => {
state.saving = true
// 已分配学员需先取有效住宿记录,走调房接口
return this.resolveActiveAccommodation(row, state).then((resResolveActiveAccommodation) => {
console.log("in_then_resResolveActiveAccommodation==============");
console.log(resResolveActiveAccommodation);
return state.accommodationId
? transferAccommodation(state.accommodationId, state.dormId, state.bedNo).then(resTransferAccommodation => {
console.log("in_then_resTransferAccommodation==============");
console.log(resTransferAccommodation);
})
: addAccommodation({
enrollmentId: row.enrollmentId,
dormId: state.dormId,
bedNo: state.bedNo,
remark: '班级住宿安排'
}).then(resAccommodation => {
console.log("in_then_resAccommodation==============");
console.log(resAccommodation);
})
})
})
.then(res => {
// 读取接口返回:成功状态 + 后端消息(msg 缺省时回落到本地兜底文案)
console.log("res----------");
console.log(res);
const result = this.parseApiResult(res, '住宿安排成功')
if (!result.success) {
// 业务失败(code 非成功值)转走统一异常分支,提示中带错误码与后端详情
const error = new Error(result.message)
error.code = result.code
error.msg = result.message
error.data = res
throw error
}
this.$modal.msgSuccess(result.message)
this.$set(state, 'editing', false)
// 刷新房间余位与列表回显
this.loadDormOptions().then(() => this.getList())
// 保存成功后统一下发门锁授权:房间/学员信息由 dormId + row 决定,
// 授权失败不影响住宿结果,不回滚住宿记录
/*this.autoPushAuth(row, dormId, state.accommodationId).catch(err => {
console.warn('[门锁授权] 下发失败,住宿安排已保存', err)
})*/
})
.catch(err => {
// 取消或失败均回滚,保持原住宿安排
this.$set(state, 'dormId', state.originDormId)
this.$set(state, 'bedNo', state.originBedNo)
if (err !== 'cancel') {
// 统一异常提示:业务错误带错误码与后端详情,网络异常/超时给出对应说明
this.$modal.msgError(this.describeApiError(err, '住宿安排失败'))
}
})
.finally(() => {
state.saving = false
})
},
/**
* 统一解析接口返回结果。
*
* 响应拦截器已把 AjaxResult 透传为 { code, msg, data },此处再兼容仅返回 msg/message 的场景,
* 使提示文案始终取自后端返回内容,避免硬编码。
*
* @param {object} res 接口返回结果
* @param {string} fallbackMsg 后端未返回消息时的兜底文案
* @returns {{success: boolean, code: (number|string|null), message: string}}
*/
parseApiResult(res, fallbackMsg) {
const code = res && res.code !== undefined && res.code !== null ? res.code : 200
const message = (res && (res.msg || res.message)) || fallbackMsg
return {
success: String(code) === '200' || String(code) === '0',
code: code,
message: message
}
},
/**
* 统一描述请求异常:
* 1. 业务错误 → 错误码 + 后端返回的错误详情;
* 2. 请求超时 / 网络异常 → 对应异常提示,不直接抛 axios 英文原文;
* 3. 无任何可用信息 → 兜底文案。
*
* @param {*} err 接口异常(业务错误 Error、axios error 或取消标记)
* @param {string} fallbackMsg 兜底文案
* @returns {string} 可直接展示的提示文案
*/
describeApiError(err, fallbackMsg) {
const fallback = fallbackMsg || '操作失败'
if (!err) return fallback
if (typeof err === 'string') {
// 拦截器对 601(警告)与“取消”类异常只返回占位字符串,此时使用兜底文案
return err === 'cancel' || err === 'error' ? fallback : err
}
const response = err.response || {}
const body = response.data || err.data || {}
const code = err.code !== undefined && err.code !== null
? err.code
: (body && body.code !== undefined && body.code !== null ? body.code : response.status)
const detail = (body && (body.msg || body.message)) || err.msg || err.message || ''
// 超时与网络异常单独提示,避免把 axios 英文原文直接抛给用户
if (/timeout/i.test(detail) || code === 'ECONNABORTED') {
return `${fallback}:请求超时,请检查网络后重试`
}
if (/network\s*error/i.test(detail)) {
return `${fallback}:网络异常,请检查网络连接后重试`
}
if (!detail) return code ? `${fallback}(错误码:${code})` : fallback
return code ? `${fallback}(错误码:${code}):${detail}` : `${fallback}:${detail}`
},
/** 取学员当前有效住宿记录(供调房/退宿使用);fillSelection 为 true 时同步回填下拉框 */
resolveActiveAccommodation(row, state, fillSelection = false) {
console.log("in resolveActiveAccommodation");
if (!state || state.accommodationId) return Promise.resolve(state)
return getActiveAccommodation(row.enrollmentId).then(res => {
const acc = (res && res.data) || null
if (!acc || !acc.accommodationId) return state
this.$set(state, 'accommodationId', acc.accommodationId)
if (fillSelection) {
if (acc.dormId) {
this.$set(state, 'dormId', acc.dormId)
this.$set(state, 'originDormId', acc.dormId)
}
if (acc.bedNo) {
this.$set(state, 'bedNo', acc.bedNo)
this.$set(state, 'originBedNo', acc.bedNo)
}
}
console.log("in resolveActiveAccommodation, state:", state);
return state
}).catch(() => state)
},
/** 调整宿舍:激活 / 取消下拉框编辑 */
handleToggleAdjust(row) {
const state = this.rowState[row.enrollmentId]
if (!state) return
if (state.editing) {
// 再次点击视为取消调整,回滚为原住宿安排并禁用下拉框
this.$set(state, 'editing', false)
this.$set(state, 'dormId', state.originDormId)
this.$set(state, 'bedNo', state.originBedNo)
return
}
this.$set(state, 'editing', true)
if (!this.hasAssignment(row)) return
// 已安排住宿:取有效住宿记录,供调房接口使用
this.resolveActiveAccommodation(row, state, true)
},
/**
* 自动门锁授权:住宿安排提交成功后按学员+房间门锁下发人脸授权。
* 授权失败不影响住宿安排结果,仅给出提示。
*/
/*autoPushAuth(row, dormId) {
if (!row || !row.studentId) return Promise.resolve()
const dorm = this.dormOptions.find(d => String(d.dormId) === String(dormId)) || {}
const lockSn = dorm.lockDeviceSn
if (!lockSn) {
this.$modal.msgWarning('住宿安排成功,但该房间未绑定门锁,未下发授权')
return Promise.resolve()
}
return listDevice({ pageNum: 1, pageSize: 500 }).then(res => {
const devices = res.rows || []
const device = devices.find(d => String(d.deviceSn) === String(lockSn)) ||
devices.find(d => row.lockDeviceId && String(d.deviceId) === String(row.lockDeviceId))
if (!device) {
this.$modal.msgWarning('住宿安排成功,但未匹配到门锁设备,未下发授权')
return
}
return extractStudent(row.studentId, device.deviceId).then(feature => {
if (!feature || !feature.data) {
this.$modal.msgWarning('住宿安排成功,但人脸特征提取失败,未下发授权')
return
}
return addAuth({
authType: AUTH_TYPE_FACE,
personNo: row.personNo != null ? String(row.personNo) : String(row.studentId),
personName: row.studentName,
deviceId: device.deviceId,
deviceSn: device.deviceSn,
authValue: feature.data,
beginTime: this.planPeriod.startTime,
endTime: this.planPeriod.endTime,
remark: `住宿安排自动授权:${dorm.dormBuilding || ''} ${dorm.dormRoom || ''}`
}).then(() => {
this.$modal.msgSuccess('门锁授权已自动下发')
})
})
}).catch(() => {
this.$modal.msgWarning('住宿安排成功,但门锁授权下发失败,请到门锁授权页面重试')
})
},*/
autoPushAuth(row, dormId, accommodationId) {
console.log("in_autoPushAuth_row=========");
console.log(row);
if (!row || !row.studentId) return Promise.resolve()
const dorm = this.dormOptions.find(d => String(d.dormId) === String(dormId)) || {}
const roomNo = dorm.dormRoom
console.log("dorm===============");
console.log(dorm);
if (!roomNo) {
this.$modal.msgWarning('住宿安排成功,但该房间缺少房间号,未下发门锁授权')
return Promise.resolve()
}
// 生效区间:当天零点 ~ 当天零点 + 15 天(与门锁开放服务约定 yyyy-MM-dd HH:mm:ss)
const pad = value => String(value).padStart(2, '0')
const formatTime = date => {
const day = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
const clock = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
return `${day} ${clock}`
}
const beginTime = new Date()
beginTime.setHours(0, 0, 0, 0)
const endTime = new Date(beginTime.getTime() + 15 * 24 * 60 * 60 * 1000)
// 入参按开放接口约定组织(TrainLockApiController /stay/checkin 原样透传)
const payload = {
bizNo: lockApiBizNo,
externalPersonId: row.studentId,
personName: row.sName || row.studentName,
roomNo: roomNo,
beginTime: formatTime(beginTime),
endTime: formatTime(endTime),
photoUrl: row.avatar
}
console.log("payload===============");
console.log(payload);
// 仅本函数内按需引入请求实例,避免改动文件其余部分
const requestModule = require('@/utils/request')
const request = requestModule.default || requestModule
// 接口返回结果即本函数的返回结果
return request({
url: '/api/lock/v1/stay/checkin',
method: 'post',
data: payload
})
},
/** 退宿:住宿状态置为“已退宿(2)”,保留分配记录不删除 */
handleCheckOut(row) {
console.log("in handleCheckOut");
console.log(row);
if (!this.hasAssignment(row)) {
this.$modal.msgWarning('该学员暂无有效住宿记录,无需退宿')
return
}
if (row.accommodationStatus === 2) {
this.$modal.msgWarning('该学员住宿状态已是“已退宿”,无需重复办理')
return
}
const state = this.rowState[row.enrollmentId]
this.$modal.confirm(`确认办理学员【${row.studentName || ''}】的退宿吗?退宿后住宿记录保留,状态置为“已退宿”。`)
.then(() => {
const accommodationIdPromise = state && state.accommodationId
? Promise.resolve(state.accommodationId)
: getActiveAccommodation(row.enrollmentId).then(res => (res && res.data && res.data.accommodationId) || null)
return accommodationIdPromise.then(accommodationId => {
if (!accommodationId) {
this.$modal.msgWarning('未查询到该学员的有效住宿记录(可能已退宿),无法办理退宿')
return
}
return checkOutAccommodation(accommodationId).then(() => {
this.$modal.msgSuccess('退宿办理成功')
if (state) {
this.$set(state, 'editing', false)
this.$set(state, 'accommodationId', null)
}
this.loadDormOptions().then(() => this.getList())
// 退宿成功后同步办理门禁退房(与 autoPushAuth 一致的 request 调用结构)
// 房间号优先取住宿回显的 dormRoom,其次按所选宿舍 dormId 匹配;
// 退房下发失败不影响退宿结果,仅记录日志
const dorm = this.dormOptions.find(d => String(d.dormId) === String(state && state.dormId)) || {}
const payload = {
bizNo: row.lockApiBizNo,
roomNo: row.dormRoom || dorm.dormRoom || ''
}
// bizNo 非空校验:为空时不发起门禁退房请求,并明确告知无法执行退宿
const bizNo = payload.bizNo === null || payload.bizNo === undefined ? '' : String(payload.bizNo).trim()
if (!bizNo) {
this.$modal.msgWarning(
`学员【${row.studentName || ''}】门禁退房未执行:bizNo 为空,无法执行退宿,请重新搜索核对门锁授权信息后重试`
)
return
}
payload.bizNo = bizNo
const requestModule = require('@/utils/request')
const request = requestModule.default || requestModule
request({
url: '/api/lock/v1/stay/checkout',
method: 'post',
data: payload
}).catch(err => {
console.warn('[门禁退房] 下发失败,退宿已办理', err)
})
})
})
})
.catch(err => {
if (err !== 'cancel') {
this.$modal.msgError((err && (err.msg || err.message)) || '退宿办理失败')
}
})
}
}
}
</script>
<style scoped>
.accommodation-panel {
padding: 5px 0;
}
.panel-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.panel-title {
font-weight: bold;
font-size: 14px;
}
.dorm-select-cell {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.enroll-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: #f5f7fa;
overflow: hidden;
}
.enroll-avatar ::v-deep .image-slot {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #c0c4cc;
}
</style>
@@ -0,0 +1,523 @@
<template>
<el-dialog
title="班级详情"
:visible.sync="innerVisible"
width="1120px"
top="5vh"
append-to-body
custom-class="class-detail-dialog"
>
<div v-loading="loading" class="cd-wrapper">
<!-- 一、班级基本信息 -->
<section class="cd-block">
<div class="cd-block-head">
<span class="cd-block-title"><i class="el-icon-office-building" /> 班级基本信息</span>
<el-tag size="mini" :type="statusTagType(planInfo.planStatus)">{{ statusLabel(planInfo.planStatus) }}</el-tag>
</div>
<el-descriptions class="cd-desc" :column="3" border size="small">
<el-descriptions-item label="班级名称">{{ planInfo.className || '-' }}</el-descriptions-item>
<el-descriptions-item label="所属专业">{{ majorText(planInfo.classMajor) }}</el-descriptions-item>
<el-descriptions-item label="班主任">{{ planInfo.manager || '-' }}</el-descriptions-item>
<el-descriptions-item label="计划人数">{{ planInfo.recruitNum != null ? planInfo.recruitNum : '-' }}</el-descriptions-item>
<el-descriptions-item label="参训人数">{{ enrolledCount }}</el-descriptions-item>
<el-descriptions-item label="总学时">{{ planInfo.totalClassHours != null ? planInfo.totalClassHours : '-' }}</el-descriptions-item>
<el-descriptions-item label="开班时间">{{ formatDate(planInfo.startTime) }}</el-descriptions-item>
<el-descriptions-item label="结课时间">{{ formatDate(planInfo.endTime) }}</el-descriptions-item>
<el-descriptions-item label="培训地点">{{ planInfo.trainAddress || '-' }}</el-descriptions-item>
<!-- <el-descriptions-item label="报名条件" :span="2">{{ planInfo.applyCondition || '-' }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ planInfo.remark || '-' }}</el-descriptions-item>-->
</el-descriptions>
</section>
<!-- 二、班级课程安排(学校周课表形式,只读) -->
<section class="cd-block">
<div class="cd-block-head">
<span class="cd-block-title"><i class="el-icon-date" /> 班级课程安排</span>
<div class="cd-block-extra">
<span class="cd-tip">总课次:{{ allLessons.length }} 节</span>
<el-tag size="mini" type="success">本周 {{ weekLessonCount }} 节</el-tag>
<el-button-group size="mini">
<el-button icon="el-icon-arrow-left" @click="shiftWeek(-1)">上一周</el-button>
<el-button @click="resetWeek">本周</el-button>
<el-button @click="shiftWeek(1)">下一周<i class="el-icon-arrow-right el-icon--right" /></el-button>
</el-button-group>
</div>
</div>
<div class="cd-week-bar">
<i class="el-icon-date" />
<span>{{ weekTitle }}</span>
</div>
<div class="cd-grid-scroll">
<table class="cd-grid">
<thead>
<tr>
<th class="cd-th-period">时段 / 星期</th>
<th
v-for="day in weekDays"
:key="day.dateStr"
:class="['cd-th-day', { 'is-today': day.isToday }]"
>
<div class="cd-day-label">{{ day.label }}</div>
<div class="cd-day-date">{{ day.displayDate }}</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="period in periods" :key="period.key">
<td class="cd-period-cell">
<div class="cd-period-name">{{ period.label }}</div>
<div class="cd-period-time">{{ period.timeRange }}</div>
</td>
<td v-for="day in weekDays" :key="`${day.dateStr}_${period.key}`" class="cd-cell">
<div
v-for="lesson in lessonsInCell(day.dateStr, period.key)"
:key="lesson.lessonId"
class="cd-lesson"
>
<div class="cd-lesson-time">
{{ formatTime(lesson.startTime) }} - {{ formatTime(lesson.endTime) }}
</div>
<div class="cd-lesson-course" :title="lesson.courseName">{{ lesson.courseName }}</div>
<div class="cd-lesson-meta" :title="lessonMeta(lesson)">
<i class="el-icon-user" /> {{ lesson.teacherName || '教师待定' }}
</div>
<div class="cd-lesson-meta" :title="lessonMeta(lesson)">
<i class="el-icon-location-outline" /> {{ lessonVenue(lesson) }}
</div>
</div>
<span v-if="lessonsInCell(day.dateStr, period.key).length === 0" class="cd-cell-empty">—</span>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- 三、班级学员信息 -->
<section class="cd-block">
<div class="cd-block-head">
<span class="cd-block-title"><i class="el-icon-user" /> 班级学员</span>
<div class="cd-block-extra">
<el-tag size="mini" type="info">共 {{ studentTotal }} 人</el-tag>
</div>
</div>
<el-table v-loading="studentLoading" :data="studentList" border size="small" style="width: 100%">
<el-table-column label="序号" type="index" width="60" align="center" :index="studentIndexMethod" />
<el-table-column label="姓名" prop="studentName" align="center" min-width="100" show-overflow-tooltip />
<!-- <el-table-column label="学号" prop="personNo" align="center" min-width="120" show-overflow-tooltip>
<template slot-scope="scope">
<span>{{ scope.row.personNo || '-' }}</span>
</template>
</el-table-column>-->
<el-table-column label="性别" align="center" width="70">
<template slot-scope="scope">
<span>{{ genderText(scope.row.gender) }}</span>
</template>
</el-table-column>
<el-table-column label="所属单位" prop="deptName" align="center" min-width="160" show-overflow-tooltip>
<template slot-scope="scope">
<span>{{ scope.row.deptName || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="联系电话" prop="phone" align="center" min-width="120" show-overflow-tooltip>
<template slot-scope="scope">
<span>{{ scope.row.phone || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="住宿" align="center" min-width="130" show-overflow-tooltip>
<template slot-scope="scope">
<span v-if="scope.row.dormRoom">{{ scope.row.dormBuilding || '' }} {{ scope.row.dormRoom }}</span>
<span v-else class="cd-muted">未分配</span>
</template>
</el-table-column>
</el-table>
<pagination
v-show="studentTotal > 0"
:total="studentTotal"
:page.sync="studentQuery.pageNum"
:limit.sync="studentQuery.pageSize"
@pagination="loadStudents"
/>
</section>
</div>
<div slot="footer" class="dialog-footer">
<el-button @click="innerVisible = false">关 闭</el-button>
</div>
</el-dialog>
</template>
<script>
import { getPlan } from '@/api/train/plan'
import { getPlanTimetable } from '@/api/train/timetable'
import { listPlanEnrollments } from '@/api/train/enrollment'
export default {
name: 'ClassDetail',
dicts: ['train_class_major'],
props: {
visible: {
type: Boolean,
default: false
},
// 班级(培训计划)行数据,用于首屏展示与 planId 取数
plan: {
type: Object,
default: () => ({})
}
},
data() {
return {
loading: false,
planInfo: {},
timetable: { days: [] },
weekAnchor: new Date(),
periods: [
{ key: 'morning', label: '上午', timeRange: '08:00 - 12:00' },
{ key: 'afternoon', label: '下午', timeRange: '14:00 - 18:00' },
{ key: 'evening', label: '晚上', timeRange: '18:30 - 21:30' }
],
studentLoading: false,
studentList: [],
studentTotal: 0,
studentQuery: {
pageNum: 1,
pageSize: 10
},
statusOptions: [
{ value: 0, label: '未开始' },
{ value: 1, label: '进行中' },
{ value: 2, label: '已结束' },
{ value: 3, label: '已取消' }
]
}
},
computed: {
innerVisible: {
get() {
return this.visible
},
set(val) {
this.$emit('update:visible', val)
}
},
planId() {
return this.plan && this.plan.planId
},
enrolledCount() {
if (this.plan && this.plan.enrolledCount != null) return this.plan.enrolledCount
return this.studentTotal || 0
},
/** 全部课次(按天拍平) */
allLessons() {
const days = (this.timetable && this.timetable.days) || []
const list = []
days.forEach(day => {
if (day.lessons && day.lessons.length) {
day.lessons.forEach(item => list.push(item))
}
})
return list
},
/** 当前锚定周的周一至周日 */
weekDays() {
const anchor = this.weekAnchor ? new Date(this.weekAnchor) : new Date()
const weekday = anchor.getDay()
const diffToMonday = weekday === 0 ? -6 : 1 - weekday
const monday = new Date(anchor)
monday.setDate(anchor.getDate() + diffToMonday)
const labels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
const todayStr = this.formatDate(new Date())
const days = []
for (let i = 0; i < 7; i++) {
const d = new Date(monday)
d.setDate(monday.getDate() + i)
const dateStr = this.formatDate(d)
days.push({
dateStr,
label: labels[i],
displayDate: `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`,
isToday: dateStr === todayStr
})
}
return days
},
weekTitle() {
if (!this.weekDays.length) return ''
return `${this.weekDays[0].dateStr} 至 ${this.weekDays[6].dateStr}`
},
weekLessonCount() {
const dates = new Set(this.weekDays.map(item => item.dateStr))
return this.allLessons.filter(item => dates.has((item.startTime || '').substring(0, 10))).length
}
},
watch: {
visible(val) {
if (val) {
this.init()
}
}
},
methods: {
init() {
this.planInfo = Object.assign({}, this.plan)
const anchor = this.plan && this.plan.startTime ? new Date(String(this.plan.startTime).replace(/-/g, '/')) : new Date()
this.weekAnchor = isNaN(anchor.getTime()) ? new Date() : anchor
this.studentQuery.pageNum = 1
this.loadPlan()
this.loadTimetable()
this.loadStudents()
},
// ---- 基本信息 ----
loadPlan() {
if (!this.planId) return
this.loading = true
getPlan(this.planId).then(res => {
this.planInfo = Object.assign({}, this.plan, res.data || {})
}).finally(() => {
this.loading = false
})
},
// ---- 课表 ----
loadTimetable() {
if (!this.planId) return
getPlanTimetable(this.planId).then(res => {
this.timetable = res.data || { days: [] }
}).catch(() => {
this.timetable = { days: [] }
})
},
shiftWeek(offset) {
const anchor = this.weekAnchor ? new Date(this.weekAnchor) : new Date()
anchor.setDate(anchor.getDate() + offset * 7)
this.weekAnchor = anchor
},
resetWeek() {
this.weekAnchor = new Date()
},
/** 单元格内课次:按日期 + 时段(上午/下午/晚上)归集 */
lessonsInCell(dateStr, periodKey) {
return this.allLessons.filter(item => {
const start = item.startTime || ''
if (start.substring(0, 10) !== dateStr) return false
const hour = Number(start.substring(11, 13))
if (isNaN(hour)) return false
if (periodKey === 'morning') return hour < 12
if (periodKey === 'afternoon') return hour >= 12 && hour < 18
return hour >= 18
})
},
lessonVenue(lesson) {
return lesson.venueName || lesson.classRoomName || lesson.siteName || '地点待定'
},
lessonMeta(lesson) {
return `${lesson.teacherName || '教师待定'} · ${this.lessonVenue(lesson)}`
},
// ---- 学员 ----
loadStudents() {
if (!this.planId) return
this.studentLoading = true
listPlanEnrollments(this.planId, {
pageNum: this.studentQuery.pageNum,
pageSize: this.studentQuery.pageSize
}).then(res => {
this.studentList = res.rows || []
this.studentTotal = res.total || 0
}).finally(() => {
this.studentLoading = false
})
},
studentIndexMethod(index) {
const { pageNum = 1, pageSize = 10 } = this.studentQuery
return (pageNum - 1) * pageSize + index + 1
},
// ---- 展示辅助 ----
formatDate(value) {
if (!value) return '-'
if (value instanceof Date) {
const year = value.getFullYear()
const month = String(value.getMonth() + 1).padStart(2, '0')
const day = String(value.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const text = String(value).replace('T', ' ')
return text.substring(0, 10)
},
formatTime(value) {
const text = value ? String(value).replace('T', ' ') : ''
return text.length >= 16 ? text.substring(11, 16) : ''
},
genderText(val) {
if (val === '0' || val === 0 || val === '男') return '男'
if (val === '1' || val === 1 || val === '女') return '女'
return '-'
},
majorText(val) {
if (val === null || val === undefined || val === '') return '-'
const dict = this.dict.type.train_class_major || []
const matched = dict.find(item => String(item.value) === String(val))
return matched ? matched.label : String(val)
},
statusLabel(status) {
const option = this.statusOptions.find(item => item.value === status)
return option ? option.label : '未知'
},
statusTagType(status) {
return { 0: 'info', 1: 'primary', 2: 'success', 3: 'danger' }[status] || 'info'
}
}
}
</script>
<style scoped>
.cd-wrapper {
max-height: 76vh;
overflow-y: auto;
padding-right: 4px;
}
.cd-block {
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 14px 16px 16px;
margin-bottom: 16px;
background: #fff;
}
.cd-block:last-child {
margin-bottom: 0;
}
.cd-block-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #f0f2f5;
}
.cd-block-title {
font-size: 14px;
font-weight: 700;
color: #1f2d3d;
}
.cd-block-title i {
color: #409eff;
margin-right: 6px;
}
.cd-block-extra {
display: flex;
align-items: center;
gap: 10px;
}
.cd-tip {
font-size: 12px;
color: #909399;
}
.cd-week-bar {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #606266;
margin-bottom: 10px;
}
.cd-week-bar i {
color: #409eff;
}
/* 周课表 */
.cd-grid-scroll {
width: 100%;
overflow-x: auto;
}
.cd-grid {
width: 100%;
min-width: 880px;
border-collapse: collapse;
table-layout: fixed;
}
.cd-grid th,
.cd-grid td {
border: 1px solid #ebeef5;
padding: 6px;
vertical-align: top;
}
.cd-th-period,
.cd-period-cell {
width: 96px;
background: #fafafa;
text-align: center;
}
.cd-th-day {
background: #fafafa;
text-align: center;
}
.cd-th-day.is-today {
background: #ecf5ff;
}
.cd-day-label {
font-size: 13px;
font-weight: 600;
color: #1f2d3d;
}
.cd-day-date {
font-size: 11px;
color: #909399;
margin-top: 2px;
}
.cd-period-name {
font-size: 13px;
font-weight: 600;
color: #1f2d3d;
}
.cd-period-time {
font-size: 11px;
color: #909399;
margin-top: 2px;
}
.cd-cell {
height: 92px;
background: #fff;
}
.cd-cell-empty {
display: block;
text-align: center;
color: #dcdfe6;
line-height: 78px;
}
.cd-lesson {
background: linear-gradient(135deg, #f0f7ff 0%, #e6f1fc 100%);
border: 1px solid #b3d8ff;
border-left: 3px solid #409eff;
border-radius: 4px;
padding: 5px 7px;
margin-bottom: 6px;
}
.cd-lesson:last-child {
margin-bottom: 0;
}
.cd-lesson-time {
font-size: 11px;
color: #409eff;
}
.cd-lesson-course {
font-size: 13px;
font-weight: 600;
color: #1f2d3d;
margin: 2px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cd-lesson-meta {
font-size: 11px;
color: #606266;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cd-lesson-meta i {
color: #909399;
}
.cd-muted {
color: #909399;
}
</style>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,912 @@
<template>
<div class="seat-panel">
<!-- ============ 左侧:本班级课程表使用的教室列表(点击行即排座) ============ -->
<section class="sp-block sp-col sp-col-left">
<div class="sp-block-head">
<h3 class="sp-block-title">教室排座</h3>
<span class="sp-block-sub">共 {{ classroomRows.length }} 间</span>
</div>
<div class="sp-hint">
<i class="el-icon-info" />
<span>点击下方任一教室行,即可在右侧为该教室排座</span>
</div>
<div v-loading="classroomLoading" class="sp-room-list">
<div
v-for="row in classroomRows"
:key="row.classRoomId"
class="sp-room-item"
:class="{ active: isCurrentRoom(row) }"
@click="handleSeat(row)"
>
<div class="sp-room-main">
<span class="sp-room-name">{{ row.roomName || '-' }}</span>
<i v-if="isCurrentRoom(row)" class="el-icon-check sp-room-check" />
</div>
<div class="sp-muted sp-room-meta">
<span v-if="row.roomLocation">{{ row.roomLocation }}</span>
<span v-if="row.capacity">{{ row.roomLocation ? ' · ' : '' }}容量 {{ row.capacity }} 人</span>
<span v-if="!row.roomLocation && !row.capacity">未填写位置</span>
</div>
</div>
<div v-if="!classroomLoading && !classroomRows.length" class="sp-room-empty">
本班级课程表暂未安排教室
</div>
</div>
</section>
<!-- ============ 右侧:所选教室的座位布局 ============ -->
<section class="sp-block sp-col sp-col-right">
<!-- 标题行:座位布局 + 教学场地(含对应课程) -->
<div class="sp-block-head sp-layout-head">
<h3 class="sp-block-title">座位布局</h3>
<span v-if="seatLocation" class="sp-layout-info">
<span class="sp-layout-label">教学场地:</span>
<b>{{ seatLocation.roomName }}</b>
<span class="sp-layout-courses" :title="seatCourseText">({{ seatCourseText }})</span>
<span v-if="seatLocation.roomLocation" class="sp-muted"> · {{ seatLocation.roomLocation }}</span>
</span>
</div>
<el-empty v-if="!seatLocation" description="请在左侧点击教室行,右侧将显示该教室的座位布局" />
<div v-else class="sp-layout">
<!-- 图例 + 行列设置:同一行展示 -->
<div class="sp-layout-tools">
<div class="sp-legend">
<span class="seat-dot free" /> 空闲(可分配)
<span class="seat-dot reserved" /> 待分配
<span class="seat-dot taken" /> 已分配
</div>
<div class="sp-layout-set">
<span class="sp-layout-label">布局:</span>
<el-input-number v-model="seatRowsInput" :min="1" :max="30" size="small" @change="onLayoutChange" />
<span class="sp-layout-unit">行</span>
<el-input-number v-model="seatColsInput" :min="1" :max="40" size="small" @change="onLayoutChange" />
<span class="sp-layout-unit">列</span>
</div>
</div>
<div v-loading="seatLoading" class="sp-seat-scroll">
<div class="sp-seat-grid">
<div v-for="(rowCells, ri) in seatMatrix" :key="ri" class="seat-row">
<span class="seat-row-no">{{ ri + 1 }}</span>
<div
v-for="cell in rowCells"
:key="cell.key"
class="seat-cell"
:class="cell.state"
@click="onSeatClick(cell)"
>
<span class="seat-cell-label">{{ cell.label }}</span>
<span class="seat-cell-pos">{{ cell.row }}-{{ cell.col }}</span>
</div>
</div>
</div>
</div>
<div class="sp-layout-tip">点击空座位可分配学员,点击已分配座位可取消分配</div>
</div>
</section>
<!-- 分配学员弹窗 -->
<el-dialog
title="分配座位"
:visible.sync="assignDialogVisible"
width="min(420px, 92%)"
append-to-body
>
<div v-if="assignSeatPos" class="assign-tip">
座位:第 {{ assignSeatPos.row }} 行 · 第 {{ assignSeatPos.col }} 列
</div>
<el-form label-width="80px">
<el-form-item label="选择学员">
<el-select
v-model="assignStudentId"
placeholder="请选择未分配座位的学员"
filterable
clearable
style="width: 100%"
>
<el-option
v-for="s in seatStudents"
:key="s.studentId"
:label="s.studentName || s.studentId"
:value="s.studentId"
/>
</el-select>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="assignDialogVisible = false">取 消</el-button>
<el-button type="primary" @click="confirmAssign">确 定</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listClassroomByPlan } from '@/api/train/classroom'
import { getPlanTimetable } from '@/api/train/timetable'
import { listPlanEnrollments } from '@/api/train/enrollment'
import { listClassSeat, addClassSeat, updateClassSeat, delClassSeat } from '@/api/train/seat'
// 座位类别:本面板只处理普通教室(实训场地不在课表教室列表中)
const SEAT_CATEGORY = 'classroom'
export default {
name: 'SeatPanel',
props: {
// 所属班级(培训班)ID
planId: {
type: [String, Number],
required: true
},
// 班级状态:与独立页面保持一致的展示口径,当前不额外限制排座操作
planStatus: {
type: Number,
default: 0
}
},
data() {
return {
// 上部:教室列表
classroomLoading: false,
classrooms: [],
lessons: [],
// 下部:当前排座的教学场地
seatLocation: null,
seatLoading: false,
seatRows: 0,
seatCols: 0,
seatRowsInput: 0,
seatColsInput: 0,
seatLayoutInited: false,
// key `${rowIndex}-${colIndex}` -> 座位记录
seatMap: {},
// 本班未占座学员(候选)
seatStudents: [],
// 分配座位弹窗
assignDialogVisible: false,
assignSeatPos: null,
assignStudentId: ''
}
},
computed: {
/** 教室行:教室基础信息 + 课表中该教室的课程信息(按教室聚合) */
classroomRows() {
const rows = []
const byId = {}
const ensureRow = (classRoomId, roomName, roomLocation, capacity) => {
const key = String(classRoomId)
if (!byId[key]) {
byId[key] = {
classRoomId: classRoomId,
roomName: roomName || classRoomId,
roomLocation: roomLocation || '',
capacity: capacity || null,
courseNames: [],
teacherNames: [],
lessonCount: 0
}
rows.push(byId[key])
}
return byId[key]
}
// 先放教室基础信息(课表中用到教室的去重结果)
this.classrooms.forEach(c => ensureRow(c.classRoomId, c.roomName, c.roomLocation, c.capacity))
// 再按课表补齐课程信息;课表有而基础信息缺失的教室同样补进来
this.lessons.forEach(lesson => {
if (!lesson.classRoomId) return
const row = ensureRow(lesson.classRoomId, lesson.classRoomName, '', null)
row.lessonCount += 1
if (lesson.courseName && row.courseNames.indexOf(lesson.courseName) === -1) {
row.courseNames.push(lesson.courseName)
}
if (lesson.teacherName && row.teacherNames.indexOf(lesson.teacherName) === -1) {
row.teacherNames.push(lesson.teacherName)
}
})
return rows
},
/** 排座矩阵:把 seatMap 转成 行×列 二维数组,标注座位状态 */
seatMatrix() {
const matrix = []
for (let r = 1; r <= this.seatRows; r++) {
const rowCells = []
for (let c = 1; c <= this.seatCols; c++) {
const key = r + '-' + c
const seat = this.seatMap[key]
let state = 'free'
let label = '空闲'
if (seat && seat.studentId) {
state = 'taken'
label = seat.studentName || seat.studentId
} else if (seat && !seat.studentId) {
state = 'reserved'
label = '待分配'
}
rowCells.push({ row: r, col: c, key, seat, state, label })
}
matrix.push(rowCells)
}
return matrix
},
/** 当前教室已分配的座位列表(仅含本教室、且已绑定学员的座位) */
assignedSeats() {
return Object.values(this.seatMap).filter(
seat => seat && seat.studentId && this.isSameLocation(seat)
)
},
/** 当前教室是否存在已分配的座位 */
hasAssignedSeats() {
return this.assignedSeats.length > 0
},
/**
* 按输入的行列调整后,会被移出可视范围的已分配座位。
* 判定条件:行号不在 1..seatRowsInput 内,或列号不在 1..seatColsInput 内(含空值/非法值)。
* 为空表示本次调整不涉及任何已分配座位,可保留现有分配只改布局。
*/
seatsOutsideNewLayout() {
return this.assignedSeats.filter(
seat => this.isSeatOutside(seat, this.seatRowsInput, this.seatColsInput)
)
},
/** 当前所选教室对应的课程名称(座位布局首行括号内展示) */
seatCourseText() {
if (!this.seatLocation) return '暂无对应课程'
const row = this.classroomRows.find(
item => String(item.classRoomId) === String(this.seatLocation.classRoomId)
)
const names = (row && row.courseNames) || []
return names.length ? names.join('、') : '暂无对应课程'
}
},
watch: {
planId: {
immediate: true,
handler(val) {
if (!val) return
this.resetSeatArea()
this.loadClassrooms()
}
}
},
methods: {
/** 加载本班级课表所用教室及课程信息 */
loadClassrooms() {
if (!this.planId) return
this.classroomLoading = true
Promise.all([
listClassroomByPlan(this.planId).catch(() => ({ data: [] })),
getPlanTimetable(this.planId).catch(() => ({ data: { days: [] }}))
]).then(([roomRes, timetableRes]) => {
const rooms = (roomRes && (roomRes.data || roomRes.rows)) || []
this.classrooms = Array.isArray(rooms) ? rooms : []
const timetable = (timetableRes && timetableRes.data) || {}
const days = timetable.days || []
const lessons = []
days.forEach(day => {
(day.lessons || []).forEach(lesson => lessons.push(lesson))
})
this.lessons = lessons
}).catch(() => {
this.classrooms = []
this.lessons = []
}).finally(() => {
this.classroomLoading = false
})
},
/** 是否为当前排座的教室(左侧列表高亮用) */
isCurrentRoom(row) {
if (!this.seatLocation || !row) return false
return String(row.classRoomId) === String(this.seatLocation.classRoomId)
},
/** 切换排座目标教室(右侧区域展示其座位布局) */
handleSeat(row) {
if (!this.planId) {
this.$modal.msgWarning('缺少班级信息,无法排座')
return
}
this.seatLocation = {
classRoomId: row.classRoomId,
roomName: row.roomName,
roomLocation: row.roomLocation
}
// 默认布局与独立排座页面一致:教室 6 行 × 8 列
this.seatRows = 6
this.seatCols = 8
this.seatRowsInput = 6
this.seatColsInput = 8
this.seatLayoutInited = false
this.seatMap = {}
this.seatStudents = []
this.loadSeatData()
},
/** 关闭/切换班级时清空下部区域状态 */
resetSeatArea() {
this.seatLocation = null
this.seatMap = {}
this.seatStudents = []
this.assignDialogVisible = false
this.assignSeatPos = null
this.assignStudentId = ''
},
/**
* 调整布局(行列变更)。
*
* 判定条件:以「调整后的行列范围是否仍完整包含本教室全部已分配座位」区分两条分支:
* 1. 不涉及已分配座位(seatsOutsideNewLayout 为空,即缩小的范围没切到任何已分配座位)
* → 弹窗询问是否保留现有座位分配、仅调整行列;确认后只改行列,座位记录不动;
* 2. 涉及已分配座位(有座位行/列超界)
* → 弹窗说明将清除的座位数量与保留数量,确认后**只删除超出新范围的座位**,
* 范围内的已分配座位原样保留,随后重新加载以刷新矩阵与可选学员。
* 两种分支取消时均把输入框回退到当前布局,避免界面与数据不一致。
*/
onLayoutChange() {
if (this.seatRowsInput === this.seatRows && this.seatColsInput === this.seatCols) return
// 无已分配座位:直接生效,无需确认
if (!this.hasAssignedSeats) {
this.applyLayoutInput()
return
}
const affectedSeats = this.seatsOutsideNewLayout
// 分支一:新范围仍包含全部已分配座位 → 询问后保留分配,仅调整布局
if (!affectedSeats.length) {
this.$modal.confirm(
`布局将调整为 ${this.seatRowsInput} 行 × ${this.seatColsInput} 列,` +
`现有 ${this.assignedSeats.length} 个已分配座位均在新布局范围内,是否保留这些座位并仅调整布局?`
).then(() => {
this.applyLayoutInput()
this.$modal.msgSuccess('布局已调整,已分配座位已保留')
}).catch(() => {
this.revertLayoutInput()
})
return
}
// 分支二:新范围切掉了已分配座位 → 只清除超出范围的座位,范围内保留
const remainCount = this.assignedSeats.length - affectedSeats.length
const keepText = remainCount > 0 ? `,范围内其余 ${remainCount} 个已分配座位将保留` : ''
this.$modal.confirm(
`布局将调整为 ${this.seatRowsInput} 行 × ${this.seatColsInput} 列,` +
`其中 ${affectedSeats.length} 个已分配座位将超出新布局范围,需清除这些座位${keepText},是否继续?`
).then(() => {
// 逐项边界校验:仅对越界座位发起删除,避免误删范围内的座位
const toRemoves = affectedSeats.filter(
seat => this.isSeatOutside(seat, this.seatRowsInput, this.seatColsInput)
)
const ids = toRemoves
.map(seat => seat.seatId)
.filter(id => id !== undefined && id !== null && id !== '')
if (!ids.length) return null
return delClassSeat(ids)
}).then(() => {
this.applyLayoutInput()
// 本地只移除被清除的越界座位,其余座位数据保持完整
affectedSeats.forEach(seat => {
this.$delete(this.seatMap, seat.rowIndex + '-' + seat.colIndex)
})
// 输出清除结果,便于验证仅越界座位被移除
console.info('[seat-layout] 越界座位已清除', {
layout: this.seatRows + '×' + this.seatCols,
removed: affectedSeats.map(seat => seat.rowIndex + '-' + seat.colIndex),
removedCount: affectedSeats.length,
keptCount: remainCount
})
this.$modal.msgSuccess(
remainCount > 0
? `已清除 ${affectedSeats.length} 个超出范围的座位,其余座位已保留`
: `已清除 ${affectedSeats.length} 个已分配座位`
)
this.loadSeatData()
}).catch(() => {
this.revertLayoutInput()
})
},
/**
* 越界判定:座位行号必须落在 1..rows、列号必须落在 1..cols。
* 空值/非数字/小于 1 同样视为越界(无法定位到有效格子)。
*/
isSeatOutside(seat, rows, cols) {
if (!seat) return false
const row = parseInt(seat.rowIndex, 10)
const col = parseInt(seat.colIndex, 10)
if (isNaN(row) || isNaN(col)) return true
return row < 1 || row > rows || col < 1 || col > cols
},
/** 应用输入框中的行列值(座位矩阵按 seatRows/seatCols 重新渲染,座位与行列的对应关系不变) */
applyLayoutInput() {
this.seatRows = this.seatRowsInput
this.seatCols = this.seatColsInput
},
/** 取消调整:输入框回退到当前生效的布局 */
revertLayoutInput() {
this.seatRowsInput = this.seatRows
this.seatColsInput = this.seatCols
},
/** 判断座位是否属于当前教室,避免不同教室座位串场 */
isSameLocation(seat) {
if (!seat || !this.seatLocation) return false
return String(seat.classroomId) === String(this.seatLocation.classRoomId)
},
/** 加载当前教室的座位与未占座学员 */
loadSeatData() {
if (!this.seatLocation) return
this.seatLoading = true
const params = {
planId: this.planId,
pageSize: 200,
classroomId: this.seatLocation.classRoomId,
category: SEAT_CATEGORY
}
Promise.all([
listClassSeat(params),
listPlanEnrollments(this.planId, { pageNum: 1, pageSize: 500 })
]).then(([seatRes, enrRes]) => {
const seats = (seatRes && (seatRes.rows || seatRes.data)) || []
const map = {}
seats.forEach(seat => {
if (this.isSameLocation(seat)) map[seat.rowIndex + '-' + seat.colIndex] = seat
})
this.seatMap = map
// 仅在首次打开该教室时,按已有座位最大行列回显布局(不低于默认布局)
if (!this.seatLayoutInited) {
const maxRow = seats.length ? Math.max.apply(null, seats.map(s => s.rowIndex)) : 0
const maxCol = seats.length ? Math.max.apply(null, seats.map(s => s.colIndex)) : 0
const finalRows = Math.max(this.seatRowsInput, maxRow)
const finalCols = Math.max(this.seatColsInput, maxCol)
this.seatRows = finalRows
this.seatCols = finalCols
this.seatRowsInput = finalRows
this.seatColsInput = finalCols
this.seatLayoutInited = true
}
const enrollments = (enrRes && (enrRes.rows || enrRes.data)) || []
const takenIds = new Set(seats.filter(s => s.studentId).map(s => String(s.studentId)))
this.seatStudents = enrollments.filter(e => !takenIds.has(String(e.studentId)))
}).catch(() => {
this.seatMap = {}
this.seatStudents = []
}).finally(() => {
this.seatLoading = false
})
},
/** 点击座位:空闲 -> 分配;已分配 -> 取消 */
onSeatClick(cell) {
if (cell.state === 'taken') {
this.$modal.confirm(`确认取消【${cell.label}】的座位分配吗?`).then(() => {
return delClassSeat(cell.seat.seatId)
}).then(() => {
this.$modal.msgSuccess('已取消座位')
this.loadSeatData()
}).catch(() => {})
return
}
this.assignSeatPos = {
row: cell.row,
col: cell.col,
key: cell.key,
seatId: cell.seat ? cell.seat.seatId : null
}
this.assignStudentId = ''
this.assignDialogVisible = true
},
/** 确认分配学员到座位 */
confirmAssign() {
if (!this.assignStudentId) {
this.$modal.msgError('请选择学员')
return
}
if (!this.seatLocation) return
const data = {
planId: this.planId,
classroomId: this.seatLocation.classRoomId,
category: SEAT_CATEGORY,
rowIndex: this.assignSeatPos.row,
colIndex: this.assignSeatPos.col,
seatCode: this.assignSeatPos.row + '-' + this.assignSeatPos.col,
studentId: this.assignStudentId,
canceled: 0
}
const request = this.assignSeatPos.seatId
? updateClassSeat(Object.assign({ seatId: this.assignSeatPos.seatId }, data))
: addClassSeat(data)
request.then(() => {
this.$modal.msgSuccess('分配成功')
this.assignDialogVisible = false
this.loadSeatData()
}).catch(() => {})
}
}
}
</script>
<style scoped>
/* 左右分栏:教室列表 | 座位布局,降低弹窗整体高度 */
.seat-panel {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 4px 0;
}
.sp-block {
margin-bottom: 0;
min-width: 0;
}
.sp-col {
display: flex;
flex-direction: column;
min-width: 0;
}
/* 左侧:教室列表(窄栏、紧凑排布,列表内部纵向滚动) */
.sp-col-left {
flex: 0 0 26%;
min-width: 210px;
max-width: 300px;
}
/* 右侧:座位布局(占满剩余宽度) */
.sp-col-right {
flex: 1 1 0;
}
.sp-block-head {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.sp-block-title {
margin: 0;
font-size: 15px;
font-weight: 700;
color: #1f2329;
}
.sp-block-sub {
font-size: 12px;
color: #9aa3b5;
}
.sp-room-name {
flex: 1 1 auto;
min-width: 0;
font-size: 13px;
font-weight: 600;
color: #1f2329;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 提示:点击教室行即可排座 */
.sp-hint {
display: flex;
align-items: flex-start;
gap: 4px;
margin-bottom: 8px;
font-size: 12px;
line-height: 1.5;
color: #909399;
}
.sp-hint i {
margin-top: 2px;
color: #2b6bff;
}
/* 教室列表:紧凑行式列表,点击行切换右侧排座 */
.sp-room-list {
max-height: 420px;
overflow-y: auto;
border: 1px solid #ebeef5;
border-radius: 6px;
background: #fff;
-webkit-overflow-scrolling: touch;
}
.sp-room-item {
padding: 7px 10px;
cursor: pointer;
border-bottom: 1px solid #f2f4f8;
border-left: 3px solid transparent;
transition: background .15s, border-color .15s;
}
.sp-room-item:last-child {
border-bottom: none;
}
.sp-room-item:hover {
background: #f5f8ff;
}
.sp-room-item.active {
background: #eef4ff;
border-left-color: #2b6bff;
}
.sp-room-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.sp-room-check {
flex: none;
font-size: 12px;
color: #2b6bff;
}
.sp-room-meta {
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sp-room-empty {
padding: 16px 10px;
text-align: center;
font-size: 12px;
color: #9aa3b5;
}
.sp-muted {
font-size: 12px;
color: #909399;
}
/* 座位布局首行:教室名称后的课程括号 */
.sp-layout-courses {
margin-left: 2px;
color: #606266;
word-break: break-all;
}
/* ===== 座位布局区域 ===== */
.sp-layout {
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 14px;
background: #fff;
}
/* 标题行:座位布局 + 教学场地信息合并展示 */
.sp-layout-head {
align-items: center;
}
.sp-layout-info {
min-width: 0;
font-size: 13px;
font-weight: 400;
color: #1f2329;
word-break: break-all;
}
.sp-layout-label {
color: #7a8099;
}
/* 图例与行列设置同一行 */
.sp-layout-tools {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.sp-layout-set {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 13px;
color: #1f2329;
}
.sp-layout-unit {
margin-right: 6px;
color: #7a8099;
}
.sp-legend {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 12px;
color: #7a8099;
}
.seat-dot {
display: inline-block;
width: 14px;
height: 12px;
border-radius: 4px;
margin-right: 4px;
vertical-align: middle;
box-sizing: border-box;
border: 2px solid #c3d4f5;
background: #fff;
}
.seat-dot.free {
border-color: #c3d4f5;
}
.seat-dot.reserved {
border-color: #ffd591;
background: #fffaf0;
}
.seat-dot.taken {
border-color: #2b6bff;
background: #e9f1ff;
}
/* 布局容器:与左侧表格等高,超出部分独立滚动(横向看全座位、纵向看全行) */
.sp-seat-scroll {
max-height: 420px;
overflow: auto;
padding: 4px 2px 8px;
-webkit-overflow-scrolling: touch;
}
.sp-seat-grid {
display: inline-block;
min-width: 100%;
}
.seat-row {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.seat-row-no {
flex: none;
width: 24px;
text-align: center;
font-size: 12px;
color: #9aa3b5;
margin-right: 8px;
}
.seat-cell {
position: relative;
flex: none;
width: 60px;
height: 56px;
margin-right: 6px;
cursor: pointer;
user-select: none;
transition: color .15s;
}
/* 椅身 */
.seat-cell::before {
content: '';
position: absolute;
top: 2px;
left: 50%;
transform: translateX(-50%);
width: 42px;
height: 36px;
border: 2px solid #c3d4f5;
border-radius: 10px;
background: #fff;
box-sizing: border-box;
transition: border-color .15s, background .15s;
}
/* 外扩底座(盖住椅身下边框,形成扶手椅轮廓) */
.seat-cell::after {
content: '';
position: absolute;
bottom: 2px;
left: 50%;
transform: translateX(-50%);
width: 52px;
height: 16px;
border: 2px solid #c3d4f5;
border-radius: 8px;
background: #fff;
box-sizing: border-box;
transition: border-color .15s, background .15s;
}
.seat-cell .seat-cell-label {
position: absolute;
top: 11px;
left: 50%;
transform: translateX(-50%);
font-size: 12px;
font-weight: 600;
line-height: 1.2;
max-width: 38px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
z-index: 1;
}
.seat-cell .seat-cell-pos {
position: absolute;
bottom: 5px;
left: 0;
width: 100%;
text-align: center;
font-size: 9px;
opacity: .7;
z-index: 1;
}
.seat-cell.free {
color: #8a94ad;
}
.seat-cell.free:hover {
color: #2b6bff;
}
.seat-cell.free:hover::before,
.seat-cell.free:hover::after {
border-color: #2b6bff;
}
.seat-cell.taken {
color: #1d54d6;
}
.seat-cell.taken::before,
.seat-cell.taken::after {
border-color: #2b6bff;
background: #e9f1ff;
}
.seat-cell.taken:hover {
opacity: .85;
}
.seat-cell.reserved {
color: #b87900;
}
.seat-cell.reserved::before,
.seat-cell.reserved::after {
border-color: #ffd591;
background: #fffaf0;
}
.sp-layout-tip {
margin-top: 6px;
font-size: 12px;
color: #b0b7c9;
}
.assign-tip {
font-size: 14px;
color: #1f2329;
margin-bottom: 12px;
}
/* ===== 窄屏(弹窗或屏幕较窄):左右分栏回退为上下堆叠 ===== */
@media (max-width: 992px) {
.seat-panel {
flex-direction: column;
gap: 12px;
}
.sp-col-left,
.sp-col-right {
flex: 1 1 auto;
width: 100%;
max-width: none;
}
.sp-col-right {
margin-bottom: 0;
}
}
/* ===== 移动端适配 ===== */
@media (max-width: 768px) {
.sp-layout {
padding: 10px;
}
.sp-layout-set {
width: 100%;
}
.sp-seat-scroll {
max-height: 55vh;
padding-bottom: 4px;
}
.seat-row {
margin-bottom: 8px;
}
.seat-row-no {
width: 18px;
margin-right: 4px;
font-size: 11px;
}
.seat-cell {
width: 46px;
height: 44px;
margin-right: 4px;
}
.seat-cell::before {
width: 32px;
height: 28px;
border-radius: 8px;
}
.seat-cell::after {
width: 40px;
height: 13px;
border-radius: 6px;
}
.seat-cell .seat-cell-label {
top: 8px;
max-width: 28px;
font-size: 10px;
}
.seat-cell .seat-cell-pos {
bottom: 2px;
font-size: 8px;
}
}
</style>
File diff suppressed because it is too large. Load diff
+997
View File
@@ -0,0 +1,997 @@
<template>
<div class="app-container">
<!-- 顶部静态布局:标题栏 -->
<div class="page-topbar">
<div class="topbar-left">
<h2 class="topbar-title">班级管理</h2>
<span class="topbar-sub">在册 {{ total }} 班</span>
</div>
<div class="topbar-actions">
<el-button icon="el-icon-download" size="small" @click="handleExport">导出班级台账</el-button>
<!-- <el-button type="primary" icon="el-icon-plus" size="small" @click="handleAdd">新增开班</el-button>-->
</div>
</div>
<!-- 顶部静态布局:筛选卡片 -->
<div class="topbar-filter">
<el-form
v-show="showSearch"
ref="queryForm"
:model="queryParams"
size="small"
:inline="true"
label-width="72px"
>
<el-form-item label="班级名称" prop="className">
<el-input v-model="queryParams.className" placeholder="请输入培训班名称" clearable style="width: 160px" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="计划状态" prop="planStatus">
<el-select v-model="queryParams.planStatus" placeholder="全部状态" clearable style="width: 130px">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="开班时间">
<el-date-picker
v-model="startTimeRange"
type="datetimerange"
value-format="yyyy-MM-dd HH:mm:ss"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
clearable
style="width: 330px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- <el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
v-hasPermi="['train:plan:add']"
type="primary"
plain
icon="el-icon-plus"
size="mini"
@click="handleAdd"
>新增计划</el-button>
</el-col>
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
</el-row>-->
<el-table v-loading="loading" :data="planList">
<el-table-column label="序号" align="center" type="index" width="60" :index="indexMethod" />
<!-- <el-table-column label="计划名称" align="left" prop="planName" min-width="150" show-overflow-tooltip />-->
<el-table-column label="班级名称" align="left" prop="className" min-width="180" show-overflow-tooltip />
<el-table-column label="开班时间" align="center" prop="startTime" width="100">
<template slot-scope="scope">
<span>{{ formatDate(scope.row.startTime) }}</span>
</template>
</el-table-column>
<el-table-column label="结束时间" align="center" prop="endTime" width="100">
<template slot-scope="scope">
<span>{{ formatDate(scope.row.endTime) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" align="center" prop="planStatus" width="100">
<template slot-scope="scope">
<el-tag :type="statusTagType(scope.row.planStatus)">{{ statusLabel(scope.row.planStatus) }}</el-tag>
</template>
</el-table-column>
<!-- <el-table-column label="总学时" align="left" prop="totalClassHours" show-overflow-tooltip />-->
<!-- <el-table-column label="报到截止" align="center" prop="reportDeadline" width="165" />
<el-table-column label="培训地点" align="left" prop="trainAddress" min-width="180" show-overflow-tooltip />
-->
<!-- <el-table-column label="计划人数" align="center" prop="recruitNum" width="90" />-->
<el-table-column label="课程数" align="center" prop="courseCount" width="90">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:plan:query']"
type="text"
@click="handleCourseConfig(scope.row)"
>{{ scope.row.courseCount || 0 }}</el-button>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="430" fixed="right">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:plan:query']"
size="mini"
type="text"
icon="el-icon-view"
@click="handleDetail(scope.row)"
>班级详情</el-button>
<el-button
v-hasPermi="['train:plan:query']"
size="mini"
type="text"
icon="el-icon-reading"
@click="handleOpenPlanDetail(scope.row)"
>课程配置</el-button>
<el-button
v-hasPermi="['train:plan:query']"
size="mini"
type="text"
icon="el-icon-data-board"
class="member-entry-btn"
@click="handleOpenStudentDetail(scope.row, 'enrollment')"
>
班级成员
<el-tooltip
v-if="memberCountText(scope.row.enrolledCount)"
:content="`共 ${scope.row.enrolledCount} 名参训学员,点击查看班级成员`"
placement="top"
>
<span class="member-count-badge">{{ memberCountText(scope.row.enrolledCount) }}</span>
</el-tooltip>
</el-button>
<el-button
v-hasPermi="['train:plan:remove']"
size="mini"
type="text"
icon="el-icon-delete"
style="color: #F56C6C;"
:disabled="scope.row.planStatus !== 0"
@click="handleDelete(scope.row)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
<!-- 创建/编辑培训计划对话框 -->
<el-dialog :title="title" :visible.sync="open" width="660px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="110px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="计划名称">
<span class="cr-readonly">{{ form.planName || '-' }}</span>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="计划人数">
<span class="cr-readonly">{{ form.recruitNum || '-' }}</span>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="培训地点">
<span class="cr-readonly">{{ form.trainAddress || '-' }}</span>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="报名条件">
<span class="cr-readonly">{{ form.applyCondition || '-' }}</span>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="班级ID" prop="classId">
<el-input v-model="form.classId" maxlength="64" placeholder="请输入班级ID" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="班级名称" prop="className">
<el-input v-model="form.className" maxlength="150" show-word-limit placeholder="请输入班级名称" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="开班时间" prop="startTime">
<el-date-picker v-model="form.startTime" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" placeholder="请选择开班时间" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="结束时间" prop="endTime">
<el-date-picker v-model="form.endTime" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" placeholder="请选择结束时间" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
</div>
</el-dialog>
<!-- 本期课程配置对话框 -->
<el-dialog :title="courseDialog.title" :visible.sync="courseDialog.open" width="900px" append-to-body>
<div style="margin-bottom: 15px; display: flex; justify-content: space-between; align-items: center;">
<div>
<span>班级状态:</span>
<el-tag :type="statusTagType(courseDialog.planStatus)">{{ statusLabel(courseDialog.planStatus) }}</el-tag>
<span style="margin-left: 15px; color: #909399; font-size: 13px;">已配置课程:{{ courseDialog.list.length }} 门</span>
</div>
<div>
<el-button
v-hasPermi="['train:plan:edit']"
type="primary"
size="mini"
icon="el-icon-plus"
:disabled="isLocked(courseDialog.planStatus)"
@click="handleOpenCourseSelector"
>引入课程</el-button>
</div>
</div>
<el-table v-loading="courseDialog.loading" :data="courseDialog.list" border style="width: 100%">
<el-table-column label="序号" type="index" width="55" align="center" />
<el-table-column label="课程编码" prop="cCode" min-width="120" show-overflow-tooltip />
<el-table-column label="课程名称" prop="cName" min-width="180" show-overflow-tooltip />
<el-table-column label="类型" prop="cType" width="80" align="center" />
<el-table-column label="学时" prop="cDuration" width="70" align="center" />
<el-table-column label="排序号" prop="sortNo" width="70" align="center" />
<el-table-column label="配置说明" prop="remark" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" align="center" width="80" fixed="right">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:plan:edit']"
size="mini"
type="text"
icon="el-icon-delete"
style="color: #F56C6C;"
:disabled="isLocked(courseDialog.planStatus)"
@click="handleRemoveCourse(scope.row)"
>移除</el-button>
</template>
</el-table-column>
</el-table>
<div slot="footer" class="dialog-footer">
<el-button @click="courseDialog.open = false">关 闭</el-button>
</div>
</el-dialog>
<!-- 引入公共课程选择器弹窗 -->
<el-dialog title="引入公共课程" :visible.sync="selectorDialog.open" width="800px" append-to-body>
<el-form :model="selectorDialog.queryParams" size="small" :inline="true">
<el-form-item label="课程名称">
<el-input v-model="selectorDialog.queryParams.cName" placeholder="课程名称" clearable @keyup.enter.native="getCoursePool" />
</el-form-item>
<el-form-item label="课程编码">
<el-input v-model="selectorDialog.queryParams.cCode" placeholder="课程编码" clearable @keyup.enter.native="getCoursePool" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="getCoursePool">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetCoursePool">重置</el-button>
</el-form-item>
</el-form>
<el-table
ref="courseTable"
v-loading="selectorDialog.loading"
:data="selectorDialog.list"
height="320"
@selection-change="handleCourseSelectionChange"
>
<el-table-column type="selection" width="55" align="center" :selectable="checkCourseSelectable" />
<el-table-column label="课程编码" prop="cCode" min-width="120" show-overflow-tooltip />
<el-table-column label="课程名称" prop="cName" min-width="180" show-overflow-tooltip />
<el-table-column label="课程类型" prop="cType" width="80" align="center" />
<el-table-column label="学时" prop="cDuration" width="70" align="center" />
<el-table-column label="状态" align="center" width="90">
<template slot-scope="scope">
<el-tag v-if="isAlreadyAdded(scope.row.cId)" type="info" size="mini">已配置</el-tag>
<el-tag v-else-if="scope.row.status === 0" type="danger" size="mini">已停用</el-tag>
<el-tag v-else type="success" size="mini">可选</el-tag>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 15px;">
<el-form size="small" label-width="110px">
<el-form-item label="配置说明 / 备注">
<el-input v-model="selectorDialog.remark" placeholder="请输入本期课程配置备注(可选)" maxlength="500" show-word-limit />
</el-form-item>
</el-form>
</div>
<div slot="footer" class="dialog-footer">
<el-button
type="primary"
:disabled="selectedCourses.length === 0"
:loading="selectorDialog.submitting"
@click="submitCourseSelection"
>确定引入 ({{ selectedCourses.length }})</el-button>
<el-button @click="selectorDialog.open = false">取 消</el-button>
</div>
</el-dialog>
<!-- 课程综合对话框 (包含:课程、课程表) -->
<el-dialog
:title="`课程详情 - ${planDetailDialog.plan.className || ''}`"
:visible.sync="planDetailDialog.open"
width="1050px"
append-to-body
top="5vh"
>
<!-- 核心三要素 Tab 切换 -->
<el-tabs v-model="planDetailDialog.activeTab">
<!-- Tab 1: 课程 -->
<el-tab-pane label="本期课程" name="course">
<div style="margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center;">
<span style="font-weight: bold; font-size: 14px;">已配置课程 ({{ courseDialog.list.length }} 门)</span>
<el-button
v-hasPermi="['train:plan:edit']"
type="primary"
size="mini"
icon="el-icon-plus"
:disabled="isLocked(planDetailDialog.plan.planStatus)"
@click="handleOpenCourseSelector"
>引入课程</el-button>
</div>
<el-table v-loading="courseDialog.loading" :data="courseDialog.list" border style="width: 100%">
<el-table-column label="序号" type="index" width="55" align="center" />
<el-table-column label="课程编码" prop="cCode" min-width="120" show-overflow-tooltip />
<el-table-column label="课程名称" prop="cName" min-width="180" show-overflow-tooltip />
<el-table-column label="类型" prop="cType" width="80" align="center" />
<el-table-column label="学时" prop="cDuration" width="70" align="center" />
<el-table-column label="排序号" prop="sortNo" width="70" align="center" />
<el-table-column label="配置说明" prop="remark" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" align="center" width="140" fixed="right">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:lesson:add']"
size="mini"
type="text"
icon="el-icon-date"
:disabled="isLocked(planDetailDialog.plan.planStatus)"
@click="handleGoScheduleCourse(scope.row)"
>去排课</el-button>
<el-button
v-hasPermi="['train:plan:edit']"
size="mini"
type="text"
icon="el-icon-delete"
style="color: #F56C6C;"
:disabled="isLocked(planDetailDialog.plan.planStatus)"
@click="handleRemoveCourse(scope.row)"
>移除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- Tab 3: 课程表 -->
<el-tab-pane label="课程表" name="timetable">
<timetable-panel
v-if="planDetailDialog.open && planDetailDialog.plan.planId"
ref="timetablePanel"
:plan-id="planDetailDialog.plan.planId"
:plan-status="planDetailDialog.plan.planStatus"
/>
</el-tab-pane>
</el-tabs>
<div slot="footer" class="dialog-footer">
<el-button @click="planDetailDialog.open = false">关 闭</el-button>
</div>
</el-dialog>
<!-- 参训人员综合对话框 (包含:参训人员、住宿等) -->
<el-dialog
:title="`班级成员详情 - ${studentDetailDialog.plan.className || ''}`"
:visible.sync="studentDetailDialog.open"
:width="detailDialogWidth"
append-to-body
top="5vh"
>
<!-- 核心三要素 Tab 切换 -->
<el-tabs v-model="studentDetailDialog.activeTab">
<!-- Tab 1: 参训人员(班级成员默认标签页) -->
<el-tab-pane label="参训人员" name="enrollment">
<enrollment-panel
v-if="studentDetailDialog.open && studentDetailDialog.plan.planId"
:plan-id="studentDetailDialog.plan.planId"
:plan-status="studentDetailDialog.plan.planStatus"
/>
</el-tab-pane>
<!-- Tab 2: 住宿安排(独立组件,按需加载)功能已加入参训人员tab -->
<!-- <el-tab-pane label="住宿安排" name="accommodation">
<accommodation-panel
v-if="studentDetailDialog.open && studentDetailDialog.activeTab === 'accommodation' && studentDetailDialog.plan.planId"
:plan-id="studentDetailDialog.plan.planId"
/>
</el-tab-pane>-->
<!-- Tab 3: 教室排座(独立组件,按需加载) -->
<el-tab-pane label="教室排座" name="classroomSeat">
<seat-panel
v-if="studentDetailDialog.open && studentDetailDialog.plan.planId"
:plan-id="studentDetailDialog.plan.planId"
:plan-status="studentDetailDialog.plan.planStatus"
/>
</el-tab-pane>
</el-tabs>
<div slot="footer" class="dialog-footer">
<el-button @click="studentDetailDialog.open = false">关 闭</el-button>
</div>
</el-dialog>
<!-- 班级详情(只读展示:基本信息 / 课程安排 / 学员信息) -->
<class-detail :visible.sync="classDetailOpen" :plan="currentClass" />
</div>
</template>
<script>
import {addPlan, completePlan, delPlan, getPlan, listPlan, updatePlan} from '@/api/train/plan'
import { listPlanCourses, configurePlanCourses, removePlanCourse } from '@/api/train/planCourse'
import { listCourse } from '@/api/train/course'
import EnrollmentPanel from './components/EnrollmentPanel.vue'
import TimetablePanel from './components/TimetablePanel.vue'
import AccommodationPanel from './components/AccommodationPanel.vue'
import SeatPanel from './components/SeatPanel.vue'
import ClassDetail from './components/ClassDetail.vue'
export default {
name: 'Plan',
components: {
EnrollmentPanel,
TimetablePanel,
AccommodationPanel,
SeatPanel,
ClassDetail
},
data() {
return {
loading: true,
showSearch: true,
total: 0,
planList: [],
startTimeRange: [],
title: '',
open: false,
statusOptions: [
{ value: 0, label: '未开始' },
{ value: 1, label: '进行中' },
{ value: 2, label: '已结束' },
{ value: 3, label: '已取消' }
],
queryParams: {
pageNum: 1,
pageSize: 10,
planName: null,
planStatus: null
},
form: {},
rules: {
planName: [{ required: true, message: '培训班名称不能为空', trigger: 'blur' }],
classId: [{ required: true, message: '班级ID不能为空', trigger: 'blur' }],
className: [{ required: true, message: '班级名称不能为空', trigger: 'blur' }],
startTime: [{ required: true, message: '开班时间不能为空', trigger: 'change' }],
endTime: [{ required: true, message: '结束时间不能为空', trigger: 'change' }],
reportDeadline: [{ required: true, message: '报到截止时间不能为空', trigger: 'change' }],
recruitNum: [{ required: true, message: '计划人数不能为空', trigger: 'change' }]
},
// 本期课程配置对话框
courseDialog: {
open: false,
loading: false,
title: '',
planId: null,
planName: '',
planStatus: null,
list: []
},
// 公共课程选择器
selectorDialog: {
open: false,
loading: false,
submitting: false,
remark: '',
queryParams: {
cName: null,
cCode: null,
status: 1
},
list: []
},
selectedCourses: [],
// 计划综合详情对话框
planDetailDialog: {
open: false,
activeTab: 'course',
plan: {}
},
// 详情弹窗宽度:宽屏固定上限、窄屏按视口自适应(打开时重新计算)
detailDialogWidth: '1260px',
studentDetailDialog: {
open: false,
activeTab: 'enrollment',
plan: {}
},
// 班级详情(只读)
classDetailOpen: false,
currentClass: {}
}
},
created() {
this.getList()
},
methods: {
/** 表格序号:按当前分页顺序连续编号 */
indexMethod(index) {
const { pageNum = 1, pageSize = 10 } = this.queryParams
return (pageNum - 1) * pageSize + index + 1
},
/** 班级成员入口的参训人数徽标文本:0 不展示,超过 99 显示 99+ */
memberCountText(count) {
const num = Number(count) || 0
if (num <= 0) return ''
return num > 99 ? '99+' : String(num)
},
getList() {
this.loading = true
const query = { ...this.queryParams }
if (this.startTimeRange && this.startTimeRange.length === 2) {
query.startTimeBegin = this.startTimeRange[0]
query.startTimeEnd = this.startTimeRange[1]
}
listPlan(query).then(response => {
this.planList = response.rows
this.total = response.total
}).finally(() => {
this.loading = false
})
},
statusLabel(status) {
const option = this.statusOptions.find(item => item.value === status)
return option ? option.label : '未知'
},
statusTagType(status) {
return { 0: 'info', 1: 'primary', 2: 'success', 3: 'danger' }[status] || 'info'
},
isLocked(status) {
return status === 2 || status === 3
},
cancel() {
this.open = false
this.reset()
},
reset() {
this.form = {
planId: null,
planName: null,
classId: null,
className: null,
startTime: null,
endTime: null,
reportDeadline: null,
trainAddress: null,
recruitNum: 1,
applyCondition: null,
remark: null
}
this.resetForm('form')
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.startTimeRange = []
this.resetForm('queryForm')
this.handleQuery()
},
handleAdd() {
this.reset()
this.open = true
this.title = '创建培训计划'
},
handleUpdate(row) {
this.reset()
getPlan(row.planId).then(response => {
this.form = response.data
//console.log(this.form);
this.open = true
this.title = '编辑班级信息'
})
},
submitForm() {
this.$refs.form.validate(valid => {
if (!valid) return
if (this.form.startTime >= this.form.endTime) {
this.$modal.msgError('结束时间必须晚于开班时间')
return
}
if (this.form.reportDeadline > this.form.startTime) {
this.$modal.msgError('报到截止时间不得晚于开班时间')
return
}
const request = this.form.planId
? updatePlan(this.form.planId, this.planPayload())
: addPlan(this.planPayload())
request.then(() => {
this.$modal.msgSuccess(this.form.planId ? '修改成功' : '创建成功')
this.open = false
this.getList()
})
})
},
/** 仅取日期部分 yyyy-MM-dd(去掉 HH:mm:ss) */
formatDate(val) {
if (!val) return '-'
const s = String(val)
return s.length >= 10 ? s.substring(0, 10) : s
},
/** 截断为日期字符串 yyyy-MM-dd,供日期选择器双向绑定 */
toDateOnly(val) {
if (!val) return null
const s = String(val)
return s.length >= 10 ? s.substring(0, 10) : s
},
/** 日期字符串补零为后端期望的 yyyy-MM-dd HH:mm:ss(时间归零),兼容 @JsonFormat 反序列化 */
toTimestamp(val) {
if (!val) return null
return val.length <= 10 ? val + ' 00:00:00' : val
},
planPayload() {
return {
planName: this.form.planName,
classId: this.form.classId,
className: this.form.className,
startTime: this.form.startTime,
endTime: this.form.endTime,
reportDeadline: this.form.reportDeadline,
trainAddress: this.form.trainAddress,
recruitNum: this.form.recruitNum,
applyCondition: this.form.applyCondition,
remark: this.form.remark
}
},
handleComplete(row) {
this.$modal.confirm('结束计划后将锁定计划信息并保留全部历史记录,是否继续?').then(() => {
return completePlan(row.planId)
}).then(() => {
this.$modal.msgSuccess('计划已结束')
this.getList()
}).catch(() => {})
},
handleDelete(row) {
const className = row.className || row.classId
this.$modal.confirm(`确认删除班级「${className}」吗?仅“未开始”的计划允许删除。`).then(() => {
//return delPlan(row.planId)
this.form.classId = "";
this.form.className = "";
//班级删除只清空开班计划中的班级信息,不真正删除开班计划
updatePlan(this.form.planId, this.planPayload().then(() => {
this.$modal.msgSuccess(this.form.planId ? '修改成功' : '创建成功')
}));
}).then(() => {
this.$modal.msgSuccess('删除成功')
this.getList()
}).catch(() => {})
},
// ---- ISSUE-02-02 本期课程配置方法 ----
handleCourseConfig(row) {
this.courseDialog.planId = row.planId
this.courseDialog.planName = row.planName
this.courseDialog.planStatus = row.planStatus
this.courseDialog.title = `本期课程配置 - ${row.planName}`
this.courseDialog.open = true
this.loadPlanCourses(row.planId)
},
loadPlanCourses(planId) {
this.courseDialog.loading = true
listPlanCourses(planId).then(response => {
this.courseDialog.list = response.data || []
}).finally(() => {
this.courseDialog.loading = false
})
},
handleOpenCourseSelector() {
this.selectorDialog.remark = ''
this.selectedCourses = []
this.selectorDialog.queryParams.cName = null
this.selectorDialog.queryParams.cCode = null
this.selectorDialog.open = true
this.getCoursePool()
},
getCoursePool() {
this.selectorDialog.loading = true
listCourse(this.selectorDialog.queryParams).then(response => {
this.selectorDialog.list = response.rows || []
}).finally(() => {
this.selectorDialog.loading = false
})
},
resetCoursePool() {
this.selectorDialog.queryParams.cName = null
this.selectorDialog.queryParams.cCode = null
this.getCoursePool()
},
isAlreadyAdded(cId) {
return this.courseDialog.list.some(item => item.cId === cId)
},
checkCourseSelectable(row) {
return !this.isAlreadyAdded(row.cId) && row.status === 1
},
handleCourseSelectionChange(selection) {
this.selectedCourses = selection
},
submitCourseSelection() {
if (this.selectedCourses.length === 0) {
this.$modal.msgWarning('请选择至少一门课程')
return
}
this.selectorDialog.submitting = true
const cIds = this.selectedCourses.map(item => item.cId)
configurePlanCourses(this.courseDialog.planId, {
cIds: cIds,
remark: this.selectorDialog.remark
}).then(() => {
this.$modal.msgSuccess('成功引入课程')
this.selectorDialog.open = false
this.loadPlanCourses(this.courseDialog.planId)
this.getList()
}).finally(() => {
this.selectorDialog.submitting = false
})
},
handleRemoveCourse(row) {
this.$modal.confirm(`确定要从本期培训计划中移除课程【${row.cName}】吗?`).then(() => {
return removePlanCourse(this.courseDialog.planId, row.planCourseId)
}).then(() => {
this.$modal.msgSuccess('移除成功')
this.loadPlanCourses(this.courseDialog.planId)
this.getList()
}).catch(() => {})
},
/** 班级详情(只读展示) */
handleDetail(row) {
this.currentClass = Object.assign({}, row)
this.classDetailOpen = true
},
/** 详情弹窗宽度:宽屏上限 1260px,窄屏按视口 94% 自适应,避免表格列被过度压缩或出现横向滚动 */
resolveDetailDialogWidth() {
const viewport = (typeof window !== 'undefined' && window.innerWidth) || 1440
return `${Math.min(1260, Math.max(720, Math.round(viewport * 0.94)))}px`
},
handleOpenPlanDetail(row, activeTab = 'course') {
this.planDetailDialog.plan = row
this.planDetailDialog.activeTab = activeTab
this.planDetailDialog.open = true
this.courseDialog.planId = row.planId
this.courseDialog.planName = row.planName
this.courseDialog.planStatus = row.planStatus
this.loadPlanCourses(row.planId)
},
handleOpenStudentDetail(row, activeTab = 'enrollment') {
this.detailDialogWidth = this.resolveDetailDialogWidth()
this.studentDetailDialog.plan = row
this.studentDetailDialog.activeTab = activeTab
this.studentDetailDialog.open = true
this.courseDialog.planId = row.planId
this.courseDialog.planName = row.planName
this.courseDialog.planStatus = row.planStatus
this.loadPlanCourses(row.planId)
},
// 跳转考勤管理中心并预选指定培训班
handleGoCheckin(row) {
this.$router.push({
path: '/train/checkin',
query: { planId: row.planId }
})
},
// 从本期课程列表中快捷前往课程表Tab并直接打开排课对话框
handleGoScheduleCourse(courseRow) {
this.planDetailDialog.activeTab = 'timetable'
this.$nextTick(() => {
if (this.$refs.timetablePanel && this.$refs.timetablePanel.openAddLessonForCourse) {
this.$refs.timetablePanel.openAddLessonForCourse(courseRow.planCourseId)
}
})
},
/** 导出按钮操作 */
handleExport() {
this.download('train/plan/export', {
...this.queryParams
}, `plan_${new Date().getTime()}.xlsx`)
},
}
}
</script>
<style scoped>
/* 编辑窗口中只读展示字段 */
.cr-readonly {
display: block;
min-height: 20px;
padding: 7px 0;
color: #606266;
word-break: break-all;
}
/* 班级成员入口的参训人数徽标 */
.member-entry-btn .member-count-badge {
display: inline-block;
min-width: 18px;
height: 18px;
line-height: 18px;
padding: 0 5px;
margin-left: 4px;
font-size: 11px;
font-weight: 600;
color: #fff;
text-align: center;
background: #409eff;
border-radius: 9px;
vertical-align: middle;
transition: background-color 0.2s, transform 0.2s;
}
.member-entry-btn:hover .member-count-badge {
background: #66b1ff;
transform: scale(1.08);
}
/* 窄屏下徽标略微收紧,保证操作列单行显示 */
@media (max-width: 1200px) {
.member-entry-btn .member-count-badge {
min-width: 16px;
height: 16px;
line-height: 16px;
padding: 0 4px;
font-size: 10px;
}
}
/* ===== 顶部统计:数字强化 ===== */
.topbar-stat {
display: inline-flex;
align-items: baseline;
gap: 5px;
margin-left: 12px;
padding: 2px 12px;
background: #ecf5ff;
border: 1px solid #d9ecff;
border-radius: 14px;
}
.topbar-stat .stat-value {
font-size: 18px;
font-weight: 700;
color: #409eff;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.topbar-stat .stat-label {
font-size: 12px;
color: #409eff;
opacity: 0.85;
}
/* ===== 表格文本层级 ===== */
.class-name {
font-weight: 600;
color: #1f2d3d;
}
.date-text {
font-size: 13px;
color: #5a6474;
font-variant-numeric: tabular-nums;
}
.status-tag {
padding: 0 9px;
font-weight: 500;
border-radius: 10px;
}
/* ===== 课程数:数值突出 ===== */
::v-deep .stat-num-btn {
display: inline-flex;
align-items: baseline;
padding: 0;
line-height: 1;
}
::v-deep .stat-num-btn .stat-num {
font-size: 17px;
font-weight: 700;
color: #409eff;
line-height: 1;
font-variant-numeric: tabular-nums;
}
::v-deep .stat-num-btn .stat-unit {
margin-left: 2px;
font-size: 12px;
font-weight: 400;
color: #909399;
}
::v-deep .stat-num-btn:hover .stat-num {
color: #66b1ff;
}
/* ===== 操作列:图标与文字垂直对齐、间距统一 ===== */
::v-deep .table-action-cell .cell {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: nowrap;
gap: 12px;
white-space: nowrap;
}
::v-deep .table-action-cell .el-button.row-action {
display: inline-flex;
align-items: center;
height: 22px;
margin: 0;
padding: 0;
font-size: 13px;
line-height: 1;
border: none;
background: transparent;
}
::v-deep .table-action-cell .el-button.row-action > span {
display: inline-flex;
align-items: center;
margin-left: 0;
line-height: 1;
}
::v-deep .table-action-cell .el-button.row-action i {
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 4px;
font-size: 14px;
line-height: 1;
vertical-align: middle;
}
::v-deep .table-action-cell .el-button.row-action.danger-action {
color: #f56c6c;
}
::v-deep .table-action-cell .el-button.row-action.is-disabled {
color: #c0c4cc;
}
/* ===== 表头与行悬停 ===== */
::v-deep .el-table th.el-table__cell {
background: #f7f9fc;
color: #3b4757;
font-weight: 600;
}
::v-deep .el-table tbody tr:hover > td.el-table__cell {
background: #f4f9ff;
}
/* 窄屏下操作按钮间距略微收紧 */
@media (max-width: 1200px) {
::v-deep .table-action-cell .cell {
gap: 8px;
}
::v-deep .table-action-cell .el-button.row-action {
font-size: 12px;
}
}
</style>
File diff suppressed because it is too large. Load diff
+184 -9
View File
@@ -1,5 +1,19 @@
<template>
<div class="app-container">
<!-- 顶部静态布局:标题栏 -->
<div class="page-topbar">
<div class="topbar-left">
<h2 class="topbar-title">教室信息</h2>
<span class="topbar-sub">在册 {{ total }} 处</span>
</div>
<div class="topbar-actions">
<el-button icon="el-icon-download" size="small" @click="handleExport">导出教室台账</el-button>
<el-button type="primary" icon="el-icon-plus" size="small" @click="handleAdd">新增教室</el-button>
</div>
</div>
<!-- 顶部静态布局:筛选卡片 -->
<div class="topbar-filter">
<el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true" label-width="88px">
<el-form-item label="教室名称" prop="roomName">
<el-input
@@ -49,8 +63,9 @@
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</div>
<el-row :gutter="10" class="mb8">
<!-- <el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
v-hasPermi="['train:classroom:edit']"
@@ -73,10 +88,11 @@
>修改</el-button>
</el-col>
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
</el-row>
</el-row>-->
<el-table v-loading="loading" :data="classroomList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<!-- <el-table-column type="selection" width="55" align="center" />-->
<el-table-column label="序号" align="center" type="index" width="60" :index="indexMethod" />
<el-table-column label="教室名称" align="center" prop="roomName" min-width="120" />
<el-table-column label="教室位置" align="center" prop="roomLocation" />
<el-table-column label="核定容纳人数" align="center" prop="capacity">
@@ -91,19 +107,45 @@
<el-tag v-else type="info">维护中</el-tag>
</template>
</el-table-column>
<el-table-column label="具备多媒体" align="center" prop="multimedia">
<el-table-column label="具备多媒体" align="center" prop="multimedia" width="100">
<template slot-scope="scope">
<dict-tag :options="dict.type.train_yes_no" :value="scope.row.multimedia" />
<el-tooltip
:content="isYesValue(scope.row.multimedia) ? '具备多媒体(有)' : '不具备多媒体(无)'"
placement="top"
>
<i
:class="['classroom-flag-icon', isYesValue(scope.row.multimedia) ? 'el-icon-success is-yes' : 'el-icon-circle-close is-no']"
/>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="具备投影" align="center" prop="projector">
<el-table-column label="具备投影" align="center" prop="projector" width="90">
<template slot-scope="scope">
<dict-tag :options="dict.type.train_yes_no" :value="scope.row.projector" />
<el-tooltip
:content="isYesValue(scope.row.projector) ? '具备投影(有)' : '不具备投影(无)'"
placement="top"
>
<i
:class="['classroom-flag-icon', isYesValue(scope.row.projector) ? 'el-icon-success is-yes' : 'el-icon-circle-close is-no']"
/>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="电子班牌" align="center" prop="bindBoardId" min-width="130">
<template slot-scope="scope">
<span v-if="scope.row.bindBoardId">{{ boardName(scope.row.bindBoardId) }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<!-- <el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true" />-->
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="140">
<el-table-column label="操作" align="center" class-name="small-padding fixed-width action-cell" width="180" fixed="right">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-view"
@click="handleDetail(scope.row)"
>详情</el-button>
<el-button
v-hasPermi="['train:classroom:edit']"
size="mini"
@@ -168,6 +210,16 @@
>{{ dict.label }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="电子班牌" prop="bindBoardId">
<el-select v-model="form.bindBoardId" placeholder="请选择电子班牌(可不选)" clearable filterable style="width: 100%">
<el-option
v-for="item in boardOptions"
:key="item.boardId"
:label="item.boardName || item.boardSn"
:value="item.boardId"
/>
</el-select>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" placeholder="请输入备注" maxlength="500" />
</el-form-item>
@@ -177,11 +229,58 @@
<el-button @click="cancel">取 消</el-button>
</div>
</el-dialog>
<!-- 教室信息详情对话框 -->
<el-dialog title="教室信息详情" :visible.sync="detailOpen" width="820px" append-to-body custom-class="detail-dialog">
<div class="detail-dialog-body">
<el-descriptions :column="2" border>
<el-descriptions-item>
<template slot="label"><span class="detail-dialog-label">教室名称</span></template>
{{ detail.roomName || '-' }}
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="detail-dialog-label">教室位置</span></template>
{{ detail.roomLocation || '-' }}
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="detail-dialog-label">容纳人数</span></template>
{{ detail.capacity != null ? detail.capacity + ' 人' : '-' }}
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="detail-dialog-label">教室状态</span></template>
<el-tag v-if="detail.roomStatus === 1" type="success" size="mini">可用空闲</el-tag>
<el-tag v-else-if="detail.roomStatus === 0" type="danger" size="mini">正在使用</el-tag>
<el-tag v-else type="info" size="mini">维护中</el-tag>
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="detail-dialog-label">具备多媒体</span></template>
<dict-tag :options="dict.type.train_yes_no" :value="detail.multimedia" />
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="detail-dialog-label">具备投影</span></template>
<dict-tag :options="dict.type.train_yes_no" :value="detail.projector" />
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="detail-dialog-label">电子班牌</span></template>
<span v-if="detail.bindBoardId">{{ boardName(detail.bindBoardId) }}</span>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="detail-dialog-label">备注</span></template>
{{ detail.remark || '-' }}
</el-descriptions-item>
</el-descriptions>
</div>
<div slot="footer" class="dialog-footer">
<el-button @click="detailOpen = false">关 闭</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listClassroom, getClassroom, addClassroom, updateClassroom, delClassroom } from '@/api/train/classroom'
import { listBoard } from '@/api/train/board'
export default {
name: 'Classroom',
@@ -203,6 +302,11 @@ export default {
total: 0,
// 教室信息表格数据
classroomList: [],
// 电子班牌选项(来自 train_electronic_board)
boardOptions: [],
// 详情弹窗
detailOpen: false,
detail: {},
// 弹出层标题
title: '',
// 是否显示弹出层
@@ -260,6 +364,7 @@ export default {
},
created() {
this.getList()
this.loadBoardOptions()
},
methods: {
/** 是否值归一化:Y->1,N->0,纯数字字符串转 Number */
@@ -272,6 +377,22 @@ export default {
if (text === 'N' || text === 'n') return 0
return /^\d+$/.test(text) ? Number(text) : value
},
/**
* 是否类取值判定:兼容 1 / '1' / 'Y' / '是' 等写法,
* 供列表“具备多媒体”“具备投影”图标列使用。
*/
isYesValue(value) {
if (value === null || value === undefined || value === '') {
return false
}
const text = String(value).trim().toLowerCase()
return ['1', 'y', 'yes', 'true', '是', '有'].indexOf(text) > -1
},
/** 表格序号:按当前分页顺序连续编号 */
indexMethod(index) {
const { pageNum = 1, pageSize = 10 } = this.queryParams
return (pageNum - 1) * pageSize + index + 1
},
/** 查询教室信息列表 */
getList() {
this.loading = true
@@ -296,6 +417,7 @@ export default {
roomStatus: 1,
multimedia: 0,
projector: 0,
bindBoardId: null,
remark: null
}
this.resetForm('form')
@@ -364,7 +486,60 @@ export default {
this.$modal.msgSuccess('删除成功')
this.getList()
}).catch(() => {})
}
},
/** 加载电子班牌选项(来自 train_electronic_board) */
loadBoardOptions() {
listBoard({ pageNum: 1, pageSize: 1000 }).then(res => {
this.boardOptions = res.rows || res.data || []
}).catch(() => {})
},
/** 根据 boardId 解析电子班牌名称 */
boardName(boardId) {
const b = this.boardOptions.find(x => x.boardId === boardId)
return b ? (b.boardName || b.boardSn || boardId) : boardId
},
/** 详情按钮操作 */
handleDetail(row) {
this.detail = row
this.detailOpen = true
},
/** 导出按钮操作 */
handleExport() {
this.download('train/classroom/export', {
...this.queryParams
}, `classroom_${new Date().getTime()}.xlsx`)
},
}
}
</script>
<style scoped lang="scss">
/* 具备多媒体 / 具备投影:以图标表达“有 / 无”状态,节省列宽 */
.classroom-flag-icon {
font-size: 16px;
line-height: 1;
vertical-align: middle;
cursor: default;
&.is-yes {
color: #67c23a;
}
&.is-no {
color: #c0c4cc;
}
}
/* 操作列固定单行,避免按钮因列宽不足换行 */
::v-deep .action-cell .cell {
white-space: nowrap;
}
/* 窄屏下压缩单元格内边距,进一步避免操作列换行 */
@media (max-width: 1200px) {
::v-deep .el-table .cell {
padding-left: 6px;
padding-right: 6px;
}
}
</style>
@@ -0,0 +1,348 @@
<template>
<div class="app-container">
<!-- 顶部静态布局:标题栏 -->
<div class="page-topbar">
<div class="topbar-left">
<h2 class="topbar-title">班级座位</h2>
<span class="topbar-sub">在册 {{ total }} 个</span>
</div>
<div class="topbar-actions">
<el-button icon="el-icon-download" size="small" @click="handleExport">导出座位台账</el-button>
<el-button type="primary" icon="el-icon-plus" size="small" @click="handleAdd">新增座位</el-button>
</div>
</div>
<!-- 顶部静态布局:筛选卡片 -->
<div class="topbar-filter">
<el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true" label-width="84px">
<el-form-item label="班级ID" prop="planId">
<el-input
v-model="queryParams.planId"
placeholder="请输入班级ID"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="教室ID" prop="classroomId">
<el-input
v-model="queryParams.classroomId"
placeholder="请输入教室ID"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="实训基地ID" prop="siteId">
<el-input
v-model="queryParams.siteId"
placeholder="请输入实训基地ID"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="学员ID" prop="studentId">
<el-input
v-model="queryParams.studentId"
placeholder="请输入占用学员ID"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="是否取消" prop="canceled">
<el-select v-model="queryParams.canceled" placeholder="是否取消" clearable>
<el-option label="正常" :value="0" />
<el-option label="取消" :value="1" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</div>
<el-table v-loading="loading" :data="seatList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="座位ID" align="center" prop="seatId" min-width="80" />
<el-table-column label="班级ID" align="center" prop="planId" min-width="140" show-overflow-tooltip />
<el-table-column label="教室ID" align="center" prop="classroomId" min-width="140" show-overflow-tooltip />
<el-table-column label="实训基地ID" align="center" prop="siteId" min-width="140" show-overflow-tooltip />
<el-table-column label="行" align="center" prop="rowIndex" min-width="60" />
<el-table-column label="列" align="center" prop="colIndex" min-width="60" />
<el-table-column label="座位标识" align="center" prop="seatCode" min-width="120" show-overflow-tooltip />
<el-table-column label="是否取消" align="center" prop="canceled" min-width="90">
<template slot-scope="scope">
<el-tag v-if="scope.row.canceled === 1" type="info">取消</el-tag>
<el-tag v-else type="success">正常</el-tag>
</template>
</el-table-column>
<el-table-column label="占用学员" align="center" min-width="120" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.studentName || scope.row.studentId || '-' }}</template>
</el-table-column>
<el-table-column label="部门" align="center" prop="deptDetail" min-width="120" show-overflow-tooltip />
<el-table-column label="职务" align="center" prop="job" min-width="100" show-overflow-tooltip />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="140">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:classseat:edit']"
size="mini"
type="text"
icon="el-icon-edit"
@click="handleUpdate(scope.row)"
>修改</el-button>
<el-button
v-hasPermi="['train:classseat:remove']"
size="mini"
type="text"
icon="el-icon-delete"
style="color: #F56C6C;"
@click="handleDelete(scope.row)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total>0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
<!-- 添加或修改班级座位对话框 -->
<el-dialog :title="title" :visible.sync="open" width="560px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="班级ID" prop="planId">
<el-input v-model="form.planId" placeholder="请输入班级ID" maxlength="64" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="教室ID" prop="classroomId">
<el-input v-model="form.classroomId" placeholder="请输入教室ID" maxlength="64" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="实训基地ID" prop="siteId">
<el-input v-model="form.siteId" placeholder="请输入实训基地ID" maxlength="64" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="区域ID" prop="zoneId">
<el-input-number v-model="form.zoneId" :min="0" placeholder="区域ID" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="行号" prop="rowIndex">
<el-input-number v-model="form.rowIndex" :min="1" placeholder="行号" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="列号" prop="colIndex">
<el-input-number v-model="form.colIndex" :min="1" placeholder="列号" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="座位标识" prop="seatCode">
<el-input v-model="form.seatCode" placeholder="请输入物理座位标识" maxlength="60" />
</el-form-item>
<el-form-item label="是否取消" prop="canceled">
<el-radio-group v-model="form.canceled">
<el-radio :label="0">正常</el-radio>
<el-radio :label="1">取消</el-radio>
</el-radio-group>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="占用学员ID" prop="studentId">
<el-input v-model="form.studentId" placeholder="请输入占用学员ID" maxlength="64" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="部门" prop="deptDetail">
<el-input v-model="form.deptDetail" placeholder="请输入部门" maxlength="100" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="职务" prop="job">
<el-input v-model="form.job" placeholder="请输入职务" maxlength="100" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listClassSeat, getClassSeat, addClassSeat, updateClassSeat, delClassSeat } from '@/api/train/seat'
export default {
name: 'ClassSeat',
data() {
return {
// 遮罩层
loading: true,
// 选中数组
ids: [],
// 非单个禁用
single: true,
// 非多个禁用
multiple: true,
// 显示搜索条件
showSearch: true,
// 总条数
total: 0,
// 班级座位表格数据
seatList: [],
// 弹出层标题
title: '',
// 是否显示弹出层
open: false,
// 查询参数
queryParams: {
pageNum: 1,
pageSize: 10,
planId: null,
classroomId: null,
siteId: null,
studentId: null,
canceled: null
},
// 表单参数
form: {},
// 表单校验
rules: {
planId: [
{ required: true, message: '班级ID不能为空', trigger: 'blur' }
],
classroomId: [
{ required: true, message: '教室ID不能为空', trigger: 'blur' }
],
rowIndex: [
{ required: true, message: '行号不能为空', trigger: 'blur' }
],
colIndex: [
{ required: true, message: '列号不能为空', trigger: 'blur' }
],
seatCode: [
{ required: true, message: '座位标识不能为空', trigger: 'blur' }
]
}
}
},
created() {
this.getList()
},
methods: {
/** 查询班级座位列表 */
getList() {
this.loading = true
listClassSeat(this.queryParams).then(response => {
this.seatList = response.rows
this.total = response.total
this.loading = false
})
},
// 取消按钮
cancel() {
this.open = false
this.reset()
},
// 表单重置
reset() {
this.form = {
seatId: null,
planId: null,
classroomId: null,
siteId: null,
rowIndex: 1,
colIndex: 1,
seatCode: null,
canceled: 0,
studentId: null,
zoneId: null,
deptDetail: null,
job: null
}
this.resetForm('form')
},
/** 搜索按钮操作 */
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
/** 重置按钮操作 */
resetQuery() {
this.resetForm('queryForm')
this.handleQuery()
},
// 多选框选中数据
handleSelectionChange(selection) {
this.ids = selection.map(item => item.seatId)
this.single = selection.length !== 1
this.multiple = !selection.length
},
/** 新增按钮操作 */
handleAdd() {
this.reset()
this.open = true
this.title = '添加班级座位'
},
/** 修改按钮操作 */
handleUpdate(row) {
this.reset()
const seatId = row.seatId || this.ids
getClassSeat(seatId).then(response => {
this.form = response.data
this.open = true
this.title = '修改班级座位'
})
},
/** 提交按钮 */
submitForm() {
this.$refs['form'].validate(valid => {
if (valid) {
if (this.form.seatId != null) {
updateClassSeat(this.form).then(response => {
this.$modal.msgSuccess('修改成功')
this.open = false
this.getList()
})
} else {
addClassSeat(this.form).then(response => {
this.$modal.msgSuccess('新增成功')
this.open = false
this.getList()
})
}
}
})
},
/** 删除按钮操作 */
handleDelete(row) {
const seatIds = row.seatId || this.ids
const tip = row.seatId ? `座位【${row.seatCode || row.seatId}】` : `选中 ${this.ids.length} 个座位`
this.$modal.confirm(`确定删除${tip}吗?`).then(() => {
return delClassSeat(seatIds)
}).then(() => {
this.$modal.msgSuccess('删除成功')
this.getList()
}).catch(() => {})
},
/** 导出按钮操作 */
handleExport() {
this.download('train/classseat/export', {
...this.queryParams
}, `classseat_${new Date().getTime()}.xlsx`)
}
}
}
</script>
+378 -6
View File
@@ -1,5 +1,20 @@
<template>
<div class="app-container">
<!-- 顶部静态布局:标题栏 -->
<div class="page-topbar">
<div class="topbar-left">
<h2 class="topbar-title">课程信息</h2>
<span class="topbar-sub">在册 {{ total }} 门</span>
</div>
<div class="topbar-actions">
<el-button icon="el-icon-folder" size="small" @click="goToResource">教学资源管理</el-button>
<el-button icon="el-icon-download" size="small" @click="handleExport">导出课程台账</el-button>
<el-button type="primary" icon="el-icon-plus" size="small" @click="handleAdd">新增课程</el-button>
</div>
</div>
<!-- 顶部静态布局:筛选卡片 -->
<div class="topbar-filter">
<el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true" label-width="68px">
<el-form-item label="课程编码" prop="cCode">
<el-input v-model="queryParams.cCode" placeholder="请输入课程编码" clearable @keyup.enter.native="handleQuery" />
@@ -23,8 +38,8 @@
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
</div>
<!-- <el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button v-hasPermi="['train:course:add']" type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd">新增</el-button>
</el-col>
@@ -32,10 +47,10 @@
<el-button v-hasPermi="['train:course:edit']" type="success" plain icon="el-icon-edit" size="mini" :disabled="single" @click="handleUpdate">修改</el-button>
</el-col>
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
</el-row>
</el-row>-->
<el-table v-loading="loading" :data="courseList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="序号" align="center" type="index" width="60" :index="indexMethod" />
<el-table-column label="课程编码" align="center" prop="cCode" min-width="120" :show-overflow-tooltip="true" />
<el-table-column label="课程名称" align="center" prop="cName" min-width="140" :show-overflow-tooltip="true" />
<el-table-column label="课程类型" align="center" prop="cType" width="120">
@@ -49,8 +64,9 @@
<el-tag :type="scope.row.status === 1 ? 'success' : 'info'">{{ scope.row.status === 1 ? '正常' : '停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="180">
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="240">
<template slot-scope="scope">
<el-button v-hasPermi="['train:course:query']" size="mini" type="text" icon="el-icon-view" @click="handleDetail(scope.row)">详情</el-button>
<el-button v-hasPermi="['train:course:edit']" size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)">修改</el-button>
<el-button
v-if="scope.row.status === 1"
@@ -231,6 +247,85 @@
</template>
</div>
</el-dialog>
<el-dialog title="课程详情 · 资源绑定" :visible.sync="detailOpen" width="560px" append-to-body>
<div v-loading="detailLoading" class="course-detail">
<div class="detail-header">
<span>课程概览</span>
<el-button type="text" icon="el-icon-edit" @click="handleDetailEdit">编辑</el-button>
</div>
<div class="detail-hero">
<div class="hero-play" @click="handlePlayFeatured">
<i class="el-icon-video-play" />
</div>
<div class="hero-info">
<div class="hero-title">{{ featuredResource ? featuredResource.resourceName : (detail.cName || '暂无关联资源') }}</div>
<div class="hero-meta">{{ heroMeta }}</div>
</div>
</div>
<div class="detail-body">
<div class="detail-name">{{ detail.cName }}</div>
<div class="detail-tags">
<span class="detail-hours">{{ detail.cDuration || 0 }} 学时</span>
<el-tag size="mini" :type="detail.status === 1 ? 'success' : 'info'">{{ detail.status === 1 ? '进行中' : '已停用' }}</el-tag>
</div>
<div class="detail-rows">
<div class="detail-row">
<span class="row-label">课程编号</span>
<span class="row-value">{{ detail.cCode || '—' }}</span>
</div>
<div class="detail-row">
<span class="row-label">课程类型</span>
<span class="row-value"><dict-tag :options="dict.type.train_course_type" :value="detail.cType" /></span>
</div>
<div class="detail-row">
<span class="row-label">教学目标</span>
<span class="row-value pre-line">{{ detail.teachingObjective || '—' }}</span>
</div>
<div class="detail-row">
<span class="row-label">课程内容</span>
<span class="row-value pre-line">{{ detail.courseContent || '—' }}</span>
</div>
<div class="detail-row">
<span class="row-label">课内资源</span>
<span class="row-value">{{ detailResources.length ? detailResources.length + ' 项关联资源' : '暂无' }}</span>
</div>
</div>
</div>
<el-card shadow="never" class="detail-card">
<div slot="header" class="detail-card-header">关联教学资源</div>
<div v-if="detailResources.length" class="resource-list">
<div v-for="item in detailResources" :key="item.resourceId" class="resource-item">
<dict-tag :options="dict.type.train_resource_type" :value="item.resourceType" />
<span class="resource-name">{{ item.resourceName }}</span>
<el-link type="primary" :underline="false" @click="handleViewResource(item)">查看</el-link>
</div>
</div>
<div v-else class="resource-empty">暂未关联教学资源</div>
</el-card>
<el-card v-if="detail.remark" shadow="never" class="detail-card">
<div slot="header" class="detail-card-header">备注信息</div>
<div class="pre-line">{{ detail.remark }}</div>
</el-card>
</div>
<div slot="footer" class="dialog-footer">
<el-button @click="detailOpen = false">关 闭</el-button>
</div>
</el-dialog>
<el-dialog :title="previewTitle" :visible.sync="previewVisible" width="760px" append-to-body @closed="closePreview">
<video v-if="previewType === 'video'" :src="previewUrl" controls autoplay class="preview-media" />
<img v-else-if="previewType === 'image'" :src="previewUrl" class="preview-media" alt="资源预览" />
<div v-else class="preview-other">
<i class="el-icon-document" />
<p>该资源暂不支持在线预览,请下载后查看。</p>
<el-button type="primary" @click="downloadPreview">下载 / 打开</el-button>
</div>
</el-dialog>
</div>
</template>
@@ -276,6 +371,14 @@ export default {
courseList: [],
title: '',
open: false,
detailOpen: false,
detailLoading: false,
detail: {},
detailResources: [],
previewVisible: false,
previewType: '',
previewUrl: '',
previewTitle: '资源预览',
activeStep: 0,
savingCourse: false,
resourceActionLoading: false,
@@ -324,6 +427,21 @@ export default {
availableResourceOptions() {
const boundIds = this.boundResources.map(item => String(item.resourceId))
return this.resourceOptions.filter(item => boundIds.indexOf(String(item.resourceId)) === -1)
},
featuredResource() {
if (!this.detailResources.length) {
return null
}
const video = this.detailResources.find(item => /\.(mp4|avi|mov|wmv|m4v|flv)(\?|$)/i.test(item.resourceUrl || ''))
return video || this.detailResources[0]
},
heroMeta() {
if (this.featuredResource) {
return [this.dictLabel('train_resource_type', this.featuredResource.resourceType), this.resourceLabel(this.featuredResource)]
.filter(Boolean).join(' · ')
}
return [this.dictLabel('train_course_type', this.detail.cType), `${this.detail.cDuration || 0} 学时`]
.filter(Boolean).join(' · ')
}
},
watch: {
@@ -342,6 +460,11 @@ export default {
this.getList()
},
methods: {
/** 表格序号:按当前分页顺序连续编号 */
indexMethod(index) {
const { pageNum = 1, pageSize = 10 } = this.queryParams
return (pageNum - 1) * pageSize + index + 1
},
getList() {
this.loading = true
listCourse(this.queryParams).then(response => {
@@ -368,6 +491,62 @@ export default {
cancel() {
this.open = false
},
handleDetail(row) {
this.detailOpen = true
this.detailLoading = true
this.detail = {}
this.detailResources = []
getCourse(row.cId).then(response => {
this.detail = { ...response.data }
this.detail.status = this.detail.status == null ? 1 : Number(this.detail.status)
this.detailResources = response.data.resources || []
}).finally(() => {
this.detailLoading = false
})
},
handleDetailEdit() {
this.detailOpen = false
this.handleUpdate(this.detail)
},
handlePlayFeatured() {
if (this.featuredResource) {
this.handleViewResource(this.featuredResource)
}
},
handleViewResource(item) {
const url = this.resourceLink(item)
if (!url) {
this.$modal.msgWarning('资源地址无效,无法查看')
return
}
const ext = (item.resourceUrl || '').split('?')[0].split('#')[0].split('.').pop().toLowerCase()
const videoExts = ['mp4', 'avi', 'mov', 'wmv', 'm4v', 'flv', 'webm', 'mkv', 'mpg', 'mpeg']
const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp']
if (videoExts.includes(ext)) {
this.previewType = 'video'
} else if (imageExts.includes(ext)) {
this.previewType = 'image'
} else {
this.previewType = 'other'
}
this.previewUrl = url
this.previewTitle = (item.resourceName || '资源预览') + ' · 预览'
this.previewVisible = true
},
downloadPreview() {
if (this.previewUrl) {
window.open(this.previewUrl, '_blank')
}
},
closePreview() {
this.previewType = ''
this.previewUrl = ''
},
dictLabel(dictName, value) {
const list = this.dict.type[dictName] || []
const item = list.find(d => String(d.value) === String(value))
return item ? item.label : ''
},
reset() {
this.activeStep = 0
this.savingCourse = false
@@ -536,7 +715,17 @@ export default {
return resource.resourceUrl
}
return resource.resourceUrl.substring(resource.resourceUrl.lastIndexOf('/') + 1)
}
},
/** 跳转教学资源管理页面(资源菜单挂在课程管理目录下,路由为 /coursemgr/resource) */
goToResource() {
this.$router.push('/coursemgr/resource')
},
/** 导出按钮操作 */
handleExport() {
this.download('train/course/export', {
...this.queryParams
}, `course_${new Date().getTime()}.xlsx`)
},
}
}
</script>
@@ -574,4 +763,187 @@ export default {
.resource-submit {
text-align: right;
}
.course-detail {
padding: 0 4px;
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
color: #303133;
margin-bottom: 12px;
}
.detail-hero {
display: flex;
align-items: center;
gap: 16px;
padding: 24px 20px;
border-radius: 8px;
background: linear-gradient(135deg, #1d2b50 0%, #16213e 60%, #101a33 100%);
color: #fff;
}
.hero-play {
width: 52px;
height: 52px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.25);
font-size: 26px;
cursor: pointer;
transition: background 0.2s;
}
.hero-play:hover {
background: rgba(255, 255, 255, 0.25);
}
.hero-info {
min-width: 0;
}
.hero-title {
font-size: 16px;
font-weight: 600;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hero-meta {
margin-top: 6px;
font-size: 12px;
color: rgba(255, 255, 255, 0.65);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-body {
padding: 16px 4px 4px;
}
.detail-name {
font-size: 18px;
font-weight: 600;
color: #303133;
}
.detail-tags {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
}
.detail-hours {
font-size: 13px;
color: #909399;
}
.detail-rows {
margin-top: 16px;
}
.detail-row {
display: flex;
padding: 10px 0;
border-bottom: 1px solid #f0f2f5;
font-size: 14px;
}
.detail-row:last-child {
border-bottom: none;
}
.row-label {
width: 90px;
flex-shrink: 0;
color: #909399;
}
.row-value {
flex: 1;
color: #303133;
word-break: break-all;
}
.pre-line {
white-space: pre-line;
}
.detail-card {
margin-top: 16px;
}
.detail-card-header {
font-weight: 600;
color: #303133;
}
.resource-list {
display: flex;
flex-direction: column;
}
.resource-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid #f0f2f5;
}
.resource-item:last-child {
border-bottom: none;
}
.resource-name {
flex: 1;
min-width: 0;
font-size: 14px;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.resource-empty {
padding: 16px 0;
text-align: center;
font-size: 13px;
color: #909399;
}
.preview-media {
display: block;
width: 100%;
max-height: 70vh;
margin: 0 auto;
background: #000;
}
.preview-other {
text-align: center;
padding: 30px 0 10px;
color: #909399;
}
.preview-other i {
font-size: 48px;
margin-bottom: 12px;
}
.preview-other p {
margin: 0 0 18px;
}
</style>
File diff suppressed because it is too large. Load diff
+177
View File
@@ -0,0 +1,177 @@
<template>
<el-drawer title="用户信息详情" :visible.sync="visible" direction="rtl" size="68%" append-to-body :before-close="handleClose" custom-class="detail-drawer">
<div v-loading="loading" class="drawer-content">
<!-- 基本信息 -->
<h4 class="section-header">基本信息</h4>
<el-row :gutter="20" class="mb8">
<el-col :span="12">
<div class="info-item">
<label class="info-label">用户名称:</label>
<span class="info-value plaintext">{{ info.nickName }}</span>
</div>
</el-col>
<el-col :span="12">
<div class="info-item">
<label class="info-label">归属部门:</label>
<span class="info-value plaintext">{{ (info.dept && info.dept.deptName) }}</span>
</div>
</el-col>
</el-row>
<el-row :gutter="20" class="mb8">
<el-col :span="12">
<div class="info-item">
<label class="info-label">手机号码:</label>
<span class="info-value plaintext">{{ info.phonenumber }}</span>
</div>
</el-col>
<el-col :span="12">
<div class="info-item">
<label class="info-label">邮箱:</label>
<span class="info-value plaintext">{{ info.email }}</span>
</div>
</el-col>
</el-row>
<el-row :gutter="20" class="mb8">
<el-col :span="12">
<div class="info-item">
<label class="info-label">登录账号:</label>
<span class="info-value plaintext">{{ info.userName }}</span>
</div>
</el-col>
<el-col :span="12">
<div class="info-item">
<label class="info-label">用户状态:</label>
<span class="info-value plaintext">
<el-tag size="small" :type="info.status === '0' ? 'success' : 'danger'">{{ info.status === '0' ? '正常' : '停用' }}</el-tag>
</span>
</div>
</el-col>
</el-row>
<el-row :gutter="20" class="mb8">
<el-col :span="12">
<div class="info-item">
<label class="info-label">岗位:</label>
<span class="info-value plaintext">{{ postNames || '无岗位' }}</span>
</div>
</el-col>
<el-col :span="12">
<div class="info-item">
<label class="info-label">用户性别:</label>
<span class="info-value plaintext">{{ sexLabel }}</span>
</div>
</el-col>
</el-row>
<el-row :gutter="20" class="mb8">
<el-col :span="24">
<div class="info-item full-width">
<label class="info-label">角色:</label>
<span class="info-value plaintext">{{ roleNames || '无角色' }}</span>
</div>
</el-col>
</el-row>
<!-- 其他信息 -->
<h4 class="section-header">其他信息</h4>
<el-row :gutter="20" class="mb8">
<el-col :span="12">
<div class="info-item">
<label class="info-label">创建者:</label>
<span class="info-value plaintext">{{ info.createBy }}</span>
</div>
</el-col>
<el-col :span="12">
<div class="info-item">
<label class="info-label">创建时间:</label>
<span class="info-value plaintext">{{ info.createTime }}</span>
</div>
</el-col>
</el-row>
<el-row :gutter="20" class="mb8">
<el-col :span="12">
<div class="info-item">
<label class="info-label">更新者:</label>
<span class="info-value plaintext">{{ info.updateBy }}</span>
</div>
</el-col>
<el-col :span="12">
<div class="info-item">
<label class="info-label">更新时间:</label>
<span class="info-value plaintext">{{ info.updateTime }}</span>
</div>
</el-col>
</el-row>
<el-row :gutter="20" class="mb8">
<el-col :span="12">
<div class="info-item">
<label class="info-label">最后登录IP:</label>
<span class="info-value plaintext">{{ info.loginIp }}</span>
</div>
</el-col>
<el-col :span="12">
<div class="info-item">
<label class="info-label">最后登录时间:</label>
<span class="info-value plaintext">{{ info.loginDate }}</span>
</div>
</el-col>
</el-row>
<el-row :gutter="20" class="mb8">
<el-col :span="24">
<div class="info-item full-width">
<label class="info-label">备注:</label>
<span class="info-value plaintext">{{ info.remark }}</span>
</div>
</el-col>
</el-row>
</div>
</el-drawer>
</template>
<script>
import { getUser } from '@/api/system/user'
export default {
name: 'UserViewDrawer',
dicts: ['sys_user_sex'],
data() {
return {
visible: false,
loading: false,
info: {},
postOptions: [],
roleOptions: []
}
},
computed: {
sexLabel() {
return this.selectDictLabel(this.dict.type.sys_user_sex, this.info.sex) || '-'
},
postNames() {
if (!this.postOptions.length) return ''
const ids = this.info.postIds || []
return this.postOptions.filter(p => ids.includes(p.postId)).map(p => p.postName).join('、') || ''
},
roleNames() {
if (!this.roleOptions.length) return ''
const ids = this.info.roleIds || []
return this.roleOptions.filter(r => ids.includes(r.roleId)).map(r => r.roleName).join('、') || ''
}
},
methods: {
open(userId) {
this.visible = true
this.loading = true
getUser(userId).then(res => {
this.info = res.data || {}
this.postOptions = res.posts || []
this.roleOptions = res.roles || []
this.info.postIds = res.postIds || []
this.info.roleIds = res.roleIds || []
}).finally(() => {
this.loading = false
})
},
handleClose() {
this.visible = false
}
}
}
</script>
+260 -62
View File
@@ -1,5 +1,19 @@
<template>
<div class="app-container">
<!-- 顶部静态布局:标题栏 -->
<div class="page-topbar">
<div class="topbar-left">
<h2 class="topbar-title">宿舍信息</h2>
<span class="topbar-sub">在册 {{ total }} 间</span>
</div>
<div class="topbar-actions">
<el-button icon="el-icon-download" size="small" @click="handleExport">导出宿舍台账</el-button>
<el-button type="primary" icon="el-icon-plus" size="small" @click="handleAdd">新增房间</el-button>
</div>
</div>
<!-- 顶部静态布局:筛选卡片 -->
<div class="topbar-filter">
<el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true" label-width="72px">
<el-form-item label="宿舍楼栋" prop="dormBuilding">
<el-input
@@ -25,13 +39,13 @@
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="适用性别" prop="genderType">
<!-- <el-form-item label="适用性别" prop="genderType">
<el-select v-model="queryParams.genderType" placeholder="适用性别" clearable>
<el-option label="不限" :value="0" />
<el-option label="男" :value="1" />
<el-option label="女" :value="2" />
</el-select>
</el-form-item>
</el-form-item>-->
<el-form-item label="宿舍状态" prop="dormStatus">
<el-select v-model="queryParams.dormStatus" placeholder="宿舍状态" clearable>
<el-option label="可用" :value="1" />
@@ -43,8 +57,8 @@
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
</div>
<!-- <el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
v-hasPermi="['train:dormitory:edit']"
@@ -56,12 +70,26 @@
>新增房间</el-button>
</el-col>
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
</el-row>
</el-row>-->
<el-table v-loading="loading" :data="dormitoryList">
<el-table-column label="序号" align="center" type="index" width="60" :index="indexMethod" />
<el-table-column label="宿舍楼栋" align="center" prop="dormBuilding" />
<el-table-column label="楼层" align="center" prop="dormFloor" width="80" />
<el-table-column label="房间号" align="center" prop="dormRoom" />
<el-table-column label="房间号" align="center" prop="dormRoom" min-width="120">
<template slot-scope="scope">
<div class="dorm-room-cell">
<span class="dorm-room-no">{{ scope.row.dormRoom || '-' }}</span>
<el-tag
v-if="isFullRoom(scope.row)"
class="dorm-full-tag"
type="danger"
size="mini"
effect="dark"
>满员</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="核定床位" align="center" prop="capacity" width="90" />
<el-table-column label="已住人数" align="center" width="90">
<template slot-scope="scope">
@@ -84,7 +112,11 @@
<span v-else>不限</span>
</template>
</el-table-column>
<el-table-column label="智能锁序列号" align="center" prop="lockDeviceSn" :show-overflow-tooltip="true" />
<el-table-column label="智能锁SN" align="center" prop="lockDeviceSn" min-width="130" :show-overflow-tooltip="true">
<template slot-scope="scope">
<span>{{ lockSn(scope.row.lockDeviceSn) || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" align="center" width="80">
<template slot-scope="scope">
<el-tag :type="scope.row.dormStatus === 1 ? 'success' : 'danger'">
@@ -102,12 +134,27 @@
@click="handleDetail(scope.row)"
>详情</el-button>
<el-button
v-hasPermi="['train:dormitory:list']"
size="mini"
type="text"
icon="el-icon-edit"
@click="handleUpdate(scope.row)"
>修改</el-button>
<el-button
v-hasPermi="['train:dormitory:edit']"
size="mini"
type="text"
style="color: #F56C6C;"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
>删除</el-button>
<!-- <el-button
v-hasPermi="['train:access:query']"
size="mini"
type="text"
icon="el-icon-key"
@click="handleAccessMapping(scope.row)"
>门禁映射</el-button>
>门禁映射</el-button>-->
</template>
</el-table-column>
</el-table>
@@ -143,7 +190,14 @@
</el-radio-group>
</el-form-item>
<el-form-item label="智能锁SN" prop="lockDeviceSn">
<el-input v-model="form.lockDeviceSn" placeholder="请输入人脸/智能门锁序列号" />
<el-select v-model="form.lockDeviceSn" placeholder="请选择智能门锁(可不选)" clearable filterable style="width: 100%">
<el-option
v-for="item in lockOptions"
:key="item.deviceId"
:label="item.deviceSn + (item.deviceName ? '(' + item.deviceName + ')' : '')"
:value="item.deviceSn"
/>
</el-select>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" placeholder="请输入备注内容" />
@@ -156,53 +210,81 @@
</el-dialog>
<!-- 宿舍详情与当前入住人员对话框 -->
<el-dialog title="宿舍详情与入住人员" :visible.sync="detailOpen" width="750px" append-to-body>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="宿舍楼栋">{{ detailData.dormBuilding }}</el-descriptions-item>
<el-descriptions-item label="所在楼层">{{ detailData.dormFloor }}</el-descriptions-item>
<el-descriptions-item label="房间号">{{ detailData.dormRoom }}</el-descriptions-item>
<el-descriptions-item label="核定床位容量">{{ detailData.capacity }} 人</el-descriptions-item>
<el-descriptions-item label="已住人数">{{ detailData.usedCount || 0 }} 人</el-descriptions-item>
<el-descriptions-item label="剩余可用床位">{{ detailData.remainingBeds != null ? detailData.remainingBeds : detailData.capacity }}</el-descriptions-item>
<el-descriptions-item label="适用性别">
<span v-if="detailData.genderType === 1">男</span>
<span v-else-if="detailData.genderType === 2">女</span>
<span v-else>不限</span>
</el-descriptions-item>
<el-descriptions-item label="智能门锁SN">{{ detailData.lockDeviceSn || '未绑定' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="detailData.dormStatus === 1 ? 'success' : 'danger'" size="mini">
{{ detailData.dormStatus === 1 ? '可用' : '禁用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="备注">{{ detailData.remark || '-' }}</el-descriptions-item>
</el-descriptions>
<div style="margin-top: 18px; margin-bottom: 8px; font-weight: bold; font-size: 14px;">
当前入住学员 ({{ (detailData.occupants && detailData.occupants.length) || 0 }} 人)
</div>
<el-table :data="detailData.occupants" size="small" border empty-text="当前暂无入住人员">
<el-table-column label="学员姓名" prop="studentName" align="center" />
<el-table-column label="性别" align="center" width="70">
<template slot-scope="scope">
<span>{{ scope.row.gender === '1' || scope.row.gender === '男' ? '男' : (scope.row.gender === '2' || scope.row.gender === '女' ? '女' : '未知') }}</span>
</template>
</el-table-column>
<el-table-column label="所属单位" prop="orgName" align="center" :show-overflow-tooltip="true" />
<el-table-column label="分配床位号" align="center" width="100">
<template slot-scope="scope">
<span>{{ scope.row.bedNo || '未指定' }}</span>
</template>
</el-table-column>
<el-table-column label="分配入住时间" prop="assignedTime" align="center" width="160" />
<el-table-column label="住宿状态" align="center" width="90">
<template slot-scope="scope">
<el-tag :type="scope.row.accommodationStatus === 1 ? 'success' : 'info'" size="mini">
{{ scope.row.accommodationStatus === 1 ? '已入住' : '预分配' }}
<el-dialog title="宿舍详情与入住人员" :visible.sync="detailOpen" width="820px" append-to-body custom-class="detail-dialog">
<div class="detail-dialog-body">
<el-descriptions :column="2" border>
<el-descriptions-item>
<template slot="label"><span class="detail-dialog-label">宿舍楼栋</span></template>
{{ detailData.dormBuilding || '-' }}
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="detail-dialog-label">所在楼层</span></template>
{{ detailData.dormFloor || '-' }}
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="detail-dialog-label">房间号</span></template>
{{ detailData.dormRoom || '-' }}
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="detail-dialog-label">核定床位容量</span></template>
{{ detailData.capacity != null ? detailData.capacity + ' 人' : '-' }}
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="detail-dialog-label">已住人数</span></template>
{{ detailData.usedCount || 0 }} 人
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="detail-dialog-label">剩余可用床位</span></template>
{{ detailData.remainingBeds != null ? detailData.remainingBeds : (detailData.capacity != null ? detailData.capacity : '-') }}
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="detail-dialog-label">适用性别</span></template>
<span v-if="detailData.genderType === 1">男</span>
<span v-else-if="detailData.genderType === 2">女</span>
<span v-else>不限</span>
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="detail-dialog-label">智能门锁SN</span></template>
{{ lockSn(detailData.lockDeviceSn) || '未绑定' }}
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="detail-dialog-label">状态</span></template>
<el-tag :type="detailData.dormStatus === 1 ? 'success' : 'danger'" size="mini">
{{ detailData.dormStatus === 1 ? '可用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="detail-dialog-label">备注</span></template>
{{ detailData.remark || '-' }}
</el-descriptions-item>
</el-descriptions>
<div class="detail-dialog-section">
当前入住学员 ({{ (detailData.occupants && detailData.occupants.length) || 0 }} 人)
</div>
<el-table :data="detailData.occupants" size="small" border empty-text="当前暂无入住人员">
<el-table-column label="学员姓名" prop="studentName" align="center" />
<el-table-column label="性别" align="center" width="70">
<template slot-scope="scope">
<span>{{ scope.row.gender === '1' || scope.row.gender === '男' ? '男' : (scope.row.gender === '2' || scope.row.gender === '女' ? '女' : '未知') }}</span>
</template>
</el-table-column>
<el-table-column label="所属单位" prop="orgName" align="center" :show-overflow-tooltip="true" />
<el-table-column label="分配床位号" align="center" width="100">
<template slot-scope="scope">
<span>{{ scope.row.bedNo || '未指定' }}</span>
</template>
</el-table-column>
<el-table-column label="分配入住时间" prop="assignedTime" align="center" width="160" />
<el-table-column label="住宿状态" align="center" width="90">
<template slot-scope="scope">
<el-tag :type="scope.row.accommodationStatus === 1 ? 'success' : 'info'" size="mini">
{{ scope.row.accommodationStatus === 1 ? '已入住' : '预分配' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<div slot="footer" class="dialog-footer">
<el-button @click="detailOpen = false">关 闭</el-button>
</div>
@@ -342,8 +424,10 @@
</template>
<script>
import { listDormitory, getDormitory, addDormitory } from '@/api/train/dormitory'
import { listDormitory, getDormitory, addDormitory, updateDormitory, delDormitory } from '@/api/train/dormitory'
import { listLockDevice } from '@/api/train/lockDevice'
import { getDormMapping, bindRoomMapping, bindLockMapping, unbindMapping } from '@/api/train/access'
import {addClassroom, updateClassroom} from "@/api/train/classroom";
export default {
name: 'Dormitory',
@@ -353,6 +437,8 @@ export default {
showSearch: true,
total: 0,
dormitoryList: [],
// 智能门锁设备选项(来自 lock_device 表,value 存 deviceSn 即门锁序列号)
lockOptions: [],
title: '',
open: false,
detailOpen: false,
@@ -416,8 +502,36 @@ export default {
},
created() {
this.getList()
this.loadLockOptions()
},
methods: {
/** 加载智能门锁设备选项(来自 lock_device 表) */
loadLockOptions() {
listLockDevice({ pageNum: 1, pageSize: 1000 }).then(res => {
this.lockOptions = res.rows || res.data || []
}).catch(() => {})
},
/** 门锁字段直接存设备 SN,这里按 SN 解析(找不到对应设备时仍展示原始 SN) */
lockSn(sn) {
if (sn === null || sn === undefined || sn === '') return null
const item = this.lockOptions.find(x => String(x.deviceSn) === String(sn))
return item ? item.deviceSn : sn
},
/** 表格序号:按当前分页顺序连续编号 */
indexMethod(index) {
const { pageNum = 1, pageSize = 10 } = this.queryParams
return (pageNum - 1) * pageSize + index + 1
},
/**
* 是否满员(仅用于列表展示标识,不参与任何床位计算)。
* 与“剩余床位”列展示口径保持一致:remainingBeds 为空时回退取 capacity。
*/
isFullRoom(row) {
if (!row) return false
const remaining = row.remainingBeds != null ? row.remainingBeds : row.capacity
if (remaining === null || remaining === undefined || remaining === '') return false
return Number(remaining) === 0
},
getList() {
this.loading = true
listDormitory(this.queryParams).then(response => {
@@ -463,6 +577,32 @@ export default {
this.detailOpen = true
})
},
/** 修改按钮操作 */
handleUpdate(row) {
this.reset()
const dormId = row.dormId || this.ids
getDormitory(dormId).then(response => {
this.form = response.data
this.open = true
this.title = '修改宿舍信息'
})
},
/** 删除按钮操作 */
handleDelete(row) {
const dormId = row.dormId
const roomLabel = (row.dormBuilding || '') + (row.dormRoom || dormId)
// 有在住/已分配人员时禁止删除(usedCount 与后端 countActiveByDormId 口径一致:accommodation_status IN (0,1))
if ((row.usedCount || 0) > 0) {
this.$modal.msgWarning('房间「' + roomLabel + '」当前有 ' + row.usedCount + ' 名在住/已分配人员,禁止删除,请先为其办理退宿或调房')
return
}
this.$modal.confirm('确认删除房间「' + roomLabel + '」?删除后不可恢复。').then(() => {
return delDormitory([dormId])
}).then(() => {
this.getList()
this.$modal.msgSuccess('删除成功')
}).catch(err => { if (err !== 'cancel') this.$modal.msgError((err && err.msg) || '删除失败') })
},
handleAccessMapping(row) {
this.currentDorm = row
this.roomForm.dormId = row.dormId
@@ -512,14 +652,72 @@ export default {
submitForm() {
this.$refs['form'].validate(valid => {
if (valid) {
addDormitory(this.form).then(() => {
this.$modal.msgSuccess('新增成功')
this.open = false
this.getList()
})
if (this.form.dormId != null) {
updateDormitory(this.form).then(response => {
this.$modal.msgSuccess('修改成功')
this.open = false
this.getList()
})
} else {
addDormitory(this.form).then(() => {
this.$modal.msgSuccess('新增成功')
this.open = false
this.getList()
})
}
}
})
}
this.$refs['form'].validate(valid => {
if (valid) {
}
})
},
/** 导出按钮操作 */
handleExport() {
this.download('train/dormitory/export', {
...this.queryParams
}, `dormitory_${new Date().getTime()}.xlsx`)
},
}
}
</script>
<style scoped lang="scss">
/* 房间号 + 满员标识:同一行内紧凑排列,避免换行破坏表格对齐 */
.dorm-room-cell {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
max-width: 100%;
white-space: nowrap;
.dorm-room-no {
font-weight: 600;
color: #1f2d3d;
overflow: hidden;
text-overflow: ellipsis;
}
.dorm-full-tag {
flex-shrink: 0;
height: 18px;
line-height: 18px;
padding: 0 6px;
font-size: 11px;
border-radius: 9px;
}
}
/* 窄屏下略微收紧间距,保证标识与房间号仍在一行显示 */
@media (max-width: 1200px) {
.dorm-room-cell {
gap: 4px;
.dorm-full-tag {
padding: 0 5px;
}
}
}
</style>
@@ -0,0 +1,256 @@
<template>
<div class="app-container">
<el-form ref="queryForm" :model="queryParams" size="small" :inline="true" v-show="showSearch" label-width="80px">
<el-form-item label="应用标识" prop="appId">
<el-input v-model="queryParams.appId" placeholder="请输入应用标识" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="批次号" prop="batchId">
<el-input v-model="queryParams.batchId" placeholder="请输入批次号" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="学员ID" prop="studentId">
<el-input v-model="queryParams.studentId" placeholder="请输入学员ID" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="匹配状态" prop="matchStatus">
<el-select v-model="queryParams.matchStatus" placeholder="请选择" clearable style="width: 130px">
<el-option label="已匹配" value="MATCHED" />
<el-option label="待认领" value="UNMATCHED" />
</el-select>
</el-form-item>
<el-form-item label="成绩状态" prop="scoreStatus">
<el-select v-model="queryParams.scoreStatus" placeholder="请选择" clearable style="width: 130px">
<el-option label="已接收" value="RECEIVED" />
<el-option label="已归档" value="ARCHIVED" />
<el-option label="已锁定" value="LOCKED" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button v-hasPermi="['train:examReport:remove']" type="danger" plain icon="el-icon-delete" size="mini" :disabled="multiple" @click="handleDelete">删除</el-button>
</el-col>
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
</el-row>
<el-table v-loading="loading" :data="reportList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="50" align="center" />
<el-table-column label="上报编号" align="center" prop="reportId" min-width="190" :show-overflow-tooltip="true" />
<el-table-column label="应用标识" align="center" prop="appId" min-width="130" :show-overflow-tooltip="true" />
<el-table-column label="学员ID" align="center" prop="studentId" width="120" />
<el-table-column label="学员姓名" align="center" prop="studentName" width="100" />
<el-table-column label="科目" align="center" prop="subjectName" min-width="150" :show-overflow-tooltip="true" />
<el-table-column label="分数" align="center" prop="score" width="90" />
<el-table-column label="通过" align="center" prop="passFlag" width="80">
<template slot-scope="scope">
<el-tag v-if="scope.row.passFlag === 1" type="success" size="mini">通过</el-tag>
<el-tag v-else type="danger" size="mini">未通过</el-tag>
</template>
</el-table-column>
<el-table-column label="匹配状态" align="center" prop="matchStatus" width="100">
<template slot-scope="scope">
<el-tag v-if="scope.row.matchStatus === 'MATCHED'" type="success" size="mini">已匹配</el-tag>
<el-tag v-else type="warning" size="mini">待认领</el-tag>
</template>
</el-table-column>
<el-table-column label="成绩状态" align="center" prop="scoreStatus" width="100">
<template slot-scope="scope">
<el-tag v-if="scope.row.scoreStatus === 'ARCHIVED'" type="success" size="mini">已归档</el-tag>
<el-tag v-else-if="scope.row.scoreStatus === 'LOCKED'" type="danger" size="mini">已锁定</el-tag>
<el-tag v-else type="info" size="mini">已接收</el-tag>
</template>
</el-table-column>
<el-table-column label="接收时间" align="center" prop="receivedTime" width="160">
<template slot-scope="scope">{{ formatTime(scope.row.receivedTime) }}</template>
</el-table-column>
<el-table-column label="操作" align="center" width="220" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button size="mini" type="text" icon="el-icon-view" @click="handleDetail(scope.row)">详情</el-button>
<el-button v-if="scope.row.matchStatus === 'UNMATCHED'" v-hasPermi="['train:examReport:claim']" size="mini" type="text" icon="el-icon-user" @click="handleClaim(scope.row)">认领</el-button>
<el-button v-hasPermi="['train:examReport:remove']" size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
<!-- 详情 -->
<el-dialog title="上报详情" :visible.sync="detailOpen" width="820px" append-to-body>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="上报编号">{{ detail.reportId }}</el-descriptions-item>
<el-descriptions-item label="应用标识">{{ detail.appId }}</el-descriptions-item>
<el-descriptions-item label="批次号">{{ detail.batchId || '-' }}</el-descriptions-item>
<el-descriptions-item label="上报渠道">{{ detail.reportChannel }}</el-descriptions-item>
<el-descriptions-item label="学员ID">{{ detail.studentId }}</el-descriptions-item>
<el-descriptions-item label="学员姓名">{{ detail.studentName || '-' }}</el-descriptions-item>
<el-descriptions-item label="科目编码">{{ detail.subjectCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="科目名称">{{ detail.subjectName }}</el-descriptions-item>
<el-descriptions-item label="考试开始">{{ formatTime(detail.examStartTime) }}</el-descriptions-item>
<el-descriptions-item label="考试结束">{{ formatTime(detail.examEndTime) }}</el-descriptions-item>
<el-descriptions-item label="分数">{{ detail.score }}</el-descriptions-item>
<el-descriptions-item label="满分">{{ detail.fullScore }}</el-descriptions-item>
<el-descriptions-item label="是否通过">{{ detail.passFlag === 1 ? '通过' : '未通过' }}</el-descriptions-item>
<el-descriptions-item label="重考">{{ detail.retake === 1 ? '是(第' + detail.retakeNo + '次)' : '否' }}</el-descriptions-item>
<el-descriptions-item label="考试工位">{{ detail.station }}</el-descriptions-item>
<el-descriptions-item label="匹配状态">{{ detail.matchStatus }}</el-descriptions-item>
<el-descriptions-item label="成绩状态">{{ detail.scoreStatus }}</el-descriptions-item>
<el-descriptions-item label="接收时间">{{ formatTime(detail.receivedTime) }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detail.remark || '-' }}</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">考试内容(原始 JSON)</el-divider>
<pre class="content-json">{{ prettyContent }}</pre>
<div slot="footer" class="dialog-footer">
<el-button @click="detailOpen = false">关 闭</el-button>
</div>
</el-dialog>
<!-- 认领 -->
<el-dialog title="认领成绩" :visible.sync="claimOpen" width="480px" append-to-body>
<el-form ref="claimForm" :model="claimForm" :rules="claimRules" label-width="90px">
<el-form-item label="上报编号">
<el-input v-model="claimForm.reportId" disabled />
</el-form-item>
<el-form-item label="平台学员ID" prop="studentId">
<el-input v-model="claimForm.studentId" placeholder="请输入平台学员ID(train_student.s_id)" maxlength="32" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitClaim">确 定</el-button>
<el-button @click="claimOpen = false">取 消</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listExamReport, getExamReport, claimExamReport, delExamReport } from '@/api/train/examReport'
export default {
name: 'TrainExamReport',
data() {
return {
loading: true,
ids: [],
single: true,
multiple: true,
showSearch: true,
total: 0,
reportList: [],
detailOpen: false,
detail: {},
claimOpen: false,
claimForm: {},
claimRules: {
studentId: [{ required: true, message: '平台学员ID不能为空', trigger: 'blur' }]
},
queryParams: {
pageNum: 1,
pageSize: 10,
appId: null,
batchId: null,
studentId: null,
matchStatus: null,
scoreStatus: null
}
}
},
computed: {
prettyContent() {
if (!this.detail || !this.detail.content) {
return '-'
}
try {
return JSON.stringify(JSON.parse(this.detail.content), null, 2)
} catch (e) {
return this.detail.content
}
}
},
created() {
this.getList()
},
methods: {
formatTime(value) {
if (!value) {
return '-'
}
const date = new Date(Number(value))
const pad = n => (n < 10 ? '0' + n : n)
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + ' ' +
pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds())
},
getList() {
this.loading = true
listExamReport(this.queryParams).then(response => {
this.reportList = response.rows
this.total = response.total
this.loading = false
}).catch(() => { this.loading = false })
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.resetForm('queryForm')
this.handleQuery()
},
handleSelectionChange(selection) {
this.ids = selection.map(item => item.reportPk)
this.single = selection.length !== 1
this.multiple = !selection.length
},
handleDetail(row) {
getExamReport(row.reportPk).then(response => {
this.detail = response.data || {}
this.detailOpen = true
})
},
handleClaim(row) {
this.claimForm = { reportPk: row.reportPk, reportId: row.reportId, studentId: null }
this.claimOpen = true
this.$nextTick(() => this.resetForm('claimForm'))
},
submitClaim() {
this.$refs['claimForm'].validate(valid => {
if (!valid) {
return
}
claimExamReport({ reportPk: this.claimForm.reportPk, studentId: this.claimForm.studentId }).then(() => {
this.$modal.msgSuccess('认领成功')
this.claimOpen = false
this.getList()
})
})
},
handleDelete(row) {
const ids = row.reportPk ? [row.reportPk] : this.ids
if (!ids || ids.length === 0) {
this.$modal.msgWarning('请选择要删除的数据')
return
}
this.$modal.confirm('是否确认删除选中的上报成绩?删除后同一 reportId 可重新上报。').then(() => {
return delExamReport(ids.join(','))
}).then(() => {
this.getList()
this.$modal.msgSuccess('删除成功')
}).catch(() => {})
}
}
}
</script>
<style scoped>
.content-json {
max-height: 260px;
overflow: auto;
background: #f6f8fa;
padding: 12px;
border-radius: 4px;
font-size: 12px;
line-height: 1.6;
}
</style>
@@ -0,0 +1,221 @@
<template>
<div class="app-container">
<el-form ref="queryForm" :model="queryParams" size="small" :inline="true" v-show="showSearch" label-width="80px">
<el-form-item label="科目编码" prop="subjectCode">
<el-input v-model="queryParams.subjectCode" placeholder="请输入科目编码" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="科目名称" prop="subjectName">
<el-input v-model="queryParams.subjectName" placeholder="请输入科目名称" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="培训班" prop="planId">
<el-input v-model="queryParams.planId" placeholder="请输入培训班ID" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width: 120px">
<el-option label="启用" :value="1" />
<el-option label="停用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button v-hasPermi="['train:examSubject:add']" type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd">新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button v-hasPermi="['train:examSubject:remove']" type="danger" plain icon="el-icon-delete" size="mini" :disabled="multiple" @click="handleDelete">删除</el-button>
</el-col>
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
</el-row>
<el-table v-loading="loading" :data="subjectList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="50" align="center" />
<el-table-column label="科目编码" align="center" prop="subjectCode" width="160" />
<el-table-column label="科目名称" align="center" prop="subjectName" min-width="180" :show-overflow-tooltip="true" />
<el-table-column label="所属培训班" align="center" prop="planId" width="150">
<template slot-scope="scope">
<span>{{ scope.row.planId || '全局' }}</span>
</template>
</el-table-column>
<el-table-column label="归档考核项ID" align="center" prop="scoreItemId" width="140">
<template slot-scope="scope">{{ scope.row.scoreItemId || '-' }}</template>
</el-table-column>
<el-table-column label="排序" align="center" prop="sortNo" width="80" />
<el-table-column label="状态" align="center" prop="status" width="90">
<template slot-scope="scope">
<el-tag v-if="scope.row.status === 1" type="success" size="mini">启用</el-tag>
<el-tag v-else type="danger" size="mini">停用</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true" />
<el-table-column label="操作" align="center" width="180" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button v-hasPermi="['train:examSubject:edit']" size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)">修改</el-button>
<el-button v-hasPermi="['train:examSubject:remove']" size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
<el-dialog :title="title" :visible.sync="open" width="560px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="110px">
<el-form-item label="科目编码" prop="subjectCode">
<el-input v-model="form.subjectCode" placeholder="如 VR-SAFE" maxlength="32" />
</el-form-item>
<el-form-item label="科目名称" prop="subjectName">
<el-input v-model="form.subjectName" placeholder="如 高处作业隐患排查" maxlength="100" />
</el-form-item>
<el-form-item label="所属培训班" prop="planId">
<el-input v-model="form.planId" placeholder="留空表示全局科目" maxlength="32" />
</el-form-item>
<el-form-item label="归档考核项ID" prop="scoreItemId">
<el-input-number v-model="form.scoreItemId" :min="1" :max="99999999" style="width: 100%" placeholder="train_score_item 主键" />
</el-form-item>
<el-form-item label="排序" prop="sortNo">
<el-input-number v-model="form.sortNo" :min="0" :max="9999" style="width: 100%" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" maxlength="200" placeholder="请输入备注" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listExamSubject, getExamSubject, addExamSubject, updateExamSubject, delExamSubject } from '@/api/train/examSubject'
export default {
name: 'TrainExamSubject',
data() {
return {
loading: true,
ids: [],
single: true,
multiple: true,
showSearch: true,
total: 0,
subjectList: [],
title: '',
open: false,
queryParams: {
pageNum: 1,
pageSize: 10,
subjectCode: null,
subjectName: null,
planId: null,
status: null
},
form: {},
rules: {
subjectCode: [{ required: true, message: '科目编码不能为空', trigger: 'blur' }],
subjectName: [{ required: true, message: '科目名称不能为空', trigger: 'blur' }]
}
}
},
created() {
this.getList()
},
methods: {
getList() {
this.loading = true
listExamSubject(this.queryParams).then(response => {
this.subjectList = response.rows
this.total = response.total
this.loading = false
}).catch(() => { this.loading = false })
},
cancel() {
this.open = false
this.reset()
},
reset() {
this.form = {
subjectId: null,
subjectCode: null,
subjectName: null,
planId: null,
scoreItemId: null,
sortNo: 0,
status: 1,
remark: null
}
this.resetForm('form')
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.resetForm('queryForm')
this.handleQuery()
},
handleSelectionChange(selection) {
this.ids = selection.map(item => item.subjectId)
this.single = selection.length !== 1
this.multiple = !selection.length
},
handleAdd() {
this.reset()
this.open = true
this.title = '新增考试科目'
},
handleUpdate(row) {
this.reset()
getExamSubject(row.subjectId).then(response => {
this.form = response.data
this.open = true
this.title = '修改考试科目'
})
},
submitForm() {
this.$refs['form'].validate(valid => {
if (!valid) {
return
}
if (this.form.subjectId != null) {
updateExamSubject(this.form).then(() => {
this.$modal.msgSuccess('修改成功')
this.open = false
this.getList()
})
} else {
addExamSubject(this.form).then(() => {
this.$modal.msgSuccess('新增成功')
this.open = false
this.getList()
})
}
})
},
handleDelete(row) {
const ids = row.subjectId ? [row.subjectId] : this.ids
if (!ids || ids.length === 0) {
this.$modal.msgWarning('请选择要删除的数据')
return
}
this.$modal.confirm('是否确认删除选中的考试科目?').then(() => {
return delExamSubject(ids.join(','))
}).then(() => {
this.getList()
this.$modal.msgSuccess('删除成功')
}).catch(() => {})
}
}
}
</script>
@@ -0,0 +1,426 @@
<template>
<div v-loading="loading" class="app-container class-history-regdata">
<!-- 顶部标题栏 -->
<div class="page-topbar">
<div class="topbar-left">
<h2 class="topbar-title">历史报到统计</h2>
<span class="topbar-sub">数据取自参训记录(train_enrollment),报到状态以 enrollment_status 为准</span>
</div>
<div>
<el-button size="small" icon="el-icon-download" @click="handleExport">导出统计报表</el-button>
</div>
</div>
<!-- 统计卡 -->
<div class="rh-stats">
<div class="rh-stat">
<div class="rh-stat-label">累计报到人次</div>
<div class="rh-stat-value dark">{{ totalReportedText }}</div>
</div>
<div class="rh-stat">
<div class="rh-stat-label">平均报到率</div>
<div class="rh-stat-value green">{{ summary.avgRate }}%</div>
</div>
<div class="rh-stat">
<div class="rh-stat-label">平均到训耗时</div>
<div class="rh-stat-value dark">{{ summary.avgCostMinutes }} <span class="rh-unit">分钟</span></div>
</div>
<div class="rh-stat">
<div class="rh-stat-label">最早到达 / 最晚到达</div>
<div class="rh-stat-value dark small">{{ summary.earliest || '—' }} / {{ summary.latest || '—' }}</div>
</div>
</div>
<!-- 中部两栏 -->
<div class="rh-middle">
<!-- 到训时间分布 -->
<div class="rh-panel rh-chart-panel">
<div class="rh-panel-head">
<span class="rh-panel-title">到训时间分布</span>
<span class="rh-panel-extra">{{ summary.batchCount || 0 }} 个批次汇总 · 单位:人</span>
</div>
<div v-if="hourlyBars.length" class="rh-chart">
<div v-for="bar in hourlyBars" :key="bar.hour" class="rh-bar-col">
<span class="rh-bar-value">{{ bar.count }}</span>
<div class="rh-bar-track">
<div class="rh-bar" :class="{ peak: bar.peak }" :style="{ height: bar.percent + '%' }" />
</div>
<span class="rh-bar-label">{{ bar.hour }}</span>
</div>
</div>
<el-empty v-else :image-size="60" description="暂无统计数据" />
</div>
<!-- 各批次报到率趋势 -->
<div class="rh-panel rh-trend-panel">
<div class="rh-panel-head">
<span class="rh-panel-title">各批次报到率趋势</span>
<span class="rh-panel-extra">近 {{ trendLimit }} 个批次 · 目标线 {{ targetRate }}%</span>
</div>
<div v-if="trendBars.length" class="rh-trend-body">
<div class="rh-target-line" :style="{ bottom: targetLinePos + 'px' }" />
<div v-for="(b, idx) in trendBars" :key="b.batchNo + '-' + idx" class="rh-trend-col">
<span class="rh-trend-value">{{ b.rate }}%</span>
<div class="rh-trend-bar" :style="{ height: b.height + 'px', background: b.color }" />
<span class="rh-trend-label" :title="b.batchName">{{ b.batchNo }}</span>
</div>
</div>
<el-empty v-else :image-size="60" description="暂无批次数据" />
</div>
</div>
<!-- 各送培单位报到情况 -->
<div class="rh-panel">
<div class="rh-panel-head">
<span class="rh-panel-title">各送培单位报到情况</span>
<span class="rh-panel-extra">按平均报到率降序</span>
</div>
<el-table :data="unitPaged" size="small" empty-text="暂无送培单位数据">
<el-table-column label="送培单位" prop="unitName" min-width="220" show-overflow-tooltip />
<el-table-column label="累计送培" width="110" align="center">
<template slot-scope="scope">{{ scope.row.totalSent }} 人</template>
</el-table-column>
<el-table-column label="已报到" width="100" align="center">
<template slot-scope="scope">{{ scope.row.reported }} 人</template>
</el-table-column>
<el-table-column label="平均报到率" width="110" align="center">
<template slot-scope="scope">{{ scope.row.avgRate }}%</template>
</el-table-column>
<el-table-column label="报到率" min-width="200">
<template slot-scope="scope">
<div class="rh-bar">
<div class="rh-bar-fill" :style="{ width: Math.min(100, Number(scope.row.avgRate) || 0) + '%', background: barColor(scope.row.avgRate) }" />
</div>
</template>
</el-table-column>
<el-table-column label="平均到训时间" prop="avgArriveTime" width="120" align="center">
<template slot-scope="scope">{{ scope.row.avgArriveTime || '—' }}</template>
</el-table-column>
<el-table-column label="评价" width="90" align="center">
<template slot-scope="scope">
<el-tag :type="levelType(scope.row.avgRate)" size="mini" effect="plain">{{ levelText(scope.row.avgRate) }}</el-tag>
</template>
</el-table-column>
</el-table>
<div class="rh-pager">
<span class="rh-pager-total">共 {{ unitTotal }} 条</span>
<el-pagination
small
background
layout="prev, pager, next"
:total="unitTotal"
:page-size="unitQuery.pageSize"
:current-page.sync="unitQuery.pageNum"
/>
</div>
</div>
</div>
</template>
<script>
import { listPlan } from '@/api/train/plan'
import {
REPORTED_STATUS,
averageClock,
buildHourly,
fetchPlanEnrollments,
filterEffective,
formatClock,
parseDateTime,
rateOf
} from '@/utils/enrollmentReport'
// 趋势柱状图固定量程与尺寸(px)
const SCALE_MIN = 70
const SCALE_MAX = 100
const BAR_AREA = 160
const LABEL_H = 22
// 趋势图展示的最近批次数
const TREND_LIMIT = 8
// 历史统计纳入的最大计划数(避免一次性拉取过多计划的参训记录)
const PLAN_LIMIT = 100
export default {
name: 'HistoryRegData',
data() {
return {
loading: false,
targetRate: 90,
trendLimit: TREND_LIMIT,
// 统计汇总
summary: { totalReported: 0, avgRate: 0, avgCostMinutes: 0, earliest: '', latest: '', hourly: [], batchCount: 0 },
// 批次报到率趋势(已按最近批次正序)
trendList: [],
// 各送培单位
unitList: [],
unitQuery: { pageNum: 1, pageSize: 10 }
}
},
computed: {
/** 累计报到人次(千分位) */
totalReportedText() {
return String(this.summary.totalReported || 0).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
},
/** 到训时间分布柱状数据(纯 CSS 柱状图) */
hourlyBars() {
const list = this.summary.hourly || []
const max = Math.max.apply(null, list.map(h => h.count).concat([1]))
return list.map(h => ({
hour: h.hour,
count: h.count,
percent: Math.round(h.count / max * 100),
peak: h.count === max && h.count > 0
}))
},
/** 批次报到率趋势柱数据 */
trendBars() {
return (this.trendList || []).map(b => {
const rate = Number(b.rate) || 0
const height = Math.round(Math.min(BAR_AREA, Math.max(40, (rate - SCALE_MIN) / (SCALE_MAX - SCALE_MIN) * BAR_AREA)))
return { batchNo: b.batchNo, batchName: b.batchName, rate, height, color: this.trendColor(rate) }
})
},
/** 目标线距面板底部的像素位置 */
targetLinePos() {
const ratio = (this.targetRate - SCALE_MIN) / (SCALE_MAX - SCALE_MIN)
return Math.round(LABEL_H + Math.min(1, Math.max(0, ratio)) * BAR_AREA)
},
/** 送培单位分页数据 */
unitTotal() {
return this.unitList.length
},
unitPaged() {
const start = (this.unitQuery.pageNum - 1) * this.unitQuery.pageSize
return this.unitList.slice(start, start + this.unitQuery.pageSize)
}
},
created() {
this.loadData()
},
methods: {
/** 拉取全部培训计划的 train_enrollment 数据并做前端聚合 */
loadData() {
this.loading = true
listPlan({ pageNum: 1, pageSize: PLAN_LIMIT }).then(res => {
const plans = (res.rows || []).slice().sort((a, b) => {
return String(b.startTime || '').localeCompare(String(a.startTime || ''))
})
return this.fetchAllPlans(plans)
}).then(groups => {
this.buildSummary(groups || [])
}).catch(() => {
this.buildSummary([])
}).finally(() => {
this.loading = false
})
},
/**
* 分批拉取各计划的参训记录,限制并发(每批 4 个计划),避免一次性打出过多请求。
* @param {Array} plans 培训计划列表
* @returns {Promise<Array>} [{ plan, rows }]
*/
fetchAllPlans(plans) {
const groups = []
const CONCURRENCY = 4
const run = index => {
if (index >= plans.length) return Promise.resolve(groups)
const chunk = plans.slice(index, index + CONCURRENCY)
return Promise.all(chunk.map(plan => {
return fetchPlanEnrollments(plan.planId)
.then(rows => ({ plan: plan, rows: rows }))
.catch(() => ({ plan: plan, rows: [] }))
})).then(results => {
results.forEach(item => groups.push(item))
return run(index + CONCURRENCY)
})
}
return run(0)
},
/**
* 汇总各批次参训记录:
* 累计报到人次、平均报到率、平均到训耗时、最早/最晚到达、到训时间分布、批次趋势、送培单位。
*/
buildSummary(groups) {
let totalReported = 0
let rateSum = 0
let rateCount = 0
let costSum = 0
let costCount = 0
let earliestMs = null
let latestMs = null
const reportedRows = []
const trendAll = []
const unitMap = {}
const unitStat = name => {
if (!unitMap[name]) {
unitMap[name] = { unitName: name, totalSent: 0, reported: 0, minutes: [] }
}
return unitMap[name]
}
groups.forEach(group => {
const plan = group.plan || {}
const effective = filterEffective(group.rows)
const reported = effective.filter(row => Number(row.enrollmentStatus) === REPORTED_STATUS)
totalReported += reported.length
if (effective.length > 0) {
rateSum += rateOf(reported.length, effective.length)
rateCount += 1
}
trendAll.push({
batchNo: plan.className || plan.planName || plan.planId,
batchName: plan.planName || '',
rate: rateOf(reported.length, effective.length),
startTime: plan.startTime || ''
})
effective.forEach(row => {
unitStat(row.deptName || '未分配单位').totalSent += 1
})
// 到训耗时基准:报到开始时间,缺失时退化为开班时间
const base = parseDateTime(plan.reportStarttime) || parseDateTime(plan.startTime)
reported.forEach(row => {
reportedRows.push(row)
const unit = unitStat(row.deptName || '未分配单位')
unit.reported += 1
const join = parseDateTime(row.joinTime)
if (!join) return
const ms = join.getTime()
if (earliestMs === null || ms < earliestMs) earliestMs = ms
if (latestMs === null || ms > latestMs) latestMs = ms
unit.minutes.push(join.getHours() * 60 + join.getMinutes())
if (base) {
const diff = Math.round((ms - base.getTime()) / 60000)
if (diff >= 0) {
costSum += diff
costCount += 1
}
}
})
})
const hourly = buildHourly(reportedRows)
this.summary = {
totalReported: totalReported,
avgRate: rateCount ? Math.round(rateSum / rateCount * 10) / 10 : 0,
avgCostMinutes: costCount ? Math.round(costSum / costCount) : 0,
earliest: earliestMs === null ? '' : formatClock(new Date(earliestMs)),
latest: latestMs === null ? '' : formatClock(new Date(latestMs)),
hourly: hourly,
batchCount: rateCount
}
// 趋势:按开班时间倒序取最近 N 个批次,再正序展示
this.trendList = trendAll
.slice()
.sort((a, b) => String(b.startTime).localeCompare(String(a.startTime)))
.slice(0, TREND_LIMIT)
.reverse()
// 送培单位:按平均报到率降序
this.unitList = Object.keys(unitMap).map(key => {
const unit = unitMap[key]
return {
unitName: unit.unitName,
totalSent: unit.totalSent,
reported: unit.reported,
avgRate: rateOf(unit.reported, unit.totalSent),
avgArriveTime: averageClock(unit.minutes)
}
}).sort((a, b) => b.avgRate - a.avgRate)
this.unitQuery.pageNum = 1
},
/** 趋势柱颜色:超过目标越多颜色越深 */
trendColor(rate) {
if (rate >= 95) return '#1d39c4'
if (rate >= 93) return '#2b6bff'
if (rate >= this.targetRate) return '#5b8dff'
return '#adc4f9'
},
/** 单位报到率进度条颜色 */
barColor(rate) {
const r = Number(rate) || 0
if (r >= 92) return '#10b981'
if (r >= this.targetRate) return '#2b6bff'
return '#f79009'
},
/** 评价标签文案:>=95 优,>=90 良,否则待改进 */
levelText(rate) {
const r = Number(rate) || 0
if (r >= 95) return '优'
if (r >= this.targetRate) return '良'
return '待改进'
},
levelType(rate) {
const r = Number(rate) || 0
if (r >= 95) return 'success'
if (r >= this.targetRate) return ''
return 'warning'
},
/** 导出统计报表(送培单位维度,字段与页面一致) */
handleExport() {
if (!this.unitList.length) {
this.$modal.msgWarning('暂无可导出的统计数据')
return
}
const headers = ['送培单位', '累计送培', '已报到', '平均报到率(%)', '平均到训时间', '评价']
const lines = [headers.join(',')]
this.unitList.forEach(item => {
const cells = [
item.unitName,
item.totalSent,
item.reported,
item.avgRate,
item.avgArriveTime,
this.levelText(item.avgRate)
]
lines.push(cells.map(cell => `"${String(cell === null || cell === undefined ? '' : cell).replace(/"/g, '""')}"`).join(','))
})
const blob = new Blob(['\uFEFF' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `历史报到统计_${new Date().getTime()}.csv`
link.click()
URL.revokeObjectURL(link.href)
}
}
}
</script>
<style scoped>
.class-history-regdata { background: #f5f7fa; }
/* 统计卡 */
.rh-stats { display: flex; background: #fff; border-radius: 10px; padding: 18px 0; margin-bottom: 16px; box-shadow: 0 1px 4px rgba(31,41,71,.06); }
.rh-stat { flex: 1; padding: 0 24px; }
.rh-stat + .rh-stat { border-left: 1px solid #f0f2f7; }
.rh-stat-label { font-size: 12px; color: #8a94a6; margin-bottom: 8px; }
.rh-stat-value { font-size: 26px; font-weight: 700; line-height: 1; }
.rh-stat-value.small { font-size: 20px; }
.rh-stat-value.dark { color: #1f2329; }
.rh-stat-value.green { color: #10b981; }
.rh-unit { font-size: 12px; font-weight: 400; color: #8a94a6; margin-left: 2px; }
/* 中部两栏 */
.rh-middle { display: flex; gap: 16px; margin-bottom: 16px; align-items: stretch; }
.rh-chart-panel { flex: 0 0 48%; min-width: 0; }
.rh-trend-panel { flex: 1; min-width: 0; }
@media (max-width: 1200px) { .rh-middle { flex-direction: column; } .rh-chart-panel { flex: none; } }
/* 通用面板 */
.rh-panel { background: #fff; border-radius: 10px; padding: 16px 20px; box-shadow: 0 1px 4px rgba(31,41,71,.06); }
.rh-panel-head { display: flex; align-items: center; margin-bottom: 12px; }
.rh-panel-title { font-size: 15px; font-weight: 700; color: #1f2329; }
.rh-panel-extra { margin-left: auto; font-size: 12px; color: #8a94a6; }
/* 到训时间分布柱状图 */
.rh-chart { display: flex; align-items: flex-end; gap: 6px; padding: 10px 4px 0; min-height: 220px; }
.rh-bar-col { flex: 1; display: flex; flex-direction: column; align-items: center; min-width: 0; }
.rh-bar-value { font-size: 11px; color: #4e5969; margin-bottom: 4px; }
.rh-bar-track { width: 100%; max-width: 34px; height: 150px; display: flex; align-items: flex-end; justify-content: center; }
.rh-bar { width: 22px; border-radius: 4px 4px 0 0; background: linear-gradient(180deg, #6ea0ff 0%, #3f7dff 100%); transition: height .3s; }
.rh-bar.peak { background: linear-gradient(180deg, #ffb14d 0%, #f79009 100%); }
.rh-bar-label { margin-top: 6px; font-size: 10px; color: #a2aab8; white-space: nowrap; }
/* 批次报到率趋势 */
.rh-trend-body { position: relative; display: flex; align-items: flex-end; justify-content: space-around; gap: 10px; padding: 6px 8px 2px; }
.rh-target-line { position: absolute; left: 0; right: 0; border-top: 1px dashed #f79009; opacity: .55; pointer-events: none; }
.rh-trend-col { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; min-width: 0; }
.rh-trend-value { font-size: 12px; font-weight: 600; color: #1f2329; margin-bottom: 6px; }
.rh-trend-bar { width: 34px; border-radius: 12px; transition: height .3s; }
.rh-trend-label { margin-top: 8px; font-size: 11px; color: #a2aab8; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 单位报到率进度条 */
.rh-bar { height: 8px; border-radius: 4px; background: #eef0f5; overflow: hidden; }
.rh-bar-fill { height: 100%; border-radius: 4px; transition: width .3s; }
/* 分页 */
.rh-pager { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; }
.rh-pager-total { font-size: 12px; color: #8a94a6; }
</style>
@@ -10,7 +10,7 @@
<div v-if="studentProfile" class="profile-chip">
<el-tag effect="dark" type="primary" class="role-tag">参训学员</el-tag>
<span class="user-name">{{ studentProfile.sName || '参训职工' }}</span>
<span class="org-name">({{ studentProfile.orgName || '房建公寓段' }})</span>
<span class="org-name">({{ studentProfile.deptName || '房建公寓段' }})</span>
</div>
</div>
<!-- 403 业务未绑定友好告警 -->
@@ -51,7 +51,7 @@
</div>
<div class="info-item">
<span class="label">所属单位:</span>
<span class="val">{{ studentProfile.orgName || '-' }}</span>
<span class="val">{{ studentProfile.deptName || '-' }}</span>
</div>
<div class="info-item">
<span class="label">联系电话:</span>
@@ -10,7 +10,7 @@
<div v-if="teacherProfile" class="profile-chip">
<el-tag effect="dark" type="success" class="role-tag">认证讲师</el-tag>
<span class="user-name">{{ teacherProfile.tName || '任课教师' }}</span>
<span class="job-title">({{ teacherProfile.jobTitle || '专职讲师' }} / {{ teacherProfile.orgName || '房建公寓段' }})</span>
<span class="job-title">({{ teacherProfile.jobTitle || '专职讲师' }} / {{ teacherProfile.deptName || '房建公寓段' }})</span>
</div>
</div>
<!-- 403 业务未绑定友好告警 -->
@@ -47,7 +47,7 @@
</div>
<div class="info-item">
<span class="label">所属单位:</span>
<span class="val">{{ teacherProfile.orgName || '-' }}</span>
<span class="val">{{ teacherProfile.deptName || '-' }}</span>
</div>
<div class="info-item">
<span class="label">职务/职称:</span>
@@ -206,7 +206,7 @@
<el-table v-loading="studentsLoading" :data="studentList" border stripe style="width: 100%; margin-top: 15px;">
<el-table-column prop="studentId" label="学号/编号" width="120" align="center" />
<el-table-column prop="studentName" label="学员姓名" width="110" align="center" />
<el-table-column prop="orgName" label="选派单位" min-width="140" show-overflow-tooltip />
<el-table-column prop="deptName" label="选派单位" min-width="140" show-overflow-tooltip />
<el-table-column prop="phone" label="联系电话(脱敏)" width="140" align="center" />
<el-table-column label="参训状态" width="110" align="center">
<template slot-scope="scope">
+337
View File
@@ -0,0 +1,337 @@
<template>
<div class="app-container">
<el-form ref="queryForm" :model="queryParams" size="small" :inline="true" v-show="showSearch" label-width="80px">
<el-form-item label="应用标识" prop="appId">
<el-input v-model="queryParams.appId" placeholder="请输入应用标识" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="应用名称" prop="appName">
<el-input v-model="queryParams.appName" placeholder="请输入应用名称" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="应用类型" prop="appType">
<el-select v-model="queryParams.appType" placeholder="请选择应用类型" clearable style="width: 160px">
<el-option v-for="item in appTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width: 120px">
<el-option label="启用" :value="1" />
<el-option label="停用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button v-hasPermi="['train:openApp:add']" type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd">新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button v-hasPermi="['train:openApp:remove']" type="danger" plain icon="el-icon-delete" size="mini" :disabled="multiple" @click="handleDelete">删除</el-button>
</el-col>
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
</el-row>
<el-table v-loading="loading" :data="appList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="50" align="center" />
<el-table-column label="应用标识" align="center" prop="appId" min-width="140" :show-overflow-tooltip="true" />
<el-table-column label="应用名称" align="center" prop="appName" min-width="160" :show-overflow-tooltip="true" />
<el-table-column label="类型" align="center" prop="appType" width="110">
<template slot-scope="scope">
<el-tag size="mini">{{ appTypeLabel(scope.row.appType) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="归属单位" align="center" prop="orgName" min-width="160" :show-overflow-tooltip="true" />
<el-table-column label="密钥" align="center" prop="appSecretMasked" width="120" />
<el-table-column label="限流" align="center" prop="rateLimit" width="90">
<template slot-scope="scope">{{ scope.row.rateLimit }} 次/秒</template>
</el-table-column>
<el-table-column label="实时上报" align="center" prop="realtimeEnabled" width="90">
<template slot-scope="scope">
<el-tag v-if="scope.row.realtimeEnabled === 1" type="success" size="mini">启用</el-tag>
<el-tag v-else type="info" size="mini">关闭</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" align="center" prop="status" width="80">
<template slot-scope="scope">
<el-tag v-if="scope.row.status === 1" type="success" size="mini">启用</el-tag>
<el-tag v-else type="danger" size="mini">停用</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" align="center" prop="createTime" width="160" />
<el-table-column label="操作" align="center" width="300" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button v-hasPermi="['train:openApp:edit']" size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)">修改</el-button>
<el-button v-hasPermi="['train:openApp:edit']" size="mini" type="text" icon="el-icon-key" @click="handleResetSecret(scope.row)">重置密钥</el-button>
<el-button v-hasPermi="['train:openApp:query']" size="mini" type="text" icon="el-icon-s-check" @click="handleScope(scope.row)">授权范围</el-button>
<el-button v-hasPermi="['train:openApp:remove']" size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
<!-- 新增/修改 -->
<el-dialog :title="title" :visible.sync="open" width="620px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
<el-form-item label="应用标识" prop="appId">
<el-input v-model="form.appId" placeholder="如 VR-TRAIN-01" maxlength="64" :disabled="form.appIdDisabled" />
</el-form-item>
<el-form-item label="应用名称" prop="appName">
<el-input v-model="form.appName" placeholder="请输入应用名称" maxlength="100" />
</el-form-item>
<el-form-item label="应用类型" prop="appType">
<el-select v-model="form.appType" placeholder="请选择应用类型" style="width: 100%">
<el-option v-for="item in appTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="归属单位" prop="orgName">
<el-input v-model="form.orgName" placeholder="请输入归属单位" maxlength="100" />
</el-form-item>
<el-form-item label="联系人" prop="contactName">
<el-input v-model="form.contactName" placeholder="请输入联系人" maxlength="50" />
</el-form-item>
<el-form-item label="联系电话" prop="contactPhone">
<el-input v-model="form.contactPhone" placeholder="请输入联系电话" maxlength="20" />
</el-form-item>
<el-form-item label="限流(次/秒)" prop="rateLimit">
<el-input-number v-model="form.rateLimit" :min="1" :max="10000" style="width: 100%" />
</el-form-item>
<el-form-item label="实时上报" prop="realtimeEnabled">
<el-radio-group v-model="form.realtimeEnabled">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">关闭</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" maxlength="500" placeholder="请输入备注" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
</div>
</el-dialog>
<!-- 授权范围 -->
<el-dialog :title="'授权范围 - ' + scopeAppId" :visible.sync="scopeOpen" width="680px" append-to-body>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button type="primary" plain icon="el-icon-plus" size="mini" @click="addScopeRow">添加</el-button>
</el-col>
</el-row>
<el-table :data="scopeList" size="small">
<el-table-column label="范围类型" align="center" width="180">
<template slot-scope="scope">
<el-select v-model="scope.row.scopeType" size="mini" style="width: 100%">
<el-option label="班级" value="CLASS" />
<el-option label="科目" value="SUBJECT" />
</el-select>
</template>
</el-table-column>
<el-table-column label="范围取值" align="center">
<template slot-scope="scope">
<el-input v-model="scope.row.scopeValue" size="mini" placeholder="班级 plan_id 或科目 subjectCode" />
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="90">
<template slot-scope="scope">
<el-button size="mini" type="text" icon="el-icon-delete" @click="removeScopeRow(scope.$index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitScopes">保 存</el-button>
<el-button @click="scopeOpen = false">取 消</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import {
listOpenApp, getOpenApp, addOpenApp, updateOpenApp, delOpenApp,
resetOpenAppSecret, listOpenAppScope, saveOpenAppScope
} from '@/api/train/openApp'
export default {
name: 'TrainOpenApp',
data() {
return {
loading: true,
ids: [],
single: true,
multiple: true,
showSearch: true,
total: 0,
appList: [],
title: '',
open: false,
scopeOpen: false,
scopeAppId: '',
scopeList: [],
appTypeOptions: [
{ value: 'VR', label: 'VR实训' },
{ value: 'INSPECT3D', label: '3D巡检' },
{ value: 'PRACTICE', label: '实操实训' },
{ value: 'CAPSTONE', label: '帽石考核' },
{ value: 'OTHER', label: '其他' }
],
queryParams: {
pageNum: 1,
pageSize: 10,
appId: null,
appName: null,
appType: null,
status: null
},
form: {},
rules: {
appId: [{ required: true, message: '应用标识不能为空', trigger: 'blur' }],
appName: [{ required: true, message: '应用名称不能为空', trigger: 'blur' }],
appType: [{ required: true, message: '应用类型不能为空', trigger: 'change' }]
}
}
},
created() {
this.getList()
},
methods: {
appTypeLabel(value) {
const found = this.appTypeOptions.find(item => item.value === value)
return found ? found.label : value
},
getList() {
this.loading = true
listOpenApp(this.queryParams).then(response => {
this.appList = response.rows
this.total = response.total
this.loading = false
}).catch(() => { this.loading = false })
},
cancel() {
this.open = false
this.reset()
},
reset() {
this.form = {
appId: null,
appIdDisabled: false,
appName: null,
appType: 'VR',
orgName: null,
contactName: null,
contactPhone: null,
rateLimit: 20,
realtimeEnabled: 1,
status: 1,
remark: null
}
this.resetForm('form')
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.resetForm('queryForm')
this.handleQuery()
},
handleSelectionChange(selection) {
this.ids = selection.map(item => item.appId)
this.single = selection.length !== 1
this.multiple = !selection.length
},
handleAdd() {
this.reset()
this.open = true
this.title = '新增外部应用'
},
handleUpdate(row) {
this.reset()
getOpenApp(row.appId).then(response => {
this.form = response.data
this.form.appIdDisabled = true
this.open = true
this.title = '修改外部应用'
})
},
submitForm() {
this.$refs['form'].validate(valid => {
if (!valid) {
return
}
if (this.form.appIdDisabled) {
updateOpenApp(this.form).then(() => {
this.$modal.msgSuccess('修改成功')
this.open = false
this.getList()
})
} else {
addOpenApp(this.form).then(response => {
const secret = response.appSecret
this.$modal.msgSuccess('新增成功')
this.open = false
this.getList()
if (secret) {
this.$alert('应用密钥(仅显示一次,请立即交付接入方):' + secret, '密钥已生成', { confirmButtonText: '我已保存' })
}
})
}
})
},
handleResetSecret(row) {
this.$modal.confirm('是否确认重置应用「' + row.appName + '」的密钥?重置后旧密钥立即失效。').then(() => {
return resetOpenAppSecret(row.appId)
}).then(response => {
this.$alert('新密钥(仅显示一次):' + response.data, '密钥已重置', { confirmButtonText: '我已保存' })
}).catch(() => {})
},
handleScope(row) {
this.scopeAppId = row.appId
listOpenAppScope(row.appId).then(response => {
this.scopeList = response.data || []
this.scopeOpen = true
})
},
addScopeRow() {
this.scopeList.push({ scopeType: 'CLASS', scopeValue: '' })
},
removeScopeRow(index) {
this.scopeList.splice(index, 1)
},
submitScopes() {
const invalid = this.scopeList.some(item => !item.scopeValue)
if (invalid) {
this.$modal.msgWarning('请填写完整的授权范围取值')
return
}
saveOpenAppScope(this.scopeAppId, this.scopeList).then(() => {
this.$modal.msgSuccess('保存成功')
this.scopeOpen = false
})
},
handleDelete(row) {
const ids = row.appId ? [row.appId] : this.ids
if (!ids || ids.length === 0) {
this.$modal.msgWarning('请选择要删除的数据')
return
}
this.$modal.confirm('是否确认删除选中的外部应用?').then(() => {
return delOpenApp(ids.join(','))
}).then(() => {
this.getList()
this.$modal.msgSuccess('删除成功')
}).catch(() => {})
}
}
}
</script>
@@ -47,6 +47,15 @@
:disabled="isPlanLocked"
@click="handleOpenAddModal"
>添加参训人员</el-button>
<el-button
v-hasPermi="['train:enrollment:add']"
type="primary"
plain
icon="el-icon-folder-add"
size="mini"
:disabled="isPlanLocked"
@click="handleOpenBatchAddModal"
>批量添加参训人员</el-button>
<el-button
v-hasPermi="['train:enrollment:import']"
type="success"
@@ -62,6 +71,20 @@
<!-- 参训学员列表 -->
<el-table v-loading="loading" :data="enrollmentList" border style="width: 100%">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="头像" align="center" width="70">
<template slot-scope="scope">
<el-image
:src="avatarUrl(scope.row.avatar)"
:preview-src-list="[avatarUrl(scope.row.avatar)]"
fit="cover"
class="enroll-avatar"
>
<div slot="error" class="image-slot">
<i class="el-icon-picture-outline" />
</div>
</el-image>
</template>
</el-table-column>
<el-table-column label="姓名" align="center" prop="studentName" min-width="100" show-overflow-tooltip />
<el-table-column label="性别" align="center" prop="gender" width="65">
<template slot-scope="scope">
@@ -74,7 +97,7 @@
<span>{{ scope.row.idCardMasked || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="选派单位" align="left" prop="orgName" min-width="140" show-overflow-tooltip />
<el-table-column label="选派单位" align="left" prop="deptName" min-width="140" show-overflow-tooltip />
<el-table-column label="住宿需求" align="center" prop="accommodationRequired" width="95">
<template slot-scope="scope">
<el-tag size="mini" :type="scope.row.accommodationRequired === 1 ? 'warning' : 'info'">
@@ -114,7 +137,7 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="240" fixed="right">
<el-table-column label="操作" align="center" width="320" fixed="right">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:enrollment:query']"
@@ -130,6 +153,13 @@
icon="el-icon-office-building"
@click="handleOpenAccommodation(scope.row)"
>住宿安排</el-button>
<el-button
v-hasPermi="['lock:auth:add']"
size="mini"
type="text"
icon="el-icon-key"
@click="handlePushAuth(scope.row)"
>授权下发</el-button>
<el-button
v-if="scope.row.enrollmentStatus === 0"
v-hasPermi="['train:report:confirm']"
@@ -230,7 +260,7 @@
<span>{{ scope.row.idCardMasked || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="选派单位" prop="orgName" min-width="140" show-overflow-tooltip />
<el-table-column label="选派单位" prop="deptName" min-width="140" show-overflow-tooltip />
</el-table>
</div>
@@ -272,6 +302,117 @@
</div>
</el-dialog>
<!-- 批量添加参训人员对话框:勾选多名学员后统一入班(后台按单个添加流程逐个提交) -->
<el-dialog
title="批量添加参训人员"
:visible.sync="batchAddDialog.open"
width="900px"
append-to-body
@close="closeBatchAddDialog"
>
<el-form :model="batchAddQuery" size="small" :inline="true" label-width="80px">
<el-form-item label="学员姓名">
<el-input
v-model="batchAddQuery.sName"
placeholder="请输入姓名检索"
clearable
style="width: 160px"
@keyup.enter.native="searchBatchStudentPool"
/>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="batchAddQuery.createTimeRange"
type="daterange"
value-format="yyyy-MM-dd"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 250px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="searchBatchStudentPool">检索学员</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetBatchAddQuery">重置</el-button>
</el-form-item>
</el-form>
<el-table
ref="batchAddTable"
v-loading="batchAddDialog.searchLoading"
:data="batchAddDialog.studentCandidates"
height="260"
border
@selection-change="handleBatchSelectionChange"
>
<el-table-column type="selection" width="50" align="center" />
<el-table-column label="姓名" prop="sName" min-width="100">
<template slot-scope="scope">
<span>{{ scope.row.sName || scope.row.studentName || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="性别" prop="sGender" width="65" align="center">
<template slot-scope="scope">
<span>{{ formatGender(scope.row.sGender !== undefined && scope.row.sGender !== null ? scope.row.sGender : scope.row.gender) }}</span>
</template>
</el-table-column>
<el-table-column label="身份证号" prop="idCardMasked" min-width="170" show-overflow-tooltip>
<template slot-scope="scope">
<span>{{ scope.row.idCardMasked || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="选派单位" prop="deptName" min-width="140" show-overflow-tooltip />
<el-table-column label="创建时间" prop="createTime" width="160" align="center">
<template slot-scope="scope">
<span>{{ scope.row.createTime || '-' }}</span>
</template>
</el-table-column>
</el-table>
<el-form :model="batchAddForm" label-width="110px" size="small" style="margin-top: 15px;">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="参训来源">
<el-select v-model="batchAddForm.sourceType" placeholder="请选择参训来源" style="width: 100%">
<el-option label="管理员录入" :value="1" />
<el-option label="统一调训" :value="2" />
<el-option label="内部平台同步" :value="3" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="住宿需求">
<el-radio-group v-model="batchAddForm.accommodationRequired">
<el-radio :label="1">需要住宿</el-radio>
<el-radio :label="0">不住宿</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注说明">
<el-input
v-model="batchAddForm.remark"
type="textarea"
:rows="2"
maxlength="500"
show-word-limit
placeholder="选填入班备注,将应用到本次批量添加的全部学员"
/>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<span class="batch-add-count">已选 {{ batchAddDialog.selected.length }} 人</span>
<el-button
type="primary"
:loading="batchAddDialog.submitting"
:disabled="!batchAddDialog.selected.length"
@click="submitBatchAdd"
>确认添加</el-button>
<el-button @click="batchAddDialog.open = false">取 消</el-button>
</div>
</el-dialog>
<!-- Excel 批量导入对话框 (UC-05) -->
<el-dialog
title="批量导入参训学员 (Excel)"
@@ -321,11 +462,25 @@
<el-descriptions title="基础信息" :column="2" border size="small">
<el-descriptions-item label="参训编号">{{ detailDialog.data.enrollmentId || '-' }}</el-descriptions-item>
<el-descriptions-item label="计划名称">{{ detailDialog.data.planName || '-' }}</el-descriptions-item>
<el-descriptions-item label="学员姓名">{{ detailDialog.data.studentName || '-' }}</el-descriptions-item>
<el-descriptions-item label="学员姓名">
<div style="display: flex; align-items: center; gap: 8px;">
<el-image
:src="avatarUrl(detailDialog.data.avatar)"
:preview-src-list="[avatarUrl(detailDialog.data.avatar)]"
fit="cover"
class="enroll-avatar-sm"
>
<div slot="error" class="image-slot">
<i class="el-icon-picture-outline" />
</div>
</el-image>
<span>{{ detailDialog.data.studentName || '-' }}</span>
</div>
</el-descriptions-item>
<el-descriptions-item label="学员学号">{{ detailDialog.data.studentId || '-' }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ detailDialog.data.gender || '-' }}</el-descriptions-item>
<el-descriptions-item label="身份证号">{{ detailDialog.data.idCardMasked || '-' }}</el-descriptions-item>
<el-descriptions-item label="选派单位" :span="2">{{ detailDialog.data.orgName || '-' }}</el-descriptions-item>
<el-descriptions-item label="选派单位" :span="2">{{ detailDialog.data.deptName || '-' }}</el-descriptions-item>
<el-descriptions-item label="参训状态">
<el-tag size="mini" :type="statusTagType(detailDialog.data.enrollmentStatus)">
{{ statusLabel(detailDialog.data.enrollmentStatus) }}
@@ -587,6 +742,53 @@
<el-button @click="accommodationDialog.open = false">关 闭</el-button>
</div>
</el-dialog>
<!-- 门禁授权下发对话框(复用 iot /lock/auth 新增即下发) -->
<el-dialog title="门禁授权下发" :visible.sync="pushAuthDialog.open" width="600px" append-to-body>
<el-descriptions :column="2" border size="small" style="margin-bottom: 16px;">
<el-descriptions-item label="学员姓名">{{ pushAuthDialog.studentName || '-' }}</el-descriptions-item>
<el-descriptions-item label="学号">{{ pushAuthDialog.studentId || '-' }}</el-descriptions-item>
<el-descriptions-item label="宿舍" :span="2">{{ (pushAuthDialog.dormBuilding || '') + ' ' + (pushAuthDialog.dormRoom || '未分配') }}</el-descriptions-item>
</el-descriptions>
<el-form ref="pushForm" :model="pushForm" :rules="pushRules" label-width="90px">
<el-form-item label="授权类型" prop="authType">
<el-select v-model="pushForm.authType" placeholder="请先选择授权类型" style="width: 100%" @change="pushChangeAuthType">
<el-option v-for="dict in dict.type.lock_auth_type" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</el-form-item>
<el-form-item v-if="pushIsFaceAuth" label="人员" prop="personNo">
<el-select v-model="pushForm.personNo" placeholder="请选择人员(来源:人脸库)" filterable style="width: 100%" @change="pushChangePerson">
<el-option v-for="item in pushPersonOptions" :key="item.personNo" :label="item.personName ? item.personName + '(' + item.personNo + ')' : item.personNo" :value="item.personNo" />
</el-select>
</el-form-item>
<el-form-item v-else label="人员编号" prop="personNo">
<el-input v-model="pushForm.personNo" :placeholder="pushPersonNoPlaceholder" maxlength="32" style="width: 100%" @change="pushChangePersonNo" />
</el-form-item>
<el-form-item label="授权门锁" prop="deviceId">
<el-select v-model="pushForm.deviceId" placeholder="请选择门锁(决定下发分组)" filterable style="width: 100%" @change="pushChangeDevice">
<el-option v-for="item in pushDeviceOptions" :key="item.deviceId" :label="item.deviceName ? item.deviceName + '(' + item.deviceSn + ')' : item.deviceSn" :value="item.deviceId" />
</el-select>
</el-form-item>
<el-form-item label="授权内容" prop="authValue">
<el-input v-if="pushIsFaceAuth" v-model="pushForm.authValue" type="textarea" :rows="2" :placeholder="pushAuthValuePlaceholder" />
<el-input v-else v-model="pushForm.authValue" :placeholder="pushAuthValuePlaceholder" maxlength="128" style="width: 100%" />
<div v-if="pushIsFaceAuth" style="margin-top: 6px">
<el-button size="mini" type="primary" :loading="extractLoading" icon="el-icon-user-solid" @click="handlePushExtractFace">提取人脸特征</el-button>
<span style="margin-left: 8px; color: #909399; font-size: 12px;">人脸授权内容取自人脸库特征,需先选择人员并选定门锁(确定算法版本)</span>
</div>
</el-form-item>
<el-form-item label="生效时间" prop="beginTime">
<el-date-picker clearable v-model="pushForm.beginTime" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" placeholder="请选择生效开始时间" style="width: 100%" />
</el-form-item>
<el-form-item label="失效时间" prop="endTime">
<el-date-picker clearable v-model="pushForm.endTime" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" placeholder="请选择生效结束时间" style="width: 100%" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="pushSubmit">确 定 下 发</el-button>
<el-button @click="pushCancel">取 消</el-button>
</div>
</el-dialog>
</div>
</template>
@@ -609,10 +811,16 @@ import {
checkOutAccommodation,
delAccommodation
} from '@/api/train/accommodation'
import { listDormitory } from '@/api/train/dormitory'
import { listDormitory, getDormitory } from '@/api/train/dormitory'
import { getStudentAvatar } from '@/utils/avatar'
import { addAuth } from '@/api/iot/auth'
import { listDevice } from '@/api/iot/device'
import { listFace, extract } from '@/api/iot/face'
import { getPlan } from '@/api/train/plan'
export default {
name: 'EnrollmentPanel',
dicts: ['lock_auth_type'],
props: {
planId: {
type: [String, Number],
@@ -665,6 +873,23 @@ export default {
sourceType: [{ required: true, message: '请选择参训来源', trigger: 'change' }],
accommodationRequired: [{ required: true, message: '请选择住宿需求', trigger: 'change' }]
},
// 批量添加参训人员对话框(与单个添加相互独立,互不影响)
batchAddDialog: {
open: false,
searchLoading: false,
submitting: false,
studentCandidates: [],
selected: []
},
batchAddQuery: {
sName: '',
createTimeRange: []
},
batchAddForm: {
sourceType: 1,
accommodationRequired: 1,
remark: ''
},
// 批量导入
batchDialog: {
open: false,
@@ -697,18 +922,62 @@ export default {
dormId: [{ required: true, message: '请选择目标宿舍房间', trigger: 'change' }],
bedNo: [{ required: true, message: '请输入床位号', trigger: 'blur' }]
}
}
},
// 授权下发对话框
pushAuthDialog: {
open: false,
studentName: '',
studentId: '',
dormBuilding: '',
dormRoom: ''
},
pushForm: {
authType: '01',
personNo: undefined,
personName: undefined,
deviceId: undefined,
deviceSn: undefined,
authValue: undefined,
beginTime: undefined,
endTime: undefined,
remark: undefined
},
pushRules: {
personNo: [{ validator: this.pushValidatePersonNo, trigger: 'change' }],
deviceId: [{ required: true, message: '授权门锁不能为空', trigger: 'blur' }],
authType: [{ required: true, message: '授权类型不能为空', trigger: 'change' }],
authValue: [{ validator: this.pushValidateAuthValue, trigger: 'change' }]
},
pushDeviceOptions: [],
pushPersonOptions: [],
extractLoading: false,
AUTH_TYPE_FACE: '01',
AUTH_TYPE_PASSWORD: '02'
}
},
computed: {
isPlanLocked() {
return this.planStatus === 2 || this.planStatus === 3
},
pushIsFaceAuth() {
return this.pushForm && this.pushForm.authType === this.AUTH_TYPE_FACE
},
pushAuthValuePlaceholder() {
if (this.pushIsFaceAuth) return '请点击「提取人脸特征」自动填充'
if (this.pushForm && this.pushForm.authType === this.AUTH_TYPE_PASSWORD) return '请输入开门密码(将明文下发给门锁)'
return '指纹特征 / IC卡号'
},
pushPersonNoPlaceholder() {
if (this.pushForm && this.pushForm.authType === this.AUTH_TYPE_PASSWORD) {
return '以 BA 开头的 8 位编号,如 BA000001(或直接录入学员学号)'
}
return '请输入人员编号(可直接录入学员学号)'
},
selectedStudentText() {
const s = this.addDialog.selectedStudent
if (!s) return ''
const name = (s.sName || s.studentName || '').trim()
const org = s.orgName ? ` - ${s.orgName}` : ''
const org = s.deptName ? ` - ${s.deptName}` : ''
return name ? `${name}${org}` : '-'
},
selectedDormInfo() {
@@ -748,6 +1017,182 @@ export default {
const map = { 0: 'info', 1: 'success', 2: 'warning', 3: 'danger', 4: 'primary' }
return map[status] || 'info'
},
/** 学员头像地址解析:空值回退学员工装默认头像;相对路径(如 /profile/upload/...)自动补 VUE_APP_BASE_API 前缀,已是 http(s)/data 则原样返回 */
avatarUrl(avatar) {
return getStudentAvatar(avatar)
},
// ===== 授权下发(复用 iot /lock/auth 新增即下发) =====
loadPushOptions(row) {
listDevice({ pageNum: 1, pageSize: 500 }).then(res => {
this.pushDeviceOptions = (res.rows || [])
// 门锁默认绑定本宿舍 dormId 对应的门锁:先取当前住宿的 dormId → 查宿舍 → lockDeviceSn
this.prefillPushLock(row)
})
listFace({ pageNum: 1, pageSize: 500 }).then(res => {
this.pushPersonOptions = (res.rows || [])
})
},
prefillPushLock(row) {
if (!row) return
// 优先:直接用列表已返回的门锁 deviceId 精确预填(无需反查 SN 映射)
if (row.lockDeviceId) {
const dev = this.pushDeviceOptions.find(d => String(d.deviceId) === String(row.lockDeviceId))
if (dev) {
this.$set(this.pushForm, 'deviceId', dev.deviceId)
return
}
}
// 兜底:按 dormId 精确绑定 → 宿舍 → lockDeviceSn(或 building/room 反查)
const byDormId = () => {
if (!row.enrollmentId) return Promise.reject()
return getActiveAccommodation(row.enrollmentId)
.then(accRes => {
const acc = accRes && accRes.data
const dormId = acc && acc.dormId
if (!dormId) return Promise.reject()
return getDormitory(dormId)
})
.then(dres => {
const lockSn = dres && dres.data && dres.data.lockDeviceSn
this.matchPushLock(lockSn)
})
}
byDormId().catch(() => {
// 兜底:用 building/room 反查 dormitory 的 lockDeviceSn
if (row.dormBuilding && row.dormRoom) {
listDormitory({ pageNum: 1, pageSize: 500 }).then(dres => {
const dorm = (dres.rows || []).find(d => d.dormBuilding === row.dormBuilding && d.dormRoom === row.dormRoom)
this.matchPushLock(dorm && dorm.lockDeviceSn)
})
}
})
},
matchPushLock(lockSn) {
if (!lockSn) return
const dev = this.pushDeviceOptions.find(d => String(d.deviceSn) === String(lockSn))
if (dev) this.$set(this.pushForm, 'deviceId', dev.deviceId)
},
// 将计划开班/结课时间规范为 datetime picker 需要的 yyyy-MM-dd HH:mm:ss 格式
normalizeDateTime(val) {
if (!val) return undefined
let s = String(val).replace('T', ' ')
if (s.length === 10) s = s + ' 00:00:00'
return s
},
handlePushAuth(row) {
this.pushReset()
this.pushAuthDialog.studentName = row.studentName
this.pushAuthDialog.studentId = row.studentId
this.pushAuthDialog.dormBuilding = row.dormBuilding
this.pushAuthDialog.dormRoom = row.dormRoom
// 默认人员即为当前点击行的学员(学号作为人员编号,姓名作为人员名称)
this.$set(this.pushForm, 'personNo', row.studentId != null ? String(row.studentId) : undefined)
this.$set(this.pushForm, 'personName', row.studentName)
// 默认生效/失效时间 = 本开班计划的开班/结课时间
if (this.planId) {
getPlan(this.planId).then(res => {
const plan = (res && res.data) || {}
this.$set(this.pushForm, 'beginTime', this.normalizeDateTime(plan.startTime))
this.$set(this.pushForm, 'endTime', this.normalizeDateTime(plan.endTime))
}).catch(() => {})
}
this.pushAuthDialog.open = true
this.loadPushOptions(row)
},
pushChangePerson(personNo) {
const person = this.pushPersonOptions.find(item => item.personNo === personNo)
this.pushForm.personName = person ? person.personName : undefined
this.pushSyncFaceFeature()
},
pushChangeDevice(deviceId) {
const device = this.pushDeviceOptions.find(item => item.deviceId === deviceId)
this.pushForm.deviceSn = device ? device.deviceSn : undefined
this.pushSyncFaceFeature()
},
pushChangePersonNo() {
this.$set(this.pushForm, 'personName', this.pushAuthDialog.studentName)
},
pushChangeAuthType() {
this.$set(this.pushForm, 'personNo', undefined)
this.$set(this.pushForm, 'personName', undefined)
this.$set(this.pushForm, 'authValue', undefined)
this.pushSyncFaceFeature()
},
pushValidatePersonNo(rule, value, callback) {
if (!value) {
callback(new Error(this.pushIsFaceAuth ? '请选择人员' : '请输入人员编号'))
return
}
callback()
},
currentPushPerson() {
return this.pushPersonOptions.find(item => item.personNo === this.pushForm.personNo)
},
pushSyncFaceFeature(force) {
if (this.pushForm.authType !== this.AUTH_TYPE_FACE || !this.pushForm.personNo) return
if (!force && this.pushForm.authValue) return
const person = this.currentPushPerson()
if (!person || !person.faceId) return
if (!this.pushForm.deviceId) {
this.$modal.msgWarning('请先选择授权门锁,以确定人脸算法版本')
return
}
this.extractLoading = true
extract(person.faceId, this.pushForm.deviceId).then(response => {
if (response && response.data) {
this.$set(this.pushForm, 'authValue', response.data)
this.$refs.pushForm && this.$refs.pushForm.validateField('authValue')
this.$modal.msgSuccess('人脸特征已提取到授权内容')
} else {
this.$modal.msgWarning((response && response.msg) || '特征提取失败')
}
}).catch(() => {}).then(() => { this.extractLoading = false })
},
handlePushExtractFace() {
this.pushSyncFaceFeature(true)
},
pushValidateAuthValue(rule, value, callback) {
if (!value) {
callback(new Error(this.pushIsFaceAuth ? '人脸授权内容不能为空,请点击「提取人脸特征」' : '授权内容不能为空'))
return
}
callback()
},
pushReset() {
this.pushForm = {
authType: '01',
personNo: undefined,
personName: undefined,
deviceId: undefined,
deviceSn: undefined,
authValue: undefined,
beginTime: undefined,
endTime: undefined,
remark: undefined
}
this.pushDeviceOptions = []
this.pushPersonOptions = []
this.extractLoading = false
this.resetForm('pushForm')
},
pushSubmit() {
this.$refs.pushForm.validate(valid => {
if (!valid) return
const payload = Object.assign({}, this.pushForm)
if (!payload.personName && this.pushAuthDialog.studentName) {
payload.personName = this.pushAuthDialog.studentName
}
addAuth(payload).then(() => {
this.$modal.msgSuccess('授权意图已记录,请查询设备确认状态')
this.pushAuthDialog.open = false
this.getList()
}).catch(err => this.$modal.msgError((err && err.msg) || '授权下发失败'))
})
},
pushCancel() {
this.pushAuthDialog.open = false
this.pushReset()
},
canRemove(row) {
// 班级未结束且处于待报到状态允许开班前移除
return this.planStatus === 0 && row.enrollmentStatus === 0
@@ -774,14 +1219,18 @@ export default {
},
// ---- 查看参训档案全景 ----
handleViewDetail(row) {
console.log('row', row)
this.detailDialog.loading = true
this.detailDialog.open = true
Promise.all([
getEnrollmentDetail(row.enrollmentId),
getActiveAccommodation(row.enrollmentId).catch(() => ({ data: null }))
]).then(([detailRes, accRes]) => {
const data = Object.assign({}, detailRes.data || row)
// 详情 VO 不含 avatar 等字段,以列表行 row 为基座合并,确保头像等前端已有字段不会丢失(避免回退为默认工装图)
const data = Object.assign({}, row, detailRes.data || {})
console.log('accRes.data', accRes)
if (accRes && accRes.data) {
console.log('accRes.data', accRes.data)
data.accommodation = {
building: accRes.data.dormBuilding,
room: accRes.data.dormRoom,
@@ -894,6 +1343,88 @@ export default {
this.addDialog.selectedStudentId = null
this.addDialog.selectedStudent = null
},
// ---- 批量添加参训人员 ----
/** 打开批量添加窗口:重置条件与勾选,默认拉取全部学员 */
handleOpenBatchAddModal() {
this.batchAddQuery = { sName: '', createTimeRange: [] }
this.batchAddForm = { sourceType: 1, accommodationRequired: 1, remark: '' }
this.batchAddDialog.studentCandidates = []
this.batchAddDialog.selected = []
this.batchAddDialog.open = true
this.searchBatchStudentPool()
},
/** 重置检索条件后重新拉取 */
resetBatchAddQuery() {
this.batchAddQuery = { sName: '', createTimeRange: [] }
this.searchBatchStudentPool()
},
/** 检索学员库:按姓名 + 建档时间范围(train_student.create_time)筛选 */
searchBatchStudentPool() {
this.batchAddDialog.searchLoading = true
const keyword = (this.batchAddQuery.sName || '').trim()
const range = this.batchAddQuery.createTimeRange || []
listStudent({
pageNum: 1,
pageSize: 1000,
sName: keyword || undefined,
beginCreateTime: range[0] ? range[0] + ' 00:00:00' : undefined,
endCreateTime: range[1] ? range[1] + ' 23:59:59' : undefined
}).then(res => {
this.batchAddDialog.studentCandidates = res.rows || []
this.batchAddDialog.selected = []
this.$nextTick(() => {
if (this.$refs.batchAddTable) {
this.$refs.batchAddTable.clearSelection()
}
})
}).finally(() => {
this.batchAddDialog.searchLoading = false
})
},
handleBatchSelectionChange(rows) {
this.batchAddDialog.selected = rows || []
},
/** 逐个调用单条入班接口:单个失败不影响其余学员,最后统一汇总结果 */
submitBatchAdd() {
const rows = (this.batchAddDialog.selected || []).slice()
if (!rows.length) {
this.$modal.msgWarning('请先勾选需要添加的学员')
return
}
this.batchAddDialog.submitting = true
let successCount = 0
const failures = []
const buildTask = row => () => addPlanEnrollment(this.planId, {
studentId: row.sId || row.studentId,
sourceType: this.batchAddForm.sourceType,
accommodationRequired: this.batchAddForm.accommodationRequired,
remark: this.batchAddForm.remark
}).then(() => {
successCount += 1
}).catch(error => {
const name = row.sName || row.studentName || row.sId
failures.push(name + ':' + ((error && error.message) || '添加失败'))
})
rows.map(buildTask).reduce((chain, task) => chain.then(task), Promise.resolve()).then(() => {
this.batchAddDialog.submitting = false
if (successCount > 0) {
this.getList()
}
if (!failures.length) {
this.batchAddDialog.open = false
this.$modal.msgSuccess('成功添加 ' + successCount + ' 名参训人员')
return
}
const detail = failures.slice(0, 5).join(';')
this.$modal.msgWarning(
'成功 ' + successCount + ' 人,失败 ' + failures.length + ' 人。' + detail + (failures.length > 5 ? ' 等' : '')
)
})
},
closeBatchAddDialog() {
this.batchAddDialog.selected = []
this.batchAddDialog.studentCandidates = []
},
// ---- Excel 批量导入 ----
handleOpenBatchModal() {
this.batchDialog.file = null
@@ -1095,4 +1626,34 @@ export default {
.enrollment-panel {
padding: 5px 0;
}
.enroll-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: #f5f7fa;
overflow: hidden;
}
.enroll-avatar-sm {
width: 32px;
height: 32px;
border-radius: 50%;
background: #f5f7fa;
overflow: hidden;
}
/* 批量添加:底部已选人数提示 */
.batch-add-count {
float: left;
line-height: 32px;
font-size: 12px;
color: #909399;
}
.enroll-avatar ::v-deep .image-slot,
.enroll-avatar-sm ::v-deep .image-slot {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #c0c4cc;
}
</style>
@@ -117,7 +117,7 @@
:disabled="isPlanLocked"
@click.stop="handleEditLesson(lesson)"
>修改</el-button>
<el-button
<!-- <el-button
v-hasPermi="['train:lesson:remove']"
size="mini"
type="text"
@@ -125,7 +125,7 @@
style="color: #F56C6C;"
:disabled="isPlanLocked"
@click.stop="handleRemoveLesson(lesson)"
>删除</el-button>
>删除</el-button>-->
</div>
</div>
@@ -812,7 +812,7 @@ export default {
if (t.disabled) {
return `${name}(已停用)`
}
return `${name} - ${t.workDept || t.orgName || '基地'}`
return `${name} - ${t.workDept || t.deptName || '基地'}`
},
// 编辑历史课次时,原授课教师若已停用则不在候选列表中,
// 临时补一条禁用选项保证原教师名可见且不可被重新选中,绝不自动换人
+252 -116
View File
@@ -1,30 +1,45 @@
<template>
<div class="app-container">
<!-- 顶部静态布局:标题栏 -->
<div class="page-topbar">
<div class="topbar-left">
<h2 class="topbar-title">开班计划</h2>
<span class="topbar-sub">在册 {{ total }} 个</span>
</div>
<div class="topbar-actions">
<el-button icon="el-icon-download" size="small" @click="handleExport">导出开班计划台账</el-button>
<el-button type="primary" icon="el-icon-plus" size="small" @click="handleAdd">新增开班计划</el-button>
</div>
</div>
<!-- 顶部静态布局:筛选卡片 -->
<div class="topbar-filter">
<el-form
v-show="showSearch"
ref="queryForm"
:model="queryParams"
size="small"
:inline="true"
label-width="88px"
label-width="72px"
>
<el-form-item label="培训班名称" prop="planName">
<el-input v-model="queryParams.planName" placeholder="请输入培训班名称" clearable @keyup.enter.native="handleQuery" />
<el-form-item label="计划名称" prop="planName">
<el-input v-model="queryParams.planName" placeholder="请输入培训班名称" clearable style="width: 160px" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="计划状态" prop="planStatus">
<el-select v-model="queryParams.planStatus" placeholder="全部状态" clearable>
<el-select v-model="queryParams.planStatus" placeholder="全部状态" clearable style="width: 130px">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="开班时间">
<el-date-picker
v-model="startTimeRange"
type="datetimerange"
value-format="yyyy-MM-dd HH:mm:ss"
type="daterange"
value-format="yyyy-MM-dd"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
start-placeholder="开始日期"
end-placeholder="结束日期"
clearable
style="width: 250px"
/>
</el-form-item>
<el-form-item>
@@ -32,8 +47,8 @@
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
</div>
<!-- <el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
v-hasPermi="['train:plan:add']"
@@ -45,21 +60,45 @@
>新增计划</el-button>
</el-col>
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
</el-row>
</el-row>-->
<el-table v-loading="loading" :data="planList">
<el-table-column label="培训班名称" align="left" prop="planName" min-width="240" show-overflow-tooltip />
<el-table-column label="状态" align="center" prop="planStatus" width="100">
<el-table-column label="序号" align="center" type="index" width="60" :index="indexMethod" />
<!-- <el-table-column label="计划编号" align="left" prop="planId" min-width="200" show-overflow-tooltip />
-->
<el-table-column label="计划名称" align="left" prop="planName" min-width="200" show-overflow-tooltip />
<el-table-column label="班级名称" align="left" prop="className" min-width="200" show-overflow-tooltip />
<el-table-column label="专业" align="left" prop="classMajor" min-width="120">
<template slot-scope="scope">
<span>{{ majorLabel(scope.row.classMajor) }}</span>
</template>
</el-table-column>
<el-table-column label="开班时间" align="center" prop="startTime" width="100">
<template slot-scope="scope">
<span>{{ formatDate(scope.row.startTime) }}</span>
</template>
</el-table-column>
<el-table-column label="结束时间" align="center" prop="endTime" width="100">
<template slot-scope="scope">
<span>{{ formatDate(scope.row.endTime) }}</span>
</template>
</el-table-column>
<el-table-column label="总学时" align="left" prop="totalClassHours" show-overflow-tooltip />
<el-table-column label="计划人数" align="center" prop="recruitNum" />
<el-table-column label="状态" align="center" prop="planStatus">
<template slot-scope="scope">
<el-tag :type="statusTagType(scope.row.planStatus)">{{ statusLabel(scope.row.planStatus) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="开班时间" align="center" prop="startTime" width="165" />
<el-table-column label="结束时间" align="center" prop="endTime" width="165" />
<el-table-column label="报到截止" align="center" prop="reportDeadline" width="165" />
<el-table-column label="培训地点" align="left" prop="trainAddress" min-width="180" show-overflow-tooltip />
<el-table-column label="计划人数" align="center" prop="recruitNum" width="90" />
<el-table-column label="课程数" align="center" prop="courseCount" width="90">
<!-- <el-table-column label="报到截止" align="center" prop="reportDeadline" />-->
<el-table-column label="培训地点" align="left" prop="trainAddress" min-width="130" show-overflow-tooltip />
<!-- <el-table-column label="负责人" align="center" prop="manager" />
<el-table-column label="主办部门" align="center" prop="hostDeptId" min-width="130">
<template slot-scope="scope">
<span>{{ findDeptName(scope.row.hostDeptId) || '-' }}</span>
</template>
</el-table-column>-->
<!-- <el-table-column label="课程数" align="center" prop="courseCount">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:plan:query']"
@@ -68,7 +107,7 @@
>{{ scope.row.courseCount || 0 }}</el-button>
</template>
</el-table-column>
<el-table-column label="参训人数" align="center" prop="enrolledCount" width="90">
<el-table-column label="参训人数" align="center" prop="enrolledCount">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:enrollment:list']"
@@ -76,8 +115,9 @@
@click="handleOpenPlanDetail(scope.row, 'enrollment')"
>{{ scope.row.enrolledCount || 0 }}</el-button>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="340" fixed="right">
</el-table-column>-->
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="320" fixed="right">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:plan:query']"
@@ -86,7 +126,7 @@
icon="el-icon-data-board"
@click="handleOpenPlanDetail(scope.row, 'course')"
>计划详情</el-button>
<el-button
<!-- <el-button
v-hasPermi="['train:plan:query']"
size="mini"
type="text"
@@ -99,7 +139,7 @@
type="text"
icon="el-icon-date"
@click="handleGoCheckin(scope.row)"
>考勤</el-button>
>考勤</el-button>-->
<el-button
v-hasPermi="['train:plan:edit']"
size="mini"
@@ -116,6 +156,15 @@
:disabled="isLocked(scope.row.planStatus)"
@click="handleComplete(scope.row)"
>结束计划</el-button>
<el-button
v-hasPermi="['train:plan:remove']"
size="mini"
type="text"
icon="el-icon-delete"
style="color: #F56C6C;"
:disabled="scope.row.planStatus !== 0"
@click="handleDelete(scope.row)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -131,27 +180,37 @@
<!-- 创建/编辑培训计划对话框 -->
<el-dialog :title="title" :visible.sync="open" width="660px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="110px">
<el-form-item label="培训班名称" prop="planName">
<el-input v-model="form.planName" maxlength="150" show-word-limit placeholder="请输入培训班名称" />
<el-form-item label="培训计划名称" prop="planName">
<el-input v-model="form.planName" maxlength="150" show-word-limit placeholder="请输入培训计划名称" />
</el-form-item>
<el-form-item label="班级名称" prop="className">
<el-input v-model="form.className" maxlength="150" show-word-limit placeholder="请输入班级名称" />
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="开班时间" prop="startTime">
<el-date-picker v-model="form.startTime" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" placeholder="请选择开班时间" style="width: 100%" />
<el-date-picker v-model="form.startTime" type="date" value-format="yyyy-MM-dd" placeholder="请选择开班日期" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="结束时间" prop="endTime">
<el-date-picker v-model="form.endTime" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" placeholder="请选择结束时间" style="width: 100%" />
<el-date-picker v-model="form.endTime" type="date" value-format="yyyy-MM-dd" placeholder="请选择结束日期" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="报到开始" prop="reportStarttime">
<el-date-picker v-model="form.reportStarttime" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" placeholder="请选择报到开始时间" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="报到截止" prop="reportDeadline">
<el-date-picker v-model="form.reportDeadline" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" placeholder="请选择报到截止时间" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="计划人数" prop="recruitNum">
<el-input-number v-model="form.recruitNum" :min="1" :precision="0" style="width: 100%" />
@@ -161,6 +220,26 @@
<el-form-item label="培训地点" prop="trainAddress">
<el-input v-model="form.trainAddress" maxlength="200" show-word-limit placeholder="请输入培训地点" />
</el-form-item>
<el-form-item label="所属专业" prop="classMajor">
<el-select v-model="form.classMajor" placeholder="请选择专业" clearable style="width: 100%">
<el-option v-for="opt in majorOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="总学时" prop="totalClassHours">
<el-input-number v-model="form.totalClassHours" :min="0" :precision="0" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="负责人" prop="manager">
<el-input v-model="form.manager" maxlength="50" placeholder="请输入负责人" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="主办部门" prop="hostDeptId">
<treeselect v-model="form.hostDeptId" :options="deptOptions" :normalizer="normalizer" :show-count="true" placeholder="请选择主办部门" />
</el-form-item>
<el-form-item label="报名条件" prop="applyCondition">
<el-input v-model="form.applyCondition" type="textarea" :rows="3" placeholder="请输入报名条件" />
</el-form-item>
@@ -281,113 +360,52 @@
<el-dialog
:title="`培训计划详情 - ${planDetailDialog.plan.planName || ''}`"
:visible.sync="planDetailDialog.open"
width="1050px"
width="650px"
append-to-body
top="5vh"
>
<!-- 计划基础信息概览 -->
<el-descriptions :column="3" border size="small" style="margin-bottom: 15px;">
<el-descriptions-item label="计划编号">{{ planDetailDialog.plan.planId }}</el-descriptions-item>
<el-descriptions-item label="培训班名称">{{ planDetailDialog.plan.planName }}</el-descriptions-item>
<el-descriptions :column="1" border size="small" style="margin-bottom: 15px;">
<!-- <el-descriptions-item label="计划编号">{{ planDetailDialog.plan.planId }}</el-descriptions-item>-->
<el-descriptions-item label="培训计划名称">{{ planDetailDialog.plan.planName }}</el-descriptions-item>
<el-descriptions-item label="班级名称">{{ planDetailDialog.plan.className }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag size="mini" :type="statusTagType(planDetailDialog.plan.planStatus)">
{{ statusLabel(planDetailDialog.plan.planStatus) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="开班时间">{{ planDetailDialog.plan.startTime }}</el-descriptions-item>
<el-descriptions-item label="结业时间">{{ planDetailDialog.plan.endTime }}</el-descriptions-item>
<el-descriptions-item label="开班时间">{{ formatDate(planDetailDialog.plan.startTime) }}</el-descriptions-item>
<el-descriptions-item label="结业时间">{{ formatDate(planDetailDialog.plan.endTime) }}</el-descriptions-item>
<el-descriptions-item label="报到开始">{{ planDetailDialog.plan.reportStarttime }}</el-descriptions-item>
<el-descriptions-item label="报到截止">{{ planDetailDialog.plan.reportDeadline }}</el-descriptions-item>
<el-descriptions-item label="培训地点" :span="2">{{ planDetailDialog.plan.trainAddress || '-' }}</el-descriptions-item>
<el-descriptions-item label="总学时">{{ planDetailDialog.plan.totalClassHours != null ? planDetailDialog.plan.totalClassHours + ' 学时' : '-' }}</el-descriptions-item>
<el-descriptions-item label="负责人">{{ planDetailDialog.plan.manager || '-' }}</el-descriptions-item>
<el-descriptions-item label="主办部门">{{ findDeptName(planDetailDialog.plan.hostDeptId) || '-' }}</el-descriptions-item>
<el-descriptions-item label="计划人数">{{ planDetailDialog.plan.recruitNum }} 人</el-descriptions-item>
</el-descriptions>
<!-- 核心三要素 Tab 切换 -->
<el-tabs v-model="planDetailDialog.activeTab">
<!-- Tab 1: 课程 -->
<el-tab-pane label="本期课程" name="course">
<div style="margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center;">
<span style="font-weight: bold; font-size: 14px;">已配置课程 ({{ courseDialog.list.length }} 门)</span>
<el-button
v-hasPermi="['train:plan:edit']"
type="primary"
size="mini"
icon="el-icon-plus"
:disabled="isLocked(planDetailDialog.plan.planStatus)"
@click="handleOpenCourseSelector"
>引入课程</el-button>
</div>
<el-table v-loading="courseDialog.loading" :data="courseDialog.list" border style="width: 100%">
<el-table-column label="序号" type="index" width="55" align="center" />
<el-table-column label="课程编码" prop="cCode" min-width="120" show-overflow-tooltip />
<el-table-column label="课程名称" prop="cName" min-width="180" show-overflow-tooltip />
<el-table-column label="类型" prop="cType" width="80" align="center" />
<el-table-column label="学时" prop="cDuration" width="70" align="center" />
<el-table-column label="排序号" prop="sortNo" width="70" align="center" />
<el-table-column label="配置说明" prop="remark" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" align="center" width="140" fixed="right">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:lesson:add']"
size="mini"
type="text"
icon="el-icon-date"
:disabled="isLocked(planDetailDialog.plan.planStatus)"
@click="handleGoScheduleCourse(scope.row)"
>去排课</el-button>
<el-button
v-hasPermi="['train:plan:edit']"
size="mini"
type="text"
icon="el-icon-delete"
style="color: #F56C6C;"
:disabled="isLocked(planDetailDialog.plan.planStatus)"
@click="handleRemoveCourse(scope.row)"
>移除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- Tab 2: 参训人员 -->
<el-tab-pane label="参训人员" name="enrollment">
<enrollment-panel
v-if="planDetailDialog.open && planDetailDialog.plan.planId"
:plan-id="planDetailDialog.plan.planId"
:plan-status="planDetailDialog.plan.planStatus"
/>
</el-tab-pane>
<!-- Tab 3: 课程表 -->
<el-tab-pane label="课程表" name="timetable">
<timetable-panel
v-if="planDetailDialog.open && planDetailDialog.plan.planId"
ref="timetablePanel"
:plan-id="planDetailDialog.plan.planId"
:plan-status="planDetailDialog.plan.planStatus"
/>
</el-tab-pane>
</el-tabs>
<div slot="footer" class="dialog-footer">
<el-button @click="planDetailDialog.open = false">关 闭</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { addPlan, completePlan, getPlan, listPlan, updatePlan } from '@/api/train/plan'
import { addPlan, completePlan, delPlan, getPlan, listPlan, updatePlan } from '@/api/train/plan'
import { listPlanCourses, configurePlanCourses, removePlanCourse } from '@/api/train/planCourse'
import { listCourse } from '@/api/train/course'
import EnrollmentPanel from './components/EnrollmentPanel.vue'
import TimetablePanel from './components/TimetablePanel.vue'
import { deptTreeSelect } from '@/api/system/user'
import Treeselect from '@riophae/vue-treeselect'
import '@riophae/vue-treeselect/dist/vue-treeselect.css'
export default {
name: 'Plan',
components: {
EnrollmentPanel,
TimetablePanel
TimetablePanel,
Treeselect
},
dicts: ['train_class_major'],
data() {
return {
loading: true,
@@ -395,6 +413,7 @@ export default {
total: 0,
planList: [],
startTimeRange: [],
deptOptions: undefined,
title: '',
open: false,
statusOptions: [
@@ -415,6 +434,7 @@ export default {
startTime: [{ required: true, message: '开班时间不能为空', trigger: 'change' }],
endTime: [{ required: true, message: '结束时间不能为空', trigger: 'change' }],
reportDeadline: [{ required: true, message: '报到截止时间不能为空', trigger: 'change' }],
reportStarttime: [{ required: true, message: '报到开始时间不能为空', trigger: 'change' }],
recruitNum: [{ required: true, message: '计划人数不能为空', trigger: 'change' }]
},
// 本期课程配置对话框
@@ -451,14 +471,29 @@ export default {
},
created() {
this.getList()
this.getDeptTree()
},
computed: {
/** 专业下拉备选项:字典值转数字,与后端 Integer 字段对齐 */
majorOptions() {
return (this.dict.type.train_class_major || []).map(item => ({
label: item.label,
value: /^\d+$/.test(String(item.value)) ? Number(item.value) : item.value
}))
}
},
methods: {
/** 表格序号:按当前分页顺序连续编号 */
indexMethod(index) {
const { pageNum = 1, pageSize = 10 } = this.queryParams
return (pageNum - 1) * pageSize + index + 1
},
getList() {
this.loading = true
const query = { ...this.queryParams }
if (this.startTimeRange && this.startTimeRange.length === 2) {
query.startTimeBegin = this.startTimeRange[0]
query.startTimeEnd = this.startTimeRange[1]
query.startTimeBegin = this.startTimeRange[0] + ' 00:00:00'
query.startTimeEnd = this.startTimeRange[1] + ' 23:59:59'
}
listPlan(query).then(response => {
this.planList = response.rows
@@ -485,11 +520,18 @@ export default {
this.form = {
planId: null,
planName: null,
classId: null,
className: null,
startTime: null,
endTime: null,
reportDeadline: null,
reportStarttime: null,
trainAddress: null,
classMajor: null,
recruitNum: 1,
totalClassHours: 0,
manager: null,
hostDeptId: null,
applyCondition: null,
remark: null
}
@@ -512,7 +554,12 @@ export default {
handleUpdate(row) {
this.reset()
getPlan(row.planId).then(response => {
this.form = response.data
const data = response.data || {}
this.form = {
...data,
startTime: this.toDateOnly(data.startTime),
endTime: this.toDateOnly(data.endTime)
}
this.open = true
this.title = '编辑培训计划'
})
@@ -524,9 +571,21 @@ export default {
this.$modal.msgError('结束时间必须晚于开班时间')
return
}
if (this.form.reportDeadline > this.form.startTime) {
this.$modal.msgError('报到截止时间不得晚于开班时间')
return
if (this.form.reportDeadline && this.form.startTime) {
const deadlineDate = String(this.form.reportDeadline).slice(0, 10)
const startDate = String(this.form.startTime).slice(0, 10)
if (deadlineDate > startDate) {
this.$modal.msgError('报到截止时间不得晚于开班时间')
return
}
}
if (this.form.reportStarttime && this.form.reportDeadline) {
const startTime = String(this.form.reportStarttime).slice(0, 10)
const deadlineTime = String(this.form.reportDeadline).slice(0, 10)
if (startTime > deadlineTime) {
this.$modal.msgError('报到开始时间不得晚于报到截止时间')
return
}
}
const request = this.form.planId
? updatePlan(this.form.planId, this.planPayload())
@@ -538,12 +597,74 @@ export default {
})
})
},
/** 仅取日期部分 yyyy-MM-dd(去掉 HH:mm:ss) */
formatDate(val) {
if (!val) return '-'
const s = String(val)
return s.length >= 10 ? s.substring(0, 10) : s
},
/** 截断为日期字符串 yyyy-MM-dd,供日期选择器双向绑定 */
toDateOnly(val) {
if (!val) return null
const s = String(val)
return s.length >= 10 ? s.substring(0, 10) : s
},
/** 日期字符串补零为后端期望的 yyyy-MM-dd HH:mm:ss(时间归零),兼容 @JsonFormat 反序列化 */
toTimestamp(val) {
if (!val) return null
return val.length <= 10 ? val + ' 00:00:00' : val
},
/** 专业字典值转标签,兼容数值/字符串,空值显示 - */
majorLabel(val) {
if (val === null || val === undefined || val === '') return '-'
const item = (this.dict.type.train_class_major || []).find(d => String(d.value) === String(val))
return item ? item.label : String(val)
},
/** 查询部门下拉树结构 */
getDeptTree() {
deptTreeSelect().then(response => {
this.deptOptions = response.data
})
},
/** 部门树形转换为 Treeselect 所需格式(兼容 deptId/deptName 与 id/label) */
normalizer(node) {
const id = (node.deptId !== undefined && node.deptId !== null) ? node.deptId : node.id
const label = (node.deptName !== undefined && node.deptName !== null) ? node.deptName : node.label
return {
id: id,
label: label,
children: node.children && node.children.length ? node.children : 0
}
},
/** 根据部门ID在 deptOptions 树中查找部门名称 */
findDeptName(id) {
if (id === null || id === undefined || id === '') {
return null
}
const walk = (nodes) => {
if (!nodes) return null
for (const n of nodes) {
if (n.deptId === id || n.id === id) return n.deptName || n.label
const found = walk(n.children)
if (found) return found
}
return null
}
return walk(this.deptOptions)
},
planPayload() {
return {
planName: this.form.planName,
startTime: this.form.startTime,
endTime: this.form.endTime,
classId: this.form.classId,
className: this.form.className,
classMajor: this.form.classMajor,
totalClassHours: this.form.totalClassHours,
manager: this.form.manager,
hostDeptId: this.form.hostDeptId,
startTime: this.toTimestamp(this.form.startTime),
endTime: this.toTimestamp(this.form.endTime),
reportDeadline: this.form.reportDeadline,
reportStarttime: this.form.reportStarttime,
trainAddress: this.form.trainAddress,
recruitNum: this.form.recruitNum,
applyCondition: this.form.applyCondition,
@@ -558,6 +679,15 @@ export default {
this.getList()
}).catch(() => {})
},
handleDelete(row) {
const planName = row.planName || row.planId
this.$modal.confirm(`确认删除开班计划「${planName}」吗?仅“未开始”的计划允许删除。`).then(() => {
return delPlan(row.planId)
}).then(() => {
this.$modal.msgSuccess('删除成功')
this.getList()
}).catch(() => {})
},
// ---- ISSUE-02-02 本期课程配置方法 ----
handleCourseConfig(row) {
this.courseDialog.planId = row.planId
@@ -657,7 +787,13 @@ export default {
this.$refs.timetablePanel.openAddLessonForCourse(courseRow.planCourseId)
}
})
}
},
/** 导出按钮操作 */
handleExport() {
this.download('train/plan/export', {
...this.queryParams
}, `plan_${new Date().getTime()}.xlsx`)
},
}
}
</script>
@@ -0,0 +1,136 @@
<template>
<div class="portrait-chart" :style="{ height: height }">
<div ref="canvas" class="chart-canvas"></div>
<div v-if="!hasData" class="chart-empty">暂无活跃度分项数据</div>
</div>
</template>
<script>
import chartMixin from './chartMixin'
const COLORS = ['#409EFF', '#67C23A', '#E6A23C', '#909399']
export default {
name: 'PortraitActivity',
mixins: [chartMixin],
props: {
activity: {
type: Object,
default: null
},
height: {
type: String,
default: '300px'
}
},
computed: {
chartSource() {
return this.activity
},
/** 仅统计实际参与加权的分项,缺数据的分项不进图 */
includedParts() {
const parts = (this.activity && this.activity.parts) || []
return parts.filter(part => part &&
part.included === true &&
part.score !== null &&
part.score !== undefined)
},
hasData() {
return this.includedParts.length > 0
}
},
methods: {
buildOption() {
if (!this.hasData) {
return null
}
const parts = this.includedParts
const score = this.activity.score
const centerScore = score === null || score === undefined ? '—' : String(score)
return {
tooltip: {
trigger: 'item',
formatter: params => `${params.marker}${params.name}<br/>得分 ${params.value} 分 · 权重 ${params.data.weight}%`
},
legend: {
bottom: 0,
left: 'center',
itemWidth: 8,
itemHeight: 8,
itemGap: 10,
textStyle: { fontSize: 12, color: '#606266' },
formatter: name => {
const part = parts.find(item => item.name === name)
return part ? `${name} ${part.weight}%` : name
}
},
series: [{
type: 'pie',
radius: ['46%', '66%'],
center: ['50%', '44%'],
avoidLabelOverlap: true,
label: { show: false },
labelLine: { show: false },
itemStyle: { borderColor: '#fff', borderWidth: 2 },
data: parts.map((part, index) => ({
name: part.name,
value: part.score,
weight: part.weight,
itemStyle: { color: COLORS[index % COLORS.length] }
}))
}],
graphic: [
{
type: 'text',
left: 'center',
top: '34%',
style: {
text: centerScore,
fill: '#303133',
fontSize: 26,
fontWeight: 'bold',
textAlign: 'center'
}
},
{
type: 'text',
left: 'center',
top: '46%',
style: {
text: '活跃度得分',
fill: '#909399',
fontSize: 12,
textAlign: 'center'
}
}
]
}
}
}
}
</script>
<style scoped>
.portrait-chart {
position: relative;
width: 100%;
}
.chart-canvas {
width: 100%;
height: 100%;
}
.chart-empty {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #c0c4cc;
}
</style>
@@ -0,0 +1,171 @@
<template>
<div class="portrait-chart" :style="{ height: height }">
<div ref="canvas" class="chart-canvas"></div>
<div v-if="!hasData" class="chart-empty">暂无跨班期记录</div>
</div>
</template>
<script>
import chartMixin from './chartMixin'
export default {
name: 'PortraitPeriods',
mixins: [chartMixin],
props: {
periods: {
type: Array,
default: () => []
},
height: {
type: String,
default: '300px'
}
},
computed: {
chartSource() {
return this.periods
},
hasData() {
return (this.periods || []).length > 0
}
},
methods: {
toNumber(value) {
if (value === null || value === undefined || value === '') {
return null
}
const num = Number(value)
return isNaN(num) ? null : num
},
shortName(name) {
const text = String(name || '-')
return text.length > 8 ? `${text.slice(0, 8)}…` : text
},
buildOption() {
if (!this.hasData) {
return null
}
const periods = this.periods
const labels = periods.map(item => this.shortName(item.planName))
const overall = periods.map(item => this.toNumber(item.overallScore))
const attendance = periods.map(item => this.toNumber(item.attendanceRate))
const hours = periods.map(item => this.toNumber(item.studyHours))
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: params => {
const index = params[0] ? params[0].dataIndex : 0
const item = periods[index] || {}
const rows = [`<b>${item.planName || '-'}</b>`]
rows.push(`综合分:${overall[index] === null ? '-' : overall[index]} 分`)
rows.push(`出勤率:${attendance[index] === null ? '-' : attendance[index] + '%'}`)
rows.push(`学习学时:${hours[index] === null ? '-' : hours[index] + ' h'}`)
return rows.join('<br/>')
}
},
legend: {
bottom: 0,
left: 'center',
itemWidth: 10,
itemHeight: 8,
itemGap: 14,
textStyle: { fontSize: 12, color: '#606266' }
},
grid: {
left: 8,
right: 8,
top: 24,
bottom: 44,
containLabel: true
},
xAxis: {
type: 'category',
data: labels,
axisTick: { show: false },
axisLine: { lineStyle: { color: '#ebeef5' } },
axisLabel: { color: '#909399', fontSize: 11, interval: 0 }
},
yAxis: [
{
type: 'value',
name: '分/%',
min: 0,
max: 100,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: '#909399', fontSize: 11 },
nameTextStyle: { color: '#909399', fontSize: 11 },
splitLine: { lineStyle: { color: '#f2f3f5' } }
},
{
type: 'value',
name: '学时(h)',
min: 0,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: '#909399', fontSize: 11 },
nameTextStyle: { color: '#909399', fontSize: 11 },
splitLine: { show: false }
}
],
series: [
{
name: '综合分',
type: 'bar',
yAxisIndex: 0,
barMaxWidth: 22,
data: overall,
itemStyle: { color: '#409EFF', borderRadius: [3, 3, 0, 0] }
},
{
name: '出勤率',
type: 'bar',
yAxisIndex: 0,
barMaxWidth: 22,
data: attendance,
itemStyle: { color: 'rgba(103, 194, 58, 0.75)', borderRadius: [3, 3, 0, 0] }
},
{
name: '学习学时',
type: 'line',
yAxisIndex: 1,
smooth: true,
symbolSize: 6,
connectNulls: true,
data: hours,
lineStyle: { color: '#E6A23C', width: 2 },
itemStyle: { color: '#E6A23C' }
}
]
}
}
}
}
</script>
<style scoped>
.portrait-chart {
position: relative;
width: 100%;
}
.chart-canvas {
width: 100%;
height: 100%;
}
.chart-empty {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #c0c4cc;
}
</style>
@@ -0,0 +1,117 @@
<template>
<div class="portrait-chart" :style="{ height: height }">
<div ref="canvas" class="chart-canvas"></div>
<div v-if="!hasData" class="chart-empty">暂无可用的维度评分</div>
</div>
</template>
<script>
import chartMixin from './chartMixin'
/** 雷达轴名过长时缩短,避免窄屏裁切 */
const NAME_ALIAS = {
身份与数据质量: '身份质量'
}
export default {
name: 'PortraitRadar',
mixins: [chartMixin],
props: {
dimensions: {
type: Array,
default: () => []
},
height: {
type: String,
default: '300px'
}
},
computed: {
chartSource() {
return this.dimensions
},
/** 仅取有分数且数据可用的维度,组织归属等展示项不进入雷达 */
scoredDimensions() {
return (this.dimensions || []).filter(item => item &&
item.available === true &&
item.score !== null &&
item.score !== undefined)
},
hasData() {
return this.scoredDimensions.length >= 3
}
},
methods: {
buildOption() {
if (!this.hasData) {
return null
}
const dims = this.scoredDimensions
return {
tooltip: {
trigger: 'item',
formatter: () => dims.map(item => `${item.name}:${item.score} 分(${item.level || '-'})`).join('<br/>')
},
radar: {
center: ['50%', '52%'],
radius: '62%',
splitNumber: 4,
indicator: dims.map(item => ({
name: NAME_ALIAS[item.name] || item.name,
max: 100
})),
axisName: {
color: '#606266',
fontSize: 12
},
axisLine: {
lineStyle: { color: '#ebeef5' }
},
splitLine: {
lineStyle: { color: '#ebeef5' }
},
splitArea: {
areaStyle: { color: ['#fafcff', '#ffffff'] }
}
},
series: [{
type: 'radar',
symbolSize: 5,
lineStyle: { color: '#409EFF', width: 2 },
itemStyle: { color: '#409EFF' },
areaStyle: { color: 'rgba(64, 158, 255, 0.18)' },
data: [{
value: dims.map(item => item.score),
name: '本人维度得分'
}]
}]
}
}
}
}
</script>
<style scoped>
.portrait-chart {
position: relative;
width: 100%;
}
.chart-canvas {
width: 100%;
height: 100%;
}
.chart-empty {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #c0c4cc;
}
</style>
@@ -0,0 +1,167 @@
<template>
<div class="portrait-chart" :style="{ height: height }">
<div ref="canvas" class="chart-canvas"></div>
<div v-if="!hasData" class="chart-empty">暂无周维度学习与出勤记录</div>
</div>
</template>
<script>
import chartMixin from './chartMixin'
export default {
name: 'PortraitWeekly',
mixins: [chartMixin],
props: {
weekly: {
type: Array,
default: () => []
},
height: {
type: String,
default: '300px'
}
},
computed: {
chartSource() {
return this.weekly
},
hasData() {
return (this.weekly || []).length > 0
}
},
methods: {
toNumber(value) {
if (value === null || value === undefined || value === '') {
return null
}
const num = Number(value)
return isNaN(num) ? null : num
},
buildOption() {
if (!this.hasData) {
return null
}
const weekly = this.weekly
const labels = weekly.map(item => String(item.week || '').slice(5))
const attendance = weekly.map(item => this.toNumber(item.attendanceRate))
const hours = weekly.map(item => this.toNumber(item.studyHours))
const scores = weekly.map(item => this.toNumber(item.avgScore))
const hasScore = scores.some(value => value !== null)
const series = [
{
name: '出勤率',
type: 'line',
smooth: true,
yAxisIndex: 0,
connectNulls: true,
symbolSize: 6,
data: attendance,
lineStyle: { color: '#409EFF', width: 2 },
itemStyle: { color: '#409EFF' },
areaStyle: { color: 'rgba(64, 158, 255, 0.12)' }
},
{
name: '学时',
type: 'bar',
yAxisIndex: 1,
barMaxWidth: 20,
data: hours,
itemStyle: { color: 'rgba(103, 194, 58, 0.55)' }
}
]
if (hasScore) {
series.push({
name: '考核均分',
type: 'line',
smooth: true,
yAxisIndex: 0,
connectNulls: true,
symbolSize: 5,
data: scores,
lineStyle: { color: '#E6A23C', width: 2, type: 'dashed' },
itemStyle: { color: '#E6A23C' }
})
}
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' }
},
legend: {
bottom: 0,
left: 'center',
itemWidth: 10,
itemHeight: 8,
itemGap: 14,
textStyle: { fontSize: 12, color: '#606266' }
},
grid: {
left: 8,
right: 8,
top: 24,
bottom: 44,
containLabel: true
},
xAxis: {
type: 'category',
data: labels,
boundaryGap: true,
axisTick: { show: false },
axisLine: { lineStyle: { color: '#ebeef5' } },
axisLabel: { color: '#909399', fontSize: 11 }
},
yAxis: [
{
type: 'value',
name: '%',
min: 0,
max: 100,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: '#909399', fontSize: 11 },
splitLine: { lineStyle: { color: '#f2f3f5' } }
},
{
type: 'value',
name: '学时(h)',
min: 0,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: '#909399', fontSize: 11 },
nameTextStyle: { color: '#909399', fontSize: 11 },
splitLine: { show: false }
}
],
series
}
}
}
}
</script>
<style scoped>
.portrait-chart {
position: relative;
width: 100%;
}
.chart-canvas {
width: 100%;
height: 100%;
}
.chart-empty {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #c0c4cc;
}
</style>
@@ -0,0 +1,82 @@
import * as echarts from 'echarts'
import { debounce } from '@/utils'
/**
* 画像图表通用混入:统一负责实例初始化、数据变更重绘与容器自适应。
*
* <p>使用方需实现 buildOption(),并声明 chartSource 计算属性驱动重绘;
* 返回 null 表示当前无有效数据,图表会被清空并展示组件内的空状态文案。</p>
*/
export default {
data() {
return {
chart: null,
chartObserver: null
}
},
watch: {
chartSource: {
deep: true,
handler() {
this.renderChart()
}
}
},
mounted() {
this.$nextTick(() => {
this.renderChart()
})
},
activated() {
if (this.chart) {
this.chart.resize()
}
},
beforeDestroy() {
window.removeEventListener('resize', this.handleChartResize)
if (this.chartObserver) {
this.chartObserver.disconnect()
this.chartObserver = null
}
if (this.chart) {
this.chart.dispose()
this.chart = null
}
},
methods: {
renderChart() {
const el = this.$refs.canvas
if (!el) {
return
}
if (!this.chart) {
this.chart = echarts.init(el)
this.handleChartResize = debounce(() => {
if (this.chart) {
this.chart.resize()
}
}, 120)
window.addEventListener('resize', this.handleChartResize)
this.observeContainer()
}
const option = this.buildOption()
if (!option) {
this.chart.clear()
return
}
this.chart.setOption(option, true)
},
/** 侧边栏收起、栅格换行等容器尺寸变化不触发 window.resize,用容器观察器兜底 */
observeContainer() {
if (typeof ResizeObserver === 'undefined' || !this.$el) {
return
}
this.chartObserver = new ResizeObserver(debounce(() => {
if (this.chart) {
this.chart.resize()
}
}, 100))
this.chartObserver.observe(this.$el)
}
}
}
@@ -0,0 +1,294 @@
/**
* 画像数据结构兜底。
*
* <p>图表组件依赖固定结构(dimensions 数组、activity.parts 数组、trend.weekly/periods 数组、
* meta 对象等)。后端接口在画像生成失败时会直接省略 portrait 字段,或在数据不足时返回部分空值,
* 直接绑定会导致 props 为 undefined、图表初始化失败或整块区域不渲染。</p>
*
* <p>本模块提供与接口完全一致的初始结构,并在接口返回后按字段合并,保证:
* 1. 数组/对象字段始终存在,不出现 undefined;
* 2. 数值字段始终为数字(缺省为 0),展示型字段为占位文案;
* 3. 不伪造分数:无数据的维度 available=false、score=null,图表按“数据不足”空态呈现。</p>
*/
const DIMENSION_SEEDS = [
{
code: 'attendance',
name: '考勤纪律',
metrics: [
['expected', '应到课次', '0', '次'],
['present', '实到课次', '0', '次'],
['absent', '缺勤(含未签到)', '0', '次'],
['leave', '请假', '0', '次'],
['late', '迟到', '0', '次'],
['early', '早退', '0', '次'],
['attendanceRate', '出勤率', '0', '%'],
['onTimeRate', '准时率', '0', '%'],
['proxyRate', '代签率', '0', '%'],
['manualRate', '补卡率', '0', '%'],
['leadMinutes', '平均提前', '0', '分钟'],
['inClassRate', '平均在课率', '0', '%'],
['maxAbsenceStreak', '连续缺勤峰值', '0', '次']
]
},
{
code: 'study',
name: '学习投入',
metrics: [
['totalHours', '总学时', '0', '小时'],
['plannedHours', '计划学时', '0', '小时'],
['achievement', '投入达成率', '0', '%'],
['activeDays', '有效学习天数', '0', '天'],
['maxConsecutive', '最长连续学习', '0', '天'],
['records', '学习记录数', '0', '条'],
['resourceCoverage', '资源覆盖度', '0', '%'],
['lastStudy', '最近学习', '-', '']
]
},
{
code: 'score',
name: '考核能力',
metrics: [
['overall', '综合分', '-', '分'],
['weightCoverage', '考核权重覆盖', '0', '%'],
['theoryAvg', '理论均分', '-', '分'],
['theoryPassRate', '理论通过率', '0', '%'],
['practiceAvg', '实训均分', '-', '分'],
['practicePassRate', '实训通过率', '0', '%'],
['absent', '缺考课程', '0', '门'],
['retake', '补考次数', '0', '次'],
['improvement', '补考提升', '0', '分'],
['weak', '薄弱课程', '-', '']
]
},
{
code: 'progress',
name: '进度完成',
metrics: [
['timeProgress', '时间进度', '0', '%'],
['lessonProgress', '课次进度', '0', '%'],
['deviation', '进度偏差', '0', '%'],
['examCompletion', '考核完成度', '0', '%'],
['lessons', '已完成课次', '0/0', '次'],
['courses', '课程数', '0', '门'],
['status', '参训状态', '-', '']
]
},
{
code: 'org',
name: '组织归属',
metrics: [
['plan', '所属班期', '-', ''],
['dept', '所属单位', '-', ''],
['source', '档案来源', '-', ''],
['teachers', '授课教师数', '0', '人'],
['dormMates', '同寝人数', '0', '人'],
['accommodation', '住宿状态', '-', '']
]
},
{
code: 'identity',
name: '身份与数据质量',
metrics: [
['completeness', '主档完整度', '0', '%'],
['account', '账号绑定', '未绑定', ''],
['face', '人脸特征', '待提取', ''],
['card', '门禁卡', '未发放', ''],
['faceSync', '门禁下发', '未下发', ''],
['autoRate', '考勤自动采集率', '0', '%']
]
},
{
code: 'overall',
name: '综合画像',
metrics: [
['usedWeight', '参与权重', '0', '%']
]
}
]
const ACTIVITY_PARTS = [
{ name: '出勤', weight: 40 },
{ name: '学习投入', weight: 30 },
{ name: '考核参与', weight: 20 },
{ name: '资源覆盖', weight: 10 }
]
function toNumber(value, fallback) {
// 注意 Number(null) === 0,空值必须走兜底,避免"无数据"被当成 0 分
if (value === null || value === undefined || value === '' || value === '-') {
return fallback
}
const num = Number(value)
return isNaN(num) ? fallback : num
}
function buildMetrics(seedMetrics, rawMetrics) {
const source = Array.isArray(rawMetrics) ? rawMetrics : []
const merged = seedMetrics.map(seed => {
const [key, label, defaultValue, unit] = seed
const hit = source.find(item => item && item.key === key)
if (!hit) {
return { key, label, value: defaultValue, unit }
}
return {
key,
label: hit.label || label,
value: hit.value === null || hit.value === undefined || hit.value === '' ? defaultValue : String(hit.value),
unit: hit.unit === undefined || hit.unit === null ? unit : hit.unit
}
})
// 保留接口新增但种子未覆盖的指标,避免结构裁剪
source.forEach(item => {
if (item && item.key && !merged.some(metric => metric.key === item.key)) {
merged.push({
key: item.key,
label: item.label || item.key,
value: item.value === null || item.value === undefined || item.value === '' ? '-' : String(item.value),
unit: item.unit || ''
})
}
})
return merged
}
function buildDimensions(rawDimensions) {
const source = Array.isArray(rawDimensions) ? rawDimensions : []
return DIMENSION_SEEDS.map(seed => {
const hit = source.find(item => item && item.code === seed.code)
if (!hit) {
return {
code: seed.code,
name: seed.name,
score: null,
level: '数据不足',
available: false,
metrics: buildMetrics(seed.metrics, [])
}
}
const score = toNumber(hit.score, null)
return {
code: seed.code,
name: hit.name || seed.name,
score: score === null ? null : Math.max(0, Math.min(100, score)),
level: hit.level || (score === null ? '数据不足' : '-'),
available: hit.available === true,
metrics: buildMetrics(seed.metrics, hit.metrics)
}
})
}
function buildActivity(rawActivity) {
const raw = rawActivity || {}
const rawParts = Array.isArray(raw.parts) ? raw.parts : []
const parts = ACTIVITY_PARTS.map(seed => {
const hit = rawParts.find(item => item && item.name === seed.name)
if (!hit) {
return { name: seed.name, score: 0, weight: seed.weight, included: false }
}
return {
name: seed.name,
score: toNumber(hit.score, 0),
weight: toNumber(hit.weight, seed.weight),
included: hit.included === true
}
})
return {
score: toNumber(raw.score, 0),
level: raw.level || '数据不足',
parts
}
}
function buildTrend(rawTrend) {
const raw = rawTrend || {}
const weekly = (Array.isArray(raw.weekly) ? raw.weekly : []).map(item => ({
week: item && item.week ? String(item.week) : '-',
attendanceRate: toNumber(item && item.attendanceRate, null),
studyHours: toNumber(item && item.studyHours, 0),
avgScore: toNumber(item && item.avgScore, null)
}))
const periods = (Array.isArray(raw.periods) ? raw.periods : []).map(item => ({
planName: item && item.planName ? String(item.planName) : '-',
overallScore: toNumber(item && item.overallScore, null),
attendanceRate: toNumber(item && item.attendanceRate, null),
studyHours: toNumber(item && item.studyHours, 0)
}))
return { weekly, periods }
}
function buildMeta(rawMeta) {
const raw = rawMeta || {}
return {
dataCutoff: raw.dataCutoff || '-',
completeness: toNumber(raw.completeness, 0),
metricVersion: raw.metricVersion || '-',
warnings: Array.isArray(raw.warnings) ? raw.warnings.slice() : []
}
}
/** 图表与页面使用的完整初始结构(在组件 data 阶段创建,早于任何图表初始化) */
export function createDefaultPortrait() {
return {
dimensions: buildDimensions([]),
layer: {
code: 'INSUFFICIENT',
name: '数据不足',
basis: '暂无足够数据参与分层判定'
},
tags: [],
activity: buildActivity(null),
risk: {
score: 0,
level: '低',
rules: []
},
classCompare: {
scope: '',
sampleSize: 0,
sufficient: false,
items: []
},
trend: buildTrend(null),
meta: buildMeta(null)
}
}
/**
* 将接口返回的画像按字段合并到初始结构上。
*
* @param raw 接口返回的 portrait(可能为 null / 缺字段)
* @param fallback createDefaultPortrait() 的实例
*/
export function normalizePortrait(raw, fallback) {
const base = fallback || createDefaultPortrait()
if (!raw || typeof raw !== 'object') {
return base
}
const rawRisk = raw.risk || {}
const rawLayer = raw.layer || {}
const rawClassCompare = raw.classCompare || {}
return {
dimensions: buildDimensions(raw.dimensions),
layer: {
code: rawLayer.code || base.layer.code,
name: rawLayer.name || base.layer.name,
basis: rawLayer.basis || base.layer.basis
},
tags: Array.isArray(raw.tags) ? raw.tags : [],
activity: buildActivity(raw.activity),
risk: {
score: toNumber(rawRisk.score, 0),
level: rawRisk.level || '低',
rules: Array.isArray(rawRisk.rules) ? rawRisk.rules : []
},
classCompare: {
scope: rawClassCompare.scope || '',
sampleSize: toNumber(rawClassCompare.sampleSize, 0),
sufficient: rawClassCompare.sufficient === true,
items: Array.isArray(rawClassCompare.items) ? rawClassCompare.items : []
},
trend: buildTrend(raw.trend),
meta: buildMeta(raw.meta)
}
}
File diff suppressed because it is too large. Load diff
+531
View File
@@ -0,0 +1,531 @@
<template>
<div v-loading="loading" class="app-container class-regdata">
<!-- 顶部标题栏 -->
<div class="page-topbar">
<div class="topbar-left">
<h2 class="topbar-title">班级报到数据</h2>
<span class="topbar-sub">数据取自参训记录(train_enrollment),报到状态以 enrollment_status 为准</span>
</div>
<div class="rd-topbar-actions">
<el-button size="small" icon="el-icon-download" @click="handleExport">导出报到名册</el-button>
<el-button type="primary" size="small" icon="el-icon-plus" @click="goStartRegister">启动班级报到</el-button>
</div>
</div>
<!-- 培训班级标签:仅展示未结束(plan_status != 2)的班级 -->
<div v-loading="planLoading" class="rd-tabs">
<span
v-for="p in visiblePlans"
:key="p.planId"
class="rd-tab"
:class="{ active: p.planId === currentPlanId }"
@click="switchPlan(p.planId)"
>{{ tabLabel(p) }}</span>
<span v-if="!planLoading && !visiblePlans.length" class="rd-empty-tab">暂无未结束的培训班级</span>
</div>
<!-- 统计卡 -->
<div class="rd-stats">
<div class="rd-stat">
<div class="rd-stat-label">应报到</div>
<div class="rd-stat-value dark">{{ summary.expected }} <span class="rd-unit">人</span></div>
</div>
<div class="rd-stat">
<div class="rd-stat-label">已报到</div>
<div class="rd-stat-value green">{{ summary.reported }} <span class="rd-unit">人</span></div>
</div>
<div class="rd-stat">
<div class="rd-stat-label">待报到</div>
<div class="rd-stat-value red">{{ summary.notReported }} <span class="rd-unit">人</span></div>
</div>
<div class="rd-stat">
<div class="rd-stat-label">报到率</div>
<div class="rd-stat-value green">{{ summary.rate }}%</div>
</div>
</div>
<!-- 中部两栏 -->
<div class="rd-middle">
<!-- 待报到学员 -->
<div class="rd-panel rd-unreported">
<div class="rd-panel-head">
<span class="rd-panel-title">待报到学员</span>
<span class="rd-badge-red">{{ notReportedTotal }} 人待处理</span>
</div>
<el-table :data="notReportedPaged" size="small" class="rd-table" empty-text="该班级暂无待报到学员">
<el-table-column label="学员" min-width="130">
<template slot-scope="scope">
<div class="rd-student">
<span class="rd-avatar" :style="{ background: avatarColor(scope.row.studentName) }">{{ (scope.row.studentName || '').charAt(0) }}</span>
<div>
<div class="rd-student-name">{{ scope.row.studentName }}</div>
<div class="rd-student-code">{{ scope.row.personNo || scope.row.studentId }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="选派单位" prop="deptName" min-width="100" show-overflow-tooltip />
<el-table-column label="联系电话" prop="phone" width="115" />
<el-table-column label="报到状态" width="86" align="center">
<template slot-scope="scope">
<el-tag type="danger" size="mini">{{ statusTag(scope.row.enrollmentStatus).text }}</el-tag>
</template>
</el-table-column>
<el-table-column label="说明" prop="remark" min-width="90" show-overflow-tooltip />
</el-table>
<div class="rd-pager">
<span class="rd-pager-total">共 {{ notReportedTotal }} 条 · 每页 {{ notQuery.pageSize }} 条</span>
<el-pagination
small
background
layout="prev, pager, next"
:total="notReportedTotal"
:page-size="notQuery.pageSize"
:current-page.sync="notQuery.pageNum"
/>
</div>
</div>
<!-- 报到时间分布 -->
<div class="rd-panel rd-chart-panel">
<div class="rd-panel-head">
<span class="rd-panel-title">报到时间分布</span>
<span class="rd-head-right rd-panel-extra">按入班报到时间 join_time 统计</span>
</div>
<div v-if="hourlyBars.length" class="rd-chart">
<div v-for="bar in hourlyBars" :key="bar.hour" class="rd-bar-col">
<span class="rd-bar-value">{{ bar.count }}</span>
<div class="rd-bar-track">
<div class="rd-bar" :class="{ peak: bar.peak }" :style="{ height: bar.percent + '%' }" />
</div>
<span class="rd-bar-label">{{ bar.hour }}</span>
</div>
</div>
<el-empty v-else :image-size="60" description="暂无报到数据" />
<div v-if="summary.peakNote" class="rd-chart-note">{{ summary.peakNote }}</div>
</div>
</div>
<!-- 报到名册 -->
<div class="rd-panel">
<div class="rd-panel-head">
<span class="rd-panel-title">报到名册 · {{ currentPlanLabel }}</span>
<span class="rd-head-right">
<el-input
v-model="rosterFilter.studentName"
size="mini"
class="rd-filter-item"
placeholder="学员姓名"
clearable
@input="handleRosterFilterChange"
/>
<el-select
v-model="rosterFilter.enrollmentStatus"
size="mini"
class="rd-filter-item"
placeholder="全部状态"
clearable
@change="handleRosterFilterChange"
>
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<span class="rd-panel-extra">{{ rosterRange }}</span>
</span>
</div>
<el-table
:data="rosterPaged"
size="small"
class="rd-table"
empty-text="该班级暂无参训记录"
:default-sort="{ prop: 'studentName', order: 'ascending' }"
@sort-change="handleRosterSort"
>
<el-table-column label="学员" sortable="custom" prop="studentName" min-width="140">
<template slot-scope="scope">
<div class="rd-student">
<span class="rd-avatar" :style="{ background: avatarColor(scope.row.studentName) }">{{ (scope.row.studentName || '').charAt(0) }}</span>
<div>
<div class="rd-student-name">{{ scope.row.studentName }}</div>
<div class="rd-student-code">{{ scope.row.personNo || scope.row.studentId }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="选派单位" prop="deptName" min-width="120" show-overflow-tooltip />
<el-table-column label="联系电话" prop="phone" width="120" />
<el-table-column label="报到状态" width="95" align="center">
<template slot-scope="scope">
<el-tag :type="statusTag(scope.row.enrollmentStatus).type" size="mini">{{ statusTag(scope.row.enrollmentStatus).text }}</el-tag>
</template>
</el-table-column>
<el-table-column label="报到时间" prop="joinTime" width="160" align="center">
<template slot-scope="scope">{{ scope.row.joinTime || '—' }}</template>
</el-table-column>
<el-table-column label="结课时间" prop="completeTime" width="160" align="center">
<template slot-scope="scope">{{ scope.row.completeTime || '—' }}</template>
</el-table-column>
<el-table-column label="备注" prop="remark" min-width="90" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.remark || '—' }}</template>
</el-table-column>
<el-table-column label="操作" width="70" align="center">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="showDetail(scope.row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div class="rd-pager">
<span class="rd-pager-total">共 {{ rosterTotal }} 条</span>
<el-pagination
small
background
layout="prev, pager, next"
:total="rosterTotal"
:page-size="rosterQuery.pageSize"
:current-page.sync="rosterQuery.pageNum"
/>
</div>
</div>
<!-- 学员报到详情 -->
<el-dialog title="报到详情" :visible.sync="detailOpen" width="560px" append-to-body>
<el-descriptions :column="2" size="small" border>
<el-descriptions-item label="学员姓名">{{ detailRow.studentName }}</el-descriptions-item>
<el-descriptions-item label="人员编号">{{ detailRow.personNo || detailRow.studentId }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ detailRow.phone || '—' }}</el-descriptions-item>
<el-descriptions-item label="选派单位">{{ detailRow.deptName || '—' }}</el-descriptions-item>
<el-descriptions-item label="报到状态">
<el-tag :type="statusTag(detailRow.enrollmentStatus).type" size="mini">{{ statusTag(detailRow.enrollmentStatus).text }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="住宿需求">{{ detailRow.accommodationRequired === 1 ? '需要住宿' : '不住宿' }}</el-descriptions-item>
<el-descriptions-item label="报到时间">{{ detailRow.joinTime || '—' }}</el-descriptions-item>
<el-descriptions-item label="结课时间">{{ detailRow.completeTime || '—' }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detailRow.remark || '—' }}</el-descriptions-item>
</el-descriptions>
<div slot="footer">
<el-button @click="detailOpen = false">关 闭</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listPlan } from '@/api/train/plan'
import { ENROLLMENT_STATUS_OPTIONS, formatEnrollmentStatusTag } from '@/views/train/checkin/attendancePolicy'
import {
WAITING_STATUS,
filterEffective,
fetchPlanEnrollments,
summarizeEnrollments
} from '@/utils/enrollmentReport'
// 培训计划状态字典:0未开始 1进行中 2已结束 3已取消
// 顶部班级标签只保留"未结束"的班级,即排除已结束(2)
const ENDED_PLAN_STATUS = 2
export default {
name: 'ClassRegData',
data() {
return {
loading: false,
planLoading: false,
planList: [],
currentPlanId: '',
// train_enrollment 全量快照(当前班级)
snapshot: [],
summary: { expected: 0, reported: 0, notReported: 0, rate: 0, hourly: [], peakNote: '' },
statusOptions: ENROLLMENT_STATUS_OPTIONS,
// 待报到学员分页
notQuery: { pageNum: 1, pageSize: 6 },
// 报到名册分页与筛选、排序
rosterQuery: { pageNum: 1, pageSize: 10 },
rosterFilter: { studentName: '', enrollmentStatus: null },
rosterSort: { prop: 'studentName', order: 'ascending' },
// 详情弹窗
detailOpen: false,
detailRow: {}
}
},
computed: {
/** 顶部班级标签数据:过滤已结束的班级(保留全量 planList 供 currentPlan 等区域回查) */
visiblePlans() {
return this.planList.filter(item => Number(item.planStatus) !== ENDED_PLAN_STATUS)
},
currentPlan() {
return this.planList.find(item => item.planId === this.currentPlanId) || null
},
currentPlanLabel() {
const plan = this.currentPlan
if (!plan) return '—'
return plan.className ? plan.planName + '(' + plan.className + ')' : plan.planName
},
/** 有效参训记录(排除已放弃) */
effectiveList() {
return filterEffective(this.snapshot)
},
/** 待报到学员(enrollment_status=0) */
waitingList() {
return this.effectiveList.filter(row => Number(row.enrollmentStatus) === WAITING_STATUS)
},
notReportedTotal() {
return this.waitingList.length
},
notReportedPaged() {
const start = (this.notQuery.pageNum - 1) * this.notQuery.pageSize
return this.waitingList.slice(start, start + this.notQuery.pageSize)
},
/** 名册筛选结果 */
rosterFiltered() {
const keyword = (this.rosterFilter.studentName || '').trim()
const rawStatus = this.rosterFilter.enrollmentStatus
const status = rawStatus === '' || rawStatus === null || rawStatus === undefined ? null : Number(rawStatus)
return this.effectiveList.filter(row => {
if (keyword && String(row.studentName || '').indexOf(keyword) === -1) return false
if (status !== null && Number(row.enrollmentStatus) !== status) return false
return true
})
},
/** 名册全量排序(对筛选后的全部数据排序,再分页) */
rosterSorted() {
const list = this.rosterFiltered.slice()
const prop = this.rosterSort.prop
const order = this.rosterSort.order
if (!prop || !order) return list
const factor = order === 'descending' ? -1 : 1
return list.sort((a, b) => {
const left = a[prop] === null || a[prop] === undefined ? '' : a[prop]
const right = b[prop] === null || b[prop] === undefined ? '' : b[prop]
if (typeof left === 'number' && typeof right === 'number') return (left - right) * factor
return String(left).localeCompare(String(right), 'zh-Hans-CN') * factor
})
},
rosterTotal() {
return this.rosterSorted.length
},
rosterPaged() {
const start = (this.rosterQuery.pageNum - 1) * this.rosterQuery.pageSize
return this.rosterSorted.slice(start, start + this.rosterQuery.pageSize)
},
/** 报到时间分布柱状数据(纯 CSS 柱状图) */
hourlyBars() {
const list = this.summary.hourly || []
const max = Math.max.apply(null, list.map(h => h.count).concat([1]))
return list.map(h => ({
hour: h.hour,
count: h.count,
percent: Math.round(h.count / max * 100),
peak: h.count === max && h.count > 0
}))
},
/** 报到时间窗描述(取自培训计划) */
rosterRange() {
const plan = this.currentPlan
if (!plan) return ''
const fmt = time => (time ? String(time).slice(5, 16) : '')
const start = fmt(plan.startTime)
const end = fmt(plan.endTime)
if (!start && !end) return ''
let text = start + ' ~ ' + end
if (plan.reportDeadline) text += ' · 报到截止 ' + fmt(plan.reportDeadline)
return text
}
},
created() {
this.loadPlans()
},
methods: {
/** 加载培训计划(进行中优先),默认选中第一个未结束的班级 */
loadPlans() {
this.planLoading = true
listPlan({ pageNum: 1, pageSize: 100 }).then(res => {
// 全量保留,供 currentPlan / 详情等区域按 planId 回查
this.planList = (res.rows || []).slice().sort((a, b) => {
const weightA = a.planStatus === 1 ? 0 : 1
const weightB = b.planStatus === 1 ? 0 : 1
return weightA - weightB
})
// 刷新后原选中班级若已结束(或已不存在),重新落到第一个可见班级
const visible = this.visiblePlans
const stillVisible = visible.some(item => item.planId === this.currentPlanId)
if (!stillVisible) {
this.resetPlanData()
if (visible.length) {
this.switchPlan(visible[0].planId)
}
}
}).catch(() => {
this.planList = []
this.resetPlanData()
}).finally(() => {
this.planLoading = false
})
},
/** 清空当前班级相关的展示数据 */
resetPlanData() {
this.currentPlanId = ''
this.snapshot = []
this.summary = { expected: 0, reported: 0, notReported: 0, rate: 0, hourly: [], peakNote: '' }
this.notQuery.pageNum = 1
this.rosterQuery.pageNum = 1
this.rosterFilter = { studentName: '', enrollmentStatus: null }
},
tabLabel(plan) {
return plan.className || plan.planName
},
/** 切换班级并重新拉取 train_enrollment 数据(已结束班级不允许进入) */
switchPlan(planId) {
if (!planId || this.currentPlanId === planId) return
const target = this.planList.find(item => item.planId === planId)
if (!target || Number(target.planStatus) === ENDED_PLAN_STATUS) return
this.currentPlanId = planId
this.notQuery.pageNum = 1
this.rosterQuery.pageNum = 1
this.rosterFilter = { studentName: '', enrollmentStatus: null }
this.loadEnrollments()
},
/** 拉取当前班级全量参训记录,本地完成统计/分页/筛选 */
loadEnrollments() {
const planId = this.currentPlanId
if (!planId) {
this.snapshot = []
this.summary = { expected: 0, reported: 0, notReported: 0, rate: 0, hourly: [], peakNote: '' }
return
}
this.loading = true
fetchPlanEnrollments(planId).then(rows => {
// 请求期间用户已切换班级则丢弃过期结果
if (planId !== this.currentPlanId) return
this.snapshot = rows
const summary = summarizeEnrollments(rows)
this.summary = {
expected: summary.expected,
reported: summary.reported,
notReported: summary.notReported,
rate: summary.rate,
hourly: summary.hourly,
peakNote: summary.peakNote
}
if (this.notQuery.pageNum > 1 && !this.notReportedPaged.length) {
this.notQuery.pageNum = 1
}
if (this.rosterQuery.pageNum > 1 && !this.rosterPaged.length) {
this.rosterQuery.pageNum = 1
}
}).catch(() => {
if (planId !== this.currentPlanId) return
this.snapshot = []
this.summary = { expected: 0, reported: 0, notReported: 0, rate: 0, hourly: [], peakNote: '' }
}).finally(() => {
this.loading = false
})
},
statusTag(status) {
return formatEnrollmentStatusTag(status)
},
handleRosterFilterChange() {
this.rosterQuery.pageNum = 1
},
/** 名册排序:对筛选后的全量数据排序(前端排序,接口不支持动态排序) */
handleRosterSort({ prop, order }) {
this.rosterSort = { prop, order }
this.rosterQuery.pageNum = 1
},
/** 学员报到详情 */
showDetail(row) {
this.detailRow = row
this.detailOpen = true
},
/** 导出当前班级报到名册(字段与页面一致,数据源 train_enrollment) */
handleExport() {
if (!this.currentPlanId) {
this.$modal.msgWarning('请先选择培训班级')
return
}
const list = this.rosterFiltered
if (!list.length) {
this.$modal.msgWarning('当前条件下没有可导出的报到数据')
return
}
const headers = ['学员姓名', '人员编号', '选派单位', '联系电话', '报到状态', '报到时间', '结课时间', '备注']
const lines = [headers.join(',')]
list.forEach(item => {
const cells = [
item.studentName,
item.personNo || item.studentId,
item.deptName,
item.phone,
this.statusTag(item.enrollmentStatus).text,
item.joinTime,
item.completeTime,
item.remark
]
lines.push(cells.map(cell => `"${String(cell === null || cell === undefined ? '' : cell).replace(/"/g, '""')}"`).join(','))
})
const blob = new Blob(['\uFEFF' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `报到名册_${this.currentPlan ? this.tabLabel(this.currentPlan) : '班级'}_${new Date().getTime()}.csv`
link.click()
URL.revokeObjectURL(link.href)
},
/** 跳转班级报到启动页 */
goStartRegister() {
this.$router.push('/register/classregister')
},
/** 根据姓名生成头像底色 */
avatarColor(name) {
const colors = ['#6c7cff', '#f56c6c', '#e6a23c', '#67c23a', '#36cfc9', '#722ed1', '#eb2f96']
const code = String(name || '').split('').reduce((acc, ch) => acc + ch.charCodeAt(0), 0)
return colors[code % colors.length]
}
}
}
</script>
<style scoped>
.class-regdata { background: #f5f7fa; }
/* 顶栏按钮 */
.rd-topbar-actions { display: flex; gap: 8px; }
/* 班级标签 */
.rd-tabs { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; min-height: 28px; }
.rd-tab { padding: 4px 14px; border-radius: 14px; background: #fff; border: 1px solid #e5e8f0; font-size: 12px; color: #4e5969; cursor: pointer; transition: all .2s; }
.rd-tab:hover { color: #2b6bff; border-color: #2b6bff; }
.rd-tab.active { background: #2b6bff; border-color: #2b6bff; color: #fff; font-weight: 600; }
.rd-empty-tab { font-size: 12px; color: #a2aab8; }
/* 统计卡 */
.rd-stats { display: flex; background: #fff; border-radius: 10px; padding: 18px 0; margin-bottom: 16px; box-shadow: 0 1px 4px rgba(31,41,71,.06); }
.rd-stat { flex: 1; padding: 0 24px; }
.rd-stat + .rd-stat { border-left: 1px solid #f0f2f7; }
.rd-stat-label { font-size: 12px; color: #8a94a6; margin-bottom: 8px; }
.rd-stat-value { font-size: 26px; font-weight: 700; line-height: 1; }
.rd-stat-value.dark { color: #1f2329; }
.rd-stat-value.green { color: #10b981; }
.rd-stat-value.red { color: #f04438; }
.rd-unit { font-size: 12px; font-weight: 400; color: #8a94a6; margin-left: 2px; }
/* 中部两栏 */
.rd-middle { display: flex; gap: 16px; margin-bottom: 16px; align-items: stretch; }
.rd-unreported { flex: 0 0 48%; min-width: 0; }
.rd-chart-panel { flex: 1; min-width: 0; display: flex; flex-direction: column; }
@media (max-width: 1200px) { .rd-middle { flex-direction: column; } .rd-unreported { flex: none; } }
/* 通用面板 */
.rd-panel { background: #fff; border-radius: 10px; padding: 16px 20px; box-shadow: 0 1px 4px rgba(31,41,71,.06); margin-bottom: 16px; }
.rd-panel-head { display: flex; align-items: center; margin-bottom: 12px; }
.rd-panel-title { font-size: 15px; font-weight: 700; color: #1f2329; }
.rd-badge-red { margin-left: 10px; font-size: 12px; color: #f04438; font-weight: 600; }
.rd-head-right { margin-left: auto; display: flex; align-items: center; }
.rd-panel-extra { font-size: 12px; color: #8a94a6; }
.rd-filter-item { width: 140px; margin-right: 8px; }
/* 学员单元格 */
.rd-student { display: flex; align-items: center; }
.rd-avatar { flex: none; width: 30px; height: 30px; border-radius: 50%; color: #fff; font-size: 13px; display: flex; align-items: center; justify-content: center; margin-right: 8px; }
.rd-student-name { font-size: 13px; color: #1f2329; line-height: 17px; }
.rd-student-code { font-size: 11px; color: #a2aab8; line-height: 15px; }
/* 分页 */
.rd-pager { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; }
.rd-pager-total { font-size: 12px; color: #8a94a6; }
/* 纯 CSS 柱状图 */
.rd-chart { flex: 1; display: flex; align-items: flex-end; gap: 6px; padding: 10px 4px 0; min-height: 220px; }
.rd-bar-col { flex: 1; display: flex; flex-direction: column; align-items: center; min-width: 0; }
.rd-bar-value { font-size: 11px; color: #4e5969; margin-bottom: 4px; }
.rd-bar-track { width: 100%; max-width: 34px; height: 150px; display: flex; align-items: flex-end; justify-content: center; }
.rd-bar { width: 22px; border-radius: 4px 4px 0 0; background: linear-gradient(180deg, #6ea0ff 0%, #3f7dff 100%); transition: height .3s; }
.rd-bar.peak { background: linear-gradient(180deg, #ffb14d 0%, #f79009 100%); }
.rd-bar-label { margin-top: 6px; font-size: 10px; color: #a2aab8; white-space: nowrap; }
.rd-chart-note { margin-top: 12px; text-align: center; font-size: 12px; color: #8a94a6; }
</style>
+266 -48
View File
@@ -1,20 +1,28 @@
<template>
<div class="app-container">
<el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true">
<el-form-item label="培训计划" prop="planName">
<el-input v-model="queryParams.planName" placeholder="请输入计划名称" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="学员" prop="studentName">
<el-input v-model="queryParams.studentName" placeholder="请输入学员姓名" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="报到方式" prop="reportWay">
<el-select v-model="queryParams.reportWay" placeholder="全部" clearable>
<el-option v-for="item in reportWays" :key="item.value" :label="item.label" :value="item.value" />
<el-form-item label="培训计划" prop="planId">
<el-select
v-model="queryParams.planId"
placeholder="请选择培训计划"
filterable
style="width: 280px"
@change="handleQuery"
>
<el-option
v-for="item in planOptions"
:key="item.planId"
:label="planLabel(item)"
:value="item.planId"
/>
</el-select>
</el-form-item>
<el-form-item label="核验结果" prop="verifyResult">
<el-select v-model="queryParams.verifyResult" placeholder="全部" clearable>
<el-option v-for="item in verifyResults" :key="item.value" :label="item.label" :value="item.value" />
<el-form-item label="学员姓名" prop="studentName">
<el-input v-model="queryParams.studentName" placeholder="请输入学员姓名" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="报到状态" prop="enrollmentStatus">
<el-select v-model="queryParams.enrollmentStatus" placeholder="全部状态" clearable style="width: 140px" @change="handleQuery">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item>
@@ -30,24 +38,69 @@
plain
icon="el-icon-download"
size="mini"
:loading="exportLoading"
@click="handleExport"
>导出</el-button>
</el-col>
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
</el-row>
<el-alert title="报到记录由报到确认流程生成,本页面仅供查询。" type="info" :closable="false" show-icon class="mb8" />
<el-table v-loading="loading" :data="rows">
<el-table-column label="培训计划" prop="planName" min-width="160" show-overflow-tooltip />
<el-table-column label="学员" prop="studentName" min-width="110" />
<el-table-column label="报到方式" align="center" width="110">
<template slot-scope="scope">{{ labelOf(reportWays, scope.row.reportWay) }}</template>
<el-alert
title="报到状态取自参训记录表 train_enrollment 的 enrollment_status 字段(0 待报到 / 1 已报到 / 2 请假 / 3 放弃 / 4 已完成);「已报到」由报到确认流程产生,人工仅可标记请假、放弃与恢复参训。"
type="info"
:closable="false"
show-icon
class="mb8"
/>
<el-table v-loading="loading" :data="rows" :empty-text="emptyText">
<el-table-column label="学员姓名" prop="studentName" min-width="110" />
<el-table-column label="人员编号" prop="personNo" width="130" show-overflow-tooltip />
<el-table-column label="手机号" prop="phone" width="120" />
<el-table-column label="选派单位" prop="deptName" min-width="160" show-overflow-tooltip />
<el-table-column label="报到状态" align="center" width="110">
<template slot-scope="scope">
<el-tag size="mini" :type="statusTag(scope.row.enrollmentStatus).type">
{{ statusTag(scope.row.enrollmentStatus).text }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="核验结果" align="center" width="110">
<template slot-scope="scope">{{ labelOf(verifyResults, scope.row.verifyResult) }}</template>
<el-table-column label="报到时间" align="center" width="170">
<template slot-scope="scope">{{ scope.row.joinTime || '-' }}</template>
</el-table-column>
<el-table-column label="结课时间" align="center" width="170">
<template slot-scope="scope">{{ scope.row.completeTime || '-' }}</template>
</el-table-column>
<el-table-column label="备注" prop="remark" min-width="150" show-overflow-tooltip />
<el-table-column label="操作" align="center" width="200" fixed="right">
<template slot-scope="scope">
<!-- 报到重置:仅「已报到」可重置(解绑人脸 + 清除门锁授权 + 状态回到待报到) -->
<el-button
v-if="scope.row.enrollmentStatus === 1"
size="mini"
type="text"
icon="el-icon-refresh-left"
:disabled="isPlanLocked"
@click="handleResetReport(scope.row)"
>报到重置</el-button>
<!-- 状态变更:与后端状态迁移守卫保持一致,仅暴露请假(2)/放弃(3)/恢复参训 -->
<el-dropdown
v-if="scope.row.enrollmentStatus !== 4"
v-hasPermi="['train:enrollment:edit']"
size="mini"
trigger="click"
@command="cmd => handleStatusAction(cmd, scope.row)"
>
<el-button size="mini" type="text" icon="el-icon-more" :disabled="isPlanLocked">
状态<i class="el-icon-arrow-down el-icon--right" />
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="2" :disabled="scope.row.enrollmentStatus === 2">标记请假</el-dropdown-item>
<el-dropdown-item command="3" :disabled="scope.row.enrollmentStatus === 3">标记放弃</el-dropdown-item>
<el-dropdown-item v-if="scope.row.enrollmentStatus === 2 || scope.row.enrollmentStatus === 3" command="resume">恢复参训</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<el-tag v-if="scope.row.enrollmentStatus === 4" size="mini" type="info">已结训归档</el-tag>
</template>
</el-table-column>
<el-table-column label="报到时间" prop="reportTime" width="170" />
<el-table-column label="核验设备" prop="reportDeviceSn" min-width="130" show-overflow-tooltip />
<el-table-column label="核验说明" prop="verifyNote" min-width="180" show-overflow-tooltip />
</el-table>
<pagination
v-show="total > 0"
@@ -60,47 +113,212 @@
</template>
<script>
import { listReport } from '@/api/train/report'
import { listPlan } from '@/api/train/plan'
import { listPlanEnrollments, updateEnrollmentStatus, resetReport } from '@/api/train/enrollment'
import { ENROLLMENT_STATUS_OPTIONS, formatEnrollmentStatusTag } from '@/views/train/checkin/attendancePolicy'
export default {
name: 'Report',
data() {
return {
loading: true,
loading: false,
exportLoading: false,
showSearch: true,
total: 0,
rows: [],
reportWays: [
{ value: 1, label: '人脸识别' },
{ value: 2, label: '身份证刷卡' },
{ value: 3, label: '二维码' },
{ value: 4, label: '人工录入' }
],
verifyResults: [
{ value: 1, label: '核验通过' },
{ value: 2, label: '核验不匹配' },
{ value: 3, label: '人工放行' }
],
queryParams: { pageNum: 1, pageSize: 10, planName: null, studentName: null, reportWay: null, verifyResult: null }
// 培训计划选项(后端参训名单接口以 planId 为路径参数,必须先选定计划)
planOptions: [],
// 报到状态字典:严格对齐 train_enrollment.enrollment_status 语义
statusOptions: ENROLLMENT_STATUS_OPTIONS,
queryParams: {
planId: null,
studentName: null,
enrollmentStatus: null,
pageNum: 1,
pageSize: 10
}
}
},
created() { this.getList() },
computed: {
selectedPlan() {
return this.planOptions.find(item => item.planId === this.queryParams.planId) || null
},
// plan_status 2=已结训 3=已取消:后端 checkPlanStatusNotLocked 禁止任何参训状态变更
isPlanLocked() {
const status = this.selectedPlan && this.selectedPlan.planStatus
return status === 2 || status === 3
},
emptyText() {
return this.queryParams.planId ? '暂无报到数据' : '请先选择培训计划'
}
},
created() {
this.loadPlans()
},
methods: {
/** 加载培训计划下拉选项,默认选中第一个计划后立即查询 */
loadPlans() {
listPlan({ pageNum: 1, pageSize: 100 }).then(res => {
// 进行中的培训计划(plan_status=1)优先置顶,便于直接查看在训班级的报到状态;
// sort 为稳定排序,同组内保持后端原有顺序(按开班时间倒序)
this.planOptions = (res.rows || []).slice().sort((a, b) => {
const weightA = a.planStatus === 1 ? 0 : 1
const weightB = b.planStatus === 1 ? 0 : 1
return weightA - weightB
})
if (!this.queryParams.planId && this.planOptions.length > 0) {
this.queryParams.planId = this.planOptions[0].planId
}
}).catch(() => {
this.planOptions = []
}).finally(() => {
this.getList()
})
},
planLabel(item) {
if (!item) return ''
return item.className ? `${item.planName}(${item.className})` : item.planName
},
statusTag(status) {
return formatEnrollmentStatusTag(status)
},
/** 查询 train_enrollment 参训名单(报到状态 = enrollment_status) */
getList() {
const planId = this.queryParams.planId
if (!planId) {
this.rows = []
this.total = 0
this.loading = false
return
}
this.loading = true
listReport(this.queryParams).then(response => {
this.rows = response.rows
this.total = response.total
}).finally(() => { this.loading = false })
const query = {
pageNum: this.queryParams.pageNum,
pageSize: this.queryParams.pageSize,
studentName: this.queryParams.studentName,
// el-select 清空时会回填空字符串,这里归一化为 null,避免空串参与条件比较
enrollmentStatus: this.queryParams.enrollmentStatus === '' ? null : this.queryParams.enrollmentStatus
}
listPlanEnrollments(planId, query).then(res => {
this.rows = res.rows || []
this.total = res.total || 0
}).catch(() => {
this.rows = []
this.total = 0
}).finally(() => {
this.loading = false
})
},
labelOf(options, value) {
const match = options.find(item => item.value === value)
return match ? match.label : '-'
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
handleQuery() { this.queryParams.pageNum = 1; this.getList() },
resetQuery() { this.resetForm('queryForm'); this.handleQuery() },
resetQuery() {
// 培训计划为必选查询条件,重置时保留当前计划,仅清空学员姓名与报到状态
const planId = this.queryParams.planId
this.resetForm('queryForm')
this.queryParams.planId = planId
this.handleQuery()
},
/**
* 变更报到状态。
* 状态迁移规则以后端守卫为准:0→2/3、1→2/3、2→(0或1)/3、3→(0或1),4 为终态不可变更。
* 「恢复参训」按是否存在报到时间预判目标状态,最终由后端按报到事实裁决。
*/
handleStatusAction(command, row) {
if (this.isPlanLocked) {
this.$modal.msgWarning('该培训计划已结训或已取消,禁止变更报到状态')
return
}
let status
let label
if (command === 'resume') {
status = row.joinTime ? 1 : 0
label = row.joinTime ? '已报到' : '待报到'
} else {
status = Number(command)
label = status === 2 ? '请假' : '放弃'
}
this.$prompt(`请输入将学员【${row.studentName}】标记为【${label}】的备注原因:`, '变更报到状态', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPattern: /\S+/,
inputErrorMessage: '备注原因不能为空'
}).then(({ value }) => {
return updateEnrollmentStatus(row.enrollmentId, {
enrollmentStatus: status,
remark: value
})
}).then(() => {
this.$modal.msgSuccess(`已标记为${label}`)
this.getList()
}).catch(() => {})
},
/** 报到重置:解绑人脸 → 清除门锁入住授权 → 参训状态回到待报到(宿舍分配保留) */
handleResetReport(row) {
if (!row || row.enrollmentStatus !== 1) return
if (this.isPlanLocked) {
this.$modal.msgWarning('该培训计划已结训或已取消,禁止重置报到状态')
return
}
this.$confirm(
`确认重置学员【${row.studentName || '该学员'}】的报到状态吗?重置后将解除人脸绑定、清除门锁入住授权,参训状态回到「待报到」。`,
'报到重置',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
return resetReport(this.queryParams.planId, row.studentId)
}).then(() => {
this.$modal.msgSuccess('报到状态已重置')
this.getList()
}).catch(() => {})
},
/** 导出当前查询条件下的报到状态数据(前端生成 CSV,字段与页面展示一致) */
handleExport() {
this.download('train/report/export', { ...this.queryParams }, `report_${new Date().getTime()}.xlsx`)
if (!this.queryParams.planId) {
this.$modal.msgWarning('请先选择培训计划')
return
}
this.exportLoading = true
const query = {
pageNum: 1,
pageSize: 10000,
studentName: this.queryParams.studentName,
enrollmentStatus: this.queryParams.enrollmentStatus === '' ? null : this.queryParams.enrollmentStatus
}
listPlanEnrollments(this.queryParams.planId, query).then(res => {
const list = res.rows || []
if (list.length === 0) {
this.$modal.msgWarning('当前条件下没有可导出的报到数据')
return
}
const headers = ['学员姓名', '人员编号', '手机号', '选派单位', '报到状态', '报到时间', '结课时间', '备注']
const lines = [headers.join(',')]
list.forEach(item => {
const cells = [
item.studentName,
item.personNo,
item.phone,
item.deptName,
this.statusTag(item.enrollmentStatus).text,
item.joinTime,
item.completeTime,
item.remark
]
lines.push(cells.map(cell => `"${String(cell === null || cell === undefined ? '' : cell).replace(/"/g, '""')}"`).join(','))
})
const blob = new Blob(['\uFEFF' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `报到状态_${this.queryParams.planId}_${new Date().getTime()}.csv`
link.click()
URL.revokeObjectURL(link.href)
}).catch(() => {}).finally(() => {
this.exportLoading = false
})
}
}
}
@@ -1,5 +1,15 @@
<template>
<div class="app-container">
<!-- 顶部静态布局:标题栏 -->
<div class="page-topbar">
<div class="topbar-left">
<h2 class="topbar-title">教学资源管理</h2>
</div>
<div class="topbar-actions">
<el-button icon="el-icon-back" size="small" @click="goBackCourse">返回课程管理</el-button>
</div>
</div>
<el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true" label-width="68px">
<el-form-item label="资源名称" prop="resourceName">
<el-input
@@ -263,6 +273,10 @@ export default {
this.getList()
},
methods: {
/** 返回课程管理页面(课程信息路由为 /coursemgr/course) */
goBackCourse() {
this.$router.push('/equip/course')
},
/** 查询教学资源列表 */
getList() {
this.loading = true
+87 -5
View File
@@ -1,5 +1,19 @@
<template>
<div class="app-container">
<!-- 顶部静态布局:标题栏 -->
<div class="page-topbar">
<div class="topbar-left">
<h2 class="topbar-title">实训场地信息</h2>
<span class="topbar-sub">在册 {{ total }} 处</span>
</div>
<div class="topbar-actions">
<el-button icon="el-icon-download" size="small" @click="handleExport">导出实训场地台账</el-button>
<el-button type="primary" icon="el-icon-plus" size="small" @click="handleAdd">新增实训场地</el-button>
</div>
</div>
<!-- 顶部静态布局:筛选卡片 -->
<div class="topbar-filter">
<el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true" label-width="68px">
<el-form-item label="场地名称" prop="siteName">
<el-input
@@ -36,8 +50,8 @@
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
</div>
<!-- <el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
v-hasPermi="['train:site:edit']"
@@ -60,10 +74,11 @@
>修改</el-button>
</el-col>
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
</el-row>
</el-row>-->
<el-table v-loading="loading" :data="siteList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<!-- <el-table-column type="selection" width="55" align="center" />-->
<el-table-column label="序号" align="center" type="index" width="60" :index="indexMethod" />
<el-table-column label="实训场地名称" align="center" prop="siteName" min-width="140" />
<el-table-column label="场地位置" align="center" prop="siteLocation" />
<el-table-column label="最大容纳人数" align="center" prop="capacity">
@@ -82,6 +97,12 @@
<!-- <el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true" />-->
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-view"
@click="handleDetail(scope.row)"
>详情</el-button>
<el-button
v-hasPermi="['train:site:edit']"
size="mini"
@@ -141,6 +162,46 @@
<el-button @click="cancel">取 消</el-button>
</div>
</el-dialog>
<!-- 实训场地详情对话框 -->
<el-dialog title="实训场地详情" :visible.sync="detailOpen" width="820px" append-to-body custom-class="detail-dialog">
<div class="detail-dialog-body">
<el-descriptions :column="2" border>
<el-descriptions-item>
<template slot="label"><span class="detail-dialog-label">实训场地名称</span></template>
{{ detail.siteName || '-' }}
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="detail-dialog-label">场地位置</span></template>
{{ detail.siteLocation || '-' }}
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="detail-dialog-label">最大容纳人数</span></template>
{{ detail.capacity != null ? detail.capacity + ' 人' : '-' }}
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="detail-dialog-label">适用专业</span></template>
{{ detail.major || '-' }}
</el-descriptions-item>
<el-descriptions-item :span="2">
<template slot="label"><span class="detail-dialog-label">场地配套设备</span></template>
{{ detail.siteDevice || '-' }}
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="detail-dialog-label">场地状态</span></template>
<el-tag v-if="detail.siteStatus === 1" type="success" size="mini">可用</el-tag>
<el-tag v-else type="danger" size="mini">停用</el-tag>
</el-descriptions-item>
<el-descriptions-item>
<template slot="label"><span class="detail-dialog-label">备注</span></template>
{{ detail.remark || '-' }}
</el-descriptions-item>
</el-descriptions>
</div>
<div slot="footer" class="dialog-footer">
<el-button @click="detailOpen = false">关 闭</el-button>
</div>
</el-dialog>
</div>
</template>
@@ -169,6 +230,9 @@ export default {
title: '',
// 是否显示弹出层
open: false,
// 详情弹窗
detailOpen: false,
detail: {},
// 查询参数
queryParams: {
pageNum: 1,
@@ -208,6 +272,11 @@ export default {
this.getList()
},
methods: {
/** 表格序号:按当前分页顺序连续编号 */
indexMethod(index) {
const { pageNum = 1, pageSize = 10 } = this.queryParams
return (pageNum - 1) * pageSize + index + 1
},
/** 查询实训场地列表 */
getList() {
this.loading = true
@@ -266,6 +335,13 @@ export default {
this.$modal.msgSuccess('删除成功')
}).catch(() => {})
},
/** 详情按钮操作 */
handleDetail(row) {
getSite(row.siteId).then(response => {
this.detail = response.data || {}
this.detailOpen = true
}).catch(() => {})
},
/** 新增按钮操作 */
handleAdd() {
this.reset()
@@ -301,7 +377,13 @@ export default {
}
}
})
}
},
/** 导出按钮操作 */
handleExport() {
this.download('train/site/export', {
...this.queryParams
}, `site_${new Date().getTime()}.xlsx`)
},
}
}
</script>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -14,7 +14,7 @@ function resolve(dir) {
const name = process.env.VUE_APP_TITLE || 'SYFJGYD学员管理系统' // 网页标题
const port = process.env.port || process.env.npm_config_port || 1024 // 端口
const port = process.env.port || process.env.npm_config_port || 1026 // 端口
const { VueLoaderPlugin } = require('vue-loader')
// vue.config.js 配置说明
//官方vue.config.js 参考文档 https://cli.vuejs.org/zh/config/#css-loaderoptions
@@ -44,6 +44,7 @@ module.exports = {
// detail: https://cli.vuejs.org/config/#devserver-proxy
[process.env.VUE_APP_BASE_API]: {
target: process.env.VUE_APP_BACKEND_URL || 'http://127.0.0.1:8096',
//target: process.env.VUE_APP_BACKEND_URL || 'http://192.168.1.202:8096',
changeOrigin: true,
pathRewrite: {
['^' + process.env.VUE_APP_BASE_API]: process.env.VUE_APP_BACKEND_PATH !== undefined ? process.env.VUE_APP_BACKEND_PATH : ''
+159
View File
@@ -0,0 +1,159 @@
'use strict'
const crypto = require('crypto')
try {
crypto.createHash('md4')
} catch (e) {
const origCreateHash = crypto.createHash
crypto.createHash = (alg, opts) => origCreateHash(alg === 'md4' ? 'sha256' : alg, opts)
}
const path = require('path')
function resolve(dir) {
return path.join(__dirname, dir)
}
const name = process.env.VUE_APP_TITLE || 'SYFJGYD学员管理系统' // 网页标题
const port = process.env.port || process.env.npm_config_port || 1026 // 端口
const { VueLoaderPlugin } = require('vue-loader')
// vue.config.js 配置说明
//官方vue.config.js 参考文档 https://cli.vuejs.org/zh/config/#css-loaderoptions
// 这里只列一部分,具体配置参考文档
module.exports = {
// 部署生产环境和开发环境下的URL。
// 默认情况下,Vue CLI 会假设你的应用是被部署在一个域名的根路径上
// 例如 https://www.ruoyi.vip/。如果应用被部署在一个子路径上,你就需要用这个选项指定这个子路径。例如,如果你的应用被部署在 https://www.ruoyi.vip/admin/,则设置 baseUrl 为 /admin/。
publicPath: process.env.NODE_ENV === "production" ? "/" : "/",
// 在npm run build 或 yarn build 时 ,生成文件的目录名称(要和baseUrl的生产环境路径一致)(默认dist)
outputDir: 'dist',
// 用于放置生成的静态资源 (js、css、img、fonts) 的;(项目打包之后,静态资源会放在这个文件夹下)
assetsDir: 'static',
// 是否开启eslint保存检测,有效值:ture | false | 'error'
lintOnSave: process.env.NODE_ENV === 'development',
// 如果你不需要生产环境的 source map,可以将其设置为 false 以加速生产环境构建。
productionSourceMap: false,
// Quill 2 ships class fields; match the tested fusion build under Webpack 4.
transpileDependencies: ['quill'],
// webpack-dev-server 相关配置
devServer: {
host: '0.0.0.0',
port: port,
open: false,
proxy: {
// detail: https://cli.vuejs.org/config/#devserver-proxy
[process.env.VUE_APP_BASE_API]: {
//target: process.env.VUE_APP_BACKEND_URL || 'http://127.0.0.1:8096',
target: process.env.VUE_APP_BACKEND_URL || 'http://192.168.1.230:8096',
changeOrigin: true,
pathRewrite: {
['^' + process.env.VUE_APP_BASE_API]: process.env.VUE_APP_BACKEND_PATH !== undefined ? process.env.VUE_APP_BACKEND_PATH : ''
}
}
},
disableHostCheck: true
},
configureWebpack: {
name: name,
resolve: {
alias: {
'@': resolve('src'),
'neo4j-driver-alias': 'node_modules/neo4j-driver/lib/browser/neo4j-web.min.js'
}
},
plugins: [
//new VueLoaderPlugin()
//'coffeescript'
],
performance: {
hints: 'warning',
// 入口起点的最大体积
maxEntrypointSize: 50000000,
// 生成文件的最大体积
maxAssetSize: 30000000,
// 只给出 js 文件的性能提示
assetFilter: function (assetFilename) {
return assetFilename.endsWith('.js')
}
},
module: {
rules: [
{test:/\.coffee$/, use:'coffee-loader',exclude:/node_modules/}
// {test: /\.css$/, use:['style-loader','css-loader']},//配置处理.css文件的第三方处理规则
// {test: /\.less$/, use: ["style-loader",'css-loader','less-loader']},
// // {test: /\.scss$/, use: ["style-loader",'css-loader','sass-loader']},
// {test: /\.(jpg|png|gif|bmp|jpeg)$/, use: "url-loader?limit=8000"},
// // {test: /\.(tff|eot|svg|woff|woff2)$/, use: "url-loader"},
// {test:/\.js$/, use:'babel-loader',exclude:/node_modules/},
// {test: /\.vue$/, use: 'vue-loader'}
]
}
},
chainWebpack(config) {
config.plugins.delete('preload') // TODO: need test
config.plugins.delete('prefetch') // TODO: need test
// set svg-sprite-loader
config.module
.rule('svg')
.exclude.add(resolve('src/assets/icons'))
.end()
config.module
.rule('icons')
.test(/\.svg$/)
.include.add(resolve('src/assets/icons'))
.end()
.use('svg-sprite-loader')
.loader('svg-sprite-loader')
.options({
symbolId: 'icon-[name]'
})
.end()
config
.when(process.env.NODE_ENV !== 'development',
config => {
config
.plugin('ScriptExtHtmlWebpackPlugin')
.after('html')
.use('script-ext-html-webpack-plugin', [{
// `runtime` must same as runtimeChunk name. default is `runtime`
inline: /runtime\..*\.js$/
}])
.end()
config
.optimization.splitChunks({
chunks: 'all',
cacheGroups: {
libs: {
name: 'chunk-libs',
test: /[\\/]node_modules[\\/]/,
priority: 10,
chunks: 'initial' // only package third parties that are initially dependent
},
elementUI: {
name: 'chunk-elementUI', // split elementUI into a single package
priority: 20, // the weight needs to be larger than libs and app or it will be packaged into libs or app
test: /[\\/]node_modules[\\/]_?element-ui(.*)/ // in order to adapt to cnpm
},
commons: {
name: 'chunk-commons',
test: resolve('src/components'), // can customize your rules
minChunks: 3, // minimum common number
priority: 5,
reuseExistingChunk: true
}
}
})
config.optimization.runtimeChunk('single'),
{
from: path.resolve(__dirname, './public/robots.txt'), //防爬虫文件
to: './' //到根目录下
}
}
)
}
}
+159
View File
@@ -0,0 +1,159 @@
'use strict'
const crypto = require('crypto')
try {
crypto.createHash('md4')
} catch (e) {
const origCreateHash = crypto.createHash
crypto.createHash = (alg, opts) => origCreateHash(alg === 'md4' ? 'sha256' : alg, opts)
}
const path = require('path')
function resolve(dir) {
return path.join(__dirname, dir)
}
const name = process.env.VUE_APP_TITLE || 'SYFJGYD学员管理系统' // 网页标题
const port = process.env.port || process.env.npm_config_port || 1026 // 端口
const { VueLoaderPlugin } = require('vue-loader')
// vue.config.js 配置说明
//官方vue.config.js 参考文档 https://cli.vuejs.org/zh/config/#css-loaderoptions
// 这里只列一部分,具体配置参考文档
module.exports = {
// 部署生产环境和开发环境下的URL。
// 默认情况下,Vue CLI 会假设你的应用是被部署在一个域名的根路径上
// 例如 https://www.ruoyi.vip/。如果应用被部署在一个子路径上,你就需要用这个选项指定这个子路径。例如,如果你的应用被部署在 https://www.ruoyi.vip/admin/,则设置 baseUrl 为 /admin/。
publicPath: process.env.NODE_ENV === "production" ? "/" : "/",
// 在npm run build 或 yarn build 时 ,生成文件的目录名称(要和baseUrl的生产环境路径一致)(默认dist)
outputDir: 'dist',
// 用于放置生成的静态资源 (js、css、img、fonts) 的;(项目打包之后,静态资源会放在这个文件夹下)
assetsDir: 'static',
// 是否开启eslint保存检测,有效值:ture | false | 'error'
lintOnSave: process.env.NODE_ENV === 'development',
// 如果你不需要生产环境的 source map,可以将其设置为 false 以加速生产环境构建。
productionSourceMap: false,
// Quill 2 ships class fields; match the tested fusion build under Webpack 4.
transpileDependencies: ['quill'],
// webpack-dev-server 相关配置
devServer: {
host: '0.0.0.0',
port: port,
open: false,
proxy: {
// detail: https://cli.vuejs.org/config/#devserver-proxy
[process.env.VUE_APP_BASE_API]: {
//target: process.env.VUE_APP_BACKEND_URL || 'http://127.0.0.1:8096',
target: process.env.VUE_APP_BACKEND_URL || 'http://192.168.106.24:8096',
changeOrigin: true,
pathRewrite: {
['^' + process.env.VUE_APP_BASE_API]: process.env.VUE_APP_BACKEND_PATH !== undefined ? process.env.VUE_APP_BACKEND_PATH : ''
}
}
},
disableHostCheck: true
},
configureWebpack: {
name: name,
resolve: {
alias: {
'@': resolve('src'),
'neo4j-driver-alias': 'node_modules/neo4j-driver/lib/browser/neo4j-web.min.js'
}
},
plugins: [
//new VueLoaderPlugin()
//'coffeescript'
],
performance: {
hints: 'warning',
// 入口起点的最大体积
maxEntrypointSize: 50000000,
// 生成文件的最大体积
maxAssetSize: 30000000,
// 只给出 js 文件的性能提示
assetFilter: function (assetFilename) {
return assetFilename.endsWith('.js')
}
},
module: {
rules: [
{test:/\.coffee$/, use:'coffee-loader',exclude:/node_modules/}
// {test: /\.css$/, use:['style-loader','css-loader']},//配置处理.css文件的第三方处理规则
// {test: /\.less$/, use: ["style-loader",'css-loader','less-loader']},
// // {test: /\.scss$/, use: ["style-loader",'css-loader','sass-loader']},
// {test: /\.(jpg|png|gif|bmp|jpeg)$/, use: "url-loader?limit=8000"},
// // {test: /\.(tff|eot|svg|woff|woff2)$/, use: "url-loader"},
// {test:/\.js$/, use:'babel-loader',exclude:/node_modules/},
// {test: /\.vue$/, use: 'vue-loader'}
]
}
},
chainWebpack(config) {
config.plugins.delete('preload') // TODO: need test
config.plugins.delete('prefetch') // TODO: need test
// set svg-sprite-loader
config.module
.rule('svg')
.exclude.add(resolve('src/assets/icons'))
.end()
config.module
.rule('icons')
.test(/\.svg$/)
.include.add(resolve('src/assets/icons'))
.end()
.use('svg-sprite-loader')
.loader('svg-sprite-loader')
.options({
symbolId: 'icon-[name]'
})
.end()
config
.when(process.env.NODE_ENV !== 'development',
config => {
config
.plugin('ScriptExtHtmlWebpackPlugin')
.after('html')
.use('script-ext-html-webpack-plugin', [{
// `runtime` must same as runtimeChunk name. default is `runtime`
inline: /runtime\..*\.js$/
}])
.end()
config
.optimization.splitChunks({
chunks: 'all',
cacheGroups: {
libs: {
name: 'chunk-libs',
test: /[\\/]node_modules[\\/]/,
priority: 10,
chunks: 'initial' // only package third parties that are initially dependent
},
elementUI: {
name: 'chunk-elementUI', // split elementUI into a single package
priority: 20, // the weight needs to be larger than libs and app or it will be packaged into libs or app
test: /[\\/]node_modules[\\/]_?element-ui(.*)/ // in order to adapt to cnpm
},
commons: {
name: 'chunk-commons',
test: resolve('src/components'), // can customize your rules
minChunks: 3, // minimum common number
priority: 5,
reuseExistingChunk: true
}
}
})
config.optimization.runtimeChunk('single'),
{
from: path.resolve(__dirname, './public/robots.txt'), //防爬虫文件
to: './' //到根目录下
}
}
)
}
}
+159
View File
@@ -0,0 +1,159 @@
'use strict'
const crypto = require('crypto')
try {
crypto.createHash('md4')
} catch (e) {
const origCreateHash = crypto.createHash
crypto.createHash = (alg, opts) => origCreateHash(alg === 'md4' ? 'sha256' : alg, opts)
}
const path = require('path')
function resolve(dir) {
return path.join(__dirname, dir)
}
const name = process.env.VUE_APP_TITLE || 'SYFJGYD学员管理系统' // 网页标题
const port = process.env.port || process.env.npm_config_port || 1026 // 端口
const { VueLoaderPlugin } = require('vue-loader')
// vue.config.js 配置说明
//官方vue.config.js 参考文档 https://cli.vuejs.org/zh/config/#css-loaderoptions
// 这里只列一部分,具体配置参考文档
module.exports = {
// 部署生产环境和开发环境下的URL。
// 默认情况下,Vue CLI 会假设你的应用是被部署在一个域名的根路径上
// 例如 https://www.ruoyi.vip/。如果应用被部署在一个子路径上,你就需要用这个选项指定这个子路径。例如,如果你的应用被部署在 https://www.ruoyi.vip/admin/,则设置 baseUrl 为 /admin/。
publicPath: process.env.NODE_ENV === "production" ? "/" : "/",
// 在npm run build 或 yarn build 时 ,生成文件的目录名称(要和baseUrl的生产环境路径一致)(默认dist)
outputDir: 'dist',
// 用于放置生成的静态资源 (js、css、img、fonts) 的;(项目打包之后,静态资源会放在这个文件夹下)
assetsDir: 'static',
// 是否开启eslint保存检测,有效值:ture | false | 'error'
lintOnSave: process.env.NODE_ENV === 'development',
// 如果你不需要生产环境的 source map,可以将其设置为 false 以加速生产环境构建。
productionSourceMap: false,
// Quill 2 ships class fields; match the tested fusion build under Webpack 4.
transpileDependencies: ['quill'],
// webpack-dev-server 相关配置
devServer: {
host: '0.0.0.0',
port: port,
open: false,
proxy: {
// detail: https://cli.vuejs.org/config/#devserver-proxy
[process.env.VUE_APP_BASE_API]: {
target: process.env.VUE_APP_BACKEND_URL || 'http://127.0.0.1:8096',
//target: process.env.VUE_APP_BACKEND_URL || 'http://192.168.1.202:8096',
changeOrigin: true,
pathRewrite: {
['^' + process.env.VUE_APP_BASE_API]: process.env.VUE_APP_BACKEND_PATH !== undefined ? process.env.VUE_APP_BACKEND_PATH : ''
}
}
},
disableHostCheck: true
},
configureWebpack: {
name: name,
resolve: {
alias: {
'@': resolve('src'),
'neo4j-driver-alias': 'node_modules/neo4j-driver/lib/browser/neo4j-web.min.js'
}
},
plugins: [
//new VueLoaderPlugin()
//'coffeescript'
],
performance: {
hints: 'warning',
// 入口起点的最大体积
maxEntrypointSize: 50000000,
// 生成文件的最大体积
maxAssetSize: 30000000,
// 只给出 js 文件的性能提示
assetFilter: function (assetFilename) {
return assetFilename.endsWith('.js')
}
},
module: {
rules: [
{test:/\.coffee$/, use:'coffee-loader',exclude:/node_modules/}
// {test: /\.css$/, use:['style-loader','css-loader']},//配置处理.css文件的第三方处理规则
// {test: /\.less$/, use: ["style-loader",'css-loader','less-loader']},
// // {test: /\.scss$/, use: ["style-loader",'css-loader','sass-loader']},
// {test: /\.(jpg|png|gif|bmp|jpeg)$/, use: "url-loader?limit=8000"},
// // {test: /\.(tff|eot|svg|woff|woff2)$/, use: "url-loader"},
// {test:/\.js$/, use:'babel-loader',exclude:/node_modules/},
// {test: /\.vue$/, use: 'vue-loader'}
]
}
},
chainWebpack(config) {
config.plugins.delete('preload') // TODO: need test
config.plugins.delete('prefetch') // TODO: need test
// set svg-sprite-loader
config.module
.rule('svg')
.exclude.add(resolve('src/assets/icons'))
.end()
config.module
.rule('icons')
.test(/\.svg$/)
.include.add(resolve('src/assets/icons'))
.end()
.use('svg-sprite-loader')
.loader('svg-sprite-loader')
.options({
symbolId: 'icon-[name]'
})
.end()
config
.when(process.env.NODE_ENV !== 'development',
config => {
config
.plugin('ScriptExtHtmlWebpackPlugin')
.after('html')
.use('script-ext-html-webpack-plugin', [{
// `runtime` must same as runtimeChunk name. default is `runtime`
inline: /runtime\..*\.js$/
}])
.end()
config
.optimization.splitChunks({
chunks: 'all',
cacheGroups: {
libs: {
name: 'chunk-libs',
test: /[\\/]node_modules[\\/]/,
priority: 10,
chunks: 'initial' // only package third parties that are initially dependent
},
elementUI: {
name: 'chunk-elementUI', // split elementUI into a single package
priority: 20, // the weight needs to be larger than libs and app or it will be packaged into libs or app
test: /[\\/]node_modules[\\/]_?element-ui(.*)/ // in order to adapt to cnpm
},
commons: {
name: 'chunk-commons',
test: resolve('src/components'), // can customize your rules
minChunks: 3, // minimum common number
priority: 5,
reuseExistingChunk: true
}
}
})
config.optimization.runtimeChunk('single'),
{
from: path.resolve(__dirname, './public/robots.txt'), //防爬虫文件
to: './' //到根目录下
}
}
)
}
}
Loaded 100 of 534 files, more files were not shown because too many files have changed in this diff. Show more