iframe 通信

iframe 通信 API

本文介绍在父应用中通过 iframe 嵌入 Seatmap Editor 时使用的 postMessage 通信协议。

概览

Seatmap Editor 嵌入 iframe 后,支持通过浏览器 postMessage API 双向通信。父应用可以:

  • 请求编辑器保存当前座位图。
  • 获取当前场地的 SVG 导出。
  • 接收座位图更改和保存状态通知。
  • 接收座位图发布通知。
  • 跟踪各部分的保存进度,包括座位图元数据、场地图形和价格分配。

设置

嵌入编辑器

<iframe
  id="seatmap-editor"
  src="https://editor.seatmap.pro/app?token=YOUR_JWT_TOKEN"
  width="100%"
  height="800px"
>
</iframe>

消息事件监听器

在父窗口设置消息监听器,以接收编辑器消息:

window.addEventListener('message', (event) => {
  // Verify origin if needed (recommended for production)
  // if (event.origin !== 'https://editor.seatmap.pro') return;

  const data = event.data;

  switch (data.type) {
    case 'SCHEMA_CHANGED':
      console.log('Schema has changes:', data.hasChanges);
      break;
    case 'SCHEMA_UPDATE_SUCCESS':
      console.log('Schema saved successfully');
      break;
    case 'SCHEMA_UPDATE_ERROR':
      console.error('Schema save failed');
      break;
    case 'SEATMAP_UPDATE_SUCCESS':
      console.log('Venue graphics saved successfully');
      break;
    case 'SEATMAP_UPDATE_ERROR':
      console.error('Venue graphics save failed');
      break;
    case 'UPDATE_PRICE_ASSIGNMENTS_SUCCESS':
      console.log('Price assignments saved successfully');
      break;
    case 'UPDATE_PRICE_ASSIGNMENTS_ERROR':
      console.error('Price assignments save failed');
      break;
    case 'PUBLISH_REQUEST':
      console.log('Schema is being published');
      break;
    case 'SVG_RESPONSE':
      handleSvgResponse(data);
      break;
    default:
      console.log('Unknown message type:', data.type);
  }
});

消息类型参考

概览

父窗口 → Editor(传入):

消息类型 说明 旧格式
SAVE_REQUEST 请求保存当前座位图 'SAVE'
FETCH_SVG 请求导出当前座位图的 SVG -
SET_LANGUAGE 在运行时设置编辑器界面语言 -

Editor → 父窗口(传出):

消息类型 说明 载荷
SCHEMA_CHANGED 座位图存在未保存更改 { hasChanges: boolean }
SCHEMA_UPDATE_SUCCESS 座位图元数据保存成功 -
SCHEMA_UPDATE_ERROR 座位图元数据保存失败 -
SEATMAP_UPDATE_SUCCESS 场地图形或形状保存成功 -
SEATMAP_UPDATE_ERROR 场地图形或形状保存失败 { payload: unknown }
UPDATE_PRICE_ASSIGNMENTS_SUCCESS 价格分配保存成功 -
UPDATE_PRICE_ASSIGNMENTS_ERROR 价格分配保存失败 { payload: unknown }
PRICES_CHANGED 价格分配存在未保存更改 { hasPriceChanges: boolean }
PRICE_CREATED 在活动价格列表中创建了价格 { price: { id, name } }
PRICE_UPDATED 在活动价格列表中重命名了价格 { price: { id, name } }
PRICE_DELETED 从活动价格列表删除了价格 { priceId: number }
PUBLISH_REQUEST 用户点击发布按钮 { payload: ISchemaSettings }
SVG_RESPONSE FETCH_SVG 请求的成功或错误响应 { success, svg?, error? }

父窗口 → Editor(传入消息)

从父窗口发送到编辑器 iframe 的消息。

SAVE_REQUEST

触发编辑器保存当前座位图,持久化场地布局的所有更改,包括座位、区域、排、价格区域和背景图形。

保存内容:

  • 场地布局和结构。
  • 座位位置、标签和属性。
  • 区域定义和边界。
  • 排配置和编号。
  • 价格区域分配。
  • 背景 SVG、图片及其位置。
  • 自定义形状和标签。
  • 客户端视图框设置。

格式:

iframe.contentWindow.postMessage({ type: 'SAVE_REQUEST' }, '*');

旧格式(已弃用):

iframe.contentWindow.postMessage('SAVE', '*');

响应消息:

保存操作可能根据所保存的内容触发多条响应消息:

  • SCHEMA_UPDATE_SUCCESS:座位图元数据和设置保存成功。
  • SCHEMA_UPDATE_ERROR:座位图保存失败。
  • SEATMAP_UPDATE_SUCCESS:场地图形或形状保存成功。
  • SEATMAP_UPDATE_ERROR:场地图形或形状保存失败。
  • UPDATE_PRICE_ASSIGNMENTS_SUCCESS:价格分配保存成功。
  • UPDATE_PRICE_ASSIGNMENTS_ERROR:价格分配保存失败。

基础保存示例:

const iframe = document.getElementById('seatmap-editor');

// Send save request
iframe.contentWindow.postMessage({ type: 'SAVE_REQUEST' }, '*');

// Listen for response
window.addEventListener('message', (event) => {
  if (event.data.type === 'SCHEMA_UPDATE_SUCCESS') {
    console.log('Schema saved successfully!');
    // Proceed with next action
  } else if (event.data.type === 'SCHEMA_UPDATE_ERROR') {
    console.error('Failed to save schema');
  }
});

完整保存处理器示例:

let saveInProgress = false;
let saveResults = {
  schema: null,
  seatmap: null,
  prices: null,
};

