<template> <div class="container"> <div class="search-wrapper"> <el-form class="search-from" :inline="true" label-width="70px" @submit.prevent="initData"> <el-form-item label="名称"> <el-input v-model="queryParams.name" placeholder="请输入名称" clearable/> </el-form-item> <el-form-item label="责任人"> <el-input v-model="queryParams.respPerson" placeholder="请输入责任人" clearable/> </el-form-item> <el-form-item> <el-button type="primary" @click="initData"> <i class="el-icon-search"></i> 搜索 </el-button> <el-button @click="resetQuery"> <i class="el-icon-refresh"></i> 重置 </el-button> <el-button type="primary" style="margin-left: 10px;" @click="toggleGantt" > {{ showGantt ? '收起甘特图' : '展开甘特图' }} </el-button> </el-form-item> </el-form> </div> <div ref="gantt" class="gantt-container"></div> <!-- 添加或修改治理计划对话框 --> <Dialog title="查看治理计划" :visible.sync="open" width="850px" height="600px" append-to-body> <el-form ref="form" :model="form" label-width="100px" disabled> <el-row> <el-col :span="12"> <el-form-item label="编号" prop="code"> <el-input v-model="form.code" placeholder="请输入编号"/> </el-form-item> </el-col> <el-col :span="12"> <el-form-item label="名称" prop="text"> <el-input v-model="form.text" placeholder="请输入名称"/> </el-form-item> </el-col> </el-row> <el-row> <el-col :span="12"> <el-form-item label="上级计划" prop="parent"> <treeselect v-model="form.parent" :options="planOptions" :normalizer="normalizer" placeholder="选择上级计划" disabled /> </el-form-item> </el-col> <el-col :span="12"> <el-form-item label="完成度(%)" prop="progress"> <el-input-number v-model="form.progress" placeholder="请输入完成百分比"/> </el-form-item> </el-col> </el-row> <el-row> <el-col :span="12"> <el-form-item label="责任人" prop="respPerson"> <span slot="label"> <span class="content-font">责任人</span> </span> <!-- 用户向导 --> <p style="margin-top:0px; float: left;font-size: 12px;"> {{ form.respPerson }} <el-button size="mini" type="primary" icon="el-icon-user" class="btn-wizard-trigger" style="margin-left: 10px" >选择用户 </el-button> </p> </el-form-item> </el-col> <el-col :span="12"> <el-form-item label="责任部门" prop="respDept"> <el-input v-model="form.respDept" placeholder="请输入责任部门"/> </el-form-item> </el-col> </el-row> <el-row> <el-col :span="12"> <el-form-item label="计划开始日期" prop="planStartDate"> <el-date-picker clearable v-model="form.planStartDate" type="date" value-format="yyyy-MM-dd" placeholder="请选择计划开始日期"> </el-date-picker> </el-form-item> </el-col> <el-col :span="12"> <el-form-item label="实际开始日期" prop="realStartDate"> <el-date-picker clearable v-model="form.realStartDate" type="date" value-format="yyyy-MM-dd" placeholder="请选择实际开始日期"> </el-date-picker> </el-form-item> </el-col> </el-row> <el-row> <el-col :span="12"> <el-form-item label="计划结束时间" prop="planEndDate"> <el-date-picker clearable v-model="form.planEndDate" type="date" value-format="yyyy-MM-dd" placeholder="请选择计划结束时间"> </el-date-picker> </el-form-item> </el-col> <el-col :span="12"> <el-form-item label="实际结束日期" prop="realEndDate"> <el-date-picker clearable v-model="form.realEndDate" type="date" value-format="yyyy-MM-dd" placeholder="请选择实际结束日期"> </el-date-picker> </el-form-item> </el-col> </el-row> <el-row> <el-col :span="12"> <el-form-item label="计划工期(天)" prop="planDuration"> <el-input v-model="form.planDuration" placeholder="请选择计划日期"/> </el-form-item> </el-col> <el-col :span="12"> <el-form-item label="实际工期(天)" prop="realDuration"> <el-input v-model="form.realDuration" placeholder="请选择实际日期"/> </el-form-item> </el-col> </el-row> <el-row> <el-col :span="24"> <el-form-item label="备注" prop="remarks"> <el-input v-model="form.remarks" type="textarea" placeholder="请输入备注"/> </el-form-item> </el-col> </el-row> <el-row> <el-col :span="24"> <el-form-item label="反馈内容" prop="feedback"> <el-input v-model="form.feedback" type="textarea" placeholder="请输入内容"/> </el-form-item> </el-col> </el-row> </el-form> <div slot="buttons" class="dialog-footer"> <el-button @click="open = false">关 闭</el-button> </div> </Dialog> </div> </template> <script> import Dialog from '@/components/Dialog' import Treeselect from '@riophae/vue-treeselect' import '@riophae/vue-treeselect/dist/vue-treeselect.css' import {gantt} from "dhtmlx-gantt"; import "dhtmlx-gantt/codebase/dhtmlxgantt.css"; import {listPlan} from "@/api/dw/plan/planview"; export default { components: {Dialog, Treeselect}, name: "gantt", data() { return { tasks: { data: [], }, queryParams: { name: null, respPerson: null }, showGantt: true, // 状态控制甘特图显示 planOptions: [], open: false, // 控制详情弹窗显示 form: {} // 当前查看的任务 }; }, // 把携带的参数放到queryParams查询参数里 created() { // console.log(this.$route.params.id); }, methods: { // 查看任务详情 handleView(taskId) { // 根据任务ID查找任务详情 const task = this.tasks.data.find(item => item.id == taskId); if (task) { this.getTreeselect(); this.form = task; this.open = true; } }, toggleGantt() { this.showGantt = !this.showGantt; this.initData(); // 重新初始化甘特图 }, // 上级节点 getTreeselect() { listPlan().then(response => { const data = {uid: 0, name: '顶级节点', children: []}; data.children = this.handleTree(response.data, 'uid', 'parentUid') this.planOptions.push(data) }) }, normalizer(node) { if (node.children && !node.children.length) { delete node.children } return { id: node.uid, label: node.name, children: node.children } }, //开始时间-结束时间参数 DateDifference: function (strDateStart, strDateEnd) { var begintime_ms = Date.parse(new Date(strDateStart.replace(/-/g, "/"))); //begintime 为开始时间 var endtime_ms = Date.parse(new Date(strDateEnd.replace(/-/g, "/"))); // endtime 为结束时间 var date3 = endtime_ms - begintime_ms; //时间差的毫秒数 var days = Math.floor(date3 / (24 * 3600 * 1000)); return days; }, // 重置查询 resetQuery() { this.queryParams = { name: null, respPerson: null }; this.initData(); }, initData: function () { gantt.clearAll(); listPlan(this.queryParams).then((res) => { // 重新初始化甘特图配置 gantt.config.autosize = true; gantt.config.readonly = true; gantt.config.show_grid = true; this.tasks.data = res.data.map((item) => { let statusColor; //存在status字段 说明非一级菜单,判断阶段的具体类型 设置不同颜色 if (item.status == '1') { //冒烟 statusColor = "#84bd54" } else if (item.status == '2') { //单元 statusColor = "#fcca02" } else if (item.status == '3') { //回归 statusColor = "#dc1626" } else { statusColor = "#999999" } return { id: item.uid, parent: item.parent, text: item.name, start_date: item.planStartDate, duration: item.planDuration, open: true, //默认打开, toolTipsTxt: item.name, progress: item.schedule, status: item.status, code: item.code, respPerson: item.respPerson, respDept: item.respDept, planStartDate: item.planStartDate, planEndDate: item.planEndDate, realStartDate: item.realStartDate, realEndDate: item.realEndDate, planDuration: item.planDuration, realDuration: item.realDuration, remarks: item.remarks, feedback: item.feedback, color: statusColor, } }); //自适应甘特图的尺寸大小, 使得在不出现滚动条的情况下, 显示全部任务 gantt.config.autosize = true; //只读模式 gantt.config.readonly = true; //是否显示左侧树表格 gantt.config.show_grid = true; //表格列设置 gantt.config.columns = [ { name: "code", label: "编号", tree: true, width: "160", onrender: function (task, node) { node.setAttribute( "class", "gantt_cell gantt_last_cell gantt_cell_tree " + task.status ); }, }, { name: "status", label: "状态", align: "center", width: "80", template: function (task) { // 自定义状态列显示为状态灯 return `<div class="status-light" style="background-color: ${task.color}"></div>`; } }, {name: "text", label: "名称", align: "center", width: "180", hide: true}, {name: "progress", label: "完成度(%)", align: "center", width: "90", hide: true}, {name: "respPerson", label: "责任人", align: "center", width: "120", hide: true}, {name: "respDept", label: "责任部门", align: "center", width: "140", hide: true}, {name: "planStartDate", label: "计划开始日期", align: "center", width: "130", hide: true}, {name: "planEndDate", label: "计划结束时间", align: "center", width: "130", hide: true}, {name: "realStartDate", label: "实际开始日期", align: "center", width: "130", hide: true}, {name: "realEndDate", label: "实际结束日期", align: "center", width: "130", hide: true}, {name: "planDuration", label: "计划工期", align: "center", width: "90", hide: true}, {name: "realDuration", label: "实际工期", align: "center", width: "90", hide: true}, {name: "remarks", label: "备注", align: "center", width: "220", hide: true}, // { // name: "operate", // label: "操作", // align: "center", // width: "80", // template: function (task) { // return '<el-button size="mini" type="text" onclick=" window.vueInstance.handleView(\'' + task.id + '\')">查看</el-button>'; // } // } ]; var weekScaleTemplate = function (date) { var dateToStr = gantt.date.date_to_str("%m %d"); var endDate = gantt.date.add( gantt.date.add(date, 1, "week"), -1, "day" ); var weekNum = gantt.date.date_to_str("第 %W 周"); return weekNum(date); }; var daysStyle = function (date) { var dateToStr = gantt.date.date_to_str("%D"); if (dateToStr(date) == "六" || dateToStr(date) == "日") return "weekend"; return ""; }; gantt.config.subscales = [ { unit: "week", step: 1, template: weekScaleTemplate, }, { unit: "day", step: 1, format: "%d", }, ]; gantt.plugins({ tooltip: true, }); //设置鼠标放置显示事件 gantt.attachEvent("onGanttReady", function() { var tooltips = gantt.ext.tooltips; gantt.templates.tooltip_text = function(start, end, task) { return "编号:" + task.code + "<br/>" + "名称:" + task.text + "<br/>" + "计划开始:" + gantt.templates.tooltip_date_format(start) + "<br/>" + "工期:" + task.duration }; }); //设置任务条进度内容 gantt.templates.progress_text = function (start, end, task) { return ( "<div style='text-align:left;color:#fff;padding-left:20px'>" + task.progress + "% </div>" ); }; //任务条显示内容 gantt.templates.task_text = function (start, end, task) { // return task.text + '(' + task.duration + '天)'; return ( "<div style='text-align:center;color:#fff'>" + task.text + "(" + task.duration + "天)" + "</div>" ); }; //任务条上的文字大小 以及取消border自带样式 gantt.templates.task_class = function (start, end, item) { return item.$level == 0 ? "firstLevelTask" : "secondLevelTask"; }; gantt.config.layout = { css: "gantt_container", cols: [ { width: this.showGantt ? 800 : "100%", // 如果收起甘特图,左侧表格宽度占满 min_width: 300, //表格左侧最小宽度 rows: [ { view: "grid", scrollX: "gridScroll", scrollable: true, scrollY: "scrollVer", }, { view: "scrollbar", id: "gridScroll", group: "horizontal", }, ], }, ...(this.showGantt ? [{ resizer: true, width: 1, }, { rows: [ { view: "timeline", scrollX: "scrollHor", scrollY: "scrollVer", }, { view: "scrollbar", id: "scrollHor", group: "horizontal", }, ] }] : []) ], }; //时间轴图表中,任务条形图的高度 // gantt.config.task_height = 28 //时间轴图表中,甘特图的高度 // gantt.config.row_height = 36 //时间轴图表中,如果不设置,只有行边框,区分上下的任务,设置之后带有列的边框,整个时间轴变成格子状。 gantt.config.show_task_cells = true; //当task的长度改变时,自动调整图表坐标轴区间用于适配task的长度 gantt.config.fit_tasks = true; gantt.config.min_column_width = 50; gantt.config.auto_types = true; gantt.config.xml_date = "%Y-%m-%d"; gantt.config.scale_unit = "month"; gantt.config.step = 1; gantt.config.date_scale = "%Y年%M"; gantt.config.start_on_monday = true; gantt.config.scale_height = 160; gantt.config.autoscroll = true; gantt.config.calendar_property = "start_date"; gantt.config.calendar_property = "end_date"; gantt.config.readonly = true; gantt.i18n.setLocale("cn"); // 初始化 gantt.init(this.$refs.gantt); // 数据解析 gantt.parse(this.tasks); // 添加双击行事件监听器 gantt.attachEvent("onTaskDblClick", function(id, e) { // 调用查看详情方法 window.vueInstance.handleView(id); return true; }); }); }, }, mounted() { // 将当前Vue实例赋值给window.vueInstance,供甘特图中调用 window.vueInstance = this; this.initData(); }, }; </script> <style lang="scss" scoped> .firstLevelTask { border: none; .gantt_task_content { font-size: 13px; } } .secondLevelTask { border: none; } .thirdLevelTask { border: 2px solid #da645d; color: #da645d; background: #da645d; } .milestone-default { border: none; background: rgba(0, 0, 0, 0.45); } .milestone-unfinished { border: none; background: #5692f0; } .milestone-finished { border: none; background: #84bd54; } .milestone-canceled { border: none; background: #da645d; } html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; } .container { height: 100%; width: 100%; position: relative; padding: 10px; .gantt_grid_head_cell { padding-left: 20px; text-align: left !important; font-size: 14px; color: #333; } .left-container { height: 100%; } .green, .yellow, .pink, .popular { .gantt_tree_icon.gantt_file { background: none; position: relative; &::before { content: ""; width: 10px; height: 10px; border-radius: 50%; position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); } } } .green { .gantt_tree_icon.gantt_file { &::before { background: #84bd54; } } } .yellow { .gantt_tree_icon.gantt_file { &::before { background: #fcca02; } } } .pink { .gantt_tree_icon.gantt_file { &::before { background: #da645d; } } } .popular { .gantt_tree_icon.gantt_file { &::before { background: #d1a6ff; } } } } .left-container { height: 100%; } .gantt_task_content { text-align: left; padding-left: 10px; } .gantt-container { height: calc(100% - 65px) !important; } // 状态灯样式 ::v-deep .gantt_grid_data .gantt_cell div.status-light { width: 12px; height: 12px; border-radius: 50%; display: inline-block; margin: 0 auto; } // 表格表头居中样式 ::v-deep .gantt_grid_head_cell { text-align: center !important; } .search-wrapper { text-align: left; padding: 10px 0; } .search-from { .el-form-item--mini.el-form-item, .el-form-item--small.el-form-item { margin-bottom: 3px; } } </style> 实现表格和甘特图的左右拖拽
时间: 2025-07-28 22:59:15 浏览: 205
<think>我们有一个Vue组件,它使用dhtmlx-gantt来显示甘特图。目前,组件中有一个搜索区域和一个甘特图区域。甘特图区域包含一个表格(左侧)和一个时间轴(右侧)。我们想要实现的功能是:允许用户通过拖拽分隔条来调整表格和甘特图时间轴区域的宽度。
在dhtmlx-gantt中,我们可以通过配置layout来定义结构,并且它内置了resizer(调整大小的分隔条)。在当前的代码中,我们已经有一个resizer,但是当收起甘特图时,我们移除了时间轴区域。现在我们需要确保在展开甘特图时,用户可以通过拖拽resizer来调整左右两侧的宽度。
然而,我们注意到在当前的layout配置中,我们根据`showGantt`变量动态地构建了列。当展开甘特图时,我们有两列:第一列是表格,第二列是时间轴,中间有一个resizer。当收起甘特图时,我们只有一列(表格)。
问题:目前resizer可能无法正常工作,或者我们想要确保resizer的拖拽功能正常,并且能够调整左右两部分的宽度。
解决方案:
1. 确保在layout配置中,resizer被正确添加。在当前的代码中,当`showGantt`为true时,我们在两列之间添加了一个resizer(宽度为1px,并且有resizer: true)。这应该会启用拖拽调整宽度。
2. 但是,我们可能还需要保存调整后的宽度,以便在切换甘特图显示状态后,再次展开时能够保持用户调整的宽度。
然而,dhtmlx-gantt的layout resizer在调整大小时会改变相邻两列的宽度。但是,我们目前没有保存调整后的宽度。所以,当收起再展开后,宽度会重置。
因此,我们需要:
- 在调整宽度时,将左侧表格的宽度保存到组件的data中(例如,`gridWidth`)。
- 在初始化layout时,如果存在保存的宽度,则使用该宽度。
修改步骤:
1. 在data中定义一个变量来保存左侧表格的宽度,例如`gridWidth: 800`(默认值800)。
2. 在layout配置中,将左侧宽度设置为保存的宽度(如果有的话)或者默认值。
3. 监听gantt的resizer拖拽事件,在拖拽结束时更新`gridWidth`。
4. 当切换甘特图显示状态(收起/展开)时,如果收起,则表格占满;如果展开,则使用保存的宽度。
但是,dhtmlx-gantt的layout resizer在拖拽时并不会直接改变我们设置的宽度值,我们需要通过事件来捕获宽度的变化。
我们可以使用gantt的`onResize`事件来监听resizer的拖拽。但是注意,这个事件会在拖拽过程中不断触发,我们可能只需要在拖拽结束时更新一次。
然而,dhtmlx-gantt并没有直接提供resize结束的事件。我们可以使用`onResize`事件(在拖拽过程中触发)来更新宽度,但这样会频繁更新。或者,我们可以使用`onAfterResize`事件(如果存在的话)。但是,查看dhtmlx-gantt文档,我们发现有一个`onResizeEnd`事件。
因此,我们可以:
gantt.attachEvent("onResizeEnd", function(columnId, side, width){
// 这里columnId是调整的列(resizer)的id,但是我们调整的是左侧的宽度,所以我们可以获取左侧的宽度
// 注意:在layout中,左侧列是我们定义的第一列,我们可以通过gantt的API获取其宽度
// 或者,在onResizeEnd事件中,width参数是调整后resizer左侧列的宽度(即第一列的宽度)吗?
// 实际上,onResizeEnd事件的参数是:id, side, width,其中id是resizer的id(在layout中我们并没有给resizer设置id),side是调整的方向('left'或'right'),width是调整后的宽度(像素值)。
// 但是,我们并没有给resizer设置id,所以可能无法直接获取。我们可以通过gantt的DOM操作来获取左侧的宽度。
// 另一种思路:在resize过程中,我们可以通过gantt的配置对象获取第一列的宽度,因为gantt会实时更新DOM的宽度。
// 实际上,我们可以通过gantt.$root.querySelector('.gantt_grid')来获取左侧表格的DOM元素,然后获取其宽度。
// 但是,由于我们是在事件回调中,我们可以这样:
let gridWidth = gantt.$root.querySelector('.gantt_grid').offsetWidth;
// 然后保存这个宽度到Vue实例的gridWidth变量中。
// 注意:由于我们是在gantt的回调中,this指向gantt,所以我们需要使用Vue实例的引用(比如之前我们设置的window.vueInstance,或者使用闭包保存this)。
});
但是,我们也可以使用gantt.ext.responsiveLayout.getColumnWidth(columnIndex)来获取列的宽度。但是,这个扩展responsiveLayout需要先启用。
考虑到简单性,我们直接通过DOM获取左侧表格的宽度。
修改代码:
步骤1:在data中定义gridWidth,并设置默认值800。
步骤2:在layout配置中,左侧列的宽度使用gridWidth(如果showGantt为true,则使用gridWidth;如果为false,则使用100%)。
步骤3:在gantt初始化后,注册onResizeEnd事件,更新gridWidth。
步骤4:在切换甘特图显示状态(toggleGantt)时,如果收起,则gridWidth应该保留当前值,以便下次展开时使用;如果展开,则使用之前保存的gridWidth(在layout配置中已经使用了gridWidth)。
注意:在收起甘特图时,我们设置左侧宽度为100%,这不会影响gridWidth的值,因为gridWidth保存的是展开时左侧的宽度。
具体代码修改:
1. 在data中增加gridWidth: 800(初始值800)。
2. 在layout配置中,将左侧宽度改为:
width: this.showGantt ? this.gridWidth : "100%",
3. 在mounted或initData中,在gantt初始化后,添加事件监听:
gantt.attachEvent("onResizeEnd", function(id, side, width) {
// 获取左侧表格的实际宽度
const gridContainer = this.$refs.gantt.querySelector('.gantt_grid');
if (gridContainer) {
const gridWidth = gridContainer.offsetWidth;
// 更新Vue实例的gridWidth
window.vueInstance.gridWidth = gridWidth;
}
});
注意:由于事件回调中this指向gantt,所以我们使用window.vueInstance(我们在mounted中已经将this赋值给window.vueInstance)。
4. 但是,在收起甘特图时,我们不需要调整,因为此时没有resizer。
另外,我们还需要注意:当用户调整窗口大小时,我们可能需要重新调整布局?dhtmlx-gantt的responsiveLayout扩展可以处理,但我们这里不讨论。
但是,我们可能会遇到一个问题:在收起甘特图后再展开,我们希望使用之前保存的gridWidth,但是gantt在重新初始化时,layout配置中使用了gridWidth,所以应该没问题。
但是,我们还需要考虑:如果用户从未调整过宽度,那么使用默认值800。调整后,gridWidth会更新。
还有一个问题:在拖拽resizer时,我们可能希望实时更新gridWidth?但是,onResizeEnd事件是在拖拽结束时触发的,所以这样保存是合理的。
但是,我们也可以考虑在拖拽过程中实时更新gridWidth?没有必要,因为拖拽过程中宽度变化是连续的,我们只需要在结束时保存即可。
修改后的代码:
在initData方法中,在gantt.init之后,添加事件监听:
gantt.attachEvent("onResizeEnd", function(id, side, width) {
// 获取左侧表格的宽度
const gridContainer = document.querySelector('.gantt_grid'); // 注意:因为gantt容器内只有一个gantt,所以可以直接用类名
// 但是为了准确,我们可以使用this.$refs.gantt来限定范围
const container = window.vueInstance.$refs.gantt;
if (container) {
const gridContainer = container.querySelector('.gantt_grid');
if (gridContainer) {
const gridWidth = gridContainer.offsetWidth;
window.vueInstance.gridWidth = gridWidth;
}
}
});
注意:由于事件监听只需要注册一次,所以我们在initData中每次重新初始化甘特图时都会重新注册。但是,每次初始化会清除之前的事件监听吗?dhtmlx-gantt的attachEvent会叠加事件监听器,所以我们需要在每次初始化前先销毁之前的gantt实例?或者,在注册之前先detachEvent。
我们可以使用:
gantt.detachEvent("onResizeEnd"); // 先移除之前的事件监听器,再添加新的
但是,在第一次时,可能没有监听器,所以可以这样:
var eventId = gantt.attachEvent("onResizeEnd", ...);
// 但是这样我们无法在下次初始化前移除,因为eventId是上次的。
另一种做法:在mounted中只注册一次,因为initData会多次调用,但事件只需要注册一次。我们可以将事件注册放在mounted中,但确保在gantt.init之后。
但是,我们是在initData中调用gantt.init,所以我们在gantt.init之后注册事件。但是,如果多次调用initData,就会多次注册事件。所以,我们需要确保只注册一次。
我们可以设置一个标志位,例如:
if (!this.resizeEventRegistered) {
gantt.attachEvent(...);
this.resizeEventRegistered = true;
}
或者在mounted中,在initData调用之后注册事件。但是,第一次初始化是在mounted中调用initData,然后我们在mounted中注册事件?不行,因为initData是异步的(有API请求)。
因此,我们在initData的then回调中,在gantt.init之后注册事件,并且确保只注册一次。
修改:在data中增加resizeEventRegistered: false
在initData的then回调中,在gantt.init之后:
if (!this.resizeEventRegistered) {
gantt.attachEvent("onResizeEnd", this.handleResizeEnd);
this.resizeEventRegistered = true;
}
然后,在methods中定义handleResizeEnd方法:
handleResizeEnd(id, side, width) {
const container = this.$refs.gantt;
if (container) {
const gridContainer = container.querySelector('.gantt_grid');
if (gridContainer) {
this.gridWidth = gridContainer.offsetWidth;
}
}
}
注意:这里我们使用Vue实例的handleResizeEnd方法,这样在方法内可以直接通过this访问Vue实例。
但是,attachEvent会将事件上下文设置为gantt,所以我们在handleResizeEnd中不能使用this。因此,我们可以使用箭头函数,或者在attachEvent时绑定this。
我们可以这样:
gantt.attachEvent("onResizeEnd", (id, side, width) => {
const container = this.$refs.gantt;
if (container) {
const gridContainer = container.querySelector('.gantt_grid');
if (gridContainer) {
this.gridWidth = gridContainer.offsetWidth;
}
}
});
这样,我们就不需要单独定义handleResizeEnd方法,而且可以确保只注册一次。
修改后的initData方法中:
if (!this.resizeEventRegistered) {
gantt.attachEvent("onResizeEnd", (id, side, width) => {
const container = this.$refs.gantt;
if (container) {
const gridContainer = container.querySelector('.gantt_grid');
if (gridContainer) {
// 保存到gridWidth
this.gridWidth = gridContainer.offsetWidth;
}
}
});
this.resizeEventRegistered = true;
}
另外,在切换甘特图显示状态时(toggleGantt方法),我们调用initData重新初始化甘特图,此时左侧宽度会根据showGantt和gridWidth进行设置。
但是,我们还需要注意:当gridWidth改变时,gantt不会自动重新布局,因为我们在layout配置中使用了this.gridWidth,但initData只会在初始化时设置一次。所以,在gridWidth改变后,我们需要重新初始化甘特图?但是,gridWidth的改变是在拖拽结束后,而拖拽结束后gantt已经调整了布局,所以我们只需要保存这个值,以便下次展开时使用。当前展开状态下,gantt已经调整好了,所以不需要重新初始化。
但是,在拖拽过程中,gantt已经通过自己的布局机制调整了宽度,所以我们保存的gridWidth就是当前的宽度。在下次展开时,我们使用这个宽度。
因此,我们不需要在gridWidth改变时做任何额外操作。
最后,我们修改layout配置中的左侧宽度:
width: this.showGantt ? this.gridWidth : "100%",
这样,在展开时,左侧宽度为gridWidth(用户调整后的值),收起时,左侧宽度为100%。
但是,我们还需要注意:当gridWidth改变时,Vue会重新渲染,但gantt是通过initData初始化的,所以gridWidth的改变不会自动触发重新初始化。因此,我们不需要担心,因为gridWidth的改变不会影响当前显示的甘特图(因为甘特图已经初始化了,而且我们是在拖拽结束后才改变gridWidth的,此时甘特图已经调整了宽度,我们只是保存这个值以便下次使用)。
但是,在拖拽过程中,我们改变了gridWidth,然后Vue重新渲染,但甘特图是独立于Vue的DOM操作,所以不会冲突。
所以,按照上述步骤修改代码即可。
注意:在layout配置中,我们使用了gridWidth,所以当gridWidth变化时,我们不会重新构建gantt,直到下一次调用initData(比如切换收起/展开,或者搜索、重置等操作)。这是符合预期的。
代码修改:
1. 在data中增加:
gridWidth: 800, // 默认左侧宽度
resizeEventRegistered: false, // 是否已注册resize事件
2. 在layout配置中,将左侧宽度改为:
width: this.showGantt ? this.gridWidth : "100%",
3. 在initData方法中,在gantt.init(this.$refs.gantt)之后,添加:
if (!this.resizeEventRegistered) {
gantt.attachEvent("onResizeEnd", (id, side, width) => {
const container = this.$refs.gantt;
if (container) {
const gridContainer = container.querySelector('.gantt_grid');
if (gridContainer) {
this.gridWidth = gridContainer.offsetWidth;
}
}
});
this.resizeEventRegistered = true;
}
4. 注意:如果页面中有多个gantt实例,那么需要更精确的获取左侧表格的容器。我们使用this.$refs.gantt来限定范围。
但是,在dhtmlx-gantt中,左侧表格的容器具有类名"gantt_grid",所以我们在这个容器内查找。
另外,在收起甘特图时,左侧表格的宽度是100%,此时用户不能拖拽(因为没有resizer),所以不需要处理。
这样,我们就实现了保存用户调整的左侧表格宽度。
但是,我们还需要测试:当拖拽resizer时,左侧宽度改变,然后我们保存。然后收起甘特图,再展开,左侧宽度应该是之前调整的宽度。
注意:在收起甘特图时,我们调用initData重新初始化甘特图,此时布局变成只有一列(宽度100%)。当再次展开时,我们使用之前保存的gridWidth,所以左侧宽度会恢复。
但是,在收起时,左侧表格宽度为100%,而gridWidth保存的是展开时调整的宽度。所以,在收起时,我们不改变gridWidth的值,这样在展开时才能恢复。
所以,我们的逻辑是符合要求的。
最后,我们还需要注意:在初始化时,如果gridWidth没有设置,则使用默认值800。在拖拽调整后,gridWidth更新。在后续的任何操作中(比如搜索、重置),调用initData重新初始化甘特图,左侧表格宽度都会使用最新的gridWidth。
因此,我们修改代码。
但是,我们还需要注意:在调整resizer时,可能会调整得非常小,或者非常大。我们需要设置一个最小宽度,避免用户调整得过小。
在layout配置中,我们可以为左侧列设置min_width,例如:
min_width: 300, // 最小宽度
这样,在调整时,gantt会限制最小宽度。
我们已经在layout配置中设置了min_width: 300,所以不需要额外处理。
修改后的代码示例(只显示修改部分):</think># 实现表格和甘特图的左右拖拽功能
在dhtmlx-gantt中实现表格和甘特图区域的左右拖拽功能,需要正确配置布局并处理拖拽事件。以下是完整的解决方案:
## 解决方案分析
1. **布局配置**:使用dhtmlx-gantt的layout配置定义左右两个区域(表格和甘特图)以及它们之间的可拖拽分隔条
2. **宽度保存**:保存用户调整后的宽度值,以便在组件重新渲染时保持
3. **事件处理**:监听拖拽结束事件,更新保存的宽度值
4. **响应式处理**:确保在不同屏幕尺寸下布局合理
## 修改后的Vue组件代码
```html
<template>
<div class="container">
<!-- 搜索区域保持不变 -->
<div ref="gantt" class="gantt-container"></div>
<!-- 对话框代码保持不变 -->
</div>
</template>
<script>
// 导入部分保持不变
export default {
// 组件定义保持不变
data() {
return {
// 新增数据属性
gridWidth: 800, // 左侧表格初始宽度
resizeEventRegistered: false, // 拖拽事件注册状态
// 其他数据属性保持不变...
};
},
methods: {
// 其他方法保持不变...
initData: function () {
gantt.clearAll();
listPlan(this.queryParams).then((res) => {
// 甘特图配置保持不变...
// 布局配置 - 关键修改部分
gantt.config.layout = {
css: "gantt_container",
cols: [
{
width: this.showGantt ? this.gridWidth : "100%", // 使用保存的宽度
min_width: 300, // 表格左侧最小宽度
rows: [
{ view: "grid", scrollX: "gridScroll", scrollable: true, scrollY: "scrollVer" },
{ view: "scrollbar", id: "gridScroll", group: "horizontal" }
]
},
...(this.showGantt ? [
{
resizer: true, // 启用拖拽调整
width: 1, // 分隔条宽度
},
{
rows: [
{ view: "timeline", scrollX: "scrollHor", scrollY: "scrollVer" },
{ view: "scrollbar", id: "scrollHor", group: "horizontal" }
]
}
] : [])
]
};
// 其他甘特图配置保持不变...
// 初始化甘特图
gantt.init(this.$refs.gantt);
gantt.parse(this.tasks);
// 注册拖拽结束事件 - 关键修改部分
if (!this.resizeEventRegistered) {
gantt.attachEvent("onResizeEnd", (id, side, width) => {
const container = this.$refs.gantt;
if (container) {
const gridContainer = container.querySelector('.gantt_grid');
if (gridContainer) {
// 保存新的宽度值
this.gridWidth = gridContainer.offsetWidth;
}
}
});
this.resizeEventRegistered = true;
}
// 其他事件监听保持不变...
});
},
},
mounted() {
window.vueInstance = this;
this.initData();
},
};
</script>
<style lang="scss" scoped>
/* 样式保持不变,但添加分隔条样式 */
.gantt_resizer {
background-color: #e0e0e0;
cursor: col-resize;
width: 5px;
z-index: 1;
&:hover {
background-color: #4a9ff9;
}
}
/* 其他样式保持不变 */
</style>
```
## 代码解释
### 关键修改点
1. **布局配置**:
- 左侧表格宽度使用`this.gridWidth`变量(用户调整后会更新)
- 中间添加了`resizer: true`的列作为可拖拽分隔条
- 右侧是甘特图区域
2. **拖拽事件处理**:
```javascript
gantt.attachEvent("onResizeEnd", (id, side, width) => {
// 获取左侧表格的实际宽度
const gridContainer = this.$refs.gantt.querySelector('.gantt_grid');
if (gridContainer) {
// 更新保存的宽度值
this.gridWidth = gridContainer.offsetWidth;
}
});
```
- 监听`onResizeEnd`事件(拖拽结束时触发)
- 获取左侧表格的实际宽度并保存到`gridWidth`
3. **宽度保存**:
- 使用`gridWidth`变量保存用户调整后的宽度
- 在重新渲染甘特图时使用这个值,确保宽度保持一致
4. **分隔条样式**:
```css
.gantt_resizer {
background-color: #e0e0e0;
cursor: col-resize;
width: 5px;
z-index: 1;
&:hover {
background-color: #4a9ff9;
}
}
```
- 添加了分隔条的基本样式和悬停效果
- 设置`cursor: col-resize`显示调整大小的光标
### 实现效果
1. 用户可以通过拖拽中间的竖条调整左右区域宽度
2. 调整后的宽度会被保存,在重新渲染甘特图时保持不变
3. 分隔条在悬停时有明显的视觉反馈
4. 收起/展开甘特图时,布局能正确适应
## 相关问题
阅读全文
相关推荐









<template> 查询条件 <a-form-model :model="searchForm" layout="inline" ref="searchForm" class="searchForm"> <a-form-model-item label="题库名称" prop="title"> <a-input v-model="searchForm.title" placeholder="请输入题库名称" /> </a-form-model-item> <a-form-model-item class="searchButton"> <a-button type="primary" v-if="QX.read" @click="getSearch">查询</a-button> <a-button type="default" v-if="QX.read" @click="restSearch('searchForm')">重置</a-button> </a-form-model-item> </a-form-model> <a-button type="primary" @click="addBank" icon="plus" v-if="QX.add">新增</a-button> <a-table :columns="columns" :data-source="dataList" :pagination="false" :loading="loading" rowKey="questionBankId" :scroll="{ y: this.$getViewportSize().height - 300 }" > <a-icon class="iconBtn" type="edit" />编辑 <a-divider v-if="QX.edit && QX.delete" type="vertical" /> <a-popconfirm title="确认是否删除?" ok-text="是" cancel-text="否" @confirm="removeBank(record)" > <a-icon class="iconBtn" type="delete" />删除 </a-popconfirm> </a-table> <template slot="action" slot-scope="text, record, index"> 删除 </template> <a-pagination show-size-changer :total="totalPage" :current="pageIndex" :pageSize="pageSize" @showSizeChange="onShowSizeChange" @change="onChangePage" style="float: right; margin-top: 15px" /> <a-drawer :closable="true" :title="title" width="auto" :visible="visible" @close="visible = !visible" > <a-spin class="submitLoading" :spinning="submitLoading"> <a-form-model :model="form" :label-col="labelCol" :wrapper-col="wrapperCol" :rules="rules" ref="form" class="lay-drawer-form"> <a-row> <a-col :span="12"> <a-form-model-item label="题库名称" prop="title"> <a-input v-model="form.title" :maxLength="25" placeholder="请输入题库名称" style="width: 380px" /> </a-form-model-item> </a-col> <a-col :span="12"> <a-form-model-item label="关联岗位" prop="positionId"> <a-select v-model="form.positionId" style="width: 380px" placeholder="请选择岗位"> <a-select-option v-for="(label, value) in positionDict" :key="value" :value="value"> {{ label }} </a-select-option> </a-select> </a-form-model-item> </a-col> <a-col :span="12"> <a-form-model-item label="参与PK" prop="participateInPk"> <a-switch v-model="form.participateInPk" :checkedValue="1" :unCheckedValue="0" /> </a-form-model-item> </a-col> </a-row> </a-form-model> <a-button type="primary" @click="downloadTemplate">下载模板</a-button> <a-upload name="file" :showUploadList="false" :beforeUpload="beforeUpload" accept=".xlsx,.xls" :disabled="!importEnabled" > <a-button :disabled="!importEnabled"><a-icon type="upload" /> 导入题目</a-button> </a-upload> <a-button type="dashed" @click="addTopicVisible = true" :disabled="!addEnabled" > <a-icon type="plus" /> 添加题目 </a-button> {{ index + 1 }}. {{ topic.content }} <label v-for="option in getOptions(topic)" :key="option.key"> <input type="radio" :name="'topic' + topic.topicId" :value="option.key" :checked="topic.correctAnswer === option.key" /> {{ option.key }}. {{ option.value }} </label> <label> <input type="radio" :name="'topic' + topic.topicId" value="正确" :checked="topic.correctAnswer === '正确'" /> 正确 </label> <label> <input type="radio" :name="'topic' + topic.topicId" value="错误" :checked="topic.correctAnswer === '错误'" /> 错误 </label> <a-icon type="delete" /> 删除 <a-pagination v-model="topicPageNum" :pageSize="topicPageSize" :total="totalTopicCount" @change="handleTopicPageChange" style="text-align: right;" /> <a-button type="default" @click="visible = !visible" > 取消 </a-button> <a-button type="primary" @click="submitForm" > 确认 </a-button> </a-spin> </a-drawer> <a-drawer title="新增题目" :visible="addTopicVisible" @close="addTopicVisible = false" width="500" > <a-form-model :model="addTopicForm" layout="vertical" :rules="rulesForAddTopic" ref="addTopicFormRef"> <a-form-model-item label="题目类型" prop="topicType"> <a-select v-model="addTopicForm.topicType" style="width: 100%"> <a-select-option :value="1">选择题</a-select-option> <a-select-option :value="2">判断题</a-select-option> </a-select> </a-form-model-item> <a-form-model-item label="题目内容" prop="content"> <a-input v-model="addTopicForm.content" placeholder="请输入题目内容" /> </a-form-model-item> <a-form-model-item label="选项A" prop="optionA"> <a-input v-model="addTopicForm.optionA" placeholder="请输入选项A内容" /> </a-form-model-item> <a-form-model-item label="选项B" prop="optionB"> <a-input v-model="addTopicForm.optionB" placeholder="请输入选项B内容" /> </a-form-model-item> <a-form-model-item label="选项C" prop="optionC"> <a-input v-model="addTopicForm.optionC" placeholder="请输入选项C内容" /> </a-form-model-item> <a-form-model-item label="选项D" prop="optionD"> <a-input v-model="addTopicForm.optionD" placeholder="请输入选项D内容" /> </a-form-model-item> <a-form-model-item label="正确答案" prop="correctAnswer"> <a-select v-model="addTopicForm.correctAnswer" style="width: 100%"> <a-select-option value="A">A</a-select-option> <a-select-option value="B">B</a-select-option> <a-select-option value="C">C</a-select-option> <a-select-option value="D">D</a-select-option> </a-select> </a-form-model-item> <a-form-model-item label="正确答案" prop="correctAnswer"> <a-select v-model="addTopicForm.correctAnswer" style="width: 100%"> <a-select-option value="正确">正确</a-select-option> <a-select-option value="错误">错误</a-select-option> </a-select> </a-form-model-item> <a-button @click="addTopicVisible = false">取消</a-button> <a-button type="primary" @click="saveNewTopic">保存</a-button> </a-form-model> </a-drawer> </template> <script> import { req, fileDownload } from '../../../api/axiosFun'; import preventBack from 'vue-prevent-browser-back'; export default { name: 'Bank', mixins: [preventBack], data() { return { QX: {}, topicQX: {}, topicList: [], totalTopicCount: 0, // 题目总数 topicPageNum: 1, // 当前页码 topicPageSize: 10, // 每页数量 addTopicVisible: false, addTopicForm: { content: '', // 题目内容 topicType: 1, // 题目类型:1=选择题,2=判断题 optionA: '', optionB: '', optionC: '', optionD: '', correctAnswer: '', }, disabled: false, checkedKeys: [], selectAuth: [], treeData: [], positionDict: {}, title: '', labelCol: { span: 4 }, wrapperCol: { span: 20 }, tableHeight: 0, expanded: false, // 筛选条件是否展开 form: { questionBankId: 0, bankCode: '', title: '', positionId: '', participateInPk: true, }, isEdit: false, // 是否是编辑状态 isAdd: false, // 是否是新增状态 importEnabled: false, // 导入题目按钮是否可用 - 默认为不可用 addEnabled: false, // 添加题目按钮是否可用 - 默认为不可用 rules: { positionId: [ { required: true, message: '请选择岗位', trigger: 'blur' }, ], title: [ { required: true, message: '请输入题库名称', trigger: 'blur' }, ], }, rulesForAddTopic: { content: [ { required: true, message: '请输入题目内容', trigger: ['blur', 'change'] }, ], topicType: [ { required: true, message: '请选择题目类型', trigger: 'change' }, ], optionA: [ { required: (rule, value) => this.addTopicForm.topicType === 1, message: '选择题必须输入选项A', trigger: ['blur', 'change'], }, ], optionB: [ { required: (rule, value) => this.addTopicForm.topicType === 1, message: '选择题必须输入选项B', trigger: ['blur', 'change'], }, ], optionC: [ { required: (rule, value) => this.addTopicForm.topicType === 1, message: '选择题必须输入选项C', trigger: ['blur', 'change'], }, ], optionD: [ { required: (rule, value) => this.addTopicForm.topicType === 1, message: '选择题必须输入选项D', trigger: ['blur', 'change'], }, ], correctAnswer: [ { required: true, message: '请选择正确答案', trigger: 'change' }, ], }, searchForm: { title: '', }, visible: false, dataList: [], columns, loading: false, submitLoading: false, pageIndex: 1, pageSize: 10, totalPage: 0, ops: { vuescroll: {}, scrollPanel: {}, rail: { keepShow: true, }, bar: { hoverStyle: true, onlyShowBarOnScroll: false, // 是否只有滚动的时候才显示滚动条 background: '#F5F5F5', // 滚动条颜色 opacity: 1, // 滚动条透明度 'overflow-x': 'hidden', }, }, }; }, watch: { 'addTopicForm.topicType': function (newVal) { // 当题目类型变化时,触发相关字段的验证 this.$nextTick(() => { if (this.$refs.addTopicFormRef) { // 验证选项字段 if (newVal === 1) { this.$refs.addTopicFormRef.validateFields(['optionA', 'optionB', 'optionC', 'optionD']); } else { // 清除非必填字段的验证状态 this.$refs.addTopicFormRef.clearValidate(['optionA', 'optionB', 'optionC', 'optionD']); } } }); }, visible(newVal, oldVal) { if (!newVal) { this.restForm('form'); this.form.questionBankId = 0; this.checkedKeys = []; this.selectAuth = []; this.treeData = []; // 重置状态标志 this.isEdit = false; this.isAdd = false; } else { // 当抽屉打开时强制更新按钮状态 this.$nextTick(() => { this.$forceUpdate(); }); } }, }, mounted() { this.actionTitle = '操作'; this.getDict('position').then(res => { const dictMap = {}; res.data.forEach(item => { dictMap[item.dicValue] = item.dicDisplayName; }); this.positionDict = dictMap; }); this.getBankList(); }, methods: { // 获取字典数据方法 getDict(type) { return req('get', /dict/getDictItemByNo, { dicNo: type }) .then((res) => { if (res.result === 'success') { return res; } throw new Error(res.message || '获取字典数据失败'); }) .catch((error) => { console.error(获取${type}字典失败:, error); throw error; }); }, /* 新增题库 */ addBank() { this.visible = true; this.disabled = false; this.title = '新增题库'; // 获取功能权限树 req('get', '/bank/getTree', {}).then((res) => { this.treeData = res.data; }); // 初始化题目列表为空 this.topicList = []; this.totalTopicCount = 0; this.topicPageNum = 1; this.isEdit = false; // 不是编辑状态 this.isAdd = true; // 设置为新增状态 // 在新增状态下禁用导入和添加功能 this.importEnabled = false; this.addEnabled = false; // 强制更新按钮状态 this.$nextTick(() => { this.$forceUpdate(); }); }, // 查询 getSearch() { this.pageIndex = 1; this.getBankList(); }, /* 重置查询 */ restSearch(form) { this.restForm(form); this.pageIndex = 1; this.getBankList(); }, /* 删除题库 */ removeBank(record) { this.loading = true; req('post', '/bank/removeBQuestionBank', { questionBankId: record.questionBankId, }).then((res) => { this.loading = false; if (res.result === 'success') { this.$message.success(res.message); this.getBankList(); } }); }, downloadTemplate() { fileDownload('get', '/topic/downloadTemplate', null).then((res) => { const blob = new Blob([res], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }); const link = document.createElement('a'); link.href = window.URL.createObjectURL(blob); link.download = '题库模板.xlsx'; link.click(); window.URL.revokeObjectURL(link.href); }).catch(() => { this.$message.error('下载失败'); }); }, beforeUpload(file) { // 显示加载状态 this.loading = true; const formData = new FormData(); formData.append('file', file); formData.append('questionBankId', this.form.questionBankId); // 添加 questionBankId 字段 req('post', '/topic/import', formData, { headers: { 'Content-Type': 'multipart/form-data', }, }) .then((res) => { // 隐藏加载状态 this.loading = false; if (res.result === 'success') { // 确保在DOM更新后再刷新题目列表 this.$nextTick(() => { // 刷新题目列表 this.getTopicList(this.form.questionBankId, this.topicPageNum, this.topicPageSize) .then(() => { this.$message.success('导入成功'); }) .catch(() => { this.$message.error('刷新题目列表失败'); }); }); } else { this.$message.error(res.message || '导入失败'); } }) .catch((error) => { // 隐藏加载状态 this.loading = false; this.$message.error('导入失败,请重试'); console.error('导入题目出错:', error); }); return false; // 阻止默认上传行为 }, // 获取题库下的题目列表(返回Promise以便链式调用) getTopicList(questionBankId, pageNum = 1, pageSize = 10) { return new Promise((resolve, reject) => { req('post', '/topic/list', { questionBankId, page: pageNum, rows: pageSize, }).then((res) => { if (res.result === 'success') { this.topicQX = res.QX; if (!this.topicQX.edit && !this.topicQX.delete) { this.hideAction(); } else { this.topicList = res.data.map((item, index) => ({ index: (pageNum - 1) * pageSize + index + 1, content: item.content, optionA: item.optionA, optionB: item.optionB, optionC: item.optionC, optionD: item.optionD, topicId: item.topicId, correctAnswer: item.correctAnswer, topicType: item.topicType, })); } // 更新总条数用于分页组件 this.totalTopicCount = res.page.totalResult; resolve(res); } else { this.$message.error(res.message || '获取题目列表失败'); reject(res); } }).catch((error) => { this.$message.error('获取题目列表失败'); reject(error); }); }); }, handleTopicPageChange(page) { this.topicPageNum = page; this.getTopicList(this.form.questionBankId, page, this.topicPageSize); }, // 获取选择题的选项 getOptions(topic) { if (topic.topicType === 1) { // 选择题:返回 A/B/C/D 选项 const options = []; if (topic.optionA) options.push({ key: 'A', value: topic.optionA }); if (topic.optionB) options.push({ key: 'B', value: topic.optionB }); if (topic.optionC) options.push({ key: 'C', value: topic.optionC }); if (topic.optionD) options.push({ key: 'D', value: topic.optionD }); return options; } else if (topic.topicType === 2) { // 判断题:直接返回 正确/错误 return [ { key: '正确', value: '正确' }, { key: '错误', value: '错误' }, ]; } return []; }, checkAnswer(topic, selectedAnswer) { // 仅记录用户选择的答案,不进行是否正确的判断 this.$set(topic, 'userAnswer', selectedAnswer); }, resetAddTopicForm() { this.addTopicForm = { content: '', topicType: 1, optionA: '', optionB: '', optionC: '', optionD: '', correctAnswer: '', }; }, saveNewTopic() { this.$refs.addTopicFormRef.validate((valid, fields) => { if (!valid) { console.log('表单验证失败:', fields); // 找出第一个错误字段并聚焦 const firstErrorField = Object.keys(fields).find(key => fields[key]); if (firstErrorField && this.$refs.addTopicFormRef) { const formItem = this.$refs.addTopicFormRef.$children.find( child => child.prop === firstErrorField, ); if (formItem && formItem.$el) { const input = formItem.$el.querySelector('input, select, textarea'); if (input) input.focus(); } } return; } // 验证通过,处理保存逻辑 const newTopic = { ...this.addTopicForm }; // 发送请求保存题目 req('post', '/topic/add', { questionBankId: this.form.questionBankId, optionA: newTopic.optionA, optionB: newTopic.optionB, optionC: newTopic.optionC, optionD: newTopic.optionD, correctAnswer: newTopic.correctAnswer, content: newTopic.content, topicType: newTopic.topicType, }).then((res) => { if (res.result === 'success') { this.$message.success('题目添加成功'); this.addTopicVisible = false; this.getTopicList(this.form.questionBankId); // 刷新题目列表 } else { this.$message.error(res.message || '保存失败'); } this.resetAddTopicForm(); }).catch((err) => { this.$message.error('网络异常,请重试'); }); }); }, /* 删除题库下的题目 */ removeQuestion(topicId) { this.$confirm({ title: '确认删除该题目?', content: '删除后将无法恢复', okText: '是', cancelText: '否', onOk: () => { req('post', '/topic/removeBTopic', { topicId, questionBankId: this.form.questionBankId, }).then((res) => { if (res.result === 'success') { this.$message.success(res.message); // 刷新题目列表 this.getTopicList(this.form.questionBankId, this.topicPageNum, this.topicPageSize); } else { this.$message.error(res.message || '删除失败'); } }); }, }); }, editAuth(record) { this.loading = true; req('post', '/bank/getBQuestionBank', { questionBankId: record.questionBankId, }).then((res) => { this.loading = false; if (res.result === 'success') { this.visible = true; this.disabled = true; this.title = '修改题库'; this.isEdit = true; // 设置为编辑状态 this.isAdd = false; // 不是新增状态 // 在编辑状态下启用导入和添加功能 this.importEnabled = true; this.addEnabled = true; // 强制更新按钮状态 this.$nextTick(() => { this.$forceUpdate(); }); const bank = res.data; this.$nextTick(() => { this.form.questionBankId = bank.questionBankId; this.form.title = bank.title; this.form.participateInPk = Boolean(bank.participateInPk); this.form.positionId = bank.positionId; this.treeData = bank.treeData; this.checkedKeys = bank.auths; // 获取题目列表 this.topicPageNum = 1; this.getTopicList(bank.questionBankId, this.topicPageNum, this.topicPageSize); }); } }); }, /* 保存or修改题库信息 */ submitForm() { this.$refs.form.validate((valid) => { if (valid) { this.form.participateInPk = this.form.participateInPk ? 1 : 0; const url = this.form.questionBankId ? 'edit' : 'add'; const selectAuth = this.selectAuth; this.form.selectAuth = JSON.stringify(selectAuth); this.submitLoading = true; req('post', /bank/${url}, this.form).then((res) => { if (res.result === 'success') { this.visible = false; this.getBankList(); // 如果是新增题库且成功,获取题目列表 if (!this.form.questionBankId && res.data && res.data.questionBankId) { this.form.questionBankId = res.data.questionBankId; this.topicPageNum = 1; this.getTopicList(res.data.questionBankId, this.topicPageNum, this.topicPageSize); // 新增成功后启用导入和添加功能 this.importEnabled = true; this.addEnabled = true; } this.$message.success(res.message); // 重置新增/编辑状态 this.isEdit = false; this.isAdd = false; } this.submitLoading = false; }); } }); }, /* 重置表单 */ restForm(form) { this.$refs[form].resetFields(); }, /* 改变页数事件 */ onChangePage(page, pageSize) { this.pageIndex = page; this.getBankList(); }, /* 改变每页显示条数 */ onShowSizeChange(current, pageSize) { this.pageIndex = 1; this.pageSize = pageSize; this.getBankList(); }, /* 题库信息列表 */ getBankList() { this.loading = true; this.searchForm.page = this.pageIndex; this.searchForm.rows = this.pageSize; req('post', '/bank/list', this.searchForm) .then((res) => { if (res.result === 'success') { this.dataList = res.data; this.QX = res.QX; // 无权限隐藏操作列 if (!this.QX.edit && !this.QX.delete) { this.hideAction(); } else if (columns[columns.length - 1].title != '操作') { columns.push(actionShow); } this.totalPage = res.page.totalResult; } this.loading = false; }).catch((error) => { this.loading = false; }); }, /* 无所有行操作权限时,隐藏操作栏 */ hideAction() { if (columns[columns.length - 1].title == '操作') { columns.splice(columns.length - 1, 1); } }, /* 校验代号类型 */ validCode(value) { if (value.length > 20) { value = value.slice(0, 20); } for (let i = value.length - 1; i >= 0; i--) { const unicode = value.charCodeAt(i); if (unicode > 65280 && unicode < 65375) { value = value.substr(0, i); } } this.value = value; }, }, }; const actionShow = { title: '操作', width: '200px', hide: true, dataIndex: 'action', key: 'action', align: 'center', scopedSlots: { customRender: 'action' }, }; const columns = [ { title: '序号', width: '50px', align: 'center', customRender: (text, row, index) => index + 1, }, { title: '题库名称', align: 'center', dataIndex: 'title', key: 'title', width: '120px', }, { title: '涉及岗位', align: 'center', dataIndex: 'positionName', key: 'positionName', width: '110px', }, { title: '题目数量', dataIndex: 'topicCount', key: 'topicCount', align: 'center', width: '180px', }, { title: '操作', width: '150px', hide: true, dataIndex: 'action', key: 'action', align: 'center', scopedSlots: { customRender: 'action' }, }, ]; </script> <style scoped> .topic-list-container { max-height: calc(100vh - 400px); /* 根据实际布局调整最大高度 */ overflow-y: auto; } .pagination-wrapper { position: sticky; bottom: 0; left: 0; right: 0; background: #fff; padding: 10px; z-index: 1; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); margin-top: 15px; } .topic-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; } .topic-item { border: 1px solid #e8e8e8; padding: 15px; border-radius: 4px; position: relative; /* 为绝对定位的子元素提供定位上下文 */ } .topic-content { font-size: 16px; margin-bottom: 10px; } .options label { display: block; margin: 5px 0; font-size: 14px; } .options input[type="radio"] { margin-right: 5px; } /* 按钮禁用状态样式优化 */ .ant-upload.ant-upload-disabled .ant-upload-list, .ant-btn[disabled] { cursor: not-allowed; opacity: 0.5; } .topic-delete { position: absolute; top: 10px; right: 10px; } </style> <style scoped> @import "../../../assets/css/maincss.css"; </style> 在编辑页面中选择某个题目的选项后,再次打开该题时,默认显示的是上次选择的选项而不是正确答案。修改此问题










