html+canvas+thikphp 可视化工具拖拽、编辑生成JSON,渲染成海报图片 完全自定义

升级版,支持文字旋转,文字增加背景色   图片,二维码旋转

https://blog.youkuaiyun.com/weixin_44944193/article/details/155188958

效果截图

后台编辑效果

文字

图片

二维码

生成图片后效果



直接上代码(可以直接用的代码)

前端添加代码

ps:注意css路径问题

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>海报(添加)</title>
   <link href="poster.css" rel="stylesheet"
          type="text/css">
</head>
<body>
<div class="header">
    <div class="toolbar">
        <button class="btn btn-default" id="备用按钮" style="display:none;">备用</button>
    </div>
</div>

<div class="container">
    <div class="sidebar" id="sidebar">
        <div class="sidebar-toggle" id="sidebar-toggle">≡</div>
        <div class="component-list" id="component-list">
            <div class="component-item" data-type="text">文本</div>
            <div class="component-item" data-type="image">图片</div>
            <div class="component-item" data-type="qrcode">二维码</div>
        </div>
    </div>

    <div class="editor-area">
        <div class="canvas-container" id="canvas-container">
            <div class="canvas-drag-handle" id="canvas-drag-handle">☰</div>
            <!-- 关键改动 2:画布默认宽高改为 422x750 -->
            <canvas id="poster-canvas" width="422" height="750"></canvas>
            <div class="element-overlay hidden" id="element-overlay"></div>
            <div class="resize-handle resize-se canvas-resize-handle" id="canvas-resize-handle"></div>
        </div>

        <!-- 在这里添加新的输入框 HTML -->
        <div class="json-name-input">
            <label for="json-name">名称:</label>
            <input type="text" id="json-name" autocomplete="off" placeholder="请输入海报名称">
        </div>

        <button class="btn btn-primary" id="save-json-btn">保存JSON</button>
    </div>

    <div class="properties-panel" id="properties-panel">
        <div class="canvas-properties-wrapper">
            <div class="property-group">
                <div class="property-title">画布属性</div>
                <div class="property-item">
                    <div class="property-label">宽度</div>
                    <!-- 关键改动 2:宽度输入框默认值改为 422 -->
                    <input type="number" class="property-input" id="canvas-width" value="422" min="100" max="2000">
                </div>
                <div class="property-item">
                    <div class="property-label">高度</div>
                    <!-- 关键改动 2:高度输入框默认值改为 750 -->
                    <input type="number" class="property-input" id="canvas-height" value="750" min="100" max="2000">
                </div>
                <div class="property-item">
                    <div class="property-label">背景颜色</div>
                    <div class="color-picker">
                        <div class="color-preview" id="canvas-bg-color" style="background-color: #ffffff;"></div>
                        <input type="color" class="property-input" id="canvas-bg-color-input" value="#ffffff">
                    </div>
                </div>
            </div>

            <div class="property-group" id="element-properties" style="display: none;">
                <div class="property-title">元素属性</div>
                <div class="property-item">
                    <div class="property-label">X坐标</div>
                    <input type="number" class="property-input" id="element-x" value="0">
                </div>
                <div class="property-item">
                    <div class="property-label">Y坐标</div>
                    <input type="number" class="property-input" id="element-y" value="0">
                </div>
                <div class="property-item">
                    <div class="property-label">宽度</div>
                    <div class="property-slider">
                        <input type="range" id="element-width-slider" min="10" max="500" value="100">
                        <input type="number" id="element-width-value" class="slider-value" min="10" max="500"
                               value="100">
                    </div>
                </div>
                <div class="property-item">
                    <div class="property-label">高度</div>
                    <div class="property-slider">
                        <input type="range" id="element-height-slider" min="10" max="500" value="100">
                        <input type="number" id="element-height-value" class="slider-value" min="10" max="500"
                               value="100">
                    </div>
                </div>

                <div class="layer-control" id="layer-control" style="display: none;">
                    <div class="property-label">层级控制</div>
                    <div class="layer-info">
                        当前: <span id="current-layer">1</span> / 总: <span id="total-layers">1</span>
                    </div>
                    <div class="btn-group">
                        <button id="btn-bring-to-front" class="btn btn-default">置顶</button>
                        <button id="btn-move-up" class="btn btn-default">上移</button>
                        <button id="btn-move-down" class="btn btn-default">下移</button>
                        <button id="btn-send-to-back" class="btn btn-default">置底</button>
                    </div>
                </div>

                <div class="property-item" id="text-content-item" style="display: none;">
                    <div class="property-label">文本内容</div>
                    <input type="text" class="property-input" id="element-text" value="">
                </div>
                <div class="property-item" id="text-color-item" style="display: none;">
                    <div class="property-label">文本颜色</div>
                    <div class="color-picker">
                        <div class="color-preview" id="text-color" style="background-color: #000000;"></div>
                        <input type="color" class="property-input" id="text-color-input" value="#000000">
                    </div>
                </div>
                <div class="property-item" id="text-font-size-item" style="display: none;">
                    <div class="property-label">字体大小</div>
                    <div class="property-slider">
                        <input type="range" id="text-font-size-slider" min="1" max="100" value="16">
                        <input type="number" id="text-font-size-value" class="slider-value" min="1" max="100"
                               value="16">
                    </div>
                </div>
                <div class="property-item" id="text-align-item" style="display: none;">
                    <div class="property-label">对齐方式</div>
                    <select class="property-select" id="text-align">
                        <option value="left">靠左</option>
                        <option value="center">居中</option>
                        <option value="right">靠右</option>
                    </select>
                </div>
                <div class="property-item" id="text-overflow-item" style="display: none;">
                    <div class="property-label">超出处理</div>
                    <select class="property-select" id="text-overflow">
                        <option value="wrap">自动换行</option>
                        <option value="ellipsis">隐藏超出字符</option>
                    </select>
                </div>

                <div class="property-item" id="image-url-item" style="display: none;">
                    <div class="property-label">图片URL</div>
                    <input type="text" class="property-input" id="image-url" value="">
                </div>
                <div class="property-item" id="border-color-item" style="display: none;">
                    <div class="property-label">边框颜色</div>
                    <div class="color-picker">
                        <div class="color-preview" id="border-color"
                             style="background-color: rgba(255,255,255,0);"></div>
                        <input type="color" class="property-input" id="border-color-input" value="#ffffff">
                    </div>
                </div>
                <div class="property-item" id="container-bg-color-item" style="display: none;">
                    <div class="property-label">容器背景</div>
                    <div class="color-picker">
                        <div class="color-preview" id="container-bg-color"
                             style="background-color: rgba(255,255,255,0);"></div>
                        <input type="color" class="property-input" id="container-bg-color-input" value="#ffffff">
                    </div>
                </div>
                <div class="property-item" id="image-border-radius-item" style="display: none;">
                    <div class="property-label">圆角半径</div>
                    <div class="property-slider">
                        <input type="range" id="image-border-radius-slider" min="0" max="50" value="0">
                        <input type="number" id="image-border-radius-value" class="slider-value" min="0" max="50"
                               value="0">
                    </div>
                </div>

                <div class="property-item" id="qrcode-url-item" style="display: none;">
                    <div class="property-label">二维码内容</div>
                    <input type="text" class="property-input" id="qrcode-url" value="">
                </div>

                <div class="property-item" id="delete-element-item" style="display: none; margin-top: 15px;">
                    <button id="delete-element-btn">删除元素</button>
                </div>
            </div>
        </div>

        <div class="property-group">
            <div class="property-title">JSON数据预览</div>
            <div class="json-preview" id="json-preview"></div>
        </div>
    </div>
</div>

<div class="element-list-panel" id="element-list-panel">
    <div class="panel-resize-handle" id="panel-resize-handle"></div>
    <div class="panel-title">元素层级</div>
    <div class="element-list" id="element-list"></div>
</div>

</body>
</html>


<!-- 先引入 jQuery -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<!-- 引入 layui JS -->
<script src="https://cdn.staticfile.org/layui/2.9.10/layui.js"></script>