function handleSave() {
  if (saveInProgress) {
    console.warn('Save already in progress');
    return;
  }

  saveInProgress = true;
  saveResults = { schema: null, seatmap: null, prices: null };

  const iframe = document.getElementById('seatmap-editor');
  iframe.contentWindow.postMessage({ type: 'SAVE_REQUEST' }, '*');

  // Set timeout to detect save completion
  setTimeout(checkSaveCompletion, 5000);
}

function checkSaveCompletion() {
  if (!saveInProgress) return;

  const allCompleted =
    saveResults.schema !== null && saveResults.seatmap !== null && saveResults.prices !== null;

  if (allCompleted) {
    const allSuccess = saveResults.schema && saveResults.seatmap && saveResults.prices;

    if (allSuccess) {
      console.log('✓ All changes saved successfully');
      onSaveComplete(true);
    } else {
      console.error('✗ Some save operations failed');
      onSaveComplete(false);
    }

    saveInProgress = false;
  }
}

window.addEventListener('message', (event) => {
  if (!saveInProgress) return;

  switch (event.data.type) {
    case 'SCHEMA_UPDATE_SUCCESS':
      saveResults.schema = true;
      checkSaveCompletion();
      break;

    case 'SCHEMA_UPDATE_ERROR':
      saveResults.schema = false;
      checkSaveCompletion();
      break;

    case 'SEATMAP_UPDATE_SUCCESS':
      saveResults.seatmap = true;
      checkSaveCompletion();
      break;

    case 'SEATMAP_UPDATE_ERROR':
      saveResults.seatmap = false;
      checkSaveCompletion();
      break;

    case 'UPDATE_PRICE_ASSIGNMENTS_SUCCESS':
      saveResults.prices = true;
      checkSaveCompletion();
      break;

    case 'UPDATE_PRICE_ASSIGNMENTS_ERROR':
      saveResults.prices = false;
      checkSaveCompletion();
      break;
  }
});

function onSaveComplete(success) {
  if (success) {
    showNotification('Changes saved successfully', 'success');
    // Enable other actions that require saved state
    document.getElementById('export-btn').disabled = false;
  } else {
    showNotification('Failed to save some changes', 'error');
  }
}

带加载指示器的保存:

function saveWithFeedback() {
  const iframe = document.getElementById('seatmap-editor');
  const saveBtn = document.getElementById('save-btn');
  const spinner = document.getElementById('spinner');

  // Show loading state
  saveBtn.disabled = true;
  spinner.style.display = 'inline-block';

  // Send save request
  iframe.contentWindow.postMessage({ type: 'SAVE_REQUEST' }, '*');

  // Set timeout for UI feedback
  const timeout = setTimeout(() => {
    saveBtn.disabled = false;
    spinner.style.display = 'none';
    showNotification('Save operation timed out', 'warning');
  }, 30000); // 30 second timeout

  // Track save responses
  const listener = (event) => {
    if (event.data.type === 'SCHEMA_UPDATE_SUCCESS') {
      clearTimeout(timeout);
      saveBtn.disabled = false;
      spinner.style.display = 'none';
      showNotification('Saved successfully!', 'success');
      window.removeEventListener('message', listener);
    } else if (event.data.type === 'SCHEMA_UPDATE_ERROR') {
      clearTimeout(timeout);
      saveBtn.disabled = false;
      spinner.style.display = 'none';
      showNotification('Save failed', 'error');
      window.removeEventListener('message', listener);
    }
  };

  window.addEventListener('message', listener);
}

自动保存实现:

let autoSaveTimeout = null;
let hasUnsavedChanges = false;

window.addEventListener('message', (event) => {
  // Track unsaved changes
  if (event.data.type === 'SCHEMA_CHANGED') {
    hasUnsavedChanges = event.data.hasChanges;

    if (hasUnsavedChanges) {
      // Debounce auto-save
      clearTimeout(autoSaveTimeout);
      autoSaveTimeout = setTimeout(() => {
        autoSave();
      }, 5000); // Auto-save after 5 seconds of inactivity
    }
  }
});

function autoSave() {
  if (!hasUnsavedChanges) return;

  console.log('Auto-saving...');
  const iframe = document.getElementById('seatmap-editor');
  iframe.contentWindow.postMessage({ type: 'SAVE_REQUEST' }, '*');
}

// Save before page unload
window.addEventListener('beforeunload', (event) => {
  if (hasUnsavedChanges) {
    event.preventDefault();
    event.returnValue = 'You have unsaved changes. Do you want to leave?';
  }
});

FETCH_SVG

请求导出当前座位图的 SVG。导出前必须先保存座位图。

格式:

iframe.contentWindow.postMessage({ type: 'FETCH_SVG' }, '*');

响应消息:

  • SVG_RESPONSE:包含成功状态、SVG 数据或错误信息。

示例:

const iframe = document.getElementById('seatmap-editor');

// Request SVG export
iframe.contentWindow.postMessage({ type: 'FETCH_SVG' }, '*');

// Listen for response
window.addEventListener('message', (event) => {
  if (event.data.type === 'SVG_RESPONSE') {
    if (event.data.success) {
      const svgContent = event.data.svg;
      // Use SVG data (download, display, etc.)
      downloadSvg(svgContent);
    } else {
      console.error('SVG export failed:', event.data.error);
      // Possible errors:
      // - "Schema must be saved before export"
      // - Export API errors
    }
  }
});

function downloadSvg(svgContent) {
  const blob = new Blob([svgContent], { type: 'image/svg+xml' });
  const url = URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = url;
  link.download = 'venue.svg';
  link.click();
  URL.revokeObjectURL(url);
}

SET_LANGUAGE

在运行时设置编辑器界面语言,让嵌入编辑器与宿主应用语言保持同步,无需重新加载 iframe。

格式:

iframe.contentWindow.postMessage({ type: 'SET_LANGUAGE', language: 'pl' }, '*');

