spine连接器和导出修改
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import type { ParticleSystem } from '../store/particleStore'
|
||||
import type { SpineExportVersion } from './spineExportSettings'
|
||||
|
||||
const CONNECTOR_URL = 'http://127.0.0.1:27843'
|
||||
|
||||
export interface SpineConnectorHealth {
|
||||
available: boolean
|
||||
authorized: boolean
|
||||
connectorVersion?: string
|
||||
spineFound?: boolean
|
||||
spinePath?: string
|
||||
platform?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ConnectorImage {
|
||||
path: string
|
||||
dataBase64: string
|
||||
}
|
||||
|
||||
interface ConnectorErrorBody {
|
||||
code?: string
|
||||
message?: string
|
||||
pairUrl?: string
|
||||
}
|
||||
|
||||
export class SpineConnectorError extends Error {
|
||||
code: string
|
||||
pairUrl?: string
|
||||
|
||||
constructor(message: string, code = 'CONNECTOR_ERROR', pairUrl?: string) {
|
||||
super(message)
|
||||
this.name = 'SpineConnectorError'
|
||||
this.code = code
|
||||
this.pairUrl = pairUrl
|
||||
}
|
||||
}
|
||||
|
||||
function cleanPart(value: string, fallback: string) {
|
||||
const cleaned = String(value || '').trim().replace(/[\\/:*?"<>|]+/g, '_').replace(/^\.+/, '')
|
||||
return cleaned || fallback
|
||||
}
|
||||
|
||||
function extensionFor(name: string, contentType: string) {
|
||||
const match = /\.([a-z0-9]{2,5})$/i.exec(name)
|
||||
if (match) return `.${match[1].toLowerCase()}`
|
||||
if (contentType.includes('jpeg')) return '.jpg'
|
||||
if (contentType.includes('webp')) return '.webp'
|
||||
if (contentType.includes('gif')) return '.gif'
|
||||
return '.png'
|
||||
}
|
||||
|
||||
function imagePath(folder: string, name: string, contentType: string) {
|
||||
const parts = String(folder || '').replace(/\\/g, '/').split('/').filter(Boolean).map((part) => cleanPart(part, 'images'))
|
||||
const base = cleanPart(name.replace(/\.[^./\\]+$/, ''), 'image')
|
||||
return [...parts, `${base}${extensionFor(name, contentType)}`].join('/')
|
||||
}
|
||||
|
||||
async function sourceToImage(source: string, folder: string, name: string): Promise<ConnectorImage | null> {
|
||||
if (!source) return null
|
||||
const response = await fetch(source)
|
||||
if (!response.ok) throw new Error(`无法读取导出图片:${name}`)
|
||||
const blob = await response.blob()
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer())
|
||||
let binary = ''
|
||||
const chunkSize = 0x8000
|
||||
for (let index = 0; index < bytes.length; index += chunkSize) {
|
||||
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize))
|
||||
}
|
||||
return {
|
||||
path: imagePath(folder, name, blob.type),
|
||||
dataBase64: btoa(binary),
|
||||
}
|
||||
}
|
||||
|
||||
/** 收集 Spine JSON 中附件会引用的粒子与拖尾图片。 */
|
||||
export async function collectSpineExportImages(systems: ParticleSystem[]) {
|
||||
const requests: Array<Promise<ConnectorImage | null>> = []
|
||||
for (const system of systems.filter((item) => item.visible !== false)) {
|
||||
for (const resource of system.config.imageResources) {
|
||||
if (resource.imageMode === 'sequence' && resource.sequenceFrames.length) {
|
||||
for (const frame of resource.sequenceFrames) {
|
||||
requests.push(sourceToImage(frame.previewUrl, resource.textureFolder, frame.name || resource.textureName))
|
||||
}
|
||||
} else {
|
||||
requests.push(sourceToImage(resource.previewUrl, resource.textureFolder, resource.textureName))
|
||||
}
|
||||
}
|
||||
if (system.config.trail) {
|
||||
for (const resource of system.config.trailResources) {
|
||||
requests.push(sourceToImage(resource.previewUrl, '', resource.textureName))
|
||||
}
|
||||
}
|
||||
}
|
||||
const unique = new Map<string, ConnectorImage>()
|
||||
for (const image of await Promise.all(requests)) if (image && !unique.has(image.path)) unique.set(image.path, image)
|
||||
return [...unique.values()]
|
||||
}
|
||||
|
||||
export async function checkSpineConnector(): Promise<SpineConnectorHealth> {
|
||||
try {
|
||||
const response = await fetch(`${CONNECTOR_URL}/v1/health`, { cache: 'no-store' })
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const body = await response.json()
|
||||
return { available: true, ...body }
|
||||
} catch (error) {
|
||||
return { available: false, authorized: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
export async function convertWithSpineConnector(input: {
|
||||
projectName: string
|
||||
spineVersion: SpineExportVersion
|
||||
skeletonJson: string
|
||||
systems: ParticleSystem[]
|
||||
}) {
|
||||
const images = await collectSpineExportImages(input.systems)
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`${CONNECTOR_URL}/v1/convert`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
projectName: input.projectName,
|
||||
spineVersion: input.spineVersion,
|
||||
skeletonJson: input.skeletonJson,
|
||||
images,
|
||||
}),
|
||||
})
|
||||
} catch {
|
||||
throw new SpineConnectorError('未检测到本地 Spine 连接器,请先启动连接器', 'CONNECTOR_OFFLINE')
|
||||
}
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({} as ConnectorErrorBody)) as ConnectorErrorBody
|
||||
throw new SpineConnectorError(body.message || `Spine 连接器返回错误:HTTP ${response.status}`, body.code, body.pairUrl)
|
||||
}
|
||||
const encodedFileName = response.headers.get('X-Spine-File-Name')
|
||||
let fileName = `${cleanPart(input.projectName, 'SpineParticle')}.zip`
|
||||
if (encodedFileName) {
|
||||
try { fileName = decodeURIComponent(encodedFileName) } catch { /* 保留安全的默认文件名 */ }
|
||||
}
|
||||
return {
|
||||
blob: await response.blob(),
|
||||
fileName,
|
||||
spineEditorVersion: response.headers.get('X-Spine-Editor-Version') || input.spineVersion,
|
||||
imageCount: images.length,
|
||||
}
|
||||
}
|
||||
|
||||
export function downloadSpineProject(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = /\.zip$/i.test(fileName) ? fileName : `${fileName}.zip`
|
||||
anchor.style.display = 'none'
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ParticleSystem } from '../store/particleStore'
|
||||
import { collectSpineExportImages } from './spineConnectorClient'
|
||||
import { createZipArchive } from './zipArchive'
|
||||
|
||||
function decodeBase64(value: string) {
|
||||
const binary = atob(value)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index)
|
||||
return bytes
|
||||
}
|
||||
|
||||
function imagesDirectory(value: string) {
|
||||
const path = String(value || './images/').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+|\/+$/g, '')
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
if (!parts.length || parts.some((part) => part === '..' || /^[a-z]+:/i.test(part))) return 'images'
|
||||
return parts.join('/')
|
||||
}
|
||||
|
||||
function safeFileName(value: string) {
|
||||
const name = String(value || 'SpineParticle').replace(/\.json$/i, '').replace(/[\\/:*?"<>|]+/g, '_').replace(/^\.+/, '')
|
||||
return name || 'SpineParticle'
|
||||
}
|
||||
|
||||
export async function buildSpineImagesArchive(input: { systems: ParticleSystem[]; imagesPath: string }) {
|
||||
const images = await collectSpineExportImages(input.systems)
|
||||
if (!images.length) throw new Error('当前可见粒子系统没有可导出的图片')
|
||||
const folder = imagesDirectory(input.imagesPath)
|
||||
const blob = createZipArchive(images.map((image) => ({
|
||||
name: `${folder}/${image.path}`,
|
||||
data: decodeBase64(image.dataBase64),
|
||||
})))
|
||||
return { blob, imageCount: images.length, folder }
|
||||
}
|
||||
|
||||
export function downloadSpineImagesArchive(blob: Blob, exportFileName: string) {
|
||||
const fileName = `${safeFileName(exportFileName)}-images.zip`
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = fileName
|
||||
anchor.style.display = 'none'
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
return fileName
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
export interface ZipArchiveFile {
|
||||
name: string
|
||||
data: Uint8Array
|
||||
}
|
||||
|
||||
const crcTable = Array.from({ length: 256 }, (_, value) => {
|
||||
let crc = value
|
||||
for (let bit = 0; bit < 8; bit++) crc = (crc & 1) ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1
|
||||
return crc >>> 0
|
||||
})
|
||||
|
||||
function crc32(data: Uint8Array) {
|
||||
let crc = 0xffffffff
|
||||
for (const byte of data) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8)
|
||||
return (crc ^ 0xffffffff) >>> 0
|
||||
}
|
||||
|
||||
function safeArchivePath(value: string) {
|
||||
const parts = String(value || '').replace(/\\/g, '/').split('/').filter((part) => part && part !== '.')
|
||||
if (!parts.length || parts.some((part) => part === '..')) throw new Error('ZIP 文件路径无效')
|
||||
return parts.join('/')
|
||||
}
|
||||
|
||||
function dateTime(date = new Date()) {
|
||||
const year = Math.max(1980, date.getFullYear())
|
||||
return {
|
||||
time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2),
|
||||
date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
|
||||
}
|
||||
}
|
||||
|
||||
function header(size: number) {
|
||||
const bytes = new Uint8Array(size)
|
||||
return { bytes, view: new DataView(bytes.buffer) }
|
||||
}
|
||||
|
||||
function combine(chunks: Uint8Array[]) {
|
||||
const output = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0))
|
||||
let offset = 0
|
||||
for (const chunk of chunks) { output.set(chunk, offset); offset += chunk.length }
|
||||
return output
|
||||
}
|
||||
|
||||
/** 创建 Store 模式 ZIP;图片本身已经压缩,避免引入额外打包依赖。 */
|
||||
export function createZipArchive(files: ZipArchiveFile[]) {
|
||||
const encoder = new TextEncoder()
|
||||
const localChunks: Uint8Array[] = []
|
||||
const centralChunks: Uint8Array[] = []
|
||||
const timestamp = dateTime()
|
||||
let offset = 0
|
||||
|
||||
for (const file of files) {
|
||||
const name = encoder.encode(safeArchivePath(file.name))
|
||||
const crc = crc32(file.data)
|
||||
const local = header(30)
|
||||
local.view.setUint32(0, 0x04034b50, true)
|
||||
local.view.setUint16(4, 20, true)
|
||||
local.view.setUint16(6, 0x0800, true)
|
||||
local.view.setUint16(10, timestamp.time, true)
|
||||
local.view.setUint16(12, timestamp.date, true)
|
||||
local.view.setUint32(14, crc, true)
|
||||
local.view.setUint32(18, file.data.length, true)
|
||||
local.view.setUint32(22, file.data.length, true)
|
||||
local.view.setUint16(26, name.length, true)
|
||||
localChunks.push(local.bytes, name, file.data)
|
||||
|
||||
const central = header(46)
|
||||
central.view.setUint32(0, 0x02014b50, true)
|
||||
central.view.setUint16(4, 20, true)
|
||||
central.view.setUint16(6, 20, true)
|
||||
central.view.setUint16(8, 0x0800, true)
|
||||
central.view.setUint16(12, timestamp.time, true)
|
||||
central.view.setUint16(14, timestamp.date, true)
|
||||
central.view.setUint32(16, crc, true)
|
||||
central.view.setUint32(20, file.data.length, true)
|
||||
central.view.setUint32(24, file.data.length, true)
|
||||
central.view.setUint16(28, name.length, true)
|
||||
central.view.setUint32(42, offset, true)
|
||||
centralChunks.push(central.bytes, name)
|
||||
offset += local.bytes.length + name.length + file.data.length
|
||||
}
|
||||
|
||||
const centralDirectory = combine(centralChunks)
|
||||
const end = header(22)
|
||||
end.view.setUint32(0, 0x06054b50, true)
|
||||
end.view.setUint16(8, files.length, true)
|
||||
end.view.setUint16(10, files.length, true)
|
||||
end.view.setUint32(12, centralDirectory.length, true)
|
||||
end.view.setUint32(16, offset, true)
|
||||
return new Blob([combine([...localChunks, centralDirectory, end.bytes])], { type: 'application/zip' })
|
||||
}
|
||||
+158
-7
@@ -900,15 +900,32 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button type="button" class="export-spine-json" :disabled="exportBusy" :title="exportBusy ? '正在重新计算并烘焙动画' : `导出 Spine ${exportSettings.spineVersion} JSON`" @click="exportSpineJson">
|
||||
{{ exportBusy ? '正在准备导出…' : '导出Spine Json' }}
|
||||
</button>
|
||||
<div v-if="exportMessage" class="export-message" :class="{ error: exportError }">{{ exportMessage }}</div>
|
||||
<div class="spine-export-actions">
|
||||
<button type="button" class="export-spine-json" :disabled="exportBusy" :title="exportBusy ? '正在重新计算并烘焙动画' : `导出 Spine ${exportSettings.spineVersion} JSON`" @click="exportSpineJson">
|
||||
{{ exportBusy && exportTarget === 'json' ? '正在准备导出…' : '导出Spine Json' }}
|
||||
</button>
|
||||
<button type="button" class="export-spine-images" :disabled="exportBusy" title="单独下载当前粒子系统使用的图片 ZIP" @click="exportSpineImages">
|
||||
{{ exportBusy && exportTarget === 'images' ? '正在整理图片…' : '导出 images 包' }}
|
||||
</button>
|
||||
<button type="button" class="export-spine-project" :disabled="exportBusy || !connectorReady" :title="connectorButtonTitle" @click="exportSpineProject">
|
||||
<span>{{ exportBusy && exportTarget === 'spine' ? '正在生成工程…' : '导出 .spine 包' }}</span>
|
||||
<small v-if="!exportBusy && connectorDisabledReason">{{ connectorDisabledReason }}</small>
|
||||
</button>
|
||||
</div>
|
||||
<div class="connector-status" :class="connectorStatusClass">
|
||||
<span class="connector-dot"></span>{{ connectorStatusText }}
|
||||
<a v-if="connectorAuthorizationUrl" :href="connectorAuthorizationUrl" target="_blank" rel="noopener" title="在本机连接器中授权当前网站">授权当前网站</a>
|
||||
<button type="button" title="重新检测连接器" @click="refreshConnectorHealth">刷新</button>
|
||||
</div>
|
||||
<div v-if="exportMessage" class="export-message" :class="{ error: exportError }">
|
||||
{{ exportMessage }}
|
||||
<a v-if="connectorPairUrl" :href="connectorPairUrl" target="_blank" rel="noopener" @click="connectorPairUrl = ''">授权当前网站</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, markRaw, onMounted, reactive, ref, watchEffect } from 'vue'
|
||||
import { computed, markRaw, onBeforeUnmount, onMounted, reactive, ref, watchEffect } from 'vue'
|
||||
import { Texture } from 'pixi.js'
|
||||
import { useParticleStore } from '../store/particleStore'
|
||||
import type { ParticleSystem } from '../store/particleStore'
|
||||
@@ -923,6 +940,14 @@ import { downloadEditorConfig, loadEditorConfigFile, loadEditorConfigText } from
|
||||
import { defaultSpineExportSettings, ensureSpineExportSettings } from '../export/spineExportSettings'
|
||||
import { buildSpineJson, downloadSpineJson } from '../export/spineJsonExporter'
|
||||
import { validateSpineJson } from '../export/spineJsonValidator'
|
||||
import { buildSpineImagesArchive, downloadSpineImagesArchive } from '../export/spineImagesExporter'
|
||||
import {
|
||||
SpineConnectorError,
|
||||
checkSpineConnector,
|
||||
convertWithSpineConnector,
|
||||
downloadSpineProject,
|
||||
type SpineConnectorHealth,
|
||||
} from '../export/spineConnectorClient'
|
||||
import { automaticLoopTransitionFrames } from '../core/loopSegmentTiming'
|
||||
|
||||
const store = useParticleStore()
|
||||
@@ -935,8 +960,43 @@ const presetLoading = ref(false)
|
||||
const configMessage = ref('')
|
||||
const configError = ref(false)
|
||||
const exportBusy = ref(false)
|
||||
const exportTarget = ref<'json' | 'spine' | 'images' | null>(null)
|
||||
const exportMessage = ref('')
|
||||
const exportError = ref(false)
|
||||
const connectorHealth = ref<SpineConnectorHealth | null>(null)
|
||||
const connectorPairUrl = ref('')
|
||||
|
||||
const connectorStatusText = computed(() => {
|
||||
const health = connectorHealth.value
|
||||
if (!health) return '正在检测本地 Spine 连接器…'
|
||||
if (!health.available) return '本地 Spine 连接器未启动'
|
||||
if (!health.spineFound) return '连接器已启动,但未找到 Spine'
|
||||
if (!health.authorized) return '连接器已启动,当前网站尚未授权'
|
||||
return '连接器可用 · Spine 已就绪'
|
||||
})
|
||||
const connectorStatusClass = computed(() => ({
|
||||
ready: !!connectorHealth.value?.available && !!connectorHealth.value?.spineFound && !!connectorHealth.value?.authorized,
|
||||
warning: !!connectorHealth.value?.available && (!connectorHealth.value?.spineFound || !connectorHealth.value?.authorized),
|
||||
}))
|
||||
const connectorReady = computed(() => !!connectorHealth.value?.available && !!connectorHealth.value?.spineFound && !!connectorHealth.value?.authorized)
|
||||
const connectorAuthorizationUrl = computed(() => {
|
||||
const health = connectorHealth.value
|
||||
if (!health?.available || !health.spineFound || health.authorized || typeof window === 'undefined') return ''
|
||||
return `http://127.0.0.1:27843/pair?origin=${encodeURIComponent(window.location.origin)}`
|
||||
})
|
||||
const connectorDisabledReason = computed(() => {
|
||||
const health = connectorHealth.value
|
||||
if (!health) return '检测中'
|
||||
if (!health.available) return '未启动'
|
||||
if (!health.spineFound) return '未找到 Spine'
|
||||
if (!health.authorized) return '未授权'
|
||||
return ''
|
||||
})
|
||||
const connectorButtonTitle = computed(() => {
|
||||
if (exportBusy.value) return '正在重新计算并生成 Spine 工程'
|
||||
if (connectorDisabledReason.value) return `暂不可用:${connectorDisabledReason.value}`
|
||||
return `调用本机 Spine 自动生成 ${exportSettings.value.spineVersion} 工程`
|
||||
})
|
||||
|
||||
function resolvedCurveEditorMode(mode: '跟随导出' | '线性' | '贝塞尔') {
|
||||
if (mode === '跟随导出') return exportSettings.value.keyframeCurve === 'bezier' ? 'bezier' : 'linear'
|
||||
@@ -1002,7 +1062,20 @@ async function loadSelectedPreset() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshPresets)
|
||||
onMounted(() => {
|
||||
refreshPresets()
|
||||
refreshConnectorHealth()
|
||||
connectorHealthTimer = window.setInterval(refreshConnectorHealth, 3000)
|
||||
})
|
||||
|
||||
let connectorHealthTimer: number | undefined
|
||||
onBeforeUnmount(() => {
|
||||
if (connectorHealthTimer !== undefined) window.clearInterval(connectorHealthTimer)
|
||||
})
|
||||
|
||||
async function refreshConnectorHealth() {
|
||||
connectorHealth.value = await checkSpineConnector()
|
||||
}
|
||||
|
||||
function nextPaint() {
|
||||
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
@@ -1027,7 +1100,9 @@ async function prepareExportFrames() {
|
||||
async function exportSpineJson() {
|
||||
if (exportBusy.value) return
|
||||
exportBusy.value = true
|
||||
exportTarget.value = 'json'
|
||||
exportError.value = false
|
||||
connectorPairUrl.value = ''
|
||||
exportMessage.value = '正在重新计算粒子动画…'
|
||||
try {
|
||||
await prepareExportFrames()
|
||||
@@ -1041,6 +1116,63 @@ async function exportSpineJson() {
|
||||
exportMessage.value = error instanceof Error ? error.message : String(error)
|
||||
} finally {
|
||||
exportBusy.value = false
|
||||
exportTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function exportSpineProject() {
|
||||
if (exportBusy.value) return
|
||||
exportBusy.value = true
|
||||
exportTarget.value = 'spine'
|
||||
exportError.value = false
|
||||
connectorPairUrl.value = ''
|
||||
exportMessage.value = '正在重新计算粒子动画…'
|
||||
try {
|
||||
await prepareExportFrames()
|
||||
const result = buildSpineJson({ systems: store.systems as unknown as ParticleSystem[], timeline: store.timeline, settings: exportSettings.value })
|
||||
validateSpineJson(result.text, exportSettings.value.spineVersion)
|
||||
exportMessage.value = '正在整理图片并调用本机 Spine…'
|
||||
const projectName = exportSettings.value.fileName.replace(/\.json$/i, '') || 'SpineParticle'
|
||||
const converted = await convertWithSpineConnector({
|
||||
projectName,
|
||||
spineVersion: exportSettings.value.spineVersion,
|
||||
skeletonJson: result.text,
|
||||
systems: store.systems as unknown as ParticleSystem[],
|
||||
})
|
||||
downloadSpineProject(converted.blob, converted.fileName)
|
||||
exportMessage.value = `已生成 ${converted.fileName}:包含 .spine 工程和 ${converted.imageCount} 张图片,Spine ${converted.spineEditorVersion}`
|
||||
await refreshConnectorHealth()
|
||||
} catch (error) {
|
||||
exportError.value = true
|
||||
if (error instanceof SpineConnectorError && error.pairUrl) connectorPairUrl.value = error.pairUrl
|
||||
exportMessage.value = error instanceof Error ? error.message : String(error)
|
||||
await refreshConnectorHealth()
|
||||
} finally {
|
||||
exportBusy.value = false
|
||||
exportTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function exportSpineImages() {
|
||||
if (exportBusy.value) return
|
||||
exportBusy.value = true
|
||||
exportTarget.value = 'images'
|
||||
exportError.value = false
|
||||
connectorPairUrl.value = ''
|
||||
exportMessage.value = '正在整理当前粒子系统使用的图片…'
|
||||
try {
|
||||
const archive = await buildSpineImagesArchive({
|
||||
systems: store.systems as unknown as ParticleSystem[],
|
||||
imagesPath: exportSettings.value.imagesPath,
|
||||
})
|
||||
const fileName = downloadSpineImagesArchive(archive.blob, exportSettings.value.fileName)
|
||||
exportMessage.value = `已生成 ${fileName}:${archive.folder}/ 内包含 ${archive.imageCount} 张图片`
|
||||
} catch (error) {
|
||||
exportError.value = true
|
||||
exportMessage.value = error instanceof Error ? error.message : String(error)
|
||||
} finally {
|
||||
exportBusy.value = false
|
||||
exportTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1960,11 +2092,30 @@ function toggleResourceLock(resourceId: number) {
|
||||
.export-switches { display: grid; gap: 13px; margin: 4px 0 7px; padding-top: 2px; }
|
||||
.export-switch { font-weight: 600; }
|
||||
.export-switch-last { margin-top: 13px; }
|
||||
.export-spine-json { width: 100%; margin-top: 10px; padding: 9px 12px; flex: 0 0 auto; border: 1px solid #4f4b9a; border-radius: 7px; background: #403b91; color: #f1f0ff; font-size: 12px; font-weight: 700; cursor: pointer; }
|
||||
.spine-export-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-top: 10px; }
|
||||
.export-spine-json { width: 100%; padding: 9px 8px; flex: 0 0 auto; border: 1px solid #4f4b9a; border-radius: 7px; background: #403b91; color: #f1f0ff; font-size: 12px; font-weight: 700; cursor: pointer; }
|
||||
.export-spine-json:hover { border-color: #7774ff; background: #514bc0; }
|
||||
.export-spine-json:disabled { cursor: wait; opacity: .68; }
|
||||
.export-spine-project { grid-column: 1 / -1; width: 100%; padding: 7px 8px; border: 1px solid #287d69; border-radius: 7px; background: #196854; color: #edfff9; font-size: 12px; font-weight: 700; cursor: pointer; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; }
|
||||
.export-spine-project small { color: inherit; font-size: 9px; font-weight: 500; line-height: 1.1; }
|
||||
.export-spine-project:hover:not(:disabled) { border-color: #47c89f; background: #238267; }
|
||||
.export-spine-project:disabled { cursor: not-allowed; opacity: .48; }
|
||||
.export-spine-images { width: 100%; padding: 8px; border: 1px solid #3b5f8b; border-radius: 7px; background: #243e64; color: #eaf3ff; font-size: 12px; font-weight: 700; cursor: pointer; }
|
||||
.export-spine-images:hover:not(:disabled) { border-color: #6099dc; background: #315581; }
|
||||
.export-spine-images:disabled { cursor: wait; opacity: .55; }
|
||||
.connector-status { display: flex; align-items: center; justify-content: center; gap: 5px; padding-top: 6px; color: #717b99; font-size: 10px; }
|
||||
.connector-status .connector-dot { width: 6px; height: 6px; border-radius: 50%; background: #66718f; }
|
||||
.connector-status.ready { color: #70d8ae; }
|
||||
.connector-status.ready .connector-dot { background: #49c993; box-shadow: 0 0 5px #49c99388; }
|
||||
.connector-status.warning { color: #d4aa67; }
|
||||
.connector-status.warning .connector-dot { background: #d4aa67; }
|
||||
.connector-status button { padding: 0 3px; border: 0; background: transparent; color: #8290b6; font-size: 10px; cursor: pointer; }
|
||||
.connector-status button:hover { color: #c5caff; }
|
||||
.connector-status a { padding: 0 3px; color: #a8b0ff; font-size: 10px; text-decoration: underline; }
|
||||
.connector-status a:hover { color: #d8dcff; }
|
||||
.export-message { padding: 7px 5px 0; color: #70d8ae; font-size: 10px; line-height: 1.45; text-align: center; }
|
||||
.export-message.error { color: #ff858f; }
|
||||
.export-message a { display: inline-block; margin-left: 5px; color: #9da7ff; text-decoration: underline; }
|
||||
/* 外观与资源 参考版式 */
|
||||
.look-title { font-size: 12px; font-weight: 600; color: #ccd; margin: 10px 0 6px; padding-bottom: 4px; border-bottom: 1px solid #2a2a3c; }
|
||||
.look-title:first-child { margin-top: 2px; }
|
||||
|
||||
Reference in New Issue
Block a user