feat:导出功能重构
This commit is contained in:
@@ -26,16 +26,24 @@
|
||||
"@vue-flow/minimap": "^1.5.4",
|
||||
"@vueuse/core": "^14.0.0",
|
||||
"axios": "^1.12.2",
|
||||
"d3": "^7.9.0",
|
||||
"docx-preview": "^0.3.7",
|
||||
"dompurify": "^3.3.0",
|
||||
"element-plus": "^2.11.5",
|
||||
"lodash": "^4.17.21",
|
||||
"markdown-it": "^14.1.0",
|
||||
"marked": "^17.0.4",
|
||||
"markmap": "^0.6.1",
|
||||
"markmap-lib": "^0.18.12",
|
||||
"markmap-view": "^0.18.12",
|
||||
"pinia": "^3.0.3",
|
||||
"pptxjs": "^0.0.0",
|
||||
"qs": "^6.14.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"uuid": "^13.0.0",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.6.3"
|
||||
"vue-router": "^4.6.3",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.15",
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
import Layout from './layout/index.vue'
|
||||
import Share from './views/Share.vue'
|
||||
|
||||
const route = useRoute()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout />
|
||||
<!-- 分享页面使用独立布局 -->
|
||||
<Share v-if="route.path.startsWith('/share/')" />
|
||||
<!-- 其他页面使用主布局 -->
|
||||
<Layout v-else />
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -146,7 +146,7 @@ class Api {
|
||||
rehearsalLog?: any[],
|
||||
TaskID?: string,
|
||||
) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
||||
void _useWebSocket // 保留参数位置以保持兼容性
|
||||
const data = {
|
||||
RehearsalLog: rehearsalLog || [], // 使用传递的 RehearsalLog
|
||||
@@ -999,12 +999,8 @@ class Api {
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
return response.data
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除导出记录
|
||||
*/
|
||||
deleteExport = async (recordId: number): Promise<boolean> => {
|
||||
const configStore = useConfigStoreHook()
|
||||
const baseURL = configStore.config.apiBaseUrl || ''
|
||||
|
||||
@@ -1 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1772506712715" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5360" xmlns:xlink="http://www.w3.org/1999/xlink" width="256" height="256"><path d="M279.5 354.3l198-162.8V734c0 19.3 15.7 35 35 35s35-15.7 35-35V191.6l197.1 162.6c6.5 5.4 14.4 8 22.3 8 10.1 0 20.1-4.3 27-12.7 12.3-14.9 10.2-37-4.7-49.3L534.7 90.4c-0.2-0.2-0.4-0.3-0.6-0.5-0.2-0.1-0.4-0.3-0.5-0.4-0.6-0.5-1.2-0.9-1.8-1.3-0.6-0.4-1.3-0.8-2-1.2-0.1-0.1-0.2-0.1-0.3-0.2-1.4-0.8-2.9-1.5-4.4-2.1h-0.1c-1.5-0.6-3.1-1.1-4.7-1.4h-0.2c-0.7-0.2-1.5-0.3-2.2-0.4h-0.2c-0.8-0.1-1.5-0.2-2.3-0.3h-0.3c-0.6 0-1.3-0.1-1.9-0.1h-3.1c-0.6 0-1.2 0.1-1.8 0.2-0.2 0-0.4 0-0.6 0.1l-2.1 0.3h-0.1c-0.7 0.1-1.4 0.3-2.1 0.5-0.1 0-0.3 0.1-0.4 0.1-1.5 0.4-2.9 0.9-4.3 1.5-0.1 0-0.1 0.1-0.2 0.1-1.5 0.6-2.9 1.4-4.3 2.2-1.4 0.9-2.8 1.8-4.1 2.9L235 300.3c-14.9 12.3-17.1 34.3-4.8 49.3 12.3 14.9 34.3 17 49.3 4.7z" fill="#ffffff" p-id="5361"></path><path d="M925.8 598.2c-19.3 0-35 15.7-35 35v238.4H133.2V633.2c0-19.3-15.7-35-35-35s-35 15.7-35 35v273.4c0 19.3 15.7 35 35 35h827.5c19.3 0 35-15.7 35-35V633.2c0.1-19.3-15.6-35-34.9-35z" p-id="5362"></path></svg>
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1772506712715" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5360" xmlns:xlink="http://www.w3.org/1999/xlink" width="256" height="256"><path d="M279.5 354.3l198-162.8V734c0 19.3 15.7 35 35 35s35-15.7 35-35V191.6l197.1 162.6c6.5 5.4 14.4 8 22.3 8 10.1 0 20.1-4.3 27-12.7 12.3-14.9 10.2-37-4.7-49.3L534.7 90.4c-0.2-0.2-0.4-0.3-0.6-0.5-0.2-0.1-0.4-0.3-0.5-0.4-0.6-0.5-1.2-0.9-1.8-1.3-0.6-0.4-1.3-0.8-2-1.2-0.1-0.1-0.2-0.1-0.3-0.2-1.4-0.8-2.9-1.5-4.4-2.1h-0.1c-1.5-0.6-3.1-1.1-4.7-1.4h-0.2c-0.7-0.2-1.5-0.3-2.2-0.4h-0.2c-0.8-0.1-1.5-0.2-2.3-0.3h-0.3c-0.6 0-1.3-0.1-1.9-0.1h-3.1c-0.6 0-1.2 0.1-1.8 0.2-0.2 0-0.4 0-0.6 0.1l-2.1 0.3h-0.1c-0.7 0.1-1.4 0.3-2.1 0.5-0.1 0-0.3 0.1-0.4 0.1-1.5 0.4-2.9 0.9-4.3 1.5-0.1 0-0.1 0.1-0.2 0.1-1.5 0.6-2.9 1.4-4.3 2.2-1.4 0.9-2.8 1.8-4.1 2.9L235 300.3c-14.9 12.3-17.1 34.3-4.8 49.3 12.3 14.9 34.3 17 49.3 4.7z" p-id="5361"></path><path d="M925.8 598.2c-19.3 0-35 15.7-35 35v238.4H133.2V633.2c0-19.3-15.7-35-35-35s-35 15.7-35 35v273.4c0 19.3 15.7 35 35 35h827.5c19.3 0 35-15.7 35-35V633.2c0.1-19.3-15.6-35-34.9-35z" p-id="5362"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
@@ -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 {
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Share from '@/views/Share.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [],
|
||||
routes: [
|
||||
{
|
||||
path: '/share/:token',
|
||||
name: 'share',
|
||||
component: Share,
|
||||
meta: { title: '文件分享' }
|
||||
}
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -336,8 +336,10 @@ export const useAgentsStore = defineStore('agents', () => {
|
||||
)
|
||||
// 监听 configStore.config.agentRepository.storageVersionIdentifier 改变
|
||||
watch(
|
||||
() => configStore.config.agentRepository.storageVersionIdentifier,
|
||||
() => configStore.config.agentRepository?.storageVersionIdentifier,
|
||||
(value) => {
|
||||
// 跳过无效值
|
||||
if (!value) return
|
||||
// value与storageVersionIdentifier不一致清除所有storageKey开头的localStorage
|
||||
if (value !== storageVersionIdentifier.value) {
|
||||
clearStorageByVersion()
|
||||
|
||||
@@ -28,7 +28,7 @@ export const useConfigStore = defineStore('config', () => {
|
||||
const data = await readConfig<Config>('config.json')
|
||||
config.value = {
|
||||
...defaultConfig,
|
||||
...data
|
||||
...(data || {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
export async function readConfig<T>(fileName = 'config.json'): Promise<T> {
|
||||
const url = `${location.protocol}//${location.host}${location.pathname}${fileName}`
|
||||
return await fetch(url).then<T>((res) => res.json())
|
||||
export async function readConfig<T>(fileName = 'config.json'): Promise<T | null> {
|
||||
try {
|
||||
const url = `${location.protocol}//${location.host}${location.pathname}${fileName}`
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
console.warn(`Config file not found: ${url}, status: ${res.status}`)
|
||||
return null
|
||||
}
|
||||
return await res.json() as T
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load config file: ${fileName}`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
325
frontend/src/views/Share.vue
Normal file
325
frontend/src/views/Share.vue
Normal file
@@ -0,0 +1,325 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Download } from '@element-plus/icons-vue'
|
||||
|
||||
interface ShareInfo {
|
||||
file_name: string
|
||||
export_type: string
|
||||
created_at: string
|
||||
file_size: number
|
||||
}
|
||||
|
||||
// 获取 API 地址
|
||||
const getApiBaseUrl = (): string => {
|
||||
// 尝试从 localStorage 获取配置
|
||||
const configStr = localStorage.getItem('app_config')
|
||||
if (configStr) {
|
||||
try {
|
||||
const config = JSON.parse(configStr)
|
||||
if (config.apiBaseUrl) {
|
||||
return config.apiBaseUrl
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
// 返回 /api 作为基础路径
|
||||
return '/api'
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const loading = ref(true)
|
||||
const shareInfo = ref<ShareInfo | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// 解析分享 token
|
||||
const parseShareToken = (token: string): { recordId: number; timestamp: number } | null => {
|
||||
// 格式: export_{id}_{timestamp}
|
||||
const match = token.match(/^export_(\d+)_(\d+)$/)
|
||||
if (!match) return null
|
||||
return {
|
||||
recordId: parseInt(match[1], 10),
|
||||
timestamp: parseInt(match[2], 10)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取分享信息
|
||||
const fetchShareInfo = async () => {
|
||||
const token = route.params.token as string
|
||||
console.log('分享 token:', token)
|
||||
|
||||
if (!token) {
|
||||
error.value = '无效的分享链接'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = parseShareToken(token)
|
||||
if (!parsed) {
|
||||
error.value = '无效的分享链接格式'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
console.log('解析后的 recordId:', parsed.recordId)
|
||||
|
||||
try {
|
||||
// 从配置获取 API 地址
|
||||
const apiBaseUrl = getApiBaseUrl()
|
||||
const url = `${apiBaseUrl}/export/${parsed.recordId}/share/info`
|
||||
console.log('请求 URL:', url)
|
||||
|
||||
// 添加超时
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000)
|
||||
|
||||
const response = await fetch(url, { signal: controller.signal })
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
console.log('响应状态:', response.status)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
error.value = '分享链接不存在或已失效'
|
||||
} else {
|
||||
error.value = '获取分享信息失败'
|
||||
}
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
shareInfo.value = await response.json()
|
||||
console.log('分享信息:', shareInfo.value)
|
||||
} catch (e: any) {
|
||||
console.error('获取分享信息失败:', e)
|
||||
if (e.name === 'AbortError') {
|
||||
error.value = '请求超时,请稍后重试'
|
||||
} else {
|
||||
error.value = '网络错误,请稍后重试'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
const downloadFile = async () => {
|
||||
if (!shareInfo.value) return
|
||||
|
||||
try {
|
||||
const apiBaseUrl = getApiBaseUrl()
|
||||
const token = route.params.token as string
|
||||
const parsed = parseShareToken(token)
|
||||
|
||||
if (!parsed) {
|
||||
ElMessage.error('无效的分享链接')
|
||||
return
|
||||
}
|
||||
|
||||
// 触发下载
|
||||
window.location.href = `${apiBaseUrl}/api/export/${parsed.recordId}/download`
|
||||
} catch (e) {
|
||||
console.error('下载失败:', e)
|
||||
ElMessage.error('下载失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr: string): string => {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取文件类型显示名称
|
||||
const getFileTypeName = (type: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
'doc': 'Word 文档',
|
||||
'markdown': 'Markdown',
|
||||
'mindmap': '思维导图',
|
||||
'infographic': '信息图',
|
||||
'excel': 'Excel 表格',
|
||||
'ppt': 'PPT 演示文稿'
|
||||
}
|
||||
return typeMap[type] || type
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 设置页面标题
|
||||
document.title = '文件分享'
|
||||
fetchShareInfo()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="share-page">
|
||||
<div class="share-container">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="error" class="error-state">
|
||||
<div class="error-icon">!</div>
|
||||
<h2>出错了</h2>
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 成功状态 -->
|
||||
<div v-else-if="shareInfo" class="success-state">
|
||||
<div class="success-icon">
|
||||
<svg viewBox="0 0 24 24" width="64" height="64">
|
||||
<path fill="currentColor" d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2>{{ shareInfo.file_name }}</h2>
|
||||
<div class="file-info">
|
||||
<div class="info-item">
|
||||
<span class="label">文件类型:</span>
|
||||
<span class="value">{{ getFileTypeName(shareInfo.export_type) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">创建时间:</span>
|
||||
<span class="value">{{ formatDate(shareInfo.created_at) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">文件大小:</span>
|
||||
<span class="value">{{ formatFileSize(shareInfo.file_size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-button type="primary" size="large" :icon="Download" @click="downloadFile" class="download-btn">
|
||||
下载文件
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.share-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.share-container {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
}
|
||||
|
||||
.error-state {
|
||||
.error-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: #fee;
|
||||
color: #c00;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.success-state {
|
||||
.success-icon {
|
||||
color: #667eea;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #333;
|
||||
margin-bottom: 24px;
|
||||
word-break: break-all;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
text-align: left;
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -48,8 +48,8 @@ export default defineConfig({
|
||||
changeOrigin: true,
|
||||
// 接口地址
|
||||
// target: 'http://82.157.183.212:21092',
|
||||
target: 'http://82.157.183.212:21097',
|
||||
// target: 'http://localhost:8000',
|
||||
// target: 'http://82.157.183.212:21097',
|
||||
target: 'http://localhost:8000',
|
||||
// rewrite: (path: string) => path.replace(/^\/api/, ''),
|
||||
// configure: (proxy, options) => {
|
||||
// console.log('Proxy configured:', options)
|
||||
|
||||
Reference in New Issue
Block a user