支持的语言代码:en、ru、de、es、fr、it、zh、pl。不支持或格式错误的代码会被忽略,保留当前语言。

要在编辑器首次加载、用户尚未交互时设置语言,请在 iframe URL 中添加 language 查询参数:

<iframe src="https://editor.seatmap.pro/app?language=pl" width="100%" height="800px"></iframe>

示例:

const iframe = document.getElementById('seatmap-editor');

// Switch the editor to Polish
iframe.contentWindow.postMessage({ type: 'SET_LANGUAGE', language: 'pl' }, '*');

Editor → 父窗口(传出消息)

从编辑器 iframe 发送到父窗口的消息。

SCHEMA_CHANGED

座位图存在未保存更改时通知父窗口。

载荷:

{
  type: 'SCHEMA_CHANGED',
  hasChanges: boolean
}

示例:

window.addEventListener('message', (event) => {
  if (event.data.type === 'SCHEMA_CHANGED') {
    if (event.data.hasChanges) {
      // Show unsaved changes indicator
      document.getElementById('save-indicator').style.display = 'block';
    } else {
      // Hide unsaved changes indicator
      document.getElementById('save-indicator').style.display = 'none';
    }
  }
});

SCHEMA_UPDATE_SUCCESS

座位图保存成功后发送。

载荷:

{
  type: 'SCHEMA_UPDATE_SUCCESS';
}

SCHEMA_UPDATE_ERROR

座位图保存失败时发送。

载荷:

{
  type: 'SCHEMA_UPDATE_ERROR';
}

SEATMAP_UPDATE_SUCCESS

场地图形或形状保存成功后发送。

载荷:

{
  type: 'SEATMAP_UPDATE_SUCCESS';
}

SEATMAP_UPDATE_ERROR

场地图形或形状保存失败时发送。

载荷:

{
  type: 'SEATMAP_UPDATE_ERROR';
}

SVG_RESPONSE

对 FETCH_SVG 请求的响应,包含导出的 SVG 或错误信息。

成功载荷:

{
  type: 'SVG_RESPONSE',
  success: true,
  svg: string  // SVG content as string
}

错误载荷:

{
  type: 'SVG_RESPONSE',
  success: false,
  error: string  // Error message
}

常见错误信息:

  • "Schema must be saved before export":座位图尚未保存。
  • API 返回的本地化导出错误信息。

UPDATE_PRICE_ASSIGNMENTS_SUCCESS

价格分配更新成功后发送。

载荷:

{
  type: 'UPDATE_PRICE_ASSIGNMENTS_SUCCESS';
}

UPDATE_PRICE_ASSIGNMENTS_ERROR

价格分配更新失败时发送。

载荷:

{
  type: 'UPDATE_PRICE_ASSIGNMENTS_ERROR',
  payload: unknown  // Error details
}

PRICES_CHANGED

价格分配存在未保存更改时通知父窗口,相当于定价领域的 SCHEMA_CHANGED。用户为座位或区域分配、清除价格时发送 hasPriceChanges: true;分配保存后发送 hasPriceChanges: false。

载荷:

{
  type: 'PRICES_CHANGED',
  hasPriceChanges: boolean
}

PRICE_CREATED

在活动价格列表创建新价格后立即发送。

与价格分配不同,价格列表本身属于活动,且立即持久化。创建、重命名或删除价格会立即调用后端,不等待座位图 Save 操作。父应用可利用 PRICE_CREATED、PRICE_UPDATED 和 PRICE_DELETED 消息同步自己的价格目录。

载荷:

{
  type: 'PRICE_CREATED',
  price: {
    id: number,
    name: string
  }
}

示例:

window.addEventListener('message', (event) => {
  switch (event.data.type) {
    case 'PRICE_CREATED':
      console.log('Price created:', event.data.price.id, event.data.price.name);
      break;
    case 'PRICE_UPDATED':
      console.log('Price renamed:', event.data.price.id, event.data.price.name);
      break;
    case 'PRICE_DELETED':
      console.log('Price deleted:', event.data.priceId);
      break;
  }
});

PRICE_UPDATED

活动价格列表中的已有价格被重命名后立即发送。

载荷:

{
  type: 'PRICE_UPDATED',
  price: {
    id: number,
    name: string
  }
}

PRICE_DELETED

从活动价格列表删除价格后立即发送。

载荷:

{
  type: 'PRICE_DELETED',
  priceId: number
}

PUBLISH_REQUEST

用户点击编辑器中的 Publish 按钮时发送,表示座位图正从草稿状态发布为正式状态。

载荷:

{
  type: 'PUBLISH_REQUEST',
  payload: ISchemaSettings  // Schema settings with draft: false
}

示例:

window.addEventListener('message', (event) => {
  if (event.data.type === 'PUBLISH_REQUEST') {
    console.log('Schema is being published');
    console.log('Schema settings:', event.data.payload);

    // Parent application might want to:
    // - Show publish confirmation notification
    // - Update UI to reflect published status
    // - Trigger additional workflows (notifications, webhooks, etc.)
  }
});

注意:

  • 此消息在用户发起发布、服务器操作完成之前发送。
  • 座位图仍通过正常保存流程保存,并触发 SCHEMA_UPDATE_SUCCESS 或 SCHEMA_UPDATE_ERROR。
  • 父应用应等待 SCHEMA_UPDATE_SUCCESS,确认发布成功完成。

发布流程示例:

let publishInProgress = false;

window.addEventListener('message', (event) => {
  switch (event.data.type) {
    case 'PUBLISH_REQUEST':
      publishInProgress = true;
      console.log('Publishing schema...');
      showNotification('Publishing schema...', 'info');
      break;

    case 'SCHEMA_UPDATE_SUCCESS':
      if (publishInProgress) {
        publishInProgress = false;
        console.log('Schema published successfully!');
        showNotification('Schema published successfully!', 'success');
        // Trigger post-publish workflows
        onSchemaPublished(event.data);
      }
      break;

    case 'SCHEMA_UPDATE_ERROR':
      if (publishInProgress) {
        publishInProgress = false;
        console.error('Failed to publish schema');
        showNotification('Failed to publish schema', 'error');
      }
      break;
  }
});

