Commit 44ffb5e2 by Sweet Zhang

封装搜索区域

parent 09b9c2b9
// src/api/search.ts
import request from '@/utils/request'
// 通用搜索接口
export function commonSearch(params: {
module: string
keyword?: string
pageSize?: number
}) {
return request({
url: '/common/search',
method: 'get',
params
})
}
// 特定模块搜索
export function searchCompanies(params: {
type?: string
keyword?: string
pageSize?: number
}) {
return request({
url: '/company/search',
method: 'get',
params
})
}
export function searchCommissionTypes(params: {
keyword?: string
pageSize?: number
}) {
return request({
url: '/commission/type/search',
method: 'get',
params
})
}
export function searchInsurers(params: {
keyword?: string
pageSize?: number
}) {
return request({
url: '/insurer/search',
method: 'get',
params
})
}
export function searchProducts(params: {
keyword?: string
pageSize?: number
}) {
return request({
url: '/product/search',
method: 'get',
params
})
}
\ No newline at end of file
<!-- src/components/RemoteMultiSelect.vue -->
<template>
<el-select
v-model="selectedValues"
:placeholder="placeholder"
:multiple="multiple"
:filterable="remote || filterable"
:remote="remote"
:remote-method="handleRemoteSearch"
:loading="loading"
:reserve-keyword="false"
:clearable="clearable"
:collapse-tags="collapseTags"
:max-collapse-tags="maxCollapseTags"
:size="size"
:popper-class="['remote-multi-select', popperClass]"
:disabled="disabled"
@change="handleChange"
@visible-change="handleVisibleChange"
>
<!-- 自定义下拉头部:全选功能 -->
<template #header v-if="showCheckAll && multiple">
<div class="select-header">
<el-checkbox
v-model="checkAll"
:indeterminate="indeterminate"
@change="handleCheckAllChange"
:disabled="disabled"
>
{{ checkAllLabel }}
</el-checkbox>
</div>
</template>
<!-- 选项列表 -->
<el-option
v-for="option in options"
:key="getOptionKey(option)"
:label="getOptionLabel(option)"
:value="getOptionValue(option)"
:disabled="option.disabled"
/>
<!-- 无数据时的提示 -->
<template #empty>
<div class="empty-options">
<span v-if="loading">加载中...</span>
<span v-else-if="options.length === 0 && !hasSearched">请输入关键词搜索</span>
<span v-else>无匹配数据</span>
</div>
</template>
</el-select>
</template>
<script setup lang="ts">
import { computed, ref, watch, toRefs, defineProps, defineEmits } from 'vue'
import { useRemoteSearch } from '@/hooks/useRemoteSearch'
import type { RemoteSearchConfig } from '@/hooks/useRemoteSearch'
interface OptionItem {
label: string
value: string | number
disabled?: boolean
[key: string]: any
}
interface Props {
modelValue: (string | number)[] | string | number
config: RemoteSearchConfig
placeholder?: string
multiple?: boolean
clearable?: boolean
collapseTags?: boolean
maxCollapseTags?: number
size?: 'large' | 'default' | 'small'
disabled?: boolean
showCheckAll?: boolean
checkAllLabel?: string
popperClass?: string
// 自定义选项键名
labelKey?: string
valueKey?: string
optionKey?: string
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => ([]),
placeholder: '请选择',
multiple: true,
clearable: true,
collapseTags: true,
maxCollapseTags: 1,
size: 'large',
disabled: false,
showCheckAll: true,
checkAllLabel: '全选',
labelKey: 'label',
valueKey: 'value',
optionKey: 'value'
})
const emit = defineEmits<{
'update:modelValue': [value: (string | number)[] | string | number]
'change': [value: (string | number)[] | string | number]
'search': [query: string]
}>()
const {
config,
multiple,
showCheckAll,
checkAllLabel,
labelKey,
valueKey,
optionKey
} = toRefs(props)
// 远程搜索实例
const remoteSearch = useRemoteSearch(config.value)
// 本地状态
const selectedValues = ref<(string | number)[] | string | number>(
Array.isArray(props.modelValue) ? [...props.modelValue] : props.modelValue
)
const hasSearched = ref(false)
const currentQuery = ref('')
// 计算属性
const loading = computed(() => remoteSearch.state.loading)
const options = computed(() => remoteSearch.state.options)
const checkAll = computed({
get() {
if (!props.multiple || !Array.isArray(selectedValues.value)) return false
const enabledOptions = options.value.filter(opt => !opt.disabled)
return selectedValues.value.length === enabledOptions.length && enabledOptions.length > 0
},
set(val: boolean) {
handleCheckAllChange(val)
}
})
const indeterminate = computed(() => {
if (!props.multiple || !Array.isArray(selectedValues.value)) return false
const enabledOptions = options.value.filter(opt => !opt.disabled)
return selectedValues.value.length > 0 && selectedValues.value.length < enabledOptions.length
})
// 方法
const getOptionKey = (option: OptionItem) => {
return option[optionKey.value] ?? option.value
}
const getOptionLabel = (option: OptionItem) => {
return option[labelKey.value] ?? option.label
}
const getOptionValue = (option: OptionItem) => {
return option[valueKey.value] ?? option.value
}
const handleRemoteSearch = async (query: string) => {
currentQuery.value = query
hasSearched.value = true
emit('search', query)
await remoteSearch.search(query)
}
const handleChange = (value: (string | number)[] | string | number) => {
selectedValues.value = value
emit('update:modelValue', value)
emit('change', value)
}
const handleCheckAllChange = (checked: boolean) => {
if (!props.multiple) return
if (checked) {
// 全选:只选择非禁用的选项
selectedValues.value = options.value
.filter(opt => !opt.disabled)
.map(opt => getOptionValue(opt))
} else {
selectedValues.value = []
}
emit('update:modelValue', selectedValues.value)
emit('change', selectedValues.value)
}
const handleVisibleChange = (visible: boolean) => {
if (visible && options.value.length === 0 && !hasSearched.value) {
// 下拉框打开时,如果没有搜索过,执行一次搜索
handleRemoteSearch('')
}
}
// 监听外部值变化
watch(() => props.modelValue, (newVal) => {
if (JSON.stringify(newVal) !== JSON.stringify(selectedValues.value)) {
selectedValues.value = newVal
}
}, { deep: true })
// 监听配置变化
watch(() => props.config, (newConfig) => {
// 重新初始化远程搜索实例(实际实现中可能需要更复杂的处理)
Object.assign(remoteSearch, useRemoteSearch(newConfig))
}, { deep: true })
// 初始化
if (config.value.defaultOptions?.length > 0) {
remoteSearch.setDefaultOptions(config.value.defaultOptions)
}
// 暴露方法给父组件
defineExpose({
search: handleRemoteSearch,
clearCache: remoteSearch.clearCache,
preload: remoteSearch.preload
})
</script>
<style scoped>
.select-header {
padding: 8px 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
background-color: var(--el-bg-color);
.el-checkbox {
width: 100%;
.el-checkbox__label {
font-weight: 500;
color: var(--el-color-primary);
}
}
}
.empty-options {
padding: 8px 12px;
text-align: center;
color: var(--el-text-color-secondary);
font-size: 14px;
}
</style>
<style>
.remote-multi-select .el-select-dropdown__list {
padding-top: 0;
}
.remote-multi-select .el-select-dropdown__item {
padding: 8px 12px;
}
.remote-multi-select .el-select-dropdown__item.selected {
background-color: var(--el-color-primary-light-9);
}
.remote-multi-select .el-select-dropdown__item.is-disabled {
opacity: 0.6;
}
</style>
\ No newline at end of file
<template>
<el-select
ref="selectRef"
v-model="selectedValue"
:placeholder="placeholder"
:clearable="clearable"
:filterable="true"
:remote="true"
:remote-method="handleRemoteSearch"
:loading="loading"
:reserve-keyword="false"
:disabled="disabled"
:size="size"
:popper-class="['remote-select', popperClass]"
@change="handleChange"
@visible-change="handleVisibleChange"
>
<el-option
v-for="option in options"
:key="getOptionKey(option)"
:label="getOptionLabel(option)"
:value="getOptionValue(option)"
:disabled="option.disabled"
/>
<template #empty>
<div class="empty-options">
<span v-if="loading">加载中...</span>
<span v-else-if="options.length === 0 && !hasSearched">请输入关键词搜索</span>
<span v-else>无匹配数据</span>
</div>
</template>
</el-select>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useRemoteSearch } from '@/hooks/useRemoteSearch'
import type { RemoteSearchConfig, FormOption } from '@/types/search-form'
interface Props {
modelValue: string | number
config: RemoteSearchConfig
placeholder?: string
clearable?: boolean
disabled?: boolean
size?: 'large' | 'default' | 'small'
popperClass?: string
optionLabel?: string
optionValue?: string
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
placeholder: '请搜索选择',
clearable: true,
disabled: false,
size: 'default',
popperClass: '',
optionLabel: 'label',
optionValue: 'value'
})
const emit = defineEmits<{
'update:modelValue': [value: string | number]
'change': [value: string | number]
}>()
// 远程搜索实例
const remoteSearch = useRemoteSearch(props.config)
// 本地状态
const selectRef = ref()
const selectedValue = ref(props.modelValue)
const hasSearched = ref(false)
const currentQuery = ref('')
// 计算属性
const loading = computed(() => remoteSearch.state.loading)
const options = computed(() => remoteSearch.state.options)
// 方法
const getOptionKey = (option: FormOption) => {
return option[props.optionValue] ?? option.value
}
const getOptionLabel = (option: FormOption) => {
return option[props.optionLabel] ?? option.label
}
const getOptionValue = (option: FormOption) => {
return option[props.optionValue] ?? option.value
}
const handleRemoteSearch = async (query: string) => {
currentQuery.value = query
hasSearched.value = true
await remoteSearch.search(query)
}
const handleChange = (value: string | number) => {
selectedValue.value = value
emit('update:modelValue', value)
emit('change', value)
}
const handleVisibleChange = (visible: boolean) => {
if (visible && options.value.length === 0 && !hasSearched.value) {
handleRemoteSearch('')
}
}
// 监听外部值变化
watch(() => props.modelValue, (newVal) => {
if (newVal !== selectedValue.value) {
selectedValue.value = newVal
}
})
// 暴露方法
defineExpose({
search: handleRemoteSearch,
clearCache: remoteSearch.clearCache,
focus: () => selectRef.value?.focus()
})
// 初始化
onMounted(() => {
if (props.config.defaultOptions?.length > 0) {
remoteSearch.setDefaultOptions(props.config.defaultOptions)
}
})
</script>
\ No newline at end of file
// src/hooks/useRemoteSearch.ts
import { ref, reactive, onUnmounted } from 'vue'
import type { Ref } from 'vue'
export interface SearchOption {
label: string
value: string | number
[key: string]: any
}
export interface RemoteSearchConfig {
type: string
apiMethod: (params: any) => Promise<any>
formatResult?: (data: any[]) => SearchOption[]
cacheKey?: string
debounceDelay?: number
defaultOptions?: SearchOption[]
}
export interface RemoteSearchState {
loading: boolean
options: SearchOption[]
cache: Map<string, SearchOption[]>
}
export function useRemoteSearch(config: RemoteSearchConfig) {
const {
type,
apiMethod,
formatResult = defaultFormatResult,
cacheKey = type,
debounceDelay = 500,
defaultOptions = []
} = config
// 状态
const state = reactive<RemoteSearchState>({
loading: false,
options: [...defaultOptions],
cache: new Map()
})
// 防抖相关
let debounceTimer: NodeJS.Timeout | null = null
let lastQuery = ''
// 默认格式化函数
function defaultFormatResult(data: any[]): SearchOption[] {
return data.map(item => ({
label: item.name || item.label || item.text || String(item.value),
value: item.id || item.value || item.key,
...item
}))
}
// 从缓存获取
function getFromCache(query: string): SearchOption[] | null {
const cacheKeyWithQuery = `${cacheKey}:${query || 'all'}`
return state.cache.get(cacheKeyWithQuery) || null
}
// 保存到缓存
function saveToCache(query: string, data: SearchOption[]) {
const cacheKeyWithQuery = `${cacheKey}:${query || 'all'}`
state.cache.set(cacheKeyWithQuery, data)
// 限制缓存大小(最多100条记录)
if (state.cache.size > 100) {
const firstKey = state.cache.keys().next().value
state.cache.delete(firstKey)
}
}
// 执行搜索
async function performSearch(query: string): Promise<SearchOption[]> {
// 检查缓存
const cached = getFromCache(query)
if (cached && cached.length > 0) {
return cached
}
state.loading = true
try {
// 调用API
const params = query ? { keyword: query, pageSize: 50 } : { pageSize: 100 }
const response = await apiMethod(params)
// 格式化结果
const result = formatResult(response.data || response.list || response.records || [])
// 保存到缓存
saveToCache(query, result)
return result
} catch (error) {
console.error(`远程搜索失败 [${type}]:`, error)
throw error
} finally {
state.loading = false
}
}
// 搜索方法(带防抖)
async function search(query: string = ''): Promise<SearchOption[]> {
// 清除之前的定时器
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
// 如果查询相同,直接返回当前选项
if (query === lastQuery) {
return state.options
}
lastQuery = query
// 如果是空查询且有默认选项,直接返回
if (!query && defaultOptions.length > 0) {
state.options = defaultOptions
return defaultOptions
}
return new Promise((resolve) => {
debounceTimer = setTimeout(async () => {
try {
const result = await performSearch(query)
state.options = result
resolve(result)
} catch (error) {
state.options = []
resolve([])
}
}, debounceDelay)
})
}
// 预加载数据(初始化时调用)
async function preload(): Promise<void> {
if (state.options.length === 0) {
await search('')
}
}
// 清空缓存
function clearCache(): void {
state.cache.clear()
state.options = [...defaultOptions]
}
// 设置默认选项
function setDefaultOptions(options: SearchOption[]): void {
defaultOptions.length = 0
defaultOptions.push(...options)
if (state.options.length === 0) {
state.options = [...options]
}
}
// 组件卸载时清理
onUnmounted(() => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
})
return {
state,
search,
preload,
clearCache,
setDefaultOptions,
loading: () => state.loading,
options: () => state.options
}
}
// 创建多搜索实例的管理器
export function useRemoteSearchManager() {
const instances = new Map<string, ReturnType<typeof useRemoteSearch>>()
function getInstance(config: RemoteSearchConfig) {
const { type } = config
if (!instances.has(type)) {
instances.set(type, useRemoteSearch(config))
}
return instances.get(type)!
}
function clearAllCache() {
instances.forEach(instance => {
instance.clearCache()
})
}
return {
getInstance,
clearAllCache
}
}
\ No newline at end of file
// src/types/search-form.ts
export type FormFieldType =
| 'input' // 输入框
| 'textarea' // 文本域
| 'select' // 普通下拉框
| 'multi-select' // 多选下拉框
| 'remote-select' // 远程搜索下拉框(单选)
| 'remote-multi-select' // 远程搜索下拉框(多选)
| 'date' // 日期选择器
| 'daterange' // 日期范围选择器
| 'datetime' // 日期时间选择器
| 'datetimerange' // 日期时间范围选择器
| 'cascader' // 级联选择器
| 'radio' // 单选框
| 'checkbox' // 复选框
| 'switch' // 开关
| 'number' // 数字输入框
| 'password' // 密码输入框
| 'custom' // 自定义组件
export interface FormOption {
label: string
value: string | number
disabled?: boolean
children?: FormOption[]
[key: string]: any
}
export interface RemoteSearchConfig {
type: string
apiMethod: (params: any) => Promise<any>
formatResult?: (data: any[]) => FormOption[]
cacheKey?: string
debounceDelay?: number
defaultOptions?: FormOption[]
params?: Record<string, any>
}
export interface ValidationRule {
required?: boolean
message?: string
trigger?: 'blur' | 'change' | ['blur', 'change']
validator?: (rule: any, value: any, callback: (error?: Error) => void) => void
pattern?: RegExp
min?: number
max?: number
len?: number
type?: 'string' | 'number' | 'boolean' | 'method' | 'regexp' | 'integer' | 'float' | 'array' | 'object' | 'enum' | 'date' | 'url' | 'hex' | 'email'
enum?: Array<string | number>
transform?: (value: any) => any
}
export interface FormField {
// 基础配置
type: FormFieldType
field: string
label: string
defaultValue?: any
// 显示配置
placeholder?: string
width?: string | number
colSpan?: number // 栅格占据的列数 (1-24)
hidden?: boolean
disabled?: boolean
readonly?: boolean
clearable?: boolean
// 选项配置(用于select/radio/checkbox等)
options?: FormOption[]
optionLabel?: string
optionValue?: string
// 远程搜索配置
remoteConfig?: RemoteSearchConfig
// 特殊类型配置
dateFormat?: string
rangeSeparator?: string
startPlaceholder?: string
endPlaceholder?: string
showPassword?: boolean
minRows?: number
maxRows?: number
showWordLimit?: boolean
maxlength?: number
step?: number
precision?: number
// 校验规则
rules?: ValidationRule[]
// 自定义组件
component?: any
props?: Record<string, any>
slots?: Record<string, any>
// 事件
events?: Record<string, Function>
// 样式
class?: string
style?: Record<string, string | number>
}
export interface SearchFormProps {
modelValue: Record<string, any>
fields: FormField[]
labelWidth?: string | number
labelPosition?: 'left' | 'right' | 'top'
inline?: boolean
size?: 'large' | 'default' | 'small'
disabled?: boolean
showReset?: boolean
showSearch?: boolean
resetText?: string
searchText?: string
gutter?: number
rowClass?: string
formClass?: string
// 自定义插槽
prefixSlot?: boolean
suffixSlot?: boolean
extraSlot?: boolean
// 搜索和重置前的钩子
beforeSearch?: (formData: Record<string, any>) => boolean | Promise<boolean>
beforeReset?: () => boolean | Promise<boolean>
}
export interface SearchFormEmits {
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search', formData: Record<string, any>): void
(e: 'reset', formData: Record<string, any>): void
(e: 'change', field: string, value: any): void
(e: 'field-validate', field: string, isValid: boolean, message?: string): void
}
\ No newline at end of file
// 格式化金额为货币格式
export function formatCurrency(value) {
if (value === undefined || value === null) return '¥0.00'
return '¥' + value.toFixed(2).replace(/\d(?=(\d{3})+\.)/g, '$&,')
export function formatCurrency(value,currency='') {
if (value === undefined || value === null) return currency + '0.00'
return currency + value.toFixed(2).replace(/\d(?=(\d{3})+\.)/g, '$&,')
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment