689 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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-meta">
<div class="app-team" v-if="app.team">
<n-icon><Icon icon="tabler:users" /></n-icon>
<span>{{ app.team }}</span>
</div>
<span v-if="app.status" class="status-badge" :class="getStatusClass(app.status)">
{{ t(app.status) }}
</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="error"
@click="deleteApp(app)"
>
{{ t('Delete') }}
</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, useDialog } from 'naive-ui'
import { Icon } from '@iconify/vue'
import axios from 'axios'
import { t } from '@/shared/i18n'
const message = useMessage()
const dialog = useDialog()
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({
path: `/app-marketplace/${app.name}`,
query: { returnTo: '/my-published-apps' }
})
}
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) + '...'
}
function getStatusClass(status: string): string {
if (!status) return ''
// 将状态转换为小写,并将空格替换为连字符
return status.toLowerCase().replace(/\s+/g, '-')
}
async function deleteApp(app: any) {
// 使用记录的name字段删除
const recordName = app.name
if (!recordName) {
message.error(t('应用名称不存在'))
return
}
// 显示确认对话框,显示应用标题
const appTitle = app.title || app.app_name || recordName
dialog.warning({
title: t('确认删除'),
content: t('确定要删除应用 "{0}" 吗?此操作不可恢复。').replace('{0}', appTitle),
positiveText: t('确认删除'),
negativeText: t('取消'),
onPositiveClick: async () => {
await performDelete(recordName)
}
})
}
async function performDelete(appName: string) {
try {
// 调用本地API由后端转发到云端
const response = await axios.post('/jingrow/delete-published-app', {
name: appName
}, {
withCredentials: true
})
if (response.data && response.data.success) {
message.success(response.data.message || t('应用删除成功'))
// 刷新应用列表
loadApps()
} else {
const errorMsg = response.data?.message || response.data?.error || t('删除失败')
message.error(errorMsg)
}
} catch (error: any) {
console.error('Delete app error:', error)
const errorMsg = error.response?.data?.detail ||
error.response?.data?.message ||
error.message ||
t('删除失败')
message.error(errorMsg)
}
}
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-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 4px;
}
.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;
}
.status-badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.3px;
}
.status-badge.published {
background: #d1fae5;
color: #065f46;
}
.status-badge.unpublished {
background: #fee2e2;
color: #991b1b;
}
.status-badge.draft {
background: #fef3c7;
color: #92400e;
}
.status-badge.active {
background: #dbeafe;
color: #1e40af;
}
.status-badge.inactive {
background: #f3f4f6;
color: #6b7280;
}
.status-badge.pending {
background: #dbeafe;
color: #1e40af;
}
.status-badge.pending-review {
background: #dbeafe;
color: #1e40af;
}
.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>