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

@@ -5,8 +5,8 @@ AgentCoord 数据库模块
""" """
from .database import get_db, get_db_context, test_connection, engine, text from .database import get_db, get_db_context, test_connection, engine, text
from .models import MultiAgentTask, UserAgent, ExportRecord, TaskStatus from .models import MultiAgentTask, UserAgent, ExportRecord, PlanShare, TaskStatus
from .crud import MultiAgentTaskCRUD, UserAgentCRUD, ExportRecordCRUD from .crud import MultiAgentTaskCRUD, UserAgentCRUD, ExportRecordCRUD, PlanShareCRUD
__all__ = [ __all__ = [
# 连接管理 # 连接管理
@@ -19,9 +19,11 @@ __all__ = [
"MultiAgentTask", "MultiAgentTask",
"UserAgent", "UserAgent",
"ExportRecord", "ExportRecord",
"PlanShare",
"TaskStatus", "TaskStatus",
# CRUD # CRUD
"MultiAgentTaskCRUD", "MultiAgentTaskCRUD",
"UserAgentCRUD", "UserAgentCRUD",
"ExportRecordCRUD", "ExportRecordCRUD",
"PlanShareCRUD",
] ]

View File

@@ -9,7 +9,7 @@ from datetime import datetime, timezone
from typing import List, Optional from typing import List, Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import MultiAgentTask, UserAgent, ExportRecord from .models import MultiAgentTask, UserAgent, ExportRecord, PlanShare
class MultiAgentTaskCRUD: class MultiAgentTaskCRUD:
@@ -500,3 +500,78 @@ class ExportRecordCRUD:
db.commit() db.commit()
return True return True
return False return False
class PlanShareCRUD:
"""任务分享 CRUD 操作"""
@staticmethod
def create(
db: Session,
share_token: str,
task_id: str,
task_data: dict,
expires_at: Optional[datetime] = None,
extraction_code: Optional[str] = None,
) -> PlanShare:
"""创建分享记录"""
share = PlanShare(
share_token=share_token,
extraction_code=extraction_code,
task_id=task_id,
task_data=task_data,
expires_at=expires_at,
)
db.add(share)
db.commit()
db.refresh(share)
return share
@staticmethod
def get_by_token(db: Session, share_token: str) -> Optional[PlanShare]:
"""根据 token 获取分享记录"""
return db.query(PlanShare).filter(PlanShare.share_token == share_token).first()
@staticmethod
def get_by_task_id(
db: Session, task_id: str, limit: int = 10
) -> List[PlanShare]:
"""根据任务 ID 获取分享记录列表"""
return (
db.query(PlanShare)
.filter(PlanShare.task_id == task_id)
.order_by(PlanShare.created_at.desc())
.limit(limit)
.all()
)
@staticmethod
def increment_view_count(db: Session, share_token: str) -> Optional[PlanShare]:
"""增加查看次数"""
share = db.query(PlanShare).filter(PlanShare.share_token == share_token).first()
if share:
share.view_count = (share.view_count or 0) + 1
db.commit()
db.refresh(share)
return share
@staticmethod
def delete(db: Session, share_token: str) -> bool:
"""删除分享记录"""
share = db.query(PlanShare).filter(PlanShare.share_token == share_token).first()
if share:
db.delete(share)
db.commit()
return True
return False
@staticmethod
def delete_by_task_id(db: Session, task_id: str) -> bool:
"""删除任务的所有分享记录"""
shares = db.query(PlanShare).filter(PlanShare.task_id == task_id).all()
if shares:
for share in shares:
db.delete(share)
db.commit()
return True
return False

View File

@@ -10,7 +10,7 @@ import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from db.database import engine, Base from db.database import engine, Base
from db.models import MultiAgentTask, UserAgent from db.models import MultiAgentTask, UserAgent, PlanShare
def init_database(): def init_database():

View File

@@ -137,3 +137,34 @@ class UserAgent(Base):
"agent_config": self.agent_config, "agent_config": self.agent_config,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": self.created_at.isoformat() if self.created_at else None,
} }
class PlanShare(Base):
"""任务分享记录模型"""
__tablename__ = "plan_shares"
id = Column(Integer, primary_key=True, autoincrement=True)
share_token = Column(String(64), unique=True, index=True, nullable=False) # 唯一分享码
extraction_code = Column(String(8), nullable=True) # 提取码4位字母数字
task_id = Column(String(64), nullable=False, index=True) # 关联的任务ID
task_data = Column(JSONB, nullable=False) # 完整的任务数据(脱敏后)
created_at = Column(DateTime(timezone=True), default=utc_now)
expires_at = Column(DateTime(timezone=True), nullable=True) # 过期时间
view_count = Column(Integer, default=0) # 查看次数
__table_args__ = (
Index("idx_plan_shares_token", "share_token"),
)
def to_dict(self) -> dict:
"""转换为字典"""
return {
"id": self.id,
"share_token": self.share_token,
"extraction_code": self.extraction_code,
"task_id": self.task_id,
"task_data": self.task_data,
"created_at": self.created_at.isoformat() if self.created_at else None,
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
"view_count": self.view_count,
}

