Files
SpineParticlesWeb/src/views/ImageResourceCard.vue
T
2026-08-31 02:15:38 +08:00

160 lines
8.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="image-card">
<div class="imgrow">
<button class="img-thumb" :title="supportsSequence && resource.imageMode === 'sequence' ? '选择序列帧图片' : '选择本地图片'" @click="pickImage">
<img v-if="displayPreviewUrl" :src="displayPreviewUrl" :alt="resource.textureName" />
<svg v-else viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#8a93bb" stroke-width="1.6"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.6"/><path d="M21 15l-5-5-9 9"/></svg>
</button>
<div class="img-fields">
<label v-if="showFolder" class="row"><span>子文件夹</span><input v-model="resource.textureFolder" type="text" class="inp" placeholder="subfolder/" /></label>
<label class="row"><span>名称</span><input v-model="resource.textureName" type="text" class="inp" :placeholder="namePlaceholder" /></label>
</div>
<div class="img-actions">
<button class="mini-btn2" :title="`恢复 ${defaultFile}`" @click="$emit('reset', resource.id)">⟳</button>
<button class="mini-btn2" title="删除图片资源" :disabled="resourceCount === 1" @click="$emit('remove', resource.id)">🗑</button>
</div>
</div>
<div class="thumb-note">{{ displayFileName }}</div>
<label v-if="supportsSequence" class="row"><span>图片模式</span>
<select v-model="resource.imageMode" class="inp">
<option value="fixed">固定</option>
<option value="sequence">序列帧</option>
</select>
</label>
<template v-if="supportsSequence && resource.imageMode === 'sequence'">
<label class="row"><span>播放模式</span>
<select v-model="resource.sequencePlayback" class="inp">
<option value="once-hide">单次-尾帧消失</option>
<option value="once-hold">单次-尾帧固定</option>
<option value="loop-forward">正向循环</option>
<option value="loop-reverse">反向循环</option>
</select>
</label>
<label class="row"><span>播放帧率</span><input v-model.number="resource.sequenceFps" type="number" min="1" max="120" step="1" class="inp" /></label>
<button class="sequence-pick" @click="$emit('pickSequence', resource.id)">选择序列帧(多选)</button>
<div class="sequence-note">{{ sequenceOrderText }}</div>
</template>
<div class="resource-weight-row">
<NumSlider
class="resource-weight-control"
label="占比%"
:min="0"
:max="100"
:step="1"
:model-value="resource.weight"
:disabled="resource.locked || resourceCount === 1"
@update:model-value="$emit('updateWeight', resource.id, $event)"
/>
<button
class="weight-lock"
:class="{ on: resource.locked }"
:disabled="resourceCount === 1"
:title="resource.locked ? '解除占比锁定' : '锁定当前占比'"
@click="$emit('toggleLock', resource.id)"
>
<svg v-if="resource.locked" viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>
<svg v-else viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M16 10V7a4 4 0 0 0-7.5-2"/></svg>
</button>
</div>
<slot />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import NumSlider from './NumSlider.vue'
type CommonImageResource = {
id: number
previewUrl: string
imageMode?: 'fixed' | 'sequence'
sequencePlayback?: 'once-hide' | 'once-hold' | 'loop-forward' | 'loop-reverse'
sequenceFps?: number
sequenceFrames?: Array<{ name: string; previewUrl: string }>
textureName: string
textureFolder?: string
weight: number
locked: boolean
}
const props = withDefaults(defineProps<{
resource: CommonImageResource
resourceCount: number
defaultFile: string
namePlaceholder?: string
showFolder?: boolean
supportsSequence?: boolean
}>(), {
namePlaceholder: 'particle',
showFolder: true,
supportsSequence: false,
})
const emit = defineEmits<{
pick: [id: number]
pickSequence: [id: number]
reset: [id: number]
remove: [id: number]
updateWeight: [id: number, value: number]
toggleLock: [id: number]
}>()
const displayFileName = computed(() => {
if (props.resource.imageMode === 'sequence') {
const frames = props.resource.sequenceFrames || []
if (!frames.length) return '未选择序列帧'
const first = frames[0]?.name || '第 1 帧'
const last = frames[frames.length - 1]?.name || first
return frames.length === 1 ? `1 帧 · ${first}` : `${frames.length} 帧 · ${first} → ${last}`
}
const name = props.resource.textureName || props.defaultFile.replace(/\.[^.]+$/, '')
return name.includes('.') ? name : `${name}.png`
})
const displayPreviewUrl = computed(() => props.resource.imageMode === 'sequence'
? props.resource.sequenceFrames?.[0]?.previewUrl || ''
: props.resource.previewUrl)
const sequenceOrderText = computed(() => {
const frames = props.resource.sequenceFrames || []
if (!frames.length) return '未选择序列帧'
const first = frames[0]?.name || '第一张图片'
const last = frames[frames.length - 1]?.name || '最后一张图片'
return `已选择 ${frames.length} 帧,按 ${first}-${last} 顺序播放`
})
function pickImage() {
if (props.supportsSequence && props.resource.imageMode === 'sequence') emit('pickSequence', props.resource.id)
else emit('pick', props.resource.id)
}
</script>
<style scoped>
.image-card { margin: 8px 0; padding: 9px; border: 1px solid #2e3c55; border-radius: 7px; background: #171d2a; }
.imgrow { display: flex; align-items: flex-start; gap: 8px; }
.img-thumb { width: 48px; height: 48px; padding: 0; border: 1px solid #3a4963; border-radius: 6px; background: #1a2232; display: flex; align-items: center; justify-content: center; flex-shrink: 0; cursor: pointer; overflow: hidden; }
.img-thumb:hover { border-color: #7774ff; }
.img-thumb img { display: block; width: 100%; height: 100%; object-fit: contain; }
.img-fields { flex: 1; min-width: 0; }
.img-actions { display: flex; flex-direction: column; gap: 4px; }
.row { display: flex; align-items: center; gap: 8px; margin: 6px 0; color: #aab; font-size: 12px; }
.row > span:first-child { width: 60px; flex-shrink: 0; color: #8a93bb; }
.inp { min-width: 0; flex: 1; padding: 3px 5px; border: 1px solid #2e2e44; border-radius: 5px; background: #1a1a28; color: #dde; font-size: 12px; }
.mini-btn2 { width: 26px; height: 24px; background: #1e1e2e; border: 1px solid #2e2e44; border-radius: 5px; color: #aab; font-size: 12px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.mini-btn2:hover { background: #2b3a5c; }
.mini-btn2:disabled { opacity: 0.35; cursor: not-allowed; }
.thumb-note { margin: 4px 0 2px; color: #667; font-size: 11px; }
.sequence-pick { width: 100%; margin: 2px 0 4px; padding: 6px 8px; border: 1px solid #4a4a76; border-radius: 5px; background: #27264b; color: #d8d7ff; font-size: 12px; cursor: pointer; }
.sequence-pick:hover { border-color: #7774ff; background: #35336a; }
.sequence-note { margin: 0 0 6px; color: #727b9b; font-size: 11px; line-height: 1.4; }
.resource-weight-row { display: grid; grid-template-columns: minmax(0, 1fr) 28px; align-items: center; gap: 7px; }
.resource-weight-control { width: 100%; min-width: 0; }
.resource-weight-control :deep(.ns-range) { min-width: 0; }
.resource-weight-control :deep(.ns-num) { flex: 0 0 56px; box-sizing: border-box; }
.weight-lock { width: 28px; height: 26px; flex: 0 0 28px; padding: 5px; border: 1px solid #35435c; border-radius: 5px; background: #202a3b; color: #78859e; cursor: pointer; }
.weight-lock svg { display: block; width: 100%; height: 100%; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.weight-lock:hover:not(:disabled) { border-color: #7774ff; color: #b8b6ff; }
.weight-lock.on { border-color: #7774ff; background: #514fd0; color: #fff; }
.weight-lock:disabled { opacity: 0.35; cursor: not-allowed; }
</style>