feat: add playground component save workflow

This commit is contained in:
sunlei 2026-05-13 18:17:07 +08:00
parent 51acbd50db
commit 3e7c9e329f
15 changed files with 743 additions and 70 deletions

2
.env.development Normal file
View File

@ -0,0 +1,2 @@
VITE_APP_API_BASE = "/api"
VITE_APP_PROXY = "http://192.168.0.49:48085/"

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vue SFC Playground</title> <title>KT-Template - Playground</title>
<style> <style>
body { body {
margin: 0; margin: 0;
@ -13,6 +13,16 @@
height: 100vh; height: 100vh;
height: 100dvh; height: 100dvh;
} }
.playground-shell {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.playground-shell > .vue-repl {
flex: 1;
min-height: 0;
}
</style> </style>
<script type="module" src="./test/main.ts"></script> <script type="module" src="./test/main.ts"></script>

View File

@ -47,7 +47,7 @@
"tag": "latest" "tag": "latest"
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite --host",
"build": "vite build", "build": "vite build",
"build-preview": "vite build -c vite.preview.config.ts", "build-preview": "vite build -c vite.preview.config.ts",
"format": "prettier --write .", "format": "prettier --write .",
@ -115,9 +115,12 @@
"typescript-eslint": "^8.39.0", "typescript-eslint": "^8.39.0",
"vite": "^8.0.8", "vite": "^8.0.8",
"vite-plugin-dts": "^4.5.4", "vite-plugin-dts": "^4.5.4",
"vscode-uri": "^3.1.0",
"volar-service-typescript": "0.0.65", "volar-service-typescript": "0.0.65",
"vscode-uri": "^3.1.0",
"vue": "^3.5.18", "vue": "^3.5.18",
"vue-tsc": "3.0.8" "vue-tsc": "3.0.8"
},
"dependencies": {
"axios": "^1.16.0"
} }
} }

View File

