Files
aiagent/frontend/src/views/MainConsole.vue
renjianbo 1b5f9deb44 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>
2026-05-06 21:30:58 +08:00

611 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<MainLayout>
<div class="main-console">
<!-- 顶部标题栏 -->
<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>
<h3>AI 对话</h3>
<p class="muted"> AI Agent 自由对话</p>
</div>
</div>
</el-card>
</el-col>
<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>
<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-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>
<!-- 动态参数 -->
<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-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 { 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 api from '@/api'
const router = useRouter()
// ---------- 模板数据 ----------
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 {
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 handleCategoryChange = () => {
// 筛选由 computed 自动处理
}
const categoryTagType = (cat: string) => {
const map: Record<string, string> = {
customer_service: 'success',
dev: 'primary',
ops: 'warning',
education: 'danger',
}
return map[cat] || 'info'
}
const getCategoryName = (cat: string) => {
const map: Record<string, string> = {
customer_service: '客服场景',
dev: '研发辅助',
ops: '运维分析',
education: '学习教育',
}
return map[cat] || cat
}
const openExecuteDialog = (tpl: TemplateItem) => {
selectedTemplate.value = tpl
executeForm.value = {
message: '',
parameters: {
temperature: tpl.default_temperature || 0.7,
enable_tools: true,
},
}
showExecute.value = true
}
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(() => {
loadTemplates()
})
</script>
<style scoped>
.main-console {
max-width: 1200px;
margin: 0 auto;
}
.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;
}
.quick-card {
cursor: pointer;
transition: transform 0.2s;
}
.quick-card:hover {
transform: translateY(-2px);
}
.quick-inner {
display: flex;
gap: 16px;
align-items: center;
}
.quick-inner h3 {
margin: 0;
font-size: 15px;
}
.muted {
color: var(--el-text-color-secondary);
font-size: 13px;
margin: 4px 0 0 0;
}
.progress-area {
padding: 8px 0;
}
.result-area {
max-height: 500px;
overflow-y: auto;
}
</style>