<script>
    /**
     * 海报编辑器核心类
     * 负责画布初始化、元素管理(添加/删除/修改)、拖拽缩放、属性面板同步、JSON 保存等功能
     */
    class PosterEditor {
        /**
         * 构造函数:初始化编辑器实例
         * @param {string} canvasId - 画布 DOM 元素的 ID
         */
        constructor(canvasId) {
            // 1. 获取画布和上下文
            this.canvas = document.getElementById(canvasId);
            this.ctx = this.canvas.getContext('2d'); // 2D 绘图上下文

            // 2. 核心数据存储
            this.elements = []; // 存储所有海报元素(文本、图片、二维码)
            this.selectedElement = null; // 当前选中的元素

            // 3. 拖拽/缩放状态标识
            this.isDragging = false; // 元素是否正在拖拽
            this.dragOffset = {x: 0, y: 0}; // 拖拽时的鼠标偏移量(避免点击元素时跳动)
            this.isResizing = false; // 元素是否正在缩放
            this.resizeDirection = null; // 缩放方向(nw/ne/sw/se)
            this.isCanvasResizing = false; // 画布是否正在缩放

            // 4. 画布基础配置
            this.canvasMinWidth = 100; // 画布最小宽度
            this.canvasMinHeight = 100; // 画布最小高度

            // 5. 元素计数器(用于生成默认名称,如 text_1、image_2)
            this.globalCounter = 1;

            // 6. DOM 元素引用(方便后续操作)
            this.elementList = document.getElementById('element-list'); // 元素层级列表
            this.draggingIndex = -1; // 拖拽元素在列表中的索引
            this.sidebar = document.getElementById('sidebar'); // 左侧组件库侧边栏
            this.sidebarToggle = document.getElementById('sidebar-toggle'); // 侧边栏折叠按钮
            this.elementListPanel = document.getElementById('element-list-panel'); // 元素层级面板
            this.panelTitle = this.elementListPanel.querySelector('.panel-title'); // 层级面板标题(拖拽用)
            this.saveBtn = document.getElementById('save-json-btn'); // 保存 JSON 按钮
            this.propertiesPanel = document.getElementById('properties-panel'); // 右侧属性面板
            this.canvasContainer = document.getElementById('canvas-container'); // 画布容器
            this.canvasDragHandle = document.getElementById('canvas-drag-handle'); // 画布拖拽手柄

            // 7. 面板/画布拖拽配置
            this.isDraggingPanel = false; // 元素层级面板是否正在拖拽
            this.panelOffset = {x: 0, y: 0}; // 面板拖拽偏移量
            this.isDraggingCanvas = false; // 画布是否正在拖拽
            this.canvasOffset = {x: 0, y: 0}; // 画布拖拽偏移量

            // 8. 不同类型元素的默认容器颜色(区分元素类型,提升视觉体验)
            this.defaultContainerColors = {
                text: '#e8f4f8',    // 文本元素:浅蓝色
                image: '#faf6ed',   // 图片元素:浅橙色
                qrcode: '#fdf2f8'   // 二维码元素:浅粉色
            };

            // 9. 初始化编辑器(顺序不可乱)
            this.initCanvas(); // 初始化画布尺寸
            this.initEventListeners(); // 绑定所有事件(点击、拖拽、输入等)
            this.initSidebarToggle(); // 初始化侧边栏折叠功能
            this.initPanelDrag(); // 初始化元素层级面板拖拽
            this.initCanvasDrag(); // 初始化画布拖拽
            this.updateUI(); // 首次更新 UI(渲染画布、层级列表、属性面板)
        }

        /**
         * 初始化画布尺寸(从输入框读取默认值,无值则用画布自身尺寸)
         */
        initCanvas() {
            const widthInput = document.getElementById('canvas-width');
            const heightInput = document.getElementById('canvas-height');
            // 优先使用输入框的值,无值则用画布默认尺寸
            this.canvas.width = parseInt(widthInput.value) || this.canvas.width;
            this.canvas.height = parseInt(heightInput.value) || this.canvas.height;
            this.render(); // 初始化后渲染画布
        }

        /**
         * 创建滑块和输入框的双向绑定(同步数值,如元素宽度、字体大小)
         * @param {string} sliderId - 滑块 DOM 元素 ID
         * @param {string} inputId - 输入框 DOM 元素 ID
         * @param {function} callback - 数值变化后的回调函数(更新元素属性)
         */
        createTwoWayBinding(sliderId, inputId, callback) {
            const slider = document.getElementById(sliderId);
            const input = document.getElementById(inputId);

            // 同步滑块和输入框的值
            const updateValue = (source, value) => {
                if (source === 'slider') input.value = value; // 滑块动 → 输入框同步
                if (source === 'input') slider.value = value; // 输入框动 → 滑块同步
                callback && callback(parseInt(value)); // 数值变化后执行回调(更新元素)
            };

            // 绑定滑块输入事件
            slider.addEventListener('input', (e) => updateValue('slider', e.target.value));
            // 绑定输入框输入事件(处理边界值,如不小于最小值、不大于最大值)
            input.addEventListener('input', (e) => {
                let value = parseInt(e.target.value) || 0;
                if (slider.min) value = Math.max(parseInt(slider.min), value); // 不小于最小值
                if (slider.max) value = Math.min(parseInt(slider.max), value); // 不大于最大值
                e.target.value = value; // 修正输入框值
                updateValue('input', value);
            });
        }

        /**
         * 初始化所有事件监听器(核心方法,覆盖所有交互逻辑)
         */
        initEventListeners() {
            // -------------------------- 画布属性事件 --------------------------
            // 画布宽度输入框变化 → 更新画布宽度
            document.getElementById('canvas-width').addEventListener('change', (e) => {
                const newWidth = Math.max(this.canvasMinWidth, Math.min(parseInt(e.target.value) || this.canvasMinWidth, 2000));
                this.canvas.width = newWidth;
                e.target.value = newWidth; // 修正输入框值(避免超出范围)
                this.render(); // 重新渲染画布
            });

            // 画布高度输入框变化 → 更新画布高度
            document.getElementById('canvas-height').addEventListener('change', (e) => {
                const newHeight = Math.max(this.canvasMinHeight, Math.min(parseInt(e.target.value) || this.canvasMinHeight, 2000));
                this.canvas.height = newHeight;
                e.target.value = newHeight; // 修正输入框值
                this.render(); // 重新渲染画布
            });

            // 画布背景颜色选择器变化 → 更新画布背景
            document.getElementById('canvas-bg-color-input').addEventListener('input', (e) => {
                document.getElementById('canvas-bg-color').style.backgroundColor = e.target.value;
                this.render(); // 重新渲染画布
            });

            // -------------------------- 元素通用属性事件 --------------------------
            // 元素宽度(滑块+输入框)双向绑定 → 更新元素宽度
            this.createTwoWayBinding('element-width-slider', 'element-width-value', (value) => {
                if (this.selectedElement) {
                    this.selectedElement.width = value;
                    this.updateElementOverlay(); // 更新元素选中框(覆盖层)
                    this.render(); // 重新渲染画布
                }
            });

            // 元素高度(滑块+输入框)双向绑定 → 更新元素高度
            this.createTwoWayBinding('element-height-slider', 'element-height-value', (value) => {
                if (this.selectedElement) {
                    this.selectedElement.height = value;
                    this.updateElementOverlay(); // 更新元素选中框
                    this.render(); // 重新渲染画布
                }
            });

            // 元素X坐标输入框变化 → 更新元素X坐标
            document.getElementById('element-x').addEventListener('input', (e) => {
                if (this.selectedElement) {
                    this.selectedElement.x = parseInt(e.target.value) || 0;
                    this.updateElementOverlay(); // 更新元素选中框
                    this.render(); // 重新渲染画布
                }
            });

            // 元素Y坐标输入框变化 → 更新元素Y坐标
            document.getElementById('element-y').addEventListener('input', (e) => {
                if (this.selectedElement) {
                    this.selectedElement.y = parseInt(e.target.value) || 0;
                    this.updateElementOverlay(); // 更新元素选中框
                    this.render(); // 重新渲染画布
                }
            });

            // -------------------------- 文本元素专属事件 --------------------------
            // 文本内容输入框变化 → 更新文本内容
            document.getElementById('element-text').addEventListener('input', (e) => {
                if (this.selectedElement?.type === 'text') { // 仅文本元素生效
                    this.selectedElement.text = e.target.value;
                    this.updateUI(); // 更新 UI(渲染画布+层级列表)
                }
            });

            // 文本颜色选择器变化 → 更新文本颜色
            document.getElementById('text-color-input').addEventListener('input', (e) => {
                if (this.selectedElement?.type === 'text') { // 仅文本元素生效
                    this.selectedElement.color = e.target.value;
                    document.getElementById('text-color').style.backgroundColor = e.target.value;
                    this.render(); // 重新渲染画布
                }
            });

            // 字体大小(滑块+输入框)双向绑定 → 更新字体大小
            this.createTwoWayBinding('text-font-size-slider', 'text-font-size-value', (value) => {
                if (this.selectedElement?.type === 'text') { // 仅文本元素生效
                    this.selectedElement.fontSize = value;
                    this.render(); // 重新渲染画布
                }
            });

            // 文本对齐方式下拉框变化 → 更新文本对齐
            document.getElementById('text-align').addEventListener('change', (e) => {
                if (this.selectedElement?.type === 'text') { // 仅文本元素生效
                    this.selectedElement.align = e.target.value;
                    this.render(); // 重新渲染画布
                }
            });

            // 文本超出处理下拉框变化 → 更新超出逻辑(换行/省略)
            document.getElementById('text-overflow').addEventListener('change', (e) => {
                if (this.selectedElement?.type === 'text') { // 仅文本元素生效
                    this.selectedElement.overflow = e.target.value;
                    this.render(); // 重新渲染画布
                }
            });

            // -------------------------- 图片元素专属事件 --------------------------
            // 图片URL输入框变化 → 更新图片地址
            document.getElementById('image-url').addEventListener('input', (e) => {
                if (this.selectedElement?.type === 'image') { // 仅图片元素生效
                    this.selectedElement.src = e.target.value;
                    this.updateUI(); // 更新 UI
                }
            });

            // 图片边框颜色选择器变化 → 更新边框颜色
            document.getElementById('border-color-input').addEventListener('input', (e) => {
                if (this.selectedElement?.type === 'image') { // 仅图片元素生效
                    this.selectedElement.borderColor = e.target.value;
                    document.getElementById('border-color').style.backgroundColor = e.target.value;
                    this.render(); // 重新渲染画布
                }
            });

            // 图片圆角(滑块+输入框)双向绑定 → 更新圆角半径
            this.createTwoWayBinding('image-border-radius-slider', 'image-border-radius-value', (value) => {
                if (this.selectedElement?.type === 'image') { // 仅图片元素生效
                    this.selectedElement.borderRadius = value;
                    this.render(); // 重新渲染画布
                }
            });

            // -------------------------- 二维码元素专属事件 --------------------------
            // 二维码内容输入框变化 → 更新二维码内容
            document.getElementById('qrcode-url').addEventListener('input', (e) => {
                if (this.selectedElement?.type === 'qrcode') { // 仅二维码元素生效
                    this.selectedElement.text = e.target.value;
                    this.updateUI(); // 更新 UI
                }
            });

            // -------------------------- 容器背景色(通用)事件 --------------------------
            // 容器背景色选择器变化 → 更新元素容器背景
            document.getElementById('container-bg-color-input').addEventListener('input', (e) => {
                if (this.selectedElement) {
                    this.selectedElement.containerBackgroundColor = e.target.value;
                    document.getElementById('container-bg-color').style.backgroundColor = e.target.value;
                    this.render(); // 重新渲染画布
                }
            });

            // -------------------------- 画布/元素拖拽/缩放事件 --------------------------
            // 画布点击事件 → 选中元素/取消选中
            this.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
            // 鼠标移动事件 → 拖拽元素/缩放元素/缩放画布
            document.addEventListener('mousemove', this.handleMouseMove.bind(this));
            // 鼠标抬起事件 → 结束拖拽/缩放
            document.addEventListener('mouseup', this.handleMouseUp.bind(this));
            // 鼠标离开页面事件 → 结束拖拽/缩放(避免异常状态)
            document.addEventListener('mouseleave', this.handleMouseUp.bind(this));

            // 画布缩放手柄点击事件 → 开始缩放画布
            document.getElementById('canvas-resize-handle').addEventListener('mousedown', (e) => {
                e.stopPropagation(); // 阻止事件冒泡(避免触发画布点击)
                this.isCanvasResizing = true;
                document.getElementById('element-overlay').classList.add('hidden'); // 隐藏元素选中框
            });

            // -------------------------- 左侧组件库事件 --------------------------
            // 组件库元素点击事件 → 添加对应类型的元素(文本/图片/二维码)
            document.querySelectorAll('.component-item').forEach(item => {
                item.addEventListener('click', () => this.addElement(item.getAttribute('data-type')));
            });

            // -------------------------- 保存/删除/层级事件 --------------------------
            // 保存 JSON 按钮点击事件 → 调用保存方法
            this.saveBtn.addEventListener('click', () => this.saveJSON());

            // 元素置顶按钮 → 提升元素层级到最高
            document.getElementById('btn-bring-to-front').addEventListener('click', () => this.bringToFront());
            // 元素上移按钮 → 提升元素层级
            document.getElementById('btn-move-up').addEventListener('click', () => this.moveUp());
            // 元素下移按钮 → 降低元素层级
            document.getElementById('btn-move-down').addEventListener('click', () => this.moveDown());
            // 元素置底按钮 → 降低元素层级到最低
            document.getElementById('btn-send-to-back').addEventListener('click', () => this.sendToBack());

            // 删除元素按钮 → 删除当前选中元素
            document.getElementById('delete-element-btn').addEventListener('click', () => {
                if (this.selectedElement) {
                    this.deleteElement(this.selectedElement.id);
                }
            });

            // 键盘 Delete/Backspace 键 → 删除当前选中元素(快捷键)
            document.addEventListener('keydown', (e) => {
                if (e.key === 'Delete' || e.key === 'Backspace') {
                    if (this.selectedElement) {
                        this.deleteElement(this.selectedElement.id);
                    }
                }
            });

            // -------------------------- 元素层级面板缩放事件 --------------------------
            // 层级面板缩放手柄 → 调整面板宽度
            const panelResizeHandle = document.getElementById('panel-resize-handle');
            panelResizeHandle.addEventListener('mousedown', (e) => {
                e.stopPropagation(); // 阻止事件冒泡
                const startX = e.clientX;
                const startWidth = this.elementListPanel.offsetWidth;

                // 鼠标移动 → 计算新宽度并更新
                const handleMouseMove = (e) => {
                    const diffX = e.clientX - startX;
                    let newWidth = startWidth + diffX;
                    const minWidth = 150; // 面板最小宽度
                    if (newWidth < minWidth) newWidth = minWidth;
                    this.elementListPanel.style.width = `${newWidth}px`;
                };

                // 鼠标抬起 → 移除事件监听
                const handleMouseUp = () => {
                    document.removeEventListener('mousemove', handleMouseMove);
                    document.removeEventListener('mouseup', handleMouseUp);
                };

                document.addEventListener('mousemove', handleMouseMove);
                document.addEventListener('mouseup', handleMouseUp);
            });

            // 窗口 resize 事件 → 调整层级面板位置(避免超出窗口)
            window.addEventListener('resize', () => {
                this.adjustElementListPanelPosition();
            });
        }

        /**
         * 初始化侧边栏折叠功能
         */
        initSidebarToggle() {
            this.sidebarToggle.addEventListener('click', () => {
                this.sidebar.classList.toggle('collapsed'); // 切换折叠类
                // 切换按钮文本(折叠→箭头,展开→三条杠)
                this.sidebarToggle.textContent = this.sidebar.classList.contains('collapsed') ? '→' : '≡';
            });
        }

        /**
         * 初始化元素层级面板拖拽功能
         */
        initPanelDrag() {
            // 面板标题点击 → 开始拖拽
            this.panelTitle.addEventListener('mousedown', (e) => {
                e.stopPropagation(); // 阻止事件冒泡
                this.isDraggingPanel = true;
                const panelRect = this.elementListPanel.getBoundingClientRect(); // 获取面板位置信息
                // 计算鼠标相对于面板左上角的偏移量
                this.panelOffset.x = e.clientX - panelRect.left;
                this.panelOffset.y = e.clientY - panelRect.top;
                this.elementListPanel.classList.add('dragging'); // 添加拖拽样式(半透明+阴影)
                document.body.style.cursor = 'move'; // 鼠标样式改为移动指针
            });

            // 鼠标移动 → 更新面板位置
            document.addEventListener('mousemove', (e) => {
                if (!this.isDraggingPanel) return; // 未拖拽则返回

                let newLeft = e.clientX - this.panelOffset.x;
                let newTop = e.clientY - this.panelOffset.y;
                const panelWidth = this.elementListPanel.offsetWidth;
                const panelHeight = this.elementListPanel.offsetHeight;
                const screenWidth = window.innerWidth;
                const screenHeight = window.innerHeight;

                // 限制面板不超出窗口边界
                newLeft = Math.max(0, Math.min(newLeft, screenWidth - panelWidth));
                newTop = Math.max(0, Math.min(newTop, screenHeight - panelHeight));

                // 更新面板位置(取消 right 定位,改用 left+top)
                this.elementListPanel.style.left = `${newLeft}px`;
                this.elementListPanel.style.top = `${newTop}px`;
                this.elementListPanel.style.right = 'auto';
            });

            // 鼠标抬起 → 结束拖拽
            document.addEventListener('mouseup', () => {
                if (this.isDraggingPanel) {
                    this.isDraggingPanel = false;
                    this.elementListPanel.classList.remove('dragging'); // 移除拖拽样式
                    document.body.style.cursor = ''; // 恢复默认鼠标样式
                }
            });

            // 面板内部点击(非标题区域)→ 取消拖拽状态
            this.elementListPanel.addEventListener('mousedown', (e) => {
                if (e.target !== this.panelTitle && !this.panelTitle.contains(e.target)) {
                    this.isDraggingPanel = false;
                }
            });
        }

        /**
         * 初始化画布拖拽功能(通过左上角手柄拖拽)
         */
        initCanvasDrag() {
            // 画布拖拽手柄点击 → 开始拖拽
            this.canvasDragHandle.addEventListener('mousedown', (e) => {
                e.stopPropagation(); // 阻止事件冒泡
                this.isDraggingCanvas = true;
                const containerRect = this.canvasContainer.getBoundingClientRect(); // 获取容器位置
                const editorRect = document.querySelector('.editor-area').getBoundingClientRect(); // 获取编辑区位置
                // 计算鼠标相对于容器左上角的偏移量
                this.canvasOffset.x = e.clientX - containerRect.left;
                this.canvasOffset.y = e.clientY - containerRect.top;
                this.canvasContainer.style.cursor = 'grabbing'; // 鼠标样式改为抓取
                this.canvasDragHandle.style.cursor = 'grabbing';
                this.canvasContainer.style.zIndex = '100'; // 提升层级,避免被覆盖
            });

            // 鼠标移动 → 更新画布位置
            document.addEventListener('mousemove', (e) => {
                if (!this.isDraggingCanvas) return; // 未拖拽则返回

                const editorRect = document.querySelector('.editor-area').getBoundingClientRect();
                let newLeft = e.clientX - this.canvasOffset.x - editorRect.left;
                let newTop = e.clientY - this.canvasOffset.y - editorRect.top;
                const containerWidth = this.canvasContainer.offsetWidth;
                const containerHeight = this.canvasContainer.offsetHeight;
                const editorWidth = editorRect.width;
                const editorHeight = editorRect.height;

                // 限制画布不超出编辑区边界(底部留 50px 空白,避免遮挡保存按钮)
                newLeft = Math.max(0, Math.min(newLeft, editorWidth - containerWidth));
                newTop = Math.max(0, Math.min(newTop, editorHeight - containerHeight - 50));

                // 更新画布容器位置(改为绝对定位)
                this.canvasContainer.style.position = 'absolute';
                this.canvasContainer.style.left = `${newLeft}px`;
                this.canvasContainer.style.top = `${newTop}px`;
            });

            // 鼠标抬起 → 结束拖拽
            document.addEventListener('mouseup', () => {
                if (this.isDraggingCanvas) {
                    this.isDraggingCanvas = false;
                    this.canvasContainer.style.cursor = 'move'; // 恢复移动鼠标样式
                    this.canvasDragHandle.style.cursor = 'move';
                    this.canvasContainer.style.zIndex = '1'; // 恢复默认层级
                }
            });

            // 画布内部点击(非手柄区域)→ 取消拖拽状态
            this.canvas.addEventListener('mousedown', (e) => {
                if (!this.canvasDragHandle.contains(e.target)) {
                    this.isDraggingCanvas = false;
                }
            });
        }

        /**
         * 调整元素层级面板位置(窗口 resize 时避免超出窗口)
         */
        adjustElementListPanelPosition() {
            if (this.isDraggingPanel) return; // 拖拽中不调整

            const propertiesPanelRect = this.propertiesPanel.getBoundingClientRect(); // 右侧属性面板位置
            const panelWidth = this.elementListPanel.offsetWidth;
            // 面板左边界 = 属性面板左边界 - 面板宽度 - 20px(间距)
            const newLeft = propertiesPanelRect.left - panelWidth - 20;
            // 确保面板不超出左边界
            this.elementListPanel.style.left = `${Math.max(0, newLeft)}px`;
            this.elementListPanel.style.right = 'auto';
        }

        /**
         * 渲染元素层级列表(同步 elements 数组和 DOM 列表)
         */
        renderElementList() {
            this.elementList.innerHTML = ''; // 清空现有列表

            // 遍历所有元素,生成列表项
            this.elements.forEach((element, index) => {
                const item = document.createElement('div');
                // 列表项样式(选中状态添加 selected 类)
                item.className = `element-list-item ${this.selectedElement?.id === element.id ? 'selected' : ''}`;
                item.draggable = true; // 支持拖拽排序
                item.dataset.id = element.id; // 存储元素 ID(用于后续操作)
                item.dataset.index = index; // 存储元素索引

                // 1. 元素类型图标(T=文本,I=图片,Q=二维码)
                const icon = document.createElement('div');
                icon.className = `element-icon ${element.type}-icon`;
                icon.textContent = element.type === 'text' ? 'T' : element.type === 'image' ? 'I' : 'Q';

                // 2. 元素名称(显示类型+内容/URL,超出隐藏)
                const name = document.createElement('div');
                name.className = 'element-name';
                const displayText = `${element.type === 'text' ? '文本' : element.type === 'image' ? '图片' : '二维码'}: ${element.text || element.src || '未命名'}`;
                name.textContent = displayText;

                // 3. 元素层级标识(数字越小层级越高)
                const layerBadge = document.createElement('div');
                layerBadge.className = 'layer-badge';
                layerBadge.textContent = element.layer;

                // 4. 删除按钮
                const deleteBtn = document.createElement('div');
                deleteBtn.className = 'delete-btn';
                deleteBtn.textContent = '×';
                deleteBtn.title = '删除元素';
                deleteBtn.addEventListener('click', (e) => {
                    e.stopPropagation(); // 阻止事件冒泡(避免触发列表项点击)
                    this.deleteElement(element.id);
                });

                // 5. 拖拽手柄
                const handle = document.createElement('div');
                handle.className = 'drag-handle';
                handle.innerHTML = '⋮⋮'; // 拖拽图标

                // 组装列表项
                item.append(icon, name, layerBadge, deleteBtn, handle);

                // 列表项拖拽事件(排序用)
                item.addEventListener('dragstart', (e) => this.handleDragStart(e, index));
                item.addEventListener('dragover', (e) => e.preventDefault()); // 允许拖拽放置
                item.addEventListener('drop', (e) => this.handleDrop(e, index)); // 放置时触发排序
                // 列表项点击 → 选中对应元素
                item.addEventListener('click', () => this.handleElementItemClick(element.id));

                this.elementList.appendChild(item);
            });
        }

        /**
         * 元素层级列表拖拽开始 → 记录拖拽元素索引
         * @param {Event} e - 拖拽事件对象
         * @param {number} startIndex - 拖拽元素的初始索引
         */
        handleDragStart(e, startIndex) {
            this.draggingIndex = startIndex;
            e.target.classList.add('dragging'); // 添加拖拽样式
        }

        /**
         * 元素层级列表拖拽放置 → 调整元素顺序(更新层级)
         * @param {Event} e - 放置事件对象
         * @param {number} targetIndex - 放置目标的索引
         */
        handleDrop(e, targetIndex) {
            e.preventDefault(); // 阻止默认行为
            const draggingItem = this.elementList.querySelector('.dragging');
            // 校验:无拖拽项/拖拽索引无效/拖拽到自身位置 → 直接返回
            if (!draggingItem || this.draggingIndex === -1 || this.draggingIndex === targetIndex) {
                if (draggingItem) draggingItem.classList.remove('dragging');
                return;
            }

            // 调整元素数组顺序(从拖拽索引移除,插入到目标索引)
            const [movedElement] = this.elements.splice(this.draggingIndex, 1);
            this.elements.splice(targetIndex, 0, movedElement);
            this.reassignLayers(); // 重新分配层级编号
            this.reassignNames(); // 重新分配元素默认名称(如 text_1 → text_2)
            draggingItem.classList.remove('dragging'); // 移除拖拽样式
            this.updateUI(); // 更新 UI
            this.draggingIndex = -1; // 重置拖拽索引
        }

        /**
         * 重新分配元素层级编号(数组顺序决定层级,索引 0 层级最高为 1)
         */
        reassignLayers() {
            this.elements.forEach((elem, index) => {
                elem.layer = index + 1; // 索引 0 → 层级 1(最高),索引 1 → 层级 2,以此类推
            });
        }

        /**
         * 重新分配元素默认名称(根据类型和数组索引,如 text_1、image_2)
         */
        reassignNames() {
            this.elements.forEach((element, index) => {
                const newNumber = index + 1;
                switch (element.type) {
                    case 'text':
                        element.text = `文字{text_${newNumber}}`;
                        break;
                    case 'image':
                        element.src = `{image_${newNumber}}`;
                        break;
                    case 'qrcode':
                        element.text = `{qr_${newNumber}}`;
                        break;
                }
            });
            this.globalCounter = this.elements.length + 1; // 更新计数器(下一个元素用)
        }

        /**
         * 元素层级列表项点击 → 选中对应元素
         * @param {string} elementId - 元素 ID
         */
        handleElementItemClick(elementId) {
            const element = this.elements.find(el => el.id === elementId);
            if (element) this.selectElement(element); // 选中元素
        }

        /**
         * 更新层级控制 UI(禁用/启用上下移按钮,显示当前层级/总层级)
         */
        updateLayerUI() {
            if (!this.selectedElement) {
                document.getElementById('layer-control').style.display = 'none'; // 无选中元素则隐藏
                return;
            }
            document.getElementById('layer-control').style.display = 'block'; // 显示层级控制

            // 显示当前层级和总层级
            document.getElementById('current-layer').textContent = this.selectedElement.layer;
            document.getElementById('total-layers').textContent = this.elements.length;

            // 禁用/启用按钮(层级 1 则无法置顶/上移,层级等于总长度则无法置底/下移)
            document.getElementById('btn-bring-to-front').disabled = this.selectedElement.layer === 1;
            document.getElementById('btn-move-up').disabled = this.selectedElement.layer === 1;
            document.getElementById('btn-move-down').disabled = this.selectedElement.layer === this.elements.length;
            document.getElementById('btn-send-to-back').disabled = this.selectedElement.layer === this.elements.length;
        }

        /**
         * 元素置顶 → 层级设为 1(最高)
         */
        bringToFront() {
            if (!this.selectedElement || this.elements.length <= 1 || this.selectedElement.layer === 1) return;
            this.selectedElement.layer = 1;
            this.sortElementsByLayer(); // 按层级排序
            this.updateUI(); // 更新 UI
        }

        /**
         * 元素置底 → 层级设为总元素数(最低)
         */
        sendToBack() {
            if (!this.selectedElement || this.elements.length <= 1 || this.selectedElement.layer === this.elements.length) return;
            this.selectedElement.layer = this.elements.length;
            this.sortElementsByLayer(); // 按层级排序
            this.updateUI(); // 更新 UI
        }

        /**
         * 元素上移 → 层级减 1
         */
        moveUp() {
            if (!this.selectedElement || this.elements.length <= 1 || this.selectedElement.layer <= 1) return;
            this.selectedElement.layer--;
            this.sortElementsByLayer(); // 按层级排序
            this.updateUI(); // 更新 UI
        }

        /**
         * 元素下移 → 层级加 1
         */
        moveDown() {
            if (!this.selectedElement || this.elements.length <= 1 || this.selectedElement.layer >= this.elements.length) return;
            this.selectedElement.layer++;
            this.sortElementsByLayer(); // 按层级排序
            this.updateUI(); // 更新 UI
        }

        /**
         * 按层级排序元素数组(层级 1 在前,层级越高越靠后)
         */
        sortElementsByLayer() {
            this.elements.sort((a, b) => a.layer - b.layer);
        }

        /**
         * 添加新元素(文本/图片/二维码)
         * @param {string} type - 元素类型(text/image/qrcode)
         */
        addElement(type) {
            // 计算新元素的层级(现有最高层级 + 1)
            const newLayer = this.elements.length > 0 ? Math.max(...this.elements.map(e => e.layer)) + 1 : 1;
            // 基础元素配置(所有元素通用属性)
            const element = {
                id: Date.now().toString(), // 用时间戳作为唯一 ID
                type,
                x: 50, // 默认 X 坐标
                y: 50, // 默认 Y 坐标
                width: 100, // 默认宽度
                height: 100, // 默认高度
                layer: newLayer,
                containerBackgroundColor: this.defaultContainerColors[type] // 默认容器颜色
            };

            // 根据类型添加专属属性
            switch (type) {
                case 'text':
                    element.text = `文字{text_${this.globalCounter}}`; // 默认文本内容
                    element.color = '#000000'; // 默认文本颜色(黑色)
                    element.fontSize = 16; // 默认字体大小
                    element.align = 'left'; // 默认对齐方式(左对齐)
                    element.overflow = 'wrap'; // 默认超出处理(自动换行)
                    break;
                case 'image':
                    element.src = `{image_${this.globalCounter}}`; // 默认图片 URL 占位符
                    element.borderColor = 'rgba(255,255,255,0)'; // 默认边框颜色(透明)
                    element.borderRadius = 0; // 默认圆角(0px)
                    break;
                case 'qrcode':
                    element.text = `{qr_${this.globalCounter}}`; // 默认二维码内容
                    break;
            }

            this.elements.push(element); // 添加到元素数组
            this.globalCounter++; // 计数器自增(下一个元素用)
            this.sortElementsByLayer(); // 按层级排序
            this.selectElement(element); // 自动选中新添加的元素
            this.updateUI(); // 更新 UI
        }

        /**
         * 选中元素(更新 selectedElement 并同步 UI)
         * @param {object} element - 要选中的元素
         */
        selectElement(element) {
            this.selectedElement = element;
            this.updateUI(); // 更新 UI(属性面板、元素选中框等)
        }

        /**
         * 更新右侧属性面板(根据选中元素类型显示/隐藏专属属性)
         */
        updatePropertiesPanel() {
            const panel = document.getElementById('element-properties');
            if (!this.selectedElement) {
                panel.style.display = 'none'; // 无选中元素则隐藏面板
                return;
            }
            panel.style.display = 'block'; // 显示面板

            // 1. 更新通用属性(坐标、宽高)
            document.getElementById('element-x').value = this.selectedElement.x || 0;
            document.getElementById('element-y').value = this.selectedElement.y || 0;
            document.getElementById('element-width-slider').value = this.selectedElement.width || 100;
            document.getElementById('element-width-value').value = this.selectedElement.width || 100;
            document.getElementById('element-height-slider').value = this.selectedElement.height || 100;
            document.getElementById('element-height-value').value = this.selectedElement.height || 100;

            // 2. 隐藏所有属性项(后续按需显示)
            document.querySelectorAll('[id$="-item"]').forEach(item => item.style.display = 'none');
            document.getElementById('delete-element-item').style.display = 'block'; // 显示删除按钮

            // 3. 根据元素类型显示专属属性项
            if (this.selectedElement.type === 'text') {
                // 文本元素专属属性:内容、颜色、字体大小、对齐方式、超出处理、容器背景
                ['text-content-item', 'text-color-item', 'text-font-size-item', 'text-align-item', 'text-overflow-item', 'container-bg-color-item'].forEach(id => {
                    document.getElementById(id).style.display = 'block';
                });
                // 同步文本属性值到 UI
                document.getElementById('element-text').value = this.selectedElement.text || '';
                document.getElementById('text-color-input').value = this.selectedElement.color || '#000000';
                document.getElementById('text-color').style.backgroundColor = this.selectedElement.color || '#000000';
                document.getElementById('text-font-size-slider').value = this.selectedElement.fontSize || 16;
                document.getElementById('text-font-size-value').value = this.selectedElement.fontSize || 16;
                document.getElementById('text-align').value = this.selectedElement.align || 'left';
                document.getElementById('text-overflow').value = this.selectedElement.overflow || 'wrap';
                // 同步容器背景色
                document.getElementById('container-bg-color-input').value = this.selectedElement.containerBackgroundColor || this.defaultContainerColors.text;
                document.getElementById('container-bg-color').style.backgroundColor = this.selectedElement.containerBackgroundColor || this.defaultContainerColors.text;
            } else if (this.selectedElement.type === 'image') {
                // 图片元素专属属性:URL、边框颜色、容器背景、圆角
                ['image-url-item', 'border-color-item', 'container-bg-color-item', 'image-border-radius-item'].forEach(id => {
                    document.getElementById(id).style.display = 'block';
                });
                // 同步图片属性值到 UI
                document.getElementById('image-url').value = this.selectedElement.src || '';
                document.getElementById('border-color-input').value = this.selectedElement.borderColor || 'rgba(255,255,255,0)';
                document.getElementById('border-color').style.backgroundColor = this.selectedElement.borderColor || 'rgba(255,255,255,0)';
                // 同步容器背景色
                document.getElementById('container-bg-color-input').value = this.selectedElement.containerBackgroundColor || this.defaultContainerColors.image;
                document.getElementById('container-bg-color').style.backgroundColor = this.selectedElement.containerBackgroundColor || this.defaultContainerColors.image;
                // 同步圆角
                document.getElementById('image-border-radius-slider').value = this.selectedElement.borderRadius || 0;
                document.getElementById('image-border-radius-value').value = this.selectedElement.borderRadius || 0;
            } else if (this.selectedElement.type === 'qrcode') {
                // 二维码元素专属属性:内容、容器背景
                ['qrcode-url-item', 'container-bg-color-item'].forEach(id => {
                    document.getElementById(id).style.display = 'block';
                });
                // 同步二维码属性值到 UI
                document.getElementById('qrcode-url').value = this.selectedElement.text || '';
                // 同步容器背景色
                document.getElementById('container-bg-color-input').value = this.selectedElement.containerBackgroundColor || this.defaultContainerColors.qrcode;
                document.getElementById('container-bg-color').style.backgroundColor = this.selectedElement.containerBackgroundColor || this.defaultContainerColors.qrcode;
            }

            this.updateLayerUI(); // 更新层级控制 UI
        }

        /**
         * 更新元素选中框(覆盖层,显示缩放手柄)
         */
        updateElementOverlay() {
            const overlay = document.getElementById('element-overlay');
            if (!this.selectedElement || this.isCanvasResizing) {
                overlay.classList.add('hidden'); // 无选中元素/画布缩放时隐藏
                return;
            }

            // 1. 设置选中框位置和大小(与元素一致)
            overlay.style.cssText = `left: ${this.selectedElement.x}px; top: ${this.selectedElement.y}px; width: ${this.selectedElement.width}px; height: ${this.selectedElement.height}px;`;
            overlay.classList.remove('hidden'); // 显示选中框

            // 2. 移除现有缩放手柄(避免重复)
            overlay.querySelectorAll('.resize-handle').forEach(handle => handle.remove());

            // 3. 添加四个方向的缩放手柄(nw/ne/sw/se)
            ['nw', 'ne', 'sw', 'se'].forEach(dir => {
                const handle = document.createElement('div');
                handle.className = `resize-handle resize-${dir}`;
                // 手柄点击 → 开始缩放元素
                handle.addEventListener('mousedown', (e) => {
                    e.stopPropagation(); // 阻止事件冒泡
                    this.isResizing = true;
                    this.resizeDirection = dir; // 记录缩放方向
                });
                overlay.appendChild(handle);
            });
        }

        /**
         * 画布点击事件处理 → 选中元素/取消选中
         * @param {Event} e - 鼠标点击事件
         */
        handleMouseDown(e) {
            // 过滤:画布缩放/面板拖拽/画布拖拽时不处理
            if (this.isCanvasResizing || this.isDraggingPanel || this.isDraggingCanvas) return;

            const rect = this.canvas.getBoundingClientRect();
            // 计算鼠标在画布坐标系中的位置(相对于画布左上角)
            const x = e.clientX - rect.left;
            const y = e.clientY - rect.top;

            // 点击缩放手柄则不处理(交给缩放逻辑)
            if (this.selectedElement && Array.from(document.querySelectorAll('.resize-handle')).some(h => h.contains(e.target))) {
                return;
            }

            // 查找点击位置的元素(倒序遍历,优先选中上层元素)
            const clickedElement = [...this.elements].reverse().find(el => this.isPointInElement(x, y, el));

            if (clickedElement) {
                this.selectElement(clickedElement); // 选中点击的元素
                this.isDragging = true; // 标记为拖拽状态
                // 计算拖拽偏移量(鼠标相对于元素左上角的位置)
                this.dragOffset.x = x - clickedElement.x;
                this.dragOffset.y = y - clickedElement.y;
            } else {
                this.selectedElement = null; // 未点击到元素 → 取消选中
                this.updateUI(); // 更新 UI
            }
        }

        /**
         * 鼠标移动事件处理 → 拖拽元素/缩放元素/缩放画布
         * @param {Event} e - 鼠标移动事件
         */
        handleMouseMove(e) {
            // 1. 画布缩放逻辑
            if (this.isCanvasResizing) {
                const rect = this.canvas.getBoundingClientRect();
                const newWidth = e.clientX - rect.left; // 新宽度 = 鼠标X - 画布左边界
                const newHeight = e.clientY - rect.top; // 新高度 = 鼠标Y - 画布上边界
                // 限制画布大小不小于最小值
                if (newWidth > this.canvasMinWidth && newHeight > this.canvasMinHeight) {
                    this.canvas.width = newWidth;
                    this.canvas.height = newHeight;
                    // 同步更新输入框值
                    document.getElementById('canvas-width').value = newWidth;
                    document.getElementById('canvas-height').value = newHeight;
                    this.render(); // 重新渲染画布
                }
                return;
            }

            // 2. 未拖拽/缩放则返回
            if (!this.isDragging && !this.isResizing) return;

            const rect = this.canvas.getBoundingClientRect();
            // 计算鼠标在画布坐标系中的位置
            const x = e.clientX - rect.left;
            const y = e.clientY - rect.top;

            // 3. 元素拖拽逻辑
            if (this.isDragging && this.selectedElement) {
                // 元素新坐标 = 鼠标位置 - 拖拽偏移量(避免点击时跳动)
                this.selectedElement.x = x - this.dragOffset.x;
                this.selectedElement.y = y - this.dragOffset.y;
                // 同步更新输入框值
                document.getElementById('element-x').value = this.selectedElement.x;
                document.getElementById('element-y').value = this.selectedElement.y;
                this.updateElementOverlay(); // 更新选中框
                this.render(); // 重新渲染画布
            }
            // 4. 元素缩放逻辑
            else if (this.isResizing && this.selectedElement) {
                const minSize = 10; // 元素最小宽高
                let newX = this.selectedElement.x, newY = this.selectedElement.y;
                let newWidth = this.selectedElement.width, newHeight = this.selectedElement.height;

                // 根据缩放方向计算新坐标和宽高
                switch (this.resizeDirection) {
                    case 'nw': // 左上 → 同时调整X、Y、宽度、高度
                        newX = x;
                        newY = y;
                        newWidth = this.selectedElement.x + this.selectedElement.width - x;
                        newHeight = this.selectedElement.y + this.selectedElement.height - y;
                        break;
                    case 'ne': // 右上 → 调整Y、宽度、高度
                        newY = y;
                        newWidth = x - this.selectedElement.x;
                        newHeight = this.selectedElement.y + this.selectedElement.height - y;
                        break;
                    case 'sw': // 左下 → 调整X、宽度、高度
                        newX = x;
                        newWidth = this.selectedElement.x + this.selectedElement.width - x;
                        newHeight = y - this.selectedElement.y;
                        break;
                    case 'se': // 右下 → 仅调整宽度、高度
                        newWidth = x - this.selectedElement.x;
                        newHeight = y - this.selectedElement.y;
                        break;
                }

                // 限制元素大小不小于最小值
                if (newWidth >= minSize && newHeight >= minSize) {
                    // 更新元素属性
                    Object.assign(this.selectedElement, {x: newX, y: newY, width: newWidth, height: newHeight});
                    // 同步更新输入框/滑块值
                    document.getElementById('element-x').value = newX;
                    document.getElementById('element-y').value = newY;
                    document.getElementById('element-width-slider').value = newWidth;
                    document.getElementById('element-width-value').value = newWidth;
                    document.getElementById('element-height-slider').value = newHeight;
                    document.getElementById('element-height-value').value = newHeight;
                    this.updateElementOverlay(); // 更新选中框
                    this.render(); // 重新渲染画布
                }
            }
        }

        /**
         * 鼠标抬起/离开事件处理 → 结束拖拽/缩放
         */
        handleMouseUp() {
            // 重置所有拖拽/缩放状态
            this.isDragging = this.isResizing = this.isCanvasResizing = false;
            this.resizeDirection = this.draggingIndex = -1; // 重置方向和拖拽索引
            this.updateElementOverlay(); // 更新选中框(恢复正常状态)
            this.updateJSONPreview(); // 更新 JSON 预览(同步最新数据)
        }

        /**
         * 判断点是否在元素内(用于点击选中元素)
         * @param {number} x - 点的 X 坐标(画布坐标系)
         * @param {number} y - 点的 Y 坐标(画布坐标系)
         * @param {object} element - 要判断的元素
         * @returns {boolean} 是否在元素内
         */
        isPointInElement(x, y, element) {
            // 元素矩形区域:x ≤ 点X ≤ x+宽度,y ≤ 点Y ≤ y+高度
            return x >= element.x && x <= element.x + element.width && y >= element.y && y <= element.y + element.height;
        }

        /**
         * 核心渲染方法 → 绘制画布背景和所有元素
         */
        render() {
            // 1. 清空画布(避免残影)
            this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);

            // 2. 绘制画布背景(从颜色选择器获取)
            const bgColor = document.getElementById('canvas-bg-color-input').value;
            this.ctx.fillStyle = bgColor;
            this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);

            // 3. 绘制所有元素(倒序遍历,上层元素后绘制,覆盖下层)
            [...this.elements].reverse().forEach(element => {
                // 绘制元素容器背景(区分元素类型的默认颜色)
                const containerBg = element.containerBackgroundColor || this.defaultContainerColors[element.type];
                this.ctx.fillStyle = containerBg;
                this.ctx.beginPath();

                // 图片元素支持圆角容器
                if (element.type === 'image' && element.borderRadius > 0) {
                    this.ctx.roundRect(element.x, element.y, element.width, element.height, element.borderRadius);
                } else {
                    this.ctx.rect(element.x, element.y, element.width, element.height); // 矩形容器
                }
                this.ctx.fill(); // 填充容器背景

                // 图片元素绘制边框
                if (element.type === 'image') {
                    const borderColor = element.borderColor || 'rgba(255,255,255,0)';
                    this.ctx.strokeStyle = borderColor;
                    this.ctx.lineWidth = 1;
                    this.ctx.stroke(); // 绘制边框
                }

                // 绘制元素内容(文本/图片/二维码)
                this.renderElement(element);
            });
        }

        /**
         * 绘制单个元素内容(根据类型调用不同绘制方法)
         * @param {object} element - 要绘制的元素
         */
        renderElement(element) {
            switch (element.type) {
                case 'text':
                    this.renderTextElement(element); // 绘制文本
                    break;
                case 'image':
                    this.renderImageElement(element); // 绘制图片
                    break;
                case 'qrcode':
                    this.renderQrCodeElement(element); // 绘制二维码
                    break;
            }
        }

        /**
         * 绘制文本元素(支持换行、对齐、超出省略)
         * @param {object} element - 文本元素
         */
        renderTextElement(element) {
            this.ctx.fillStyle = element.color || '#000000'; // 文本颜色
            this.ctx.font = `${element.fontSize || 16}px Arial`; // 字体大小和字体族
            const text = element.text || '';
            const maxWidth = element.width; // 文本最大宽度(元素宽度)
            const lineHeight = (element.fontSize || 16) * 1.2; // 行高(字体大小的 1.2 倍)
            const x = element.x; // 文本起始 X 坐标
            const y = element.y + (element.fontSize || 16); // 文本起始 Y 坐标(向下偏移一个字体大小)

            // 处理文本超出:换行/省略
            let lines = element.overflow === 'wrap' ? this.wrapText(text, maxWidth) : [this.getEllipsisText(text, maxWidth)];
            const totalHeight = lines.length * lineHeight; // 文本总高度

            // 绘制每一行文本
            lines.forEach((line, index) => {
                let drawX = x; // 每行文本的 X 坐标
                // 根据对齐方式调整 X 坐标
                if (element.align === 'center') {
                    // 居中:X = 元素X + (元素宽度 - 该行文本宽度)/2
                    drawX = x + (maxWidth - this.ctx.measureText(line).width) / 2;
                } else if (element.align === 'right') {
                    // 右对齐:X = 元素X + 元素宽度 - 该行文本宽度
                    drawX = x + maxWidth - this.ctx.measureText(line).width;
                }

                // 垂直居中:Y = 起始Y - (总高度 - 行高)/2 + 行索引*行高
                const drawY = y - (totalHeight - lineHeight) / 2 + index * lineHeight;
                this.ctx.fillText(line, drawX, drawY); // 绘制文本
            });
        }

        /**
         * 文本换行处理(按空格分割,避免单词截断)
         * @param {string} text - 要处理的文本
         * @param {number} maxWidth - 最大宽度(元素宽度)
         * @returns {array} 换行后的文本行数组
         */
        wrapText(text, maxWidth) {
            // 按空格分割单词
            return text.split(' ').reduce((acc, word) => {
                const lastLine = acc[acc.length - 1] || ''; // 最后一行文本
                const testLine = lastLine ? `${lastLine} ${word}` : word; // 测试添加当前单词后的行

                // 如果添加后宽度不超过最大宽度 → 更新最后一行
                if (this.ctx.measureText(testLine).width <= maxWidth && lastLine) {
                    acc[acc.length - 1] = testLine;
                } else {
                    acc.push(word); // 超过则新增一行
                }
                return acc;
            }, []);
        }

        /**
         * 文本超出省略处理(末尾添加 ...)
         * @param {string} text - 要处理的文本
         * @param {number} maxWidth - 最大宽度(元素宽度)
         * @returns {string} 省略后的文本
         */
        getEllipsisText(text, maxWidth) {
            // 文本宽度未超出则直接返回
            if (this.ctx.measureText(text).width <= maxWidth) return text;

            let truncated = text;
            // 逐字删除,直到添加 ... 后宽度不超出
            while (this.ctx.measureText(truncated + '...').width > maxWidth && truncated.length > 0) {
                truncated = truncated.slice(0, -1);
            }
            return truncated + '...'; // 返回省略后的文本
        }

        /**
         * 绘制图片元素(支持网络图片、占位符、圆角裁剪)
         * @param {object} element - 图片元素
         */
        renderImageElement(element) {
            // 网络图片(以 http/https 开头)
            if (element.src && element.src.startsWith('http')) {
                const img = new Image();
                img.crossOrigin = 'anonymous'; // 解决跨域图片绘制问题
                const renderProps = {...element}; // 保存当前元素属性(避免异步绘制时属性变化)

                // 图片加载成功 → 绘制图片(支持圆角裁剪)
                img.onload = () => {
                    this.ctx.save(); // 保存当前绘图状态
                    // 圆角裁剪(圆角半径 > 0 时)
                    if (renderProps.borderRadius > 0) {
                        this.ctx.beginPath();
                        this.ctx.roundRect(renderProps.x, renderProps.y, renderProps.width, renderProps.height, renderProps.borderRadius);
                        this.ctx.clip(); // 裁剪成圆角
                    }
                    // 绘制图片(铺满元素区域)
                    this.ctx.drawImage(img, renderProps.x, renderProps.y, renderProps.width, renderProps.height);
                    this.ctx.restore(); // 恢复绘图状态
                };

                // 图片加载失败 → 绘制占位符
                img.onerror = () => this.renderImagePlaceholder(renderProps);
                img.src = element.src; // 设置图片 URL
            } else {
                // 非网络图片 → 绘制占位符
                this.renderImagePlaceholder(element);
            }
        }

        /**
         * 绘制图片占位符(显示文本提示)
         * @param {object} props - 图片元素属性
         */
        renderImagePlaceholder(props) {
            this.ctx.fillStyle = '#999'; // 占位符文本颜色
            this.ctx.font = '12px Arial'; // 字体大小
            this.ctx.textAlign = 'center'; // 水平居中
            this.ctx.textBaseline = 'middle'; // 垂直居中
            // 显示提示文本(优先显示 text,无则显示 src,再无则显示默认提示)
            const placeholderText = props.text || props.src || '图片占位符';
            this.ctx.fillText(placeholderText, props.x + props.width / 2, props.y + props.height / 2);
            // 恢复默认文本对齐方式(避免影响其他绘制)
            this.ctx.textAlign = 'left';
            this.ctx.textBaseline = 'alphabetic';
        }

        /**
         * 绘制二维码占位符(简化版,实际项目可集成 qrcode.js 生成真实二维码)
         * @param {object} element - 二维码元素
         */
        renderQrCodeElement(element) {
            // 绘制二维码外框(黑色)
            this.ctx.fillStyle = '#000000';
            this.ctx.fillRect(element.x + 5, element.y + 5, element.width - 10, element.height - 10);

            // 绘制二维码定位点(三个角的白色方块)
            const posSize = (element.width - 10) / 7; // 定位点大小
            this.ctx.fillStyle = '#ffffff';
            this.ctx.fillRect(element.x + 8, element.y + 8, posSize, posSize); // 左上定位点
            this.ctx.fillRect(element.x + element.width - posSize - 8, element.y + 8, posSize, posSize); // 右上定位点
            this.ctx.fillRect(element.x + 8, element.y + element.height - posSize - 8, posSize, posSize); // 左下定位点

            // 绘制二维码内容提示(居中)
            this.ctx.font = '10px Arial';
            this.ctx.textAlign = 'center';
            this.ctx.textBaseline = 'middle';
            const qrText = element.text || '二维码内容';
            this.ctx.fillText(qrText, element.x + element.width / 2, element.y + element.height / 2);
            // 恢复默认文本对齐方式
            this.ctx.textAlign = 'left';
            this.ctx.textBaseline = 'alphabetic';
        }

        /**
         * 更新 JSON 预览区域(显示当前海报的完整数据)
         */
        updateJSONPreview() {
            const jsonData = {
                width: this.canvas.width, // 画布宽度
                height: this.canvas.height, // 画布高度
                backgroundColor: document.getElementById('canvas-bg-color-input').value, // 画布背景色
                elements: this.elements.map(element => {
                    const elemCopy = {...element};
                    delete elemCopy.id; // 移除前端临时 ID(后端不需要)
                    return elemCopy;
                })
            };
            // 格式化 JSON 并显示(缩进 2 空格,便于阅读)
            document.getElementById('json-preview').textContent = JSON.stringify(jsonData, null, 2);
        }

        /**
         * 保存 JSON 到后端(使用 jQuery AJAX)
         * 数据格式:{ name: '海报名称', materials: { 画布+元素数据 } }
         */
        saveJSON() {
            if (typeof $ === 'undefined') {
                alert('错误:未检测到 jQuery 库。请先引入 jQuery 才能使用保存功能。');
                return;
            }

            // 1. 获取海报名称
            const posterNameInput = document.getElementById('json-name');
            const posterName = posterNameInput.value.trim() || '未命名海报';

            // 2. 构造请求体
            const requestData = {
                name: posterName,
                materials: {
                    width: this.canvas.width,
                    height: this.canvas.height,
                    backgroundColor: document.getElementById('canvas-bg-color-input').value,
                    elements: this.elements.map(element => {
                        const elemCopy = {...element};
                        delete elemCopy.id; // 移除前端临时 ID
                        return elemCopy;
                    })
                }
            };

            // 3. 使用 jQuery AJAX 发送请求
            // !!! 替换为你的后端接口地址 !!!
            const apiUrl = "{:url('add_post')}";

            $.ajax({
                url: apiUrl,
                type: 'POST',
                contentType: 'application/json',
                data: JSON.stringify(requestData),
                // headers: {
                //     'Authorization': 'Bearer ' + yourAuthToken // 如果需要认证
                // },
                success: function (response) {
                    console.log('保存成功:', response);

                    // 使用 layer.msg 显示成功提示
                    // 假设后端返回 {code: 0, msg: '保存成功'} 这样的格式
                    // 如果 response.msg 不存在,则使用默认文本
                    const successMsg = response.msg || `海报 "${posterName}" 已成功保存!`;
                    parent.layer.msg(successMsg, {icon: 1}); // icon: 1 表示成功的笑脸图标

                    // 延迟 500 毫秒后关闭弹框并刷新父页面
                    // **** 根据需求,我现在有弹框关闭弹框了,或者就是根据后台返回页面跳转一下 ****
                    setTimeout(function () {
                        // 关闭父级页面上所有的 layer 弹框
                        parent.layer.closeAll();
                        // 刷新父级页面
                        parent.location.reload();
                    }, 500);

                },
                error: function (jqXHR, textStatus, errorThrown) {
                    console.error('保存失败:');
                    console.error('状态:', textStatus);
                    console.error('错误:', errorThrown);
                    console.error('服务器响应:', jqXHR.responseText);

                    parent.layer.msg('错误:' + errorThrown, {icon: 2}); // icon: 1 表示成功的笑脸图标
                }
            });
        }


        /**
         * 删除元素(根据 ID 从 elements 数组中移除)
         * @param {string} elementId - 要删除的元素 ID
         */
        deleteElement(elementId) {
            const index = this.elements.findIndex(el => el.id === elementId); // 查找元素索引
            if (index !== -1) {
                this.elements.splice(index, 1); // 从数组中移除
                this.reassignLayers(); // 重新分配层级
                this.reassignNames(); // 重新分配元素名称
                this.selectedElement = null; // 取消选中(删除的元素已不存在)
                this.updateUI(); // 更新 UI
            }
        }

        /**
         * 统一更新 UI(调用所有需要刷新的方法,避免重复代码)
         */
        updateUI() {
            this.render(); // 渲染画布
            this.renderElementList(); // 渲染元素层级列表
            this.updatePropertiesPanel(); // 更新属性面板
            this.updateElementOverlay(); // 更新元素选中框
            this.updateJSONPreview(); // 更新 JSON 预览
        }
    }

    // 页面 DOM 加载完成后初始化编辑器
    document.addEventListener('DOMContentLoaded', () => {
        new PosterEditor('poster-canvas'); // 传入画布 ID 初始化
    });
