面板布局更新

This commit is contained in:
tianmo committed 2026-10-24 19:30:32 +08:00
1 parent a417c9119d
commit 7069b168de
18 files changed
+220 -145

No files matched your search

+4 -2
View File
@@ -18,6 +18,7 @@
<nav
v-if="store.settings.floatingMenuEnabled"
class="floating-section-nav"
:class="{ 'nav-left': !store.settings.parameterPanelOnRight }"
role="tablist"
:aria-label="$t('settings.floatingMenu')"
>
@@ -107,13 +108,14 @@ function onTimelineDrag(e: PointerEvent) {
.panel-right .parameter-panel { order: 3; }
.stage-area { position: relative; flex: 1; min-height: 0; overflow: clip; }
.floating-section-nav {
position: absolute; right: 12px; top: 72px; z-index: 6; display: flex;
width: clamp(184px, 15vw, 236px); max-height: calc(100% - 92px);
position: absolute; right: 12px; top: 12px; z-index: 6; display: flex;
width: clamp(184px, 15vw, 236px); max-height: calc(100% - 124px);
flex-direction: column; gap: 5px; overflow-y: auto; box-sizing: border-box; padding: 8px;
border: 1px solid rgba(79, 91, 121, .72); border-radius: 10px;
background: rgba(15, 18, 29, .9); box-shadow: 0 12px 30px rgba(0, 0, 0, .36);
scrollbar-width: thin; scrollbar-color: #3a455e transparent;
}
.floating-section-nav.nav-left { left: 12px; right: auto; }
.floating-section-tab {
display: flex; width: 100%; min-height: 40px; flex: 0 0 auto; align-items: center; gap: 9px;
padding: 0 10px; border: 1px solid #2d3548; border-radius: 8px;
+16 -12
View File
@@ -1,10 +1,18 @@
<template>
<div class="stage-wrap">
<div ref="holder" class="stage-holder" :class="{ grid: store.settings.showGrid }" tabindex="0" @wheel.prevent="onWheelEvent" @pointerdown="onPanDown" @dblclick.prevent="onStageDoubleClick"></div>
<img
class="stage-watermark"
:class="{ 'watermark-left-panel': !store.settings.parameterPanelOnRight }"
src="/images/yeomai-watermark.png"
alt=""
aria-hidden="true"
/>
<StageToolbar
:ctx="stageContext"
:active-tool="activeTool"
:hud="info"
:show-settings="showSettings"
:show-help="showHelp"
@update:active-tool="activeTool = $event"
@@ -18,10 +26,6 @@
<HelpDialog :show="showHelp" @close="showHelp = false" />
<div class="hud" v-if="info">
{{ $t('app.systems') }}: <b class="hud-number hud-system-count">{{ info.systems }}</b> · {{ $t('app.particlesBones') }}: <b class="hud-number hud-particle-count">{{ info.particles }}</b> · {{ $t('app.rootBone') }}: <b>root</b>
<span class="hud-tip">{{ $t('app.hudTip') }}</span>
</div>
</div>
</template>
@@ -584,10 +588,10 @@ watch(() => [store.activeObjectType, store.activeObjectId], () => pathEditing.re
</script>
<style scoped>
.stage-wrap { position: relative; width: 100%; height: 100%; overflow: hidden; }
.stage-wrap { position: relative; display: flex; width: 100%; height: 100%; min-height: 0; flex-direction: column; overflow: hidden; }
/* 画布:默认纯色;开启网格时显示深色棋盘格 */
.stage-holder {
width: 100%; height: 100%; cursor: grab; touch-action: none;
width: 100%; min-height: 0; flex: 1; cursor: grab; touch-action: none;
background-color: #0e1219;
outline: none;
}
@@ -596,10 +600,10 @@ watch(() => [store.activeObjectType, store.activeObjectId], () => pathEditing.re
background-size: 28px 28px;
}
.stage-holder:active { cursor: grabbing; }
.hud { position: absolute; bottom: 12px; left: 12px; padding: 8px 12px; background: rgba(20,20,32,0.75); border-radius: 8px; font-size: 12px; color: #a0a8cc; }
.hud-number { display: inline-block; box-sizing: border-box; text-align: right; font-variant-numeric: tabular-nums; font-feature-settings: 'tnum' 1; }
.hud-system-count { width: 3ch; }
.hud-particle-count { width: 7ch; text-align: left; }
.hud-tip { margin-left: 12px; color: #5f6a8a; font-size: 11px; }
b { color: #ffcc33; }
.stage-watermark {
position: absolute; top: 14px; left: 14px; z-index: 1;
width: clamp(105px, 13.5vw, 150px); height: auto;
opacity: .8; pointer-events: none; user-select: none;
}
.stage-watermark.watermark-left-panel { left: auto; right: 14px; }
</style>
+23 -3
View File
@@ -1,13 +1,32 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { useParticleStore } from '../store/particleStore'
import { setUndoLimit } from '../editor/editorHistory'
import { useI18n } from 'vue-i18n'
defineProps<{ show: boolean }>()
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ (e: 'close'): void }>()
const store = useParticleStore()
const { t } = useI18n()
const panelRef = ref<HTMLElement | null>(null)
watch(
() => props.show,
async (show) => {
if (!show) return
await nextTick()
panelRef.value?.focus()
},
)
function onFocusOut(event: FocusEvent) {
const next = event.relatedTarget as Node | null
if (next && panelRef.value?.contains(next)) return
requestAnimationFrame(() => {
if (props.show && !panelRef.value?.contains(document.activeElement)) emit('close')
})
}
function onUndoLimitChange(event: Event) {
setUndoLimit(store, Number((event.target as HTMLInputElement).value))
@@ -20,7 +39,7 @@ function normalizeBoneDebugSizes() {
</script>
<template>
<div v-if="show" class="settings-panel">
<div v-if="show" ref="panelRef" class="settings-panel" tabindex="-1" @focusout="onFocusOut">
<div class="sp-head">{{ t('settings.title') }} <button class="sp-close" @click="emit('close')">✕</button></div>
<div class="sp-section-title">{{ t('settings.layout') }}</div>
<label class="sp-switch-row">
@@ -105,10 +124,11 @@ function normalizeBoneDebugSizes() {
<style scoped>
.settings-panel {
position: absolute; top: 52px; left: 10px; width: 240px; z-index: 10;
position: absolute; bottom: 92px; left: 10px; width: 240px; z-index: 30;
background: rgba(20,20,32,0.97); border: 1px solid #2a2a3c; border-radius: 10px; padding: 10px 12px;
font-size: 12px; color: #aab; box-shadow: 0 6px 20px rgba(0,0,0,0.4);
}
.settings-panel:focus { outline: none; }
.sp-head { display: flex; justify-content: space-between; align-items: center; font-weight: 700; color: #cdf; margin-bottom: 10px; }
.sp-section-title { margin: 2px 0 8px; color: #8a93bb; font-size: 11px; }
.sp-switch-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: #aab; }
+89 -59
View File
@@ -9,6 +9,7 @@ import { setAppLocale, SUPPORTED_LOCALES, type AppLocale } from '../i18n'
const props = defineProps<{
ctx: StageContext
activeTool: '' | 'translate' | 'rotate' | 'scale'
hud: { systems: number; particles: number } | null
showSettings: boolean
showHelp: boolean
}>()
@@ -67,76 +68,105 @@ function changeLanguage(event: Event) {
</script>
<template>
<div class="topbar">
<button class="settings-btn" @click="toggleSettings" :title="t('toolbar.settings')">{{ t('toolbar.settings') }}</button>
<button class="undo-btn" :disabled="editorHistoryState.undoCount === 0" @click="undo" :title="t('toolbar.undo')">↶</button>
<div class="top-operation-stack">
<div class="topbar">
<button class="settings-btn" @click="toggleSettings" :title="t('toolbar.settings')">{{ t('toolbar.settings') }}</button>
<button class="undo-btn" :disabled="editorHistoryState.undoCount === 0" @click="undo" :title="t('toolbar.undo')">↶</button>
<!-- 根变换工具:拖动(默认) / 位移 / 旋转 / 缩放 -->
<div class="tool-group">
<button class="tool-btn" :class="{ on: activeTool === '' }" @click="toggleTool('')" :title="t('toolbar.pan')">✋</button>
<button class="tool-btn" :class="{ on: activeTool === 'translate' }" @click="toggleTool('translate')" :title="t('toolbar.translate')">✥</button>
<button class="tool-btn" :class="{ on: activeTool === 'rotate' }" @click="toggleTool('rotate')" :title="t('toolbar.rotate')">⟳</button>
<button class="tool-btn" :class="{ on: activeTool === 'scale' }" @click="toggleTool('scale')" :title="t('toolbar.scale')">⤢</button>
</div>
<!-- 根变换工具:拖动(默认) / 位移 / 旋转 / 缩放 -->
<div class="tool-group">
<button class="tool-btn" :class="{ on: activeTool === '' }" @click="toggleTool('')" :title="t('toolbar.pan')">✋</button>
<button class="tool-btn" :class="{ on: activeTool === 'translate' }" @click="toggleTool('translate')" :title="t('toolbar.translate')">✥</button>
<button class="tool-btn" :class="{ on: activeTool === 'rotate' }" @click="toggleTool('rotate')" :title="t('toolbar.rotate')">⟳</button>
<button class="tool-btn" :class="{ on: activeTool === 'scale' }" @click="toggleTool('scale')" :title="t('toolbar.scale')">⤢</button>
</div>
<!-- 工具按钮组(参考画布工具栏) -->
<div class="tool-group">
<button class="tool-btn" @click="zoomIn(ctx)" :title="t('toolbar.zoomIn')">+</button>
<button class="tool-btn" @click="zoomOut(ctx)" :title="t('toolbar.zoomOut')">-</button>
<button class="tool-btn" @click="fitView(ctx)" :title="t('toolbar.fitView')">⤢</button>
<button class="tool-btn" :class="{ on: store.settings.showGrid }" @click="toggleGrid(ctx)" :title="t('toolbar.toggleGrid')">▦</button>
<button class="tool-btn" @click="resetView(ctx)" :title="t('toolbar.resetView')">⟳</button>
</div>
<!-- 工具按钮组(参考画布工具栏) -->
<div class="tool-group">
<button class="tool-btn" @click="zoomIn(ctx)" :title="t('toolbar.zoomIn')">+</button>
<button class="tool-btn" @click="zoomOut(ctx)" :title="t('toolbar.zoomOut')">-</button>
<button class="tool-btn" @click="fitView(ctx)" :title="t('toolbar.fitView')">⤢</button>
<button class="tool-btn" :class="{ on: store.settings.showGrid }" @click="toggleGrid(ctx)" :title="t('toolbar.toggleGrid')">▦</button>
<button class="tool-btn" @click="resetView(ctx)" :title="t('toolbar.resetView')">⟳</button>
</div>
<div class="background-menu" @click.stop>
<button
class="background-btn"
:class="{ on: showBackgroundMenu }"
:title="t('toolbar.backgroundSettings')"
@click="toggleBackgroundMenu"
>{{ t('toolbar.background') }}</button>
<div v-if="showBackgroundMenu" class="background-panel">
<button class="background-upload" @click="backgroundFileInput?.click()">
{{ backgroundFileName ? t('toolbar.changeBackground') : t('toolbar.uploadBackground') }}
</button>
<button class="background-clear" :disabled="!backgroundFileName" @click="clearBackground()">{{ t('toolbar.clearBackground') }}</button>
<input
ref="backgroundFileInput"
class="background-file-input"
type="file"
accept="image/png,image/jpeg,image/webp"
@change="onBackgroundFileChange"
/>
<div class="background-file-name" :title="backgroundFileName">{{ backgroundFileName || t('toolbar.noBackground') }}</div>
<div class="background-position-row">
<label><span>X</span><input v-model.number="store.settings.backgroundX" type="number" step="1" :aria-label="t('toolbar.backgroundX')" /></label>
<label><span>Y</span><input v-model.number="store.settings.backgroundY" type="number" step="1" :aria-label="t('toolbar.backgroundY')" /></label>
<div class="background-menu" @click.stop>
<button
class="background-btn"
:class="{ on: showBackgroundMenu }"
:title="t('toolbar.backgroundSettings')"
@click="toggleBackgroundMenu"
>{{ t('toolbar.background') }}</button>
<div v-if="showBackgroundMenu" class="background-panel">
<button class="background-upload" @click="backgroundFileInput?.click()">
{{ backgroundFileName ? t('toolbar.changeBackground') : t('toolbar.uploadBackground') }}
</button>
<button class="background-clear" :disabled="!backgroundFileName" @click="clearBackground()">{{ t('toolbar.clearBackground') }}</button>
<input
ref="backgroundFileInput"
class="background-file-input"
type="file"
accept="image/png,image/jpeg,image/webp"
@change="onBackgroundFileChange"
/>
<div class="background-file-name" :title="backgroundFileName">{{ backgroundFileName || t('toolbar.noBackground') }}</div>
<div class="background-position-row">
<label><span>X</span><input v-model.number="store.settings.backgroundX" type="number" step="1" :aria-label="t('toolbar.backgroundX')" /></label>
<label><span>Y</span><input v-model.number="store.settings.backgroundY" type="number" step="1" :aria-label="t('toolbar.backgroundY')" /></label>
</div>
<label class="background-opacity-row">
<span>{{ t('toolbar.opacity') }}</span>
<input v-model.number="store.settings.backgroundOpacity" type="range" min="0" max="1" step="0.01" :aria-label="t('toolbar.backgroundOpacity')" />
<b>{{ Math.round(store.settings.backgroundOpacity * 100) }}%</b>
</label>
</div>
<label class="background-opacity-row">
<span>{{ t('toolbar.opacity') }}</span>
<input v-model.number="store.settings.backgroundOpacity" type="range" min="0" max="1" step="0.01" :aria-label="t('toolbar.backgroundOpacity')" />
<b>{{ Math.round(store.settings.backgroundOpacity * 100) }}%</b>
</label>
</div>
<label class="language-picker">
<span>{{ t('language.label') }}</span>
<select :value="locale" :aria-label="t('language.label')" @change="changeLanguage">
<option v-for="entry in SUPPORTED_LOCALES" :key="entry.code" :value="entry.code">{{ entry.label }}</option>
</select>
</label>
<button class="help-btn" :class="{ on: showHelp }" :title="t('toolbar.openHelp')" @click="openHelp">{{ t('toolbar.help') }}</button>
<span class="zoom-pct">{{ Math.round(store.editor.viewScale * 100) }}%</span>
</div>
<div class="timeline-controls-row">
<div id="timeline-controls-host" class="timeline-controls-host"></div>
<div v-if="hud" class="stage-hud">
{{ t('app.systems') }}: <b class="hud-number hud-system-count">{{ hud.systems }}</b> · {{ t('app.particlesBones') }}: <b class="hud-number hud-particle-count">{{ hud.particles }}</b> · {{ t('app.rootBone') }}: <b>root</b>
<span class="hud-tip">{{ t('app.hudTip') }}</span>
</div>
</div>
<label class="language-picker">
<span>{{ t('language.label') }}</span>
<select :value="locale" :aria-label="t('language.label')" @change="changeLanguage">
<option v-for="entry in SUPPORTED_LOCALES" :key="entry.code" :value="entry.code">{{ entry.label }}</option>
</select>
</label>
<button class="help-btn" :class="{ on: showHelp }" :title="t('toolbar.openHelp')" @click="openHelp">{{ t('toolbar.help') }}</button>
<span class="zoom-pct">{{ Math.round(store.editor.viewScale * 100) }}%</span>
</div>
</template>
<style scoped>
.topbar {
position: absolute; top: 10px; left: 10px; right: 10px; display: flex; align-items: center; gap: 10px;
padding: 8px 12px; background: rgba(20,20,32,0.85); border-radius: 10px; border: 1px solid rgba(255,255,255,0.08);
font-size: 12px; color: #aab; z-index: 5; flex-wrap: wrap;
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
font-size: 12px; color: #aab;
}
.top-operation-stack {
position: relative; z-index: 20; flex: 0 0 auto;
display: flex; flex-direction: column; gap: 6px; padding: 8px 12px;
border-top: 1px solid #2a2a3c; background: #12121c;
}
.timeline-controls-host {
min-width: 0;
}
.timeline-controls-row {
position: relative; min-width: 0; padding-top: 6px; border-top: 1px solid #2a2a3c;
}
.stage-hud {
position: absolute; right: 42px; top: 6px; bottom: 0;
display: flex; align-items: center; color: #8390ab; font-size: 10px; line-height: 1;
white-space: nowrap; pointer-events: none;
}
.hud-number { display: inline-block; box-sizing: border-box; text-align: right; font-variant-numeric: tabular-nums; font-feature-settings: 'tnum' 1; }
.hud-system-count { width: 3ch; }
.hud-particle-count { width: 7ch; text-align: left; }
.hud-tip { margin-left: 10px; color: #59657e; font-size: 10px; }
b { color: #ffcc33; }
.topbar input[type=range] { background: #1a1a28; border: 1px solid #2e2e44; color: #dde; border-radius: 5px; }
/* 工具按钮组 */
.tool-group { display: flex; gap: 6px; margin-left: 6px; padding-left: 12px; border-left: 1px solid #2a2a3c; }
@@ -153,7 +183,7 @@ function changeLanguage(event: Event) {
}
.background-btn:hover, .background-btn.on { border-color: #7774ff; background: #343b63; color: #fff; }
.background-panel {
position: absolute; top: calc(100% + 9px); right: 0; width: 248px; box-sizing: border-box; padding: 10px;
position: absolute; right: 0; bottom: calc(100% + 9px); width: 248px; box-sizing: border-box; padding: 10px;
border: 1px solid #343b50; border-radius: 9px; background: rgba(20,20,32,.98); color: #aeb8cc;
box-shadow: 0 8px 24px rgba(0,0,0,.48); z-index: 12;
}
+21 -4
View File
@@ -1,6 +1,7 @@
<template>
<div class="timeline">
<!-- 控制按钮 -->
<Teleport defer to="#timeline-controls-host">
<div class="tl-controls">
<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 ? '❚❚' : '▶' }}
@@ -65,6 +66,7 @@
</label>
<span class="tl-zoom">{{ zoomLabel }}</span>
</div>
</Teleport>
<!-- 时间轴视口 -->
<div
@@ -145,6 +147,8 @@ type SysLike = { id: number; config: any }
const store = useParticleStore()
const { t } = useI18n()
const vpRef = ref<HTMLElement | null>(null)
const viewportWidth = ref(0)
let viewportResizeObserver: ResizeObserver | null = null
const animationMenuRef = ref<HTMLElement | null>(null)
const animationMenuOpen = ref(false)
const animationNameDraft = ref('animation')
@@ -203,8 +207,21 @@ function onDocumentPointerDown(event: PointerEvent) {
if (!animationMenuRef.value?.contains(event.target as Node)) animationMenuOpen.value = false
}
onMounted(() => document.addEventListener('pointerdown', onDocumentPointerDown))
onBeforeUnmount(() => document.removeEventListener('pointerdown', onDocumentPointerDown))
onMounted(() => {
document.addEventListener('pointerdown', onDocumentPointerDown)
const viewport = vpRef.value
if (!viewport) return
viewportWidth.value = viewport.clientWidth
viewportResizeObserver = new ResizeObserver(([entry]) => {
viewportWidth.value = Math.max(1, entry.contentRect.width)
})
viewportResizeObserver.observe(viewport)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocumentPointerDown)
viewportResizeObserver?.disconnect()
viewportResizeObserver = null
})
// 时间轴与固定步长模拟统一读取 Store;默认 1 秒 = 30 帧,与 Spine 对齐。
const fps = computed(() => Math.max(1, store.timeline.fps || 30))
@@ -258,7 +275,7 @@ function commitPlaybackEnd() {
// 每像素代表的帧数(视口宽度内放下 viewFrames 帧)
function pxPerF() {
const vw = vpRef.value?.clientWidth || 600
const vw = viewportWidth.value || vpRef.value?.clientWidth || 600
return viewFrames.value / vw
}
@@ -494,7 +511,7 @@ function onSpineBarDown(e: PointerEvent, spine: SpineSceneObject) {
<style scoped>
.timeline { height: 100%; background: #12121c; border-top: 1px solid #2a2a3c; display: flex; flex-direction: column; padding: 6px 10px; gap: 4px; }
.tl-controls { display: flex; align-items: center; gap: 8px; }
.tl-controls { display: flex; width: 100%; align-items: center; gap: 8px; flex-wrap: wrap; }
.tl-btn { width: 30px; height: 26px; background: #1e1e2e; border: 1px solid #2e2e44; border-radius: 6px; color: #aab; font-size: 12px; cursor: pointer; }
.tl-btn.on { background: #3a5a8c; color: #fff; border-color: #5a7ab8; }
.tl-ab-loop { position: relative; }