feat: Agent技能市场 — 公开/发布/分类/精选/评分
- AgentCreate 新增 category/tags/is_public/is_featured 字段 - 迁移 011 添加 agents 市场相关列(category/tags/is_public/rating_avg 等) - agent_market API:按分类/标签/精选筛选,排序,分页 - 前端 AgentMarket.vue:市场浏览页,搜索/筛选/安装 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
633
frontend/src/views/AgentMarket.vue
Normal file
633
frontend/src/views/AgentMarket.vue
Normal file
@@ -0,0 +1,633 @@
|
||||
<template>
|
||||
<MainLayout>
|
||||
<div class="agent-market">
|
||||
<!-- 搜索和筛选 -->
|
||||
<el-card class="search-card">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索 Agent..."
|
||||
clearable
|
||||
@clear="handleSearch"
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
<template #append>
|
||||
<el-button @click="handleSearch">搜索</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-select v-model="categoryFilter" placeholder="分类" clearable @change="handleSearch">
|
||||
<el-option label="LLM" value="llm" />
|
||||
<el-option label="数据处理" value="data_processing" />
|
||||
<el-option label="自动化" value="automation" />
|
||||
<el-option label="集成" value="integration" />
|
||||
<el-option label="聊天助手" value="chat_assistant" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-select v-model="sortBy" placeholder="排序" @change="handleSearch">
|
||||
<el-option label="最新" value="created_at" />
|
||||
<el-option label="评分最高" value="rating_avg" />
|
||||
<el-option label="使用最多" value="use_count" />
|
||||
<el-option label="查看最多" value="view_count" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-switch
|
||||
v-model="featuredOnly"
|
||||
active-text="仅精选"
|
||||
@change="handleSearch"
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-button-group>
|
||||
<el-button :type="viewMode === 'market' ? 'primary' : 'default'" @click="switchView('market')">
|
||||
市场
|
||||
</el-button>
|
||||
<el-button :type="viewMode === 'favorites' ? 'primary' : 'default'" @click="switchView('favorites')">
|
||||
我的收藏
|
||||
</el-button>
|
||||
<el-button :type="viewMode === 'shared' ? 'primary' : 'default'" @click="switchView('shared')">
|
||||
我分享的
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- Agent 列表 -->
|
||||
<el-card class="agents-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>{{ viewTitle }}</span>
|
||||
<span class="total-count">共 {{ totalCount }} 个 Agent</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-row :gutter="20" v-loading="loading">
|
||||
<el-col
|
||||
v-for="agent in agents"
|
||||
:key="agent.id"
|
||||
:span="6"
|
||||
style="margin-bottom: 20px;"
|
||||
>
|
||||
<el-card
|
||||
class="agent-card"
|
||||
:body-style="{ padding: '10px' }"
|
||||
shadow="hover"
|
||||
@click="viewAgent(agent)"
|
||||
>
|
||||
<div class="agent-badge" v-if="agent.is_featured">
|
||||
<el-tag type="warning" size="small">精选</el-tag>
|
||||
</div>
|
||||
<div class="agent-info">
|
||||
<h3 class="agent-name">
|
||||
<el-icon><User /></el-icon>
|
||||
{{ agent.name }}
|
||||
</h3>
|
||||
<p class="agent-description">{{ agent.description || '无描述' }}</p>
|
||||
<div class="agent-meta">
|
||||
<div class="agent-stats">
|
||||
<el-icon><View /></el-icon>
|
||||
<span>{{ agent.view_count }}</span>
|
||||
<el-icon><Star /></el-icon>
|
||||
<span>{{ agent.rating_avg.toFixed(1) }}</span>
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
<span>{{ agent.use_count }}</span>
|
||||
</div>
|
||||
<div class="agent-category" v-if="agent.category">
|
||||
<el-tag size="small">{{ getCategoryName(agent.category) }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="agent-creator">
|
||||
作者: {{ agent.creator_username || '未知' }}
|
||||
<span class="version">v{{ agent.version }}</span>
|
||||
</div>
|
||||
<div class="agent-tags" v-if="agent.tags && agent.tags.length > 0">
|
||||
<el-tag
|
||||
v-for="tag in agent.tags.slice(0, 3)"
|
||||
:key="tag"
|
||||
size="small"
|
||||
style="margin-right: 5px;"
|
||||
>
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="agent-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click.stop="installAgent(agent)"
|
||||
>
|
||||
<el-icon><Download /></el-icon>
|
||||
一键安装
|
||||
</el-button>
|
||||
<el-button
|
||||
:type="agent.is_favorited ? 'warning' : 'default'"
|
||||
size="small"
|
||||
@click.stop="toggleFavorite(agent)"
|
||||
>
|
||||
<el-icon><Star /></el-icon>
|
||||
{{ agent.is_favorited ? '已收藏' : '收藏' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<el-empty v-if="!loading && agents.length === 0" description="暂无 Agent" />
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[12, 24, 48, 96]"
|
||||
:total="totalCount"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
style="margin-top: 20px; justify-content: center;"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- Agent 详情对话框 -->
|
||||
<el-dialog
|
||||
v-model="showDetailDialog"
|
||||
:title="selectedAgent?.name"
|
||||
width="1000px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div v-if="selectedAgent" class="agent-detail">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="Agent 名称">{{ selectedAgent.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建者">{{ selectedAgent.creator_username || '未知' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="分类">{{ getCategoryName(selectedAgent.category) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="版本">v{{ selectedAgent.version }}</el-descriptions-item>
|
||||
<el-descriptions-item label="评分">
|
||||
<el-rate v-model="selectedAgent.rating_avg" disabled show-score />
|
||||
<span style="margin-left: 8px; color: #909399;">({{ selectedAgent.rating_count }} 人评分)</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="安装次数">{{ selectedAgent.use_count }}</el-descriptions-item>
|
||||
<el-descriptions-item label="描述" :span="2">
|
||||
{{ selectedAgent.description || '无描述' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="标签" :span="2">
|
||||
<el-tag
|
||||
v-for="tag in (selectedAgent.tags || [])"
|
||||
:key="tag"
|
||||
style="margin-right: 5px;"
|
||||
>
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="agent-actions-detail" style="margin-top: 20px;">
|
||||
<el-button type="primary" @click="installAgent(selectedAgent)">
|
||||
<el-icon><Download /></el-icon>
|
||||
一键安装到我的工作区
|
||||
</el-button>
|
||||
<el-button
|
||||
:type="selectedAgent.is_favorited ? 'warning' : 'default'"
|
||||
@click="toggleFavorite(selectedAgent)"
|
||||
>
|
||||
<el-icon><Star /></el-icon>
|
||||
{{ selectedAgent.is_favorited ? '已收藏' : '收藏' }}
|
||||
</el-button>
|
||||
<div style="display: inline-flex; align-items: center; margin-left: 16px;">
|
||||
<span style="margin-right: 8px;">我的评分:</span>
|
||||
<el-rate
|
||||
v-model="myRating"
|
||||
:max="5"
|
||||
@change="rateAgent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 评论列表 -->
|
||||
<el-divider>评论 ({{ comments.length }})</el-divider>
|
||||
<div class="comment-section">
|
||||
<div class="comment-input">
|
||||
<el-input
|
||||
v-model="newComment"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="写下你的评论..."
|
||||
/>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
style="margin-top: 8px;"
|
||||
@click="submitComment"
|
||||
:loading="submittingComment"
|
||||
>
|
||||
提交评论
|
||||
</el-button>
|
||||
</div>
|
||||
<div
|
||||
v-for="c in comments"
|
||||
:key="c.id"
|
||||
class="comment-item"
|
||||
>
|
||||
<div class="comment-header">
|
||||
<span class="comment-user">{{ c.username || '匿名用户' }}</span>
|
||||
<el-rate :model-value="c.rating" disabled size="small" />
|
||||
<span class="comment-time">{{ formatTime(c.created_at) }}</span>
|
||||
</div>
|
||||
<div class="comment-body" v-if="c.comment">{{ c.comment }}</div>
|
||||
</div>
|
||||
<el-empty v-if="comments.length === 0" description="暂无评论" :image-size="60" />
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</MainLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import MainLayout from '@/components/MainLayout.vue'
|
||||
import { Search, Star, View, DocumentCopy, User, Download } from '@element-plus/icons-vue'
|
||||
import api from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 数据
|
||||
const agents = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const totalCount = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(12)
|
||||
|
||||
// 搜索和筛选
|
||||
const searchQuery = ref('')
|
||||
const categoryFilter = ref('')
|
||||
const sortBy = ref('created_at')
|
||||
const featuredOnly = ref(false)
|
||||
const viewMode = ref('market')
|
||||
|
||||
// 对话框
|
||||
const showDetailDialog = ref(false)
|
||||
const selectedAgent = ref<any>(null)
|
||||
const comments = ref<any[]>([])
|
||||
const myRating = ref(0)
|
||||
const newComment = ref('')
|
||||
const submittingComment = ref(false)
|
||||
|
||||
const viewTitle = ref('Agent 技能市场')
|
||||
|
||||
// 加载 Agent 列表
|
||||
const loadAgents = async (url?: string) => {
|
||||
loading.value = true
|
||||
try {
|
||||
let apiUrl = url || '/api/v1/agent-market'
|
||||
const params: any = {
|
||||
skip: (currentPage.value - 1) * pageSize.value,
|
||||
limit: pageSize.value,
|
||||
sort_by: sortBy.value,
|
||||
sort_order: 'desc'
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
if (searchQuery.value) params.search = searchQuery.value
|
||||
if (categoryFilter.value) params.category = categoryFilter.value
|
||||
if (featuredOnly.value) params.featured_only = true
|
||||
}
|
||||
|
||||
const response = await api.get(apiUrl, { params })
|
||||
agents.value = response.data
|
||||
totalCount.value = parseInt(response.headers['x-total-count'] || response.data.length)
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.response?.data?.detail || '加载 Agent 列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
currentPage.value = 1
|
||||
viewMode.value = 'market'
|
||||
viewTitle.value = 'Agent 技能市场'
|
||||
loadAgents()
|
||||
}
|
||||
|
||||
// 分页
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
loadPageForView()
|
||||
}
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1
|
||||
loadPageForView()
|
||||
}
|
||||
|
||||
const loadPageForView = () => {
|
||||
if (viewMode.value === 'favorites') loadAgents('/api/v1/agent-market/my/favorites')
|
||||
else if (viewMode.value === 'shared') loadAgents('/api/v1/agent-market/my/shared')
|
||||
else loadAgents()
|
||||
}
|
||||
|
||||
// 切换视图
|
||||
const switchView = (mode: string) => {
|
||||
viewMode.value = mode
|
||||
currentPage.value = 1
|
||||
if (mode === 'market') {
|
||||
viewTitle.value = 'Agent 技能市场'
|
||||
loadAgents()
|
||||
} else if (mode === 'favorites') {
|
||||
viewTitle.value = '我的收藏'
|
||||
loadAgents('/api/v1/agent-market/my/favorites')
|
||||
} else if (mode === 'shared') {
|
||||
viewTitle.value = '我分享的 Agent'
|
||||
loadAgents('/api/v1/agent-market/my/shared')
|
||||
}
|
||||
}
|
||||
|
||||
// 查看 Agent 详情
|
||||
const viewAgent = async (agent: any) => {
|
||||
try {
|
||||
const response = await api.get(`/api/v1/agent-market/${agent.id}`)
|
||||
selectedAgent.value = response.data
|
||||
myRating.value = response.data.user_rating || 0
|
||||
showDetailDialog.value = true
|
||||
// 加载评论
|
||||
loadComments(agent.id)
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.response?.data?.detail || '加载 Agent 详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 加载评论
|
||||
const loadComments = async (agentId: string) => {
|
||||
try {
|
||||
const response = await api.get(`/api/v1/agent-market/${agentId}/ratings`)
|
||||
comments.value = response.data
|
||||
} catch {
|
||||
comments.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 提交评论
|
||||
const submitComment = async () => {
|
||||
if (!selectedAgent.value) return
|
||||
if (!myRating.value && !newComment.value) {
|
||||
ElMessage.warning('请至少评分或填写评论')
|
||||
return
|
||||
}
|
||||
submittingComment.value = true
|
||||
try {
|
||||
await api.post(`/api/v1/agent-market/${selectedAgent.value.id}/rate`, {
|
||||
rating: myRating.value || 5,
|
||||
comment: newComment.value || ''
|
||||
})
|
||||
ElMessage.success('评论提交成功')
|
||||
newComment.value = ''
|
||||
loadComments(selectedAgent.value.id)
|
||||
// 刷新列表更新评分
|
||||
if (selectedAgent.value) {
|
||||
const resp = await api.get(`/api/v1/agent-market/${selectedAgent.value.id}`)
|
||||
selectedAgent.value = resp.data
|
||||
myRating.value = resp.data.user_rating || 0
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.response?.data?.detail || '提交评论失败')
|
||||
} finally {
|
||||
submittingComment.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 评分
|
||||
const rateAgent = async (rating: number) => {
|
||||
if (!selectedAgent.value) return
|
||||
try {
|
||||
await api.post(`/api/v1/agent-market/${selectedAgent.value.id}/rate`, {
|
||||
rating,
|
||||
comment: ''
|
||||
})
|
||||
ElMessage.success('评分成功')
|
||||
loadComments(selectedAgent.value.id)
|
||||
const resp = await api.get(`/api/v1/agent-market/${selectedAgent.value.id}`)
|
||||
selectedAgent.value = resp.data
|
||||
myRating.value = rating
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.response?.data?.detail || '评分失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 安装 Agent
|
||||
const installAgent = async (agent: any) => {
|
||||
try {
|
||||
const resp = await api.post(`/api/v1/agent-market/${agent.id}/install`)
|
||||
ElMessage.success(`已安装到我的工作区: ${resp.data.agent_name}`)
|
||||
// 直接跳转到 Agent 设计器
|
||||
router.push(`/agents/${resp.data.agent_id}/design`)
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.response?.data?.detail || '安装失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 收藏/取消收藏
|
||||
const toggleFavorite = async (agent: any) => {
|
||||
try {
|
||||
if (agent.is_favorited) {
|
||||
await api.delete(`/api/v1/agent-market/${agent.id}/favorite`)
|
||||
agent.is_favorited = false
|
||||
ElMessage.success('已取消收藏')
|
||||
} else {
|
||||
await api.post(`/api/v1/agent-market/${agent.id}/favorite`)
|
||||
agent.is_favorited = true
|
||||
ElMessage.success('已收藏')
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.response?.data?.detail || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取分类名称
|
||||
const getCategoryName = (category: string) => {
|
||||
const categoryMap: Record<string, string> = {
|
||||
'llm': 'LLM',
|
||||
'data_processing': '数据处理',
|
||||
'automation': '自动化',
|
||||
'integration': '集成',
|
||||
'chat_assistant': '聊天助手',
|
||||
'other': '其他'
|
||||
}
|
||||
return categoryMap[category] || category
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (t: string) => {
|
||||
if (!t) return ''
|
||||
return new Date(t).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAgents()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.agent-market {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.search-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.agents-card {
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.total-count {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.agent-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.agent-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.agent-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.agent-info {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.agent-name {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin: 0 0 8px 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.agent-description {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin: 0 0 8px 0;
|
||||
height: 36px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.agent-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.agent-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.agent-creator {
|
||||
font-size: 11px;
|
||||
color: #c0c4cc;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.agent-creator .version {
|
||||
margin-left: 6px;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.agent-tags {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.agent-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.agent-actions-detail {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.comment-section {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.comment-input {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.comment-item {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.comment-user {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.comment-time {
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.comment-body {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user