feat:导出面板动态编写一半

This commit is contained in:
liailing1026
2026-03-05 11:00:21 +08:00
parent 7a8acc7375
commit 8cd3152c29
10 changed files with 1960 additions and 43 deletions

View File

@@ -1,6 +1,8 @@
import websocket from '@/utils/websocket'
import type { Agent, IApiStepTask, IRawPlanResponse, IRawStepTask } from '@/stores'
import { withRetry } from '@/utils/retry'
import request from '@/utils/request'
import { useConfigStoreHook } from '@/stores'
export interface ActionHistory {
ID: string
@@ -770,6 +772,209 @@ class Api {
return false
}
}
// ==================== 导出功能 ====================
/**
* 导出任务为指定格式
*/
exportTask = async (params: {
task_id: string
export_type: string
user_id?: string
}): Promise<{
record_id: number
file_name: string
file_url: string
file_size: number
export_type: string
}> => {
if (!websocket.connected) {
throw new Error('WebSocket未连接')
}
const rawResponse = await websocket.send('export', {
task_id: params.task_id,
export_type: params.export_type,
user_id: params.user_id || 'anonymous',
})
const response = this.extractResponse<{
record_id: number
file_name: string
file_url: string
file_size: number
export_type: string
}>(rawResponse)
if (response) {
console.log('导出成功:', response)
return response
}
throw new Error('导出失败')
}
/**
* 获取导出记录列表
*/
getExportList = async (params: {
task_id: string
}): Promise<{
list: Array<{
id: number
task_id: string
user_id: string
export_type: string
file_name: string
file_path: string
file_url: string
file_size: number
created_at: string
}>
total: number
}> => {
if (!websocket.connected) {
throw new Error('WebSocket未连接')
}
const rawResponse = await websocket.send('get_export_list', {
task_id: params.task_id,
})
const response = this.extractResponse<{
list: Array<{
id: number
task_id: string
user_id: string
export_type: string
file_name: string
file_path: string
file_url: string
file_size: number
created_at: string
}>
total: number
}>(rawResponse)
if (response) {
return response
}
return { list: [], total: 0 }
}
/**
* 下载导出文件
*/
downloadExport = async (recordId: number): Promise<void> => {
const configStore = useConfigStoreHook()
const baseURL = configStore.config.apiBaseUrl || ''
const url = `${baseURL}/api/export/${recordId}/download`
try {
const response = await fetch(url, {
method: 'GET',
})
if (!response.ok) {
throw new Error('下载失败')
}
// 获取文件名从 Content-Disposition 头
const contentDisposition = response.headers.get('Content-Disposition')
let fileName = 'download'
if (contentDisposition) {
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
if (match) {
fileName = match[1].replace(/['"]/g, '')
}
}
// 创建 Blob 并下载
const blob = await response.blob()
const downloadUrl = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = downloadUrl
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(downloadUrl)
} catch (error) {
console.error('下载失败:', error)
throw error
}
}
/**
* 预览导出文件
*/
previewExport = async (recordId: number): Promise<{
content?: string
file_url?: string
file_name?: string
type: string
}> => {
const configStore = useConfigStoreHook()
const baseURL = configStore.config.apiBaseUrl || ''
const url = `${baseURL}/api/export/${recordId}/preview`
const response = await request<{
content?: string
file_url?: string
file_name?: string
type: string
}>({
url,
method: 'GET',
})
return response.data
}
/**
* 生成分享链接
*/
shareExport = async (recordId: number): Promise<{
share_url: string
file_name: string
expired_at: string | null
}> => {
const configStore = useConfigStoreHook()
const baseURL = configStore.config.apiBaseUrl || ''
const url = `${baseURL}/api/export/${recordId}/share`
const response = await request<{
share_url: string
file_name: string
expired_at: string | null
}>({
url,
method: 'GET',
})
return response.data
}
/**
* 删除导出记录
*/
deleteExport = async (recordId: number): Promise<boolean> => {
const configStore = useConfigStoreHook()
const baseURL = configStore.config.apiBaseUrl || ''
const url = `${baseURL}/api/export/${recordId}`
try {
await request({
url,
method: 'DELETE',
})
console.log('删除导出记录成功:', recordId)
return true
} catch (error) {
console.error('删除导出记录失败:', error)
return false
}
}
}
export default new Api()

View File

@@ -20,7 +20,7 @@ onMounted(async () => {
agentsStore.setAgents(res)
}
await api.setAgents(
agentsStore.agents.map(item => pick(item, ['Name', 'Profile', 'apiUrl', 'apiKey', 'apiModel']))
agentsStore.agents.map(item => pick(item, ['Name', 'Profile', 'Icon', 'Classification', 'apiUrl', 'apiKey', 'apiModel']))
)
})

