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
<template>
<div class="search-form-container">
<el-form
ref="formRef"
:model="formData"
:label-width="labelWidth"
:label-position="labelPosition"
:inline="inline"
:size="size"
:disabled="disabled"
:class="formClass"
>
<!-- 前置插槽 -->
<slot v-if="prefixSlot" name="prefix"></slot>
<el-row :gutter="gutter" :class="rowClass">
<template v-for="field in visibleFields" :key="field.field">
<el-col
:span="getColSpan(field, 'span')"
:xs="getColSpan(field, 'xs')"
:sm="getColSpan(field, 'sm')"
:md="getColSpan(field, 'md')"
:lg="getColSpan(field, 'lg')"
:xl="getColSpan(field, 'xl')"
>
<el-form-item
:label="field.label"
:prop="field.field"
:rules="getRules(field)"
:class="field.class"
:style="field.style"
:label-width="field.labelWidth"
>
<!-- 输入框 -->
<template v-if="field.type === 'input'">
<el-input
v-model="formData[field.field]"
:placeholder="field.placeholder || `请输入${field.label}`"
:clearable="field.clearable ?? true"
:disabled="field.disabled"
:readonly="field.readonly"
:show-password="field.type === 'password'"
:show-word-limit="field.showWordLimit"
:maxlength="field.maxlength"
:style="{ width: getFieldWidth(field) }"
@change="handleChange(field.field, $event)"
v-bind="field.props"
/>
</template>
<!-- 文本域 -->
<template v-else-if="field.type === 'textarea'">
<el-input
v-model="formData[field.field]"
type="textarea"
:placeholder="field.placeholder || `请输入${field.label}`"
:clearable="field.clearable ?? true"
:disabled="field.disabled"
:readonly="field.readonly"
:rows="field.minRows || 3"
:max-rows="field.maxRows"
:show-word-limit="field.showWordLimit"
:maxlength="field.maxlength"
:style="{ width: getFieldWidth(field) }"
@change="handleChange(field.field, $event)"
v-bind="field.props"
/>
</template>
<!-- 数字输入框 -->
<template v-else-if="field.type === 'number'">
<el-input-number
v-model="formData[field.field]"
:placeholder="field.placeholder || `请输入${field.label}`"
:disabled="field.disabled"
:readonly="field.readonly"
:step="field.step"
:precision="field.precision"
:min="field.min"
:max="field.max"
:style="{ width: getFieldWidth(field) }"
@change="handleChange(field.field, $event)"
v-bind="field.props"
/>
</template>
<!-- 普通下拉框(单选) -->
<template v-else-if="field.type === 'select'">
<el-select
v-model="formData[field.field]"
:placeholder="field.placeholder || `请选择${field.label}`"
:clearable="field.clearable ?? true"
:disabled="field.disabled"
:style="{ width: getFieldWidth(field) }"
@change="handleChange(field.field, $event)"
v-bind="field.props"
>
<el-option
v-for="option in getOptions(field)"
:key="getOptionValue(option, field)"
:label="getOptionLabel(option, field)"
:value="getOptionValue(option, field)"
:disabled="option.disabled"
/>
</el-select>
</template>
<!-- 多选下拉框 -->
<template v-else-if="field.type === 'multi-select'">
<el-select
v-model="formData[field.field]"
:placeholder="field.placeholder || `请选择${field.label}`"
:clearable="field.clearable ?? true"
:multiple="true"
:collapse-tags="true"
:max-collapse-tags="1"
:disabled="field.disabled"
:style="{ width: getFieldWidth(field) }"
@change="handleChange(field.field, $event)"
v-bind="field.props"
>
<el-option
v-for="option in getOptions(field)"
:key="getOptionValue(option, field)"
:label="getOptionLabel(option, field)"
:value="getOptionValue(option, field)"
:disabled="option.disabled"
/>
</el-select>
</template>
<!-- 远程搜索下拉框(单选) -->
<template v-else-if="field.type === 'remote-select'">
<RemoteSelect
v-model="formData[field.field]"
:config="field.remoteConfig!"
:placeholder="field.placeholder || `请搜索选择${field.label}`"
:clearable="field.clearable ?? true"
:disabled="field.disabled"
:style="{ width: getFieldWidth(field) }"
:option-label="field.optionLabel"
:option-value="field.optionValue"
@change="handleChange(field.field, $event)"
v-bind="field.props"
/>
</template>
<!-- 远程搜索下拉框(多选) -->
<template v-else-if="field.type === 'remote-multi-select'">
<RemoteMultiSelect
v-model="formData[field.field]"
:config="field.remoteConfig!"
:placeholder="field.placeholder || `请搜索选择${field.label}`"
:clearable="field.clearable ?? true"
:disabled="field.disabled"
:style="{ width: getFieldWidth(field) }"
:option-label="field.optionLabel"
:option-value="field.optionValue"
@change="handleChange(field.field, $event)"
v-bind="field.props"
/>
</template>
<!-- 日期选择器 -->
<template v-else-if="field.type === 'date'">
<el-date-picker
v-model="formData[field.field]"
type="date"
:placeholder="field.placeholder || `请选择${field.label}`"
:clearable="field.clearable ?? true"
:disabled="field.disabled"
:readonly="field.readonly"
:format="field.dateFormat"
:value-format="field.dateFormat"
:style="{ width: getFieldWidth(field) }"
@change="handleChange(field.field, $event)"
v-bind="field.props"
/>
</template>
<!-- 日期范围选择器 -->
<template v-else-if="field.type === 'daterange'">
<el-date-picker
v-model="formData[field.field]"
type="daterange"
:range-separator="field.rangeSeparator || '至'"
:start-placeholder="field.startPlaceholder || '开始日期'"
:end-placeholder="field.endPlaceholder || '结束日期'"
:clearable="field.clearable ?? true"
:disabled="field.disabled"
:readonly="field.readonly"
:format="field.dateFormat"
:value-format="field.dateFormat"
:style="{ width: getFieldWidth(field) }"
@change="handleChange(field.field, $event)"
v-bind="field.props"
/>
</template>
<!-- 日期时间选择器 -->
<template v-else-if="field.type === 'datetime'">
<el-date-picker
v-model="formData[field.field]"
type="datetime"
:placeholder="field.placeholder || `请选择${field.label}`"
:clearable="field.clearable ?? true"
:disabled="field.disabled"
:readonly="field.readonly"
:format="field.dateFormat"
:value-format="field.dateFormat"
:style="{ width: getFieldWidth(field) }"
@change="handleChange(field.field, $event)"
v-bind="field.props"
/>
</template>
<!-- 日期时间范围选择器 -->
<template v-else-if="field.type === 'datetimerange'">
<el-date-picker
v-model="formData[field.field]"
type="datetimerange"
:range-separator="field.rangeSeparator || '至'"
:start-placeholder="field.startPlaceholder || '开始时间'"
:end-placeholder="field.endPlaceholder || '结束时间'"
:clearable="field.clearable ?? true"
:disabled="field.disabled"
:readonly="field.readonly"
:format="field.dateFormat"
:value-format="field.dateFormat"
:style="{ width: getFieldWidth(field) }"
@change="handleChange(field.field, $event)"
v-bind="field.props"
/>
</template>
<!-- 单选框 -->
<template v-else-if="field.type === 'radio'">
<el-radio-group
v-model="formData[field.field]"
:disabled="field.disabled"
@change="handleChange(field.field, $event)"
v-bind="field.props"
>
<el-radio
v-for="option in getOptions(field)"
:key="getOptionValue(option, field)"
:label="getOptionValue(option, field)"
:disabled="option.disabled"
>
{{ getOptionLabel(option, field) }}
</el-radio>
</el-radio-group>
</template>
<!-- 复选框 -->
<template v-else-if="field.type === 'checkbox'">
<el-checkbox-group
v-model="formData[field.field]"
:disabled="field.disabled"
@change="handleChange(field.field, $event)"
v-bind="field.props"
>
<el-checkbox
v-for="option in getOptions(field)"
:key="getOptionValue(option, field)"
:label="getOptionValue(option, field)"
:disabled="option.disabled"
>
{{ getOptionLabel(option, field) }}
</el-checkbox>
</el-checkbox-group>
</template>
<!-- 开关 -->
<template v-else-if="field.type === 'switch'">
<el-switch
v-model="formData[field.field]"
:disabled="field.disabled"
@change="handleChange(field.field, $event)"
v-bind="field.props"
/>
</template>
<!-- 级联选择器 -->
<template v-else-if="field.type === 'cascader'">
<el-cascader
v-model="formData[field.field]"
:options="getOptions(field)"
:placeholder="field.placeholder || `请选择${field.label}`"
:clearable="field.clearable ?? true"
:disabled="field.disabled"
:style="{ width: getFieldWidth(field) }"
@change="handleChange(field.field, $event)"
v-bind="field.props"
/>
</template>
<!-- 自定义组件 -->
<template v-else-if="field.type === 'custom'">
<component
:is="field.component"
v-model="formData[field.field]"
v-bind="field.props"
@change="handleChange(field.field, $event)"
>
<template v-for="(slotContent, slotName) in field.slots" #[slotName]>
<component :is="slotContent" />
</template>
</component>
</template>
<!-- 其他类型可以在这里扩展 -->
</el-form-item>
</el-col>
</template>
</el-row>
<!-- 后置插槽 -->
<slot v-if="suffixSlot" name="suffix"></slot>
<!-- 操作按钮 -->
<el-form-item v-if="showSearch || showReset" class="form-actions">
<el-button
v-if="showSearch"
type="primary"
:loading="searchLoading"
@click="handleSearch"
>
{{ searchText }}
</el-button>
<el-button
v-if="showReset"
@click="handleReset"
>
{{ resetText }}
</el-button>
</el-form-item>
<!-- 额外插槽 -->
<slot v-if="extraSlot" name="extra"></slot>
</el-form>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, toRefs } from 'vue'
import type { FormInstance } from 'element-plus'
import RemoteSelect from './RemoteSelect.vue'
import RemoteMultiSelect from './RemoteMultiSelect.vue'
import type {
SearchFormProps,
SearchFormEmits,
FormField,
FormOption
} from '@/types/search-form'
const props = withDefaults(defineProps<SearchFormProps>(), {
labelWidth: '150px',
labelPosition: 'right',
inline: false,
size: 'default',
disabled: false,
showReset: false,
showSearch: false,
resetText: '重置',
searchText: '搜索',
gutter: 20,
rowClass: '',
formClass: '',
prefixSlot: false,
suffixSlot: false,
extraSlot: false
})
const emit = defineEmits<SearchFormEmits>()
// 表单引用
const formRef = ref<FormInstance>()
const searchLoading = ref(false)
// 表单数据
const formData = reactive<Record<string, any>>({})
// 初始化表单数据
const initFormData = () => {
// 设置默认值
props.fields.forEach(field => {
if (field.field in props.modelValue) {
formData[field.field] = props.modelValue[field.field]
} else if (field.defaultValue !== undefined) {
formData[field.field] = field.defaultValue
} else {
// 根据类型设置默认值
switch (field.type) {
case 'input':
case 'textarea':
case 'password':
formData[field.field] = ''
break
case 'select':
case 'radio':
formData[field.field] = ''
break
case 'multi-select':
case 'checkbox':
case 'remote-multi-select':
formData[field.field] = []
break
case 'switch':
formData[field.field] = false
break
case 'number':
formData[field.field] = null
break
case 'date':
case 'datetime':
formData[field.field] = ''
break
case 'daterange':
case 'datetimerange':
formData[field.field] = []
break
case 'cascader':
formData[field.field] = []
break
default:
formData[field.field] = null
}
}
})
}
// 计算可见的字段
const visibleFields = computed(() => {
return props.fields.filter(field => !field.hidden)
})
// 获取字段宽度
const getFieldWidth = (field: FormField) => {
if (field.width) {
return typeof field.width === 'number' ? `${field.width}px` : field.width
}
return '100%'
}
// 获取栅格跨度
const getColSpan = (field: FormField, type: 'span' | 'xs' | 'sm' | 'md' | 'lg' | 'xl') => {
const userColSpan = field.colSpan || 6 // 默认6(大屏4列)
switch (type) {
case 'xs': // 超小屏幕(手机)
return 24 // 1列
case 'sm': // 小屏幕(平板)
return userColSpan * 2 // 转换为栅格跨度
case 'md': // 中等屏幕
return userColSpan
case 'lg': // 大屏幕
return userColSpan
case 'xl': // 超大屏幕
return userColSpan
case 'span': // 默认
return userColSpan
}
}
// 获取选项列表
const getOptions = (field: FormField): FormOption[] => {
return field.options || []
}
// 获取选项标签
const getOptionLabel = (option: FormOption, field: FormField) => {
if (field.optionLabel && option[field.optionLabel]) {
return option[field.optionLabel]
}
return option.label
}
// 获取选项值
const getOptionValue = (option: FormOption, field: FormField) => {
if (field.optionValue && option[field.optionValue]) {
return option[field.optionValue]
}
return option.value
}
// 获取校验规则
const getRules = (field: FormField) => {
const rules = field.rules || []
// 自动添加必填校验
if (field.rules?.some(rule => rule.required)) {
return rules
}
// 如果有required属性但没有规则,自动创建
if (field.props?.required) {
return [
{
required: true,
message: `${field.label}不能为空`,
trigger: field.type.includes('select') ? 'change' : 'blur'
},
...rules
]
}
return rules
}
// 处理字段变化
const handleChange = (field: string, value: any) => {
emit('change', field, value)
emit('update:modelValue', { ...formData })
}
// 处理搜索
const handleSearch = async () => {
if (!formRef.value) return
try {
searchLoading.value = true
// 执行表单验证
const isValid = await formRef.value.validate()
if (!isValid) return
// 执行前置钩子
if (props.beforeSearch) {
const canProceed = await props.beforeSearch(formData)
if (!canProceed) return
}
// 触发搜索事件
emit('search', formData)
} catch (error) {
console.error('表单验证失败:', error)
} finally {
searchLoading.value = false
}
}
// 处理重置
const handleReset = async () => {
if (!formRef.value) return
// 执行前置钩子
if (props.beforeReset) {
const canProceed = await props.beforeReset()
if (!canProceed) return
}
// 重置表单
formRef.value.resetFields()
// 重置为默认值
initFormData()
// 触发重置事件
emit('reset', formData)
emit('update:modelValue', { ...formData })
}
// 监听外部modelValue变化
watch(() => props.modelValue, (newVal) => {
Object.keys(formData).forEach(key => {
if (key in newVal) {
formData[key] = newVal[key]
}
})
}, { deep: true })
// 监听字段配置变化
watch(() => props.fields, () => {
initFormData()
}, { deep: true })
// 暴露方法给父组件
defineExpose({
validate: () => formRef.value?.validate(),
resetFields: () => {
formRef.value?.resetFields()
initFormData()
},
clearValidate: () => formRef.value?.clearValidate(),
getFormData: () => ({ ...formData }),
getFormRef: () => formRef.value
})
// 初始化
initFormData()
</script>
<style scoped lang="scss">
.search-form-container {
width: 100%;
.form-actions {
margin-bottom: 0;
margin-top: 20px;
.el-button + .el-button {
margin-left: 10px;
}
}
}
// 统一所有表单项的宽度和高度
:deep(.el-form) {
&.el-form--label-top {
.el-form-item {
margin-bottom: 24px;
.el-form-item__label {
display: block;
text-align: left;
margin-bottom: 8px;
padding-bottom: 0;
line-height: 1.4;
font-weight: 500;
color: #606266;
font-size: 14px;
height: 20px;
}
.el-form-item__content {
margin-left: 0 !important;
height: 40px; // 固定内容高度
width: 100%; // 确保内容区域宽度100%
// 基础容器样式
> * {
display: block !important;
width: 100% !important;
box-sizing: border-box !important;
}
// 日期选择器 - 修复日期区间宽度
.el-date-editor {
&.el-range-editor {
// 日期区间选择器的特殊处理
height: 40px !important;
width: 100% !important;
line-height: 38px !important;
display: flex !important;
align-items: center !important;
padding: 0 !important;
// 修复内部flex布局
.el-range-input {
height: 38px !important;
line-height: 38px !important;
flex: 1 !important; // 让两个输入框平分剩余空间
min-width: 0 !important; // 防止内容溢出
padding: 0 8px !important;
font-size: 14px !important;
border: none !important;
background: transparent !important;
outline: none !important;
// 修复placeholder样式
&::placeholder {
color: var(--el-text-color-placeholder);
font-size: 14px;
}
}
// 分隔符
.el-range-separator {
height: 38px !important;
line-height: 38px !important;
padding: 0 4px !important;
font-size: 14px !important;
color: var(--el-text-color-placeholder);
flex: none !important;
width: auto !important;
min-width: 20px !important;
}
// 关闭图标
.el-range__close-icon {
height: 38px !important;
line-height: 38px !important;
flex: none !important;
width: 30px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
.el-icon {
font-size: 14px !important;
}
}
// 日历图标
.el-range__icon {
height: 38px !important;
line-height: 38px !important;
flex: none !important;
width: 30px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
.el-icon {
font-size: 14px !important;
}
}
// 悬停和聚焦状态
&:hover,
&:focus-within {
.el-range-input {
background: transparent !important;
}
}
// 禁用的日期区间
&.is-disabled {
.el-range-input {
color: var(--el-disabled-text-color) !important;
background: var(--el-disabled-bg-color) !important;
}
}
}
// 单个日期选择器
&:not(.el-range-editor) {
height: 40px;
width: 100% !important;
.el-input__wrapper {
height: 40px;
width: 100%;
padding: 1px 30px 1px 11px; // 为图标留出空间
box-sizing: border-box;
.el-input__inner {
height: 38px;
line-height: 38px;
width: 100%;
padding-right: 20px; // 为图标留出空间
}
}
}
// 日期时间选择器
&.el-date-editor--datetime {
.el-input__wrapper {
padding-right: 30px;
}
}
}
// 其他组件样式保持不变...
// 输入框
.el-input {
height: 40px;
width: 100%;
.el-input__wrapper {
height: 40px;
width: 100%;
padding: 1px 11px;
border-radius: 4px;
box-sizing: border-box;
.el-input__inner {
height: 38px;
line-height: 38px;
width: 100%;
}
}
}
// 下拉选择框(单选)
.el-select:not(.is-multiple) {
height: 40px;
width: 100%;
.el-select__wrapper {
height: 40px;
width: 100%;
padding: 1px 30px 1px 11px; // 为下拉箭头留出空间
border-radius: 4px;
box-sizing: border-box;
.el-select__placeholder,
.el-select__selected-item {
line-height: 38px;
width: 100%;
padding-right: 20px; // 为下拉箭头留出空间
}
// 下拉箭头
.el-select__suffix {
right: 8px;
}
}
}
// 下拉选择框(多选)
.el-select.is-multiple {
height: auto;
min-height: 40px;
width: 100%;
.el-select__wrapper {
min-height: 40px;
width: 100%;
padding: 3px 30px 3px 11px;
border-radius: 4px;
box-sizing: border-box;
.el-select__tags-wrapper {
min-height: 32px;
line-height: 32px;
width: 100%;
}
}
}
// 数字输入框
.el-input-number {
height: 40px;
width: 100% !important;
.el-input {
height: 40px;
width: 100%;
.el-input__wrapper {
height: 40px;
width: 100%;
padding: 1px 40px 1px 11px; // 左右都为按钮留出空间
box-sizing: border-box;
.el-input__inner {
height: 38px;
line-height: 38px;
width: 100%;
text-align: left;
}
}
}
}
// 确保远程搜索组件继承样式
.remote-select,
.remote-multi-select {
width: 100% !important;
.el-select {
width: 100% !important;
}
}
}
}
}
}
// 响应式调整
@media (max-width: 768px) {
:deep(.el-form) {
&.el-form--label-top {
.el-form-item {
margin-bottom: 20px;
.el-form-item__content {
height: 38px;
.el-date-editor {
&.el-range-editor {
height: 38px !important;
.el-range-input {
height: 36px !important;
line-height: 36px !important;
padding: 0 6px !important;
font-size: 13px !important;
}
.el-range-separator {
height: 36px !important;
line-height: 36px !important;
padding: 0 2px !important;
font-size: 13px !important;
}
.el-range__close-icon,
.el-range__icon {
height: 36px !important;
line-height: 36px !important;
width: 26px !important;
}
}
}
.el-input,
.el-select,
.el-input-number {
height: 38px;
.el-input__wrapper,
.el-select__wrapper {
height: 38px;
padding: 1px 8px;
.el-input__inner,
.el-select__placeholder,
.el-select__selected-item {
height: 36px;
line-height: 36px;
}
}
}
}
}
}
}
}
</style>
// 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, '$&,')
}
......
<template>
<CommonPage
:operationBtnList="operationBtnList"
:showSearchForm="true"
:show-pagination="true"
:total="pageTotal"
:current-page="currentPage"
:page-size="pageSize"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<!-- 搜索区域 -->
<template #searchForm>
<SearchForm
ref="searchFormRef"
v-model="searchFormData"
:fields="searchFields"
label-position="top"
:label-width="null"
:inline="false"
:gutter="20"
class="custom-search-form"
/>
</template>
<!-- 列表区域 -->
<template #table>
<!-- 应付款管理列表 -->
<el-table
:data="tableData"
height="400"
border
highlight-current-row
style="width: 100%"
v-loading="loading"
>
<el-table-column prop="commissionBizType" label="应付款类型" width="120" fixed="left" sortable />
<el-table-column prop="payableNo" label="应付款编号" width="120" />
<el-table-column prop="policyNo" label="保单号" width="120" />
<el-table-column prop="status" label="出账状态" width="120" sortable>
<template #default="{ row }">
<el-tag :type="row.status === '1' ? 'success' : 'warning'">
{{ row.status === '1' ? '已入账' : '待入账' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="commissionPeriod" label="出账期数" width="120" sortable />
<el-table-column prop="totalPeriod" label="出账总期数" width="120" sortable />
<el-table-column prop="commissionType" label="出账项目" width="120" sortable />
<el-table-column prop="paymentDate" label="出账日(估)" width="120" sortable />
<el-table-column prop="commissionRatio" label="出账比例(估)" width="140" sortable>
<template #default="{ row }">
{{ (row.commissionRatio || 0) + '%' }}
</template>
</el-table-column>
<el-table-column prop="expectedAmount" label="出账金额(估)" width="140" sortable>
<template #default="{ row }">
{{ formatCurrency(row.expectedAmount) }}
</template>
</el-table-column>
<el-table-column prop="paidAmountRatio" label="已出账比例" width="120" sortable>
<template #default="{ row }">
{{ (row.paidAmountRatio || 0) + '%' }}
</template>
</el-table-column>
<el-table-column prop="paidAmount" label="已出账金额" width="120" sortable>
<template #default="{ row }">
{{ formatCurrency(row.paidAmount) }}
</template>
</el-table-column>
<el-table-column prop="pendingRatio" label="待出账比例" width="120" sortable>
<template #default="{ row }">
{{ (row.pendingRatio || 0) + '%' }}
</template>
</el-table-column>
<el-table-column prop="pendingPaidAmount" label="待出账金额(估)" width="160" sortable>
<template #default="{ row }">
{{ formatCurrency(row.pendingPaidAmount) }}
</template>
</el-table-column>
<el-table-column prop="insurerBizId" label="保险公司" width="120" sortable />
<el-table-column prop="productLaunchBizId" label="产品计划" width="120" sortable />
<el-table-column prop="premium" label="期交保费" width="120" sortable>
<template #default="{ row }">
{{ formatCurrency(row.premium) }}
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" width="150" />
</el-table>
</template>
</CommonPage>
<!-- 统计信息卡片 -->
<div class="statistics-cards" v-if="statisticsData.totalPolicyCount > 0">
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="4" :lg="4">
<el-card shadow="hover" class="statistics-card">
<div class="card-content">
<div class="card-label">应出款总金额</div>
<div class="card-value">{{ formatCurrency(statisticsData.totalAmount) }}</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="4" :lg="4">
<el-card shadow="hover" class="statistics-card">
<div class="card-content">
<div class="card-label">已出账金额</div>
<div class="card-value">{{ formatCurrency(statisticsData.totalPaidAmount) }}</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="4" :lg="4">
<el-card shadow="hover" class="statistics-card">
<div class="card-content">
<div class="card-label">待出账金额</div>
<div class="card-value">{{ formatCurrency(statisticsData.pendingPaidAmount) }}</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="4" :lg="4">
<el-card shadow="hover" class="statistics-card">
<div class="card-content">
<div class="card-label">已出账比例</div>
<div class="card-value">{{ statisticsData.paidAmountRatio }}</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="4" :lg="4">
<el-card shadow="hover" class="statistics-card">
<div class="card-content">
<div class="card-label">总保单数</div>
<div class="card-value">{{ statisticsData.totalPolicyCount }}</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="4" :lg="4">
<el-card shadow="hover" class="statistics-card">
<div class="card-content">
<div class="card-label">总保费</div>
<div class="card-value">{{ statisticsData.totalPolicyCount }}</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup name="Payables">
import CommonPage from '@/components/commonPage'
import { ref, reactive, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { formatCurrency } from '@/utils/number'
import { expectedFortuneList } from '@/api/financial/commission'
import SearchForm from '@/components/SearchForm/index.vue'
import { searchCompanies, searchCommissionTypes } from '@/api/search'
// 分页相关
const currentPage = ref(1)
const pageSize = ref(10)
const pageTotal = ref(0)
const loading = ref(false)
// 搜索表单数据 - 修正字段名
const searchFormData = reactive({
policyNo: '',
incomeDateRange: [], // 改为数组格式
statusList: [],
fortuneName: [], // 修改字段名
fortunePeriod: '',
insurerBizId: [],
productLaunchBizId: [],
commissionBizType: '',
teamBizId: '',
})
const searchFields = ref([
{
type: 'input',
field: 'policyNo',
label: '保单号',
placeholder: '请输入保单号',
colSpan: 6,
clearable: true,
rules: [
{ max: 50, message: '保单号长度不能超过50个字符', trigger: 'blur' }
]
},
{
type: 'daterange',
field: 'incomeDateRange',
label: '入账日期',
startPlaceholder: '开始日期',
endPlaceholder: '结束日期',
colSpan: 6,
dateFormat: 'YYYY-MM-DD',
props: {
valueFormat: 'YYYY-MM-DD',
style: 'width: 100%'
}
},
{
type: 'multi-select',
field: 'statusList',
label: '入账状态',
placeholder: '请选择入账状态',
colSpan: 6,
options: [
{ label: '已入账', value: '1' },
{ label: '待入账', value: '0' },
{ label: '部分入账', value: '2' }
]
},
{
type: 'remote-multi-select',
field: 'commissionNameList',
label: '入账项目',
placeholder: '请输入关键词搜索',
colSpan: 6,
remoteConfig: {
type: 'commissionType',
apiMethod: searchCommissionTypes,
formatResult: (data) => data.map(item => ({
label: item.typeName,
value: item.typeCode,
remark: item.remark
})),
defaultOptions: [
{ label: '佣金', value: 'COMMISSION' },
{ label: '服务费', value: 'SERVICE_FEE' }
]
}
},
{
type: 'remote-multi-select',
field: 'reconciliationCompanyList',
label: '对账公司',
placeholder: '请输入关键词搜索',
colSpan: 6,
remoteConfig: {
type: 'company',
apiMethod: (params) => searchCompanies({ ...params, type: 'reconciliation' }),
defaultOptions: []
}
},
{
type: 'input',
field: 'commissionPeriod',
label: '入账期数',
placeholder: '请输入期数',
colSpan: 6,
},
{
type: 'remote-multi-select',
field: 'insurerBizId',
label: '保险公司',
placeholder: '请输入关键词搜索保险公司',
colSpan: 6,
remoteConfig: {
type: 'insurer',
apiMethod: (params) => searchCompanies({ ...params, type: 'insurer' }),
defaultOptions: []
}
},
{
type: 'remote-multi-select',
field: 'productLaunchBizId',
label: '产品计划',
placeholder: '请输入关键词搜索产品计划',
colSpan: 6,
remoteConfig: {
type: 'product',
apiMethod: (params) => searchCompanies({ ...params, type: 'product' }),
defaultOptions: []
}
},
{
type: 'select',
field: 'commissionBizType',
label: '应收款类型',
placeholder: '请选择应收款类型',
colSpan: 6,
options: [
{ label: '全部', value: '' },
{ label: '关联保单应收单', value: '1' },
{ label: '非关联保单应收单', value: '2' }
]
},
{
type: 'remote-multi-select',
field: 'teamBizId',
label: '出单团队',
placeholder: '请输入关键词搜索出单团队',
colSpan: 6,
remoteConfig: {
type: 'team',
apiMethod: (params) => searchCompanies({ ...params, type: 'team' }),
defaultOptions: []
}
}
])
// 表格数据
const tableData = ref([])
// 统计信息
const statisticsData = ref({
totalExpectedAmount: 0,
totalPaidAmount: 0,
totalUnpaidAmount: 0,
paidAmountRatio: 0,
totalPolicyCount: 0,
totalPremiumAmount: 0
})
// 按钮事件处理
const handleAdd = () => {
ElMessage.info('点击新增按钮')
}
const handleImport = () => {
ElMessage.info('点击导入按钮')
}
const handleExport = () => {
ElMessage.info('点击导出按钮')
}
const handleReset = () => {
// 重置搜索表单
Object.keys(searchFormData).forEach(key => {
if (Array.isArray(searchFormData[key])) {
searchFormData[key] = []
} else {
searchFormData[key] = ''
}
})
ElMessage.success('搜索条件已重置')
// 重新加载数据
loadTableData()
}
const handleQuery = async () => {
// 表单验证
// const valid = await proxy.$refs.searchFormRef.validate()
// if (!valid) return
ElMessage.info('执行查询操作')
loadTableData()
}
// 控制要显示的默认按钮
// const visibleDefaultButtons = ref(['add', 'import', 'export']) // 只显示新增和查询两个默认按钮
// 按钮配置
const operationBtnList = ref([
{
key: 'add',
direction: 'left',
click: handleAdd
},
{
key: 'import',
direction: 'left',
click: handleImport
},
{
key: 'export',
direction: 'right',
click: handleExport
},
{
key: 'reset',
direction: 'right',
click: handleReset
},
{
key: 'query',
direction: 'right',
click: handleQuery
}
])
// 分页事件
const handleSizeChange = (val) => {
pageSize.value = val
loadTableData()
}
const handleCurrentChange = (val) => {
currentPage.value = val
loadTableData()
}
// 加载表格数据
const loadTableData = async () => {
loading.value = true
try {
const params = {
...searchFormData,
currentPage: currentPage.value,
pageSize: pageSize.value
}
const response = await expectedFortuneList(params)
tableData.value = response.data.page.records
pageTotal.value = response.data.page.total
pageSize.value = response.data.page.size
// 统计信息
statisticsData.value = {
totalExpectedAmount: response.data.expectedStatisticsVO.totalExpectedAmount,
totalPaidAmount: response.data.expectedStatisticsVO.totalPaidAmount,
totalUnpaidAmount: response.data.expectedStatisticsVO.totalUnpaidAmount,
paidAmountRatio: response.data.expectedStatisticsVO.paidAmountRatio,
totalPolicyCount: response.data.expectedStatisticsVO.totalPolicyCount,
totalPremiumAmount: response.data.expectedStatisticsVO.totalPremiumAmount
}
} catch (error) {
console.error('加载数据失败:', error)
ElMessage.error('加载数据失败')
} finally {
loading.value = false
}
}
// 初始化加载数据
onMounted(() => {
loadTableData()
})
</script>
<style scoped lang="scss">
.page-search-container {
padding: 20px;
background: #fff;
border-radius: 8px;
margin-bottom: 20px;
}
.search-actions {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #ebeef5;
text-align: right;
}
</style>
\ No newline at end of file
......@@ -3,162 +3,98 @@
:operationBtnList="operationBtnList"
:showSearchForm="true"
:show-pagination="true"
:total="pageTotal"
:current-page="currentPage"
:page-size="pageSize"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<!-- 搜索区域 -->
<template #searchForm>
<el-form
:model="searchFormData"
ref="searchFormRef"
label-position="top"
class="search-form"
>
<el-row :gutter="20">
<el-col
:xs="24" :sm="12" :md="8" :lg="6" :xl="6"
v-for="item in searchFormItems"
:key="item.value"
>
<el-form-item
:label="item.label"
:prop="item.value"
:rules="item.rules"
>
<template v-if="item.type === 'input'">
<el-input
v-model="searchFormData[item.value]"
:placeholder="item.placeholder"
size="large"
clearable
/>
</template>
<template v-else-if="item.type === 'select'">
<el-select
v-model="searchFormData[item.value]"
:placeholder="item.placeholder"
size="large"
clearable
>
<el-option
v-for="option in item.options"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</template>
<template v-else-if="item.type === 'daterange'">
<el-date-picker
v-model="searchFormData[item.value]"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
size="large"
clearable
style="width: 100%"
/>
</template>
</el-form-item>
</el-col>
</el-row>
</el-form>
<SearchForm
ref="searchFormRef"
v-model="searchFormData"
:fields="searchFields"
label-position="top"
:label-width="null"
:inline="false"
:gutter="20"
class="custom-search-form"
/>
</template>
<!-- 列表区域 -->
<template #table>
<!-- 应收款管理列表 -->
<el-table
:data="tableData"
height="260"
height="400"
border
highlight-current-row
style="width: 100%"
v-loading="loading"
>
<el-table-column prop="commissionBizType" label="应收款类型" width="120" fixed="left" />
<el-table-column prop="commissionBizType" label="应收款类型" width="120" fixed="left" sortable />
<el-table-column prop="receivableNo" label="应收款编号" width="120" />
<el-table-column prop="policyNo" label="保单号" width="120" />
<el-table-column prop="reconciliationCompany" label="对账公司" width="120" />
<el-table-column prop="status" label="入账状态" width="100">
<el-table-column prop="reconciliationCompany" label="对账公司" width="120" sortable />
<el-table-column prop="status" label="入账状态" width="120" sortable>
<template #default="{ row }">
<el-tag :type="row.status === '1' ? 'success' : 'warning'">
{{ row.status === '1' ? '已入账' : '待入账' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="commissionPeriod" label="入账期数" width="100" />
<el-table-column prop="totalPeriod" label="入账总期数" width="100" />
<el-table-column prop="commissionType" label="入账项目" width="100" />
<el-table-column prop="commissionDate" label="入账日(估)" width="120" />
<el-table-column prop="commissionRatio" label="入账比例(估)" width="120">
<el-table-column prop="commissionPeriod" label="入账期数" width="120" sortable />
<el-table-column prop="totalPeriod" label="入账总期数" width="120" sortable />
<el-table-column prop="commissionType" label="入账项目" width="120" sortable />
<el-table-column prop="commissionDate" label="入账日(估)" width="120" sortable />
<el-table-column prop="commissionRatio" label="入账比例(估)" width="140" sortable>
<template #default="{ row }">
{{ (row.commissionRatio || 0) + '%' }}
</template>
</el-table-column>
<el-table-column prop="expectedAmount" label="入账金额(估)" width="120">
<el-table-column prop="expectedAmount" label="入账金额(估)" width="140" sortable>
<template #default="{ row }">
{{ formatCurrency(row.expectedAmount) }}
</template>
</el-table-column>
<el-table-column prop="paidRatio" label="已入账比例" width="120">
<el-table-column prop="paidAmountRatio" label="已入账比例" width="120" sortable>
<template #default="{ row }">
{{ (row.paidRatio || 0) + '%' }}
{{ (row.paidAmountRatio || 0) + '%' }}
</template>
</el-table-column>
<el-table-column prop="paidAmount" label="已入账金额" width="120">
<el-table-column prop="paidAmount" label="已入账金额" width="120" sortable>
<template #default="{ row }">
{{ formatCurrency(row.paidAmount) }}
</template>
</el-table-column>
<el-table-column prop="pendingRatio" label="待入账比例" width="120">
<el-table-column prop="pendingRatio" label="待入账比例" width="120" sortable>
<template #default="{ row }">
{{ (row.pendingRatio || 0) + '%' }}
</template>
</el-table-column>
<el-table-column prop="pendingAmount" label="待入账金额(估)" width="120">
<el-table-column prop="pendingPaidAmount" label="待入账金额(估)" width="160" sortable>
<template #default="{ row }">
{{ formatCurrency(row.pendingAmount) }}
{{ formatCurrency(row.pendingPaidAmount) }}
</template>
</el-table-column>
<el-table-column prop="defaultExchangeRate" label="结算汇率(估)" width="120" />
<el-table-column prop="insurerBizId" label="保险公司" width="120" />
<el-table-column prop="productLaunchBizId" label="产品计划" width="120" />
<el-table-column prop="premium" label="期交保费" width="120">
<el-table-column prop="insurerBizId" label="保险公司" width="120" sortable />
<el-table-column prop="productLaunchBizId" label="产品计划" width="120" sortable />
<el-table-column prop="premium" label="期交保费" width="120" sortable>
<template #default="{ row }">
{{ formatCurrency(row.premium) }}
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" width="150" />
<el-table-column fixed="right" label="操作" width="150">
<template #default="{ row }">
<el-button
link
type="primary"
size="small"
@click="handleViewDetail(row)"
>
查看
</el-button>
<el-button
link
type="danger"
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</template>
</CommonPage>
<!-- 统计信息卡片 -->
<div class="statistics-cards" v-if="statisticsData.totalPolicyCount > 0">
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="6" :lg="6">
<el-col :xs="24" :sm="12" :md="4" :lg="4">
<el-card shadow="hover" class="statistics-card">
<div class="card-content">
<div class="card-label">应收款总金额</div>
......@@ -166,7 +102,7 @@
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6" :lg="6">
<el-col :xs="24" :sm="12" :md="4" :lg="4">
<el-card shadow="hover" class="statistics-card">
<div class="card-content">
<div class="card-label">已入账金额</div>
......@@ -174,15 +110,23 @@
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6" :lg="6">
<el-col :xs="24" :sm="12" :md="4" :lg="4">
<el-card shadow="hover" class="statistics-card">
<div class="card-content">
<div class="card-label">待入账金额</div>
<div class="card-value">{{ formatCurrency(statisticsData.pendingAmount) }}</div>
<div class="card-value">{{ formatCurrency(statisticsData.pendingPaidAmount) }}</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6" :lg="6">
<el-col :xs="24" :sm="12" :md="4" :lg="4">
<el-card shadow="hover" class="statistics-card">
<div class="card-content">
<div class="card-label">已入账比例</div>
<div class="card-value">{{ statisticsData.paidAmountRatio }}</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="4" :lg="4">
<el-card shadow="hover" class="statistics-card">
<div class="card-content">
<div class="card-label">总保单数</div>
......@@ -199,7 +143,9 @@ import CommonPage from '@/components/commonPage'
import { ref, reactive, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { formatCurrency } from '@/utils/number'
import { receivedFortuneList } from '@/api/financial/commission'
import SearchForm from '@/components/SearchForm/index.vue'
import { searchCompanies, searchCommissionTypes } from '@/api/search'
// 分页相关
const currentPage = ref(1)
const pageSize = ref(10)
......@@ -207,211 +153,170 @@ const pageTotal = ref(0)
const loading = ref(false)
// 搜索表单数据
// 搜索表单数据 - 修正字段名
const searchFormData = reactive({
policyNo: '',
incomeDateRange: [''] ,
incomeStatus: '',
incomeTerm: '',
incomeItem: '',
outTeam: '',
insurer: '',
productPlan: '',
paymentType: '',
intermediary: '',
signer: ''
incomeDateRange: [], // 改为数组格式
statusList: [],
commissionNameList: [], // 修改字段名
commissionPeriod: '',
reconciliationCompanyList: [], // 修改字段名
insurerBizId: [],
productLaunchBizId: [],
commissionBizType: '',
teamBizId: '',
})
// 搜索表单项配置
const searchFormItems = ref([
const searchFields = ref([
{
label: '保单号',
value: 'policyNo',
type: 'input',
placeholder: '请输入保单号'
field: 'policyNo',
label: '保单号',
placeholder: '请输入保单号',
colSpan: 6,
clearable: true,
rules: [
{ max: 50, message: '保单号长度不能超过50个字符', trigger: 'blur' }
]
},
{
label: '入账日(估)',
value: 'incomeDateRange',
type: 'daterange',
placeholder: '请选择入账日(估)'
field: 'incomeDateRange',
label: '入账日期',
startPlaceholder: '开始日期',
endPlaceholder: '结束日期',
colSpan: 6,
dateFormat: 'YYYY-MM-DD',
props: {
valueFormat: 'YYYY-MM-DD',
style: 'width: 100%'
}
},
{
type: 'multi-select',
field: 'statusList',
label: '入账状态',
value: 'incomeStatus',
type: 'select',
placeholder: '请选择入账状态',
colSpan: 6,
options: [
{ label: '全部', value: '' },
{ label: '已入账', value: '1' },
{ label: '待入账', value: '0' }
{ label: '待入账', value: '0' },
{ label: '部分入账', value: '2' }
]
},
{
label: '入账期数',
value: 'incomeTerm',
type: 'input',
placeholder: '请输入入账期数'
type: 'remote-multi-select',
field: 'commissionNameList',
label: '入账项目',
placeholder: '请输入关键词搜索',
colSpan: 6,
remoteConfig: {
type: 'commissionType',
apiMethod: searchCommissionTypes,
formatResult: (data) => data.map(item => ({
label: item.typeName,
value: item.typeCode,
remark: item.remark
})),
defaultOptions: [
{ label: '佣金', value: 'COMMISSION' },
{ label: '服务费', value: 'SERVICE_FEE' }
]
}
},
{
label: '入账项目',
value: 'incomeItem',
type: 'select',
placeholder: '请选择入账项目',
options: [
{ label: '全部', value: '' },
{ label: '佣金', value: '1' },
{ label: '服务费', value: '2' }
]
type: 'remote-multi-select',
field: 'reconciliationCompanyList',
label: '对账公司',
placeholder: '请输入关键词搜索',
colSpan: 6,
remoteConfig: {
type: 'company',
apiMethod: (params) => searchCompanies({ ...params, type: 'reconciliation' }),
defaultOptions: []
}
},
{
label: '出单团队',
value: 'outTeam',
type: 'select',
placeholder: '请选择出单团队',
options: [
{ label: '全部', value: '' },
{ label: '团队A', value: '1' },
{ label: '团队B', value: '2' }
]
type: 'input',
field: 'commissionPeriod',
label: '入账期数',
placeholder: '请输入期数',
colSpan: 6,
},
{
type: 'remote-multi-select',
field: 'insurerBizId',
label: '保险公司',
value: 'insurer',
type: 'select',
placeholder: '请选择保险公司',
options: [
{ label: '全部', value: '' },
{ label: '保险公司A', value: '1' },
{ label: '保险公司B', value: '2' }
]
placeholder: '请输入关键词搜索保险公司',
colSpan: 6,
remoteConfig: {
type: 'insurer',
apiMethod: (params) => searchCompanies({ ...params, type: 'insurer' }),
defaultOptions: []
}
},
{
type: 'remote-multi-select',
field: 'productLaunchBizId',
label: '产品计划',
value: 'productPlan',
type: 'select',
placeholder: '请选择产品计划',
options: [
{ label: '全部', value: '' },
{ label: '计划A', value: '1' },
{ label: '计划B', value: '2' }
]
placeholder: '请输入关键词搜索产品计划',
colSpan: 6,
remoteConfig: {
type: 'product',
apiMethod: (params) => searchCompanies({ ...params, type: 'product' }),
defaultOptions: []
}
},
{
label: '应收款类型',
value: 'paymentType',
type: 'select',
field: 'commissionBizType',
label: '应收款类型',
placeholder: '请选择应收款类型',
colSpan: 6,
options: [
{ label: '全部', value: '' },
{ label: '关联保单应收单', value: '1' },
{ label: '非关联保单应收单', value: '2' }
]
},
{
label: '转介人',
value: 'intermediary',
type: 'select',
placeholder: '请选择转介人',
options: [
{ label: '全部', value: '' },
{ label: '转介人A', value: '1' },
{ label: '转介人B', value: '2' }
]
},
{
label: '签单员',
value: 'signer',
type: 'select',
placeholder: '请选择签单员',
options: [
{ label: '全部', value: '' },
{ label: '签单员A', value: '1' },
{ label: '签单员B', value: '2' }
]
type: 'remote-multi-select',
field: 'teamBizId',
label: '出单团队',
placeholder: '请输入关键词搜索出单团队',
colSpan: 6,
remoteConfig: {
type: 'team',
apiMethod: (params) => searchCompanies({ ...params, type: 'team' }),
defaultOptions: []
}
}
])
// 表格数据
const tableData = ref([
// 示例数据
{
id: 1,
commissionBizType: '佣金',
receivableNo: 'YSK001',
policyNo: 'POL001',
reconciliationCompany: '对账公司A',
status: '1',
commissionPeriod: 1,
totalPeriod: 12,
commissionType: '首期佣金',
commissionDate: '2024-01-15',
commissionRatio: 8.5,
expectedAmount: 8500,
paidRatio: 100,
paidAmount: 8500,
pendingRatio: 0,
pendingAmount: 0,
defaultExchangeRate: 1.0,
insurerBizId: '保险公司A',
productLaunchBizId: '计划A',
premium: 100000,
remark: '备注信息'
},
{
id: 2,
commissionBizType: '服务费',
receivableNo: 'YSK002',
policyNo: 'POL002',
reconciliationCompany: '对账公司B',
status: '0',
commissionPeriod: 2,
totalPeriod: 12,
commissionType: '服务费',
commissionDate: '2024-02-15',
commissionRatio: 5.0,
expectedAmount: 5000,
paidRatio: 50,
paidAmount: 2500,
pendingRatio: 50,
pendingAmount: 2500,
defaultExchangeRate: 1.0,
insurerBizId: '保险公司B',
productLaunchBizId: '计划B',
premium: 50000,
remark: ''
}
])
const tableData = ref([])
// 统计信息
const statisticsData = ref({
totalAmount: 13500,
totalPaidAmount: 11000,
pendingAmount: 2500,
paidRatio: 81.48, // (11000/13500*100)
totalPolicyCount: 2
totalAmount: 0,
totalPaidAmount: 0,
pendingPaidAmount: 0,
paidAmountRatio: 0,
totalPolicyCount: 0,
paidAmountRatio: 0
})
// 按钮事件处理
const handleAdd = () => {
ElMessage.info('点击新增按钮')
// 这里可以打开新增弹窗
// proxy.$refs.addForm.resetFields()
}
const handleImport = () => {
ElMessage.info('点击导入按钮')
// 这里可以打开导入弹窗
// proxy.$refs.importForm.resetFields()
}
const handleExport = () => {
ElMessage.info('点击导出按钮')
// 这里可以执行导出逻辑
// proxy.$refs.exportForm.resetFields()
}
const handleReset = () => {
......@@ -466,34 +371,6 @@ const operationBtnList = ref([
click: handleQuery
}
])
// 表格操作
const handleViewDetail = (row) => {
ElMessage.info(`查看应收款详情:${row.receivableNo}`)
// 这里可以打开详情弹窗或跳转到详情页
}
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm(
`确定要删除应收款 "${row.receivableNo}" 吗?`,
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
// 执行删除操作
tableData.value = tableData.value.filter(item => item.id !== row.id)
ElMessage.success('删除成功')
// 重新计算统计信息
calculateStatistics()
} catch {
// 用户取消了操作
}
}
// 分页事件
const handleSizeChange = (val) => {
......@@ -510,21 +387,25 @@ const handleCurrentChange = (val) => {
const loadTableData = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 500))
// 这里应该调用真实的API接口
// const params = {
// ...searchFormData,
// currentPage: currentPage.value,
// pageSize: pageSize.value
// }
// const response = await api.getReceivablesList(params)
// tableData.value = response.data.list
// pageTotal.value = response.data.total
const params = {
...searchFormData,
currentPage: currentPage.value,
pageSize: pageSize.value
}
const response = await receivedFortuneList(params)
tableData.value = response.data.page.records
pageTotal.value = response.data.page.total
pageSize.value = response.data.page.size
// 计算统计信息
calculateStatistics()
// 统计信息
statisticsData.value = {
totalAmount: response.data.expectedStatisticsVO.totalAmount,
totalPaidAmount: response.data.expectedStatisticsVO.totalPaidAmount,
pendingPaidAmount: response.data.expectedStatisticsVO.pendingPaidAmount,
paidAmountRatio: response.data.expectedStatisticsVO.paidAmountRatio,
totalPolicyCount: response.data.expectedStatisticsVO.totalPolicyCount,
paidAmountRatio: response.data.expectedStatisticsVO.paidAmountRatio
}
} catch (error) {
console.error('加载数据失败:', error)
......@@ -534,81 +415,27 @@ const loadTableData = async () => {
}
}
// 计算统计信息
const calculateStatistics = () => {
const data = tableData.value
const totalAmount = data.reduce((sum, item) => sum + (item.expectedAmount || 0), 0)
const totalPaidAmount = data.reduce((sum, item) => sum + (item.paidAmount || 0), 0)
const pendingAmount = totalAmount - totalPaidAmount
const paidRatio = totalAmount > 0 ? (totalPaidAmount / totalAmount * 100) : 0
statisticsData.value = {
totalAmount,
totalPaidAmount,
pendingAmount,
paidRatio: Number(paidRatio.toFixed(2)),
totalPolicyCount: data.length
}
}
// 监听搜索条件变化(可选:自动查询)
watch(
() => searchFormData,
() => {
// 如果需要自动查询,可以在这里调用
// debounce(() => loadTableData(), 500)
},
{ deep: true }
)
// 初始化加载数据
onMounted(() => {
loadTableData()
calculateStatistics()
})
</script>
<style scoped lang="scss">
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 20px;
padding: 15px 0;
border-top: 1px solid var(--el-border-color-lighter);
.page-search-container {
padding: 20px;
background: #fff;
border-radius: 8px;
margin-bottom: 20px;
}
.statistics-cards {
.search-actions {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #ebeef5;
text-align: right;
}
.statistics-card {
margin-bottom: 15px;
.card-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
.card-label {
color: var(--el-text-color-secondary);
margin-bottom: 8px;
}
.card-value {
font-size: 24px;
font-weight: 600;
color: var(--el-color-primary);
}
}
}
// 响应式调整
@media (max-width: 768px) {
.statistics-cards {
.el-col {
margin-bottom: 15px;
}
}
}
</style>
\ No newline at end of file
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