Files
offerpai_web/src/components/tools/IndustrySelector.vue
T
2026-07-13 23:01:49 +08:00

444 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>