160 lines
8.0 KiB
Vue
160 lines
8.0 KiB
Vue
<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>
|