275 lines
7.8 KiB
Vue
275 lines
7.8 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* F006 通用 SearchBar 组件 (W17 设计规范 v2 整改)
|
||
*
|
||
* 按 ui-preview-v2.html 模板: 紧凑布局 + 无 label + 同行末尾 chip + 简洁按钮
|
||
*
|
||
* 用法:
|
||
* <SearchBar
|
||
* v-model:filters="filters"
|
||
* :fields="[
|
||
* { key: 'name', label: '名称', type: 'text', placeholder: '请输入' },
|
||
* { key: 'status', label: '状态', type: 'select', options: [...] }
|
||
* ]"
|
||
* @search="onSearch"
|
||
* @reset="onReset"
|
||
* />
|
||
*/
|
||
import { computed } from 'vue'
|
||
|
||
export interface SearchFieldOption {
|
||
value: any
|
||
label: string
|
||
}
|
||
|
||
export interface SearchField {
|
||
key: string
|
||
label: string
|
||
type?: 'text' | 'select' | 'date' | 'daterange' | 'number'
|
||
placeholder?: string
|
||
options?: SearchFieldOption[]
|
||
width?: number | string
|
||
clearable?: boolean
|
||
showLabel?: boolean
|
||
}
|
||
|
||
interface Props {
|
||
filters: Record<string, any>
|
||
fields: SearchField[]
|
||
showReset?: boolean
|
||
showChips?: boolean
|
||
showLabel?: boolean
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
showReset: true,
|
||
showChips: true,
|
||
showLabel: false
|
||
})
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'update:filters', filters: Record<string, any>): void
|
||
(e: 'search'): void
|
||
(e: 'reset'): void
|
||
}>()
|
||
|
||
const localFilters = computed({
|
||
get: () => props.filters,
|
||
set: (v) => emit('update:filters', v)
|
||
})
|
||
|
||
function onSearch() { emit('search') }
|
||
function onReset() {
|
||
for (const f of props.fields) {
|
||
if (f.type === 'daterange') localFilters.value[f.key] = []
|
||
else if (f.type === 'number') localFilters.value[f.key] = undefined
|
||
else localFilters.value[f.key] = ''
|
||
}
|
||
emit('reset')
|
||
}
|
||
function onEnter() { onSearch() }
|
||
function onClearField(key: string) {
|
||
const f = props.fields.find(it => it.key === key)
|
||
if (!f) return
|
||
if (f.type === 'daterange') localFilters.value[key] = []
|
||
else if (f.type === 'number') localFilters.value[key] = undefined
|
||
else localFilters.value[key] = ''
|
||
}
|
||
|
||
const activeChips = computed(() => {
|
||
return props.fields
|
||
.map(f => {
|
||
const v = props.filters[f.key]
|
||
let display: string | null = null
|
||
if (f.type === 'daterange') {
|
||
if (Array.isArray(v) && v.length === 2) display = `${v[0]} ~ ${v[1]}`
|
||
} else if (f.type === 'select' && v !== '' && v !== undefined && v !== null) {
|
||
const opt = f.options?.find(o => String(o.value) === String(v))
|
||
display = opt ? opt.label : String(v)
|
||
} else if (v !== '' && v !== undefined && v !== null) {
|
||
display = String(v)
|
||
}
|
||
if (display) {
|
||
return { key: f.key, label: `${f.label}: ${display}` }
|
||
}
|
||
return null
|
||
})
|
||
.filter(Boolean) as { key: string; label: string }[]
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="rr-search-bar">
|
||
<div class="rr-search-bar__row">
|
||
<div class="rr-search-bar__field" v-for="f in fields" :key="f.key">
|
||
<span v-if="showLabel || f.showLabel" class="rr-search-bar__label">{{ f.label }}</span>
|
||
<el-input
|
||
v-if="f.type === 'text' || !f.type"
|
||
v-model="localFilters[f.key]"
|
||
:placeholder="f.placeholder || `请输入${f.label}`"
|
||
:clearable="f.clearable !== false"
|
||
:style="{ width: (typeof f.width === 'number' ? f.width + 'px' : (f.width || '180px')) }"
|
||
@keyup.enter="onEnter"
|
||
/>
|
||
<el-select
|
||
v-else-if="f.type === 'select'"
|
||
v-model="localFilters[f.key]"
|
||
:placeholder="f.placeholder || `请选择${f.label}`"
|
||
:clearable="f.clearable !== false"
|
||
:style="{ width: (typeof f.width === 'number' ? f.width + 'px' : (f.width || '160px')) }"
|
||
>
|
||
<el-option
|
||
v-for="opt in f.options || []"
|
||
:key="opt.value"
|
||
:label="opt.label"
|
||
:value="opt.value"
|
||
/>
|
||
</el-select>
|
||
<el-date-picker
|
||
v-else-if="f.type === 'date'"
|
||
v-model="localFilters[f.key]"
|
||
type="date"
|
||
:placeholder="f.placeholder || `请选择${f.label}`"
|
||
:style="{ width: (typeof f.width === 'number' ? f.width + 'px' : (f.width || '180px')) }"
|
||
value-format="YYYY-MM-DD"
|
||
/>
|
||
<el-date-picker
|
||
v-else-if="f.type === 'daterange'"
|
||
v-model="localFilters[f.key]"
|
||
type="daterange"
|
||
range-separator="~"
|
||
:start-placeholder="f.placeholder || '开始日期'"
|
||
:end-placeholder="'结束日期'"
|
||
:style="{ width: (typeof f.width === 'number' ? f.width + 'px' : (f.width || '260px')) }"
|
||
value-format="YYYY-MM-DD"
|
||
/>
|
||
<el-input-number
|
||
v-else-if="f.type === 'number'"
|
||
v-model="localFilters[f.key]"
|
||
:placeholder="f.placeholder || `请输入${f.label}`"
|
||
:style="{ width: (typeof f.width === 'number' ? f.width + 'px' : (f.width || '180px')) }"
|
||
/>
|
||
</div>
|
||
<div class="rr-search-bar__chips" v-if="showChips && activeChips.length">
|
||
<div
|
||
v-for="c in activeChips"
|
||
:key="c.key"
|
||
class="rr-search-bar__chip"
|
||
>
|
||
{{ c.label }}
|
||
<span class="rr-search-bar__chip-close" @click="onClearField(c.key)">×</span>
|
||
</div>
|
||
</div>
|
||
<div class="rr-search-bar__actions">
|
||
<el-button type="primary" @click="onSearch">查询</el-button>
|
||
<el-button v-if="showReset" @click="onReset">重置</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped lang="scss">
|
||
/* === 统一 input/select 圆角 (按 ui-preview-v2.html 模板) === */
|
||
:deep(.el-input__wrapper),
|
||
:deep(.el-select__wrapper) {
|
||
border-radius: var(--radius, 8px);
|
||
box-shadow: 0 0 0 1px var(--rr-border-color, #E2E8F0) inset;
|
||
transition: box-shadow 0.15s, border-color 0.15s;
|
||
min-height: 34px;
|
||
}
|
||
:deep(.el-input__wrapper:hover),
|
||
:deep(.el-select__wrapper:hover) {
|
||
box-shadow: 0 0 0 1px var(--ink-3, #94A3B8) inset;
|
||
}
|
||
:deep(.el-input__wrapper.is-focus),
|
||
:deep(.el-select__wrapper.is-focused) {
|
||
box-shadow: 0 0 0 1px var(--brand, #2563EB) inset, 0 0 0 3px var(--brand-glow, rgba(37, 99, 235, 0.18));
|
||
}
|
||
/* === el-select 宽度兜底 (Vue :style 没生效时也保证最小宽度) === */
|
||
:deep(.el-select) {
|
||
min-width: 140px;
|
||
width: auto !important;
|
||
}
|
||
:deep(.el-select .el-select__wrapper) {
|
||
min-width: 140px;
|
||
}
|
||
:deep(.el-select .el-select__wrapper .el-select__selection) {
|
||
min-width: 100px;
|
||
}
|
||
/* === 下拉弹层圆角 (跟 input 一致) === */
|
||
:deep(.el-select-dropdown),
|
||
:deep(.el-select__popper),
|
||
:deep(.el-popper.is-light) {
|
||
border-radius: var(--radius, 8px) !important;
|
||
overflow: hidden;
|
||
}
|
||
:deep(.el-select-dropdown__item) {
|
||
border-radius: var(--radius-sm, 6px);
|
||
margin: 2px 4px;
|
||
padding: 0 12px;
|
||
height: 32px;
|
||
line-height: 32px;
|
||
font-size: 13px;
|
||
}
|
||
:deep(.el-select-dropdown__item.is-hovering) {
|
||
background: var(--brand-soft, rgba(37, 99, 235, 0.08));
|
||
color: var(--brand, #2563EB);
|
||
}
|
||
:deep(.el-select-dropdown__item.is-selected) {
|
||
font-weight: 600;
|
||
color: var(--brand, #2563EB);
|
||
}
|
||
|
||
.rr-search-bar {
|
||
margin-bottom: 16px;
|
||
&__row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
&__field {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
}
|
||
&__label {
|
||
font-size: 13px;
|
||
color: var(--ink-2, #334155);
|
||
margin-right: 6px;
|
||
}
|
||
&__chips {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
&__chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 4px 10px;
|
||
background: var(--brand-soft, rgba(37, 99, 235, 0.08));
|
||
color: var(--brand, #2563EB);
|
||
border-radius: var(--radius-pill, 9999px);
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
&-close {
|
||
cursor: pointer;
|
||
opacity: 0.6;
|
||
font-size: 14px;
|
||
line-height: 1;
|
||
&:hover { opacity: 1; }
|
||
}
|
||
}
|
||
&__actions {
|
||
display: inline-flex;
|
||
gap: 8px;
|
||
margin-left: auto;
|
||
:deep(.el-button) {
|
||
padding: 7px 16px;
|
||
font-weight: 500;
|
||
border-radius: var(--radius, 8px);
|
||
}
|
||
}
|
||
}
|
||
</style>
|