新增: 首页 + 资料导入 + PDF导出 + AI文档生成
This commit is contained in:
+60
-8
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 || '让这页内容更有吸引力、表达更精炼'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 图片导入 → SlideElement(image 类型)
|
||||
* ============================================================ */
|
||||
|
||||
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(',') || '未导入任何内容'
|
||||
}
|
||||
Reference in New Issue
Block a user