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

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

相关推荐
崔庆才丨静觅13 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606114 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了14 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅14 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅14 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅15 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment15 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅15 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊15 小时前
jwt介绍
前端
爱敲代码的小鱼15 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax