chore(sync): 同步当前工作区全部变更(学员学号可编辑并留空自动生成等)
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 <> #{enrollmentId}
|
||||
and a2.accommodation_status <> 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 <> a.enrollment_id
|
||||
and a2.accommodation_status <> 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,陈旧) |
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -42,3 +42,11 @@ export function completePlan(planId) {
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
|
||||
// 删除开班计划
|
||||
export function delPlan(planId) {
|
||||
return request({
|
||||
url: '/train/plans/' + planId,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 509 KiB After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 509 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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/',
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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 || '',
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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 || '',
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
/**
|
||||
* 持久化标签页
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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 || '基地'}`
|
||||
},
|
||||
// 编辑历史课次时,原授课教师若已停用则不在候选列表中,
|
||||
// 临时补一条禁用选项保证原教师名可见且不可被重新选中,绝不自动换人
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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 : ''
|
||||
|
||||
@@ -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: './' //到根目录下
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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: './' //到根目录下
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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: './' //到根目录下
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||