444 lines
16 KiB
Vue
444 lines
16 KiB
Vue
<template>
|
||
<!-- 行业选择器根容器 -->
|
||
<div class="industry-selector" ref="selectorRef">
|
||
|
||
<!-- 触发按钮:显示已选行业名称或默认文字"行业" -->
|
||
<div class="industry-selector__trigger" :style="{ ...triggerStyle, ...(isHovered || visible ? hoverStyle : {}) }" @click="toggleDropdown" @mouseenter="isHovered = true" @mouseleave="isHovered = false">
|
||
<span class="industry-selector__display" :style="displayStyle" :title="displayText">{{ displayText }}</span>
|
||
<svg
|
||
class="industry-selector__arrow"
|
||
:class="{ 'industry-selector__arrow--open': visible }"
|
||
:style="(isHovered || visible) && hoverStyle?.color ? { color: hoverStyle.color } : {}"
|
||
viewBox="0 0 12 12"
|
||
fill="none"
|
||
>
|
||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||
</svg>
|
||
</div>
|
||
|
||
<!-- 下拉面板 -->
|
||
<div
|
||
v-if="visible"
|
||
class="industry-selector__panel"
|
||
:class="{ 'industry-selector__panel--one-col': level === 1 }"
|
||
@click.stop
|
||
>
|
||
|
||
<!-- 选中区:小方块标签展示已选中的行业名称 -->
|
||
<div class="industry-selector__selected-area" v-if="selectedItems.length">
|
||
<span
|
||
class="industry-selector__tag"
|
||
v-for="item in selectedItems"
|
||
:key="item.id"
|
||
>
|
||
{{ item.name }}
|
||
<!-- 点击关闭图标移除该选中项 -->
|
||
<svg class="industry-selector__tag-close" viewBox="0 0 12 12" @click="removeItem(item)">
|
||
<path d="M3 3L9 9M9 3L3 9" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
|
||
</svg>
|
||
</span>
|
||
</div>
|
||
|
||
<!-- 搜索输入框:输入超过 2 个字符时触发模糊匹配 -->
|
||
<div class="industry-selector__search-wrap">
|
||
<input
|
||
v-model="searchText"
|
||
class="industry-selector__search"
|
||
placeholder="搜索行业"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 搜索结果列表(有匹配结果时显示) -->
|
||
<div v-if="searchText.length >= 2 && searchResults.length" class="industry-selector__search-results">
|
||
<div
|
||
class="industry-selector__search-item"
|
||
v-for="r in searchResults"
|
||
:key="r.node.id"
|
||
@click="handleSearchItemClick(r.node)"
|
||
>
|
||
<span>{{ r.path }}</span>
|
||
<!-- 已选中项显示勾选图标 -->
|
||
<svg v-if="isSelected(r.node.id)" class="industry-selector__check" viewBox="0 0 12 12">
|
||
<path d="M2 6L5 9L10 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 搜索无结果提示 -->
|
||
<div v-else-if="searchText.length >= 2 && !searchResults.length" class="industry-selector__search-results">
|
||
<div class="industry-selector__search-empty">无匹配结果</div>
|
||
</div>
|
||
|
||
<!-- 提示:双击选中一级(仅 allowParentSelect 开启且 allowParentSelectClick 关闭时显示) -->
|
||
<div v-if="searchText.length < 2 && allowParentSelect && !allowParentSelectClick" class="industry-selector__hint">(双击可选中一级行业分类)</div>
|
||
|
||
<!-- 分栏联动选择区(搜索关键词不足 2 字符时显示) -->
|
||
<div v-if="searchText.length < 2" class="industry-selector__columns">
|
||
<!-- 左栏:一级行业列表 -->
|
||
<div class="industry-selector__col industry-selector__col--left" :class="{ 'industry-selector__col--full': level === 1 }">
|
||
<div
|
||
class="industry-selector__col-item"
|
||
:class="{
|
||
'industry-selector__col-item--active': level > 1 && activeParentId === parent.id,
|
||
'industry-selector__col-item--selected': (level === 1 || allowParentSelect || allowParentSelectClick) && isSelected(parent.id)
|
||
}"
|
||
v-for="parent in industries"
|
||
:key="parent.id"
|
||
@click="selectParent(parent.id)"
|
||
>
|
||
{{ parent.name }}
|
||
<svg v-if="(level === 1 || allowParentSelect || allowParentSelectClick) && isSelected(parent.id)" class="industry-selector__check" viewBox="0 0 12 12">
|
||
<path d="M2 6L5 9L10 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
<!-- 右栏:当前一级下的二级行业列表(仅 level=2 时显示) -->
|
||
<div v-if="level === 2" class="industry-selector__col industry-selector__col--right">
|
||
<template v-if="activeChildren.length">
|
||
<div
|
||
class="industry-selector__col-item"
|
||
:class="{ 'industry-selector__col-item--selected': isSelected(child.id) }"
|
||
v-for="child in activeChildren"
|
||
:key="child.id"
|
||
@click="selectChild(child)"
|
||
>
|
||
{{ child.name }}
|
||
<svg v-if="isSelected(child.id)" class="industry-selector__check" viewBox="0 0 12 12">
|
||
<path d="M2 6L5 9L10 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||
</svg>
|
||
</div>
|
||
</template>
|
||
<div v-else class="industry-selector__col-empty">请先选择左侧行业分类</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 底部操作按钮 -->
|
||
<div class="industry-selector__actions">
|
||
<button class="industry-selector__btn industry-selector__btn--reset" @click="handleReset">重置</button>
|
||
<button class="industry-selector__btn industry-selector__btn--confirm" @click="handleConfirm">确认</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||
import { useStore } from 'vuex'
|
||
import type { IndustryChild, IndustryItem } from '@/api/common'
|
||
|
||
/** 统一的选中节点类型,兼容一二级 */
|
||
type SelectedNode = { id: string; name: string; level: number }
|
||
|
||
// ==================== 事件与属性定义 ====================
|
||
|
||
/** 向父组件发送已选行业 ID 数组(integer[]) */
|
||
const emit = defineEmits<{
|
||
(e: 'update:industryIds', ids: number[]): void
|
||
}>()
|
||
|
||
/** 接收父组件传入的已选行业 ID 数组 */
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
industryIds?: number[]
|
||
/** 最多可选数量 */
|
||
maxSelect?: number
|
||
/** 是否允许双击选中一级行业 */
|
||
allowParentSelect?: boolean
|
||
/** 是否允许单击选中父级(选子级时自动移除已选的父级) */
|
||
allowParentSelectClick?: boolean
|
||
/** 展示/选择到第几级:1=只选一级,2=选到二级 */
|
||
level?: 1 | 2
|
||
/** 父组件传入的触发按钮自定义样式,用于在不同场景下覆盖默认外观 */
|
||
triggerStyle?: Record<string, string>
|
||
/** 父组件传入的触发按钮 hover 时的自定义样式 */
|
||
hoverStyle?: Record<string, string>
|
||
/** 父组件传入的显示文字自定义样式,用于覆盖 max-width 等默认样式 */
|
||
displayStyle?: Record<string, string>
|
||
}>(),
|
||
{
|
||
maxSelect: 3,
|
||
allowParentSelect: false,
|
||
allowParentSelectClick: false,
|
||
level: 2,
|
||
}
|
||
)
|
||
|
||
// ==================== 基础引用 ====================
|
||
|
||
/** Vuex store 实例 */
|
||
const store = useStore()
|
||
|
||
/** 组件根元素引用,用于点击外部关闭判断 */
|
||
const selectorRef = ref<HTMLElement | null>(null)
|
||
|
||
// ==================== 响应式状态 ====================
|
||
|
||
/** 下拉面板是否可见 */
|
||
const visible = ref(false)
|
||
|
||
/** 触发按钮是否处于 hover 状态 */
|
||
const isHovered = ref(false)
|
||
|
||
/** 搜索关键词 */
|
||
const searchText = ref('')
|
||
|
||
/** 当前选中的一级行业 ID(左栏高亮项) */
|
||
const activeParentId = ref<string>('')
|
||
|
||
/** 已选中的行业列表(支持一二级混合选择) */
|
||
const selectedItems = ref<SelectedNode[]>([])
|
||
|
||
/** 一级行业上次点击时间戳,用于双击检测 */
|
||
const level1LastClickTime = ref<Record<string, number>>({})
|
||
|
||
/** 双击判定间隔(毫秒) */
|
||
const DOUBLE_CLICK_DELAY = 500
|
||
|
||
// ==================== 计算属性 ====================
|
||
|
||
/** 从全局 store 获取行业树分类数据 */
|
||
const industries = computed<IndustryItem[]>(() => store.state.industries)
|
||
|
||
/** 已选中行业 ID 集合,用于快速判断某项是否选中 */
|
||
const selectedIdSet = computed(() => new Set(selectedItems.value.map(i => i.id)))
|
||
|
||
/** 触发按钮显示文字:无选中显示"行业",有选中则用逗号拼接名称 */
|
||
const displayText = computed(() => {
|
||
if (!selectedItems.value.length) return '行业'
|
||
return selectedItems.value.map(i => i.name).join(',')
|
||
})
|
||
|
||
/** 当前左栏选中的一级行业对应的二级子项列表 */
|
||
const activeChildren = computed<IndustryChild[]>(() => {
|
||
if (!activeParentId.value) return []
|
||
const parent = industries.value.find(p => p.id === activeParentId.value)
|
||
return parent ? parent.children : []
|
||
})
|
||
|
||
/** 搜索结果:根据 level 和 allowParentSelect/allowParentSelectClick 匹配对应级别 */
|
||
const searchResults = computed(() => {
|
||
if (searchText.value.length < 2) return []
|
||
const keyword = searchText.value.toLowerCase()
|
||
const results: { path: string; node: SelectedNode }[] = []
|
||
/** 是否允许选中父级 */
|
||
const canSelectParent = props.allowParentSelect || props.allowParentSelectClick
|
||
for (const parent of industries.value) {
|
||
// 一级:level=1 时作为末级可搜索,或允许选中父级且 level=2 时可搜索
|
||
if ((props.level === 1 || canSelectParent) && parent.name.toLowerCase().includes(keyword)) {
|
||
results.push({ path: parent.name, node: { id: parent.id, name: parent.name, level: parent.level } })
|
||
}
|
||
if (props.level === 2) {
|
||
for (const child of parent.children) {
|
||
if (child.name.toLowerCase().includes(keyword)) {
|
||
results.push({ path: `${parent.name} → ${child.name}`, node: { id: child.id, name: child.name, level: child.level } })
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return results
|
||
})
|
||
|
||
// ==================== 方法 ====================
|
||
|
||
/** 判断指定行业 ID 是否已被选中 */
|
||
function isSelected(id: string) {
|
||
return selectedIdSet.value.has(id)
|
||
}
|
||
|
||
/** 点击左栏一级行业 */
|
||
function selectParent(id: string) {
|
||
// level=1 时,一级就是末级,单击直接选中
|
||
if (props.level === 1) {
|
||
const l1 = industries.value.find(c => c.id === id)
|
||
if (l1) toggleItem({ id: l1.id, name: l1.name, level: l1.level })
|
||
return
|
||
}
|
||
// allowParentSelectClick 模式:单击直接选中一级,同时展开子级
|
||
if (props.allowParentSelectClick) {
|
||
const l1 = industries.value.find(c => c.id === id)
|
||
if (l1) toggleItemWithHierarchy({ id: l1.id, name: l1.name, level: l1.level })
|
||
activeParentId.value = id
|
||
return
|
||
}
|
||
if (props.allowParentSelect) {
|
||
const now = Date.now()
|
||
const lastTime = level1LastClickTime.value[id] || 0
|
||
|
||
if (now - lastTime < DOUBLE_CLICK_DELAY) {
|
||
const l1 = industries.value.find(c => c.id === id)
|
||
if (l1) toggleItem({ id: l1.id, name: l1.name, level: l1.level })
|
||
level1LastClickTime.value[id] = 0
|
||
} else {
|
||
activeParentId.value = id
|
||
level1LastClickTime.value[id] = now
|
||
}
|
||
} else {
|
||
activeParentId.value = id
|
||
}
|
||
}
|
||
|
||
/** 切换某个行业节点的选中/取消状态(支持一二级),超过上限时提示 */
|
||
function toggleItem(node: SelectedNode) {
|
||
const idx = selectedItems.value.findIndex(i => i.id === node.id)
|
||
if (idx >= 0) {
|
||
selectedItems.value.splice(idx, 1)
|
||
} else {
|
||
if (selectedItems.value.length >= props.maxSelect) {
|
||
ElMessage.warning(`最多只能选择${props.maxSelect}个行业`)
|
||
return
|
||
}
|
||
selectedItems.value.push({ ...node })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* allowParentSelectClick 模式下的选中/取消逻辑:
|
||
* - 选中子级时,移除已选中的父级
|
||
* - 选中父级时,移除已选中的该父级下的所有子级
|
||
*/
|
||
function toggleItemWithHierarchy(node: SelectedNode) {
|
||
const idx = selectedItems.value.findIndex(i => i.id === node.id)
|
||
if (idx >= 0) {
|
||
// 取消选中
|
||
selectedItems.value.splice(idx, 1)
|
||
return
|
||
}
|
||
// 获取祖先和后代 ID
|
||
const ancestorIds = getAncestorIds(node.id)
|
||
const descendantIds = getDescendantIds(node.id)
|
||
// 移除已选中的祖先和后代
|
||
selectedItems.value = selectedItems.value.filter(
|
||
i => !ancestorIds.has(i.id) && !descendantIds.has(i.id)
|
||
)
|
||
// 检查数量限制
|
||
if (selectedItems.value.length >= props.maxSelect) {
|
||
ElMessage.warning(`最多只能选择${props.maxSelect}个行业`)
|
||
return
|
||
}
|
||
selectedItems.value.push({ ...node })
|
||
}
|
||
|
||
/** 获取某个节点的所有祖先 ID(向上查找) */
|
||
function getAncestorIds(nodeId: string): Set<string> {
|
||
const ancestors = new Set<string>()
|
||
for (const parent of industries.value) {
|
||
for (const child of parent.children) {
|
||
if (child.id === nodeId) {
|
||
ancestors.add(parent.id)
|
||
return ancestors
|
||
}
|
||
}
|
||
}
|
||
return ancestors
|
||
}
|
||
|
||
/** 获取某个节点的所有后代 ID(向下查找) */
|
||
function getDescendantIds(nodeId: string): Set<string> {
|
||
const descendants = new Set<string>()
|
||
for (const parent of industries.value) {
|
||
if (parent.id === nodeId) {
|
||
for (const child of parent.children) {
|
||
descendants.add(child.id)
|
||
}
|
||
return descendants
|
||
}
|
||
}
|
||
return descendants
|
||
}
|
||
|
||
/** 点击右栏二级行业 */
|
||
function selectChild(child: { id: string; name: string; level: number }) {
|
||
if (props.allowParentSelectClick) {
|
||
toggleItemWithHierarchy({ id: child.id, name: child.name, level: child.level })
|
||
} else {
|
||
toggleItem({ id: child.id, name: child.name, level: child.level })
|
||
}
|
||
}
|
||
|
||
/** 搜索结果项点击处理 */
|
||
function handleSearchItemClick(node: SelectedNode) {
|
||
if (props.allowParentSelectClick) {
|
||
toggleItemWithHierarchy(node)
|
||
} else {
|
||
toggleItem(node)
|
||
}
|
||
}
|
||
|
||
/** 从选中区移除指定行业 */
|
||
function removeItem(item: SelectedNode) {
|
||
selectedItems.value = selectedItems.value.filter(i => i.id !== item.id)
|
||
}
|
||
|
||
/** 重置:清空所有已选项 */
|
||
function handleReset() {
|
||
selectedItems.value = []
|
||
}
|
||
|
||
/** 确认:将当前选中结果发送给父组件并关闭面板 */
|
||
function handleConfirm() {
|
||
emitIds()
|
||
visible.value = false
|
||
}
|
||
|
||
/** 向父组件发送当前选中的行业 ID 数组(转为整数) */
|
||
function emitIds() {
|
||
emit('update:industryIds', selectedItems.value.map(i => Number(i.id)))
|
||
}
|
||
|
||
/** 切换下拉面板的显示/隐藏,打开时清空搜索词并默认选中第一个一级行业 */
|
||
function toggleDropdown() {
|
||
visible.value = !visible.value
|
||
if (visible.value) {
|
||
searchText.value = ''
|
||
// 默认选中第一个一级行业
|
||
if (industries.value.length && !activeParentId.value) {
|
||
activeParentId.value = industries.value[0].id
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 点击组件外部时关闭下拉面板 */
|
||
function onClickOutside(e: MouseEvent) {
|
||
if (selectorRef.value && !selectorRef.value.contains(e.target as Node)) {
|
||
visible.value = false
|
||
}
|
||
}
|
||
|
||
// ==================== 生命周期 ====================
|
||
|
||
onMounted(() => {
|
||
document.addEventListener('click', onClickOutside)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
document.removeEventListener('click', onClickOutside)
|
||
})
|
||
|
||
// ==================== 监听器 ====================
|
||
|
||
/** 同步外部传入的 industryIds 到内部选中状态(支持一二级) */
|
||
function syncFromProps() {
|
||
const ids = props.industryIds
|
||
if (!ids || !industries.value.length) return
|
||
const nodeMap = new Map<string, SelectedNode>()
|
||
for (const p of industries.value) {
|
||
nodeMap.set(p.id, { id: p.id, name: p.name, level: p.level })
|
||
for (const c of p.children) {
|
||
nodeMap.set(c.id, { id: c.id, name: c.name, level: c.level })
|
||
}
|
||
}
|
||
selectedItems.value = ids
|
||
.map(id => nodeMap.get(String(id)))
|
||
.filter(Boolean) as SelectedNode[]
|
||
}
|
||
|
||
watch(
|
||
() => props.industryIds,
|
||
() => syncFromProps(),
|
||
{ immediate: true }
|
||
)
|
||
|
||
/** 树数据加载完成后重新同步选中项 */
|
||
watch(industries, () => syncFromProps())
|
||
</script>
|