Layui连线题编辑器组件(ConnectQuestion)
连线题编辑器组件(ConnectQuestion)文档
一、组件概述
ConnectQuestion 是基于 Layui + jQuery 开发的问题 - 答案连线编辑组件,支持动态添加 / 编辑 / 删除问题与答案、手动创建连线、设置正确答案、自定义连线颜色等功能,适用于在线考试、互动练习等场景中需要 “匹配题” 编辑的需求。
/*** 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. 拼接完整HTMLvar 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存在时,才执行blurif ($input.length && $input[0]) {$input[0].blur();}}});// 答案输入框回车this.$answerList.on('keypress', '.text-edit', function (e) {if (e.key === 'Enter') {e.preventDefault(); // 阻止默认行为(避免意外触发其他事件)const $input = $(this);// 确保元素存在再执行blurif ($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);});
});
.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、基础使用(默认配置)
最简单的初始化方式,指定容器并添加初始问题 / 答案。
<!-- 组件容器 -->
<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、自定义配置(颜色 + 回调)
修改连线颜色、隐藏 “添加区域”,并添加 “设置正确答案” 和 “清除连线” 的回调。
<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 提交数据到后端。
<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 或问题 / 答案列表滚动时,连线会自动重绘,无需手动处理。
模式切换:“设置正确答案模式” 下,用户连线会被隐藏,仅显示正确连线;切换回 “编辑模式” 后恢复用户连线。