</script>

前端编辑代码

ps:注意css路径问题

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>海报(编辑)</title>
    <link href="poster.css" rel="stylesheet"
          type="text/css">
</head>
<body>
<div class="header">
    <div class="toolbar">
        <button class="btn btn-default" id="备用按钮" style="display:none;">备用</button>
    </div>
</div>

<div class="container">
    <div class="sidebar" id="sidebar">
        <div class="sidebar-toggle" id="sidebar-toggle">≡</div>
        <div class="component-list" id="component-list">
            <div class="component-item" data-type="text">文本</div>
            <div class="component-item" data-type="image">图片</div>
            <div class="component-item" data-type="qrcode">二维码</div>
        </div>
    </div>

    <div class="editor-area">
        <div class="canvas-container" id="canvas-container">
            <div class="canvas-drag-handle" id="canvas-drag-handle">☰</div>
            <canvas id="poster-canvas" width="422" height="750"></canvas>
            <div class="element-overlay hidden" id="element-overlay"></div>
            <div class="resize-handle resize-se canvas-resize-handle" id="canvas-resize-handle"></div>
        </div>

        <div class="json-name-input">
            <label for="json-name">名称:</label>
            <input type="text" id="json-name" autocomplete="off" placeholder="请输入海报名称">
        </div>

        <!-- 新增:隐藏的ID输入框 -->
        <input type="hidden" id="poster-id" name="id" value="{$id}">

        <button class="btn btn-primary" id="save-json-btn">保存JSON</button>
    </div>

    <div class="properties-panel" id="properties-panel">
        <div class="canvas-properties-wrapper">
            <div class="property-group">
                <div class="property-title">画布属性</div>
                <div class="property-item">
                    <div class="property-label">宽度</div>
                    <input type="number" class="property-input" id="canvas-width" value="422" min="100" max="2000">
                </div>
                <div class="property-item">
                    <div class="property-label">高度</div>
                    <input type="number" class="property-input" id="canvas-height" value="750" min="100" max="2000">
                </div>
                <div class="property-item">
                    <div class="property-label">背景颜色</div>
                    <div class="color-picker">
                        <div class="color-preview" id="canvas-bg-color" style="background-color: #ffffff;"></div>
                        <input type="color" class="property-input" id="canvas-bg-color-input" value="#ffffff">
                    </div>
                </div>
            </div>

            <div class="property-group" id="element-properties" style="display: none;">
                <div class="property-title">元素属性</div>
                <div class="property-item">
                    <div class="property-label">X坐标</div>
                    <input type="number" class="property-input" id="element-x" value="0">
                </div>
                <div class="property-item">
                    <div class="property-label">Y坐标</div>
                    <input type="number" class="property-input" id="element-y" value="0">
                </div>
                <div class="property-item">
                    <div class="property-label">宽度</div>
                    <div class="property-slider">
                        <input type="range" id="element-width-slider" min="10" max="500" value="100">
                        <input type="number" id="element-width-value" class="slider-value" min="10" max="500"
                               value="100">
                    </div>
                </div>
                <div class="property-item">
                    <div class="property-label">高度</div>
                    <div class="property-slider">
                        <input type="range" id="element-height-slider" min="10" max="500" value="100">
                        <input type="number" id="element-height-value" class="slider-value" min="10" max="500"
                               value="100">
                    </div>
                </div>

                <div class="layer-control" id="layer-control" style="display: none;">
                    <div class="property-label">层级控制</div>
                    <div class="layer-info">
                        当前: <span id="current-layer">1</span> / 总: <span id="total-layers">1</span>
                    </div>
                    <div class="btn-group">
                        <button id="btn-bring-to-front" class="btn btn-default">置顶</button>
                        <button id="btn-move-up" class="btn btn-default">上移</button>
                        <button id="btn-move-down" class="btn btn-default">下移</button>
                        <button id="btn-send-to-back" class="btn btn-default">置底</button>
                    </div>
                </div>

                <div class="property-item" id="text-content-item" style="display: none;">
                    <div class="property-label">文本内容</div>
                    <input type="text" class="property-input" id="element-text" value="">
                </div>
                <div class="property-item" id="text-color-item" style="display: none;">
                    <div class="property-label">文本颜色</div>
                    <div class="color-picker">
                        <div class="color-preview" id="text-color" style="background-color: #000000;"></div>
                        <input type="color" class="property-input" id="text-color-input" value="#000000">
                    </div>
                </div>
                <div class="property-item" id="text-font-size-item" style="display: none;">
                    <div class="property-label">字体大小</div>
                    <div class="property-slider">
                        <input type="range" id="text-font-size-slider" min="1" max="100" value="16">
                        <input type="number" id="text-font-size-value" class="slider-value" min="1" max="100"
                               value="16">
                    </div>
                </div>
                <div class="property-item" id="text-align-item" style="display: none;">
                    <div class="property-label">对齐方式</div>
                    <select class="property-select" id="text-align">
                        <option value="left">靠左</option>
                        <option value="center">居中</option>
                        <option value="right">靠右</option>
                    </select>
                </div>
                <div class="property-item" id="text-overflow-item" style="display: none;">
                    <div class="property-label">超出处理</div>
                    <select class="property-select" id="text-overflow">
                        <option value="wrap">自动换行</option>
                        <option value="ellipsis">隐藏超出字符</option>
                    </select>
                </div>

                <div class="property-item" id="image-url-item" style="display: none;">
                    <div class="property-label">图片URL</div>
                    <input type="text" class="property-input" id="image-url" value="">
                </div>
                <div class="property-item" id="border-color-item" style="display: none;">
                    <div class="property-label">边框颜色</div>
                    <div class="color-picker">
                        <div class="color-preview" id="border-color"
                             style="background-color: rgba(255,255,255,0);"></div>
                        <input type="color" class="property-input" id="border-color-input" value="#ffffff">
                    </div>
                </div>
                <div class="property-item" id="container-bg-color-item" style="display: none;">
                    <div class="property-label">容器背景</div>
                    <div class="color-picker">
                        <div class="color-preview" id="container-bg-color"
                             style="background-color: rgba(255,255,255,0);"></div>
                        <input type="color" class="property-input" id="container-bg-color-input" value="#ffffff">
                    </div>
                </div>
                <div class="property-item" id="image-border-radius-item" style="display: none;">
                    <div class="property-label">圆角半径</div>
                    <div class="property-slider">
                        <input type="range" id="image-border-radius-slider" min="0" max="50" value="0">
                        <input type="number" id="image-border-radius-value" class="slider-value" min="0" max="50"
                               value="0">
                    </div>
                </div>

                <div class="property-item" id="qrcode-url-item" style="display: none;">
                    <div class="property-label">二维码内容</div>
                    <input type="text" class="property-input" id="qrcode-url" value="">
                </div>

                <div class="property-item" id="delete-element-item" style="display: none; margin-top: 15px;">
                    <button id="delete-element-btn">删除元素</button>
                </div>
            </div>
        </div>

        <div class="property-group">
            <div class="property-title">JSON数据预览</div>
            <div class="json-preview" id="json-preview"></div>
        </div>
    </div>
