Просмотр исходного кода

feat(web-admin): add useServerColumnFilters for list column queries.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 месяцев назад
Родитель
Сommit
e492cedab9

+ 38 - 2
web-admin/src/components/business/column-header-filter/index.vue

@@ -118,6 +118,7 @@
               size="small"
               clearable
               style="width: 100%"
+              @visible-change="onDatePickerVisibleChange"
             />
           </div>
           <div class="field">
@@ -130,6 +131,7 @@
               size="small"
               clearable
               style="width: 100%"
+              @visible-change="onDatePickerVisibleChange"
             />
           </div>
           <p class="field-tip">闭区间:含起止当天</p>
@@ -154,7 +156,7 @@
 </template>
 
 <script setup lang="ts">
-  import { computed, ref, watch } from 'vue'
+  import { computed, onUnmounted, ref, watch } from 'vue'
   import { ElMessage } from 'element-plus'
   import { Filter, Delete, Close, Check } from '@element-plus/icons-vue'
   import {
@@ -192,6 +194,9 @@
   const draftMax = ref('')
   const draftFrom = ref('')
   const draftTo = ref('')
+  /** 日期面板 teleport 到 body,点选日期会被 popover 当成外部点击;短暂抑制关闭 */
+  const holdPopoverOpen = ref(false)
+  let holdPopoverTimer: ReturnType<typeof setTimeout> | undefined
 
   const typeHint = computed(() => {
     if (props.type === 'text') return '文本:搜索并勾选本页出现的值'
@@ -212,7 +217,7 @@
   function resetDraftFromModel() {
     const m = props.modelValue
     if (props.type === 'text' || props.type === 'enum') {
-      const s = (m || { selected: [], keyword: '' }) as TextColumnFilterState
+      const s = (m || { text: '', selected: [], keyword: '' }) as TextColumnFilterState
       draftSelected.value = [...(s.selected || [])]
       draftKeyword.value = s.keyword || ''
       return
@@ -232,12 +237,35 @@
     }
   }
 
+  function onDatePickerVisibleChange(open: boolean) {
+    if (holdPopoverTimer !== undefined) {
+      clearTimeout(holdPopoverTimer)
+      holdPopoverTimer = undefined
+    }
+    if (open) {
+      holdPopoverOpen.value = true
+      return
+    }
+    // 选中日期后,同一轮点击会触发 popover 的 click-outside,延迟释放抑制
+    holdPopoverOpen.value = true
+    holdPopoverTimer = setTimeout(() => {
+      holdPopoverOpen.value = false
+      holdPopoverTimer = undefined
+    }, 200)
+  }
+
   function onVisibleChange(next: boolean) {
+    if (!next && holdPopoverOpen.value) return
     if (next) resetDraftFromModel()
     visible.value = next
   }
 
   function close() {
+    holdPopoverOpen.value = false
+    if (holdPopoverTimer !== undefined) {
+      clearTimeout(holdPopoverTimer)
+      holdPopoverTimer = undefined
+    }
     visible.value = false
   }
 
@@ -263,6 +291,7 @@
   function buildState(): ColumnFilterState {
     if (props.type === 'text' || props.type === 'enum') {
       return {
+        text: '',
         selected: [...draftSelected.value],
         keyword: draftKeyword.value
       }
@@ -321,6 +350,13 @@
       if (visible.value) resetDraftFromModel()
     }
   )
+
+  onUnmounted(() => {
+    if (holdPopoverTimer !== undefined) {
+      clearTimeout(holdPopoverTimer)
+      holdPopoverTimer = undefined
+    }
+  })
 </script>
 
 <style scoped lang="scss">

+ 25 - 6
web-admin/src/composables/useClientColumnFilters.ts

@@ -19,9 +19,12 @@ export interface ColumnFilterDef {
   getValue?: (row: any) => unknown
 }
 
+/** 文本列:服务端模糊 / 客户端也可只用 text */
 export interface TextColumnFilterState {
-  selected: string[]
-  keyword: string
+  text: string
+  /** @deprecated 旧客户端勾选;服务端 text 不用 */
+  selected?: string[]
+  keyword?: string
 }
 
 export interface NumberColumnFilterState {
@@ -63,7 +66,8 @@ function defaultState(type: ColumnFilterType): ColumnFilterState {
   if (type === 'datetime') {
     return { from: '', to: '' }
   }
-  return { selected: [], keyword: '' }
+  // text / enum 共用形状;enum 主要用 selected,text 主要用 text
+  return { text: '', selected: [] }
 }
 
 export function isColumnFilterActive(
@@ -71,7 +75,12 @@ export function isColumnFilterActive(
   state?: ColumnFilterState | null
 ): boolean {
   if (!state) return false
-  if (type === 'text' || type === 'enum') {
+  if (type === 'text') {
+    const s = state as TextColumnFilterState
+    if (String(s.text || '').trim()) return true
+    return Array.isArray(s.selected) && s.selected.length > 0
+  }
+  if (type === 'enum') {
     const s = state as TextColumnFilterState
     return Array.isArray(s.selected) && s.selected.length > 0
   }
@@ -103,10 +112,20 @@ function resolveCompareKey(row: any, def: ColumnFilterDef): string {
 function matchOne(row: any, def: ColumnFilterDef, state: ColumnFilterState): boolean {
   if (!isColumnFilterActive(def.type, state)) return true
 
-  if (def.type === 'text' || def.type === 'enum') {
+  if (def.type === 'text') {
+    const s = state as TextColumnFilterState
+    const text = String(s.text || '').trim()
+    if (text) {
+      return String(resolveRawValue(row, def) ?? '').includes(text)
+    }
+    const key = resolveCompareKey(row, def)
+    return Array.isArray(s.selected) && s.selected.includes(key)
+  }
+
+  if (def.type === 'enum') {
     const s = state as TextColumnFilterState
     const key = resolveCompareKey(row, def)
-    return s.selected.includes(key)
+    return Array.isArray(s.selected) && s.selected.includes(key)
   }
 
   if (def.type === 'number') {

+ 157 - 0
web-admin/src/composables/useServerColumnFilters.ts

@@ -0,0 +1,157 @@
+import { computed, reactive, watch, type Ref } from 'vue'
+import {
+  isColumnFilterActive,
+  type ColumnFilterDef,
+  type ColumnFilterState,
+  type DatetimeColumnFilterState,
+  type NumberColumnFilterState,
+  type TextColumnFilterState
+} from '@/composables/useClientColumnFilters'
+
+/**
+ * 服务端列条件:管理 state、toQueryParams、藏列清条件。
+ * toQueryParams 本页(报销申请)YAGNI 写死映射 applyDateFrom / amountOp / statusIn 等;
+ * 日后推广可在 ColumnFilterDef 增加可选 queryKey。
+ */
+export function useServerColumnFilters(
+  columnDefs: ColumnFilterDef[],
+  options?: {
+    initialFilters?: Record<string, ColumnFilterState | null>
+    onChange?: () => void
+  }
+) {
+  const defMap = new Map(columnDefs.map((d) => [d.prop, d]))
+  const filters = reactive<Record<string, ColumnFilterState>>({})
+
+  function applyInitial() {
+    const init = options?.initialFilters || {}
+    Object.keys(filters).forEach((k) => delete filters[k])
+    for (const [prop, state] of Object.entries(init)) {
+      const def = defMap.get(prop)
+      if (!def || !state || !isColumnFilterActive(def.type, state)) continue
+      filters[prop] = JSON.parse(JSON.stringify(state))
+    }
+  }
+  applyInitial()
+
+  const activeFilterProps = computed(() =>
+    Object.keys(filters).filter((prop) => {
+      const def = defMap.get(prop)
+      return def && isColumnFilterActive(def.type, filters[prop])
+    })
+  )
+  const hasActiveFilters = computed(() => activeFilterProps.value.length > 0)
+
+  function setFilterState(prop: string, state: ColumnFilterState | null) {
+    const def = defMap.get(prop)
+    if (!def) return
+    if (!state || !isColumnFilterActive(def.type, state)) {
+      delete filters[prop]
+    } else {
+      filters[prop] = JSON.parse(JSON.stringify(state))
+    }
+    options?.onChange?.()
+  }
+
+  function clearFilter(prop: string) {
+    if (!(prop in filters)) return
+    delete filters[prop]
+    options?.onChange?.()
+  }
+
+  function clearAllFilters() {
+    const had = Object.keys(filters).length > 0
+    Object.keys(filters).forEach((k) => delete filters[k])
+    if (had) options?.onChange?.()
+  }
+
+  function resetToInitial() {
+    applyInitial()
+    options?.onChange?.()
+  }
+
+  function isFilterActive(prop: string) {
+    const def = defMap.get(prop)
+    if (!def) return false
+    return isColumnFilterActive(def.type, filters[prop])
+  }
+
+  /** 隐藏列:清该列条件;若确实清过则触发 onChange */
+  function onColumnHidden(prop: string) {
+    if (!(prop in filters)) return
+    delete filters[prop]
+    options?.onChange?.()
+  }
+
+  function toQueryParams(): Record<string, string> {
+    const q: Record<string, string> = {}
+    for (const prop of Object.keys(filters)) {
+      const def = defMap.get(prop)
+      const state = filters[prop]
+      if (!def || !isColumnFilterActive(def.type, state)) continue
+
+      if (def.type === 'datetime') {
+        // 本页仅 applyTime → applyDateFrom / applyDateTo
+        const s = state as DatetimeColumnFilterState
+        if (s.from) q.applyDateFrom = s.from
+        if (s.to) q.applyDateTo = s.to
+        continue
+      }
+      if (def.type === 'number') {
+        // 本页仅 amount → amountOp / amountMin|amountMax|amount
+        const s = state as NumberColumnFilterState
+        q.amountOp = s.op || 'between'
+        if (s.op === 'between') {
+          if (s.min !== '' && s.min != null) q.amountMin = String(s.min)
+          if (s.max !== '' && s.max != null) q.amountMax = String(s.max)
+        } else if (s.value !== '' && s.value != null) {
+          q.amount = String(s.value)
+        }
+        continue
+      }
+      if (def.type === 'enum') {
+        // 本页仅 status → statusIn
+        const s = state as TextColumnFilterState
+        if (s.selected?.length) q.statusIn = s.selected.join(',')
+        continue
+      }
+      // text:prop 直接作为 query key(applyCode/title/expenseName/currentNode)
+      const s = state as TextColumnFilterState
+      const text = String(s.text || '').trim()
+      if (text) q[prop] = text
+    }
+    return q
+  }
+
+  /**
+   * 监听可见列:从有→无时 onColumnHidden。
+   * visibleProps: Ref<string[]> 当前可见的业务列 prop 列表。
+   */
+  function watchVisibleColumns(visibleProps: Ref<string[]>) {
+    watch(
+      visibleProps,
+      (next, prev) => {
+        if (!prev) return
+        const nextSet = new Set(next || [])
+        for (const prop of prev) {
+          if (!nextSet.has(prop)) onColumnHidden(prop)
+        }
+      },
+      { deep: true }
+    )
+  }
+
+  return {
+    filters,
+    hasActiveFilters,
+    activeFilterProps,
+    setFilterState,
+    clearFilter,
+    clearAllFilters,
+    resetToInitial,
+    isFilterActive,
+    onColumnHidden,
+    toQueryParams,
+    watchVisibleColumns
+  }
+}