连线题编辑器组件(ConnectQuestion)文档
一、组件概述
ConnectQuestion 是基于 Layui + jQuery 开发的问题 - 答案连线编辑组件,支持动态添加 / 编辑 / 删除问题与答案、手动创建连线、设置正确答案、自定义连线颜色等功能,适用于在线考试、互动练习等场景中需要 "匹配题" 编辑的需求。
javascript
/**
* ConnectQuestion 是基于Layui + jQuery开发的问题-答案连线编辑组件
* @class
* @author 戴铖
* @version 1.0.0
* @date: 2025-10-20 16:49:00
* @memberof layui
* @param {Object} options - 配置选项
* @param {string} options.elem - 容器选择器(必填)
* @param {string} [options.elem_other] - 多组件区分标识(可选)
* @param {Array} [options.questions=[]] - 初始问题列表
* @param {Array} [options.answers=[]] - 初始答案列表
* @param {Array} [options.correctConnections=[]] - 初始正确连线
* @param {boolean} [options.showOperationArea=true] - 是否显示操作区
* @param {boolean} [options.showAddForm=true] - 是否显示添加区域
* @param {boolean} [options.defaultIsSettingCorrect=false] - 是否默认进入设置模式
* @param {string} [options.correctLineColor='#16baaa'] - 正确连线颜色
* @param {string} [options.userLineColor='#fa8c16'] - 用户连线颜色
* @param {string} [options.tempLineColor='#999'] - 临时连线颜色
* @param {Function} [options.onSetCorrectCallback] - 设置正确答案回调
* @param {Function} [options.onClearAllCallback] - 清除所有连线回调
* @example
* // 基本用法
* // HTML
* <div id="connectQuestion"></div>
*
* // JavaScript
* layui.use(['connectQuestion'], function(){
* var ConnectQuestion = layui.connectQuestion;
* var editor = new ConnectQuestion({
* elem: '#connectQuestion',
* questions: [
* {id: 'q1', text: '问题1'},
* {id: 'q2', text: '问题2'}
* ],
* answers: [
* {id: 'a1', text: '答案1'},
* {id: 'a2', text: '答案2'}
* ]
* });
* });
*
* @example 高级使用
* // 自定义颜色和回调
* var editor = new ConnectQuestion({
* elem: '#advancedEditor',
* correctLineColor: '#0066ff',
* userLineColor: '#ff6600',
* onSetCorrectCallback: function(data){
* console.log('正确答案已设置', data);
* }
* });
*/
layui.define(['jquery', 'layer'], function (exports) {
"use strict";
var $ = layui.jquery;
var layer = layui.layer;
// 定义题编辑器类
var ConnectQuestion = function (options) {
// 默认配置(新增3个连线颜色配置属性)
this.config = {
/**
* 题编辑器容器,例如:'#connectQuestion'
*/
elem: null,
/**
* 连线容器父项或子项,会显示在问题与答案的id属性中
*/
elem_other:"",
/**
* 问题列表,数据结构:[{ id: 'q1', text: '问题一' }]
*/
questions: [],
/**
* 答案列表,数据结构:[{ id: 'a1', text: '答案一' }]
*/
answers: [],
/**
* 正确连线数组,数据结构:[{ questionId: 'q1', answerId: 'a1' }]
*/
correctConnections: [],
/**
* 是否显示操作区(operation-area),默认true
*/
showOperationArea: true,
/**
* 是否显示问题/答案列表的添加区域(add-form),默认true
*/
showAddForm: true,
/**
* 初始化时是否默认进入"设置正确答案模式";默认false
*/
defaultIsSettingCorrect: false,
// ========== 新增:连线颜色配置 ==========
/**
* 正确答案连线颜色,默认'#009688'(深青色)
*/
correctLineColor: '#16baaa',
/**
* 用户自定义连线颜色(编辑模式),默认'#fa8c16'(橙色)
*/
userLineColor: '#fa8c16',
/**
* 临时连线颜色(拖拽/未确认),默认'#999'(灰色)
*/
tempLineColor: '#999',
/**
* 点击"设置正确答案"按钮时的回调函数
* @param {Object} data - 从getData()获取的完整数据(含问题、答案、连线)
*/
onSetCorrectCallback: null,
/**
* 点击"清除所有连线"按钮并确认后,触发的回调函数
* @param {Object} data - 清除连线后,从getData()获取的最新数据
*/
onClearAllCallback: null
};
// 合并配置(新增属性会被外部参数覆盖)
$.extend(this.config, options);
// 数据模型(不变)
this.data = {
questions: this.config.questions || [],
answers: this.config.answers || [],
correctConnections: this.config.correctConnections || [],
userConnections: [],
isSettingCorrect: false,
activeQuestion: null,
activeAnswer: null,
tempLine: null
};
// DOM元素(不变)
this.$elem = $(this.config.elem);
this.$questionList = null;
this.$answerList = null;
this.$connectionSvg = null;
this.$modeIndicator = null;
this.$questionCount = null;
this.$answerCount = null;
// 初始化(不变)
this.init();
};
// 原型方法
ConnectQuestion.prototype = {
constructor: ConnectQuestion,
// 初始化(不变)
init: function () {
if (!this.$elem.length) {
layer.error(`容器题编辑器容器未找到容器元素`);
return;
}
this.renderHtml();
this.getElements();
this.initData();
this.renderQuestions();
this.renderAnswers();
this.updateCounts();
this.renderConnections();
this.setupEventListeners();
},
// 渲染HTML结构(不变)
renderHtml: function () {
// 1. 构建问题列表HTML(含add-form显示控制)
let questionListHtml = `
<div class="list-container">
<div class="list-title">
<span>问题列表</span>
<span class="layui-badge" id="question-count">0</span>
</div>
`;
if (this.config.showAddForm) {
questionListHtml += `
<div class="add-form">
<input type="text" id="question-input" placeholder="请输入问题" class="layui-input">
<a id="add-question" class="layui-btn layui-btn-normal">添加问题</a>
</div>
`;
}
questionListHtml += `
<div class="item-list" id="question-list"></div>
</div>
`;
// 2. 构建答案列表HTML(含add-form显示控制)
let answerListHtml = `
<div class="list-container">
<div class="list-title">
<span>答案列表</span>
<span class="layui-badge" id="answer-count">0</span>
</div>
`;
if (this.config.showAddForm) {
answerListHtml += `
<div class="add-form">
<input type="text" id="answer-input" placeholder="请输入答案" class="layui-input">
<a id="add-answer" class="layui-btn layui-btn-normal">添加答案</a>
</div>
`;
}
answerListHtml += `
<div class="item-list" id="answer-list"></div>
</div>
`;
// 3. 构建操作区HTML(含operation-area显示控制)
let operationAreaHtml = '';
if (this.config.showOperationArea) {
operationAreaHtml = `
<div class="operation-area">
<a class="layui-btn layui-btn-primary layui-border" id="mode-indicator">编辑模式</a>
<a id="set-correct" class="layui-btn layui-bg-blue">设置正确答案</a>
<a id="clear-all" class="layui-btn layui-btn-danger">清除所有连线</a>
</div>
`;
}
// 4. 拼接完整HTML
var html = `
<div class="main-content">
${questionListHtml}
<!-- 连线区域 -->
<div class="connection-area">
<svg id="connection-svg"></svg>
</div>
${answerListHtml}
</div>
${operationAreaHtml}
`;
this.$elem.html(html).css({ 'margin-bottom': '15px', 'border': '1px solid #eeeeee'});
},
// 获取DOM元素(不变)
getElements: function () {
this.$questionList = this.$elem.find('#question-list');
this.$answerList = this.$elem.find('#answer-list');
this.$connectionSvg = this.$elem.find('#connection-svg');
this.$modeIndicator = this.$elem.find('#mode-indicator');
this.$questionCount = this.$elem.find('#question-count');
this.$answerCount = this.$elem.find('#answer-count');
},
// 初始化数据(不变)
initData: function () {
if (this.data.questions.length === 0) {
this.data.questions = [];
}
if (this.data.answers.length === 0) {
this.data.answers = [];
}
if (this.data.correctConnections.length === 0) {
this.data.correctConnections = [];
}
},
// 渲染问题列表(不变)
renderQuestions: function () {
this.$questionList.empty();
this.data.questions.forEach(question => {
const connections = this.data.isSettingCorrect ? this.data.correctConnections : this.data.userConnections;
const isConnected = this.questionHasConnection(question.id, connections);
const connectedAnswerId = this.getConnectedAnswerId(question.id, connections);
let connectedAnswerText = '';
if (connectedAnswerId) {
const connectedAnswer = this.data.answers.find(a => a.id === connectedAnswerId);
connectedAnswerText = connectedAnswer ? connectedAnswer.text : '';
}
let itemHtml = `
<div class="item ${isConnected ? 'connected' : ''}" data-id="${question.id}">
<div class="item-content">
<span class="text-view">${question.text}</span>
<input type="text" class="text-edit layui-input" value="${question.text}" style="display: none;">
${isConnected ? `<span class="layui-badge layui-bg-blue ml-2">已连接</span>` : ''}
${connectedAnswerText ? `<span class="layui-badge layui-bg-gray ml-2">→ ${connectedAnswerText}</span>` : ''}
</div>
<div class="item-actions">
<a class="layui-btn layui-btn-normal layui-btn-xs edit-question">编辑</a>
<a class="layui-btn layui-btn-danger layui-btn-xs delete-question">删除</a>
</div>
</div>
`;
const $item = $(itemHtml);
this.$questionList.append($item);
});
},
// 渲染答案列表(不变)
renderAnswers: function () {
this.$answerList.empty();
this.data.answers.forEach(answer => {
const connections = this.data.isSettingCorrect ? this.data.correctConnections : this.data.userConnections;
const isConnected = this.answerHasConnection(answer.id, connections);
const connectedQuestionId = this.getConnectedQuestionId(answer.id, connections);
let connectedQuestionText = '';
if (connectedQuestionId) {
const connectedQuestion = this.data.questions.find(q => q.id === connectedQuestionId);
connectedQuestionText = connectedQuestion ? connectedQuestion.text : '';
}
let itemHtml = `
<div class="item ${isConnected ? 'connected' : ''}" data-id="${answer.id}">
<div class="item-content">
<span class="text-view">${answer.text}</span>
<input type="text" class="text-edit layui-input" value="${answer.text}" style="display: none;">
${isConnected ? `<span class="layui-badge layui-bg-blue ml-2">已连接</span>` : ''}
${connectedQuestionText ? `<span class="layui-badge layui-bg-gray ml-2">← ${connectedQuestionText}</span>` : ''}
</div>
<div class="item-actions">
<a class="layui-btn layui-btn-normal layui-btn-xs edit-answer">编辑</a>
<a class="layui-btn layui-btn-danger layui-btn-xs delete-answer">删除</a>
</div>
</div>
`;
const $item = $(itemHtml);
this.$answerList.append($item);
});
},
// 更新计数(不变)
updateCounts: function () {
this.$questionCount.text(this.data.questions.length);
this.$answerCount.text(this.data.answers.length);
},
// 添加问题(不变)
addQuestion: function (text) {
if (!text.trim()) {
layer.msg(`请输入问题内容!`, { icon: 2 });
return;
}
//const questionId = 'q' + Date.now();
const questionId = `q${($.generateRandomWithUUID(8))}_${this.config.elem_other}`;
this.data.questions.push({ id: questionId, text: text.trim() });
this.renderQuestions();
this.updateCounts();
this.$elem.find('#question-input').val('');
},
// 添加答案(不变)
addAnswer: function (text) {
if (!text.trim()) {
layer.msg(`请输入答案内容!`, { icon: 2 });
return;
}
//const answerId = 'a' + Date.now();
const answerId = `a${($.generateRandomWithUUID(8))}_${this.config.elem_other}`;
this.data.answers.push({ id: answerId, text: text.trim() });
this.renderAnswers();
this.updateCounts();
this.$elem.find('#answer-input').val('');
},
// 删除问题(不变)
deleteQuestion: function (questionId) {
this.data.questions = this.data.questions.filter(q => q.id !== questionId);
this.data.correctConnections = this.data.correctConnections.filter(conn => conn.questionId !== questionId);
this.data.userConnections = this.data.userConnections.filter(conn => conn.questionId !== questionId);
this.renderQuestions();
this.updateCounts();
this.renderConnections();
},
// 删除答案(不变)
deleteAnswer: function (answerId) {
this.data.answers = this.data.answers.filter(a => a.id !== answerId);
this.data.correctConnections = this.data.correctConnections.filter(conn => conn.answerId !== answerId);
this.data.userConnections = this.data.userConnections.filter(conn => conn.answerId !== answerId);
this.renderAnswers();
this.updateCounts();
this.renderConnections();
},
// 渲染所有连线(不变)
renderConnections: function () {
this.$connectionSvg.find('line').remove();
// 渲染正确答案连线
this.data.correctConnections.forEach(conn => {
this.drawConnection(conn.questionId, conn.answerId, true);
});
// 渲染用户连线
if (!this.data.isSettingCorrect) {
this.data.userConnections.forEach(conn => {
this.drawConnection(conn.questionId, conn.answerId, false);
});
}
},
// 绘制连线(核心修改:使用配置的颜色)
drawConnection: function (questionId, answerId, isCorrect) {
const $question = this.$questionList.find(`.item[data-id="${questionId}"]`);
const $answer = this.$answerList.find(`.item[data-id="${answerId}"]`);
if ($question.length && $answer.length) {
const questionPos = this.getElementCenterPosition($question, this.$questionList);
const answerPos = this.getElementCenterPosition($answer, this.$answerList);
const svgRect = this.$connectionSvg[0].getBoundingClientRect();
const questionRightX = this.$questionList[0].getBoundingClientRect().right;
const startX = questionRightX - svgRect.left;
const startY = questionPos.y - svgRect.top;
const answerLeftX = this.$answerList[0].getBoundingClientRect().left;
const endX = answerLeftX - svgRect.left;
const endY = answerPos.y - svgRect.top;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', startX);
line.setAttribute('y1', startY);
line.setAttribute('x2', endX);
line.setAttribute('y2', endY);
line.setAttribute('stroke-width', '2');
line.classList.add('connection-line');
// 核心修改:根据类型应用配置的颜色
if (isCorrect) {
line.classList.add('correct');
line.setAttribute('stroke', this.config.correctLineColor); // 正确连线用配置色
} else {
line.classList.add('temp');
line.setAttribute('stroke', this.config.userLineColor); // 用户连线用配置色
}
line.dataset.questionId = questionId;
line.dataset.answerId = answerId;
this.$connectionSvg.append(line);
}
},
// 获取元素中心位置(不变)
getElementCenterPosition: function ($element, $container) {
const elementRect = $element[0].getBoundingClientRect();
const containerRect = $container[0].getBoundingClientRect();
return {
x: elementRect.left + elementRect.width / 2,
y: elementRect.top + elementRect.height / 2
};
},
// 创建临时连线(核心修改:使用配置的临时颜色)
createTempLine: function (x1, y1, x2, y2) {
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', x1);
line.setAttribute('y1', y1);
line.setAttribute('x2', x2);
line.setAttribute('y2', y2);
// 核心修改:临时连线用配置色
line.setAttribute('stroke', this.config.tempLineColor);
line.setAttribute('stroke-width', '2');
line.setAttribute('stroke-dasharray', '5,5');
line.classList.add('connection-line', 'temp');
this.$connectionSvg.append(line);
return line;
},
// 更新临时连线(不变)
updateTempLine: function (line, x1, y1, x2, y2) {
if (line) {
line.setAttribute('x2', x2);
line.setAttribute('y2', y2);
}
},
// 移除临时连线(不变)
removeTempLine: function (line) {
if (line && line.parentNode) {
line.parentNode.removeChild(line);
}
},
// 检查连接是否已存在(不变)
connectionExists: function (questionId, answerId, connections) {
return connections.some(conn => conn.questionId === questionId && conn.answerId === answerId);
},
// 检查问题是否已连接(不变)
questionHasConnection: function (questionId, connections) {
return connections.some(conn => conn.questionId === questionId);
},
// 检查答案是否已连接(不变)
answerHasConnection: function (answerId, connections) {
return connections.some(conn => conn.answerId === answerId);
},
// 获取问题已连接的答案ID(不变)
getConnectedAnswerId: function (questionId, connections) {
const conn = connections.find(conn => conn.questionId === questionId);
return conn ? conn.answerId : null;
},
// 获取答案已连接的问题ID(不变)
getConnectedQuestionId: function (answerId, connections) {
const conn = connections.find(conn => conn.answerId === answerId);
return conn ? conn.questionId : null;
},
// 添加连接(不变)
addConnection: function (questionId, answerId) {
if (this.data.isSettingCorrect) {
if (this.questionHasConnection(questionId, this.data.correctConnections)) {
const oldAnswerId = this.getConnectedAnswerId(questionId, this.data.correctConnections);
if (oldAnswerId === answerId) return;
this.removeConnection(questionId, oldAnswerId);
}
if (this.answerHasConnection(answerId, this.data.correctConnections)) {
const oldQuestionId = this.getConnectedQuestionId(answerId, this.data.correctConnections);
if (oldQuestionId === questionId) return;
this.removeConnection(oldQuestionId, answerId);
}
if (!this.connectionExists(questionId, answerId, this.data.correctConnections)) {
this.data.correctConnections.push({ questionId: questionId, answerId: answerId });
}
} else {
if (this.questionHasConnection(questionId, this.data.userConnections)) {
const oldAnswerId = this.getConnectedAnswerId(questionId, this.data.userConnections);
if (oldAnswerId === answerId) return;
this.removeConnection(questionId, oldAnswerId);
}
if (this.answerHasConnection(answerId, this.data.userConnections)) {
const oldQuestionId = this.getConnectedQuestionId(answerId, this.data.userConnections);
if (oldQuestionId === questionId) return;
this.removeConnection(oldQuestionId, answerId);
}
if (!this.connectionExists(questionId, answerId, this.data.userConnections)) {
this.data.userConnections.push({ questionId: questionId, answerId: answerId });
}
}
this.renderConnections();
},
// 移除连接(不变)
removeConnection: function (questionId, answerId) {
if (this.data.isSettingCorrect) {
this.data.correctConnections = this.data.correctConnections.filter(conn => !(conn.questionId === questionId && conn.answerId === answerId));
} else {
this.data.userConnections = this.data.userConnections.filter(conn => !(conn.questionId === questionId && conn.answerId === answerId));
}
this.renderConnections();
},
// 清除所有连线(不变)
clearAllConnections: function () {
if (this.data.isSettingCorrect) {
this.data.correctConnections = [];
} else {
this.data.userConnections = [];
}
this.renderQuestions();
this.renderAnswers();
this.renderConnections();
},
// 切换设置正确答案模式(不变)
toggleSetCorrectMode: function () {
this.data.isSettingCorrect = !this.data.isSettingCorrect;
if (this.data.isSettingCorrect) {
this.$modeIndicator.text('设置正确答案模式').addClass('setting');
this.$elem.find('#set-correct').removeClass('layui-bg-blue').addClass('layui-btn-success');
}
else {
this.$modeIndicator.text(`编辑模式`).removeClass('setting');
this.$elem.find('#set-correct').removeClass('layui-btn-success').addClass('layui-bg-blue');
}
this.renderQuestions();
this.renderAnswers();
this.renderConnections();
},
// 设置事件监听(不变)
setupEventListeners: function () {
var that = this;
// 添加问题
this.$elem.find('#add-question').on('click', function () {
const text = that.$elem.find('#question-input').val();
that.addQuestion(text);
});
this.$elem.find('#question-input').on('keypress', function (e) {
if (e.key === 'Enter') {
const text = $(this).val();
that.addQuestion(text);
}
});
// 添加答案
this.$elem.find('#add-answer').on('click', function () {
const text = that.$elem.find('#answer-input').val();
that.addAnswer(text);
});
this.$elem.find('#answer-input').on('keypress', function (e) {
if (e.key === 'Enter') {
const text = $(this).val();
that.addAnswer(text);
}
});
// 编辑问题
this.$questionList.on('click', '.edit-question', function (e) {
e.stopPropagation();
const $item = $(this).closest('.item');
const $textView = $item.find('.text-view');
const $textEdit = $item.find('.text-edit');
$textView.hide();
$textEdit.show();
$textEdit.focus();
$textEdit.select();
});
// 编辑答案
this.$answerList.on('click', '.edit-answer', function (e) {
e.stopPropagation();
const $item = $(this).closest('.item');
const $textView = $item.find('.text-view');
const $textEdit = $item.find('.text-edit');
$textView.hide();
$textEdit.show();
$textEdit.focus();
$textEdit.select();
});
// 问题输入框失去焦点
// 问题输入框失去焦点(修改后)
this.$questionList.on('blur', '.text-edit', function () {
const $textEdit = $(this);
// 1. 第一层验证:确保jQuery对象有效且DOM存在
if (!$textEdit.length || !$textEdit[0]) {
return;
}
const $textView = $textEdit.siblings('.text-view');
// 2. 第二层验证:确保文本显示元素存在
if (!$textView.length || !$textView[0]) {
$textEdit.remove(); // 清理无效输入框
return;
}
const $item = $textEdit.closest('.item');
// 3. 第三层验证:确保问题项容器存在(避免孤立元素)
if (!$item.length) {
$textEdit.remove();
return;
}
const questionId = $item.data('id');
// 安全获取输入值:若DOM意外消失则直接返回
const rawValue = $textEdit[0] ? $textEdit.val() : '';
const newText = (rawValue || '').trim();
if (!newText) {
// 安全赋值:仅当DOM存在时执行
if ($textEdit[0]) {
$textEdit.val($textView.text());
}
$textView.show();
$textEdit.hide();
return;
}
// 最终数据更新:确保问题数据存在
const question = that.data.questions.find(q => q.id === questionId);
if (question && $textEdit[0] && $textView[0]) {
question.text = newText;
$textView.text(newText);
$textView.show();
$textEdit.hide();
// 仅当DOM未销毁时,再执行重渲染(避免无效操作)
that.renderQuestions();
that.renderAnswers();
that.renderConnections();
}
});
// 答案输入框失去焦点
this.$answerList.on('blur', '.text-edit', function () {
const $textEdit = $(this);
// 检查jQuery对象和底层DOM元素有效性
if (!$textEdit.length || !$textEdit[0]) return;
const $textView = $textEdit.siblings('.text-view');
if (!$textView.length || !$textView[0]) return;
const $item = $textEdit.closest('.item');
if (!$item.length) return;
const answerId = $item.data('id');
// 提前缓存输入值
const rawValue = $textEdit.val();
const newText = (rawValue !== undefined ? rawValue.trim() : '') || '';
if (!newText) {
if ($textEdit[0]) {
$textEdit.val($textView.text());
}
$textView.show();
$textEdit.hide();
return;
}
const answer = that.data.answers.find(a => a.id === answerId);
if (answer) {
answer.text = newText;
$textView.text(newText);
$textView.show();
$textEdit.hide();
that.renderQuestions();
that.renderAnswers();
that.renderConnections();
}
});
// 问题输入框回车// 问题输入框回车(优化后)
this.$questionList.on('keypress', '.text-edit', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
const $input = $(this);
// 增加防呆:仅当输入框DOM存在时,才执行blur
if ($input.length && $input[0]) {
$input[0].blur();
}
}
});
// 答案输入框回车
this.$answerList.on('keypress', '.text-edit', function (e) {
if (e.key === 'Enter') {
e.preventDefault(); // 阻止默认行为(避免意外触发其他事件)
const $input = $(this);
// 确保元素存在再执行blur
if ($input.length && $input[0]) {
$input[0].blur();
}
}
});
// 删除问题
this.$questionList.on('click', '.delete-question', function (e) {
e.stopPropagation();
const $item = $(this).closest('.item');
const questionId = $item.data('id');
layer.confirm(`确定要删除这个问题吗?`, { icon: 3, title: `温馨提示`, closeBtn: 0 }, function (index) {
that.deleteQuestion(questionId);
layer.close(index);
});
});
// 删除答案
this.$answerList.on('click', '.delete-answer', function (e) {
e.stopPropagation();
const $item = $(this).closest('.item');
const answerId = $item.data('id');
layer.confirm(`确定要删除这个答案吗?`, { icon: 3, title: `温馨提示`, closeBtn: 0 }, function (index) {
that.deleteAnswer(answerId);
layer.close(index);
});
});
// 设置正确答案
this.$elem.find('#set-correct').on('click', function () {
// 1. 记录切换前的模式(编辑模式为false,设置模式为true)
const prevMode = that.data.isSettingCorrect;
// 2. 执行模式切换
that.toggleSetCorrectMode();
// 3. 判断切换后的模式:仅当从设置模式(true)切换到编辑模式(false)时,触发回调
if (prevMode && !that.data.isSettingCorrect) {
if (typeof that.config.onSetCorrectCallback === 'function') {
const fullData = that.getData();
fullData["elem"] = $(that.config.elem);
fullData["elem_other"] = that.config.elem_other;
that.config.onSetCorrectCallback(fullData);
}
}
});
// 清除所有连线
this.$elem.find('#clear-all').on('click', function () {
layer.confirm(`确定要清除所有连线吗?`, { icon: 3, title: `温馨提示`, closeBtn: 0 }, function (index) {
// 1. 先执行原有清除逻辑
that.clearAllConnections();
// 2. 清除后,判断回调是否存在且为函数,存在则触发
if (typeof that.config.onClearAllCallback === 'function') {
const latestData = that.getData(); // 获取清除后的最新数据
that.config.onClearAllCallback(latestData); // 执行回调并传数据
}
layer.close(index);
});
});
// 问题项点击
this.$questionList.on('click', '.item', function () {
const $item = $(this);
const questionId = $item.data('id');
if (that.data.activeQuestion === questionId) {
$item.removeClass('active');
that.data.activeQuestion = null;
that.removeTempLine(that.data.tempLine);
that.data.tempLine = null;
return;
}
$item.addClass('active');
that.data.activeQuestion = questionId;
if (that.data.activeAnswer) {
that.addConnection(questionId, that.data.activeAnswer);
$item.removeClass('active');
that.$answerList.find(`.item[data-id="${that.data.activeAnswer}"]`).removeClass('active');
that.data.activeQuestion = null;
that.data.activeAnswer = null;
} else {
const connections = that.data.isSettingCorrect ? that.data.correctConnections : that.data.userConnections;
if (that.questionHasConnection(questionId, connections)) {
const connectedAnswerId = that.getConnectedAnswerId(questionId, connections);
const connectedAnswer = that.data.answers.find(a => a.id === connectedAnswerId);
if (connectedAnswer) {
layer.tips(`已连接到: ${connectedAnswer.text}`, $item, { tips: [3, '#52c41a'], time: 2000 });
}
}
}
});
// 答案项点击
this.$answerList.on('click', '.item', function () {
const $item = $(this);
const answerId = $item.data('id');
if (that.data.activeAnswer === answerId) {
$item.removeClass('active');
that.data.activeAnswer = null;
that.removeTempLine(that.data.tempLine);
that.data.tempLine = null;
return;
}
$item.addClass('active');
that.data.activeAnswer = answerId;
if (that.data.activeQuestion) {
that.addConnection(that.data.activeQuestion, answerId);
$item.removeClass('active');
that.$questionList.find(`.item[data-id="${that.data.activeQuestion}"]`).removeClass('active');
that.data.activeQuestion = null;
that.data.activeAnswer = null;
} else {
const connections = that.data.isSettingCorrect ? that.data.correctConnections : that.data.userConnections;
if (that.answerHasConnection(answerId, connections)) {
const connectedQuestionId = that.getConnectedQuestionId(answerId, connections);
const connectedQuestion = that.data.questions.find(q => q.id === connectedQuestionId);
if (connectedQuestion) {
layer.tips(`已连接到: ${connectedQuestion.text}`, $item, { tips: [3, '#52c41a'], time: 2000 });
}
}
}
});
// 连线点击删除
this.$connectionSvg.on('click', 'line', function (e) {
e.stopPropagation();
const questionId = $(this).data('question-id');
const answerId = $(this).data('answer-id');
that.removeConnection(questionId, answerId);
});
// 窗口 resize 重绘连线
$(window).on('resize', function () {
that.renderConnections();
});
// 容器滚动重绘连线
this.$elem.find('.item-list').on('scroll', function () {
that.renderConnections();
});
},
// 获取数据(不变)
getData: function () {
return {
questions: this.data.questions,
answers: this.data.answers,
userConnections: ((this.data.userConnections.length == 0) ? [] : this.data.userConnections),
correctConnections: ((this.data.correctConnections.length == 0) ? [] : this.data.correctConnections)
};
},
// 设置数据(不变)
setData: function (data) {
if (data) {
this.data.questions = data.questions || [];
this.data.answers = data.answers || [];
this.data.correctConnections = data.correctConnections || [];
this.data.userConnections = [];
this.renderQuestions();
this.renderAnswers();
this.updateCounts();
this.renderConnections();
}
}
};
// 扩展jQuery,添加基于UUID种子的随机数生成函数
(function ($) {
/**
* 使用UUID作为种子生成指定位数的随机数
* @param {number} digits - 生成的随机数位数,必须是正整数
* @returns {string} 指定位数的随机数字符串
*/
$.generateRandomWithUUID = function (digits) {
// 验证输入参数有效性
if (typeof digits !== 'number' || digits <= 0 || !Number.isInteger(digits)) {
throw new Error('请传入有效的正整数作为随机数位数');
}
// 生成UUID作为种子
const uuid = generateUUID();
// 将UUID转换为数值种子
const seed = uuidToSeed(uuid);
// 使用种子初始化随机数生成器
const rng = new LinearCongruentialGenerator(seed);
// 生成指定位数的随机数
let result = '';
for (let i = 0; i < digits; i++) {
// 生成0-9之间的随机整数
result += Math.floor(rng.next() * 10);
}
return result;
};
/**
* 生成UUID v4
* @returns {string} 标准UUID字符串
*/
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
/**
* 将UUID转换为种子数值
* @param {string} uuid - UUID字符串
* @returns {number} 用于随机数生成器的种子
*/
function uuidToSeed(uuid) {
// 移除UUID中的短横线并取前16个字符
const hexStr = uuid.replace(/-/g, '').substring(0, 16);
// 将十六进制字符串转换为十进制数值
return parseInt(hexStr, 16);
}
/**
* 线性同余生成器(LCG)实现
* @param {number} seed - 初始种子值
*/
class LinearCongruentialGenerator {
constructor(seed) {
// LCG参数 (使用glibc的参数)
this.modulus = 2 ** 31;
this.multiplier = 1103515245;
this.increment = 12345;
// 初始化状态
this.state = seed % this.modulus;
}
/**
* 生成下一个随机数
* @returns {number} 0到1之间的随机浮点数
*/
next() {
this.state = (this.multiplier * this.state + this.increment) % this.modulus;
return this.state / this.modulus;
}
}
})(jQuery);
layui.link(`${layui.cache.base}connectQuestion/connectQuestion.css`); // 加载CSS样式文件
// 暴露接口(不变)
exports('connectQuestion', function (options) {
return new ConnectQuestion(options);
});
});
css
.main-content {
display: flex;
overflow: hidden;
background-color: white;
justify-content: space-between;
box-shadow: 0 1px 10px rgba(0, 0, 0, 0.05);
}
.list-container {
width: 40%;
padding: 20px;
border-right: 1px solid #eee;
}
.list-container:last-child {
border-right: none;
}
.list-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 15px;
color: #333;
display: flex;
justify-content: space-between;
align-items: center;
}
.item-list {
padding: 10px;
overflow-y: auto;
min-height: 60px;
max-height: 500px;
border-radius: 4px;
border: 1px dashed #e6e6e6;
}
.item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
margin-bottom: 10px;
background-color: #f9f9f9;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.item:hover {
background-color: #f0f7ff;
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.item.active {
background-color: #e6f7ff;
border-left: 3px solid #1890ff;
}
.item.connected {
background-color: #f0f7ff;
border-left: 3px solid #52c41a;
}
.item.connected.active {
background-color: #e6f7ff;
border-left: 3px solid #1890ff;
}
.item-content {
flex: 1;
word-break: break-all;
}
.text-edit {
width: 70%;
}
.item-actions {
display: flex;
gap: 5px;
}
.item-actions button {
font-size: 12px;
}
.connection-area {
width: 20%;
position: relative;
background-color: #fafafa;
}
#connection-svg {
position: absolute;
top: 0;
left: 0;
z-index: 1;
width: 100%;
height: 100%;
pointer-events: none;
}
.operation-area {
gap: 10px;
display: flex;
padding: 20px;
justify-content: center;
background-color: white;
}
.add-form {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.add-form input {
flex: 1;
}
.add-form a.layui-btn {
letter-spacing: 1px;
}
.mode-indicator {
background-color: #31bdec;
}
.mode-indicator.setting {
background-color: #52c41a;
}
/* 动画效果 */
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
.pulse-animation {
animation: pulse 1.5s infinite;
}
/* 连线样式 */
.connection-line {
stroke-dasharray: 5, 5;
transition: all 0.3s;
}
.connection-line.correct {
stroke-dasharray: none;
}
.connection-line.temp {
stroke-dasharray: 5, 5;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #999;
}
1.1、依赖资源
基础依赖:Layui 的 jquery 模块、layer 模块
样式依赖:组件专属 CSS 文件(connectQuestion.css,已通过 layui.link 自动加载)
二、配置项说明
初始化组件时可通过 options 参数配置,所有配置项如下表所示:
属性名 | 类型 | 默认值 | 说明 |
---|---|---|---|
elem | String | null | 组件容器选择器(如 #connectQuestion),必填,需确保容器已存在。 |
elem_other | String | "" | 连线容器关联标识,会拼接在问题 / 答案的 id 中,用于多组件区分。 |
questions | Array | [] | 初始问题列表,数据结构:[{ id: 'q1', text: '问题一' }, ...] |
answers | Array | [] | 初始答案列表,数据结构:[{ id: 'a1', text: '答案一' }, ...] |
correctConnections | Array | [] | 初始正确连线列表,数据结构:[{ questionId: 'q1', answerId: 'a1' }, ...] |
showOperationArea | Boolean | true | 是否显示操作区(含模式切换、设置正确答案、清除连线按钮)。 |
showAddForm | Boolean | true | 是否显示问题 / 答案的 "添加输入框" 区域。 |
defaultIsSettingCorrect | Boolean | false | 初始化时是否默认进入 "设置正确答案模式"(默认是 "编辑模式")。 |
correctLineColor | String | "#16baaa" | 正确答案连线的颜色(支持十六进制、RGB 等 CSS 颜色格式)。 |
userLineColor | String | "#fa8c16" | 用户自定义连线的颜色(编辑模式下的连线颜色)。 |
tempLineColor | String | "#999" | 临时连线颜色(拖拽未确认、悬停时的虚线颜色)。 |
onSetCorrectCallback | Function | null | 点击 "设置正确答案" 按钮切换模式时触发的回调,参数为 data(组件完整数据)。 |
onClearAllCallback | Function | null | 确认 "清除所有连线" 后触发的回调,参数为 data(清除后的最新数据)。 |
三、原型方法说明
组件实例化后,可通过实例调用以下方法操作数据或界面:
四、jQuery 扩展方法说明
组件扩展了 jQuery 方法 $.generateRandomWithUUID,用于生成基于 UUID 种子的指定位数随机数,确保问题 / 答案 ID 的唯一性。
方法名 | 作用 | 参数说明 | 返回值 | 异常说明 |
---|---|---|---|---|
$.generateRandomWithUUID(digits) | 生成指定位数的随机数字符串,用于生成问题 / 答案的唯一 ID。 | digits:Number,随机数位数(必须为正整数) | String:指定位数随机数 | 若 digits 非有效正整数,抛出 Error |
五、使用示例
以下是 3 种常见使用场景的完整代码示例,需在 layui.use 中初始化组件。
5.1、基础使用(默认配置)
最简单的初始化方式,指定容器并添加初始问题 / 答案。
html
<!-- 组件容器 -->
<div id="connectQuestion"></div>
<script>
layui.use(['jquery', 'layer', 'connectQuestion'], function () {
const $ = layui.jquery;
const layer = layui.layer;
const ConnectQuestion = layui.connectQuestion;
// 1. 初始化组件
const questionEditor = new ConnectQuestion({
elem: '#connectQuestion', // 容器选择器(必填)
elem_other: 'exam1', // 多组件区分标识(可选)
// 初始问题列表
questions: [
{ id: 'q1', text: '《静夜思》的作者' },
{ id: 'q2', text: '《蜀道难》的作者' }
],
// 初始答案列表
answers: [
{ id: 'a1', text: '李白' },
{ id: 'a2', text: '杜甫' }
],
// 初始正确连线(q1→a1,q2→a1)
correctConnections: [
{ questionId: 'q1', answerId: 'a1' },
{ questionId: 'q2', answerId: 'a1' }
]
});
// 2. 可选:手动调用方法(如添加新问题)
$('#addNewQ').on('click', function () {
questionEditor.addQuestion('《登高》的作者');
});
});
</script>
5.2、自定义配置(颜色 + 回调)
修改连线颜色、隐藏 "添加区域",并添加 "设置正确答案" 和 "清除连线" 的回调。
html
<div id="customConnectQuestion"></div>
<script>
layui.use(['jquery', 'layer', 'connectQuestion'], function () {
const $ = layui.jquery;
const ConnectQuestion = layui.connectQuestion;
const customEditor = new ConnectQuestion({
elem: '#customConnectQuestion',
// 1. 自定义连线颜色
correctLineColor: '#0066ff', // 正确连线:蓝色
userLineColor: '#ff6600', // 用户连线:橙色
tempLineColor: '#cccccc', // 临时连线:浅灰
// 2. 隐藏"问题/答案添加区域"
showAddForm: false,
// 3. "设置正确答案"回调(切换模式时触发)
onSetCorrectCallback: function (data) {
layer.msg('已保存正确答案配置', { icon: 1 });
console.log('正确答案数据:', data); // data 包含 questions、answers、correctConnections 等
},
// 4. "清除所有连线"回调(清除后触发)
onClearAllCallback: function (data) {
layer.msg('所有连线已清除', { icon: 2 });
console.log('清除后的数据:', data);
}
});
});
</script>
5.3、动态交互(setData + getData)
通过 setData 动态加载后端数据,通过 getData 提交数据到后端。
html
<div id="dynamicConnectQuestion"></div>
<button id="loadDataBtn" class="layui-btn">加载后端数据</button>
<button id="submitDataBtn" class="layui-btn layui-btn-blue">提交数据</button>
<script>
layui.use(['jquery', 'layer', 'connectQuestion'], function () {
const $ = layui.jquery;
const layer = layui.layer;
const ConnectQuestion = layui.connectQuestion;
// 初始化空组件
const dynamicEditor = new ConnectQuestion({
elem: '#dynamicConnectQuestion',
showOperationArea: true
});
// 1. 加载后端数据(模拟 AJAX 请求)
$('#loadDataBtn').on('click', function () {
// 模拟后端返回的数据
const backendData = {
questions: [
{ id: 'q3', text: '计算机的核心部件' },
{ id: 'q4', text: '存储数据的硬件' }
],
answers: [
{ id: 'a3', text: 'CPU' },
{ id: 'a4', text: '硬盘' }
],
correctConnections: [
{ questionId: 'q3', answerId: 'a3' },
{ questionId: 'q4', answerId: 'a4' }
]
};
// 调用 setData 加载数据
dynamicEditor.setData(backendData);
layer.msg('数据加载完成', { icon: 1 });
});
// 2. 提交数据到后端(模拟 AJAX 提交)
$('#submitDataBtn').on('click', function () {
// 调用 getData 获取当前组件数据
const submitData = dynamicEditor.getData();
// 模拟 AJAX 提交
$.ajax({
url: '/api/save-connect-question',
method: 'POST',
data: JSON.stringify(submitData),
contentType: 'application/json',
success: function (res) {
if (res.code === 0) {
layer.msg('数据提交成功', { icon: 1 });
}
}
});
});
});
</script>
六、注意事项
容器要求:elem 必须指向已存在的 DOM 元素,否则会触发 layer.error 提示。
ID 唯一性:问题 / 答案的 id 需唯一,组件通过 $.generateRandomWithUUID 自动生成唯一 ID(手动添加时需确保不重复)。
自动重绘:窗口 resize 或问题 / 答案列表滚动时,连线会自动重绘,无需手动处理。
模式切换:"设置正确答案模式" 下,用户连线会被隐藏,仅显示正确连线;切换回 "编辑模式" 后恢复用户连线。