Files
u-ppt/src/components/editor/Toolbar.vue
T

225 lines
9.7 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.
<!-- =====================================================================
Toolbar.vue 顶部工具栏
信息架构分层高频创作动作直接暴露文件域/存取/导出收进文件菜单
===================================================================== -->
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { store } from '../../core/store'
import { themes } from '../../core/sample'
import { appConfirm } from '../../core/dialog'
const emit = defineEmits<{
(e: 'present'): void
(e: 'open-library'): void
(e: 'open-settings'): void
(e: 'open-oss'): void
(e: 'save'): void
(e: 'open-templates'): void
(e: 'export-json'): void
(e: 'import-json'): void
(e: 'import-materials'): void
(e: 'export-pdf'): void
(e: 'home'): void
(e: 'share'): void
}>()
const themeKeys = Object.keys(themes)
const currentTheme = ref(store.getTheme())
// store 主题变化时同步下拉
watch(() => store.theme.value, (v) => { currentTheme.value = v })
function onThemeChange() {
store.setTheme(currentTheme.value)
}
async function action(a: string) {
switch (a) {
case 'add-slide': store.addSlide(store.getCurrentIndex()); break
case 'dup-slide': store.dupSlide(); break
case 'del-slide':
if (await appConfirm('删除这页幻灯片?', '第 ' + (store.getCurrentIndex() + 1) + ' 页,可用 Ctrl+Z 撤销', { danger: true, okText: '删除' })) {
store.delSlide()
}
break
case 'reset':
if (await appConfirm('重置为内置示例?', '当前编辑内容将丢失(可用 Ctrl+Z 撤销)', { danger: true })) {
store.reset()
}
break
case 'present': emit('present'); break
case 'library': emit('open-library'); break
case 'save': emit('save'); break
case 'settings': emit('open-settings'); break
case 'oss': emit('open-oss'); break
}
}
/* ---------- 文件菜单(低频动作收纳) ---------- */
const fileMenuOpen = ref(false)
const fileMenuBtn = ref<HTMLElement>()
function toggleFileMenu() {
fileMenuOpen.value = !fileMenuOpen.value
}
function fileAction(a: string) {
fileMenuOpen.value = false
switch (a) {
case 'library': emit('open-library'); break
case 'save': emit('save'); break
case 'export-json': emit('export-json'); break
case 'import-materials': emit('import-materials'); break
case 'import-json': emit('import-json'); break
case 'export-pdf': emit('export-pdf'); break
case 'share': emit('share'); break
case 'reset': action('reset'); break
}
}
function onDocClick(e: MouseEvent) {
if (fileMenuOpen.value && fileMenuBtn.value && !fileMenuBtn.value.contains(e.target as Node)) {
fileMenuOpen.value = false
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && fileMenuOpen.value) fileMenuOpen.value = false
}
onMounted(() => {
document.addEventListener('mousedown', onDocClick)
document.addEventListener('keydown', onKeydown)
})
onUnmounted(() => {
document.removeEventListener('mousedown', onDocClick)
document.removeEventListener('keydown', onKeydown)
})
defineProps<{ disabledActions?: string[] }>()
</script>
<template>
<header class="toolbar">
<button class="brand" title="返回首页" @click="emit('home')">
<span class="logo"></span>
<span class="name">u-ppt</span>
<span class="sub">在线演示工具</span>
</button>
<div class="tools">
<!-- 高频页面操作 -->
<button class="btn tool-add" data-action="add-slide" title="新建幻灯片" :disabled="disabledActions?.includes('add-slide')" @click="action('add-slide')"> 幻灯片</button>
<button class="btn" data-action="templates" title="从模板新建页" @click="emit('open-templates')">📋 模板</button>
<span class="sep"></span>
<button class="btn icon-only" data-action="dup-slide" title="复制当前页" :disabled="disabledActions?.includes('dup-slide')" @click="action('dup-slide')"></button>
<button class="btn icon-only danger-hover" data-action="del-slide" title="删除当前页" :disabled="disabledActions?.includes('del-slide')" @click="action('del-slide')">🗑</button>
<span class="sep"></span>
<!-- 主题 -->
<label class="theme-select">
主题
<select v-model="currentTheme" @change="onThemeChange">
<option v-for="k in themeKeys" :key="k" :value="k">{{ themes[k].name }}</option>
</select>
</label>
<span class="sep"></span>
<!-- 低频文件域收进菜单 -->
<div class="file-menu" ref="fileMenuBtn">
<button class="btn" data-action="file-menu" :class="{ active: fileMenuOpen }" @click="toggleFileMenu">
🗀 文件 <span class="chevron" :class="{ open: fileMenuOpen }"></span>
</button>
<Transition name="menu-pop">
<div v-if="fileMenuOpen" class="file-dropdown" role="menu">
<button class="menu-item" role="menuitem" @click="fileAction('library')">
<span class="mi-icon">📁</span><span class="mi-label">文库</span><span class="mi-hint">我的演示</span>
</button>
<button class="menu-item" role="menuitem" :disabled="disabledActions?.includes('save')" @click="fileAction('save')">
<span class="mi-icon">💾</span><span class="mi-label">保存到文库</span><span class="mi-hint">已自动暂存</span>
</button>
<div class="menu-sep"></div>
<button class="menu-item" role="menuitem" @click="fileAction('import-materials')">
<span class="mi-icon">📂</span><span class="mi-label">导入资料</span><span class="mi-hint">图片 / 文档 / 目录</span>
</button>
<button class="menu-item" role="menuitem" @click="fileAction('import-json')">
<span class="mi-icon">📥</span><span class="mi-label">导入 JSON</span>
</button>
<button class="menu-item" role="menuitem" @click="fileAction('export-json')">
<span class="mi-icon">📤</span><span class="mi-label">导出 JSON</span>
</button>
<button class="menu-item" role="menuitem" @click="fileAction('export-pdf')">
<span class="mi-icon">🖨</span><span class="mi-label">导出 PDF</span><span class="mi-hint">打印 / 另存</span>
</button>
<button class="menu-item" role="menuitem" @click="fileAction('share')">
<span class="mi-icon">🔗</span><span class="mi-label">生成分享链接</span><span class="mi-hint">公开只读 · 云端</span>
</button>
<div class="menu-sep"></div>
<button class="menu-item danger" role="menuitem" :disabled="disabledActions?.includes('reset')" @click="fileAction('reset')">
<span class="mi-icon"></span><span class="mi-label">重置为示例</span>
</button>
</div>
</Transition>
</div>
<!-- 终端动作 -->
<button class="btn primary" data-action="present" title="开始演示 (F5)" :disabled="disabledActions?.includes('present')" @click="action('present')"> 演示</button>
<button class="btn ghost icon-only" data-action="oss" title="云存储 OSS 设置" @click="action('oss')"></button>
<button class="btn ghost icon-only" data-action="settings" title="AI 设置" @click="action('settings')"></button>
</div>
</header>
</template>
<style scoped>
/* 品牌区按钮化:去掉 button 默认外观,保留原有视觉,hover 轻反馈 */
.brand { border: none; background: transparent; padding: 0; font: inherit; text-align: left; cursor: pointer; }
.brand:hover { opacity: .8; }
/* 新建幻灯片:创作起点,给主色轻底强调 */
.tool-add { background: var(--ui-primary-soft); border-color: transparent; color: var(--ui-primary); font-weight: 500; }
.tool-add:hover { background: #e0e7ff; }
.tool-add:disabled { opacity: .5; }
/* 纯图标按钮:删除/复制降为图标,减文字噪音 */
.btn.icon-only { width: 36px; padding: 0; justify-content: center; font-size: 16px; }
.btn.danger-hover:hover { color: var(--ui-danger); border-color: var(--ui-danger); background: rgba(225, 29, 72, .05); }
/* ---------- 文件菜单 ---------- */
.file-menu { position: relative; display: inline-flex; }
.file-menu .btn.active { background: var(--ui-hover); border-color: var(--ui-primary); }
.chevron { font-size: 16px; margin-left: 2px; opacity: .6; transition: transform .15s; }
.chevron.open { transform: rotate(180deg); }
.file-dropdown {
position: absolute; top: calc(100% + 6px); left: 0;
width: max-content; min-width: 216px;
background: var(--ui-panel);
border: 1px solid var(--ui-border);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
padding: 5px;
z-index: 100;
}
.menu-item {
display: flex; align-items: center; gap: 10px;
width: 100%; padding: 7px 10px;
border: none; background: transparent;
border-radius: var(--radius-sm);
font-size: 13px; color: var(--ui-text);
text-align: left; cursor: pointer;
white-space: nowrap;
}
.menu-item:hover { background: var(--ui-hover); }
.menu-item:disabled { opacity: .45; cursor: not-allowed; background: transparent; }
.menu-item .mi-icon { width: 18px; text-align: center; flex-shrink: 0; }
.menu-item .mi-label { flex-shrink: 0; }
.menu-item .mi-hint { margin-left: auto; font-size: 11px; color: var(--ui-muted); padding-left: 14px; }
.menu-item.danger { color: var(--ui-danger); }
.menu-item.danger:hover { background: rgba(225, 29, 72, .06); }
.menu-sep { height: 1px; background: var(--ui-border); margin: 5px 8px; }
/* 弹出动画 */
.menu-pop-enter-active { transition: opacity .14s, transform .14s; }
.menu-pop-leave-active { transition: opacity .1s, transform .1s; }
.menu-pop-enter-from, .menu-pop-leave-to { opacity: 0; transform: translateY(-4px); }
</style>