master
lion 4 weeks ago
parent 1ffbd97577
commit 06342737c5

@ -1,56 +0,0 @@
import request from "@/utils/request";
function customParamsSerializer(params) {
let result = "";
for (let key in params) {
if (Object.prototype.hasOwnProperty.call(params, key)) {
if (Array.isArray(params[key])) {
params[key].forEach((item, index) => {
if (item && item.key) {
result += `${key}[${index}][key]=${item.key}&${key}[${index}][op]=${item.op}&${key}[${index}][value]=${item.value}&`;
} else {
result += `${key}[${index}]=${item}&`;
}
});
} else {
result += `${key}=${params[key]}&`;
}
}
}
return result.slice(0, -1);
}
export function index(params, isLoading = false) {
return request({
method: "get",
url: "/api/admin/course-plan/index",
params,
paramsSerializer: customParamsSerializer,
isLoading,
});
}
export function show(params, isLoading = true) {
return request({
method: "get",
url: "/api/admin/course-plan/show",
params,
isLoading,
});
}
export function save(data) {
return request({
method: "post",
url: "/api/admin/course-plan/save",
data,
});
}
export function destroy(params) {
return request({
method: "get",
url: "/api/admin/course-plan/destroy",
params,
});
}

@ -1,56 +0,0 @@
import request from "@/utils/request";
function customParamsSerializer(params) {
let result = "";
for (let key in params) {
if (Object.prototype.hasOwnProperty.call(params, key)) {
if (Array.isArray(params[key])) {
params[key].forEach((item, index) => {
if (item && item.key) {
result += `${key}[${index}][key]=${item.key}&${key}[${index}][op]=${item.op}&${key}[${index}][value]=${item.value}&`;
} else {
result += `${key}[${index}]=${item}&`;
}
});
} else {
result += `${key}=${params[key]}&`;
}
}
}
return result.slice(0, -1);
}
export function index(params, isLoading = false) {
return request({
method: "get",
url: "/api/admin/course-plan-location/index",
params,
paramsSerializer: customParamsSerializer,
isLoading,
});
}
export function show(params, isLoading = true) {
return request({
method: "get",
url: "/api/admin/course-plan-location/show",
params,
isLoading,
});
}
export function save(data) {
return request({
method: "post",
url: "/api/admin/course-plan-location/save",
data,
});
}
export function destroy(params) {
return request({
method: "get",
url: "/api/admin/course-plan-location/destroy",
params,
});
}

@ -1,56 +0,0 @@
import request from "@/utils/request";
function customParamsSerializer(params) {
let result = "";
for (let key in params) {
if (Object.prototype.hasOwnProperty.call(params, key)) {
if (Array.isArray(params[key])) {
params[key].forEach((item, index) => {
if (item && item.key) {
result += `${key}[${index}][key]=${item.key}&${key}[${index}][op]=${item.op}&${key}[${index}][value]=${item.value}&`;
} else {
result += `${key}[${index}]=${item}&`;
}
});
} else {
result += `${key}=${params[key]}&`;
}
}
}
return result.slice(0, -1);
}
export function index(params, isLoading = false) {
return request({
method: "get",
url: "/api/admin/course-plan-system/index",
params,
paramsSerializer: customParamsSerializer,
isLoading,
});
}
export function show(params, isLoading = true) {
return request({
method: "get",
url: "/api/admin/course-plan-system/show",
params,
isLoading,
});
}
export function save(data) {
return request({
method: "post",
url: "/api/admin/course-plan-system/save",
data,
});
}
export function destroy(params) {
return request({
method: "get",
url: "/api/admin/course-plan-system/destroy",
params,
});
}

@ -1,155 +0,0 @@
<template>
<div>
<xy-dialog
ref="dialog"
:width="45"
:is-show.sync="isShow"
:type="'form'"
:title="type === 'add' ? '新增地点' : '编辑地点'"
:form="form"
:rules="rules"
@submit="submit"
>
<template v-slot:name>
<div class="xy-table-item">
<div class="xy-table-item-label" style="font-weight: bold">
<span style="color: red; font-weight: bold; padding-right: 4px;">*</span>地点名称
</div>
<div class="xy-table-item-content">
<el-input v-model="form.name" placeholder="请输入地点名称" clearable style="width: 100%;" />
</div>
</div>
</template>
<template v-slot:address>
<div class="xy-table-item">
<div class="xy-table-item-label" style="font-weight: bold">详细地址</div>
<div class="xy-table-item-content">
<el-input
v-model="form.address"
type="textarea"
:rows="3"
placeholder="请输入详细地址"
style="width: 100%;"
/>
</div>
</div>
</template>
<template v-slot:sort>
<div class="xy-table-item">
<div class="xy-table-item-label" style="font-weight: bold">排序</div>
<div class="xy-table-item-content">
<el-input-number v-model="form.sort" :min="0" :precision="0" style="width: 100%;" />
</div>
</div>
</template>
<template v-slot:status>
<div class="xy-table-item">
<div class="xy-table-item-label" style="font-weight: bold">状态</div>
<div class="xy-table-item-content">
<el-select v-model="form.status" placeholder="请选择状态" style="width: 100%;">
<el-option v-for="item in types_status" :key="item.id" :label="item.value" :value="item.id" />
</el-select>
</div>
</div>
</template>
<template v-slot:remark>
<div class="xy-table-item">
<div class="xy-table-item-label" style="font-weight: bold">备注</div>
<div class="xy-table-item-content">
<el-input
v-model="form.remark"
type="textarea"
:rows="3"
placeholder="请输入备注"
style="width: 100%;"
/>
</div>
</div>
</template>
</xy-dialog>
</div>
</template>
<script>
import myMixins from "@/mixin/selectMixin.js";
import { showMockLocation as show, saveMockLocation as save } from "../mockService.js";
const getDefaultForm = () => ({
name: "",
address: "",
sort: 0,
status: 1,
remark: "",
});
export default {
mixins: [myMixins],
data() {
return {
isShow: false,
type: "add",
id: "",
form: getDefaultForm(),
rules: {
name: [{
required: true,
message: "请输入地点名称",
}],
},
};
},
methods: {
submit() {
const payload = {
...this.form,
id: this.type === "editor" ? this.id : "",
};
save(payload).then(() => {
this.$message({
type: "success",
message: this.type === "add" ? "新增成功" : "编辑成功",
});
this.isShow = false;
this.$emit("refresh");
});
},
getDetail() {
show({ id: this.id }).then((res) => {
this.form = {
name: res.name || "",
address: res.address || "",
sort: res.sort || 0,
status: res.status === 0 ? 0 : (res.status || 1),
remark: res.remark || "",
};
});
},
resetForm() {
this.id = "";
this.form = getDefaultForm();
this.$refs.dialog.reset();
},
},
watch: {
isShow(newVal) {
if (newVal) {
if (this.type === "editor" && this.id) {
this.getDetail();
}
} else if (this.$refs.dialog) {
this.resetForm();
}
},
},
};
</script>
<style scoped lang="scss">
::v-deep .name,
::v-deep .address,
::v-deep .sort,
::v-deep .status,
::v-deep .remark {
flex-basis: 100%;
}
</style>

