feat:导出功能重构

This commit is contained in:
liailing1026
2026-03-11 17:46:42 +08:00
parent 14b79bc282
commit 26c42697e8
20 changed files with 4070 additions and 126 deletions

View File

@@ -29,6 +29,7 @@
</div>
<div class="result-actions">
<el-popover
:ref="el => setPopoverRef(index, el)"
placement="bottom-end"
:width="120"
:show-arrow="false"
@@ -86,14 +87,32 @@
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载中...</span>
</div>
<div v-else-if="previewData?.type === 'markdown'" class="preview-markdown">
<pre>{{ previewData?.content }}</pre>
<div v-else-if="previewData?.isWord" id="docx-preview-container" class="preview-docx"></div>
<!-- Markdown/Mindmap 预览容器 -->
<div
v-else-if="previewData?.type === 'markdown' || previewData?.type === 'mindmap'"
class="preview-markdown"
v-html="renderedMarkdown"
></div>
<div v-else-if="previewData?.isExcel" id="excel-preview-container" class="preview-excel">
<el-table
v-if="previewData?.excelData?.length > 1"
:data="previewData.excelData.slice(1)"
stripe
border
style="width: 100%"
>
<el-table-column
v-for="(col, idx) in previewData.excelData[0]"
:key="idx"
:prop="String(idx)"
:label="String(col)"
/>
</el-table>
<div v-else class="excel-empty">暂无数据</div>
</div>
<div v-else-if="previewData?.file_url" class="preview-iframe">
<iframe :src="previewData?.file_url" frameborder="0"></iframe>
<div class="preview-tip">该文件类型暂不支持直接预览请下载查看</div>
</div>
<div v-else class="preview-empty">无法预览此文件</div>
<div v-else-if="previewData?.isPpt" id="ppt-preview-container" class="preview-ppt"></div>
<div v-else class="preview-empty">该文件类型暂不支持预览请下载查看</div>
</div>
<template #footer>
<el-button @click="previewVisible = false">关闭</el-button>
@@ -104,15 +123,17 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { marked } from 'marked'
import { ElMessage } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import SvgIcon from '@/components/SvgIcon/index.vue'
import DeleteConfirmDialog from '@/components/DeleteConfirmDialog/index.vue'
import { useAgentsStore } from '@/stores'
import { useAgentsStore, useConfigStoreHook } from '@/stores'
import { useNotification } from '@/composables/useNotification'
import api from '@/api'
const agentsStore = useAgentsStore()
const notification = useNotification()
// Props 接收大任务ID数据库主键
const props = defineProps<{
@@ -175,8 +196,49 @@ const previewData = ref<{
file_url?: string
file_name?: string
type: string
isWord?: boolean
isExcel?: boolean
excelData?: any[]
excelSheetName?: string
isPpt?: boolean
}>({ type: '' })
// Markdown 渲染
const renderedMarkdown = computed(() => {
if (previewData.value.content && (previewData.value.type === 'markdown' || previewData.value.type === 'mindmap')) {
return marked(previewData.value.content)
}
return ''
})
// Popover 引用管理
const popoverRefs = ref<Map<number, any>>(new Map())
const setPopoverRef = (index: number, el: any) => {
if (el) {
popoverRefs.value.set(index, el)
}
}
const closeAllPopovers = () => {
popoverRefs.value.forEach(popover => {
popover?.hide()
})
}
// 判断是否为 Word 文件
const isWord = (ext?: string) => {
return ['doc', 'docx'].includes(ext?.toLowerCase() || '')
}
// 判断是否为 Excel 文件
const isExcel = (ext?: string) => {
return ['xlsx', 'xls', 'excel'].includes(ext?.toLowerCase() || '')
}
// 判断是否为 PPT 文件
const isPPT = (ext?: string) => {
return ['pptx', 'ppt'].includes(ext?.toLowerCase() || '')
}
// 格式化时间显示
const formatTime = (timestamp: any): string => {
if (!timestamp) return '未知时间'
@@ -222,8 +284,8 @@ const fetchExportList = async () => {
const result = await api.getExportList({ task_id: taskId })
// 转换为前端显示格式
exportResults.value = result.list.map((item) => {
const style = exportStyles.value.find((s) => s.type === item.export_type)
exportResults.value = result.list.map(item => {
const style = exportStyles.value.find(s => s.type === item.export_type)
return {
id: item.id,
record_id: item.id,
@@ -235,7 +297,7 @@ const fetchExportList = async () => {
created_at: item.created_at,
file_url: item.file_url,
file_name: item.file_name,
export_type: item.export_type,
export_type: item.export_type
}
})
@@ -247,42 +309,165 @@ const fetchExportList = async () => {
// 预览文件
const previewResult = async (result: ExportResult) => {
closeAllPopovers() // 关闭所有 popover
const recordId = result.id || result.record_id
if (!recordId) {
ElMessage.error('无法获取文件ID')
return
}
// 先初始化数据,再显示对话框,避免渲染时属性不存在
const fileUrl = result.file_url || ''
const fileName = result.file_name || '文件预览'
// 从 file_name 中提取扩展名
const ext = fileName.split('.').pop()?.toLowerCase() || ''
// 先初始化数据
previewData.value = {
content: '',
file_url: '',
file_name: result.file_name || '文件预览',
type: result.type || ''
file_url: fileUrl,
file_name: fileName,
type: result.type || '',
isWord: false,
isExcel: false,
isPpt: false
}
previewVisible.value = true
previewLoading.value = true
try {
const data = await api.previewExport(recordId)
previewData.value = {
...previewData.value,
...data,
previewVisible.value = true
// 如果是 Word 文件,先获取数据
if (isWord(ext)) {
try {
// 先显示loading
previewLoading.value = true
// 调用 preview 接口获取文件内容(后端直接返回文件流)
const response = await fetch(`/api/export/${recordId}/preview`)
if (!response.ok) {
ElMessage.error('获取文件失败')
previewLoading.value = false
return
}
// 获取 blob
const blob = await response.blob()
console.log('Word blob created, size:', blob.size)
if (blob.size === 0) {
ElMessage.error('文件内容为空')
previewLoading.value = false
return
}
// 关闭loading让容器显示出来
previewLoading.value = false
previewData.value.isWord = true
// 等待 DOM 渲染容器
await new Promise(resolve => setTimeout(resolve, 100))
// 导入 docx-preview 并渲染
const docxPreview = await import('docx-preview')
// 获取预览容器
const container = document.getElementById('docx-preview-container')
console.log('Container found:', !!container, container?.id)
if (container) {
container.innerHTML = ''
// 使用 renderAsync 并等待完成inWrapper: false 避免创建额外包装器
await docxPreview.renderAsync(blob, container, undefined, {
inWrapper: false
})
console.log('Word docx-preview rendering completed')
}
} catch (error) {
console.error('Word 预览失败:', error)
previewLoading.value = false
ElMessage.error('Word 预览失败')
}
} else if (isExcel(ext)) {
// Excel 文件预览
try {
previewLoading.value = true
const response = await fetch(`/api/export/${recordId}/preview`)
if (!response.ok) {
ElMessage.error('获取文件失败')
previewLoading.value = false
return
}
const blob = await response.blob()
console.log('Excel blob created, size:', blob.size)
if (blob.size === 0) {
ElMessage.error('文件内容为空')
previewLoading.value = false
return
}
// 读取 Excel 文件
const XLSX = await import('xlsx')
const arrayBuffer = await blob.arrayBuffer()
const workbook = XLSX.read(arrayBuffer, { type: 'array' })
// 获取第一个sheet
const firstSheetName = workbook.SheetNames[0]
const worksheet = workbook.Sheets[firstSheetName]
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][]
console.log('Excel data parsed, rows:', jsonData.length)
// 关闭loading显示数据
previewLoading.value = false
previewData.value.isExcel = true
previewData.value.excelData = jsonData
previewData.value.excelSheetName = firstSheetName
} catch (error) {
console.error('Excel 预览失败:', error)
previewLoading.value = false
ElMessage.error('Excel 预览失败')
}
} else if (isPPT(ext)) {
// PPT 文件预览 - 由于技术限制,暂时提示下载查看
// 如需完整预览功能,可以考虑部署后使用 Microsoft Office Online 或其他方案
ElMessage.info('PPT文件暂不支持预览请下载查看')
previewLoading.value = false
} catch (error) {
console.error('预览失败:', error)
ElMessage.error('预览失败')
previewLoading.value = false
} else {
// 其他文件类型Markdown 等),调用 preview 接口获取内容
try {
previewLoading.value = true
const response = await fetch(`/api/export/${recordId}/preview`)
const data = await response.json()
if (data.error) {
ElMessage.error(data.error)
previewLoading.value = false
return
}
// 设置内容
if (data.content) {
previewData.value.content = data.content
previewData.value.type = data.type || ext
}
} catch (error) {
console.error('获取预览内容失败:', error)
} finally {
previewLoading.value = false
}
}
}
// 预览弹窗中的下载按钮
const handlePreviewDownload = () => {
const fileName = previewData.value?.file_name
const recordId = fileName ? exportResults.value.find(
(r) => r.file_name === fileName
)?.id : null
const recordId = fileName ? exportResults.value.find(r => r.file_name === fileName)?.id : null
if (recordId) {
downloadById(recordId)
@@ -303,6 +488,8 @@ const downloadById = async (recordId: number) => {
// 下载文件
const downloadResult = (result: ExportResult) => {
closeAllPopovers() // 关闭所有 popover
const recordId = result.id || result.record_id
if (!recordId) {
ElMessage.error('无法获取文件ID')
@@ -314,6 +501,8 @@ const downloadResult = (result: ExportResult) => {
// 分享
const shareResult = async (result: ExportResult) => {
closeAllPopovers() // 关闭所有 popover
const recordId = result.id || result.record_id
if (!recordId) {
ElMessage.error('无法获取文件ID')
@@ -334,6 +523,8 @@ const shareResult = async (result: ExportResult) => {
// 删除
const deleteResult = (result: ExportResult) => {
closeAllPopovers() // 关闭所有 popover
resultToDelete.value = result
dialogVisible.value = true
}
@@ -354,9 +545,7 @@ const confirmDelete = async () => {
const success = await api.deleteExport(recordId)
if (success) {
// 从列表中移除
const index = exportResults.value.findIndex(
(r) => (r.id || r.record_id) === recordId
)
const index = exportResults.value.findIndex(r => (r.id || r.record_id) === recordId)
if (index > -1) {
exportResults.value.splice(index, 1)
}
@@ -392,13 +581,22 @@ const handleSelect = async (item: ExportStyle) => {
// 添加加载状态
loadingTypes.value.push(item.type)
// 显示正在生成的通知
const notifyId = notification.info(`正在生成${item.name}...`, '请稍候', {
duration: 0, // 不自动关闭
showProgress: true,
progress: 0
})
try {
// 调用后端接口导出(导出整个大任务的执行结果)
console.log('开始导出 - task_id:', taskId, 'type:', item.type)
// 获取实际用户ID
const userId = localStorage.getItem('user_id')
const result = await api.exportTask({
task_id: taskId,
export_type: item.type,
user_id: 'current_user', // TODO: 获取实际用户ID
user_id: userId || ''
})
console.log('导出结果:', result)
@@ -414,15 +612,31 @@ const handleSelect = async (item: ExportStyle) => {
created_at: new Date().toISOString(),
file_url: result.file_url,
file_name: result.file_name,
export_type: item.type,
export_type: item.type
}
exportResults.value.unshift(newItem)
console.log('导出成功:', result)
// 更新通知为成功状态
notification.updateProgressDetail(notifyId, `${item.name}生成完成`, result.file_name || item.name, 100, 100)
// 3秒后关闭通知
setTimeout(() => {
notification.removeNotification(notifyId)
}, 3000)
ElMessage.success(`导出成功: ${item.name}`)
} catch (error) {
console.error('导出失败:', error)
// 更新通知为失败状态
notification.updateProgressDetail(notifyId, `${item.name}生成失败`, '请重试', 0, 100)
// 5秒后关闭通知
setTimeout(() => {
notification.removeNotification(notifyId)
}, 5000)
ElMessage.error(`导出失败: ${item.name}`)
} finally {
// 移除加载状态
@@ -476,7 +690,6 @@ onMounted(() => {
}
&.is-loading {
opacity: 0.6;
cursor: not-allowed;
}
@@ -625,17 +838,161 @@ onMounted(() => {
}
.preview-markdown {
min-height: 300px;
max-height: 55vh;
overflow: auto;
padding: 16px;
background: var(--color-bg-detail);
border-radius: 8px;
line-height: 1.6;
color: var(--el-text-color-primary);
:deep(h1),
:deep(h2),
:deep(h3),
:deep(h4),
:deep(h5),
:deep(h6) {
margin-top: 16px;
margin-bottom: 8px;
font-weight: 600;
}
:deep(h1) {
font-size: 24px;
border-bottom: 1px solid var(--el-border-color);
padding-bottom: 8px;
}
:deep(h2) {
font-size: 20px;
}
:deep(h3) {
font-size: 18px;
}
:deep(p) {
margin: 8px 0;
}
:deep(ul),
:deep(ol) {
padding-left: 24px;
margin: 8px 0;
}
:deep(li) {
margin: 4px 0;
}
:deep(code) {
background: var(--el-fill-color-light);
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
font-size: 13px;
}
:deep(pre) {
background: var(--el-fill-color-light);
padding: 12px;
border-radius: 8px;
overflow-x: auto;
code {
background: none;
padding: 0;
}
}
:deep(blockquote) {
border-left: 4px solid var(--el-color-primary);
margin: 8px 0;
padding-left: 16px;
color: var(--el-text-color-secondary);
}
:deep(table) {
border-collapse: collapse;
width: 100%;
margin: 12px 0;
td,
th {
border: 1px solid var(--el-border-color);
padding: 8px;
text-align: left;
}
th {
background: var(--el-fill-color-light);
}
}
:deep(a) {
color: var(--el-color-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
:deep(img) {
max-width: 100%;
height: auto;
}
// Mindmap 样式
:deep(svg) {
width: 100%;
height: 100%;
min-height: 400px;
}
:deep(.markmap-node) {
cursor: pointer;
}
:deep(.markmap-node:hover) {
opacity: 0.8;
}
}
.preview-excel {
min-height: 300px;
max-height: 55vh;
overflow: auto;
padding: 16px;
background: var(--color-bg-detail);
border-radius: 8px;
pre {
white-space: pre-wrap;
word-wrap: break-word;
font-family: monospace;
font-size: 14px;
line-height: 1.6;
.excel-empty {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: var(--color-text-placeholder);
}
:deep(.el-table) {
font-size: 13px;
}
:deep(.el-table__header th) {
background: var(--el-fill-color-light) !important;
font-weight: 600;
}
}
.preview-docx {
min-height: 400px;
max-height: 55vh;
overflow: auto;
padding: 16px;
background: var(--color-text-detail);
border-radius: 8px;
}
.preview-iframe {