@ -7,6 +7,10 @@ settings:
importers: importers:
.: .:
dependencies:
axios:
specifier: ^1.16.0
version: 1.16.0
devDependencies: devDependencies:
'@babel/standalone': '@babel/standalone':
specifier: ^7.28.2 specifier: ^7.28.2
@ -988,10 +992,16 @@ packages:
assert@2.1.0: assert@2.1.0:
resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==} resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
available-typed-arrays@1.0.7: available-typed-arrays@1.0.7:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
axios@1.16.0:
resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==}
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@ -1082,6 +1092,10 @@ packages:
colorette@2.0.20: colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
comma-separated-tokens@2.0.3: comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
@ -1217,6 +1231,10 @@ packages:
defu@6.1.4: defu@6.1.4:
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
dequal@2.0.3: dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -1278,6 +1296,10 @@ packages:
resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==} resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
escalade@3.2.0: escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -1407,6 +1429,15 @@ packages:
flatted@3.3.2: flatted@3.3.2:
resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==} resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==}
follow-redirects@1.16.0:
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
for-each@0.3.3: for-each@0.3.3:
resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
@ -1414,6 +1445,10 @@ packages:
resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==}
engines: {node: '>=14'} engines: {node: '>=14'}
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
fs-extra@11.3.0: fs-extra@11.3.0:
resolution: {integrity: sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==} resolution: {integrity: sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==}
engines: {node: '>=14.14'} engines: {node: '>=14.14'}
@ -1822,6 +1857,14 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
mimic-function@5.0.1: mimic-function@5.0.1:
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -2016,6 +2059,10 @@ packages:
property-information@7.1.0: property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
pug-error@2.1.0: pug-error@2.1.0:
resolution: {integrity: sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==} resolution: {integrity: sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==}
@ -3534,10 +3581,20 @@ snapshots:
object.assign: 4.1.7 object.assign: 4.1.7
util: 0.12.5 util: 0.12.5
asynckit@0.4.0: {}
available-typed-arrays@1.0.7: available-typed-arrays@1.0.7:
dependencies: dependencies:
possible-typed-array-names: 1.0.0 possible-typed-array-names: 1.0.0
axios@1.16.0:
dependencies:
follow-redirects: 1.16.0
form-data: 4.0.5
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
boolbase@1.0.0: {} boolbase@1.0.0: {}
@ -3633,6 +3690,10 @@ snapshots:
colorette@2.0.20: {} colorette@2.0.20: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
comma-separated-tokens@2.0.3: {} comma-separated-tokens@2.0.3: {}
commander@14.0.0: {} commander@14.0.0: {}
@ -3766,6 +3827,8 @@ snapshots:
defu@6.1.4: {} defu@6.1.4: {}
delayed-stream@1.0.0: {}
dequal@2.0.3: {} dequal@2.0.3: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
@ -3813,6 +3876,13 @@ snapshots:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.2.6
has-tostringtag: 1.0.2
hasown: 2.0.2
escalade@3.2.0: {} escalade@3.2.0: {}
escape-string-regexp@4.0.0: {} escape-string-regexp@4.0.0: {}
@ -3962,6 +4032,8 @@ snapshots:
flatted@3.3.2: {} flatted@3.3.2: {}
follow-redirects@1.16.0: {}
for-each@0.3.3: for-each@0.3.3:
dependencies: dependencies:
is-callable: 1.2.7 is-callable: 1.2.7
@ -3971,6 +4043,14 @@ snapshots:
cross-spawn: 7.0.6 cross-spawn: 7.0.6
signal-exit: 4.1.0 signal-exit: 4.1.0
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
mime-types: 2.1.35
fs-extra@11.3.0: fs-extra@11.3.0:
dependencies: dependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
@ -4370,6 +4450,12 @@ snapshots:
braces: 3.0.3 braces: 3.0.3
picomatch: 2.3.1 picomatch: 2.3.1
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
mimic-function@5.0.1: {} mimic-function@5.0.1: {}
minimatch@10.0.3: minimatch@10.0.3:
@ -4554,6 +4640,8 @@ snapshots:
property-information@7.1.0: {} property-information@7.1.0: {}
proxy-from-env@2.1.0: {}
pug-error@2.1.0: {} pug-error@2.1.0: {}
pug-lexer@5.0.1: pug-lexer@5.0.1:

407
src/PlaygroundHeader.vue Normal file
View File

