新增: 首页 + 资料导入 + PDF导出 + AI文档生成
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user