Skip to content

Vue 3 接入:挂件生成器示例

目标:

  1. 先创建一个 Vue 3 项目
  2. 接入登录 SDK
  3. 接入 Open in Studio

对于新的标准生成器,优先推荐使用 generator-workbench 作为统一官方壳层。这篇文档更适合你理解或定制 Vue 项目里的原始 SDK 接入方式。

查看完整示例

第 1 步:创建 Vue 3 项目

本步涉及文件:项目初始化后自动生成的整个 Vue 工程文件。

根据 Vue 官方文档,推荐先用 create-vue 创建项目:

bash
npm create vue@latest

如果你用 pnpm

bash
pnpm create vue@latest

如果你只是想快速跑通这个示例,建议先选最简单配置:

  • TypeScript:No
  • JSX:No
  • Vue Router:No
  • Pinia:No
  • Vitest:No
  • E2E:No
  • ESLint:No
  • Prettier:No

创建完成后,进入目录并启动项目:

bash
cd your-project-name
npm install
npm run dev

第 2 步:安装 generator-sdk

本步修改文件:package.json

bash
npm install @atomm-developer/generator-sdk

第 3 步:创建 SDK 单例

本步修改文件:src/sdk.js.env.local

新建 src/sdk.js

js
import { GeneratorSDK } from '@atomm-developer/generator-sdk'

export const sdk = GeneratorSDK.init({
  appKey: import.meta.env.VITE_ATOMM_APP_KEY,
  env: import.meta.env.MODE === 'production' ? 'prod' : 'dev',
})

再在项目根目录新建 .env.local

bash
VITE_ATOMM_APP_KEY=your_app_key

第 4 步:实现登录

本步修改文件:src/App.vue

登录逻辑和 CDN 版一样,重点还是这 3 个动作:

  1. 点击按钮时调用 sdk.auth.login()
  2. 监听 sdk.auth.onChange()
  3. 直接使用 status.userInfo.headpic
js
import { onMounted, onUnmounted, ref } from 'vue'
import { sdk } from './sdk'

const authStatus = ref({ isLogin: false, userInfo: null })
let unsubscribeAuth = null

onMounted(() => {
  authStatus.value = sdk.auth.getStatus()
  unsubscribeAuth = sdk.auth.onChange((status) => {
    authStatus.value = status
  })
})

onUnmounted(() => {
  unsubscribeAuth?.()
})

async function handleLogin() {
  try {
    await sdk.auth.login()
  } catch (error) {}
}

async function handleLogout() {
  try {
    await sdk.auth.logout()
  } catch (error) {}
}

模板里直接用:

vue
<button v-if="!authStatus.isLogin" @click="handleLogin">Log in</button>

<div v-else class="user-menu">
  <img :src="authStatus.userInfo?.headpic" alt="User avatar" />
  <button @click="handleLogout">Log out</button>
</div>

第 5 步:实现挂件预览

本步修改文件:src/App.vue

当前 demo 的挂件是极简矩形吊牌:挂洞在矩形内部,只有一个单词 generator,描边是 2px

js
import { computed, reactive } from 'vue'

const state = reactive({
  width: 96,
  height: 128,
})

function getPendantGeometry(width, height) {
  const viewBoxWidth = 360
  const viewBoxHeight = 420
  const bodyWidth = Math.max(168, width * 2.08)
  const bodyHeight = Math.max(210, height * 2.08)
  const bodyX = (viewBoxWidth - bodyWidth) / 2
  const bodyY = 96
  const cornerRadius = Math.max(14, Math.min(bodyWidth, bodyHeight) * 0.06)
  const holeRadius = Math.max(10, Math.min(width, height) * 0.1)
  const holeCenterX = viewBoxWidth / 2
  const holeCenterY = bodyY + Math.max(26, bodyHeight * 0.14)
  const textY = bodyY + bodyHeight * 0.58
  const textSize = Math.max(20, Math.min(28, bodyWidth * 0.11))

  return {
    viewBoxWidth,
    viewBoxHeight,
    bodyWidth,
    bodyHeight,
    bodyX,
    bodyY,
    cornerRadius,
    holeRadius,
    holeCenterX,
    holeCenterY,
    textY,
    textSize,
  }
}

