1
0
Эх сурвалжийг харах

feat(site): save sync node ids

Jacky 6 сар өмнө
parent
commit
6c137e5229

+ 0 - 0
.db


+ 1 - 0
api/sites/router.go

@@ -6,6 +6,7 @@ func InitRouter(r *gin.RouterGroup) {
 	r.GET("domains", GetSiteList)
 	r.GET("domains/:name", GetSite)
 	r.POST("domains/:name", SaveSite)
+	r.PUT("domains", BatchUpdateSites)
 	r.POST("domains/:name/enable", EnableSite)
 	r.POST("domains/:name/disable", DisableSite)
 	r.POST("domains/:name/advance", DomainEditByAdvancedMode)

+ 39 - 5
api/sites/domain.go → api/sites/site.go

@@ -9,7 +9,9 @@ import (
 	"github.com/0xJacky/Nginx-UI/query"
 	"github.com/gin-gonic/gin"
 	"github.com/sashabaranov/go-openai"
+	"github.com/uozi-tech/cosy"
 	"github.com/uozi-tech/cosy/logger"
+	"gorm.io/gorm/clause"
 	"net/http"
 	"os"
 )
@@ -125,10 +127,11 @@ func SaveSite(c *gin.Context) {
 	}
 
 	var json struct {
-		Name           string `json:"name" binding:"required"`
-		Content        string `json:"content" binding:"required"`
-		SiteCategoryID uint64 `json:"site_category_id"`
-		Overwrite      bool   `json:"overwrite"`
+		Name           string   `json:"name" binding:"required"`
+		Content        string   `json:"content" binding:"required"`
+		SiteCategoryID uint64   `json:"site_category_id"`
+		SyncNodeIDs    []uint64 `json:"sync_node_ids"`
+		Overwrite      bool     `json:"overwrite"`
 	}
 
 	if !api.BindAndValid(c, &json) {
@@ -152,7 +155,12 @@ func SaveSite(c *gin.Context) {
 	enabledConfigFilePath := nginx.GetConfPath("sites-enabled", name)
 	s := query.Site
 
-	_, err = s.Where(s.Path.Eq(path)).Update(s.SiteCategoryID, json.SiteCategoryID)
+	_, err = s.Where(s.Path.Eq(path)).
+		Select(s.SiteCategoryID, s.SyncNodeIDs).
+		Updates(&model.Site{
+			SiteCategoryID: json.SiteCategoryID,
+			SyncNodeIDs:    json.SyncNodeIDs,
+		})
 	if err != nil {
 		api.ErrHandler(c, err)
 		return
@@ -336,3 +344,29 @@ func DeleteSite(c *gin.Context) {
 		"message": "ok",
 	})
 }
+
+func BatchUpdateSites(c *gin.Context) {
+	cosy.Core[model.Site](c).SetValidRules(gin.H{
+		"site_category_id": "required",
+	}).SetItemKey("path").
+		BeforeExecuteHook(func(ctx *cosy.Ctx[model.Site]) {
+			effectedPath := make([]string, len(ctx.BatchEffectedIDs))
+			var sites []*model.Site
+			for i, name := range ctx.BatchEffectedIDs {
+				path := nginx.GetConfPath("sites-available", name)
+				effectedPath[i] = path
+				sites = append(sites, &model.Site{
+					Path: path,
+				})
+			}
+			s := query.Site
+			err := s.Clauses(clause.OnConflict{
+				DoNothing: true,
+			}).Create(sites...)
+			if err != nil {
+				ctx.AbortWithError(err)
+				return
+			}
+			ctx.BatchEffectedIDs = effectedPath
+		}).BatchModify()
+}

+ 0 - 0
api/sites/sites.go → api/sites/type.go


+ 93 - 93
app/.eslint-auto-import.mjs

@@ -1,95 +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,
-  },
+  "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
+  }
 }

+ 2 - 0
app/components.d.ts