完整集成示例

<!DOCTYPE html>
<html>
  <head>
    <title>Seatmap Editor Integration</title>
    <style>
      #container {
        display: flex;
        flex-direction: column;
        height: 100vh;
      }
      #controls {
        padding: 10px;
        background: #f5f5f5;
        border-bottom: 1px solid #ddd;
      }
      button {
        margin-right: 10px;
        padding: 8px 16px;
      }
      .indicator {
        display: none;
        color: orange;
        margin-left: 10px;
      }
      .indicator.visible {
        display: inline;
      }
      #editor {
        flex: 1;
        border: none;
      }
      #log {
        height: 150px;
        overflow-y: auto;
        padding: 10px;
        background: #fafafa;
        border-top: 1px solid #ddd;
        font-family: monospace;
        font-size: 12px;
      }
    </style>
  </head>
  <body>
    <div id="container">
      <div id="controls">
        <button id="save-btn">Save Schema</button>
        <button id="export-svg-btn">Export SVG</button>
        <span id="unsaved-indicator" class="indicator">● Unsaved changes</span>
      </div>

      <iframe
        id="editor"
        src="https://editor.seatmap.pro/app?token=YOUR_TOKEN&hidden=save"
      ></iframe>

      <div id="log"></div>
    </div>

    <script>
      const iframe = document.getElementById('editor');
      const logEl = document.getElementById('log');
      const indicator = document.getElementById('unsaved-indicator');

      function log(message) {
        const time = new Date().toLocaleTimeString();
        logEl.innerHTML += `[${time}] ${message}<br>`;
        logEl.scrollTop = logEl.scrollHeight;
      }

      // Listen for messages from iframe
      window.addEventListener('message', (event) => {
        // In production, verify origin:
        // if (event.origin !== 'https://editor.seatmap.pro') return;

        const data = event.data;
        log(`Received: ${JSON.stringify(data)}`);

        switch (data.type) {
          case 'SCHEMA_CHANGED':
            if (data.hasChanges) {
              indicator.classList.add('visible');
            } else {
              indicator.classList.remove('visible');
            }
            break;

          case 'SCHEMA_UPDATE_SUCCESS':
            log('✓ Schema saved successfully');
            break;

          case 'SCHEMA_UPDATE_ERROR':
            log('✗ Schema save failed');
            break;

          case 'SEATMAP_UPDATE_SUCCESS':
            log('✓ Venue graphics saved successfully');
            break;

          case 'SEATMAP_UPDATE_ERROR':
            log('✗ Venue graphics save failed');
            break;

          case 'UPDATE_PRICE_ASSIGNMENTS_SUCCESS':
            log('✓ Price assignments saved successfully');
            break;

          case 'UPDATE_PRICE_ASSIGNMENTS_ERROR':
            log('✗ Price assignments save failed');
            break;

          case 'PUBLISH_REQUEST':
            log('📤 Schema is being published...');
            break;

          case 'SVG_RESPONSE':
            if (data.success) {
              log('✓ SVG export successful');
              downloadSvg(data.svg);
            } else {
              log(`✗ SVG export failed: ${data.error}`);
              alert(data.error);
            }
            break;
        }
      });

      // Save button
      document.getElementById('save-btn').addEventListener('click', () => {
        log('Sending SAVE_REQUEST...');
        iframe.contentWindow.postMessage({ type: 'SAVE_REQUEST' }, '*');
      });

      // Export SVG button
      document.getElementById('export-svg-btn').addEventListener('click', () => {
        log('Sending FETCH_SVG...');
        iframe.contentWindow.postMessage({ type: 'FETCH_SVG' }, '*');
      });

      function downloadSvg(svgContent) {
        const blob = new Blob([svgContent], { type: 'image/svg+xml' });
        const url = URL.createObjectURL(blob);
        const link = document.createElement('a');
        link.href = url;
        link.download = `venue_${Date.now()}.svg`;
        link.click();
        URL.revokeObjectURL(url);
        log('SVG downloaded');
      }
    </script>
  </body>
</html>

错误处理

常见场景

座位图未保存

未先保存便请求导出 SVG:

// Response:
{
  type: 'SVG_RESPONSE',
  success: false,
  error: 'Schema must be saved before export'
}

解决方法: 先触发保存,再请求 SVG:

function saveAndExport() {
  const iframe = document.getElementById('seatmap-editor');

  // Send save request
  iframe.contentWindow.postMessage({ type: 'SAVE_REQUEST' }, '*');

  // Wait for save confirmation
  const listener = (event) => {
    if (event.data.type === 'SCHEMA_UPDATE_SUCCESS') {
      // Now request SVG
      iframe.contentWindow.postMessage({ type: 'FETCH_SVG' }, '*');
      window.removeEventListener('message', listener);
    } else if (event.data.type === 'SCHEMA_UPDATE_ERROR') {
      console.error('Save failed, cannot export SVG');
      alert('Please save the schema before exporting');
      window.removeEventListener('message', listener);
    }
  };
  window.addEventListener('message', listener);
}

完整的先保存再导出流程:

function saveAndExportWithFeedback() {
  const iframe = document.getElementById('seatmap-editor');
  const exportBtn = document.getElementById('export-btn');

  exportBtn.disabled = true;
  exportBtn.textContent = 'Saving...';

  // Step 1: Save the schema
  iframe.contentWindow.postMessage({ type: 'SAVE_REQUEST' }, '*');

  let saveCompleted = false;

  const messageHandler = (event) => {
    // Handle save response
    if (!saveCompleted) {
      if (event.data.type === 'SCHEMA_UPDATE_SUCCESS') {
        saveCompleted = true;
        exportBtn.textContent = 'Exporting...';

        // Step 2: Request SVG export
        iframe.contentWindow.postMessage({ type: 'FETCH_SVG' }, '*');
      } else if (event.data.type === 'SCHEMA_UPDATE_ERROR') {
        exportBtn.disabled = false;
        exportBtn.textContent = 'Export SVG';
        alert('Failed to save schema. Cannot export.');
        window.removeEventListener('message', messageHandler);
      }
    }
    // Handle export response
    else {
      if (event.data.type === 'SVG_RESPONSE') {
        exportBtn.disabled = false;
        exportBtn.textContent = 'Export SVG';

        if (event.data.success) {
          console.log('Export successful');
          downloadSvg(event.data.svg);
        } else {
          alert('Export failed: ' + event.data.error);
        }

        window.removeEventListener('message', messageHandler);
      }
    }
  };

  window.addEventListener('message', messageHandler);

  // Timeout handler
  setTimeout(() => {
    exportBtn.disabled = false;
    exportBtn.textContent = 'Export SVG';
    window.removeEventListener('message', messageHandler);
  }, 60000); // 60 second timeout
}

function downloadSvg(svgContent) {
  const blob = new Blob([svgContent], { type: 'image/svg+xml' });
  const url = URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = url;
  link.download = `venue_${Date.now()}.svg`;
  link.click();
  URL.revokeObjectURL(url);
}

API 错误

导出时发生网络或服务器错误:

{
  type: 'SVG_RESPONSE',
  success: false,
  error: 'Export failed'  // Localized error message
}

解决方法: 显示易懂的错误提示,并提供重试选项。

消息大小

SVG 导出可能较大,复杂场地尤其如此:

  • 小型场地:10KB–100KB。
  • 中型场地:100KB–1MB。
  • 大型场地:1MB–10MB 以上。

现代浏览器的 postMessage API 支持约 64MB 的消息,但过大载荷可能影响性能。对于图形极其复杂的场地,可考虑:

  • 改用直接 API 端点(/api/export/${schemaId}/)。
  • 通过服务器端处理下载。
  • 向用户显示进度。

测试

测试文件

仓库包含用于开发和调试的 HTML 测试文件:

  1. public/sso_test.html:完整测试页面,包含 SSO 身份验证、弹窗 iframe 嵌入、保存请求按钮、消息日志面板和实时消息显示。
  2. public/iframe_modal_test.html:简易弹窗测试,包含基础 iframe 嵌入、消息事件日志和座位图更改跟踪。

本地测试

# Start development server
yarn start

# Open test page
open http://localhost:3000/sso_test.html

安全注意事项

验证来源

生产环境中始终验证收到消息的来源:

window.addEventListener('message', (event) => {
  // Verify the origin
  const allowedOrigins = ['https://editor.seatmap.pro', 'https://editor.seatmap.dev'];

  if (!allowedOrigins.includes(event.origin)) {
    console.warn('Message from untrusted origin:', event.origin);
    return;
  }

  // Process message
  handleMessage(event.data);
});

目标来源

向 iframe 发送消息时指定目标来源:

// Instead of '*', use specific origin
iframe.contentWindow.postMessage({ type: 'SAVE_REQUEST' }, 'https://editor.seatmap.pro');

身份验证

编辑器需要 JWT 身份验证。通过 URL 参数传入令牌:

https://editor.seatmap.pro/app?token=JWT_TOKEN&refreshToken=REFRESH_TOKEN

确保令牌安全传输,并设置合适的过期时间。

故障排查

未收到消息

  1. 检查 iframe 已加载:

    iframe.addEventListener('load', () => {
      console.log('Iframe loaded, ready for messages');
    });
    
  2. 检查 contentWindow 访问:

    if (!iframe.contentWindow) {
      console.error('Cannot access iframe contentWindow');
    }
    
  3. 检查跨域限制:

    • 确保设置正确的 CORS 响应头。
    • 使用浏览器控制台检查安全错误。

保存不起作用

常见原因:

  1. 身份验证问题:令牌过期或无效、用户无写入权限、会话超时。
  2. 校验错误:座位图数据不完整或无效、缺少必填字段、数据格式错误。
  3. 网络问题:API 端点不可访问、CORS 限制、防火墙阻止请求。
  4. 状态问题:没有待保存更改、编辑器尚未完全加载、存在并发保存操作。

调试步骤:

// Check if editor is loaded
iframe.addEventListener('load', () => {
  console.log('Editor iframe loaded');
});

// Monitor all messages
window.addEventListener('message', (event) => {
  console.log('Message received:', event.data);
});

// Check for errors in browser console
// Check Network tab for failed API calls
// Verify authentication token is present in URL

保存操作检查清单:

  • 编辑器 iframe 已完全加载。
  • 用户已通过身份验证,令牌存在。
  • 用户有该场地的编辑权限。
  • 座位图有未保存更改(监听 SCHEMA_CHANGED)。
  • 没有其他保存操作正在进行。
  • 网络连接稳定。

SVG 导出失败

常见原因:座位图未保存(最常见)、网络连接问题、服务器端导出错误,或座位图数据无效。

消息流程图

保存操作流程

Parent sends SAVE_REQUEST
    ↓
Editor begins save operation
    ↓
Editor sends SCHEMA_CHANGED (hasChanges: false)
    ↓
Editor saves schema metadata → SCHEMA_UPDATE_SUCCESS or SCHEMA_UPDATE_ERROR
    ↓
Editor saves venue graphics → SEATMAP_UPDATE_SUCCESS or SEATMAP_UPDATE_ERROR
    ↓