// 用于屏幕预览 — 白色填充 + 投影
const previewSvg = computed(() => {
  const g = getPendantGeometry(state.width, state.height)

  return `
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${g.viewBoxWidth} ${g.viewBoxHeight}" style="width:min(100%,520px);height:auto;">
    <defs>
      <filter id="softShadow" x="-20%" y="-20%" width="140%" height="160%">
        <feDropShadow dx="0" dy="14" stdDeviation="18" flood-color="#000000" flood-opacity="0.16" />
      </filter>
    </defs>
    <g filter="url(#softShadow)">
      <path d="M ${g.bodyX + g.cornerRadius} ${g.bodyY}
        L ${g.bodyX + g.bodyWidth - g.cornerRadius} ${g.bodyY}
        Q ${g.bodyX + g.bodyWidth} ${g.bodyY} ${g.bodyX + g.bodyWidth} ${g.bodyY + g.cornerRadius}
        L ${g.bodyX + g.bodyWidth} ${g.bodyY + g.bodyHeight - g.cornerRadius}
        Q ${g.bodyX + g.bodyWidth} ${g.bodyY + g.bodyHeight} ${g.bodyX + g.bodyWidth - g.cornerRadius} ${g.bodyY + g.bodyHeight}
        L ${g.bodyX + g.cornerRadius} ${g.bodyY + g.bodyHeight}
        Q ${g.bodyX} ${g.bodyY + g.bodyHeight} ${g.bodyX} ${g.bodyY + g.bodyHeight - g.cornerRadius}
        L ${g.bodyX} ${g.bodyY + g.cornerRadius}
        Q ${g.bodyX} ${g.bodyY} ${g.bodyX + g.cornerRadius} ${g.bodyY}
        Z"
        fill="#ffffff" stroke="#111111" stroke-width="2"
      />
      <circle cx="${g.holeCenterX}" cy="${g.holeCenterY}" r="${g.holeRadius}" fill="#ffffff" stroke="#111111" stroke-width="2" />
      <text x="${g.holeCenterX}" y="${g.textY}" text-anchor="middle" dominant-baseline="middle" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="${g.textSize}" font-weight="600" letter-spacing="0.06em" fill="#111111">generator</text>
    </g>
  </svg>
  `
})

