html 可视化海报模板编辑器

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>海报模板编辑器 (原生 JS 版)</title>
  <style>
    body {
      margin: 0;
      padding: 0;
      background-color: #fff;
    }
    .poster-template-container {
      max-width: 1000px;
      margin: 0 auto;
      padding: 20px;
      font-family: sans-serif;
    }

    .main-layout {
      display: flex;
      gap: 20px;
      align-items: flex-start;
    }

    .config-panel {
      flex: 0 0 320px;
      background: #f5f7fa;
      padding: 20px;
      border-radius: 8px;
      box-shadow: 0 2px 8px rgba(0,0,0,0.05);
    }

    .config-panel h3 {
      margin-top: 0;
      margin-bottom: 16px;
      font-size: 16px;
      color: #303133;
    }

    .form-group {
      margin-bottom: 16px;
    }
    .form-group label {
      display: block;
      font-size: 13px;
      color: #606266;
      margin-bottom: 6px;
    }
    .form-group input[type="text"],
    .form-group input[type="number"] {
      width: 100%;
      padding: 8px 10px;
      border: 1px solid #dcdfe6;
      border-radius: 4px;
      box-sizing: border-box;
      font-size: 14px;
    }
    .form-group input[type="range"] {
      width: 70%;
      vertical-align: middle;
    }
    .form-group span {
      font-size: 13px;
      margin-left: 10px;
      color: #409eff;
    }

    .color-picker-wrap {
      display: flex;
      align-items: center;
      gap: 10px;
    }
    .color-picker-wrap input[type="color"] {
      width: 40px;
      height: 32px;
      padding: 0;
      border: none;
      cursor: pointer;
    }
    .color-text {
      flex: 1;
    }

    .form-divider {
      height: 1px;
      background: #e4e7ed;
      margin: 20px 0;
    }

    .upload-section {
      margin-top: 10px;
    }
    .tip-text {
      display: block;
      font-size: 12px;
      color: #909399;
      margin-bottom: 10px;
    }

    .workspace-panel {
      flex: 1;
      min-width: 0;
    }

    .actions {
      display: flex;
      justify-content: flex-end;
      margin-bottom: 15px;
    }

    .upload-btn, button {
      padding: 8px 16px;
      background: #fff;
      border: 1px solid #dcdfe6;
      border-radius: 4px;
      cursor: pointer;
      font-size: 14px;
      color: #606266;
      display: inline-block;
    }

    .upload-btn:hover, button:hover {
      color: #409eff;
      border-color: #c6e2ff;
      background-color: #ecf5ff;
    }

    .primary-btn {
      background: #409eff;
      color: white;
      border-color: #409eff;
      margin-left: 10px;
    }
    .primary-btn:hover {
      background: #66b1ff;
      color: white;
    }

    .empty-state {
      text-align: center;
      padding: 100px 0;
      background: #fafafa;
      border: 2px dashed #dcdfe6;
      border-radius: 8px;
      color: #909399;
      font-size: 14px;
    }

    .editor-workspace {
      display: none; /* 默认隐藏,有图片时显示 */
      justify-content: center;
      background: #e4e7ed;
      padding: 40px 20px;
      border-radius: 8px;
      overflow: hidden;
    }

    .canvas-container {
      position: relative;
      background-size: cover;
      background-position: center;
      background-repeat: no-repeat;
      box-shadow: 0 4px 12px rgba(0,0,0,0.1);
      background-color: #fff;
      margin: 0 auto;
    }

    .draggable-element {
      position: absolute;
      background: rgba(64, 158, 255, 0.3);
      border: 1px solid transparent;
      cursor: move;
      user-select: none;
      display: flex;
      align-items: center;
      justify-content: center;
      box-sizing: border-box;
    }

    .draggable-element.active {
      border: 2px dashed #409eff;
      background: rgba(64, 158, 255, 0.5);
      z-index: 10;
    }

    .draggable-element.text-element {
      background: transparent !important;
      border: 1px dashed transparent;
    }
    .draggable-element.text-element.active {
      border: 1px dashed #409eff;
    }

    .element-content {
      pointer-events: none;
      width: 100%;
      height: 100%;
      display: flex;
      align-items: center;
      justify-content: center;
    }
    .label {
      font-size: 14px;
      font-weight: bold;
      text-shadow: 0 1px 2px rgba(0,0,0,0.5);
      white-space: nowrap;
    }

    .resize-handle {
      position: absolute;
      right: -6px;
      bottom: -6px;
      width: 12px;
      height: 12px;
      background: #fff;
      border: 2px solid #409eff;
      border-radius: 50%;
      cursor: se-resize;
      z-index: 20;
    }

    .preview-section {
      display: none;
      margin-top: 30px;
      padding: 20px;
      background: #282c34;
      border-radius: 8px;
      color: #abb2bf;
    }

    .preview-section h3 {
      margin-top: 0;
      color: #61afef;
    }
    .preview-section .tip {
      font-size: 12px;
      color: #98c379;
    }
    .preview-section pre {
      font-size: 13px;
      white-space: pre-wrap;
      word-wrap: break-word;
    }
  </style>
