feat: #36 主控台/应用商店 — 模板一键执行与模板市场一键安装

- MainConsole.vue: 完全重写为应用商店主控台,支持分类筛选、参数表单、一键执行、进度轮询、结果下载
- TemplateMarket.vue: 新增"一键安装"和"快速执行"按钮,优化业务用户体验
- platform_templates.py: 新增模板执行API(POST execute + GET progress),支持Celery异步/同步回退

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
renjianbo
2026-05-06 21:30:58 +08:00
parent fa13ffe479
commit 1b5f9deb44
3 changed files with 797 additions and 113 deletions

View File

@@ -1,15 +1,19 @@
"""
场景模板 API独立路由避免与 /api/v1/agents/{agent_id} 在部分部署中的匹配顺序问题)。
"""
from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, Depends, status, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from typing import List
from typing import Any, Dict, List, Optional
import logging
import uuid
import json
from app.core.database import get_db
from app.api.auth import get_current_user
from app.models.user import User
from app.models.agent import Agent
from app.models.execution import Execution
from app.core.exceptions import ValidationError, ConflictError
from app.services.workflow_validator import validate_workflow
from app.services.scene_templates import build_workflow_for_template, list_scene_template_meta
@@ -19,6 +23,27 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/platform", tags=["platform-templates"])
# ---------- 执行模板请求体 ----------
class ExecuteTemplateRequest(BaseModel):
message: str = Field(..., description="用户输入/任务描述")
parameters: Optional[Dict[str, Any]] = Field(default_factory=dict, description="模板参数覆盖")
class ExecuteTemplateResponse(BaseModel):
execution_id: str
status: str
message: str
# ---------- 执行进度响应 ----------
class ExecutionProgressResponse(BaseModel):
execution_id: str
status: str
progress_pct: int = 0
output: Optional[str] = None
error: Optional[str] = None
execution_time_ms: Optional[int] = None
@router.get("/scene-templates", response_model=List[SceneTemplateItem])
async def list_scene_templates_v1(current_user: User = Depends(get_current_user)):
@@ -70,3 +95,183 @@ async def create_agent_from_template_v1(
f"用户 {current_user.username} 从模板 {body.template_id} 创建 Agent: {agent.name} ({agent.id})"
)
return agent
@router.post("/templates/{template_id}/execute", response_model=ExecuteTemplateResponse)
async def execute_template(
template_id: str,
body: ExecuteTemplateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""一键执行模板:生成工作流 → 创建 Execution → 触发异步执行 → 返回 execution_id 用于轮询进度。"""
# 1. 根据模板 ID 和参数构建工作流
try:
workflow_config = build_workflow_for_template(
template_id, body.parameters or {}
)
except ValueError as e:
raise ValidationError(str(e))
# 2. 校验工作流
validation_result = validate_workflow(
workflow_config.get("nodes", []), workflow_config.get("edges", [])
)
if not validation_result["valid"]:
raise ValidationError(
"工作流配置验证失败: " + ", ".join(validation_result["errors"])
)
# 3. 创建临时 Agent内联不持久化到 agents 表,避免污染列表)
# 使用 name = f"平台模板执行-{template_id}" 作为标记
agent_name = f"平台模板执行-{template_id}-{uuid.uuid4().hex[:8]}"
temp_agent = Agent(
name=agent_name,
description=f"来自平台模板 {template_id} 的一次性执行",
workflow_config=workflow_config,
user_id=current_user.id,
status="draft",
)
db.add(temp_agent)
db.flush() # 获取 agent.id
# 4. 创建 Execution 记录
execution = Execution(
agent_id=temp_agent.id,
input_data={
"USER_INPUT": body.message,
"query": body.message,
"message": body.message,
"template_id": template_id,
"parameters": body.parameters or {},
},
status="pending",
)
db.add(execution)
db.flush()
execution_id = str(execution.id)
agent_id_str = str(temp_agent.id)
# 5. 触发异步执行
try:
from app.tasks.agent_tasks import execute_agent_task
task = execute_agent_task.delay(
agent_id_str,
{
"USER_INPUT": body.message,
"query": body.message,
"message": body.message,
},
execution_id=execution_id,
)
execution.task_id = task.id
execution.status = "running"
db.commit()
logger.info(
f"模板执行已触发: template={template_id} execution={execution_id} "
f"task={task.id} user={current_user.username}"
)
except Exception as e:
# Celery 不可用时,回退到同步执行
logger.warning(f"Celery 不可用,回退同步执行: {e}")
execution.status = "running"
db.commit()
_run_template_sync(db, temp_agent, execution, body.message)
return ExecuteTemplateResponse(
execution_id=execution_id,
status=execution.status,
message="模板执行已触发,请通过 execution_id 轮询进度",
)
def _run_template_sync(db: Session, agent: Agent, execution: Execution, message: str):
"""同步执行模板Celery 不可用时的回退方案)。"""
import asyncio
from app.agent_runtime.core import AgentRuntime
from app.agent_runtime.schemas import AgentConfig, AgentLLMConfig, AgentToolConfig, AgentMemoryConfig
wf = agent.workflow_config or {}
nodes = wf.get("nodes", [])
system_prompt = "你是一个有用的AI助手。"
model = "deepseek-v4-flash"
provider = "deepseek"
temperature = 0.7
max_iterations = 10
for n in nodes:
cfg = n.get("data", {}) if isinstance(n, dict) else {}
if n.get("type") in ("agent", "llm", "template"):
system_prompt = cfg.get("system_prompt", "") or cfg.get("prompt", "") or system_prompt
model = cfg.get("model", model)
provider = cfg.get("provider", provider)
temperature = float(cfg.get("temperature", temperature))
max_iterations = int(cfg.get("max_iterations", max_iterations))
break
async def _run():
config = AgentConfig(
name=agent.name,
system_prompt=system_prompt,
llm=AgentLLMConfig(model=model, provider=provider, temperature=temperature, max_iterations=max_iterations),
tools=AgentToolConfig(),
memory=AgentMemoryConfig(),
)
runtime = AgentRuntime(config=config)
return await runtime.run(message)
try:
loop = asyncio.get_event_loop()
if loop.is_running():
import nest_asyncio
nest_asyncio.apply()
result = asyncio.run(_run())
except RuntimeError:
result = asyncio.run(_run())
import time
execution.output_data = {"result": result.content, "iterations": result.iterations_used}
execution.status = "completed" if result.success else "failed"
execution.execution_time = 0
if not result.success:
execution.error_message = result.error
db.commit()
@router.get("/templates/executions/{execution_id}/progress", response_model=ExecutionProgressResponse)
async def get_execution_progress(
execution_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""查询模板执行进度。"""
execution = db.query(Execution).filter(Execution.id == execution_id).first()
if not execution:
raise HTTPException(status_code=404, detail="执行记录不存在")
progress_pct = 0
if execution.status == "running":
progress_pct = 50
elif execution.status == "completed":
progress_pct = 100
elif execution.status == "failed":
progress_pct = 100
output = None
if execution.output_data:
if isinstance(execution.output_data, dict):
output = execution.output_data.get("result") or execution.output_data.get("output") or json.dumps(execution.output_data, ensure_ascii=False)
elif isinstance(execution.output_data, str):
output = execution.output_data
return ExecutionProgressResponse(
execution_id=execution_id,
status=execution.status,
progress_pct=progress_pct,
output=output,
error=execution.error_message,
execution_time_ms=execution.execution_time,
)

