feat: 为应用页面添加"已安装"Tab选项卡 (#1535)

* Initial plan

* feat: 为应用页面添加"已安装"Tab选项卡

- 后端:app/list 和 environment/list 添加 installed 过滤参数
- 前端:创建 InstalledView 组件展示已安装的应用和环境
- 前端:IndexView 添加"已安装"Tab 并设为默认选项

Agent-Logs-Url: https://github.com/acepanel/panel/sessions/9b460f4b-c69e-4a5b-8131-c68099edcb7c

Co-authored-by: h2zi <5967335+h2zi@users.noreply.github.com>

* refactor: 移除 InstalledView 中未使用的 VersionModal 组件

Agent-Logs-Url: https://github.com/acepanel/panel/sessions/9b460f4b-c69e-4a5b-8131-c68099edcb7c

Co-authored-by: h2zi <5967335+h2zi@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: h2zi <5967335+h2zi@users.noreply.github.com>
This commit is contained in:
Copilot
2026-04-03 16:15:30 +08:00
committed by GitHub
parent 6f8c1b410f
commit 1079065220
6 changed files with 381 additions and 6 deletions
+4
View File
@@ -37,6 +37,7 @@ func (s *AppService) Categories(w http.ResponseWriter, r *http.Request) {
func (s *AppService) List(w http.ResponseWriter, r *http.Request) {
category := r.URL.Query().Get("category")
query := strings.ToLower(r.URL.Query().Get("query"))
onlyInstalled := r.URL.Query().Get("installed") == "true"
all := s.appRepo.All()
installedApps, err := s.appRepo.Installed()
@@ -60,6 +61,9 @@ func (s *AppService) List(w http.ResponseWriter, r *http.Request) {
updateExist = s.appRepo.UpdateExist(item.Slug)
show = installedAppMap[item.Slug].Show
}
if onlyInstalled && !installed {
continue
}
if category != "" && !strings.Contains(strings.Join(item.Categories, ","), category) {
continue
}
+6 -1
View File
@@ -32,6 +32,7 @@ func (s *EnvironmentService) Types(w http.ResponseWriter, r *http.Request) {
func (s *EnvironmentService) List(w http.ResponseWriter, r *http.Request) {
typ := r.URL.Query().Get("type")
query := strings.ToLower(r.URL.Query().Get("query"))
onlyInstalled := r.URL.Query().Get("installed") == "true"
all := s.environmentRepo.All()
var environments []types.EnvironmentDetail
for _, item := range all {
@@ -43,6 +44,10 @@ func (s *EnvironmentService) List(w http.ResponseWriter, r *http.Request) {
!strings.Contains(strings.ToLower(item.Description), query) {
continue
}
installed := s.environmentRepo.IsInstalled(item.Type, item.Slug)
if onlyInstalled && !installed {
continue
}
environments = append(environments, types.EnvironmentDetail{
Type: item.Type,
Name: item.Name,
@@ -50,7 +55,7 @@ func (s *EnvironmentService) List(w http.ResponseWriter, r *http.Request) {
Slug: item.Slug,
Version: item.Version,
InstalledVersion: s.environmentRepo.InstalledVersion(item.Type, item.Slug),
Installed: s.environmentRepo.IsInstalled(item.Type, item.Slug),
Installed: installed,
HasUpdate: s.environmentRepo.HasUpdate(item.Type, item.Slug),
})
}
+2 -2
View File
@@ -4,8 +4,8 @@ export default {
// 获取分类列表
categories: (): any => http.Get('/app/categories'),
// 获取应用列表
list: (page: number, limit: number, category?: string, query?: string): any =>
http.Get('/app/list', { params: { page, limit, category, query } }),
list: (page: number, limit: number, category?: string, query?: string, installed?: boolean): any =>
http.Get('/app/list', { params: { page, limit, category, query, installed } }),
// 安装应用
install: (slug: string, channel: string | null): any =>
http.Post('/app/install', { slug, channel }),
+2 -2
View File
@@ -4,8 +4,8 @@ export default {
// 获取环境类型列表
types: (): any => http.Get('/environment/types'),
// 获取环境列表
list: (page: number, limit: number, type?: string, query?: string): any =>
http.Get('/environment/list', { params: { page, limit, type, query } }),
list: (page: number, limit: number, type?: string, query?: string, installed?: boolean): any =>
http.Get('/environment/list', { params: { page, limit, type, query, installed } }),
// 安装环境
install: (type: string, slug: string): any => http.Post('/environment/install', { type, slug }),
// 卸载环境
+4 -1
View File
@@ -10,12 +10,13 @@ import app from '@/api/panel/app'
import { useTabStore } from '@/stores'
import AppView from '@/views/app/AppView.vue'
import EnvironmentView from '@/views/app/EnvironmentView.vue'
import InstalledView from '@/views/app/InstalledView.vue'
import TemplateView from '@/views/app/TemplateView.vue'
const { $gettext } = useGettext()
const tabStore = useTabStore()
const currentTab = ref('app')
const currentTab = ref('installed')
const updateCacheLoading = ref(false)
const handleUpdateCache = () => {
@@ -36,6 +37,7 @@ const handleUpdateCache = () => {
<template #tabbar>
<div class="flex items-center justify-between">
<n-tabs v-model:value="currentTab" animated class="flex-1">
<n-tab name="installed" :tab="$gettext('Installed')" />
<n-tab name="app" :tab="$gettext('Native App')" />
<n-tab name="environment" :tab="$gettext('Operating Environment')" />
<n-tab name="template" :tab="$gettext('Container Template')" />
@@ -45,6 +47,7 @@ const handleUpdateCache = () => {
</n-button>
</div>
</template>
<installed-view v-if="currentTab === 'installed'" />
<app-view v-if="currentTab === 'app'" />
<environment-view v-if="currentTab === 'environment'" />
<template-view v-if="currentTab === 'template'" />
+363
View File
@@ -0,0 +1,363 @@
<script setup lang="ts">
import { NButton, NDataTable, NFlex, NPopconfirm, NSwitch, NH3 } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
import app from '@/api/panel/app'
import environment from '@/api/panel/environment'
import { router } from '@/router'
import { renderLocalIcon } from '@/utils'
const { $gettext } = useGettext()
// 应用表格列
const appColumns: any = [
{
key: 'icon',
fixed: 'left',
width: 80,
align: 'center',
render(row: any) {
return renderLocalIcon('app', row.slug, { size: 26 })()
}
},
{
title: $gettext('App Name'),
key: 'name',
width: 200,
ellipsis: { tooltip: true }
},
{
title: $gettext('Description'),
key: 'description',
minWidth: 300,
ellipsis: { tooltip: true }
},
{
title: $gettext('Installed Version'),
key: 'installed_version',
width: 160,
ellipsis: { tooltip: true }
},
{
title: $gettext('Show in Home'),
key: 'show',
width: 140,
render(row: any) {
return h(NSwitch, {
size: 'small',
rubberBand: false,
value: row.show,
onUpdateValue: () => handleAppShowChange(row)
})
}
},
{
title: $gettext('Actions'),
key: 'actions',
width: 350,
hideInExcel: true,
render(row: any) {
return h(NFlex, null, {
default: () => [
row.update_exist
? h(
NPopconfirm,
{
onPositiveClick: () => handleAppUpdate(row.slug)
},
{
default: () => {
return $gettext(
'Updating app %{ app } may reset related configurations to default state, are you sure to continue?',
{ app: row.name }
)
},
trigger: () => {
return h(
NButton,
{
size: 'small',
type: 'warning'
},
{
default: () => $gettext('Update')
}
)
}
}
)
: null,
h(
NButton,
{
size: 'small',
type: 'info',
onClick: () => handleAppManage(row.slug)
},
{
default: () => $gettext('Manage')
}
),
h(
NPopconfirm,
{
onPositiveClick: () => handleAppUninstall(row.slug)
},
{
default: () => {
if (row.categories.includes('webserver')) {
return $gettext(
'Reinstalling/Switching to a different web server will reset the configuration of all websites, are you sure to continue?'
)
}
return $gettext('Are you sure to uninstall app %{ app }?', { app: row.name })
},
trigger: () => {
return h(
NButton,
{
size: 'small',
type: 'error'
},
{
default: () => $gettext('Uninstall')
}
)
}
}
)
]
})
}
}
]
// 环境表格列
const envColumns: any = [
{
key: 'icon',
fixed: 'left',
width: 80,
align: 'center',
render(row: any) {
return renderLocalIcon('environment', row.type, { size: 26 })()
}
},
{
title: $gettext('Name'),
key: 'name',
width: 200,
ellipsis: { tooltip: true }
},
{
title: $gettext('Description'),
key: 'description',
minWidth: 300,
ellipsis: { tooltip: true }
},
{
title: $gettext('Installed Version'),
key: 'installed_version',
width: 160,
ellipsis: { tooltip: true }
},
{
title: $gettext('Actions'),
key: 'actions',
width: 240,
hideInExcel: true,
render(row: any) {
return h(NFlex, null, {
default: () => [
row.has_update
? h(
NPopconfirm,
{
onPositiveClick: () => handleEnvUpdate(row.type, row.slug)
},
{
default: () => {
return $gettext('Are you sure to update environment %{ environment }?', {
environment: row.name
})
},
trigger: () => {
return h(
NButton,
{
size: 'small',
type: 'warning'
},
{
default: () => $gettext('Update')
}
)
}
}
)
: null,
h(
NButton,
{
size: 'small',
type: 'info',
onClick: () => handleEnvManage(row.type, row.slug)
},
{
default: () => $gettext('Manage')
}
),
h(
NPopconfirm,
{
onPositiveClick: () => handleEnvUninstall(row.type, row.slug)
},
{
default: () => {
return $gettext('Are you sure to uninstall environment %{ environment }?', {
environment: row.name
})
},
trigger: () => {
return h(
NButton,
{
size: 'small',
type: 'error'
},
{
default: () => $gettext('Uninstall')
}
)
}
}
)
]
})
}
}
]
// 获取已安装应用
const {
loading: appLoading,
data: appData,
page: appPage,
total: appTotal,
pageSize: appPageSize,
pageCount: appPageCount,
refresh: appRefresh
} = usePagination(
(page, pageSize) => app.list(page, pageSize, undefined, undefined, true),
{
initialData: { total: 0, list: [] },
initialPageSize: 20,
total: (res: any) => res.total,
data: (res: any) => res.items
}
)
// 获取已安装环境(环境数量通常很少,无需分页)
const {
loading: envLoading,
data: envData,
send: envRefresh
} = useRequest(() => environment.list(1, 200, undefined, undefined, true), {
initialData: []
})
// 应用操作
const handleAppShowChange = (row: any) => {
useRequest(app.updateShow(row.slug, !row.show)).onSuccess(() => {
row.show = !row.show
window.$message.success($gettext('Setup successfully'))
})
}
const handleAppUpdate = (slug: string) => {
useRequest(app.update(slug)).onSuccess(() => {
window.$message.success(
$gettext('Task submitted, please check the progress in background tasks')
)
})
}
const handleAppUninstall = (slug: string) => {
useRequest(app.uninstall(slug)).onSuccess(() => {
window.$message.success(
$gettext('Task submitted, please check the progress in background tasks')
)
})
}
const handleAppManage = (slug: string) => {
router.push({ name: 'apps-' + slug + '-index' })
}
// 环境操作
const handleEnvUpdate = (type: string, slug: string) => {
useRequest(environment.update(type, slug)).onSuccess(() => {
window.$message.success(
$gettext('Task submitted, please check the progress in background tasks')
)
})
}
const handleEnvUninstall = (type: string, slug: string) => {
useRequest(environment.uninstall(type, slug)).onSuccess(() => {
window.$message.success(
$gettext('Task submitted, please check the progress in background tasks')
)
})
}
const handleEnvManage = (type: string, slug: string) => {
router.push({ name: 'environment-' + type, params: { slug } })
}
onMounted(() => {
appRefresh()
envRefresh()
})
</script>
<template>
<n-flex vertical :size="24">
<!-- 已安装应用 -->
<n-flex vertical>
<n-h3 prefix="bar">{{ $gettext('Native App') }}</n-h3>
<n-data-table
striped
remote
:scroll-x="1200"
:loading="appLoading"
:columns="appColumns"
:data="appData"
:row-key="(row: any) => row.slug"
v-model:page="appPage"
v-model:pageSize="appPageSize"
:pagination="{
page: appPage,
pageCount: appPageCount,
pageSize: appPageSize,
itemCount: appTotal,
showQuickJumper: true,
showSizePicker: true,
pageSizes: [20, 50, 100, 200]
}"
/>
</n-flex>
<!-- 已安装环境 -->
<n-flex vertical>
<n-h3 prefix="bar">{{ $gettext('Operating Environment') }}</n-h3>
<n-data-table
striped
:scroll-x="1000"
:loading="envLoading"
:columns="envColumns"
:data="envData"
:row-key="(row: any) => row.slug"
/>
</n-flex>
</n-flex>
</template>