中英双语

This commit is contained in:
tianmo committed 2026-10-22 18:13:43 +08:00
1 parent ea26af26ee
commit b98c0333e6
50 files changed
+4082 -2049

No files matched your search

+35 -33
View File
@@ -2,33 +2,33 @@
<div class="timeline">
<!-- 控制按钮 -->
<div class="tl-controls">
<button class="tl-btn" :class="{ on: store.timeline.playing }" @click="store.togglePlay()" :title="store.timeline.playing ? '暂停 (D)' : '播放 (D)'">
<button class="tl-btn" :class="{ on: store.timeline.playing }" @click="store.togglePlay()" :title="store.timeline.playing ? $t('timeline.pause') : $t('timeline.play')">
{{ store.timeline.playing ? '❚❚' : '▶' }}
</button>
<button class="tl-btn" @click="store.toStart()" title="回到第一帧 (S)">⏮</button>
<button class="tl-btn" @click="store.previousFrame()" title="前一帧">◀</button>
<button class="tl-btn" @click="store.nextFrame()" title="后一帧">▶</button>
<button class="tl-btn" @click="store.toEnd()" title="移动到最后一帧">⏭</button>
<button class="tl-btn" :class="{ on: store.timeline.loop }" title="持续循环播放" aria-label="持续循环播放" @click="store.toggleLoop()">⟳</button>
<button class="tl-btn" @click="store.toStart()" :title="$t('timeline.toStart')">⏮</button>
<button class="tl-btn" @click="store.previousFrame()" :title="$t('timeline.previous')">◀</button>
<button class="tl-btn" @click="store.nextFrame()" :title="$t('timeline.next')">▶</button>
<button class="tl-btn" @click="store.toEnd()" :title="$t('timeline.toEnd')">⏭</button>
<button class="tl-btn" :class="{ on: store.timeline.loop }" :title="$t('timeline.continuousLoop')" :aria-label="$t('timeline.continuousLoop')" @click="store.toggleLoop()">⟳</button>
<div class="tl-ab-loop">
<button class="tl-ab-toggle" :class="{ on: store.timeline.abLoop }" title="独立 A/B 区间循环" @click="store.toggleABLoop()">
<span>AB 循环</span><i class="tl-switch"><b></b></i>
<button class="tl-ab-toggle" :class="{ on: store.timeline.abLoop }" :title="$t('timeline.abLoop')" @click="store.toggleABLoop()">
<span>{{ $t('timeline.abLoopShort') }}</span><i class="tl-switch"><b></b></i>
</button>
<div v-if="store.timeline.abLoop" class="tl-ab-popover" @pointerdown.stop>
<div class="tl-ab-title">AB 循环区间</div>
<label class="tl-range" title="自定义播放起始帧"><span>A</span><input v-model="playbackStartDraft" type="number" min="0" :max="Math.max(0, store.timeline.playbackEnd - 1)" aria-label="播放起始帧" @change="commitPlaybackStart" @keydown.enter.prevent="commitPlaybackStart" /></label>
<label class="tl-range" title="自定义播放结束帧"><span>B</span><input v-model="playbackEndDraft" type="number" :min="store.timeline.playbackStart + 1" :max="Math.max(1, store.timeline.totalFrames - 1)" aria-label="播放结束帧" @change="commitPlaybackEnd" @keydown.enter.prevent="commitPlaybackEnd" /></label>
<div class="tl-ab-title">{{ $t('timeline.abRange') }}</div>
<label class="tl-range" :title="$t('timeline.playbackStart')"><span>A</span><input v-model="playbackStartDraft" type="number" min="0" :max="Math.max(0, store.timeline.playbackEnd - 1)" :aria-label="$t('timeline.startFrame')" @change="commitPlaybackStart" @keydown.enter.prevent="commitPlaybackStart" /></label>
<label class="tl-range" :title="$t('timeline.playbackEnd')"><span>B</span><input v-model="playbackEndDraft" type="number" :min="store.timeline.playbackStart + 1" :max="Math.max(1, store.timeline.totalFrames - 1)" :aria-label="$t('timeline.endFrame')" @change="commitPlaybackEnd" @keydown.enter.prevent="commitPlaybackEnd" /></label>
</div>
</div>
<span class="tl-frame">帧 <b>{{ store.timeline.frame }}</b> / <b>{{ Math.max(0, store.timeline.totalFrames - 1) }}</b></span>
<span class="tl-legend"><i class="legend-emission"></i>持续发射 <i class="legend-life"></i>粒子存在 <i class="legend-spine"></i>Spine 动画</span>
<span class="tl-frame">{{ $t('timeline.frameStatus', { current: store.timeline.frame, total: Math.max(0, store.timeline.totalFrames - 1) }) }}</span>
<span class="tl-legend"><i class="legend-emission"></i>{{ $t('timeline.emission') }} <i class="legend-life"></i>{{ $t('timeline.particleLife') }} <i class="legend-spine"></i>{{ $t('timeline.spineAnimation') }}</span>
<div ref="animationMenuRef" class="tl-anim tl-owner-animation">
<span>所属动画</span>
<button class="tl-animation-trigger" :disabled="!activeParticleSystem" title="设置当前粒子系统所属的动画" @click.stop="toggleAnimationMenu">
<span>{{ $t('timeline.ownerAnimation') }}</span>
<button class="tl-animation-trigger" :disabled="!activeParticleSystem" :title="$t('timeline.setOwnerAnimation')" @click.stop="toggleAnimationMenu">
<span>{{ activeParticleSystem?.animation || store.timeline.animation }}</span><i>⌄</i>
</button>
<div v-if="animationMenuOpen" class="tl-animation-menu" @pointerdown.stop>
<div class="tl-animation-menu-title">选择所属动画</div>
<div class="tl-animation-menu-title">{{ $t('timeline.chooseOwnerAnimation') }}</div>
<button
v-for="animation in store.timeline.animations"
:key="animation"
@@ -39,25 +39,25 @@
<span>{{ animation }}</span><i v-if="activeParticleSystem?.animation === animation">✓</i>
</button>
<div class="tl-animation-editor">
<input v-model="animationNameDraft" class="tl-animation-name" maxlength="64" aria-label="动画名称" @keydown.enter="renameCurrentAnimation" />
<button title="重命名当前动画" @click="renameCurrentAnimation">重命名</button>
<input v-model="animationNameDraft" class="tl-animation-name" maxlength="64" :aria-label="$t('timeline.animationName')" @keydown.enter="renameCurrentAnimation" />
<button :title="$t('timeline.renameAnimation')" @click="renameCurrentAnimation">{{ $t('timeline.rename') }}</button>
</div>
<div v-if="animationError" class="tl-animation-error">{{ animationError }}</div>
<div class="tl-animation-actions">
<button @click="addAnimation">+ 新增</button>
<button :disabled="store.timeline.animations.length <= 1" @click="deleteCurrentAnimation">删除</button>
<button @click="addAnimation">{{ $t('timeline.newAnimation') }}</button>
<button :disabled="store.timeline.animations.length <= 1" @click="deleteCurrentAnimation">{{ $t('common.delete') }}</button>
</div>
<div v-if="store.timeline.animations.length <= 1" class="tl-animation-hint">至少需要保留一个动画</div>
<div v-if="store.timeline.animations.length <= 1" class="tl-animation-hint">{{ $t('timeline.keepOneAnimation') }}</div>
</div>
</div>
<label class="tl-anim tl-spine-preview">Spine 预览动画
<label class="tl-anim tl-spine-preview">{{ $t('timeline.spinePreview') }}
<select
class="tl-sel"
:value="previewSpine?.selectedAnimation || ''"
:disabled="!previewSpine"
@change="onSpinePreviewAnimationChange"
>
<option value="">{{ previewSpine ? '无' : '无可用 Spine 动画' }}</option>
<option value="">{{ previewSpine ? $t('timeline.none') : $t('timeline.noSpineAnimation') }}</option>
<option v-for="animation in previewSpine?.animations || []" :key="animation.name" :value="animation.name">
{{ animation.name }}({{ animation.duration.toFixed(2) }}s)
</option>
@@ -99,17 +99,17 @@
:style="{ left: barLeftPx(sys) + 'px', width: barWidthPx(sys) + 'px' }"
@pointerdown.stop="onBarDown($event, sys)"
>
<span class="lane-name">{{ (sys.config as any).name || '系统 ' + (i + 1) }}</span>
<span class="lane-name">{{ (sys.config as any).name || $t('timeline.systemName', { index: i + 1 }) }}</span>
<span class="bar-life" :class="{ 'loop-life': !isSubEmitter(sys) && isLoopStream(sys) }" :style="{ width: lifeWidthPx(sys) + 'px' }"></span>
<template v-if="!isSubEmitter(sys) && isLoopStream(sys)">
<span v-if="loopStartWidthPx(sys)" class="bar-loop-transition bar-loop-start" :style="{ width: loopStartWidthPx(sys) + 'px' }"></span>
<span class="bar-emission bar-loop" :style="{ left: loopStartWidthPx(sys) + 'px', width: emissionWidthPx(sys) + 'px' }">
<span class="bar-handle" title="拖动循环段时长" @pointerdown.stop="onBarResizeDown($event, sys)"></span>
<span class="bar-handle" :title="$t('timeline.dragLoopDuration')" @pointerdown.stop="onBarResizeDown($event, sys)"></span>
</span>
<span v-if="loopEndWidthPx(sys)" class="bar-loop-transition bar-loop-end" :style="{ left: (loopStartWidthPx(sys) + emissionWidthPx(sys)) + 'px', width: loopEndWidthPx(sys) + 'px' }"></span>
</template>
<span v-else-if="!isSubEmitter(sys) && sys.config.mode === 'stream'" class="bar-emission" :style="{ width: emissionWidthPx(sys) + 'px' }">
<span class="bar-handle" title="拖动持续发射时长" @pointerdown.stop="onBarResizeDown($event, sys)"></span>
<span class="bar-handle" :title="$t('timeline.dragEmissionDuration')" @pointerdown.stop="onBarResizeDown($event, sys)"></span>
</span>
</div>
</div>
@@ -119,13 +119,13 @@
<div
class="spine-bar"
:style="{ left: spineLeftPx(spine) + 'px', width: spineWidthPx(spine) + 'px' }"
title="拖动调整动画开始时间"
:title="$t('timeline.dragSpineStart')"
@pointerdown.stop="onSpineBarDown($event, spine)"
>
<span class="lane-name">{{ spine.name }} · {{ spine.selectedAnimation }}</span>
</div>
</div>
<div v-if="!store.systems.length && !spineLanes.length" class="tl-empty">暂无可显示的动画数据</div>
<div v-if="!store.systems.length && !spineLanes.length" class="tl-empty">{{ $t('timeline.noAnimationData') }}</div>
</div>
</div>
</div>
@@ -133,6 +133,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import { useParticleStore } from '../store/particleStore'
import type { SpineSceneObject } from '../spine/spineTypes'
import { loopSegmentTiming } from '../core/loopSegmentTiming'
@@ -142,6 +143,7 @@ import { subEmitterTriggerTimings } from '../core/subEmitterTiming'
type SysLike = { id: number; config: any }
const store = useParticleStore()
const { t } = useI18n()
const vpRef = ref<HTMLElement | null>(null)
const animationMenuRef = ref<HTMLElement | null>(null)
const animationMenuOpen = ref(false)
@@ -182,16 +184,16 @@ function addAnimation() {
function renameCurrentAnimation() {
const current = activeParticleSystem.value?.animation || store.timeline.animation
const next = animationNameDraft.value.trim()
if (!next) { animationError.value = '动画名称不能为空'; return }
if (next !== current && store.timeline.animations.includes(next)) { animationError.value = '动画名称不能重复'; return }
if (!store.renameAnimation(current, next)) { animationError.value = '无法修改动画名称'; return }
if (!next) { animationError.value = t('timeline.animationNameEmpty'); return }
if (next !== current && store.timeline.animations.includes(next)) { animationError.value = t('timeline.animationNameDuplicate'); return }
if (!store.renameAnimation(current, next)) { animationError.value = t('timeline.animationRenameFailed'); return }
animationNameDraft.value = next
animationError.value = ''
}
function deleteCurrentAnimation() {
const current = activeParticleSystem.value?.animation || store.timeline.animation
if (!store.removeAnimation(current)) { animationError.value = '至少需要保留一个动画'; return }
if (!store.removeAnimation(current)) { animationError.value = t('timeline.keepOneAnimation'); return }
const next = activeParticleSystem.value?.animation || store.timeline.animation
animationNameDraft.value = next
animationError.value = ''
@@ -225,7 +227,7 @@ const totalFrames = computed(() => store.timeline.totalFrames)
watch(totalFrames, (value) => {
if (value > viewFrames.value) viewFrames.value = Math.min(2000, value)
})
const zoomLabel = computed(() => (viewFrames.value <= 30 ? '放大' : viewFrames.value >= 300 ? '缩小' : '适中'))
const zoomLabel = computed(() => (viewFrames.value <= 30 ? t('timeline.zoomIn') : viewFrames.value >= 300 ? t('timeline.zoomOut') : t('timeline.zoomFit')))
const spineLanes = computed(() => store.spines.filter((spine) =>
spine.selectedAnimation && spine.animations.some((animation) => animation.name === spine.selectedAnimation && animation.duration > 0),
))