</head>
<body>

<div class="poster-template-container">
  <h2>海报模板编辑器</h2>
  
  <div class="main-layout">
    <!-- 左侧:属性配置表单 -->
    <div class="config-panel">
      <h3>基本信息配置</h3>
      
      <div class="form-group">
        <label>商品 ID</label>
        <input type="number" id="input-product-id" placeholder="请输入商品ID" value="1" />
      </div>

      <div class="form-group">
        <label>标题1</label>
        <input type="text" id="input-zh-url" placeholder="输入中文海报图片地址" />
      </div>

      <div class="form-group">
        <label>标题2</label>
        <input type="text" id="input-ug-url" placeholder="输入维语海报图片地址" />
      </div>

      <div class="form-divider"></div>
      <h3>元素样式配置</h3>

      <div class="form-group">
        <label>头像圆角 (avatar_corner, 0-50)</label>
        <input type="range" id="input-avatar-corner" min="0" max="50" value="50" />
        <span id="text-avatar-corner">50%</span>
      </div>

      <div class="form-group">
        <label>昵称颜色 (name_color)</label>
        <div class="color-picker-wrap">
          <input type="color" id="input-name-color" value="#ffffff" />
          <input type="text" id="text-name-color" class="color-text" value="#ffffff" />
        </div>
      </div>

      <div class="form-divider"></div>
      
      <div class="upload-section">
        <label class="tip-text">也可以选择本地文件作为底图测试:</label>
        <label for="bg-upload" class="upload-btn">本地上传测试底图</label>
        <input id="bg-upload" type="file" accept="image/*" hidden />
      </div>
    </div>

    <!-- 右侧:画板预览 -->
    <div class="workspace-panel">
      <div class="actions" id="actions-panel" style="display: none;">
        <button id="btn-reset">重置元素</button>
        <button id="btn-save" class="primary-btn">保存模板</button>
      </div>

      <div class="editor-workspace" id="workspace">
        <!-- 实际画板容器 -->
        <div class="canvas-container" id="canvas-container">
          
          <!-- 头像 -->
          <div class="draggable-element" id="el-avatar" data-key="avatar">
            <div class="element-content"><span class="label" style="color: #fff;">用户头像</span></div>
            <div class="resize-handle"></div>
          </div>

          <!-- 二维码 -->
          <div class="draggable-element" id="el-qrCode" data-key="qrCode">
            <div class="element-content"><span class="label" style="color: #fff;">二维码</span></div>
            <div class="resize-handle"></div>
          </div>

          <!-- 昵称 -->
          <div class="draggable-element text-element" id="el-name" data-key="name">
            <div class="element-content"><span class="label" id="label-name" style="color: #ffffff;">用户昵称</span></div>
          </div>

        </div>
      </div>
      
      <div id="empty-state" class="empty-state">
        请在左侧输入中文底图 URL,或上传一张本地图片开始编辑
      </div>
    </div>
  </div>

  <!-- 预览与代码输出区 -->
  <div class="preview-section" id="preview-section">
    <h3>导出结果 (符合 poster_template 表结构)</h3>
    <p class="tip">由于表结构没有保存底图宽高的字段,此处坐标是以当前画板的真实图片尺寸 (<span id="real-size">0x0</span>) 换算得出的绝对坐标。</p>
    <pre id="output-json"></pre>
  </div>
</div>

<script>
// --- 状态数据 ---
const state = {
  bgImage: null,
  baseSize: { width: 0, height: 0 },
  canvasStyle: { width: 300, height: 500 },
  activeElement: null
};

// 初始默认元素配置
const defaultElements = {
  avatar: { x: 50, y: 50, w: 60, h: 60, shape: 'circle' },
  qrCode: { x: 100, y: 300, w: 100, h: 100, shape: 'square' },
  name: { x: 50, y: 120, w: 100, h: 30, shape: 'square', isText: true }
};

// 当前工作的元素配置 (深拷贝)
let elements = JSON.parse(JSON.stringify(defaultElements));