@ -1,348 +0,0 @@
<template>
<div>
<xy-dialog
ref="dialog"
:width="80"
:is-show.sync="isShow"
:type="'form'"
:title="type === 'add' ? '新增计划' : '编辑计划'"
:form="form"
:rules="rules"
@submit="submit"
>
<template v-slot:year>
<div class="xy-table-item">
<div class="xy-table-item-label" style="font-weight: bold">
<span style="color: red; font-weight: bold; padding-right: 4px;">*</span>计划年份
</div>
<div class="xy-table-item-content">
<el-date-picker
v-model="form.year"
type="year"
value-format="yyyy"
format="yyyy"
placeholder="请选择计划年份"
style="width: 100%;"
/>
</div>
</div>
</template>
<template v-slot:plan_system_id>
<div class="xy-table-item">
<div class="xy-table-item-label" style="font-weight: bold">
<span style="color: red; font-weight: bold; padding-right: 4px;">*</span>计划体系
</div>
<div class="xy-table-item-content">
<el-select v-model="form.plan_system_id" filterable clearable placeholder="请选择计划体系" style="width: 100%;">
<el-option
v-for="item in planSystemList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</div>
</div>
</template>
<template v-slot:course_type_id>
<div class="xy-table-item">
<div class="xy-table-item-label" style="font-weight: bold">
<span style="color: red; font-weight: bold; padding-right: 4px;">*</span>课程体系
</div>
<div class="xy-table-item-content">
<el-select v-model="form.course_type_id" filterable clearable placeholder="请选择课程体系" style="width: 100%;">
<el-option
v-for="item in courseTypesList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</div>
</div>
</template>
<template v-slot:course_name>
<div class="xy-table-item">
<div class="xy-table-item-label" style="font-weight: bold">
<span style="color: red; font-weight: bold; padding-right: 4px;">*</span>课程名称
</div>
<div class="xy-table-item-content">
<el-input v-model="form.course_name" placeholder="请输入课程名称" clearable style="width: 100%;" />
</div>
</div>
</template>
<template v-slot:details>
<div class="xy-table-item">
<div class="xy-table-item-label detail-label" style="font-weight: bold">
<span style="color: red; font-weight: bold; padding-right: 4px;">*</span>模块/期数
</div>
<div class="xy-table-item-content">
<div class="detail-wrap">
<div class="detail-toolbar">
<el-button type="primary" size="small" @click="addDetail"></el-button>
</div>
<el-table :data="form.details" border size="small" style="width: 100%;">
<el-table-column label="名称" min-width="180">
<template slot-scope="scope">
<el-input v-model="scope.row.name" placeholder="请输入模块/期数名称,非必填" size="small" />
</template>
</el-table-column>
<el-table-column label="月份" width="120">
<template slot-scope="scope">
<el-select v-model="scope.row.month" placeholder="月份" size="small" 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="180">
<template slot-scope="scope">
<el-select v-model="scope.row.location_id" filterable placeholder="请选择地点" size="small" style="width: 100%;">
<el-option
v-for="item in locationList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="负责人" min-width="160">
<template slot-scope="scope">
<el-input v-model="scope.row.owner_name" placeholder="请输入负责人" size="small" />
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template slot-scope="scope">
<el-button type="danger" size="mini" @click="removeDetail(scope.$index)"></el-button>
</template>
</el-table-column>
</el-table>
<div class="detail-tip">至少保留一条模块/期数名称可不填月份地点负责人必填</div>
<div v-if="type === 'editor'" class="danger-actions">
<el-button type="danger" size="small" @click="handleDeletePlan"></el-button>
</div>
</div>
</div>
</div>
</template>
</xy-dialog>
</div>
</template>
<script>
import { showMockPlan as show, saveMockPlan as save, destroyMockPlan as destroy } from "../mockService.js";
const getCurrentYear = () => String(new Date().getFullYear());
const createDetail = () => ({
id: "",
name: "",
month: "",
location_id: "",
owner_name: "",
});
const getDefaultForm = () => ({
year: getCurrentYear(),
plan_system_id: "",
course_type_id: "",
course_name: "",
details: [createDetail()],
});
export default {
data() {
return {
isShow: false,
type: "add",
id: "",
courseTypesList: [],
planSystemList: [],
locationList: [],
form: getDefaultForm(),
rules: {
year: [{
required: true,
message: "请选择计划年份",
}],
plan_system_id: [{
required: true,
message: "请选择计划体系",
}],
course_type_id: [{
required: true,
message: "请选择课程体系",
}],
course_name: [{
required: true,
message: "请输入课程名称",
}],
},
monthOptions: Array.from({ length: 12 }, (_, index) => ({
value: index + 1,
label: `${index + 1}`,
})),
};
},
methods: {
addDetail() {
this.form.details.push(createDetail());
},
removeDetail(index) {
if (this.form.details.length === 1) {
this.$message.warning("模块/期数至少保留一条");
return;
}
this.form.details.splice(index, 1);
},
normalizeDetail(item) {
return {
id: item.id || "",
name: item.name || item.module_name || "",
month: item.month === 0 ? 0 : Number(item.month || ""),
location_id: item.location_id || item.location?.id || "",
owner_name: item.owner_name || item.owner || item.principal || "",
};
},
getDetailListFromResponse(res) {
const list = res.details || res.detail_list || res.modules || res.items || [];
if (!Array.isArray(list) || !list.length) {
return [createDetail()];
}
return list.map((item) => this.normalizeDetail(item));
},
validateDetails() {
if (!Array.isArray(this.form.details) || this.form.details.length === 0) {
this.$message.warning("请至少新增一条模块/期数");
return false;
}
for (let index = 0; index < this.form.details.length; index += 1) {
const item = this.form.details[index];
if (!item.month) {
this.$message.warning(`${index + 1} 条模块/期数未选择月份`);
return false;
}
if (!item.location_id) {
this.$message.warning(`${index + 1} 条模块/期数未选择地点`);
return false;
}
if (!item.owner_name) {
this.$message.warning(`${index + 1} 条模块/期数未填写负责人`);
return false;
}
}
return true;
},
submit() {
if (!this.validateDetails()) {
return;
}
const payload = {
id: this.type === "editor" ? this.id : "",
year: this.form.year,
plan_system_id: this.form.plan_system_id,
course_type_id: this.form.course_type_id,
course_name: this.form.course_name,
details: this.form.details.map((item) => ({
id: item.id || "",
name: item.name || "",
month: Number(item.month),
location_id: item.location_id,
owner_name: item.owner_name,
})),
};
save(payload).then(() => {
this.$message({
type: "success",
message: this.type === "add" ? "新增成功" : "编辑成功",
});
this.isShow = false;
this.$emit("refresh");
});
},
getDetail() {
show({ id: this.id }).then((res) => {
this.form = {
year: String(res.year || getCurrentYear()),
plan_system_id: res.plan_system_id || res.plan_system?.id || res.plan_system_detail?.id || "",
course_type_id: res.course_type_id || res.course_type?.id || res.type_detail?.id || "",
course_name: res.course_name || res.name || "",
details: this.getDetailListFromResponse(res),
};
});
},
handleDeletePlan() {
this.$confirm(
"删除该计划将删除所有的模块/期数,是否确认删除?",
"提示",
{
type: "warning",
confirmButtonText: "确定",
cancelButtonText: "取消",
}
).then(() => {
destroy({ id: this.id }).then(() => {
this.$message.success("删除成功");
this.isShow = false;
this.$emit("refresh");
});
}).catch(() => {});
},
resetForm() {
this.id = "";
this.form = getDefaultForm();
this.$refs.dialog.reset();
},
},
watch: {
isShow(newVal) {
if (newVal) {
if (this.type === "editor" && this.id) {
this.getDetail();
}
} else if (this.$refs.dialog) {
this.resetForm();
}
},
},
};
</script>
<style scoped lang="scss">
::v-deep .year,
::v-deep .plan_system_id,
::v-deep .course_type_id,
::v-deep .course_name,
::v-deep .details {
flex-basis: 100%;
}
.detail-label {
align-self: flex-start;
padding-top: 8px;
}
.detail-wrap {
width: 100%;
}
.detail-toolbar {
margin-bottom: 10px;
}
.detail-tip {
margin-top: 8px;
font-size: 12px;
color: #909399;
}
.danger-actions {
margin-top: 16px;
text-align: right;
}
</style>

@ -1,138 +0,0 @@
<template>
<div>
<xy-dialog
ref="dialog"
:width="40"
:is-show.sync="isShow"
:type="'form'"
:title="type === 'add' ? '新增计划体系' : '编辑计划体系'"
:form="form"
:rules="rules"
@submit="submit"
>
<template v-slot:name>
<div class="xy-table-item">
<div class="xy-table-item-label" style="font-weight: bold">
<span style="color: red; font-weight: bold; padding-right: 4px;">*</span>计划体系
</div>
<div class="xy-table-item-content">
<el-input v-model="form.name" placeholder="请输入计划体系名称" clearable style="width: 100%;" />
</div>
</div>
</template>
<template v-slot:sort>
<div class="xy-table-item">
<div class="xy-table-item-label" style="font-weight: bold">排序</div>
<div class="xy-table-item-content">
<el-input-number v-model="form.sort" :min="0" :precision="0" style="width: 100%;" />
</div>
</div>
</template>
<template v-slot:status>
<div class="xy-table-item">
<div class="xy-table-item-label" style="font-weight: bold">状态</div>
<div class="xy-table-item-content">
<el-select v-model="form.status" placeholder="请选择状态" style="width: 100%;">
<el-option v-for="item in types_status" :key="item.id" :label="item.value" :value="item.id" />
</el-select>
</div>
</div>
</template>
<template v-slot:remark>
<div class="xy-table-item">
<div class="xy-table-item-label" style="font-weight: bold">备注</div>
<div class="xy-table-item-content">
<el-input
v-model="form.remark"
type="textarea"
:rows="3"
placeholder="请输入备注"
style="width: 100%;"
/>
</div>
</div>
</template>
</xy-dialog>
</div>
</template>
<script>
import myMixins from "@/mixin/selectMixin.js";
import { showMockPlanSystem as show, saveMockPlanSystem as save } from "../mockService.js";
const getDefaultForm = () => ({
name: "",
sort: 0,
status: 1,
remark: "",
});
export default {
mixins: [myMixins],
data() {
return {
isShow: false,
type: "add",
id: "",
form: getDefaultForm(),
rules: {
name: [{
required: true,
message: "请输入计划体系名称",
}],
},
};
},
methods: {
submit() {
const payload = {
...this.form,
id: this.type === "editor" ? this.id : "",
};
save(payload).then(() => {
this.$message({
type: "success",
message: this.type === "add" ? "新增成功" : "编辑成功",
});
this.isShow = false;
this.$emit("refresh");
});
},
getDetail() {
show({ id: this.id }).then((res) => {
this.form = {
name: res.name || "",
sort: res.sort || 0,
status: res.status === 0 ? 0 : (res.status || 1),
remark: res.remark || "",
};
});
},
resetForm() {
this.id = "";
this.form = getDefaultForm();
this.$refs.dialog.reset();
},
},
watch: {
isShow(newVal) {
if (newVal) {
if (this.type === "editor" && this.id) {
this.getDetail();
}
} else if (this.$refs.dialog) {
this.resetForm();
}
},
},
};
</script>
<style scoped lang="scss">
::v-deep .name,
::v-deep .sort,
::v-deep .status,
::v-deep .remark {
flex-basis: 100%;
}
</style>