</div>

<div class="element-list-panel" id="element-list-panel">
    <div class="panel-resize-handle" id="panel-resize-handle"></div>
    <div class="panel-title">元素层级</div>
    <div class="element-list" id="element-list"></div>
</div>

</body>
</html>

<!-- 先引入 jQuery -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<!-- 引入 layui JS -->
<script src="https://cdn.staticfile.org/layui/2.9.10/layui.js"></script>

<script>
    /**
     * 海报编辑器核心类
     * 负责画布初始化、元素管理(添加/删除/修改)、拖拽缩放、属性面板同步、JSON 保存等功能
     */
    class PosterEditor {
        /**
         * 构造函数:初始化编辑器实例
         * @param {string} canvasId - 画布 DOM 元素的 ID
         */
        constructor(canvasId) {
            // 1. 获取画布和上下文
            this.canvas = document.getElementById(canvasId);
            this.ctx = this.canvas.getContext('2d'); // 2D 绘图上下文

            // 2. 核心数据存储
            this.elements = []; // 存储所有海报元素(文本、图片、二维码)
            this.selectedElement = null; // 当前选中的元素

            // 3. 拖拽/缩放状态标识
            this.isDragging = false; // 元素是否正在拖拽
            this.dragOffset = {x: 0, y: 0}; // 拖拽时的鼠标偏移量(避免点击元素时跳动)
            this.isResizing = false; // 元素是否正在缩放
            this.resizeDirection = null; // 缩放方向(nw/ne/sw/se)
            this.isCanvasResizing = false; // 画布是否正在缩放

            // 4. 画布基础配置
            this.canvasMinWidth = 100; // 画布最小宽度
            this.canvasMinHeight = 100; // 画布最小高度

            // 5. 元素计数器(用于生成默认名称,如 text_1、image_2)
            this.globalCounter = 1;

            // 6. DOM 元素引用(方便后续操作)
            this.elementList = document.getElementById('element-list'); // 元素层级列表
            this.draggingIndex = -1; // 拖拽元素在列表中的索引
            this.sidebar = document.getElementById('sidebar'); // 左侧组件库侧边栏
            this.sidebarToggle = document.getElementById('sidebar-toggle'); // 侧边栏折叠按钮
            this.elementListPanel = document.getElementById('element-list-panel'); // 元素层级面板
            this.panelTitle = this.elementListPanel.querySelector('.panel-title'); // 层级面板标题(拖拽用)
            this.saveBtn = document.getElementById('save-json-btn'); // 保存 JSON 按钮
            this.propertiesPanel = document.getElementById('properties-panel'); // 右侧属性面板
            this.canvasContainer = document.getElementById('canvas-container'); // 画布容器
            this.canvasDragHandle = document.getElementById('canvas-drag-handle'); // 画布拖拽手柄

            // 7. 面板/画布拖拽配置
            this.isDraggingPanel = false; // 元素层级面板是否正在拖拽
            this.panelOffset = {x: 0, y: 0}; // 面板拖拽偏移量
            this.isDraggingCanvas = false; // 画布是否正在拖拽
            this.canvasOffset = {x: 0, y: 0}; // 画布拖拽偏移量

            // 8. 不同类型元素的默认容器颜色(区分元素类型,提升视觉体验)
            this.defaultContainerColors = {
                text: '#e8f4f8',    // 文本元素:浅蓝色
                image: '#faf6ed',   // 图片元素:浅橙色
                qrcode: '#fdf2f8'   // 二维码元素:浅粉色
            };

            // 9. 初始化编辑器(顺序不可乱)
            this.initCanvas(); // 初始化画布尺寸
            this.initEventListeners(); // 绑定所有事件(点击、拖拽、输入等)
            this.initSidebarToggle(); // 初始化侧边栏折叠功能
            this.initPanelDrag(); // 初始化元素层级面板拖拽
            this.initCanvasDrag(); // 初始化画布拖拽

            // 新增:尝试加载海报数据
            this.fetchAndLoadPosterData();

            this.updateUI(); // 首次更新 UI(渲染画布、层级列表、属性面板)
        }


        /**
         * 新增:从API获取并加载海报数据(使用 $.ajax)
         */
        fetchAndLoadPosterData() {
            const apiUrl = "{:url('get_poster')}"; // 你的后端接口地址
            console.log(apiUrl);

            $.ajax({
                url: apiUrl,
                type: 'GET', // 请求类型
                data: {id: document.getElementById('poster-id').value}, // 请求参数(可选)
                dataType: 'json', // 预期返回的数据类型
                success: (res) => {
                    var response = res.data; // 方便后续操作
                    console.log('加载的原始数据:', response);

                    if (response && response.materials) {
                        console.log('成功加载海报数据:', response);

                        // 1. 设置隐藏域的 ID
                        document.getElementById('poster-id').value = response.id || '';

                        // 2. 设置海报名称
                        document.getElementById('json-name').value = response.name || '';

                        // 3. 加载画布属性
                        const materials = response.materials;
                        this.canvas.width = parseInt(materials.width, 10) || 422;
                        this.canvas.height = parseInt(materials.height, 10) || 750;
                        document.getElementById('canvas-width').value = this.canvas.width;
                        document.getElementById('canvas-height').value = this.canvas.height;
                        document.getElementById('canvas-bg-color-input').value = materials.backgroundColor || '#ffffff';
                        document.getElementById('canvas-bg-color').style.backgroundColor = materials.backgroundColor || '#ffffff';

                        // 4. 清空现有元素并加载新元素
                        this.elements = []; // 清空
                        if (materials.elements && materials.elements.length > 0) {
                            materials.elements.forEach(elementData => {
                                // 校验并修正宽高,防止出现过大或非数字值
                                const width = parseInt(elementData.width, 10);
                                const height = parseInt(elementData.height, 10);

                                // 根据元素类型,确保关键属性存在
                                let contentProps = {};
                                switch (elementData.type) {
                                    case 'text':
                                        contentProps = {
                                            text: elementData.text || '未命名文本',
                                            color: elementData.color || '#000000',
                                            fontSize: parseInt(elementData.fontSize, 10) || 16,
                                            align: elementData.align || 'left',
                                            overflow: elementData.overflow || 'wrap',
                                        };
                                        break;
                                    case 'image':
                                        contentProps = {
                                            src: elementData.src || '{image_placeholder}',
                                            borderColor: elementData.borderColor || 'rgba(255,255,255,0)',
                                            borderRadius: parseInt(elementData.borderRadius, 10) || 0,
                                        };
                                        break;
                                    case 'qrcode':
                                        contentProps = {
                                            text: elementData.text || '未设置二维码内容',
                                        };
                                        break;
                                }

                                // 为每个加载的元素生成一个新的唯一ID
                                const newElement = {
                                    ...elementData,
                                    ...contentProps, // 合并关键属性
                                    id: Date.now().toString() + Math.floor(Math.random() * 1000).toString(),
                                    width: isNaN(width) ? 100 : Math.max(10, width),
                                    height: isNaN(height) ? 100 : Math.max(10, height),
                                    x: parseInt(elementData.x, 10) || 0,
                                    y: parseInt(elementData.y, 10) || 0,
                                    // 确保容器背景色存在
                                    containerBackgroundColor: elementData.containerBackgroundColor || this.defaultContainerColors[elementData.type] || '#f0f0f0'
                                };
                                this.elements.push(newElement);
                            });
                            // 根据 layer 排序
                            this.sortElementsByLayer();
                            // 更新计数器
                            this.globalCounter = this.elements.length > 0 ? this.elements.length + 1 : 1;
                        }

                        // 5. 强制更新UI
                        this.updateUI();
                    } else {
                        console.warn('未获取到有效的海报数据,将创建新海报。');
                        this.updateUI(); // 即使数据无效也初始化UI
                    }
                },
                error: (xhr, status, error) => {
                    console.error('加载海报数据失败:', status, error);
                    console.error('响应内容:', xhr.responseText); // 打印完整的错误响应

                    // 显示用户友好的错误提示
                    parent.layer.msg('加载海报数据失败,请检查网络或联系管理员。', {icon: 2});

                    // 即使失败也继续初始化UI,以便用户可以创建新海报
                    this.updateUI();
                },
                timeout: 10000 // 设置超时时间(10秒)
            });
        }


        /**
         * 初始化画布尺寸(从输入框读取默认值,无值则用画布自身尺寸)
         */
        initCanvas() {
            const widthInput = document.getElementById('canvas-width');
            const heightInput = document.getElementById('canvas-height');
            // 优先使用输入框的值,无值则用画布默认尺寸
            this.canvas.width = parseInt(widthInput.value) || this.canvas.width;
            this.canvas.height = parseInt(heightInput.value) || this.canvas.height;
            this.render(); // 初始化后渲染画布
        }

        /**
         * 创建滑块和输入框的双向绑定(同步数值,如元素宽度、字体大小)
         * @param {string} sliderId - 滑块 DOM 元素 ID
         * @param {string} inputId - 输入框 DOM 元素 ID
         * @param {function} callback - 数值变化后的回调函数(更新元素属性)
         */
        createTwoWayBinding(sliderId, inputId, callback) {
            const slider = document.getElementById(sliderId);
            const input = document.getElementById(inputId);

            // 同步滑块和输入框的值
            const updateValue = (source, value) => {
                if (source === 'slider') input.value = value; // 滑块动 → 输入框同步
                if (source === 'input') slider.value = value; // 输入框动 → 滑块同步
                callback && callback(parseInt(value)); // 数值变化后执行回调(更新元素)
            };

            // 绑定滑块输入事件
            slider.addEventListener('input', (e) => updateValue('slider', e.target.value));
            // 绑定输入框输入事件(处理边界值,如不小于最小值、不大于最大值)
            input.addEventListener('input', (e) => {
                let value = parseInt(e.target.value) || 0;
                if (slider.min) value = Math.max(parseInt(slider.min), value); // 不小于最小值
                if (slider.max) value = Math.min(parseInt(slider.max), value); // 不大于最大值
                e.target.value = value; // 修正输入框值
                updateValue('input', value);
            });
        }

        /**
         * 初始化所有事件监听器(核心方法,覆盖所有交互逻辑)
         */
        initEventListeners() {
            // -------------------------- 画布属性事件 --------------------------
            // 画布宽度输入框变化 → 更新画布宽度
            document.getElementById('canvas-width').addEventListener('change', (e) => {
                const newWidth = Math.max(this.canvasMinWidth, Math.min(parseInt(e.target.value) || this.canvasMinWidth, 2000));
                this.canvas.width = newWidth;
                e.target.value = newWidth; // 修正输入框值(避免超出范围)
                this.render(); // 重新渲染画布
            });

            // 画布高度输入框变化 → 更新画布高度
            document.getElementById('canvas-height').addEventListener('change', (e) => {
                const newHeight = Math.max(this.canvasMinHeight, Math.min(parseInt(e.target.value) || this.canvasMinHeight, 2000));
                this.canvas.height = newHeight;
                e.target.value = newHeight; // 修正输入框值
                this.render(); // 重新渲染画布
            });

            // 画布背景颜色选择器变化 → 更新画布背景
            document.getElementById('canvas-bg-color-input').addEventListener('input', (e) => {
                document.getElementById('canvas-bg-color').style.backgroundColor = e.target.value;
                this.render(); // 重新渲染画布
            });

            // -------------------------- 元素通用属性事件 --------------------------
            // 元素宽度(滑块+输入框)双向绑定 → 更新元素宽度
            this.createTwoWayBinding('element-width-slider', 'element-width-value', (value) => {
                if (this.selectedElement) {
                    this.selectedElement.width = value;
                    this.updateElementOverlay(); // 更新元素选中框(覆盖层)
                    this.render(); // 重新渲染画布
                }
            });

            // 元素高度(滑块+输入框)双向绑定 → 更新元素高度
            this.createTwoWayBinding('element-height-slider', 'element-height-value', (value) => {
                if (this.selectedElement) {
                    this.selectedElement.height = value;
                    this.updateElementOverlay(); // 更新元素选中框
                    this.render(); // 重新渲染画布
                }
            });

            // 元素X坐标输入框变化 → 更新元素X坐标
            document.getElementById('element-x').addEventListener('input', (e) => {
                if (this.selectedElement) {
                    this.selectedElement.x = parseInt(e.target.value) || 0;
                    this.updateElementOverlay(); // 更新元素选中框
                    this.render(); // 重新渲染画布
                }
            });

            // 元素Y坐标输入框变化 → 更新元素Y坐标
            document.getElementById('element-y').addEventListener('input', (e) => {
                if (this.selectedElement) {
                    this.selectedElement.y = parseInt(e.target.value) || 0;
                    this.updateElementOverlay(); // 更新元素选中框
                    this.render(); // 重新渲染画布
                }
            });

            // -------------------------- 文本元素专属事件 --------------------------
            // 文本内容输入框变化 → 更新文本内容
            document.getElementById('element-text').addEventListener('input', (e) => {
                if (this.selectedElement?.type === 'text') { // 仅文本元素生效
                    this.selectedElement.text = e.target.value;
                    this.updateUI(); // 更新 UI(渲染画布+层级列表)
                }
            });

            // 文本颜色选择器变化 → 更新文本颜色
            document.getElementById('text-color-input').addEventListener('input', (e) => {
                if (this.selectedElement?.type === 'text') { // 仅文本元素生效
                    this.selectedElement.color = e.target.value;
                    document.getElementById('text-color').style.backgroundColor = e.target.value;
                    this.render(); // 重新渲染画布
                }
            });

            // 字体大小(滑块+输入框)双向绑定 → 更新字体大小
            this.createTwoWayBinding('text-font-size-slider', 'text-font-size-value', (value) => {
                if (this.selectedElement?.type === 'text') { // 仅文本元素生效
                    this.selectedElement.fontSize = value;
                    this.render(); // 重新渲染画布
                }
            });

            // 文本对齐方式下拉框变化 → 更新文本对齐
            document.getElementById('text-align').addEventListener('change', (e) => {
                if (this.selectedElement?.type === 'text') { // 仅文本元素生效
                    this.selectedElement.align = e.target.value;
                    this.render(); // 重新渲染画布
                }
            });

            // 文本超出处理下拉框变化 → 更新超出逻辑(换行/省略)
            document.getElementById('text-overflow').addEventListener('change', (e) => {
                if (this.selectedElement?.type === 'text') { // 仅文本元素生效
                    this.selectedElement.overflow = e.target.value;
                    this.render(); // 重新渲染画布
                }
            });

            // -------------------------- 图片元素专属事件 --------------------------
            // 图片URL输入框变化 → 更新图片地址
            document.getElementById('image-url').addEventListener('input', (e) => {
                if (this.selectedElement?.type === 'image') { // 仅图片元素生效
                    this.selectedElement.src = e.target.value;
                    this.updateUI(); // 更新 UI
                }
            });

            // 图片边框颜色选择器变化 → 更新边框颜色
            document.getElementById('border-color-input').addEventListener('input', (e) => {
                if (this.selectedElement?.type === 'image') { // 仅图片元素生效
                    this.selectedElement.borderColor = e.target.value;
                    document.getElementById('border-color').style.backgroundColor = e.target.value;
                    this.render(); // 重新渲染画布
                }
            });

            // 图片圆角(滑块+输入框)双向绑定 → 更新圆角半径
            this.createTwoWayBinding('image-border-radius-slider', 'image-border-radius-value', (value) => {
                if (this.selectedElement?.type === 'image') { // 仅图片元素生效
                    this.selectedElement.borderRadius = value;
                    this.render(); // 重新渲染画布
                }
            });

            // -------------------------- 二维码元素专属事件 --------------------------
            // 二维码内容输入框变化 → 更新二维码内容
            document.getElementById('qrcode-url').addEventListener('input', (e) => {
                if (this.selectedElement?.type === 'qrcode') { // 仅二维码元素生效
                    this.selectedElement.text = e.target.value;
                    this.updateUI(); // 更新 UI
                }
            });

            // -------------------------- 容器背景色(通用)事件 --------------------------
            // 容器背景色选择器变化 → 更新元素容器背景
            document.getElementById('container-bg-color-input').addEventListener('input', (e) => {
                if (this.selectedElement) {
                    this.selectedElement.containerBackgroundColor = e.target.value;
                    document.getElementById('container-bg-color').style.backgroundColor = e.target.value;
                    this.render(); // 重新渲染画布
                }
            });

            // -------------------------- 画布/元素拖拽/缩放事件 --------------------------
            // 画布点击事件 → 选中元素/取消选中
            this.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
            // 鼠标移动事件 → 拖拽元素/缩放元素/缩放画布
            document.addEventListener('mousemove', this.handleMouseMove.bind(this));
            // 鼠标抬起事件 → 结束拖拽/缩放
            document.addEventListener('mouseup', this.handleMouseUp.bind(this));
            // 鼠标离开页面事件 → 结束拖拽/缩放(避免异常状态)
            document.addEventListener('mouseleave', this.handleMouseUp.bind(this));

            // 画布缩放手柄点击事件 → 开始缩放画布
            document.getElementById('canvas-resize-handle').addEventListener('mousedown', (e) => {
                e.stopPropagation(); // 阻止事件冒泡(避免触发画布点击)
                this.isCanvasResizing = true;
                document.getElementById('element-overlay').classList.add('hidden'); // 隐藏元素选中框
            });

            // -------------------------- 左侧组件库事件 --------------------------
            // 组件库元素点击事件 → 添加对应类型的元素(文本/图片/二维码)
            document.querySelectorAll('.component-item').forEach(item => {
                item.addEventListener('click', () => this.addElement(item.getAttribute('data-type')));
            });

            // -------------------------- 保存/删除/层级事件 --------------------------
            // 保存 JSON 按钮点击事件 → 调用保存方法
            this.saveBtn.addEventListener('click', () => this.saveJSON());

            // 元素置顶按钮 → 提升元素层级到最高
            document.getElementById('btn-bring-to-front').addEventListener('click', () => this.bringToFront());
            // 元素上移按钮 → 提升元素层级
            document.getElementById('btn-move-up').addEventListener('click', () => this.moveUp());
            // 元素下移按钮 → 降低元素层级
            document.getElementById('btn-move-down').addEventListener('click', () => this.moveDown());
            // 元素置底按钮 → 降低元素层级到最低
            document.getElementById('btn-send-to-back').addEventListener('click', () => this.sendToBack());

            // 删除元素按钮 → 删除当前选中元素
            document.getElementById('delete-element-btn').addEventListener('click', () => {
                if (this.selectedElement) {
                    this.deleteElement(this.selectedElement.id);
                }
            });

            // 键盘 Delete/Backspace 键 → 删除当前选中元素(快捷键)
            document.addEventListener('keydown', (e) => {
                if (e.key === 'Delete' || e.key === 'Backspace') {
                    if (this.selectedElement) {
                        this.deleteElement(this.selectedElement.id);
                    }
                }
            });

            // -------------------------- 元素层级面板缩放事件 --------------------------
            // 层级面板缩放手柄 → 调整面板宽度
            const panelResizeHandle = document.getElementById('panel-resize-handle');
            panelResizeHandle.addEventListener('mousedown', (e) => {
                e.stopPropagation(); // 阻止事件冒泡
                const startX = e.clientX;
                const startWidth = this.elementListPanel.offsetWidth;

                // 鼠标移动 → 计算新宽度并更新
                const handleMouseMove = (e) => {
                    const diffX = e.clientX - startX;
                    let newWidth = startWidth + diffX;
                    const minWidth = 150; // 面板最小宽度
                    if (newWidth < minWidth) newWidth = minWidth;
                    this.elementListPanel.style.width = `${newWidth}px`;
                };

                // 鼠标抬起 → 移除事件监听
                const handleMouseUp = () => {
                    document.removeEventListener('mousemove', handleMouseMove);
                    document.removeEventListener('mouseup', handleMouseUp);
                };

                document.addEventListener('mousemove', handleMouseMove);
                document.addEventListener('mouseup', handleMouseUp);
            });

            // 窗口 resize 事件 → 调整层级面板位置(避免超出窗口)
            window.addEventListener('resize', () => {
                this.adjustElementListPanelPosition();
            });
        }

        /**
         * 初始化侧边栏折叠功能
         */
        initSidebarToggle() {
            this.sidebarToggle.addEventListener('click', () => {
                this.sidebar.classList.toggle('collapsed'); // 切换折叠类
                // 切换按钮文本(折叠→箭头,展开→三条杠)
                this.sidebarToggle.textContent = this.sidebar.classList.contains('collapsed') ? '→' : '≡';
            });
        }

        /**
         * 初始化元素层级面板拖拽功能
         */
        initPanelDrag() {
            // 面板标题点击 → 开始拖拽
            this.panelTitle.addEventListener('mousedown', (e) => {
                e.stopPropagation(); // 阻止事件冒泡
                this.isDraggingPanel = true;
                const panelRect = this.elementListPanel.getBoundingClientRect(); // 获取面板位置信息
                // 计算鼠标相对于面板左上角的偏移量
                this.panelOffset.x = e.clientX - panelRect.left;
                this.panelOffset.y = e.clientY - panelRect.top;
                this.elementListPanel.classList.add('dragging'); // 添加拖拽样式(半透明+阴影)
                document.body.style.cursor = 'move'; // 鼠标样式改为移动指针
            });

            // 鼠标移动 → 更新面板位置
            document.addEventListener('mousemove', (e) => {
                if (!this.isDraggingPanel) return; // 未拖拽则返回

                let newLeft = e.clientX - this.panelOffset.x;
                let newTop = e.clientY - this.panelOffset.y;
                const panelWidth = this.elementListPanel.offsetWidth;
                const panelHeight = this.elementListPanel.offsetHeight;
                const screenWidth = window.innerWidth;
                const screenHeight = window.innerHeight;

                // 限制面板不超出窗口边界
                newLeft = Math.max(0, Math.min(newLeft, screenWidth - panelWidth));
                newTop = Math.max(0, Math.min(newTop, screenHeight - panelHeight));

                // 更新面板位置(取消 right 定位,改用 left+top)
                this.elementListPanel.style.left = `${newLeft}px`;
                this.elementListPanel.style.top = `${newTop}px`;
                this.elementListPanel.style.right = 'auto';
            });

            // 鼠标抬起 → 结束拖拽
            document.addEventListener('mouseup', () => {
                if (this.isDraggingPanel) {
                    this.isDraggingPanel = false;
                    this.elementListPanel.classList.remove('dragging'); // 移除拖拽样式
                    document.body.style.cursor = ''; // 恢复默认鼠标样式
                }
            });

            // 面板内部点击(非标题区域)→ 取消拖拽状态
            this.elementListPanel.addEventListener('mousedown', (e) => {
                if (e.target !== this.panelTitle && !this.panelTitle.contains(e.target)) {
                    this.isDraggingPanel = false;
                }
            });
        }

        /**
         * 初始化画布拖拽功能(通过左上角手柄拖拽)
         */
        initCanvasDrag() {
            // 画布拖拽手柄点击 → 开始拖拽
            this.canvasDragHandle.addEventListener('mousedown', (e) => {
                e.stopPropagation(); // 阻止事件冒泡
                this.isDraggingCanvas = true;
                const containerRect = this.canvasContainer.getBoundingClientRect(); // 获取容器位置
                const editorRect = document.querySelector('.editor-area').getBoundingClientRect(); // 获取编辑区位置
                // 计算鼠标相对于容器左上角的偏移量
                this.canvasOffset.x = e.clientX - containerRect.left;
                this.canvasOffset.y = e.clientY - containerRect.top;
                this.canvasContainer.style.cursor = 'grabbing'; // 鼠标样式改为抓取
                this.canvasDragHandle.style.cursor = 'grabbing';
                this.canvasContainer.style.zIndex = '100'; // 提升层级,避免被覆盖
            });

            // 鼠标移动 → 更新画布位置
            document.addEventListener('mousemove', (e) => {
                if (!this.isDraggingCanvas) return; // 未拖拽则返回

                const editorRect = document.querySelector('.editor-area').getBoundingClientRect();
                let newLeft = e.clientX - this.canvasOffset.x - editorRect.left;
                let newTop = e.clientY - this.canvasOffset.y - editorRect.top;
                const containerWidth = this.canvasContainer.offsetWidth;
                const containerHeight = this.canvasContainer.offsetHeight;
                const editorWidth = editorRect.width;
                const editorHeight = editorRect.height;

                // 限制画布不超出编辑区边界(底部留 50px 空白,避免遮挡保存按钮)
                newLeft = Math.max(0, Math.min(newLeft, editorWidth - containerWidth));
                newTop = Math.max(0, Math.min(newTop, editorHeight - containerHeight - 50));

                // 更新画布容器位置(改为绝对定位)
                this.canvasContainer.style.position = 'absolute';
                this.canvasContainer.style.left = `${newLeft}px`;
                this.canvasContainer.style.top = `${newTop}px`;
            });

            // 鼠标抬起 → 结束拖拽
            document.addEventListener('mouseup', () => {
                if (this.isDraggingCanvas) {
                    this.isDraggingCanvas = false;
                    this.canvasContainer.style.cursor = 'move'; // 恢复移动鼠标样式
                    this.canvasDragHandle.style.cursor = 'move';
                    this.canvasContainer.style.zIndex = '1'; // 恢复默认层级
                }
            });

            // 画布内部点击(非手柄区域)→ 取消拖拽状态
            this.canvas.addEventListener('mousedown', (e) => {
                if (!this.canvasDragHandle.contains(e.target)) {
                    this.isDraggingCanvas = false;
                }
            });
        }

        /**
         * 调整元素层级面板位置(窗口 resize 时避免超出窗口)
         */
        adjustElementListPanelPosition() {
            if (this.isDraggingPanel) return; // 拖拽中不调整

            const propertiesPanelRect = this.propertiesPanel.getBoundingClientRect(); // 右侧属性面板位置
            const panelWidth = this.elementListPanel.offsetWidth;
            // 面板左边界 = 属性面板左边界 - 面板宽度 - 20px(间距)
            const newLeft = propertiesPanelRect.left - panelWidth - 20;
            // 确保面板不超出左边界
            this.elementListPanel.style.left = `${Math.max(0, newLeft)}px`;
            this.elementListPanel.style.right = 'auto';
        }

        /**
         * 渲染元素层级列表(同步 elements 数组和 DOM 列表)
         */
        renderElementList() {
            this.elementList.innerHTML = ''; // 清空现有列表

            // 遍历所有元素,生成列表项
            this.elements.forEach((element, index) => {
                const item = document.createElement('div');
                // 列表项样式(选中状态添加 selected 类)
                item.className = `element-list-item ${this.selectedElement?.id === element.id ? 'selected' : ''}`;
                item.draggable = true; // 支持拖拽排序
                item.dataset.id = element.id; // 存储元素 ID(用于后续操作)
                item.dataset.index = index; // 存储元素索引

                // 1. 元素类型图标(T=文本,I=图片,Q=二维码)
                const icon = document.createElement('div');
                icon.className = `element-icon ${element.type}-icon`;
                icon.textContent = element.type === 'text' ? 'T' : element.type === 'image' ? 'I' : 'Q';

                // 2. 元素名称(显示类型+内容/URL,超出隐藏)
                const name = document.createElement('div');
                name.className = 'element-name';
                const displayText = `${element.type === 'text' ? '文本' : element.type === 'image' ? '图片' : '二维码'}: ${element.text || element.src || '未命名'}`;
                name.textContent = displayText;

                // 3. 元素层级标识(数字越小层级越高)
                const layerBadge = document.createElement('div');
                layerBadge.className = 'layer-badge';
                layerBadge.textContent = element.layer;

                // 4. 删除按钮
                const deleteBtn = document.createElement('div');
                deleteBtn.className = 'delete-btn';
                deleteBtn.textContent = '×';
                deleteBtn.title = '删除元素';
                deleteBtn.addEventListener('click', (e) => {
                    e.stopPropagation(); // 阻止事件冒泡(避免触发列表项点击)
                    this.deleteElement(element.id);
                });

                // 5. 拖拽手柄
                const handle = document.createElement('div');
                handle.className = 'drag-handle';
                handle.innerHTML = '⋮⋮'; // 拖拽图标

                // 组装列表项
                item.append(icon, name, layerBadge, deleteBtn, handle);

                // 列表项拖拽事件(排序用)
                item.addEventListener('dragstart', (e) => this.handleDragStart(e, index));
                item.addEventListener('dragover', (e) => e.preventDefault()); // 允许拖拽放置
                item.addEventListener('drop', (e) => this.handleDrop(e, index)); // 放置时触发排序
                // 列表项点击 → 选中对应元素
                item.addEventListener('click', () => this.handleElementItemClick(element.id));

                this.elementList.appendChild(item);
            });
        }

        /**
         * 元素层级列表拖拽开始 → 记录拖拽元素索引
         * @param {Event} e - 拖拽事件对象
         * @param {number} startIndex - 拖拽元素的初始索引
         */
        handleDragStart(e, startIndex) {
            this.draggingIndex = startIndex;
            e.target.classList.add('dragging'); // 添加拖拽样式
        }

        /**
         * 元素层级列表拖拽放置 → 调整元素顺序(更新层级)
         * @param {Event} e - 放置事件对象
         * @param {number} targetIndex - 放置目标的索引
         */
        handleDrop(e, targetIndex) {
            e.preventDefault(); // 阻止默认行为
            const draggingItem = this.elementList.querySelector('.dragging');
            // 校验:无拖拽项/拖拽索引无效/拖拽到自身位置 → 直接返回
            if (!draggingItem || this.draggingIndex === -1 || this.draggingIndex === targetIndex) {
                if (draggingItem) draggingItem.classList.remove('dragging');
                return;
            }

            // 调整元素数组顺序(从拖拽索引移除,插入到目标索引)
            const [movedElement] = this.elements.splice(this.draggingIndex, 1);
            this.elements.splice(targetIndex, 0, movedElement);
            this.reassignLayers(); // 重新分配层级编号
            this.reassignNames(); // 重新分配元素默认名称(如 text_1 → text_2)
            draggingItem.classList.remove('dragging'); // 移除拖拽样式
            this.updateUI(); // 更新 UI
            this.draggingIndex = -1; // 重置拖拽索引
        }

        /**
         * 重新分配元素层级编号(数组顺序决定层级,索引 0 层级最高为 1)
         */
        reassignLayers() {
            this.elements.forEach((elem, index) => {
                elem.layer = index + 1; // 索引 0 → 层级 1(最高),索引 1 → 层级 2,以此类推
            });
        }

        /**
         * 重新分配元素默认名称(根据类型和数组索引,如 text_1、image_2)
         */
        reassignNames() {
            this.elements.forEach((element, index) => {
                const newNumber = index + 1;
                switch (element.type) {
                    case 'text':
                        if (element.text && element.text.match(/{text_\d+}/)) {
                            element.text = element.text.replace(/{text_\d+}/, `{text_${newNumber}}`);
                        }
                        break;
                    case 'image':
                        if (element.src && element.src.match(/{image_\d+}/)) {
                            element.src = element.src.replace(/{image_\d+}/, `{image_${newNumber}}`);
                        }
                        break;
                    case 'qrcode':
                        if (element.text && element.text.match(/{qr_\d+}/)) {
                            element.text = element.text.replace(/{qr_\d+}/, `{qr_${newNumber}}`);
                        }
                        break;
                }
            });
            this.globalCounter = this.elements.length + 1; // 更新计数器(下一个元素用)
        }

        /**
         * 元素层级列表项点击 → 选中对应元素
         * @param {string} elementId - 元素 ID
         */
        handleElementItemClick(elementId) {
            const element = this.elements.find(el => el.id === elementId);
            if (element) this.selectElement(element); // 选中元素
        }

        /**
         * 更新层级控制 UI(禁用/启用上下移按钮,显示当前层级/总层级)
         */
        updateLayerUI() {
            if (!this.selectedElement) {
                document.getElementById('layer-control').style.display = 'none'; // 无选中元素则隐藏
                return;
            }
            document.getElementById('layer-control').style.display = 'block'; // 显示层级控制

            // 显示当前层级和总层级
            document.getElementById('current-layer').textContent = this.selectedElement.layer;
            document.getElementById('total-layers').textContent = this.elements.length;

            // 禁用/启用按钮(层级 1 则无法置顶/上移,层级等于总长度则无法置底/下移)
            document.getElementById('btn-bring-to-front').disabled = this.selectedElement.layer === 1;
            document.getElementById('btn-move-up').disabled = this.selectedElement.layer === 1;
            document.getElementById('btn-move-down').disabled = this.selectedElement.layer === this.elements.length;
            document.getElementById('btn-send-to-back').disabled = this.selectedElement.layer === this.elements.length;
        }

        /**
         * 元素置顶 → 层级设为 1(最高)
         */
        bringToFront() {
            if (!this.selectedElement || this.elements.length <= 1 || this.selectedElement.layer === 1) return;
            this.selectedElement.layer = 1;
            this.sortElementsByLayer(); // 按层级排序
            this.updateUI(); // 更新 UI
        }

        /**
         * 元素置底 → 层级设为总元素数(最低)
         */
        sendToBack() {
            if (!this.selectedElement || this.elements.length <= 1 || this.selectedElement.layer === this.elements.length) return;
            this.selectedElement.layer = this.elements.length;
            this.sortElementsByLayer(); // 按层级排序
            this.updateUI(); // 更新 UI
        }

        /**
         * 元素上移 → 层级减 1
         */
        moveUp() {
            if (!this.selectedElement || this.elements.length <= 1 || this.selectedElement.layer <= 1) return;
            this.selectedElement.layer--;
            this.sortElementsByLayer(); // 按层级排序
            this.updateUI(); // 更新 UI
        }

        /**
         * 元素下移 → 层级加 1
         */
        moveDown() {
            if (!this.selectedElement || this.elements.length <= 1 || this.selectedElement.layer >= this.elements.length) return;
            this.selectedElement.layer++;
            this.sortElementsByLayer(); // 按层级排序
            this.updateUI(); // 更新 UI
        }

        /**
         * 按层级排序元素数组(层级 1 在前,层级越高越靠后)
         */
        sortElementsByLayer() {
            this.elements.sort((a, b) => a.layer - b.layer);
        }

        /**
         * 添加新元素(文本/图片/二维码)
         * @param {string} type - 元素类型(text/image/qrcode)
         */
        addElement(type) {
            // 计算新元素的层级(现有最高层级 + 1)
            const newLayer = this.elements.length > 0 ? Math.max(...this.elements.map(e => e.layer)) + 1 : 1;
            // 基础元素配置(所有元素通用属性)
            const element = {
                id: Date.now().toString(), // 用时间戳作为唯一 ID
                type,
                x: 50, // 默认 X 坐标
                y: 50, // 默认 Y 坐标
                width: 100, // 默认宽度
                height: 100, // 默认高度
                layer: newLayer,
                containerBackgroundColor: this.defaultContainerColors[type] // 默认容器颜色
            };

            // 根据类型添加专属属性
            switch (type) {
                case 'text':
                    element.text = `文字{text_${this.globalCounter}}`; // 默认文本内容
                    element.color = '#000000'; // 默认文本颜色(黑色)
                    element.fontSize = 16; // 默认字体大小
                    element.align = 'left'; // 默认对齐方式(左对齐)
                    element.overflow = 'wrap'; // 默认超出处理(自动换行)
                    break;
                case 'image':
                    element.src = `{image_${this.globalCounter}}`; // 默认图片 URL 占位符
                    element.borderColor = 'rgba(255,255,255,0)'; // 默认边框颜色(透明)
                    element.borderRadius = 0; // 默认圆角(0px)
                    break;
                case 'qrcode':
                    element.text = `{qr_${this.globalCounter}}`; // 默认二维码内容
                    break;
            }

            this.elements.push(element); // 添加到元素数组
            this.globalCounter++; // 计数器自增(下一个元素用)
            this.sortElementsByLayer(); // 按层级排序
            this.selectElement(element); // 自动选中新添加的元素
            this.updateUI(); // 更新 UI
        }

        /**
         * 选中元素(更新 selectedElement 并同步 UI)
         * @param {object} element - 要选中的元素
         */
        selectElement(element) {
            this.selectedElement = element;
            this.updateUI(); // 更新 UI(属性面板、元素选中框等)
        }

        /**
         * 更新右侧属性面板(根据选中元素类型显示/隐藏专属属性)
         */
        updatePropertiesPanel() {
            const panel = document.getElementById('element-properties');
            if (!this.selectedElement) {
                panel.style.display = 'none'; // 无选中元素则隐藏面板
                return;
            }
            panel.style.display = 'block'; // 显示面板

            // 1. 更新通用属性(坐标、宽高)
            document.getElementById('element-x').value = this.selectedElement.x || 0;
            document.getElementById('element-y').value = this.selectedElement.y || 0;
            document.getElementById('element-width-slider').value = this.selectedElement.width || 100;
            document.getElementById('element-width-value').value = this.selectedElement.width || 100;
            document.getElementById('element-height-slider').value = this.selectedElement.height || 100;
            document.getElementById('element-height-value').value = this.selectedElement.height || 100;

            // 2. 隐藏所有属性项(后续按需显示)
            document.querySelectorAll('[id$="-item"]').forEach(item => item.style.display = 'none');
            document.getElementById('delete-element-item').style.display = 'block'; // 显示删除按钮

            // 3. 根据元素类型显示专属属性项
            if (this.selectedElement.type === 'text') {
                // 文本元素专属属性:内容、颜色、字体大小、对齐方式、超出处理、容器背景
                ['text-content-item', 'text-color-item', 'text-font-size-item', 'text-align-item', 'text-overflow-item', 'container-bg-color-item'].forEach(id => {
                    document.getElementById(id).style.display = 'block';
                });
                // 同步文本属性值到 UI
                document.getElementById('element-text').value = this.selectedElement.text || '';
                document.getElementById('text-color-input').value = this.selectedElement.color || '#000000';
                document.getElementById('text-color').style.backgroundColor = this.selectedElement.color || '#000000';
                document.getElementById('text-font-size-slider').value = this.selectedElement.fontSize || 16;
                document.getElementById('text-font-size-value').value = this.selectedElement.fontSize || 16;
                document.getElementById('text-align').value = this.selectedElement.align || 'left';
                document.getElementById('text-overflow').value = this.selectedElement.overflow || 'wrap';
                // 同步容器背景色
                document.getElementById('container-bg-color-input').value = this.selectedElement.containerBackgroundColor || this.defaultContainerColors.text;
                document.getElementById('container-bg-color').style.backgroundColor = this.selectedElement.containerBackgroundColor || this.defaultContainerColors.text;
            } else if (this.selectedElement.type === 'image') {
                // 图片元素专属属性:URL、边框颜色、容器背景、圆角
                ['image-url-item', 'border-color-item', 'container-bg-color-item', 'image-border-radius-item'].forEach(id => {
                    document.getElementById(id).style.display = 'block';
                });
                // 同步图片属性值到 UI
                document.getElementById('image-url').value = this.selectedElement.src || '';
                document.getElementById('border-color-input').value = this.selectedElement.borderColor || 'rgba(255,255,255,0)';
                document.getElementById('border-color').style.backgroundColor = this.selectedElement.borderColor || 'rgba(255,255,255,0)';
                // 同步容器背景色
                document.getElementById('container-bg-color-input').value = this.selectedElement.containerBackgroundColor || this.defaultContainerColors.image;
                document.getElementById('container-bg-color').style.backgroundColor = this.selectedElement.containerBackgroundColor || this.defaultContainerColors.image;
                // 同步圆角
                document.getElementById('image-border-radius-slider').value = this.selectedElement.borderRadius || 0;
                document.getElementById('image-border-radius-value').value = this.selectedElement.borderRadius || 0;
            } else if (this.selectedElement.type === 'qrcode') {
                // 二维码元素专属属性:内容、容器背景
                ['qrcode-url-item', 'container-bg-color-item'].forEach(id => {
                    document.getElementById(id).style.display = 'block';
                });
                // 同步二维码属性值到 UI
                document.getElementById('qrcode-url').value = this.selectedElement.text || '';
                // 同步容器背景色
                document.getElementById('container-bg-color-input').value = this.selectedElement.containerBackgroundColor || this.defaultContainerColors.qrcode;
                document.getElementById('container-bg-color').style.backgroundColor = this.selectedElement.containerBackgroundColor || this.defaultContainerColors.qrcode;
            }

            this.updateLayerUI(); // 更新层级控制 UI
        }

        /**
         * 更新元素选中框(覆盖层,显示缩放手柄)
         */
        updateElementOverlay() {
            const overlay = document.getElementById('element-overlay');
            if (!this.selectedElement || this.isCanvasResizing) {
                overlay.classList.add('hidden'); // 无选中元素/画布缩放时隐藏
                return;
            }

            // 1. 设置选中框位置和大小(与元素一致)
            overlay.style.cssText = `left: ${this.selectedElement.x}px; top: ${this.selectedElement.y}px; width: ${this.selectedElement.width}px; height: ${this.selectedElement.height}px;`;
            overlay.classList.remove('hidden'); // 显示选中框

            // 2. 移除现有缩放手柄(避免重复)
            overlay.querySelectorAll('.resize-handle').forEach(handle => handle.remove());

            // 3. 添加四个方向的缩放手柄(nw/ne/sw/se)
            ['nw', 'ne', 'sw', 'se'].forEach(dir => {
                const handle = document.createElement('div');
                handle.className = `resize-handle resize-${dir}`;
                // 手柄点击 → 开始缩放元素
                handle.addEventListener('mousedown', (e) => {
                    e.stopPropagation(); // 阻止事件冒泡
                    this.isResizing = true;
                    this.resizeDirection = dir; // 记录缩放方向
                });
                overlay.appendChild(handle);
            });
        }

        /**
         * 画布点击事件处理 → 选中元素/取消选中
         * @param {Event} e - 鼠标点击事件
         */
        handleMouseDown(e) {
            // 过滤:画布缩放/面板拖拽/画布拖拽时不处理
            if (this.isCanvasResizing || this.isDraggingPanel || this.isDraggingCanvas) return;

            const rect = this.canvas.getBoundingClientRect();
            // 计算鼠标在画布坐标系中的位置(相对于画布左上角)
            const x = e.clientX - rect.left;
            const y = e.clientY - rect.top;

            // 点击缩放手柄则不处理(交给缩放逻辑)
            if (this.selectedElement && Array.from(document.querySelectorAll('.resize-handle')).some(h => h.contains(e.target))) {
                return;
            }

            // 查找点击位置的元素(倒序遍历,优先选中上层元素)
            const clickedElement = [...this.elements].reverse().find(el => this.isPointInElement(x, y, el));

            if (clickedElement) {
                this.selectElement(clickedElement); // 选中点击的元素
                this.isDragging = true; // 标记为拖拽状态
                // 计算拖拽偏移量(鼠标相对于元素左上角的位置)
                this.dragOffset.x = x - clickedElement.x;
                this.dragOffset.y = y - clickedElement.y;
            } else {
                this.selectedElement = null; // 未点击到元素 → 取消选中
                this.updateUI(); // 更新 UI
            }
        }

        /**
         * 鼠标移动事件处理 → 拖拽元素/缩放元素/缩放画布
         * @param {Event} e - 鼠标移动事件
         */
        handleMouseMove(e) {
            // 1. 画布缩放逻辑
            if (this.isCanvasResizing) {
                const rect = this.canvas.getBoundingClientRect();
                const newWidth = e.clientX - rect.left; // 新宽度 = 鼠标X - 画布左边界
                const newHeight = e.clientY - rect.top; // 新高度 = 鼠标Y - 画布上边界
                // 限制画布大小不小于最小值
                if (newWidth > this.canvasMinWidth && newHeight > this.canvasMinHeight) {
                    this.canvas.width = newWidth;
                    this.canvas.height = newHeight;
                    // 同步更新输入框值
                    document.getElementById('canvas-width').value = newWidth;
                    document.getElementById('canvas-height').value = newHeight;
                    this.render(); // 重新渲染画布
                }
                return;
            }

            // 2. 未拖拽/缩放则返回
            if (!this.isDragging && !this.isResizing) return;

            const rect = this.canvas.getBoundingClientRect();
            // 计算鼠标在画布坐标系中的位置
            const x = e.clientX - rect.left;
            const y = e.clientY - rect.top;

            // 3. 元素拖拽逻辑
            if (this.isDragging && this.selectedElement) {
                // 元素新坐标 = 鼠标位置 - 拖拽偏移量(避免点击时跳动)
                this.selectedElement.x = x - this.dragOffset.x;
                this.selectedElement.y = y - this.dragOffset.y;
                // 同步更新输入框值
                document.getElementById('element-x').value = this.selectedElement.x;
                document.getElementById('element-y').value = this.selectedElement.y;
                this.updateElementOverlay(); // 更新选中框
                this.render(); // 重新渲染画布
            }
            // 4. 元素缩放逻辑
            else if (this.isResizing && this.selectedElement) {
                const minSize = 10; // 元素最小宽高
                let newX = this.selectedElement.x, newY = this.selectedElement.y;
                let newWidth = this.selectedElement.width, newHeight = this.selectedElement.height;

                // 根据缩放方向计算新坐标和宽高
                switch (this.resizeDirection) {
                    case 'nw': // 左上 → 同时调整X、Y、宽度、高度
                        newX = x;
                        newY = y;
                        newWidth = this.selectedElement.x + this.selectedElement.width - x;
                        newHeight = this.selectedElement.y + this.selectedElement.height - y;
                        break;
                    case 'ne': // 右上 → 调整Y、宽度、高度
                        newY = y;
                        newWidth = x - this.selectedElement.x;
                        newHeight = this.selectedElement.y + this.selectedElement.height - y;
                        break;
                    case 'sw': // 左下 → 调整X、宽度、高度
                        newX = x;
                        newWidth = this.selectedElement.x + this.selectedElement.width - x;
                        newHeight = y - this.selectedElement.y;
                        break;
                    case 'se': // 右下 → 仅调整宽度、高度
                        newWidth = x - this.selectedElement.x;
                        newHeight = y - this.selectedElement.y;
                        break;
                }

                // 限制元素大小不小于最小值
                if (newWidth >= minSize && newHeight >= minSize) {
                    // 更新元素属性
                    Object.assign(this.selectedElement, {x: newX, y: newY, width: newWidth, height: newHeight});
                    // 同步更新输入框/滑块值
                    document.getElementById('element-x').value = newX;
                    document.getElementById('element-y').value = newY;
                    document.getElementById('element-width-slider').value = newWidth;
                    document.getElementById('element-width-value').value = newWidth;
                    document.getElementById('element-height-slider').value = newHeight;
                    document.getElementById('element-height-value').value = newHeight;
                    this.updateElementOverlay(); // 更新选中框
                    this.render(); // 重新渲染画布
                }
            }
        }

        /**
         * 鼠标抬起/离开事件处理 → 结束拖拽/缩放
         */
        handleMouseUp() {
            // 重置所有拖拽/缩放状态
            this.isDragging = this.isResizing = this.isCanvasResizing = false;
            this.resizeDirection = this.draggingIndex = -1; // 重置方向和拖拽索引
            this.updateElementOverlay(); // 更新选中框(恢复正常状态)
            this.updateJSONPreview(); // 更新 JSON 预览(同步最新数据)
        }

        /**
         * 判断点是否在元素内(用于点击选中元素)
         * @param {number} x - 点的 X 坐标(画布坐标系)
         * @param {number} y - 点的 Y 坐标(画布坐标系)
         * @param {object} element - 要判断的元素
         * @returns {boolean} 是否在元素内
         */
        isPointInElement(x, y, element) {
            // 元素矩形区域:x ≤ 点X ≤ x+宽度,y ≤ 点Y ≤ y+高度
            return x >= element.x && x <= element.x + element.width && y >= element.y && y <= element.y + element.height;
        }

        /**
         * 核心渲染方法 → 绘制画布背景和所有元素
         */
        render() {
            // 1. 清空画布(避免残影)
            this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);

            // 2. 绘制画布背景(从颜色选择器获取)
            const bgColor = document.getElementById('canvas-bg-color-input').value;
            this.ctx.fillStyle = bgColor;
            this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);

            // 3. 绘制所有元素(倒序遍历,上层元素后绘制,覆盖下层)
            [...this.elements].reverse().forEach(element => {
                // 绘制元素容器背景(区分元素类型的默认颜色)
                const containerBg = element.containerBackgroundColor || this.defaultContainerColors[element.type];
                this.ctx.fillStyle = containerBg;
                this.ctx.beginPath();

                // 图片元素支持圆角容器
                if (element.type === 'image' && element.borderRadius > 0) {
                    this.ctx.roundRect(element.x, element.y, element.width, element.height, element.borderRadius);
                } else {
                    this.ctx.rect(element.x, element.y, element.width, element.height); // 矩形容器
                }
                this.ctx.fill(); // 填充容器背景

                // 图片元素绘制边框
                if (element.type === 'image') {
                    const borderColor = element.borderColor || 'rgba(255,255,255,0)';
                    this.ctx.strokeStyle = borderColor;
                    this.ctx.lineWidth = 1;
                    this.ctx.stroke(); // 绘制边框
                }

                // 绘制元素内容(文本/图片/二维码)
                this.renderElement(element);
            });
        }

        /**
         * 绘制单个元素内容(根据类型调用不同绘制方法)
         * @param {object} element - 要绘制的元素
         */
        renderElement(element) {
            switch (element.type) {
                case 'text':
                    this.renderTextElement(element); // 绘制文本
                    break;
                case 'image':
                    this.renderImageElement(element); // 绘制图片
                    break;
                case 'qrcode':
                    this.renderQrCodeElement(element); // 绘制二维码
                    break;
            }
        }

        /**
         * 绘制文本元素(支持换行、对齐、超出省略)
         * @param {object} element - 文本元素
         */
        renderTextElement(element) {
            this.ctx.fillStyle = element.color || '#000000'; // 文本颜色
            this.ctx.font = `${element.fontSize || 16}px Arial`; // 字体大小和字体族
            const text = element.text || '';
            const maxWidth = element.width; // 文本最大宽度(元素宽度)
            const lineHeight = (element.fontSize || 16) * 1.2; // 行高(字体大小的 1.2 倍)
            const x = element.x; // 文本起始 X 坐标
            const y = element.y + (element.fontSize || 16); // 文本起始 Y 坐标(向下偏移一个字体大小)

            // 处理文本超出:换行/省略
            let lines = element.overflow === 'wrap' ? this.wrapText(text, maxWidth) : [this.getEllipsisText(text, maxWidth)];
            const totalHeight = lines.length * lineHeight; // 文本总高度

            // 绘制每一行文本
            lines.forEach((line, index) => {
                let drawX = x; // 每行文本的 X 坐标
                // 根据对齐方式调整 X 坐标
                if (element.align === 'center') {
                    // 居中:X = 元素X + (元素宽度 - 该行文本宽度)/2
                    drawX = x + (maxWidth - this.ctx.measureText(line).width) / 2;
                } else if (element.align === 'right') {
                    // 右对齐:X = 元素X + 元素宽度 - 该行文本宽度
                    drawX = x + maxWidth - this.ctx.measureText(line).width;
                }

                // 垂直居中:Y = 起始Y - (总高度 - 行高)/2 + 行索引*行高
                const drawY = y - (totalHeight - lineHeight) / 2 + index * lineHeight;
                this.ctx.fillText(line, drawX, drawY); // 绘制文本
            });
        }

        /**
         * 文本换行处理(按空格分割,避免单词截断)
         * @param {string} text - 要处理的文本
         * @param {number} maxWidth - 最大宽度(元素宽度)
         * @returns {array} 换行后的文本行数组
         */
        wrapText(text, maxWidth) {
            // 按空格分割单词
            return text.split(' ').reduce((acc, word) => {
                const lastLine = acc[acc.length - 1] || ''; // 最后一行文本
                const testLine = lastLine ? `${lastLine} ${word}` : word; // 测试添加当前单词后的行

                // 如果添加后宽度不超过最大宽度 → 更新最后一行
                if (this.ctx.measureText(testLine).width <= maxWidth && lastLine) {
                    acc[acc.length - 1] = testLine;
                } else {
                    acc.push(word); // 超过则新增一行
                }
                return acc;
            }, []);
        }

        /**
         * 文本超出省略处理(末尾添加 ...)
         * @param {string} text - 要处理的文本
         * @param {number} maxWidth - 最大宽度(元素宽度)
         * @returns {string} 省略后的文本
         */
        getEllipsisText(text, maxWidth) {
            // 文本宽度未超出则直接返回
            if (this.ctx.measureText(text).width <= maxWidth) return text;

            let truncated = text;
            // 逐字删除,直到添加 ... 后宽度不超出
            while (this.ctx.measureText(truncated + '...').width > maxWidth && truncated.length > 0) {
                truncated = truncated.slice(0, -1);
            }
            return truncated + '...'; // 返回省略后的文本
        }

        /**
         * 绘制图片元素(支持网络图片、占位符、圆角裁剪)
         * @param {object} element - 图片元素
         */
        renderImageElement(element) {
            // 网络图片(以 http/https 开头)
            if (element.src && element.src.startsWith('http')) {
                const img = new Image();
                img.crossOrigin = 'anonymous'; // 解决跨域图片绘制问题
                const renderProps = {...element}; // 保存当前元素属性(避免异步绘制时属性变化)

                // 图片加载成功 → 绘制图片(支持圆角裁剪)
                img.onload = () => {
                    this.ctx.save(); // 保存当前绘图状态
                    // 圆角裁剪(圆角半径 > 0 时)
                    if (renderProps.borderRadius > 0) {
                        this.ctx.beginPath();
                        this.ctx.roundRect(renderProps.x, renderProps.y, renderProps.width, renderProps.height, renderProps.borderRadius);
                        this.ctx.clip(); // 裁剪成圆角
                    }
                    // 绘制图片(铺满元素区域)
                    this.ctx.drawImage(img, renderProps.x, renderProps.y, renderProps.width, renderProps.height);
                    this.ctx.restore(); // 恢复绘图状态
                };

                // 图片加载失败 → 绘制占位符
                img.onerror = () => this.renderImagePlaceholder(renderProps);
                img.src = element.src; // 设置图片 URL
            } else {
                // 非网络图片 → 绘制占位符
                this.renderImagePlaceholder(element);
            }
        }

        /**
         * 绘制图片占位符(显示文本提示)
         * @param {object} props - 图片元素属性
         */
        renderImagePlaceholder(props) {
            this.ctx.fillStyle = '#999'; // 占位符文本颜色
            this.ctx.font = '12px Arial'; // 字体大小
            this.ctx.textAlign = 'center'; // 水平居中
            this.ctx.textBaseline = 'middle'; // 垂直居中
            // 显示提示文本(优先显示 text,无则显示 src,再无则显示默认提示)
            const placeholderText = props.text || props.src || '图片占位符';
            this.ctx.fillText(placeholderText, props.x + props.width / 2, props.y + props.height / 2);
            // 恢复默认文本对齐方式(避免影响其他绘制)
            this.ctx.textAlign = 'left';
            this.ctx.textBaseline = 'alphabetic';
        }

        /**
         * 绘制二维码占位符(简化版,实际项目可集成 qrcode.js 生成真实二维码)
         * @param {object} element - 二维码元素
         */
        renderQrCodeElement(element) {
            // 绘制二维码外框(黑色)
            this.ctx.fillStyle = '#000000';
            this.ctx.fillRect(element.x + 5, element.y + 5, element.width - 10, element.height - 10);

            // 绘制二维码定位点(三个角的白色方块)
            const posSize = (element.width - 10) / 7; // 定位点大小
            this.ctx.fillStyle = '#ffffff';
            this.ctx.fillRect(element.x + 8, element.y + 8, posSize, posSize); // 左上定位点
            this.ctx.fillRect(element.x + element.width - posSize - 8, element.y + 8, posSize, posSize); // 右上定位点
            this.ctx.fillRect(element.x + 8, element.y + element.height - posSize - 8, posSize, posSize); // 左下定位点

            // 绘制二维码内容提示(居中)
            this.ctx.font = '10px Arial';
            this.ctx.textAlign = 'center';
            this.ctx.textBaseline = 'middle';
            const qrText = element.text || '二维码内容';
            this.ctx.fillText(qrText, element.x + element.width / 2, element.y + element.height / 2);
            // 恢复默认文本对齐方式
            this.ctx.textAlign = 'left';
            this.ctx.textBaseline = 'alphabetic';
        }

        /**
         * 更新 JSON 预览区域(显示当前海报的完整数据)
         */
        updateJSONPreview() {
            const jsonData = {
                id: document.getElementById('poster-id').value || '', // 新增:包含海报ID
                name: document.getElementById('json-name').value.trim() || '未命名海报', // 包含名称
                materials: {
                    width: this.canvas.width, // 画布宽度
                    height: this.canvas.height, // 画布高度
                    backgroundColor: document.getElementById('canvas-bg-color-input').value, // 画布背景色
                    elements: this.elements.map(element => {
                        const elemCopy = {...element};
                        delete elemCopy.id; // 移除前端临时 ID(后端不需要)
                        return elemCopy;
                    })
                }
            };
            // 格式化 JSON 并显示(缩进 2 空格,便于阅读)
            document.getElementById('json-preview').textContent = JSON.stringify(jsonData, null, 2);
        }

        /**
         * 保存 JSON 到后端(支持新增/编辑,自动携带 ID)
         * 数据格式:{ id: '海报ID', name: '海报名称', materials: { 画布+元素数据 } }
         */
        saveJSON() {
            if (typeof $ === 'undefined') {
                alert('错误:未检测到 jQuery 库。请先引入 jQuery 才能使用保存功能。');
                return;
            }

            // 1. 获取核心数据
            const posterId = document.getElementById('poster-id').value.trim(); // 获取隐藏域的 ID
            const posterName = document.getElementById('json-name').value.trim() || '未命名海报';

            // 2. 构造请求体(包含 ID,新增时为空字符串)
            const requestData = {
                id: posterId, // 关键:编辑时携带 ID,新增时为空
                name: posterName,
                materials: {
                    width: this.canvas.width,
                    height: this.canvas.height,
                    backgroundColor: document.getElementById('canvas-bg-color-input').value,
                    elements: this.elements.map(element => {
                        const elemCopy = {...element};
                        delete elemCopy.id; // 移除前端临时 ID
                        return elemCopy;
                    })
                }
            };

            // 3. 接口地址(用户可根据后端逻辑调整,新增/编辑可共用一个接口)
            const apiUrl = "{:url('add_post')}"; // 后端统一接收接口(支持新增/编辑)

            // 4. 发送 AJAX 请求
            $.ajax({
                url: apiUrl,
                type: 'POST',
                contentType: 'application/json',
                data: JSON.stringify(requestData),
                success: function (response) {
                    console.log('保存成功:', response);

                    // 显示成功提示
                    const successMsg = response.msg || (posterId ? `海报 "${posterName}" 已成功更新!` : `海报 "${posterName}" 已成功创建!`);
                    parent.layer.msg(successMsg, {icon: 1});

                    // 延迟关闭弹框并刷新父页面
                    setTimeout(function () {
                        parent.layer.closeAll();
                        parent.location.reload();
                    }, 500);

                    // 如果是新增,后端返回新 ID 后更新隐藏域(可选)
                    if (!posterId && response.data?.id) {
                        document.getElementById('poster-id').value = response.data.id;
                    }
                },
                error: function (jqXHR, textStatus, errorThrown) {
                    console.error('保存失败:', textStatus, errorThrown);
                    parent.layer.msg('保存失败:' + (errorThrown || '网络异常'), {icon: 2});
                }
            });
        }

        /**
         * 删除元素(根据 ID 从 elements 数组中移除)
         * @param {string} elementId - 要删除的元素 ID
         */
        deleteElement(elementId) {
            const index = this.elements.findIndex(el => el.id === elementId); // 查找元素索引
            if (index !== -1) {
                this.elements.splice(index, 1); // 从数组中移除
                this.reassignLayers(); // 重新分配层级
                this.reassignNames(); // 重新分配元素名称
                this.selectedElement = null; // 取消选中(删除的元素已不存在)
                this.updateUI(); // 更新 UI
            }
        }

        /**
         * 统一更新 UI(调用所有需要刷新的方法,避免重复代码)
         */
        updateUI() {
            this.render(); // 渲染画布
            this.renderElementList(); // 渲染元素层级列表
            this.updatePropertiesPanel(); // 更新属性面板
            this.updateElementOverlay(); // 更新元素选中框
            this.updateJSONPreview(); // 更新 JSON 预览
            this.adjustElementListPanelPosition(); // 调整层级面板位置
        }
    }

    // 页面 DOM 加载完成后初始化编辑器
    document.addEventListener('DOMContentLoaded', () => {
        new PosterEditor('poster-canvas'); // 传入画布 ID 初始化
    });