View File

@@ -6,6 +6,7 @@
v-for="item in exportStyles"
:key="item.type"
class="export-style-item"
:class="{ 'is-loading': loadingTypes.includes(item.type) }"
@click="handleSelect(item)"
>
<div class="style-icon" :style="{ color: item.color }">
@@ -18,13 +19,13 @@
<!-- 导出结果列表 -->
<div class="export-result-section">
<div v-if="exportResults.length > 0" class="result-list">
<div v-for="(result, index) in exportResults" :key="index" class="result-item">
<div v-for="(result, index) in exportResults" :key="result.id || index" class="result-item">
<div class="result-icon" :style="{ color: result.color }">
<SvgIcon :icon-class="result.icon" size="30px" />
</div>
<div class="result-info">
<div class="result-name">{{ result.name }}</div>
<div class="result-time">{{ formatTime(result.exportTime || Date.now()) }}</div>
<div class="result-time">{{ formatTime(result.exportTime || result.created_at) }}</div>
</div>
<div class="result-actions">
<el-popover
@@ -71,18 +72,53 @@
content="删除后,该记录无法恢复 !"
@confirm="confirmDelete"
/>
<!-- 预览弹窗 -->
<el-dialog
v-model="previewVisible"
:title="previewData?.file_name || '文件预览'"
width="80%"
:close-on-click-modal="true"
class="preview-dialog"
>
<div class="preview-content">
<div v-if="previewLoading" class="preview-loading">
<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>
<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>
<template #footer>
<el-button @click="previewVisible = false">关闭</el-button>
<el-button type="primary" @click="handlePreviewDownload">下载</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
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 api from '@/api'
const agentsStore = useAgentsStore()
// Props 接收大任务ID数据库主键
const props = defineProps<{
TaskID?: string // 大任务ID用于导出整个任务的执行结果
}>()
// 事件定义
const emit = defineEmits<{
(e: 'close'): void
@@ -96,13 +132,19 @@ interface ExportStyle {
color: string
}
// 导出结果类型
// 导出结果类型(与后端返回的数据结构匹配)
interface ExportResult {
id?: number
record_id?: number
name: string
icon: string
type: string
color: string
exportTime?: number // 时间戳
exportTime?: number
created_at?: string
file_url?: string
file_name?: string
export_type?: string
}
// 导出样式数据
@@ -118,14 +160,40 @@ const exportStyles = ref<ExportStyle[]>([
// 导出结果列表
const exportResults = ref<ExportResult[]>([])
// 加载中的导出类型
const loadingTypes = ref<string[]>([])
// 删除对话框相关
const dialogVisible = ref(false)
const resultToDelete = ref<ExportResult | null>(null)
// 预览相关
const previewVisible = ref(false)
const previewLoading = ref(false)
const previewData = ref<{
content?: string
file_url?: string
file_name?: string
type: string
}>({ type: '' })
// 格式化时间显示
const formatTime = (timestamp: number): string => {
const formatTime = (timestamp: any): string => {
if (!timestamp) return '未知时间'
let time: number
// 如果是 ISO 字符串,转换为时间戳
if (typeof timestamp === 'string') {
time = new Date(timestamp).getTime()
} else if (typeof timestamp === 'number') {
time = timestamp
} else {
return '未知时间'
}
const now = Date.now()
const diff = now - timestamp
const diff = now - time
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
@@ -135,25 +203,133 @@ const formatTime = (timestamp: number): string => {
if (hours < 24) return `${hours}小时前`
if (days < 7) return `${days}天前`
const date = new Date(timestamp)
const date = new Date(time)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(
date.getDate()
).padStart(2, '0')}`
}
// 获取导出记录列表
const fetchExportList = async () => {
// 使用大任务ID从 TaskTemplate 传入的 props.TaskID
const taskId = props.TaskID
if (!taskId) {
console.warn('没有任务ID无法获取导出列表')
return
}
try {
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)
return {
id: item.id,
record_id: item.id,
name: item.file_name,
icon: style?.icon || 'DOCX',
type: item.export_type,
color: style?.color || '#999',
exportTime: new Date(item.created_at).getTime(),
created_at: item.created_at,
file_url: item.file_url,
file_name: item.file_name,
export_type: item.export_type,
}
})
console.log('获取导出列表成功:', exportResults.value)
} catch (error) {
console.error('获取导出列表失败:', error)
}
}
// 预览文件
const previewResult = (_result: ExportResult) => {
ElMessage.success('预览功能开发中')
const previewResult = async (result: ExportResult) => {
const recordId = result.id || result.record_id
if (!recordId) {
ElMessage.error('无法获取文件ID')
return
}
// 先初始化数据,再显示对话框,避免渲染时属性不存在
previewData.value = {
content: '',
file_url: '',
file_name: result.file_name || '文件预览',
type: result.type || ''
}
previewVisible.value = true
previewLoading.value = true
try {
const data = await api.previewExport(recordId)
previewData.value = {
...previewData.value,
...data,
}
previewLoading.value = false
} catch (error) {
console.error('预览失败:', error)
ElMessage.error('预览失败')
previewLoading.value = false
}
}
// 预览弹窗中的下载按钮
const handlePreviewDownload = () => {
const fileName = previewData.value?.file_name
const recordId = fileName ? exportResults.value.find(
(r) => r.file_name === fileName
)?.id : null
if (recordId) {
downloadById(recordId)
}
previewVisible.value = false
}
// 下载文件根据ID
const downloadById = async (recordId: number) => {
try {
await api.downloadExport(recordId)
ElMessage.success('开始下载')
} catch (error) {
console.error('下载失败:', error)
ElMessage.error('下载失败')
}
}
// 下载文件
const downloadResult = (_result: ExportResult) => {
ElMessage.success('下载功能开发中')
const downloadResult = (result: ExportResult) => {
const recordId = result.id || result.record_id
if (!recordId) {
ElMessage.error('无法获取文件ID')
return
}
downloadById(recordId)
}
// 分享
const shareResult = (_result: ExportResult) => {
ElMessage.success('分享功能开发中')
const shareResult = async (result: ExportResult) => {
const recordId = result.id || result.record_id
if (!recordId) {
ElMessage.error('无法获取文件ID')
return
}
try {
const data = await api.shareExport(recordId)
// 将分享链接复制到剪贴板
const fullUrl = window.location.origin + data.share_url
await navigator.clipboard.writeText(fullUrl)
ElMessage.success('分享链接已复制到剪贴板')
} catch (error) {
console.error('分享失败:', error)
ElMessage.error('分享失败')
}
}
// 删除
@@ -163,44 +339,104 @@ const deleteResult = (result: ExportResult) => {
}
// 确认删除
const confirmDelete = () => {
const confirmDelete = async () => {
if (!resultToDelete.value) return
const index = exportResults.value.indexOf(resultToDelete.value)
if (index > -1) {
exportResults.value.splice(index, 1)
ElMessage.success('删除成功')
const recordId = resultToDelete.value.id || resultToDelete.value.record_id
if (!recordId) {
ElMessage.error('无法获取文件ID')
dialogVisible.value = false
resultToDelete.value = null
return
}
try {
const success = await api.deleteExport(recordId)
if (success) {
// 从列表中移除
const index = exportResults.value.findIndex(
(r) => (r.id || r.record_id) === recordId
)
if (index > -1) {
exportResults.value.splice(index, 1)
}
ElMessage.success('删除成功')
} else {
ElMessage.error('删除失败')
}
} catch (error) {
console.error('删除失败:', error)
ElMessage.error('删除失败')
}
dialogVisible.value = false
resultToDelete.value = null
}
// 选择导出样式
const handleSelect = (item: ExportStyle) => {
const currentTask = agentsStore.currentTask
if (!currentTask) {
const handleSelect = async (item: ExportStyle) => {
// 使用大任务ID从 TaskTemplate 传入的 props.TaskID
const taskId = props.TaskID
console.log('导出任务 - TaskID:', taskId)
if (!taskId) {
ElMessage.warning('请先选择任务')
return
}
// 生成导出名称:任务名称 + 样式名称
const taskName = currentTask.StepName || '未知任务'
const exportName = `${taskName}${item.name}`
// 添加到导出结果列表(添加到最前面)
const newItem = {
name: exportName,
icon: item.icon,
type: item.type,
color: item.color,
exportTime: Date.now()
// 防止重复点击
if (loadingTypes.value.includes(item.type)) {
return
}
exportResults.value.unshift(newItem)
// 添加加载状态
loadingTypes.value.push(item.type)
console.log('导出任务:', { task: currentTask, style: item })
ElMessage.success(`已开始导出: ${item.name}`)
try {
// 调用后端接口导出(导出整个大任务的执行结果)
console.log('开始导出 - task_id:', taskId, 'type:', item.type)
const result = await api.exportTask({
task_id: taskId,
export_type: item.type,
user_id: 'current_user', // TODO: 获取实际用户ID
})
console.log('导出结果:', result)
// 添加到导出结果列表(添加到最前面)
const newItem: ExportResult = {
id: result.record_id,
record_id: result.record_id,
name: result.file_name,
icon: item.icon,
type: item.type,
color: item.color,
exportTime: Date.now(),
created_at: new Date().toISOString(),
file_url: result.file_url,
file_name: result.file_name,
export_type: item.type,
}
exportResults.value.unshift(newItem)
console.log('导出成功:', result)
ElMessage.success(`导出成功: ${item.name}`)
} catch (error) {
console.error('导出失败:', error)
ElMessage.error(`导出失败: ${item.name}`)
} finally {
// 移除加载状态
const idx = loadingTypes.value.indexOf(item.type)
if (idx > -1) {
loadingTypes.value.splice(idx, 1)
}
}
}
// 组件挂载时获取导出列表
onMounted(() => {
fetchExportList()
})
</script>
<style scoped lang="scss">
@@ -239,6 +475,11 @@ const handleSelect = (item: ExportStyle) => {
background: var(--color-bg-content-hover);
}
&.is-loading {
opacity: 0.6;
cursor: not-allowed;
}
.style-icon {
flex-shrink: 0;
color: var(--color-text-plan-item);
@@ -362,6 +603,69 @@ const handleSelect = (item: ExportStyle) => {
font-size: 14px;
}
}
// 预览内容样式
.preview-content {
min-height: 300px;
max-height: 60vh;
overflow: auto;
.preview-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
gap: 12px;
color: var(--color-text-placeholder);
.el-icon {
font-size: 32px;
}
}
.preview-markdown {
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;
}
}
.preview-iframe {
display: flex;
flex-direction: column;
align-items: center;
iframe {
width: 100%;
height: 400px;
border: none;
border-radius: 8px;
background: var(--color-bg-detail);
}
.preview-tip {
margin-top: 12px;
color: var(--color-text-placeholder);
font-size: 14px;
}
}
.preview-empty {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: var(--color-text-placeholder);
}
}
</style>
<style lang="scss">
@@ -415,4 +719,11 @@ const handleSelect = (item: ExportStyle) => {
border-radius: 0 0 8px 8px;
}
}
// 预览弹窗样式
.preview-dialog {
.el-dialog__body {
padding: 16px;
}
}
</style>

View File

@@ -174,7 +174,7 @@ defineExpose({
</div>
<div class="h-[1px] w-full bg-[var(--color-border-separate)] my-[8px]"></div>
<div class="export-drawer-body">
<ExportList @close="exportDialogVisible = false" />
<ExportList :TaskID="TaskID" @close="exportDialogVisible = false" />
</div>
</div>
</div>