feat:1.历史记录重构UI重构2.置顶功能实现

This commit is contained in:
liailing1026
2026-02-27 11:39:20 +08:00
parent dad1ac2e51
commit c009db12a6
6 changed files with 376 additions and 149 deletions

View File

@@ -66,10 +66,10 @@ class MultiAgentTaskCRUD:
def get_recent(
db: Session, limit: int = 20, offset: int = 0
) -> List[MultiAgentTask]:
"""获取最近的任务记录"""
"""获取最近的任务记录,置顶的排在最前面"""
return (
db.query(MultiAgentTask)
.order_by(MultiAgentTask.created_at.desc())
.order_by(MultiAgentTask.is_pinned.desc(), MultiAgentTask.created_at.desc())
.offset(offset)
.limit(limit)
.all()
@@ -184,6 +184,18 @@ class MultiAgentTaskCRUD:
db.refresh(task)
return task
@staticmethod
def update_is_pinned(
db: Session, task_id: str, is_pinned: bool
) -> Optional[MultiAgentTask]:
"""更新任务置顶状态"""
task = db.query(MultiAgentTask).filter(MultiAgentTask.task_id == task_id).first()
if task:
task.is_pinned = is_pinned
db.commit()
db.refresh(task)
return task
@staticmethod
def append_rehearsal_log(
db: Session, task_id: str, log_entry: dict

View File

@@ -6,7 +6,7 @@ SQLAlchemy ORM 数据模型
import uuid
from datetime import datetime, timezone
from enum import Enum as PyEnum
from sqlalchemy import Column, String, Text, DateTime, Integer, Enum, Index, ForeignKey
from sqlalchemy import Column, String, Text, DateTime, Integer, Enum, Index, ForeignKey, Boolean
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
@@ -48,6 +48,7 @@ class MultiAgentTask(Base):
execution_id = Column(String(64))
rehearsal_log = Column(JSONB)
branches = Column(JSONB) # 任务大纲探索分支数据
is_pinned = Column(Boolean, default=False, nullable=False) # 置顶标志
created_at = Column(DateTime(timezone=True), default=utc_now)
updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
@@ -74,6 +75,7 @@ class MultiAgentTask(Base):
"execution_id": self.execution_id,
"rehearsal_log": self.rehearsal_log,
"branches": self.branches,
"is_pinned": self.is_pinned,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}

View File

@@ -331,10 +331,11 @@ def handle_execute_plan_optimized_ws(data):
MultiAgentTaskCRUD.update_status(db, task_id, TaskStatus.STOPPED)
print(f"[execute_plan_optimized] 用户停止执行,跳过保存执行数据,已完成 {completed_steps_count}/{plan_steps_count} 步骤task_id={task_id}")
# 任务大纲(用户可能编辑了)仍然保存
if plan:
MultiAgentTaskCRUD.update_task_outline(db, task_id, plan)
print(f"[execute_plan_optimized] 已保存 task_outline 到数据库,task_id={task_id}")
# # 任务大纲(用户可能编辑了)仍然保存
# # 注释原因:执行任务时不保存 task_outline避免覆盖导致步骤 ID 变化与 agent_scores 不匹配
# if plan:
# MultiAgentTaskCRUD.update_task_outline(db, task_id, plan)
# print(f"[execute_plan_optimized] 已保存 task_outline 到数据库task_id={task_id}")
# # 保存 assigned_agents每个步骤使用的 agent
# # 注释原因assigned_agents 只在生成阶段由用户手动选择写入,执行时不覆盖
@@ -1849,6 +1850,7 @@ def handle_get_plans(data):
"status": task.status.value if task.status else 'unknown',
"execution_count": task.execution_count or 0,
"created_at": task.created_at.isoformat() if task.created_at else None,
"is_pinned": task.is_pinned or False, # 置顶标志
# 完整数据用于恢复
"task_outline": task.task_outline,
"assigned_agents": task.assigned_agents,
@@ -2060,6 +2062,54 @@ def handle_delete_plan(data):
})
@socketio.on('pin_plan')
def handle_pin_plan(data):
"""
WebSocket版本置顶/取消置顶历史任务
"""
# socketio 包装: data = { id: 'pin_plan-xxx', action: 'pin_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')
is_pinned = incoming_data.get('is_pinned', 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.update_is_pinned(db, plan_id, is_pinned)
if not task:
emit('response', {
'id': request_id,
'status': 'error',
'error': f'任务不存在: {plan_id}'
})
return
# 通知所有客户端刷新历史列表
socketio.emit('history_updated', {'task_id': plan_id})
emit('response', {
'id': request_id,
'status': 'success',
'data': {"message": "置顶成功" if is_pinned else "取消置顶成功"}
})
except Exception as e:
emit('response', {
'id': request_id,
'status': 'error',
'error': str(e)
})
@socketio.on('save_branches')
def handle_save_branches(data):
"""

View File

@@ -0,0 +1,157 @@
<template>
<el-dialog
v-model="dialogVisible"
:width="width"
:modal="false"
:close-on-click-modal="false"
:center="true"
:show-close="false"
top="20vh"
@closed="handleClosed"
>
<template #header>
<div class="dialog-header">
<SvgIcon icon-class="JingGao" size="20px" color="#ff6712" />
<span>{{ title }}</span>
<button class="dialog-close-btn" @click="handleCancel">
<SvgIcon icon-class="close" size="18px" />
</button>
</div>
</template>
<slot>
<span>{{ content }}</span>
</slot>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleCancel">{{ cancelText }}</el-button>
<el-button class="confirm-btn" type="danger" :loading="loading" @click="handleConfirm">
{{ confirmText }}
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import SvgIcon from '@/components/SvgIcon/index.vue'
interface Props {
modelValue: boolean
title?: string
content?: string
width?: string | number
confirmText?: string
cancelText?: string
}
const props = withDefaults(defineProps<Props>(), {
title: '确认删除',
content: '删除后,该操作无法恢复!',
width: '400px',
confirmText: '删除',
cancelText: '取消'
})
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
(e: 'cancel'): void
}>()
const loading = ref(false)
const dialogVisible = ref(props.modelValue)
// 监听 v-model 变化
watch(
() => props.modelValue,
val => {
dialogVisible.value = val
}
)
// 监听对话框显示状态变化
watch(dialogVisible, val => {
emit('update:modelValue', val)
})
// 确认删除
const handleConfirm = async () => {
loading.value = true
emit('confirm')
}
// 取消
const handleCancel = () => {
emit('cancel')
dialogVisible.value = false
}
// 对话框关闭后重置 loading 状态
const handleClosed = () => {
loading.value = false
}
// 暴露方法供外部调用
defineExpose({
setLoading: (val: boolean) => {
loading.value = val
},
close: () => {
dialogVisible.value = false
}
})
</script>
<style scoped lang="scss">
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 16px;
font-weight: 600;
width: 100%;
.dialog-close-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
margin-left: auto;
transition: transform 0.3s ease;
&:hover {
transform: rotate(360deg);
}
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
.el-button:not(.confirm-btn) {
background: #ffffff;
border-color: #dcdfe6;
color: #000000;
&:hover {
background: #f5f5f5;
border-color: #c0c0c0;
}
}
}
.confirm-btn {
--el-button-bg-color: #ff6712;
--el-button-border-color: #ff6712;
--el-button-hover-bg-color: #e55a0f;
--el-button-hover-border-color: #e55a0f;
}
</style>

View File

@@ -18,7 +18,7 @@ const symbolId = computed(() => `#${props.prefix}-${props.iconClass}`)
</script>
<template>
<svg aria-hidden="true" class="svg-icon" :style="`color:${props.color}`">
<svg aria-hidden="true" class="svg-icon" :style="props.color ? `color:${props.color}` : ''">
<use :xlink:href="symbolId" />
</svg>
</template>

View File

@@ -1,13 +1,5 @@
<template>
<div class="history-list">
<div class="header">
<h3>📋 历史任务</h3>
<el-button type="primary" link @click="fetchPlans">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading">
<el-icon class="is-loading"><Loading /></el-icon>
@@ -24,66 +16,61 @@
:key="plan.id"
class="plan-item"
:class="{ active: selectedPlanId === plan.id }"
@click="selectPlan(plan)"
@click="restorePlan(plan)"
>
<div class="plan-info">
<div class="plan-goal">{{ plan.general_goal || '未知任务' }}</div>
<div class="plan-meta">
<el-tag size="small" :type="getStatusType(plan.status)">
{{ getStatusText(plan.status) }}
</el-tag>
<span class="plan-time">{{ formatTime(plan.created_at) }}</span>
</div>
<div class="plan-stats">
<span>执行次数: {{ plan.execution_count }}</span>
<div class="plan-goal">
<SvgIcon icon-class="XiaoXi" size="20px" />
{{ plan.general_goal || '未知任务' }}
</div>
</div>
<div class="plan-actions">
<el-button
type="primary"
size="small"
@click.stop="restorePlan(plan)"
:disabled="restoring"
<el-popover
placement="bottom-end"
:show-arrow="false"
trigger="click"
popper-class="action-popover"
>
恢复
</el-button>
<el-button
type="danger"
size="small"
link
@click.stop="deletePlan(plan)"
>
<el-icon><Delete /></el-icon>
</el-button>
<template #reference>
<button class="more-btn" @click.stop>
<SvgIcon icon-class="more" class="more-icon" size="16px" />
</button>
</template>
<div class="action-menu">
<div class="action-item" @click="pinPlan(plan)">
<SvgIcon icon-class="ZhiDing" size="14px" />
<span>{{ plan.is_pinned ? '取消置顶' : '置顶' }}</span>
</div>
<div class="action-item">
<SvgIcon icon-class="FenXiang" size="14px" />
<span>分享</span>
</div>
<div class="action-item" @click="deletePlan(plan)">
<SvgIcon icon-class="ShanChu" size="14px" />
<span>删除</span>
</div>
</div>
</el-popover>
</div>
</div>
<!-- 分页 -->
<div v-if="plans.length > 0" class="pagination">
<span> {{ total }} 个任务</span>
</div>
<!-- 删除确认对话框 -->
<el-dialog
<DeleteConfirmDialog
v-model="dialogVisible"
title="删除确认"
width="400px"
:close-on-click-modal="false"
>
<span>确定要删除任务 "{{ planToDelete?.general_goal }}" 此操作不可恢复</span>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="danger" @click="confirmDelete" :loading="deleting">确定删除</el-button>
</template>
</el-dialog>
title="确认删除该任务 ?"
content="删除后,该任务无法恢复 !"
@confirm="confirmDelete"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh, Loading, Delete } from '@element-plus/icons-vue'
import { Loading } from '@element-plus/icons-vue'
import SvgIcon from '@/components/SvgIcon/index.vue'
import DeleteConfirmDialog from '@/components/DeleteConfirmDialog/index.vue'
import websocket from '@/utils/websocket'
// 事件定义
@@ -94,12 +81,9 @@ const emit = defineEmits<{
// 数据类型
interface PlanInfo {
id: string // 对应数据库 task_id
general_goal: string // 对应数据库 query
status: string
execution_count: number
created_at: string
// 完整恢复数据
id: string
general_goal: string
is_pinned?: boolean
task_outline?: any
assigned_agents?: any
agent_scores?: any
@@ -111,8 +95,6 @@ const plans = ref<PlanInfo[]>([])
const loading = ref(false)
const restoring = ref(false)
const selectedPlanId = ref<string | null>(null)
const total = ref(0)
const isConnected = ref(false)
// 删除对话框相关
const dialogVisible = ref(false)
@@ -137,22 +119,14 @@ const fetchPlans = async () => {
try {
const result = await websocket.send('get_plans', { id: reqId })
plans.value = (result.data || []) as PlanInfo[]
total.value = plans.value.length
isConnected.value = true
} catch (error) {
console.error('获取任务列表失败:', error)
ElMessage.error('获取任务列表失败')
isConnected.value = false
} finally {
loading.value = false
}
}
// 选择任务
const selectPlan = (plan: PlanInfo) => {
selectedPlanId.value = plan.id
}
// 恢复任务
const restorePlan = async (plan: PlanInfo) => {
if (restoring.value) return
@@ -192,6 +166,25 @@ const restorePlan = async (plan: PlanInfo) => {
}
}
// 置顶/取消置顶任务
const pinPlan = async (plan: PlanInfo) => {
const newPinnedState = !plan.is_pinned
const reqId = generateRequestId()
try {
await websocket.send('pin_plan', {
id: reqId,
data: { plan_id: plan.id, is_pinned: newPinnedState }
})
ElMessage.success(newPinnedState ? '置顶成功' : '取消置顶成功')
// 成功后会自动通过 history_updated 事件刷新列表
} catch (error) {
console.error('置顶任务失败:', error)
ElMessage.error('置顶失败')
}
}
// 删除任务
const deletePlan = (plan: PlanInfo) => {
planToDelete.value = plan
@@ -222,44 +215,6 @@ const confirmDelete = async () => {
}
}
// 格式化时间
const formatTime = (timeStr: string | undefined) => {
if (!timeStr) return ''
try {
const date = new Date(timeStr)
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
} catch {
return timeStr
}
}
// 获取状态类型
const getStatusType = (status: string): string => {
const statusMap: Record<string, string> = {
'generating': 'warning',
'executing': 'warning',
'completed': 'success',
'stopped': 'danger'
}
return statusMap[status] || 'info'
}
// 获取状态文本
const getStatusText = (status: string): string => {
const statusMap: Record<string, string> = {
'generating': '生成中',
'executing': '执行中',
'completed': '已完成',
'stopped': '已停止'
}
return statusMap[status] || status
}
// 生命周期
onMounted(() => {
fetchPlans()
@@ -281,19 +236,6 @@ onUnmounted(() => {
flex-direction: column;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
}
.loading {
display: flex;
align-items: center;
@@ -315,17 +257,21 @@ onUnmounted(() => {
padding: 12px;
margin-bottom: 8px;
border-radius: 8px;
background: #f5f7fa;
background: var(--color-bg-three);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #ecf5ff;
background: var(--color-bg-hover);
.plan-goal {
color: var(--color-text-plan-item-hover);
}
}
&.active {
background: #ecf5ff;
border-left: 3px solid #409eff;
background: var(--color-bg-hover);
border-left: 3px solid var(--color-primary);
}
}
@@ -335,29 +281,17 @@ onUnmounted(() => {
}
.plan-goal {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 500;
margin-bottom: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.plan-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.plan-time {
font-size: 12px;
color: #909399;
}
.plan-stats {
font-size: 12px;
color: #606266;
color: var(--color-text-plan-item);
transition: color 0.2s;
}
.plan-actions {
@@ -366,11 +300,83 @@ onUnmounted(() => {
margin-left: 12px;
}
.pagination {
// 更多按钮
.more-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 16px;
color: #909399;
border-radius: 50%;
transition: background-color 0.2s;
.more-icon {
color: var(--color-text-plan-item);
transition: color 0.2s;
}
&:hover {
background-color: var(--color-card-border-three);
border-radius: 50%;
.more-icon {
color: var(--color-text-plan-item-hover);
}
}
}
// 操作菜单
.action-menu {
display: flex;
flex-direction: column;
:deep(.el-popover__content) {
padding: 0;
}
}
.action-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
cursor: pointer;
font-size: 13px;
color: var(--color-text-plan-item);
transition: background-color 0.2s, color 0.2s;
.svg-icon {
color: var(--color-text-plan-item);
transition: color 0.2s;
}
&:first-child {
border-radius: 8px 8px 0 0;
}
&:last-child {
border-radius: 0 0 8px 8px;
}
&:hover {
background-color: var(--color-card-border-three);
color: var(--color-text-plan-item-hover);
.svg-icon {
color: var(--color-text-plan-item-hover);
}
}
}
</style>
<style lang="scss">
.action-popover {
padding: 0 !important;
border-radius: 8px !important;
width: 120px !important;
min-width: 120px !important;
}
</style>