</script>

前端css代码

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
    background-color: #f5f7fa;
    color: #333;
    display: flex;
    flex-direction: column;
    height: 100vh;
    overflow: hidden;
}

.toolbar {
    display: flex;
    gap: 10px;
}

.btn {
    padding: 8px 16px;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    font-size: 14px;
    transition: all 0.3s;
}

.btn-primary {
    background: #409EFF;
    color: white;
}

.btn-default {
    background: #f4f4f5;
    color: #606266;
    border: 1px solid #dcdfe6;
}

.btn-default:disabled {
    background: #ebeef5;
    color: #c0c4cc;
    cursor: not-allowed;
}

.container {
    display: flex;
    flex: 1;
    overflow: hidden;
}

.sidebar {
    width: 240px;
    background: white;
    padding: 16px;
    overflow-y: auto;
    border-right: 1px solid #dcdfe6;
    transition: width 0.3s ease;
    position: relative;
    flex-shrink: 0;
}

.sidebar.collapsed {
    width: 40px;
}

.sidebar-toggle {
    position: absolute;
    top: 10px;
    right: 10px;
    width: 24px;
    height: 24px;
    line-height: 24px;
    text-align: center;
    cursor: pointer;
    background: #f4f4f5;
    border-radius: 4px;
    z-index: 10;
    transition: transform 0.3s ease;
    user-select: none;
}

