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

@@ -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",

View File

@@ -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">

View File

@@ -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 || ''

View File

@@ -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

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 {

View File

@@ -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

View File

@@ -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()

View File

@@ -28,7 +28,7 @@ export const useConfigStore = defineStore('config', () => {
const data = await readConfig<Config>('config.json')
config.value = {
...defaultConfig,
...data
...(data || {})
}
}

View File

@@ -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
}
}

View 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>

View File

@@ -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)