// --- DOM 节点获取 ---
const dom = {
  productId: document.getElementById('input-product-id'),
  zhUrl: document.getElementById('input-zh-url'),
  ugUrl: document.getElementById('input-ug-url'),
  avatarCorner: document.getElementById('input-avatar-corner'),
  avatarCornerText: document.getElementById('text-avatar-corner'),
  nameColor: document.getElementById('input-name-color'),
  nameColorText: document.getElementById('text-name-color'),
  bgUpload: document.getElementById('bg-upload'),
  
  workspace: document.getElementById('workspace'),
  canvasContainer: document.getElementById('canvas-container'),
  emptyState: document.getElementById('empty-state'),
  actionsPanel: document.getElementById('actions-panel'),
  
  elAvatar: document.getElementById('el-avatar'),
  elQrCode: document.getElementById('el-qrCode'),
  elName: document.getElementById('el-name'),
  labelName: document.getElementById('label-name'),
  
  btnReset: document.getElementById('btn-reset'),
  btnSave: document.getElementById('btn-save'),
  
  previewSection: document.getElementById('preview-section'),
  realSizeText: document.getElementById('real-size'),
  outputJson: document.getElementById('output-json')
};

// --- 初始化绑定与渲染 ---
function init() {
  bindEvents();
  renderElements();
}

function getScaleRatio() {
  if (state.baseSize.width === 0) return 1;
  return state.canvasStyle.width / state.baseSize.width;
}

// 将 DOM 样式更新到最新状态
function renderElements() {
  const els = {
    avatar: dom.elAvatar,
    qrCode: dom.elQrCode,
    name: dom.elName
  };

  for (const key in els) {
    const data = elements[key];
    const node = els[key];
    
    node.style.left = data.x + 'px';
    node.style.top = data.y + 'px';
    node.style.width = data.w + 'px';
    node.style.height = data.h + 'px';

    if (key === 'avatar') {
      node.style.borderRadius = dom.avatarCorner.value + '%';
    }

    if (key === 'name') {
      const color = dom.nameColor.value;
      node.style.color = color;
      dom.labelName.style.color = color;
    }

    if (state.activeElement === key) {
      node.classList.add('active');
    } else {
      node.classList.remove('active');
    }
  }

  updateJSON();
}

// --- 生成与更新 JSON ---
function updateJSON() {
  if (!state.bgImage || state.baseSize.width === 0) {
    dom.previewSection.style.display = 'none';
    return;
  }

  dom.previewSection.style.display = 'block';
  dom.realSizeText.innerText = `${state.baseSize.width}x${state.baseSize.height}`;

  const ratio = getScaleRatio();
  const getRealVal = (val) => Math.round(val / ratio);

  let bgStr = state.bgImage;
  if (bgStr.length > 1000) {
    bgStr = 'data:image/base64...'; // 截断演示
  }

  const outputConfig = {
    product_id: parseInt(dom.productId.value) || 1,
    zh: dom.zhUrl.value || bgStr,
    ug: dom.ugUrl.value,
    qr_size: getRealVal(elements.qrCode.w),
    qr_x: getRealVal(elements.qrCode.x),
    qr_y: getRealVal(elements.qrCode.y),
    avatar_size: getRealVal(elements.avatar.w),
    avatar_corner: parseInt(dom.avatarCorner.value) || 0,
    avatar_x: getRealVal(elements.avatar.x),
    avatar_y: getRealVal(elements.avatar.y),
    name_x: getRealVal(elements.name.x),
    name_y: getRealVal(elements.name.y),
    name_color: dom.nameColor.value
  };

  dom.outputJson.innerText = JSON.stringify(outputConfig, null, 2);
  return outputConfig;
}

// --- 图片加载逻辑 ---
function loadBgImage(url) {
  state.bgImage = url;
  const img = new Image();
  img.crossOrigin = 'anonymous';
  img.onload = () => {
    state.baseSize.width = img.width;
    state.baseSize.height = img.height;
    
    // 计算适应画板的尺寸
    const maxWidth = dom.workspace.clientWidth || 400;
    const displayWidth = Math.min(maxWidth, 400);
    const displayHeight = (img.height / img.width) * displayWidth;
    
    state.canvasStyle.width = displayWidth;
    state.canvasStyle.height = displayHeight;
    
    dom.canvasContainer.style.width = displayWidth + 'px';
    dom.canvasContainer.style.height = displayHeight + 'px';
    dom.canvasContainer.style.backgroundImage = `url(${url})`;
    
    // 切换显示状态
    dom.emptyState.style.display = 'none';
    dom.workspace.style.display = 'flex';
    dom.actionsPanel.style.display = 'flex';
    
    resetElementsData();
  };
  img.src = url;
}

function resetElementsData() {
  elements = JSON.parse(JSON.stringify(defaultElements));
  state.activeElement = null;
  renderElements();
}

// --- 拖拽与缩放逻辑 ---
let isDragging = false;
let isResizing = false;
let currentKey = null;
let startPos = { x: 0, y: 0 };
let startElem = { x: 0, y: 0, w: 0, h: 0 };