// 用于导出 — 根节点设置 fill="none",<path> 和 <circle> 不带 fill 属性
// 不要给子元素添加 fill="#ffffff",否则会覆盖根节点设置,导致 Studio 导入后图形全黑
function buildExportSvg(width, height) {
  const g = getPendantGeometry(width, height)
  return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${width}mm" height="${height}mm" viewBox="0 0 ${g.viewBoxWidth} ${g.viewBoxHeight}" fill="none">
  <path d="M ${g.bodyX + g.cornerRadius} ${g.bodyY} L ${g.bodyX + g.bodyWidth - g.cornerRadius} ${g.bodyY} Q ${g.bodyX + g.bodyWidth} ${g.bodyY} ${g.bodyX + g.bodyWidth} ${g.bodyY + g.cornerRadius} L ${g.bodyX + g.bodyWidth} ${g.bodyY + g.bodyHeight - g.cornerRadius} Q ${g.bodyX + g.bodyWidth} ${g.bodyY + g.bodyHeight} ${g.bodyX + g.bodyWidth - g.cornerRadius} ${g.bodyY + g.bodyHeight} L ${g.bodyX + g.cornerRadius} ${g.bodyY + g.bodyHeight} Q ${g.bodyX} ${g.bodyY + g.bodyHeight} ${g.bodyX} ${g.bodyY + g.bodyHeight - g.cornerRadius} L ${g.bodyX} ${g.bodyY + g.cornerRadius} Q ${g.bodyX} ${g.bodyY} ${g.bodyX + g.cornerRadius} ${g.bodyY} Z"
    stroke="#111111" stroke-width="2"/>
  <circle cx="${g.holeCenterX}" cy="${g.holeCenterY}" r="${g.holeRadius}" stroke="#111111" stroke-width="2"/>
  <text x="${g.holeCenterX}" y="${g.textY}" text-anchor="middle" dominant-baseline="middle" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="${g.textSize}" font-weight="600" letter-spacing="0.06em" fill="#111111">generator</text>
</svg>`
}

模板里直接渲染:

vue
<div v-html="previewSvg"></div>

第 6 步:接入导出 SDK

本步修改文件:src/App.vue

注册导出 provider:SVG 格式直接返回 SVG 字符串,其他格式回退到 canvas;调用 sdk.export.openInStudio({ format: 'svg' }) 将 SVG 发送给 Studio。

先准备一个隐藏 canvas(供非 SVG 格式回退使用):

vue
<canvas ref="exportCanvasRef" style="display: none;" />

然后注册导出 provider:

js
import { onMounted, ref } from 'vue'

const exportCanvasRef = ref(null)

onMounted(() => {
  sdk.export.register({
    getExportData: (purpose, format) => {
      if (format === 'svg') {
        return { type: 'svg', svgString: buildExportSvg(state.width, state.height) }
      }
      return { type: 'canvas', canvas: exportCanvasRef.value }
    },
    getFileName: () => `pendant-${state.width}x${state.height}.svg`,
  })
})

canvas 回退时,把导出 SVG 同步到隐藏 canvas:

js
async function syncExportCanvas() {
  const svgText = buildExportSvg(state.width, state.height)
  const blob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' })
  const url = URL.createObjectURL(blob)
  const image = new Image()
  const canvas = exportCanvasRef.value
  const ctx = canvas.getContext('2d')

  try {
    await new Promise((resolve, reject) => {
      image.onload = resolve
      image.onerror = reject
      image.src = url
    })

    canvas.width = 1080
    canvas.height = 1260
    ctx.clearRect(0, 0, canvas.width, canvas.height)
    ctx.fillStyle = '#ffffff'
    ctx.fillRect(0, 0, canvas.width, canvas.height)
    ctx.drawImage(image, 0, 0, canvas.width, canvas.height)
  } finally {
    URL.revokeObjectURL(url)
  }
}

最后给按钮加 loading:

js
const isStudioLoading = ref(false)

async function handleOpenInStudio() {
  try {
    isStudioLoading.value = true
    await sdk.export.openInStudio({ format: 'svg' })
  } finally {
    isStudioLoading.value = false
  }
}

第 7 步:完成完整示例

本步修改文件:src/App.vuesrc/sdk.js

src/App.vue

vue
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { sdk } from './sdk'

const authStatus = ref({ isLogin: false, userInfo: null })
const exportCanvasRef = ref(null)
const isStudioLoading = ref(false)
const fallbackAvatar =
  'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><rect width="32" height="32" rx="4" fill="%23000"/></svg>'

const state = reactive({
  width: 96,
  height: 128,
})

let unsubscribeAuth = null
let exportSyncToken = 0

function getPendantGeometry(width, height) {
  const viewBoxWidth = 360
  const viewBoxHeight = 420
  const bodyWidth = Math.max(168, width * 2.08)
  const bodyHeight = Math.max(210, height * 2.08)
  const bodyX = (viewBoxWidth - bodyWidth) / 2
  const bodyY = 96
  const cornerRadius = Math.max(14, Math.min(bodyWidth, bodyHeight) * 0.06)
  const holeRadius = Math.max(10, Math.min(width, height) * 0.1)
  const holeCenterX = viewBoxWidth / 2
  const holeCenterY = bodyY + Math.max(26, bodyHeight * 0.14)
  const textY = bodyY + bodyHeight * 0.58
  const textSize = Math.max(20, Math.min(28, bodyWidth * 0.11))

  return {
    viewBoxWidth,
    viewBoxHeight,
    bodyWidth,
    bodyHeight,
    bodyX,
    bodyY,
    cornerRadius,
    holeRadius,
    holeCenterX,
    holeCenterY,
    textY,
    textSize,
  }
}

// 用于屏幕预览 — 白色填充 + 投影
function buildPreviewSvgMarkup(width, height) {
  const g = getPendantGeometry(width, height)
  return `
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${g.viewBoxWidth} ${g.viewBoxHeight}" style="width:min(100%,520px);height:auto;">
    <defs>
      <filter id="softShadow" x="-20%" y="-20%" width="140%" height="160%">
        <feDropShadow dx="0" dy="14" stdDeviation="18" flood-color="#000000" flood-opacity="0.16" />
      </filter>
    </defs>
    <g filter="url(#softShadow)">
      <path d="M ${g.bodyX + g.cornerRadius} ${g.bodyY} L ${g.bodyX + g.bodyWidth - g.cornerRadius} ${g.bodyY} Q ${g.bodyX + g.bodyWidth} ${g.bodyY} ${g.bodyX + g.bodyWidth} ${g.bodyY + g.cornerRadius} L ${g.bodyX + g.bodyWidth} ${g.bodyY + g.bodyHeight - g.cornerRadius} Q ${g.bodyX + g.bodyWidth} ${g.bodyY + g.bodyHeight} ${g.bodyX + g.bodyWidth - g.cornerRadius} ${g.bodyY + g.bodyHeight} L ${g.bodyX + g.cornerRadius} ${g.bodyY + g.bodyHeight} Q ${g.bodyX} ${g.bodyY + g.bodyHeight} ${g.bodyX} ${g.bodyY + g.bodyHeight - g.cornerRadius} L ${g.bodyX} ${g.bodyY + g.cornerRadius} Q ${g.bodyX} ${g.bodyY} ${g.bodyX + g.cornerRadius} ${g.bodyY} Z"
        fill="#ffffff" stroke="#111111" stroke-width="2"/>
      <circle cx="${g.holeCenterX}" cy="${g.holeCenterY}" r="${g.holeRadius}" fill="#ffffff" stroke="#111111" stroke-width="2"/>
      <text x="${g.holeCenterX}" y="${g.textY}" text-anchor="middle" dominant-baseline="middle" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="${g.textSize}" font-weight="600" letter-spacing="0.06em" fill="#111111">generator</text>
    </g>
  </svg>`
}

// 用于导出 — 根节点设置 fill="none",<path> 和 <circle> 不带 fill 属性
// 不要给子元素添加 fill="#ffffff",否则会覆盖根节点设置,导致 Studio 导入后图形全黑
function buildExportSvg(width, height) {
  const g = getPendantGeometry(width, height)
  return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${width}mm" height="${height}mm" viewBox="0 0 ${g.viewBoxWidth} ${g.viewBoxHeight}" fill="none">
  <path d="M ${g.bodyX + g.cornerRadius} ${g.bodyY} L ${g.bodyX + g.bodyWidth - g.cornerRadius} ${g.bodyY} Q ${g.bodyX + g.bodyWidth} ${g.bodyY} ${g.bodyX + g.bodyWidth} ${g.bodyY + g.cornerRadius} L ${g.bodyX + g.bodyWidth} ${g.bodyY + g.bodyHeight - g.cornerRadius} Q ${g.bodyX + g.bodyWidth} ${g.bodyY + g.bodyHeight} ${g.bodyX + g.bodyWidth - g.cornerRadius} ${g.bodyY + g.bodyHeight} L ${g.bodyX + g.cornerRadius} ${g.bodyY + g.bodyHeight} Q ${g.bodyX} ${g.bodyY + g.bodyHeight} ${g.bodyX} ${g.bodyY + g.bodyHeight - g.cornerRadius} L ${g.bodyX} ${g.bodyY + g.cornerRadius} Q ${g.bodyX} ${g.bodyY} ${g.bodyX + g.cornerRadius} ${g.bodyY} Z"
    stroke="#111111" stroke-width="2"/>
  <circle cx="${g.holeCenterX}" cy="${g.holeCenterY}" r="${g.holeRadius}" stroke="#111111" stroke-width="2"/>
  <text x="${g.holeCenterX}" y="${g.textY}" text-anchor="middle" dominant-baseline="middle" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="${g.textSize}" font-weight="600" letter-spacing="0.06em" fill="#111111">generator</text>
</svg>`
}

