Layui连线题编辑器组件(ConnectQuestion)

连线题编辑器组件(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 或问题 / 答案列表滚动时,连线会自动重绘,无需手动处理。

模式切换:"设置正确答案模式" 下,用户连线会被隐藏,仅显示正确连线;切换回 "编辑模式" 后恢复用户连线。

相关推荐
艾小码4 小时前
告别页面呆板!这5个DOM操作技巧让你的网站活起来
前端·javascript
正在学习前端的---小方同学5 小时前
vue-easy-tree树状结构
前端·javascript·vue.js
键盘不能没有CV键9 小时前
【图片处理】✈️HTML转图片字体异常处理
前端·javascript·html
yantuguiguziPGJ9 小时前
WPF 联合 Web 开发调试流程梳理(基于 Microsoft.Web.WebView2)
前端·microsoft·wpf
添砖java‘’9 小时前
vim高效编辑:从入门到精通
linux·编辑器·操作系统·vim
大飞记Python10 小时前
部门管理|“编辑部门”功能实现(Django5零基础Web平台)
前端·数据库·python·django
tsumikistep11 小时前
【前端】前端运行环境的结构
前端
你的人类朋友11 小时前
【Node】认识multer库
前端·javascript·后端
Aitter11 小时前
PDF和Word文件转换为Markdown的技术实现
前端·ai编程