Editor saves price assignments → UPDATE_PRICE_ASSIGNMENTS_SUCCESS or UPDATE_PRICE_ASSIGNMENTS_ERROR
    ↓
Save operation complete

发布操作流程

User clicks "Publish" button in editor
    ↓
Editor sends PUBLISH_REQUEST (with payload: { draft: false, ... })
    ↓
Editor triggers save operation (same flow as above)
    ↓
SCHEMA_UPDATE_SUCCESS indicates publish completed successfully

SVG 导出流程

Parent sends FETCH_SVG
    ↓
Editor checks if schema is saved
    ↓
If not saved: SVG_RESPONSE (success: false, error: "Schema must be saved before export")
    ↓
If saved: Editor fetches SVG from API
    ↓
SVG_RESPONSE (success: true/false, svg or error)

常见工作流程

完整消息处理器

class SeatmapEditorIntegration {
  constructor(iframeId) {
    this.iframe = document.getElementById(iframeId);
    this.hasUnsavedChanges = false;
    this.saveInProgress = false;
    this.publishInProgress = false;
    this.saveResults = { schema: null, seatmap: null, prices: null };

    this.initMessageListener();
  }

  initMessageListener() {
    window.addEventListener('message', (event) => {
      // Verify origin in production
      // if (event.origin !== 'https://editor.seatmap.pro') return;

      this.handleMessage(event.data);
    });
  }

  handleMessage(data) {
    console.log('Received message:', data);

    switch (data.type) {
      case 'SCHEMA_CHANGED':
        this.hasUnsavedChanges = data.hasChanges;
        this.updateUI();
        break;

      case 'SCHEMA_UPDATE_SUCCESS':
        this.saveResults.schema = true;
        this.checkSaveCompletion();
        break;

      case 'SCHEMA_UPDATE_ERROR':
        this.saveResults.schema = false;
        this.checkSaveCompletion();
        break;

      case 'SEATMAP_UPDATE_SUCCESS':
        this.saveResults.seatmap = true;
        this.checkSaveCompletion();
        break;

      case 'SEATMAP_UPDATE_ERROR':
        this.saveResults.seatmap = false;
        this.checkSaveCompletion();
        break;

      case 'UPDATE_PRICE_ASSIGNMENTS_SUCCESS':
        this.saveResults.prices = true;
        this.checkSaveCompletion();
        break;

      case 'UPDATE_PRICE_ASSIGNMENTS_ERROR':
        this.saveResults.prices = false;
        this.checkSaveCompletion();
        break;

      case 'PUBLISH_REQUEST':
        console.log('Schema is being published');
        this.publishInProgress = true;
        this.showNotification('Publishing schema...', 'info');
        break;

      case 'SVG_RESPONSE':
        this.handleSvgResponse(data);
        break;

      default:
        console.log('Unknown message type:', data.type);
    }
  }

  checkSaveCompletion() {
    if (!this.saveInProgress) return;

    const { schema, seatmap, prices } = this.saveResults;
    if (schema === null || seatmap === null || prices === null) return;

    this.saveInProgress = false;
    const success = schema && seatmap && prices;

    if (success) {
      this.hasUnsavedChanges = false;
      this.showNotification(
        this.publishInProgress ? 'Published successfully!' : 'Saved successfully!',
        'success',
      );
      this.onSaveComplete(true);
    } else {
      this.showNotification('Save failed', 'error');
      this.onSaveComplete(false);
    }

    this.publishInProgress = false;
  }

  save() {
    if (this.saveInProgress) {
      console.warn('Save already in progress');
      return;
    }

    this.saveInProgress = true;
    this.saveResults = { schema: null, seatmap: null, prices: null };
    this.iframe.contentWindow.postMessage({ type: 'SAVE_REQUEST' }, '*');
  }

  exportSvg() {
    this.iframe.contentWindow.postMessage({ type: 'FETCH_SVG' }, '*');
  }

  handleSvgResponse(data) {
    if (data.success) {
      this.downloadFile(data.svg, 'venue.svg', 'image/svg+xml');
      this.showNotification('SVG exported successfully', 'success');
    } else {
      this.showNotification(`Export failed: ${data.error}`, 'error');
    }
  }

  downloadFile(content, filename, mimeType) {
    const blob = new Blob([content], { type: mimeType });
    const url = URL.createObjectURL(blob);
    const link = document.createElement('a');
    link.href = url;
    link.download = filename;
    link.click();
    URL.revokeObjectURL(url);
  }

  updateUI() {
    const indicator = document.getElementById('unsaved-indicator');
    if (indicator) {
      indicator.style.display = this.hasUnsavedChanges ? 'inline' : 'none';
    }
  }

  showNotification(message, type) {
    console.log(`[${type}] ${message}`);
    // Implement your notification system here
  }

  onSaveComplete(success) {
    // Override this method to handle save completion
    console.log('Save completed:', success);
  }
}

// Usage
const editor = new SeatmapEditorIntegration('seatmap-editor');

document.getElementById('save-btn').addEventListener('click', () => {
  editor.save();
});

document.getElementById('export-btn').addEventListener('click', () => {
  editor.exportSvg();
});

// Warn before leaving with unsaved changes
window.addEventListener('beforeunload', (event) => {
  if (editor.hasUnsavedChanges) {
    event.preventDefault();
    event.returnValue = 'You have unsaved changes. Do you want to leave?';
  }
});

保存并关闭弹窗

function saveAndClose() {
  const iframe = document.getElementById('seatmap-editor');
  const modal = document.getElementById('editor-modal');

  // Show saving indicator
  showStatus('Saving changes...');

  // Send save request
  iframe.contentWindow.postMessage({ type: 'SAVE_REQUEST' }, '*');

  const listener = (event) => {
    if (event.data.type === 'SCHEMA_UPDATE_SUCCESS') {
      showStatus('Saved successfully!');
      setTimeout(() => {
        modal.style.display = 'none';
      }, 1000);
      window.removeEventListener('message', listener);
    } else if (event.data.type === 'SCHEMA_UPDATE_ERROR') {
      showStatus('Save failed!', 'error');
      // Keep modal open on error
      window.removeEventListener('message', listener);
    }
  };

  window.addEventListener('message', listener);
}

