Prechádzať zdrojové kódy

fix(table): sorter invalid

Jacky 5 mesiacov pred
rodič
commit
902aa28746
32 zmenil súbory, kde vykonal 811 pridanie a 405 odobranie
  1. 2 2
      api/sites/list.go
  2. 5 0
      app/components.d.ts
  3. 3 3
      app/src/components/Notification/Notification.vue
  4. 2 2
      app/src/components/Notification/detailRender.ts
  5. 110 0
      app/src/components/StdDesign/StdDataDisplay/StdBulkActions.vue
  6. 34 50
      app/src/components/StdDesign/StdDataDisplay/StdCurd.vue
  7. 8 4
      app/src/components/StdDesign/StdDataDisplay/StdPagination.vue
  8. 132 137
      app/src/components/StdDesign/StdDataDisplay/StdTable.vue
  9. 86 24
      app/src/components/StdDesign/StdDataDisplay/StdTableTransformer.tsx
  10. 3 2
      app/src/components/StdDesign/StdDataDisplay/components/CustomRender.tsx
  11. 1 5
      app/src/components/StdDesign/StdDataDisplay/index.ts
  12. 4 6
      app/src/components/StdDesign/StdDataDisplay/methods/exportCsv.ts
  13. 98 104
      app/src/components/StdDesign/StdDataDisplay/methods/sortable.ts
  14. 48 4
      app/src/components/StdDesign/StdDataDisplay/types.d.ts
  15. 3 6
      app/src/components/StdDesign/StdDataEntry/components/StdSelect.vue
  16. 2 0
      app/src/components/StdDesign/StdDataEntry/components/StdSelector.vue
  17. 20 13
      app/src/components/StdDesign/StdDataEntry/index.tsx
  18. 5 5
      app/src/components/StdDesign/StdDataEntry/style.less
  19. 25 0
      app/src/components/StdDesign/StdDataEntry/types.d.ts
  20. 141 0
      app/src/components/StdDesign/StdDetail/StdDetail.vue
  21. 53 14
      app/src/components/StdDesign/types.d.ts
  22. 2 2
      app/src/views/certificate/ACMEUser.vue
  23. 4 4
      app/src/views/certificate/CertificateList/certColumns.tsx
  24. 2 2
      app/src/views/certificate/DNSCredential.vue
  25. 2 2
      app/src/views/config/configColumns.ts
  26. 3 3
      app/src/views/environment/envColumns.tsx
  27. 3 3
      app/src/views/notification/notificationColumns.tsx
  28. 2 2
      app/src/views/preference/AuthSettings.vue
  29. 2 2
      app/src/views/site/site_list/columns.tsx
  30. 2 2
      app/src/views/stream/StreamList.vue
  31. 2 2
      app/src/views/user/userColumns.tsx
  32. 2 0
      internal/config/config_list.go

+ 2 - 2
api/sites/list.go

