feat: 为应用管理(原生应用、运行环境和容器模板)添加搜索功能 (#1487)

* Initial plan

* feat: 为应用管理(原生应用、运行环境和容器模板)添加搜索功能

后端通过 GET query 参数传入关键词,按名称和描述进行大小写不敏感过滤。
容器模板额外支持按 website 字段搜索。
前端在分类标签旁添加搜索输入框,输入变化时自动触发重新查询。

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* refactor: 优化搜索过滤代码格式和 TemplateView 一致性

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>
This commit is contained in:
Copilot
2026-03-15 18:07:28 +08:00
committed by GitHub
parent 01585dcc3b
commit 69ff73a393
10 changed files with 143 additions and 70 deletions
+6
View File
@@ -36,6 +36,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"))
all := s.appRepo.All()
installedApps, err := s.appRepo.Installed()
@@ -62,6 +63,11 @@ func (s *AppService) List(w http.ResponseWriter, r *http.Request) {
if category != "" && !strings.Contains(strings.Join(item.Categories, ","), category) {
continue
}
if query != "" &&
!strings.Contains(strings.ToLower(item.Name), query) &&
!strings.Contains(strings.ToLower(item.Description), query) {
continue
}
app := types.AppDetail{
Name: item.Name,
+6
View File
@@ -31,12 +31,18 @@ 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"))
all := s.environmentRepo.All()
var environments []types.EnvironmentDetail
for _, item := range all {
if typ != "" && !strings.EqualFold(item.Type, typ) {
continue
}
if query != "" &&
!strings.Contains(strings.ToLower(item.Name), query) &&
!strings.Contains(strings.ToLower(item.Description), query) {
continue
}
environments = append(environments, types.EnvironmentDetail{
Type: item.Type,
Name: item.Name,
+5
View File
@@ -27,12 +27,17 @@ func NewTemplateService(t *gotext.Locale, template biz.TemplateRepo, setting biz
// List 获取所有模版
func (s *TemplateService) List(w http.ResponseWriter, r *http.Request) {
category := r.URL.Query().Get("category")
query := r.URL.Query().Get("query")
templates := s.templateRepo.List()
// 按分类过滤
if category != "" {
templates = templates.FilterByCategory(category)
}
// 按关键词过滤
if query != "" {
templates = templates.FilterByQuery(query)
}
paged, total := Paginate(r, templates)
Success(w, chix.M{
+15
View File
@@ -3,6 +3,7 @@ package api
import (
"fmt"
"slices"
"strings"
"time"
)
@@ -40,6 +41,20 @@ func (t Templates) FilterByCategory(category string) Templates {
return filtered
}
// FilterByQuery 按关键词过滤模版
func (t Templates) FilterByQuery(query string) Templates {
query = strings.ToLower(query)
filtered := make(Templates, 0)
for _, tpl := range t {
if strings.Contains(strings.ToLower(tpl.Name), query) ||
strings.Contains(strings.ToLower(tpl.Description), query) ||
strings.Contains(strings.ToLower(tpl.Website), query) {
filtered = append(filtered, tpl)
}
}
return filtered
}
// Templates 返回所有模版
func (r *API) Templates() (*Templates, error) {
resp, err := r.client.R().SetResult(&Response{}).Get("/templates")
+2 -2
View File
@@ -4,8 +4,8 @@ export default {
// 获取分类列表
categories: (): any => http.Get('/app/categories'),
// 获取应用列表
list: (page: number, limit: number, category?: string): any =>
http.Get('/app/list', { params: { page, limit, category } }),
list: (page: number, limit: number, category?: string, query?: string): any =>
http.Get('/app/list', { params: { page, limit, category, query } }),
// 安装应用
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): any =>
http.Get('/environment/list', { params: { page, limit, type } }),
list: (page: number, limit: number, type?: string, query?: string): any =>
http.Get('/environment/list', { params: { page, limit, type, query } }),
// 安装环境
install: (type: string, slug: string): any => http.Post('/environment/install', { type, slug }),
// 卸载环境
+2 -2
View File
@@ -2,8 +2,8 @@ import { http } from '@/utils'
export default {
// 获取模版列表
list: (page: number, pageSize: number, category?: string): any =>
http.Get(`/template`, { params: { page, limit: pageSize, category } }),
list: (page: number, pageSize: number, category?: string, query?: string): any =>
http.Get(`/template`, { params: { page, limit: pageSize, category, query } }),
// 获取模版详情
get: (slug: string): any => http.Get(`/template/${slug}`),
// 使用模版创建编排
+32 -22
View File
@@ -3,7 +3,7 @@ defineOptions({
name: 'app-index'
})
import { NButton, NDataTable, NFlex, NPopconfirm, NSwitch, NTag } from 'naive-ui'
import { NButton, NDataTable, NFlex, NInput, NPopconfirm, NSwitch, NTag } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
import app from '@/api/panel/app'
@@ -19,6 +19,7 @@ const versionModalInfo = ref<any>({})
// 当前选中的分类
const selectedCategory = ref<string>('')
const searchQuery = ref<string>('')
const columns: any = [
{
@@ -169,13 +170,14 @@ const { data: categories } = useRequest(app.categories, {
})
const { loading, data, page, total, pageSize, pageCount, refresh } = usePagination(
(page, pageSize) => app.list(page, pageSize, selectedCategory.value || undefined),
(page, pageSize) =>
app.list(page, pageSize, selectedCategory.value || undefined, searchQuery.value || undefined),
{
initialData: { total: 0, list: [] },
initialPageSize: 20,
total: (res: any) => res.total,
data: (res: any) => res.items,
watchingStates: [selectedCategory]
watchingStates: [selectedCategory, searchQuery]
}
)
@@ -219,25 +221,33 @@ onMounted(() => {
<template>
<n-flex vertical>
<n-flex>
<n-tag
:type="selectedCategory === '' ? 'primary' : 'default'"
:bordered="selectedCategory !== ''"
style="cursor: pointer"
@click="handleCategoryChange('')"
>
{{ $gettext('All') }}
</n-tag>
<n-tag
v-for="cat in categories"
:key="cat.value"
:type="selectedCategory === cat.value ? 'primary' : 'default'"
:bordered="selectedCategory !== cat.value"
style="cursor: pointer"
@click="handleCategoryChange(cat.value)"
>
{{ cat.label }}
</n-tag>
<n-flex justify="space-between">
<n-flex>
<n-tag
:type="selectedCategory === '' ? 'primary' : 'default'"
:bordered="selectedCategory !== ''"
style="cursor: pointer"
@click="handleCategoryChange('')"
>
{{ $gettext('All') }}
</n-tag>
<n-tag
v-for="cat in categories"
:key="cat.value"
:type="selectedCategory === cat.value ? 'primary' : 'default'"
:bordered="selectedCategory !== cat.value"
style="cursor: pointer"
@click="handleCategoryChange(cat.value)"
>
{{ cat.label }}
</n-tag>
</n-flex>
<n-input
v-model:value="searchQuery"
:placeholder="$gettext('Search')"
clearable
style="width: 240px"
/>
</n-flex>
<n-data-table
striped
+32 -22
View File
@@ -2,12 +2,13 @@
import environment from '@/api/panel/environment'
import { router } from '@/router'
import { renderLocalIcon } from '@/utils'
import { NButton, NDataTable, NFlex, NPopconfirm, NTag } from 'naive-ui'
import { NButton, NDataTable, NFlex, NInput, NPopconfirm, NTag } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
const { $gettext } = useGettext()
const selectedType = ref<string>('')
const searchQuery = ref<string>('')
const { data: types } = useRequest(environment.types, {
initialData: []
@@ -156,13 +157,14 @@ const columns: any = [
]
const { loading, data, page, total, pageSize, pageCount, refresh } = usePagination(
(page, pageSize) => environment.list(page, pageSize, selectedType.value || undefined),
(page, pageSize) =>
environment.list(page, pageSize, selectedType.value || undefined, searchQuery.value || undefined),
{
initialData: { total: 0, list: [] },
initialPageSize: 20,
total: (res: any) => res.total,
data: (res: any) => res.items,
watchingStates: [selectedType]
watchingStates: [selectedType, searchQuery]
}
)
@@ -207,25 +209,33 @@ onMounted(() => {
<template>
<n-flex vertical>
<n-flex>
<n-tag
:type="selectedType === '' ? 'primary' : 'default'"
:bordered="selectedType !== ''"
style="cursor: pointer"
@click="handleTypeChange('')"
>
{{ $gettext('All') }}
</n-tag>
<n-tag
v-for="type in types"
:key="type.value"
:type="selectedType === type.value ? 'primary' : 'default'"
:bordered="selectedType !== type.value"
style="cursor: pointer"
@click="handleTypeChange(type.value)"
>
{{ type.label }}
</n-tag>
<n-flex justify="space-between">
<n-flex>
<n-tag
:type="selectedType === '' ? 'primary' : 'default'"
:bordered="selectedType !== ''"
style="cursor: pointer"
@click="handleTypeChange('')"
>
{{ $gettext('All') }}
</n-tag>
<n-tag
v-for="type in types"
:key="type.value"
:type="selectedType === type.value ? 'primary' : 'default'"
:bordered="selectedType !== type.value"
style="cursor: pointer"
@click="handleTypeChange(type.value)"
>
{{ type.label }}
</n-tag>
</n-flex>
<n-input
v-model:value="searchQuery"
:placeholder="$gettext('Search')"
clearable
style="width: 240px"
/>
</n-flex>
<n-data-table
striped
+41 -20
View File
@@ -7,6 +7,7 @@ import {
NFlex,
NGrid,
NGridItem,
NInput,
NPagination,
NSpin,
NTag
@@ -21,11 +22,18 @@ import type { Template } from './types'
const { $gettext } = useGettext()
const selectedCategory = ref<string>('')
const searchQuery = ref<string>('')
const deployModalShow = ref(false)
const selectedTemplate = ref<Template | null>(null)
const { loading, data, page, total, pageSize, pageCount, refresh } = usePagination(
(page, pageSize) => template.list(page, pageSize, selectedCategory.value || undefined),
(page, pageSize) =>
template.list(
page,
pageSize,
selectedCategory.value || undefined,
searchQuery.value || undefined
),
{
initialData: { total: 0, list: [] },
initialPageSize: 12,
@@ -50,6 +58,11 @@ const handleCategoryChange = (category: string) => {
refresh()
}
watch(searchQuery, () => {
page.value = 1
refresh()
})
const handleDeploy = (tpl: Template) => {
selectedTemplate.value = tpl
deployModalShow.value = true
@@ -62,25 +75,33 @@ onMounted(() => {
<template>
<n-flex vertical :size="20">
<n-flex>
<n-tag
:type="selectedCategory === '' ? 'primary' : 'default'"
:bordered="selectedCategory !== ''"
style="cursor: pointer"
@click="handleCategoryChange('')"
>
{{ $gettext('All') }}
</n-tag>
<n-tag
v-for="cat in categories"
:key="cat.value"
:type="selectedCategory === cat.value ? 'primary' : 'default'"
:bordered="selectedCategory !== cat.value"
style="cursor: pointer"
@click="handleCategoryChange(cat.value)"
>
{{ cat.label }}
</n-tag>
<n-flex justify="space-between">
<n-flex>
<n-tag
:type="selectedCategory === '' ? 'primary' : 'default'"
:bordered="selectedCategory !== ''"
style="cursor: pointer"
@click="handleCategoryChange('')"
>
{{ $gettext('All') }}
</n-tag>
<n-tag
v-for="cat in categories"
:key="cat.value"
:type="selectedCategory === cat.value ? 'primary' : 'default'"
:bordered="selectedCategory !== cat.value"
style="cursor: pointer"
@click="handleCategoryChange(cat.value)"
>
{{ cat.label }}
</n-tag>
</n-flex>
<n-input
v-model:value="searchQuery"
:placeholder="$gettext('Search')"
clearable
style="width: 240px"
/>
</n-flex>
<n-spin :show="loading">