目录
详细需求设计文档,及实施任务请参见本篇文章:
一、需求如下
实现一个带搜索功能的下拉单选组件,左侧为支持搜索的下拉框,中间按钮默认隐藏(仅当搜索无结果时显示),右侧为提示文本。当搜索无匹配项时,展示按钮;点击按钮弹出含单选表格的弹窗,选中数据后关闭弹窗并将指定字段回显至下拉框。该交互需满足:
1)下拉框搜索匹配时隐藏按钮
2)弹窗表格支持单选
3) 数据回显功能。
功能说明
- 搜索功能:下拉框支持输入搜索,实时过滤选项
- 动态按钮:当搜索无结果时显示添加按钮,有匹配结果时隐藏
- 弹窗交互:点击按钮弹出含单选表格的模态框
- 数据回显:从表格选择数据后自动关闭弹窗并回显到下拉框
- 自定义配置:支持通过props传递选项数据、表格数据、列配置等
关键实现点
- 使用
show-search
属性启用Ant Design Select的搜索功能 - 通过
filteredOptions
计算属性实现搜索过滤逻辑 - 使用
showAddButton
计算属性控制按钮显示状态 - 通过
rowSelection
配置实现表格单选功能 - 使用
v-model:value
实现双向数据绑定
该组件已完整实现需求中的所有交互功能,可根据实际项目需求调整样式和配置参数。
二、效果图
三、代码结构图
四、实现代码
<template>
<div class="searchable-dropdown-container">
<!-- 主要搜索区域 -->
<div class="dropdown-section">
<label class="dropdown-label">客户单位名称</label>
<a-select
v-model:value="selectedValue"
show-search
placeholder="请选择单位名称"
class="dropdown-select"
:filter-option="false"
:options="filteredOptions"
@search="handleSearch"
@change="handleSelectionChange"
@keydown="handleKeyDown"
ref="selectRef"
>
<template #notFoundContent>
<div v-if="showNoResultsMessage" class="no-results-content">
<div class="no-results-text">
没有找到匹配的单位信息
</div>
<div class="help-message">
您可以点击启动企查查云端检索查询<br />
若单位未在企查查内登记,请联系CRM管理员
</div>
</div>
</template>
</a-select>
</div>
<!-- 云端检索按钮区域 -->
<div class="button-section">
<a-button
v-if="showCloudSearchButton"
type="primary"
class="cloud-search-button"
:loading="loading"
@click="openCloudSearchModal"
>
启动企查查云端检索
</a-button>
</div>
<!-- 右侧提示文本 -->
<div class="help-text-section">
<span class="help-text">
{{ helpText }}
</span>
</div>
</div>
<!-- 云端检索弹窗 -->
<a-modal
v-model:open="modalVisible"
title="企查查单位查询"
width="800px"
:confirm-loading="loading"
@ok="handleModalConfirm"
@cancel="handleModalCancel"
>
<a-table
:columns="tableColumns"
:data-source="cloudSearchResults"
row-key="id"
:pagination="false"
:row-selection="rowSelection"
:loading="loading"
size="small"
/>
</a-modal>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import { message } from 'ant-design-vue'
import { debounce } from './utils/debounce'
// 组件引用
const selectRef = ref(null)
// 原始下拉选项
const options = ref([
{ label: '阿里巴巴集团', value: 'alibaba' },
{ label: '腾讯科技有限公司', value: 'tencent' },
{ label: '百度在线网络技术公司', value: 'baidu' },
{ label: '京东集团', value: 'jd' },
{ label: '网易公司', value: 'netease' },
{ label: '新浪微博', value: 'weibo' }
])
// 响应式状态
const selectedValue = ref(null)
const searchValue = ref('')
const loading = ref(false)
const modalVisible = ref(false)
const selectedRowKeys = ref([])
const highlightedIndex = ref(-1)
// 右侧提示文本
const helpText = ref('请输入单位名称进行搜索,如果找不到可以使用云端检索功能')
// 云端检索结果数据
const cloudSearchResults = ref([
{ id: 1, name: '字节跳动有限公司', code: '91110108MA01', address: '北京市海淀区知春路63号' },
{ id: 2, name: '小米科技有限责任公司', code: '9111010855', address: '北京市海淀区西二旗中路33号' },
{ id: 3, name: '美团点评', code: '9111010877', address: '北京市朝阳区望京东路6号' }
])
// 表格列配置
const tableColumns = [
{ title: '单位名称', dataIndex: 'name', key: 'name', width: '40%' },
{ title: '统一社会信用代码', dataIndex: 'code', key: 'code', width: '30%' },
{ title: '注册地址', dataIndex: 'address', key: 'address', width: '30%' }
]
// 防抖函数已从 utils/debounce.js 导入
// 计算属性:过滤后的选项(大小写不敏感)
const filteredOptions = computed(() => {
if (!searchValue.value || searchValue.value.trim() === '') {
return options.value
}
const searchTerm = searchValue.value.toLowerCase().trim()
return options.value.filter(option =>
option.label.toLowerCase().includes(searchTerm)
)
})
// 计算属性:是否显示无结果消息
const showNoResultsMessage = computed(() => {
return searchValue.value &&
searchValue.value.trim() !== '' &&
filteredOptions.value.length === 0
})
// 计算属性:是否显示云端检索按钮
const showCloudSearchButton = computed(() => {
return searchValue.value &&
searchValue.value.trim() !== '' &&
filteredOptions.value.length === 0
})
// 防抖搜索处理函数
const debouncedSearch = debounce((value) => {
searchValue.value = value
highlightedIndex.value = -1 // 重置高亮索引
}, 300)
// 搜索处理
const handleSearch = (value) => {
debouncedSearch(value)
}
// 选择变化处理
const handleSelectionChange = (value) => {
selectedValue.value = value
searchValue.value = '' // 清空搜索值
highlightedIndex.value = -1
}
// 键盘导航处理
const handleKeyDown = (event) => {
const { key } = event
const visibleOptions = filteredOptions.value
if (visibleOptions.length === 0) return
switch (key) {
case 'ArrowDown':
event.preventDefault()
highlightedIndex.value = Math.min(
highlightedIndex.value + 1,
visibleOptions.length - 1
)
break
case 'ArrowUp':
event.preventDefault()
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
break
case 'Enter':
event.preventDefault()
if (highlightedIndex.value >= 0 && highlightedIndex.value < visibleOptions.length) {
const selectedOption = visibleOptions[highlightedIndex.value]
handleSelectionChange(selectedOption.value)
}
break
case 'Escape':
event.preventDefault()
searchValue.value = ''
highlightedIndex.value = -1
if (selectRef.value) {
selectRef.value.blur()
}
break
}
}
// 表格单选配置
const rowSelection = computed(() => ({
type: 'radio',
selectedRowKeys: selectedRowKeys.value,
onChange: (selectedKeys) => {
selectedRowKeys.value = selectedKeys
}
}))
// 打开云端检索弹窗
const openCloudSearchModal = () => {
modalVisible.value = true
selectedRowKeys.value = []
}
// 弹窗确认处理
const handleModalConfirm = () => {
if (selectedRowKeys.value.length === 0) {
message.warning('请选择一个单位')
return
}
const selectedRow = cloudSearchResults.value.find(
row => row.id === selectedRowKeys.value[0]
)
if (selectedRow) {
// 检查是否已存在,避免重复添加
const existingOption = options.value.find(opt => opt.value === selectedRow.code)
if (!existingOption) {
options.value.push({
label: selectedRow.name,
value: selectedRow.code
})
}
// 设置选中值并关闭弹窗
selectedValue.value = selectedRow.code
modalVisible.value = false
selectedRowKeys.value = []
searchValue.value = ''
message.success('单位选择成功')
}
}
// 弹窗取消处理
const handleModalCancel = () => {
modalVisible.value = false
selectedRowKeys.value = []
}
// 搜索相关函数已移至 utils/searchUtils.js
</script>
<style scoped>
.searchable-dropdown-container {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
border-bottom: 1px solid #eee;
}
.dropdown-section {
display: flex;
align-items: center;
gap: 8px;
}
.dropdown-label {
margin-right: 8px;
white-space: nowrap;
font-weight: 500;
}
.dropdown-select {
width: 250px;
}
.button-section {
flex-shrink: 0;
}
.cloud-search-button {
white-space: nowrap;
}
.help-text-section {
flex: 1;
margin-left: 16px;
}
.help-text {
color: #ff4d4f;
font-size: 14px;
line-height: 1.4;
}
.no-results-content {
padding: 8px 12px;
text-align: center;
}
.no-results-text {
margin-bottom: 4px;
color: #999;
font-weight: 500;
}
.help-message {
color: #999;
font-size: 12px;
line-height: 1.4;
}
/* 响应式设计 */
@media (max-width: 768px) {
.searchable-dropdown-container {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.dropdown-section {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.dropdown-select {
width: 100%;
}
.help-text-section {
margin-left: 0;
}
}
</style>
{
"name": "my-vue3-antd-app-no-jsx",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"ant-design-vue": "^4.2.3",
"vue": "^3.4.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"@vue/test-utils": "^2.4.0",
"jsdom": "^23.0.0",
"vite": "^5.2.0",
"vitest": "^1.0.0"
}
}