feat:历史记录分享功能实现

This commit is contained in:
liailing1026
2026-03-12 13:35:04 +08:00
parent 26c42697e8
commit 130f78108f
9 changed files with 1500 additions and 80 deletions

View File

@@ -0,0 +1,471 @@
<template>
<el-dialog
v-model="dialogVisible"
:width="width"
:close-on-click-modal="true"
:center="true"
:show-close="true"
top="20vh"
title="链接分享"
@closed="handleClosed"
>
<div class="share-content">
<!-- 设置区域 -->
<div v-if="!loading && !error" class="settings-section">
<!-- 有效期设置 -->
<div class="setting-row inline">
<div class="setting-label">有效期</div>
<div class="option-box">
<div
v-for="option in expirationOptions"
:key="option.value"
:class="['option-item', { active: expirationDays === option.value }]"
@click="expirationDays = option.value"
>
{{ option.label }}
</div>
</div>
</div>
<!-- 提取码设置 -->
<div class="setting-row inline">
<div class="setting-label">提取码</div>
<div class="code-option-box">
<div
:class="['option-item', { active: codeType === 'random' }]"
@click="handleCodeTypeChange('random')"
>
随机生成
</div>
<div
:class="['option-item', { active: codeType === 'custom' }]"
@click="handleCodeTypeChange('custom')"
>
自定义
</div>
</div>
<!-- 自定义输入框 -->
<div v-if="codeType === 'custom'" class="custom-code-wrapper">
<el-input
v-model="customCode"
placeholder="仅支持字母/数字"
class="custom-code-input"
maxlength="4"
@input="validateCustomCode"
>
<template #suffix>
<span v-if="customCode" class="code-count">{{ customCode.length }}/4</span>
</template>
</el-input>
<span v-if="!customCode" class="code-hint">请输入提取码</span>
</div>
</div>
<!-- 自动填充提取码选项 -->
<div class="setting-row">
<el-checkbox v-model="autoFillCode" class="auto-fill-checkbox">
分享链接自动填充提取码
</el-checkbox>
</div>
<!-- 复制链接按钮 -->
<el-button
type="primary"
size="large"
class="generate-btn"
:disabled="!canGenerate"
:loading="loading"
@click="handleCopyLink"
>
复制链接
</el-button>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-state">
<div class="error-icon">
<SvgIcon icon-class="JingGao" size="48px" color="#f56c6c" />
</div>
<p class="error-text">{{ error }}</p>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import SvgIcon from '@/components/SvgIcon/index.vue'
import websocket from '@/utils/websocket'
interface Props {
modelValue: boolean
planId?: string
width?: string | number
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
planId: '',
width: '450px'
})
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
// 有效期选项
const expirationOptions = [
{ label: '1天', value: 1 },
{ label: '7天', value: 7 },
{ label: '30天', value: 30 },
{ label: '365天', value: 365 },
{ label: '永久有效', value: 0 }
]
const loading = ref(false)
const error = ref('')
const dialogVisible = ref(props.modelValue)
const expirationDays = ref(7) // 默认7天
const codeType = ref<'random' | 'custom'>('random') // 提取码类型
const customCode = ref('') // 自定义提取码
const generatedCode = ref('') // 生成的提取码
const autoFillCode = ref(true) // 分享链接自动填充提取码
// 是否可以生成链接
const canGenerate = computed(() => {
if (codeType.value === 'random') {
return true // 随机生成总是可以
} else {
return /^[a-zA-Z0-9]{4}$/.test(customCode.value)
}
})
// 生成唯一请求ID
let requestIdCounter = 0
const generateRequestId = () => `ws_req_${Date.now()}_${++requestIdCounter}`
// 监听 v-model 变化
watch(
() => props.modelValue,
val => {
dialogVisible.value = val
if (val && props.planId) {
// 打开弹窗时重置状态
loading.value = false
error.value = ''
// 生成随机提取码
generateRandomCode()
}
}
)
// 监听对话框显示状态变化
watch(dialogVisible, val => {
emit('update:modelValue', val)
})
// 生成随机提取码4位字母数字
const generateRandomCode = () => {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
let code = ''
for (let i = 0; i < 4; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length))
}
generatedCode.value = code
}
// 处理提取码类型变化
const handleCodeTypeChange = (type: 'random' | 'custom') => {
codeType.value = type
if (type === 'random') {
generateRandomCode()
}
}
// 验证自定义提取码
const validateCustomCode = (value: string) => {
// 只允许字母和数字
customCode.value = value.replace(/[^a-zA-Z0-9]/g, '').toUpperCase()
}
// 生成并复制链接
const handleCopyLink = async () => {
if (!props.planId) {
error.value = '任务 ID 为空'
return
}
// 获取提取码
const extractionCode = codeType.value === 'random' ? generatedCode.value : customCode.value
loading.value = true
error.value = ''
const reqId = generateRequestId()
try {
const result = await websocket.send('share_plan', {
id: reqId,
data: {
plan_id: props.planId,
expiration_days: expirationDays.value,
extraction_code: extractionCode,
auto_fill_code: autoFillCode.value
}
})
if (result.data?.share_url) {
// 获取当前域名
const baseUrl = window.location.origin
const fullUrl = baseUrl + result.data.share_url
// 复制到剪贴板
try {
await navigator.clipboard.writeText(fullUrl)
ElMessage.success('链接已复制到剪贴板')
dialogVisible.value = false
} catch {
ElMessage.error('复制失败,请手动复制')
}
} else {
error.value = '生成分享链接失败'
}
} catch (err: any) {
error.value = err.message || '生成分享链接失败'
} finally {
loading.value = false
}
}
// 对话框关闭后重置状态
const handleClosed = () => {
loading.value = false
error.value = ''
expirationDays.value = 7
codeType.value = 'random'
customCode.value = ''
generatedCode.value = ''
autoFillCode.value = true
}
// 暴露方法供外部调用
defineExpose({
close: () => {
dialogVisible.value = false
}
})
</script>
<style scoped lang="scss">
.share-content {
padding: 20px 0;
}
.settings-section {
.setting-row {
margin-bottom: 20px;
&.inline {
display: flex;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
.setting-label {
margin-bottom: 0;
margin-right: 12px;
white-space: nowrap;
}
.option-box {
flex: 1;
min-width: 200px;
}
.code-option-box {
flex: none;
}
.custom-code-wrapper {
margin-left: 8px;
}
}
}
.setting-label {
font-size: 14px;
color: var(--color-text-primary);
margin-bottom: 8px;
font-weight: 500;
}
.option-box {
display: flex;
border: 1px solid var(--color-border);
border-radius: 6px;
overflow: hidden;
.option-item {
flex: 1;
padding: 10px 0;
text-align: center;
cursor: pointer;
font-size: 13px;
color: var(--color-text-secondary);
border-right: 1px solid var(--color-border);
transition: all 0.2s;
&:last-child {
border-right: none;
}
&:hover {
background-color: var(--color-bg-subtle);
}
&.active {
background-color: var(--color-primary);
color: white;
}
}
}
.code-option-box {
display: flex;
width: 160px;
border: 1px solid var(--color-border);
border-radius: 6px;
overflow: hidden;
.option-item {
flex: 1;
padding: 10px 0;
text-align: center;
cursor: pointer;
font-size: 13px;
color: var(--color-text-secondary);
border-right: 1px solid var(--color-border);
transition: all 0.2s;
&:last-child {
border-right: none;
}
&:hover {
background-color: var(--color-bg-subtle);
}
&.active {
background-color: var(--color-primary);
color: white;
}
}
}
.custom-code-wrapper {
display: flex;
align-items: center;
margin-left: 8px;
.custom-code-input {
width: 100px;
:deep(.el-input__wrapper) {
padding-right: 8px;
}
:deep(.el-input__suffix) {
right: 4px;
}
:deep(.el-input__inner) {
&::placeholder {
font-size: 11px;
color: #999;
}
}
.code-count {
font-size: 10px;
color: var(--color-text-secondary);
}
}
.code-hint {
font-size: 12px;
color: var(--color-error, #f56c6c);
margin-left: 8px;
white-space: nowrap;
}
}
.auto-fill-checkbox {
margin-top: 8px;
}
.generate-btn {
margin-top: 24px;
width: 100%;
}
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 0;
gap: 12px;
color: var(--color-text-secondary);
.el-icon {
font-size: 32px;
}
}
.success-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 0;
.success-icon {
margin-bottom: 16px;
}
.success-text {
font-size: 16px;
font-weight: 500;
color: var(--color-text-primary);
margin-bottom: 20px;
}
.share-link-box {
width: 100%;
margin-bottom: 16px;
.link-input {
width: 100%;
}
}
.tip-text {
font-size: 13px;
color: var(--color-text-secondary);
text-align: center;
}
}
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 0;
gap: 12px;
.error-text {
color: var(--color-error, #f56c6c);
font-size: 14px;
}
}
</style>

