增加已发布应用菜单显示我发布到市场的应用

This commit is contained in:
jingrow 2025-11-02 22:21:53 +08:00
parent d53b4ccfd3
commit 96adb215e0
5 changed files with 670 additions and 1 deletions

View File

@ -150,6 +150,12 @@ const router = createRouter({
name: 'PublishApp',
component: () => import('../../views/dev/PublishApp.vue'),
meta: { requiresAuth: true }
},
{
path: 'my-published-apps',
name: 'MyPublishedApps',
component: () => import('../../views/dev/MyPublishedApps.vue'),
meta: { requiresAuth: true }
}
]
}

View File

@ -946,6 +946,10 @@
"Environment restart request submitted. The system will restart shortly.": "环境重启请求已提交,系统将在稍后重启。",
"Failed to restart environment": "重启环境失败",
"Publish App": "发布应用",
"My Published Apps": "已发布应用",
"Manage your published applications in the marketplace": "管理您在应用市场中发布的应用",
"View in Marketplace": "在市场查看",
"Publish Your First App": "发布您的第一个应用",
"Active": "活跃",
"View detailed information about the application": "查看应用的详细信息",
"Loading application details...": "正在加载应用详情...",

View File

@ -62,6 +62,7 @@ function getDefaultMenus(): AppMenuItem[] {
{ id: 'app-installer', key: 'AppInstaller', label: 'App Installer', icon: 'tabler:upload', type: 'route', routeName: 'AppInstaller', parentId: 'dev-group', order: 5 },
{ id: 'installed-apps', key: 'InstalledApps', label: 'Installed Apps', icon: 'tabler:apps', type: 'route', routeName: 'InstalledApps', parentId: 'dev-group', order: 6 },
{ id: 'app-marketplace', key: 'AppMarketplace', label: 'App Marketplace', icon: 'tabler:shopping-cart', type: 'route', routeName: 'AppMarketplace', parentId: 'dev-group', order: 7 },
{ id: 'my-published-apps', key: 'MyPublishedApps', label: 'My Published Apps', icon: 'tabler:cloud-upload', type: 'route', routeName: 'MyPublishedApps', parentId: 'dev-group', order: 7.5 },
{ id: 'node-marketplace', key: 'NodeMarketplace', label: 'Node Marketplace', icon: 'carbon:add-child-node', type: 'route', routeName: 'NodeMarketplace', parentId: 'dev-group', order: 8 },
{ id: 'menuManager', key: 'MenuManager', label: 'Menu Management', icon: 'tabler:menu-2', type: 'route', routeName: 'MenuManager', order: 10 },
{ id: 'settings', key: 'Settings', label: 'Settings', icon: 'tabler:settings', routeName: 'Settings', order: 11, type: 'route' }

View File

@ -0,0 +1,578 @@
<template>
<div class="my-published-apps">
<div class="page-header">
<div class="header-content">
<div class="header-text">
<h1>{{ t('My Published Apps') }}</h1>
<p>{{ t('Manage your published applications in the marketplace') }}</p>
</div>
<n-button type="primary" @click="publishApp">
<template #icon>
<n-icon><Icon icon="tabler:plus" /></n-icon>
</template>
{{ t('Publish App') }}
</n-button>
</div>
</div>
<div class="content">
<div class="search-container">
<div class="search-bar">
<n-input
v-model:value="searchQuery"
:placeholder="t('Search applications...')"
clearable
size="large"
@keyup.enter="loadApps"
class="search-input"
>
<template #prefix>
<n-icon><Icon icon="tabler:search" /></n-icon>
</template>
</n-input>
<n-button type="primary" size="large" @click="loadApps" class="search-button">
<template #icon>
<n-icon><Icon icon="tabler:search" /></n-icon>
</template>
{{ t('Search') }}
</n-button>
</div>
</div>
<div class="apps-section" v-if="!loading && apps.length > 0">
<!-- 排序控件 -->
<div class="apps-header">
<div class="apps-title">
</div>
<div class="sort-controls">
<n-select
v-model:value="sortBy"
:options="sortOptions"
:placeholder="t('Sort by')"
style="width: 150px"
@update:value="loadApps"
/>
</div>
</div>
<div class="apps-grid">
<div v-for="app in apps" :key="app.name" class="app-card">
<!-- 应用图片 -->
<div class="app-image" @click="viewAppDetail(app)">
<img
v-if="app.app_image"
:src="getImageUrl(app.app_image)"
:alt="app.title || app.name"
@error="handleImageError"
/>
<div v-else class="app-image-placeholder">
<n-icon size="48"><Icon icon="tabler:apps" /></n-icon>
</div>
</div>
<!-- 应用信息 -->
<div class="app-content">
<div class="app-header">
<div class="app-title-section">
<h3 @click="viewAppDetail(app)" class="clickable-title">{{ app.title || app.name }}</h3>
<div class="app-team" v-if="app.team">
<n-icon><Icon icon="tabler:users" /></n-icon>
<span>{{ app.team }}</span>
</div>
</div>
<div class="app-name" v-if="app.app_name">
{{ app.app_name }}
</div>
</div>
<div class="app-subtitle" v-if="app.subtitle">
{{ truncateText(app.subtitle, 60) }}
</div>
</div>
<div class="app-actions">
<n-button type="default" @click="viewAppDetail(app)">
{{ t('View Details') }}
</n-button>
<n-button
type="info"
@click="viewInMarketplace(app)"
>
{{ t('View in Marketplace') }}
</n-button>
</div>
</div>
</div>
<!-- 分页 -->
<div class="pagination-container">
<n-pagination
v-model:page="page"
:page-count="pageCount"
size="large"
show-size-picker
:page-sizes="[20, 50, 100]"
:page-size="pageSize"
@update:page="loadApps"
@update:page-size="handlePageSizeChange"
/>
</div>
</div>
<div v-if="loading" class="loading">
<n-spin size="large">
<template #description>{{ t('Loading applications...') }}</template>
</n-spin>
</div>
<div v-if="!loading && apps.length === 0" class="empty">
<n-empty :description="t('No applications found')">
<template #icon>
<n-icon><Icon icon="tabler:apps" /></n-icon>
</template>
<template #extra>
<n-button type="primary" @click="publishApp">
{{ t('Publish Your First App') }}
</n-button>
</template>
</n-empty>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { NInput, NButton, NIcon, NSpin, NEmpty, NSelect, NPagination, useMessage } from 'naive-ui'
import { Icon } from '@iconify/vue'
import axios from 'axios'
import { t } from '@/shared/i18n'
const message = useMessage()
const router = useRouter()
const searchQuery = ref('')
const loading = ref(false)
const apps = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(parseInt(localStorage.getItem('itemsPerPage') || '20'))
const sortBy = ref('creation desc')
//
const sortOptions = computed(() => [
{ label: t('Latest'), value: 'creation desc' },
{ label: t('Oldest'), value: 'creation asc' },
{ label: t('Name A-Z'), value: 'app_name asc' },
{ label: t('Name Z-A'), value: 'app_name desc' },
{ label: t('Most Popular'), value: 'modified desc' }
])
//
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
async function loadApps() {
loading.value = true
try {
const params = new URLSearchParams({
page: page.value.toString(),
page_size: pageSize.value.toString(),
search: searchQuery.value,
sort_by: sortBy.value
})
const response = await axios.get(`/jingrow/my-published-apps?${params}`)
const data = response.data
// API
if (data.items) {
apps.value = data.items
total.value = data.total || 0
} else {
// API
apps.value = data || []
total.value = apps.value.length
}
} catch (error) {
console.error('Failed to load apps:', error)
message.error(t('Failed to load applications'))
apps.value = []
total.value = 0
} finally {
loading.value = false
}
}
function publishApp() {
router.push('/publish-app')
}
function handlePageSizeChange(newPageSize: number) {
pageSize.value = newPageSize
page.value = 1
localStorage.setItem('itemsPerPage', newPageSize.toString())
loadApps()
}
function viewAppDetail(app: any) {
//
router.push(`/app-marketplace/${app.name}`)
}
function viewInMarketplace(app: any) {
//
router.push(`/app-marketplace?app=${app.name}`)
}
function getImageUrl(imageUrl: string): string {
if (!imageUrl) return ''
if (imageUrl.startsWith('http')) {
return imageUrl
}
// 使URL
const cloudUrl = import.meta.env.VITE_JINGROW_CLOUD_URL || 'https://cloud.jingrow.com'
return `${cloudUrl}${imageUrl.startsWith('/') ? '' : '/'}${imageUrl}`
}
function handleImageError(event: Event) {
const img = event.target as HTMLImageElement
img.style.display = 'none'
img.parentElement?.querySelector('.app-image-placeholder')?.classList.add('show')
}
function truncateText(text: string, maxLength: number): string {
if (!text) return ''
if (text.length <= maxLength) return text
return text.substring(0, maxLength) + '...'
}
onMounted(() => {
loadApps()
})
//
watch([searchQuery, sortBy], () => {
page.value = 1 //
loadApps()
}, { deep: true })
//
watch([page], () => {
loadApps()
})
//
watch(() => localStorage.getItem('itemsPerPage'), (newValue) => {
if (newValue) {
pageSize.value = parseInt(newValue)
page.value = 1 //
loadApps()
}
})
</script>
<style scoped>
.my-published-apps {
padding: 24px;
}
.page-header {
margin-bottom: 32px;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.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;
}
.search-container {
display: flex;
justify-content: center;
margin-bottom: 32px;
}
.apps-section {
margin-bottom: 32px;
}
.apps-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.apps-title h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #1f2937;
}
.sort-controls {
display: flex;
align-items: center;
gap: 12px;
}
.pagination-container {
display: flex;
justify-content: center;
margin-top: 32px;
padding: 20px 0;
}
.search-bar {
display: flex;
gap: 16px;
align-items: center;
max-width: 600px;
width: 100%;
padding: 20px;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-radius: 20px;
border: 1px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.search-input {
flex: 1;
min-width: 0;
}
.search-input .n-input {
border-radius: 12px;
border: 1px solid #d1d5db;
transition: all 0.2s ease;
}
.search-input .n-input:focus-within {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.search-button {
border-radius: 12px;
font-weight: 600;
padding: 0 24px;
transition: all 0.2s ease;
}
.search-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.apps-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
}
.app-card {
border: 1px solid #e5e7eb;
border-radius: 16px;
background: white;
overflow: hidden;
transition: all 0.3s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.app-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
border-color: #d1d5db;
}
.app-image {
position: relative;
width: 100%;
height: 200px;
overflow: hidden;
background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
cursor: pointer;
transition: opacity 0.2s ease;
}
.app-image:hover {
opacity: 0.9;
}
.app-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.app-card:hover .app-image img {
transform: scale(1.05);
}
.app-image-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #9ca3af;
background: linear-gradient(135deg, #f9fafb 0%, #f3f4f6 100%);
}
.app-image-placeholder.show {
display: flex;
}
.app-content {
padding: 20px;
}
.app-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.app-title-section {
flex: 1;
margin-right: 12px;
}
.app-title-section h3 {
margin: 0 0 4px 0;
font-size: 18px;
font-weight: 600;
color: #1f2937;
line-height: 1.2;
}
.clickable-title {
cursor: pointer;
transition: color 0.2s ease;
}
.clickable-title:hover {
color: #10b981;
}
.app-team {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #6b7280;
font-weight: 500;
}
.app-team .n-icon {
color: #9ca3af;
font-size: 14px;
}
.app-name {
color: #6b7280;
font-size: 11px;
font-weight: 500;
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
background: transparent;
border: 1px solid #d1d5db;
border-radius: 12px;
padding: 4px 10px;
text-align: center;
min-width: 70px;
letter-spacing: 0.3px;
text-transform: uppercase;
font-size: 10px;
transition: all 0.2s ease;
}
.app-name:hover {
border-color: #9ca3af;
color: #374151;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.app-subtitle {
color: #6b7280;
font-size: 14px;
line-height: 1.5;
margin-bottom: 16px;
}
.app-actions {
padding: 0 20px 20px;
display: flex;
gap: 12px;
}
.app-actions .n-button {
flex: 1;
}
.loading, .empty {
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: 16px;
align-items: stretch;
}
.search-container {
margin-bottom: 24px;
}
.search-bar {
flex-direction: column;
gap: 12px;
padding: 16px;
max-width: 100%;
}
.search-input {
width: 100%;
}
.search-button {
width: 100%;
}
.apps-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.app-card {
border-radius: 12px;
}
.app-image {
height: 180px;
}
}
</style>