@@ -49,6 +49,8 @@ declare module 'vue' {
     APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
     APopover: typeof import('ant-design-vue/es')['Popover']
     AProgress: typeof import('ant-design-vue/es')['Progress']
+    ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
+    ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
     AResult: typeof import('ant-design-vue/es')['Result']
     ARow: typeof import('ant-design-vue/es')['Row']
     ASelect: typeof import('ant-design-vue/es')['Select']

+ 1 - 1
app/eslint.config.mjs

@@ -5,7 +5,7 @@ import autoImport from './.eslint-auto-import.mjs'
 export default createConfig(
   {
     stylistic: true,
-    ignores: ['**/version.json', 'tsconfig.json', 'tsconfig.node.json'],
+    ignores: ['**/version.json', 'tsconfig.json', 'tsconfig.node.json', '.eslint-auto-import.mjs'],
     languageOptions: {
       globals: autoImport.globals,
     },

+ 1 - 0
app/src/api/domain.ts

@@ -19,6 +19,7 @@ export interface Site {
   cert_info?: Record<number, CertificateInfo[]>
   site_category_id: number
   site_category?: SiteCategory
+  sync_node_ids: number[]
 }
 
 export interface AutoCertRequest {

+ 43 - 23
app/src/components/StdDesign/StdDataDisplay/StdBatchEdit.vue

@@ -1,48 +1,60 @@
 <script setup lang="ts">
+import type Curd from '@/api/curd'
+import type { Column } from '@/components/StdDesign/types'
+import { getPithyColumns } from '@/components/StdDesign/StdDataDisplay/methods/columns'
 import StdDataEntry from '@/components/StdDesign/StdDataEntry'
 import { message } from 'ant-design-vue'
 
 const props = defineProps<{
   // eslint-disable-next-line ts/no-explicit-any
-  api: (ids: number[], data: any) => Promise<void>
+  api: Curd<any>
   beforeSave?: () => Promise<void>
+  columns: Column[]
 }>()
 
-const emit = defineEmits(['onSave'])
+const emit = defineEmits(['save'])
 
-const batchColumns = ref([])
+const batchColumns = ref<Column[]>([])
+const selectedRowKeys = ref<(number | string)[]>([])
+// eslint-disable-next-line ts/no-explicit-any
+const selectedRows = ref<any[]>([])
 
 const visible = ref(false)
+const data = ref({})
+const error = ref({})
+const loading = ref(false)
 
-const selectedRowKeys = ref([])
 // eslint-disable-next-line ts/no-explicit-any
-function showModal(c: any, rowKeys: any) {
+function showModal(c: Column[], rowKeys: (number | string)[], rows: any[]) {
+  data.value = {}
   visible.value = true
   selectedRowKeys.value = rowKeys
   batchColumns.value = c
+  selectedRows.value = rows
 }
 
 defineExpose({
   showModal,
 })
 
-const data = reactive({})
-const error = reactive({})
-const loading = ref(false)
-
 async function ok() {
   loading.value = true
 
   await props.beforeSave?.()
 
-  await props.api(selectedRowKeys.value, data).then(async () => {
-    message.success($gettext('Save successfully'))
-    emit('onSave')
-  }).catch(e => {
-    message.error($gettext(e?.message) ?? $gettext('Server error'))
-  }).finally(() => {
-    loading.value = false
-  })
+  await props.api.batch_save(selectedRowKeys.value, data.value)
+    .then(async () => {
+      message.success($gettext('Save successfully'))
+      emit('save')
+      visible.value = false
+    })
+    .catch(e => {
+      error.value = e.errors
+      message.error($gettext(e?.message) ?? $gettext('Server error'))
+    })
+    .finally(() => {
+      loading.value = false
+    })
 }
 </script>
 
@@ -52,23 +64,31 @@ async function ok() {
     class="std-curd-edit-modal"
     :mask="false"
     :title="$gettext('Batch Modify')"
-    :cancel-text="$gettext('Cancel')"
-    :ok-text="$gettext('OK')"
+    :cancel-text="$gettext('No')"
+    :ok-text="$gettext('Save')"
     :confirm-loading="loading"
     :width="600"
     destroy-on-close
     @ok="ok"
   >
+    <p>{{ $gettext('Belows are selected items that you want to batch modify') }}</p>
+    <ATable
+      class="mb-4"
+      size="small"
+      :columns="getPithyColumns(columns)"
+      :data-source="selectedRows"
+      :pagination="{ showSizeChanger: false, pageSize: 5, size: 'small' }"
+    />
+
+    <p>{{ $gettext('Leave blank if do not want to modify') }}</p>
     <StdDataEntry
       :data-list="batchColumns"
       :data-source="data"
-      :error="error"
+      :errors="error"
     />
 
     <slot name="extra" />
   </AModal>
 </template>
 
-<style scoped>
-
-</style>
+<style scoped></style>

+ 43 - 28
app/src/components/StdDesign/StdDataDisplay/StdCurd.vue

@@ -1,8 +1,8 @@
 <script setup lang="ts" generic="T=any">
-import type { StdTableSlots } from '@/components/StdDesign/StdDataDisplay/types'
 import type { Column } from '@/components/StdDesign/types'
-import type { ComputedRef } from 'vue'
+import type { ComputedRef, Ref } from 'vue'
 import type { StdTableProps } from './StdTable.vue'
+import StdBatchEdit from '@/components/StdDesign/StdDataDisplay/StdBatchEdit.vue'
 import StdCurdDetail from '@/components/StdDesign/StdDataDisplay/StdCurdDetail.vue'
 import StdDataEntry from '@/components/StdDesign/StdDataEntry'
 import { message } from 'ant-design-vue'
@@ -14,7 +14,7 @@ export interface StdCurdProps<T> extends StdTableProps<T> {
   modalMask?: boolean
   exportExcel?: boolean
   importExcel?: boolean
-  disableTrash?: boolean
+
   disableAdd?: boolean
   onClickAdd?: () => void
 
@@ -24,6 +24,10 @@ export interface StdCurdProps<T> extends StdTableProps<T> {
 }
 
 const props = defineProps<StdTableProps<T> & StdCurdProps<T>>()
+
+const selectedRowKeys = ref<(string | number)[]>([])
+const selectedRows: Ref<T[]> = ref([])
+
 const visible = ref(false)
 // eslint-disable-next-line ts/no-explicit-any
 const data: any = reactive({ id: null })
@@ -61,24 +65,13 @@ function add(preset: any = undefined) {
   if (preset)
     Object.assign(data, preset)
 
-  clear_error()
+  clearError()
   visible.value = true
   editMode.value = 'create'
   modifyMode.value = true
 }
 
-const table = ref()
-
-// eslint-disable-next-line ts/no-explicit-any
-const selectedRowKeys = defineModel<any[]>('selectedRowKeys', {
-  default: () => [],
-})
-
-// eslint-disable-next-line ts/no-explicit-any
-const selectedRows = defineModel<any[]>('selectedRows', {
-  type: Array,
-  default: () => [],
-})
+const table = useTemplateRef('table')
 
 const getParams = reactive({
   trash: false,
@@ -101,20 +94,23 @@ defineExpose({
   setParams,
 })
 
-function clear_error() {
+function clearError() {
   Object.keys(error).forEach(v => {
     delete error[v]
   })
 }
 
-const stdEntryRef = ref()
+const stdEntryRef = useTemplateRef('stdEntryRef')
 
 async function ok() {
+  if (!stdEntryRef.value)
+    return
+
   const { formRef } = stdEntryRef.value
 
-  clear_error()
+  clearError()
   try {
-    await formRef.validateFields()
+    await formRef?.validateFields()
     props?.beforeSave?.(data)
     props
       .api!.save(data.id, { ...data, ...props.overwriteParams }, { params: { ...props.overwriteParams } }).then(r => {
@@ -135,7 +131,7 @@ async function ok() {
 function cancel() {
   visible.value = false
 
-  clear_error()
+  clearError()
 
   if (shouldRefetchList.value) {
     get_list()
@@ -159,7 +155,6 @@ function view(id: number | string) {
   get(id).then(() => {
     visible.value = true
     modifyMode.value = false
-    editMode.value = 'modify'
   }).catch(e => {
     message.error($gettext(e?.message ?? 'Server error'), 5)
   })
@@ -183,6 +178,17 @@ const modalTitle = computed(() => {
 })
 
 const localOverwriteParams = reactive(props.overwriteParams ?? {})
+
+const stdBatchEditRef = useTemplateRef('stdBatchEditRef')
+
+async function handleClickBatchEdit(batchColumns: Column[]) {
+  stdBatchEditRef.value?.showModal(batchColumns, selectedRowKeys.value, selectedRows.value)
+}
+
+function handleBatchUpdated() {
+  table.value?.get_list()
+  table.value?.resetSelection()
+}
 </script>
 
 <template>
@@ -202,7 +208,7 @@ const localOverwriteParams = reactive(props.overwriteParams ?? {})
             @click="add"
           >{{ $gettext('Add') }}</a>
           <slot name="extra" />
-          <template v-if="!disableDelete && !disableTrash">
+          <template v-if="!disableDelete">
             <a
               v-if="!getParams.trash"
               @click="getParams.trash = true"
@@ -219,21 +225,23 @@ const localOverwriteParams = reactive(props.overwriteParams ?? {})
         </ASpace>
       </template>
 
+      <slot name="beforeTable" />
       <StdTable
         ref="table"
-        v-model:selected-row-keys="selectedRowKeys"
-        v-model:selected-rows="selectedRows"
         v-bind="{
           ...props,
           getParams,
           overwriteParams: localOverwriteParams,
         }"
+        v-model:selected-row-keys="selectedRowKeys"
+        v-model:selected-rows="selectedRows"
         @click-edit="edit"
         @click-view="view"
         @selected="onSelect"
+        @click-batch-modify="handleClickBatchEdit"
       >
         <template
-          v-for="(_, key) in ($slots as unknown as StdTableSlots)"
+          v-for="(_, key) in $slots"
           :key="key"
           #[key]="slotProps"
         >
@@ -295,10 +303,17 @@ const localOverwriteParams = reactive(props.overwriteParams ?? {})
 
       <StdCurdDetail
         v-else
-        :columns="columns"
-        :data="data"
+        :columns
+        :data
       />
     </AModal>
+
+    <StdBatchEdit
+      ref="stdBatchEditRef"
+      :api
+      :columns
+      @save="handleBatchUpdated"
+    />
   </div>
 </template>
 

+ 70 - 70
app/src/components/StdDesign/StdDataDisplay/StdTable.vue

@@ -8,9 +8,11 @@ import type { FilterValue } from 'ant-design-vue/es/table/interface'
 import type { SorterResult, TablePaginationConfig } from 'ant-design-vue/lib/table/interface'
 import type { ComputedRef, Ref } from 'vue'
 import type { RouteParams } from 'vue-router'
+import { getPithyColumns } from '@/components/StdDesign/StdDataDisplay/methods/columns'
 import useSortable from '@/components/StdDesign/StdDataDisplay/methods/sortable'
 import StdDataEntry from '@/components/StdDesign/StdDataEntry'
 import { HolderOutlined } from '@ant-design/icons-vue'
+import { watchPausable } from '@vueuse/core'
 import { message } from 'ant-design-vue'
 import _ from 'lodash'
 import StdPagination from './StdPagination.vue'
@@ -20,6 +22,7 @@ export interface StdTableProps<T = any> {
   title?: string
   mode?: string
   rowKey?: string
+
   api: Curd<T>
   columns: Column[]
   // eslint-disable-next-line ts/no-explicit-any
@@ -48,7 +51,20 @@ const props = withDefaults(defineProps<StdTableProps<T>>(), {
   rowKey: 'id',
 })
 
-const emit = defineEmits(['clickEdit', 'clickView', 'clickBatchModify', 'update:selectedRowKeys'])
+const emit = defineEmits([
+  'clickEdit',
+  'clickView',
+  'clickBatchModify',
+])
+
+const selectedRowKeys = defineModel<(number | string)[]>('selectedRowKeys', {
+  default: () => reactive([]),
+})
+
+const selectedRows = defineModel<T[]>('selectedRows', {
+  default: () => reactive([]),
+})
+
 const route = useRoute()
 
 const dataSource: Ref<T[]> = ref([])
@@ -93,17 +109,6 @@ const params = reactive({
   ...props.getParams,
 })
 
-// eslint-disable-next-line ts/no-explicit-any
-const selectedRowKeys = defineModel<any[]>('selectedRowKeys', {
-  default: () => [],
-})
-
-// eslint-disable-next-line ts/no-explicit-any
-const selectedRows = defineModel<any[]>('selectedRows', {
-  type: Array,
-  default: () => [],
-})
-
 onMounted(() => {
   selectedRows.value.forEach(v => {
     selectedRecords.value[v[props.rowKey]] = v
@@ -122,7 +127,9 @@ const searchColumns = computed(() => {
         })
       }
 
-      else { _searchColumns.push({ ...column }) }
+      else {
+        _searchColumns.push({ ...column })
+      }
     }
   })
 
@@ -130,11 +137,8 @@ const searchColumns = computed(() => {
 })
 
 const pithyColumns = computed<Column[]>(() => {
-  if (props.pithy) {
-    return props.columns?.filter(c => {
-      return c.pithy === true && !c.hiddenInTable
-    })
-  }
+  if (props.pithy)
+    return getPithyColumns(props.columns)
 
   return props.columns?.filter(c => {
     return !c.hiddenInTable
@@ -142,19 +146,12 @@ const pithyColumns = computed<Column[]>(() => {
 })
 
 const batchColumns = computed(() => {
-  const batch: Column[] = []
-
-  props.columns?.forEach(column => {
-    if (column.batch)
-      batch.push(column)
-  })
-
-  return batch
+  return props.columns?.filter(column => column.batch) || []
 })
 
 const get_list = _.debounce(_get_list, 100, {
-  leading: false,
-  trailing: true,
+  leading: true,
+  trailing: false,
 })
 
 const filterParams = reactive({})
@@ -184,15 +181,13 @@ onMounted(() => {
   if (props.sortable)
     initSortable()
 
-  if (!selectedRowKeys.value?.length)
-    selectedRowKeys.value = []
-
   init.value = true
 })
 
 defineExpose({
   get_list,
   pagination,
+  resetSelection,
 })
 
 function destroy(id: number | string) {
@@ -229,13 +224,17 @@ function buildIndexMap(data: any, level: number = 0, index: number = 0, total: n
   }
 }
 
-async function _get_list(page_num = null, page_size = 20) {
+async function _get_list(page_num: number | null = null, page_size = 20) {
   dataSource.value = []
   loading.value = true
   if (page_num) {
     params.page = page_num
     params.page_size = page_size
   }
+  else {
+    params.page = 1
+    params.page_size = page_size
+  }
   props.api?.get_list({ ...params, ...props.overwriteParams }).then(async r => {
     dataSource.value = r.data
     rowsKeyIndexMap.value = {}
@@ -245,9 +244,7 @@ async function _get_list(page_num = null, page_size = 20) {
     if (r.pagination)
       Object.assign(pagination, r.pagination)
 
-    setTimeout(() => {
-      loading.value = false
-    }, 200)
+    loading.value = false
   }).catch(e => {
     message.error(e?.message ?? $gettext('Server error'))
   })
@@ -288,7 +285,8 @@ function expandedTable(keys: Key[]) {
 
 // eslint-disable-next-line ts/no-explicit-any
 async function onSelect(record: any, selected: boolean, _selectedRows: any[]) {
-  if (props.selectionType === 'checkbox' || props.exportExcel) {
+  // console.log('onSelect', record, selected, _selectedRows)
+  if (props.selectionType === 'checkbox' || batchColumns.value.length > 0 || props.exportExcel) {
     if (selected) {
       _selectedRows.forEach(v => {
         if (v) {
@@ -300,20 +298,11 @@ async function onSelect(record: any, selected: boolean, _selectedRows: any[]) {
       })
     }
     else {
-      // eslint-disable-next-line ts/no-explicit-any
-      selectedRowKeys.value = selectedRowKeys.value.filter((v: any) => v !== record[props.rowKey])
+      selectedRowKeys.value.splice(selectedRowKeys.value.indexOf(record[props.rowKey]), 1)
       delete selectedRecords.value[record[props.rowKey]]
     }
-
-    await nextTick(async () => {
-      // eslint-disable-next-line ts/no-explicit-any
-      const filteredRows: any[] = []
-
-      selectedRowKeys.value.forEach(v => {
-        filteredRows.push(selectedRecords.value[v])
-      })
-      selectedRows.value = filteredRows
-    })
+    await nextTick()
+    selectedRows.value = [...selectedRowKeys.value.map(v => selectedRecords.value[v])]
   }
   else if (selected) {
     selectedRowKeys.value = record[props.rowKey]
@@ -327,7 +316,7 @@ async function onSelect(record: any, selected: boolean, _selectedRows: any[]) {
 
 // eslint-disable-next-line ts/no-explicit-any
 async function onSelectAll(selected: boolean, _selectedRows: any[], changeRows: any[]) {
-  // console.log(selected, selectedRows, changeRows)
+  // console.log('onSelectAll', selected, selectedRows, changeRows)
   // eslint-disable-next-line ts/no-explicit-any
   changeRows.forEach((v: any) => {
     if (v) {
@@ -342,22 +331,19 @@ async function onSelectAll(selected: boolean, _selectedRows: any[], changeRows:
   })
 
   if (!selected) {
-    selectedRowKeys.value = selectedRowKeys.value.filter(v => {
-      return selectedRecords.value[v]
-    })
+    selectedRowKeys.value.splice(0, selectedRowKeys.value.length, ...selectedRowKeys.value.filter(v => selectedRecords.value[v]))
   }
 
   // console.log(selectedRowKeysBuffer.value, selectedRecords.value)
 
-  await nextTick(async () => {
-    // eslint-disable-next-line ts/no-explicit-any
-    const filteredRows: any[] = []
+  await nextTick()
+  selectedRows.value.splice(0, selectedRows.value.length, ...selectedRowKeys.value.map(v => selectedRecords.value[v]))
+}
 
-    selectedRowKeys.value.forEach(v => {
-      filteredRows.push(selectedRecords.value[v])
-    })
-    selectedRows.value = filteredRows
-  })
+function resetSelection() {
+  selectedRowKeys.value = reactive([])
+  selectedRows.value = reactive([])
+  selectedRecords.value = reactive({})
 }
 
 const router = useRouter()
@@ -381,7 +367,7 @@ async function resetSearch() {
   updateFilter.value++
 }
 
-watch(params, v => {
+const { stop: stopWatchParams, resume: resumeWatchParams } = watchPausable(params, v => {
   if (!init.value)
     return
 
@@ -393,7 +379,6 @@ watch(params, v => {
 
 watch(() => route.query, async () => {
   params.trash = route.query.trash === 'true'
-  params.team_id = route.query.team_id
 
   if (init.value)
     await get_list()
@@ -425,14 +410,16 @@ if (props.overwriteParams) {
 const rowSelection = computed(() => {
   if (batchColumns.value.length > 0 || props.selectionType || props.exportExcel) {
     return {
-      selectedRowKeys: selectedRowKeys.value,
+      selectedRowKeys: unref(selectedRowKeys),
       onSelect,
       onSelectAll,
       getCheckboxProps: props?.getCheckboxProps,
       type: (batchColumns.value.length > 0 || props.exportExcel) ? 'checkbox' : props.selectionType,
     }
   }
-  else { return null }
+  else {
+    return null
+  }
 }) as ComputedRef<TableProps['rowSelection']>
 
 const hasSelectedRow = computed(() => {
@@ -440,18 +427,21 @@ const hasSelectedRow = computed(() => {
 })
 
 function clickBatchEdit() {
-  emit('clickBatchModify', batchColumns.value, selectedRowKeys.value)
+  emit('clickBatchModify', batchColumns.value, selectedRowKeys.value, selectedRows.value)
 }
 
 function initSortable() {
   useSortable(props, randomId, dataSource, rowsKeyIndexMap, expandKeysList)
 }
 
-function changePage(page: number, page_size: number) {
+async function changePage(page: number, page_size: number) {
+  stopWatchParams()
   Object.assign(params, {
     page,
     page_size,
   })
+  resumeWatchParams()
+  await get_list(page, page_size)
 }
 
 const paginationSize = computed(() => {
@@ -529,10 +519,7 @@ const paginationSize = computed(() => {
             >
               {{ $gettext('Modify') }}
             </AButton>
-            <ADivider
-              v-if="!props.disableDelete"
-              type="vertical"
-            />
+            <ADivider type="vertical" />
           </template>
 
           <slot
@@ -569,6 +556,7 @@ const paginationSize = computed(() => {
                 {{ $gettext('Recover') }}
               </AButton>
             </APopconfirm>
+            <ADivider type="vertical" />
             <APopconfirm
               v-if="params.trash"
               :cancel-text="$gettext('No')"
@@ -601,8 +589,14 @@ const paginationSize = computed(() => {
 .ant-table-scroll {
   .ant-table-body {
     overflow-x: auto !important;
+    overflow-y: hidden !important;
   }
 }
+
+.std-table {
+  overflow-x: hidden !important;
+  overflow-y: hidden !important;
+}
 </style>
 
 <style lang="less" scoped>
@@ -624,6 +618,12 @@ const paginationSize = computed(() => {
 :deep(.ant-form-inline .ant-form-item) {
   margin-bottom: 10px;
 }
+
+.ant-divider {
+  &:last-child {
+    display: none;
+  }
+}
 </style>
 
 <style lang="less">

+ 7 - 0
app/src/components/StdDesign/StdDataDisplay/methods/columns.ts

@@ -0,0 +1,7 @@
+import type { Column } from '@/components/StdDesign/types'
+
+export function getPithyColumns(columns: Column[]) {
+  return columns.filter(c => {
+    return c.pithy === true && !c.hiddenInTable
+  })
+}

+ 1 - 0
app/src/views/site/SiteEdit.vue

@@ -135,6 +135,7 @@ async function save() {
     content: configText.value,
     overwrite: true,
     site_category_id: data.value.site_category_id,
+    sync_node_ids: data.value.sync_node_ids,
   }).then(r => {
     handle_response(r)
     router.push({

+ 6 - 96
app/src/views/site/components/Deploy.vue

@@ -1,107 +1,17 @@
 <script setup lang="ts">
-import type { Ref } from 'vue'
-import domain from '@/api/domain'
 import NodeSelector from '@/components/NodeSelector/NodeSelector.vue'
-import { InfoCircleOutlined } from '@ant-design/icons-vue'
-import { Modal, notification } from 'ant-design-vue'
 
 const node_map = ref({})
 const target = ref([])
-const overwrite = ref(false)
-const enabled = ref(false)
-const name = inject('name') as Ref<string>
-const [modal, ContextHolder] = Modal.useModal()
-function deploy() {
-  modal.confirm({
-    title: () => $ngettext('Do you want to deploy this file to remote server?', 'Do you want to deploy this file to remote servers?', target.value.length),
-    mask: false,
-    centered: true,
-    okText: $gettext('OK'),
-    cancelText: $gettext('Cancel'),
-    onOk() {
-      target.value.forEach(id => {
-        const node_name = node_map.value[id]
-
-        // get source content
-        domain.get(name.value).then(r => {
-          domain.save(name.value, {
-            name: name.value,
-            content: r.config,
-            overwrite: overwrite.value,
-
-          }, { headers: { 'X-Node-ID': id } }).then(async () => {
-            notification.success({
-              message: $gettext('Deploy successfully'),
-              description:
-                $gettext('Deploy %{conf_name} to %{node_name} successfully', { conf_name: name.value, node_name }),
-            })
-            if (enabled.value) {
-              domain.enable(name.value, { headers: { 'X-Node-ID': id } }).then(() => {
-                notification.success({
-                  message: $gettext('Enable successfully'),
-                  description:
-                    $gettext('Enable %{conf_name} in %{node_name} successfully', { conf_name: name.value, node_name }),
-                })
-              }).catch(e => {
-                notification.error({
-                  message: $gettext('Enable %{conf_name} in %{node_name} failed', {
-                    conf_name: name.value,
-                    node_name,
-                  }),
-                  description: $gettext(e?.message ?? 'Server error'),
-                })
-              })
-            }
-          }).catch(e => {
-            notification.error({
-              message: $gettext('Deploy %{conf_name} to %{node_name} failed', {
-                conf_name: name.value,
-                node_name,
-              }),
-              description: $gettext(e?.message ?? 'Server error'),
-            })
-          })
-        })
-      })
-    },
-  })
-}
 </script>
 
 <template>
-  <div>
-    <ContextHolder />
-    <NodeSelector
-      v-model:target="target"
-      v-model:map="node_map"
-      hidden-local
-    />
-    <div class="node-deploy-control">
-      <ACheckbox v-model:checked="enabled">
-        {{ $gettext('Enable') }}
-      </ACheckbox>
-      <div class="overwrite">
-        <ACheckbox v-model:checked="overwrite">
-          {{ $gettext('Overwrite') }}
-        </ACheckbox>
-        <ATooltip placement="bottom">
-          <template #title>
-            {{ $gettext('Overwrite exist file') }}
-          </template>
-          <InfoCircleOutlined />
-        </ATooltip>
-      </div>
-
-      <AButton
-        :disabled="target.length === 0"
-        type="primary"
-        ghost
-        @click="deploy"
-      >
-        {{ $gettext('Deploy') }}
-      </AButton>
-    </div>
-  </div>
+  <NodeSelector
+    v-model:target="target"
+    v-model:map="node_map"
+    class="mb-4"
+    hidden-local
+  />
 </template>
 
 <style scoped lang="less">

+ 29 - 3
app/src/views/site/components/RightSettings.vue

@@ -6,11 +6,12 @@ import type { Ref } from 'vue'
 import domain from '@/api/domain'
 import site_category from '@/api/site_category'
 import ChatGPT from '@/components/ChatGPT/ChatGPT.vue'
+import NodeSelector from '@/components/NodeSelector/NodeSelector.vue'
 import StdSelector from '@/components/StdDesign/StdDataEntry/components/StdSelector.vue'
 import { formatDateTime } from '@/lib/helper'
 import { useSettingsStore } from '@/pinia'
-import Deploy from '@/views/site/components/Deploy.vue'
 import siteCategoryColumns from '@/views/site/site_category/columns'
+import { InfoCircleOutlined } from '@ant-design/icons-vue'
 import { message, Modal } from 'ant-design-vue'
 
 const settings = useSettingsStore()
@@ -71,6 +72,7 @@ function on_change_enabled(checked: CheckedType) {
     <ACollapse
       v-model:active-key="active_key"
       ghost
+      collapsible="header"
     >
       <ACollapsePanel
         key="1"
@@ -103,9 +105,33 @@ function on_change_enabled(checked: CheckedType) {
       <ACollapsePanel
         v-if="!settings.is_remote"
         key="2"
-        :header="$gettext('Deploy')"
       >
-        <Deploy />
+        <template #header>
+          {{ $gettext('Synchronization') }}
+        </template>
+        <template #extra>
+          <APopover placement="bottomRight" :title="$gettext('Sync strategy')">
+            <template #content>
+              <div class="max-w-200px mb-2">
+                {{ $gettext('When you enable/disable, delete, or save this site, '
+                  + 'the nodes set in the site category and the nodes selected below will be synchronized.') }}
+              </div>
+              <div class="max-w-200px">
+                {{ $gettext('Note, if the configuration file include other configurations or certificates, '
+                  + 'please synchronize them to the remote nodes in advance.') }}
+              </div>
+            </template>
+            <div class="text-trueGray-600">
+              <InfoCircleOutlined class="mr-1" />
+              {{ $gettext('Sync strategy') }}
+            </div>
+          </APopover>
+        </template>
+        <NodeSelector
+          v-model:target="data.sync_node_ids"
+          class="mb-4"
+          hidden-local
+        />
       </ACollapsePanel>
       <ACollapsePanel
         key="3"

+ 21 - 1
app/src/views/site/site_list/SiteList.vue

@@ -1,5 +1,7 @@
 <script setup lang="tsx">
+import type { Site } from '@/api/domain'
 import type { SiteCategory } from '@/api/site_category'
+import type { Column } from '@/components/StdDesign/types'
 import domain from '@/api/domain'
 import site_category from '@/api/site_category'
 import StdTable from '@/components/StdDesign/StdDataDisplay/StdTable.vue'
@@ -7,6 +9,7 @@ import InspectConfig from '@/views/config/InspectConfig.vue'
 import SiteDuplicate from '@/views/site/components/SiteDuplicate.vue'
 import columns from '@/views/site/site_list/columns'
 import { message } from 'ant-design-vue'
+import StdBatchEdit from '../../../components/StdDesign/StdDataDisplay/StdBatchEdit.vue'
 
 const route = useRoute()
 const router = useRouter()
@@ -77,6 +80,17 @@ function handle_click_duplicate(name: string) {
   show_duplicator.value = true
   target.value = name
 }
+
+const stdBatchEditRef = useTemplateRef('stdBatchEditRef')
+
+async function handleClickBatchEdit(batchColumns: Column[], selectedRowKeys: string[], selectedRows: Site[]) {
+  stdBatchEditRef.value?.showModal(batchColumns, selectedRowKeys, selectedRows)
+}
+
+function handleBatchUpdated() {
+  table.value?.get_list()
+  table.value?.resetSelection()
+}
 </script>
 
 <template>
@@ -101,9 +115,9 @@ function handle_click_duplicate(name: string) {
       @click-edit="(r: string) => router.push({
         path: `/sites/${r}`,
       })"
+      @click-batch-modify="handleClickBatchEdit"
     >
       <template #actions="{ record }">
-        <ADivider type="vertical" />
         <AButton
           v-if="record.enabled"
           type="link"
@@ -146,6 +160,12 @@ function handle_click_duplicate(name: string) {
         </APopconfirm>
       </template>
     </StdTable>
+    <StdBatchEdit
+      ref="stdBatchEditRef"
+      :api="domain"
+      :columns
+      @save="handleBatchUpdated"
+    />
     <SiteDuplicate
       v-model:visible="show_duplicator"
       :name="target"

+ 13 - 1
app/src/views/site/site_list/columns.tsx

@@ -1,10 +1,12 @@
 import type { Column, JSXElements } from '@/components/StdDesign/types'
+import site_category from '@/api/site_category'
 import {
   actualValueRender,
   type CustomRenderProps,
   datetime,
 } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
-import { input, select } from '@/components/StdDesign/StdDataEntry'
+import { input, select, selector } from '@/components/StdDesign/StdDataEntry'
+import siteCategoryColumns from '@/views/site/site_category/columns'
 import { Badge } from 'ant-design-vue'
 
 const columns: Column[] = [{
@@ -20,8 +22,18 @@ const columns: Column[] = [{
   title: () => $gettext('Category'),
   dataIndex: 'site_category_id',
   customRender: actualValueRender('site_category.name'),
+  edit: {
+    type: selector,
+    selector: {
+      api: site_category,
+      columns: siteCategoryColumns,
+      recordValueIndex: 'name',
+      selectionType: 'radio',
+    },
+  },
   sorter: true,
   pithy: true,
+  batch: true,
 }, {
   title: () => $gettext('Status'),
   dataIndex: 'enabled',

+ 0 - 1
app/src/views/stream/StreamList.vue

@@ -135,7 +135,6 @@ function handleAddStream() {
       })"
     >
       <template #actions="{ record }">
-        <ADivider type="vertical" />
         <AButton
           v-if="record.enabled"
           type="link"

+ 4 - 3
cmd/generate/generate.go

@@ -4,7 +4,8 @@ import (
 	"flag"
 	"fmt"
 	"github.com/0xJacky/Nginx-UI/model"
-	"github.com/uozi-tech/cosy/settings"
+	"github.com/0xJacky/Nginx-UI/settings"
+	cSettings "github.com/uozi-tech/cosy/settings"
 	"gorm.io/driver/sqlite"
 	"gorm.io/gen"
 	"gorm.io/gorm"
@@ -39,8 +40,8 @@ func main() {
 	flag.StringVar(&confPath, "config", "app.ini", "Specify the configuration file")
 	flag.Parse()
 
-	settings.Init(confPath)
-	dbPath := path.Join(path.Dir(confPath), fmt.Sprintf("%s.db", settings.DataBaseSettings.Name))
+	cSettings.Init(confPath)
+	dbPath := path.Join(path.Dir(confPath), fmt.Sprintf("%s.db", settings.DatabaseSettings.Name))
 
 	var err error
 	db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{

+ 2 - 1
model/site.go

@@ -2,8 +2,9 @@ package model
 
 type Site struct {
 	Model
-	Path           string        `json:"path"`
+	Path           string        `json:"path" gorm:"uniqueIndex"`
 	Advanced       bool          `json:"advanced"`
 	SiteCategoryID uint64        `json:"site_category_id"`
 	SiteCategory   *SiteCategory `json:"site_category,omitempty"`
+	SyncNodeIDs    []uint64      `json:"sync_node_ids" gorm:"serializer:json"`
 }

+ 5 - 1
query/sites.gen.go

@@ -35,6 +35,7 @@ func newSite(db *gorm.DB, opts ...gen.DOOption) site {
 	_site.Path = field.NewString(tableName, "path")
 	_site.Advanced = field.NewBool(tableName, "advanced")
 	_site.SiteCategoryID = field.NewUint64(tableName, "site_category_id")
+	_site.SyncNodeIDs = field.NewField(tableName, "sync_node_ids")
 	_site.SiteCategory = siteBelongsToSiteCategory{
 		db: db.Session(&gorm.Session{}),
 
@@ -57,6 +58,7 @@ type site struct {
 	Path           field.String
 	Advanced       field.Bool
 	SiteCategoryID field.Uint64
+	SyncNodeIDs    field.Field
 	SiteCategory   siteBelongsToSiteCategory
 
 	fieldMap map[string]field.Expr
@@ -81,6 +83,7 @@ func (s *site) updateTableName(table string) *site {
 	s.Path = field.NewString(table, "path")
 	s.Advanced = field.NewBool(table, "advanced")
 	s.SiteCategoryID = field.NewUint64(table, "site_category_id")
+	s.SyncNodeIDs = field.NewField(table, "sync_node_ids")
 
 	s.fillFieldMap()
 
@@ -97,7 +100,7 @@ func (s *site) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
 }
 
 func (s *site) fillFieldMap() {
-	s.fieldMap = make(map[string]field.Expr, 8)
+	s.fieldMap = make(map[string]field.Expr, 9)
 	s.fieldMap["id"] = s.ID
 	s.fieldMap["created_at"] = s.CreatedAt
 	s.fieldMap["updated_at"] = s.UpdatedAt
@@ -105,6 +108,7 @@ func (s *site) fillFieldMap() {
 	s.fieldMap["path"] = s.Path
 	s.fieldMap["advanced"] = s.Advanced
 	s.fieldMap["site_category_id"] = s.SiteCategoryID
+	s.fieldMap["sync_node_ids"] = s.SyncNodeIDs
 
 }