新增: 首页 + 资料导入 + PDF导出 + AI文档生成

This commit is contained in:
lxy
2026-08-17 09:34:12 +08:00
parent a5ebfd8b74
commit 352496c00a
13 changed files with 1698 additions and 12 deletions
+266
View File
@@ -0,0 +1,266 @@
<!-- =====================================================================
HomePage.vue 首页品牌展示快捷入口最近文库
===================================================================== -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { store } from '../core/store'
import type { LibItem } from '../core/types'
const emit = defineEmits<{
(e: 'new-blank'): void
(e: 'open-library'): void
(e: 'import-materials'): void
(e: 'open-settings'): void
(e: 'toast', msg: string): void
(e: 'open-deck'): void
(e: 'ai-create'): void
}>()
const libVersion = ref(0)
const library = computed<LibItem[]>(() => {
void libVersion.value
return store.getLibrary().slice(0, 6) // 最近 6 个
})
function bump() { libVersion.value++ }
onMounted(bump)
function loadItem(id: string) {
if (store.loadFromLibrary(id)) {
emit('toast', '已加载:' + (library.value.find(x => x.id === id)?.name || ''))
emit('open-deck')
}
}
function formatTime(ts: number): string {
if (!ts) return ''
const diff = Date.now() - ts
if (diff < 60000) return '刚刚'
if (diff < 3600000) return Math.floor(diff / 60000) + ' 分钟前'
if (diff < 86400000) return Math.floor(diff / 3600000) + ' 小时前'
const d = new Date(ts)
return d.getMonth() + 1 + '/' + d.getDate()
}
function firstSlideTitle(item: LibItem): string {
const el = item?.deck?.slides?.[0]?.elements?.[0]
return el?.type === 'title' ? (el.content || '无标题') : '无标题'
}
</script>
<template>
<div class="home">
<!-- 背景装饰 -->
<div class="home-bg">
<div class="home-glow top-right"></div>
<div class="home-glow bottom-left"></div>
</div>
<div class="home-inner">
<!-- 品牌区 -->
<header class="home-brand">
<span class="home-logo"></span>
<h1 class="home-title">u-ppt</h1>
<p class="home-desc">轻量在线演示工具 · 支持 AI 创作与本地资料导入</p>
</header>
<!-- 快捷入口 -->
<div class="home-actions">
<button class="action-card" @click="emit('new-blank')">
<span class="action-icon"></span>
<span class="action-label">新建空白</span>
<span class="action-hint">从空白页开始创作</span>
</button>
<button class="action-card" @click="emit('open-library')">
<span class="action-icon">📁</span>
<span class="action-label">从文库打开</span>
<span class="action-hint">继续已有的演示</span>
</button>
<button class="action-card" @click="emit('import-materials')">
<span class="action-icon">📂</span>
<span class="action-label">导入资料</span>
<span class="action-hint">图片/PDF/DOCX AI 生成</span>
</button>
<button class="action-card accent" @click="emit('ai-create')">
<span class="action-icon">🤖</span>
<span class="action-label">AI 创作</span>
<span class="action-hint">输入主题AI 生成全套演示</span>
</button>
</div>
<!-- 最近文库 -->
<div v-if="library.length > 0" class="home-recent">
<h2 class="section-title">最近文档</h2>
<div class="recent-list">
<button
v-for="item in library"
:key="item.id"
class="recent-item"
@click="loadItem(item.id)"
:title="item.name"
>
<span class="recent-icon">📄</span>
<span class="recent-info">
<span class="recent-name">{{ item.name }}</span>
<span class="recent-meta">
{{ item.deck.slides.length }} · {{ formatTime(item.updatedAt || item.createdAt) }}
</span>
</span>
<span class="recent-preview">{{ firstSlideTitle(item) }}</span>
</button>
</div>
</div>
<!-- 底部 -->
<footer class="home-footer">
<button class="btn ghost" @click="emit('open-settings')"> 设置</button>
</footer>
</div>
</div>
</template>
<style scoped>
.home {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
background: var(--ui-bg, #f1f5f9);
}
/* 背景光晕 */
.home-bg { position: absolute; inset: 0; pointer-events: none; }
.home-glow {
position: absolute;
width: 480px; height: 480px;
border-radius: 50%;
filter: blur(120px);
opacity: .08;
}
.home-glow.top-right { top: -120px; right: -80px; background: var(--ui-primary, #4f46e5); }
.home-glow.bottom-left { bottom: -160px; left: -80px; background: var(--ui-primary, #4f46e5); }
.home-inner {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 40px;
padding: 40px 24px;
max-width: 680px;
width: 100%;
}
/* 品牌 */
.home-brand { text-align: center; }
.home-logo {
font-size: 56px; line-height: 1;
color: var(--ui-primary, #4f46e5);
display: block; margin-bottom: 8px;
}
.home-title {
font-size: 32px; font-weight: 700;
letter-spacing: -.02em;
color: var(--ui-text, #1e293b);
}
.home-desc {
margin-top: 6px;
font-size: 15px;
color: var(--ui-muted, #64748b);
}
/* 快捷入口网格 */
.home-actions {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
width: 100%;
}
.action-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 20px;
border: 1px solid var(--ui-border, #e2e8f0);
border-radius: 12px;
background: var(--ui-panel, #fff);
cursor: pointer;
transition: all .15s;
text-align: left;
}
.action-card:hover {
border-color: var(--ui-primary, #4f46e5);
box-shadow: var(--shadow-md, 0 4px 12px rgba(15,23,42,.08));
transform: translateY(-2px);
}
.action-card:active { transform: translateY(0); }
.action-icon { font-size: 28px; line-height: 1; margin-bottom: 4px; }
.action-label { font-size: 16px; font-weight: 600; color: var(--ui-text, #1e293b); }
.action-hint { font-size: 13px; color: var(--ui-muted, #64748b); }
/* 最近文档 */
.home-recent { width: 100%; }
.section-title {
font-size: 14px; font-weight: 600;
color: var(--ui-muted, #64748b);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: .05em;
}
.recent-list {
display: flex;
flex-direction: column;
border: 1px solid var(--ui-border, #e2e8f0);
border-radius: 10px;
overflow: hidden;
background: var(--ui-panel, #fff);
}
.recent-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border: none;
border-bottom: 1px solid var(--ui-border, #f1f5f9);
background: transparent;
cursor: pointer;
transition: background .1s;
text-align: left;
width: 100%;
}
.recent-item:last-child { border-bottom: none; }
.recent-item:hover { background: var(--ui-hover, #f1f5f9); }
.recent-icon { font-size: 20px; flex-shrink: 0; }
.recent-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.recent-name {
font-size: 14px; font-weight: 500;
color: var(--ui-text, #1e293b);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recent-meta {
font-size: 12px;
color: var(--ui-muted, #64748b);
}
.recent-preview {
font-size: 13px;
color: var(--ui-muted, #64748b);
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 底部 */
.home-footer { display: flex; gap: 8px; }
</style>
+2
View File
@@ -15,6 +15,7 @@ const emit = defineEmits<{
(e: 'open-templates'): void
(e: 'export-json'): void
(e: 'import-json'): void
(e: 'import-materials'): void
(e: 'export-pdf'): void
}>()
@@ -74,6 +75,7 @@ defineProps<{ disabledActions?: string[] }>()
<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>
<button class="btn" data-action="import-materials" title="导入本地资料(图片/文档/目录)" @click="emit('import-materials')">📂 资料</button>
<button class="btn" data-action="import-json" title="导入 JSON" @click="emit('import-json')">📥 导入</button>
<button class="btn" data-action="export-pdf" title="打印/导出 PDF" @click="emit('export-pdf')">🖨 PDF</button>
<button class="btn" data-action="open-ai" title="打开 AI 助手" @click="action('open-ai')">🤖 AI</button>
+373
View File
@@ -0,0 +1,373 @@
<!-- =====================================================================
ImportModal.vue 本地资料导入对话框
支持图片直接插入文档PDF/DOCX/MD/TXT LLM 分析生成幻灯片
===================================================================== -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { store } from '../../core/store'
import {
scanFiles, readEntries,
aiAnalyzeDocument,
fileToImageElement, imagesToSlideElements,
type FileEntry, type ImportReport,
describeReport
} from '../../core/importer'
const props = withDefaults(defineProps<{ visible: boolean }>(), { visible: false })
const emit = defineEmits<{
(e: 'close'): void
(e: 'toast', msg: string): void
(e: 'open-settings'): void
}>()
/* ---------- 状态 ---------- */
const activeTab = ref<'files' | 'dir'>('files')
const entries = ref<FileEntry[]>([])
const loading = ref(false)
const loaded = ref(false)
const analyzing = ref(false) // AI 分析中
const analyzeDone = ref(false) // AI 分析完成
const analyzeError = ref('') // AI 分析错误
const showImages = ref(true)
const showDocs = ref(true)
/* ---------- 过滤后的文件 ---------- */
const filteredEntries = computed(() => {
return entries.value.filter(e => {
if (e.kind === 'image' && !showImages.value) return false
if (e.kind === 'document' && !showDocs.value) return false
return true
})
})
const imageEntries = computed(() => filteredEntries.value.filter(e => e.kind === 'image'))
const docEntries = computed(() => filteredEntries.value.filter(e => e.kind === 'document'))
const unsupportedCount = computed(() => entries.value.filter(e => e.kind === 'unsupported').length)
/** 是否有文档需要 AI 分析 */
const hasDocs = computed(() => docEntries.value.length > 0)
/** AI 是否已配置 */
const aiReady = computed(() => !!store.getCfg().key)
/* ---------- 打开文件选择器 ---------- */
let fileInput: HTMLInputElement | null = null
let dirInput: HTMLInputElement | null = null
function openFilePicker() {
if (!fileInput) {
fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.multiple = true
fileInput.accept = '.png,.jpg,.jpeg,.gif,.webp,.svg,.md,.markdown,.txt,.text,.pdf,.docx,.doc'
fileInput.onchange = () => handleFiles(fileInput!.files)
}
fileInput.value = ''
fileInput.click()
}
function openDirPicker() {
if (!dirInput) {
dirInput = document.createElement('input')
dirInput.type = 'file'
;(dirInput as any).webkitdirectory = true
dirInput.onchange = () => handleFiles(dirInput!.files)
}
dirInput.value = ''
dirInput.click()
}
async function handleFiles(fl: FileList | null) {
if (!fl || fl.length === 0) return
loading.value = true
loaded.value = false
analyzeDone.value = false
analyzeError.value = ''
try {
const scanned = await scanFiles(fl)
entries.value = scanned
// 读取文件原始内容
if (scanned.some(e => e.kind === 'image' || e.kind === 'document')) {
await readEntries(scanned)
}
loaded.value = true
} catch (e: any) {
emit('toast', '读取失败: ' + (e?.message || String(e)))
} finally {
loading.value = false
}
}
/* ---------- AI 分析文档 ---------- */
async function runAiAnalysis() {
const docs = docEntries.value.filter(d => d.data && !d.slides)
if (docs.length === 0) return
if (!aiReady.value) {
analyzeError.value = '请先在 ⚙ 设置中配置 AI API Key'
return
}
analyzing.value = true
analyzeError.value = ''
let successCount = 0
for (const doc of docs) {
analyzeError.value = `正在 AI 分析(${successCount + 1}/${docs.length}):${doc.name}`
try {
await aiAnalyzeDocument(doc)
successCount++
} catch (e: any) {
const msg = e?.message || String(e)
doc.error = msg
analyzeError.value = `${doc.name}」分析失败: ${msg}`
console.warn(`AI 分析失败 ${doc.name}:`, e)
}
}
analyzing.value = false
analyzeDone.value = successCount > 0
if (successCount > 0 && !analyzeError.value) {
emit('toast', `AI 完成 ${successCount} 个文档分析,共生成 ${docs.reduce((s, d) => s + (d.slides?.length || 0), 0)} 页幻灯片`)
}
}
/* ---------- 执行导入 ---------- */
function doImport() {
const images = imageEntries.value.filter(e => e.data)
const docs = docEntries.value.filter(e => e.slides && e.slides.length > 0)
if (images.length === 0 && docs.length === 0) {
// 如果有文档但还没 AI 分析,先分析
if (docEntries.value.filter(d => d.data && !d.slides).length > 0) {
void runAiAnalysis() // fire-and-forget:分析完成后 UI 自动更新按钮
return
}
emit('toast', '没有可导入的内容')
return
}
let insertedImages = 0
let insertedSlides = 0
// 1. 图片 → 当前幻灯片
if (images.length > 0) {
if (images.length === 1) {
const el = fileToImageElement(images[0].file, images[0].data!)
store.addElement('image', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style })
insertedImages = 1
} else {
const els = imagesToSlideElements(images.map(f => ({ file: f.file, dataUrl: f.data! })))
for (const el of els) {
store.addElement('image', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style })
insertedImages++
}
}
}
// 2. AI 生成的幻灯片 → 追加到 deck
if (docs.length > 0) {
for (const d of docs) {
if (d.slides) {
for (const slide of d.slides) {
store.appendSlide(slide)
insertedSlides++
}
}
}
}
const report: ImportReport = { images: insertedImages, slides: insertedSlides, files: entries.value.length }
emit('toast', describeReport(report))
emit('close')
}
/* ---------- 清除 ---------- */
function clearFiles() {
entries.value = []
loaded.value = false
analyzeDone.value = false
analyzeError.value = ''
}
/* ---------- 格式友好文件大小 ---------- */
function fmtSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
}
/** 截取文本预览前一段 */
function textPreview(data: string, maxLen = 80): string {
return data.replace(/\n/g, ' ').slice(0, maxLen) + (data.length > maxLen ? '…' : '')
}
</script>
<template>
<div v-if="props.visible" class="modal-mask" @click.self="emit('close')">
<div class="modal import-modal">
<header class="modal-header">
<h2>📂 导入本地资料</h2>
<button class="close" @click="emit('close')"></button>
</header>
<!-- ======== 选择区域 ======== -->
<div v-if="!loaded" class="import-select">
<div class="import-tabs">
<button class="tab" :class="{ active: activeTab === 'files' }" @click="activeTab = 'files'">📄 选择文件</button>
<button class="tab" :class="{ active: activeTab === 'dir' }" @click="activeTab = 'dir'">📁 读取目录</button>
</div>
<div v-if="activeTab === 'files'" class="dropzone" @click="openFilePicker">
<div class="dropzone-icon">📄</div>
<div class="dropzone-text">
<strong>点击选择文件</strong>
<span class="hint">图片直接插入 · 文档PDF/DOCX/MD/TXT AI 分析生成幻灯片</span>
</div>
</div>
<div v-else class="dropzone" @click="openDirPicker">
<div class="dropzone-icon">📁</div>
<div class="dropzone-text">
<strong>点击选择目录</strong>
<span class="hint">读取目录下所有支持的图片和文档AI 自动分析生成</span>
</div>
</div>
<div v-if="loading" class="loading">
<span class="spin"></span> 正在读取文件...
</div>
</div>
<!-- ======== 预览区 ======== -->
<div v-else class="import-preview">
<div class="preview-actions">
<span class="file-count">
已选 {{ filteredEntries.length }} 个文件
<template v-if="unsupportedCount > 0">{{ unsupportedCount }} 个不支持的类型已忽略</template>
</span>
<button class="btn-sm" @click="clearFiles">重新选择</button>
</div>
<!-- 过滤 -->
<div class="filter-bar">
<label class="chk"><input type="checkbox" v-model="showImages" /> 🖼 图片 ({{ imageEntries.length }})</label>
<label class="chk"><input type="checkbox" v-model="showDocs" /> 📄 文档 ({{ docEntries.length }})</label>
</div>
<!-- AI 分析进度 -->
<div v-if="analyzing" class="ai-progress">
<span class="spin"></span> AI 正在分析文档内容理解语义并生成幻灯片
</div>
<!-- AI 分析错误 -->
<div v-if="analyzeError && !analyzing" class="ai-error">
{{ analyzeError }}
<template v-if="!aiReady">
<button class="btn-sm" @click="emit('open-settings')">去配置</button>
</template>
</div>
<!-- 文件列表 -->
<div class="file-list">
<div v-for="(entry, i) in filteredEntries" :key="i" class="file-item" :class="entry.kind">
<span class="file-icon">{{ entry.kind === 'image' ? '🖼' : '📄' }}</span>
<span class="file-name" :title="entry.name">{{ entry.name }}</span>
<span class="file-size">{{ fmtSize(entry.file.size) }}</span>
<span class="file-status">
<!-- 图片 -->
<template v-if="entry.kind === 'image' && entry.data"> 图片就绪</template>
<!-- 文档AI 分析结果 -->
<template v-else-if="entry.kind === 'document' && entry.slides"> AI {{ entry.slides.length }} </template>
<template v-else-if="entry.kind === 'document' && entry.data && analyzeDone"> 待分析</template>
<template v-else-if="entry.kind === 'document' && entry.data && analyzing"> AI 分析中</template>
<template v-else-if="entry.kind === 'document' && entry.data">📄 {{ textPreview(entry.data) }}</template>
<template v-else-if="entry.error"> {{ entry.error }}</template>
<template v-else-if="entry.kind === 'unsupported'"> 跳过</template>
<template v-else> 读取中</template>
</span>
</div>
</div>
<!-- 文档幻灯片预览 -->
<div v-if="docEntries.filter(e => e.slides).length > 0" class="preview-slides">
<span class="preview-title">🤖 AI 解析结果</span>
<div class="preview-scroll">
<div v-for="(entry, i) in docEntries.filter(e => e.slides)" :key="'p' + i" class="preview-item">
<strong>{{ entry.name }}</strong>
{{ entry.slides?.length }} 页幻灯片
<span class="preview-titles">
{{ entry.slides?.map(s => {
const t = s.elements.find(e => e.type === 'title')
return t ? t.content.slice(0, 20) : '无标题'
}).join(' · ') }}
</span>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="import-actions">
<button class="btn primary" @click="doImport" :disabled="filteredEntries.length === 0 || analyzing">
{{ hasDocs && !analyzeDone ? '🤖 AI 分析文档并导入' : '✅ 导入以上内容' }}
</button>
<button class="btn" @click="emit('close')">取消</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.import-modal { max-width: 660px; max-height: 85vh; display: flex; flex-direction: column; }
.import-select { padding: 8px 0; }
.import-tabs { display: flex; gap: 4px; margin-bottom: 16px; }
.import-tabs .tab {
flex: 1; padding: 8px 16px; border: 1px solid var(--border, #e2e8f0);
background: var(--bg, #fff); border-radius: 8px; cursor: pointer;
font-size: 14px; transition: all .15s;
}
.import-tabs .tab.active { background: var(--primary, #4f46e5); color: #fff; border-color: var(--primary, #4f46e5); }
.dropzone {
border: 2px dashed var(--border, #cbd5e1); border-radius: 12px; padding: 36px 24px;
display: flex; align-items: center; gap: 20px; cursor: pointer; transition: all .2s;
background: var(--panel, #f8fafc);
}
.dropzone:hover { border-color: var(--primary, #4f46e5); background: color-mix(in srgb, var(--primary, #4f46e5) 5%, var(--panel, #f8fafc)); }
.dropzone-icon { font-size: 44px; }
.dropzone-text { display: flex; flex-direction: column; gap: 4px; }
.dropzone-text strong { font-size: 16px; }
.hint { font-size: 13px; color: var(--muted, #64748b); }
.import-preview { display: flex; flex-direction: column; gap: 8px; overflow: hidden; }
.preview-actions { display: flex; justify-content: space-between; align-items: center; }
.file-count { font-size: 14px; color: var(--muted, #64748b); }
.btn-sm { padding: 4px 12px; font-size: 13px; border: 1px solid var(--border, #e2e8f0); border-radius: 6px; background: var(--bg, #fff); cursor: pointer; }
.filter-bar { display: flex; gap: 16px; padding: 4px 0; }
.filter-bar .chk { display: flex; align-items: center; gap: 6px; font-size: 14px; cursor: pointer; }
.ai-progress { padding: 6px 12px; background: rgba(79, 70, 229, 0.06); border-radius: 8px; font-size: 13px; color: var(--primary, #4f46e5); }
.ai-error { padding: 6px 12px; background: rgba(239, 68, 68, 0.06); border-radius: 8px; font-size: 13px; color: #dc2626; display: flex; align-items: center; gap: 8px; }
.file-list { max-height: 200px; overflow-y: auto; border: 1px solid var(--border, #e2e8f0); border-radius: 8px; display: flex; flex-direction: column; }
.file-item { display: flex; align-items: center; gap: 8px; padding: 5px 10px; font-size: 13px; border-bottom: 1px solid var(--border, #f1f5f9); }
.file-item:last-child { border-bottom: none; }
.file-item.document { background: rgba(5, 150, 105, 0.04); }
.file-item.unsupported { opacity: 0.5; }
.file-icon { font-size: 16px; width: 24px; text-align: center; }
.file-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-size { color: var(--muted, #64748b); min-width: 56px; text-align: right; }
.file-status { min-width: 80px; text-align: right; font-size: 12px; }
.preview-slides { font-size: 13px; }
.preview-title { font-weight: 600; display: block; margin-bottom: 4px; }
.preview-scroll { max-height: 100px; overflow-y: auto; }
.preview-item { padding: 3px 0; }
.preview-titles { display: block; font-size: 12px; color: var(--muted, #64748b); margin-top: 1px; }
.import-actions { display: flex; gap: 8px; justify-content: flex-end; padding-top: 8px; border-top: 1px solid var(--border, #e2e8f0); }
.loading { text-align: center; padding: 24px; color: var(--muted, #64748b); font-size: 14px; }
.spin { display: inline-block; animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
+185
View File
@@ -0,0 +1,185 @@
<!-- =====================================================================
PrintModal.vue PDF 导出全量幻灯片打印预览
渲染所有 slide隐藏编辑 UI纯展示
===================================================================== -->
<script setup lang="ts">
import type { Slide } from '../../core/types'
import { store, resolveBg, resolveColor } from '../../core/store'
const emit = defineEmits<{ (e: 'close'): void }>()
const deck = store.getDeck()
const slides = deck.slides
function onPrint() { window.print() }
</script>
<template>
<div class="print-modal" @print="onPrint">
<!-- 操作栏打印时不显示 -->
<div class="print-bar no-print">
<button class="btn primary" @click="onPrint">🖨 打印 / 另存为 PDF</button>
<span class="muted">{{ slides.length }} </span>
<span class="sep"></span>
<button class="btn ghost" @click="emit('close')"> 关闭</button>
</div>
<!-- 所有幻灯片逐页渲染 -->
<div
v-for="(slide, i) in slides"
:key="slide.id"
class="print-page"
:style="{ background: resolveBg(slide.background) }"
>
<!-- 页码每页第一个标题元素作为内容提示 -->
<div class="print-number">{{ i + 1 }}</div>
<!-- 元素渲染 -->
<div
v-for="el in slide.elements"
:key="el.id"
class="el"
:data-type="el.type"
:style="{
left: el.x + '%',
top: el.y + '%',
width: el.w + '%',
height: el.h + '%',
fontSize: el.style?.fontSize ? el.style.fontSize + 'px' : '',
color: resolveColor(el.style?.color, false),
textAlign: el.style?.align || undefined,
fontWeight: el.style?.bold ? 'bold' : undefined,
fontStyle: el.style?.italic ? 'italic' : undefined,
}"
>
<!-- 标题/正文/引用 -->
<template v-if="['title', 'text', 'quote'].includes(el.type)">
<div class="el-text">{{ el.content }}</div>
</template>
<!-- 列表 -->
<template v-else-if="el.type === 'list'">
<div class="el-list">
<div v-for="(line, li) in el.content.split('\n')" :key="li" class="li">{{ line }}</div>
</div>
</template>
<!-- 数据 -->
<template v-else-if="el.type === 'stat'">
<div class="el-stat">
<div class="num" :style="{ fontSize: el.style?.fontSize ? el.style.fontSize + 'px' : '', color: resolveColor(el.style?.color, false) }">{{ el.content }}</div>
<div class="label" v-if="el.style?.label">{{ el.style.label }}</div>
</div>
</template>
<!-- 图片 -->
<img v-else-if="el.type === 'image'" class="el-image" :src="el.content" draggable="false" />
<!-- 形状 -->
<template v-else-if="el.type === 'shape'">
<div
class="el-shape"
:style="{
borderRadius: el.style?.shapeType === 'circle' ? '50%' : (el.style?.radius || 0) + 'px',
background: resolveBg(el.style?.fill || 'accent'),
opacity: el.style?.opacity ?? 1,
}"
></div>
</template>
<!-- 卡片 -->
<template v-else-if="el.type === 'card'">
<div class="el-card-bar" :style="{ background: resolveBg(el.style?.accent || 'primary') }"></div>
<div class="el-card">
<div class="card-icon" v-if="el.style?.icon">{{ el.style.icon }}</div>
<div class="card-title">{{ el.content.split('\n')[0] }}</div>
<div class="card-body">{{ el.content.split('\n').slice(1).join('\n') }}</div>
</div>
</template>
<!-- 图表 -> 简化为文本描述 -->
<template v-else-if="el.type === 'chart'">
<div class="el-chart">
<div class="chart-text">[{{ el.style?.chartType || 'bar' }} 图表]</div>
<div class="chart-text muted">{{ el.content.slice(0, 60) }}</div>
</div>
</template>
<!-- 其他table/code/formula-> 按文本显示 -->
<template v-else>
<div class="el-text">{{ el.content }}</div>
</template>
</div>
</div>
</div>
</template>
<style scoped>
/* 屏幕上的容器 */
.print-modal {
position: fixed; inset: 0; z-index: 900;
background: #e2e8f0;
overflow-y: auto;
}
.print-bar {
position: sticky; top: 0; z-index: 10;
display: flex; align-items: center; gap: 12px;
padding: 12px 24px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
}
.print-page {
width: 210mm; min-height: 297mm;
margin: 12mm auto;
padding: 20mm 25mm;
background: #fff;
box-shadow: 0 2px 16px rgba(0,0,0,0.1);
position: relative;
page-break-after: always;
overflow: hidden;
}
.print-number {
position: absolute; bottom: 8mm; right: 10mm;
font-size: 11px; color: #94a3b8;
}
/* 打印样式 */
@media print {
.no-print { display: none !important; }
.print-modal { position: static; background: none; overflow: visible; }
.print-page {
margin: 0; padding: 0;
box-shadow: none;
width: 100%; height: 100vh;
min-height: auto;
page-break-after: always;
/* 确保背景色打印 */
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
/* 元素样式 */
.el { position: absolute; overflow: hidden; }
.el-text {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.5;
height: 100%; width: 100%;
}
.el-list { white-space: pre-wrap; line-height: 1.6; }
.el-list .li::before { content: '• '; }
.el-stat { display: flex; flex-direction: column; justify-content: center; align-items: center; height: 100%; }
.el-stat .num { font-weight: 700; line-height: 1.2; }
.el-stat .label { font-size: 14px; color: #64748b; margin-top: 4px; text-align: center; }
.el-image { width: 100%; height: 100%; object-fit: contain; }
.el-shape { width: 100%; height: 100%; }
.el-card-bar { position: absolute; top: 0; left: 0; right: 0; height: 6px; border-radius: 4px 4px 0 0; }
.el-card { padding: 12px; }
.card-title { font-size: 18px; font-weight: 600; margin-bottom: 6px; }
.card-body { font-size: 13px; color: #475569; white-space: pre-wrap; line-height: 1.5; }
.el-chart { display: flex; flex-direction: column; justify-content: center; align-items: center; height: 100%; gap: 6px; }
.chart-text { font-size: 14px; color: #1e293b; text-align: center; }
.muted { color: #94a3b8; }
.sep { flex: 1; }
</style>