Files
Pointly-Tel/web/src/components/CascaderSelect.vue
T
2026-07-30 17:26:01 +08:00

326 lines
7.0 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="cascader" ref="wrapper">
<div class="cascader-trigger" :class="{ open: visible }" @click="toggle">
<span class="cascader-label" :class="{ placeholder: !displayText }">{{ displayText || placeholder }}</span>
<span class="cascader-arrow"></span>
</div>
<Teleport to="body">
<div v-if="visible" class="cascader-panel" :style="panelStyle">
<div class="cascader-cols">
<div
v-for="(col, ci) in columns"
:key="ci"
class="cascader-col"
:class="{ active: ci === activeCol }"
>
<div class="cascader-opt all" @click="selectAll(ci)">
全部{{ ci === 0 ? '' : ' ' + (selectedPath[ci-1]?.label || '') }}
</div>
<div
v-for="item in col"
:key="item.value"
class="cascader-opt"
:class="{
selected: selectedPath[ci] && selectedPath[ci].value === item.value,
hasChildren: item.children
}"
@click="selectItem(item, ci)"
>
<span>{{ item.label }}</span>
<span v-if="item.children" class="opt-arrow"></span>
</div>
<div v-if="loadingCol === ci" class="cascader-status">加载中...</div>
<div v-if="!loadingCol && col.length === 0" class="cascader-status">暂无数据</div>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
const props = defineProps({
modelValue: { type: Array, default: () => [] },
placeholder: { type: String, default: '全部' },
treeData: { type: Array, default: () => [] },
lazyLoad: { type: Function, default: null },
})
const emit = defineEmits(['update:modelValue', 'change'])
const wrapper = ref(null)
const visible = ref(false)
const selectedPath = ref([])
const columns = ref([[]])
const activeCol = ref(0)
const loadingCol = ref(-1)
const panelStyle = ref({})
const displayText = computed(() => {
return selectedPath.value.map(n => n.label).join(' / ')
})
function rebuildColumns() {
const cols = []
let current = props.treeData
for (let i = 0; i <= selectedPath.value.length; i++) {
cols.push(current || [])
if (i < selectedPath.value.length) {
const sel = (current || []).find(n => n.value === selectedPath.value[i].value)
current = sel ? (sel.children || []) : []
}
}
columns.value = cols
activeCol.value = cols.length - 1
}
function syncFromValue() {
const val = props.modelValue || []
selectedPath.value = []
if (val.length === 0) {
rebuildColumns()
return
}
let current = props.treeData
let ok = true
for (const v of val) {
const node = (current || []).find(n => n.value === v)
if (!node) { ok = false; break }
selectedPath.value.push(node)
current = node.children
}
if (!ok) selectedPath.value = []
rebuildColumns()
}
watch(() => props.modelValue, syncFromValue, { deep: true })
watch(() => props.treeData, () => {
if (visible.value) {
loadingCol.value = -1
rebuildColumns()
}
}, { deep: true })
watch(visible, async (v) => {
if (v) {
syncFromValue()
await nextTick()
const rect = wrapper.value?.getBoundingClientRect()
if (rect) {
panelStyle.value = {
top: rect.bottom + 'px',
left: rect.left + 'px',
minWidth: Math.max(rect.width, 200) + 'px',
}
}
}
})
function toggle() {
visible.value = !visible.value
}
function hide() {
visible.value = false
loadingCol.value = -1
}
async function selectItem(item, colIdx) {
selectedPath.value = selectedPath.value.slice(0, colIdx)
selectedPath.value.push(item)
if (item.children && item.children.length > 0) {
rebuildColumns()
return
}
if (props.lazyLoad && item.loadable !== false) {
loadingCol.value = colIdx + 1
rebuildColumns()
try {
const children = await props.lazyLoad(item, selectedPath.value.map(n => n.value))
item.children = children || []
} catch {
item.children = []
}
loadingCol.value = -1
if (!visible.value) return
if (item.children.length > 0) {
rebuildColumns()
} else {
emitChange()
hide()
}
return
}
// Leaf node
emitChange()
hide()
}
function selectAll(colIdx) {
selectedPath.value = selectedPath.value.slice(0, colIdx)
emitChange()
hide()
}
function emitChange() {
const values = selectedPath.value.map(n => n.value)
const labels = selectedPath.value.map(n => n.label)
emit('update:modelValue', values)
emit('change', { values, labels })
}
function onClickOutside(e) {
if (!visible.value) return
if (wrapper.value && wrapper.value.contains(e.target)) return
if (e.target.closest('.cascader-panel')) return
hide()
}
onMounted(() => {
document.addEventListener('mousedown', onClickOutside, true)
syncFromValue()
})
onUnmounted(() => {
document.removeEventListener('mousedown', onClickOutside, true)
})
</script>
<style scoped>
.cascader {
position: relative;
display: inline-block;
}
.cascader-trigger {
display: flex;
align-items: center;
gap: 4px;
padding: 9px 10px;
border: 1px solid #ddd;
border-radius: 8px;
background: #fff;
cursor: pointer;
font-size: 14px;
user-select: none;
transition: border-color 0.2s;
min-width: 100px;
}
.cascader-trigger:hover,
.cascader-trigger.open {
border-color: #1a73e8;
}
.cascader-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #333;
}
.cascader-label.placeholder {
color: #aaa;
}
.cascader-arrow {
font-size: 10px;
color: #999;
transition: transform 0.2s;
flex-shrink: 0;
}
.open .cascader-arrow {
transform: rotate(180deg);
}
</style>
<style>
/* Unscoped so Teleported panel gets these styles */
.cascader-panel {
position: fixed;
z-index: 99999;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
overflow: hidden;
}
.cascader-cols {
display: flex;
max-height: 280px;
}
.cascader-col {
min-width: 140px;
max-height: 280px;
overflow-y: auto;
border-right: 1px solid #f0f0f0;
padding: 4px 0;
}
.cascader-col:last-child {
border-right: none;
}
.cascader-col.active .cascader-opt.selected {
background: #e8f0fe;
color: #1a73e8;
font-weight: 600;
}
.cascader-opt {
padding: 8px 14px;
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
white-space: nowrap;
transition: background 0.1s;
}
.cascader-opt:hover {
background: #f5f7fa;
}
.cascader-opt.selected {
background: #e8f0fe;
color: #1a73e8;
font-weight: 600;
}
.cascader-opt.all {
border-bottom: 1px solid #f0f0f0;
margin-bottom: 2px;
color: #999;
font-size: 12px;
}
.cascader-opt.all:hover {
color: #1a73e8;
}
.opt-arrow {
font-size: 16px;
color: #bbb;
flex-shrink: 0;
}
.cascader-status {
padding: 20px 14px;
text-align: center;
color: #999;
font-size: 12px;
}
</style>