@ -1,468 +0,0 @@
<template>
<div>
<div ref="lxHeader">
<lx-header icon="md-apps" :text="$route.meta.title" style="margin-bottom: 10px; border: 0; margin-top: 15px;">
<div slot="content">
<div class="searchwrap">
<div class="search-group">
<el-date-picker
v-model="select.year"
type="year"
value-format="yyyy"
format="yyyy"
placeholder="查询年份"
style="width: 150px;"
/>
<el-button type="primary" size="small" @click="getList()"></el-button>
<el-button type="primary" size="small" @click="resetSelect"></el-button>
</div>
<div class="action-group">
<el-button type="primary" size="small" @click="editPlan('add')"></el-button>
<el-button type="primary" size="small" @click="openAddLocation"></el-button>
<el-button type="primary" size="small" @click="openAddPlanSystem"></el-button>
</div>
</div>
</div>
</lx-header>
</div>
<div class="table-wrap">
<el-table
v-loading="loading"
:data="list"
border
stripe
row-key="id"
empty-text="暂无课程计划数据"
:span-method="objectSpanMethod"
:header-cell-style="headerCellStyle"
>
<el-table-column prop="plan_system_name" label="计划体系" min-width="180" align="center" header-align="center" />
<el-table-column prop="course_type_name" label="课程体系" min-width="180" align="center" header-align="center" />
<el-table-column
v-for="month in monthOptions"
:key="month.value"
:label="month.label"
min-width="180"
align="left"
header-align="center"
>
<template slot-scope="scope">
<div v-if="hasMonthData(scope.row, month.value)">
<el-popover placement="top" width="360" trigger="hover">
<div class="popover-content">
<div v-for="(item, index) in getMonthData(scope.row, month.value)" :key="item.detail_id || index" class="popover-item">
<div class="popover-title">{{ item.display_text }}</div>
<div class="popover-owner">负责人{{ item.owner_name || "-" }}</div>
</div>
<div class="popover-footer">
<el-button type="primary" size="mini" @click="editPlan('editor', scope.row.id)">编辑</el-button>
</div>
</div>
<div slot="reference" class="month-cell">
<div
v-for="(item, index) in getMonthData(scope.row, month.value)"
:key="item.detail_id || index"
class="month-line"
:style="getPlanItemStyle(scope.row)"
>
{{ item.display_text }}
</div>
</div>
</el-popover>
</div>
<div v-else class="empty-cell">-</div>
</template>
</el-table-column>
</el-table>
</div>
<add-plan ref="addPlan" @refresh="getList" />
<add-plan-system ref="addPlanSystem" @refresh="handlePlanSystemSaved" />
<add-location ref="addLocation" @refresh="handleLocationSaved" />
</div>
</template>
<script>
import addPlan from "./components/addPlan.vue";
import addPlanSystem from "./components/addPlanSystem.vue";
import addLocation from "./components/addLocation.vue";
import {
listMockPlans as index,
listMockCourseTypes as indexCourseType,
listMockPlanSystems as indexPlanSystem,
listMockLocations as indexLocation,
} from "./mockService.js";
const getCurrentYear = () => String(new Date().getFullYear());
export default {
components: {
addPlan,
addPlanSystem,
addLocation,
},
data() {
return {
loading: false,
select: {
year: getCurrentYear(),
},
list: [],
total: 0,
courseTypesList: [],
planSystemList: [],
locationList: [],
monthOptions: Array.from({ length: 12 }, (_, index) => ({
value: index + 1,
label: `${index + 1}`,
})),
colorPalette: [
{ background: "#e8f4ff", border: "#b6d7ff" },
{ background: "#eaf7ea", border: "#b7dfb9" },
{ background: "#fff5e8", border: "#ffd6a5" },
{ background: "#f3ecff", border: "#d4c2ff" },
{ background: "#e9f7f5", border: "#aadfd7" },
{ background: "#fff0f0", border: "#ffc9c9" },
],
};
},
created() {
this.initPage();
},
methods: {
async initPage() {
await Promise.all([
this.getCourseTypes(),
this.getPlanSystems(),
this.getLocations(),
]);
this.getList();
},
async getCourseTypes() {
const res = await indexCourseType({
page: 1,
page_size: 999,
sort_name: "sort",
sort_type: "ASC",
});
this.courseTypesList = res.data || [];
},
async getPlanSystems() {
const res = await indexPlanSystem({
page: 1,
page_size: 999,
sort_name: "sort",
sort_type: "ASC",
filter: [{
key: "status",
op: "eq",
value: 1,
}],
});
this.planSystemList = res.data || [];
},
async getLocations() {
const res = await indexLocation({
page: 1,
page_size: 999,
sort_name: "sort",
sort_type: "ASC",
filter: [{
key: "status",
op: "eq",
value: 1,
}],
});
this.locationList = res.data || [];
},
resetSelect() {
this.select.year = getCurrentYear();
this.getList();
},
buildMonthsMap() {
return this.monthOptions.reduce((map, item) => {
map[item.value] = [];
return map;
}, {});
},
getRelationId(record) {
return record?.id || record?.value || "";
},
getPlanSystemName(row) {
return row.plan_system_name
|| row.plan_system?.name
|| row.plan_system_detail?.name
|| row.planSystem?.name
|| "";
},
getCourseTypeName(row) {
return row.course_type_name
|| row.course_type?.name
|| row.course_type_detail?.name
|| row.type_detail?.name
|| "";
},
getCourseName(row) {
return row.course_name || row.name || "";
},
getLocationName(item) {
return item.location_name
|| item.location?.name
|| item.location_detail?.name
|| "";
},
formatPlanText(courseName, moduleName, locationName) {
if (moduleName) {
return `${courseName}${moduleName}- ${locationName}`;
}
return `${courseName} - ${locationName}`;
},
normalizeMonthItems(row) {
const months = this.buildMonthsMap();
const courseName = this.getCourseName(row);
if (row.months && typeof row.months === "object") {
Object.keys(row.months).forEach((monthKey) => {
const month = Number(monthKey);
const list = Array.isArray(row.months[monthKey]) ? row.months[monthKey] : [];
months[month] = list.map((item) => ({
...item,
detail_id: item.detail_id || item.id || "",
name: item.name || item.module_name || "",
owner_name: item.owner_name || item.owner || item.principal || "",
location_name: this.getLocationName(item),
display_text: this.formatPlanText(courseName, item.name || item.module_name || "", this.getLocationName(item)),
}));
});
return months;
}
const details = row.details || row.detail_list || row.modules || row.items || [];
if (!Array.isArray(details)) {
return months;
}
details.forEach((item) => {
const month = Number(item.month);
if (!months[month]) {
return;
}
const moduleName = item.name || item.module_name || "";
const locationName = this.getLocationName(item);
months[month].push({
...item,
detail_id: item.detail_id || item.id || "",
name: moduleName,
owner_name: item.owner_name || item.owner || item.principal || "",
location_name: locationName,
display_text: this.formatPlanText(courseName, moduleName, locationName),
});
});
return months;
},
normalizeList(data) {
const rows = Array.isArray(data) ? data : [];
return rows.map((row) => {
const planSystemName = this.getPlanSystemName(row);
const courseTypeName = this.getCourseTypeName(row);
return {
...row,
plan_system_id: row.plan_system_id || this.getRelationId(row.plan_system) || this.getRelationId(row.plan_system_detail) || "",
plan_system_name: planSystemName,
course_type_id: row.course_type_id || this.getRelationId(row.course_type) || this.getRelationId(row.type_detail) || "",
course_type_name: courseTypeName,
course_name: this.getCourseName(row),
months: this.normalizeMonthItems(row),
merge_key: `${planSystemName}__${courseTypeName}`,
};
}).sort((a, b) => {
const planCompare = (a.plan_system_name || "").localeCompare(b.plan_system_name || "", "zh-CN");
if (planCompare !== 0) {
return planCompare;
}
const typeCompare = (a.course_type_name || "").localeCompare(b.course_type_name || "", "zh-CN");
if (typeCompare !== 0) {
return typeCompare;
}
return (a.course_name || "").localeCompare(b.course_name || "", "zh-CN");
});
},
async getList() {
const filter = [];
if (this.select.year) {
filter.push({
key: "year",
op: "eq",
value: this.select.year,
});
}
this.loading = true;
try {
const res = await index({
filter,
});
this.list = this.normalizeList(res.data || []);
this.total = res.total || 0;
} finally {
this.loading = false;
}
},
getMonthData(row, month) {
return (row.months && row.months[month]) || [];
},
hasMonthData(row, month) {
return this.getMonthData(row, month).length > 0;
},
getPlanColor(row) {
const source = `${row.plan_system_name || ""}-${row.course_type_name || ""}-${row.course_name || ""}`;
const index = source.split("").reduce((sum, char) => sum + char.charCodeAt(0), 0) % this.colorPalette.length;
return this.colorPalette[index];
},
getPlanItemStyle(row) {
const color = this.getPlanColor(row);
return {
backgroundColor: color.background,
borderColor: color.border,
};
},
headerCellStyle() {
return {
background: "#f5f7fa",
color: "#303133",
fontWeight: "600",
};
},
objectSpanMethod({ rowIndex, columnIndex }) {
if (columnIndex !== 0 && columnIndex !== 1) {
return { rowspan: 1, colspan: 1 };
}
const currentRow = this.list[rowIndex];
if (!currentRow) {
return { rowspan: 1, colspan: 1 };
}
if (columnIndex === 0) {
if (rowIndex > 0 && this.list[rowIndex - 1].plan_system_name === currentRow.plan_system_name) {
return { rowspan: 0, colspan: 0 };
}
let rowspan = 1;
for (let index = rowIndex + 1; index < this.list.length; index += 1) {
if (this.list[index].plan_system_name === currentRow.plan_system_name) {
rowspan += 1;
} else {
break;
}
}
return { rowspan, colspan: 1 };
}
if (rowIndex > 0 && this.list[rowIndex - 1].merge_key === currentRow.merge_key) {
return { rowspan: 0, colspan: 0 };
}
let rowspan = 1;
for (let index = rowIndex + 1; index < this.list.length; index += 1) {
if (this.list[index].merge_key === currentRow.merge_key) {
rowspan += 1;
} else {
break;
}
}
return { rowspan, colspan: 1 };
},
editPlan(type, id) {
this.$refs.addPlan.id = id || "";
this.$refs.addPlan.type = type;
this.$refs.addPlan.courseTypesList = this.courseTypesList;
this.$refs.addPlan.planSystemList = this.planSystemList;
this.$refs.addPlan.locationList = this.locationList;
this.$refs.addPlan.isShow = true;
},
openAddPlanSystem() {
this.$refs.addPlanSystem.id = "";
this.$refs.addPlanSystem.type = "add";
this.$refs.addPlanSystem.isShow = true;
},
openAddLocation() {
this.$refs.addLocation.id = "";
this.$refs.addLocation.type = "add";
this.$refs.addLocation.isShow = true;
},
async handlePlanSystemSaved() {
await this.getPlanSystems();
this.getList();
},
async handleLocationSaved() {
await this.getLocations();
this.getList();
},
},
};
</script>
<style scoped lang="scss">
.searchwrap {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
}
.search-group,
.action-group {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.table-wrap {
background: #fff;
padding: 10px;
}
.month-cell {
min-height: 28px;
white-space: pre-line;
}
.month-line {
line-height: 20px;
word-break: break-word;
margin-bottom: 4px;
padding: 4px 6px;
border: 1px solid transparent;
border-radius: 4px;
}
.empty-cell {
text-align: center;
color: #c0c4cc;
}
.popover-content {
max-height: 300px;
overflow-y: auto;
}
.popover-item + .popover-item {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #ebeef5;
}
.popover-title {
color: #303133;
line-height: 20px;
word-break: break-word;
}
.popover-owner {
margin-top: 4px;
color: #606266;
font-size: 12px;
}
.popover-footer {
margin-top: 12px;
text-align: right;
}
</style>

@ -1,161 +0,0 @@
<template>
<div>
<div ref="lxHeader">
<lx-header icon="md-apps" :text="$route.meta.title" style="margin-bottom: 10px; border: 0; margin-top: 15px;">
<div slot="content">
<div class="searchwrap">
<div>
<el-input v-model="select.name" placeholder="请输入地点名称" clearable />
</div>
<div>
<el-button type="primary" size="small" @click="select.page = 1; getList()">查询</el-button>
<el-button type="primary" size="small" @click="resetSelect"></el-button>
</div>
<div>
<el-button type="primary" size="small" @click="editInfo('add')"></el-button>
</div>
</div>
</div>
</lx-header>
</div>
<xy-table
:list="list"
:total="total"
:table-item="tableItem"
@pageIndexChange="pageIndexChange"
@pageSizeChange="pageSizeChange"
>
<template v-slot:status>
<el-table-column align="center" label="状态" width="100" header-align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'info'">{{ scope.row.status === 1 ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
</template>
<template v-slot:btns>
<el-table-column align="center" label="操作" width="180" header-align="center">
<template slot-scope="scope">
<el-button type="primary" size="small" @click="editInfo('editor', scope.row.id)">编辑</el-button>
<el-popconfirm style="margin: 0 10px;" title="确定删除吗?" @confirm="deleteList(scope.row.id)">
<el-button slot="reference" type="danger" size="small">删除</el-button>
</el-popconfirm>
</template>
</el-table-column>
</template>
</xy-table>
<add-location ref="addLocation" @refresh="getList" />
</div>
</template>
<script>
import myMixins from "@/mixin/selectMixin.js";
import { listMockLocations as index, destroyMockLocation as destroy } from "./mockService.js";
import addLocation from "./components/addLocation.vue";
export default {
mixins: [myMixins],
components: {
addLocation,
},
data() {
return {
select: {
name: "",
page: 1,
page_size: 10,
},
list: [],
total: 0,
tableItem: [{
prop: "name",
label: "地点名称",
align: "left",
width: 180,
}, {
prop: "address",
label: "详细地址",
align: "left",
}, {
prop: "sort",
label: "排序",
align: "center",
width: 100,
}, {
prop: "status",
label: "状态",
align: "center",
width: 100,
}, {
prop: "remark",
label: "备注",
align: "left",
}],
};
},
created() {
this.getList();
},
methods: {
pageIndexChange(page) {
this.select.page = page;
this.getList();
},
pageSizeChange(pageSize) {
this.select.page_size = pageSize;
this.select.page = 1;
this.getList();
},
resetSelect() {
this.select.name = "";
this.select.page = 1;
this.getList();
},
async getList() {
const filter = [];
if (this.select.name) {
filter.push({
key: "name",
op: "like",
value: this.select.name,
});
}
const res = await index({
page: this.select.page,
page_size: this.select.page_size,
sort_name: "sort",
sort_type: "ASC",
filter,
});
this.list = res.data || [];
this.total = res.total || 0;
},
editInfo(type, id) {
this.$refs.addLocation.id = id || "";
this.$refs.addLocation.type = type;
this.$refs.addLocation.isShow = true;
},
deleteList(id) {
destroy({ id }).then(() => {
this.$message.success("删除成功");
this.getList();
});
},
},
};
</script>
<style scoped lang="scss">
.searchwrap {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
& > div {
display: flex;
align-items: center;
}
}
</style>

@ -1,494 +0,0 @@
const STORAGE_KEY = "course-plan-mock-store";
const getCurrentYear = () => String(new Date().getFullYear());
const createInitialState = () => {
const currentYear = getCurrentYear();
return {
courseTypes: [
{ id: 1, name: "初创班", sort: 1, status: 1 },
{ id: 2, name: "高研班", sort: 2, status: 1 },
{ id: 3, name: "零单班", sort: 3, status: 1 },
{ id: 4, name: "产业加速营", sort: 4, status: 1 },
{ id: 5, name: "第二课堂", sort: 5, status: 1 },
{ id: 6, name: "人才培训", sort: 6, status: 1 },
{ id: 7, name: "科技大讲堂", sort: 7, status: 1 },
{ id: 8, name: "万人培训", sort: 8, status: 1 },
],
planSystems: [
{ id: 1, name: "0-1", sort: 1, status: 1, remark: "初创企业培育计划" },
{ id: 2, name: "0-10", sort: 2, status: 1, remark: "成长型企业高研计划" },
{ id: 3, name: "10-100", sort: 3, status: 1, remark: "规模企业专题计划" },
{ id: 4, name: "产业结构协同创新", sort: 4, status: 1, remark: "产业升级计划" },
{ id: 5, name: "科技人才服务", sort: 5, status: 1, remark: "科技人才专项" },
{ id: 6, name: "育民营企业家万人培训", sort: 6, status: 1, remark: "民营企业家年度培训计划" },
],
locations: [
{ id: 1, name: "苏州", address: "苏州校区", sort: 1, status: 1, remark: "" },
{ id: 2, name: "苏州工业园区", address: "苏州工业园区教学点", sort: 2, status: 1, remark: "" },
{ id: 3, name: "苏州高新区", address: "苏州高新区教学点", sort: 3, status: 1, remark: "" },
{ id: 4, name: "上海", address: "上海教学点", sort: 4, status: 1, remark: "" },
{ id: 5, name: "深圳", address: "深圳教学点", sort: 5, status: 1, remark: "" },
{ id: 6, name: "南京", address: "南京教学点", sort: 6, status: 1, remark: "" },
{ id: 7, name: "杭州", address: "杭州教学点", sort: 7, status: 1, remark: "" },
{ id: 8, name: "无锡", address: "无锡教学点", sort: 8, status: 1, remark: "" },
{ id: 9, name: "常州", address: "常州教学点", sort: 9, status: 1, remark: "" },
{ id: 10, name: "线上", address: "线上直播", sort: 10, status: 1, remark: "" },
{ id: 11, name: "浙江", address: "浙江教学点", sort: 11, status: 1, remark: "" },
{ id: 12, name: "北京", address: "北京教学点", sort: 12, status: 1, remark: "" },
],
plans: [
{
id: 1,
year: currentYear,
plan_system_id: 1,
course_type_id: 1,
course_name: "第二期高校科技成果转化班",
details: [
{ id: 101, name: "第二期", month: 5, location_id: 1, owner_name: "王老师" },
{ id: 102, name: "第二期", month: 7, location_id: 1, owner_name: "王老师" },
{ id: 103, name: "第二期", month: 9, location_id: 1, owner_name: "王老师" },
],
},
{
id: 2,
year: currentYear,
plan_system_id: 1,
course_type_id: 1,
course_name: "第二期技术经理人班",
details: [
{ id: 104, name: "南大班", month: 6, location_id: 1, owner_name: "陈老师" },
{ id: 105, name: "南大班", month: 8, location_id: 1, owner_name: "陈老师" },
{ id: 106, name: "南大班", month: 10, location_id: 1, owner_name: "陈老师" },
],
},
{
id: 3,
year: currentYear,
plan_system_id: 2,
course_type_id: 2,
course_name: "第七届北大光华班",
details: [
{ id: 107, name: "第六模块", month: 3, location_id: 1, owner_name: "周老师" },
{ id: 108, name: "第六模块", month: 5, location_id: 1, owner_name: "周老师" },
{ id: 109, name: "第七模块", month: 7, location_id: 1, owner_name: "周老师" },
],
},
{
id: 4,
year: currentYear,
plan_system_id: 2,
course_type_id: 2,
course_name: "第八届苏大班",
details: [
{ id: 110, name: "第二模块", month: 4, location_id: 1, owner_name: "李老师" },
{ id: 111, name: "第三模块", month: 5, location_id: 1, owner_name: "李老师" },
{ id: 112, name: "第五模块", month: 6, location_id: 1, owner_name: "李老师" },
{ id: 113, name: "六模块", month: 7, location_id: 1, owner_name: "李老师" },
{ id: 114, name: "七模块", month: 9, location_id: 4, owner_name: "李老师" },
{ id: 115, name: "八模块", month: 11, location_id: 5, owner_name: "李老师" },
{ id: 116, name: "结业", month: 12, location_id: 1, owner_name: "李老师" },
],
},
{
id: 5,
year: currentYear,
plan_system_id: 2,
course_type_id: 2,
course_name: "第九期中欧班",
details: [
{ id: 117, name: "开学", month: 5, location_id: 1, owner_name: "赵老师" },
{ id: 118, name: "第二模块", month: 8, location_id: 4, owner_name: "赵老师" },
{ id: 119, name: "第三模块", month: 10, location_id: 1, owner_name: "赵老师" },
{ id: 120, name: "第四模块", month: 12, location_id: 1, owner_name: "赵老师" },
],
},
{
id: 6,
year: currentYear,
plan_system_id: 2,
course_type_id: 2,
course_name: "第十期商研班",
details: [
{ id: 121, name: "开学", month: 11, location_id: 1, owner_name: "孙老师" },
],
},
{
id: 7,
year: currentYear,
plan_system_id: 3,
course_type_id: 3,
course_name: "第二期肇单班",
details: [
{ id: 122, name: "开学", month: 6, location_id: 1, owner_name: "钱老师" },
],
},
{
id: 8,
year: currentYear,
plan_system_id: 4,
course_type_id: 4,
course_name: "AI+能源OPC加速营",
details: [
{ id: 123, name: "", month: 4, location_id: 1, owner_name: "吴老师" },
{ id: 124, name: "", month: 5, location_id: 1, owner_name: "吴老师" },
{ id: 125, name: "", month: 6, location_id: 1, owner_name: "吴老师" },
],
},
{
id: 9,
year: currentYear,
plan_system_id: 4,
course_type_id: 4,
course_name: "南大OPC加速营",
details: [
{ id: 126, name: "", month: 8, location_id: 1, owner_name: "郑老师" },
{ id: 127, name: "", month: 9, location_id: 1, owner_name: "郑老师" },
{ id: 128, name: "", month: 10, location_id: 1, owner_name: "郑老师" },
],
},
{
id: 10,
year: currentYear,
plan_system_id: 4,
course_type_id: 5,
course_name: "第二课堂创新沙龙",
details: [],
},
{
id: 11,
year: currentYear,
plan_system_id: 5,
course_type_id: 6,
course_name: "宣传部培训",
details: [
{ id: 129, name: "第一期", month: 6, location_id: 1, owner_name: "冯老师" },
{ id: 130, name: "第二期", month: 9, location_id: 1, owner_name: "冯老师" },
{ id: 131, name: "第三期", month: 12, location_id: 1, owner_name: "冯老师" },
],
},
{
id: 12,
year: currentYear,
plan_system_id: 5,
course_type_id: 7,
course_name: "科技大讲堂专题课",
details: [],
},
{
id: 13,
year: currentYear,
plan_system_id: 6,
course_type_id: 8,
course_name: "第一期万人培训",
details: [
{ id: 132, name: "南京市", month: 4, location_id: 1, owner_name: "蒋老师" },
{ id: 133, name: "苏州市", month: 5, location_id: 11, owner_name: "蒋老师" },
{ id: 134, name: "无锡市", month: 6, location_id: 1, owner_name: "蒋老师" },
{ id: 135, name: "南通市", month: 7, location_id: 1, owner_name: "蒋老师" },
{ id: 136, name: "扬州市", month: 8, location_id: 1, owner_name: "蒋老师" },
{ id: 137, name: "镇江市", month: 9, location_id: 1, owner_name: "蒋老师" },
{ id: 138, name: "盐城市", month: 10, location_id: 1, owner_name: "蒋老师" },
{ id: 139, name: "淮安市", month: 11, location_id: 1, owner_name: "蒋老师" },
{ id: 140, name: "宿迁市", month: 12, location_id: 1, owner_name: "蒋老师" },
],
},
{
id: 14,
year: currentYear,
plan_system_id: 6,
course_type_id: 8,
course_name: "第二期万人培训",
details: [
{ id: 141, name: "常州市", month: 5, location_id: 12, owner_name: "韩老师" },
{ id: 142, name: "泰州市", month: 8, location_id: 1, owner_name: "韩老师" },
{ id: 143, name: "徐州市", month: 9, location_id: 1, owner_name: "韩老师" },
{ id: 144, name: "徐州市", month: 11, location_id: 1, owner_name: "韩老师" },
],
},
],
};
};
const clone = (data) => JSON.parse(JSON.stringify(data));
const getStorage = () => {
if (typeof window === "undefined" || !window.localStorage) {
return null;
}
return window.localStorage;
};
const loadState = () => {
const storage = getStorage();
if (!storage) {
return createInitialState();
}
const cache = storage.getItem(STORAGE_KEY);
if (!cache) {
const initialState = createInitialState();
storage.setItem(STORAGE_KEY, JSON.stringify(initialState));
return initialState;
}
try {
return JSON.parse(cache);
} catch (error) {
const initialState = createInitialState();
storage.setItem(STORAGE_KEY, JSON.stringify(initialState));
return initialState;
}
};
let store = loadState();
const persist = () => {
const storage = getStorage();
if (storage) {
storage.setItem(STORAGE_KEY, JSON.stringify(store));
}
};
const nextId = (list) => list.reduce((max, item) => Math.max(max, Number(item.id) || 0), 0) + 1;
const nextDetailId = () => {
const maxId = store.plans.reduce((max, plan) => {
const detailMax = (plan.details || []).reduce((detailCurrentMax, detail) => Math.max(detailCurrentMax, Number(detail.id) || 0), 0);
return Math.max(max, detailMax);
}, 0);
return maxId + 1;
};
const getFilterValue = (filter, key) => {
const target = (filter || []).find((item) => item.key === key);
return target ? target.value : "";
};
const applyLikeFilter = (list, filter, key) => {
const value = getFilterValue(filter, key);
if (!value) {
return list;
}
return list.filter((item) => String(item[key] || "").includes(String(value)));
};
const applyEqFilter = (list, filter, key) => {
const value = getFilterValue(filter, key);
if (value === "" || value === undefined || value === null) {
return list;
}
return list.filter((item) => String(item[key]) === String(value));
};
const sortBySort = (list) => clone(list).sort((a, b) => {
const sortCompare = Number(a.sort || 0) - Number(b.sort || 0);
if (sortCompare !== 0) {
return sortCompare;
}
return String(a.name || "").localeCompare(String(b.name || ""), "zh-CN");
});
const getPlanSystemMap = () => {
return store.planSystems.reduce((map, item) => {
map[item.id] = item;
return map;
}, {});
};
const getLocationMap = () => {
return store.locations.reduce((map, item) => {
map[item.id] = item;
return map;
}, {});
};
const getCourseTypeMap = () => {
return store.courseTypes.reduce((map, item) => {
map[item.id] = item;
return map;
}, {});
};
const enrichPlan = (plan) => {
const planSystemMap = getPlanSystemMap();
const locationMap = getLocationMap();
const courseTypeMap = getCourseTypeMap();
return {
...clone(plan),
plan_system: clone(planSystemMap[plan.plan_system_id] || {}),
course_type: clone(courseTypeMap[plan.course_type_id] || {}),
details: (plan.details || []).map((detail) => ({
...clone(detail),
location: clone(locationMap[detail.location_id] || {}),
})),
};
};
export function listMockCourseTypes() {
return Promise.resolve({
data: sortBySort(store.courseTypes.filter((item) => item.status !== 0)),
total: store.courseTypes.length,
});
}
export function listMockPlanSystems(params = {}) {
let list = sortBySort(store.planSystems);
list = applyLikeFilter(list, params.filter, "name");
list = applyEqFilter(list, params.filter, "status");
return Promise.resolve({
data: list,
total: list.length,
});
}
export function showMockPlanSystem({ id }) {
const item = store.planSystems.find((planSystem) => String(planSystem.id) === String(id));
return Promise.resolve(clone(item || {}));
}
export function saveMockPlanSystem(data) {
if (data.id) {
const index = store.planSystems.findIndex((item) => String(item.id) === String(data.id));
if (index > -1) {
store.planSystems.splice(index, 1, {
...store.planSystems[index],
...clone(data),
});
}
} else {
store.planSystems.push({
id: nextId(store.planSystems),
name: data.name,
sort: data.sort || 0,
status: data.status === 0 ? 0 : 1,
remark: data.remark || "",
});
}
persist();
return Promise.resolve({ success: true });
}
export function destroyMockPlanSystem({ id }) {
store.planSystems = store.planSystems.filter((item) => String(item.id) !== String(id));
store.plans = store.plans.filter((item) => String(item.plan_system_id) !== String(id));
persist();
return Promise.resolve({ success: true });
}
export function listMockLocations(params = {}) {
let list = sortBySort(store.locations);
list = applyLikeFilter(list, params.filter, "name");
list = applyEqFilter(list, params.filter, "status");
return Promise.resolve({
data: list,
total: list.length,
});
}
export function showMockLocation({ id }) {
const item = store.locations.find((location) => String(location.id) === String(id));
return Promise.resolve(clone(item || {}));
}
export function saveMockLocation(data) {
if (data.id) {
const index = store.locations.findIndex((item) => String(item.id) === String(data.id));
if (index > -1) {
store.locations.splice(index, 1, {
...store.locations[index],
...clone(data),
});
}
} else {
store.locations.push({
id: nextId(store.locations),
name: data.name,
address: data.address || "",
sort: data.sort || 0,
status: data.status === 0 ? 0 : 1,
remark: data.remark || "",
});
}
persist();
return Promise.resolve({ success: true });
}
export function destroyMockLocation({ id }) {
store.locations = store.locations.filter((item) => String(item.id) !== String(id));
store.plans = store.plans.map((plan) => ({
...plan,
details: (plan.details || []).filter((detail) => String(detail.location_id) !== String(id)),
}));
persist();
return Promise.resolve({ success: true });
}
export function listMockPlans({ filter = [] } = {}) {
const year = getFilterValue(filter, "year") || getCurrentYear();
const planSystemMap = getPlanSystemMap();
const courseTypeMap = getCourseTypeMap();
const list = store.plans
.filter((plan) => String(plan.year) === String(year))
.map((plan) => enrichPlan(plan))
.sort((a, b) => {
const planSort = Number((planSystemMap[a.plan_system_id] || {}).sort || 0) - Number((planSystemMap[b.plan_system_id] || {}).sort || 0);
if (planSort !== 0) {
return planSort;
}
const courseSort = Number((courseTypeMap[a.course_type_id] || {}).sort || 0) - Number((courseTypeMap[b.course_type_id] || {}).sort || 0);
if (courseSort !== 0) {
return courseSort;
}
return String(a.course_name || "").localeCompare(String(b.course_name || ""), "zh-CN");
});
return Promise.resolve({
data: list,
total: list.length,
});
}
export function showMockPlan({ id }) {
const item = store.plans.find((plan) => String(plan.id) === String(id));
return Promise.resolve(enrichPlan(item || { details: [] }));
}
export function saveMockPlan(data) {
const details = (data.details || []).map((detail) => ({
id: detail.id || nextDetailId(),
name: detail.name || "",
month: Number(detail.month),
location_id: detail.location_id,
owner_name: detail.owner_name || "",
}));
if (data.id) {
const index = store.plans.findIndex((item) => String(item.id) === String(data.id));
if (index > -1) {
store.plans.splice(index, 1, {
...store.plans[index],
year: String(data.year),
plan_system_id: data.plan_system_id,
course_type_id: data.course_type_id,
course_name: data.course_name,
details,
});
}
} else {
store.plans.push({
id: nextId(store.plans),
year: String(data.year),
plan_system_id: data.plan_system_id,
course_type_id: data.course_type_id,
course_name: data.course_name,
details,
});
}
persist();
return Promise.resolve({ success: true });
}
export function destroyMockPlan({ id }) {
store.plans = store.plans.filter((item) => String(item.id) !== String(id));
persist();
return Promise.resolve({ success: true });
}

@ -1,156 +0,0 @@
<template>
<div>
<div ref="lxHeader">
<lx-header icon="md-apps" :text="$route.meta.title" style="margin-bottom: 10px; border: 0; margin-top: 15px;">
<div slot="content">
<div class="searchwrap">
<div>
<el-input v-model="select.name" placeholder="请输入计划体系名称" clearable />
</div>
<div>
<el-button type="primary" size="small" @click="select.page = 1; getList()">查询</el-button>
<el-button type="primary" size="small" @click="resetSelect"></el-button>
</div>
<div>
<el-button type="primary" size="small" @click="editInfo('add')"></el-button>
</div>
</div>
</div>
</lx-header>
</div>
<xy-table
:list="list"
:total="total"
:table-item="tableItem"
@pageIndexChange="pageIndexChange"
@pageSizeChange="pageSizeChange"
>
<template v-slot:status>
<el-table-column align="center" label="状态" width="100" header-align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'info'">{{ scope.row.status === 1 ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
</template>
<template v-slot:btns>
<el-table-column align="center" label="操作" width="180" header-align="center">
<template slot-scope="scope">
<el-button type="primary" size="small" @click="editInfo('editor', scope.row.id)">编辑</el-button>
<el-popconfirm style="margin: 0 10px;" title="确定删除吗?" @confirm="deleteList(scope.row.id)">
<el-button slot="reference" type="danger" size="small">删除</el-button>
</el-popconfirm>
</template>
</el-table-column>
</template>
</xy-table>
<add-plan-system ref="addPlanSystem" @refresh="getList" />
</div>
</template>
<script>
import myMixins from "@/mixin/selectMixin.js";
import { listMockPlanSystems as index, destroyMockPlanSystem as destroy } from "./mockService.js";
import addPlanSystem from "./components/addPlanSystem.vue";
export default {
mixins: [myMixins],
components: {
addPlanSystem,
},
data() {
return {
select: {
name: "",
page: 1,
page_size: 10,
},
list: [],
total: 0,
tableItem: [{
prop: "name",
label: "计划体系",
align: "left",
}, {
prop: "sort",
label: "排序",
align: "center",
width: 100,
}, {
prop: "status",
label: "状态",
align: "center",
width: 100,
}, {
prop: "remark",
label: "备注",
align: "left",
}],
};
},
created() {
this.getList();
},
methods: {
pageIndexChange(page) {
this.select.page = page;
this.getList();
},
pageSizeChange(pageSize) {
this.select.page_size = pageSize;
this.select.page = 1;
this.getList();
},
resetSelect() {
this.select.name = "";
this.select.page = 1;
this.getList();
},
async getList() {
const filter = [];
if (this.select.name) {
filter.push({
key: "name",
op: "like",
value: this.select.name,
});
}
const res = await index({
page: this.select.page,
page_size: this.select.page_size,
sort_name: "sort",
sort_type: "ASC",
filter,
});
this.list = res.data || [];
this.total = res.total || 0;
},
editInfo(type, id) {
this.$refs.addPlanSystem.id = id || "";
this.$refs.addPlanSystem.type = type;
this.$refs.addPlanSystem.isShow = true;
},
deleteList(id) {
destroy({ id }).then(() => {
this.$message.success("删除成功");
this.getList();
});
},
},
};
</script>
<style scoped lang="scss">
.searchwrap {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
& > div {
display: flex;
align-items: center;
}
}
</style>

@ -1,704 +0,0 @@
# 课程计划页面方案
## 1. 背景与目标
课程计划用于管理某一年度内,每个月份、不同计划体系、不同课程体系下预计开设的课程。该功能的核心不是单纯维护一条课程记录,而是围绕“年度计划”集中维护某门课程在 1-12 月内的开设安排。
本方案目标:
- 提供一套适配当前项目风格的前端页面设计方案。
- 明确课程计划、计划体系、地点三类数据的职责边界。
- 给出后端表结构、接口风格、索引与约束建议,方便前后端同步实施。
## 2. 业务对象拆分
建议将课程计划拆为 4 类业务对象,而不是把所有信息都塞进一张表。
### 2.1 课程计划主表
主表只保存“这是一条什么计划”,负责描述计划的基础维度:
- 计划年份
- 计划体系
- 课程体系
- 课程名称
主表不直接展开存储月份、地点、负责人,而是通过明细表维护每一个模块/期数。
### 2.2 课程计划明细表
明细表对应 `模块/期数` 数组中的每一项,一条记录代表一个模块/期数计划,字段包含:
- 名称:非必填,可为空
- 月份:必填,范围 1-12
- 地点:必填
- 负责人:必填
这样设计的原因:
- 一条计划可能跨多个月份。
- 同一月份可能出现多个模块/期数。
- 编辑时需要单独删除某一条模块/期数明细。
- 列表页按月份渲染时,后端可以先按主表聚合、再按月份分组返回。
### 2.3 计划体系表
计划体系属于基础数据,独立维护,作为课程计划主表的下拉来源。后续如果还有排序、停用、年度适用范围等扩展,独立成表更稳定。
### 2.4 地点表
地点同样建议作为基础数据独立维护,作为课程计划明细中的下拉来源。地点后续很可能扩展为:
- 地址详情
- 排序
- 是否启用
- 联系信息
- 容量说明
因此不建议仅保存为字符串。
## 3. 前端页面建议
建议在 `coursePlan` 目录下拆分为以下页面与组件:
- `index.vue`:课程计划主页面
- `components/addPlan.vue`:新增/编辑计划弹窗
- `planSystem.vue`:计划体系基础数据页
- `components/addPlanSystem.vue`:计划体系新增/编辑弹窗
- `location.vue`:地点基础数据页
- `components/addLocation.vue`:地点新增/编辑弹窗
如果一期只做主页面,也建议先按上述结构设计,避免后续把“新增地点”“新增计划体系”继续塞在当前页面中,导致页面职责过重。
## 4. 课程计划主页面设计
### 4.1 查询与功能按钮区
页面顶部建议保留一行查询区和操作区,沿用项目中 `lx-header + slot content` 的风格。
建议包含以下内容:
- 年份查询
- 使用 `el-date-picker`
- `type=\"year\"`
- 默认值为当前年份
- 查询按钮
- 重置按钮
- 新增计划按钮
- 新增地点按钮
- 新增计划体系按钮
说明:
- 年份是主筛选条件,应默认带上当前年,页面进入时自动查询。
- “新增地点”“新增计划体系”可以直接打开对应弹窗,也可以跳转到独立管理页。若从用户效率考虑,建议先做弹窗方式;若从数据管理完整性考虑,建议跳转到独立页面。
### 4.2 主表格列设计
表格建议使用 `el-table`,列如下:
- 计划体系
- 课程体系
- 一月
- 二月
- 三月
- 四月
- 五月
- 六月
- 七月
- 八月
- 九月
- 十月
- 十一月
- 十二月
### 4.3 行数据组织方式
列表展示建议一行代表一条课程计划主表数据,即:
- 一条主计划 = 某年 + 某计划体系 + 某课程体系 + 某课程名称
但考虑到列表中不单独展示“课程名称”列,而是把课程名称写进月份单元格中,所以需要把月份列数据预处理成:
```js
{
id: 1,
year: "2026",
plan_system_id: 2,
plan_system_name: "年度重点计划",
course_type_id: 5,
course_type_name: "企业管理体系",
course_name: "高层管理研修班",
months: {
1: [
{
detail_id: 11,
module_name: "第一期",
location_name: "苏州校区",
owner_name: "张三"
}
],
2: [],
3: [
{
detail_id: 12,
module_name: "第二期",
location_name: "上海教学点",
owner_name: "李四"
},
{
detail_id: 13,
module_name: "",
location_name: "线上",
owner_name: "王五"
}
]
}
}
```
这样前端渲染逻辑会比较直接,不需要在单元格中做大量即时计算。
### 4.4 单元格展示规则
每个月份单元格中,每条明细显示格式为:
`课程名称(模块/期数)- 地点`
具体规则:
- 若 `模块/期数名称` 有值:显示为 `课程名称(模块名称)- 地点`
- 若 `模块/期数名称` 为空:显示为 `课程名称 - 地点`
- 同一计划的同一月份存在多条明细时,单元格内换行展示
建议前端统一通过格式化方法生成显示文本,例如:
```js
formatPlanText(courseName, moduleName, locationName) {
if (moduleName) {
return `${courseName}${moduleName}- ${locationName}`
}
return `${courseName} - ${locationName}`
}
```
### 4.5 单元格悬浮交互
鼠标移入某月份单元格时,显示浮层,内容建议如下:
- 第一行开始逐条展示:
- `课程名称(模块/期数)- 地点`
- `负责人xxx`
- 底部操作区:
- 编辑按钮
交互建议:
- 当该单元格没有数据时,不显示浮层。
- 当单元格有多条明细时,浮层按列表形式展示全部明细。
- “编辑”点击后应打开当前计划的编辑弹窗,而不是只编辑单条明细,因为需求中说明“点击后弹出该计划的编辑弹窗,可在弹窗中删除某一模块/期数”。
建议使用 `el-popover``el-tooltip + 自定义内容` 实现。结合项目现状,优先建议 `el-popover`,因为底部有按钮交互。
### 4.6 合并单元格规则
表格中应对“计划体系”“课程体系”执行合并单元格。
建议规则:
- 如果连续多行的 `计划体系 + 课程体系` 相同,则这两列合并。
- 合并后月份列保持逐行展示。
注意:
- 只有在列表接口已经按 `计划体系排序 + 课程体系排序 + 课程名称排序` 返回时,前端 `span-method` 才容易实现。
- 如果存在同一 `计划体系 + 课程体系` 下多门不同课程,则它们仍是多行,只是前两列被合并。
建议后端排序:
- `plan_system_sort ASC`
- `course_type_sort ASC`
- `course_name ASC`
- `month ASC`
## 5. 新增/编辑计划弹窗设计
建议使用一个弹窗组件同时处理新增和编辑,标题分别为:
- 新增计划
- 编辑计划
### 5.1 基础字段
基础区字段如下:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| 计划年份 | 年份选择 | 是 | 默认当前年 |
| 计划体系 | 下拉选择 | 是 | 由计划体系接口返回 |
| 课程体系 | 下拉选择 | 是 | 复用 `@/api/course/courseType.js``index` 接口 |
| 课程名称 | 输入框 | 是 | 建议长度限制 100 |
### 5.2 模块/期数数组区
该字段建议在弹窗中使用 `el-table` 或卡片列表维护,数组最少 1 条。
每项字段:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| 名称 | 输入框 | 否 | 模块名或期数名 |
| 月份 | 下拉选择 | 是 | 1-12 月 |
| 地点 | 下拉选择 | 是 | 来源于地点接口 |
| 负责人 | 输入框 | 是 | 建议先录入姓名,后续可扩展成员选择 |
建议操作:
- 新增一条模块/期数
- 删除当前条目
校验规则:
- 数组不能为空
- 至少有 1 条数据
- 每条数据的 `月份/地点/负责人` 必填
### 5.3 编辑弹窗底部操作
编辑状态下,弹窗底部除“取消”“保存”外,增加:
- 删除计划
删除逻辑建议:
- 点击“删除计划”后弹出确认框
- 确认文案:
`删除该计划将删除所有的模块/期数,是否确认删除?`
删除成功后:
- 关闭弹窗
- 刷新列表
## 6. 基础数据页面建议
### 6.1 计划体系管理
建议字段:
- 名称
- 排序
- 状态
- 备注
页面能力:
- 列表
- 新增
- 编辑
- 删除
### 6.2 地点管理
建议字段:
- 名称
- 详细地址
- 排序
- 状态
- 备注
页面能力:
- 列表
- 新增
- 编辑
- 删除
说明:
- 本次需求里的“地点”更接近课程计划业务内的地点基础数据,不一定完全等同于现有预约场地类型。
- 如果现有 `book/type` 数据能直接复用,也可以不新建地点表,而是直接复用已有接口;但从语义上看,现有接口偏“预约场地类型”,未必完全适合课程计划,因此更建议新建独立地点表。
## 7. 前端接口建议
建议延续项目当前的接口命名风格,统一采用:
- `index`
- `show`
- `save`
- `destroy`
### 7.1 课程计划接口
建议新增:
- `@/api/coursePlan/index.js`
建议接口:
| 方法名 | 请求方式 | 路径建议 | 说明 |
| --- | --- | --- | --- |
| `index` | GET | `/api/admin/course-plan/index` | 课程计划列表 |
| `show` | GET | `/api/admin/course-plan/show` | 查看单条计划详情 |
| `save` | POST | `/api/admin/course-plan/save` | 新增/编辑计划 |
| `destroy` | GET | `/api/admin/course-plan/destroy` | 删除计划 |
列表查询参数建议:
| 参数 | 说明 |
| --- | --- |
| `page` | 页码 |
| `page_size` | 每页条数 |
| `year` | 计划年份 |
| `sort_name` | 排序字段 |
| `sort_type` | 排序方式 |
列表返回建议除基础字段外,直接返回 `months` 聚合结构,减少前端二次整理成本。
### 7.2 计划体系接口
建议新增:
- `@/api/coursePlan/system.js`
建议接口:
| 方法名 | 请求方式 | 路径建议 | 说明 |
| --- | --- | --- | --- |
| `index` | GET | `/api/admin/course-plan-system/index` | 计划体系列表 |
| `show` | GET | `/api/admin/course-plan-system/show` | 计划体系详情 |
| `save` | POST | `/api/admin/course-plan-system/save` | 新增/编辑计划体系 |
| `destroy` | GET | `/api/admin/course-plan-system/destroy` | 删除计划体系 |
### 7.3 地点接口
建议新增:
- `@/api/coursePlan/location.js`
建议接口:
| 方法名 | 请求方式 | 路径建议 | 说明 |
| --- | --- | --- | --- |
| `index` | GET | `/api/admin/course-plan-location/index` | 地点列表 |
| `show` | GET | `/api/admin/course-plan-location/show` | 地点详情 |
| `save` | POST | `/api/admin/course-plan-location/save` | 新增/编辑地点 |
| `destroy` | GET | `/api/admin/course-plan-location/destroy` | 删除地点 |
### 7.4 下拉接口使用建议
课程体系下拉直接复用:
- `@/api/course/courseType.js``index`
建议前端取数时增加固定筛选:
- 状态为启用
- 排序按 `sort ASC`
计划体系、地点下拉也建议提供可直接用于表单的轻量列表返回。
## 8. 后端表结构建议
建议至少新增 4 张表。
### 8.1 课程计划主表 `course_plan`
| 字段名 | 类型建议 | 说明 |
| --- | --- | --- |
| `id` | bigint | 主键 |
| `year` | varchar(4) 或 int | 计划年份 |
| `plan_system_id` | bigint | 计划体系 ID |
| `course_type_id` | bigint | 课程体系 ID |
| `course_name` | varchar(100) | 课程名称 |
| `sort` | int | 排序,默认 0 |
| `status` | tinyint | 状态,默认 1 |
| `remark` | varchar(255) | 备注,可空 |
| `created_by` | bigint | 创建人,可空 |
| `updated_by` | bigint | 更新人,可空 |
| `created_at` | datetime | 创建时间 |
| `updated_at` | datetime | 更新时间 |
| `deleted_at` | datetime | 软删除时间,可空 |
建议唯一约束:
- 如果业务上允许“同一年、同一计划体系、同一课程体系、同一课程名称”只出现一次,则增加唯一索引:
- `uniq_year_system_course_type_course_name`
建议普通索引:
- `idx_year`
- `idx_plan_system_id`
- `idx_course_type_id`
- `idx_year_plan_system_course_type`
### 8.2 课程计划明细表 `course_plan_detail`
| 字段名 | 类型建议 | 说明 |
| --- | --- | --- |
| `id` | bigint | 主键 |
| `plan_id` | bigint | 关联 `course_plan.id` |
| `module_name` | varchar(100) | 模块/期数名称,可空 |
| `month` | tinyint | 月份1-12 |
| `location_id` | bigint | 地点 ID |
| `owner_name` | varchar(50) | 负责人 |
| `sort` | int | 排序,默认 0 |
| `remark` | varchar(255) | 备注,可空 |
| `created_at` | datetime | 创建时间 |
| `updated_at` | datetime | 更新时间 |
| `deleted_at` | datetime | 软删除时间,可空 |
建议约束:
- `month` 限制为 1-12
- `plan_id` 必须存在
- `location_id` 必须存在
建议索引:
- `idx_plan_id`
- `idx_month`
- `idx_plan_id_month`
- `idx_location_id`
说明:
- 同一计划下同一月份允许出现多条明细,因此不建议对 `plan_id + month` 建唯一索引。
### 8.3 计划体系表 `course_plan_system`
| 字段名 | 类型建议 | 说明 |
| --- | --- | --- |
| `id` | bigint | 主键 |
| `name` | varchar(100) | 计划体系名称 |
| `sort` | int | 排序,默认 0 |
| `status` | tinyint | 状态,默认 1 |
| `remark` | varchar(255) | 备注,可空 |
| `created_at` | datetime | 创建时间 |
| `updated_at` | datetime | 更新时间 |
| `deleted_at` | datetime | 软删除时间,可空 |
建议约束:
- `name` 唯一,避免重复维护相同计划体系
### 8.4 地点表 `course_plan_location`
| 字段名 | 类型建议 | 说明 |
| --- | --- | --- |
| `id` | bigint | 主键 |
| `name` | varchar(100) | 地点名称 |
| `address` | varchar(255) | 详细地址,可空 |
| `sort` | int | 排序,默认 0 |
| `status` | tinyint | 状态,默认 1 |
| `remark` | varchar(255) | 备注,可空 |
| `created_at` | datetime | 创建时间 |
| `updated_at` | datetime | 更新时间 |
| `deleted_at` | datetime | 软删除时间,可空 |
建议约束:
- `name` 可考虑唯一,避免地点重名引发误选
## 9. 表关系与删除策略建议
关系建议如下:
- `course_plan.plan_system_id -> course_plan_system.id`
- `course_plan.course_type_id -> course_type.id`
- `course_plan_detail.plan_id -> course_plan.id`
- `course_plan_detail.location_id -> course_plan_location.id`
删除策略建议:
- 删除课程计划主表时,同时删除全部明细
- 删除计划体系前,需校验是否已被课程计划使用
- 删除地点前,需校验是否已被课程计划明细使用
若后端采用软删除:
- 主表删除时同步软删除明细
- 基础表删除前仍需做引用检查
## 10. 建议的数据返回结构
为减少前端拼装成本,建议 `course-plan/index` 直接返回可渲染结构:
```json
{
"data": [
{
"id": 1,
"year": "2026",
"plan_system_id": 1,
"plan_system_name": "年度重点计划",
"course_type_id": 2,
"course_type_name": "经营管理体系",
"course_name": "企业经营实战课",
"months": {
"1": [
{
"detail_id": 10,
"module_name": "第一期",
"month": 1,
"location_id": 3,
"location_name": "本部校区",
"owner_name": "张老师"
}
],
"2": [],
"3": []
}
}
],
"total": 1
}
```
这样前端可以直接:
- 遍历 1-12 月列
- 读取 `row.months[month]`
- 渲染多行文本
- 在悬浮层中使用同一份数据
## 11. 前端实现建议
### 11.1 列表页实现顺序
建议优先顺序:
1. 完成年份查询与主表格静态布局
2. 打通课程计划列表接口
3. 完成月份列渲染
4. 实现 `计划体系 + 课程体系` 合并单元格
5. 接入单元格悬浮层
6. 完成新增/编辑计划弹窗
7. 完成计划体系和地点基础数据管理
### 11.2 弹窗数组区实现方式
建议直接参考当前项目中弹窗内 `el-table` 维护数组的方式,不建议在一期引入过于复杂的拖拽方案。原因是本需求只是“增删模块/期数条目”,不涉及复杂表单搭建。
建议前端表单结构:
```js
form: {
id: "",
year: "2026",
plan_system_id: "",
course_type_id: "",
course_name: "",
details: [
{
id: "",
module_name: "",
month: "",
location_id: "",
owner_name: ""
}
]
}
```
### 11.3 保存接口提交结构
保存建议直接提交主表和明细数组:
```json
{
"id": 1,
"year": "2026",
"plan_system_id": 2,
"course_type_id": 5,
"course_name": "高层管理研修班",
"details": [
{
"id": 11,
"module_name": "第一期",
"month": 3,
"location_id": 8,
"owner_name": "张三"
},
{
"module_name": "",
"month": 6,
"location_id": 9,
"owner_name": "李四"
}
]
}
```
后端处理建议:
- 有 `id` 的明细按更新处理
- 没有 `id` 的明细按新增处理
- 编辑时前端未提交的旧明细,可视为删除,或由前端显式传删除标识;二者选其一并保持统一
更推荐:
- 前端显式维护当前剩余明细列表
- 后端保存时以 `plan_id` 为维度做差异同步
## 12. 风险点与注意事项
### 12.1 合并单元格前提
如果接口返回顺序不稳定,前端合并单元格会错乱,因此后端必须按统一顺序返回。
### 12.2 同月多条数据展示
单元格内多条明细换行后,高度会被撑开,建议:
- 允许行高自适应
- 单元格设置适当 padding
- 当数据过多时提供 `max-height` 或悬浮层查看更多
### 12.3 负责人字段
当前需求中负责人是字符串字段,落地最快;但如果后续希望与员工体系关联,建议预留扩展方案:
- 短期先存 `owner_name`
- 中长期新增 `owner_id`
### 12.4 地点是否复用旧表
如果后端确认现有预约场地类型可直接复用,则本方案中的地点表可以取消,改为复用旧接口。若复用,需要额外确认:
- 旧数据是否都可作为课程计划地点
- 字段含义是否匹配
- 停用逻辑是否一致
## 13. 推荐一期落地范围
建议一期范围:
- 课程计划主页面
- 新增/编辑计划弹窗
- 计划体系基础管理
- 地点基础管理
- 年份筛选
- 月份表格展示
- 单元格悬浮编辑
- 删除计划确认
建议暂缓项:
- 负责人关联员工选择器
- 导入导出
- 批量复制上一年度计划
- 月份维度统计分析
## 14. 与现有项目实现风格的对应关系
本方案与现有项目风格保持一致,主要可复用如下思路:
- 列表页骨架可参考 `src/views/course/types.vue`
- 基础配置页可参考 `src/views/courseTypeConfig/index.vue`
- 动态数组表单可参考 `src/views/dataMenu/components/addInfo.vue`
- 合并单元格可参考 `src/views/statistics/index.vue`
- 地点管理页风格可参考 `src/views/book/type.vue`
这样可以降低前端开发成本,也能让课程计划功能在交互上与现有系统保持统一。

@ -0,0 +1,43 @@
<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>
</div>
</template>
<script>
export default {
name: 'ScheduleMemberOverview',
props: {
memberAnalysis: {
type: Array,
default: () => []
}
}
}
</script>

@ -0,0 +1,53 @@
<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>
</div>
</template>
<script>
export default {
name: 'ScheduleMonthlyHeatmap',
props: {
monthLabels: {
type: Array,
default: () => []
},
monthlyStats: {
type: Array,
default: () => []
},
heatClass: {
type: Function,
default: () => () => 'heat-empty'
}
}
}
</script>

@ -0,0 +1,62 @@
<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 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>
</div>
</td>
</tr>
<tr v-if="!planRows.length">
<td :colspan="monthLabels.length + 2" class="empty-cell">暂无数据</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script>
export default {
name: 'SchedulePlanMatrix',
props: {
monthLabels: {
type: Array,
default: () => []
},
planRows: {
type: Array,
default: () => []
}
},
methods: {
onCellClick(row, month, items) {
this.$emit('cell-click', row, month, items)
}
}
}
</script>

@ -0,0 +1,26 @@
<template>
<div class="summary-panel">
<div class="summary-grid">
<div v-for="item in summaryCards" :key="item.label" class="summary-card">
<div class="summary-label">{{ item.label }}</div>
<div class="summary-value">
<span class="summary-number">{{ item.value }}</span>
<span v-if="item.unit" class="summary-unit">{{ item.unit }}</span>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'ScheduleSummaryPanel',
props: {
summaryCards: {
type: Array,
default: () => []
}
}
}
</script>

