Vue 3 接入:挂件生成器示例
目标:
- 先创建一个 Vue 3 项目
- 接入登录 SDK
- 接入
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 个动作:
- 点击按钮时调用
sdk.auth.login() - 监听
sdk.auth.onChange() - 直接使用
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.vue、src/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 步:上传资源
- 打包资源为 zip 包,zip 包必须包含
html文件。如果是 Vue/React 工程项目,需要执行构建打包(如npm run build),将dist目录压缩为 zip。 - 上传 zip 包到 https://www.atomm.com/generator-upload 地址。注意:如果没有 xTool 账号,需要预先注册 xTool 账号。
关键步骤回顾
Vue 版本的关键点只有 4 个:
- 先用
create-vue创建最小 Vue 3 项目 - 用单独的
src/sdk.js初始化 SDK,不要把 SDK 放进响应式对象 - 用
sdk.auth.login()+sdk.auth.onChange()接入登录,并显示userInfo.headpic - 用
sdk.export.register()注册隐藏 canvas,再通过sdk.export.openInStudio()打开到 xTool Studio
如果你想了解 Vue 3 项目创建方式,可以直接参考官方文档:Vue 3 Quick Start。