Add tool publishing functionality to marketplace
This commit is contained in:
parent
026b2a5f50
commit
f7d9fa01d5
@ -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',
|
||||
|
||||
@ -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": "请输入分类",
|
||||
|
||||
552
apps/jingrow/frontend/src/views/dev/PublishTool.vue
Normal file
552
apps/jingrow/frontend/src/views/dev/PublishTool.vue
Normal 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>
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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)}")
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user