View File

@@ -26,6 +26,7 @@
</div>
<div class="plan-actions">
<el-popover
:ref="(el: any) => setPopoverRef(plan.id, el)"
placement="bottom-end"
:show-arrow="false"
trigger="click"
@@ -37,15 +38,15 @@
</button>
</template>
<div class="action-menu">
<div class="action-item" @click="pinPlan(plan)">
<div class="action-item" @click="handleAction(plan, 'pin')">
<SvgIcon icon-class="ZhiDing" size="14px" />
<span>{{ plan.is_pinned ? '取消置顶' : '置顶' }}</span>
</div>
<div class="action-item">
<div class="action-item" @click="handleAction(plan, 'share')">
<SvgIcon icon-class="FenXiang" size="14px" />
<span>分享</span>
</div>
<div class="action-item" @click="deletePlan(plan)">
<div class="action-item" @click="handleAction(plan, 'delete')">
<SvgIcon icon-class="ShanChu" size="14px" />
<span>删除</span>
</div>
@@ -62,6 +63,12 @@
content="删除后,该任务无法恢复 !"
@confirm="confirmDelete"
/>
<!-- 分享对话框 -->
<SharePlanDialog
v-model="shareDialogVisible"
:plan-id="sharePlanId"
/>
</div>
</template>
@@ -71,6 +78,7 @@ 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 SharePlanDialog from '@/components/SharePlanDialog/index.vue'
import websocket from '@/utils/websocket'
// 事件定义
@@ -101,6 +109,44 @@ const dialogVisible = ref(false)
const planToDelete = ref<PlanInfo | null>(null)
const deleting = ref(false)
// 分享对话框相关
const shareDialogVisible = ref(false)
const sharePlanId = ref<string>('')
// Popover 引用管理
const popoverRefs = new Map<string, any>()
const setPopoverRef = (planId: string, el: any) => {
if (el) {
popoverRefs.set(planId, el)
}
}
// 统一处理操作点击
const handleAction = (plan: PlanInfo, action: 'pin' | 'share' | 'delete') => {
// 关闭 popover
const popover = popoverRefs.get(plan.id)
if (popover) {
popover.hide()
}
// 延迟执行操作,让 popover 有时间关闭
setTimeout(() => {
if (action === 'pin') {
pinPlan(plan)
} else if (action === 'share') {
openShareDialog(plan)
} else if (action === 'delete') {
deletePlan(plan)
}
}, 100)
}
// 打开分享弹窗
const openShareDialog = (plan: PlanInfo) => {
sharePlanId.value = plan.id
shareDialogVisible.value = true
}
// 生成唯一请求ID
let requestIdCounter = 0
const generateRequestId = () => `ws_req_${Date.now()}_${++requestIdCounter}`