.sidebar.collapsed .sidebar-toggle {
    transform: rotate(90deg);
}

.sidebar.collapsed .component-list {
    display: none;
}

.component-list {
    display: flex;
    flex-direction: column;
    gap: 12px;
    margin-top: 40px;
}

.component-item {
    padding: 12px;
    background: #f9f9f9;
    border-radius: 4px;
    cursor: move;
    text-align: center;
    border: 1px dashed #dcdfe6;
    transition: all 0.2s;
}

.component-item:hover {
    border-color: #409EFF;
    color: #409EFF;
}

.editor-area {
    flex: 1;
    padding: 40px 20px;
    overflow: auto;
    display: flex;
    justify-content: center;
    align-items: flex-start;
    position: relative; /* 作为保存按钮和输入框的定位父容器 */
}

.canvas-container {
    position: relative;
    background: white;
    box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
    border-radius: 4px;
    overflow: hidden;
    cursor: move;
    margin-top: 20px;
    margin-right: 40%;
}

#poster-canvas {
    display: block;
    background: white;
}

.canvas-drag-handle {
    position: absolute;
    top: -12px;
    left: -12px;
    width: 24px;
    height: 24px;
    line-height: 24px;
    text-align: center;
    background: rgba(64, 158, 255, 0.9);
    color: white;
    border-radius: 50%;
    cursor: move;
    z-index: 1000;
    user-select: none;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
    border: 2px solid white;
}

.canvas-drag-handle:hover {
    background: #409EFF;
}

.element-overlay {
    position: absolute;
    border: 1px dashed #409EFF;
    pointer-events: none;
}

.properties-panel {
    width: 280px;
    background: white;
    padding: 16px;
    overflow-y: auto;
    border-left: 1px solid #dcdfe6;
    position: fixed;
    top: 0;
    bottom: 0;
    right: 0;
    transition: right 0.1s ease;
    z-index: 90;
    display: flex;
    flex-direction: column;
}

/* --- 新增的 CSS 样式 --- */
/* 包裹名称输入框的容器 */
.json-name-input {
    position: absolute;
    bottom: 110px; /* 在保存按钮上方 */
    right: 310px; /* 与保存按钮对齐 */
    display: flex;
    align-items: center;
    gap: 10px; /* 标签和输入框之间的间距 */
    width: 100%;
    max-width: 300px; /* 限制最大宽度 */
    z-index: 100;
}

/* 名称标签 */
.json-name-input label {
    font-size: 14px;
    color: #606266;
    white-space: nowrap; /* 防止标签文字换行 */
}

/* 名称输入框 */
.json-name-input input[type="text"] {
    flex: 1; /* 让输入框占据剩余空间 */
    padding: 8px 12px;
    border: 1px solid #dcdfe6;
    border-radius: 4px;
    font-size: 14px;
    transition: border-color 0.3s ease;
}

/* 输入框聚焦时的样式 */
.json-name-input input[type="text"]:focus {
    outline: none;
    border-color: #409EFF; /* 聚焦时边框变色,与主题一致 */
    box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}

/* --- CSS 样式结束 --- */

/* 关键改动:调整保存按钮的 bottom 值,为输入框留出空间 */
#save-json-btn {
    position: absolute;
    bottom: 70px; /* 从 30px 调整到 70px */
    right: 310px; /* 画布属性面板宽度280px + 间距30px */
    width: 120px;
    padding: 10px;
    font-size: 14px;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
    z-index: 100;
    background-color: #409EFF;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    transition: all 0.3s ease;
    transform: none;
}

#save-json-btn:hover {
    transform: translateY(-2px);
    box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
    background-color: #3689e6;
}

.property-group {
    margin-bottom: 15px;
}

.property-title {
    font-size: 14px;
    font-weight: bold;
    margin-bottom: 10px;
    color: #606266;
}

.property-item {
    margin-bottom: 12px;
}

.property-label {
    font-size: 12px;
    color: #909399;
    margin-bottom: 4px;
}

.property-input, .property-select {
    width: 100%;
    padding: 8px;
    border: 1px solid #dcdfe6;
    border-radius: 4px;
    font-size: 14px;
}

.property-slider {
    width: 100%;
    display: flex;
    align-items: center;
    gap: 10px;
}

.property-slider input[type="range"] {
    flex: 1;
    height: 6px;
    border-radius: 3px;
    background: #e6e6e6;
    outline: none;
    -webkit-appearance: none;
}

.property-slider input[type="range"]::-webkit-slider-thumb {
    -webkit-appearance: none;
    width: 16px;
    height: 16px;
    border-radius: 50%;
    background: #409EFF;
    cursor: pointer;
    border: none;
}

.property-slider .slider-value {
    width: 40px;
    text-align: center;
    border: 1px solid #dcdfe6;
    border-radius: 4px;
    padding: 4px 0;
    font-size: 12px;
}

.color-picker {
    display: flex;
    align-items: center;
}

.color-preview {
    width: 24px;
    height: 24px;
    border-radius: 4px;
    margin-right: 8px;
    border: 1px solid #dcdfe6;
}

.json-preview {
    background: #2d2d2d;
    color: #f8f8f2;
    padding: 12px;
    border-radius: 4px;
    font-family: 'Courier New', monospace;
    font-size: 12px;
    max-height: 300px;
    overflow-y: auto;
    white-space: pre-wrap;
    margin-top: auto;
}

.hidden {
    display: none;
}

.resize-handle {
    position: absolute;
    width: 8px;
    height: 8px;
    background: #409EFF;
    border: 1px solid white;
    border-radius: 50%;
    z-index: 10;
    pointer-events: all;
    cursor: pointer;
}

.resize-nw {
    top: -4px;
    left: -4px;
    cursor: nw-resize;
}

.resize-ne {
    top: -4px;
    right: -4px;
    cursor: ne-resize;
}

.resize-sw {
    bottom: -4px;
    left: -4px;
    cursor: sw-resize;
}

.resize-se {
    bottom: -4px;
    right: -4px;
    cursor: se-resize;
}

.canvas-resize-handle {
    width: 12px;
    height: 12px;
    bottom: -6px;
    right: -6px;
    box-shadow: 0 0 0 1px #409EFF, 0 2px 4px rgba(0, 0, 0, 0.2);
}