// Warn before closing with unsaved changes
let hasUnsavedChanges = false;

window.addEventListener('message', (event) => {
  if (event.data.type === 'SCHEMA_CHANGED') {
    hasUnsavedChanges = event.data.hasChanges;
  }
});

function attemptClose() {
  if (hasUnsavedChanges) {
    if (confirm('You have unsaved changes. Save before closing?')) {
      saveAndClose();
    } else {
      closeModal();
    }
  } else {
    closeModal();
  }
}

带状态显示的定期自动保存

class EditorManager {
  constructor(iframeId) {
    this.iframe = document.getElementById(iframeId);
    this.hasChanges = false;
    this.lastSaveTime = null;
    this.autoSaveInterval = 60000; // 1 minute
    this.autoSaveTimer = null;

    this.initMessageListener();
    this.startAutoSave();
  }

  initMessageListener() {
    window.addEventListener('message', (event) => {
      this.handleMessage(event.data);
    });
  }

  handleMessage(data) {
    switch (data.type) {
      case 'SCHEMA_CHANGED':
        this.hasChanges = data.hasChanges;
        this.updateStatus();
        if (data.hasChanges) {
          this.scheduleAutoSave();
        }
        break;

      case 'SCHEMA_UPDATE_SUCCESS':
        this.hasChanges = false;
        this.lastSaveTime = new Date();
        this.updateStatus();
        break;

      case 'SCHEMA_UPDATE_ERROR':
        this.showError('Failed to save changes');
        break;
    }
  }

  scheduleAutoSave() {
    clearTimeout(this.autoSaveTimer);
    this.autoSaveTimer = setTimeout(() => {
      if (this.hasChanges) {
        this.save();
      }
    }, this.autoSaveInterval);
  }

  startAutoSave() {
    setInterval(() => {
      if (this.hasChanges) {
        console.log('Auto-saving...');
        this.save();
      }
    }, this.autoSaveInterval);
  }

  save() {
    this.iframe.contentWindow.postMessage({ type: 'SAVE_REQUEST' }, '*');
  }

  updateStatus() {
    const statusEl = document.getElementById('save-status');
    if (this.hasChanges) {
      statusEl.textContent = '● Unsaved changes';
      statusEl.className = 'status-warning';
    } else if (this.lastSaveTime) {
      const elapsed = Math.floor((new Date() - this.lastSaveTime) / 1000);
      statusEl.textContent = `✓ Saved ${elapsed}s ago`;
      statusEl.className = 'status-success';
    } else {
      statusEl.textContent = 'No changes';
      statusEl.className = 'status-info';
    }
  }

  showError(message) {
    const statusEl = document.getElementById('save-status');
    statusEl.textContent = `✗ ${message}`;
    statusEl.className = 'status-error';
  }
}

// Usage
const editor = new EditorManager('seatmap-editor');

// Manual save button
document.getElementById('save-btn').addEventListener('click', () => {
  editor.save();
});

保存、导出、下载和关闭流程

async function completeWorkflow() {
  const iframe = document.getElementById('seatmap-editor');
  const statusEl = document.getElementById('workflow-status');

  try {
    // Step 1: Save
    statusEl.textContent = 'Step 1/3: Saving schema...';
    await sendMessageAndWait(
      iframe,
      { type: 'SAVE_REQUEST' },
      'SCHEMA_UPDATE_SUCCESS',
      'SCHEMA_UPDATE_ERROR',
    );

    // Step 2: Export SVG
    statusEl.textContent = 'Step 2/3: Exporting SVG...';
    const svgData = await sendMessageAndWait(iframe, { type: 'FETCH_SVG' }, null, null, true);

    if (!svgData.success) {
      throw new Error(svgData.error);
    }

    // Step 3: Download
    statusEl.textContent = 'Step 3/3: Downloading...';
    downloadFile(svgData.svg, 'venue.svg', 'image/svg+xml');

    // Step 4: Close
    statusEl.textContent = 'Complete! Closing...';
    setTimeout(() => {
      closeEditor();
    }, 1000);
  } catch (error) {
    statusEl.textContent = `Error: ${error.message}`;
    console.error('Workflow failed:', error);
  }
}

function sendMessageAndWait(iframe, message, successType, errorType, returnData = false) {
  return new Promise((resolve, reject) => {
    const timeout = setTimeout(() => {
      window.removeEventListener('message', handler);
      reject(new Error('Operation timed out'));
    }, 30000);

    const handler = (event) => {
      if (returnData && event.data.type === 'SVG_RESPONSE') {
        clearTimeout(timeout);
        window.removeEventListener('message', handler);
        resolve(event.data);
      } else if (successType && event.data.type === successType) {
        clearTimeout(timeout);
        window.removeEventListener('message', handler);
        resolve(event.data);
      } else if (errorType && event.data.type === errorType) {
        clearTimeout(timeout);
        window.removeEventListener('message', handler);
        reject(new Error('Operation failed'));
      }
    };

    window.addEventListener('message', handler);
    iframe.contentWindow.postMessage(message, '*');
  });
}

function downloadFile(content, filename, mimeType) {
  const blob = new Blob([content], { type: mimeType });
  const url = URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = url;
  link.download = filename;
  link.click();
  URL.revokeObjectURL(url);
}

带进度跟踪的批量操作

class BatchOperationManager {
  constructor(iframeId) {
    this.iframe = document.getElementById(iframeId);
    this.operations = [];
    this.currentOperation = null;
  }

