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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-09-06 07:47:15 +08:00
1 parent 36afab503a
commit e492cedab9
3 files changed
+220 -8

No files matched your search

@@ -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">
@@ -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') {
@@ -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
}
}