1、添加实施计划统计分析;

2、添加单课程实施计划列表;
3、恢复同步班历;
4、冲突项添加颜色分级;
5、校历补上正课;
6、添加教学场地总览;
This commit is contained in:
2026-09-17 18:20:48 +08:00
parent 351df2cba2
commit 971ea46da5
11 changed files with 973 additions and 48 deletions
@@ -13,10 +13,11 @@
<result column="备注显示" property="bzxs" /> <result column="备注显示" property="bzxs" />
<result column="自动排课" property="zdpk" /> <result column="自动排课" property="zdpk" />
<result column="节次" property="courseClass"/> <result column="节次" property="courseClass"/>
<result column="正课" property="zk"/>
</resultMap> </resultMap>
<sql id="Base_Column_List"> <sql id="Base_Column_List">
编号, 年度, 假期时间, 假期名称, 加粗, 备注, 可排课, 备注显示, 自动排课, 节次 编号, 年度, 假期时间, 假期名称, 加粗, 备注, 可排课, 备注显示, 自动排课, 节次, 正课
</sql> </sql>
<!-- 分页查询 --> <!-- 分页查询 -->
@@ -59,6 +59,12 @@ public class ClassSemesterServiceImpl implements ClassSemesterService {
@Resource @Resource
private SemesterMapper semesterMapper; private SemesterMapper semesterMapper;
@Resource
private com.roomroot.jwgl.mapper.JQBMapper jqbMapper;
@Resource
private com.roomroot.jwgl.service.ClassEventCalendarService classEventCalendarService;
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@Override @Override
public void add(XYDNDXQJBXXB xydndxqjbxxb) { public void add(XYDNDXQJBXXB xydndxqjbxxb) {
@@ -247,46 +253,23 @@ public class ClassSemesterServiceImpl implements ClassSemesterService {
} }
// 更新日期范围 // 更新日期范围
xydndxqjbxxb.setKxrq(LocalDate.parse(xqkssj)); LocalDate kxrq = LocalDate.parse(xqkssj);
xydndxqjbxxb.setJsrq(LocalDate.parse(xqjssj)); LocalDate jsrq = LocalDate.parse(xqjssj);
xydndxqjbxxb.setKxrq(kxrq);
xydndxqjbxxb.setJsrq(jsrq);
classSemesterMapper.updateById(xydndxqjbxxb); classSemesterMapper.updateById(xydndxqjbxxb);
count++; count++;
// // 删除日期范围之外的日历记录 // 同步班历(假期表 JQB):删除新范围之外的格子,再按校历补齐范围内缺失的格子。
// classSemesterCalendarMapper.deleteOutsideDateRange(bh, xqkssj, xqjssj); // JQB.假期时间 存的是 yyyyMMdd 整数(与班历写入口径一致)。
// int start = Integer.parseInt(kxrq.format(DateTimeFormatter.ofPattern("yyyyMMdd")));
// // 查询现有任务记录 int end = Integer.parseInt(jsrq.format(DateTimeFormatter.ofPattern("yyyyMMdd")));
// List<XYDRWB> existingCalendar = classSemesterCalendarMapper.selectByBcxqbhAndDateRange(bh, xqkssj, xqjssj); jqbMapper.delete(new LambdaQueryWrapper<com.roomroot.jwgl.entity.JQB>()
// .eq(com.roomroot.jwgl.entity.JQB::getXydxqbh, bh)
// // 查询学期校历,用于补充缺少的记录 .and(w -> w.lt(com.roomroot.jwgl.entity.JQB::getJqsj, start)
// String kxrqStr = xydndxqjbxxb.getKxrq() != null ? xydndxqjbxxb.getKxrq().toString() : xqkssj; .or().gt(com.roomroot.jwgl.entity.JQB::getJqsj, end)));
// String jsrqStr = xydndxqjbxxb.getJsrq() != null ? xydndxqjbxxb.getJsrq().toString() : xqjssj; // 只补缺失格、不覆盖人工改动(与班历弹窗「同步校历」同一口径)
// List<XQXLB> xqxlbs = semesterCalendarMapper.selectByNdAndJqsjRange(xydndxqjbxxb.getNd(), kxrqStr, jsrqStr); classEventCalendarService.resync(bh, true);
//
// if (!CollectionUtils.isEmpty(xqxlbs)) {
// // 提取已有编号
// java.util.Set<String> existingBhs = new java.util.HashSet<>();
// for (XYDRWB cal : existingCalendar) {
// existingBhs.add(cal.getBh());
// }
//
// // 补充缺少的记录
// for (XQXLB xqxlb : xqxlbs) {
// XYDRWB xydrwb = new XYDRWB();
// String newBh = UuidUtil.getUUID();
// if (!existingBhs.contains(newBh)) {
// xydrwb.setBh(newBh);
// xydrwb.setNd(xydndxqjbxxb.getNd());
// xydrwb.setXydbh(xydndxqjbxxb.getXydbh());
// xydrwb.setXqdc(xydndxqjbxxb.getXqdc());
// xydrwb.setXydxqbh(bh);
// xydrwb.setDelFlag(0);
// classSemesterCalendarMapper.insert(xydrwb);
// }
// }
// }
//
// count++;
} }
return count; return count;
@@ -29,8 +29,12 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/** /**
* 教学运行数据导入服务实现类。 * 教学运行数据导入服务实现类。
@@ -190,6 +194,9 @@ public class CourseRunningImportServiceImpl implements CourseRunningImportServic
@Resource @Resource
private OperationAnalysisMapper operationAnalysisMapper; private OperationAnalysisMapper operationAnalysisMapper;
@Resource
private com.roomroot.jwgl.mapper.ClassRoomMapper classRoomMapper;
/** /**
* 教员表数据访问接口,用于审批时根据教员编号查询教研室代号。 * 教员表数据访问接口,用于审批时根据教员编号查询教研室代号。
*/ */
@@ -569,7 +576,10 @@ public class CourseRunningImportServiceImpl implements CourseRunningImportServic
query.getTxzt() query.getTxzt()
); );
// 步骤5:封装分页结果并返回 // 步骤5:回填主讲/辅讲教员与上课场地(按本页课次批量查,避免逐行查库)
fillLessonTeachersAndRooms(resultPage.getRecords());
// 步骤6:封装分页结果并返回
return new PageResult<>( return new PageResult<>(
resultPage.getRecords(), resultPage.getRecords(),
resultPage.getTotal(), resultPage.getTotal(),
@@ -578,6 +588,71 @@ public class CourseRunningImportServiceImpl implements CourseRunningImportServic
); );
} }
/**
* 回填课次的主讲/辅讲教员姓名与场地名称。
* <p>主讲 = 实施_课程表_辅助教员.主教员=1;其余为辅讲。场地取 实施_课程表_教室 关联的教室名称。</p>
*/
private void fillLessonTeachersAndRooms(List<TeachingPlanLessonVO> records) {
if (records == null || records.isEmpty()) {
return;
}
List<String> lessonBhs = records.stream()
.map(TeachingPlanLessonVO::getSskcbbh)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
if (lessonBhs.isEmpty()) {
return;
}
Map<String, List<String>> mainTeachers = new HashMap<>();
Map<String, List<String>> subTeachers = new HashMap<>();
Set<String> jybhSet = new HashSet<>();
for (SSKCBFZJY link : sskcbfzjymMapper.selectList(new LambdaQueryWrapper<SSKCBFZJY>()
.in(SSKCBFZJY::getSskcbbh, lessonBhs))) {
if (link.getFzjybh() == null || link.getFzjybh().isEmpty()) {
continue;
}
jybhSet.add(link.getFzjybh());
(link.getZjy() != null && link.getZjy() == 1 ? mainTeachers : subTeachers)
.computeIfAbsent(link.getSskcbbh(), k -> new ArrayList<>())
.add(link.getFzjybh());
}
Map<String, String> teacherNames = jybhSet.isEmpty() ? new HashMap<>()
: jybMapper.selectList(new LambdaQueryWrapper<com.roomroot.jwgl.entity.JYB>()
.in(com.roomroot.jwgl.entity.JYB::getJybh, jybhSet))
.stream()
.filter(Objects::nonNull)
.collect(Collectors.toMap(com.roomroot.jwgl.entity.JYB::getJybh,
j -> j.getJyxm() == null ? j.getJybh() : j.getJyxm(), (a, b) -> a));
Map<String, List<String>> rooms = new HashMap<>();
Set<String> jsbhSet = new HashSet<>();
for (SSKCBJS link : sskcbjsMapper.selectList(new LambdaQueryWrapper<SSKCBJS>()
.in(SSKCBJS::getSskcbbh, lessonBhs))) {
if (link.getJsbh() == null || link.getJsbh().isEmpty()) {
continue;
}
jsbhSet.add(link.getJsbh());
rooms.computeIfAbsent(link.getSskcbbh(), k -> new ArrayList<>()).add(link.getJsbh());
}
Map<String, String> roomNames = jsbhSet.isEmpty() ? new HashMap<>()
: classRoomMapper.selectList(new LambdaQueryWrapper<com.roomroot.jwgl.entity.JSB>()
.in(com.roomroot.jwgl.entity.JSB::getJsbh, jsbhSet)
.eq(com.roomroot.jwgl.entity.JSB::getDelFlag, 0))
.stream()
.filter(Objects::nonNull)
.collect(Collectors.toMap(com.roomroot.jwgl.entity.JSB::getJsbh,
j -> j.getJsmc() == null ? j.getJsbh() : j.getJsmc(), (a, b) -> a));
for (TeachingPlanLessonVO vo : records) {
String bh = vo.getSskcbbh();
vo.setZjyxm(mainTeachers.getOrDefault(bh, List.of()).stream()
.map(b2 -> teacherNames.getOrDefault(b2, b2)).collect(Collectors.joining("、")));
vo.setFjyxm(subTeachers.getOrDefault(bh, List.of()).stream()
.map(b2 -> teacherNames.getOrDefault(b2, b2)).collect(Collectors.joining("、")));
vo.setJsmc(rooms.getOrDefault(bh, List.of()).stream()
.map(b2 -> roomNames.getOrDefault(b2, b2)).collect(Collectors.joining("、")));
}
}
/** /**
* 按查询条件导出课次数据为 Excel 文件。 * 按查询条件导出课次数据为 Excel 文件。
* *
@@ -280,9 +280,14 @@ public class TimetableConflictServiceImpl implements TimetableConflictService {
case EVENT_CONFLICT -> checkEventConflicts(nd); case EVENT_CONFLICT -> checkEventConflicts(nd);
case GUARANTEE_CONFLICT -> Collections.emptyList(); case GUARANTEE_CONFLICT -> Collections.emptyList();
}; };
// 本检查器的维度全部为硬冲突(双占 / 三类历不可排);软提示由排课窗实时计算 // 未显式标注级别的明细默认硬冲突(双占 / 三类历不可排);
// checkEventConflicts 中"非正课时段"已标 soft,不在此覆盖
if (details != null) { if (details != null) {
details.forEach(d -> d.setLevel("hard")); details.forEach(d -> {
if (d.getLevel() == null || d.getLevel().isEmpty()) {
d.setLevel("hard");
}
});
} }
} catch (Exception e) { } catch (Exception e) {
log.error("[课表冲突检查][维度:{}][年度:{}] 执行SQL失败: {}", log.error("[课表冲突检查][维度:{}][年度:{}] 执行SQL失败: {}",
@@ -589,6 +594,72 @@ public class TimetableConflictServiceImpl implements TimetableConflictService {
} }
} }
} }
// ---- 软提示:非正课时段(正课=0 但可排课)已有课次 → 橙色提示,不算硬冲突 ----
Set<String> hardSchoolKeys = new HashSet<>();
for (XQXLB grid : schoolUnavailable) {
for (Integer jc : PeriodUtil.parsePeriods(grid.getCourseClass())) {
hardSchoolKeys.add(grid.getJqsj() + "#" + jc);
}
}
List<XQXLB> schoolSoft = semesterCalendarMapper.selectList(new LambdaQueryWrapper<XQXLB>()
.eq(XQXLB::getNd, nd)
.eq(XQXLB::getZk, false)
.ne(XQXLB::getKpk, false));
for (XQXLB grid : schoolSoft) {
for (Integer jc : PeriodUtil.parsePeriods(grid.getCourseClass())) {
if (hardSchoolKeys.contains(grid.getJqsj() + "#" + jc)) {
continue;
}
List<SSKCB> hits = byGrid.get(grid.getJqsj() + "#" + jc);
if (hits != null && !hits.isEmpty()) {
TimetableConflictDetailVO d = buildEventDetail(
"SOFT_SCHOOL|" + grid.getJqsj() + "#" + jc, "非正课时段",
grid.getJqsj(), jc, hits, courseNames,
"校历标记该日期节次为非正课时段,但已有课次安排");
d.setLevel("soft");
result.add(d);
}
}
}
{
List<JQB> classSoft = semesterBhs.isEmpty() ? Collections.emptyList()
: jqbMapper.selectList(new LambdaQueryWrapper<JQB>()
.in(JQB::getXydxqbh, semesterBhs)
.eq(JQB::getZk, 0)
.ne(JQB::getKpk, 0));
if (!classSoft.isEmpty()) {
Map<String, SSKCB> lessonByBh = lessons.stream()
.collect(Collectors.toMap(SSKCB::getBh, l -> l, (a, b) -> a));
List<SSKCXYD> links = sskcxydMapper.selectList(new LambdaQueryWrapper<SSKCXYD>()
.in(SSKCXYD::getSskcbh, lessonByBh.keySet()));
Map<String, String> teamNames = xydbMapper.selectList(new LambdaQueryWrapper<>()).stream()
.collect(Collectors.toMap(XYDB::getXydbh, XYDB::getXydmc, (a, b) -> a));
Map<String, List<String>> teamsOfLesson = new HashMap<>();
for (SSKCXYD link : links) {
teamsOfLesson.computeIfAbsent(link.getSskcbh(), k -> new ArrayList<>()).add(link.getXydbh());
}
for (JQB grid : classSoft) {
if (grid.getJqsj() == null || grid.getCourseClass() == null) continue;
String dateStr = intDateToString(grid.getJqsj());
for (Integer jc : PeriodUtil.parsePeriods(grid.getCourseClass())) {
for (Map.Entry<String, List<String>> e : teamsOfLesson.entrySet()) {
if (!e.getValue().contains(grid.getXydbh())) continue;
SSKCB lesson = lessonByBh.get(e.getKey());
if (lesson == null || lesson.getRq() == null || lesson.getJc() == null) continue;
if (!dateStr.equals(lesson.getRq().toLocalDate().toString()) || lesson.getJc() != jc) continue;
TimetableConflictDetailVO d = buildEventDetail(
"SOFT_CLASS|" + grid.getXydbh() + "|" + dateStr + "#" + jc, "非正课时段",
dateStr, jc, List.of(lesson), courseNames,
"班历标记该日期节次对该学员队为非正课时段,但已有课次安排");
d.setLevel("soft");
d.setXydNames(teamNames.getOrDefault(grid.getXydbh(), grid.getXydbh()));
result.add(d);
}
}
}
}
}
return result; return result;
} }
@@ -59,6 +59,21 @@ public class TeachingPlanLessonVO {
*/ */
private String jxff; private String jxff;
/**
* 主讲教员姓名(课次辅讲教员表中 主教员=1 的教员)。
*/
private String zjyxm;
/**
* 辅讲教员姓名(多个用「、」连接)。
*/
private String fjyxm;
/**
* 上课场地名称(课次教室关联,多个用「、」连接)。
*/
private String jsmc;
/** /**
* 填写状态:0-未填写,1-已填写,2-已提交。 * 填写状态:0-未填写,1-已填写,2-已提交。
*/ */
@@ -108,3 +108,85 @@ export function cancelScheduleAdjust(params) {
params params
}) })
} }
// ==================== 运行分析 /course-running/analysis ====================
// 接口依据:CourseRunningController 五个 analysis 端点(POST,body 为 OperationAnalysisQueryDTO)
/**
* 教学运行概览指标
* POST /course-running/analysis/overview
* 请求体:{ nd, xqd, jxxbh, startDate, endDate, dimension }
* 返回:OperationOverviewVO(排课完成率/缺勤率/场地使用率/周均课时/调课数等)
*/
export function getAnalysisOverview(data) {
return request({
url: '/course-running/analysis/overview',
method: 'post',
data
})
}
/**
* 各教学系排课完成率
* POST /course-running/analysis/course-completion
* 返回:List<CourseCompletionByDeptVO>(jxxbh/jxxmc/completionRate/completedCount/totalCount)
*/
export function getCourseCompletion(data) {
return request({
url: '/course-running/analysis/course-completion',
method: 'post',
data
})
}
/**
* 场地使用率分布
* POST /course-running/analysis/venue-usage
* 返回:List<VenueUsageRateVO>(cdlybh/cdlymc/usageRate/usageCount/totalCount)
*/
export function getVenueUsage(data) {
return request({
url: '/course-running/analysis/venue-usage',
method: 'post',
data
})
}
/**
* 调课频次趋势
* POST /course-running/analysis/adjust-trend
* 返回:List<ScheduleAdjustTrendVO>(period/periodName/adjustCount)
*/
export function getAdjustTrend(data) {
return request({
url: '/course-running/analysis/adjust-trend',
method: 'post',
data
})
}
/**
* 导出运行分析报表(四个 sheet:概览/系别完成率/场地使用率/调课趋势)
* POST /course-running/analysis/export
*/
export function exportAnalysisReport(data) {
return request({
url: '/course-running/analysis/export',
method: 'post',
data,
responseType: 'blob'
})
}
/**
* 导出教学实施计划课次列表
* GET /course-running/plan/export(参数同 listTeachingPlan,无分页)
*/
export function exportTeachingPlan(params) {
return request({
url: '/course-running/plan/export',
method: 'get',
params,
responseType: 'blob'
})
}
@@ -0,0 +1,304 @@
<template>
<div class="app-container analysis-page">
<!-- 筛选区 -->
<div class="section-card">
<div class="section-header">
<div>
<div class="section-title">实施计划分析</div>
<div class="section-desc">教学运行关键指标与分布统计,数据来自课程教学运行模块</div>
</div>
<div class="header-actions">
<el-form inline class="toolbar-form" @submit.native.prevent>
<el-form-item label="年度">
<el-select v-model="query.nd" placeholder="全部" clearable filterable style="width: 120px">
<el-option v-for="y in yearOptions" :key="y" :label="y + ' 年'" :value="y" />
</el-select>
</el-form-item>
<el-form-item label="学期第次">
<el-select v-model="query.xqd" placeholder="全部" clearable style="width: 120px">
<el-option v-for="n in [1, 2, 3, 4]" :key="n" :label="'第 ' + n + ' 学期'" :value="String(n)" />
</el-select>
</el-form-item>
<el-form-item label="日期范围">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="yyyy-MM-dd"
style="width: 260px"
/>
</el-form-item>
</el-form>
<el-button icon="el-icon-refresh" size="mini" @click="handleReset">重置</el-button>
<el-button type="primary" size="mini" icon="el-icon-search" :loading="loading" @click="loadAll">查询</el-button>
<el-button type="success" size="mini" icon="el-icon-download" :loading="exporting" @click="handleExport">导出报表</el-button>
</div>
</div>
</div>
<!-- 概览指标卡 -->
<div class="metric-grid" v-loading="loading">
<div v-for="m in metricCards" :key="m.label" class="metric-card">
<div class="metric-value" :style="{ color: m.color }">{{ m.value }}</div>
<div class="metric-label">{{ m.label }}</div>
</div>
</div>
<!-- 明细统计 -->
<div class="detail-grid">
<div class="section-card">
<div class="section-title small">各教学系排课完成率</div>
<el-table :data="completionList" size="mini" border stripe v-loading="loading">
<el-table-column type="index" label="序号" width="55" align="center" />
<el-table-column prop="jxxmc" label="教学系" min-width="140" show-overflow-tooltip />
<el-table-column label="完成 / 总数" width="110" align="center">
<template slot-scope="scope">{{ scope.row.completedCount || 0 }} / {{ scope.row.totalCount || 0 }}</template>
</el-table-column>
<el-table-column label="完成率" min-width="160" align="center">
<template slot-scope="scope">
<el-progress :percentage="pct(scope.row.completionRate)" :stroke-width="12" />
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && completionList.length === 0" description="暂无数据" />
</div>
<div class="section-card">
<div class="section-title small">场地使用率分布</div>
<el-table :data="venueList" size="mini" border stripe v-loading="loading">
<el-table-column type="index" label="序号" width="55" align="center" />
<el-table-column prop="cdlymc" label="场地类型" min-width="140" show-overflow-tooltip />
<el-table-column label="使用 / 总数" width="110" align="center">
<template slot-scope="scope">{{ scope.row.usageCount || 0 }} / {{ scope.row.totalCount || 0 }}</template>
</el-table-column>
<el-table-column label="使用率" min-width="160" align="center">
<template slot-scope="scope">
<el-progress :percentage="pct(scope.row.usageRate)" :stroke-width="12" color="#e6a23c" />
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && venueList.length === 0" description="暂无数据" />
</div>
</div>
<div class="section-card">
<div class="section-title small">学期调课频次趋势</div>
<el-table :data="trendList" size="mini" border stripe v-loading="loading">
<el-table-column type="index" label="序号" width="55" align="center" />
<el-table-column prop="periodName" label="时段" min-width="160" show-overflow-tooltip />
<el-table-column prop="adjustCount" label="调课次数" width="120" align="center" />
<el-table-column label="分布" min-width="200" align="center">
<template slot-scope="scope">
<el-progress
:percentage="trendPct(scope.row.adjustCount)"
:stroke-width="12"
:show-text="false"
color="#f56c6c"
/>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && trendList.length === 0" description="暂无数据" />
</div>
</div>
</template>
<script>
import {
getAnalysisOverview,
getCourseCompletion,
getVenueUsage,
getAdjustTrend,
exportAnalysisReport
} from '@/api/teachBusiness/courseRunning'
import { listAllSemester } from '@/api/teachBusiness/semester'
import { saveAs } from 'file-saver'
export default {
name: 'OperationAnalysis',
data() {
return {
loading: false,
exporting: false,
query: { nd: undefined, xqd: undefined, jxxbh: undefined },
dateRange: null,
yearOptions: [],
overview: {},
completionList: [],
venueList: [],
trendList: []
}
},
computed: {
metricCards() {
const o = this.overview || {}
const pct = v => (v === null || v === undefined) ? '-' : Number(v).toFixed(1) + '%'
const num = v => (v === null || v === undefined) ? '-' : v
return [
{ label: '整体排课完成率', value: pct(o.overallCourseCompletionRate), color: '#00875a' },
{ label: '学员缺勤率', value: pct(o.studentAbsenceRate), color: '#e6a23c' },
{ label: '场地平均使用率', value: pct(o.venueUsageRate), color: '#e6a23c' },
{ label: '教师周均课时', value: num(o.avgWeeklyHoursPerTeacher), color: '#303133' },
{ label: '调课申请总数', value: num(o.totalScheduleAdjustCount), color: '#303133' },
{ label: '待审批调课', value: num(o.pendingAdjustCount), color: '#f56c6c' },
{ label: '保障计划数', value: num(o.totalSupportPlanCount), color: '#303133' },
{ label: '请假总数 / 待审批', value: num(o.totalLeaveCount) + ' / ' + num(o.pendingLeaveCount), color: '#303133' }
]
}
},
created() {
this.loadYears()
this.loadAll()
},
methods: {
pct(v) {
const n = Number(v)
if (isNaN(n)) return 0
return Math.min(100, Math.max(0, Math.round(n * 10) / 10))
},
trendPct(count) {
const max = Math.max(1, ...this.trendList.map(t => t.adjustCount || 0))
return Math.round(((count || 0) / max) * 100)
},
buildQuery() {
const q = {
nd: this.query.nd ? String(this.query.nd) : undefined,
xqd: this.query.xqd || undefined
}
if (this.dateRange && this.dateRange.length === 2) {
q.startDate = this.dateRange[0] + 'T00:00:00'
q.endDate = this.dateRange[1] + 'T23:59:59'
}
return q
},
loadYears() {
listAllSemester().then(response => {
const set = new Set()
;(response.data || []).forEach(item => {
if (item.nd !== null && item.nd !== undefined && item.nd !== '') set.add(item.nd)
})
this.yearOptions = Array.from(set).sort((a, b) => String(b).localeCompare(String(a)))
if (!this.query.nd && this.yearOptions.length) {
const current = (response.data || []).find(i => i.dqxq === true)
this.query.nd = String((current && current.nd) || this.yearOptions[0])
this.loadAll()
}
}).catch(() => {})
},
loadAll() {
const q = this.buildQuery()
this.loading = true
Promise.all([
getAnalysisOverview(q),
getCourseCompletion(q),
getVenueUsage(q),
getAdjustTrend(q)
]).then(([ov, cc, vu, at]) => {
this.overview = ov.data || {}
this.completionList = cc.data || []
this.venueList = vu.data || []
this.trendList = at.data || []
}).catch(() => {}).finally(() => {
this.loading = false
})
},
handleReset() {
this.query = { nd: undefined, xqd: undefined, jxxbh: undefined }
this.dateRange = null
this.loadAll()
},
handleExport() {
this.exporting = true
exportAnalysisReport(this.buildQuery()).then(blob => {
saveAs(blob, '运行分析报表.xlsx')
}).catch(() => {
this.$message.error('导出失败')
}).finally(() => {
this.exporting = false
})
}
}
}
</script>
<style scoped lang="scss">
.analysis-page {
.section-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 16px 20px;
margin-bottom: 16px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
.header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
.toolbar-form ::v-deep .el-form-item {
margin-bottom: 0;
}
}
}
.section-title {
font-size: 16px;
font-weight: 700;
color: #303133;
&.small {
font-size: 14px;
margin-bottom: 12px;
}
}
.section-desc {
margin-top: 8px;
font-size: 13px;
color: #909399;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 16px;
}
.metric-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 18px 20px;
text-align: center;
.metric-value {
font-size: 26px;
font-weight: 700;
}
.metric-label {
margin-top: 8px;
font-size: 13px;
color: #909399;
}
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
}
</style>
@@ -1578,8 +1578,8 @@ export default {
} }
&.is-warn { &.is-warn {
background: #fdf8e8; background: #fdf6ec;
box-shadow: inset 0 0 0 1px #e6c974; box-shadow: inset 0 0 0 1px #e6a23c;
} }
&.is-picked { &.is-picked {
@@ -1708,14 +1708,14 @@ export default {
font-size: 12px; font-size: 12px;
} }
/* 冲突格着色(橙色,按所选冲突维度命中) */ /* 冲突格着色(深红=硬冲突,与冲突明细页色板统一;橙色仅用于软提示) */
.tb-slot.is-conflict { .tb-slot.is-conflict {
background: #fdece0; background: #fde2e2;
box-shadow: inset 0 0 0 2px #d97706; box-shadow: inset 0 0 0 2px #f56c6c;
} }
.dot-cur { background: #f7c9d8; border: 1px solid #c2477a; } .dot-cur { background: #f7c9d8; border: 1px solid #c2477a; }
.dot-conflict { background: #fdece0; border: 1px solid #d97706; } .dot-conflict { background: #fde2e2; border: 1px solid #f56c6c; }
/* 全学期视图:每周一段,可跨周框选 */ /* 全学期视图:每周一段,可跨周框选 */
.timetable.is-overview .tb-weekline { .timetable.is-overview .tb-weekline {
@@ -0,0 +1,252 @@
<template>
<div class="app-container plan-page">
<div class="section-card">
<div class="section-header">
<div>
<div class="section-title">教学实施计划</div>
<div class="section-desc">按日期 / 节次排序的课次列表,含主讲、辅讲与上课场地</div>
</div>
<div class="header-actions">
<el-button icon="el-icon-refresh" size="mini" @click="handleReset">重置</el-button>
<el-button type="primary" size="mini" icon="el-icon-search" :loading="loading" @click="handleQuery">查询</el-button>
<el-button type="success" size="mini" icon="el-icon-download" :loading="exporting" @click="handleExport">导出</el-button>
</div>
</div>
<el-form inline class="filter-form" @submit.native.prevent>
<el-form-item label="年度">
<el-select v-model="query.nd" placeholder="全部" clearable filterable style="width: 120px" @change="handleQuery">
<el-option v-for="y in yearOptions" :key="y" :label="y + ' 年'" :value="Number(y)" />
</el-select>
</el-form-item>
<el-form-item label="上课日期">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="yyyy-MM-dd"
style="width: 260px"
@change="handleQuery"
/>
</el-form-item>
<el-form-item label="教学方法">
<el-input v-model="query.jxff" placeholder="如:理论讲授" clearable style="width: 140px" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="填写状态">
<el-select v-model="query.txzt" placeholder="全部" clearable style="width: 120px" @change="handleQuery">
<el-option label="未填写" :value="0" />
<el-option label="已填写" :value="1" />
<el-option label="已提交" :value="2" />
</el-select>
</el-form-item>
</el-form>
</div>
<div class="section-card">
<el-table v-loading="loading" :data="sortedRecords" border stripe size="mini">
<el-table-column type="index" label="序号" width="55" align="center" />
<el-table-column prop="kcmc" label="课程名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="kcxh" label="课次" width="70" align="center" />
<el-table-column label="上课日期" width="110" align="center">
<template slot-scope="scope">{{ fmtDate(scope.row.rq) }}</template>
</el-table-column>
<el-table-column prop="jc" label="节次" width="70" align="center" />
<el-table-column prop="zjyxm" label="主讲" min-width="90" align="center" show-overflow-tooltip />
<el-table-column prop="fjyxm" label="辅讲" min-width="110" align="center" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.fjyxm || '-' }}</template>
</el-table-column>
<el-table-column prop="jsmc" label="场地" min-width="120" align="center" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.jsmc || '-' }}</template>
</el-table-column>
<el-table-column prop="jxnr" label="教学内容" min-width="160" show-overflow-tooltip />
<el-table-column prop="jxff" label="教学方法" width="100" align="center" />
<el-table-column label="填写状态" width="90" align="center">
<template slot-scope="scope">
<el-tag size="mini" :type="txztTag(scope.row.txzt)">{{ txztText(scope.row.txzt) }}</el-tag>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="total > 0"
class="detail-pagination"
background
layout="total, sizes, prev, pager, next"
:current-page="query.pageNum"
:page-size="query.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
<el-empty v-if="total === 0 && !loading" description="暂无课次数据" />
</div>
</div>
</template>
<script>
import { listTeachingPlan, exportTeachingPlan } from '@/api/teachBusiness/courseRunning'
import { listAllSemester } from '@/api/teachBusiness/semester'
import { saveAs } from 'file-saver'
export default {
name: 'TeachingPlan',
data() {
return {
loading: false,
exporting: false,
query: { nd: undefined, jxff: '', txzt: undefined, pageNum: 1, pageSize: 20 },
dateRange: null,
yearOptions: [],
records: [],
total: 0
}
},
computed: {
/* 按上课日期 + 节次排序(后端按课程分组排序,本页按时间轴展示) */
sortedRecords() {
return [...this.records].sort((a, b) => {
const da = this.fmtDate(a.rq)
const db = this.fmtDate(b.rq)
if (da !== db) return da < db ? -1 : 1
return (a.jc || 0) - (b.jc || 0)
})
}
},
created() {
this.loadYears()
this.loadList()
},
methods: {
fmtDate(rq) {
return (rq || '').substring(0, 10)
},
txztText(v) {
return v === 2 ? '已提交' : v === 1 ? '已填写' : '未填写'
},
txztTag(v) {
return v === 2 ? 'success' : v === 1 ? 'primary' : 'info'
},
buildParams() {
const p = {
nd: this.query.nd,
jxff: this.query.jxff || undefined,
txzt: this.query.txzt,
pageNum: this.query.pageNum,
pageSize: this.query.pageSize
}
if (this.dateRange && this.dateRange.length === 2) {
p.rqStart = this.dateRange[0] + 'T00:00:00'
p.rqEnd = this.dateRange[1] + 'T23:59:59'
}
return p
},
loadYears() {
listAllSemester().then(response => {
const set = new Set()
;(response.data || []).forEach(item => {
if (item.nd !== null && item.nd !== undefined && item.nd !== '') set.add(item.nd)
})
this.yearOptions = Array.from(set).sort((a, b) => String(b).localeCompare(String(a)))
if (!this.query.nd && this.yearOptions.length) {
const current = (response.data || []).find(i => i.dqxq === true)
this.query.nd = Number((current && current.nd) || this.yearOptions[0])
this.loadList()
}
}).catch(() => {})
},
loadList() {
this.loading = true
listTeachingPlan(this.buildParams()).then(response => {
const page = response.data || {}
this.records = page.records || []
this.total = page.total || 0
}).catch(() => {
this.records = []
this.total = 0
}).finally(() => {
this.loading = false
})
},
handleQuery() {
this.query.pageNum = 1
this.loadList()
},
handleReset() {
this.query = { nd: undefined, jxff: '', txzt: undefined, pageNum: 1, pageSize: 20 }
this.dateRange = null
this.loadList()
},
handlePageChange(page) {
this.query.pageNum = page
this.loadList()
},
handleSizeChange(size) {
this.query.pageSize = size
this.query.pageNum = 1
this.loadList()
},
handleExport() {
const p = this.buildParams()
delete p.pageNum
delete p.pageSize
this.exporting = true
exportTeachingPlan(p).then(blob => {
saveAs(blob, '教学实施计划.xlsx')
}).catch(() => {
this.$message.error('导出失败')
}).finally(() => {
this.exporting = false
})
}
}
}
</script>
<style scoped lang="scss">
.plan-page {
.section-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 16px 20px;
margin-bottom: 16px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
}
.section-title {
font-size: 16px;
font-weight: 700;
color: #303133;
}
.section-desc {
margin-top: 8px;
font-size: 13px;
color: #909399;
}
.filter-form ::v-deep .el-form-item {
margin-bottom: 0;
}
.detail-pagination {
margin-top: 12px;
text-align: right;
}
}
</style>
@@ -61,8 +61,27 @@
<span v-if="activeKey" class="detail-count">共 {{ detailTotal }} 条记录</span> <span v-if="activeKey" class="detail-count">共 {{ detailTotal }} 条记录</span>
</div> </div>
<template v-if="activeKey"> <template v-if="activeKey">
<el-table v-loading="detailLoading" :data="activeDetails" border stripe size="mini" max-height="500"> <div class="level-legend">
<span class="legend-item"><i class="legend-swatch legend-hard" />硬冲突(双占 / 历表不可排)</span>
<span class="legend-item"><i class="legend-swatch legend-soft" />提示(非正课时段排课)</span>
</div>
<el-table
v-loading="detailLoading"
:data="activeDetails"
border
stripe
size="mini"
max-height="500"
:row-class-name="conflictRowClass"
>
<el-table-column type="index" label="序号" width="55" align="center" /> <el-table-column type="index" label="序号" width="55" align="center" />
<el-table-column label="级别" width="80" align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.level === 'soft' ? 'warning' : 'danger'" size="mini" effect="dark">
{{ scope.row.level === 'soft' ? '提示' : '硬冲突' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="冲突号" width="90" align="center" show-overflow-tooltip> <el-table-column label="冲突号" width="90" align="center" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.conflictNo || '-' }}</template> <template slot-scope="scope">{{ scope.row.conflictNo || '-' }}</template>
</el-table-column> </el-table-column>
@@ -75,6 +94,7 @@
<el-table-column prop="teacherNames" label="教员" min-width="120" align="center" show-overflow-tooltip /> <el-table-column prop="teacherNames" label="教员" min-width="120" align="center" show-overflow-tooltip />
<el-table-column prop="classroomNames" label="场地" min-width="140" align="center" show-overflow-tooltip /> <el-table-column prop="classroomNames" label="场地" min-width="140" align="center" show-overflow-tooltip />
<el-table-column prop="responsibleDept" label="责任单位" min-width="110" align="center" show-overflow-tooltip /> <el-table-column prop="responsibleDept" label="责任单位" min-width="110" align="center" show-overflow-tooltip />
<el-table-column prop="reason" label="冲突说明" min-width="200" show-overflow-tooltip />
</el-table> </el-table>
<el-pagination <el-pagination
v-if="detailTotal > 0" v-if="detailTotal > 0"
@@ -157,6 +177,10 @@ export default {
fmtDate(rq) { fmtDate(rq) {
return (rq || '').substring(0, 10) return (rq || '').substring(0, 10)
}, },
/* 硬冲突行深红底、提示行橙底(与排课窗软硬两级口径一致) */
conflictRowClass({ row }) {
return row && row.level === 'soft' ? 'conflict-row-soft' : 'conflict-row-hard'
},
getStatus(item) { getStatus(item) {
if (!item.checked) return { type: 'info', text: '未检查' } if (!item.checked) return { type: 'info', text: '未检查' }
return item.count > 0 return item.count > 0
@@ -416,6 +440,45 @@ export default {
color: #909399; color: #909399;
} }
.level-legend {
display: flex;
gap: 18px;
margin-bottom: 8px;
font-size: 12px;
color: #606266;
.legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.legend-swatch {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 2px;
}
.legend-hard {
background: #fde2e2;
border: 1px solid #f56c6c;
}
.legend-soft {
background: #fdf6ec;
border: 1px solid #e6a23c;
}
}
::v-deep .el-table .conflict-row-hard td {
background-color: #fde2e2;
}
::v-deep .el-table .conflict-row-soft td {
background-color: #fdf6ec;
}
.detail-pagination { .detail-pagination {
margin-top: 12px; margin-top: 12px;
text-align: right; text-align: right;
@@ -50,6 +50,7 @@
<div class="list-header"> <div class="list-header">
<div class="list-title">教室列表</div> <div class="list-title">教室列表</div>
<div class="list-actions"> <div class="list-actions">
<el-button icon="el-icon-view" @click="openScheduleOverview">场地占用总览</el-button>
<el-button type="primary" icon="el-icon-plus" @click="handleAdd">新增</el-button> <el-button type="primary" icon="el-icon-plus" @click="handleAdd">新增</el-button>
</div> </div>
</div> </div>
@@ -206,6 +207,47 @@
<!-- 场地历编辑弹窗 --> <!-- 场地历编辑弹窗 -->
<VenueCalendarEditor :visible.sync="calendarVisible" :venue="calendarVenue" /> <VenueCalendarEditor :visible.sync="calendarVisible" :venue="calendarVenue" />
<!-- 场地占用总览:某日某节全部场地的占用状态 -->
<el-dialog title="场地占用总览" :visible.sync="scheduleVisible" width="860px" append-to-body>
<el-form inline @submit.native.prevent>
<el-form-item label="日期">
<el-date-picker
v-model="scheduleQuery.rq"
type="date"
value-format="yyyy-MM-dd"
placeholder="选择日期"
style="width: 160px"
@change="loadScheduleOverview"
/>
</el-form-item>
<el-form-item label="节次">
<el-select v-model="scheduleQuery.jc" placeholder="选择节次" style="width: 120px" @change="loadScheduleOverview">
<el-option v-for="n in 12" :key="n" :label="'第 ' + n + ' 节'" :value="n" />
</el-select>
</el-form-item>
</el-form>
<el-table v-loading="scheduleLoading" :data="scheduleList" border stripe size="mini" max-height="460">
<el-table-column prop="jsbh" label="教室编号" width="110" align="center" show-overflow-tooltip />
<el-table-column prop="jsmc" label="教室名称" min-width="130" show-overflow-tooltip />
<el-table-column prop="jxldh" label="教学楼" width="110" align="center" show-overflow-tooltip />
<el-table-column label="状态" width="90" align="center">
<template slot-scope="scope">
<el-tag size="mini" :type="scheduleStatusTag(scope.row.status)">{{ scheduleStatusText(scope.row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="kcmc" label="占用课程" min-width="140" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.kcmc || '-' }}</template>
</el-table-column>
<el-table-column prop="xydmc" label="学员队" min-width="110" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.xydmc || '-' }}</template>
</el-table-column>
<el-table-column prop="mc" label="备注" min-width="120" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.mc || '-' }}</template>
</el-table-column>
</el-table>
<el-empty v-if="!scheduleLoading && scheduleList.length === 0" description="请选择日期与节次查看占用情况" />
</el-dialog>
</div> </div>
</template> </template>
@@ -218,6 +260,7 @@ import {
listAllClassroom listAllClassroom
} from '@/api/teachBusiness/classroom' } from '@/api/teachBusiness/classroom'
import { listAllBuild } from '@/api/teachBusiness/build' import { listAllBuild } from '@/api/teachBusiness/build'
import { venueSchedule } from '@/api/teachBusiness/venueCalendar'
import VenueCalendarEditor from './calendarEditor.vue' import VenueCalendarEditor from './calendarEditor.vue'
export default { export default {
@@ -250,6 +293,11 @@ export default {
// 场地历编辑弹窗 // 场地历编辑弹窗
calendarVisible: false, calendarVisible: false,
calendarVenue: null, calendarVenue: null,
// 场地占用总览
scheduleVisible: false,
scheduleLoading: false,
scheduleQuery: { rq: '', jc: undefined },
scheduleList: [],
rules: { rules: {
jsmc: [{ required: true, message: '请输入教室名称', trigger: 'blur' }] jsmc: [{ required: true, message: '请输入教室名称', trigger: 'blur' }]
} }
@@ -277,6 +325,37 @@ export default {
const base = [item.jxldh, item.jxlmc].filter(Boolean).join(' ') const base = [item.jxldh, item.jxlmc].filter(Boolean).join(' ')
return base || item.id return base || item.id
}, },
/* ---------- 场地占用总览 ---------- */
openScheduleOverview() {
this.scheduleVisible = true
if (!this.scheduleQuery.rq) {
const now = new Date()
const p = n => String(n).padStart(2, '0')
this.scheduleQuery.rq = `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}`
}
this.loadScheduleOverview()
},
loadScheduleOverview() {
if (!this.scheduleQuery.rq || !this.scheduleQuery.jc) {
this.scheduleList = []
return
}
this.scheduleLoading = true
venueSchedule(this.scheduleQuery.rq + ' 00:00:00', this.scheduleQuery.jc).then(response => {
this.scheduleList = response.data || []
}).catch(() => {
this.scheduleList = []
}).finally(() => {
this.scheduleLoading = false
})
},
scheduleStatusText(status) {
return { free: '空闲', unavailable: '不可用', occupied: '已占用', noted: '有备注' }[status] || status || '-'
},
scheduleStatusTag(status) {
return { free: 'success', unavailable: 'danger', occupied: 'warning', noted: 'info' }[status] || 'info'
},
/** /**
* 独立加载全部场地类型,避免查询结果收窄后下拉候选项同步丢失。 * 独立加载全部场地类型,避免查询结果收窄后下拉候选项同步丢失。
*/ */