.layer-control {
    margin-top: 15px;
    padding-top: 10px;
    border-top: 1px solid #e6e6e6;
}

.layer-control .btn-group {
    display: flex;
    gap: 5px;
    margin-top: 8px;
}

.layer-control .btn-group .btn {
    flex: 1;
    padding: 6px;
    font-size: 12px;
}

.layer-control .layer-info {
    font-size: 12px;
    color: #606266;
    margin-bottom: 8px;
}

.element-list-panel {
    position: fixed;
    right: 300px;
    top: 20px;
    background-color: white;
    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
    border-radius: 4px;
    min-width: 150px;
    width: 300px; /* 加宽默认宽度 */
    padding: 12px;
    z-index: 100;
    max-height: calc(100vh - 220px);
    display: flex;
    flex-direction: column;
    border: 1px solid #dcdfe6;
    cursor: move;
    transition: box-shadow 0.2s ease;
}

.element-list-panel:hover {
    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}

.element-list-panel.dragging {
    opacity: 0.8;
    box-shadow: 0 6px 24px rgba(0, 0, 0, 0.2);
}

.panel-title {
    font-size: 14px;
    font-weight: bold;
    margin-bottom: 8px;
    color: #606266;
    cursor: move;
    user-select: none;
    display: flex;
    align-items: center;
    gap: 8px;
}

.panel-title::before {
    content: '☰';
    font-size: 12px;
    color: #909399;
}

.element-list {
    flex: 1;
    overflow-y: auto;
    max-height: calc(100vh - 100px);
    margin-bottom: 10px;
    cursor: default;
}

.element-list-item {
    padding: 8px 10px;
    margin-bottom: 6px;
    border: 1px solid #dcdfe6;
    border-radius: 4px;
    cursor: move;
    font-size: 13px;
    transition: all 0.2s;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    display: flex;
    align-items: center;
    gap: 8px;
}

.element-list-item:hover {
    border-color: #409EFF;
    background-color: #f5faff;
}

.element-list-item.selected {
    border-color: #409EFF;
    background-color: #e8f4ff;
    color: #409EFF;
}

.element-list-item.dragging {
    opacity: 0.5;
    background-color: #f0f0f0;
}

.element-icon {
    width: 16px;
    height: 16px;
    border-radius: 2px;
    margin-right: 8px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 10px;
    color: white;
}

.text-icon {
    background: #67c23a; /* 文本:绿色 */
}

.image-icon {
    background: #e6a23c; /* 图片:橙色 */
}

.qrcode-icon {
    background: #f56c6c; /* 二维码:红色 */
}

.element-name {
    flex: 1;
    overflow: hidden;
    text-overflow: ellipsis;
}

.layer-badge {
    background-color: #409EFF;
    color: white;
    font-size: 10px;
    padding: 1px 4px;
    border-radius: 4px;
    margin-left: auto;
}

.delete-btn {
    width: 18px;
    height: 18px;
    line-height: 18px;
    text-align: center;
    border-radius: 50%;
    background-color: #f56c6c;
    color: white;
    font-size: 12px;
    cursor: pointer;
    margin-left: 5px;
    transition: background-color 0.2s;
}

.delete-btn:hover {
    background-color: #e4393c;
}

.drag-handle {
    color: #c0c4cc;
    cursor: move;
    margin-left: 5px;
}

.panel-resize-handle {
    position: absolute;
    top: 0;
    right: 0;
    width: 6px;
    height: 100%;
    cursor: ew-resize;
    z-index: 101;
    background-color: transparent;
}

.panel-resize-handle:hover {
    background-color: rgba(64, 158, 255, 0.1);
}

#delete-element-btn {
    width: 100%;
    background-color: #f56c6c;
    color: white;
    border: none;
    padding: 8px;
    border-radius: 4px;
    cursor: pointer;
    transition: background-color 0.3s;
}

#delete-element-btn:hover {
    background-color: #e4393c;
}

后台数据库