View File

@@ -62,9 +62,45 @@ CREATE TRIGGER update_multi_agent_tasks_updated_at
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column(); EXECUTE FUNCTION update_updated_at_column();
-- =============================================================================
-- 表3: export_records (导出记录)
-- =============================================================================
CREATE TABLE IF NOT EXISTS export_records (
id SERIAL PRIMARY KEY,
task_id VARCHAR(64) NOT NULL,
user_id VARCHAR(64) NOT NULL,
export_type VARCHAR(32) NOT NULL,
file_name VARCHAR(256) NOT NULL,
file_path VARCHAR(512) NOT NULL,
file_url VARCHAR(512),
file_size INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_export_records_task_user ON export_records(task_id, user_id);
-- =============================================================================
-- 表4: plan_shares (任务分享记录)
-- =============================================================================
CREATE TABLE IF NOT EXISTS plan_shares (
id SERIAL PRIMARY KEY,
share_token VARCHAR(64) NOT NULL UNIQUE,
extraction_code VARCHAR(8),
task_id VARCHAR(64) NOT NULL,
task_data JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP WITH TIME ZONE,
view_count INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_plan_shares_token ON plan_shares(share_token);
CREATE INDEX IF NOT EXISTS idx_plan_shares_task_id ON plan_shares(task_id);
DO $$ DO $$
BEGIN BEGIN
RAISE NOTICE '✅ PostgreSQL 数据库表结构创建完成!'; RAISE NOTICE '✅ PostgreSQL 数据库表结构创建完成!';
RAISE NOTICE '表: multi_agent_tasks (多智能体任务记录)'; RAISE NOTICE '表: multi_agent_tasks (多智能体任务记录)';
RAISE NOTICE '表: user_agents (用户智能体配置)'; RAISE NOTICE '表: user_agents (用户智能体配置)';
RAISE NOTICE '表: export_records (导出记录)';
RAISE NOTICE '表: plan_shares (任务分享记录)';
END $$; END $$;

View File

@@ -23,6 +23,7 @@ import uuid
import copy import copy
import base64 import base64
from typing import List, Dict, Optional from typing import List, Dict, Optional
from datetime import datetime, timezone, timedelta
# 数据库模块导入 # 数据库模块导入
from db import ( from db import (
@@ -30,6 +31,7 @@ from db import (
MultiAgentTaskCRUD, MultiAgentTaskCRUD,
UserAgentCRUD, UserAgentCRUD,
ExportRecordCRUD, ExportRecordCRUD,
PlanShareCRUD,
TaskStatus, TaskStatus,
text, text,
) )
@@ -2239,6 +2241,200 @@ def handle_pin_plan(data):
}) })
import secrets
@socketio.on('share_plan')
def handle_share_plan(data):
"""
WebSocket版本分享任务
"""
# socketio 包装: data = { id: 'share_plan-xxx', action: 'share_plan', data: { id: 'ws_req_xxx', data: {...} } }
request_id = data.get('id') # socketio 包装的 id
incoming_data = data.get('data', {}).get('data', {}) # 真正的请求数据
plan_id = incoming_data.get('plan_id')
expiration_days = incoming_data.get('expiration_days', 7) # 默认为7天0表示永久
extraction_code = incoming_data.get('extraction_code', '') # 提取码
auto_fill_code = incoming_data.get('auto_fill_code', True) # 是否在链接中自动填充提取码
if not plan_id:
emit('response', {
'id': request_id,
'status': 'error',
'error': '缺少 plan_idtask_id'
})
return
try:
with get_db_context() as db:
# 获取任务详情
task = MultiAgentTaskCRUD.get_by_id(db, plan_id)
if not task:
emit('response', {
'id': request_id,
'status': 'error',
'error': f'任务不存在: {plan_id}'
})
return
# 生成唯一分享 token
share_token = secrets.token_urlsafe(16)
# 设置过期时间
if expiration_days == 0:
# 永久有效
expires_at = None
else:
expires_at = datetime.now(timezone.utc) + timedelta(days=expiration_days)
# 准备分享数据(脱敏处理,移除敏感信息)
task_data = {
"general_goal": task.query,
"task_outline": task.task_outline,
"assigned_agents": task.assigned_agents,
"agent_scores": task.agent_scores,
"agents_info": task.agents_info,
"branches": task.branches,
"result": task.result,
"rehearsal_log": task.rehearsal_log,
"status": task.status.value if task.status else None,
}
# 创建分享记录
share = PlanShareCRUD.create(
db=db,
share_token=share_token,
task_id=plan_id,
task_data=task_data,
expires_at=expires_at,
extraction_code=extraction_code.upper() if extraction_code else None,
)
# 生成分享链接根据auto_fill_code决定是否在URL中携带提取码
if extraction_code and auto_fill_code:
share_url = f"/share/{share_token}?code={extraction_code.upper()}"
else:
share_url = f"/share/{share_token}"
emit('response', {
'id': request_id,
'status': 'success',
'data': {
"share_url": share_url,
"share_token": share_token,
"extraction_code": extraction_code.upper() if extraction_code else None,
"auto_fill_code": auto_fill_code,
"task_id": plan_id,
}
})
except Exception as e:
emit('response', {
'id': request_id,
'status': 'error',
'error': str(e)
})
@socketio.on('import_shared_plan')
def handle_import_shared_plan(data):
"""
WebSocket版本导入分享的任务到自己的历史记录
"""
# socketio 包装: data = { id: 'import_shared_plan-xxx', action: 'import_shared_plan', data: { id: 'ws_req_xxx', data: {...} } }
request_id = data.get('id') # socketio 包装的 id
incoming_data = data.get('data', {}).get('data', {}) # 真正的请求数据
share_token = incoming_data.get('share_token')
user_id = incoming_data.get('user_id')
if not share_token:
emit('response', {
'id': request_id,
'status': 'error',
'error': '缺少 share_token'
})
return
if not user_id:
emit('response', {
'id': request_id,
'status': 'error',
'error': '缺少 user_id'
})
return
try:
with get_db_context() as db:
# 获取分享记录
share = PlanShareCRUD.get_by_token(db, share_token)
if not share:
emit('response', {
'id': request_id,
'status': 'error',
'error': '分享链接无效或已失效'
})
return
# 检查是否过期
if share.expires_at and share.expires_at.replace(tzinfo=None) < datetime.now():
emit('response', {
'id': request_id,
'status': 'error',
'error': '分享链接已过期'
})
return
# 获取分享的任务数据
task_data = share.task_data
# 生成新的 task_id因为是导入到自己的账号
import uuid
new_task_id = str(uuid.uuid4())
# 创建新的任务记录
task = MultiAgentTaskCRUD.create(
db=db,
task_id=new_task_id,
user_id=user_id,
query=task_data.get("general_goal", ""),
agents_info=task_data.get("agents_info", []),
task_outline=task_data.get("task_outline"),
assigned_agents=task_data.get("assigned_agents"),
agent_scores=task_data.get("agent_scores"),
result=task_data.get("result"),
)
# 如果有分支数据,也保存
if task_data.get("branches"):
MultiAgentTaskCRUD.update_branches(db, new_task_id, task_data["branches"])
# 如果有执行日志,也保存
if task_data.get("rehearsal_log"):
MultiAgentTaskCRUD.update_rehearsal_log(db, new_task_id, task_data["rehearsal_log"])
# 增加分享的查看次数
PlanShareCRUD.increment_view_count(db, share_token)
# 通知所有客户端刷新历史列表
socketio.emit('history_updated', {'user_id': user_id})
emit('response', {
'id': request_id,
'status': 'success',
'data': {
"message": "导入成功",
"task_id": new_task_id,
}
})
except Exception as e:
emit('response', {
'id': request_id,
'status': 'error',
'error': str(e)
})
@socketio.on('save_branches') @socketio.on('save_branches')
def handle_save_branches(data): def handle_save_branches(data):
""" """
@@ -3157,6 +3353,168 @@ def get_share_info(record_id: int):
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
# ==================== 任务分享页面 ====================
@app.route('/share/<share_token>', methods=['GET'])
def get_shared_plan_page(share_token: str):
"""获取分享任务页面(无需登录验证)"""
try:
with get_db_context() as db:
share = PlanShareCRUD.get_by_token(db, share_token)
if not share:
return jsonify({'error': '分享链接无效或已失效'}), 404
# 检查是否过期
if share.expires_at and share.expires_at.replace(tzinfo=None) < datetime.now():
return jsonify({'error': '分享链接已过期'}), 404
# 增加查看次数
PlanShareCRUD.increment_view_count(db, share_token)
# 返回分享数据
task_data = share.task_data
return jsonify({
'share_token': share_token,
'task_id': share.task_id,
'task_data': task_data,
'created_at': share.created_at.isoformat() if share.created_at else None,
'view_count': share.view_count,
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/share/<share_token>/check', methods=['GET'])
def check_share_code(share_token: str):
"""检查分享链接是否需要提取码"""
try:
with get_db_context() as db:
share = PlanShareCRUD.get_by_token(db, share_token)
if not share:
return jsonify({'error': '分享链接无效或已失效'}), 404
# 检查是否过期
if share.expires_at and share.expires_at.replace(tzinfo=None) < datetime.now():
return jsonify({'error': '分享链接已过期'}), 404
# 如果有提取码,则需要提取码
need_code = bool(share.extraction_code)
return jsonify({
'need_code': need_code,
'has_extraction_code': bool(share.extraction_code)
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/share/<share_token>', methods=['GET'])
def get_shared_plan_info(share_token: str):
"""获取分享任务详情API 接口,无需登录验证)"""
# 获取URL参数中的提取码
code = request.args.get('code', '').upper()
try:
with get_db_context() as db:
share = PlanShareCRUD.get_by_token(db, share_token)
if not share:
return jsonify({'error': '分享链接无效或已失效'}), 404
# 检查是否过期
if share.expires_at and share.expires_at.replace(tzinfo=None) < datetime.now():
return jsonify({'error': '分享链接已过期'}), 404
# 验证提取码
if share.extraction_code:
if not code:
return jsonify({'error': '请输入提取码'}), 403
if code != share.extraction_code:
return jsonify({'error': '提取码错误'}), 403
# 增加查看次数
PlanShareCRUD.increment_view_count(db, share_token)
# 返回分享数据
task_data = share.task_data
return jsonify({
'share_token': share_token,
'task_id': share.task_id,
'task_data': task_data,
'created_at': share.created_at.isoformat() if share.created_at else None,
'expires_at': share.expires_at.isoformat() if share.expires_at else None,
'view_count': share.view_count,
'extraction_code': share.extraction_code,
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/share/import', methods=['POST'])
def import_shared_plan():
"""导入分享的任务到自己的历史记录HTTP API无需 WebSocket"""
try:
data = request.get_json()
share_token = data.get('share_token')
user_id = data.get('user_id')
if not share_token:
return jsonify({'error': '缺少 share_token'}), 400
if not user_id:
return jsonify({'error': '缺少 user_id请先登录'}), 401
with get_db_context() as db:
# 获取分享记录
share = PlanShareCRUD.get_by_token(db, share_token)
if not share:
return jsonify({'error': '分享链接无效或已失效'}), 404
# 检查是否过期
if share.expires_at and share.expires_at.replace(tzinfo=None) < datetime.now():
return jsonify({'error': '分享链接已过期'}), 404
# 获取分享的任务数据
task_data = share.task_data
# 生成新的 task_id因为是导入到自己的账号
new_task_id = str(uuid.uuid4())
# 创建新的任务记录
task = MultiAgentTaskCRUD.create(
db=db,
task_id=new_task_id,
user_id=user_id,
query=task_data.get("general_goal", ""),
agents_info=task_data.get("agents_info", []),
task_outline=task_data.get("task_outline"),
assigned_agents=task_data.get("assigned_agents"),
agent_scores=task_data.get("agent_scores"),
result=task_data.get("result"),
)
# 如果有分支数据,也保存
if task_data.get("branches"):
MultiAgentTaskCRUD.update_branches(db, new_task_id, task_data["branches"])
# 如果有执行日志,也保存
if task_data.get("rehearsal_log"):
MultiAgentTaskCRUD.update_rehearsal_log(db, new_task_id, task_data["rehearsal_log"])
# 增加分享的查看次数
PlanShareCRUD.increment_view_count(db, share_token)
return jsonify({
'success': True,
'message': '导入成功',
'task_id': new_task_id,
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/export/<int:record_id>', methods=['DELETE']) @app.route('/api/export/<int:record_id>', methods=['DELETE'])
def delete_export(record_id: int): def delete_export(record_id: int):
"""删除导出记录""" """删除导出记录"""

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>
<div class="plan-actions"> <div class="plan-actions">
<el-popover <el-popover
:ref="(el: any) => setPopoverRef(plan.id, el)"
placement="bottom-end" placement="bottom-end"
:show-arrow="false" :show-arrow="false"
trigger="click" trigger="click"
@@ -37,15 +38,15 @@
</button> </button>
</template> </template>
<div class="action-menu"> <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" /> <SvgIcon icon-class="ZhiDing" size="14px" />
<span>{{ plan.is_pinned ? '取消置顶' : '置顶' }}</span> <span>{{ plan.is_pinned ? '取消置顶' : '置顶' }}</span>
</div> </div>
<div class="action-item"> <div class="action-item" @click="handleAction(plan, 'share')">
<SvgIcon icon-class="FenXiang" size="14px" /> <SvgIcon icon-class="FenXiang" size="14px" />
<span>分享</span> <span>分享</span>
</div> </div>
<div class="action-item" @click="deletePlan(plan)"> <div class="action-item" @click="handleAction(plan, 'delete')">
<SvgIcon icon-class="ShanChu" size="14px" /> <SvgIcon icon-class="ShanChu" size="14px" />
<span>删除</span> <span>删除</span>
</div> </div>
@@ -62,6 +63,12 @@
content="删除后,该任务无法恢复 !" content="删除后,该任务无法恢复 !"
@confirm="confirmDelete" @confirm="confirmDelete"
/> />
<!-- 分享对话框 -->
<SharePlanDialog
v-model="shareDialogVisible"
:plan-id="sharePlanId"
/>
</div> </div>
</template> </template>
@@ -71,6 +78,7 @@ import { ElMessage } from 'element-plus'
import { Loading } from '@element-plus/icons-vue' import { Loading } from '@element-plus/icons-vue'
import SvgIcon from '@/components/SvgIcon/index.vue' import SvgIcon from '@/components/SvgIcon/index.vue'
import DeleteConfirmDialog from '@/components/DeleteConfirmDialog/index.vue' import DeleteConfirmDialog from '@/components/DeleteConfirmDialog/index.vue'
import SharePlanDialog from '@/components/SharePlanDialog/index.vue'
import websocket from '@/utils/websocket' import websocket from '@/utils/websocket'
// 事件定义 // 事件定义
@@ -101,6 +109,44 @@ const dialogVisible = ref(false)
const planToDelete = ref<PlanInfo | null>(null) const planToDelete = ref<PlanInfo | null>(null)
const deleting = ref(false) 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 // 生成唯一请求ID
let requestIdCounter = 0 let requestIdCounter = 0
const generateRequestId = () => `ws_req_${Date.now()}_${++requestIdCounter}` const generateRequestId = () => `ws_req_${Date.now()}_${++requestIdCounter}`

View File

@@ -1,19 +1,36 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus' 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 file_name: string
export_type: string export_type: string
created_at: string created_at: string
file_size: number 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 地址 // 获取 API 地址
const getApiBaseUrl = (): string => { const getApiBaseUrl = (): string => {
// 尝试从 localStorage 获取配置
const configStr = localStorage.getItem('app_config') const configStr = localStorage.getItem('app_config')
if (configStr) { if (configStr) {
try { try {
@@ -25,23 +42,117 @@ const getApiBaseUrl = (): string => {
// ignore // ignore
} }
} }
// 返回 /api 作为基础路径
return '/api' return '/api'
} }
const route = useRoute() const route = useRoute()
const loading = ref(true) 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) const error = ref<string | null>(null)
// 解析分享 token // 导入相关
const parseShareToken = (token: string): { recordId: number; timestamp: number } | null => { const importing = ref(false)
// 格式: export_{id}_{timestamp} 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+)$/) const match = token.match(/^export_(\d+)_(\d+)$/)
if (!match) return null if (!match) return null
return { return {
recordId: parseInt(match[1], 10), recordId: parseInt(match[1]!, 10),
timestamp: parseInt(match[2], 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 const token = route.params.token as string
console.log('分享 token:', token) console.log('分享 token:', token)
// 从URL参数中获取提取码
const queryCode = (route.query.code as string) || ''
if (!token) { if (!token) {
error.value = '无效的分享链接' error.value = '无效的分享链接'
loading.value = false loading.value = false
return return
} }
const parsed = parseShareToken(token) // 判断分享类型
if (isExportShare(token)) {
shareType.value = 'export'
const parsed = parseExportToken(token)
if (!parsed) { if (!parsed) {
error.value = '无效的分享链接格式' error.value = '无效的分享链接格式'
loading.value = false loading.value = false
return 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 { try {
// 从配置获取 API 地址
const apiBaseUrl = getApiBaseUrl() const apiBaseUrl = getApiBaseUrl()
const url = `${apiBaseUrl}/export/${parsed.recordId}/share/info` const url = `${apiBaseUrl}/share/${token}/check`
console.log('请求 URL:', url)
// 添加超时
const controller = new AbortController() const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10000) const timeoutId = setTimeout(() => controller.abort(), 10000)
const response = await fetch(url, { signal: controller.signal }) const response = await fetch(url, { signal: controller.signal })
clearTimeout(timeoutId) clearTimeout(timeoutId)
console.log('响应状态:', response.status) if (response.ok) {
const data = await response.json()
if (!response.ok) { if (data.need_code) {
if (response.status === 404) { needCode.value = true
error.value = '分享链接不存在或已失效'
} else { } 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 { } else {
error.value = '网络错误,请稍后重试' // 默认需要提取码
needCode.value = true
} }
} finally { } catch (e) {
loading.value = false 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 () => { const downloadFile = async () => {
if (!shareInfo.value) return if (!exportInfo.value) return
try { try {
const apiBaseUrl = getApiBaseUrl() const apiBaseUrl = getApiBaseUrl()
const token = route.params.token as string const token = route.params.token as string
const parsed = parseShareToken(token) const parsed = parseExportToken(token)
if (!parsed) { if (!parsed) {
ElMessage.error('无效的分享链接') ElMessage.error('无效的分享链接')
return return
} }
// 触发下载
window.location.href = `${apiBaseUrl}/api/export/${parsed.recordId}/download` window.location.href = `${apiBaseUrl}/api/export/${parsed.recordId}/download`
} catch (e) { } catch (e) {
console.error('下载失败:', e) console.error('下载失败:', e)
@@ -126,13 +270,44 @@ const downloadFile = async () => {
} }
} }
// 格式化文件大小 // 导入任务到我的历史记录
const formatFileSize = (bytes: number): string => { const importPlan = async () => {
if (bytes === 0) return '0 B' if (!planInfo.value) return
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB'] const userId = localStorage.getItem('user_id')
const i = Math.floor(Math.log(bytes) / Math.log(k)) if (!userId) {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] 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 getFileTypeName = (type: string): string => {
const typeMap: Record<string, string> = { const typeMap: Record<string, string> = {
'doc': 'Word 文档', doc: 'Word 文档',
'markdown': 'Markdown', markdown: 'Markdown',
'mindmap': '思维导图', mindmap: '思维导图',
'infographic': '信息图', infographic: '信息图',
'excel': 'Excel 表格', excel: 'Excel 表格',
'ppt': 'PPT 演示文稿' ppt: 'PPT 演示文稿'
} }
return typeMap[type] || type 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(() => { onMounted(() => {
// 设置页面标题 document.title = '分享'
document.title = '文件分享'
fetchShareInfo() fetchShareInfo()
}) })
</script> </script>
@@ -183,32 +388,135 @@ onMounted(() => {
<p>{{ error }}</p> <p>{{ error }}</p>
</div> </div>
<!-- 成功状态 --> <!-- 需要提取码 -->
<div v-else-if="shareInfo" class="success-state"> <div v-else-if="shareType === 'plan' && needCode && !planInfo" class="code-input-state">
<div class="success-icon"> <div class="code-icon">
<svg viewBox="0 0 24 24" width="64" height="64"> <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> </svg>
</div> </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="file-info">
<div class="info-item"> <div class="info-item">
<span class="label">文件类型</span> <span class="label">文件类型</span>
<span class="value">{{ getFileTypeName(shareInfo.export_type) }}</span> <span class="value">{{ getFileTypeName(exportInfo.export_type) }}</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="label">创建时间</span> <span class="label">创建时间</span>
<span class="value">{{ formatDate(shareInfo.created_at) }}</span> <span class="value">{{ formatDate(exportInfo.created_at) }}</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="label">文件大小</span> <span class="label">文件大小</span>
<span class="value">{{ formatFileSize(shareInfo.file_size) }}</span> <span class="value">{{ formatFileSize(exportInfo.file_size) }}</span>
</div> </div>
</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> </el-button>
</div> </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>
</div> </div>
</template> </template>
@@ -245,8 +553,12 @@ onMounted(() => {
} }
@keyframes spin { @keyframes spin {
0% { transform: rotate(0deg); } 0% {
100% { transform: rotate(360deg); } 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-state {
.success-icon { .success-icon {
color: #667eea; color: #667eea;
margin-bottom: 20px; margin-bottom: 20px;
} }
&.plan-share .success-icon.plan-icon {
color: #409eff;
}
h2 { h2 {
color: #333; color: #333;
margin-bottom: 24px; margin-bottom: 24px;
@@ -288,7 +654,8 @@ onMounted(() => {
font-size: 20px; font-size: 20px;
} }
.file-info { .file-info,
.plan-info {
background: #f8f9fa; background: #f8f9fa;
border-radius: 12px; border-radius: 12px;
padding: 20px; padding: 20px;
@@ -297,7 +664,8 @@ onMounted(() => {
.info-item { .info-item {
display: flex; display: flex;
justify-content: space-between; align-items: center;
gap: 8px;
padding: 8px 0; padding: 8px 0;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
@@ -305,6 +673,11 @@ onMounted(() => {
border-bottom: none; border-bottom: none;
} }
.el-icon {
color: #666;
font-size: 16px;
}
.label { .label {
color: #666; color: #666;
} }
@@ -316,10 +689,38 @@ onMounted(() => {
} }
} }
.download-btn { .plan-info .info-item {
flex-wrap: wrap;
}
.action-btn {
width: 100%; width: 100%;
height: 48px; height: 48px;
font-size: 16px; 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> </style>