325 lines
18 KiB
Vue
325 lines
18 KiB
Vue
<template>
|
|
<div class="image-card" data-matomo-section="resources">
|
|
<div class="imgrow">
|
|
<button class="img-thumb" data-matomo-event="resources.pick-image" :title="supportsSequence && resource.imageMode === 'sequence' ? $t('image.chooseSequence') : $t('image.chooseLocal')" @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>{{ $t('image.subfolder') }}</span><input v-model="resource.textureFolder" type="text" class="inp" placeholder="subfolder/" /></label>
|
|
<label class="row"><span>{{ $t('image.name') }}</span><input v-model="resource.textureName" type="text" class="inp" :placeholder="namePlaceholder" /></label>
|
|
</div>
|
|
<div class="img-actions">
|
|
<button class="mini-btn2" data-matomo-event="resources.reset-image" :title="$t('image.restore', { name: defaultFile })" @click="$emit('reset', resource.id)">⟳</button>
|
|
<button class="mini-btn2" data-matomo-event="resources.delete-image" :title="$t('image.deleteResource')" :disabled="resourceCount === 1" @click="$emit('remove', resource.id)">🗑</button>
|
|
</div>
|
|
</div>
|
|
<div class="thumb-note">{{ displayFileName }}</div>
|
|
<label v-if="supportsSequence" class="row"><span>{{ $t('image.imageMode') }}</span>
|
|
<select :value="resource.imageMode || 'fixed'" class="inp" @change="changeImageMode">
|
|
<option value="fixed">{{ $t('image.fixed') }}</option>
|
|
<option value="sequence">{{ $t('image.sequence') }}</option>
|
|
</select>
|
|
</label>
|
|
<template v-if="supportsSequence && resource.imageMode === 'sequence'">
|
|
<label class="row"><span>{{ $t('image.playbackMode') }}</span>
|
|
<select v-model="resource.sequencePlayback" class="inp">
|
|
<option value="once-hide">{{ $t('image.onceHide') }}</option>
|
|
<option value="once-hold">{{ $t('image.onceHold') }}</option>
|
|
<option value="loop-forward">{{ $t('image.loopForward') }}</option>
|
|
<option value="loop-reverse">{{ $t('image.loopReverse') }}</option>
|
|
</select>
|
|
</label>
|
|
<label class="row"><span>{{ $t('image.playbackFps') }}</span><input v-model.number="resource.sequenceFps" type="number" min="1" max="120" step="1" class="inp" /></label>
|
|
<button class="sequence-pick" data-matomo-event="resources.pick-sequence" @click="$emit('pickSequence', resource.id)">{{ $t('image.chooseSequenceMulti') }}</button>
|
|
<div class="sequence-note">{{ sequenceOrderText }}</div>
|
|
</template>
|
|
<div class="resource-weight-row">
|
|
<NumSlider
|
|
class="resource-weight-control"
|
|
:label="$t('image.weight')"
|
|
:min="0"
|
|
:max="100"
|
|
:step="1"
|
|
:model-value="resource.weight"
|
|
:disabled="resource.locked || resourceCount === 1"
|
|
@update:model-value="$emit('updateWeight', resource.id, $event)"
|
|
/>
|
|
<button
|
|
data-matomo-event="resources.toggle-lock"
|
|
class="weight-lock"
|
|
:class="{ on: resource.locked }"
|
|
:disabled="resourceCount === 1"
|
|
:title="resource.locked ? $t('image.unlockWeight') : $t('image.lockWeight')"
|
|
@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 v-if="supportsPivot" class="pivot-toggle-row">
|
|
<div class="pivot-toggle-icon">◎</div>
|
|
<div class="pivot-toggle-text">
|
|
<div class="pivot-toggle-title">{{ $t('image.pivot') }}</div>
|
|
<div class="pivot-toggle-sub" :title="pivotSummary">{{ pivotSummary }}</div>
|
|
</div>
|
|
<label class="pivot-switch" :title="pivotOpen ? $t('image.collapsePivot') : $t('image.expandPivot')">
|
|
<input v-model="pivotOpen" type="checkbox" @change="onPivotToggle" />
|
|
<span class="pivot-switch-ui"></span>
|
|
</label>
|
|
</div>
|
|
<PivotEditor
|
|
v-if="supportsPivot && pivotOpen"
|
|
:preview-url="displayPreviewUrl"
|
|
:anchor-x="resource.anchorX ?? 0.5"
|
|
:anchor-y="resource.anchorY ?? 0.5"
|
|
@update:anchorX="resource.anchorX = $event"
|
|
@update:anchorY="resource.anchorY = $event"
|
|
@fullscreen="openPivotFullscreen"
|
|
/>
|
|
</div>
|
|
|
|
<Teleport to="body">
|
|
<div v-if="pivotFullscreen" class="pivot-modal-backdrop" @pointerdown.self="closePivotFullscreen(false)">
|
|
<div class="pivot-modal" data-matomo-section="resources" role="dialog" aria-modal="true" :aria-label="$t('image.pivotModal')">
|
|
<div class="pivot-modal-head">
|
|
<div class="pivot-modal-icon">◎</div>
|
|
<div class="pivot-modal-info">
|
|
<div class="pivot-modal-title">
|
|
{{ $t('image.pivotModal') }}
|
|
<span class="pivot-modal-badge">{{ resource.textureName || defaultFile }}</span>
|
|
</div>
|
|
<div class="pivot-modal-desc">{{ $t('image.pivotModalDesc') }}</div>
|
|
</div>
|
|
<button type="button" class="pivot-modal-close" :title="$t('common.close')" @click="closePivotFullscreen(false)">✕</button>
|
|
</div>
|
|
<div class="pivot-modal-hint">{{ $t('image.pivotZoomHint') }}</div>
|
|
<PivotEditor
|
|
large
|
|
:preview-url="displayPreviewUrl"
|
|
:anchor-x="draftX"
|
|
:anchor-y="draftY"
|
|
@update:anchorX="draftX = $event"
|
|
@update:anchorY="draftY = $event"
|
|
/>
|
|
<div class="pivot-modal-actions">
|
|
<button type="button" class="pivot-modal-cancel" @click="closePivotFullscreen(false)">{{ $t('common.cancel') }}</button>
|
|
<button type="button" class="pivot-modal-confirm" @click="closePivotFullscreen(true)">{{ $t('image.confirmPivot') }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import NumSlider from './NumSlider.vue'
|
|
import PivotEditor from './PivotEditor.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
|
|
anchorX?: number
|
|
anchorY?: number
|
|
weight: number
|
|
locked: boolean
|
|
}
|
|
|
|
const props = withDefaults(defineProps<{
|
|
resource: CommonImageResource
|
|
resourceCount: number
|
|
defaultFile: string
|
|
namePlaceholder?: string
|
|
showFolder?: boolean
|
|
supportsSequence?: boolean
|
|
/** 资源卡片的稳定标识(如“系统id:资源id”),用于记住轴点面板展开状态。 */
|
|
pivotKey?: string
|
|
}>(), {
|
|
namePlaceholder: 'particle',
|
|
showFolder: true,
|
|
supportsSequence: false,
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
pick: [id: number]
|
|
pickSequence: [id: number]
|
|
modeChange: [id: number, mode: 'fixed' | 'sequence']
|
|
reset: [id: number]
|
|
remove: [id: number]
|
|
updateWeight: [id: number, value: number]
|
|
toggleLock: [id: number]
|
|
}>()
|
|
const { t } = useI18n()
|
|
|
|
const displayFileName = computed(() => {
|
|
if (props.resource.imageMode === 'sequence') {
|
|
const frames = props.resource.sequenceFrames || []
|
|
if (!frames.length) return t('image.noSequence')
|
|
const first = frames[0]?.name || t('image.firstFrame')
|
|
const last = frames[frames.length - 1]?.name || first
|
|
return frames.length === 1
|
|
? t('image.frameCountOne', { name: first })
|
|
: t('image.frameCountMany', { count: 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)
|
|
|
|
// 轴点面板展开状态属于 UI 状态:按资源独立记忆;用 sessionStorage 保证组件重挂载/HMR 后也不收起。
|
|
const PIVOT_UI_STORAGE_KEY = 'SpineParticle.PivotPanels.v1'
|
|
function loadPivotPanelState(): Record<string, boolean> {
|
|
try {
|
|
const parsed = JSON.parse(sessionStorage.getItem(PIVOT_UI_STORAGE_KEY) || '{}')
|
|
return parsed && typeof parsed === 'object' ? parsed as Record<string, boolean> : {}
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|
|
const pivotOpenState = loadPivotPanelState()
|
|
const resolvedPivotKey = computed(() => props.pivotKey || `particle-image:${props.resource.id}`)
|
|
const supportsPivot = computed(() => typeof props.resource.anchorX === 'number' && typeof props.resource.anchorY === 'number')
|
|
const pivotCentered = computed(() =>
|
|
Math.abs((props.resource.anchorX ?? 0.5) - 0.5) < 1e-4 && Math.abs((props.resource.anchorY ?? 0.5) - 0.5) < 1e-4,
|
|
)
|
|
// 有明确记忆就用记忆;没有记忆时,已自定义轴点的资源默认展开,避免“开关关闭但轴点生效”的错觉。
|
|
const hasStoredPivotOpen = typeof pivotOpenState[resolvedPivotKey.value] === 'boolean'
|
|
const pivotOpen = ref(hasStoredPivotOpen ? pivotOpenState[resolvedPivotKey.value] : !pivotCentered.value)
|
|
watch(pivotOpen, (open) => {
|
|
pivotOpenState[resolvedPivotKey.value] = open
|
|
try { sessionStorage.setItem(PIVOT_UI_STORAGE_KEY, JSON.stringify(pivotOpenState)) } catch { /* 隐私模式忽略 */ }
|
|
})
|
|
watch(resolvedPivotKey, (key) => {
|
|
pivotOpen.value = typeof pivotOpenState[key] === 'boolean' ? pivotOpenState[key] : !pivotCentered.value
|
|
})
|
|
// 轴点已自定义时开关必须为开启状态,避免出现“开关关闭但轴点仍生效”的不一致。
|
|
watch(() => pivotCentered.value, (centered) => {
|
|
if (!centered) pivotOpen.value = true
|
|
}, { immediate: true })
|
|
const pivotFullscreen = ref(false)
|
|
const draftX = ref(0.5)
|
|
const draftY = ref(0.5)
|
|
const pivotSummary = computed(() => {
|
|
const x = ((props.resource.anchorX ?? 0.5) * 100).toFixed(1)
|
|
const y = ((props.resource.anchorY ?? 0.5) * 100).toFixed(1)
|
|
if (pivotOpen.value) return t('image.pivotOn', { x, y })
|
|
if (pivotCentered.value) return t('image.pivotOff')
|
|
return t('image.pivotCustom', { x, y })
|
|
})
|
|
|
|
function openPivotFullscreen() {
|
|
draftX.value = props.resource.anchorX ?? 0.5
|
|
draftY.value = props.resource.anchorY ?? 0.5
|
|
pivotFullscreen.value = true
|
|
}
|
|
|
|
/** 关闭开关表示回到默认中心轴点;自定义轴点不允许处于“关闭”状态。 */
|
|
function onPivotToggle() {
|
|
if (pivotOpen.value) return
|
|
props.resource.anchorX = 0.5
|
|
props.resource.anchorY = 0.5
|
|
pivotFullscreen.value = false
|
|
}
|
|
|
|
function closePivotFullscreen(commit: boolean) {
|
|
if (commit) {
|
|
props.resource.anchorX = draftX.value
|
|
props.resource.anchorY = draftY.value
|
|
}
|
|
pivotFullscreen.value = false
|
|
}
|
|
|
|
function onKeydown(event: KeyboardEvent) {
|
|
if (event.key === 'Escape' && pivotFullscreen.value) closePivotFullscreen(false)
|
|
}
|
|
|
|
onMounted(() => window.addEventListener('keydown', onKeydown))
|
|
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
|
|
|
const sequenceOrderText = computed(() => {
|
|
const frames = props.resource.sequenceFrames || []
|
|
if (!frames.length) return t('image.noSequence')
|
|
const first = frames[0]?.name || t('image.firstImage')
|
|
const last = frames[frames.length - 1]?.name || t('image.lastImage')
|
|
return t('image.sequenceSelected', { count: frames.length, first, last })
|
|
})
|
|
|
|
function pickImage() {
|
|
if (props.supportsSequence && props.resource.imageMode === 'sequence') emit('pickSequence', props.resource.id)
|
|
else emit('pick', props.resource.id)
|
|
}
|
|
|
|
function changeImageMode(event: Event) {
|
|
const mode = (event.target as HTMLSelectElement).value as 'fixed' | 'sequence'
|
|
emit('modeChange', props.resource.id, mode)
|
|
}
|
|
</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; }
|
|
.pivot-toggle-row { display: flex; align-items: center; gap: 9px; margin-top: 9px; padding: 8px 9px; border: 1px solid #2c3a54; border-radius: 8px; background: #131b28; }
|
|
.pivot-toggle-icon { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; flex: 0 0 32px; border: 1px solid #35435c; border-radius: 7px; background: #1c2638; color: #8f8cff; font-size: 17px; }
|
|
.pivot-toggle-text { flex: 1; min-width: 0; }
|
|
.pivot-toggle-title { color: #d6deee; font-size: 12px; font-weight: 600; }
|
|
.pivot-toggle-sub { margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #7584a0; font-size: 9px; }
|
|
.pivot-switch { position: relative; flex: 0 0 auto; cursor: pointer; }
|
|
.pivot-switch input { position: absolute; opacity: 0; pointer-events: none; }
|
|
.pivot-switch-ui { position: relative; display: block; width: 34px; height: 19px; border-radius: 10px; background: #354258; transition: background .16s ease; }
|
|
.pivot-switch-ui::after { content: ''; position: absolute; left: 2px; top: 2px; width: 15px; height: 15px; border-radius: 50%; background: #fff; transition: transform .16s ease; }
|
|
.pivot-switch input:checked + .pivot-switch-ui { background: #625ff1; }
|
|
.pivot-switch input:checked + .pivot-switch-ui::after { transform: translateX(15px); }
|
|
|
|
.pivot-modal-backdrop { position: fixed; inset: 0; z-index: 9999; display: flex; align-items: center; justify-content: center; padding: 24px; background: rgba(6, 9, 15, .78); backdrop-filter: blur(2px); }
|
|
.pivot-modal { display: flex; flex-direction: column; width: min(980px, 94vw); max-height: 92vh; overflow: auto; padding: 14px; border: 1px solid #2f3f5c; border-radius: 14px; background: #121927; box-shadow: 0 24px 70px rgba(0, 0, 0, .6); }
|
|
.pivot-modal-head { display: flex; align-items: flex-start; gap: 10px; padding-bottom: 10px; border-bottom: 1px solid #253148; }
|
|
.pivot-modal-icon { display: flex; align-items: center; justify-content: center; width: 34px; height: 34px; flex: 0 0 34px; border: 1px solid #3b4a66; border-radius: 8px; background: #1c2638; color: #8f8cff; font-size: 18px; }
|
|
.pivot-modal-info { flex: 1; min-width: 0; }
|
|
.pivot-modal-title { display: flex; align-items: center; gap: 7px; color: #e4e9f5; font-size: 15px; font-weight: 700; }
|
|
.pivot-modal-badge { padding: 2px 7px; border: 1px solid #4b4a8f; border-radius: 999px; background: #2c2b58; color: #b9b6ff; font-size: 10px; font-weight: 500; }
|
|
.pivot-modal-desc { margin-top: 3px; color: #7f8daa; font-size: 11px; }
|
|
.pivot-modal-close { width: 28px; height: 28px; padding: 0; border: 0; border-radius: 6px; background: transparent; color: #8b98b3; font-size: 15px; cursor: pointer; }
|
|
.pivot-modal-close:hover { background: #253148; color: #fff; }
|
|
.pivot-modal-hint { margin: 10px 0 0; color: #8b98b3; font-size: 11px; }
|
|
.pivot-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; padding-top: 10px; border-top: 1px solid #253148; }
|
|
.pivot-modal-cancel { height: 34px; padding: 0 16px; border: 1px solid #34435e; border-radius: 7px; background: #1a2334; color: #a9b6d0; font-size: 12px; cursor: pointer; }
|
|
.pivot-modal-cancel:hover { border-color: #5d5aa8; color: #fff; }
|
|
.pivot-modal-confirm { height: 34px; padding: 0 22px; border: 1px solid #7774ff; border-radius: 7px; background: #5b57e8; color: #fff; font-size: 12px; font-weight: 700; cursor: pointer; }
|
|
.pivot-modal-confirm:hover { background: #6a66ff; }
|
|
</style>
|