ps:我这有写好的例子

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for cmf_poster
-- ----------------------------
DROP TABLE IF EXISTS `cmf_poster`;
CREATE TABLE `cmf_poster`  (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '名称',
  `materials` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '海报图内容',
  `create_time` bigint(20) NULL DEFAULT NULL COMMENT '创建时间',
  `update_time` bigint(20) NULL DEFAULT NULL COMMENT '更新时间',
  `delete_time` bigint(20) NULL DEFAULT 0 COMMENT '删除时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 7 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '海报图管理' ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of cmf_poster
-- ----------------------------
INSERT INTO `cmf_poster` VALUES (4, '测试', '{\"width\":\"422\",\"height\":\"750\",\"backgroundColor\":\"#ffffff\",\"elements\":[{\"type\":\"text\",\"x\":\"50\",\"y\":\"50\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"1\",\"containerBackgroundColor\":\"#e8f4f8\",\"text\":\"文字{text_1}\",\"color\":\"#000000\",\"fontSize\":\"16\",\"align\":\"left\",\"overflow\":\"wrap\"},{\"type\":\"image\",\"x\":\"239\",\"y\":\"200\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"2\",\"containerBackgroundColor\":\"#faf6ed\",\"src\":\"{image_2}\",\"borderColor\":\"rgba(255,255,255,0)\",\"borderRadius\":\"0\"},{\"type\":\"qrcode\",\"x\":\"86\",\"y\":\"336\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"3\",\"containerBackgroundColor\":\"#fdf2f8\",\"text\":\"{qr_3}\"}]}', 1763798735, NULL, 0);
INSERT INTO `cmf_poster` VALUES (5, '888', '{\"width\":\"422\",\"height\":\"750\",\"backgroundColor\":\"#ffffff\",\"elements\":[{\"type\":\"text\",\"x\":\"69\",\"y\":\"32\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"1\",\"containerBackgroundColor\":\"#e8f4f8\",\"text\":\"文字{text_1}\",\"color\":\"#000000\",\"fontSize\":\"16\",\"align\":\"left\",\"overflow\":\"wrap\"},{\"type\":\"image\",\"x\":\"198\",\"y\":\"522\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"2\",\"containerBackgroundColor\":\"#faf6ed\",\"src\":\"{image_2}\",\"borderColor\":\"rgba(255,255,255,0)\",\"borderRadius\":\"0\"},{\"type\":\"qrcode\",\"x\":\"38\",\"y\":\"152\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"3\",\"containerBackgroundColor\":\"#fdf2f8\",\"text\":\"{qr_3}\"},{\"type\":\"qrcode\",\"x\":\"231\",\"y\":\"39\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"4\",\"containerBackgroundColor\":\"#fdf2f8\",\"text\":\"{qr_4}\"},{\"type\":\"qrcode\",\"x\":\"71\",\"y\":\"542\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"5\",\"containerBackgroundColor\":\"#fdf2f8\",\"text\":\"{qr_5}\"},{\"type\":\"image\",\"x\":\"284\",\"y\":\"255\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"6\",\"containerBackgroundColor\":\"#faf6ed\",\"src\":\"{image_6}\",\"borderColor\":\"rgba(255,255,255,0)\",\"borderRadius\":\"0\"},{\"type\":\"text\",\"x\":\"277\",\"y\":\"397\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"7\",\"containerBackgroundColor\":\"#e8f4f8\",\"text\":\"文字{text_7}\",\"color\":\"#000000\",\"fontSize\":\"16\",\"align\":\"left\",\"overflow\":\"wrap\"},{\"type\":\"text\",\"x\":\"74\",\"y\":\"341\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"8\",\"containerBackgroundColor\":\"#e8f4f8\",\"text\":\"文字{text_8}\",\"color\":\"#000000\",\"fontSize\":\"16\",\"align\":\"left\",\"overflow\":\"wrap\"}]}', 1763799146, NULL, 0);
INSERT INTO `cmf_poster` VALUES (6, '测试全屏444888', '{\"width\":\"422\",\"height\":\"750\",\"backgroundColor\":\"#ffffff\",\"elements\":[{\"type\":\"text\",\"x\":\"157\",\"y\":\"8\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"1\",\"containerBackgroundColor\":\"#e8f4f8\",\"text\":\"文字{text_1}\",\"color\":\"#000000\",\"fontSize\":\"16\",\"align\":\"left\",\"overflow\":\"wrap\"},{\"type\":\"text\",\"x\":\"29\",\"y\":\"114\",\"width\":\"114\",\"height\":\"39\",\"layer\":\"2\",\"containerBackgroundColor\":\"#e8f4f8\",\"text\":\"文字{text_2}\",\"color\":\"#000000\",\"fontSize\":\"16\",\"align\":\"left\",\"overflow\":\"wrap\"},{\"type\":\"text\",\"x\":\"294\",\"y\":\"476\",\"width\":\"126\",\"height\":\"27\",\"layer\":\"3\",\"containerBackgroundColor\":\"#e8f4f8\",\"text\":\"文字{text_3}\",\"color\":\"#000000\",\"fontSize\":\"16\",\"align\":\"left\",\"overflow\":\"wrap\"},{\"type\":\"image\",\"x\":\"199\",\"y\":\"129\",\"width\":\"45\",\"height\":\"45\",\"layer\":\"4\",\"containerBackgroundColor\":\"#faf6ed\",\"src\":\"{image_4}\",\"borderColor\":\"rgba(255,255,255,0)\",\"borderRadius\":\"48\"},{\"type\":\"image\",\"x\":\"33\",\"y\":\"4\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"5\",\"containerBackgroundColor\":\"#faf6ed\",\"src\":\"{image_5}\",\"borderColor\":\"rgba(255,255,255,0)\",\"borderRadius\":\"31\"},{\"type\":\"image\",\"x\":\"287\",\"y\":\"364\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"6\",\"containerBackgroundColor\":\"#faf6ed\",\"src\":\"{image_6}\",\"borderColor\":\"rgba(255,255,255,0)\",\"borderRadius\":\"34\"},{\"type\":\"qrcode\",\"x\":\"144\",\"y\":\"325\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"7\",\"containerBackgroundColor\":\"#fdf2f8\",\"text\":\"{qr_7}\"},{\"type\":\"text\",\"x\":\"25\",\"y\":\"320\",\"width\":\"109\",\"height\":\"169\",\"layer\":\"8\",\"containerBackgroundColor\":\"#e8f4f8\",\"text\":\"文字{text_8}\",\"color\":\"#000000\",\"fontSize\":\"12\",\"align\":\"left\",\"overflow\":\"wrap\"},{\"type\":\"image\",\"x\":\"7\",\"y\":\"534\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"9\",\"containerBackgroundColor\":\"#faf6ed\",\"src\":\"{image_9}\",\"borderColor\":\"rgba(255,255,255,0)\",\"borderRadius\":\"0\"},{\"type\":\"text\",\"x\":\"116\",\"y\":\"532\",\"width\":\"291\",\"height\":\"70\",\"layer\":\"10\",\"containerBackgroundColor\":\"#e8f4f8\",\"text\":\"文字{text_10}\",\"color\":\"#3818d8\",\"fontSize\":\"11\",\"align\":\"center\",\"overflow\":\"wrap\"},{\"type\":\"text\",\"x\":\"119\",\"y\":\"611\",\"width\":\"284\",\"height\":\"22\",\"layer\":\"11\",\"containerBackgroundColor\":\"#e8f4f8\",\"text\":\"文字{text_11}\",\"color\":\"#f93e3e\",\"fontSize\":\"11\",\"align\":\"right\",\"overflow\":\"ellipsis\"},{\"type\":\"qrcode\",\"x\":\"308\",\"y\":\"641\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"12\",\"containerBackgroundColor\":\"#fdf2f8\",\"text\":\"{qr_12}\"},{\"type\":\"text\",\"x\":\"262\",\"y\":\"233\",\"width\":\"127\",\"height\":\"20\",\"layer\":\"13\",\"containerBackgroundColor\":\"#e8f4f8\",\"text\":\"文字{text_13}\",\"color\":\"#000000\",\"fontSize\":\"16\",\"align\":\"left\",\"overflow\":\"wrap\"},{\"type\":\"image\",\"x\":\"149\",\"y\":\"640\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"14\",\"containerBackgroundColor\":\"#faf6ed\",\"src\":\"{image_14}\",\"borderColor\":\"rgba(255,255,255,0)\",\"borderRadius\":\"2\"},{\"type\":\"qrcode\",\"x\":\"146\",\"y\":\"204\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"15\",\"containerBackgroundColor\":\"#fdf2f8\",\"text\":\"{qr_15}\"},{\"type\":\"text\",\"x\":\"17\",\"y\":\"497\",\"width\":\"118\",\"height\":\"29\",\"layer\":\"16\",\"containerBackgroundColor\":\"#e8f4f8\",\"text\":\"文字{text_16}\",\"color\":\"#000000\",\"fontSize\":\"16\",\"align\":\"left\",\"overflow\":\"wrap\"},{\"type\":\"qrcode\",\"x\":\"32\",\"y\":\"212\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"17\",\"containerBackgroundColor\":\"#fdf2f8\",\"text\":\"{qr_17}\"},{\"type\":\"text\",\"x\":\"283\",\"y\":\"120\",\"width\":\"122\",\"height\":\"28\",\"layer\":\"18\",\"containerBackgroundColor\":\"#e8f4f8\",\"text\":\"文字{text_18}\",\"color\":\"#000000\",\"fontSize\":\"16\",\"align\":\"left\",\"overflow\":\"wrap\"},{\"type\":\"image\",\"x\":\"291\",\"y\":\"16\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"19\",\"containerBackgroundColor\":\"#faf6ed\",\"src\":\"{image_19}\",\"borderColor\":\"rgba(255,255,255,0)\",\"borderRadius\":\"26\"},{\"type\":\"qrcode\",\"x\":\"40\",\"y\":\"638\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"20\",\"containerBackgroundColor\":\"#fdf2f8\",\"text\":\"{qr_20}\"},{\"type\":\"image\",\"x\":\"164\",\"y\":\"427\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"21\",\"containerBackgroundColor\":\"#faf6ed\",\"src\":\"{image_21}\",\"borderColor\":\"rgba(255,255,255,0)\",\"borderRadius\":\"33\"}]}', 1763801812, 1763888059, 0);

SET FOREIGN_KEY_CHECKS = 1;

ps:操作数据库增删改查我这里就不写了,项目不一样语法不一样

ps: 注意给 materials 用   json_decode     json_encode($materials, JSON_UNESCAPED_UNICODE)   一下

接口调用方法

  /**
     * 海报图创建
     *  https://lscs001.aaaa.net/api/wxapp/poster/create_poster?id=6
     */
    public function create_poster()
    {
        $PosterInit  = new \init\PosterInit();//海报图管理    (ps:InitController)
        $PosterModel = new \initmodel\PosterModel(); //海报图管理   (ps:InitModel)

        /** 获取参数 **/
        $params            = $this->request->param();
        $params["user_id"] = $this->user_id;

        /** 查询条件 **/
        $where   = [];
        $where[] = ["id", "=", $params["id"]];

        /** 查询数据,我这封装的,根据自己需求改这里 **/
        $params["InterfaceType"] = "api";//接口类型
        $params["DataFormat"]    = "find";//数据格式,find详情,list列表
        $result                  = $PosterInit->get_find($where, $params);
        if (empty($result)) $this->error("暂无数据");


        $generator = new \init\PosterGeneratorInit();
        // 替换参数
        $poster_data = [
            'text_1'   => '标题文字',
            'text_2'   => '副标题恶hi各位合规文化古诶,给我黑鬼文化馆',
            'text_3'   => '底部文字恶hi各位合规文化古诶,给我黑鬼文化馆',
            'image_4'  => 'https://oss.ausite.cn/xcxkf220/default/20251015/aa2f16d64e549a70583356ba30c786d9.jpg',
            'image_5'  => 'https://oss.ausite.cn/kf203/admin/20250912/978a00a76ede33dd6717801db314d815.png',
            'image_6'  => 'https://oss.ausite.cn/xcxkf220/default/20251023/c70bba27ebc75502f07669ecd3619e36.jpg',
            'qr_7'     => 'https://example.com/qr1',
            'text_8'   => '右侧文字恶hi各位合规文化古诶,给我黑鬼文化馆',
            'image_9'  => 'https://oss.ausite.cn/xcxkf220/default/20251015/aa2f16d64e549a70583356ba30c786d9.jpg',
            'text_10'  => '居中文字',
            'text_11'  => '底部左侧文字我吃了个亏,给黑恶hi各位合规文化古诶,给我黑鬼文化馆iu额',
            'qr_12'    => 'https://example.com/qr2',
            'text_13'  => '中间文字恶hi各位合规文化古诶,给我黑鬼文化馆',
            'image_14' => 'https://oss.ausite.cn/xcxkf220/default/20251022/4b54357b288d4d923bca7a75227c1e63.jpg',
            'qr_15'    => 'https://example.com/qr3',
            'text_16'  => '左下角文字恶hi各位合规文化古诶,给我黑鬼文化馆',
            'qr_17'    => 'https://example.com/qr4',
            'text_18'  => '右上角文字恶hi各位合规文化古诶,给我黑鬼文化馆',
            'image_19' => 'https://oss.ausite.cn/xcxkf220/default/20251113/ba514859d0b19b3b511834de9de95081.jpg',
            'qr_20'    => 'https://example.com/qr5',
            'image_21' => 'https://oss.ausite.cn/xcxkf220/default/20251026/9e60b85da853c9a5b9a0b2040866221c.jpg'
        ];//完整版

        //测试数据  这要转成数组格式,我在 get_find()  转过了
        //$result['materials'] = '{"width":"422","height":"750","backgroundColor":"#ffffff","elements":[{"type":"text","x":"134","y":"12","width":"100","height":"100","layer":"1","containerBackgroundColor":"#e8f4f8","text":"文字{text_1}","color":"#000000","fontSize":"16","align":"left","overflow":"wrap"},{"type":"text","x":"29","y":"114","width":"100","height":"100","layer":"2","containerBackgroundColor":"#e8f4f8","text":"文字{text_2}","color":"#000000","fontSize":"16","align":"left","overflow":"wrap"},{"type":"text","x":"294","y":"476","width":"126","height":"27","layer":"3","containerBackgroundColor":"#e8f4f8","text":"文字{text_3}","color":"#000000","fontSize":"16","align":"left","overflow":"wrap"},{"type":"image","x":"169","y":"123","width":"100","height":"100","layer":"4","containerBackgroundColor":"#faf6ed","src":"{image_4}","borderColor":"rgba(255,255,255,0)","borderRadius":"0"},{"type":"image","x":"15","y":"10","width":"100","height":"100","layer":"5","containerBackgroundColor":"#faf6ed","src":"{image_5}","borderColor":"rgba(255,255,255,0)","borderRadius":"0"},{"type":"image","x":"277","y":"291","width":"100","height":"100","layer":"6","containerBackgroundColor":"#faf6ed","src":"{image_6}","borderColor":"rgba(255,255,255,0)","borderRadius":"0"},{"type":"qrcode","x":"144","y":"325","width":"100","height":"100","layer":"7","containerBackgroundColor":"#fdf2f8","text":"{qr_7}"},{"type":"text","x":"25","y":"320","width":"118","height":"34","layer":"8","containerBackgroundColor":"#e8f4f8","text":"文字{text_8}","color":"#000000","fontSize":"16","align":"right","overflow":"ellipsis"},{"type":"image","x":"7","y":"534","width":"100","height":"100","layer":"9","containerBackgroundColor":"#faf6ed","src":"{image_9}","borderColor":"rgba(255,255,255,0)","borderRadius":"0"},{"type":"text","x":"145","y":"534","width":"117","height":"34","layer":"10","containerBackgroundColor":"#e8f4f8","text":"文字{text_10}","color":"#000000","fontSize":"16","align":"left","overflow":"wrap"},{"type":"text","x":"159","y":"595","width":"115","height":"25","layer":"11","containerBackgroundColor":"#e8f4f8","text":"文字{text_11}","color":"#000000","fontSize":"16","align":"left","overflow":"wrap"},{"type":"qrcode","x":"308","y":"641","width":"100","height":"100","layer":"12","containerBackgroundColor":"#fdf2f8","text":"{qr_12}"},{"type":"text","x":"262","y":"233","width":"105","height":"33","layer":"13","containerBackgroundColor":"#e8f4f8","text":"文字{text_13}","color":"#000000","fontSize":"16","align":"left","overflow":"wrap"},{"type":"image","x":"188","y":"640","width":"100","height":"100","layer":"14","containerBackgroundColor":"#faf6ed","src":"{image_14}","borderColor":"rgba(255,255,255,0)","borderRadius":"0"},{"type":"qrcode","x":"145","y":"223","width":"100","height":"100","layer":"15","containerBackgroundColor":"#fdf2f8","text":"{qr_15}"},{"type":"text","x":"16","y":"432","width":"118","height":"29","layer":"16","containerBackgroundColor":"#e8f4f8","text":"文字{text_16}","color":"#000000","fontSize":"16","align":"left","overflow":"wrap"},{"type":"qrcode","x":"32","y":"212","width":"100","height":"100","layer":"17","containerBackgroundColor":"#fdf2f8","text":"{qr_17}"},{"type":"text","x":"283","y":"120","width":"122","height":"28","layer":"18","containerBackgroundColor":"#e8f4f8","text":"文字{text_18}","color":"#000000","fontSize":"16","align":"left","overflow":"wrap"},{"type":"image","x":"291","y":"16","width":"100","height":"100","layer":"19","containerBackgroundColor":"#faf6ed","src":"{image_19}","borderColor":"rgba(255,255,255,0)","borderRadius":"0"},{"type":"qrcode","x":"40","y":"638","width":"100","height":"100","layer":"20","containerBackgroundColor":"#fdf2f8","text":"{qr_20}"},{"type":"image","x":"164","y":"427","width":"100","height":"100","layer":"21","containerBackgroundColor":"#faf6ed","src":"{image_21}","borderColor":"rgba(255,255,255,0)","borderRadius":"0"}]}';
       


  $posterResult = $generator->generatePoster($result['materials'], $poster_data);


        //有错误提示一下
        if ($posterResult['code'] == 0) $this->error("海报生成失败!", $posterResult['msg']);


        $this->success("生成成功", cmf_get_asset_url($posterResult['data']));
    }

处理图片,二维码,文字方法

ps:    引入了插架    QRcode,Image  注意下载一下

ps: 我给图上传到oss上了 最后一步需要改一下你们,没时间整理了,   这里可能需要改动一些地方有不懂的私信

<?php

namespace init;

use cmf\lib\Storage;
use cmf\phpqrcode\QRcode;
use think\Image;

/**
 * 海报生成器
 */
class PosterGeneratorInit
{
    // 字体文件路径
    private $fontPath;
    private $storage;
    private $plugin;
    private $config;
    private $dir;
    private $tempDir;

    /**
     * 构造函数
     */
    public function __construct()
    {
        $this->storage = cmf_get_option('storage');
        $pluginClass   = cmf_get_plugin_class($this->storage['type']);
        $this->plugin  = new $pluginClass();
        $this->config  = $this->plugin->getConfig();
        $this->dir     = $this->config['dir'] ?? 'default';

        // 初始化字体路径(确保字体文件存在,否则文字无法显示)
        $this->fontPath = app()->getRootPath() . 'public/font/song.otf';
        if (!file_exists($this->fontPath)) {
            throw new \Exception("字体文件不存在:{$this->fontPath}");
        }

        // 初始化临时目录
        $this->initTempDir();
    }

    /**
     * 初始化临时目录
     */
    private function initTempDir()
    {
        $date          = date('Ymd');
        $this->tempDir = app()->getRootPath() . "public/upload/{$this->dir}/poster/{$date}/";

        if (!file_exists($this->tempDir)) {
            mkdir($this->tempDir, 0777, true);
        }
    }

    /**
     * 核心生成方法
     */
    public function generatePoster($materials, $replaceData)
    {
        // 创建背景图
        $bgPath = $this->createBackground($materials);
        if (!$bgPath) {
            return ['code' => 0, 'msg' => '背景图创建失败'];
        }

        // 处理所有元素
        $finalPath = $this->processElements($materials, $replaceData, $bgPath);
        if (!$finalPath) {
            return ['code' => 0, 'msg' => '海报元素处理失败'];
        }

        // 上传到云存储
        $urlPath = $this->uploadToCloud($finalPath);
        if (!$urlPath) {
            return ['code' => 0, 'msg' => '上传到云存储失败'];
        }

        return ['code' => 1, 'msg' => '生成成功', 'data' => $urlPath];
    }

    /**
     * 创建背景图
     */
    private function createBackground($template)
    {
        $width           = $template['width'] ?? 422;
        $height          = $template['height'] ?? 750;
        $backgroundColor = $template['backgroundColor'] ?? '#ec8383';

        $bgPath = $this->tempDir . 'bg_' . uniqid(uniqid(rand(11111, 99999))) . '.png';
        $this->createBg($bgPath, $width, $height, $backgroundColor);
        return file_exists($bgPath) ? $bgPath : '';
    }

    /**
     * 处理所有元素
     */
    private function processElements($materials, $replaceData, $bgPath)
    {
        $elements = $materials['elements'];
        // 从模板配置获取尺寸,如果没有则从第一个元素获取
        $width  = $materials['width'] ?? 422;
        $height = $materials['height'] ?? 750;

        // 尝试从元素中获取画布尺寸
        foreach ($elements as $element) {
            if (isset($element['canvasWidth']) && isset($element['canvasHeight'])) {
                $width  = $element['canvasWidth'];
                $height = $element['canvasHeight'];
                break;
            }
        }

        // 创建真彩色画布
        $canvas = imagecreatetruecolor($width, $height);

        // 设置背景 - 修复:不使用透明背景,直接使用背景图
        if (file_exists($bgPath)) {
            $bgImage = imagecreatefrompng($bgPath);
            imagecopy($canvas, $bgImage, 0, 0, 0, 0, $width, $height);
            imagedestroy($bgImage);
        } else {
            // 如果没有背景图,使用白色背景
            $white = imagecolorallocate($canvas, 255, 255, 255);
            imagefill($canvas, 0, 0, $white);
        }

        $sortedElements = $this->sortElements($elements);

        // 循环处理所有元素
        foreach ($sortedElements as $element) {
            $this->processElement($canvas, $element, $replaceData, $width, $height);
        }

        // 保存最终图片
        $finalPath = $this->tempDir . 'final_' . uniqid(uniqid(rand(11111, 99999))) . '.png';
        imagepng($canvas, $finalPath, 9); // 最高质量
        imagedestroy($canvas);

        return file_exists($finalPath) ? $finalPath : '';
    }

    /**
     * 处理单个元素
     */
    private function processElement($canvas, $element, $replaceData, $canvasWidth, $canvasHeight)
    {
        $type = $element['type'] ?? '';

        switch ($type) {
            case 'text':
                $this->handleTextElement($canvas, $element, $replaceData, $canvasWidth, $canvasHeight);
                break;
            case 'image':
                $this->handleImageElement($canvas, $element, $replaceData);
                break;
            case 'qrcode':
                $this->handleQrcodeElement($canvas, $element, $replaceData);
                break;
        }
    }

    /**
     * 处理文字元素 - 修复溢出处理
     */
    private function handleTextElement($canvas, $element, $replaceData, $canvasWidth, $canvasHeight)
    {
        $text = $this->replacePlaceholders($element['text'], $replaceData);
        if (empty($text)) return;

        $fontSize = $element['fontSize'] ?? 16;
        $color    = $element['color'] ?? '#000000';
        $x        = $element['x'] ?? 0;
        $y        = $element['y'] ?? 0;
        $width    = $element['width'] ?? $canvasWidth;
        $height   = $element['height'] ?? 0; // 文字区域高度
        $align    = $element['align'] ?? 'left';
        $overflow = $element['overflow'] ?? 'wrap'; // 溢出处理方式

        // 处理文字溢出(包括宽度和高度限制)
        $processedText = $this->handleTextOverflow($text, $element);

        // 计算文字位置
        $position = $this->calculateTextPosition($element, $processedText, $canvasWidth, $canvasHeight);
        $x        = $position['x'];
        $y        = $position['y'];

        // 将十六进制颜色转换为GD颜色
        $rgb       = $this->hexToRgb($color);
        $textColor = imagecolorallocate($canvas, $rgb['r'], $rgb['g'], $rgb['b']);

        // 处理多行文本
        $lines      = explode("\n", $processedText);
        $lineHeight = $fontSize * 1.5; // 行高

        // 如果有高度限制,计算最大显示行数
        $maxLines = 0;
        if ($height > 0) {
            $maxLines = floor($height / $lineHeight);
            if ($maxLines > 0 && count($lines) > $maxLines) {
                $lines = array_slice($lines, 0, $maxLines);
                // 如果最后一行被截断且设置了省略号,在最后一行添加省略号
                if ($overflow === 'ellipsis') {
                    $lastLine                 = $lines[count($lines) - 1];
                    $lines[count($lines) - 1] = $this->truncateLineWithEllipsis($lastLine, $width, $fontSize);
                }
            }
        }

        foreach ($lines as $index => $line) {
            $currentY = $y + ($index * $lineHeight);

            // 使用imagettftext绘制文字
            imagettftext(
                $canvas,
                $fontSize,
                0, // 角度
                $x,
                $currentY,
                $textColor,
                $this->fontPath,
                $line
            );
        }
    }

    /**
     * 处理文字超出 - 增强版本(支持宽度和高度限制)
     */
    private function handleTextOverflow($text, $element)
    {
        $overflow   = $element['overflow'] ?? 'wrap';
        $width      = $element['width'] ?? 100;
        $height     = $element['height'] ?? 0; // 高度限制
        $fontSize   = $element['fontSize'] ?? 16;
        $lineHeight = $fontSize * 1.5;

        // 首先处理宽度溢出
        $processedText = $this->handleWidthOverflow($text, $width, $fontSize, $overflow);

        // 如果有高度限制,再处理高度溢出
        if ($height > 0) {
            $processedText = $this->handleHeightOverflow($processedText, $height, $lineHeight, $overflow);
        }

        return $processedText;
    }

    /**
     * 处理宽度溢出
     */
    private function handleWidthOverflow($text, $maxWidth, $fontSize, $overflow)
    {
        // 如果是单行省略模式
        if ($overflow === 'ellipsis') {
            return $this->truncateLineWithEllipsis($text, $maxWidth, $fontSize);
        }

        // 如果是换行模式
        if ($overflow === 'wrap') {
            return $this->autoWrapText($text, $maxWidth, $fontSize);
        }

        // 默认不处理
        return $text;
    }

    /**
     * 处理高度溢出
     */
    private function handleHeightOverflow($text, $maxHeight, $lineHeight, $overflow)
    {
        $lines    = explode("\n", $text);
        $maxLines = floor($maxHeight / $lineHeight);

        if (count($lines) <= $maxLines) {
            return $text;
        }

        // 截断到最大行数
        $truncatedLines = array_slice($lines, 0, $maxLines);

        // 如果设置了省略号模式,在最后一行添加省略号
        if ($overflow === 'ellipsis' && !empty($truncatedLines)) {
            $lastLine = $truncatedLines[count($truncatedLines) - 1];
            // 如果最后一行已经有省略号,不再重复添加
            if (mb_substr($lastLine, -3) !== '...') {
                $truncatedLines[count($truncatedLines) - 1] = rtrim($lastLine) . '...';
            }
        }

        return implode("\n", $truncatedLines);
    }

    /**
     * 截断单行文本并添加省略号
     */
    private function truncateLineWithEllipsis($text, $maxWidth, $fontSize)
    {
        $ellipsis      = '...';
        $ellipsisWidth = $this->calculateTextWidth($ellipsis, $fontSize);

        // 如果文本本身宽度就小于最大宽度,直接返回
        $textWidth = $this->calculateTextWidth($text, $fontSize);
        if ($textWidth <= $maxWidth) {
            return $text;
        }

        // 如果连省略号都放不下,直接返回空字符串
        if ($ellipsisWidth > $maxWidth) {
            return '';
        }

        $maxWidth -= $ellipsisWidth;
        $result   = '';

        for ($i = 0; $i < mb_strlen($text); $i++) {
            $char       = mb_substr($text, $i, 1);
            $testString = $result . $char;

            if ($this->calculateTextWidth($testString, $fontSize) > $maxWidth) {
                break;
            }

            $result = $testString;
        }

        return $result . $ellipsis;
    }

    /**
     * 计算文本宽度
     */
    private function calculateTextWidth($text, $fontSize)
    {
        $box = imagettfbbox($fontSize, 0, $this->fontPath, $text);
        return abs($box[2] - $box[0]);
    }

    /**
     * 自动换行文本
     */
    private function autoWrapText($text, $maxWidth, $fontSize)
    {
        $words       = preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY);
        $lines       = [];
        $currentLine = '';

        foreach ($words as $word) {
            $testLine = $currentLine . $word;

            if ($this->calculateTextWidth($testLine, $fontSize) <= $maxWidth) {
                $currentLine = $testLine;
            } else {
                if (!empty($currentLine)) {
                    $lines[] = $currentLine;
                }
                $currentLine = $word;
            }
        }

        if (!empty($currentLine)) {
            $lines[] = $currentLine;
        }

        return implode("\n", $lines);
    }

    /**
     * 计算文字位置
     */
    private function calculateTextPosition($element, $text, $canvasWidth, $canvasHeight)
    {
        $x        = $element['x'] ?? 0;
        $y        = $element['y'] ?? 0;
        $width    = $element['width'] ?? $canvasWidth;
        $height   = $element['height'] ?? 0;
        $align    = $element['align'] ?? 'left';
        $fontSize = $element['fontSize'] ?? 16;

        // 处理多行文本的高度计算
        $lines      = explode("\n", $text);
        $lineHeight = $fontSize * 1.5;
        $textHeight = count($lines) * $lineHeight;

        // 垂直居中调整(如果需要)
        if (isset($element['verticalAlign']) && $element['verticalAlign'] === 'middle') {
            $containerHeight = $height > 0 ? $height : $textHeight;
            $y               = $y + ($containerHeight - $textHeight) / 2;
        }

        // 计算最长行的宽度
        $maxLineWidth = 0;
        foreach ($lines as $line) {
            $lineWidth = $this->calculateTextWidth($line, $fontSize);
            if ($lineWidth > $maxLineWidth) {
                $maxLineWidth = $lineWidth;
            }
        }

        // 水平对齐
        switch ($align) {
            case 'center':
                $x = $x + ($width - $maxLineWidth) / 2;
                break;
            case 'right':
                $x = $x + $width - $maxLineWidth;
                break;
            default: // left
                // $x 保持不变
                break;
        }

        // 调整Y坐标(GD库的Y坐标是从基线开始的,需要调整)
        $y = $y + $fontSize; // 基础调整

        return ['x' => (int)$x, 'y' => (int)$y];
    }

    /**
     * 处理图片元素 - 优化圆角处理
     */
    private function handleImageElement($canvas, $element, $replaceData)
    {
        $src       = $this->replacePlaceholders($element['src'], $replaceData);
        $localPath = $this->downloadImage($src);

        if ($localPath && file_exists($localPath)) {
            $x            = $element['x'] ?? 0;
            $y            = $element['y'] ?? 0;
            $width        = $element['width'] ?? 100;
            $height       = $element['height'] ?? 100;
            $borderRadius = $element['borderRadius'] ?? 0;

            // 根据图片类型加载图片
            $imageInfo = getimagesize($localPath);
            if (!$imageInfo) return;

            $sourceImage = null;
            switch ($imageInfo[2]) {
                case IMAGETYPE_JPEG:
                    $sourceImage = imagecreatefromjpeg($localPath);
                    break;
                case IMAGETYPE_PNG:
                    $sourceImage = imagecreatefrompng($localPath);
                    break;
                case IMAGETYPE_GIF:
                    $sourceImage = imagecreatefromgif($localPath);
                    break;
                default:
                    return;
            }

            if (!$sourceImage) return;

            // 调整图片尺寸
            $resizedImage = imagecreatetruecolor($width, $height);

            // 统一设置透明度
            imagealphablending($resizedImage, false);
            imagesavealpha($resizedImage, true);
            $transparent = imagecolorallocatealpha($resizedImage, 0, 0, 0, 127);
            imagefill($resizedImage, 0, 0, $transparent);

            // 高质量缩放
            imagecopyresampled($resizedImage, $sourceImage, 0, 0, 0, 0, $width, $height, imagesx($sourceImage), imagesy($sourceImage));

            // 应用圆角效果 - 优化:限制圆角半径不超过宽度或高度的一半
            if ($borderRadius > 0) {
                // 限制圆角半径不超过宽度或高度的一半
                $maxRadius = min($width, $height) / 2;
                $borderRadius = min($borderRadius, $maxRadius);

                $roundedImage = $this->applyRoundedCornersOptimized($resizedImage, $borderRadius, $width, $height);
                if ($roundedImage) {
                    imagedestroy($resizedImage);
                    $resizedImage = $roundedImage;
                }
            }

            // 合并到画布
            imagecopy($canvas, $resizedImage, $x, $y, 0, 0, $width, $height);

            // 释放内存
            imagedestroy($sourceImage);
            imagedestroy($resizedImage);
        }
    }

    /**
     * 应用圆角效果 - 优化版本(修复小尺寸图片圆角问题)
     */
    private function applyRoundedCornersOptimized($image, $radius, $width, $height)
    {
        // 如果圆角为0,直接返回原图
        if ($radius <= 0) {
            return $image;
        }

        // 创建带透明背景的画布
        $roundedImage = imagecreatetruecolor($width, $height);
        imagealphablending($roundedImage, false);
        imagesavealpha($roundedImage, true);

        // 填充透明背景
        $transparent = imagecolorallocatealpha($roundedImage, 0, 0, 0, 127);
        imagefill($roundedImage, 0, 0, $transparent);

        // 创建圆形遮罩
        $mask = imagecreatetruecolor($width, $height);
        imagealphablending($mask, false);
        imagesavealpha($mask, true);
        imagefill($mask, 0, 0, imagecolorallocatealpha($mask, 0, 0, 0, 127));

        // 绘制圆角矩形遮罩
        $white = imagecolorallocate($mask, 255, 255, 255);
        $this->drawRoundedRectangleOptimized($mask, 0, 0, $width - 1, $height - 1, $radius, $white);

        // 应用遮罩
        for ($x = 0; $x < $width; $x++) {
            for ($y = 0; $y < $height; $y++) {
                $maskColor = imagecolorat($mask, $x, $y);
                $alpha = ($maskColor >> 24) & 0x7F;

                // 如果遮罩像素是白色(不透明),复制原图像素
                if ($alpha < 127) {
                    $sourceColor = imagecolorat($image, $x, $y);
                    imagesetpixel($roundedImage, $x, $y, $sourceColor);
                }
            }
        }

        imagedestroy($mask);
        return $roundedImage;
    }

    /**
     * 绘制圆角矩形 - 优化版本(修复小尺寸圆角问题)
     */
    private function drawRoundedRectangleOptimized($image, $x1, $y1, $x2, $y2, $radius, $color)
    {
        if ($radius <= 0) {
            imagefilledrectangle($image, $x1, $y1, $x2, $y2, $color);
            return;
        }

        // 绘制四个圆角
        $this->drawCircleQuarterOptimized($image, $x1 + $radius, $y1 + $radius, $radius, 1, $color);
        $this->drawCircleQuarterOptimized($image, $x2 - $radius, $y1 + $radius, $radius, 2, $color);
        $this->drawCircleQuarterOptimized($image, $x2 - $radius, $y2 - $radius, $radius, 3, $color);
        $this->drawCircleQuarterOptimized($image, $x1 + $radius, $y2 - $radius, $radius, 4, $color);

        // 绘制矩形区域
        imagefilledrectangle($image, $x1 + $radius, $y1, $x2 - $radius, $y2, $color);
        imagefilledrectangle($image, $x1, $y1 + $radius, $x2, $y2 - $radius, $color);
    }

    /**
     * 绘制圆的四分之一 - 优化版本(使用更精确的算法)
     */
    private function drawCircleQuarterOptimized($image, $centerX, $centerY, $radius, $quarter, $color)
    {
        // 使用更精确的圆角绘制算法
        for ($x = 0; $x <= $radius; $x++) {
            $y = (int)round(sqrt($radius * $radius - $x * $x));

            switch ($quarter) {
                case 1: // 左上角
                    for ($drawY = $centerY - $y; $drawY <= $centerY; $drawY++) {
                        imagesetpixel($image, $centerX - $x, $drawY, $color);
                    }
                    break;
                case 2: // 右上角
                    for ($drawY = $centerY - $y; $drawY <= $centerY; $drawY++) {
                        imagesetpixel($image, $centerX + $x, $drawY, $color);
                    }
                    break;
                case 3: // 右下角
                    for ($drawY = $centerY; $drawY <= $centerY + $y; $drawY++) {
                        imagesetpixel($image, $centerX + $x, $drawY, $color);
                    }
                    break;
                case 4: // 左下角
                    for ($drawY = $centerY; $drawY <= $centerY + $y; $drawY++) {
                        imagesetpixel($image, $centerX - $x, $drawY, $color);
                    }
                    break;
            }
        }
    }

    /**
     * 处理二维码元素 - 同样优化圆角处理
     */
    private function handleQrcodeElement($canvas, $element, $replaceData)
    {
        $text   = $this->replacePlaceholders($element['text'], $replaceData);
        $qrPath = $this->tempDir . 'qr_' . uniqid(uniqid(rand(11111, 99999))) . '.png';

        $width        = $element['width'] ?? 100;
        $height       = $element['height'] ?? 100;
        $x            = $element['x'] ?? 0;
        $y            = $element['y'] ?? 0;
        $borderRadius = $element['borderRadius'] ?? 0;

        // 生成二维码
        QRcode::png($text, $qrPath, 1, 10, 1, true, 0xFFFFFF, 0x000000);

        if (file_exists($qrPath)) {
            $qrcodeImage = imagecreatefrompng($qrPath);
            if ($qrcodeImage) {
                // 调整二维码尺寸
                $resizedQr = imagecreatetruecolor($width, $height);

                // 统一设置透明度
                imagealphablending($resizedQr, false);
                imagesavealpha($resizedQr, true);
                $transparent = imagecolorallocatealpha($resizedQr, 0, 0, 0, 127);
                imagefill($resizedQr, 0, 0, $transparent);

                imagecopyresampled($resizedQr, $qrcodeImage, 0, 0, 0, 0, $width, $height, imagesx($qrcodeImage), imagesy($qrcodeImage));

                // 应用圆角效果 - 同样优化
                if ($borderRadius > 0) {
                    // 限制圆角半径不超过宽度或高度的一半
                    $maxRadius = min($width, $height) / 2;
                    $borderRadius = min($borderRadius, $maxRadius);

                    $roundedQr = $this->applyRoundedCornersOptimized($resizedQr, $borderRadius, $width, $height);
                    if ($roundedQr) {
                        imagedestroy($resizedQr);
                        $resizedQr = $roundedQr;
                    }
                }

                // 合并到画布
                imagecopy($canvas, $resizedQr, $x, $y, 0, 0, $width, $height);

                imagedestroy($qrcodeImage);
                imagedestroy($resizedQr);

                // 清理临时文件
                @unlink($qrPath);
            }
        }
    }

    /**
     * 替换占位符
     */
    private function replacePlaceholders($text, $replaceData)
    {
        return preg_replace_callback('/\{([^}]+)\}/', function ($matches) use ($replaceData) {
            $key = $matches[1];
            return $replaceData[$key] ?? $matches[0];
        }, $text);
    }

    /**
     * 下载网络图片
     */
    private function downloadImage($url)
    {
        $localPath = $this->tempDir . 'download_' . uniqid(uniqid(rand(11111, 99999)));

        // 根据URL确定文件扩展名
        $extension = pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION);
        if (empty($extension)) {
            $extension = 'png'; // 默认使用png
        }
        $localPath .= '.' . $extension;

        $content = @file_get_contents($url);
        if ($content === false) {
            return '';
        }
        if (file_put_contents($localPath, $content) === false) {
            return '';
        }
        return file_exists($localPath) ? $localPath : '';
    }

    /**
     * 上传到云存储
     */
    private function uploadToCloud($localPath)
    {
        $date      = date('Ymd');
        $cloudPath = "{$this->dir}/poster/{$date}/" . uniqid(uniqid(rand(11111, 99999))) . ".png";

        $storage = new Storage($this->storage['type'], $this->storage['storages'][$this->storage['type']]);
        $result  = $storage->upload($cloudPath, $localPath);
        return $result['url'] ? $cloudPath : '';
    }

    /**
     * 对元素进行排序(按图层)
     */
    private function sortElements($elements)
    {
        usort($elements, function ($a, $b) {
            return ($b['layer'] ?? 0) - ($a['layer'] ?? 0);
        });
        return $elements;
    }

    /**
     * 创建背景图 - 修复背景颜色问题
     */
    private function createBg($bgPath, $width, $height, $backgroundColor)
    {
        // 使用真彩色
        $image = imagecreatetruecolor($width, $height);

        // 分配背景颜色
        $rgb     = $this->hexToRgb($backgroundColor);
        $bgColor = imagecolorallocate($image, $rgb['r'], $rgb['g'], $rgb['b']);

        // 填充背景
        imagefill($image, 0, 0, $bgColor);

        // 保存为PNG,不使用透明色
        imagepng($image, $bgPath, 9);
        imagedestroy($image);
    }

    /**
     * 十六进制转RGB
     */
    private function hexToRgb($hex)
    {
        $hex = str_replace('#', '', $hex);
        if (strlen($hex) == 3) {
            $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
        }
        return [
            'r' => hexdec(substr($hex, 0, 2)),
            'g' => hexdec(substr($hex, 2, 2)),
            'b' => hexdec(substr($hex, 4, 2))
        ];
    }

    /**
     * 清理临时目录
     */
    private function clearTempDir()
    {
        if (file_exists($this->tempDir)) {
            $files = glob($this->tempDir . '*');
            foreach ($files as $file) {
                if (is_file($file)) {
                    @unlink($file);
                }
            }
        }
    }

    /**
     * 析构函数 - 自动清理
     */
    public function __destruct()
    {
        $this->clearTempDir();
    }
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值