feat:历史记录分享功能实现
This commit is contained in:
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 $$;
|
||||||
|
|||||||
@@ -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_id(task_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):
|
||||||
"""删除导出记录"""
|
"""删除导出记录"""
|
||||||
|
|||||||
471
frontend/src/components/SharePlanDialog/index.vue
Normal file
471
frontend/src/components/SharePlanDialog/index.vue
Normal 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>
|
||||||
@@ -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}`
|
||||||
|
|||||||
@@ -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
|
} else {
|
||||||
|
// 默认需要提取码
|
||||||
|
needCode.value = true
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('检查提取码失败:', e)
|
||||||
|
needCode.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证提取码
|
||||||
|
const verifyCode = async () => {
|
||||||
|
if (!inputCode.value || inputCode.value.length !== 4) {
|
||||||
|
ElMessage.warning('请输入4位提取码')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
loading.value = true
|
||||||
shareInfo.value = await response.json()
|
codeError.value = ''
|
||||||
console.log('分享信息:', shareInfo.value)
|
const success = await fetchPlanShareInfo(
|
||||||
} catch (e: any) {
|
route.params.token as string,
|
||||||
console.error('获取分享信息失败:', e)
|
inputCode.value.toUpperCase(),
|
||||||
if (e.name === 'AbortError') {
|
false
|
||||||
error.value = '请求超时,请稍后重试'
|
)
|
||||||
} else {
|
if (!success) {
|
||||||
error.value = '网络错误,请稍后重试'
|
codeError.value = '提取码错误,请重新输入'
|
||||||
|
inputCode.value = '' // 清空输入框
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
loading.value = false
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user