Add tool publishing functionality to marketplace

This commit is contained in:
jingrow 2025-11-21 20:21:15 +08:00
parent 026b2a5f50
commit f7d9fa01d5
5 changed files with 646 additions and 0 deletions

View File

@ -156,6 +156,12 @@ const router = createRouter({
name: 'ToolDetail',
component: () => import('../../views/dev/ToolDetail.vue')
},
{
path: 'publish-tool',
name: 'PublishTool',
component: () => import('../../views/dev/PublishTool.vue'),
meta: { requiresAuth: true }
},
{
path: 'app-marketplace/:name',
name: 'AppDetail',

View File

@ -952,6 +952,7 @@
"Environment restart request submitted. The system will restart shortly.": "环境重启请求已提交,系统将在稍后重启。",
"Failed to restart environment": "重启环境失败",
"Publish App": "发布应用",
"Publish Tool": "发布工具",
"My Published Apps": "已发布应用",
"Manage your published applications in the marketplace": "管理您在应用市场中发布的应用",
"View in Marketplace": "在市场查看",
@ -1064,6 +1065,7 @@
"Loading tools...": "加载工具中...",
"Edit Tool": "编辑工具",
"Tool Name": "工具名称",
"Tool name": "工具名称",
"Enter tool name": "请输入工具名称",
"Enter tool description": "请输入工具描述",
"Enter category": "请输入分类",

View File

@ -0,0 +1,552 @@
<template>
<div class="tool-detail">
<div class="page-header">
<div class="header-content">
<div class="header-text">
<h1>{{ t('Publish Tool') }}</h1>
</div>
<div class="header-actions">
<n-button @click="goBack" size="medium">
<template #icon>
<n-icon><Icon icon="tabler:arrow-left" /></n-icon>
</template>
{{ t('Back') }}
</n-button>
<n-button type="primary" @click="handlePublishTool" :loading="publishing" size="medium">
<template #icon>
<n-icon><Icon icon="tabler:upload" /></n-icon>
</template>
{{ t('Publish') }}
</n-button>
</div>
</div>
</div>
<div class="tool-content">
<!-- 整体卡片布局 -->
<div class="tool-card">
<!-- 上部分工具信息 -->
<div class="tool-info-section">
<div class="tool-content-layout">
<!-- 左侧工具图标 -->
<div class="tool-image-section">
<div
class="tool-image"
@dragover.prevent="handleDragOver"
@dragenter.prevent="handleDragEnter"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleImageDrop"
@click="handleImageClick"
:class="{ 'drag-over': isDragOver }"
>
<img
v-if="imagePreview"
:src="imagePreview"
alt="工具图片预览"
/>
<div v-if="imagePreview" class="image-actions">
<n-button
type="error"
size="small"
circle
@click.stop="removeImage"
class="remove-btn"
>
<template #icon>
<n-icon><Icon icon="tabler:x" /></n-icon>
</template>
</n-button>
</div>
<div v-else class="placeholder-image">
<n-icon size="80"><Icon icon="tabler:tool" /></n-icon>
<p class="upload-hint">{{ t('Click or drag to upload') }}</p>
</div>
<div v-if="isDragOver" class="drag-overlay">
<n-icon size="60"><Icon icon="tabler:upload" /></n-icon>
<p>{{ t('Drop image here') }}</p>
</div>
</div>
<!-- 隐藏的文件输入 -->
<input
ref="fileInput"
type="file"
accept="image/*"
style="display: none"
@change="handleFileSelect"
/>
</div>
<!-- 右侧工具信息 -->
<div class="tool-info-content">
<div class="tool-header">
</div>
<div class="info-list">
<div class="info-item">
<span class="label">{{ t('Title') }}:<span class="required">*</span></span>
<n-input v-model:value="form.title" :placeholder="t('Title')" />
</div>
<div class="info-item">
<span class="label">{{ t('Tool Name') }}:<span class="required">*</span></span>
<n-input v-model:value="form.tool_name" :placeholder="t('Tool name')" />
</div>
<div class="info-item">
<span class="label">{{ t('Subtitle') }}:</span>
<n-input
v-model:value="form.subtitle"
type="textarea"
:placeholder="t('Brief description')"
:rows="3"
/>
</div>
<div class="info-item">
<span class="label">{{ t('File URL') }}:</span>
<n-input v-model:value="form.file_url" :placeholder="t('File URL')" />
</div>
<div class="info-item">
<span class="label">{{ t('Icon') }}:</span>
<IconPicker v-model="form.icon" />
</div>
<div class="info-item">
<span class="label">{{ t('Color') }}:</span>
<n-color-picker v-model:value="form.color" />
</div>
</div>
</div>
</div>
</div>
<!-- 下部分描述 -->
<div class="tool-description-section">
<div class="description-content">
<Jeditor
:df="{ fieldname: 'description', label: t('Description'), fieldtype: 'Jeditor' }"
v-model="form.description"
:ctx="{ t }"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { NButton, NInput, NIcon, NColorPicker, useMessage } from 'naive-ui'
import { Icon } from '@iconify/vue'
import { t } from '@/shared/i18n'
import axios from 'axios'
import Jeditor from '@/core/pagetype/form/controls/Jeditor.vue'
import IconPicker from '@/core/components/IconPicker.vue'
const router = useRouter()
const message = useMessage()
const publishing = ref(false)
const imagePreview = ref('')
const uploadedImageUrl = ref('')
const isDragOver = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const form = ref({
title: '',
tool_name: '',
subtitle: '',
file_url: '',
description: '',
icon: '',
color: ''
})
const goBack = () => {
router.push('/tool-marketplace')
}
const handleImageDrop = async (event: DragEvent) => {
isDragOver.value = false
const files = event.dataTransfer?.files
if (files && files.length > 0) {
const file = files[0]
if (file.type.startsWith('image/')) {
await handleImageUpload({ file: { file } })
} else {
message.error('请选择图片文件')
}
}
}
const handleDragOver = (event: DragEvent) => {
event.preventDefault()
}
const handleDragEnter = (event: DragEvent) => {
event.preventDefault()
isDragOver.value = true
}
const handleDragLeave = (event: DragEvent) => {
event.preventDefault()
// false
const currentTarget = event.currentTarget as HTMLElement
const relatedTarget = event.relatedTarget as Node | null
if (!currentTarget.contains(relatedTarget)) {
isDragOver.value = false
}
}
const handleImageClick = () => {
fileInput.value?.click()
}
const handleFileSelect = async (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
await handleImageUpload({ file: { file } })
}
}
const removeImage = () => {
imagePreview.value = ''
uploadedImageUrl.value = ''
//
if (fileInput.value) {
fileInput.value.value = ''
}
}
const handleImageUpload = async (options: any) => {
const { file, onFinish, onError } = options.file?.file ?
{ file: options.file.file, onFinish: options.onFinish, onError: options.onError } :
options.fileList?.length ?
{ file: options.fileList[0].file, onFinish: options.onFinish, onError: options.onError } :
options instanceof File ?
{ file: options, onFinish: null, onError: null } :
{ file: null, onFinish: null, onError: null }
if (!file || !(file instanceof File)) {
if (onError) onError(new Error('无效的文件'))
return
}
//
const reader = new FileReader()
reader.onload = (e) => {
imagePreview.value = e.target?.result as string
}
reader.readAsDataURL(file)
try {
const formData = new FormData()
formData.append('file', file)
const response = await axios.post('/jingrow/upload-image', formData, { timeout: 30000 })
if (response.data.success) {
uploadedImageUrl.value = response.data.url
message.success('图片上传成功')
if (onFinish) onFinish()
} else {
const errorMsg = response.data.error || '图片上传失败'
message.error(errorMsg)
if (onError) onError(new Error(errorMsg))
}
} catch (error: any) {
const errorMsg = error.response?.data?.detail || error.message || '图片上传失败'
message.error('图片上传失败: ' + errorMsg)
if (onError) onError(new Error(errorMsg))
}
}
const handlePublishTool = async () => {
if (!form.value.title || !form.value.tool_name) {
message.error('请填写工具标题和工具名称')
return
}
publishing.value = true
try {
const formData = new FormData()
Object.entries(form.value).forEach(([key, value]) => {
if (value) formData.append(key, value)
})
if (uploadedImageUrl.value) {
formData.append('tool_image', uploadedImageUrl.value)
}
const response = await axios.post('/jingrow/tool/publish', formData)
if (response.data.success) {
message.success('工具发布成功')
router.push('/tool-marketplace')
} else {
message.error('工具发布失败')
}
} catch (error: any) {
message.error(error.response?.data?.detail || '工具发布失败')
} finally {
publishing.value = false
}
}
</script>
<style scoped>
.tool-detail {
padding: 24px;
}
.page-header {
margin-bottom: 32px;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.header-actions {
display: flex;
align-items: center;
gap: 30px;
}
.header-text h1 {
margin: 0 0 8px 0;
font-size: 28px;
font-weight: 700;
color: #1a1a1a;
}
.header-text p {
margin: 0;
color: #666;
font-size: 16px;
}
.tool-content {
display: flex;
flex-direction: column;
gap: 32px;
}
/* 整体卡片布局 */
.tool-card {
background: white;
border-radius: 8px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
gap: 24px;
}
/* 上部分:工具信息 */
.tool-content-layout {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 24px;
}
.tool-image-section {
display: flex;
justify-content: center;
align-items: center;
}
.tool-image {
width: 100%;
min-width: 300px;
min-height: 300px;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
position: relative;
border: 2px dashed #d1d5db;
transition: all 0.3s ease;
cursor: pointer;
}
.tool-image:hover {
border-color: #3b82f6;
background-color: #f8fafc;
}
.tool-image.drag-over {
border-color: #10b981;
background-color: #f0fdf4;
border-style: solid;
}
.drag-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(16, 185, 129, 0.1);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #10b981;
font-weight: 500;
z-index: 10;
}
.drag-overlay p {
margin: 8px 0 0 0;
font-size: 14px;
}
.tool-image img {
width: 100%;
height: auto;
object-fit: contain;
}
.placeholder-image {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #9ca3af;
}
.upload-hint {
margin: 12px 0 0 0;
font-size: 14px;
color: #6b7280;
text-align: center;
}
.image-actions {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
}
.remove-btn {
width: 24px;
height: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
background: rgba(239, 68, 68, 0.9);
border: none;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.remove-btn:hover {
background: #ef4444;
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
}
.remove-btn .n-icon {
font-size: 14px;
}
.tool-info-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.tool-header {
margin-bottom: 8px;
}
.info-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.info-item {
display: flex;
align-items: center;
gap: 12px;
}
.info-item .label {
font-weight: 500;
color: #374151;
min-width: 100px;
flex-shrink: 0;
}
.info-item .value {
color: #6b7280;
}
.info-item .n-input {
flex: 1;
}
.required {
color: #ef4444;
font-weight: bold;
margin-left: 2px;
}
/* 下部分:描述 */
.tool-description-section {
border-top: 1px solid #e5e7eb;
padding-top: 24px;
}
.description-content {
color: #6b7280;
line-height: 1.6;
}
/* Jeditor 样式调整 */
.description-content .field-wrapper {
margin: 0;
}
.description-content .field-label {
display: none; /* 隐藏标签,因为已经有 h3 标题了 */
}
.description-content .jeditor-control {
margin-top: 0;
}
@media (max-width: 768px) {
.tool-detail {
padding: 16px;
}
.header-content {
flex-direction: column;
gap: 16px;
}
.header-actions {
width: 100%;
justify-content: flex-end;
}
.tool-content-layout {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -6,6 +6,12 @@
<h1>{{ t('Tool Marketplace') }}</h1>
<p>{{ t('Browse and install tools from Jingrow Tool Marketplace') }}</p>
</div>
<n-button type="primary" @click="publishTool">
<template #icon>
<n-icon><Icon icon="tabler:plus" /></n-icon>
</template>
{{ t('Publish Tool') }}
</n-button>
</div>
</div>
@ -236,6 +242,10 @@ function handlePageSizeChange(newPageSize: number) {
loadTools()
}
function publishTool() {
router.push('/publish-tool')
}
function viewToolDetail(tool: any) {
//
router.push({

View File

@ -383,3 +383,79 @@ async def uninstall_tool(tool_name: str):
logger.error(f"Traceback: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"卸载工具失败: {str(e)}")
@router.post("/jingrow/tool/publish")
async def publish_tool_to_marketplace(
tool_name: str = Form(...),
title: str = Form(...),
subtitle: Optional[str] = Form(None),
description: Optional[str] = Form(None),
category: Optional[str] = Form(None),
file_url: Optional[str] = Form(None),
repository_url: Optional[str] = Form(None),
tool_image: Optional[str] = Form(None),
icon: Optional[str] = Form(None),
color: Optional[str] = Form(None)
):
"""
发布工具到Jingrow Cloud工具市场
"""
try:
# 如果file_url不是绝对地址拼接完整的服务器地址
if file_url and not file_url.startswith('http://') and not file_url.startswith('https://'):
from jingrow.config import Config
# 确保URL以 / 开头
if not file_url.startswith('/'):
file_url = '/' + file_url
# 使用Config中的服务器URL
server_url = Config.jingrow_server_url
# 确保服务器URL不以 / 结尾
server_url = server_url.rstrip('/')
file_url = f"{server_url}{file_url}"
url = f"{get_jingrow_cloud_url()}/api/action/jcloud.api.jlocal.create_local_tool"
headers = get_jingrow_cloud_api_headers()
headers['Content-Type'] = 'application/json'
response = requests.post(url, json={
"tool_data": {
"tool_name": tool_name,
"title": title,
"subtitle": subtitle or "",
"description": description or "",
"category": category or "",
"file_url": file_url,
"repository_url": repository_url,
"tool_image": tool_image,
"icon": icon,
"color": color
}
}, headers=headers, timeout=20)
if response.status_code != 200:
error_detail = response.json().get('detail', f"HTTP {response.status_code}") if response.headers.get('content-type', '').startswith('application/json') else f"HTTP {response.status_code}"
raise HTTPException(status_code=response.status_code, detail=error_detail)
result = response.json()
# 检查错误
if isinstance(result, dict) and result.get('error'):
raise HTTPException(status_code=400, detail=result['error'])
message = result.get('message', {})
if isinstance(message, dict) and message.get('error'):
raise HTTPException(status_code=400, detail=message['error'])
# 成功响应
tool_name_result = message.get('name', 'unknown') if isinstance(message, dict) else result.get('message', 'unknown')
return {"success": True, "message": f"工具发布成功,工具名称: {tool_name_result}"}
except HTTPException:
raise
except Exception as e:
logger.error(f"发布工具失败: {str(e)}")
logger.error(f"Traceback: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"发布工具失败: {str(e)}")