Browse Source

feat: add filter of category for sites list

Jacky 6 tháng trước cách đây
mục cha
commit
aa556767f2

+ 9 - 1
api/sites/domain.go

@@ -49,7 +49,7 @@ func GetSite(c *gin.Context) {
 	}
 
 	s := query.Site
-	site, err := s.Where(s.Path.Eq(path)).FirstOrInit()
+	site, err := s.Where(s.Path.Eq(path)).FirstOrCreate()
 	if err != nil {
 		api.ErrHandler(c, err)
 		return
@@ -300,6 +300,14 @@ func DeleteSite(c *gin.Context) {
 	var err error
 	name := c.Param("name")
 	availablePath := nginx.GetConfPath("sites-available", name)
+
+	s := query.Site
+	_, err = s.Where(s.Path.Eq(availablePath)).Unscoped().Delete(&model.Site{})
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+
 	enabledPath := nginx.GetConfPath("sites-enabled", name)
 	if _, err = os.Stat(availablePath); os.IsNotExist(err) {
 		c.JSON(http.StatusNotFound, gin.H{

+ 56 - 18
api/sites/list.go

@@ -4,9 +4,14 @@ import (
 	"github.com/0xJacky/Nginx-UI/api"
 	"github.com/0xJacky/Nginx-UI/internal/config"
 	"github.com/0xJacky/Nginx-UI/internal/nginx"
+	"github.com/0xJacky/Nginx-UI/model"
+	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/gin-gonic/gin"
+	"github.com/samber/lo"
+	"github.com/spf13/cast"
 	"net/http"
 	"os"
+	"path/filepath"
 	"strings"
 )
 
@@ -15,6 +20,7 @@ func GetSiteList(c *gin.Context) {
 	enabled := c.Query("enabled")
 	orderBy := c.Query("order_by")
 	sort := c.DefaultQuery("sort", "desc")
+	querySiteCategoryId := cast.ToUint64(c.Query("site_category_id"))
 
 	configFiles, err := os.ReadDir(nginx.GetConfPath("sites-available"))
 	if err != nil {
@@ -28,6 +34,20 @@ func GetSiteList(c *gin.Context) {
 		return
 	}
 
+	s := query.Site
+	sTx := s.Preload(s.SiteCategory)
+	if querySiteCategoryId != 0 {
+		sTx.Where(s.SiteCategoryID.Eq(querySiteCategoryId))
+	}
+	sites, err := sTx.Find()
+	if err != nil {
+		api.ErrHandler(c, err)
+		return
+	}
+	sitesMap := lo.SliceToMap(sites, func(item *model.Site) (string, *model.Site) {
+		return filepath.Base(item.Path), item
+	})
+
 	enabledConfigMap := make(map[string]bool)
 	for i := range enabledConfig {
 		enabledConfigMap[enabledConfig[i].Name()] = true
@@ -38,28 +58,46 @@ func GetSiteList(c *gin.Context) {
 	for i := range configFiles {
 		file := configFiles[i]
 		fileInfo, _ := file.Info()
-		if !file.IsDir() {
-			// name filter
-			if name != "" && !strings.Contains(file.Name(), name) {
+		if file.IsDir() {
+			continue
+		}
+		// name filter
+		if name != "" && !strings.Contains(file.Name(), name) {
+			continue
+		}
+		// status filter
+		if enabled != "" {
+			if enabled == "true" && !enabledConfigMap[file.Name()] {
 				continue
 			}
-			// status filter
-			if enabled != "" {
-				if enabled == "true" && !enabledConfigMap[file.Name()] {
-					continue
-				}
-				if enabled == "false" && enabledConfigMap[file.Name()] {
-					continue
-				}
+			if enabled == "false" && enabledConfigMap[file.Name()] {
+				continue
 			}
-			configs = append(configs, config.Config{
-				Name:       file.Name(),
-				ModifiedAt: fileInfo.ModTime(),
-				Size:       fileInfo.Size(),
-				IsDir:      fileInfo.IsDir(),
-				Enabled:    enabledConfigMap[file.Name()],
-			})
 		}
+		var (
+			siteCategoryId uint64
+			siteCategory   *model.SiteCategory
+		)
+
+		if site, ok := sitesMap[file.Name()]; ok {
+			siteCategoryId = site.SiteCategoryID
+			siteCategory = site.SiteCategory
+		}
+
+		// site category filter
+		if querySiteCategoryId != 0 && siteCategoryId != querySiteCategoryId {
+			continue
+		}
+
+		configs = append(configs, config.Config{
+			Name:           file.Name(),
+			ModifiedAt:     fileInfo.ModTime(),
+			Size:           fileInfo.Size(),
+			IsDir:          fileInfo.IsDir(),
+			Enabled:        enabledConfigMap[file.Name()],
+			SiteCategoryID: siteCategoryId,
+			SiteCategory:   siteCategory,
+		})
 	}
 
 	configs = config.Sort(orderBy, sort, configs)

+ 95 - 0
app/.eslint-auto-import.mjs

@@ -0,0 +1,95 @@
+export default {
+  globals: {
+    $gettext: true,
+    $ngettext: true,
+    $npgettext: true,
+    $pgettext: true,
+    Component: true,
+    ComponentPublicInstance: true,
+    ComputedRef: true,
+    DirectiveBinding: true,
+    EffectScope: true,
+    ExtractDefaultPropTypes: true,
+    ExtractPropTypes: true,
+    ExtractPublicPropTypes: true,
+    InjectionKey: true,
+    MaybeRef: true,
+    MaybeRefOrGetter: true,
+    PropType: true,
+    Ref: true,
+    VNode: true,
+    WritableComputedRef: true,
+    acceptHMRUpdate: true,
+    computed: true,
+    createApp: true,
+    createPinia: true,
+    customRef: true,
+    defineAsyncComponent: true,
+    defineComponent: true,
+    defineStore: true,
+    effectScope: true,
+    getActivePinia: true,
+    getCurrentInstance: true,
+    getCurrentScope: true,
+    h: true,
+    inject: true,
+    isProxy: true,
+    isReactive: true,
+    isReadonly: true,
+    isRef: true,
+    mapActions: true,
+    mapGetters: true,
+    mapState: true,
+    mapStores: true,
+    mapWritableState: true,
+    markRaw: true,
+    nextTick: true,
+    onActivated: true,
+    onBeforeMount: true,
+    onBeforeRouteLeave: true,
+    onBeforeRouteUpdate: true,
+    onBeforeUnmount: true,
+    onBeforeUpdate: true,
+    onDeactivated: true,
+    onErrorCaptured: true,
+    onMounted: true,
+    onRenderTracked: true,
+    onRenderTriggered: true,
+    onScopeDispose: true,
+    onServerPrefetch: true,
+    onUnmounted: true,
+    onUpdated: true,
+    onWatcherCleanup: true,
+    provide: true,
+    reactive: true,
+    readonly: true,
+    ref: true,
+    resolveComponent: true,
+    setActivePinia: true,
+    setMapStoreSuffix: true,
+    shallowReactive: true,
+    shallowReadonly: true,
+    shallowRef: true,
+    storeToRefs: true,
+    toRaw: true,
+    toRef: true,
+    toRefs: true,
+    toValue: true,
+    triggerRef: true,
+    unref: true,
+    useAttrs: true,
+    useCssModule: true,
+    useCssVars: true,
+    useId: true,
+    useLink: true,
+    useModel: true,
+    useRoute: true,
+    useRouter: true,
+    useSlots: true,
+    useTemplateRef: true,
+    watch: true,
+    watchEffect: true,
+    watchPostEffect: true,
+    watchSyncEffect: true,
+  },
+}

+ 4 - 0
app/eslint.config.mjs

@@ -1,10 +1,14 @@
 import createConfig from '@antfu/eslint-config'
 import sonarjs from 'eslint-plugin-sonarjs'
+import autoImport from './.eslint-auto-import.mjs'
 
 export default createConfig(
   {
     stylistic: true,
     ignores: ['**/version.json', 'tsconfig.json', 'tsconfig.node.json'],
+    languageOptions: {
+      globals: autoImport.globals,
+    },
   },
   sonarjs.configs.recommended,
   {

+ 4 - 2
app/src/components/StdDesign/StdDataDisplay/StdTableTransformer.tsx

@@ -48,8 +48,10 @@ export function mask(maskObj: any): (args: CustomRenderProps) => JSX.Element {
 export function arrayToTextRender(args: CustomRenderProps) {
   return args.text?.join(', ')
 }
-export function actualValueRender(args: CustomRenderProps, actualDataIndex: string | string[]) {
-  return get(args.record, actualDataIndex)
+export function actualValueRender(actualDataIndex: string | string[]) {
+  return (args: CustomRenderProps) => {
+    return get(args.record, actualDataIndex) || '/'
+  }
 }
 
 export function longTextWithEllipsis(len: number): (args: CustomRenderProps) => JSX.Element {

+ 1 - 1
app/src/routes/index.ts

@@ -52,7 +52,7 @@ export const routes: RouteRecordRaw[] = [
         children: [{
           path: 'list',
           name: 'Sites List',
-          component: () => import('@/views/site/SiteList.vue'),
+          component: () => import('@/views/site/site_list/SiteList.vue'),
           meta: {
             name: () => $gettext('Sites List'),
           },

+ 40 - 58
app/src/views/site/SiteList.vue → app/src/views/site/site_list/SiteList.vue

@@ -1,64 +1,44 @@
 <script setup lang="tsx">
-import type { CustomRenderProps } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
-import type { Column, JSXElements } from '@/components/StdDesign/types'
+import type { SiteCategory } from '@/api/site_category'
 import domain from '@/api/domain'
+import site_category from '@/api/site_category'
 import StdTable from '@/components/StdDesign/StdDataDisplay/StdTable.vue'
-import { datetime } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
-import { input, select } from '@/components/StdDesign/StdDataEntry'
 import InspectConfig from '@/views/config/InspectConfig.vue'
 import SiteDuplicate from '@/views/site/components/SiteDuplicate.vue'
-import { Badge, message } from 'ant-design-vue'
-
-const columns: Column[] = [{
-  title: () => $gettext('Name'),
-  dataIndex: 'name',
-  sorter: true,
-  pithy: true,
-  edit: {
-    type: input,
-  },
-  search: true,
-}, {
-  title: () => $gettext('Status'),
-  dataIndex: 'enabled',
-  customRender: (args: CustomRenderProps) => {
-    const template: JSXElements = []
-    const { text } = args
-    if (text === true || text > 0) {
-      template.push(<Badge status="success" />)
-      template.push($gettext('Enabled'))
-    }
-    else {
-      template.push(<Badge status="warning" />)
-      template.push($gettext('Disabled'))
-    }
+import columns from '@/views/site/site_list/columns'
+import { message } from 'ant-design-vue'
 
-    return h('div', template)
-  },
-  search: {
-    type: select,
-    mask: {
-      true: $gettext('Enabled'),
-      false: $gettext('Disabled'),
-    },
-  },
-  sorter: true,
-  pithy: true,
-}, {
-  title: () => $gettext('Updated at'),
-  dataIndex: 'modified_at',
-  customRender: datetime,
-  sorter: true,
-  pithy: true,
-}, {
-  title: () => $gettext('Action'),
-  dataIndex: 'action',
-}]
+const route = useRoute()
+const router = useRouter()
 
 const table = ref()
-
 const inspect_config = ref()
 
+const siteCategoryId = ref(Number.parseInt(route.query.site_category_id as string) || 0)
+const siteCategories = ref([]) as Ref<SiteCategory[]>
+
+watch(route, () => {
+  inspect_config.value?.test()
+})
+
+onMounted(async () => {
+  while (true) {
+    try {
+      const { data, pagination } = await site_category.get_list()
+      if (!data || !pagination)
+        return
+      siteCategories.value.push(...data)
+      if (data.length < pagination?.per_page) {
+        return
+      }
+    }
+    catch (e: any) {
+      message.error(e?.message ?? $gettext('Server error'))
+      return
+    }
+  }
+})
+
 function enable(name: string) {
   domain.enable(name).then(() => {
     message.success($gettext('Enabled successfully'))
@@ -97,18 +77,17 @@ function handle_click_duplicate(name: string) {
   show_duplicator.value = true
   target.value = name
 }
-
-const route = useRoute()
-
-watch(route, () => {
-  inspect_config.value?.test()
-})
 </script>
 
 <template>
   <ACard :title="$gettext('Manage Sites')">
     <InspectConfig ref="inspect_config" />
 
+    <ATabs v-model:active-key="siteCategoryId">
+      <ATabPane :key="0" :tab="$gettext('All')" />
+      <ATabPane v-for="c in siteCategories" :key="c.id" :tab="c.name" />
+    </ATabs>
+
     <StdTable
       ref="table"
       :api="domain"
@@ -116,7 +95,10 @@ watch(route, () => {
       row-key="name"
       disable-delete
       disable-view
-      @click-edit="r => $router.push({
+      :get-params="{
+        site_category_id: siteCategoryId,
+      }"
+      @click-edit="(r: string) => router.push({
         path: `/sites/${r}`,
       })"
     >

+ 62 - 0
app/src/views/site/site_list/columns.tsx

@@ -0,0 +1,62 @@
+import type { Column, JSXElements } from '@/components/StdDesign/types'
+import {
+  actualValueRender,
+  type CustomRenderProps,
+  datetime,
+} from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
+import { input, select } from '@/components/StdDesign/StdDataEntry'
+import { Badge } from 'ant-design-vue'
+
+const columns: Column[] = [{
+  title: () => $gettext('Name'),
+  dataIndex: 'name',
+  sorter: true,
+  pithy: true,
+  edit: {
+    type: input,
+  },
+  search: true,
+}, {
+  title: () => $gettext('Category'),
+  dataIndex: 'site_category_id',
+  customRender: actualValueRender('site_category.name'),
+  sorter: true,
+  pithy: true,
+}, {
+  title: () => $gettext('Status'),
+  dataIndex: 'enabled',
+  customRender: (args: CustomRenderProps) => {
+    const template: JSXElements = []
+    const { text } = args
+    if (text === true || text > 0) {
+      template.push(<Badge status="success" />)
+      template.push($gettext('Enabled'))
+    }
+    else {
+      template.push(<Badge status="warning" />)
+      template.push($gettext('Disabled'))
+    }
+
+    return h('div', template)
+  },
+  search: {
+    type: select,
+    mask: {
+      true: $gettext('Enabled'),
+      false: $gettext('Disabled'),
+    },
+  },
+  sorter: true,
+  pithy: true,
+}, {
+  title: () => $gettext('Updated at'),
+  dataIndex: 'modified_at',
+  customRender: datetime,
+  sorter: true,
+  pithy: true,
+}, {
+  title: () => $gettext('Action'),
+  dataIndex: 'action',
+}]
+
+export default columns

+ 4 - 0
app/vite.config.ts

@@ -56,6 +56,10 @@ export default defineConfig(({ mode }) => {
           },
         ],
         vueTemplate: true,
+        eslintrc: {
+          enabled: true,
+          filepath: '.eslint-auto-import.mjs',
+        },
       }),
       DefineOptions(),
     ],

+ 3 - 0
internal/config/config.go

@@ -1,6 +1,7 @@
 package config
 
 import (
+	"github.com/0xJacky/Nginx-UI/model"
 	"github.com/sashabaranov/go-openai"
 	"time"
 )
@@ -13,5 +14,7 @@ type Config struct {
 	ModifiedAt      time.Time                      `json:"modified_at"`
 	Size            int64                          `json:"size,omitempty"`
 	IsDir           bool                           `json:"is_dir"`
+	SiteCategoryID  uint64                         `json:"site_category_id"`
+	SiteCategory    *model.SiteCategory            `json:"site_category,omitempty"`
 	Enabled         bool                           `json:"enabled"`
 }