View File

@@ -1,19 +1,36 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Download } from '@element-plus/icons-vue'
import { Download, Document, User, Clock } from '@element-plus/icons-vue'
interface ShareInfo {
// 分享数据类型
type ShareType = 'export' | 'plan'
interface ExportShareInfo {
file_name: string
export_type: string
created_at: string
file_size: number
}
interface PlanShareInfo {
share_token: string
task_id: string
task_data: {
general_goal: string
task_outline?: any
assigned_agents?: any
agents_info?: any[]
status?: string
}
created_at: string
expires_at: string | null
view_count: number
}
// 获取 API 地址
const getApiBaseUrl = (): string => {
// 尝试从 localStorage 获取配置
const configStr = localStorage.getItem('app_config')
if (configStr) {
try {
@@ -25,23 +42,117 @@ const getApiBaseUrl = (): string => {
// ignore
}
}
// 返回 /api 作为基础路径
return '/api'
}
const route = useRoute()
const loading = ref(true)
const shareInfo = ref<ShareInfo | null>(null)
const shareType = ref<ShareType>('plan')
const urlCode = ref('') // URL中的提取码
const inputCode = ref('') // 用户输入的提取码
const codeError = ref('') // 提取码输入错误提示
const needCode = ref(false) // 是否需要提取码
const codeVerified = ref(false) // 提取码是否已验证
const exportInfo = ref<ExportShareInfo | null>(null)
const planInfo = ref<PlanShareInfo | null>(null)
const error = ref<string | null>(null)
// 解析分享 token
const parseShareToken = (token: string): { recordId: number; timestamp: number } | null => {
// 格式: export_{id}_{timestamp}
// 导入相关
const importing = ref(false)
const importSuccess = ref(false)
// 判断分享类型
const isExportShare = (token: string | undefined): boolean => {
if (!token) return false
return /^export_\d+_\d+$/.test(token)
}
// 解析导出分享 token
const parseExportToken = (token: string): { recordId: number; timestamp: number } | null => {
const match = token.match(/^export_(\d+)_(\d+)$/)
if (!match) return null
return {
recordId: parseInt(match[1], 10),
timestamp: parseInt(match[2], 10)
recordId: parseInt(match[1]!, 10),
timestamp: parseInt(match[2]!, 10)
}
}
// 获取导出分享信息
const fetchExportShareInfo = async (recordId: number) => {
try {
const apiBaseUrl = getApiBaseUrl()
const url = `${apiBaseUrl}/export/${recordId}/share/info`
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10000)
const response = await fetch(url, { signal: controller.signal })
clearTimeout(timeoutId)
if (!response.ok) {
if (response.status === 404) {
error.value = '分享链接不存在或已失效'
} else {
error.value = '获取分享信息失败'
}
return
}
exportInfo.value = await response.json()
} catch (e: any) {
console.error('获取导出分享信息失败:', e)
if (e.name === 'AbortError') {
error.value = '请求超时,请稍后重试'
} else {
error.value = '网络错误,请稍后重试'
}
}
}
// 获取任务分享信息
const fetchPlanShareInfo = async (
token: string | undefined,
code?: string,
setGlobalError: boolean = true
) => {
try {
const apiBaseUrl = getApiBaseUrl()
let url = `${apiBaseUrl}/share/${token}`
if (code) {
url += `?code=${code}`
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10000)
const response = await fetch(url, { signal: controller.signal })
clearTimeout(timeoutId)
if (!response.ok) {
if (response.status === 404) {
if (setGlobalError) error.value = '分享链接不存在或已失效'
} else if (response.status === 403) {
// 提取码错误不设置全局error
codeError.value = '提取码错误,请重新输入'
} else {
if (setGlobalError) error.value = '获取分享信息失败'
}
return false
}
planInfo.value = await response.json()
codeVerified.value = true
return true
} catch (e: any) {
console.error('获取任务分享信息失败:', e)
if (setGlobalError) {
if (e.name === 'AbortError') {
error.value = '请求超时,请稍后重试'
} else {
error.value = '网络错误,请稍后重试'
}
}
return false
}
}
@@ -50,75 +161,108 @@ const fetchShareInfo = async () => {
const token = route.params.token as string
console.log('分享 token:', token)
// 从URL参数中获取提取码
const queryCode = (route.query.code as string) || ''
if (!token) {
error.value = '无效的分享链接'
loading.value = false
return
}
const parsed = parseShareToken(token)
if (!parsed) {
error.value = '无效的分享链接格式'
loading.value = false
return
// 判断分享类型
if (isExportShare(token)) {
shareType.value = 'export'
const parsed = parseExportToken(token)
if (!parsed) {
error.value = '无效的分享链接格式'
loading.value = false
return
}
await fetchExportShareInfo(parsed.recordId)
} else {
shareType.value = 'plan'
// 如果有提取码,先尝试用提取码获取
if (queryCode) {
await fetchPlanShareInfo(token, queryCode)
if (!planInfo.value) {
// 提取码错误,显示需要输入提取码
needCode.value = true
urlCode.value = queryCode
}
} else {
// 没有提取码,先检查是否需要提取码
await checkNeedCode(token)
}
}
console.log('解析后的 recordId:', parsed.recordId)
loading.value = false
}
// 检查是否需要提取码
const checkNeedCode = async (token: string) => {
try {
// 从配置获取 API 地址
const apiBaseUrl = getApiBaseUrl()
const url = `${apiBaseUrl}/export/${parsed.recordId}/share/info`
console.log('请求 URL:', url)
const url = `${apiBaseUrl}/share/${token}/check`
// 添加超时
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 = '分享链接不存在或已失效'
if (response.ok) {
const data = await response.json()
if (data.need_code) {
needCode.value = true
} else {
error.value = '获取分享信息失败'
// 不需要提取码,直接获取
await fetchPlanShareInfo(token)
}
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 = '网络错误,请稍后重试'
// 默认需要提取码
needCode.value = true
}
} finally {
loading.value = false
} catch (e) {
console.error('检查提取码失败:', e)
needCode.value = true
}
}
// 下载文件
// 验证提取码
const verifyCode = async () => {
if (!inputCode.value || inputCode.value.length !== 4) {
ElMessage.warning('请输入4位提取码')
return
}
loading.value = true
codeError.value = ''
const success = await fetchPlanShareInfo(
route.params.token as string,
inputCode.value.toUpperCase(),
false
)
if (!success) {
codeError.value = '提取码错误,请重新输入'
inputCode.value = '' // 清空输入框
}
loading.value = false
}
// 下载导出文件
const downloadFile = async () => {
if (!shareInfo.value) return
if (!exportInfo.value) return
try {
const apiBaseUrl = getApiBaseUrl()
const token = route.params.token as string
const parsed = parseShareToken(token)
const parsed = parseExportToken(token)
if (!parsed) {
ElMessage.error('无效的分享链接')
return
}
// 触发下载
window.location.href = `${apiBaseUrl}/api/export/${parsed.recordId}/download`
} catch (e) {
console.error('下载失败:', e)
@@ -126,13 +270,44 @@ const downloadFile = async () => {
}
}
// 格式化文件大小
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 importPlan = async () => {
if (!planInfo.value) return
const userId = localStorage.getItem('user_id')
if (!userId) {
ElMessage.warning('请先登录后再导入任务')
return
}
importing.value = true
try {
const response = await fetch(`/api/share/import`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
share_token: planInfo.value.share_token,
user_id: userId
})
})
const result = await response.json()
if (result.success) {
importSuccess.value = true
ElMessage.success('任务已导入到您的历史记录')
} else {
ElMessage.error(result.error || '导入失败')
}
} catch (e: any) {
console.error('导入失败:', e)
ElMessage.error(e.message || '导入失败')
} finally {
importing.value = false
}
}
// 格式化日期
@@ -147,22 +322,52 @@ const formatDate = (dateStr: string): string => {
})
}
// 格式化文件大小
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 getFileTypeName = (type: string): string => {
const typeMap: Record<string, string> = {
'doc': 'Word 文档',
'markdown': 'Markdown',
'mindmap': '思维导图',
'infographic': '信息图',
'excel': 'Excel 表格',
'ppt': 'PPT 演示文稿'
doc: 'Word 文档',
markdown: 'Markdown',
mindmap: '思维导图',
infographic: '信息图',
excel: 'Excel 表格',
ppt: 'PPT 演示文稿'
}
return typeMap[type] || type
}
// 任务状态显示
const taskStatusText = computed(() => {
if (!planInfo.value?.task_data?.status) return '未知'
const statusMap: Record<string, string> = {
generating: '生成中',
executing: '执行中',
stopped: '已停止',
completed: '已完成'
}
return statusMap[planInfo.value.task_data.status] || planInfo.value.task_data.status
})
// 返回首页(刷新页面)
const goToHome = () => {
window.location.href = '/'
}
// 智能体数量
const agentCount = computed(() => {
return planInfo.value?.task_data?.agents_info?.length || 0
})
onMounted(() => {
// 设置页面标题
document.title = '文件分享'
document.title = '分享'
fetchShareInfo()
})
</script>
@@ -183,32 +388,135 @@ onMounted(() => {
<p>{{ error }}</p>
</div>
<!-- 成功状态 -->
<div v-else-if="shareInfo" class="success-state">
<div class="success-icon">
<!-- 需要提取码 -->
<div v-else-if="shareType === 'plan' && needCode && !planInfo" class="code-input-state">
<div class="code-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" />
<path
fill="currentColor"
d="M12,17A2,2 0 0,0 14,15C14,13.89 13.1,13 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V10C4,8.89 4.9,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z"
/>
</svg>
</div>
<h2>{{ shareInfo.file_name }}</h2>
<h2>请输入提取码</h2>
<div class="code-input-wrapper">
<div class="code-input-box">
<el-input
v-model="inputCode"
placeholder="请输入4位提取码"
maxlength="4"
@input="inputCode = inputCode.toUpperCase().replace(/[^A-Z0-9]/g, '')"
@keyup.enter="verifyCode"
class="code-input"
/>
<el-button type="primary" @click="verifyCode" :loading="loading"> 确认 </el-button>
</div>
</div>
<p v-if="codeError" class="tip-text error">{{ codeError }}</p>
<p v-else class="tip-text">请输入分享者提供给您的提取码</p>
</div>
<!-- 导出文件分享 -->
<div v-else-if="shareType === 'export' && exportInfo" 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>{{ exportInfo.file_name }}</h2>
<div class="file-info">
<div class="info-item">
<span class="label">文件类型</span>
<span class="value">{{ getFileTypeName(shareInfo.export_type) }}</span>
<span class="value">{{ getFileTypeName(exportInfo.export_type) }}</span>
</div>
<div class="info-item">
<span class="label">创建时间</span>
<span class="value">{{ formatDate(shareInfo.created_at) }}</span>
<span class="value">{{ formatDate(exportInfo.created_at) }}</span>
</div>
<div class="info-item">
<span class="label">文件大小</span>
<span class="value">{{ formatFileSize(shareInfo.file_size) }}</span>
<span class="value">{{ formatFileSize(exportInfo.file_size) }}</span>
</div>
</div>
<el-button type="primary" size="large" :icon="Download" @click="downloadFile" class="download-btn">
<el-button
type="primary"
size="large"
:icon="Download"
@click="downloadFile"
class="action-btn"
>
下载文件
</el-button>
</div>
<!-- 任务分享 -->
<div v-else-if="shareType === 'plan' && planInfo" class="success-state plan-share">
<div class="success-icon plan-icon">
<svg viewBox="0 0 24 24" width="64" height="64">
<path
fill="currentColor"
d="M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M19,19H5V5H19V19M17,17H7V7H17V17Z"
/>
</svg>
</div>
<div v-if="importSuccess" class="import-success">
<svg viewBox="0 0 24 24" width="48" height="48" class="success-check">
<path
fill="#67c23a"
d="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4M11,16.5L6.5,12L7.91,10.59L11,13.67L16.59,8.09L18,9.5L11,16.5Z"
/>
</svg>
<h2>导入成功</h2>
<p>任务已添加到您的历史记录</p>
<el-button type="primary" @click="goToHome" class="action-btn"> 返回首页 </el-button>
</div>
<div v-else>
<h2>{{ planInfo.task_data.general_goal || '任务分享' }}</h2>
<div class="plan-info">
<div class="info-item">
<el-icon><Document /></el-icon>
<span class="label">任务状态</span>
<span class="value">{{ taskStatusText }}</span>
</div>
<div class="info-item">
<el-icon><User /></el-icon>
<span class="label">智能体数量</span>
<span class="value">{{ agentCount }} </span>
</div>
<div class="info-item">
<el-icon><Clock /></el-icon>
<span class="label">分享时间</span>
<span class="value">{{ formatDate(planInfo.created_at) }}</span>
</div>
<div v-if="planInfo.expires_at" class="info-item">
<el-icon><Clock /></el-icon>
<span class="label">过期时间</span>
<span class="value">{{ formatDate(planInfo.expires_at) }}</span>
</div>
<div class="info-item">
<span class="label">查看次数</span>
<span class="value">{{ planInfo.view_count }} </span>
</div>
</div>
<el-button
type="primary"
size="large"
:loading="importing"
@click="importPlan"
class="action-btn"
>
{{ importing ? '导入中...' : '导入到我的历史记录' }}
</el-button>
<p class="tip-text">导入后您可以在历史记录中查看和恢复此任务</p>
</div>
</div>
</div>
</div>
</template>
@@ -245,8 +553,12 @@ onMounted(() => {
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
}
@@ -275,12 +587,66 @@ onMounted(() => {
}
}
.code-input-state {
.code-icon {
color: #667eea;
margin-bottom: 20px;
}
h2 {
color: #333;
margin-bottom: 24px;
}
.code-input-wrapper {
width: 100%;
display: flex;
justify-content: center;
}
.code-input-box {
display: flex;
gap: 12px;
margin-bottom: 16px;
.code-input {
width: 160px;
:deep(.el-input__inner) {
text-align: center;
letter-spacing: 4px;
font-size: 18px;
font-weight: bold;
&::placeholder {
font-size: 12px;
letter-spacing: 0;
}
}
}
}
.tip-text {
font-size: 13px;
color: #999;
text-align: center;
&.error {
color: #f56c6c;
}
}
}
.success-state {
.success-icon {
color: #667eea;
margin-bottom: 20px;
}
&.plan-share .success-icon.plan-icon {
color: #409eff;
}
h2 {
color: #333;
margin-bottom: 24px;
@@ -288,7 +654,8 @@ onMounted(() => {
font-size: 20px;
}
.file-info {
.file-info,
.plan-info {
background: #f8f9fa;
border-radius: 12px;
padding: 20px;
@@ -297,7 +664,8 @@ onMounted(() => {
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid #eee;
@@ -305,6 +673,11 @@ onMounted(() => {
border-bottom: none;
}
.el-icon {
color: #666;
font-size: 16px;
}
.label {
color: #666;
}
@@ -316,10 +689,38 @@ onMounted(() => {
}
}
.download-btn {
.plan-info .info-item {
flex-wrap: wrap;
}
.action-btn {
width: 100%;
height: 48px;
font-size: 16px;
}
.tip-text {
margin-top: 16px;
font-size: 13px;
color: #999;
}
}
.import-success {
padding: 20px 0;
.success-check {
margin-bottom: 16px;
}
h2 {
color: #67c23a;
margin-bottom: 8px;
}
p {
color: #666;
margin-bottom: 24px;
}
}
</style>