计划课程

master
lion 3 months ago
parent 06342737c5
commit 010c3cb947

@ -0,0 +1,8 @@
## 本地开发环境覆盖(不会提交到线上配置,优先级高于 .env.development
ENV='development'
# 后端本地 Laravelphp artisan serve 默认 8000
VUE_APP_PRO_API = http://127.0.0.1:8000
VUE_APP_BASE_API = http://127.0.0.1:8000
VUE_APP_UPLOAD_API = http://127.0.0.1:8000/api/admin/upload-file

@ -79,3 +79,51 @@ export function destroySchedule(params) {
params
})
}
export function getLocationList(params) {
return request({
method: 'get',
url: '/api/admin/schedule-overview/locations/index',
params
})
}
export function saveLocation(data) {
return request({
method: 'post',
url: '/api/admin/schedule-overview/locations/save',
data
})
}
export function destroyLocation(params) {
return request({
method: 'get',
url: '/api/admin/schedule-overview/locations/destroy',
params
})
}
export function getOwnerList(params) {
return request({
method: 'get',
url: '/api/admin/schedule-overview/owners/index',
params
})
}
export function saveOwner(data) {
return request({
method: 'post',
url: '/api/admin/schedule-overview/owners/save',
data
})
}
export function destroyOwner(params) {
return request({
method: 'get',
url: '/api/admin/schedule-overview/owners/destroy',
params
})
}

@ -0,0 +1,71 @@
<template>
<el-dialog
:title="mode === 'add' ? '新增课程体系' : '编辑课程体系'"
:visible.sync="innerVisible"
width="520px"
append-to-body
@open="handleOpen"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="88px">
<el-form-item label="所属计划体系" prop="system_id">
<el-select v-model="form.system_id" filterable placeholder="请选择计划体系" style="width: 100%;">
<el-option v-for="item in systems" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="课程体系" prop="course_type_id">
<el-select
v-model="form.course_type_id"
filterable
placeholder="请选择课程体系"
style="width: 100%;"
@change="(val) => $emit('course-type-change', val)"
>
<el-option v-for="item in courseTypes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="form.sort" :min="1" :max="999" style="width: 100%;" />
</el-form-item>
</el-form>
<span slot="footer">
<el-button @click="innerVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit"></el-button>
</span>
</el-dialog>
</template>
<script>
export default {
name: 'CourseFormDialog',
props: {
visible: { type: Boolean, default: false },
mode: { type: String, default: 'add' },
form: { type: Object, required: true },
rules: { type: Object, default: () => ({}) },
systems: { type: Array, default: () => [] },
courseTypes: { type: Array, default: () => [] }
},
computed: {
innerVisible: {
get() { return this.visible },
set(val) { this.$emit('update:visible', val) }
}
},
methods: {
handleOpen() {
this.$nextTick(() => {
if (this.$refs.formRef) {
this.$refs.formRef.clearValidate()
}
})
},
handleSubmit() {
this.$refs.formRef.validate((valid) => {
if (!valid) return
this.$emit('submit')
})
}
}
}
</script>

