283 lines
8.5 KiB
Vue
283 lines
8.5 KiB
Vue
<!-- =====================================================================
|
||
App.vue — 根组件:编辑/演示模式切换、工具栏、三栏布局、弹窗
|
||
===================================================================== -->
|
||
<script setup lang="ts">
|
||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||
import { store } from './core/store'
|
||
import Toolbar from './components/editor/Toolbar.vue'
|
||
import ThumbBar from './components/editor/ThumbBar.vue'
|
||
import Canvas from './components/editor/Canvas.vue'
|
||
import PropsPanel from './components/editor/PropsPanel.vue'
|
||
import AiPanel from './components/ai/AiPanel.vue'
|
||
import SettingsModal from './components/modals/SettingsModal.vue'
|
||
import LibraryModal from './components/modals/LibraryModal.vue'
|
||
import TemplateModal from './components/modals/TemplateModal.vue'
|
||
import ImportModal from './components/modals/ImportModal.vue'
|
||
import PrintModal from './components/modals/PrintModal.vue'
|
||
import PresentMode from './components/present/PresentMode.vue'
|
||
import HomePage from './components/HomePage.vue'
|
||
|
||
/* ---------- 模式 ---------- */
|
||
const mode = ref<'home' | 'editor' | 'present'>('home')
|
||
const presentVisible = ref(false)
|
||
const presentStartIndex = ref(0)
|
||
|
||
/* ---------- 活动面板 tab ---------- */
|
||
const activeTab = ref<'props' | 'ai'>('props')
|
||
function switchTab(name: 'props' | 'ai') {
|
||
activeTab.value = name
|
||
}
|
||
|
||
/* ---------- 弹窗 ---------- */
|
||
const settingsVisible = ref(false)
|
||
const libraryVisible = ref(false)
|
||
const templateVisible = ref(false)
|
||
const importVisible = ref(false)
|
||
const printVisible = ref(false)
|
||
const deckLoaded = ref(false) // 文库/导入是否加载了新 deck,防止误切
|
||
|
||
/* ---------- toast ---------- */
|
||
const toastText = ref('')
|
||
const toastShow = ref(false)
|
||
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
||
function toast(msg: string) {
|
||
toastText.value = msg
|
||
toastShow.value = true
|
||
if (toastTimer) clearTimeout(toastTimer)
|
||
toastTimer = setTimeout(() => { toastShow.value = false }, 2200)
|
||
}
|
||
|
||
/* ---------- AI busy 状态(禁用部分按钮) ---------- */
|
||
const aiBusy = ref(false)
|
||
const disabledActions = computed(() => aiBusy.value
|
||
? ['present', 'library', 'save', 'reset', 'add-slide', 'dup-slide', 'del-slide']
|
||
: []
|
||
)
|
||
function onBusyChange(busy: boolean) { aiBusy.value = busy }
|
||
|
||
/* ---------- 工具栏动作 ---------- */
|
||
function onPresent() {
|
||
presentStartIndex.value = store.currentIndex.value
|
||
presentVisible.value = true
|
||
mode.value = 'present'
|
||
document.body.dataset.mode = 'present'
|
||
}
|
||
function onPresentExit() {
|
||
presentVisible.value = false
|
||
mode.value = 'editor'
|
||
document.body.dataset.mode = 'editor'
|
||
}
|
||
function onSave() {
|
||
if (store.getActiveLibId()) {
|
||
store.saveToLibrary()
|
||
toast('已更新到文库')
|
||
} else {
|
||
libraryVisible.value = true
|
||
}
|
||
}
|
||
function openAi() { switchTab('ai') }
|
||
|
||
/* ---------- 导出/导入 ---------- */
|
||
function onExportJson() {
|
||
const json = store.exportJSON()
|
||
const blob = new Blob([json], { type: 'application/json' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = 'u-ppt-export.json'
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
toast('已导出 JSON')
|
||
}
|
||
|
||
function onImportJson() {
|
||
const input = document.createElement('input')
|
||
input.type = 'file'
|
||
input.accept = '.json,application/json'
|
||
input.onchange = () => {
|
||
const file = input.files?.[0]
|
||
if (!file) return
|
||
const reader = new FileReader()
|
||
reader.onload = () => {
|
||
try {
|
||
store.importJSON(reader.result as string)
|
||
toast('已导入')
|
||
if (mode.value === 'home') mode.value = 'editor'
|
||
} catch (e: any) {
|
||
toast('导入失败:' + (e?.message || String(e)))
|
||
}
|
||
}
|
||
reader.readAsText(file)
|
||
}
|
||
input.click()
|
||
}
|
||
|
||
function onNewBlank() {
|
||
store.newBlankDeck()
|
||
deckLoaded.value = true
|
||
mode.value = 'editor'
|
||
}
|
||
|
||
function onImportMaterials() { importVisible.value = true }
|
||
|
||
function onImportClose() {
|
||
importVisible.value = false
|
||
if (mode.value === 'home') {
|
||
const slides = store.getSlides()
|
||
// 不是仅有 1 页空白页时视为已导入内容
|
||
if (slides.length > 1 || slides[0]?.elements?.length > 0) {
|
||
mode.value = 'editor'
|
||
}
|
||
}
|
||
}
|
||
|
||
function onExportPdf() {
|
||
printVisible.value = true
|
||
}
|
||
|
||
/* ---------- 全局快捷键(编辑模式) ---------- */
|
||
function onKey(e: KeyboardEvent) {
|
||
if (document.body.dataset.mode === 'present') return
|
||
const inField = /^(INPUT|TEXTAREA|SELECT)$/.test((e.target as HTMLElement).tagName) || (e.target as HTMLElement).isContentEditable
|
||
|
||
if (e.key === 'Escape') {
|
||
if (settingsVisible.value) { settingsVisible.value = false; return }
|
||
if (libraryVisible.value) { libraryVisible.value = false; return }
|
||
}
|
||
|
||
if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) {
|
||
if (inField) return
|
||
e.preventDefault()
|
||
if (e.shiftKey ? store.redo() : store.undo()) toast(e.shiftKey ? '已重做' : '已撤销')
|
||
return
|
||
}
|
||
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || e.key === 'Y')) {
|
||
if (inField) return; e.preventDefault(); if (store.redo()) toast('已重做'); return
|
||
}
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||
e.preventDefault(); if (!inField) toast('已自动保存到本地'); return
|
||
}
|
||
|
||
if (e.key === 'Delete' && !inField) {
|
||
const id = store.getSelectedId()
|
||
if (id) { e.preventDefault(); store.delElement(id) }
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
document.addEventListener('keydown', onKey)
|
||
})
|
||
|
||
/* 持久化失败告警 → toast(防止静默丢数据) */
|
||
watch(() => store.saveWarning.text, (txt) => {
|
||
if (txt) toast(txt)
|
||
})
|
||
|
||
/* 文库关闭时若在首页且从文库加载了 deck 则切入编辑 */
|
||
watch(libraryVisible, (now, prev) => {
|
||
if (prev && !now && deckLoaded.value) {
|
||
deckLoaded.value = false
|
||
if (mode.value === 'home') mode.value = 'editor'
|
||
}
|
||
})
|
||
onUnmounted(() => {
|
||
document.removeEventListener('keydown', onKey)
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<!-- ===================== 首页 ===================== -->
|
||
<HomePage
|
||
v-show="mode === 'home'"
|
||
@new-blank="onNewBlank"
|
||
@open-library="libraryVisible = true"
|
||
@import-materials="importVisible = true"
|
||
@open-deck="deckLoaded = true; mode = 'editor'"
|
||
@ai-create="onNewBlank(); switchTab('ai')"
|
||
@toast="toast"
|
||
@open-settings="settingsVisible = true"
|
||
/>
|
||
|
||
<!-- ===================== 编辑模式 ===================== -->
|
||
<div v-show="mode === 'editor'" class="app-editor">
|
||
<Toolbar
|
||
:disabled-actions="disabledActions"
|
||
@present="onPresent"
|
||
@open-library="libraryVisible = true"
|
||
@open-settings="settingsVisible = true"
|
||
@open-ai="openAi"
|
||
@save="onSave"
|
||
@open-templates="templateVisible = true"
|
||
@export-json="onExportJson"
|
||
@import-materials="onImportMaterials"
|
||
@import-json="onImportJson"
|
||
@export-pdf="onExportPdf"
|
||
/>
|
||
|
||
<div class="editor-body">
|
||
<!-- 左:缩略图 -->
|
||
<ThumbBar />
|
||
|
||
<!-- 中:画布 -->
|
||
<Canvas />
|
||
|
||
<!-- 右:Tabs(属性 / AI) -->
|
||
<aside class="side-panel">
|
||
<div class="panel-tabs">
|
||
<button class="panel-tab" :class="{ active: activeTab === 'props' }" @click="switchTab('props')">🎨 属性</button>
|
||
<button class="panel-tab" :class="{ active: activeTab === 'ai' }" @click="switchTab('ai')">🤖 AI 助手</button>
|
||
</div>
|
||
|
||
<PropsPanel v-show="activeTab === 'props'" />
|
||
|
||
<AiPanel
|
||
v-show="activeTab === 'ai'"
|
||
@busy-change="onBusyChange"
|
||
@toast="toast"
|
||
@open-settings="settingsVisible = true"
|
||
@switch-tab="(t: string) => switchTab(t as 'props' | 'ai')"
|
||
/>
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===================== 演示模式 ===================== -->
|
||
<PresentMode
|
||
:visible="presentVisible"
|
||
:start-index="presentStartIndex"
|
||
@exit="onPresentExit"
|
||
/>
|
||
|
||
<!-- ===================== 弹窗 ===================== -->
|
||
<SettingsModal
|
||
:visible="settingsVisible"
|
||
@close="settingsVisible = false"
|
||
@toast="toast"
|
||
/>
|
||
<LibraryModal
|
||
:visible="libraryVisible"
|
||
@close="libraryVisible = false"
|
||
@toast="toast"
|
||
@switch-tab="(t: string) => switchTab(t as 'props' | 'ai')"
|
||
@open-deck="mode = 'editor'"
|
||
/>
|
||
<TemplateModal
|
||
:visible="templateVisible"
|
||
@close="templateVisible = false"
|
||
@toast="toast"
|
||
/>
|
||
<ImportModal
|
||
:visible="importVisible"
|
||
@close="onImportClose"
|
||
@toast="toast"
|
||
@open-settings="settingsVisible = true"
|
||
/>
|
||
<PrintModal
|
||
v-if="printVisible"
|
||
@close="printVisible = false"
|
||
/>
|
||
|
||
<!-- 轻提示 -->
|
||
<div class="toast" :class="{ show: toastShow }">{{ toastText }}</div>
|
||
</template>
|