View File

@ -20,7 +20,7 @@ from datetime import datetime
import requests
from jingrow.utils.app_installer import install_app, get_installed_apps as get_apps, get_app_directories, ensure_package_and_module
from jingrow.utils.jingrow_api import get_jingrow_api_headers
from jingrow.utils.jingrow_api import get_jingrow_api_headers, get_logged_user
from jingrow.utils.auth import get_jingrow_cloud_url, get_jingrow_cloud_api_headers, get_jingrow_cloud_api_url, get_jingrow_api_headers
from jingrow.utils.export_app_package import export_app_package_from_local
from jingrow.config import Config
@ -1027,6 +1027,86 @@ async def get_app_meta():
raise HTTPException(status_code=500, detail=f"获取元数据失败: {str(e)}")
@router.get("/jingrow/my-published-apps")
async def get_my_published_apps(
request: Request,
search: Optional[str] = None,
page: int = 1,
page_size: int = 20,
sort_by: Optional[str] = None
):
"""获取当前用户已发布的应用列表,支持搜索、分页和排序"""
try:
# 获取当前登录用户
session_cookie = request.cookies.get('sid')
if not session_cookie:
raise HTTPException(status_code=401, detail="未提供认证信息")
user = get_logged_user(session_cookie)
if not user:
raise HTTPException(status_code=401, detail="认证失败")
url = f"{get_jingrow_cloud_url()}/api/action/jcloud.api.local_app.get_local_apps"
# 构建过滤条件:过滤当前用户发布的应用
filters = {"owner": user}
if search:
filters["title"] = ["like", f"%{search}%"]
# 1. 先获取总数(不分页)
total_params = {
'filters': json.dumps(filters, ensure_ascii=False),
'limit_start': 0,
'limit_page_length': 0 # 不限制数量,获取所有数据来计算总数
}
headers = get_jingrow_cloud_api_headers()
# 添加session cookie以获取用户的应用
headers['Cookie'] = f'sid={session_cookie}'
total_response = requests.get(url, params=total_params, headers=headers, timeout=20)
total_count = 0
if total_response.status_code == 200:
total_data = total_response.json()
total_count = len(total_data.get('message', []))
# 2. 获取分页数据
params = {
'filters': json.dumps(filters, ensure_ascii=False)
}
# 排序参数
if sort_by:
params['order_by'] = sort_by
# 分页参数
limit_start = (page - 1) * page_size
params['limit_start'] = limit_start
params['limit_page_length'] = page_size
response = requests.get(url, params=params, headers=headers, timeout=20)
if response.status_code == 200:
data = response.json()
apps = data.get('message', [])
# 返回分页格式的数据
return {
"items": apps,
"total": total_count,
"page": page,
"page_size": page_size
}
else:
raise HTTPException(status_code=response.status_code, detail="获取已发布应用数据失败")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取已发布应用数据失败: {str(e)}")
@router.post("/jingrow/upload-image")
async def upload_image(file: UploadFile = File(...)):
"""上传应用图片"""