@@ -18,8 +18,8 @@ import (
 func GetSiteList(c *gin.Context) {
 	name := c.Query("name")
 	enabled := c.Query("enabled")
-	orderBy := c.Query("order_by")
-	sort := c.DefaultQuery("sort", "desc")
+	orderBy := c.Query("sort_by")
+	sort := c.DefaultQuery("order", "desc")
 	querySiteCategoryId := cast.ToUint64(c.Query("site_category_id"))
 
 	configFiles, err := os.ReadDir(nginx.GetConfPath("sites-available"))

+ 5 - 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']
@@ -86,6 +88,7 @@ declare module 'vue' {
     SensitiveStringSensitiveString: typeof import('./src/components/SensitiveString/SensitiveString.vue')['default']
     SetLanguageSetLanguage: typeof import('./src/components/SetLanguage/SetLanguage.vue')['default']
     StdDesignStdDataDisplayStdBatchEdit: typeof import('./src/components/StdDesign/StdDataDisplay/StdBatchEdit.vue')['default']
+    StdDesignStdDataDisplayStdBulkActions: typeof import('./src/components/StdDesign/StdDataDisplay/StdBulkActions.vue')['default']
     StdDesignStdDataDisplayStdCurd: typeof import('./src/components/StdDesign/StdDataDisplay/StdCurd.vue')['default']
     StdDesignStdDataDisplayStdCurdDetail: typeof import('./src/components/StdDesign/StdDataDisplay/StdCurdDetail.vue')['default']
     StdDesignStdDataDisplayStdPagination: typeof import('./src/components/StdDesign/StdDataDisplay/StdPagination.vue')['default']
@@ -95,6 +98,8 @@ declare module 'vue' {
     StdDesignStdDataEntryComponentsStdSelector: typeof import('./src/components/StdDesign/StdDataEntry/components/StdSelector.vue')['default']
     StdDesignStdDataEntryStdDataEntry: typeof import('./src/components/StdDesign/StdDataEntry/StdDataEntry.vue')['default']
     StdDesignStdDataEntryStdFormItem: typeof import('./src/components/StdDesign/StdDataEntry/StdFormItem.vue')['default']
+    StdDesignStdDataImportStdDataImport: typeof import('./src/components/StdDesign/StdDataImport/StdDataImport.vue')['default']
+    StdDesignStdDetailStdDetail: typeof import('./src/components/StdDesign/StdDetail/StdDetail.vue')['default']
     SwitchAppearanceIconsVPIconMoon: typeof import('./src/components/SwitchAppearance/icons/VPIconMoon.vue')['default']
     SwitchAppearanceIconsVPIconSun: typeof import('./src/components/SwitchAppearance/icons/VPIconSun.vue')['default']
     SwitchAppearanceSwitchAppearance: typeof import('./src/components/SwitchAppearance/SwitchAppearance.vue')['default']

+ 3 - 3
app/src/components/Notification/Notification.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
 import type { Notification } from '@/api/notification'
-import type { CustomRenderProps } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
+import type { CustomRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
 import type { SSEvent } from 'sse.js'
 import type { Ref } from 'vue'
 import notificationApi from '@/api/notification'
@@ -52,7 +52,7 @@ function newSSE() {
 
     notification[typeTrans[data.type]]({
       message: $gettext(data.title),
-      description: detailRender({ text: data.details, record: data } as CustomRenderProps),
+      description: detailRender({ text: data.details, record: data } as CustomRender),
     })
   }
 
@@ -161,7 +161,7 @@ function viewAll() {
               </template>
               <AListItemMeta
                 :title="$gettext(item.title)"
-                :description="detailRender({ text: item.details, record: item } as CustomRenderProps)"
+                :description="detailRender({ text: item.details, record: item } as CustomRender)"
               >
                 <template #avatar>
                   <div>

+ 2 - 2
app/src/components/Notification/detailRender.ts

@@ -1,4 +1,4 @@
-import type { CustomRenderProps } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
+import type { CustomRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
 import { syncCertificateError, syncCertificateSuccess } from '@/components/Notification/cert'
 import {
   deleteSiteError,
@@ -17,7 +17,7 @@ import {
   syncRenameConfigSuccess,
 } from '@/components/Notification/config'
 
-export function detailRender(args: CustomRenderProps) {
+export function detailRender(args: CustomRender) {
   try {
     switch (args.record.title) {
       case 'Sync Certificate Success':

+ 110 - 0
app/src/components/StdDesign/StdDataDisplay/StdBulkActions.vue

@@ -0,0 +1,110 @@
+<script setup lang="ts" generic="T=any">
+import type Curd from '@/api/curd'
+import type { BulkActionOptions, BulkActions } from '@/components/StdDesign/types'
+import { message } from 'ant-design-vue'
+
+const props = defineProps<{
+  api: Curd<T>
+  actions: BulkActions
+  selectedRowKeys: Array<number | string>
+  inTrash?: boolean
+}>()
+
+const emit = defineEmits(['onSuccess'])
+
+const computedActions = computed(() => {
+  if (!props.inTrash) {
+    const result = { ...props.actions }
+
+    if (result.delete) {
+      result.delete = {
+        text: () => $gettext('Delete'),
+        action: ids => {
+          return props.api.batch_destroy(ids)
+        },
+      }
+    }
+    if (result.recover)
+      delete result.recover
+    return result
+  }
+  else {
+    const result = {} as { [key: string]: BulkActionOptions }
+    if (props.actions.delete) {
+      result.delete = {
+        text: () => $gettext('Delete Permanently'),
+        action: ids => {
+          return props.api.batch_destroy(ids, { permanent: true })
+        },
+      }
+    }
+    if (props.actions.recover) {
+      result.recover = {
+        text: () => $gettext('Recover'),
+        action: ids => {
+          return props.api.batch_recover(ids)
+        },
+      }
+    }
+    return result
+  }
+}) as ComputedRef<Record<string, BulkActionOptions>>
+
+const actionValue = ref('')
+
+watch(() => props.inTrash, () => {
+  actionValue.value = ''
+})
+
+function onClickApply() {
+  return new Promise(resolve => {
+    if (actionValue.value === '')
+      return resolve(false)
+
+    // call action
+    return resolve(
+      computedActions.value[actionValue.value]?.action(props.selectedRowKeys).then(async () => {
+        message.success($gettext('Apply bulk action successfully'))
+        emit('onSuccess')
+      }).catch(e => {
+        message.error($gettext(e?.message) ?? $gettext('Server error'))
+      }),
+    )
+  })
+}
+</script>
+
+<template>
+  <AFormItem>
+    <ASpace>
+      <ASelect
+        v-model:value="actionValue"
+        style="min-width: 150px"
+      >
+        <ASelectOption value="">
+          {{ $gettext('Batch Actions') }}
+        </ASelectOption>
+        <ASelectOption
+          v-for="(action, key) in computedActions"
+          :key
+          :value="key"
+        >
+          {{ action.text() }}
+        </ASelectOption>
+      </ASelect>
+      <APopconfirm
+        :cancel-text="$gettext('No')"
+        :ok-text="$gettext('OK')"
+        :title="$gettext('Are you sure you want to apply to all selected?')"
+        @confirm="onClickApply"
+      >
+        <AButton
+          danger
+          :disabled="!actionValue || !selectedRowKeys?.length"
+        >
+          {{ $gettext('Apply') }}
+        </AButton>
+      </APopconfirm>
+    </ASpace>
+  </AFormItem>
+</template>

+ 34 - 50
app/src/components/StdDesign/StdDataDisplay/StdCurd.vue

@@ -1,28 +1,13 @@
 <script setup lang="ts" generic="T=any">
+import type { StdCurdProps, StdTableProps } from '@/components/StdDesign/StdDataDisplay/types'
 import type { Column } from '@/components/StdDesign/types'
 import type { ComputedRef } 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'
 import StdTable from './StdTable.vue'
 
-export interface StdCurdProps<T> extends StdTableProps<T> {
-  cardTitleKey?: string
-  modalMaxWidth?: string | number
-  modalMask?: boolean
-  exportExcel?: boolean
-  importExcel?: boolean
-
-  disableAdd?: boolean
-  onClickAdd?: () => void
-
-  onClickEdit?: (id: number | string, record: T, index: number) => void
-  // eslint-disable-next-line ts/no-explicit-any
-  beforeSave?: (data: any) => Promise<void>
-}
-
 const props = defineProps<StdTableProps<T> & StdCurdProps<T>>()
 
 const selectedRowKeys = defineModel<(number | string)[]>('selectedRowKeys', {
@@ -76,16 +61,9 @@ function add(preset: any = undefined) {
   modifyMode.value = true
 }
 
-const table = useTemplateRef('table')
-
-const getParams = reactive({
-  trash: false,
-})
-
-// eslint-disable-next-line ts/no-explicit-any
-function setParams(k: string, v: any) {
-  getParams[k] = v
-}
+const table = ref()
+const inTrash = ref(false)
+const getParams = reactive(props.getParams ?? {})
 
 function get_list() {
   table.value?.get_list()
@@ -95,8 +73,7 @@ defineExpose({
   add,
   get_list,
   data,
-  getParams,
-  setParams,
+  inTrash,
 })
 
 function clearError() {
@@ -105,17 +82,14 @@ function clearError() {
   })
 }
 
-const stdEntryRef = useTemplateRef('stdEntryRef')
+const stdEntryRef = ref()
 
 async function ok() {
-  if (!stdEntryRef.value)
-    return
-
   const { formRef } = stdEntryRef.value
 
   clearError()
   try {
-    await formRef?.validateFields()
+    await formRef.validateFields()
     props?.beforeSave?.(data)
     props
       .api!.save(data.id, { ...data, ...props.overwriteParams }, { params: { ...props.overwriteParams } }).then(r => {
@@ -177,22 +151,21 @@ async function get(id: number | string) {
 }
 
 const modalTitle = computed(() => {
-  if (data.id)
-    return modifyMode.value ? $gettext('Modify') : $gettext('View Details')
-  return $gettext('Add')
+  // eslint-disable-next-line sonarjs/no-nested-conditional
+  return data.id ? modifyMode.value ? $gettext('Modify') : $gettext('View Details') : $gettext('Add')
 })
 
 const localOverwriteParams = reactive(props.overwriteParams ?? {})
 
-const stdBatchEditRef = useTemplateRef('stdBatchEditRef')
+const stdBatchEditRef = ref()
 
 async function handleClickBatchEdit(batchColumns: Column[]) {
-  stdBatchEditRef.value?.showModal(batchColumns, selectedRowKeys.value, selectedRows.value)
+  stdBatchEditRef.value.showModal(batchColumns, selectedRowKeys.value, selectedRows.value)
 }
 
 function handleBatchUpdated() {
-  table.value?.get_list()
-  table.value?.resetSelection()
+  table.value.get_list()
+  table.value.resetSelection()
 }
 </script>
 
@@ -208,24 +181,34 @@ function handleBatchUpdated() {
       <template #extra>
         <ASpace>
           <slot name="beforeAdd" />
-          <a
-            v-if="!disableAdd && !getParams.trash"
+          <AButton
+            v-if="!disableAdd && !inTrash"
+            type="link"
+            size="small"
             @click="add"
-          >{{ $gettext('Add') }}</a>
+          >
+            {{ $gettext('Add') }}
+          </AButton>
           <slot name="extra" />
           <template v-if="!disableDelete">
-            <a
-              v-if="!getParams.trash"
-              @click="getParams.trash = true"
+            <AButton
+              v-if="!inTrash"
+              type="link"
+              size="small"
+              :loading="table?.loading"
+              @click="inTrash = true"
             >
               {{ $gettext('Trash') }}
-            </a>
-            <a
+            </AButton>
+            <AButton
               v-else
-              @click="getParams.trash = false"
+              type="link"
+              size="small"
+              :loading="table?.loading"
+              @click="inTrash = false"
             >
               {{ $gettext('Back to list') }}
-            </a>
+            </AButton>
           </template>
         </ASpace>
       </template>
@@ -240,6 +223,7 @@ function handleBatchUpdated() {
         }"
         v-model:selected-row-keys="selectedRowKeys"
         v-model:selected-rows="selectedRows"
+        :in-trash="inTrash"
         @click-edit="edit"
         @click-view="view"
         @selected="onSelect"

+ 8 - 4
app/src/components/StdDesign/StdDataDisplay/StdPagination.vue

@@ -1,11 +1,14 @@
 <script setup lang="ts">
 import type { Pagination } from '@/api/curd'
 
-const props = defineProps<{
+const props = withDefaults(defineProps<{
   pagination: Pagination
   size?: 'default' | 'small'
-  loading: boolean
-}>()
+  loading?: boolean
+  showSizeChanger?: boolean
+}>(), {
+  showSizeChanger: true,
+})
 
 const emit = defineEmits(['change', 'changePageSize', 'update:pagination'])
 
@@ -33,7 +36,8 @@ const pageSize = computed({
       v-model:page-size="pageSize"
       :disabled="loading"
       :current="pagination.current_page"
-      show-size-changer
+      :show-size-changer="showSizeChanger"
+      :show-total="(total:number) => $ngettext('Total %{total} item', 'Total %{total} items', total, { total: total.toString() })"
       :size="size"
       :total="pagination.total"
       @change="change"

+ 132 - 137
app/src/components/StdDesign/StdDataDisplay/StdTable.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts" generic="T=any">
-import type { Pagination } from '@/api/curd'
-import type Curd from '@/api/curd'
+import type { GetListResponse, Pagination } from '@/api/curd'
+import type { StdTableProps } from '@/components/StdDesign/StdDataDisplay/types'
 import type { Column } from '@/components/StdDesign/types'
 import type { TableProps } from 'ant-design-vue'
 import type { Key } from 'ant-design-vue/es/_util/type'
@@ -10,43 +10,13 @@ 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 StdBulkActions from '@/components/StdDesign/StdDataDisplay/StdBulkActions.vue'
+import StdDataEntry, { labelRender } 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'
 
-// eslint-disable-next-line ts/no-explicit-any
-export interface StdTableProps<T = any> {
-  title?: string
-  mode?: string
-  rowKey?: string
-
-  api: Curd<T>
-  columns: Column[]
-  // eslint-disable-next-line ts/no-explicit-any
-  getParams?: Record<string, any>
-  size?: string
-  disableQueryParams?: boolean
-  disableSearch?: boolean
-  pithy?: boolean
-  exportExcel?: boolean
-  exportMaterial?: boolean
-  // eslint-disable-next-line ts/no-explicit-any
-  overwriteParams?: Record<string, any>
-  disableView?: boolean
-  disableModify?: boolean
-  selectionType?: string
-  sortable?: boolean
-  disableDelete?: boolean
-  disablePagination?: boolean
-  sortableMoveHook?: (oldRow: number[], newRow: number[]) => boolean
-  scrollX?: string | number
-  // eslint-disable-next-line ts/no-explicit-any
-  getCheckboxProps?: (record: any) => any
-}
-
 const props = withDefaults(defineProps<StdTableProps<T>>(), {
   rowKey: 'id',
 })
@@ -71,6 +41,9 @@ const dataSource: Ref<T[]> = ref([])
 const expandKeysList: Ref<Key[]> = ref([])
 
 watch(dataSource, () => {
+  if (!props.expandAll)
+    return
+
   const res: Key[] = []
 
   function buildKeysList(record) {
@@ -105,8 +78,26 @@ const pagination: Pagination = reactive({
   total_pages: 1,
 })
 
-const params = reactive({
-  ...props.getParams,
+const filterParams = ref({})
+
+const paginationParams = ref({
+  page: 1,
+  page_size: 20,
+})
+
+const sortParams = ref({
+  order: 'desc' as 'desc' | 'asc' | undefined,
+  sort_by: '' as Key | readonly Key[] | undefined,
+})
+
+const params = computed(() => {
+  return {
+    ...filterParams.value,
+    ...sortParams.value,
+    ...props.getParams,
+    ...props.overwriteParams,
+    trash: props.inTrash,
+  }
 })
 
 onMounted(() => {
@@ -149,33 +140,27 @@ const batchColumns = computed(() => {
   return props.columns?.filter(column => column.batch) || []
 })
 
-const get_list = _.debounce(_get_list, 100, {
-  leading: true,
-  trailing: false,
+const radioColumns = computed(() => {
+  return props.columns?.filter(column => column.radio) || []
 })
 
-const filterParams = reactive({})
-
-watch(filterParams, () => {
-  Object.assign(params, {
-    ...filterParams,
-    page: 1,
-    trash: route.query.trash === 'true',
-  })
+const get_list = _.debounce(_get_list, 100, {
+  leading: false,
+  trailing: true,
 })
 
-onMounted(() => {
+onMounted(async () => {
   if (!props.disableQueryParams) {
-    Object.assign(params, {
-      ...route.query,
-      trash: route.query.trash === 'true',
-    })
-
-    Object.assign(filterParams, {
+    filterParams.value = {
       ...route.query,
-    })
+      ...props.getParams,
+    }
+    paginationParams.value.page = Number(route.query.page) || 1
+    paginationParams.value.page_size = Number(route.query.page_size) || 20
   }
 
+  await nextTick()
+
   get_list()
 
   if (props.sortable)
@@ -188,10 +173,11 @@ defineExpose({
   get_list,
   pagination,
   resetSelection,
+  loading,
 })
 
 function destroy(id: number | string) {
-  props.api!.destroy(id, { permanent: params.trash }).then(() => {
+  props.api!.destroy(id, { permanent: props.inTrash }).then(() => {
     get_list()
     message.success($gettext('Deleted successfully'))
   }).catch(e => {
@@ -211,7 +197,7 @@ function recover(id: number | string) {
 // eslint-disable-next-line ts/no-explicit-any
 function buildIndexMap(data: any, level: number = 0, index: number = 0, total: number[] = []) {
   if (data && data.length > 0) {
-    // eslint-disable-next-line ts/no-explicit-any
+  // eslint-disable-next-line ts/no-explicit-any
     data.forEach((v: any) => {
       v.level = level
 
@@ -224,18 +210,12 @@ function buildIndexMap(data: any, level: number = 0, index: number = 0, total: n
   }
 }
 
-async function _get_list(page_num: number | null = null, page_size = 20) {
+async function _get_list() {
   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 => {
+
+  // eslint-disable-next-line ts/no-explicit-any
+  await props.api?.get_list({ ...params.value, ...paginationParams.value }).then(async (r: GetListResponse<any>) => {
     dataSource.value = r.data
     rowsKeyIndexMap.value = {}
     if (props.sortable)
@@ -243,11 +223,11 @@ async function _get_list(page_num: number | null = null, page_size = 20) {
 
     if (r.pagination)
       Object.assign(pagination, r.pagination)
-
-    loading.value = false
   }).catch(e => {
-    message.error(e?.message ?? $gettext('Server error'))
+    message.error($gettext(e?.message ?? 'Server error'))
   })
+
+  loading.value = false
 }
 
 // eslint-disable-next-line ts/no-explicit-any
@@ -255,17 +235,16 @@ function onTableChange(_pagination: TablePaginationConfig, filters: Record<strin
   if (sorter) {
     sorter = sorter as SorterResult
     selectedRowKeys.value = []
-    params.sort_by = sorter.field
-    params.order = sorter.order === 'ascend' ? 'asc' : 'desc'
+    sortParams.value.sort_by = sorter.field
     switch (sorter.order) {
       case 'ascend':
-        params.sort = 'asc'
+        sortParams.value.order = 'asc'
         break
       case 'descend':
-        params.sort = 'desc'
+        sortParams.value.order = 'desc'
         break
       default:
-        params.sort = null
+        sortParams.value.order = undefined
         break
     }
   }
@@ -286,7 +265,7 @@ function expandedTable(keys: Key[]) {
 // eslint-disable-next-line ts/no-explicit-any
 async function onSelect(record: any, selected: boolean, _selectedRows: any[]) {
   // console.log('onSelect', record, selected, _selectedRows)
-  if (props.selectionType === 'checkbox' || batchColumns.value.length > 0 || props.exportExcel) {
+  if (props.selectionType === 'checkbox' || props.exportExcel || batchColumns.value.length > 0 || props.bulkActions) {
     if (selected) {
       _selectedRows.forEach(v => {
         if (v) {
@@ -349,72 +328,40 @@ function resetSelection() {
 const router = useRouter()
 
 async function resetSearch() {
-  Object.keys(params).forEach(v => {
-    delete params[v]
-  })
-
-  Object.assign(params, {
-    ...props.getParams,
-  })
-
-  router.push({ query: {} }).catch(() => {
-  })
-
-  Object.keys(filterParams).forEach(v => {
-    delete filterParams[v]
-  })
-
+  filterParams.value = {}
   updateFilter.value++
 }
 
-const { stop: stopWatchParams, resume: resumeWatchParams } = watchPausable(params, v => {
+watch(params, async v => {
   if (!init.value)
     return
 
+  paginationParams.value = {
+    page: 1,
+    page_size: paginationParams.value.page_size,
+  }
+
+  await nextTick()
+
   if (!props.disableQueryParams)
-    router.push({ query: { ...v as RouteParams } })
+    await router.push({ query: { ...v as unknown as RouteParams, ...paginationParams.value } })
   else
     get_list()
-})
-
-watch(() => route.query, async () => {
-  params.trash = route.query.trash === 'true'
+}, { deep: true })
 
+watch(() => route.query, () => {
   if (init.value)
-    await get_list()
+    get_list()
 })
 
-if (props.getParams) {
-  const getParams = computed(() => props.getParams)
-
-  watch(getParams, () => {
-    Object.assign(params, {
-      ...props.getParams,
-      page: 1,
-    })
-  }, { deep: true })
-}
-
-if (props.overwriteParams) {
-  const overwriteParams = computed(() => props.overwriteParams)
-
-  watch(overwriteParams, () => {
-    Object.assign(params, {
-      page: 1,
-    })
-    if (params.page === 1)
-      get_list()
-  }, { deep: true })
-}
-
 const rowSelection = computed(() => {
-  if (batchColumns.value.length > 0 || props.selectionType || props.exportExcel) {
+  if (batchColumns.value.length > 0 || props.selectionType || props.exportExcel || props.bulkActions) {
     return {
       selectedRowKeys: unref(selectedRowKeys),
       onSelect,
       onSelectAll,
       getCheckboxProps: props?.getCheckboxProps,
-      type: (batchColumns.value.length > 0 || props.exportExcel) ? 'checkbox' : props.selectionType,
+      type: (batchColumns.value.length > 0 || props.exportExcel || props.bulkActions) ? 'checkbox' : props.selectionType,
     }
   }
   else {
@@ -435,13 +382,25 @@ function initSortable() {
 }
 
 async function changePage(page: number, page_size: number) {
-  stopWatchParams()
-  Object.assign(params, {
-    page,
-    page_size,
-  })
-  resumeWatchParams()
-  await get_list(page, page_size)
+  if (page) {
+    paginationParams.value = {
+      page,
+      page_size,
+    }
+  }
+  else {
+    paginationParams.value = {
+      page: 1,
+      page_size,
+    }
+  }
+
+  await nextTick()
+
+  if (!props.disableQueryParams)
+    await router.push({ query: { ...route.query, ...paginationParams.value } })
+
+  get_list()
 }
 
 const paginationSize = computed(() => {
@@ -454,6 +413,26 @@ const paginationSize = computed(() => {
 
 <template>
   <div class="std-table">
+    <div v-if="radioColumns.length">
+      <AFormItem
+        v-for="column in radioColumns"
+        :key="column.dataIndex as PropertyKey"
+        :label="labelRender(column.title)"
+      >
+        <ARadioGroup v-model:value="params[column.dataIndex as string]">
+          <ARadioButton :value="undefined">
+            {{ $gettext('All') }}
+          </ARadioButton>
+          <ARadioButton
+            v-for="(value, key) in column.mask"
+            :key
+            :value="key"
+          >
+            {{ labelRender(value) }}
+          </ARadioButton>
+        </ARadioGroup>
+      </AFormItem>
+    </div>
     <StdDataEntry
       v-if="!disableSearch && searchColumns.length"
       :key="updateFilter"
@@ -473,10 +452,26 @@ const paginationSize = computed(() => {
           >
             {{ $gettext('Batch Modify') }}
           </AButton>
+          <Export
+            v-if="props.exportExcel"
+            :columns="props.columns"
+            :api="props.api"
+            :total="pagination.total"
+            :query="params"
+            :ids="selectedRowKeys"
+          />
           <slot name="append-search" />
         </ASpace>
       </template>
     </StdDataEntry>
+    <StdBulkActions
+      v-if="bulkActions"
+      v-model:selected-row-keys="selectedRowKeys"
+      :api
+      :in-trash="inTrash"
+      :actions="bulkActions"
+      @on-success="() => { resetSelection(); get_list() }"
+    />
     <ATable
       :id="`std-table-${randomId}`"
       :columns="pithyColumns"
@@ -497,7 +492,7 @@ const paginationSize = computed(() => {
           {{ text }}
         </template>
         <template v-if="column.dataIndex === 'action'">
-          <template v-if="!props.disableView && !params.trash">
+          <template v-if="!props.disableView && !inTrash">
             <AButton
               type="link"
               size="small"
@@ -511,7 +506,7 @@ const paginationSize = computed(() => {
             />
           </template>
 
-          <template v-if="!props.disableModify && !params.trash">
+          <template v-if="!props.disableModify && !inTrash">
             <AButton
               type="link"
               size="small"
@@ -529,9 +524,9 @@ const paginationSize = computed(() => {
 
           <template v-if="!props.disableDelete">
             <APopconfirm
-              v-if="!params.trash"
+              v-if="!inTrash"
               :cancel-text="$gettext('No')"
-              :ok-text="$gettext('OK')"
+              :ok-text="$gettext('Ok')"
               :title="$gettext('Are you sure you want to delete this item?')"
               @confirm="destroy(record[rowKey])"
             >
@@ -545,7 +540,7 @@ const paginationSize = computed(() => {
             <APopconfirm
               v-else
               :cancel-text="$gettext('No')"
-              :ok-text="$gettext('OK')"
+              :ok-text="$gettext('Ok')"
               :title="$gettext('Are you sure you want to recover this item?')"
               @confirm="recover(record[rowKey])"
             >
@@ -558,9 +553,9 @@ const paginationSize = computed(() => {
             </APopconfirm>
             <ADivider type="vertical" />
             <APopconfirm
-              v-if="params.trash"
+              v-if="inTrash"
               :cancel-text="$gettext('No')"
-              :ok-text="$gettext('OK')"
+              :ok-text="$gettext('Ok')"
               :title="$gettext('Are you sure you want to delete this item permanently?')"
               @confirm="destroy(record[rowKey])"
             >

+ 86 - 24
app/src/components/StdDesign/StdDataDisplay/StdTableTransformer.tsx

@@ -1,14 +1,15 @@
+import type { VNode } from 'vue'
 import type { JSX } from 'vue/jsx-runtime'
-import { Tag } from 'ant-design-vue'
+import { CopyOutlined } from '@ant-design/icons-vue'
+import { message, Tag } from 'ant-design-vue'
 // text, record, index, column
 import dayjs from 'dayjs'
 import { get } from 'lodash'
 
-export interface CustomRenderProps {
-  // eslint-disable-next-line ts/no-explicit-any
-  text: any
-  // eslint-disable-next-line ts/no-explicit-any
-  record: any
+// eslint-disable-next-line ts/no-explicit-any
+export interface CustomRender<T = any, R = any> {
+  text: T
+  record: R
   // eslint-disable-next-line ts/no-explicit-any
   index: any
   // eslint-disable-next-line ts/no-explicit-any
@@ -17,11 +18,14 @@ export interface CustomRenderProps {
   isDetail?: boolean
 }
 
-export function datetime(args: CustomRenderProps) {
+export function datetime(args: CustomRender) {
+  if (!args.text)
+    return '/'
+
   return dayjs(args.text).format('YYYY-MM-DD HH:mm:ss')
 }
 
-export function date(args: CustomRenderProps) {
+export function date(args: CustomRender) {
   return args.text ? dayjs(args.text).format('YYYY-MM-DD') : '-'
 }
 
@@ -30,11 +34,10 @@ date.isDate = true
 datetime.isDatetime = true
 
 // eslint-disable-next-line ts/no-explicit-any
-export function mask(maskObj: any): (args: CustomRenderProps) => JSX.Element {
-  return (args: CustomRenderProps) => {
+export function mask(maskObj: any): (args: CustomRender) => JSX.Element {
+  return (args: CustomRender) => {
     // eslint-disable-next-line ts/no-explicit-any
     let v: any
-
     if (typeof maskObj?.[args.text] === 'function')
       v = maskObj[args.text]()
     else if (typeof maskObj?.[args.text] === 'string')
@@ -45,17 +48,17 @@ export function mask(maskObj: any): (args: CustomRenderProps) => JSX.Element {
   }
 }
 
-export function arrayToTextRender(args: CustomRenderProps) {
+export function arrayToTextRender(args: CustomRender) {
   return args.text?.join(', ')
 }
 export function actualValueRender(actualDataIndex: string | string[]) {
-  return (args: CustomRenderProps) => {
+  return (args: CustomRender) => {
     return get(args.record, actualDataIndex) || '/'
   }
 }
 
-export function longTextWithEllipsis(len: number): (args: CustomRenderProps) => JSX.Element {
-  return (args: CustomRenderProps) => {
+export function longTextWithEllipsis(len: number): (args: CustomRender) => JSX.Element {
+  return (args: CustomRender) => {
     if (args.isExport || args.isDetail)
       return args.text
 
@@ -63,13 +66,9 @@ export function longTextWithEllipsis(len: number): (args: CustomRenderProps) =>
   }
 }
 
-export function year(args: CustomRenderProps) {
-  return dayjs(args.text).format('YYYY')
-}
-
 // eslint-disable-next-line ts/no-explicit-any
-export function maskRenderWithColor(maskObj: any) {
-  return (args: CustomRenderProps) => {
+export function maskRenderWithColor(maskObj: any, customColors?: Record<string | number, string> | string) {
+  return (args: CustomRender) => {
     let label: string
     if (typeof maskObj[args.text] === 'function')
       label = maskObj[args.text]()
@@ -80,14 +79,77 @@ export function maskRenderWithColor(maskObj: any) {
     if (args.isExport)
       return label
 
-    const colorMap = {
+    let colorMap: Record<string | number, string> = {
       0: '',
       1: 'blue',
       2: 'green',
-      3: 'red',
+      3: 'purple',
       4: 'cyan',
     }
 
-    return args.text ? h(Tag, { color: colorMap[args.text] }, maskObj[args.text]) : '-'
+    if (typeof customColors === 'object')
+      colorMap = customColors
+
+    let color = colorMap[args.text]
+
+    if (typeof customColors === 'string')
+      color = customColors
+
+    return args.text ? h(Tag, { color }, () => label) : '/'
   }
 }
+
+interface MultiFieldRenderProps {
+  key: string | number | string[] | number[]
+  label?: () => string
+  prefix?: string
+  suffix?: string
+  render?: ((args: CustomRender) => string | number | VNode) | (() => ((args: CustomRender) => string | VNode))
+  direction?: 'vertical' | 'horizontal'
+}
+
+export function multiFieldsRender(fields: MultiFieldRenderProps[]) {
+  return (args: CustomRender) => {
+    const list = fields.map(field => {
+      let label = field.label?.()
+      let value = get(args.record, field.key)
+
+      if (field.prefix)
+        value = field.prefix + value
+      if (field.suffix)
+        value += field.suffix
+
+      if (label)
+        label += ':'
+
+      const valueNode = field.render?.({ ...args, text: value }) ?? value
+      const direction = field.direction ?? 'vertical'
+
+      const labelNode = label
+      // eslint-disable-next-line sonarjs/no-nested-conditional
+        ? h(direction === 'vertical' ? 'div' : 'span', { class: 'text-gray-500 my-1 mr-1' }, label)
+        : null
+
+      return h('div', { class: 'my-4' }, [labelNode, valueNode])
+    })
+
+    return h('div', null, list)
+  }
+}
+
+export function copiableFieldRender(args: CustomRender) {
+  return h('div', null, [
+    h('span', null, args.text),
+    h(CopyOutlined, {
+      style: {
+        marginLeft: '10px',
+        cursor: 'pointer',
+      },
+      onClick: () => {
+        navigator.clipboard.writeText(args.text).then(() => {
+          message.success($gettext('Copied'))
+        })
+      },
+    }),
+  ])
+}

+ 3 - 2
app/src/components/StdDesign/StdDataDisplay/components/CustomRender.tsx

@@ -1,7 +1,8 @@
-import type { CustomRenderProps } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
+import type { CustomRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
 import _ from 'lodash'
 
-export function CustomRender(props: CustomRenderProps) {
+// eslint-disable-next-line ts/no-redeclare,sonarjs/no-redeclare
+export function CustomRender(props: CustomRender) {
   return props.column.customRender
     ? props.column.customRender(props)
     : _.get(props.record, props.column.dataIndex!)

+ 1 - 5
app/src/components/StdDesign/StdDataDisplay/index.ts

@@ -2,8 +2,4 @@ import StdBatchEdit from './StdBatchEdit.vue'
 import StdCurd from './StdCurd.vue'
 import StdTable from './StdTable.vue'
 
-export {
-  StdBatchEdit,
-  StdCurd,
-  StdTable,
-}
+export { StdBatchEdit, StdCurd, StdTable }

+ 4 - 6
app/src/components/StdDesign/StdDataDisplay/methods/exportCsv.ts

@@ -1,6 +1,5 @@
-import type { GetListResponse } from '@/api/curd'
-import type { StdTableProps } from '@/components/StdDesign/StdDataDisplay/StdTable.vue'
-import type { Column } from '@/components/StdDesign/types'
+import type { StdTableProps } from '@/components/StdDesign/StdDataDisplay/types'
+import type { Column, StdTableResponse } from '@/components/StdDesign/types'
 import type { ComputedRef } from 'vue'
 import { downloadCsv } from '@/lib/helper'
 import { message } from 'ant-design-vue'
@@ -32,10 +31,9 @@ async function exportCsv(props: StdTableProps, pithyColumns: ComputedRef<Column[
   let hasMore = true
   let page = 1
   while (hasMore) {
-    // 准备 DataSource
+    // prepare dataSource
     await props
-    // eslint-disable-next-line ts/no-explicit-any
-      .api!.get_list({ page }).then((r: GetListResponse<any>) => {
+      .api!.get_list({ page }).then((r: StdTableResponse) => {
       if (r.data.length === 0) {
         hasMore = false
 

+ 98 - 104
app/src/components/StdDesign/StdDataDisplay/methods/sortable.ts

@@ -1,4 +1,4 @@
-import type { StdTableProps } from '@/components/StdDesign/StdDataDisplay/StdTable.vue'
+import type { StdTableProps } from '@/components/StdDesign/StdDataDisplay/types'
 import type { Key } from 'ant-design-vue/es/_util/type'
 import type { Ref } from 'vue'
 import { message } from 'ant-design-vue'
@@ -26,111 +26,105 @@ function useSortable(props: StdTableProps, randomId: Ref<string>, dataSource: Re
   // eslint-disable-next-line ts/no-explicit-any
   const table: any = document.querySelector(`#std-table-${randomId.value} tbody`)
 
-  try {
-    // eslint-disable-next-line no-new,new-cap
-    new sortable(table, {
-      handle: '.ant-table-drag-icon',
-      animation: 150,
-      sort: true,
-      forceFallback: true,
-      setData(dataTransfer) {
-        dataTransfer.setData('Text', '')
-      },
-      onStart({ item }) {
-        const targetRowKey = Number(getRowKey(item))
-        if (targetRowKey)
-          expandKeysList.value = expandKeysList.value.filter((_item: Key) => _item !== targetRowKey)
-      },
-      onMove({
-        dragged,
-               related,
-      }) {
-        const oldRow: number[] = rowsKeyIndexMap.value?.[Number(getRowKey(dragged))]
-        const newRow: number[] = rowsKeyIndexMap.value?.[Number(getRowKey(related))]
-
-        if (oldRow.length !== newRow.length || oldRow[oldRow.length - 2] !== newRow[newRow.length - 2])
-          return false
-
-        if (props.sortableMoveHook)
-          return props.sortableMoveHook(oldRow, newRow)
-      },
-      async onEnd({
-        item,
-                    newIndex,
-                    oldIndex,
-      }) {
-        if (newIndex === oldIndex || newIndex === undefined)
-          return
-
-        const indexDelta: number = Number(oldIndex) - Number(newIndex)
-        const direction: number = indexDelta > 0 ? +1 : -1
-
-        const rowIndex: number[] = rowsKeyIndexMap.value?.[Number(getRowKey(item))]
-        const newRow = getTargetData(dataSource.value, rowIndex)
-        const newRowParent = newRow.parent
-        const level: number = newRow.level
-
-        const currentRowIndex: number[] = [...rowsKeyIndexMap.value?.
-          [Number(getRowKey(table.children[Number(newIndex) + direction]))]]
-
+  // eslint-disable-next-line no-new,new-cap,sonarjs/constructor-for-side-effects
+  new sortable(table, {
+    handle: '.ant-table-drag-icon',
+    animation: 150,
+    sort: true,
+    forceFallback: true,
+    setData(dataTransfer) {
+      dataTransfer.setData('Text', '')
+    },
+    onStart({ item }) {
+      const targetRowKey = Number(getRowKey(item))
+      if (targetRowKey)
+        expandKeysList.value = expandKeysList.value.filter((_item: Key) => _item !== targetRowKey)
+    },
+    onMove({
+      dragged,
+      related,
+    }) {
+      const oldRow: number[] = rowsKeyIndexMap.value?.[Number(getRowKey(dragged))]
+      const newRow: number[] = rowsKeyIndexMap.value?.[Number(getRowKey(related))]
+
+      if (oldRow.length !== newRow.length || oldRow[oldRow.length - 2] !== newRow[newRow.length - 2])
+        return false
+
+      if (props.sortableMoveHook)
+        return props.sortableMoveHook(oldRow, newRow)
+    },
+    async onEnd({
+      item,
+      newIndex,
+      oldIndex,
+    }) {
+      if (newIndex === oldIndex)
+        return
+
+      const indexDelta: number = Number(oldIndex) - Number(newIndex)
+      const direction: number = indexDelta > 0 ? +1 : -1
+
+      const rowIndex: number[] = rowsKeyIndexMap.value?.[Number(getRowKey(item))]
+      const newRow = getTargetData(dataSource.value, rowIndex)
+      const newRowParent = newRow.parent
+      const level: number = newRow.level
+
+      const currentRowIndex: number[] = [...rowsKeyIndexMap.value![Number(getRowKey(table?.children?.[Number(newIndex) + direction]))]]
+
+      // eslint-disable-next-line ts/no-explicit-any
+      const currentRow: any = getTargetData(dataSource.value, currentRowIndex)
+
+      // Reset parent
+      currentRow.parent = newRow.parent = null
+      newRowParent.children.splice(rowIndex[level], 1)
+      newRowParent.children.splice(currentRowIndex[level], 0, toRaw(newRow))
+
+      const changeIds: number[] = []
+
+      // eslint-disable-next-line ts/no-explicit-any
+      function processChanges(row: any, children = false, _newIndex: number | undefined = undefined) {
+        // Build changes ID list expect new row
+        if (children || _newIndex === undefined)
+          changeIds.push(row.id)
+
+        if (_newIndex !== undefined)
+          rowsKeyIndexMap.value[row.id][level] = _newIndex
+        else if (children)
+          rowsKeyIndexMap.value[row.id][level] += direction
+
+        row.parent = null
+        if (row.children)
         // eslint-disable-next-line ts/no-explicit-any
-        const currentRow: any = getTargetData(dataSource.value, currentRowIndex)
-
-        // Reset parent
-        currentRow.parent = newRow.parent = null
-        newRowParent.children.splice(rowIndex[level], 1)
-        newRowParent.children.splice(currentRowIndex[level], 0, toRaw(newRow))
-
-        const changeIds: number[] = []
-
+          row.children.forEach((v: any) => processChanges(v, true, _newIndex))
+      }
+
+      // Replace row index for new row
+      processChanges(newRow, false, currentRowIndex[level])
+
+      // Rebuild row index maps for changes row
+      // eslint-disable-next-line sonarjs/no-equals-in-for-termination
+      for (let i = Number(oldIndex); i !== newIndex; i -= direction) {
+        const _rowIndex: number[] = rowsKeyIndexMap.value?.[getRowKey(table.children[i])]
+
+        _rowIndex[level] += direction
+        processChanges(getTargetData(dataSource.value, _rowIndex))
+      }
+
+      // console.log('Change row id', newRow.id, 'order', newRow.id, '=>', currentRow.id, ', direction: ', direction,
+      //   ', changes IDs:', changeIds
+
+      props.api.update_order({
+        target_id: newRow.id,
+        direction,
+        affected_ids: changeIds,
+      }).then(() => {
+        message.success($gettext('Updated successfully'))
         // eslint-disable-next-line ts/no-explicit-any
-        function processChanges(row: any, children = false, _newIndex: number | undefined = undefined) {
-          // Build changes ID list expect new row
-          if (children || _newIndex === undefined)
-            changeIds.push(row.id)
-
-          if (_newIndex !== undefined)
-            rowsKeyIndexMap.value[row.id][level] = _newIndex
-          else if (children)
-            rowsKeyIndexMap.value[row.id][level] += direction
-
-          row.parent = null
-          if (row.children) {
-            // eslint-disable-next-line ts/no-explicit-any
-            row.children.forEach((v: any) => processChanges(v, true, _newIndex))
-          }
-        }
-
-        // Replace row index for new row
-        processChanges(newRow, false, currentRowIndex[level])
-
-        // Rebuild row index maps for changes row
-        for (let i = Number(oldIndex); i >= newIndex; i -= direction) {
-          const _rowIndex: number[] = rowsKeyIndexMap.value?.[getRowKey(table.children[i])]
-
-          _rowIndex[level] += direction
-          processChanges(getTargetData(dataSource.value, _rowIndex))
-        }
-
-        // console.log('Change row id', newRow.id, 'order', newRow.id, '=>', currentRow.id, ', direction: ', direction,
-        //   ', changes IDs:', changeIds
-
-        props.api.update_order({
-          target_id: newRow.id,
-          direction,
-          affected_ids: changeIds,
-        }).then(() => {
-          message.success($gettext('Updated successfully'))
-          // eslint-disable-next-line ts/no-explicit-any
-        }).catch((e: any) => {
-          message.error(e?.message ?? $gettext('Server error'))
-        })
-      },
-    })
-  }
-  catch (e) {
-    console.error(e)
-  }
+      }).catch((e: any) => {
+        message.error(e?.message ?? $gettext('Server error'))
+      })
+    },
+  })
 }
 
 export default useSortable

+ 48 - 4
app/src/components/StdDesign/StdDataDisplay/types.d.ts

@@ -1,6 +1,50 @@
-/* eslint-disable ts/no-explicit-any */
+import type { ImportConfig } from '@/components/StdDesign/StdDataImport/types'
 
-export interface StdTableSlots {
-  'append-search': (action) => any
-  'actions': (actions: Record<string, any>) => any
+export interface StdCurdProps<T> extends StdTableProps<T> {
+  cardTitleKey?: string
+  modalMaxWidth?: string | number
+  modalMask?: boolean
+  exportExcel?: boolean
+  importExcel?: boolean
+
+  disableAdd?: boolean
+  onClickAdd?: () => void
+
+  onClickEdit?: (id: number | string, record: T, index: number) => void
+  // eslint-disable-next-line ts/no-explicit-any
+  beforeSave?: (data: any) => Promise<void>
+  importConfig?: ImportConfig
+}
+
+// eslint-disable-next-line ts/no-explicit-any
+export interface StdTableProps<T = any> {
+  title?: string
+  mode?: string
+  rowKey?: string
+
+  api: Curd<T>
+  columns: Column[]
+  // eslint-disable-next-line ts/no-explicit-any
+  getParams?: Record<string, any>
+  size?: string
+  disableQueryParams?: boolean
+  disableSearch?: boolean
+  pithy?: boolean
+  exportExcel?: boolean
+  exportMaterial?: boolean
+  // eslint-disable-next-line ts/no-explicit-any
+  overwriteParams?: Record<string, any>
+  disableView?: boolean
+  disableModify?: boolean
+  selectionType?: string
+  sortable?: boolean
+  disableDelete?: boolean
+  disablePagination?: boolean
+  sortableMoveHook?: (oldRow: number[], newRow: number[]) => boolean
+  scrollX?: string | number
+  // eslint-disable-next-line ts/no-explicit-any
+  getCheckboxProps?: (record: any) => any
+  bulkActions?: BulkActions
+  inTrash?: boolean
+  expandAll?: boolean
 }

+ 3 - 6
app/src/components/StdDesign/StdDataEntry/components/StdSelect.vue

@@ -1,6 +1,5 @@
 <script setup lang="ts">
 import type { SelectProps } from 'ant-design-vue'
-import { ref } from 'vue'
 
 const props = defineProps<{
   mask?: Record<string | number, string | (() => string)> | (() => Promise<Record<string | number, string>>)
@@ -62,16 +61,14 @@ onMounted(() => {
 <template>
   <ASelect
     v-model:value="selectedValue"
+    allow-clear
     :options="options"
     :placeholder="props.placeholder"
     :default-active-first-option="false"
     :mode="props.multiple ? 'multiple' : undefined"
-    style="min-width: 180px"
-    allow-clear
+    class="min-w-180px w-auto!"
     :get-popup-container="triggerNode => triggerNode.parentNode"
   />
 </template>
 
-<style lang="less" scoped>
-
-</style>
+<style lang="less" scoped></style>

+ 2 - 0
app/src/components/StdDesign/StdDataEntry/components/StdSelector.vue

@@ -27,6 +27,7 @@ const props = defineProps<{
   // eslint-disable-next-line ts/no-explicit-any
   getCheckboxProps?: (record: any) => any
   hideInputContainer?: boolean
+  expandAll?: boolean
 }>()
 
 const selectedKey = defineModel<number | number[] | undefined | null | string | string[]>('selectedKey')
@@ -195,6 +196,7 @@ defineExpose({ show })
         :columns
         :disable-search
         :row-key="itemKey"
+        :expand-all
         :get-params
         :selection-type
         :get-checkbox-props

+ 20 - 13
app/src/components/StdDesign/StdDataEntry/index.tsx

@@ -7,7 +7,6 @@ import {
   InputNumber,
   RangePicker,
   Switch,
-  Textarea,
 } from 'ant-design-vue'
 import dayjs from 'dayjs'
 import { h } from 'vue'
@@ -66,13 +65,15 @@ export function inputNumber(edit: StdDesignEdit, dataSource: any, dataIndex: any
 
 // eslint-disable-next-line ts/no-explicit-any
 export function textarea(edit: StdDesignEdit, dataSource: any, dataIndex: any) {
-  return h(Textarea, {
-    'placeholder': placeholderHelper(edit),
-    'value': dataSource?.[dataIndex] ?? edit?.config?.defaultValue,
-    'onUpdate:value': value => {
-      dataSource[dataIndex] = value
-    },
-  })
+  if (!dataSource[dataIndex])
+    dataSource[dataIndex] = edit.config?.defaultValue
+
+  return (
+    <Input
+      v-model:value={dataSource[dataIndex]}
+      placeholder={placeholderHelper(edit)}
+    />
+  )
 }
 
 // eslint-disable-next-line ts/no-explicit-any
@@ -109,12 +110,14 @@ export function selector(edit: StdDesignEdit, dataSource: any, dataIndex: any) {
       v-model:selectedKey={dataSource[dataIndex]}
       selectedKey={dataSource[dataIndex] || edit?.config?.defaultValue}
       recordValueIndex={edit.selector?.recordValueIndex}
-      selectionType={edit.selector?.selectionType}
+      selectionType={edit.selector?.selectionType ?? 'radio'}
       api={edit.selector?.api}
       columns={edit.selector?.columns}
       disableSearch={edit.selector?.disableSearch}
       getParams={edit.selector?.getParams}
       description={edit.selector?.description}
+      getCheckboxProps={edit.selector?.getCheckboxProps}
+      expandAll={edit.selector?.expandAll}
     />
   )
 }
@@ -132,13 +135,15 @@ export function switcher(edit: StdDesignEdit, dataSource: any, dataIndex: any) {
 
 // eslint-disable-next-line ts/no-explicit-any
 export function datePicker(edit: StdDesignEdit, dataSource: any, dataIndex: any) {
-  const date: Dayjs | undefined = dataSource?.[dataIndex] ? dayjs(dataSource?.[dataIndex]) : undefined
+  const date: Dayjs | undefined = dataSource?.[dataIndex] ? dayjs.unix(dataSource?.[dataIndex]) : undefined
 
   return (
     <DatePicker
-      format={DATE_FORMAT}
+      allowClear
+      format={edit?.datePicker?.format ?? DATE_FORMAT}
+      picker={edit?.datePicker?.picker}
       value={date}
-      onChange={(_, dataString) => dataSource[dataIndex] = dataString ?? undefined}
+      onChange={(_, dataString) => dataSource[dataIndex] = dayjs(dataString).unix() ?? undefined}
     />
   )
 }
@@ -152,7 +157,9 @@ export function dateRangePicker(edit: StdDesignEdit, dataSource: any, dataIndex:
 
   return (
     <RangePicker
-      format={DATE_FORMAT}
+      allowClear
+      format={edit?.datePicker?.format ?? DATE_FORMAT}
+      picker={edit?.datePicker?.picker}
       value={dates}
       onChange={(_, dateStrings: [string, string]) => dataSource[dataIndex] = dateStrings}
     />

+ 5 - 5
app/src/components/StdDesign/StdDataEntry/style.less

@@ -1,7 +1,7 @@
 .std-data-entry-action {
-    @media (max-width: 375px) {
-        display: block;
-        width: 100%;
-        margin: 10px 0;
-    }
+  @media (max-width: 375px) {
+    display: block;
+    width: 100%;
+    margin: 10px 0;
+  }
 }

+ 25 - 0
app/src/components/StdDesign/StdDataEntry/types.d.ts

@@ -0,0 +1,25 @@
+import type { DefaultOptionType } from 'ant-design-vue/es/vc-cascader'
+
+export interface Author {
+  id?: number
+  name: string
+  checked?: boolean
+  sort?: number
+  affiliated_unit?: string
+}
+
+export interface AuthorSelector {
+  input?: {
+    title?: () => string
+    placeholder?: () => string
+  }
+  checkbox?: {
+    title?: () => string
+    placeholder?: () => string
+  }
+  select?: {
+    title?: () => string
+    placeholder?: () => string
+    options?: DefaultOptionType[]
+  }
+}

+ 141 - 0
app/src/components/StdDesign/StdDetail/StdDetail.vue

@@ -0,0 +1,141 @@
+<script setup lang="ts" generic="T extends ModelBase">
+import type { ModelBase } from '@/api/curd'
+import type Curd from '@/api/curd'
+import type { Column, StdDesignEdit } from '@/components/StdDesign/types'
+import type { ButtonProps, FormInstance } from 'ant-design-vue'
+import type { DataIndex } from 'ant-design-vue/es/vc-table/interface'
+import { labelRender } from '@/components/StdDesign/StdDataEntry'
+import { message } from 'ant-design-vue'
+
+import _, { get } from 'lodash'
+
+const props = defineProps<{
+  title?: string
+  dataSource?: T
+  api: Curd<T>
+  columns: Column[]
+  actionButtonProps?: ButtonProps
+  useOutsideData?: boolean
+}>()
+
+const detail = ref(props.dataSource) as Ref<T | undefined>
+const editModel = ref({}) as Ref<T | undefined>
+const editStatus = ref(false)
+const loading = ref(false)
+
+const formRef = ref<FormInstance>()
+
+watch(() => props.dataSource, val => detail.value = val)
+
+async function save() {
+  try {
+    await formRef.value?.validate()
+    loading.value = true
+    props.api.save(editModel.value?.id, editModel.value).then(res => {
+      detail.value = res
+      editStatus.value = false
+    }).catch(() => {
+      message.error('Save failed')
+    }).finally(() => loading.value = false)
+  }
+  catch {
+    message.error('Validation failed')
+  }
+}
+
+function FormController(p: { editConfig: StdDesignEdit, dataIndex?: DataIndex }) {
+  return p?.editConfig?.type?.(p.editConfig, editModel.value, p.dataIndex)
+}
+
+function CustomRender(p: { column?: Column, text: unknown, record?: T }) {
+  const { column, text, record } = p
+  return column?.customRender?.({ text, record }) ?? text ?? '/'
+}
+
+const route = useRoute()
+
+onMounted(() => {
+  if (props?.useOutsideData) {
+    editModel.value = _.cloneDeep(props.dataSource)
+    return
+  }
+
+  props.api.get(route.params.id).then(res => {
+    detail.value = res
+  })
+})
+
+function clickEdit() {
+  editModel.value = _.cloneDeep(detail.value)
+  editStatus.value = true
+}
+</script>
+
+<template>
+  <AForm
+    ref="formRef"
+    :model="editModel"
+  >
+    <ADescriptions
+      bordered
+      :title="props.title ?? $gettext('Info')"
+      :column="2"
+    >
+      <template #extra>
+        <ASpace v-if="editStatus">
+          <AButton
+            type="primary"
+            :disabled="loading"
+            :loading="loading"
+            v-bind="props.actionButtonProps"
+            @click="save"
+          >
+            {{ $gettext('Save') }}
+          </AButton>
+          <AButton
+            :disabled="loading"
+            :loading="loading"
+            v-bind="props.actionButtonProps"
+            @click="editStatus = false"
+          >
+            {{ $gettext('Cancel') }}
+          </AButton>
+        </ASpace>
+        <div v-else>
+          <AButton
+            type="primary"
+            v-bind="props.actionButtonProps"
+            @click="clickEdit"
+          >
+            {{ $gettext('Edit') }}
+          </AButton>
+          <slot name="extra" />
+        </div>
+      </template>
+      <ADescriptionsItem
+        v-for="c in props.columns.filter(c => c.dataIndex !== 'action')"
+        :key="c.dataIndex?.toString()"
+        :label="$gettext(labelRender(c.title) ?? '')"
+      >
+        <AFormItem
+          v-if="editStatus && c.edit"
+          class="mb-0"
+          :name="c.dataIndex?.toString()"
+          :required="c?.edit?.config?.required"
+        >
+          <FormController
+            :edit-config="c.edit"
+            :data-index="c.dataIndex"
+          />
+        </AFormItem>
+        <span v-else>
+          <CustomRender
+            :column="c"
+            :text="get(detail, c.dataIndex as any)"
+            :record="detail"
+          />
+        </span>
+      </ADescriptionsItem>
+    </ADescriptions>
+  </AForm>
+</template>

+ 53 - 14
app/src/components/StdDesign/types.d.ts

@@ -1,33 +1,52 @@
+/* eslint-disable ts/no-explicit-any */
+
+import type { Pagination } from '@/api/curd'
 import type Curd from '@/api/curd'
+
 import type { TableColumnType } from 'ant-design-vue'
-import type { Ref } from 'vue'
+import type { RuleObject } from 'ant-design-vue/es/form'
 import type { JSX } from 'vue/jsx'
 
 export type JSXElements = JSX.Element[]
 
+// use for select-option
+export type StdDesignMask =
+  Record<string | number, string | (() => string)>
+  | (() => Promise<Record<string | number, string>>)
+
 export interface StdDesignEdit {
+
   type?: (edit: StdDesignEdit, dataSource: any, dataIndex: any) => JSX.Element // component type
 
   show?: (dataSource: any) => boolean // show component or not
 
   batch?: boolean // batch edit
 
-  mask?: Record<string | number, string | (() => string)> | (() => Promise<Record<string | number, string>>) // use for select-option
+  mask?: StdDesignMask
 
-  rules?: [] // validator rules
+  rules?: RuleObject[] // validator rules
 
   hint?: string | (() => string) // hint form item
 
   actualDataIndex?: string
 
+  datePicker?: {
+    picker?: 'date' | 'week' | 'month' | 'year' | 'quarter'
+    format?: string
+  }
+
+  cascader?: {
+    api: () => Promise<any>
+    fieldNames: Record<string, string>
+  }
+
   select?: {
     multiple?: boolean
   }
 
   selector?: {
-    getParams?: object
-    recordValueIndex: any // relative to api return
-    selectionType: any
+    getParams?: Record<string | number, any>
+    selectionType?: 'radio' | 'checkbox'
     api: Curd
     valueApi?: Curd
     columns: any
@@ -36,6 +55,9 @@ export interface StdDesignEdit {
     bind?: any
     itemKey?: any // default is id
     dataSourceValueIndex?: any // relative to dataSource
+    recordValueIndex?: any // relative to dataSource
+    getCheckboxProps?: (record: any) => any
+    expandAll?: boolean
   } // StdSelector Config
 
   upload?: {
@@ -73,14 +95,13 @@ export interface StdDesignEdit {
   flex?: Flex
 }
 
-type FlexType = string | number | boolean
-
 export interface Flex {
-  sm?: FlexType
-  md?: FlexType
-  lg?: FlexType
-  xl?: FlexType
-  xxl?: FlexType
+  // eslint-disable-next-line sonarjs/use-type-alias
+  sm?: string | number | boolean
+  md?: string | number | boolean
+  lg?: string | number | boolean
+  xl?: string | number | boolean
+  xxl?: string | number | boolean
 }
 
 export interface Column extends TableColumnType {
@@ -98,9 +119,11 @@ export interface Column extends TableColumnType {
   hiddenInExport?: boolean
   import?: boolean
   batch?: boolean
+  radio?: boolean
+  mask?: StdDesignMask
   customRender?: function
   selector?: {
-    getParams?: object
+    getParams?: Record<string | number, any>
     recordValueIndex: any // relative to api return
     selectionType: any
     api: Curd
@@ -111,5 +134,21 @@ export interface Column extends TableColumnType {
     bind?: any
     itemKey?: any // default is id
     dataSourceValueIndex?: any // relative to dataSource
+    getCheckboxProps?: (record: any) => any
   }
 }
+
+export interface StdTableResponse {
+  data: any[]
+  pagination: Pagination
+}
+
+export interface BulkActionOptions {
+  text: () => string
+  action: (rows: (number | string)[] | undefined) => Promise<boolean>
+}
+
+export type BulkActions = Record<string, BulkActionOptions | boolean> & {
+  delete?: boolean | BulkActionOptions
+  recover?: boolean | BulkActionOptions
+}

+ 2 - 2
app/src/views/certificate/ACMEUser.vue

@@ -1,6 +1,6 @@
 <script setup lang="tsx">
 import type { AcmeUser } from '@/api/acme_user'
-import type { CustomRenderProps } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
+import type { CustomRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
 import type { Column } from '@/components/StdDesign/types'
 import acme_user from '@/api/acme_user'
 import { StdCurd } from '@/components/StdDesign/StdDataDisplay'
@@ -64,7 +64,7 @@ const columns: Column[] = [
   {
     title: () => $gettext('Status'),
     dataIndex: ['registration', 'body', 'status'],
-    customRender: (args: CustomRenderProps) => {
+    customRender: (args: CustomRender) => {
       if (args.text === 'valid')
         return <Tag color="green">{$gettext('Valid')}</Tag>
 

+ 4 - 4
app/src/views/certificate/CertificateList/certColumns.tsx

@@ -1,4 +1,4 @@
-import type { CustomRenderProps } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
+import type { CustomRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
 import type { Column, JSXElements } from '@/components/StdDesign/types'
 import { datetime, mask } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
 import { input } from '@/components/StdDesign/StdDataEntry'
@@ -11,7 +11,7 @@ const columns: Column[] = [{
   dataIndex: 'name',
   sorter: true,
   pithy: true,
-  customRender: (args: CustomRenderProps) => {
+  customRender: (args: CustomRender) => {
     const { text, record } = args
     if (!text)
       return h('div', record.domain)
@@ -24,7 +24,7 @@ const columns: Column[] = [{
 }, {
   title: () => $gettext('Type'),
   dataIndex: 'auto_cert',
-  customRender: (args: CustomRenderProps) => {
+  customRender: (args: CustomRender) => {
     const template: JSXElements = []
     const { text } = args
     const sync = $gettext('Sync Certificate')
@@ -68,7 +68,7 @@ const columns: Column[] = [{
   title: () => $gettext('Status'),
   dataIndex: 'certificate_info',
   pithy: true,
-  customRender: (args: CustomRenderProps) => {
+  customRender: (args: CustomRender) => {
     const template: JSXElements = []
 
     const text = args.text?.not_before

+ 2 - 2
app/src/views/certificate/DNSCredential.vue

@@ -1,5 +1,5 @@
 <script setup lang="tsx">
-import type { CustomRenderProps } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
+import type { CustomRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
 import type { Column } from '@/components/StdDesign/types'
 import dns_credential from '@/api/dns_credential'
 import StdCurd from '@/components/StdDesign/StdDataDisplay/StdCurd.vue'
@@ -18,7 +18,7 @@ const columns: Column[] = [{
 }, {
   title: () => $gettext('Provider'),
   dataIndex: ['config', 'name'],
-  customRender: (args: CustomRenderProps) => {
+  customRender: (args: CustomRender) => {
     return args.record.provider
   },
   sorter: true,

+ 2 - 2
app/src/views/config/configColumns.ts

@@ -1,4 +1,4 @@
-import type { CustomRenderProps } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
+import type { CustomRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
 import type { JSXElements } from '@/components/StdDesign/types'
 import { datetime } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
 import { input } from '@/components/StdDesign/StdDataEntry'
@@ -15,7 +15,7 @@ const configColumns = [{
 }, {
   title: () => $gettext('Type'),
   dataIndex: 'is_dir',
-  customRender: (args: CustomRenderProps) => {
+  customRender: (args: CustomRender) => {
     const template: JSXElements = []
     const { text } = args
     if (text === true || text > 0)

+ 3 - 3
app/src/views/environment/envColumns.tsx

@@ -1,4 +1,4 @@
-import type { CustomRenderProps } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
+import type { CustomRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
 import type { Column, JSXElements } from '@/components/StdDesign/types'
 import { datetime } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
 import { input, switcher } from '@/components/StdDesign/StdDataEntry'
@@ -74,7 +74,7 @@ const columns: Column[] = [{
 {
   title: () => $gettext('Status'),
   dataIndex: 'status',
-  customRender: (args: CustomRenderProps) => {
+  customRender: (args: CustomRender) => {
     const template: JSXElements = []
     const { text } = args
     if (args.record.enabled) {
@@ -99,7 +99,7 @@ const columns: Column[] = [{
 }, {
   title: () => $gettext('Enabled'),
   dataIndex: 'enabled',
-  customRender: (args: CustomRenderProps) => {
+  customRender: (args: CustomRender) => {
     const template: JSXElements = []
     const { text } = args
     if (text === true || text > 0)

+ 3 - 3
app/src/views/notification/notificationColumns.tsx

@@ -1,4 +1,4 @@
-import type { CustomRenderProps } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
+import type { CustomRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
 import type { Column } from '@/components/StdDesign/types'
 import { detailRender } from '@/components/Notification/detailRender'
 import { datetime } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
@@ -8,7 +8,7 @@ import { Tag } from 'ant-design-vue'
 const columns: Column[] = [{
   title: () => $gettext('Type'),
   dataIndex: 'type',
-  customRender: (args: CustomRenderProps) => {
+  customRender: (args: CustomRender) => {
     if (args.text === NotificationTypeT.Error) {
       return (
         <Tag color="error">
@@ -43,7 +43,7 @@ const columns: Column[] = [{
 }, {
   title: () => $gettext('Title'),
   dataIndex: 'title',
-  customRender: (args: CustomRenderProps) => {
+  customRender: (args: CustomRender) => {
     return h('span', $gettext(args.text))
   },
   pithy: true,

+ 2 - 2
app/src/views/preference/AuthSettings.vue

@@ -1,6 +1,6 @@
 <script setup lang="tsx">
 import type { BannedIP, Settings } from '@/api/settings'
-import type { CustomRenderProps } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
+import type { CustomRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
 import type { Ref } from 'vue'
 import setting from '@/api/settings'
 import TOTP from '@/views/preference/components/TOTP.vue'
@@ -19,7 +19,7 @@ const bannedIPColumns = [{
 }, {
   title: $gettext('Banned Until'),
   dataIndex: 'expired_at',
-  customRender: (args: CustomRenderProps) => {
+  customRender: (args: CustomRender) => {
     return dayjs.unix(args.text).format('YYYY-MM-DD HH:mm:ss')
   },
 }, {

+ 2 - 2
app/src/views/site/site_list/columns.tsx

@@ -2,7 +2,7 @@ import type { Column, JSXElements } from '@/components/StdDesign/types'
 import site_category from '@/api/site_category'
 import {
   actualValueRender,
-  type CustomRenderProps,
+  type CustomRender,
   datetime,
 } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
 import { input, select, selector } from '@/components/StdDesign/StdDataEntry'
@@ -37,7 +37,7 @@ const columns: Column[] = [{
 }, {
   title: () => $gettext('Status'),
   dataIndex: 'enabled',
-  customRender: (args: CustomRenderProps) => {
+  customRender: (args: CustomRender) => {
     const template: JSXElements = []
     const { text } = args
     if (text === true || text > 0) {

+ 2 - 2
app/src/views/stream/StreamList.vue

@@ -1,5 +1,5 @@
 <script setup lang="tsx">
-import type { CustomRenderProps } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
+import type { CustomRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
 import type { Column, JSXElements } from '@/components/StdDesign/types'
 import stream from '@/api/stream'
 import StdTable from '@/components/StdDesign/StdDataDisplay/StdTable.vue'
@@ -21,7 +21,7 @@ const columns: Column[] = [{
 }, {
   title: () => $gettext('Status'),
   dataIndex: 'enabled',
-  customRender: (args: CustomRenderProps) => {
+  customRender: (args: CustomRender) => {
     const template: JSXElements = []
     const { text } = args
     if (text === true || text > 0) {

+ 2 - 2
app/src/views/user/userColumns.tsx

@@ -1,4 +1,4 @@
-import type { CustomRenderProps } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
+import type { CustomRender } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
 import type { Column, JSXElements } from '@/components/StdDesign/types'
 import { datetime } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
 import { input, password } from '@/components/StdDesign/StdDataEntry'
@@ -31,7 +31,7 @@ const columns: Column[] = [{
 }, {
   title: () => $gettext('2FA'),
   dataIndex: 'enabled_2fa',
-  customRender: (args: CustomRenderProps) => {
+  customRender: (args: CustomRender) => {
     const template: JSXElements = []
     const { text } = args
     if (text === true || text > 0)

+ 2 - 0
internal/config/config_list.go

@@ -33,6 +33,8 @@ func (c ConfigsSort) Less(i, j int) bool {
 		flag = boolToInt(c.ConfigList[i].IsDir) > boolToInt(c.ConfigList[j].IsDir)
 	case "enabled":
 		flag = boolToInt(c.ConfigList[i].Enabled) > boolToInt(c.ConfigList[j].Enabled)
+	case "site_category_id":
+		flag = c.ConfigList[i].SiteCategoryID > c.ConfigList[j].SiteCategoryID
 	}
 
 	if c.Order == "asc" {