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,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>