Added the ability to sort installed applications. (#11936)

This commit is contained in:
rowanchen-com
2026-02-25 10:35:29 +08:00
committed by GitHub
parent a77e61a861
commit a8a92da483
23 changed files with 146 additions and 6 deletions

View File

@@ -349,3 +349,15 @@ func (b *BaseApi) GetAppInstallInfo(c *gin.Context) {
}
helper.SuccessWithData(c, info)
}
func (b *BaseApi) UpdateAppInstallSort(c *gin.Context) {
var req request.AppInstallSort
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := appInstallService.UpdateSort(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}

View File

@@ -138,3 +138,12 @@ type AppUpdateVersion struct {
AppInstallID uint `json:"appInstallID" validate:"required"`
UpdateVersion string `json:"updateVersion"`
}
type AppInstallSortItem struct {
InstallID uint `json:"installID"`
SortOrder int `json:"sortOrder"`
}
type AppInstallSort struct {
Items []AppInstallSortItem `json:"items" validate:"required"`
}

View File

@@ -126,6 +126,7 @@ type AppInstallDTO struct {
WebUI string `json:"webUI"`
CreatedAt time.Time `json:"createdAt"`
Favorite bool `json:"favorite"`
SortOrder int `json:"sortOrder"`
App AppDetail `json:"app"`
Container string `json:"container"`
IsEdit bool `json:"isEdit"`

View File

@@ -26,6 +26,7 @@ type AppInstall struct {
HttpsPort int `json:"httpsPort"`
WebUI string `json:"webUI"`
Favorite bool `json:"favorite"`
SortOrder int `json:"sortOrder" gorm:"default:0"`
App App `json:"app" gorm:"-:migration"`
}

View File

@@ -57,6 +57,7 @@ type IAppInstallService interface {
UpdateAppConfig(req request.AppConfigUpdate) error
GetInstallList() ([]dto.AppInstallInfo, error)
GetAppInstallInfo(appInstallID uint) (*response.AppInstallInfo, error)
UpdateSort(req request.AppInstallSort) error
}
func NewIAppInstalledService() IAppInstallService {
@@ -83,6 +84,7 @@ func (a *AppInstallService) Page(req request.AppInstalledSearch) (int64, []respo
err error
)
opts = append(opts, repo.WithOrderRuleBy("favorite", "descending"))
opts = append(opts, repo.WithOrderRuleBy("sort_order", "ascending"))
if req.Name != "" {
opts = append(opts, repo.WithByLikeName(req.Name))
@@ -300,6 +302,9 @@ func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
return opNginx(install.ContainerName, constant.NginxReload)
case constant.Favorite:
install.Favorite = req.Favorite
var maxSort int
global.DB.Model(&model.AppInstall{}).Where("favorite = ?", req.Favorite).Select("COALESCE(MAX(sort_order),0)").Scan(&maxSort)
install.SortOrder = maxSort + 1
return appInstallRepo.Save(context.Background(), &install)
default:
return errors.New("operate not support")
@@ -318,6 +323,15 @@ func (a *AppInstallService) UpdateAppConfig(req request.AppConfigUpdate) error {
return appInstallRepo.Save(context.Background(), &installed)
}
func (a *AppInstallService) UpdateSort(req request.AppInstallSort) error {
for _, item := range req.Items {
if err := appInstallRepo.BatchUpdateBy(map[string]interface{}{"sort_order": item.SortOrder}, repo.WithByID(item.InstallID)); err != nil {
return err
}
}
return nil
}
func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
installed, err := appInstallRepo.GetFirst(repo.WithByID(req.InstallId))
if err != nil {

View File

@@ -1613,6 +1613,7 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
Document: installed.App.Document,
},
Favorite: installed.Favorite,
SortOrder: installed.SortOrder,
Container: installed.ContainerName,
ServiceName: strings.ToLower(installed.ServiceName),
}

View File

@@ -66,6 +66,7 @@ func InitAgentDB() {
migrations.AddAgentTables,
migrations.MigrateOpenclawAgents,
migrations.AddAgentCustomModelFields,
migrations.AddAppInstallSortOrder,
})
if err := m.Migrate(); err != nil {
global.LOG.Error(err)

View File

@@ -957,3 +957,10 @@ var AddAgentCustomModelFields = &gormigrate.Migration{
return nil
},
}
var AddAppInstallSortOrder = &gormigrate.Migration{
ID: "20260222-add-app-install-sort-order",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(&model.AppInstall{})
},
}

View File

@@ -40,6 +40,7 @@ func (a *AppRouter) InitRouter(Router *gin.RouterGroup) {
appRouter.POST("/installed/params/update", baseApi.UpdateInstalled)
appRouter.POST("/installed/update/versions", baseApi.GetUpdateVersions)
appRouter.POST("/installed/config/update", baseApi.UpdateAppConfig)
appRouter.POST("/installed/sort/update", baseApi.UpdateAppInstallSort)
appRouter.GET("/installed/info/:appInstallId", baseApi.GetAppInstallInfo)
appRouter.POST("/installed/ignore", baseApi.IgnoreAppUpgrade)

View File

@@ -51,6 +51,7 @@
"punycode": "^2.3.1",
"qs": "^6.12.1",
"screenfull": "^6.0.2",
"sortablejs": "^1.15.7",
"uuid": "^10.0.0",
"vue": "^3.4.27",
"vue-clipboard3": "^2.0.0",

View File

@@ -123,6 +123,10 @@ export const updateInstallConfig = (req: App.AppConfigUpdate) => {
return http.post(`apps/installed/config/update`, req);
};
export const updateAppInstallSort = (items: Array<{ installID: number; sortOrder: number }>) => {
return http.post(`apps/installed/sort/update`, { items });
};
export const syncCutomAppStore = (req: App.AppStoreSync) => {
return http.post(`/custom/app/sync`, req);
};

View File

@@ -2395,6 +2395,7 @@ const message = {
takeDown: 'TakeDown',
allReadyInstalled: 'Installed',
installHelper: 'If you have image pull issues, configure image acceleration.',
sortMode: 'Sort',
installWarn: `The external access isn't checked, and it will make the application unable to access through external network. Do you want to continue?`,
showIgnore: 'View ignored applications',
cancelIgnore: 'Cancel ignore',

View File

@@ -2409,6 +2409,7 @@ const message = {
takeDown: 'Desinstalar',
allReadyInstalled: 'Instaladas',
installHelper: 'Si tiene problemas con el pull de imagen, configure aceleración.',
sortMode: 'Ordenar',
installWarn: 'Si no habilita el acceso externo, la app no será accesible externamente. ¿Desea continuar?',
showIgnore: 'Ver aplicaciones ignoradas',
cancelIgnore: 'Cancelar ignoradas',

View File

@@ -2324,6 +2324,7 @@ const message = {
takeDown: '降ろす',
allReadyInstalled: 'インストール',
installHelper: '画像プルの問題がある場合は、画像アクセラレーションを構成します。',
sortMode: '並べ替え',
installWarn: `外部アクセスは有効になっていないため、アプリケーションが外部ネットワークを介してアクセスできるようになります。続けたいですか?`,
showIgnore: '無視されたアプリケーションを表示します',
cancelIgnore: 'キャンセルは無視します',

View File

@@ -2288,6 +2288,7 @@ const message = {
takeDown: '내리기',
allReadyInstalled: '설치됨',
installHelper: '이미지 풀 문제 시 이미지 가속을 구성하세요.',
sortMode: '정렬',
installWarn:
'외부 접근이 활성화되지 않아 애플리케이션이 외부 네트워크에서 접근할 수 없습니다. 계속 하시겠습니까?',
showIgnore: '무시된 애플리케이션 보기',

View File

@@ -2384,6 +2384,7 @@ const message = {
takeDown: 'Henti Operasi',
allReadyInstalled: 'Telah Dipasang',
installHelper: 'Jika terdapat isu tarikan imej, konfigurasikan pecutan imej.',
sortMode: 'Susun',
installWarn:
'Akses luaran tidak diaktifkan, yang menghalang aplikasi daripada diakses melalui rangkaian luaran. Adakah anda mahu meneruskan?',
showIgnore: 'Lihat aplikasi yang diabaikan',

View File

@@ -2378,6 +2378,7 @@ const message = {
takeDown: 'Retirar',
allReadyInstalled: 'Instalado',
installHelper: 'Se houver problemas ao puxar a imagem, configure a aceleração da imagem.',
sortMode: 'Ordenar',
upgradeHelper:
'Coloque aplicativos anormais de volta ao estado normal antes de atualizar. Se a atualização falhar, vá para "Logs > Logs do Sistema" para verificar a razão da falha.',
installWarn: `O acesso externo não foi habilitado, o que impede que o aplicativo seja acessado via redes externas. Deseja continuar?`,

View File

@@ -2376,6 +2376,7 @@ const message = {
takeDown: 'Отключить',
allReadyInstalled: 'Установлено',
installHelper: 'Если есть проблемы с загрузкой образа, настройте ускорение образов.',
sortMode: 'Сортировка',
installWarn:
'Внешний доступ не включен, что делает приложение недоступным через внешние сети. Хотите продолжить?',
showIgnore: 'Просмотреть игнорируемые приложения',

View File

@@ -2421,6 +2421,7 @@ const message = {
takeDown: 'Kaldır',
allReadyInstalled: 'Kurulu',
installHelper: 'Görüntü çekme sorunlarınız varsa, görüntü hızlandırmasını yapılandırın.',
sortMode: 'Sırala',
installWarn:
'Dış erişim işaretlenmedi, bu uygulama dış ağ üzerinden erişilemez hale getirecek. Devam etmek istiyor musunuz?',
showIgnore: 'Yoksayılan uygulamaları görüntüle',

View File

@@ -2231,6 +2231,7 @@ const message = {
takeDown: '已廢棄',
allReadyInstalled: '已安裝',
installHelper: '配置鏡像加速可以解決鏡像拉取失敗的問題',
sortMode: '排序',
installWarn: '目前未勾選埠外部訪問將無法透過外網IP:埠訪問是否繼續 ',
showIgnore: '查看忽略應用',
cancelIgnore: '取消忽略',

View File

@@ -2228,6 +2228,7 @@ const message = {
takeDown: '已废弃',
allReadyInstalled: '已安装',
installHelper: '配置镜像加速可以解决镜像拉取失败的问题',
sortMode: '排序',
installWarn: '当前未勾选端口外部访问将无法通过外网IP:端口访问是否继续',
showIgnore: '查看忽略应用',
cancelIgnore: '取消忽略',

View File

@@ -1,3 +1,7 @@
.sortable-ghost {
opacity: 0.4;
}
.install-card {
margin-top: 10px;
cursor: pointer;

View File

@@ -4,9 +4,23 @@
<Tags @change="changeTag" hideKey="Runtime" />
</template>
<template #leftToolBar>
<el-button @click="sync" type="primary" plain v-if="mode === 'installed' && data != null">
<el-button @click="sync" type="primary" plain v-if="mode === 'installed' && !sortMode && data != null">
{{ $t('commons.button.refresh') }}
</el-button>
<el-button
@click="enterSortMode"
type="primary"
plain
v-if="mode === 'installed' && !sortMode && data != null"
>
{{ $t('app.sortMode') }}
</el-button>
<el-button @click="saveSortOrder" type="primary" v-if="sortMode">
{{ $t('commons.button.confirm') }}
</el-button>
<el-button @click="exitSortMode" plain v-if="sortMode">
{{ $t('commons.button.cancel') }}
</el-button>
<el-button @click="openIgnore" type="primary" plain v-if="mode === 'upgrade'">
{{ $t('app.showIgnore') }}
</el-button>
@@ -38,10 +52,11 @@
<img src="@/assets/images/no_update_app.svg" />
</div>
</div>
<el-row :gutter="5">
<el-row :gutter="5" ref="sortContainer">
<el-col
v-for="(installed, index) in data"
:key="index"
:key="installed.id"
:data-sort-index="index"
:xs="24"
:sm="24"
:md="24"
@@ -90,7 +105,7 @@
</el-row>
</MainDiv>
</div>
<div class="page-button" v-if="mode === 'installed'">
<div class="page-button" v-if="mode === 'installed' && !sortMode">
<fu-table-pagination
v-model:current-page="paginationConfig.currentPage"
v-model:page-size="paginationConfig.pageSize"
@@ -132,8 +147,9 @@ import ComposeLogs from '@/components/log/compose/index.vue';
import IgnoreApp from '@/views/app-store/installed/ignore/create/index.vue';
import TerminalDialog from '@/views/container/container/terminal/index.vue';
import { searchAppInstalled, installedOp, appInstalledDeleteCheck } from '@/api/modules/app';
import { onMounted, onUnmounted, reactive, ref } from 'vue';
import { searchAppInstalled, installedOp, appInstalledDeleteCheck, updateAppInstallSort } from '@/api/modules/app';
import { onMounted, onUnmounted, reactive, ref, nextTick } from 'vue';
import Sortable from 'sortablejs';
import i18n from '@/lang';
import { ElMessageBox } from 'element-plus';
import { App } from '@/api/interface/app';
@@ -148,6 +164,9 @@ const { currentNode, isMaster, currentNodeAddr } = useGlobalStore();
const data = ref<any>();
const loading = ref(false);
const syncLoading = ref(false);
const sortMode = ref(false);
const sortContainer = ref();
let sortableInstance: Sortable | null = null;
let timer: NodeJS.Timer | null = null;
const paginationConfig = reactive({
cacheSizeKey: 'app-installed-page-size',
@@ -429,6 +448,61 @@ const openTerminal = (row: any) => {
dialogTerminalRef.value!.acceptParams({ containerID: row.container, title: title });
};
const enterSortMode = async () => {
sortMode.value = true;
clearInterval(Number(timer));
timer = null;
const res = await searchAppInstalled({ page: 1, pageSize: 10000, name: '', tags: [], update: false, sync: false });
data.value = res.data.items;
await nextTick();
const el = sortContainer.value?.$el;
if (el) {
const favCount = data.value.filter((i: any) => i.favorite).length;
sortableInstance = Sortable.create(el, {
animation: 150,
ghostClass: 'sortable-ghost',
onMove: (evt: any) => {
const from = evt.dragged.dataset.sortIndex;
const to = evt.related.dataset.sortIndex;
const fromFav = Number(from) < favCount;
const toFav = Number(to) < favCount;
return fromFav === toFav;
},
onEnd: (evt: any) => {
const list = [...data.value];
const [moved] = list.splice(evt.oldIndex, 1);
list.splice(evt.newIndex, 0, moved);
data.value = list;
},
});
}
};
const saveSortOrder = async () => {
if (!data.value) return;
let favIdx = 0;
let normalIdx = 0;
const items = data.value.map((item: any) => ({
installID: item.id,
sortOrder: item.favorite ? favIdx++ : normalIdx++,
}));
await updateAppInstallSort(items);
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
exitSortMode();
};
const exitSortMode = () => {
if (sortableInstance) {
sortableInstance.destroy();
sortableInstance = null;
}
sortMode.value = false;
search();
timer = setInterval(() => {
search();
}, 1000 * 30);
};
const getConfig = async () => {
try {
const res = await getAgentSettingByKey('SystemIP');