326 lines
7.0 KiB
Vue
326 lines
7.0 KiB
Vue
<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>
|