You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1542 lines
45 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div class="schedule-overview-page" v-loading="loading">
<lx-header icon="md-apps" :text="$route.meta.title" style="margin-bottom: 10px; border: 0; margin-top: 15px;">
<div slot="content" class="header-actions">
<el-date-picker
v-model="currentYear"
type="year"
value-format="yyyy"
format="yyyy"
placeholder="选择年份"
size="small"
style="width: 120px;"
@change="handleYearChange"
/>
<el-button size="small" plain @click="openSystemCourseManager">计划体系管理</el-button>
<el-button size="small" type="primary" plain @click="openScheduleManager">带班管理</el-button>
<el-button size="small" plain @click="openLocationManager">新增地点</el-button>
<el-button size="small" plain @click="openOwnerManager">新增负责人</el-button>
</div>
</lx-header>
<summary-panel :summary-cards="summaryCards" />
<monthly-heatmap
:month-labels="monthLabels"
:monthly-stats="monthlyStats"
:heat-class="heatClass"
/>
<member-overview :member-analysis="memberAnalysis" />
<plan-matrix
:year="currentYear"
:month-labels="monthLabels"
:plan-rows="planRows"
@cell-click="handlePlanCellClick"
/>
<system-course-manager-dialog
:visible.sync="systemCourseDialogVisible"
:tree-data="systemCourseTreeData"
:selected-system="selectedSystem"
:selected-tree-node="selectedTreeNode"
:current-key="selectedTreeNodeKey"
@tree-node-select="handleTreeNodeSelect"
@open-system-form="openSystemForm"
@open-course-form="handleOpenCourseFormFromDialog"
@delete-system="handleDeleteSystem"
@delete-course="handleDeleteCourse"
/>
<schedule-manager-dialog
:visible.sync="scheduleManagerDialogVisible"
:rows="scheduleTableRows"
:span-method="scheduleSpanMethod"
@open-schedule-form="openScheduleForm"
@delete-schedule="handleDeleteSchedule"
/>
<system-form-dialog
:visible.sync="systemFormVisible"
:mode="systemFormMode"
:form="systemForm"
:rules="systemRules"
@submit="submitSystemForm"
/>
<course-form-dialog
:visible.sync="courseFormVisible"
:mode="courseFormMode"
:form="courseForm"
:rules="courseRules"
:systems="systems"
:course-types="courseTypes"
@course-type-change="(val) => { courseForm.name = getCourseTypeNameById(val) || courseForm.name }"
@submit="submitCourseForm"
/>
<schedule-form-dialog
:visible.sync="scheduleFormVisible"
:mode="scheduleFormMode"
:form="scheduleForm"
:rules="scheduleRules"
:systems="scheduleFormSystems"
:course-options="scheduleFormCourseOptions"
:month-options="monthOptions"
:locations="locations"
:owners="owners"
@year-change="handleScheduleYearChange"
@system-change="handleScheduleSystemChange"
@open-location-manager="openLocationManager"
@open-owner-manager="openOwnerManager"
@cancel="handleScheduleDialogCancel"
@delete="handleDeleteSchedule"
@submit="submitScheduleForm"
/>
<location-manager-dialog
:visible.sync="locationManagerDialogVisible"
:list="locations"
@open="loadLocations"
@add="handleAddLocation"
@edit="handleEditLocation"
@delete="handleDeleteLocation"
/>
<owner-manager-dialog
:visible.sync="ownerManagerDialogVisible"
:list="owners"
@open="loadOwners"
@add="handleAddOwner"
@edit="handleEditOwner"
@delete="handleDeleteOwner"
/>
</div>
</template>
<script>
import SummaryPanel from './components/SummaryPanel.vue'
import MonthlyHeatmap from './components/MonthlyHeatmap.vue'
import MemberOverview from './components/MemberOverview.vue'
import PlanMatrix from './components/PlanMatrix.vue'
import SystemCourseManagerDialog from './components/SystemCourseManagerDialog.vue'
import ScheduleManagerDialog from './components/ScheduleManagerDialog.vue'
import SystemFormDialog from './components/SystemFormDialog.vue'
import CourseFormDialog from './components/CourseFormDialog.vue'
import ScheduleFormDialog from './components/ScheduleFormDialog.vue'
import LocationManagerDialog from './components/LocationManagerDialog.vue'
import OwnerManagerDialog from './components/OwnerManagerDialog.vue'
import { index as courseTypeIndex } from '@/api/course/courseType.js'
import {
destroyCourse,
destroySchedule,
destroySystem,
getOverview,
getLocationList,
getOwnerList,
saveCourse,
saveLocation,
saveOwner,
destroyLocation,
destroyOwner,
getScheduleList,
saveSchedule,
saveSystem
} from '@/api/scheduleOverview/index'
const getCurrentYear = () => String(new Date().getFullYear())
const createSystemForm = () => ({
id: '',
year: getCurrentYear(),
name: '',
sort: 1
})
const createCourseForm = (systemId = '') => ({
id: '',
system_id: systemId || '',
course_type_id: '',
name: '',
sort: 1
})
const createScheduleForm = () => ({
id: '',
year: getCurrentYear(),
system_id: '',
course_id: '',
title: '',
modules: []
})
const TONE_CLASSES = ['tone-green', 'tone-blue', 'tone-purple', 'tone-sand', 'tone-cyan', 'tone-cream']
const HEAT_LEVELS = [
{ min: 0, class: 'heat-empty' },
{ min: 1, class: 'heat-level-1' },
{ min: 2, class: 'heat-level-2' },
{ min: 3, class: 'heat-level-3' },
{ min: 4, class: 'heat-level-4' }
]
export default {
name: 'ScheduleOverview',
components: {
SummaryPanel,
MonthlyHeatmap,
MemberOverview,
PlanMatrix,
SystemCourseManagerDialog,
ScheduleManagerDialog,
SystemFormDialog,
CourseFormDialog,
ScheduleFormDialog,
LocationManagerDialog,
OwnerManagerDialog
},
data() {
return {
systems: [],
courses: [],
schedules: [],
scheduleGroups: [],
courseTypes: [],
locations: [],
owners: [],
loading: false,
monthLabels: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
monthOptions: Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: `${i + 1}月` })),
selectedSystemId: '',
selectedTreeNodeKey: '',
scheduleFormSystems: [],
scheduleFormCourses: [],
systemCourseDialogVisible: false,
scheduleManagerDialogVisible: false,
locationManagerDialogVisible: false,
ownerManagerDialogVisible: false,
systemFormVisible: false,
courseFormVisible: false,
scheduleFormVisible: false,
systemFormMode: 'add',
courseFormMode: 'add',
scheduleFormMode: 'add',
systemForm: createSystemForm(),
courseForm: createCourseForm(),
scheduleForm: createScheduleForm(),
systemRules: {
year: [{ required: true, message: '请选择年份', trigger: 'change' }],
name: [{ required: true, message: '请输入计划体系名称', trigger: 'blur' }],
sort: [{ required: true, message: '请输入排序', trigger: 'change' }]
},
courseRules: {
system_id: [{ required: true, message: '请选择所属计划体系', trigger: 'change' }],
course_type_id: [{ required: true, message: '请选择课程体系', trigger: 'change' }],
sort: [{ required: true, message: '请输入排序', trigger: 'change' }]
},
scheduleRules: {
year: [{ required: true, message: '请选择年份', trigger: 'change' }],
system_id: [{ required: true, message: '请选择计划体系', trigger: 'change' }],
modules: [{
trigger: 'change',
validator: (rule, value, callback) => {
if (!Array.isArray(value) || !value.length) {
callback(new Error('模块/期数至少需要一条数据'))
return
}
callback()
}
}],
title: [{ required: true, message: '请输入课程名称', trigger: 'blur' }],
// modules 内部必填项由子组件在提交前做校验提示
},
currentYear: getCurrentYear()
}
},
computed: {
selectedSystem() {
return this.systems.find((item) => String(item.id) === String(this.selectedSystemId)) || null
},
selectedTreeNode() {
const key = String(this.selectedTreeNodeKey || '')
if (!key) {
return null
}
if (key.indexOf('system-') === 0) {
const systemId = key.replace('system-', '')
const system = this.systems.find((item) => String(item.id) === String(systemId))
return system ? {
treeKey: key,
nodeType: 'system',
name: system.name,
year: system.year,
sort: system.sort,
raw: system
} : null
}
if (key.indexOf('course-') === 0) {
const courseId = key.replace('course-', '')
const course = this.courses.find((item) => String(item.id) === String(courseId))
const system = course ? this.systemMap[course.system_id] : null
return course ? {
treeKey: key,
nodeType: 'course',
name: course.name,
year: system ? system.year : '-',
sort: course.sort,
systemName: system ? system.name : '-',
raw: course
} : null
}
return null
},
selectedSystemCourses() {
return this.courses.filter((item) => String(item.system_id) === String(this.selectedSystemId))
},
systemCourseTreeData() {
return this.systems.map((system) => ({
treeKey: `system-${system.id}`,
id: system.id,
year: system.year,
name: system.name,
sort: system.sort,
nodeType: 'system',
raw: system,
children: this.courses
.filter((item) => String(item.system_id) === String(system.id))
.sort((a, b) => Number(a.sort || 0) - Number(b.sort || 0))
.map((course) => ({
treeKey: `course-${course.id}`,
id: course.id,
name: course.name,
sort: course.sort,
nodeType: 'course',
systemName: system.name,
raw: course
}))
}))
},
scheduleFormCourseOptions() {
return this.scheduleFormCourses.filter((item) => String(item.system_id) === String(this.scheduleForm.system_id))
},
summaryCards() {
const totalCount = this.schedules.length
const owners = [...new Set(this.schedules.map((item) => item.owner).filter(Boolean))]
const activeMonths = [...new Set(this.schedules.map((item) => Number(item.month)).filter(Boolean))]
const monthCounter = this.buildCounter(this.schedules, (item) => `${item.month}月`)
const ownerCounter = this.buildCounter(this.schedules, (item) => item.owner || '未分配')
const peakMonth = this.getTopCounterEntry(monthCounter)
const peakOwner = this.getTopCounterEntry(ownerCounter)
return [
{ label: '总开班次数(周期)', value: String(totalCount), unit: '次' },
{ label: '参与人员', value: String(owners.length), unit: '人' },
{ label: '活跃月度', value: String(activeMonths.length), unit: '个月' },
{ label: '峰值月度', value: peakMonth ? `${peakMonth.label}/${peakMonth.value}` : '-', unit: peakMonth ? '次' : '' },
{ label: '峰值人员', value: peakOwner ? `${peakOwner.label}/${peakOwner.value}` : '-', unit: peakOwner ? '次' : '' }
]
},
monthlyStats() {
const ownerStatsMap = {}
this.schedules.forEach((item) => {
const name = item.owner || '未分配'
if (!ownerStatsMap[name]) {
ownerStatsMap[name] = {
name,
months: Array(12).fill(0),
total: 0
}
}
const monthIndex = Number(item.month) - 1
if (monthIndex >= 0 && monthIndex < 12) {
ownerStatsMap[name].months[monthIndex] += 1
ownerStatsMap[name].total += 1
}
})
return Object.values(ownerStatsMap).sort((a, b) => {
if (b.total !== a.total) {
return b.total - a.total
}
return a.name.localeCompare(b.name, 'zh-CN')
})
},
memberAnalysis() {
const systemMap = this.systemMap
const statsMap = {}
this.schedules.forEach((item) => {
const name = item.owner || '未分配'
if (!statsMap[name]) {
statsMap[name] = {
total: 0,
monthCounter: {},
locationCounter: {},
systemCounter: {}
}
}
statsMap[name].total += 1
this.incrementCounter(statsMap[name].monthCounter, `${item.month}月`)
this.incrementCounter(statsMap[name].locationCounter, item.location || '-')
this.incrementCounter(statsMap[name].systemCounter, (systemMap[item.system_id] || {}).name || '-')
})
return Object.keys(statsMap).map((name) => ({
name,
total: `${statsMap[name].total}次`,
month: this.getTopCounterEntry(statsMap[name].monthCounter)?.label || '-',
location: this.getTopCounterEntry(statsMap[name].locationCounter)?.label || '-',
tag: this.getTopCounterEntry(statsMap[name].systemCounter)?.label || '-'
})).sort((a, b) => Number(b.total.replace('次', '')) - Number(a.total.replace('次', '')))
},
systemMap() {
return this.systems.reduce((map, item) => {
map[item.id] = item
return map
}, {})
},
courseMap() {
return this.courses.reduce((map, item) => {
map[item.id] = item
return map
}, {})
},
scheduleTableRows() {
const groups = Array.isArray(this.scheduleGroups) ? this.scheduleGroups : []
const rows = groups.reduce((list, group) => {
const modules = Array.isArray(group.modules) ? group.modules : []
if (!modules.length) {
list.push({
group_id: group.id,
year: group.year,
system_id: group.system_id,
course_id: group.course_id,
title: group.title,
moduleName: '-',
month: '',
monthLabel: '-',
location: '-',
owner: '-',
count_text: '',
systemName: (this.systemMap[group.system_id] || {}).name || '-',
courseName: (this.courseMap[group.course_id] || {}).name || '-'
})
return list
}
modules.forEach((m) => {
list.push({
id: m.id,
group_id: group.id,
year: group.year,
system_id: group.system_id,
course_id: group.course_id,
title: group.title,
moduleName: m.name || '-',
month: m.month,
monthLabel: m.month ? `${m.month}月` : '-',
location: (m.location || {}).name || '-',
owner: (m.owner || {}).name || '-',
count_text: m.count_text || '',
systemName: (this.systemMap[group.system_id] || {}).name || '-',
courseName: (this.courseMap[group.course_id] || {}).name || '-'
})
})
return list
}, [])
return rows.sort((a, b) => {
const systemA = this.systemMap[a.system_id] || {}
const systemB = this.systemMap[b.system_id] || {}
const systemSortDiff = Number(systemA.sort || 0) - Number(systemB.sort || 0)
if (systemSortDiff !== 0) {
return systemSortDiff
}
const systemNameDiff = String(systemA.name || '').localeCompare(String(systemB.name || ''), 'zh-CN')
if (systemNameDiff !== 0) {
return systemNameDiff
}
const courseA = this.courseMap[a.course_id] || {}
const courseB = this.courseMap[b.course_id] || {}
const courseSortDiff = Number(courseA.sort || 0) - Number(courseB.sort || 0)
if (courseSortDiff !== 0) {
return courseSortDiff
}
const courseNameDiff = String(courseA.name || '').localeCompare(String(courseB.name || ''), 'zh-CN')
if (courseNameDiff !== 0) {
return courseNameDiff
}
// 先按课程名称group_id聚在一起以便相同课程名称合并单元格再按月份排序
const groupDiff = String(a.group_id || '').localeCompare(String(b.group_id || ''))
if (groupDiff !== 0) {
return groupDiff
}
return Number(a.month || 0) - Number(b.month || 0)
})
},
scheduleSpanMeta() {
const rows = this.scheduleTableRows
const systemMeta = rows.map(() => ({ rowspan: 1, colspan: 1 }))
const courseMeta = rows.map(() => ({ rowspan: 1, colspan: 1 }))
const titleMeta = rows.map(() => ({ rowspan: 1, colspan: 1 }))
const opMeta = rows.map(() => ({ rowspan: 1, colspan: 1 }))
// 计划体系列:按 system_id 合并连续行
let i = 0
while (i < rows.length) {
const systemKey = String(rows[i].system_id)
let j = i + 1
while (j < rows.length && String(rows[j].system_id) === systemKey) {
j += 1
}
const span = j - i
systemMeta[i] = { rowspan: span, colspan: 1 }
for (let k = i + 1; k < j; k += 1) {
systemMeta[k] = { rowspan: 0, colspan: 0 }
}
i = j
}
// 课程体系列、课程名称列:在同一 system_id 分组内,按 course_id 合并;课程名称再按 group_id 合并
i = 0
while (i < rows.length) {
const systemKey = String(rows[i].system_id)
let j = i + 1
while (j < rows.length && String(rows[j].system_id) === systemKey) {
j += 1
}
let p = i
while (p < j) {
const courseKey = String(rows[p].course_id)
let q = p + 1
while (q < j && String(rows[q].course_id) === courseKey) {
q += 1
}
const courseSpan = q - p
courseMeta[p] = { rowspan: courseSpan, colspan: 1 }
for (let k = p + 1; k < q; k += 1) {
courseMeta[k] = { rowspan: 0, colspan: 0 }
}
// 课程名称列、操作列:在同一 course 分组内,按 group_id 合并连续行
let u = p
while (u < q) {
const groupKey = String(rows[u].group_id || '')
let v = u + 1
while (v < q && String(rows[v].group_id || '') === groupKey) {
v += 1
}
const span = v - u
titleMeta[u] = { rowspan: span, colspan: 1 }
opMeta[u] = { rowspan: span, colspan: 1 }
for (let k = u + 1; k < v; k += 1) {
titleMeta[k] = { rowspan: 0, colspan: 0 }
opMeta[k] = { rowspan: 0, colspan: 0 }
}
u = v
}
p = q
}
i = j
}
return { systemMeta, courseMeta, titleMeta, opMeta }
},
planRows() {
const rows = []
const sortedSystems = [...this.systems].sort((a, b) => Number(a.sort || 0) - Number(b.sort || 0))
const groups = Array.isArray(this.scheduleGroups) ? this.scheduleGroups : []
sortedSystems.forEach((system, systemIndex) => {
const systemGroups = groups
.filter((g) => String(g.system_id) === String(system.id))
.sort((a, b) => {
const courseA = a.course || {}
const courseB = b.course || {}
const nameA = String(courseA.name || '')
const nameB = String(courseB.name || '')
// 先按是否有课程体系排序:有名称的排在前面,没有名称的排在后面
const hasCourseA = nameA !== ''
const hasCourseB = nameB !== ''
if (hasCourseA !== hasCourseB) {
return hasCourseA ? -1 : 1
}
const sortDiff = Number(courseA.sort || 0) - Number(courseB.sort || 0)
if (sortDiff !== 0) return sortDiff
const nameDiff = nameA.localeCompare(nameB, 'zh-CN')
if (nameDiff !== 0) return nameDiff
return String(a.title || '').localeCompare(String(b.title || ''), 'zh-CN')
})
const groupClass = TONE_CLASSES[systemIndex % TONE_CLASSES.length]
// 计划体系下所有课程体系(来自 courses 表)都要显示
const allCoursesUnderSystem = this.courses
.filter((c) => String(c.system_id) === String(system.id))
.sort((a, b) => {
const sortDiff = Number(a.sort || 0) - Number(b.sort || 0)
if (sortDiff !== 0) return sortDiff
return String(a.name || '').localeCompare(String(b.name || ''), 'zh-CN')
})
// 有带班的课程体系:按 group 展开为多行(同月份多模块换行)
systemGroups.forEach((group) => {
const modules = Array.isArray(group.modules) ? group.modules : []
const monthMap = this.monthLabels.reduce((map, monthLabel) => {
const monthNumber = Number(monthLabel.replace('月', ''))
map[monthLabel] = modules
.filter((m) => Number(m.month) === monthNumber)
.sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'zh-CN'))
.map((m) => ({
id: m.id,
group_id: group.id,
year: group.year,
system_id: group.system_id,
course_id: group.course_id,
month: m.month,
title: group.title || '',
moduleName: m.name || '',
owner: (m.owner || {}).name || '',
location: (m.location || {}).name || '',
count_text: m.count_text || '',
ownerLocation: `${(m.owner || {}).name || '-'} / ${(m.location || {}).name || '-'}`,
countText: m.count_text || ''
}))
return map
}, {})
const maxModules = Math.max(
1,
...this.monthLabels.map((monthLabel) => (monthMap[monthLabel] || []).length || 0)
)
for (let idx = 0; idx < maxModules; idx += 1) {
const plan = this.monthLabels.reduce((map, monthLabel) => {
const list = monthMap[monthLabel] || []
map[monthLabel] = list[idx] ? [list[idx]] : []
return map
}, {})
rows.push({
rowKey: `${system.id}-${group.course_id || 'none'}-${group.id}-${idx}`,
systemId: system.id,
group: system.name,
groupClass,
courseId: group.course_id || '',
course: (group.course || {}).name || '-',
plan
})
}
})
// 追加:尚未有带班的课程体系也各占一行(空数据)
const appearedCourseIds = new Set(systemGroups.map((g) => String(g.course_id || '')).filter(Boolean))
allCoursesUnderSystem.forEach((course) => {
if (!appearedCourseIds.has(String(course.id))) {
const emptyPlan = this.monthLabels.reduce((map, monthLabel) => {
map[monthLabel] = []
return map
}, {})
rows.push({
rowKey: `${system.id}-${course.id}-empty`,
systemId: system.id,
group: system.name,
groupClass,
courseId: course.id,
course: course.name || '-',
plan: emptyPlan
})
}
})
// 若该计划体系下没有任何课程体系配置,至少显示一行空占位
if (!allCoursesUnderSystem.length && !systemGroups.length) {
const emptyPlan = this.monthLabels.reduce((map, monthLabel) => {
map[monthLabel] = []
return map
}, {})
rows.push({
rowKey: `${system.id}-empty`,
systemId: system.id,
group: system.name,
groupClass,
courseId: '',
course: '-',
plan: emptyPlan
})
}
})
return rows
}
},
created() {
this.loadData()
this.loadCourseTypes()
this.loadLocations()
this.loadOwners()
},
methods: {
async loadLocations() {
try {
const res = await getLocationList()
this.locations = Array.isArray(res) ? res : (res.data || res || [])
} catch (e) {
this.locations = []
}
},
async loadOwners() {
try {
const res = await getOwnerList()
this.owners = Array.isArray(res) ? res : (res.data || res || [])
} catch (e) {
this.owners = []
}
},
async handleAddLocation(payload) {
await saveLocation({ name: payload.name, status: payload.status, sort: payload.sort })
await this.loadLocations()
this.$message.success('地点新增成功')
},
async handleEditLocation(payload) {
await saveLocation({ id: payload.id, name: payload.name, status: payload.status, sort: payload.sort })
await this.loadLocations()
this.$message.success('地点保存成功')
},
handleDeleteLocation(row) {
this.$confirm(`确定删除地点“${row.name}”吗?`, '提示', { type: 'warning' })
.then(async() => {
await destroyLocation({ id: row.id })
await this.loadLocations()
this.$message.success('地点删除成功')
})
.catch(() => {})
},
async handleAddOwner(payload) {
await saveOwner({ name: payload.name, status: payload.status, sort: payload.sort })
await this.loadOwners()
this.$message.success('负责人新增成功')
},
async handleEditOwner(payload) {
await saveOwner({ id: payload.id, name: payload.name, status: payload.status, sort: payload.sort })
await this.loadOwners()
this.$message.success('负责人保存成功')
},
handleDeleteOwner(row) {
this.$confirm(`确定删除负责人“${row.name}”吗?`, '提示', { type: 'warning' })
.then(async() => {
await destroyOwner({ id: row.id })
await this.loadOwners()
this.$message.success('负责人删除成功')
})
.catch(() => {})
},
openLocationManager() {
this.locationManagerDialogVisible = true
},
openOwnerManager() {
this.ownerManagerDialogVisible = true
},
handleOpenCourseFormFromDialog(payload = {}) {
const mode = payload.mode || 'add'
const row = payload.row || null
const targetSystem = payload.targetSystem || null
this.openCourseForm(mode, row, targetSystem)
},
scheduleSpanMethod({ rowIndex, columnIndex, column }) {
const spanMeta = this.scheduleSpanMeta
// 操作列:固定列时右侧表格中 columnIndex 为 0需优先用 label 识别
if (column && column.label === '操作') {
const meta = spanMeta.opMeta[rowIndex]
return meta ? { rowspan: meta.rowspan, colspan: meta.colspan } : { rowspan: 1, colspan: 1 }
}
// 第1列计划体系按 system_id 合并)
if (columnIndex === 0) {
const meta = spanMeta.systemMeta[rowIndex]
return meta ? { rowspan: meta.rowspan, colspan: meta.colspan } : { rowspan: 1, colspan: 1 }
}
// 第2列课程体系同一计划体系内按 course_id 合并)
if (columnIndex === 1) {
const meta = spanMeta.courseMeta[rowIndex]
return meta ? { rowspan: meta.rowspan, colspan: meta.colspan } : { rowspan: 1, colspan: 1 }
}
// 第3列课程名称同一课程体系内按 group_id 合并)
if (columnIndex === 2) {
const meta = spanMeta.titleMeta[rowIndex]
return meta ? { rowspan: meta.rowspan, colspan: meta.colspan } : { rowspan: 1, colspan: 1 }
}
// 第9列操作主表体中的列索引
if (columnIndex === 8) {
const meta = spanMeta.opMeta[rowIndex]
return meta ? { rowspan: meta.rowspan, colspan: meta.colspan } : { rowspan: 1, colspan: 1 }
}
return { rowspan: 1, colspan: 1 }
},
async loadCourseTypes() {
try {
const res = await courseTypeIndex({
page: 1,
page_size: 9999,
sort_name: 'sort',
sort_type: 'ASC',
filter: [{
key: 'status',
op: 'like',
value: 1
}]
}, true)
this.courseTypes = (res && res.data) ? res.data : []
} catch (e) {
this.courseTypes = []
}
},
getCourseTypeNameById(id) {
const hit = this.courseTypes.find((item) => String(item.id) === String(id))
return hit ? hit.name : ''
},
async loadData() {
this.loading = true
try {
const data = await getOverview({ year: this.currentYear })
this.systems = data.systems || []
this.courses = data.courses || []
this.schedules = data.schedules || []
// 带班管理:使用 group 列表用于编辑/回显 modules
this.scheduleGroups = await getScheduleList({ year: this.currentYear }) || []
if (!this.selectedSystemId || !this.systems.find((item) => String(item.id) === String(this.selectedSystemId))) {
this.selectedSystemId = this.systems[0] ? this.systems[0].id : ''
}
this.syncSelectedTreeNode()
} finally {
this.loading = false
}
},
async loadScheduleFormOptions(year) {
const data = await getOverview({ year: year || this.currentYear })
this.scheduleFormSystems = data.systems || []
this.scheduleFormCourses = data.courses || []
},
buildCounter(list, getter) {
return list.reduce((map, item) => {
const label = getter(item)
this.incrementCounter(map, label)
return map
}, {})
},
incrementCounter(counter, key) {
const safeKey = key || '-'
counter[safeKey] = (counter[safeKey] || 0) + 1
},
getTopCounterEntry(counter) {
return Object.keys(counter || {}).map((label) => ({
label,
value: counter[label]
})).sort((a, b) => {
if (b.value !== a.value) {
return b.value - a.value
}
return a.label.localeCompare(b.label, 'zh-CN')
})[0]
},
heatClass(value) {
const n = Number(value) || 0
for (let i = HEAT_LEVELS.length - 1; i >= 0; i--) {
if (n >= HEAT_LEVELS[i].min) return HEAT_LEVELS[i].class
}
return 'heat-empty'
},
openSystemCourseManager() {
this.systemCourseDialogVisible = true
if (!this.selectedSystemId && this.systems[0]) {
this.selectedSystemId = this.systems[0].id
}
if (!this.selectedTreeNodeKey && this.selectedSystemId) {
this.selectedTreeNodeKey = `system-${this.selectedSystemId}`
}
},
openScheduleManager() {
this.scheduleManagerDialogVisible = true
},
handleYearChange(value) {
this.currentYear = value || getCurrentYear()
this.loadData()
},
openSystemForm(mode, row) {
this.systemFormMode = mode
this.systemForm = row ? { id: row.id, year: row.year || this.currentYear, name: row.name, sort: row.sort } : { ...createSystemForm(), year: this.currentYear }
this.systemFormVisible = true
},
submitSystemForm() {
;(async() => {
await saveSystem(this.systemForm)
await this.loadData()
if (this.systemForm.id) {
this.selectedTreeNodeKey = `system-${this.systemForm.id}`
}
if (!this.selectedSystemId && this.systems[0]) {
this.selectedSystemId = this.systems[0].id
}
if (!this.systemForm.id && this.selectedSystemId) {
this.selectedTreeNodeKey = `system-${this.selectedSystemId}`
}
this.systemFormVisible = false
this.$message.success('计划体系保存成功')
})()
},
handleDeleteSystem(row) {
this.$confirm(`删除计划体系“${row.name}”后,其下课程体系和带班也会一并删除,是否继续?`, '提示', {
type: 'warning'
}).then(async() => {
await destroySystem({ id: row.id })
await this.loadData()
this.selectedTreeNodeKey = this.selectedSystemId ? `system-${this.selectedSystemId}` : ''
this.$message.success('计划体系删除成功')
}).catch(() => {})
},
openCourseForm(mode, row, targetSystem) {
this.courseFormMode = mode
if (row) {
this.courseForm = {
id: row.id,
system_id: row.system_id,
course_type_id: row.course_type_id || (row.course_type ? row.course_type.id : ''),
name: row.name,
sort: row.sort
}
} else {
this.courseForm = createCourseForm(targetSystem ? targetSystem.id : this.selectedSystemId)
}
this.courseFormVisible = true
},
submitCourseForm() {
;(async() => {
const selectedName = this.getCourseTypeNameById(this.courseForm.course_type_id)
if (selectedName) {
this.courseForm.name = selectedName
}
await saveCourse(this.courseForm)
await this.loadData()
this.selectedSystemId = this.courseForm.system_id
if (this.courseForm.id) {
this.selectedTreeNodeKey = `course-${this.courseForm.id}`
}
this.courseFormVisible = false
this.$message.success('课程体系保存成功')
})()
},
handleDeleteCourse(row) {
this.$confirm(`删除课程体系“${row.name}”后,其下带班也会一并删除,是否继续?`, '提示', {
type: 'warning'
}).then(async() => {
await destroyCourse({ id: row.id })
await this.loadData()
this.selectedTreeNodeKey = this.selectedSystemId ? `system-${this.selectedSystemId}` : ''
this.$message.success('课程体系删除成功')
}).catch(() => {})
},
handleTreeNodeSelect(data) {
this.selectedTreeNodeKey = data.treeKey
if (data.nodeType === 'system') {
this.selectedSystemId = data.id
} else if (data.raw && data.raw.system_id) {
this.selectedSystemId = data.raw.system_id
}
},
syncSelectedTreeNode() {
const exists = this.systemCourseTreeData.some((systemNode) => {
if (systemNode.treeKey === this.selectedTreeNodeKey) {
return true
}
return (systemNode.children || []).some((courseNode) => courseNode.treeKey === this.selectedTreeNodeKey)
})
if (!exists) {
this.selectedTreeNodeKey = this.selectedSystemId ? `system-${this.selectedSystemId}` : (this.systemCourseTreeData[0] ? this.systemCourseTreeData[0].treeKey : '')
}
},
async openScheduleForm(mode, row, preset = {}) {
this.scheduleFormMode = mode
if (row) {
const groupId = row.group_id || row.id
const group = (Array.isArray(this.scheduleGroups) ? this.scheduleGroups : []).find((g) => String(g.id) === String(groupId))
const modules = group && Array.isArray(group.modules) ? group.modules : []
this.scheduleForm = {
id: group ? group.id : groupId,
year: (group ? group.year : row.year) || getCurrentYear(),
system_id: group ? group.system_id : row.system_id,
course_id: group ? group.course_id : row.course_id,
title: group ? group.title : row.title,
modules: modules.map((m) => ({
id: m.id,
name: m.name || '',
month: m.month,
location_id: m.location_id || (m.location || {}).id || '',
owner_id: m.owner_id || (m.owner || {}).id || '',
count_text: m.count_text || ''
}))
}
await this.loadScheduleFormOptions(this.scheduleForm.year)
} else {
const defaultYear = preset.year || this.currentYear
await this.loadScheduleFormOptions(defaultYear)
const defaultSystemId = preset.system_id || this.selectedSystemId || (this.scheduleFormSystems[0] ? this.scheduleFormSystems[0].id : '')
const defaultCourse = this.scheduleFormCourses.find((item) => String(item.system_id) === String(defaultSystemId))
this.scheduleForm = {
...createScheduleForm(),
year: defaultYear,
system_id: defaultSystemId,
course_id: preset.course_id || (defaultCourse ? defaultCourse.id : '')
}
if (preset.month) {
this.scheduleForm.modules = [{
name: '',
month: preset.month,
location_id: '',
owner_id: '',
count_text: ''
}]
}
}
this.scheduleFormVisible = true
},
handlePlanCellClick(row, monthLabel, items) {
if (items && items.length) {
this.openScheduleForm('edit', items[0])
return
}
const month = Number(String(monthLabel).replace('月', ''))
this.openScheduleForm('add', null, {
year: this.currentYear,
system_id: row.systemId,
course_id: row.courseId,
month
})
},
async handleScheduleYearChange(value) {
const selectedYear = value || getCurrentYear()
this.scheduleForm.year = selectedYear
await this.loadScheduleFormOptions(selectedYear)
const firstSystem = this.scheduleFormSystems[0]
const firstCourse = this.scheduleFormCourses.find((item) => firstSystem && String(item.system_id) === String(firstSystem.id))
this.scheduleForm.system_id = firstSystem ? firstSystem.id : ''
this.scheduleForm.course_id = firstCourse ? firstCourse.id : ''
this.scheduleForm.month = []
},
handleScheduleSystemChange(value) {
const currentCourseExists = this.scheduleFormCourses.find((item) => String(item.id) === String(this.scheduleForm.course_id) && String(item.system_id) === String(value))
if (!currentCourseExists) {
const firstCourse = this.scheduleFormCourses.find((item) => String(item.system_id) === String(value))
this.scheduleForm.course_id = firstCourse ? firstCourse.id : ''
}
},
async refreshAfterScheduleAction(message) {
this.scheduleFormVisible = false
await this.loadData()
if (message) {
this.$message.success(message)
}
},
handleScheduleDialogCancel() {
this.scheduleFormVisible = false
this.loadData()
},
submitScheduleForm() {
;(async() => {
await saveSchedule(this.scheduleForm)
await this.refreshAfterScheduleAction('带班保存成功')
})()
},
handleDeleteSchedule(row) {
const groupId = row.group_id || row.id
this.$confirm(`确定删除带班“${row.title}”吗?`, '提示', {
type: 'warning'
}).then(async() => {
await destroySchedule({ id: groupId })
await this.refreshAfterScheduleAction('带班删除成功')
}).catch(() => {})
}
}
}
</script>
<style lang="scss">
.schedule-overview-page {
padding: 0 16px 24px;
background: #eff2f9;
min-height: calc(100vh - 84px);
}
.header-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.summary-panel,
.summary-panel {
padding: 18px;
margin-bottom: 14px;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(31, 45, 61, 0.04);
}
.summary-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
}
.summary-card {
min-height: 72px;
padding: 14px 16px;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
}
.summary-label {
font-size: 12px;
color: #96876d;
margin-bottom: 8px;
white-space: nowrap;
}
.summary-value {
display: flex;
align-items: baseline;
flex-wrap: nowrap;
color: #285b5c;
font-weight: 700;
white-space: nowrap;
}
.summary-number {
font-size: 28px;
line-height: 1;
}
.summary-unit {
font-size: 14px;
margin-left: 4px;
}
.panel {
padding: 14px;
margin-bottom: 14px;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(31, 45, 61, 0.04);
}
.panel-title {
font-size: 16px;
font-weight: 600;
color: #303133;
margin-bottom: 10px;
text-align: center;
}
.table-scroll {
overflow-x: auto;
}
.table-scroll-y {
overflow-y: auto;
}
.limit-rows-8 {
max-height: 314px;
}
.data-table,
.plan-table {
width: 100%;
min-width: 920px;
border-collapse: collapse;
table-layout: fixed;
background: #fff;
}
.data-table th,
.data-table td,
.plan-table th,
.plan-table td {
border: 1px solid #ebeef5;
padding: 8px 10px;
font-size: 12px;
color: #606266;
text-align: center;
}
.data-table thead th,
.plan-table thead th {
background: #f8f8f9;
color: #606266;
font-weight: 600;
}
.data-table tbody tr:hover td,
.plan-table tbody tr:hover td {
background: #f5f7fa;
}
.name-cell,
.total-cell {
font-weight: 600;
}
.empty-cell {
color: #909399;
background: #fff;
}
.heat-cell {
transition: background-color 0.2s ease;
}
.heat-empty {
background: #fff;
color: #c0c4cc;
}
.heat-level-1 {
background: #edf7f5;
}
.heat-level-2 {
background: #d7eeea;
}
.heat-level-3 {
background: #b7ddd5;
}
.heat-level-4 {
background: #8bc3b8;
color: #fff;
}
.plan-table {
min-width: 1600px;
}
.group-cell,
.course-cell {
font-weight: 600;
}
.group-cell {
width: 140px;
}
.course-cell {
width: 140px;
}
.plan-month-cell {
width: 140px;
height: 74px;
vertical-align: top;
background: #fff;
cursor: pointer;
transition: background-color 0.2s ease;
}
.plan-table thead th {
position: sticky;
top: 0;
z-index: 3;
background: #f8f8f9;
}
.plan-table .sticky-col {
position: sticky;
left: 0;
z-index: 4;
background: #fff;
}
.plan-table .sticky-col-2 {
left: 140px;
}
.plan-table thead .sticky-col-1,
.plan-table thead .sticky-col-2 {
z-index: 5;
}
.plan-month-cell:hover {
background: #f5f7fa;
}
.plan-chip-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.plan-chip {
background: #ffffff;
border: 1px solid #dcdfe6;
border-radius: 6px;
padding: 6px 4px;
line-height: 1.45;
box-shadow: 0 1px 2px rgba(31, 45, 61, 0.06);
}
.tone-green {
background: #dfead6;
}
.tone-blue {
background: #dbe6f7;
}
.tone-purple {
background: #eadff0;
}
.tone-sand {
background: #efe4d2;
}
.tone-cyan {
background: #d8edf0;
}
.tone-cream {
background: #f0e5d7;
}
.manager-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.second-toolbar {
margin-top: 18px;
}
.manager-title {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.manager-actions {
display: flex;
align-items: center;
gap: 10px;
}
.selected-tip {
color: #909399;
font-size: 12px;
}
.danger-text {
color: #d94b4b;
}
.tree-manager {
display: grid;
grid-template-columns: minmax(360px, 1fr) minmax(280px, 360px);
gap: 16px;
}
.tree-panel,
.tree-detail-panel {
border: 1px solid #ebeef5;
border-radius: 10px;
background: #fff;
padding: 14px;
}
.tree-wrap {
max-height: 520px;
overflow: auto;
padding-right: 4px;
}
.tree-node {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
padding: 4px 0;
}
.tree-node-main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.tree-node-tag {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 34px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
font-size: 11px;
line-height: 20px;
}
.system-tag {
background: #ecf5ff;
color: #409eff;
}
.course-tag {
background: #f0f9eb;
color: #67c23a;
}
.tree-node-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #606266;
}
.tree-node-actions {
display: flex;
align-items: center;
flex-shrink: 0;
}
.node-detail-card {
min-height: 220px;
padding: 16px;
border-radius: 8px;
background: #fff;
border: 1px solid #ebeef5;
}
.node-detail-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 0;
border-bottom: 1px dashed #ebeef5;
}
.node-detail-row:last-of-type {
border-bottom: 0;
}
.node-detail-label {
width: 72px;
color: #909399;
}
.node-detail-value {
flex: 1;
color: #606266;
word-break: break-all;
}
.node-detail-actions {
display: flex;
gap: 10px;
margin-top: 16px;
}
.tree-empty-state {
min-height: 220px;
display: flex;
align-items: center;
justify-content: center;
color: #909399;
background: #fff;
border: 1px dashed #dcdfe6;
border-radius: 8px;
}
::v-deep .el-tree {
background: transparent;
}
::v-deep .el-tree-node__content {
height: auto;
min-height: 36px;
border-radius: 8px;
margin-bottom: 4px;
padding-right: 8px;
}
::v-deep .el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content {
background: #f5f7fa;
}
::v-deep .overview-dialog .el-dialog {
background: #eff2f9;
border-radius: 12px;
margin: 10vh auto 0;
}
::v-deep .overview-dialog .el-dialog__header {
background: #eff2f9;
border-bottom: 1px solid #e7dccb;
border-radius: 12px 12px 0 0;
}
::v-deep .overview-dialog .el-dialog__body {
background: #eff2f9;
}
@media (max-width: 1200px) {
.summary-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.tree-manager {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.schedule-overview-page {
padding: 0 10px 18px;
}
.header-actions {
justify-content: flex-start;
flex-wrap: wrap;
}
.summary-grid {
grid-template-columns: 1fr;
}
.manager-toolbar,
.manager-actions {
flex-direction: column;
align-items: flex-start;
}
}
</style>