View File

@@ -1,130 +1,505 @@
<template>
<MainLayout>
<div class="main-console">
<el-row :gutter="20" class="hero-row">
<el-col :span="12">
<el-card shadow="hover" class="panel-card" @click="router.push('/template-market')">
<div class="panel-inner">
<el-icon class="panel-icon" :size="40"><Star /></el-icon>
<!-- 顶部标题栏 -->
<div class="page-header">
<div class="header-left">
<h1>应用商店</h1>
<p class="subtitle">选择一个模板填写参数一键执行</p>
</div>
<div class="header-right">
<el-button type="primary" @click="router.push('/template-market')">
<el-icon><Shop /></el-icon>
浏览模板市场
</el-button>
<el-button @click="router.push('/executions')">
<el-icon><List /></el-icon>
执行历史
</el-button>
</div>
</div>
<!-- 分类筛选标签页 -->
<el-tabs v-model="activeCategory" @tab-change="handleCategoryChange" class="category-tabs">
<el-tab-pane label="全部" name="all" />
<el-tab-pane v-for="cat in categories" :key="cat.key" :label="cat.label" :name="cat.key" />
</el-tabs>
<!-- 模板卡片网格 -->
<div v-loading="loading" class="template-grid">
<el-empty v-if="!loading && filteredTemplates.length === 0" description="暂无模板" />
<el-row :gutter="16" v-else>
<el-col
v-for="tpl in filteredTemplates"
:key="tpl.id"
:xs="24" :sm="12" :md="8" :lg="6"
style="margin-bottom: 16px"
>
<el-card class="template-card" shadow="hover" @click="openExecuteDialog(tpl)">
<div class="card-header-area">
<el-tag
:type="categoryTagType(tpl.category)"
size="small"
class="category-badge"
>
{{ getCategoryName(tpl.category) }}
</el-tag>
</div>
<h3 class="card-title">{{ tpl.title }}</h3>
<p class="card-desc">{{ tpl.description }}</p>
<div class="card-footer">
<el-button type="primary" size="small" @click.stop="openExecuteDialog(tpl)">
<el-icon><VideoPlay /></el-icon>
立即执行
</el-button>
</div>
</el-card>
</el-col>
</el-row>
</div>
<!-- 快捷入口卡片 -->
<el-row :gutter="16" class="quick-links">
<el-col :span="8">
<el-card shadow="hover" class="quick-card" @click="router.push('/agent-chat')">
<div class="quick-inner">
<el-icon :size="32" color="#409eff"><ChatDotRound /></el-icon>
<div>
<h2>应用商店</h2>
<p class="muted">浏览模板市场从模板快速创建工作流或 Agent</p>
<el-button type="primary" text>进入模板市场 </el-button>
<h3>AI 对话</h3>
<p class="muted"> AI Agent 自由对话</p>
</div>
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="hover" class="panel-card" @click="router.push('/executions')">
<div class="panel-inner">
<el-icon class="panel-icon" :size="40"><List /></el-icon>
<el-col :span="8">
<el-card shadow="hover" class="quick-card" @click="router.push('/agents')">
<div class="quick-inner">
<el-icon :size="32" color="#67c23a"><UserFilled /></el-icon>
<div>
<h2>运行看板</h2>
<p class="muted">查看执行历史父子链路与审批挂起awaiting_approval状态</p>
<el-button type="primary" text>打开执行历史 </el-button>
<h3>我的 Agent</h3>
<p class="muted">管理和配置已有 Agent</p>
</div>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="hover" class="quick-card" @click="router.push('/executions')">
<div class="quick-inner">
<el-icon :size="32" color="#e6a23c"><DataAnalysis /></el-icon>
<div>
<h3>运行看板</h3>
<p class="muted">查看执行历史与状态</p>
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" class="hero-row">
<el-col :span="24">
<el-card shadow="hover" class="panel-card slim" @click="router.push('/execution-board')">
<div class="panel-inner">
<el-icon class="panel-icon" :size="36"><Histogram /></el-icon>
<div>
<h2>父子执行链看板</h2>
<p class="muted">输入根执行 ID查看子工作流 / invoke_agent 整棵执行树与状态汇总</p>
<el-button type="primary" text>打开看板 </el-button>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 执行对话框 -->
<el-dialog
v-model="showExecute"
:title="`执行模板:${selectedTemplate?.title || ''}`"
width="700px"
:close-on-click-modal="false"
destroy-on-close
>
<el-form v-if="selectedTemplate" :model="executeForm" label-width="120px" @submit.prevent>
<!-- 任务描述 -->
<el-form-item label="任务描述" required>
<el-input
v-model="executeForm.message"
type="textarea"
:rows="3"
placeholder="请描述你想做的事情,例如:'帮我写一个 Python 脚本分析日志文件'"
/>
</el-form-item>
<el-card class="recent-card">
<template #header>
<div class="card-header">
<span>最近执行</span>
<el-button size="small" :loading="loading" @click="loadRecent">刷新</el-button>
</div>
<!-- 动态参数 -->
<template v-if="parameterHints.length > 0">
<el-divider content-position="left">高级参数</el-divider>
<el-form-item
v-for="hint in parameterHints"
:key="hint.key"
:label="hint.label"
>
<el-input
v-if="hint.type === 'string'"
v-model="executeForm.parameters[hint.key]"
:placeholder="hint.placeholder"
/>
<el-select
v-else-if="hint.type === 'select'"
v-model="executeForm.parameters[hint.key]"
:placeholder="hint.placeholder"
>
<el-option
v-for="opt in hint.options"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-input-number
v-else-if="hint.type === 'number'"
v-model="executeForm.parameters[hint.key]"
:min="0" :max="1" :step="0.05"
/>
<el-switch
v-else-if="hint.type === 'boolean'"
v-model="executeForm.parameters[hint.key]"
/>
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="showExecute = false" :disabled="executing">取消</el-button>
<el-button type="primary" @click="doExecute" :loading="executing" :disabled="!executeForm.message.trim()">
<el-icon><VideoPlay /></el-icon>
一键执行
</el-button>
</template>
<el-table :data="recent" v-loading="loading" style="width: 100%" @row-click="onRow">
<el-table-column prop="id" label="执行 ID" width="220">
<template #default="{ row }">
<el-link type="primary" @click.stop="goDetail(row.id)">
{{ row.id.substring(0, 8) }}
</el-link>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="130">
<template #default="{ row }">
<el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="depth" label="深度" width="72" />
<el-table-column prop="created_at" label="创建时间" />
</el-table>
</el-card>
</el-dialog>
<!-- 执行进度对话框 -->
<el-dialog
v-model="showProgress"
title="执行进度"
width="700px"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="!executing"
destroy-on-close
>
<div class="progress-area">
<!-- 进度条 -->
<el-steps :active="progressStep" align-center finish-status="success">
<el-step title="提交任务" description="已创建执行记录" />
<el-step title="执行中" description="Agent 正在处理" />
<el-step title="完成" description="结果已就绪" />
</el-steps>
<el-progress
:percentage="progressPct"
:status="progressStatus"
:text-inside="true"
:stroke-width="20"
style="margin: 24px 0"
/>
<el-alert
v-if="execStatus === 'running'"
title="执行中,请稍候..."
type="info"
:closable="false"
show-icon
/>
<el-alert
v-if="execStatus === 'failed'"
:title="'执行失败: ' + (execError || '未知错误')"
type="error"
:closable="false"
show-icon
/>
</div>
<!-- 结果展示 -->
<div v-if="execStatus === 'completed' && execOutput" class="result-area" style="margin-top: 16px">
<el-divider content-position="left">执行结果</el-divider>
<el-input
:model-value="execOutput"
type="textarea"
:rows="12"
readonly
resize="vertical"
/>
<div style="margin-top: 12px; display: flex; gap: 8px">
<el-button type="primary" size="small" @click="copyResult">
<el-icon><CopyDocument /></el-icon>
复制结果
</el-button>
<el-button size="small" @click="downloadResult">
<el-icon><Download /></el-icon>
下载结果
</el-button>
<el-button size="small" @click="router.push(`/executions/${executionId}`)">
<el-icon><Link /></el-icon>
查看详情
</el-button>
</div>
</div>
<template #footer>
<el-button v-if="!executing" @click="showProgress = false">关闭</el-button>
</template>
</el-dialog>
</div>
</MainLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
Shop, List, VideoPlay, ChatDotRound, UserFilled, DataAnalysis,
CopyDocument, Download, Link,
} from '@element-plus/icons-vue'
import MainLayout from '@/components/MainLayout.vue'
import { Star, List, Histogram } from '@element-plus/icons-vue'
import { useExecutionStore } from '@/stores/execution'
import api from '@/api'
const router = useRouter()
const executionStore = useExecutionStore()
const recent = ref<any[]>([])
const loading = ref(false)
const loadRecent = async () => {
// ---------- 模板数据 ----------
interface TemplateItem {
id: string
title: string
description: string
category: string
default_temperature: number
parameter_hints: string[]
}
const templates = ref<TemplateItem[]>([])
const loading = ref(false)
const activeCategory = ref('all')
const categories = [
{ key: 'customer_service', label: '客服场景' },
{ key: 'dev', label: '研发辅助' },
{ key: 'ops', label: '运维分析' },
{ key: 'education', label: '学习教育' },
]
const filteredTemplates = computed(() => {
if (activeCategory.value === 'all') return templates.value
return templates.value.filter(t => t.category === activeCategory.value)
})
// ---------- 执行 ----------
const showExecute = ref(false)
const showProgress = ref(false)
const selectedTemplate = ref<TemplateItem | null>(null)
const executing = ref(false)
const executionId = ref('')
const execStatus = ref('')
const execOutput = ref('')
const execError = ref('')
const progressPct = ref(0)
let pollTimer: ReturnType<typeof setInterval> | null = null
const executeForm = ref<{ message: string; parameters: Record<string, any> }>({
message: '',
parameters: {},
})
// 从 parameter_hints 解析参数表单字段
const parameterHints = computed(() => {
if (!selectedTemplate.value) return []
const hints = selectedTemplate.value.parameter_hints || []
const result: Array<{ key: string; label: string; type: string; placeholder: string; options?: Array<{ label: string; value: string }> }> = []
for (const h of hints) {
const key = h.split('')[0].split('(')[0].split('')[0].trim()
if (key === 'temperature') {
result.push({ key: 'temperature', label: '温度', type: 'number', placeholder: '0.0-1.0' })
} else if (key === 'enable_tools') {
result.push({ key: 'enable_tools', label: '启用工具', type: 'boolean', placeholder: '' })
} else if (key === 'extra_instructions') {
result.push({ key: 'extra_instructions', label: '额外说明', type: 'string', placeholder: '补充指令...' })
} else if (key === 'preferred_language') {
result.push({ key: 'preferred_language', label: '编程语言', type: 'string', placeholder: '如 Python、Go' })
} else if (key === 'subject') {
result.push({
key: 'subject', label: '学科领域', type: 'select', placeholder: '选择学科',
options: [
{ label: '语文', value: '语文' }, { label: '数学', value: '数学' },
{ label: '英语', value: '英语' }, { label: '物理', value: '物理' },
{ label: '化学', value: '化学' }, { label: '编程', value: '编程' },
{ label: '通用', value: '通用' },
],
})
} else if (key === 'level') {
result.push({
key: 'level', label: '难度级别', type: 'select', placeholder: '选择级别',
options: [
{ label: '初级', value: '初级' }, { label: '中级', value: '中级' }, { label: '高级', value: '高级' },
],
})
} else {
result.push({ key: key, label: h, type: 'string', placeholder: `请输入${h}` })
}
}
return result
})
const progressStep = computed(() => {
if (execStatus.value === 'completed' || execStatus.value === 'failed') return 2
if (execStatus.value === 'running') return 1
return 0
})
const progressStatus = computed(() => {
if (execStatus.value === 'completed') return 'success' as const
if (execStatus.value === 'failed') return 'exception' as const
return undefined
})
// ---------- 方法 ----------
const loadTemplates = async () => {
loading.value = true
try {
await executionStore.fetchExecutions({ limit: 15, skip: 0 })
recent.value = executionStore.executions.slice(0, 15)
const resp = await api.get('/api/v1/platform/scene-templates')
if (Array.isArray(resp.data)) {
templates.value = resp.data
} else {
const key = Object.keys(resp.data || {})[0]
templates.value = key ? (resp.data[key] || []) : []
}
} catch {
ElMessage.warning('模板加载失败,请检查后端服务')
} finally {
loading.value = false
}
}
const statusType = (s: string) => {
const m: Record<string, string> = {
pending: 'info',
running: 'warning',
completed: 'success',
failed: 'danger',
awaiting_approval: 'warning'
const handleCategoryChange = () => {
// 筛选由 computed 自动处理
}
const categoryTagType = (cat: string) => {
const map: Record<string, string> = {
customer_service: 'success',
dev: 'primary',
ops: 'warning',
education: 'danger',
}
return m[s] || 'info'
return map[cat] || 'info'
}
const statusText = (s: string) => {
const m: Record<string, string> = {
pending: '等待中',
running: '执行中',
completed: '已完成',
failed: '失败',
awaiting_approval: '待审批'
const getCategoryName = (cat: string) => {
const map: Record<string, string> = {
customer_service: '客服场景',
dev: '研发辅助',
ops: '运维分析',
education: '学习教育',
}
return m[s] || s
return map[cat] || cat
}
const goDetail = (id: string) => {
router.push(`/executions/${id}`)
const openExecuteDialog = (tpl: TemplateItem) => {
selectedTemplate.value = tpl
executeForm.value = {
message: '',
parameters: {
temperature: tpl.default_temperature || 0.7,
enable_tools: true,
},
}
showExecute.value = true
}
const onRow = (row: any) => {
goDetail(row.id)
const doExecute = async () => {
if (!executeForm.value.message.trim()) {
ElMessage.warning('请输入任务描述')
return
}
if (!selectedTemplate.value) return
executing.value = true
showExecute.value = false
showProgress.value = true
execStatus.value = 'running'
execOutput.value = ''
execError.value = ''
progressPct.value = 10
try {
const resp = await api.post(
`/api/v1/platform/templates/${selectedTemplate.value.id}/execute`,
{
message: executeForm.value.message,
parameters: executeForm.value.parameters,
},
{ timeout: 30000 } as any,
)
executionId.value = resp.data.execution_id
execStatus.value = resp.data.status
progressPct.value = 20
// 开始轮询进度
startPolling()
} catch (err: any) {
execStatus.value = 'failed'
execError.value = err.response?.data?.detail || err.message || '执行请求失败'
executing.value = false
ElMessage.error(execError.value)
}
}
const startPolling = () => {
stopPolling()
pollTimer = setInterval(async () => {
try {
const resp = await api.get(
`/api/v1/platform/templates/executions/${executionId.value}/progress`,
)
const data = resp.data
execStatus.value = data.status
progressPct.value = data.progress_pct || (data.status === 'running' ? 50 : 100)
if (data.output) {
execOutput.value = data.output
}
if (data.error) {
execError.value = data.error
}
if (data.status === 'completed' || data.status === 'failed') {
stopPolling()
executing.value = false
if (data.status === 'completed') {
ElMessage.success('模板执行完成')
} else {
ElMessage.error(`执行失败: ${data.error || '未知错误'}`)
}
}
} catch {
// 轮询失败不中断
}
}, 2000)
}
const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
const copyResult = async () => {
try {
await navigator.clipboard.writeText(execOutput.value)
ElMessage.success('已复制到剪贴板')
} catch {
ElMessage.warning('复制失败,请手动选择文本')
}
}
const downloadResult = () => {
const blob = new Blob([execOutput.value], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `result-${executionId.value.substring(0, 8)}.txt`
a.click()
URL.revokeObjectURL(url)
}
onMounted(() => {
loadRecent()
loadTemplates()
})
</script>
@@ -133,37 +508,103 @@ onMounted(() => {
max-width: 1200px;
margin: 0 auto;
}
.hero-row {
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.header-left h1 {
margin: 0 0 4px 0;
font-size: 24px;
}
.header-right {
display: flex;
gap: 8px;
}
.subtitle {
color: var(--el-text-color-secondary);
margin: 0;
font-size: 14px;
}
.category-tabs {
margin-bottom: 8px;
}
.template-grid {
min-height: 200px;
margin-bottom: 24px;
}
.template-card {
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
height: 100%;
display: flex;
flex-direction: column;
}
.template-card:hover {
transform: translateY(-4px);
}
.card-header-area {
margin-bottom: 8px;
}
.card-title {
margin: 0 0 8px 0;
font-size: 16px;
}
.card-desc {
color: var(--el-text-color-secondary);
font-size: 13px;
margin: 0 0 12px 0;
flex: 1;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-footer {
display: flex;
justify-content: flex-end;
}
.quick-links {
margin-bottom: 20px;
}
.panel-card {
.quick-card {
cursor: pointer;
min-height: 140px;
transition: transform 0.2s;
}
.panel-card.slim {
min-height: auto;
.quick-card:hover {
transform: translateY(-2px);
}
.panel-inner {
.quick-inner {
display: flex;
gap: 16px;
align-items: flex-start;
align-items: center;
}
.panel-icon {
color: var(--el-color-primary);
flex-shrink: 0;
.quick-inner h3 {
margin: 0;
font-size: 15px;
}
.muted {
color: var(--el-text-color-secondary);
margin: 8px 0;
font-size: 14px;
font-size: 13px;
margin: 4px 0 0 0;
}
.recent-card .card-header {
display: flex;
justify-content: space-between;
align-items: center;
.progress-area {
padding: 8px 0;
}
h2 {
margin: 0;
font-size: 18px;
.result-area {
max-height: 500px;
overflow-y: auto;
}
</style>

View File

@@ -104,7 +104,17 @@
size="small"
@click.stop="useTemplate(template)"
>
使用
<el-icon><Download /></el-icon>
一键安装
</el-button>
<el-button
type="success"
size="small"
plain
@click.stop="quickRun(template)"
>
<el-icon><VideoPlay /></el-icon>
快速执行
</el-button>
<el-button
:type="template.is_favorited ? 'warning' : 'default'"
@@ -114,13 +124,6 @@
>
{{ template.is_favorited ? '已收藏' : '收藏' }}
</el-button>
<el-rate
v-model="template.user_rating"
:max="5"
size="small"
@change="(value) => rateTemplate(template, value)"
@click.stop
/>
</div>
</div>
</el-card>
@@ -245,7 +248,8 @@
</el-descriptions>
<div class="template-actions-detail" style="margin-top: 20px;">
<el-button type="primary" @click="useTemplate(selectedTemplate)">
使用此模板
<el-icon><Download /></el-icon>
一键安装
</el-button>
<el-button
:type="selectedTemplate.is_favorited ? 'warning' : 'default'"
@@ -271,7 +275,7 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import MainLayout from '@/components/MainLayout.vue'
import { Search, Share, Star, StarFilled, View, DocumentCopy, Document } from '@element-plus/icons-vue'
import { Search, Share, Star, StarFilled, View, DocumentCopy, Document, VideoPlay, Download } from '@element-plus/icons-vue'
import api from '@/api'
import { useWorkflowStore } from '@/stores/workflow'
@@ -393,6 +397,40 @@ const useTemplate = async (template: any) => {
}
}
// 快速执行(一键安装+执行)
const quickRunning = ref(false)
const quickRun = async (template: any) => {
try {
await ElMessageBox.confirm(
`将使用模板「${template.name}」创建工作流并立即执行。请输入执行内容:`,
'快速执行',
{
confirmButtonText: '执行',
cancelButtonText: '取消',
inputType: 'textarea',
inputPlaceholder: '请输入要执行的任务描述...',
}
)
// 先创建工作流
quickRunning.value = true
const createResp = await api.post(`/api/v1/template-market/${template.id}/use`, null, {
params: { name: `${template.name} (快速执行)` }
})
const workflowId = createResp.data.workflow_id
// 然后通过 agent-chat bare 执行
const msg = (document.querySelector('.el-message-box__input') as HTMLTextAreaElement)?.value || '执行工作流'
router.push(`/agent-chat`)
ElMessage.success(`工作流已创建并安装,请在 AI 对话中选择 Agent 执行`)
} catch (err: any) {
if (err !== 'cancel') {
ElMessage.error(err.response?.data?.detail || '快速执行失败')
}
} finally {
quickRunning.value = false
}
}
// 收藏/取消收藏
const toggleFavorite = async (template: any) => {
try {