2026-08-01 12:17:33 +08:00
|
|
|
<script setup lang="ts">
|
2026-08-06 19:03:13 +08:00
|
|
|
import { computed, inject, ref, watch } from 'vue'
|
|
|
|
|
import { Search, RotateCcw, Settings2 } from '@lucide/vue'
|
|
|
|
|
import LayoutPreferenceList, { type LayoutPreferenceItem } from './LayoutPreferenceList.vue'
|
|
|
|
|
import {
|
|
|
|
|
UI_PREFERENCE_SCOPE,
|
|
|
|
|
readLayoutPreference,
|
|
|
|
|
removeLayoutPreference,
|
|
|
|
|
writeLayoutPreference,
|
|
|
|
|
type LayoutPreferenceSnapshot,
|
|
|
|
|
} from '@/shared/utils/uiLayoutPreference'
|
2026-08-01 12:17:33 +08:00
|
|
|
|
|
|
|
|
defineOptions({ inheritAttrs: false })
|
|
|
|
|
|
|
|
|
|
export type QueryFieldType = 'input' | 'select' | 'date' | 'daterange'
|
|
|
|
|
export type QueryValue = string | number | boolean | null | undefined | string[] | number[] | Date | Date[]
|
|
|
|
|
export type QueryModel = Record<string, QueryValue>
|
|
|
|
|
|
|
|
|
|
export interface QueryOption {
|
|
|
|
|
label: string
|
|
|
|
|
value: string | number | boolean
|
|
|
|
|
disabled?: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface QueryField {
|
|
|
|
|
key: string
|
|
|
|
|
type: QueryFieldType
|
2026-08-06 19:03:13 +08:00
|
|
|
label: string
|
2026-08-04 21:01:39 +08:00
|
|
|
width?: string | number
|
2026-08-01 12:17:33 +08:00
|
|
|
placeholder?: string
|
|
|
|
|
options?: QueryOption[]
|
|
|
|
|
props?: Record<string, unknown>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const props = withDefaults(defineProps<{
|
|
|
|
|
ariaLabel: string
|
|
|
|
|
fields: QueryField[]
|
2026-08-06 19:03:13 +08:00
|
|
|
availableFields?: QueryField[]
|
|
|
|
|
preferenceKey?: string
|
2026-08-01 12:17:33 +08:00
|
|
|
searchText?: string
|
|
|
|
|
resetText?: string
|
|
|
|
|
searchOnReset?: boolean
|
2026-08-14 14:27:03 +08:00
|
|
|
loading?: boolean
|
2026-08-01 12:17:33 +08:00
|
|
|
}>(), {
|
|
|
|
|
searchText: '查询',
|
|
|
|
|
resetText: '重置',
|
|
|
|
|
searchOnReset: true,
|
2026-08-14 14:27:03 +08:00
|
|
|
loading: false,
|
2026-08-01 12:17:33 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const model = defineModel<QueryModel>({ required: true })
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
search: [model: QueryModel]
|
|
|
|
|
reset: [model: QueryModel]
|
|
|
|
|
}>()
|
2026-08-06 19:03:13 +08:00
|
|
|
const preferenceScope = inject(UI_PREFERENCE_SCOPE, computed(() => 'anonymous'))
|
|
|
|
|
const preference = ref<LayoutPreferenceSnapshot>({ version: 1, order: [], hidden: [] })
|
|
|
|
|
const preferenceFields = computed(() => props.availableFields ?? props.fields)
|
|
|
|
|
const preferenceFieldKeys = computed(() => preferenceFields.value.map((field) => field.key))
|
|
|
|
|
const preferenceEnabled = computed(() => Boolean(props.preferenceKey))
|
|
|
|
|
|
|
|
|
|
function fieldLabel(field: QueryField) {
|
|
|
|
|
const ariaLabel = field.props?.['aria-label']
|
|
|
|
|
return field.label || (typeof ariaLabel === 'string' ? ariaLabel : '') || field.placeholder || field.key
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyPreference() {
|
|
|
|
|
preference.value = readLayoutPreference(
|
|
|
|
|
'query',
|
|
|
|
|
preferenceScope.value,
|
|
|
|
|
props.preferenceKey || '',
|
|
|
|
|
preferenceFieldKeys.value,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
[preferenceScope, () => props.preferenceKey, () => preferenceFieldKeys.value.join('\u0000')],
|
|
|
|
|
applyPreference,
|
|
|
|
|
{ immediate: true },
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const orderedPreferenceFields = computed(() => {
|
|
|
|
|
const fieldsByKey = new Map(preferenceFields.value.map((field) => [field.key, field]))
|
|
|
|
|
return preference.value.order.flatMap((key) => {
|
|
|
|
|
const field = fieldsByKey.get(key)
|
|
|
|
|
return field ? [field] : []
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
const orderedFields = computed(() => {
|
|
|
|
|
const visibleKeys = new Set(props.fields.map((field) => field.key))
|
|
|
|
|
return orderedPreferenceFields.value.filter((field) => visibleKeys.has(field.key))
|
|
|
|
|
})
|
|
|
|
|
const preferenceItems = computed<LayoutPreferenceItem[]>(() => orderedPreferenceFields.value.map((field) => ({
|
|
|
|
|
key: field.key,
|
|
|
|
|
label: fieldLabel(field),
|
|
|
|
|
visible: true,
|
|
|
|
|
hideable: false,
|
|
|
|
|
movable: true,
|
|
|
|
|
})))
|
|
|
|
|
|
|
|
|
|
function persistPreference(order: string[]) {
|
|
|
|
|
preference.value = { version: 1, order, hidden: [] }
|
|
|
|
|
writeLayoutPreference('query', preferenceScope.value, props.preferenceKey || '', preference.value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function moveField(key: string, targetIndex: number) {
|
|
|
|
|
const order = [...preference.value.order]
|
|
|
|
|
const currentIndex = order.indexOf(key)
|
|
|
|
|
if (currentIndex < 0) return
|
|
|
|
|
order.splice(currentIndex, 1)
|
|
|
|
|
order.splice(Math.min(Math.max(targetIndex, 0), order.length), 0, key)
|
|
|
|
|
persistPreference(order)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resetPreference() {
|
|
|
|
|
removeLayoutPreference('query', preferenceScope.value, props.preferenceKey || '')
|
|
|
|
|
applyPreference()
|
|
|
|
|
}
|
2026-08-01 12:17:33 +08:00
|
|
|
|
|
|
|
|
function updateField(key: string, value: QueryValue) {
|
|
|
|
|
model.value = { ...model.value, [key]: value }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function defaultPlaceholder(field: QueryField) {
|
|
|
|
|
if (field.type === 'input') return '请输入关键词'
|
|
|
|
|
if (field.type === 'select') return '全部'
|
|
|
|
|
return field.type === 'daterange' ? '请选择日期范围' : '请选择日期'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function datePickerType(field: QueryField): 'date' | 'daterange' {
|
|
|
|
|
return field.type === 'daterange' ? 'daterange' : 'date'
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 21:01:39 +08:00
|
|
|
function fieldStyle(field: QueryField) {
|
|
|
|
|
if (field.width === undefined) return undefined
|
|
|
|
|
const width = typeof field.width === 'number' ? `${field.width}px` : field.width
|
|
|
|
|
return { '--query-field-width': width }
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 12:17:33 +08:00
|
|
|
function resetValue(field: QueryField): QueryValue {
|
|
|
|
|
return field.type === 'daterange' ? [] : ''
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function search() {
|
2026-08-14 14:27:03 +08:00
|
|
|
if (props.loading) return
|
2026-08-01 12:17:33 +08:00
|
|
|
emit('search', model.value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function reset() {
|
2026-08-14 14:27:03 +08:00
|
|
|
if (props.loading) return
|
2026-08-01 12:17:33 +08:00
|
|
|
const next = { ...model.value }
|
|
|
|
|
for (const field of props.fields) next[field.key] = resetValue(field)
|
|
|
|
|
model.value = next
|
|
|
|
|
emit('reset', next)
|
|
|
|
|
if (props.searchOnReset) emit('search', next)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 14:27:03 +08:00
|
|
|
function searchFromTextInput(event: KeyboardEvent) {
|
|
|
|
|
if (event.isComposing || event.repeat) return
|
|
|
|
|
search()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 12:17:33 +08:00
|
|
|
defineExpose({ search, reset })
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
<section
|
|
|
|
|
v-bind="$attrs"
|
|
|
|
|
class="query-toolbar"
|
|
|
|
|
:aria-label="ariaLabel"
|
|
|
|
|
>
|
|
|
|
|
<div class="query-toolbar__filters">
|
2026-08-14 10:09:51 +08:00
|
|
|
<div
|
|
|
|
|
v-for="(field, index) in orderedFields"
|
|
|
|
|
:key="field.key"
|
|
|
|
|
:class="['query-toolbar__field', { 'query-toolbar__tail': index === orderedFields.length - 1 }]"
|
|
|
|
|
:style="fieldStyle(field)"
|
|
|
|
|
>
|
|
|
|
|
<div class="query-toolbar__control">
|
|
|
|
|
<el-input
|
2026-08-01 12:17:33 +08:00
|
|
|
v-if="field.type === 'input'"
|
|
|
|
|
:model-value="model[field.key] as string"
|
|
|
|
|
:placeholder="field.placeholder || defaultPlaceholder(field)"
|
|
|
|
|
clearable
|
|
|
|
|
v-bind="field.props"
|
|
|
|
|
@update:model-value="updateField(field.key, $event)"
|
2026-08-14 14:27:03 +08:00
|
|
|
@keyup.enter.exact="searchFromTextInput"
|
2026-08-14 10:09:51 +08:00
|
|
|
>
|
|
|
|
|
<template #prefix><Search :size="16" /></template>
|
|
|
|
|
</el-input>
|
|
|
|
|
<el-select
|
2026-08-01 12:17:33 +08:00
|
|
|
v-else-if="field.type === 'select'"
|
|
|
|
|
:model-value="model[field.key] as string | number | boolean | undefined"
|
|
|
|
|
:placeholder="field.placeholder || defaultPlaceholder(field)"
|
|
|
|
|
clearable
|
|
|
|
|
v-bind="field.props"
|
|
|
|
|
@update:model-value="updateField(field.key, $event)"
|
2026-08-14 10:09:51 +08:00
|
|
|
>
|
|
|
|
|
<el-option v-for="option in field.options || []" :key="String(option.value)" :label="option.label" :value="option.value" :disabled="option.disabled" />
|
|
|
|
|
</el-select>
|
|
|
|
|
<el-date-picker
|
2026-08-01 12:17:33 +08:00
|
|
|
v-else
|
|
|
|
|
:model-value="model[field.key] as string | string[] | undefined"
|
|
|
|
|
:type="datePickerType(field)"
|
|
|
|
|
:placeholder="field.placeholder || defaultPlaceholder(field)"
|
|
|
|
|
clearable
|
|
|
|
|
value-format="YYYY-MM-DD"
|
|
|
|
|
range-separator="至"
|
|
|
|
|
start-placeholder="开始日期"
|
|
|
|
|
end-placeholder="结束日期"
|
|
|
|
|
v-bind="field.props"
|
2026-08-14 10:09:51 +08:00
|
|
|
@update:model-value="updateField(field.key, $event)"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-if="index === orderedFields.length - 1" class="query-toolbar__actions">
|
2026-08-14 14:27:03 +08:00
|
|
|
<el-button type="primary" :loading="loading" @click="search"><Search :size="16" />{{ searchText }}</el-button>
|
|
|
|
|
<el-button :disabled="loading" @click="reset"><RotateCcw :size="16" />{{ resetText }}</el-button>
|
2026-08-14 10:09:51 +08:00
|
|
|
<slot name="actions" />
|
|
|
|
|
<el-popover v-if="preferenceEnabled" placement="bottom-end" :width="340" trigger="click" popper-class="cc-layout-preference-popper">
|
|
|
|
|
<template #reference>
|
|
|
|
|
<el-button aria-label="调整筛选布局" title="调整筛选布局"><Settings2 :size="16" /></el-button>
|
|
|
|
|
</template>
|
|
|
|
|
<LayoutPreferenceList :items="preferenceItems" @move="moveField" @reset="resetPreference" />
|
|
|
|
|
</el-popover>
|
|
|
|
|
</div>
|
2026-08-06 15:32:02 +08:00
|
|
|
</div>
|
2026-08-01 12:17:33 +08:00
|
|
|
</div>
|
2026-08-14 10:09:51 +08:00
|
|
|
<div v-if="orderedFields.length === 0" class="query-toolbar__actions">
|
2026-08-14 14:27:03 +08:00
|
|
|
<el-button type="primary" :loading="loading" @click="search"><Search :size="16" />{{ searchText }}</el-button>
|
|
|
|
|
<el-button :disabled="loading" @click="reset"><RotateCcw :size="16" />{{ resetText }}</el-button>
|
2026-08-01 12:17:33 +08:00
|
|
|
<slot name="actions" />
|
2026-08-06 19:03:13 +08:00
|
|
|
<el-popover v-if="preferenceEnabled" placement="bottom-end" :width="340" trigger="click" popper-class="cc-layout-preference-popper">
|
|
|
|
|
<template #reference>
|
|
|
|
|
<el-button aria-label="调整筛选布局" title="调整筛选布局"><Settings2 :size="16" /></el-button>
|
|
|
|
|
</template>
|
|
|
|
|
<LayoutPreferenceList :items="preferenceItems" @move="moveField" @reset="resetPreference" />
|
|
|
|
|
</el-popover>
|
2026-08-01 12:17:33 +08:00
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.query-toolbar { display: flex; align-items: flex-end; justify-content: flex-start; flex-wrap: wrap; gap: 10px; padding: 16px; border: 1px solid var(--cc-border); background: #fff; }
|
2026-08-06 15:32:02 +08:00
|
|
|
.query-toolbar__filters { display: flex; flex: 0 1 auto; flex-wrap: wrap; gap: 12px 16px; }
|
2026-08-14 10:09:51 +08:00
|
|
|
.query-toolbar__field, .query-toolbar__control { display: grid; width: var(--query-field-width, 260px); max-width: 100%; min-width: 0; gap: 6px; }
|
2026-08-06 15:32:02 +08:00
|
|
|
.query-toolbar__field :deep(.el-input), .query-toolbar__field :deep(.el-select), .query-toolbar__field :deep(.el-date-editor) { width: 100%; max-width: 100%; min-width: 0; }
|
2026-08-01 12:17:33 +08:00
|
|
|
.query-toolbar__actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-start; gap: 8px; }
|
|
|
|
|
.query-toolbar__actions :deep(.el-button + .el-button) { margin-left: 0; }
|
2026-08-06 19:03:13 +08:00
|
|
|
:global(.cc-layout-preference-popper) { max-width: calc(100vw - 24px); }
|
2026-08-14 10:09:51 +08:00
|
|
|
@media (min-width: 721px) { .query-toolbar__filters { display: contents; } .query-toolbar__tail { display: flex; align-items: flex-end; width: auto; flex: 0 0 auto; gap: 10px; } }
|
|
|
|
|
@media (max-width: 720px) { .query-toolbar { align-items: stretch; flex-direction: column; } .query-toolbar__filters { width: 100%; flex-direction: column; } .query-toolbar__field { width: 100%; } .query-toolbar__tail { display: contents; } .query-toolbar__control { width: 100%; } .query-toolbar__actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); width: 100%; } .query-toolbar__actions :deep(.el-button) { min-height: 40px; margin-left: 0; } }
|
2026-08-01 12:17:33 +08:00
|
|
|
</style>
|