@ -0,0 +1,407 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import {
saveComponent,
updateComponent,
type ComponentPayload,
} from './api/component'
import {
getComponentDictByType,
getDictByKey,
type DictItem,
} from './api/dict'
import { uploadFile } from './api/minio'
import type { ReplStore } from './store'
type ComponentForm = {
id: string
name: string
type: string
componentType: string
}
const props = defineProps<{
store: ReplStore
}>()
const form = reactive<ComponentForm>({
id: '',
name: '',
type: '',
componentType: '',
})
const typeList = ref<DictItem[]>([])
const componentTypeList = ref<DictItem[]>([])
const loading = ref(false)
const message = ref('')
const messageType = ref<'info' | 'success' | 'error'>('info')
const isEdit = computed(() => !!form.id)
const canSave = computed(
() => !!form.name.trim() && !!form.type && !!form.componentType,
)
function readQuery() {
const query = new URLSearchParams(location.search)
form.id = query.get('id') || ''
form.name = query.get('name') || ''
form.type = query.get('type') || ''
form.componentType = query.get('componentType') || ''
}
function setMessage(type: typeof messageType.value, text: string) {
messageType.value = type
message.value = text
}
function nextFrame() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
})
}
function getPreviewIframe() {
return document.querySelector<HTMLIFrameElement>('.iframe-container iframe')
}
function collectStyleText(doc: Document) {
return Array.from(doc.styleSheets)
.map((sheet) => {
try {
return Array.from(sheet.cssRules)
.map((rule) => rule.cssText)
.join('\n')
} catch {
return ''
}
})
.filter(Boolean)
.join('\n')
}
function loadImage(src: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image()
image.onload = () => resolve(image)
image.onerror = () => reject(new Error('截图生成失败'))
image.src = src
})
}
async function capturePreviewImage() {
await nextFrame()
const iframe = getPreviewIframe()
const doc = iframe?.contentDocument
if (!iframe || !doc?.body) {
throw new Error('未找到预览区域,无法生成截图')
}
const width = Math.max(iframe.clientWidth, 1)
const height = Math.max(iframe.clientHeight, 1)
const cloned = doc.body.cloneNode(true) as HTMLElement
const styleText = collectStyleText(doc)
cloned.querySelectorAll('script').forEach((item) => item.remove())
cloned.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml')
cloned.style.width = `${width}px`
cloned.style.height = `${height}px`
cloned.style.margin = '0'
cloned.style.overflow = 'hidden'
const serializer = new XMLSerializer()
const xhtml = Array.from(cloned.childNodes)
.map((node) => serializer.serializeToString(node))
.join('')
const safeStyleText = styleText.replace(/\]\]>/g, ']]]]><![CDATA[>')
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
<foreignObject width="100%" height="100%">
<div xmlns="http://www.w3.org/1999/xhtml" style="width:${width}px;height:${height}px;overflow:hidden;background:#fff;">
<style><![CDATA[${safeStyleText}]]></style>
${xhtml}
</div>
</foreignObject>
</svg>
`
const svgUrl = URL.createObjectURL(
new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }),
)
try {
const image = await loadImage(svgUrl)
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
canvas.getContext('2d')?.drawImage(image, 0, 0, width, height)
return await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) =>
blob ? resolve(blob) : reject(new Error('截图生成失败')),
'image/png',
)
})
} finally {
URL.revokeObjectURL(svgUrl)
}
}
function getScreenshotName() {
const safeName = form.name.trim().replace(/[\\/:*?"<>|\s]+/g, '_')
return `screenshots/${Date.now()}-${safeName || 'component'}.png`
}
async function uploadPreviewImage() {
const imageBlob = await capturePreviewImage()
const imageFile = new File([imageBlob], `${Date.now()}-preview.png`, {
type: 'image/png',
})
const formData = new FormData()
formData.append('file', imageFile)
formData.append('objectName', getScreenshotName())
const data = await uploadFile(formData)
return data.url
}
async function getTypeList() {
typeList.value = await getDictByKey('COMPONENT_TYPE')
if (!form.type && typeList.value[0]) {
form.type = String(typeList.value[0].value)
}
}
async function getComponentTypeList() {
if (!form.type) {
componentTypeList.value = []
form.componentType = ''
return
}
componentTypeList.value = await getComponentDictByType(form.type)
const exists = componentTypeList.value.some(
(item) => String(item.value) === form.componentType,
)
if (!exists) {
form.componentType = String(componentTypeList.value[0]?.value || '')
}
}
async function handleTypeChange() {
form.componentType = ''
await getComponentTypeList()
}
function syncQuery(id = form.id) {
const query = new URLSearchParams(location.search)
if (id) query.set('id', id)
query.set('name', form.name)
query.set('type', form.type)
query.set('componentType', form.componentType)
history.replaceState(
{},
'',
`${location.pathname}?${query.toString()}${props.store.serialize()}`,
)
}
async function handleSave() {
if (!canSave.value) {
setMessage('error', '请补全名称和分类')
return
}
loading.value = true
setMessage('info', '截图上传中...')
const template = props.store.serialize().replace(/^#/, '')
try {
const image = await uploadPreviewImage()
setMessage('info', '保存中...')
const body: ComponentPayload = {
id: form.id || undefined,
name: form.name.trim(),
type: Number(form.type),
componentType: Number(form.componentType),
image,
template,
}
const data = isEdit.value
? await updateComponent(body)
: await saveComponent(body)
if (!isEdit.value && typeof data === 'string') {
form.id = data
}
syncQuery()
setMessage('success', '保存成功')
} catch (err) {
setMessage('error', err instanceof Error ? err.message : '保存失败')
} finally {
loading.value = false
}
}
onMounted(async () => {
readQuery()
try {
await getTypeList()
await getComponentTypeList()
} catch (err) {
setMessage('error', err instanceof Error ? err.message : '字典加载失败')
}
})
</script>
<template>
<header class="playground-header">
<div class="field name-field">
<label for="component-name">名称</label>
<input
id="component-name"
v-model="form.name"
placeholder="请输入组件名称"
/>
</div>
<div class="field">
<label for="component-type">类型</label>
<select
id="component-type"
v-model="form.type"
@change="handleTypeChange"
>
<option
v-for="item in typeList"
:key="item.value"
:value="String(item.value)"
>
{{ item.label }}
</option>
</select>
</div>
<div class="field">
<label for="component-category">分类</label>
<select id="component-category" v-model="form.componentType">
<option
v-for="item in componentTypeList"
:key="item.value"
:value="String(item.value)"
>
{{ item.label }}
</option>
</select>
</div>
<div class="status" :class="messageType">{{ message }}</div>
<button :disabled="loading || !canSave" @click="handleSave">
{{ loading ? '保存中' : '保存' }}
</button>
</header>
</template>
<style scoped>
.playground-header {
display: flex;
align-items: center;
gap: 12px;
height: 56px;
padding: 0 16px;
box-sizing: border-box;
border-bottom: 1px solid var(--border, #ddd);
color: var(--text, #213547);
background: var(--bg, #fff);
}
.field {
display: flex;
align-items: center;
gap: 6px;
min-width: 160px;
}
.name-field {
min-width: 260px;
}
label {
color: var(--text-light, #666);
font-size: 13px;
white-space: nowrap;
}
input,
select {
width: 100%;
height: 32px;
padding: 0 10px;
box-sizing: border-box;
border: 1px solid var(--border, #ddd);
border-radius: 4px;
color: inherit;
background: var(--bg-soft, #f8f8f8);
outline: none;
}
input:focus,
select:focus {
border-color: var(--color-branding, #42b883);
}
.status {
flex: 1;
min-width: 0;
overflow: hidden;
color: var(--text-light, #888);
text-overflow: ellipsis;
white-space: nowrap;
}
.status.success {
color: var(--color-branding, #42b883);
}
.status.error {
color: #d03050;
}
button {
height: 32px;
min-width: 72px;
padding: 0 16px;
border-radius: 4px;
color: #fff;
background: var(--color-branding, #42b883);
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
@media (max-width: 720px) {
.playground-header {
flex-wrap: wrap;
height: auto;
min-height: 56px;
padding: 10px;
}
.field,
.name-field {
flex: 1 1 100%;
min-width: 0;
}
}
</style>

18
src/api/component.ts Normal file
View File

@ -0,0 +1,18 @@
import { post } from './request'
export type ComponentPayload = {
id?: string
name: string
type: number
componentType: number
image: string
template: string
}
export const saveComponent = (data: ComponentPayload) => {
return post<string>('/component/save', data)
}
export const updateComponent = (data: ComponentPayload) => {
return post<boolean>('/component/update', data)
}

18
src/api/dict.ts Normal file
View File

@ -0,0 +1,18 @@
import { get } from './request'
export type DictItem = {
label: string
value: number
}
export const getDictByKey = (dictKey: string) => {
return get<DictItem[]>('/dict/getDictByKey', {
params: { dictKey },
})
}
export const getComponentDictByType = (type: string | number) => {
return get<DictItem[]>('/dict/getComponentDictByType', {
params: { type },
})
}

9
src/api/minio.ts Normal file
View File

@ -0,0 +1,9 @@
import { post } from './request'
export type MinioUploadResult = {
url: string
}
export const uploadFile = (data: FormData) => {
return post<MinioUploadResult>('/minio/upload', data)
}

47
src/api/request.ts Normal file
View File

@ -0,0 +1,47 @@
import axios, { type AxiosRequestConfig } from 'axios'
export type ApiResponse<T = unknown> = {
code: number
msg: string
data: T
}
const request = axios.create({
baseURL: import.meta.env.VITE_APP_API_BASE || '/api',
timeout: 1000 * 30,
})
request.interceptors.response.use(
(response) => {
const data = response.data as ApiResponse<any>
if (data.code !== 200) {
return Promise.reject(new Error(data.msg || '请求失败'))
}
return data.data as any
},
(error) => {
if (axios.isAxiosError<ApiResponse>(error)) {
return Promise.reject(
new Error(error.response?.data?.msg || error.message || '请求失败'),
)
}
return Promise.reject(error)
},
)
export const get = <T = unknown>(url: string, config?: AxiosRequestConfig) => {
return request.get<unknown, T>(url, config)
}
export const post = <T = unknown>(
url: string,
data?: unknown,
config?: AxiosRequestConfig,
) => {
return request.post<unknown, T>(url, data, config)
}
export default request

View File

@ -10,6 +10,22 @@ export function isVaporSupported(version: string): boolean{
return major > 3 || (major === 3 && minor >= 6) return major > 3 || (major === 3 && minor >= 6)
} }
export const builtinLibraryImports: Record<string, string> = {
echarts: 'https://esm.sh/echarts@latest',
'echarts/': 'https://esm.sh/echarts@latest/',
'ant-design-vue': 'https://esm.sh/ant-design-vue@latest?external=vue',
'ant-design-vue/': 'https://esm.sh/ant-design-vue@latest/',
'@ant-design/icons-vue':
'https://esm.sh/@ant-design/icons-vue@latest?external=vue',
'@ant-design/icons-vue/': 'https://esm.sh/@ant-design/icons-vue@latest/',
'element-plus': 'https://esm.sh/element-plus@latest?external=vue',
'element-plus/': 'https://esm.sh/element-plus@latest/',
'@element-plus/icons-vue':
'https://esm.sh/@element-plus/icons-vue@latest?external=vue',
'@element-plus/icons-vue/':
'https://esm.sh/@element-plus/icons-vue@latest/',
}
export function useVueImportMap( export function useVueImportMap(
defaults: { defaults: {
runtimeDev?: string | (() => string) runtimeDev?: string | (() => string)
@ -50,6 +66,7 @@ export function useVueImportMap(
imports: { imports: {
vue, vue,
'vue/server-renderer': serverRenderer, 'vue/server-renderer': serverRenderer,
...builtinLibraryImports,
}, },
} }
}) })

View File

@ -62,6 +62,23 @@ let sandbox: HTMLIFrameElement
let proxy: PreviewProxy let proxy: PreviewProxy
let stopUpdateWatcher: WatchStopHandle | undefined let stopUpdateWatcher: WatchStopHandle | undefined
const builtinLibraryHeadHTML = `
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ant-design-vue@latest/dist/reset.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/element-plus@latest/dist/index.css">
`
const antDesignVueModule = 'ant-design' + '-vue'
const elementPlusModule = 'element' + '-plus'
const builtinLibraryImportCode = [
`import __AntDesignVue from '${antDesignVueModule}'`,
`import __ElementPlus from '${elementPlusModule}'`,
].join('\n')
const builtinLibraryUseCode = `
app.use(__AntDesignVue)
app.use(__ElementPlus)
`
// create sandbox on mount // create sandbox on mount
onMounted(createSandbox) onMounted(createSandbox)
@ -118,13 +135,12 @@ function createSandbox() {
) )
const importMap = store.value.getImportMap() const importMap = store.value.getImportMap()
const headHTML =
builtinLibraryHeadHTML + (previewOptions.value?.headHTML || '')
const sandboxSrc = srcdoc const sandboxSrc = srcdoc
.replace(/<html>/, `<html class="${theme.value}">`) .replace(/<html>/, `<html class="${theme.value}">`)
.replace(/<!--IMPORT_MAP-->/, JSON.stringify(importMap)) .replace(/<!--IMPORT_MAP-->/, JSON.stringify(importMap))
.replace( .replace(/<!-- PREVIEW-OPTIONS-HEAD-HTML -->/, headHTML)
/<!-- PREVIEW-OPTIONS-HEAD-HTML -->/,
previewOptions.value?.headHTML || '',
)
.replace( .replace(
/<!--PREVIEW-OPTIONS-PLACEHOLDER-HTML-->/, /<!--PREVIEW-OPTIONS-PLACEHOLDER-HTML-->/,
previewOptions.value?.placeholderHTML || '', previewOptions.value?.placeholderHTML || '',
@ -237,6 +253,8 @@ async function updatePreview() {
...ssrModules, ...ssrModules,
`import { renderToString as _renderToString } from 'vue/server-renderer' `import { renderToString as _renderToString } from 'vue/server-renderer'
import { createSSRApp as _createApp ${vaporSupported ? ', createVaporSSRApp as _createVaporApp' : ''} } from 'vue' import { createSSRApp as _createApp ${vaporSupported ? ', createVaporSSRApp as _createVaporApp' : ''} } from 'vue'
${builtinLibraryImportCode}
${previewOptions.value?.customCode?.importCode || ''}
const AppComponent = __modules__["${mainFile}"].default const AppComponent = __modules__["${mainFile}"].default
AppComponent.name = 'Repl' AppComponent.name = 'Repl'
const vaporSupported = ${vaporSupported} const vaporSupported = ${vaporSupported}
@ -245,6 +263,8 @@ async function updatePreview() {
app.config.unwrapInjectedRef = true app.config.unwrapInjectedRef = true
} }
app.config.warnHandler = () => {} app.config.warnHandler = () => {}
${builtinLibraryUseCode}
${previewOptions.value?.customCode?.useCode || ''}
const rawContext = {} const rawContext = {}
window.__ssr_promise__ = _renderToString(app, rawContext).then(html => { window.__ssr_promise__ = _renderToString(app, rawContext).then(html => {
document.body.innerHTML = '<div id="app">' + html + '</div>' + \`${ document.body.innerHTML = '<div id="app">' + html + '</div>' + \`${
@ -313,6 +333,7 @@ async function updatePreview() {
} as _createVaporApp` } as _createVaporApp`
: '' : ''
} } from "vue" } } from "vue"
${builtinLibraryImportCode}
${previewOptions.value?.customCode?.importCode || ''} ${previewOptions.value?.customCode?.importCode || ''}
const _mount = () => { const _mount = () => {
const AppComponent = __modules__["${mainFile}"].default const AppComponent = __modules__["${mainFile}"].default
@ -323,6 +344,7 @@ async function updatePreview() {
app.config.unwrapInjectedRef = true app.config.unwrapInjectedRef = true
} }
app.config.errorHandler = e => console.error(e) app.config.errorHandler = e => console.error(e)
${builtinLibraryUseCode}
${previewOptions.value?.customCode?.useCode || ''} ${previewOptions.value?.customCode?.useCode || ''}
app.mount('#app') app.mount('#app')
} }

View File

@ -25,6 +25,13 @@ import newSFCCode from './template/new-sfc.vue?raw'
export const importMapFile = 'import-map.json' export const importMapFile = 'import-map.json'
export const tsconfigFile = 'tsconfig.json' export const tsconfigFile = 'tsconfig.json'
export const builtinDependencyVersions: Record<string, string> = {
echarts: 'latest',
'ant-design-vue': 'latest',
'@ant-design/icons-vue': 'latest',
'element-plus': 'latest',
'@element-plus/icons-vue': 'latest',
}
export function useStore( export function useStore(
{ {
@ -46,7 +53,7 @@ export function useStore(
locale = ref(), locale = ref(),
typescriptVersion = ref('latest'), typescriptVersion = ref('latest'),
dependencyVersion = ref(Object.create(null)), dependencyVersion = ref({ ...builtinDependencyVersions }),
reloadLanguageTools = ref(), reloadLanguageTools = ref(),
resourceLinks = undefined, resourceLinks = undefined,
}: Partial<StoreState> = {}, }: Partial<StoreState> = {},

View File

@ -5,6 +5,5 @@ const msg = ref('Hello World!')
</script> </script>
<template> <template>
<h1>{{ msg }}</h1> <div>{{ msg }}</div>
<input v-model="msg" />
</template> </template>

View File

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/prefer-ts-expect-error */ /* eslint-disable @typescript-eslint/prefer-ts-expect-error */
import { createApp, h, ref, watchEffect } from 'vue' import { createApp, h, ref, watchEffect } from 'vue'
import { type OutputModes, Repl, useStore, useVueImportMap } from '../src' import { type OutputModes, Repl, useStore, useVueImportMap } from '../src'
import PlaygroundHeader from '../src/PlaygroundHeader.vue'
// @ts-ignore // @ts-ignore
import MonacoEditor from '../src/editor/MonacoEditor.vue' import MonacoEditor from '../src/editor/MonacoEditor.vue'
// @ts-ignore // @ts-ignore
@ -31,7 +32,13 @@ const App = {
)) ))
console.info(store) console.info(store)
watchEffect(() => history.replaceState({}, '', store.serialize())) watchEffect(() => {
history.replaceState(
{},
'',
`${location.pathname}${location.search}${store.serialize()}`,
)
})
// setTimeout(() => { // setTimeout(() => {
// store.setFiles( // store.setFiles(
@ -53,6 +60,8 @@ const App = {
window.previewTheme = previewTheme window.previewTheme = previewTheme
return () => return () =>
h('div', { class: ['playground-shell', theme.value] }, [
h(PlaygroundHeader, { store }),
h(Repl, { h(Repl, {
store, store,
theme: theme.value, theme: theme.value,
@ -76,7 +85,8 @@ const App = {
}, },
}, },
// autoSave: false, // autoSave: false,
}) }),
])
}, },
} }

View File

@ -1,4 +1,4 @@
import { type Plugin, mergeConfig } from 'vite' import { type Plugin, loadEnv, mergeConfig } from 'vite'
import dts from 'vite-plugin-dts' import dts from 'vite-plugin-dts'
import base from './vite.preview.config' import base from './vite.preview.config'
import fs from 'node:fs' import fs from 'node:fs'
@ -34,7 +34,10 @@ const patchCssFiles: Plugin = {
}, },
} }
export default mergeConfig(base, { export default ({ mode }: { mode: string }) => {
const env = loadEnv(mode, process.cwd())
return mergeConfig(base, {
plugins: [ plugins: [
dts({ dts({
rollupTypes: true, rollupTypes: true,
@ -42,6 +45,18 @@ export default mergeConfig(base, {
genStub, genStub,
patchCssFiles, patchCssFiles,
], ],
server: {
proxy: env.VITE_APP_PROXY
? {
'/api': {
target: env.VITE_APP_PROXY,
changeOrigin: true,
rewrite: (requestPath: string) =>
requestPath.replace(/^\/api/, ''),
},
}
: undefined,
},
optimizeDeps: { optimizeDeps: {
// avoid late discovered deps // avoid late discovered deps
include: [ include: [
@ -73,3 +88,4 @@ export default mergeConfig(base, {
}, },
}, },
}) })
}