修复: 演示元素消失+画布错位+PDF横版导出+工具栏信息架构重构
This commit is contained in:
@@ -9,17 +9,30 @@ import { useEditor } from '../../composables/useEditor'
|
|||||||
import ElementView from './ElementView.vue'
|
import ElementView from './ElementView.vue'
|
||||||
|
|
||||||
const canvasRef = ref<HTMLElement>()
|
const canvasRef = ref<HTMLElement>()
|
||||||
const canvasFrameRef = ref<HTMLElement>()
|
const canvasFrameRef = ref<HTMLElement>() // 注意:绑定 .canvas-frame(不含 stage 的 padding),scale 以它为基准
|
||||||
|
|
||||||
const {
|
const {
|
||||||
drag, editing,
|
drag, marquee, editing,
|
||||||
onCanvasMouseDown, onMouseMove, onMouseUp, onContentEditableFocus
|
onCanvasMouseDown, onMouseMove, onMouseUp, onContentEditableFocus
|
||||||
} = useEditor()
|
} = useEditor()
|
||||||
|
|
||||||
const slide = computed(() => store.currentSlide.value)
|
const slide = computed(() => store.currentSlide.value)
|
||||||
const selectedId = computed(() => store.selectedId.value)
|
const selectedId = computed(() => store.selectedId.value)
|
||||||
|
const selectedIds = computed(() => store.selectedIds.value || [])
|
||||||
const HANDLES = ['tl', 'tm', 'tr', 'lm', 'rm', 'bl', 'bm', 'br']
|
const HANDLES = ['tl', 'tm', 'tr', 'lm', 'rm', 'bl', 'bm', 'br']
|
||||||
|
|
||||||
|
/** 框选矩形样式(% → CSS) */
|
||||||
|
const marqueeBox = computed(() => {
|
||||||
|
const m = marquee.value
|
||||||
|
if (!m) return null
|
||||||
|
return {
|
||||||
|
left: Math.min(m.x0, m.x1) + '%',
|
||||||
|
top: Math.min(m.y0, m.y1) + '%',
|
||||||
|
width: Math.abs(m.x1 - m.x0) + '%',
|
||||||
|
height: Math.abs(m.y1 - m.y0) + '%'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function fitCanvas() {
|
function fitCanvas() {
|
||||||
if (!canvasFrameRef.value || !canvasRef.value) return
|
if (!canvasFrameRef.value || !canvasRef.value) return
|
||||||
const rect = canvasFrameRef.value.getBoundingClientRect()
|
const rect = canvasFrameRef.value.getBoundingClientRect()
|
||||||
@@ -53,24 +66,42 @@ function liveBox(elId: string) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 多选组拖拽中各元素的实时位置 */
|
||||||
|
function liveMultiBox(elId: string) {
|
||||||
|
const d = drag.value
|
||||||
|
if (d && d.mode === 'multi-move' && d.multiResult) {
|
||||||
|
const r = d.multiResult.find(t => t.id === elId)
|
||||||
|
if (r) return { left: r.x + '%', top: r.y + '%' }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const onResize = () => fitCanvas()
|
const onResize = () => fitCanvas()
|
||||||
|
|
||||||
|
/** 监听 frame 尺寸:v-show 隐藏→显示、窗口缩放、布局变化都会触发(隐藏时 rect=0,onMounted 的 rAF 会空跑) */
|
||||||
|
let frameObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener('resize', onResize)
|
window.addEventListener('resize', onResize)
|
||||||
document.addEventListener('mousemove', onMouseMove)
|
document.addEventListener('mousemove', onMouseMove)
|
||||||
document.addEventListener('mouseup', onMouseUp)
|
document.addEventListener('mouseup', onMouseUp)
|
||||||
requestAnimationFrame(fitCanvas)
|
requestAnimationFrame(fitCanvas)
|
||||||
|
if (typeof ResizeObserver !== 'undefined' && canvasFrameRef.value) {
|
||||||
|
frameObserver = new ResizeObserver(() => fitCanvas())
|
||||||
|
frameObserver.observe(canvasFrameRef.value)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('resize', onResize)
|
window.removeEventListener('resize', onResize)
|
||||||
document.removeEventListener('mousemove', onMouseMove)
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
document.removeEventListener('mouseup', onMouseUp)
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
|
if (frameObserver) { frameObserver.disconnect(); frameObserver = null }
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="canvas-stage" ref="canvasFrameRef">
|
<main class="canvas-stage">
|
||||||
<div class="canvas-frame">
|
<div class="canvas-frame" ref="canvasFrameRef">
|
||||||
<div
|
<div
|
||||||
class="canvas"
|
class="canvas"
|
||||||
ref="canvasRef"
|
ref="canvasRef"
|
||||||
@@ -91,11 +122,18 @@ onUnmounted(() => {
|
|||||||
:el="el"
|
:el="el"
|
||||||
:bg="slide.background"
|
:bg="slide.background"
|
||||||
edit
|
edit
|
||||||
:show-handles="el.id === selectedId"
|
:show-handles="el.id === selectedId && selectedIds.length <= 1"
|
||||||
:class="{ selected: el.id === selectedId, dragging: drag?.id === el.id && !!drag?.result }"
|
:class="{
|
||||||
:style="liveBox(el.id) || {}"
|
selected: el.id === selectedId,
|
||||||
|
'multi-selected': selectedIds.includes(el.id),
|
||||||
|
dragging: drag?.id === el.id && !!drag?.result
|
||||||
|
}"
|
||||||
|
:style="liveBox(el.id) || liveMultiBox(el.id) || {}"
|
||||||
@blur="onElementBlur"
|
@blur="onElementBlur"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 框选矩形 -->
|
||||||
|
<div v-if="marqueeBox" class="marquee-box" :style="marqueeBox"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="canvas-status">
|
<div class="canvas-status">
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<!-- =====================================================================
|
<!-- =====================================================================
|
||||||
Toolbar.vue — 顶部工具栏
|
Toolbar.vue — 顶部工具栏
|
||||||
|
信息架构分层:高频创作动作直接暴露;文件域(库/存取/导出)收进「文件」菜单
|
||||||
===================================================================== -->
|
===================================================================== -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import { store } from '../../core/store'
|
import { store } from '../../core/store'
|
||||||
import { themes } from '../../core/sample'
|
import { themes } from '../../core/sample'
|
||||||
|
|
||||||
@@ -10,7 +11,6 @@ const emit = defineEmits<{
|
|||||||
(e: 'present'): void
|
(e: 'present'): void
|
||||||
(e: 'open-library'): void
|
(e: 'open-library'): void
|
||||||
(e: 'open-settings'): void
|
(e: 'open-settings'): void
|
||||||
(e: 'open-ai'): void
|
|
||||||
(e: 'save'): void
|
(e: 'save'): void
|
||||||
(e: 'open-templates'): void
|
(e: 'open-templates'): void
|
||||||
(e: 'export-json'): void
|
(e: 'export-json'): void
|
||||||
@@ -44,11 +44,49 @@ function action(a: string) {
|
|||||||
case 'present': emit('present'); break
|
case 'present': emit('present'); break
|
||||||
case 'library': emit('open-library'); break
|
case 'library': emit('open-library'); break
|
||||||
case 'save': emit('save'); break
|
case 'save': emit('save'); break
|
||||||
case 'open-ai': emit('open-ai'); break
|
|
||||||
case 'settings': emit('open-settings'); break
|
case 'settings': emit('open-settings'); 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 '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[] }>()
|
defineProps<{ disabledActions?: string[] }>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -59,12 +97,17 @@ defineProps<{ disabledActions?: string[] }>()
|
|||||||
<span class="name">u-ppt</span>
|
<span class="name">u-ppt</span>
|
||||||
<span class="sub">在线演示工具</span>
|
<span class="sub">在线演示工具</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tools">
|
<div class="tools">
|
||||||
<button class="btn" data-action="add-slide" title="新建幻灯片" :disabled="disabledActions?.includes('add-slide')" @click="action('add-slide')">+ 幻灯片</button>
|
<!-- 高频:页面操作 -->
|
||||||
|
<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>
|
<button class="btn" data-action="templates" title="从模板新建页" @click="emit('open-templates')">📋 模板</button>
|
||||||
<button class="btn" data-action="dup-slide" title="复制当前页" :disabled="disabledActions?.includes('dup-slide')" @click="action('dup-slide')">⎘ 复制</button>
|
|
||||||
<button class="btn" data-action="del-slide" title="删除当前页" :disabled="disabledActions?.includes('del-slide')" @click="action('del-slide')">🗑 删除</button>
|
|
||||||
<span class="sep"></span>
|
<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">
|
<label class="theme-select">
|
||||||
主题
|
主题
|
||||||
<select v-model="currentTheme" @change="onThemeChange">
|
<select v-model="currentTheme" @change="onThemeChange">
|
||||||
@@ -72,16 +115,94 @@ defineProps<{ disabledActions?: string[] }>()
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<span class="sep"></span>
|
<span class="sep"></span>
|
||||||
<button class="btn" data-action="library" title="我的演示文库" @click="action('library')">📁 文库</button>
|
|
||||||
<button class="btn" data-action="save" title="保存到文库" :disabled="disabledActions?.includes('save')" @click="action('save')">💾 保存</button>
|
<!-- 低频:文件域收进菜单 -->
|
||||||
<button class="btn" data-action="export-json" title="导出 JSON" @click="emit('export-json')">📤 导出</button>
|
<div class="file-menu" ref="fileMenuBtn">
|
||||||
<button class="btn" data-action="import-materials" title="导入本地资料(图片/文档/目录)" @click="emit('import-materials')">📂 资料</button>
|
<button class="btn" data-action="file-menu" :class="{ active: fileMenuOpen }" @click="toggleFileMenu">
|
||||||
<button class="btn" data-action="import-json" title="导入 JSON" @click="emit('import-json')">📥 导入</button>
|
🗀 文件 <span class="chevron" :class="{ open: fileMenuOpen }">▾</span>
|
||||||
<button class="btn" data-action="export-pdf" title="打印/导出 PDF" @click="emit('export-pdf')">🖨 PDF</button>
|
</button>
|
||||||
<button class="btn" data-action="open-ai" title="打开 AI 助手" @click="action('open-ai')">🤖 AI</button>
|
<Transition name="menu-pop">
|
||||||
<button class="btn" data-action="reset" title="重置为内置示例" :disabled="disabledActions?.includes('reset')" @click="action('reset')">⟲ 重置</button>
|
<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>
|
||||||
|
<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 primary" data-action="present" title="开始演示 (F5)" :disabled="disabledActions?.includes('present')" @click="action('present')">▶ 演示</button>
|
||||||
<button class="btn ghost" data-action="settings" title="AI 设置" @click="action('settings')">⚙</button>
|
<button class="btn ghost icon-only" data-action="settings" title="AI 设置" @click="action('settings')">⚙</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 新建幻灯片:创作起点,给主色轻底强调 */
|
||||||
|
.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>
|
||||||
|
|||||||
@@ -1,30 +1,32 @@
|
|||||||
<!-- =====================================================================
|
<!-- =====================================================================
|
||||||
PrintModal.vue — PDF 导出:全量幻灯片打印预览
|
PrintModal.vue — PDF 导出:全量幻灯片打印预览
|
||||||
复用 ElementView 渲染每个元素(与编辑器/演示一致,图表/表格/公式不降级)
|
复用 ElementView 渲染每个元素(与编辑器/演示一致,图表/表格/公式不降级)
|
||||||
|
页面几何:A4 横向满幅(297mm × 209.8mm ≈ 16:9),元素 % 定位原样映射
|
||||||
===================================================================== -->
|
===================================================================== -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
import { store, resolveBg } from '../../core/store'
|
import { store, resolveBg } from '../../core/store'
|
||||||
import ElementView from '../editor/ElementView.vue'
|
import ElementView from '../editor/ElementView.vue'
|
||||||
|
|
||||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||||
|
|
||||||
const deck = store.getDeck()
|
// 响应式引用:预览随 deck 变化(快照会导致预览与数据脱节)
|
||||||
const slides = deck.slides
|
const slides = computed(() => store.getDeck().slides)
|
||||||
|
|
||||||
function onPrint() { window.print() }
|
function onPrint() { window.print() }
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="print-modal" @print="onPrint">
|
<div class="print-modal">
|
||||||
<!-- 操作栏(打印时不显示) -->
|
<!-- 操作栏(打印时不显示) -->
|
||||||
<div class="print-bar no-print">
|
<div class="print-bar no-print">
|
||||||
<button class="btn primary" @click="onPrint">🖨 打印 / 另存为 PDF</button>
|
<button class="btn primary" @click="onPrint">🖨 打印 / 另存为 PDF</button>
|
||||||
<span class="muted">{{ slides.length }} 页</span>
|
<span class="muted">{{ slides.length }} 页 · A4 横向</span>
|
||||||
<span class="sep"></span>
|
<span class="sep"></span>
|
||||||
<button class="btn ghost" @click="emit('close')">✕ 关闭</button>
|
<button class="btn ghost" @click="emit('close')">✕ 关闭</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 所有幻灯片逐页渲染(复用编辑器元素渲染) -->
|
<!-- 所有幻灯片逐页渲染(复用编辑器元素渲染,几何与编辑画布同构) -->
|
||||||
<div
|
<div
|
||||||
v-for="(slide, i) in slides"
|
v-for="(slide, i) in slides"
|
||||||
:key="slide.id"
|
:key="slide.id"
|
||||||
@@ -56,18 +58,18 @@ function onPrint() { window.print() }
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
border-bottom: 1px solid #e2e8f0;
|
border-bottom: 1px solid #e2e8f0;
|
||||||
}
|
}
|
||||||
|
/* 打印页:A4 横向满幅 16:9(297 × 209.8mm),元素 % 坐标系与 1280×720 画布同构 */
|
||||||
.print-page {
|
.print-page {
|
||||||
width: 210mm; min-height: 297mm;
|
width: 297mm; height: 209.8mm;
|
||||||
margin: 12mm auto;
|
margin: 12mm auto;
|
||||||
padding: 20mm 25mm;
|
|
||||||
background: #fff;
|
background: #fff;
|
||||||
box-shadow: 0 2px 16px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 16px rgba(0,0,0,0.1);
|
||||||
position: relative;
|
position: relative;
|
||||||
page-break-after: always;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transform-origin: top left;
|
||||||
}
|
}
|
||||||
.print-number {
|
.print-number {
|
||||||
position: absolute; bottom: 8mm; right: 10mm;
|
position: absolute; bottom: 3mm; right: 5mm;
|
||||||
font-size: 11px; color: #94a3b8;
|
font-size: 11px; color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,15 +78,18 @@ function onPrint() { window.print() }
|
|||||||
.no-print { display: none !important; }
|
.no-print { display: none !important; }
|
||||||
.print-modal { position: static; background: none; overflow: visible; }
|
.print-modal { position: static; background: none; overflow: visible; }
|
||||||
.print-page {
|
.print-page {
|
||||||
margin: 0; padding: 0;
|
margin: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
width: 100%; height: 100vh;
|
width: 297mm; height: 209.8mm;
|
||||||
min-height: auto;
|
break-after: page;
|
||||||
page-break-after: always;
|
|
||||||
/* 确保背景色打印 */
|
/* 确保背景色打印 */
|
||||||
-webkit-print-color-adjust: exact;
|
-webkit-print-color-adjust: exact;
|
||||||
print-color-adjust: exact;
|
print-color-adjust: exact;
|
||||||
}
|
}
|
||||||
.print-number { bottom: 2mm; right: 3mm; }
|
/* 最后一页不再分页(避免尾部空白页) */
|
||||||
|
.print-page:last-child { break-after: auto; }
|
||||||
|
/* 入场动画在打印上下文中不可播放,冻结到最终态(防 opacity:0 截帧) */
|
||||||
|
.print-page * { animation: none !important; transition: none !important; }
|
||||||
|
.print-number { bottom: 3mm; right: 5mm; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -21,6 +21,23 @@ const tick = ref(0)
|
|||||||
const templates = computed(() => { tick.value; return store.getTemplates() })
|
const templates = computed(() => { tick.value; return store.getTemplates() })
|
||||||
function bump() { tick.value++ }
|
function bump() { tick.value++ }
|
||||||
|
|
||||||
|
/* ---------- 按分组展示:内置模板分节,自存模板单独一节 ---------- */
|
||||||
|
const GROUP_LABELS: Record<string, string> = {
|
||||||
|
basic: '基础版式', content: '内容版式', data: '数据版式', ending: '收尾版式', user: '我的模板'
|
||||||
|
}
|
||||||
|
const groupedTemplates = computed(() => {
|
||||||
|
const all = templates.value
|
||||||
|
const userTpls = all.filter(t => t.category === 'user')
|
||||||
|
const builtin = all.filter(t => t.category === 'built-in')
|
||||||
|
const groups: Array<{ key: string; label: string; items: typeof all }> = []
|
||||||
|
for (const key of ['basic', 'content', 'data', 'ending']) {
|
||||||
|
const items = builtin.filter(t => (t.group || 'content') === key)
|
||||||
|
if (items.length) groups.push({ key, label: GROUP_LABELS[key], items })
|
||||||
|
}
|
||||||
|
if (userTpls.length) groups.push({ key: 'user', label: GROUP_LABELS.user, items: userTpls })
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
function onSave() {
|
function onSave() {
|
||||||
const v = nameInput.value.trim()
|
const v = nameInput.value.trim()
|
||||||
if (!v) { emit('toast', '请输入模板名'); return }
|
if (!v) { emit('toast', '请输入模板名'); return }
|
||||||
@@ -68,20 +85,25 @@ function onDelete(tpl: PageTemplate) {
|
|||||||
<button class="btn primary" @click="onSave">💾 保存为模板</button>
|
<button class="btn primary" @click="onSave">💾 保存为模板</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 模板网格 -->
|
<!-- 模板网格(按分组分节) -->
|
||||||
<div class="tpl-grid">
|
<div class="tpl-groups">
|
||||||
<div v-for="tpl in templates" :key="tpl.id" class="tpl-card" :class="tpl.category" @click="onPick(tpl)">
|
<section v-for="g in groupedTemplates" :key="g.key" class="tpl-group">
|
||||||
<div class="tpl-thumb" :style="{ background: resolveBg(tpl.background) }">
|
<h4 class="tpl-group-title">{{ g.label }} <span class="tpl-group-count">{{ g.items.length }}</span></h4>
|
||||||
<div class="tpl-thumb-layer">
|
<div class="tpl-grid">
|
||||||
<ElementView v-for="el in tpl.elements" :key="el.id" :el="el" :bg="tpl.background" />
|
<div v-for="tpl in g.items" :key="tpl.id" class="tpl-card" :class="tpl.category" @click="onPick(tpl)">
|
||||||
|
<div class="tpl-thumb" :style="{ background: resolveBg(tpl.background) }">
|
||||||
|
<div class="tpl-thumb-layer">
|
||||||
|
<ElementView v-for="el in tpl.elements" :key="el.id" :el="el" :bg="tpl.background" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tpl-info">
|
||||||
|
<span class="tpl-name">{{ tpl.name }}</span>
|
||||||
|
<span class="tpl-badge" :class="tpl.category">{{ tpl.category === 'built-in' ? '内置' : '自存' }}</span>
|
||||||
|
<button v-if="tpl.category === 'user'" class="tpl-del" title="删除" @click.stop="onDelete(tpl)">🗑</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tpl-info">
|
</section>
|
||||||
<span class="tpl-name">{{ tpl.name }}</span>
|
|
||||||
<span class="tpl-badge" :class="tpl.category">{{ tpl.category === 'built-in' ? '内置' : '自存' }}</span>
|
|
||||||
<button v-if="tpl.category === 'user'" class="tpl-del" title="删除" @click.stop="onDelete(tpl)">🗑</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
@@ -110,12 +132,37 @@ function onDelete(tpl: PageTemplate) {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tpl-groups {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: .2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-group {
|
||||||
|
margin-bottom: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-group-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ui-muted, #64748b);
|
||||||
|
margin: .6em 0 .6em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-group-count {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
background: rgba(100, 116, 139, .12);
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.tpl-grid {
|
.tpl-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 1em;
|
gap: 1em;
|
||||||
overflow-y: auto;
|
|
||||||
padding: .2em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tpl-card {
|
.tpl-card {
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ body {
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== PDF 导出 / 打印 ===== */
|
||||||
|
/* A4 横向满幅:幻灯片 16:9 与 297×209.8mm 同构,元素 % 坐标原样映射 */
|
||||||
|
@media print {
|
||||||
|
@page { size: A4 landscape; margin: 0; }
|
||||||
|
body { overflow: visible !important; height: auto !important; }
|
||||||
|
html { height: auto !important; }
|
||||||
|
}
|
||||||
h1, h2, h3, h4, h5, h6, p { margin: 0; }
|
h1, h2, h3, h4, h5, h6, p { margin: 0; }
|
||||||
button { font-family: inherit; cursor: pointer; }
|
button { font-family: inherit; cursor: pointer; }
|
||||||
input, select, textarea { font-family: inherit; font-size: inherit; color: inherit; }
|
input, select, textarea { font-family: inherit; font-size: inherit; color: inherit; }
|
||||||
@@ -97,6 +105,24 @@ input[type="range"]::-webkit-slider-thumb {
|
|||||||
}
|
}
|
||||||
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||||
|
|
||||||
|
/* ---------- 全局拖放遮罩 ---------- */
|
||||||
|
.global-drop-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 9000;
|
||||||
|
background: rgba(15, 23, 42, .55); backdrop-filter: blur(2px);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
pointer-events: none; /* 不拦截 drop,让 window 接管 */
|
||||||
|
}
|
||||||
|
.global-drop-card {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
||||||
|
padding: 36px 48px; border-radius: 16px;
|
||||||
|
border: 2px dashed rgba(255, 255, 255, .65);
|
||||||
|
background: rgba(255, 255, 255, .08);
|
||||||
|
color: #fff; text-align: center;
|
||||||
|
}
|
||||||
|
.global-drop-card .global-drop-icon { font-size: 48px; }
|
||||||
|
.global-drop-card strong { font-size: 18px; }
|
||||||
|
.global-drop-card .global-drop-hint { font-size: 13px; opacity: .75; }
|
||||||
|
|
||||||
/* ---------- 弹窗 ---------- */
|
/* ---------- 弹窗 ---------- */
|
||||||
.modal-mask {
|
.modal-mask {
|
||||||
position: fixed; inset: 0; background: rgba(15, 23, 42, .45);
|
position: fixed; inset: 0; background: rgba(15, 23, 42, .45);
|
||||||
|
|||||||
@@ -84,7 +84,9 @@
|
|||||||
* renderSlide 给 slide-inner 加 .animated,并按元素顺序设 animation-delay
|
* renderSlide 给 slide-inner 加 .animated,并按元素顺序设 animation-delay
|
||||||
* data-anim 指定具体动效,未指定则默认 fade-up
|
* data-anim 指定具体动效,未指定则默认 fade-up
|
||||||
* ---------------------------------------------------------------------- */
|
* ---------------------------------------------------------------------- */
|
||||||
.present-stage .el { opacity: 0; }
|
/* 初始隐藏(入场前)。注:必须限定 .animated 上下文——若动画未挂上/被打断,
|
||||||
|
* 元素回落到此处的 opacity:0 会导致「闪现后消失」(曾因 pop/bounce 100% 帧缺 opacity 触发) */
|
||||||
|
.present-stage .slide-inner.animated .el { opacity: 0; }
|
||||||
.present-stage .slide-inner.animated .el { will-change: transform, opacity; }
|
.present-stage .slide-inner.animated .el { will-change: transform, opacity; }
|
||||||
.present-stage .slide-inner.animated .el[data-anim="fade"] { animation: ppt-fade .5s ease both; }
|
.present-stage .slide-inner.animated .el[data-anim="fade"] { animation: ppt-fade .5s ease both; }
|
||||||
.present-stage .slide-inner.animated .el[data-anim="scale"] { animation: ppt-scale .6s cubic-bezier(.22,.61,.36,1) both; }
|
.present-stage .slide-inner.animated .el[data-anim="scale"] { animation: ppt-scale .6s cubic-bezier(.22,.61,.36,1) both; }
|
||||||
@@ -102,7 +104,7 @@
|
|||||||
@keyframes ppt-scale { from { opacity: 0; transform: scale(.86); } to { opacity: 1; transform: none; } }
|
@keyframes ppt-scale { from { opacity: 0; transform: scale(.86); } to { opacity: 1; transform: none; } }
|
||||||
@keyframes ppt-slide-l { from { opacity: 0; transform: translateX(-52px); } to { opacity: 1; transform: none; } }
|
@keyframes ppt-slide-l { from { opacity: 0; transform: translateX(-52px); } to { opacity: 1; transform: none; } }
|
||||||
@keyframes ppt-slide-r { from { opacity: 0; transform: translateX(52px); } to { opacity: 1; transform: none; } }
|
@keyframes ppt-slide-r { from { opacity: 0; transform: translateX(52px); } to { opacity: 1; transform: none; } }
|
||||||
@keyframes ppt-pop { 0% { opacity: 0; transform: scale(.55); } 60% { opacity: 1; transform: scale(1.06); } 100% { transform: none; } }
|
@keyframes ppt-pop { 0% { opacity: 0; transform: scale(.55); } 60% { opacity: 1; transform: scale(1.06); } 100% { opacity: 1; transform: none; } }
|
||||||
|
|
||||||
/* 扩展特效 */
|
/* 扩展特效 */
|
||||||
.present-stage .slide-inner.animated .el[data-anim="rotate"] { animation: ppt-rotate .6s cubic-bezier(.22,.61,.36,1) both; }
|
.present-stage .slide-inner.animated .el[data-anim="rotate"] { animation: ppt-rotate .6s cubic-bezier(.22,.61,.36,1) both; }
|
||||||
@@ -111,7 +113,7 @@
|
|||||||
.present-stage .slide-inner.animated .el[data-anim="blur"] { animation: ppt-blur .6s ease both; }
|
.present-stage .slide-inner.animated .el[data-anim="blur"] { animation: ppt-blur .6s ease both; }
|
||||||
|
|
||||||
@keyframes ppt-rotate { from { opacity: 0; transform: rotate(-12deg) scale(.85); } to { opacity: 1; transform: none; } }
|
@keyframes ppt-rotate { from { opacity: 0; transform: rotate(-12deg) scale(.85); } to { opacity: 1; transform: none; } }
|
||||||
@keyframes ppt-bounce { 0% { opacity: 0; transform: translateY(-50px); } 55% { opacity: 1; } 72% { transform: translateY(8px); } 100% { transform: none; } }
|
@keyframes ppt-bounce { 0% { opacity: 0; transform: translateY(-50px); } 55% { opacity: 1; } 72% { opacity: 1; transform: translateY(8px); } 100% { opacity: 1; transform: none; } }
|
||||||
@keyframes ppt-flip { from { opacity: 0; transform: perspective(900px) rotateY(75deg); } to { opacity: 1; transform: none; } }
|
@keyframes ppt-flip { from { opacity: 0; transform: perspective(900px) rotateY(75deg); } to { opacity: 1; transform: none; } }
|
||||||
@keyframes ppt-blur { from { opacity: 0; filter: blur(14px); } to { opacity: 1; filter: none; } }
|
@keyframes ppt-blur { from { opacity: 0; filter: blur(14px); } to { opacity: 1; filter: none; } }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user