mirror of
https://github.com/KwiTsukasa/kt-template-online-playground.git
synced 2026-05-27 16:45:45 +08:00
feat: add playground component save workflow
This commit is contained in:
parent
51acbd50db
commit
3e7c9e329f
2
.env.development
Normal file
2
.env.development
Normal file
@ -0,0 +1,2 @@
|
||||
VITE_APP_API_BASE = "/api"
|
||||
VITE_APP_PROXY = "http://192.168.0.49:48085/"
|
||||
12
index.html
12
index.html
@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vue SFC Playground</title>
|
||||
<title>KT-Template - Playground</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
@ -13,6 +13,16 @@
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
.playground-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.playground-shell > .vue-repl {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="module" src="./test/main.ts"></script>
|
||||
|
||||
@ -47,7 +47,7 @@
|
||||
"tag": "latest"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "vite --host",
|
||||
"build": "vite build",
|
||||
"build-preview": "vite build -c vite.preview.config.ts",
|
||||
"format": "prettier --write .",
|
||||
@ -115,9 +115,12 @@
|
||||
"typescript-eslint": "^8.39.0",
|
||||
"vite": "^8.0.8",
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
"vscode-uri": "^3.1.0",
|
||||
"volar-service-typescript": "0.0.65",
|
||||
"vscode-uri": "^3.1.0",
|
||||
"vue": "^3.5.18",
|
||||
"vue-tsc": "3.0.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.16.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,10 @@ settings:
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
axios:
|
||||
specifier: ^1.16.0
|
||||
version: 1.16.0
|
||||
devDependencies:
|
||||
'@babel/standalone':
|
||||
specifier: ^7.28.2
|
||||
@ -988,10 +992,16 @@ packages:
|
||||
assert@2.1.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
axios@1.16.0:
|
||||
resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==}
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
@ -1082,6 +1092,10 @@ packages:
|
||||
colorette@2.0.20:
|
||||
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:
|
||||
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
||||
|
||||
@ -1217,6 +1231,10 @@ packages:
|
||||
defu@6.1.4:
|
||||
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
|
||||
|
||||
delayed-stream@1.0.0:
|
||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
dequal@2.0.3:
|
||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||
engines: {node: '>=6'}
|
||||
@ -1278,6 +1296,10 @@ packages:
|
||||
resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==}
|
||||
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:
|
||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||
engines: {node: '>=6'}
|
||||
@ -1407,6 +1429,15 @@ packages:
|
||||
flatted@3.3.2:
|
||||
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:
|
||||
resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
|
||||
|
||||
@ -1414,6 +1445,10 @@ packages:
|
||||
resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
form-data@4.0.5:
|
||||
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
fs-extra@11.3.0:
|
||||
resolution: {integrity: sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==}
|
||||
engines: {node: '>=14.14'}
|
||||
@ -1822,6 +1857,14 @@ packages:
|
||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
|
||||
engines: {node: '>=18'}
|
||||
@ -2016,6 +2059,10 @@ packages:
|
||||
property-information@7.1.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==}
|
||||
|
||||
@ -3534,10 +3581,20 @@ snapshots:
|
||||
object.assign: 4.1.7
|
||||
util: 0.12.5
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
available-typed-arrays@1.0.7:
|
||||
dependencies:
|
||||
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: {}
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
@ -3633,6 +3690,10 @@ snapshots:
|
||||
|
||||
colorette@2.0.20: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
|
||||
comma-separated-tokens@2.0.3: {}
|
||||
|
||||
commander@14.0.0: {}
|
||||
@ -3766,6 +3827,8 @@ snapshots:
|
||||
|
||||
defu@6.1.4: {}
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
dequal@2.0.3: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
@ -3813,6 +3876,13 @@ snapshots:
|
||||
dependencies:
|
||||
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: {}
|
||||
|
||||
escape-string-regexp@4.0.0: {}
|
||||
@ -3962,6 +4032,8 @@ snapshots:
|
||||
|
||||
flatted@3.3.2: {}
|
||||
|
||||
follow-redirects@1.16.0: {}
|
||||
|
||||
for-each@0.3.3:
|
||||
dependencies:
|
||||
is-callable: 1.2.7
|
||||
@ -3971,6 +4043,14 @@ snapshots:
|
||||
cross-spawn: 7.0.6
|
||||
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:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
@ -4370,6 +4450,12 @@ snapshots:
|
||||
braces: 3.0.3
|
||||
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: {}
|
||||
|
||||
minimatch@10.0.3:
|
||||
@ -4554,6 +4640,8 @@ snapshots:
|
||||
|
||||
property-information@7.1.0: {}
|
||||
|
||||
proxy-from-env@2.1.0: {}
|
||||
|
||||
pug-error@2.1.0: {}
|
||||
|
||||
pug-lexer@5.0.1:
|
||||
|
||||
407
src/PlaygroundHeader.vue
Normal file
407
src/PlaygroundHeader.vue
Normal 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
18
src/api/component.ts
Normal 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
18
src/api/dict.ts
Normal 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
9
src/api/minio.ts
Normal 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
47
src/api/request.ts
Normal 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
|
||||
@ -10,6 +10,22 @@ export function isVaporSupported(version: string): boolean{
|
||||
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(
|
||||
defaults: {
|
||||
runtimeDev?: string | (() => string)
|
||||
@ -50,6 +66,7 @@ export function useVueImportMap(
|
||||
imports: {
|
||||
vue,
|
||||
'vue/server-renderer': serverRenderer,
|
||||
...builtinLibraryImports,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@ -62,6 +62,23 @@ let sandbox: HTMLIFrameElement
|
||||
let proxy: PreviewProxy
|
||||
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
|
||||
onMounted(createSandbox)
|
||||
|
||||
@ -118,13 +135,12 @@ function createSandbox() {
|
||||
)
|
||||
|
||||
const importMap = store.value.getImportMap()
|
||||
const headHTML =
|
||||
builtinLibraryHeadHTML + (previewOptions.value?.headHTML || '')
|
||||
const sandboxSrc = srcdoc
|
||||
.replace(/<html>/, `<html class="${theme.value}">`)
|
||||
.replace(/<!--IMPORT_MAP-->/, JSON.stringify(importMap))
|
||||
.replace(
|
||||
/<!-- PREVIEW-OPTIONS-HEAD-HTML -->/,
|
||||
previewOptions.value?.headHTML || '',
|
||||
)
|
||||
.replace(/<!-- PREVIEW-OPTIONS-HEAD-HTML -->/, headHTML)
|
||||
.replace(
|
||||
/<!--PREVIEW-OPTIONS-PLACEHOLDER-HTML-->/,
|
||||
previewOptions.value?.placeholderHTML || '',
|
||||
@ -237,6 +253,8 @@ async function updatePreview() {
|
||||
...ssrModules,
|
||||
`import { renderToString as _renderToString } from 'vue/server-renderer'
|
||||
import { createSSRApp as _createApp ${vaporSupported ? ', createVaporSSRApp as _createVaporApp' : ''} } from 'vue'
|
||||
${builtinLibraryImportCode}
|
||||
${previewOptions.value?.customCode?.importCode || ''}
|
||||
const AppComponent = __modules__["${mainFile}"].default
|
||||
AppComponent.name = 'Repl'
|
||||
const vaporSupported = ${vaporSupported}
|
||||
@ -245,6 +263,8 @@ async function updatePreview() {
|
||||
app.config.unwrapInjectedRef = true
|
||||
}
|
||||
app.config.warnHandler = () => {}
|
||||
${builtinLibraryUseCode}
|
||||
${previewOptions.value?.customCode?.useCode || ''}
|
||||
const rawContext = {}
|
||||
window.__ssr_promise__ = _renderToString(app, rawContext).then(html => {
|
||||
document.body.innerHTML = '<div id="app">' + html + '</div>' + \`${
|
||||
@ -313,6 +333,7 @@ async function updatePreview() {
|
||||
} as _createVaporApp`
|
||||
: ''
|
||||
} } from "vue"
|
||||
${builtinLibraryImportCode}
|
||||
${previewOptions.value?.customCode?.importCode || ''}
|
||||
const _mount = () => {
|
||||
const AppComponent = __modules__["${mainFile}"].default
|
||||
@ -323,6 +344,7 @@ async function updatePreview() {
|
||||
app.config.unwrapInjectedRef = true
|
||||
}
|
||||
app.config.errorHandler = e => console.error(e)
|
||||
${builtinLibraryUseCode}
|
||||
${previewOptions.value?.customCode?.useCode || ''}
|
||||
app.mount('#app')
|
||||
}
|
||||
|
||||
@ -25,6 +25,13 @@ import newSFCCode from './template/new-sfc.vue?raw'
|
||||
|
||||
export const importMapFile = 'import-map.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(
|
||||
{
|
||||
@ -46,7 +53,7 @@ export function useStore(
|
||||
|
||||
locale = ref(),
|
||||
typescriptVersion = ref('latest'),
|
||||
dependencyVersion = ref(Object.create(null)),
|
||||
dependencyVersion = ref({ ...builtinDependencyVersions }),
|
||||
reloadLanguageTools = ref(),
|
||||
resourceLinks = undefined,
|
||||
}: Partial<StoreState> = {},
|
||||
|
||||
@ -5,6 +5,5 @@ const msg = ref('Hello World!')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
<input v-model="msg" />
|
||||
<div>{{ msg }}</div>
|
||||
</template>
|
||||
|
||||
14
test/main.ts
14
test/main.ts
@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/prefer-ts-expect-error */
|
||||
import { createApp, h, ref, watchEffect } from 'vue'
|
||||
import { type OutputModes, Repl, useStore, useVueImportMap } from '../src'
|
||||
import PlaygroundHeader from '../src/PlaygroundHeader.vue'
|
||||
// @ts-ignore
|
||||
import MonacoEditor from '../src/editor/MonacoEditor.vue'
|
||||
// @ts-ignore
|
||||
@ -31,7 +32,13 @@ const App = {
|
||||
))
|
||||
console.info(store)
|
||||
|
||||
watchEffect(() => history.replaceState({}, '', store.serialize()))
|
||||
watchEffect(() => {
|
||||
history.replaceState(
|
||||
{},
|
||||
'',
|
||||
`${location.pathname}${location.search}${store.serialize()}`,
|
||||
)
|
||||
})
|
||||
|
||||
// setTimeout(() => {
|
||||
// store.setFiles(
|
||||
@ -53,6 +60,8 @@ const App = {
|
||||
window.previewTheme = previewTheme
|
||||
|
||||
return () =>
|
||||
h('div', { class: ['playground-shell', theme.value] }, [
|
||||
h(PlaygroundHeader, { store }),
|
||||
h(Repl, {
|
||||
store,
|
||||
theme: theme.value,
|
||||
@ -76,7 +85,8 @@ const App = {
|
||||
},
|
||||
},
|
||||
// autoSave: false,
|
||||
})
|
||||
}),
|
||||
])
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { type Plugin, mergeConfig } from 'vite'
|
||||
import { type Plugin, loadEnv, mergeConfig } from 'vite'
|
||||
import dts from 'vite-plugin-dts'
|
||||
import base from './vite.preview.config'
|
||||
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: [
|
||||
dts({
|
||||
rollupTypes: true,
|
||||
@ -42,6 +45,18 @@ export default mergeConfig(base, {
|
||||
genStub,
|
||||
patchCssFiles,
|
||||
],
|
||||
server: {
|
||||
proxy: env.VITE_APP_PROXY
|
||||
? {
|
||||
'/api': {
|
||||
target: env.VITE_APP_PROXY,
|
||||
changeOrigin: true,
|
||||
rewrite: (requestPath: string) =>
|
||||
requestPath.replace(/^\/api/, ''),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
optimizeDeps: {
|
||||
// avoid late discovered deps
|
||||
include: [
|
||||
@ -73,3 +88,4 @@ export default mergeConfig(base, {
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user