Files
client-web/src/components/common/QueryToolbar.vue
T
2026-09-18 16:48:35 +08:00

148 lines
6.3 KiB
Vue

<script setup lang="ts">
import { Search, RotateCcw } from '@lucide/vue'
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
label?: string
type: QueryFieldType
placeholder?: string
options?: QueryOption[]
props?: Record<string, unknown>
}
const props = withDefaults(defineProps<{
layout?: 'inline' | 'grid'
ariaLabel: string
fields: QueryField[]
searchText?: string
resetText?: string
searchOnReset?: boolean
}>(), {
layout: 'inline',
searchText: '查询',
resetText: '重置',
searchOnReset: true,
})
const model = defineModel<QueryModel>({ required: true })
const emit = defineEmits<{
search: [model: QueryModel]
reset: [model: QueryModel]
}>()
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'
}
function resetValue(field: QueryField): QueryValue {
return field.type === 'daterange' ? [] : ''
}
function search() {
emit('search', model.value)
}
function reset() {
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)
}
defineExpose({ search, reset })
</script>
<template>
<section
v-bind="$attrs"
class="query-toolbar"
:class="{ 'query-toolbar--grid': layout === 'grid' }"
:aria-label="ariaLabel"
>
<div class="query-toolbar__filters">
<label v-for="field in fields" :key="field.key" class="query-toolbar__field">
<span v-if="field.label" class="query-toolbar__label">{{ field.label }}</span>
<el-input
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)"
>
<template #prefix><Search :size="16" /></template>
</el-input>
<el-select
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)"
>
<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
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"
@update:model-value="updateField(field.key, $event)"
/>
</label>
</div>
<div class="query-toolbar__actions">
<el-button class="query-toolbar__search" type="primary" @click="search"><Search :size="16" />{{ searchText }}</el-button>
<el-button class="query-toolbar__reset" @click="reset"><RotateCcw :size="16" />{{ resetText }}</el-button>
<slot name="actions" />
</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(--border); background: #fff; }
.query-toolbar__filters { display: flex; flex: 0 1 auto; flex-wrap: wrap; gap: 10px; }
.query-toolbar__field { display: flex; min-width: 0; align-items: center; gap: 9px; }
.query-toolbar__label { flex: 0 0 auto; color: #5f6877; font-size: 12px; white-space: nowrap; }
.query-toolbar__filters :deep(.el-input), .query-toolbar__filters :deep(.el-select), .query-toolbar__filters :deep(.el-date-editor) { width: 260px; max-width: 100%; min-width: 0; }
.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; }
.query-toolbar__actions :deep(.query-toolbar__search), .query-toolbar__actions :deep(.query-toolbar__reset) { min-width: 72px; }
.query-toolbar--grid { display: block; padding: var(--client-query-padding); border: 1px solid var(--client-border); border-radius: var(--client-radius); }
.query-toolbar--grid .query-toolbar__filters { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px 16px; }
.query-toolbar--grid .query-toolbar__field { min-width: 0; }
.query-toolbar--grid .query-toolbar__field :deep(.el-input), .query-toolbar--grid .query-toolbar__field :deep(.el-select), .query-toolbar--grid .query-toolbar__field :deep(.el-date-editor) { flex: 1; width: 100%; min-width: 0; }
.query-toolbar--grid .query-toolbar__actions { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--client-border); }
@media (max-width: 1200px) { .query-toolbar--grid .query-toolbar__filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .query-toolbar { align-items: stretch; flex-direction: column; } .query-toolbar__filters { width: 100%; flex-direction: column; } .query-toolbar__field { display: grid; gap: 6px; } .query-toolbar__filters :deep(.el-input), .query-toolbar__filters :deep(.el-select), .query-toolbar__filters :deep(.el-date-editor) { 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; } }
</style>