@ -0,0 +1,160 @@
<template>
<el-dialog
title="地点管理"
:visible.sync="innerVisible"
width="640px"
append-to-body
@open="$emit('open')"
>
<div class="manager-toolbar">
<div class="manager-title">地点列表</div>
<div class="manager-actions">
<el-button size="small" type="primary" @click="openCreate"></el-button>
</div>
</div>
<el-table :data="list" border size="small">
<el-table-column prop="name" label="地点" min-width="220">
<template slot-scope="{ row }">{{ row.name }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100" align="center">
<template slot-scope="{ row }">
<el-tag :type="Number(row.status) === 1 ? 'success' : 'info'">{{ Number(row.status) === 1 ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="100" align="center" />
<el-table-column label="操作" width="200" align="center">
<template slot-scope="{ row }">
<el-button type="text" size="small" @click="openEdit(row)"></el-button>
<el-button type="text" size="small" class="danger-text" @click="$emit('delete', row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<span slot="footer">
<el-button @click="innerVisible = false">关闭</el-button>
</span>
<el-dialog
:title="editor.mode === 'create' ? '新增地点' : '编辑地点'"
:visible.sync="editor.visible"
width="420px"
append-to-body
>
<el-form ref="editorRef" :model="editor.form" :rules="editorRules" label-width="80px">
<el-form-item label="地点名称" prop="name">
<el-input v-model="editor.form.name" placeholder="请输入地点名称" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="editor.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="sort">
<el-input-number v-model="editor.form.sort" :min="0" :max="9999" style="width: 100%;" />
</el-form-item>
</el-form>
<span slot="footer">
<el-button @click="editor.visible = false">取消</el-button>
<el-button type="primary" @click="submitEditor"></el-button>
</span>
</el-dialog>
</el-dialog>
</template>
<script>
export default {
name: 'LocationManagerDialog',
props: {
visible: { type: Boolean, default: false },
list: { type: Array, default: () => [] }
},
data() {
return {
editor: {
visible: false,
mode: 'create',
form: {
id: '',
name: '',
status: 1,
sort: 0
}
},
editorRules: {
name: [{ required: true, message: '请输入地点名称', trigger: 'blur' }]
}
}
},
computed: {
innerVisible: {
get() { return this.visible },
set(val) { this.$emit('update:visible', val) }
}
},
methods: {
openCreate() {
this.editor.mode = 'create'
this.editor.form = { id: '', name: '', status: 1, sort: 0 }
this.editor.visible = true
this.$nextTick(() => this.$refs.editorRef?.clearValidate?.())
},
openEdit(row) {
this.editor.mode = 'edit'
this.editor.form = {
id: row.id,
name: row.name,
status: Number(row.status) === 0 ? 0 : 1,
sort: Number(row.sort || 0)
}
this.editor.visible = true
this.$nextTick(() => this.$refs.editorRef?.clearValidate?.())
},
submitEditor() {
this.$refs.editorRef.validate((valid) => {
if (!valid) return
const payload = {
id: this.editor.form.id,
name: String(this.editor.form.name || '').trim(),
status: Number(this.editor.form.status) === 0 ? 0 : 1,
sort: Number(this.editor.form.sort || 0)
}
if (this.editor.mode === 'create') {
this.$emit('add', payload)
} else {
this.$emit('edit', payload)
}
this.editor.visible = false
})
}
}
}
</script>
<style lang="scss" scoped>
.manager-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.manager-title {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.manager-actions {
display: flex;
align-items: center;
gap: 10px;
}
.danger-text {
color: #d94b4b;
}
</style>

@ -1,30 +1,19 @@
<template>
<div class="panel">
<div class="panel-title">人员负载与交叉分布</div>
<div class="table-scroll table-scroll-y limit-rows-8">
<table class="data-table">
<thead>
<tr>
<th>人员</th>
<th>总次数</th>
<th>峰值月</th>
<th>主战场地</th>
<th>主战体系</th>
</tr>
</thead>
<tbody>
<tr v-for="item in memberAnalysis" :key="item.name">
<td>{{ item.name }}</td>
<td>{{ item.total }}</td>
<td>{{ item.month }}</td>
<td>{{ item.location }}</td>
<td>{{ item.tag }}</td>
</tr>
<tr v-if="!memberAnalysis.length">
<td colspan="5" class="empty-cell">暂无数据</td>
</tr>
</tbody>
</table>
<div class="table-scroll-y limit-rows-8">
<el-table
:data="memberAnalysis"
border
max-height="314"
:empty-text="'暂无数据'"
>
<el-table-column prop="name" label="人员" min-width="100" align="center" />
<el-table-column prop="total" label="总次数" min-width="90" align="center" />
<el-table-column prop="month" label="峰值月" min-width="90" align="center" />
<el-table-column prop="location" label="主战场地" min-width="120" align="center" />
<el-table-column prop="tag" label="主战体系" min-width="120" align="center" />
</el-table>
</div>
</div>
</template>
@ -40,4 +29,3 @@ export default {
}
}
</script>

@ -1,32 +1,29 @@
<template>
<div class="panel">
<div class="panel-title">人员月度开班次数</div>
<div class="table-scroll table-scroll-y limit-rows-8">
<table class="data-table heatmap-table">
<thead>
<tr>
<th>人员</th>
<th v-for="month in monthLabels" :key="month">{{ month }}</th>
<th>合计</th>
</tr>
</thead>
<tbody>
<tr v-for="row in monthlyStats" :key="row.name">
<td class="name-cell">{{ row.name }}</td>
<td
v-for="(value, index) in row.months"
:key="row.name + index"
:class="['heat-cell', heatClass(value)]"
>
{{ value ? value : '-' }}
</td>
<td class="total-cell">{{ row.total }}</td>
</tr>
<tr v-if="!monthlyStats.length">
<td :colspan="monthLabels.length + 2" class="empty-cell">暂无数据</td>
</tr>
</tbody>
</table>
<div class="table-scroll-y limit-rows-8">
<el-table
:data="monthlyStats"
border
max-height="314"
class="heatmap-table"
:cell-class-name="cellClassName"
:empty-text="'暂无数据'"
>
<el-table-column prop="name" label="人员" min-width="80" align="center" class-name="name-cell" />
<el-table-column
v-for="(month, idx) in monthLabels"
:key="month"
:label="month"
min-width="60"
align="center"
>
<template slot-scope="{ row }">
{{ row.months && row.months[idx] ? row.months[idx] : '-' }}
</template>
</el-table-column>
<el-table-column prop="total" label="合计" min-width="70" align="center" class-name="total-cell" />
</el-table>
</div>
</div>
</template>
@ -47,7 +44,15 @@ export default {
type: Function,
default: () => () => 'heat-empty'
}
},
methods: {
cellClassName({ row, columnIndex }) {
if (columnIndex >= 1 && columnIndex < 1 + this.monthLabels.length) {
const val = row.months && row.months[columnIndex - 1]
return ['heat-cell', this.heatClass(val)].filter(Boolean).join(' ')
}
return ''
}
}
}
</script>

@ -0,0 +1,160 @@
<template>
<el-dialog
title="负责人管理"
:visible.sync="innerVisible"
width="640px"
append-to-body
@open="$emit('open')"
>
<div class="manager-toolbar">
<div class="manager-title">负责人列表</div>
<div class="manager-actions">
<el-button size="small" type="primary" @click="openCreate"></el-button>
</div>
</div>
<el-table :data="list" border size="small">
<el-table-column prop="name" label="负责人" min-width="220">
<template slot-scope="{ row }">{{ row.name }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100" align="center">
<template slot-scope="{ row }">
<el-tag :type="Number(row.status) === 1 ? 'success' : 'info'">{{ Number(row.status) === 1 ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="100" align="center" />
<el-table-column label="操作" width="200" align="center">
<template slot-scope="{ row }">
<el-button type="text" size="small" @click="openEdit(row)"></el-button>
<el-button type="text" size="small" class="danger-text" @click="$emit('delete', row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<span slot="footer">
<el-button @click="innerVisible = false">关闭</el-button>
</span>
<el-dialog
:title="editor.mode === 'create' ? '新增负责人' : '编辑负责人'"
:visible.sync="editor.visible"
width="420px"
append-to-body
>
<el-form ref="editorRef" :model="editor.form" :rules="editorRules" label-width="80px">
<el-form-item label="负责人" prop="name">
<el-input v-model="editor.form.name" placeholder="请输入负责人名称" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="editor.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="sort">
<el-input-number v-model="editor.form.sort" :min="0" :max="9999" style="width: 100%;" />
</el-form-item>
</el-form>
<span slot="footer">
<el-button @click="editor.visible = false">取消</el-button>
<el-button type="primary" @click="submitEditor"></el-button>
</span>
</el-dialog>
</el-dialog>
</template>
<script>
export default {
name: 'OwnerManagerDialog',
props: {
visible: { type: Boolean, default: false },
list: { type: Array, default: () => [] }
},
data() {
return {
editor: {
visible: false,
mode: 'create',
form: {
id: '',
name: '',
status: 1,
sort: 0
}
},
editorRules: {
name: [{ required: true, message: '请输入负责人名称', trigger: 'blur' }]
}
}
},
computed: {
innerVisible: {
get() { return this.visible },
set(val) { this.$emit('update:visible', val) }
}
},
methods: {
openCreate() {
this.editor.mode = 'create'
this.editor.form = { id: '', name: '', status: 1, sort: 0 }
this.editor.visible = true
this.$nextTick(() => this.$refs.editorRef?.clearValidate?.())
},
openEdit(row) {
this.editor.mode = 'edit'
this.editor.form = {
id: row.id,
name: row.name,
status: Number(row.status) === 0 ? 0 : 1,
sort: Number(row.sort || 0)
}
this.editor.visible = true
this.$nextTick(() => this.$refs.editorRef?.clearValidate?.())
},
submitEditor() {
this.$refs.editorRef.validate((valid) => {
if (!valid) return
const payload = {
id: this.editor.form.id,
name: String(this.editor.form.name || '').trim(),
status: Number(this.editor.form.status) === 0 ? 0 : 1,
sort: Number(this.editor.form.sort || 0)
}
if (this.editor.mode === 'create') {
this.$emit('add', payload)
} else {
this.$emit('edit', payload)
}
this.editor.visible = false
})
}
}
}
</script>
<style lang="scss" scoped>
.manager-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.manager-title {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.manager-actions {
display: flex;
align-items: center;
gap: 10px;
}
.danger-text {
color: #d94b4b;
}
</style>

@ -1,48 +1,79 @@
<template>
<div class="panel">
<div class="table-scroll">
<table class="plan-table">
<thead>
<tr>
<th>体系</th>
<th>课程</th>
<th v-for="month in monthLabels" :key="month">{{ month }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in planRows" :key="row.rowKey">
<td v-if="row.showGroup" :rowspan="row.groupSpan" :class="['group-cell', row.groupClass]">
{{ row.group }}
</td>
<td :class="['course-cell', row.groupClass]">{{ row.course }}</td>
<td
v-for="month in monthLabels"
:key="row.rowKey + month"
class="plan-month-cell"
@click="onCellClick(row, month, row.plan[month])"
<div class="panel-header">
<div class="panel-title">{{ displayYear }}年度带班工作分工</div>
<el-button
type="primary"
size="small"
plain
icon="el-icon-download"
@click="handleExport"
>
导出
</el-button>
</div>
<el-table
:data="planRows"
border
:span-method="spanMethod"
class="plan-el-table"
:cell-class-name="cellClassName"
:header-cell-class-name="headerCellClassName"
>
<el-table-column fixed="left" prop="group" label="计划体系" width="140">
<template slot-scope="{ row }">
<div class="fixed-cell">{{ row.group }}</div>
</template>
</el-table-column>
<el-table-column fixed="left" prop="course" label="课程体系" width="140">
<template slot-scope="{ row }">
<div class="fixed-cell">{{ row.course }}</div>
</template>
</el-table-column>
<el-table-column
v-for="month in monthLabels"
:key="month"
:label="month"
:min-width="140"
>
<template slot-scope="{ row }">
<div class="month-cell" @click="onCellClick(row, month, row.plan[month])">
<div
v-if="row.plan[month] && row.plan[month].length"
class="plan-chip-list"
>
<div v-if="row.plan[month] && row.plan[month].length" class="plan-chip-list">
<div v-for="item in row.plan[month]" :key="item.id" class="plan-chip">
<div>{{ item.title }}</div>
<div>{{ item.ownerLocation }}</div>
<div>{{ item.countText }}</div>
<div
v-for="item in row.plan[month]"
:key="item.id"
class="plan-chip"
>
<div class="plan-chip-line1">
{{ formatChipLine1(item) }}
</div>
<div v-if="item.countText" class="plan-chip-line2">
{{ item.countText }}
</div>
</div>
</td>
</tr>
<tr v-if="!planRows.length">
<td :colspan="monthLabels.length + 2" class="empty-cell">暂无数据</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script>
import * as XLSX from 'xlsx'
export default {
name: 'SchedulePlanMatrix',
props: {
year: {
type: String,
default: ''
},
monthLabels: {
type: Array,
default: () => []
@ -52,11 +83,270 @@ export default {
default: () => []
}
},
computed: {
displayYear() {
return this.year || String(new Date().getFullYear())
},
spanMeta() {
const rows = this.planRows || []
const systemMeta = rows.map(() => ({ rowspan: 1, colspan: 1 }))
const courseMeta = rows.map(() => ({ rowspan: 1, colspan: 1 }))
// systemId
let i = 0
while (i < rows.length) {
const systemKey = String(rows[i].systemId)
let j = i + 1
while (j < rows.length && String(rows[j].systemId) === 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
}
// systemId courseId
i = 0
while (i < rows.length) {
const systemKey = String(rows[i].systemId)
let j = i + 1
while (j < rows.length && String(rows[j].systemId) === systemKey) {
j += 1
}
let p = i
while (p < j) {
const courseKey = String(rows[p].courseId)
let q = p + 1
while (q < j && String(rows[q].courseId) === courseKey) {
q += 1
}
const span = q - p
courseMeta[p] = { rowspan: span, colspan: 1 }
for (let k = p + 1; k < q; k += 1) {
courseMeta[k] = { rowspan: 0, colspan: 0 }
}
p = q
}
i = j
}
return { systemMeta, courseMeta }
}
},
methods: {
formatChipLine1(item) {
const title = item.title || ''
const mod = item.moduleName || ''
const loc = item.location || '-'
const owner = item.owner || '-'
const part1 = mod ? `${title}${mod}` : title
return `${part1} - ${loc} - ${owner}`.trim() || '-'
},
onCellClick(row, month, items) {
this.$emit('cell-click', row, month, items)
},
formatMonthCell(row, monthLabel) {
const items = row.plan && row.plan[monthLabel]
if (!items || !items.length) return ''
return items.map((item) => {
const line1 = this.formatChipLine1(item)
return item.countText ? `${line1}\n${item.countText}` : line1
}).join('\n')
},
handleExport() {
if (!this.planRows || !this.planRows.length) {
this.$message.warning('暂无数据可导出')
return
}
try {
const headers = ['计划体系', '课程体系', ...this.monthLabels]
const excelData = [headers]
this.planRows.forEach((row) => {
const monthValues = this.monthLabels.map((m) => this.formatMonthCell(row, m))
excelData.push([row.group || '', row.course || '', ...monthValues])
})
const wb = XLSX.utils.book_new()
const ws = XLSX.utils.aoa_to_sheet(excelData)
ws['!cols'] = headers.map((_, i) => ({ wch: i < 2 ? 14 : 28 }))
XLSX.utils.book_append_sheet(wb, ws, '带班工作分工')
const fileName = `${this.displayYear}年度带班工作分工.xlsx`
XLSX.writeFile(wb, fileName)
this.$message.success('导出成功')
} catch (err) {
console.error('导出失败:', err)
this.$message.error('导出失败,请重试')
}
},
spanMethod({ rowIndex, columnIndex }) {
const meta = this.spanMeta
// 1
if (columnIndex === 0) {
const m = meta.systemMeta[rowIndex]
return m ? { rowspan: m.rowspan, colspan: m.colspan } : { rowspan: 1, colspan: 1 }
}
// 2
if (columnIndex === 1) {
const m = meta.courseMeta[rowIndex]
return m ? { rowspan: m.rowspan, colspan: m.colspan } : { rowspan: 1, colspan: 1 }
}
return { rowspan: 1, colspan: 1 }
},
cellClassName({ row, columnIndex }) {
if (columnIndex === 0 || columnIndex === 1) {
return ['fixed-col-cell', row && row.groupClass ? row.groupClass : ''].filter(Boolean).join(' ')
}
const monthLabel = this.monthLabels[columnIndex - 2]
const hasContent = monthLabel && row.plan && row.plan[monthLabel] && row.plan[monthLabel].length > 0
const toneClass = hasContent && row.groupClass ? row.groupClass : ''
return ['month-col-cell', toneClass].filter(Boolean).join(' ')
},
headerCellClassName({ columnIndex }) {
if (columnIndex === 0 || columnIndex === 1) {
return 'fixed-col-header'
}
return ''
}
}
}
</script>
<style lang="scss" scoped>
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.panel-title {
margin-bottom: 0;
}
.plan-el-table {
width: 100%;
}
.fixed-cell {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.month-cell {
min-height: 74px;
cursor: pointer;
}
/* 空白月份单元格白色背景;有内容的单元格由下方 tone 样式着色 */
::v-deep .el-table td.month-col-cell {
background: #fff;
}
::v-deep .el-table__body tr:hover > td.month-col-cell:not([class*="tone-"]) {
background: #f5f7fa;
}
.plan-chip-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.plan-chip {
background: transparent;
border: 0;
border-radius: 0;
padding: 6px 4px;
line-height: 1.5;
box-shadow: none;
}
.plan-chip-line1 {
white-space: normal;
word-break: break-all;
}
.plan-chip-line2 {
margin-top: 4px;
font-size: 12px;
color: #909399;
}
::v-deep .el-table th {
background: #f8f8f9;
}
::v-deep .el-table__fixed,
::v-deep .el-table__fixed-right {
box-shadow: 2px 0 6px rgba(31, 45, 61, 0.06);
}
/* 鼠标 hover 行时,固定列也保持原本 tone 背景色 */
::v-deep .el-table__body tr:hover > td.tone-green,
::v-deep .el-table__fixed-body-wrapper tr:hover > td.tone-green {
background: #dfead6 !important;
}
::v-deep .el-table__body tr:hover > td.tone-blue,
::v-deep .el-table__fixed-body-wrapper tr:hover > td.tone-blue {
background: #dbe6f7 !important;
}
::v-deep .el-table__body tr:hover > td.tone-purple,
::v-deep .el-table__fixed-body-wrapper tr:hover > td.tone-purple {
background: #eadff0 !important;
}
::v-deep .el-table__body tr:hover > td.tone-sand,
::v-deep .el-table__fixed-body-wrapper tr:hover > td.tone-sand {
background: #efe4d2 !important;
}
::v-deep .el-table__body tr:hover > td.tone-cyan,
::v-deep .el-table__fixed-body-wrapper tr:hover > td.tone-cyan {
background: #d8edf0 !important;
}
::v-deep .el-table__body tr:hover > td.tone-cream,
::v-deep .el-table__fixed-body-wrapper tr:hover > td.tone-cream {
background: #f0e5d7 !important;
}
/* 保持父组件传入的色块风格 */
::v-deep .el-table td.tone-green,
::v-deep .el-table__fixed td.tone-green,
::v-deep .el-table__fixed-left td.tone-green,
::v-deep .el-table__fixed-right td.tone-green {
background: #dfead6;
}
::v-deep .el-table td.tone-blue,
::v-deep .el-table__fixed td.tone-blue,
::v-deep .el-table__fixed-left td.tone-blue,
::v-deep .el-table__fixed-right td.tone-blue {
background: #dbe6f7;
}
::v-deep .el-table td.tone-purple,
::v-deep .el-table__fixed td.tone-purple,
::v-deep .el-table__fixed-left td.tone-purple,
::v-deep .el-table__fixed-right td.tone-purple {
background: #eadff0;
}
::v-deep .el-table td.tone-sand,
::v-deep .el-table__fixed td.tone-sand,
::v-deep .el-table__fixed-left td.tone-sand,
::v-deep .el-table__fixed-right td.tone-sand {
background: #efe4d2;
}
::v-deep .el-table td.tone-cyan,
::v-deep .el-table__fixed td.tone-cyan,
::v-deep .el-table__fixed-left td.tone-cyan,
::v-deep .el-table__fixed-right td.tone-cyan {
background: #d8edf0;
}
::v-deep .el-table td.tone-cream,
::v-deep .el-table__fixed td.tone-cream,
::v-deep .el-table__fixed-left td.tone-cream,
::v-deep .el-table__fixed-right td.tone-cream {
background: #f0e5d7;
}
</style>

@ -0,0 +1,201 @@
<template>
<el-dialog
:title="mode === 'add' ? '新增带班' : '编辑带班'"
:visible.sync="innerVisible"
width="60%"
append-to-body
@open="handleOpen"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="88px">
<el-form-item label="年份" prop="year">
<el-date-picker
v-model="form.year"
type="year"
value-format="yyyy"
format="yyyy"
placeholder="请选择年份"
style="width: 100%;"
@change="(val) => $emit('year-change', val)"
/>
</el-form-item>
<el-form-item label="计划体系" prop="system_id">
<el-select v-model="form.system_id" filterable placeholder="请选择计划体系" style="width: 100%;" @change="(val) => $emit('system-change', val)">
<el-option v-for="item in systems" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="课程体系" prop="course_id">
<el-select v-model="form.course_id" filterable placeholder="请选择课程体系" style="width: 100%;">
<el-option v-for="item in courseOptions" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="课程名称" prop="title">
<el-input v-model="form.title" placeholder="请输入课程名称" />
</el-form-item>
<el-form-item label="模块/期数" prop="modules">
<div class="modules-toolbar">
<div class="modules-tip">至少添加 1 名称可不填月份/地点/负责人必填</div>
<el-button size="small" type="primary" plain @click="addModule"></el-button>
</div>
<el-table :data="form.modules" border size="mini">
<el-table-column label="名称(可选)" min-width="140">
<template slot-scope="{ row }">
<el-input v-model="row.name" size="mini" placeholder="例如模块1" />
</template>
</el-table-column>
<el-table-column label="月份" width="110">
<template slot-scope="{ row }">
<el-select v-model="row.month" size="mini" placeholder="月份" style="width: 100%;">
<el-option v-for="item in monthOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</template>
</el-table-column>
<el-table-column label="地点" min-width="160">
<template slot-scope="{ row }">
<div class="inline-field">
<el-select v-model="row.location_id" size="mini" filterable placeholder="请选择地点" style="flex: 1; min-width: 0;">
<el-option v-for="item in locations" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-button size="mini" plain @click.stop="$emit('open-location-manager')">新增</el-button>
</div>
</template>
</el-table-column>
<el-table-column label="负责人" min-width="140">
<template slot-scope="{ row }">
<div class="inline-field">
<el-select v-model="row.owner_id" size="mini" filterable placeholder="请选择负责人" style="flex: 1; min-width: 0;">
<el-option v-for="item in owners" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-button size="mini" plain @click.stop="$emit('open-owner-manager')">新增</el-button>
</div>
</template>
</el-table-column>
<el-table-column label="备注" min-width="160">
<template slot-scope="{ row }">
<el-input v-model="row.count_text" size="mini" placeholder="请输入备注" />
</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center">
<template slot-scope="{ $index }">
<el-button type="text" size="mini" class="danger-text" @click="removeModule($index)"></el-button>
</template>
</el-table-column>
</el-table>
</el-form-item>
</el-form>
<span slot="footer">
<el-button
v-if="mode === 'edit' && form.id"
type="danger"
plain
@click="$emit('delete', form)"
>
删除
</el-button>
<el-button @click="$emit('cancel')"></el-button>
<el-button type="primary" @click="handleSubmit"></el-button>
</span>
</el-dialog>
</template>
<script>
export default {
name: 'ScheduleFormDialog',
props: {
visible: { type: Boolean, default: false },
mode: { type: String, default: 'add' },
form: { type: Object, required: true },
rules: { type: Object, default: () => ({}) },
systems: { type: Array, default: () => [] },
courseOptions: { type: Array, default: () => [] },
monthOptions: { type: Array, default: () => [] },
locations: { type: Array, default: () => [] },
owners: { type: Array, default: () => [] }
},
computed: {
innerVisible: {
get() { return this.visible },
set(val) { this.$emit('update:visible', val) }
}
},
methods: {
handleOpen() {
this.$nextTick(() => {
if (this.$refs.formRef) {
this.$refs.formRef.clearValidate()
}
})
if (!Array.isArray(this.form.modules)) {
this.$set(this.form, 'modules', [])
}
if (!this.form.modules.length) {
this.addModule()
}
},
addModule() {
if (!Array.isArray(this.form.modules)) {
this.$set(this.form, 'modules', [])
}
this.form.modules.push({
name: '',
month: '',
location_id: '',
owner_id: '',
count_text: ''
})
},
removeModule(index) {
if (!Array.isArray(this.form.modules)) return
if (this.form.modules.length <= 1) {
this.$message.warning('模块/期数至少需要一条数据')
return
}
this.form.modules.splice(index, 1)
},
handleSubmit() {
this.$refs.formRef.validate((valid) => {
if (!valid) return
const list = Array.isArray(this.form.modules) ? this.form.modules : []
if (!list.length) {
this.$message.error('模块/期数至少需要一条数据')
return
}
const invalidIndex = list.findIndex((m) => !m.month || !m.location_id || !m.owner_id)
if (invalidIndex >= 0) {
this.$message.error(`请完善第 ${invalidIndex + 1} 条模块/期数的必填项`)
return
}
this.$emit('submit')
})
}
}
}
</script>
<style lang="scss" scoped>
.modules-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.inline-field {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.modules-tip {
color: #909399;
font-size: 12px;
}
.danger-text {
color: #d94b4b;
}
</style>

@ -0,0 +1,63 @@
<template>
<el-dialog
title="带班管理"
:visible.sync="innerVisible"
width="68%"
custom-class="overview-dialog"
>
<div class="manager-toolbar">
<div class="manager-header">
<div class="manager-title">带班列表</div>
<div class="manager-tip">同一课程不同模块可直接编辑</div>
</div>
<div class="manager-actions">
<el-button size="small" type="primary" @click="$emit('open-schedule-form', 'add')">新增带班</el-button>
</div>
</div>
<el-table :data="rows" border :span-method="spanMethod">
<el-table-column prop="systemName" label="计划体系" min-width="140" />
<el-table-column prop="courseName" label="课程体系" min-width="160" />
<el-table-column prop="title" label="课程名称" min-width="180" />
<el-table-column prop="moduleName" label="模块/期数" min-width="120" />
<el-table-column prop="monthLabel" label="月份" width="90" align="center" />
<el-table-column prop="location" label="地点" min-width="120" />
<el-table-column prop="owner" label="负责人" min-width="120" />
<el-table-column prop="count_text" label="备注" min-width="160" />
<el-table-column label="操作" width="160" align="center" fixed="right">
<template slot-scope="scope">
<el-button type="text" size="small" @click="$emit('open-schedule-form', 'edit', scope.row)">编辑</el-button>
<el-button type="text" size="small" class="danger-text" @click="$emit('delete-schedule', scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
</template>
<script>
export default {
name: 'ScheduleManagerDialog',
props: {
visible: { type: Boolean, default: false },
rows: { type: Array, default: () => [] },
spanMethod: { type: Function, default: null }
},
computed: {
innerVisible: {
get() { return this.visible },
set(val) { this.$emit('update:visible', val) }
}
}
}
</script>
<style lang="scss" scoped>
.manager-header {
display: flex;
flex-direction: column;
gap: 4px;
}
.manager-tip {
font-size: 12px;
color: #909399;
}
</style>

@ -0,0 +1,160 @@
<template>
<el-dialog
title="计划体系管理"
:visible.sync="innerVisible"
width="58%"
custom-class="overview-dialog"
@open="handleOpen"
>
<div class="tree-manager">
<div class="tree-panel">
<div class="manager-toolbar">
<div class="manager-title">计划体系树</div>
<div class="manager-actions">
<el-button size="small" type="primary" @click="$emit('open-system-form', 'add')">新增计划体系</el-button>
<el-button size="small" :disabled="!selectedSystem" @click="$emit('open-course-form', { mode: 'add' })">新增课程体系</el-button>
</div>
</div>
<div class="tree-wrap">
<el-tree
ref="tree"
:data="treeData"
node-key="treeKey"
highlight-current
default-expand-all
:expand-on-click-node="false"
@node-click="(data) => $emit('tree-node-select', data)"
>
<div slot-scope="{ data }" class="tree-node">
<div class="tree-node-main">
<span :class="['tree-node-tag', data.nodeType === 'system' ? 'system-tag' : 'course-tag']">
{{ data.nodeType === 'system' ? '计划体系' : '课程体系' }}
</span>
<span class="tree-node-label">{{ data.name }}</span>
</div>
<div class="tree-node-actions">
<el-button
v-if="data.nodeType === 'system'"
type="text"
size="mini"
@click.stop="$emit('open-course-form', { mode: 'add', row: null, targetSystem: data.raw })"
>
新增课程体系
</el-button>
<el-button
type="text"
size="mini"
@click.stop="data.nodeType === 'system' ? $emit('open-system-form', 'edit', data.raw) : $emit('open-course-form', { mode: 'edit', row: data.raw })"
>
编辑
</el-button>
<el-button
type="text"
size="mini"
class="danger-text"
@click.stop="data.nodeType === 'system' ? $emit('delete-system', data.raw) : $emit('delete-course', data.raw)"
>
删除
</el-button>
</div>
</div>
</el-tree>
</div>
</div>
<div class="tree-detail-panel">
<div class="manager-toolbar">
<div class="manager-title">节点信息</div>
<div class="manager-actions">
<span class="selected-tip">{{ selectedTreeNode ? selectedTreeNode.name : '请在左侧选择节点' }}</span>
</div>
</div>
<div v-if="selectedTreeNode" class="node-detail-card">
<div class="node-detail-row">
<span class="node-detail-label">类型</span>
<span class="node-detail-value">{{ selectedTreeNode.nodeType === 'system' ? '计划体系' : '课程体系' }}</span>
</div>
<div class="node-detail-row">
<span class="node-detail-label">名称</span>
<span class="node-detail-value">{{ selectedTreeNode.name }}</span>
</div>
<div class="node-detail-row">
<span class="node-detail-label">年份</span>
<span class="node-detail-value">{{ selectedTreeNode.year }}</span>
</div>
<div class="node-detail-row">
<span class="node-detail-label">排序</span>
<span class="node-detail-value">{{ selectedTreeNode.sort }}</span>
</div>
<div v-if="selectedTreeNode.nodeType === 'course'" class="node-detail-row">
<span class="node-detail-label">所属计划体系</span>
<span class="node-detail-value">{{ selectedTreeNode.systemName }}</span>
</div>
<div class="node-detail-actions">
<el-button
v-if="selectedTreeNode.nodeType === 'system'"
size="small"
type="primary"
@click="$emit('open-course-form', { mode: 'add', row: null, targetSystem: selectedTreeNode.raw })"
>
新增课程体系
</el-button>
<el-button
size="small"
@click="selectedTreeNode.nodeType === 'system' ? $emit('open-system-form', 'edit', selectedTreeNode.raw) : $emit('open-course-form', { mode: 'edit', row: selectedTreeNode.raw })"
>
编辑
</el-button>
<el-button
size="small"
type="danger"
plain
@click="selectedTreeNode.nodeType === 'system' ? $emit('delete-system', selectedTreeNode.raw) : $emit('delete-course', selectedTreeNode.raw)"
>
删除
</el-button>
</div>
</div>
<div v-else class="tree-empty-state">
请在左侧树中选择计划体系或课程体系
</div>
</div>
</div>
</el-dialog>
</template>
<script>
export default {
name: 'SystemCourseManagerDialog',
props: {
visible: { type: Boolean, default: false },
treeData: { type: Array, default: () => [] },
selectedSystem: { type: Object, default: null },
selectedTreeNode: { type: Object, default: null },
currentKey: { type: String, default: '' }
},
computed: {
innerVisible: {
get() { return this.visible },
set(val) { this.$emit('update:visible', val) }
}
},
watch: {
currentKey() {
this.syncCurrentKey()
}
},
methods: {
handleOpen() {
this.$nextTick(() => this.syncCurrentKey())
this.$emit('open')
},
syncCurrentKey() {
if (this.$refs.tree && this.currentKey) {
this.$refs.tree.setCurrentKey(this.currentKey)
}
}
}
}
</script>

@ -0,0 +1,66 @@
<template>
<el-dialog
:title="mode === 'add' ? '新增计划体系' : '编辑计划体系'"
:visible.sync="innerVisible"
width="520px"
append-to-body
@open="handleOpen"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="88px">
<el-form-item label="年份" prop="year">
<el-date-picker
v-model="form.year"
type="year"
value-format="yyyy"
format="yyyy"
placeholder="请选择年份"
style="width: 100%;"
/>
</el-form-item>
<el-form-item label="计划体系名称" prop="name">
<el-input v-model="form.name" placeholder="请输入计划体系名称" />
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="form.sort" :min="1" :max="999" style="width: 100%;" />
</el-form-item>
</el-form>
<span slot="footer">
<el-button @click="innerVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit"></el-button>
</span>
</el-dialog>
</template>
<script>
export default {
name: 'SystemFormDialog',
props: {
visible: { type: Boolean, default: false },
mode: { type: String, default: 'add' },
form: { type: Object, required: true },
rules: { type: Object, default: () => ({}) }
},
computed: {
innerVisible: {
get() { return this.visible },
set(val) { this.$emit('update:visible', val) }
}
},
methods: {
handleOpen() {
this.$nextTick(() => {
if (this.$refs.formRef) {
this.$refs.formRef.clearValidate()
}
})
},
handleSubmit() {
this.$refs.formRef.validate((valid) => {
if (!valid) return
this.$emit('submit')
})
}
}
}
</script>

File diff suppressed because it is too large Load Diff

@ -29,9 +29,10 @@ module.exports = {
publicPath: process.env.ENV === 'staging' ? '/admin' : '/admin',
// 前台打包输出到当前机器的 /Users/apple/www/wx.sstbc.com/public/admin 目录下
// 正式
outputDir: '/Users/mac/Documents/朗业/2024/s-苏州科技商学院/wx.sstbc.com/public/admin',
// outputDir: '/Users/mac/Documents/朗业/2024/s-苏州科技商学院/wx.sstbc.com/public/admin',
// 测试
// outputDir: '/Users/mac/Documents/朗业/2025/s-苏州科技商学院/wx.sstbc.com/public/admin',
outputDir: '/Users/mac/Documents/朗业/2026/s-苏州科技商学院/code/wx.sstbc.com/public/admin',
assetsDir: 'static',
css: {
loaderOptions: { // 向 CSS 相关的 loader 传递选项

Loading…
Cancel
Save