效果图:
我的想法还是和上一个封装的table组件一样,只通过一个配置文件就控制整个搜索栏
这个组件是超过两行会显示折叠按钮
思路也很简单,就是将不同的筛选字段的形式都封装到一起,包括文本输入框、单选、多选、时间选择器、radio/checkbox、联级选择器等等,此外还允许通过插槽进行自定义格式。
像选择器,他需要list选项,但各个选项所用到的value和label有可能是不一样的字段,这些我想的是将这个需要获取数据的搜索字段都写到一个方法里面去,然后对这些数据进行格式化,全都格式化为统一的数据结构形式,然后将这个方法再传递给组件,这样就可以实现渲染出来的组件是完全功能的组件了。
注意:这个组件封装是使用的ant-design组件库
组件代码:
其中使用的引用会放在下面
<script setup lang="ts">
import PageTitle from '@/components/Public/PageTitle.vue' // 标题组件
import { computed, nextTick, onActivated, onMounted, reactive, ref } from 'vue'
import { compareAndUpdateArraysName, getSearchFieldList, isInputType, queryArea } from '@/utils/utils.js' // 这是在需要扩展字段和自定义展示搜索字段时候用到的
import Tooltip from '@/components/Tooltip/Tooltip.vue' // 这是动态计算并超出指定宽度自动显示省略号的组件,主要是给label用的
import enums from '@/utils/enums.js'
import RangePicker from '@/components/Public/RangePicker.vue' // 时间选择器组件
import FoldSearch from '@/components/Public/FoldSearch.vue' // 折叠和展开组件
const props = defineProps({
fields: { // 暂时没用到
type: Array,
default: () => [],
},
params: { // 外部参数,
type: Object,
default: () => ({}),
},
title: { // 标题
type: String,
default: '',
},
extendFieldList: { // 扩展字段
type: Array,
default: () => [],
},
dateType: { // 暂时不用
type: String,
default: 'date',
},
module: { // 自定义搜索字段模块名,一般都用不到
type: String,
default: '',
},
fetchParentData: {
type: Function,
},
isResize: {
type: Boolean,
default: false,
},
isArea: {
type: Boolean,
default: false,
},
isPlaceholder: {
type: Boolean,
default: false,
},
labelWidth: {
type: Number,
default: enums.SEARCH_LABEL
}
})
const emits = defineEmits(['search', 'resize'])
const searchFieldList = ref([])
const extendFieldList = computed(() => props?.extendFieldList)
const loading = ref(false)
const searchData = reactive(props?.params)
const areaOptions = ref([])
function onSearch() {
initParams()
}
function inputChange(e) {
if (!e.target?.value && e?.type === 'click') {
initParams()
}
}
function changeExtendDate(dateString, it) {
if (!dateString?.length) {
searchData[`${it.name}_start`] = null
searchData[`${it.name}_end`] = null
} else {
const [created_at_start, created_at_end] = dateString
searchData[`${it.name}_start`] = created_at_start
searchData[`${it.name}_end`] = created_at_end
}
initParams()
}
const isSelf = ref(false)
const isDep = ref(false)
function checkSearchSelectDisable(it) {
if (it.name === 'salesman_user_id' && isSelf.value) {
return true
}
if (it.name === 'salesman_department_id' && isDep.value) {
return true
}
return false
}
function customFileSelectSearchChange(value, option) {
// console.log('value', value)
// console.log('option', option)
initParams()
}
function handleChangeArea(value) {
if (!value) { // 清空
searchData.province_code = null
searchData.city_code = null
searchData.area_code = null
} else {
const [province, city, area] = value
searchData.province_code = province
searchData.city_code = city
searchData.area_code = area
}
initParams()
}
function handleChangeCascader(value, selectedOptions) {
console.log('value', value)
console.log('selectedOptions', selectedOptions)
// initParams()
}
function initParams() {
// const params = deleteEmptyValue(searchData)
emits('search', searchData)
}
const searchRef = ref()
const foldSearchRef = ref()
const containerStyle = ref({})
function foldSearchChange(value) {
containerStyle.value = value
if (props?.isResize) {
nextTick(() => {
emits('resize')
})
}
}
const staticData = ref([])
async function initPromise(isRefreshFoldSearch = false) {
loading.value = true
const promiseList = []
if (props.fetchParentData) {
promiseList.push(props.fetchParentData())
}
if (props?.module) {
promiseList.push(getSearchFieldList(props?.module))
}
if (props?.isArea) {
promiseList.push(queryArea())
}
await Promise.all(promiseList).then((res) => {
if (res?.length) {
res.forEach((item) => {
if (item.name === 'area') {
areaOptions.value = item.data
}
if (item.name === 'cloneSearchFields') {
staticData.value = item.data
}
if (item.name === 'search') {
let JSONData = []
if (item?.data?.length) {
JSONData = JSON.parse(item?.data[0]?.json_data)
}
const formatJsonData = compareAndUpdateArraysName([...staticData.value, ...extendFieldList.value], JSONData)
searchFieldList.value = formatJsonData.length ? formatJsonData : staticData.value
}
if (!props?.module) {
searchFieldList.value = staticData.value
}
})
if (isRefreshFoldSearch) {
containerStyle.value = {
height: 'auto',
overflow: 'visible',
}
nextTick(() => {
foldSearchRef.value.searchChange()
})
}
}
}).catch((err) => {
console.log('error', err)
}).finally(() => {
loading.value = false
})
}
onMounted(async () => {
await initPromise(true)
})
onActivated(async () => {
})
defineExpose({
initPromise,
})
</script>
<template>
<div class="search-bar">
<slot name="header">
<page-title :title="props.title" />
</slot>
<a-spin :spinning="loading">
<div class="search-box">
<div ref="searchRef" class="search-left" :style="containerStyle">
<template v-for="it in searchFieldList" :key="it.name">
<div v-if="!it?.noShow" class="search-item">
<div class="search-label" :style="{ width: `${props.labelWidth}px` }">
<div class="search-label-text" :style="{ width: `${props.labelWidth - 14}px` }">
<tooltip
:is-weight="false"
:max-width="props.labelWidth - 14"
:text="it?.label"
/>
</div>
:
</div>
<a-input
v-if="isInputType(it)"
v-model:value="searchData[it.name]"
:allow-clear="it?.isClear !== enums.BOOL.NO.v"
:disabled="it?.searchDisable"
:style="enums.SEARCH_INPUT_WIDTH"
:placeholder="props?.isPlaceholder ? `请输入${it.label}` : ''"
@press-enter="onSearch"
@change="inputChange"
/>
<range-picker
v-if="(it.type === enums.FIELD_TYPE.DATE.v || it.type === enums.FIELD_TYPE.DATETIME.v) && !it?.isCustomEvent"
v-model:value="searchData[it.name]"
picker="date"
:it="it"
:disabled="it?.searchDisable"
:style="enums.SEARCH_INPUT_WIDTH"
:placeholder="props?.isPlaceholder ? ['开始日期', '结束日期'] : []"
@change="changeExtendDate"
/>
<a-select
v-if="it.type === enums.FIELD_TYPE.SELECTOR.v && !it?.isCustomEvent"
v-model:value="searchData[it.name]"
:allow-clear="it?.isClear !== enums.BOOL.NO.v"
:placeholder="props?.isPlaceholder ? '请选择' : ''"
:style="enums.SEARCH_INPUT_WIDTH"
show-search
option-filter-prop="label"
:disabled="it?.searchDisable"
@change="customFileSelectSearchChange"
>
<a-select-option
v-for="item in it?.field_options"
:key="item.the_key"
:value="item.the_key"
:label="item.the_value"
>
{{ item.the_value }}
</a-select-option>
</a-select>
<a-select
v-if="it.type === enums.FIELD_TYPE.MULTI_SELECTOR.v && !it?.isCustomEvent"
v-model:value="searchData[it.name]"
:allow-clear="it?.isClear !== enums.BOOL.NO.v"
mode="multiple"
:placeholder="props?.isPlaceholder ? '请选择' : ''"
:style="enums.SEARCH_INPUT_WIDTH"
show-search
option-filter-prop="label"
:disabled="it?.searchDisable"
@change="customFileSelectSearchChange"
>
<a-select-option
v-for="item in it?.field_options"
:key="item.the_key"
:value="item.the_key"
:label="item.the_value"
>
{{ item.the_value }}
</a-select-option>
</a-select>
<a-cascader
v-if="(it.type === enums.FIELD_TYPE.CASCADER.v && it?.isArea) && !it?.isCustomEvent"
:allow-clear="it?.isClear !== enums.BOOL.NO.v"
:style="enums.SEARCH_INPUT_WIDTH"
:options="areaOptions"
:placeholder="props?.isPlaceholder ? '请选择' : ''"
:field-names="{ label: 'name', value: 'code', children: 'children' }"
:change-on-select="it?.changeOnSelect"
:disabled="it?.searchDisable"
@change="(values) => handleChangeArea(values, it.name)"
/>
<a-cascader
v-if="(it.type === enums.FIELD_TYPE.CASCADER.v && !it?.isArea) && !it?.isCustomEvent"
:allow-clear="it?.isClear !== enums.BOOL.NO.v"
:style="enums.SEARCH_INPUT_WIDTH"
:options="it?.field_options"
:placeholder="props?.isPlaceholder ? '请选择' : ''"
:field-names="{ label: 'name', value: 'id', children: 'children' }"
:change-on-select="it?.changeOnSelect"
:disabled="it?.searchDisable"
@change="handleChangeCascader"
/>
<!-- <RangePicker -->
<!-- v-if="it.type === enums.FIELD_TYPE.DATETIME.v" -->
<!-- v-model:value="searchData[it.name]" -->
<!-- :picker="'dateTime'" -->
<!-- :format="'YYYY-MM-DD HH:mm:ss'" -->
<!-- :valueFormat="'YYYY-MM-DD HH:mm:ss'" -->
<!-- :it="it" -->
<!-- :placeholder="['开始时间','结束时间']" -->
<!-- @change="changeExtendDate" -->
<!-- /> -->
<template v-if="it?.isCustomEvent">
<slot :it="it" :name="it?.name" />
</template>
</div>
</template>
</div>
<div class="search-right">
<fold-search ref="foldSearchRef" :search-ref="searchRef" @fold-search-change="foldSearchChange" />
</div>
</div>
</a-spin>
</div>
</template>
<style scoped lang="less">
.search-bar{
width: 100%;
border-radius: 6px;
overflow: hidden;
padding: 0 20px;
background-color: #fff;
.search-box{
flex: 1;
padding: 20px 0;
display: flex;
.search-left {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 10px;
row-gap: 24px;
.search-item {
display: flex;
align-items: center;
.search-label {
width: 104px;
margin-right: 5px;
display: flex;
align-items: center;
.search-label-text{
width: 90px;
color: rgba(0, 0, 0, 0.85);
text-align: right;
display: flex;
align-items: center;
justify-content: flex-end;
}
//.tooltip-text {
// display: inline-block;
// max-width: 86px; /* 设置最大宽度以进行测试 */
// white-space: nowrap; /* 不换行 */
// overflow: hidden; /* 隐藏超出部分 */
// text-overflow: ellipsis; /* 使用省略号 */
//}
}
}
}
.search-right {
}
}
}
</style>
Search组件 使用样例:
<sqb-search-bar
ref="sqbSearchBarRef"
title="客户中心"
:fields="searchFields"
:fetch-parent-data="initSearchFields"
:params="params"
@search="initParams"
>
</sqb-search-bar>
const params = ref({})// 搜索参数
function initParams(newParams) {// 刷新table表格
params.value = newParams
refreshTable({ reload: true })
}
// 初始化筛选条件栏的数据
async function initSearchFields() {
// 1. 初始应该是 searchFields 的深拷贝,而不是空数组
const cloneSearchFields = cloneDeep(searchFields.value)
// , getDep(), formatClueStatus(enums.CLUE_STATUS), formatBool('from_seas'), formatCallStatus(enums.CUSTOMER_CALL_STATUS)
const promiseList = [getUserSelector(), getCustomerFromList(), queryStagList()]
try {
const res = await Promise.all(promiseList)
if (res?.length) {
res.forEach((item) => {
FIELD_CONFIGS.forEach((config) => {
updateFieldOptions(cloneSearchFields, item, config.name, config.format)
})
})
}
searchFields.value = cloneDeep(cloneSearchFields)
return {
name: 'cloneSearchFields',
data: cloneSearchFields,
} // 正确返回数据
} catch (err) {
console.error('initSearchFields error:', err)
throw err // 抛出错误,让调用方处理
}
}
const FIELD_CONFIGS = [
{
name: 'sale_user_id',
format: { id: 'the_key', card_user_name: 'the_value' },
},
{
name: 'customer_source_id',
format: { id: 'the_key', name: 'the_value' },
},
{
name: 'customer_stage_id',
format: { id: 'the_key', name: 'the_value' },
},
]
// 销售顾问选择器
function getUserSelector() {
return userSelector({
page_no: 1,
page_size: 999,
}).then(res => ({
name: 'sale_user_id',
data: updateListData(res?.data?.data, selfUserInfo),
})).catch(err => err)
}
// 客户来源
function getCustomerFromList() {
return DictApi.list({
page_no: 1,
page_size: 9999,
type: enums.DICT.CUSTOMER_FROM.v,
value_sort: 'ascend',
}).then(res => ({
name: 'customer_source_id',
data: res?.data?.data,
}))
}
// 客户阶段
async function queryStagList() {
return DictApi.list({
page_no: 1,
page_size: 9999,
type: enums.DICT.CLUE_STAGE.v,
value_sort: 'ascend',
}).then(res => ({
name: 'customer_stage_id',
data: res?.data?.data,
}))
}
searchFields这个就是配置文件:
import enums from '@/utils/enums.js'
import { isAdminRole } from '@/utils/utils.js'
export default [
{
label: '客户名称',
name: 'name',
type: enums.FIELD_TYPE.TEXT_IPT.v,
disable: true,
},
{
label: '客户编号',
name: 'code',
type: enums.FIELD_TYPE.TEXT_IPT.v,
},
{
label: '手机号码',
name: 'mobile',
type: enums.FIELD_TYPE.TEXT_IPT.v,
},
{
label: '销售顾问',
name: 'sale_user_id',
type: enums.FIELD_TYPE.SELECTOR.v,
searchDisable: !isAdminRole(),
noShow: !isAdminRole(),
},
// {
// label: '部门',
// name: 'salesman_department_id',
// type: enums.FIELD_TYPE.SELECTOR.v,
// },
{
label: '客户区域',
name: 'customer_region',
type: enums.FIELD_TYPE.CASCADER.v,
isArea: true,
changeOnSelect: true,
},
{
label: '客户来源',
name: 'customer_source_id',
type: enums.FIELD_TYPE.SELECTOR.v,
},
// {
// label: '跟单状态',
// name: 'clue_status',
// type: enums.FIELD_TYPE.SELECTOR.v,
// },
{
label: '客户阶段',
name: 'customer_stage_id',
type: enums.FIELD_TYPE.SELECTOR.v,
},
// {
// label: 'AI标签',
// name: 'ai_analysis_tags',
// type: enums.FIELD_TYPE.SELECTOR.v,
// isCustomEvent: true,
// },
// {
// label: '领取时间',
// name: 'claim_time',
// type: enums.FIELD_TYPE.DATE.v,
// },
{
label: '最后跟单时间',
name: 'last_follow_time',
type: enums.FIELD_TYPE.DATE.v,
},
// {
// label: '公海客户',
// name: 'from_seas',
// type: enums.FIELD_TYPE.SELECTOR.v,
// },
// {
// label: '拨打状态',
// name: 'call_status',
// type: enums.FIELD_TYPE.SELECTOR.v,
// },
{
label: '创建时间',
name: 'created_at',
type: enums.FIELD_TYPE.DATE.v,
},
]
PageTitle组件:
<script setup>
import { computed } from 'vue'
const props = defineProps({
title: {
type: String,
default: '',
},
})
const title = computed(() => props?.title)
</script>
<template>
<div class="page-title">
<div class="title-left">
<div class="title">
{{ title }}
</div>
<slot name="title_left" />
</div>
<div class="title-right">
<slot name="title_right" />
</div>
</div>
</template>
<style scoped lang="less">
.page-title{
width: 100%;
height: 60px;
box-sizing: border-box;
padding: 20px 0;
background-color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgb(239, 239, 239);
.title-left{
display: flex;
align-items: center;
gap: var(--small-gap);
.title{
font-size: 18px;
font-weight: bold;
}
}
}
</style>
Tooltip组件:
<script setup>
import { onMounted, ref, watch } from 'vue'
const props = defineProps({
text: {
type: String,
required: true,
},
maxWidth: {
type: Number,
default: 100, // 默认最大宽度
},
color: { // 使用指定颜色渲染文本
type: String,
color: 'rgba(0, 0, 0, 0.65)',
},
colorEqual: { // 不论是否超出隐藏,文本颜色都是一样的(配合指定颜色使用)
type: Boolean,
default: false,
},
isCursor: { // 是否显示选中效果
type: Boolean,
default: false,
},
isWeight: {
type: Boolean,
default: false,
},
})
const isOverflowing = ref(false)
const textRef = ref(null)
function initPage() {
if (textRef.value) {
const computedStyle = getComputedStyle(textRef.value)
const padding = Number.parseFloat(computedStyle.paddingLeft) + Number.parseFloat(computedStyle.paddingRight)
const totalMaxWidth = props.maxWidth - padding // 考虑 padding
// 创建临时元素以测量文本宽度
const tempElement = document.createElement('span')
// 复制样式
tempElement.style.font = computedStyle.font // 复制字体
tempElement.style.visibility = 'hidden'
tempElement.style.whiteSpace = 'nowrap'
tempElement.style.position = 'absolute' // 确保不占空间
tempElement.textContent = props.text
// 将元素添加到文档中
document.body.appendChild(tempElement)
// 判断文本是否超出最大宽度
isOverflowing.value = tempElement.scrollWidth > totalMaxWidth
// 移除临时元素
document.body.removeChild(tempElement)
}
}
onMounted(() => {
initPage()
})
// updated(() => {
// initPage();
// });
watch([() => props.text, () => props.maxWidth], () => {
initPage()
})
</script>
<template>
<a-tooltip v-if="isOverflowing" :title="text">
<span
ref="textRef"
:class="{ 'cursor-class': props.isCursor, 'font-weight': props?.isWeight }"
class="tooltip-text"
:style="{ color: props.color }"
>{{ text }}</span>
</a-tooltip>
<span
v-else
ref="textRef"
:class="{ 'cursor-class': props.isCursor, 'font-weight': props?.isWeight }"
:style="{ color: props.colorEqual ? props.color : 'rgba(0, 0, 0, 0.88)' }"
>{{ text }}</span>
</template>
<style scoped lang="less">
.tooltip-text {
display: inline-block; /* 使文本能正确测量 */
max-width: 100%; /* 允许文本最大宽度 */
white-space: nowrap; /* 不换行 */
overflow: hidden; /* 隐藏超出部分 */
text-overflow: ellipsis; /* 使用省略号 */
cursor: pointer; /* 鼠标悬停时显示手指光标 */
}
.font-weight{
font-weight: 500;
}
</style>
utils方法:
/**
* 处理搜索条件配置的--name为键--给扩展字段用的--格式化扩展字段
* @param staticData
* @param dynamicData
* @returns {*[]}
*/
export function compareAndUpdateArraysName(staticData, dynamicData) {
const updatedArr = []
const staticMap = new Map()
staticData.forEach((item) => {
staticMap.set(item.name, item)
})
dynamicData.forEach((item) => {
if (staticMap.has(item.name)) {
const staticItem = staticMap.get(item.name)
updatedArr.push({ ...item, ...staticItem })
}
})
return updatedArr
}
/**
* 获取搜索条件栏的字段
* @param module_name
* @returns {Promise<unknown>}
*/
export async function getSearchFieldList(module_name) {
return getWEBFilterApi({
module_name,
channel: enums.CHANNEL.WEB.v,
}).then(res => ({
name: 'search',
data: res.data,
})).catch(() => ({
name: 'search',
data: [],
})).finally(() => {
})
}
/**
* 是否是input形态的字段--用于搜索条件字段的显示
* @param it 字段
* @returns {*}
*/
export function isInputType(it) {
if (!it || !it.type || it?.isCustomEvent) return false
const inputTypes = [
enums.FIELD_TYPE.TEXT_IPT.v,
enums.FIELD_TYPE.MULTI_TEXT_IPT.v,
enums.FIELD_TYPE.EXPRESS_NUMBER.v,
]
return inputTypes.includes(it?.type)
}
/**
* 获取省市县树状结构
* @returns {Promise<{name: string, data: *}>}
*/
export async function queryArea() {
return queryRegionTree({}).then((res) => {
return {
name: 'area',
data: res.data,
}
})
}
enums.js
export default {
ALL: {
ALL: {
v: null,
name: '全部',
visitorName: '全部',
},
},
BOOL: {
YES: {
v: 1,
name: '是',
visitorName: '客户',
},
NO: {
v: 2,
name: '否',
visitorName: '微信用户',
},
},
CHANNEL: {
WECHAT: {
v: 1,
name: '小程序端',
},
WEB: {
v: 2,
name: 'WEB端',
},
APP: {
v: 3,
name: 'APP',
},
},
ERROR_CODE: {
LOGIN_INVALID: {
code: 1,
name: '登录失效',
},
BUSI: {
code: 2,
name: '业务异常',
},
DATA_NOT_EXIST: {
code: 3,
name: '数据不存在',
},
},
ROUTER_CONFIG: {
LOGIN_PATH: {
v: '/login',
name: '登录地址',
},
DEFAULT_HOME_PATH: {
v: '/customerManage/businessCardVisitor/list',
name: '默认首页地址',
},
WX_LOGIN_PATH: {
v: '/wxLogin',
name: '微信登录',
},
Bind_MOBILE_PATH: {
v: '/bindMobile',
name: '绑手机号',
},
},
MAXLENGTH: 100,
// 搜索字段label显示宽度
SEARCH_LABEL: 104,
// 搜索字段输入框宽度
SEARCH_INPUT_WIDTH: 'width: 270px',
// 字段类型
FIELD_TYPE: {
TEXT_IPT: {
v: 'text_ipt',
name: '单行文本',
icon: extendFieldIcon.textImg,
},
MULTI_TEXT_IPT: {
v: 'multi_text_ipt',
name: '多行文本',
icon: extendFieldIcon.textareaImg,
},
DATE: {
v: 'date',
name: '日期',
icon: extendFieldIcon.dateImg,
},
LOCATION: {
v: 'location',
name: '定位',
icon: extendFieldIcon.locationImg,
},
NUM_IPT: {
v: 'num_ipt',
name: '数字输入框',
icon: extendFieldIcon.locationImg,
},
SELECTOR: {
v: 'selector',
name: '单选下拉框',
icon: extendFieldIcon.SelectImg,
},
CASCADER: {
v: 'cascader',
name: '级联选择',
icon: extendFieldIcon.locationImg,
},
MULTI_SELECTOR: {
v: 'multi_selector',
name: '多选下拉框',
icon: extendFieldIcon.multiSelectorImg,
},
DATETIME: {
v: 'datetime',
name: '时间',
icon: extendFieldIcon.dataTimeImg,
},
FILE: {
v: 'file',
name: '附件',
icon: extendFieldIcon.AttachmentImg,
},
SHOT: {
v: 'shot',
name: '拍照/摄影',
icon: extendFieldIcon.locationImg,
},
MOBILE: {
v: 'mobile',
name: '手机号码',
icon: extendFieldIcon.locationImg,
},
EMAIL: {
v: 'email',
name: '邮箱',
icon: extendFieldIcon.locationImg,
},
EXPRESS_NUMBER: {
v: 'express_number',
name: '快递单号',
icon: extendFieldIcon.expressageImg,
},
SIGN: {
v: 'sign',
name: '签字',
icon: extendFieldIcon.locationImg,
},
FORMULA: {
v: 'formula',
name: '公式',
icon: extendFieldIcon.locationImg,
},
ASSOCIATION_OBJECT: {
v: 'association_object',
name: '关联对象',
icon: extendFieldIcon.locationImg,
},
ASSOCIATION_ATTR: {
v: 'association_attr',
name: '关联属性',
icon: extendFieldIcon.locationImg,
},
DOUBLE_INPUT: {
v: 'double_input',
name: '联级输入框',
icon: extendFieldIcon.locationImg,
},
RADIO: {
v: 'radio',
name: '单选',
icon: extendFieldIcon.locationImg,
},
},
// 模块类型,
MODULE_TYPE: {
DYNAMIC: {
v: 1,
name: '动态',
},
STATIC: {
v: 2,
name: '静态',
},
},
// 模块名称--扩展字段用的
MODULE: {
BUSINESS_CARD_VISITOR: 'businessCardVisitor', // 名片访客
CLUE_CONTROL: 'clueControl',
CLUE: 'clue',
FOLLOW_UP: 'followUp',
CLUE_OPEN_SEAS: 'clueOpenSeas',
ORDER_MANAGEMENT: 'clue_deal_order',
PAYMENT_EXPENDITURE: 'payment_expenditure',
INVOICE: 'invoice',
PRODUCT: 'product',
AI_ANALYSIS: 'aiAnalysis',
ELITE_RECORDING_LIBRARY: 'eliteRecordingLibrary',
GAME_REWARD: 'gameReward',
SOP_INSPECT: 'sopInspect', // 话术应用质检
VIOLATION: 'violation', // 违规质检
EXECUTE: 'execute', // 执行力报表
RETRIEVAL: 'retrieval',
CUSTOMER_INSIGHT: 'customerInsight',
PERFORMANCE_TARGET: 'performanceTarget', // 目标管理-业绩目标
FOLLOW_CLUE: 'followClue', // 目标管理-跟单拓客目标
RECOMMENDED_OFFICER_LIST: 'recommendedOfficerList', // 推荐官列表
RECOMMENDATION_OFFICER_REVIEW: 'recommendationOfficerReview', // 推荐官审核
MONEY_DISTRIBUTION: 'moneyDistribution', // 红包发放
WITHDRAWAL_SETTLEMENT: 'withdrawalSettlement', // 提现结算
REFERRER_BALANCE: 'referrerBalance', // 推荐官余额
SUBMISSION_APPROVAL: 'submissionApproval', // 订单审核
OUT_IN_APPROVAL: 'outInApproval', // 收支审核
ARTICLE: 'article', // 资讯
VIDEO: 'video', // 视频
BROCHURE: 'brochure', // 宣传册
PRODUCT_LIBRARY: 'productLibrary', // 产品
POSTER: 'poster', // 海报
EMPLOYEE_MANAGE: 'employeeManage', // 组织架构
ROLE: 'role', // 角色
},
// 表名--列配置、搜索配置用的
TABLE_NAME: {
BUSINESS_CARD_VISITOR: 'businessCardVisitor', // 名片访客
CLUE_CONTROL: 'clueControl', // 线索
CLUE: 'clue', // 客户中心
FOLLOW_UP: 'followUp', // 跟单管理
CLUE_OPEN_SEAS: 'clueOpenSeas', // 客户公海
ORDER_MANAGEMENT: 'orderManagement', // 订单管理
PAYMENT_EXPENDITURE: 'payment_expenditure', // 收支管理
INVOICE: 'invoice', // 开票、开票管理
PRODUCT: 'product', // 产品管理
AI_ANALYSIS: 'aiAnalysis', // 沟通分析
ELITE_RECORDING_LIBRARY: 'eliteRecordingLibrary', // AI销售大脑
GAME_REWARD: 'gameReward', // 游戏激励
SOP_INSPECT: 'sopInspect', // 话术应用质检
VIOLATION: 'violation', // 违规质检
EXECUTE: 'execute', // 执行力报表
RETRIEVAL: 'retrieval', // 客户回捞
CUSTOMER_INSIGHT: 'customerInsight', // 客户洞察
PERFORMANCE_TARGET: 'performanceTarget', // 目标管理-业绩目标
FOLLOW_CLUE: 'followClue', // 目标管理-跟单拓客目标
RECOMMENDED_OFFICER_LIST: 'recommendedOfficerList', // 推荐官列表
RECOMMENDATION_OFFICER_REVIEW: 'recommendationOfficerReview', // 推荐官审核
MONEY_DISTRIBUTION: 'moneyDistribution', // 红包发放
WITHDRAWAL_SETTLEMENT: 'withdrawalSettlement', // 提现结算
REFERRER_BALANCE: 'referrerBalance', // 推荐官余额
SUBMISSION_APPROVAL: 'submissionApproval', // 订单审核
OUT_IN_APPROVAL: 'outInApproval', // 收支审核
ARTICLE: 'article', // 资讯
VIDEO: 'video', // 视频
BROCHURE: 'brochure', // 宣传册
PRODUCT_LIBRARY: 'productLibrary', // 产品
POSTER: 'poster', // 海报
EMPLOYEE_MANAGE: 'employeeManage', // 组织架构
ROLE: 'role', // 角色
},
}
RangePicker组件:
<script setup>
import {
computed,
defineEmits,
defineProps,
ref,
watch,
} from 'vue'
import dayjs from 'dayjs'
const props = defineProps({
width: {
default: 280,
},
bordered: {
type: Boolean,
default: true,
},
placeholder: {
type: Array,
default: () => ['开始时间', '结束时间'],
},
value: {
type: Array,
default: () => [],
},
format: {
type: String,
default: 'YYYY-MM-DD',
},
valueFormat: {
type: String,
default: 'YYYY-MM-DD',
},
picker: {
type: String,
default: 'date',
},
it: {// 这个是给扩展字段用的
},
})
const emits = defineEmits(['update:value', 'change'])
const selectedRange = ref(props.value)
const selectedFormat = computed(() => (props.picker === 'month' ? 'YYYY-MM' : props.format))
const selectedValueFormat = computed(() => (props.picker === 'month' ? 'YYYY-MM' : props.valueFormat))
const today = dayjs()
const yesterday = today.subtract(1, 'day')
const startOfThisWeek = today.startOf('week')
const startOfThisMonth = today.startOf('month')
const startOfLastWeek = startOfThisWeek.subtract(1, 'week')
const startOfLastMonth = startOfThisMonth.subtract(1, 'month')
const startOfLastYear = today.subtract(1, 'year')
.startOf('year')
const endOfLastYear = startOfLastYear.endOf('year')
const startOfLastQuarter = dayjs()
.subtract(1, 'quarter')
.startOf('quarter')
const endOfLastQuarter = dayjs()
.startOf('quarter')
.subtract(1, 'day')
const rangePresets = [
{
label: '今天',
value: [today, today],
},
{
label: '昨天',
value: [yesterday, yesterday],
},
{
label: '本周',
value: [startOfThisWeek, today],
},
{
label: '上周',
value: [startOfLastWeek, startOfThisWeek.subtract(1, 'day')],
},
{
label: '本月',
value: [startOfThisMonth, today],
},
{
label: '上月',
value: [startOfLastMonth, startOfThisMonth.subtract(1, 'day')],
},
{
label: '本季度',
value: [dayjs()
.startOf('quarter'), today],
},
{
label: '上季度',
value: [startOfLastQuarter, endOfLastQuarter],
},
{
label: '本年',
value: [dayjs()
.startOf('year'), today],
},
{
label: '上年',
value: [startOfLastYear, endOfLastYear],
},
]
const computedPresets = computed(() => {
if (props.picker === 'month') {
return rangePresets.filter(preset => preset.label !== '本周' && preset.label !== '上周')
}
return rangePresets
})
function handleChange(value) {
selectedRange.value = value
emits('update:value', value)
if (props.it) {
emits('change', value, props.it)
} else {
emits('change', value)
}
}
watch(() => props.value, (v) => {
// console.log('时间范围变动了', v);
if (!v) {
selectedRange.value = []
}
})
</script>
<template>
<a-range-picker
v-model:value="selectedRange"
:bordered="props.bordered"
:format="selectedFormat"
:picker="picker"
:placeholder="props.placeholder"
:presets="computedPresets"
:show-time="props.picker === 'dataTime'"
:style="{ width: `${props.width}px` }"
:value-format="selectedValueFormat"
@change="handleChange"
/>
</template>
FoldSearch组件:
<script setup>
import { DownOutlined, UpOutlined } from '@ant-design/icons-vue'
import { computed, ref } from 'vue'
const props = defineProps({
searchRef: {
type: Object,
default: () => ({}),
},
foldHeight: {
type: Number,
default: 88,
},
isVisibilityHidden: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['foldSearchChange'])
const isInit = ref(true)
const showFoldSearch = ref(false)
const searchFold = ref(false)
const containerStyle = computed(() => ({
height: searchFold.value ? `${props.foldHeight}px` : 'auto', // 折叠时设置固定高度,展开时自适应内容高度
overflow: searchFold.value ? 'hidden' : 'visible', // 折叠时隐藏溢出内容
transition: 'height 1s ease', // 为了平滑过渡效果
}))
function changeFold() {
searchFold.value = !searchFold.value
emit('foldSearchChange', containerStyle.value)
}
function searchChange(isFold = false) {
searchFold.value = false
if (props.searchRef?.clientHeight && props.searchRef?.clientHeight > props.foldHeight) {
showFoldSearch.value = true
if (isInit.value || isFold) {
searchFold.value = true
}
emit('foldSearchChange', containerStyle.value)
} else {
showFoldSearch.value = false
emit('foldSearchChange', containerStyle.value)
}
isInit.value = false
}
defineExpose({ searchChange })
</script>
<template>
<div class="fold-search" :style="{ display: isVisibilityHidden || showFoldSearch ? 'block' : 'none' }">
<a-button v-if="showFoldSearch" type="primary" @click="changeFold">
<div style="display: flex;align-items: center;gap: 2px;">
{{ searchFold ? '展开' : '收起' }}筛选
<down-outlined v-if="searchFold" />
<up-outlined v-else />
</div>
</a-button>
</div>
</template>
<style lang="less" scoped>
.fold-search {
//width: 100px;
}
</style>