  addOperation(name, message, successTypes, errorTypes) {
    this.operations.push({
      name,
      message,
      successTypes: Array.isArray(successTypes) ? successTypes : [successTypes],
      errorTypes: Array.isArray(errorTypes) ? errorTypes : [errorTypes],
      status: 'pending',
    });
  }

  async executeAll() {
    const totalOps = this.operations.length;

    for (let i = 0; i < totalOps; i++) {
      const op = this.operations[i];
      this.updateProgress(i + 1, totalOps, op.name);

      try {
        await this.executeOperation(op);
        op.status = 'success';
      } catch (error) {
        op.status = 'failed';
        console.error(`Operation "${op.name}" failed:`, error);
        throw error; // Stop on first failure
      }
    }

    this.updateProgress(totalOps, totalOps, 'Complete');
  }

  executeOperation(op) {
    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        window.removeEventListener('message', handler);
        reject(new Error(`${op.name} timed out`));
      }, 60000);

      const handler = (event) => {
        if (op.successTypes.includes(event.data.type)) {
          clearTimeout(timeout);
          window.removeEventListener('message', handler);
          resolve(event.data);
        } else if (op.errorTypes.includes(event.data.type)) {
          clearTimeout(timeout);
          window.removeEventListener('message', handler);
          reject(new Error(`${op.name} failed`));
        }
      };

      window.addEventListener('message', handler);
      this.iframe.contentWindow.postMessage(op.message, '*');
    });
  }

  updateProgress(current, total, currentOp) {
    const percent = Math.round((current / total) * 100);
    const progressBar = document.getElementById('progress-bar');
    const progressText = document.getElementById('progress-text');

    if (progressBar) progressBar.style.width = `${percent}%`;
    if (progressText) progressText.textContent = `${currentOp} (${current}/${total})`;
  }
}

// Usage example
async function saveThenExportThenClose() {
  const batch = new BatchOperationManager('seatmap-editor');

  batch.addOperation(
    'Save Schema',
    { type: 'SAVE_REQUEST' },
    'SCHEMA_UPDATE_SUCCESS',
    'SCHEMA_UPDATE_ERROR',
  );

  batch.addOperation('Export SVG', { type: 'FETCH_SVG' }, 'SVG_RESPONSE', 'SVG_RESPONSE');

  try {
    await batch.executeAll();
    console.log('All operations completed successfully');
    setTimeout(() => closeEditor(), 1000);
  } catch (error) {
    alert('Operation failed: ' + error.message);
  }
}

TypeScript 支持

消息载荷的类型定义:

// Incoming message types (Parent → Editor)
type IncomingMessage = { type: 'SAVE_REQUEST' } | { type: 'FETCH_SVG' } | 'SAVE'; // Legacy format

// Outgoing message types (Editor → Parent)
type OutgoingMessage =
  | { type: 'SCHEMA_CHANGED'; hasChanges: boolean }
  | { type: 'SCHEMA_UPDATE_SUCCESS' }
  | { type: 'SCHEMA_UPDATE_ERROR' }
  | { type: 'SEATMAP_UPDATE_SUCCESS' }
  | { type: 'SEATMAP_UPDATE_ERROR' }
  | { type: 'UPDATE_PRICE_ASSIGNMENTS_SUCCESS' }
  | { type: 'UPDATE_PRICE_ASSIGNMENTS_ERROR'; payload: unknown }
  | { type: 'PUBLISH_REQUEST'; payload: ISchemaSettings }
  | { type: 'SVG_RESPONSE'; success: true; svg: string }
  | { type: 'SVG_RESPONSE'; success: false; error: string };

// Schema settings interface (simplified)
interface ISchemaSettings {
  id?: number;
  venueId: number;
  name: string;
  draft: boolean;
  // ... other properties
}

// Event listener with types
window.addEventListener('message', (event: MessageEvent<OutgoingMessage>) => {
  const data = event.data;

  if (data.type === 'SVG_RESPONSE') {
    if (data.success) {
      const svg: string = data.svg;
    } else {
      const error: string = data.error;
    }
  }
});

最佳实践

1. 始终验证消息来源

window.addEventListener('message', (event) => {
  const allowedOrigins = ['https://editor.seatmap.pro', 'https://editor.seatmap.dev'];

  if (!allowedOrigins.includes(event.origin)) {
    return;
  }

  handleMessage(event.data);
});

一次完整保存最多可触发 3 种不同的成功消息:

let saveResults = { schema: false, seatmap: false, prices: false };

function checkAllSaved() {
  return saveResults.schema && saveResults.seatmap && saveResults.prices;
}

3. 为操作设置超时

const timeout = setTimeout(() => {
  console.error('Save operation timed out');
  resetSaveState();
}, 30000); // 30 second timeout

// Clear timeout when operation completes

4. 跟踪操作状态

const operationState = {
  saveInProgress: false,
  publishInProgress: false,
  exportInProgress: false,
};

// Prevent duplicate operations
if (operationState.saveInProgress) {
  console.warn('Save already in progress');
  return;
}

5. 向用户提供反馈

// Show loading indicators
// Display success/error notifications
// Update UI state based on messages
// Log operations for debugging

6. 处理未保存更改

window.addEventListener('beforeunload', (event) => {
  if (hasUnsavedChanges) {
    event.preventDefault();
    event.returnValue = '';
  }
});

7. 导出前等待座位图保存

async function saveAndExport() {
  // Save first
  await save();

  // Then export
  exportSvg();
}

更多资源

支持

iframe 集成有疑问或问题时:

  • 查看已有测试文件中的可运行示例。
  • 检查浏览器控制台中的错误信息。
  • 联系支持团队,并提供具体错误详情和浏览器信息。

输入功能、设置、端点或 SDK 方法。