Files
u-ppt/src/components/modals/ImportModal.vue
T

482 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- =====================================================================
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,
fileToVideoElement,
ACCEPT_ATTR,
type FileEntry, type ImportReport,
describeReport
} from '../../core/importer'
import { putAsset, isOssEnabled } from '../../core/assets'
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 analyzeProgress = ref('') // AI 分析进度文案
const analyzeError = ref('') // AI 分析错误
const showImages = ref(true)
const showVideos = 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 === 'video' && !showVideos.value) return false
if (e.kind === 'document' && !showDocs.value) return false
return true
})
})
const imageEntries = computed(() => filteredEntries.value.filter(e => e.kind === 'image'))
const videoEntries = computed(() => filteredEntries.value.filter(e => e.kind === 'video'))
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 = ACCEPT_ATTR
fileInput.onchange = () => handleFiles(fileInput!.files ? Array.from(fileInput!.files) : null)
}
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 ? Array.from(dirInput!.files) : null)
}
dirInput.value = ''
dirInput.click()
}
/* ---------- 拖放导入(两个 dropzone 通用) ---------- */
const dragOver = ref(false)
function onDropzoneDragOver(e: DragEvent) {
if (!e.dataTransfer?.types.includes('Files')) return
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
dragOver.value = true
}
function onDropzoneDragLeave() {
dragOver.value = false
}
function onDropzoneDrop(e: DragEvent) {
e.preventDefault()
dragOver.value = false
const fl = e.dataTransfer?.files
if (fl && fl.length > 0) void handleFiles(Array.from(fl))
}
/** 供父组件预填拖入的文件(全局拖放 → 打开弹窗并直接进入预览态) */
function acceptDroppedFiles(fl: File[]) {
void handleFiles(fl)
}
defineExpose({ acceptDroppedFiles })
async function handleFiles(fl: File[] | null) {
if (!fl || fl.length === 0) return
loading.value = true
loaded.value = false
analyzeDone.value = false
analyzeProgress.value = ''
analyzeError.value = ''
try {
const scanned = await scanFiles(fl)
entries.value = scanned
// 读取文件原始内容
if (scanned.some(e => e.kind === 'image' || e.kind === 'video' || 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
analyzeProgress.value = ''
analyzeError.value = ''
let successCount = 0
for (const doc of docs) {
analyzeProgress.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
analyzeProgress.value = ''
analyzeDone.value = successCount > 0
if (successCount > 0 && !analyzeError.value) {
emit('toast', `AI 完成 ${successCount} 个文档分析,共生成 ${docs.reduce((s, d) => s + (d.slides?.length || 0), 0)} 页幻灯片`)
}
}
/* ---------- 执行导入 ---------- */
async function doImport() {
const images = imageEntries.value.filter(e => e.data)
const videos = videoEntries.value.filter(e => e.data)
const docs = docEntries.value.filter(e => e.slides && e.slides.length > 0)
if (images.length === 0 && videos.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
// 整次导入合并为一条 undo 记录
const batch = store.beginBatch()
// 图片体积闸门:localStorage 约 5MB 字符上限,超限导入后刷新会丢图
// (OSS 启用时走资产库引用,不占 localStorage,跳过闸门)
if (images.length > 0 && !isOssEnabled()) {
const imgChars = images.reduce((s, e) => s + (e.data?.length || 0), 0)
let deckChars = 0
try { deckChars = JSON.stringify(store.getDeck()).length } catch (e) { /* ignore */ }
const QUOTA_CHARS = 4_500_000
if (deckChars + imgChars > QUOTA_CHARS) {
emit('toast', '图片过大:导入后约 ' + ((deckChars + imgChars) / 1048576).toFixed(1) + 'MB,超本地存储上限(约 5MB),刷新可能丢失。请压缩图片、减少数量,或在 ☁ 云存储中启用 OSS')
return
}
}
// 1. 图片 → 当前幻灯片(OSS 启用时 content 存资产引用,绕过 5MB 墙)
if (images.length > 0) {
if (isOssEnabled()) {
if (images.length === 1) {
const ref = await putAsset(images[0].file)
const el = fileToImageElement(images[0].file, ref)
store.addElement('image', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style }, batch)
insertedImages = 1
} else {
const els = await Promise.all(images.map(async f => {
const ref = await putAsset(f.file)
return fileToImageElement(f.file, ref)
}))
// 复用网格布局(dataUrl 传空串,只取行列坐标/尺寸)
const grid = imagesToSlideElements(images.map(f => ({ file: f.file, dataUrl: '' })))
for (let i = 0; i < els.length; i++) {
els[i].x = grid[i].x; els[i].y = grid[i].y; els[i].w = grid[i].w; els[i].h = grid[i].h
store.addElement('image', { content: els[i].content, x: els[i].x, y: els[i].y, w: els[i].w, h: els[i].h, style: els[i].style }, batch)
insertedImages++
}
}
} else 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 }, batch)
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 }, batch)
insertedImages++
}
}
}
// 1.5 视频 → 当前幻灯片(OSS 启用时走资产库;每个视频一页,元素铺满合理区域)
if (videos.length > 0) {
for (const v of videos) {
const content = isOssEnabled() ? await putAsset(v.file) : v.data!
const el = fileToVideoElement(v.file, content)
if (videos.length === 1) {
store.addElement('video', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style }, batch)
insertedImages++
} else {
// 多视频:一视频一页
store.appendSlide({ id: `s-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, background: 'bg', elements: [el] }, batch)
insertedSlides++
}
}
}
// 2. AI 生成的幻灯片 → 追加到 deck
if (docs.length > 0) {
for (const d of docs) {
if (d.slides) {
for (const slide of d.slides) {
store.appendSlide(slide, batch)
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
analyzeProgress.value = ''
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"
:class="{ 'drag-over': dragOver }"
@click="openFilePicker"
@dragover="onDropzoneDragOver"
@dragleave="onDropzoneDragLeave"
@drop="onDropzoneDrop"
>
<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"
:class="{ 'drag-over': dragOver }"
@click="openDirPicker"
@dragover="onDropzoneDragOver"
@dragleave="onDropzoneDragLeave"
@drop="onDropzoneDrop"
>
<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="showVideos" /> 🎬 视频 ({{ videoEntries.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> {{ analyzeProgress || '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' ? '🖼' : entry.kind === 'video' ? '🎬' : '📄' }}</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>
<!-- 视频 -->
<template v-else-if="entry.kind === 'video' && 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.drag-over { border-color: var(--primary, #4f46e5); background: color-mix(in srgb, var(--primary, #4f46e5) 10%, 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>