@ -1,5 +1,5 @@
<template>
<div class="schedule-overview-page">
<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
@ -17,162 +17,21 @@
</div>
</lx-header>
<div class="summary-panel">
<div class="summary-grid">
<div v-for="item in summaryCards" :key="item.label" class="summary-card">
<div class="summary-label">{{ item.label }}</div>
<div class="summary-value">
<span class="summary-number">{{ item.value }}</span>
<span v-if="item.unit" class="summary-unit">{{ item.unit }}</span>
</div>
</div>
</div>
</div>
<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 months" :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="months.length + 2" class="empty-cell">暂无数据</td>
</tr>
</tbody>
</table>
</div>
</div>
<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>
<summary-panel :summary-cards="summaryCards" />
<!-- <div class="sub-panel single-sub-panel">
<div class="sub-panel-title">地点次数</div>
<div class="table-scroll table-scroll-y limit-rows-8">
<table class="data-table compact-table location-table">
<thead>
<tr>
<th>地点</th>
<th>次数</th>
</tr>
</thead>
<tbody>
<tr v-for="item in scheduleCountRows" :key="item.label">
<td>{{ item.label }}</td>
<td>{{ item.value }}</td>
</tr>
<tr v-if="!scheduleCountRows.length">
<td colspan="2" class="empty-cell">暂无数据</td>
</tr>
</tbody>
</table>
</div>
</div> -->
<!-- <div class="sub-panel single-sub-panel">
<div class="sub-panel-title">课程体系次数</div>
<div class="table-scroll table-scroll-y limit-rows-8">
<table class="data-table compact-table location-table">
<thead>
<tr>
<th>课程体系</th>
<th>次数</th>
</tr>
</thead>
<tbody>
<tr v-for="item in courseTypeCountRows" :key="item.label">
<td>{{ item.label }}</td>
<td>{{ item.value }}</td>
</tr>
<tr v-if="!courseTypeCountRows.length">
<td colspan="2" class="empty-cell">暂无数据</td>
</tr>
</tbody>
</table>
</div>
</div> -->
</div>
<div class="panel">
<div class="table-scroll">
<table class="plan-table">
<thead>
<tr>
<th>体系</th>
<th>课程</th>
<th v-for="month in planMonths" :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 planMonths"
:key="row.rowKey + month"
class="plan-month-cell"
@click="handlePlanCellClick(row, month, row.plan[month])"
>
<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>
</div>
</td>
</tr>
<tr v-if="!planRows.length">
<td :colspan="planMonths.length + 2" class="empty-cell">暂无数据</td>
</tr>
</tbody>
</table>
</div>
</div>
<monthly-heatmap
:month-labels="monthLabels"
:monthly-stats="monthlyStats"
:heat-class="heatClass"
/>
<member-overview :member-analysis="memberAnalysis" />
<plan-matrix
:month-labels="monthLabels"
:plan-rows="planRows"
@cell-click="handlePlanCellClick"
/>
<el-dialog
title="体系课程管理"
@ -448,6 +307,10 @@
</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 {
destroyCourse,
destroySchedule,
@ -486,28 +349,31 @@ const createScheduleForm = () => ({
count_text: ''
})
const toneClassList = [
'tone-green',
'tone-blue',
'tone-purple',
'tone-sand',
'tone-cyan',
'tone-cream'
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
},
data() {
return {
systems: [],
courses: [],
schedules: [],
months: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
planMonths: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
monthOptions: Array.from({ length: 12 }, (_, index) => ({
value: index + 1,
label: `${index + 1}`
})),
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: [],
@ -697,17 +563,6 @@ export default {
tag: this.getTopCounterEntry(statsMap[name].systemCounter)?.label || '-'
})).sort((a, b) => Number(b.total.replace('次', '')) - Number(a.total.replace('次', '')))
},
scheduleCountRows() {
const counter = this.buildCounter(this.schedules, (item) => item.location || '-')
return this.counterToRows(counter)
},
courseTypeCountRows() {
const counter = this.buildCounter(this.schedules, (item) => {
const system = this.systemMap[item.system_id]
return system ? system.name : '-'
})
return this.counterToRows(counter)
},
systemMap() {
return this.systems.reduce((map, item) => {
map[item.id] = item
@ -744,11 +599,11 @@ export default {
.sort((a, b) => Number(a.sort || 0) - Number(b.sort || 0))
const safeCourses = relatedCourses.length ? relatedCourses : [{ id: `empty-${system.id}`, name: '未配置课程', system_id: system.id }]
const groupClass = toneClassList[systemIndex % toneClassList.length]
const groupClass = TONE_CLASSES[systemIndex % TONE_CLASSES.length]
safeCourses.forEach((course, courseIndex) => {
const courseSchedules = this.schedules.filter((item) => String(item.course_id) === String(course.id))
const plan = this.planMonths.reduce((map, monthLabel) => {
const plan = this.monthLabels.reduce((map, monthLabel) => {
const monthNumber = Number(monthLabel.replace('月', ''))
map[monthLabel] = courseSchedules
.filter((item) => Number(item.month) === monthNumber)
@ -791,15 +646,20 @@ export default {
},
methods: {
async loadData() {
const data = await getOverview({ year: this.currentYear })
this.systems = data.systems || []
this.courses = data.courses || []
this.schedules = data.schedules || []
this.loading = true
try {
const data = await getOverview({ year: this.currentYear })
this.systems = data.systems || []
this.courses = data.courses || []
this.schedules = data.schedules || []
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 })
@ -828,32 +688,12 @@ export default {
return a.label.localeCompare(b.label, 'zh-CN')
})[0]
},
counterToRows(counter) {
return Object.keys(counter || {}).map((label) => ({
label,
value: `${counter[label]}`
})).sort((a, b) => {
const valueDiff = Number(b.value.replace('次', '')) - Number(a.value.replace('次', ''))
if (valueDiff !== 0) {
return valueDiff
}
return a.label.localeCompare(b.label, 'zh-CN')
})
},
heatClass(value) {
if (!value) {
return 'heat-empty'
}
if (value >= 4) {
return 'heat-level-4'
}
if (value >= 3) {
return 'heat-level-3'
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
}
if (value >= 2) {
return 'heat-level-2'
}
return 'heat-level-1'
return 'heat-empty'
},
openSystemCourseManager() {
this.systemCourseDialogVisible = true
@ -1083,7 +923,7 @@ export default {
}
</script>
<style lang="scss" scoped>
<style lang="scss">
.schedule-overview-page {
padding: 0 16px 24px;
background: #eff2f9;
@ -1097,18 +937,15 @@ export default {
}
.summary-panel,
.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-panel {
padding: 18px;
margin-bottom: 14px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
@ -1152,10 +989,13 @@ export default {
.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,
.sub-panel-title {
.panel-title {
font-size: 13px;
font-weight: 600;
color: #303133;
@ -1243,20 +1083,6 @@ export default {
color: #fff;
}
.single-sub-panel {
margin-top: 14px;
}
.compact-table td:first-child,
.compact-table th:first-child {
width: 65%;
}
.location-table {
min-width: 100%;
width: 100%;
}
.plan-table {
min-width: 1280px;
}

@ -28,7 +28,10 @@ module.exports = {
*/
publicPath: process.env.ENV === 'staging' ? '/admin' : '/admin',
// 前台打包输出到当前机器的 /Users/apple/www/wx.sstbc.com/public/admin 目录下
outputDir: '/Users/apple/www/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',
assetsDir: 'static',
css: {
loaderOptions: { // 向 CSS 相关的 loader 传递选项

Loading…
Cancel
Save