const previewSvg = computed(() => buildPreviewSvgMarkup(state.width, state.height))

async function syncExportCanvas() {
  const currentToken = ++exportSyncToken
  const svgText = buildExportSvg(state.width, state.height)
  const blob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' })
  const url = URL.createObjectURL(blob)
  const image = new Image()
  const canvas = exportCanvasRef.value
  const ctx = canvas.getContext('2d')

  try {
    await new Promise((resolve, reject) => {
      image.onload = resolve
      image.onerror = reject
      image.src = url
    })

    if (currentToken !== exportSyncToken) return

    canvas.width = 1080
    canvas.height = 1260
    ctx.clearRect(0, 0, canvas.width, canvas.height)
    ctx.fillStyle = '#ffffff'
    ctx.fillRect(0, 0, canvas.width, canvas.height)
    ctx.drawImage(image, 0, 0, canvas.width, canvas.height)
  } finally {
    URL.revokeObjectURL(url)
  }
}

onMounted(async () => {
  sdk.export.register({
    getExportData: (purpose, format) => {
      if (format === 'svg') {
        return { type: 'svg', svgString: buildExportSvg(state.width, state.height) }
      }
      return { type: 'canvas', canvas: exportCanvasRef.value }
    },
    getFileName: () => `pendant-${state.width}x${state.height}.svg`,
  })

  authStatus.value = sdk.auth.getStatus()
  unsubscribeAuth = sdk.auth.onChange((status) => {
    authStatus.value = status
  })

  await syncExportCanvas()
})