function handleMouseDown(e) {
  // 判断是点中了元素,还是手柄
  const target = e.target;
  const elementNode = target.closest('.draggable-element');
  
  if (!elementNode) {
    // 点中了空白处
    state.activeElement = null;
    renderElements();
    return;
  }

  e.stopPropagation();
  currentKey = elementNode.getAttribute('data-key');
  state.activeElement = currentKey;
  
  startPos = { x: e.clientX, y: e.clientY };
  startElem = { ...elements[currentKey] };

  if (target.classList.contains('resize-handle')) {
    isResizing = true;
  } else {
    isDragging = true;
  }
  
  renderElements();
}

function handleMouseMove(e) {
  if (!currentKey) return;

  const deltaX = e.clientX - startPos.x;
  const deltaY = e.clientY - startPos.y;
  const elem = elements[currentKey];

  if (isDragging) {
    let newX = startElem.x + deltaX;
    let newY = startElem.y + deltaY;
    
    // 边界限制
    newX = Math.max(0, Math.min(newX, state.canvasStyle.width - elem.w));
    newY = Math.max(0, Math.min(newY, state.canvasStyle.height - elem.h));
    
    elem.x = newX;
    elem.y = newY;
    renderElements();
    
  } else if (isResizing) {
    let newW = startElem.w + deltaX;
    let newH = startElem.h + deltaY;
    
    newW = Math.max(30, newW);
    newH = Math.max(30, newH);
    
    newW = Math.min(newW, state.canvasStyle.width - elem.x);
    newH = Math.min(newH, state.canvasStyle.height - elem.y);

    // 保持正方形
    const size = Math.max(newW, newH);
    if (elem.x + size <= state.canvasStyle.width && elem.y + size <= state.canvasStyle.height) {
      elem.w = size;
      elem.h = size;
      renderElements();
    }
  }
}

function handleMouseUp() {
  isDragging = false;
  isResizing = false;
}

// --- 事件绑定 ---
function bindEvents() {
  // 左侧表单事件
  dom.zhUrl.addEventListener('input', (e) => {
    if (e.target.value) {
      loadBgImage(e.target.value);
    } else {
      // 清空
      dom.emptyState.style.display = 'block';
      dom.workspace.style.display = 'none';
      dom.actionsPanel.style.display = 'none';
      dom.previewSection.style.display = 'none';
      state.bgImage = null;
    }
    updateJSON();
  });

  dom.ugUrl.addEventListener('input', updateJSON);
  dom.productId.addEventListener('input', updateJSON);

  dom.avatarCorner.addEventListener('input', (e) => {
    dom.avatarCornerText.innerText = e.target.value + '%';
    renderElements();
  });

  dom.nameColor.addEventListener('input', (e) => {
    dom.nameColorText.value = e.target.value;
    renderElements();
  });
  dom.nameColorText.addEventListener('input', (e) => {
    dom.nameColor.value = e.target.value;
    renderElements();
  });

  dom.bgUpload.addEventListener('change', (e) => {
    const file = e.target.files[0];
    if (!file) return;
    const reader = new FileReader();
    reader.onload = (event) => {
      dom.zhUrl.value = ''; // 清空手动输入的URL
      loadBgImage(event.target.result);
    };
    reader.readAsDataURL(file);
  });

  // 按钮事件
  dom.btnReset.addEventListener('click', resetElementsData);
  dom.btnSave.addEventListener('click', () => {
    const data = updateJSON();
    console.log('保存的数据:', data);
    alert('模板数据已生成,请查看下方 JSON 结果!');
  });

  // 画板拖拽事件
  document.addEventListener('mousedown', handleMouseDown);
  document.addEventListener('mousemove', handleMouseMove);
  document.addEventListener('mouseup', handleMouseUp);
}

// 启动
init();
</script>

</body>
</html>
相关推荐
逝水无殇2 小时前
HTML 文本格式化详解:掌握 <strong>、<em>、<mark>、<del> 等常用标签
前端·javascript·html
我命由我1234511 小时前
CesiumJS 笔记 - 获取容器中心点、Cartesian3 clone 方法、修改 Cartesian3 对象的高度
前端·javascript·css·前端框架·html·html5·js
FFF_634560231 天前
简单的画板小工具,下载即用
前端·javascript·css
jackyrongvip1 天前
开源一个可以把HTML敏感词替换的页面小工具
开源·html·敏感词·ai脱敏
绝世唐门三哥1 天前
CSS 虚线下划线用法指南:text-decoration 完整解析
前端·javascript·css
DsirNg1 天前
用一个登录卡片讲清 CSS 盒模型:padding、border 与 box-sizing 到底如何影响布局
css·padding·box-sizing·表单布局·border·css 盒模型·登录卡片
用户059540174462 天前
把 AI Agent 记忆存储验证从 40 分钟手测压到 3 分钟,pytest + Docker 这套组合太顶了
前端·css
@Mike@2 天前
HTML核心知识点笔记
前端·笔记·html
IMPYLH2 天前
HTML 的 <figcaption> 元素
前端·html