新增: 首页 + 资料导入 + 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
+60 -8
View File
@@ -2,7 +2,7 @@
App.vue 根组件编辑/演示模式切换工具栏三栏布局弹窗
===================================================================== -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
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'
@@ -12,10 +12,13 @@ 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<'editor' | 'present'>('editor')
const mode = ref<'home' | 'editor' | 'present'>('home')
const presentVisible = ref(false)
const presentStartIndex = ref(0)
@@ -29,6 +32,9 @@ function switchTab(name: 'props' | 'ai') {
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('')
@@ -96,6 +102,7 @@ function onImportJson() {
try {
store.importJSON(reader.result as string)
toast('已导入')
if (mode.value === 'home') mode.value = 'editor'
} catch (e: any) {
toast('导入失败:' + (e?.message || String(e)))
}
@@ -105,13 +112,27 @@ function onImportJson() {
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() {
// 用浏览器打印 + @media print CSS 实现 PDF 导出
// 演示模式的 slide-layer 已有 print 样式
document.body.classList.add('printing')
window.print()
setTimeout(() => document.body.classList.remove('printing'), 500)
toast('在打印对话框中选择「另存为 PDF」')
printVisible.value = true
}
/* ---------- 全局快捷键(编辑模式) ---------- */
@@ -146,12 +167,32 @@ function onKey(e: KeyboardEvent) {
onMounted(() => {
document.addEventListener('keydown', onKey)
})
/* 文库关闭时若在首页且从文库加载了 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
@@ -163,6 +204,7 @@ onUnmounted(() => {
@save="onSave"
@open-templates="templateVisible = true"
@export-json="onExportJson"
@import-materials="onImportMaterials"
@import-json="onImportJson"
@export-pdf="onExportPdf"
/>
@@ -218,6 +260,16 @@ onUnmounted(() => {
@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>
+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>
+29
View File
@@ -383,6 +383,35 @@ export async function generate(opts: { topic: string; count?: number; signal?: A
return { action: 'create_all', slides }
}
/** 文档内容 → AI 分析后生成幻灯片 */
export async function generateFromDocument(opts: {
text: string
filename?: string
signal?: AbortSignal
}): Promise<{ action: 'create_all'; slides: Slide[] }> {
const SYS_DOC =
SYS_BASE +
'\n任务:分析下方提供的文档内容,将其提炼为 5-10 页专业演示稿(含封面 1 页 + 内容 3-7 页 + 结尾 1 页)。\n' +
'要求:\n' +
'- 提取文档中的核心观点、关键数据、重要结论,不要遗漏重要信息\n' +
'- 每页只有一个主题,标题具体、有信息量,如「Q3 营收增长 23%」而非「业绩回顾」\n' +
'- 正文有实质内容,包含具体数字、案例、来源;避免空泛口号\n' +
'- 合理运用 card(对比/分组)、stat(突出数据)、chart(趋势/分布)等元素\n' +
'- 每个主要元素都加 style.anim 入场动画\n' +
'- 封面用 g-primary 渐变,结尾可用 g-primary 或 g-deep,内容页用 bg/panel 浅底\n' +
'严格输出 JSON{"action":"create_all","slides":[...]}'
const messages: Message[] = [
{ role: 'system', content: SYS_DOC },
{ role: 'user', content: '文档' + (opts.filename ? '' + opts.filename + '' : '') + '内容如下:\n\n' + opts.text }
]
const r = await streamChat(messages, { jsonMode: true, signal: opts.signal })
if (!r.json) throw new Error('AI 输出无法解析为 JSON,请重试。')
const slides = normSlides(r.json.slides || r.json)
if (!slides.length) throw new Error('AI 未生成有效幻灯片,请重试或检查文档内容。')
return { action: 'create_all', slides }
}
/** 润色单页 */
export async function polish(opts: { slide: Slide; instruction?: string; signal?: AbortSignal }): Promise<{ action: 'update_page'; slide: Slide; note: string }> {
const instruction = opts.instruction || '让这页内容更有吸引力、表达更精炼'
+257
View File
@@ -0,0 +1,257 @@
/* =====================================================================
* importer.ts — 本地文件/目录资料导入引擎
*
* 设计原则:
* - 图片 → data URL → 直接插入 image 元素
* - 文档(PDF/DOCX/MD/TXT)→ 提取原始文本 → 由 LLM 分析生成幻灯片
* - LLM 理解文档语义,输出结构化 PPT,比规则解析更智能
* ===================================================================== */
import type { Slide, SlideElement } from './types'
import { createElement } from './sample'
/** 允许的图片扩展名 */
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico'])
/** 允许的文档扩展名(由 LLM 解析) */
const DOC_EXTS = new Set(['.md', '.markdown', '.txt', '.text', '.pdf', '.docx', '.doc'])
/** 文件分类结果 */
export interface FileEntry {
file: File
name: string
ext: string
kind: 'image' | 'document' | 'unsupported'
/** 图片的 data URL,或文档的原始文本 */
data?: string
/** AI 解析出的幻灯片(文档读取 + LLM 分析后填充) */
slides?: Slide[]
/** 错误信息 */
error?: string
}
/** 导入统计 */
export interface ImportReport {
images: number
slides: number
files: number
}
export function getFileKind(file: File): FileEntry['kind'] {
const name = file.name.toLowerCase()
const dot = name.lastIndexOf('.')
const ext = dot >= 0 ? name.slice(dot) : ''
if (IMAGE_EXTS.has(ext)) return 'image'
if (DOC_EXTS.has(ext)) return 'document'
return 'unsupported'
}
/** 读取文件为 ArrayBuffer */
function readAsArrayBuffer(file: File): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as ArrayBuffer)
reader.onerror = () => reject(new Error(`读取失败: ${file.name}`))
reader.readAsArrayBuffer(file)
})
}
/** 读取图片为 Data URL */
function readAsDataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(new Error(`读取失败: ${file.name}`))
reader.readAsDataURL(file)
})
}
/** 读取文本文件 */
function readAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(new Error(`读取失败: ${file.name}`))
reader.readAsText(file, 'utf-8')
})
}
/* ============================================================
* PDF 文本提取(仅提取原始文本,不做结构解析)
* ============================================================ */
export async function extractPdfText(file: File): Promise<string> {
const pdfjsLib: any = await import('pdfjs-dist')
pdfjsLib.GlobalWorkerOptions.workerSrc =
`https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.mjs`
const buffer = await readAsArrayBuffer(file)
const pdf = await pdfjsLib.getDocument({ data: buffer }).promise
const pages: string[] = []
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i)
const content = await page.getTextContent()
const lines = groupPdfTextItems(
content.items as Array<{ str: string; transform: number[] }>
)
pages.push(lines.join('\n'))
}
return pages.map((p, i) => `[p${i + 1}]\n${p}`).join('\n\n')
}
// 注意:阈值 5 基于坐标差值,多栏排版可能合并错乱,交由 LLM 理解时再纠正
function groupPdfTextItems(
items: Array<{ str: string; transform: number[] }>
): string[] {
const lines: string[] = []
let currentLine = ''
let lastY = 0
for (const item of items) {
const text = item.str
if (!text && text.length === 0) continue
const y = item.transform[5]
if (currentLine && Math.abs(y - lastY) > 5) {
lines.push(currentLine.trimEnd())
currentLine = text
} else {
currentLine +=
(currentLine && !currentLine.endsWith(' ') && !text.startsWith(' ') ? ' ' : '') + text
}
lastY = y
}
if (currentLine.trim()) lines.push(currentLine.trimEnd())
return lines
}
/* ============================================================
* DOCX 文本提取(转为 Markdown 文本,不解析为幻灯片)
* ============================================================ */
export async function extractDocxText(file: File): Promise<string> {
const mammoth: any = await import('mammoth')
const buffer = await readAsArrayBuffer(file)
const result = await mammoth.convertToMarkdown({ arrayBuffer: buffer })
return result.value
}
/* ============================================================
* 从文本文件直接读取
* ============================================================ */
async function readTextFile(file: File): Promise<string> {
return readAsText(file)
}
/* ============================================================
* 图片导入 → SlideElementimage 类型)
* ============================================================ */
export function fileToImageElement(file: File, dataUrl: string): SlideElement {
const isWide = file.type === 'image/svg+xml'
return createElement('image', {
content: dataUrl,
x: 15, y: 12, w: isWide ? 60 : 40, h: isWide ? 40 : 50,
style: { fit: 'contain', anim: 'fade' }
})
}
export function imagesToSlideElements(files: Array<{ file: File; dataUrl: string }>): SlideElement[] {
if (files.length === 1) {
return [fileToImageElement(files[0].file, files[0].dataUrl)]
}
const cols = Math.ceil(Math.sqrt(files.length))
const rows = Math.ceil(files.length / cols)
const cellW = Math.floor(80 / cols)
const cellH = Math.floor(60 / rows)
return files.map((f, i) => {
const col = i % cols
const row = Math.floor(i / cols)
return createElement('image', {
content: f.dataUrl,
x: 8 + col * cellW, y: 8 + row * cellH,
w: cellW - 4, h: cellH - 4,
style: { fit: 'cover', anim: 'fade' }
})
})
}
/* ============================================================
* 批量文件扫描与读取
* ============================================================ */
export async function scanFiles(fileList: FileList): Promise<FileEntry[]> {
const entries: FileEntry[] = []
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i]
const name = file.name
const dot = name.lastIndexOf('.')
const ext = dot >= 0 ? name.slice(dot).toLowerCase() : ''
const kind = getFileKind(file)
entries.push({ file, name, ext, kind })
}
return entries
}
/** 读取文件内容(图片 → data URL,文档 → 原始文本),原位修改 entries */
export async function readEntries(entries: FileEntry[]): Promise<void> {
for (const entry of entries) {
try {
if (entry.kind === 'image') {
entry.data = await readAsDataURL(entry.file)
} else if (entry.kind === 'document') {
if (entry.ext === '.pdf') {
entry.data = await extractPdfText(entry.file)
} else if (entry.ext === '.docx' || entry.ext === '.doc') {
entry.data = await extractDocxText(entry.file)
} else {
// .md / .markdown / .txt / .text
const text = await readTextFile(entry.file)
entry.data = text
}
}
} catch (e: any) {
entry.error = e?.message || String(e)
console.warn(`读取失败 ${entry.name}:`, e)
}
}
}
/** 使用 AI 将一个文档的文本内容解析为幻灯片(异步,调用 LLM) */
export async function aiAnalyzeDocument(
entry: FileEntry,
signal?: AbortSignal
): Promise<Slide[]> {
if (!entry.data || entry.kind !== 'document') {
throw new Error('没有可分析的文本内容')
}
const { generateFromDocument } = await import('./ai')
// 文档文本太长时截断(按 token 估算,保留关键部分:开头 + 结尾)
let text = entry.data
const MAX_CHARS = 12000
if (text.length > MAX_CHARS) {
const keepFront = Math.floor(MAX_CHARS * 0.5)
const keepTail = MAX_CHARS - keepFront
text = text.slice(0, keepFront) + '\n\n...(中间省略 ' + (text.length - MAX_CHARS) + ' 字符)...\n\n' + text.slice(-keepTail)
}
const result = await generateFromDocument({
text,
filename: entry.name,
signal
})
entry.slides = result.slides
return result.slides
}
/** 生成导入统计描述 */
export function describeReport(r: ImportReport): string {
const parts: string[] = []
if (r.slides > 0) parts.push(`AI 生成 ${r.slides} 页幻灯片`)
if (r.images > 0) parts.push(`插入 ${r.images} 张图片`)
if (r.files > 0) parts.push(`共处理 ${r.files} 个文件`)
return parts.join('') || '未导入任何内容'
}