onUnmounted(() => {
  unsubscribeAuth?.()
})

watch(
  () => [state.width, state.height],
  async () => {
    await syncExportCanvas()
  },
)

async function handleLogin() {
  try {
    await sdk.auth.login()
  } catch (error) {}
}

async function handleLogout() {
  try {
    await sdk.auth.logout()
  } catch (error) {}
}

async function handleOpenInStudio() {
  try {
    isStudioLoading.value = true
    await sdk.export.openInStudio({ format: 'svg' })
  } finally {
    isStudioLoading.value = false
  }
}
</script>

<template>
  <div class="app">
    <header class="topbar">
      <div class="brand">
        <div class="brand-badge">P</div>
        <h1>Pendant Generator</h1>
      </div>

      <div class="topbar-actions">
        <button v-if="!authStatus.isLogin" class="ghost-btn" @click="handleLogin">Log in</button>

        <div v-else class="user-menu">
          <button class="user-menu-trigger" type="button">
            <img class="avatar" :src="authStatus.userInfo?.headpic || fallbackAvatar" alt="User avatar" />
          </button>
          <div class="user-menu-panel">
            <button class="ghost-btn logout-btn" @click="handleLogout">Log out</button>
          </div>
        </div>
      </div>
    </header>

    <main class="workspace">
      <section class="stage">
        <div class="artboard">
          <div class="preview-host" v-html="previewSvg"></div>
        </div>
      </section>

      <aside class="panel">
        <div class="panel-scroll">
          <div class="card">
            <div class="field">
              <div class="field-head">
                <label for="widthRange">Width</label>
                <span>{{ state.width }} mm</span>
              </div>
              <input id="widthRange" v-model="state.width" type="range" min="60" max="180" step="1" />
            </div>

            <div class="field">
              <div class="field-head">
                <label for="heightRange">Height</label>
                <span>{{ state.height }} mm</span>
              </div>
              <input id="heightRange" v-model="state.height" type="range" min="80" max="220" step="1" />
            </div>
          </div>
        </div>

        <div class="panel-footer">
          <button class="primary-btn" :disabled="isStudioLoading" @click="handleOpenInStudio">
            {{ isStudioLoading ? 'Opening...' : 'Open in Studio' }}
          </button>
        </div>
      </aside>
    </main>

    <canvas ref="exportCanvasRef" style="display: none" />

    <div v-if="isStudioLoading" class="loading-overlay">
      <div class="loading-card">Opening xTool Studio...</div>
    </div>
  </div>
</template>

