|
|
@@ -0,0 +1,208 @@
|
|
|
+<template>
|
|
|
+ <van-field
|
|
|
+ v-model="fieldValue"
|
|
|
+ :is-link="!printRead && !disabled"
|
|
|
+ readonly
|
|
|
+ :placeholder="placeholder"
|
|
|
+ :disabled="disabled"
|
|
|
+ v-bind="{ ...publicProps }"
|
|
|
+ @click="onShowPicker"
|
|
|
+ :key="printRead"
|
|
|
+ >
|
|
|
+ <template #input v-if="printRead">
|
|
|
+ {{ fieldValue }}
|
|
|
+ </template>
|
|
|
+ </van-field>
|
|
|
+
|
|
|
+ <van-popup v-model:show="show" round position="bottom" teleport="body" z-index="5000">
|
|
|
+ <div class="dept-cascader__header">
|
|
|
+ <span class="dept-cascader__title">{{ title || publicProps?.label || '' }}</span>
|
|
|
+ <div v-if="multiple" class="dept-cascader__tools">
|
|
|
+ <van-button size="mini" @click="toggleExpandAll">{{ expandAll ? '收起全部' : '展开全部' }}</van-button>
|
|
|
+ <van-button size="mini" type="primary" @click="show = false">完成</van-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="multiple" class="dept-cascader__body">
|
|
|
+ <DeptCascaderTree
|
|
|
+ :nodes="options"
|
|
|
+ :model-value="selectedValues"
|
|
|
+ :expand-all="expandAll"
|
|
|
+ @update:model-value="onMultiChange"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <van-cascader
|
|
|
+ v-else
|
|
|
+ v-model="cascaderValue"
|
|
|
+ :options="options"
|
|
|
+ :title="title || publicProps?.label"
|
|
|
+ @close="show = false"
|
|
|
+ @finish="onFinish"
|
|
|
+ v-bind="{ ...customProps, ...extendProps }"
|
|
|
+ />
|
|
|
+ </van-popup>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, watch } from 'vue'
|
|
|
+import DeptCascaderTree from './DeptCascaderTree.vue'
|
|
|
+
|
|
|
+const props = defineProps({
|
|
|
+ modelValue: [String, Number, Array],
|
|
|
+ placeholder: String,
|
|
|
+ disabled: Boolean,
|
|
|
+ customProps: Object,
|
|
|
+ extendProps: Object,
|
|
|
+ publicProps: Object,
|
|
|
+ options: { type: Array, default: () => [] },
|
|
|
+ title: String,
|
|
|
+ isPathValue: Boolean,
|
|
|
+ multiple: { type: Boolean, default: false },
|
|
|
+ printRead: Boolean
|
|
|
+})
|
|
|
+
|
|
|
+const emit = defineEmits(['update:modelValue'])
|
|
|
+
|
|
|
+const show = ref(false)
|
|
|
+const cascaderValue = ref('')
|
|
|
+const fieldValue = ref('')
|
|
|
+const expandAll = ref(false)
|
|
|
+const selectedValues = ref([])
|
|
|
+
|
|
|
+function isLeaf(node) {
|
|
|
+ return !node.children || node.children.length === 0
|
|
|
+}
|
|
|
+
|
|
|
+// 数值 / 字符串 id 统一比较,保证 Vue2 保存的数值 id 在 v3 详情返显时仍能对上
|
|
|
+function sameVal(a, b) {
|
|
|
+ return String(a) === String(b)
|
|
|
+}
|
|
|
+
|
|
|
+function getTreeText(value, options) {
|
|
|
+ for (let i = 0; i < options.length; i++) {
|
|
|
+ const cur = options[i]
|
|
|
+ if (sameVal(cur.value, value)) return cur.text
|
|
|
+ if (!isLeaf(cur)) {
|
|
|
+ const res = getTreeText(value, cur.children)
|
|
|
+ if (res) return res
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return ''
|
|
|
+}
|
|
|
+
|
|
|
+function getCascaderText(values, options, texts = []) {
|
|
|
+ if (values.length >= 1) {
|
|
|
+ const cur = options?.find((opt) => sameVal(opt.value, values[0]))
|
|
|
+ if (cur) texts.push(cur.text)
|
|
|
+ values.splice(0, 1)
|
|
|
+ return getCascaderText(values, cur?.children, texts)
|
|
|
+ }
|
|
|
+ return texts
|
|
|
+}
|
|
|
+
|
|
|
+// 根据单个 value 查找其从根到叶的完整 value 路径(用于兼容旧格式的纯叶子值)
|
|
|
+function findPath(value, options, prefix = []) {
|
|
|
+ for (const opt of options) {
|
|
|
+ const cur = [...prefix, opt.value]
|
|
|
+ if (sameVal(opt.value, value)) return cur
|
|
|
+ if (opt.children && opt.children.length) {
|
|
|
+ const r = findPath(value, opt.children, cur)
|
|
|
+ if (r) return r
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null
|
|
|
+}
|
|
|
+
|
|
|
+// 把一条完整 value 路径转成「/」分隔的文本路径
|
|
|
+function getPathText(path, options) {
|
|
|
+ const texts = []
|
|
|
+ let cur = options
|
|
|
+ for (const v of path) {
|
|
|
+ const node = cur?.find((o) => sameVal(o.value, v))
|
|
|
+ if (!node) break
|
|
|
+ texts.push(node.text)
|
|
|
+ cur = node.children
|
|
|
+ }
|
|
|
+ return texts.join('/')
|
|
|
+}
|
|
|
+
|
|
|
+watch(
|
|
|
+ () => props.modelValue,
|
|
|
+ (val) => {
|
|
|
+ if (props.multiple) {
|
|
|
+ const arr = Array.isArray(val) ? val : val ? [val] : []
|
|
|
+ // 兼容旧格式:纯叶子值自动补成完整路径
|
|
|
+ selectedValues.value = arr.map((item) =>
|
|
|
+ Array.isArray(item) ? item : findPath(item, props.options) || [item]
|
|
|
+ )
|
|
|
+ fieldValue.value = selectedValues.value
|
|
|
+ .map((p) => (Array.isArray(p) ? getPathText(p, props.options) : getTreeText(p, props.options)))
|
|
|
+ .filter(Boolean)
|
|
|
+ .join('、')
|
|
|
+ } else if (props.isPathValue) {
|
|
|
+ const values = Array.isArray(val) ? val : val ? [val] : []
|
|
|
+ cascaderValue.value = values.length ? values[values.length - 1] : ''
|
|
|
+ fieldValue.value = getCascaderText([...values], props.options).join('/')
|
|
|
+ } else {
|
|
|
+ const value = Array.isArray(val) ? val[0] : val
|
|
|
+ cascaderValue.value = value
|
|
|
+ fieldValue.value = getTreeText(value, props.options)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ { immediate: true }
|
|
|
+)
|
|
|
+
|
|
|
+function onFinish({ selectedOptions }) {
|
|
|
+ if (props.isPathValue) {
|
|
|
+ emit('update:modelValue', selectedOptions.map((i) => i.value))
|
|
|
+ fieldValue.value = selectedOptions.map((i) => i.text).join('/')
|
|
|
+ } else {
|
|
|
+ emit('update:modelValue', cascaderValue.value)
|
|
|
+ fieldValue.value = selectedOptions[selectedOptions.length - 1].text
|
|
|
+ }
|
|
|
+ show.value = false
|
|
|
+}
|
|
|
+
|
|
|
+function onMultiChange(val) {
|
|
|
+ emit('update:modelValue', val)
|
|
|
+ selectedValues.value = val
|
|
|
+ fieldValue.value = val
|
|
|
+ .map((p) => (Array.isArray(p) ? getPathText(p, props.options) : getTreeText(p, props.options)))
|
|
|
+ .filter(Boolean)
|
|
|
+ .join('、')
|
|
|
+}
|
|
|
+
|
|
|
+function onShowPicker() {
|
|
|
+ if (props.printRead || props.disabled) return
|
|
|
+ show.value = true
|
|
|
+}
|
|
|
+
|
|
|
+function toggleExpandAll() {
|
|
|
+ expandAll.value = !expandAll.value
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.dept-cascader__header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ padding: 12px 16px;
|
|
|
+ border-bottom: 1px solid #ebedf0;
|
|
|
+}
|
|
|
+.dept-cascader__title {
|
|
|
+ font-weight: 600;
|
|
|
+ font-size: 15px;
|
|
|
+ color: #323233;
|
|
|
+}
|
|
|
+.dept-cascader__tools {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+.dept-cascader__body {
|
|
|
+ max-height: 60vh;
|
|
|
+ overflow-y: auto;
|
|
|
+ padding: 4px 0;
|
|
|
+}
|
|
|
+</style>
|