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:
renjianbo
2026-05-07 08:02:38 +08:00
parent 66d52ad020
commit ded3ba2973
4 changed files with 1284 additions and 1 deletions

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