<style scoped>
* { box-sizing: border-box; }
.app { min-height: 100vh; background: #f3f3f3; color: #111; }
.topbar {
  height: 64px;
  padding: 0 24px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  background: rgba(255,255,255,.92);
  border-bottom: 1px solid #d4d4d4;
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-badge {
  width: 34px;
  height: 34px;
  border-radius: 4px;
  display: grid;
  place-items: center;
  background: #111;
  color: #fff;
  font-weight: 700;
}
.topbar-actions { display: flex; align-items: center; gap: 12px; }
.ghost-btn, .primary-btn {
  border-radius: 4px;
  padding: 10px 16px;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
}
.ghost-btn { border: 1px solid #d4d4d4; background: #fff; color: #111; }
.primary-btn { width: 100%; border: 1px solid #111; background: #111; color: #fff; }
.primary-btn:disabled { opacity: .55; cursor: not-allowed; }
.user-menu { position: relative; }
.user-menu-trigger {
  border: 1px solid #d4d4d4;
  background: #fff;
  border-radius: 4px;
  padding: 4px;
  cursor: pointer;
}
.user-menu-panel {
  position: absolute;
  top: calc(100% + 10px);
  right: 0;
  width: 132px;
  padding: 8px;
  border: 1px solid #d4d4d4;
  border-radius: 4px;
  background: #fff;
  display: none;
}
.user-menu:hover .user-menu-panel { display: block; }
.logout-btn { width: 100%; }
.avatar { width: 32px; height: 32px; border-radius: 4px; object-fit: cover; display: block; }
.workspace { display: grid; grid-template-columns: minmax(0, 1fr) 320px; min-height: calc(100vh - 64px); }
.stage {
  padding: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
  background:
    linear-gradient(rgba(0,0,0,.05) 1px, transparent 1px),
    linear-gradient(90deg, rgba(0,0,0,.05) 1px, transparent 1px),
    #f3f3f3;
  background-size: 24px 24px, 24px 24px, auto;
}
.artboard { width: 100%; height: 100%; display: grid; place-items: center; }
.preview-host { width: 100%; height: 100%; display: grid; place-items: center; }
.panel { display: flex; flex-direction: column; border-left: 1px solid #d4d4d4; background: rgba(255,255,255,.88); }
.panel-scroll { flex: 1; padding: 24px; }
.card { border: 1px solid #d4d4d4; border-radius: 4px; background: #fff; padding: 16px; }
.field + .field { margin-top: 18px; }
.field-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
input[type="range"] { width: 100%; appearance: none; height: 6px; border-radius: 4px; background: rgba(0,0,0,.14); }
input[type="range"]::-webkit-slider-thumb {
  appearance: none;
  width: 18px;
  height: 18px;
  border-radius: 4px;
  background: #fff;
  border: 1px solid #111;
}
.panel-footer { padding: 16px 24px 24px; border-top: 1px solid #d4d4d4; }
.loading-overlay {
  position: fixed;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(255,255,255,.72);
}
.loading-card {
  padding: 18px 20px;
  border: 1px solid #d4d4d4;
  border-radius: 4px;
  background: #fff;
  font-size: 13px;
  font-weight: 600;
}
</style>

src/sdk.js

js
import { GeneratorSDK } from '@atomm-developer/generator-sdk'

export const sdk = GeneratorSDK.init({
  appKey: import.meta.env.VITE_ATOMM_APP_KEY,
  env: import.meta.env.MODE === 'production' ? 'prod' : 'dev',
})

第 8 步:上传资源

  1. 打包资源为 zip 包,zip 包必须包含 html 文件。如果是 Vue/React 工程项目,需要执行构建打包(如 npm run build),将 dist 目录压缩为 zip。
  2. 上传 zip 包到 https://www.atomm.com/generator-upload 地址。注意:如果没有 xTool 账号,需要预先注册 xTool 账号。

关键步骤回顾

Vue 版本的关键点只有 4 个:

  1. 先用 create-vue 创建最小 Vue 3 项目
  2. 用单独的 src/sdk.js 初始化 SDK,不要把 SDK 放进响应式对象
  3. sdk.auth.login() + sdk.auth.onChange() 接入登录,并显示 userInfo.headpic
  4. sdk.export.register() 注册隐藏 canvas,再通过 sdk.export.openInStudio() 打开到 xTool Studio

如果你想了解 Vue 3 项目创建方式,可以直接参考官方文档:Vue 3 Quick Start

MIT Licensed