Files
2026-09-24 16:25:22 +08:00

525 lines
16 KiB
Plaintext

<script lang="ts" setup>
import { PropType, toRaw } from "vue"
import { getUid } from "../../core/util/xCoreUtil.uts"
import { getDefaultColor } from "../../core/util/xCoreColorUtil.uts"
import { checkIsCssUnit, getUnit } from "../../core/util/xCoreUtil.uts"
import { xConfig } from "../../config/xConfig.uts"
import { CASCADER_ITEM_INFO, CASCADER_TREE_ITEM } from "../../interface.uts"
import { isParent, getTreeNodesPath, getTreeSelectedNum } from "./util.uts"
type findNodePathType = (nodes : CASCADER_ITEM_INFO[], targetId : string, currentPath : CASCADER_ITEM_INFO[]) => CASCADER_ITEM_INFO[]|null;
type findNodeLayersType = (nodes : CASCADER_ITEM_INFO[], targetId : string, currentLayers : CASCADER_ITEM_INFO[][]) => CASCADER_ITEM_INFO[][]|null
/**
* @name 极联器 xCascader
* @page /pages/index/cascader
* @category 表单组件
* @description 极联选择器,单选模式。
* @constant 平台兼容
* | Harmony | H5 | andriod | IOS | 小程序 | UTS | UNIAPP-X SDK | version |
| --- | --- | --- | --- | --- | --- | --- | --- |
| ☑ | ☑ | ☑️ | ☑️ | ☑️ | ☑️ | 4.76+ | 1.1.18 |
*/
defineOptions({ name: "xCascader" })
type menuTypeCascaderType = {
selected : boolean,
item : CASCADER_ITEM_INFO
}
type xCascaderTreeProps = {
/**
* 宽,可以为auto
*/
width : string,
/**
* 高,不可为auto。
*/
height : string,
/**
* 数据结构
*/
list : CASCADER_ITEM_INFO[],
/**
* 当前选中项的id
*/
modelValue : string,
/**
* 项目文字大小id
*/
fontSize : string,
/**
* 选项项目未选中的文字颜色
*/
itemTextColor : string,
/**
* 选项项目未选中的暗黑文字颜色,空值是取白色
*/
darkItemTextColor : string,
/**
* 选项项目选中的文字颜色,空值取全局主题
*/
itemActiveColor : string,
/**
* 内容区域背景颜色
*/
sliderContentBgColor : string,
/**
* 是否在有下级的项目上显示选择本级按钮.
* 当用户选中了本级时就同选择最后一项一样会触发confirm及同步vmodel值
*/
showCurrentBtn : boolean,
}
const props = withDefaults(defineProps<xCascaderTreeProps>(), {
width: 'auto',
height: '150',
list:():CASCADER_ITEM_INFO[] => [] as CASCADER_ITEM_INFO[],
fontSize: "18",
itemTextColor: "#333333",
darkItemTextColor: "",
itemActiveColor: "",
sliderContentBgColor: 'rgba(0,0,0,0)'
})
const emit = defineEmits([
/**
* 选中触发时变化,只要路径变化了就会触发
* @param {String[]} ids - 当前id路径值
*/
'change',
/**
* 点击项目时触发
* @param {CASCADER_TREE_ITEM} item - 项目数据
* @param {number} parentIndex - 父index
* @param {number} childrenIndex - 当前子index
*/
'cellClick',
/**
* 最后一项时触发,或者选择本级时触发
* @param {String} id - 最后一级选中的值
* @param {String[]} ids - 完整的路径id值
*/
'confirm',
/**
* 等同v-model,或者选择本级时触发
* @param {String} id - 当前id
*/
'update:modelValue'
])
const _width = computed(() : string => checkIsCssUnit(props.width, xConfig.unit));
const _height = computed(() : string => checkIsCssUnit(props.height, xConfig.unit));
const _showCurrentBtn = computed(() : boolean => props.showCurrentBtn);
const _fontSize = computed(() : string => {
let fontSize = checkIsCssUnit(props.fontSize, xConfig.unit);
if (xConfig.fontScale == 1) return fontSize;
let sizeNumber = parseInt(fontSize)
if (isNaN(sizeNumber)) {
sizeNumber = 16
}
return (sizeNumber * xConfig.fontScale).toString() + getUnit(fontSize)
});
const _itemActiveColor = computed(() : string => {
return props.itemActiveColor != "" ? getDefaultColor(props.itemActiveColor) : getDefaultColor(xConfig.color);
});
const _itemTextColor = computed(() : string => {
let color = props.itemTextColor;
if (xConfig.dark == 'dark') {
color = props.darkItemTextColor != "" ? props.darkItemTextColor : "#ffffff"
}
return getDefaultColor(color);
});
const _sliderContentBgColor = computed(() : string => getDefaultColor(props.sliderContentBgColor));
const _borderColor = computed(() : string => {
if (xConfig.dark == 'dark') return xConfig.borderDarkColor
return '#f5f5f5'
});
const nowVal = ref('')
function getNodeArrayPaths(list : CASCADER_ITEM_INFO[], id : string = '') : menuTypeCascaderType[] {
if (list.length == 0) {
return [] as menuTypeCascaderType[]
}
// 如果id为空,从第一级第一个节点开始
if (id == '') {
const firstNode = list[0]
const path : CASCADER_ITEM_INFO[] = [firstNode]
// 如果第一个节点有子级,继续向下找到最后一级
let currentNode = firstNode
let children = currentNode?.children ?? ([] as CASCADER_ITEM_INFO[])
while (children.length > 0) {
currentNode = children[0]
path.push(currentNode)
children = currentNode?.children ?? ([] as CASCADER_ITEM_INFO[])
}
// 转换为menuTypeCascaderType数组,所有节点都设为未选中
return path.map((item, index) : menuTypeCascaderType => ({
selected: false,
item: item
} as menuTypeCascaderType))
}
let findNodePath : findNodePathType| null = null;
// 根据id查找节点路径
findNodePath = (nodes : CASCADER_ITEM_INFO[], targetId : string, currentPath : CASCADER_ITEM_INFO[]) : CASCADER_ITEM_INFO[] | null => {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
const newPath : CASCADER_ITEM_INFO[] = [...currentPath, node]
if (node.id == targetId) {
// 找到目标节点,如果有子级则继续向下找到最后一级
let currentNode = node
let finalPath : CASCADER_ITEM_INFO[] = [...newPath]
let children = currentNode?.children ?? ([] as CASCADER_ITEM_INFO[])
while (children.length > 0) {
currentNode = children[0]
finalPath.push(currentNode)
children = currentNode?.children ?? ([] as CASCADER_ITEM_INFO[])
}
return finalPath
}
// 在子节点中继续查找
let children = node?.children ?? ([] as CASCADER_ITEM_INFO[])
if (children.length > 0) {
let fph = findNodePath!;
const result = fph(children, targetId, newPath)
if (result != null) {
return result
}
}
}
return null
}
const result = findNodePath(list, id, [] as CASCADER_ITEM_INFO[])
if (result != null) {
// 转换为menuTypeCascaderType数组,提供的id及之前的节点设为selected,之后的为未选中
let targetIndex = -1
for (let i = 0; i < result.length; i++) {
if (result[i].id == id) {
targetIndex = i
break
}
}
return result.map((item, index) : menuTypeCascaderType => ({
selected: targetIndex >= 0 && index <= targetIndex,
item: item
} as menuTypeCascaderType))
}
return [] as menuTypeCascaderType[]
}
/**
* 同getNodeArrayPaths类似,但不同的是,它是返回 id所在层级的数组,形成一个平铺的列表组供用户选择当前组的某一项。
* 返回格式是CASCADER_ITEM_INFO[][]
*/
function getNodeArraySlier(list : CASCADER_ITEM_INFO[], id : string = '') : CASCADER_ITEM_INFO[][]{
if (list.length == 0) {
return [] as CASCADER_ITEM_INFO[][]
}
// 如果id为空,从第一级开始,继续向下找到最后一级
if (id == '') {
const layers : CASCADER_ITEM_INFO[][] = [list]
let currentNodes = list
while (currentNodes.length > 0 && (currentNodes[0]?.children??([] as CASCADER_ITEM_INFO[])).length > 0) {
currentNodes = (currentNodes[0]?.children??([] as CASCADER_ITEM_INFO[]))
layers.push(currentNodes)
}
return layers
}
let findNodeLayers : findNodeLayersType | null = null;
// 查找id所在的路径
findNodeLayers = (nodes : CASCADER_ITEM_INFO[], targetId : string, currentLayers : CASCADER_ITEM_INFO[][]) : CASCADER_ITEM_INFO[][] | null => {
// 当前层级添加到结果中
const newLayers : CASCADER_ITEM_INFO[][] = [...currentLayers, nodes]
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
if (node.id == targetId) {
// 找到目标节点,继续向下找到最后一级
let finalLayers : CASCADER_ITEM_INFO[][] = [...newLayers]
let currentNode = node
let children = currentNode?.children ?? ([] as CASCADER_ITEM_INFO[])
while (children.length > 0) {
finalLayers.push(children)
currentNode = children[0]
children = currentNode?.children ?? ([] as CASCADER_ITEM_INFO[])
}
return finalLayers
}
// 在子节点中继续查找
let children = node?.children ?? ([] as CASCADER_ITEM_INFO[])
if (children.length > 0) {
const result = findNodeLayers!(children, targetId, newLayers)
if (result != null) {
return result
}
}
}
return null
}
const result = findNodeLayers(list, id, [] as CASCADER_ITEM_INFO[][])
return result != null ? result : [list] as CASCADER_ITEM_INFO[][]
}
const currentIndex = ref(0)
const _list = computed(() : CASCADER_ITEM_INFO[][] => getNodeArraySlier(props.list, nowVal.value))
const _menus = computed(() : menuTypeCascaderType[] => getNodeArrayPaths(props.list, nowVal.value))
const _menusList = computed(() : CASCADER_ITEM_INFO[] => {
return _menus.value.map((el : menuTypeCascaderType) : CASCADER_ITEM_INFO => el.item)
})
const getIds = () : string[] => {
let ids : string[] = []
for (let i = 0; i < _menus.value.length; i++) {
let item = _menus.value[i]
if (!item.selected) {
break;
}
ids.push(item.item.id)
}
return ids;
}
const elitext = (text : string) : string => {
let len = text.length;
return len <= 7 ? text : (text.substring(0, 7) + '..')
}
const isSelected = (item : CASCADER_ITEM_INFO) : boolean => {
let ids = _menus.value.findIndex((el : menuTypeCascaderType) : boolean => el.item.id == item.id && el.selected)
return ids > -1
}
const isCurrentNext = computed(() : boolean => {
return _menus.value.some((el : menuTypeCascaderType) : boolean => el.selected == false)
})
const getNowvalIndex = () : number => {
let index = 0
for (let i = 0; i < _menus.value.length; i++) {
let item = _menus.value[i]
if (!item.selected) {
break;
}
index += 1;
}
index = Math.min(_menus.value.length - 1, index)
return index;
}
const menuBarClick = (item : menuTypeCascaderType, index : number) => {
let cindex = index
if (item.selected) {
if (index == 0) {
nowVal.value = '';
} else {
let cureentindex = index - 1;
cureentindex = Math.max(0, Math.min(cureentindex, _menus.value.length - 1))
nowVal.value = _menus.value[cureentindex].item.id
}
} else {
nowVal.value = item.item.id;
}
nextTick(() => {
currentIndex.value = getNowvalIndex();
emit('change', getIds())
emit('update:modelValue', nowVal.value)
})
}
const selectedCurrentChildren = (item : CASCADER_ITEM_INFO, index : number) => {
let disabled = item?.disabled ?? false
if (item.id == nowVal.value || disabled) {
return;
}
nowVal.value = item.id;
// currentIndex.value = index;
emit('change', getIds())
}
const nextCellClick = (item : CASCADER_ITEM_INFO, index : number, childrenIndex : number) => {
let disabled = item?.disabled ?? false
if (disabled) {
return;
}
if (nowVal.value != item.id) {
emit('change', getIds())
}
nowVal.value = item.id;
nextTick(() => {
currentIndex.value = getNowvalIndex();
emit('update:modelValue', nowVal.value)
emit('cellClick', item, index, childrenIndex)
if ((item?.children?.length ?? 0) == 0) {
emit('confirm', nowVal.value, getIds())
}
})
}
watch(() : any => props.modelValue, () => {
nowVal.value = props.modelValue;
nextTick(() => {
currentIndex.value = getNowvalIndex();
})
})
onMounted(() => {
nowVal.value = props.modelValue;
nextTick(() => {
currentIndex.value = getNowvalIndex();
})
})
</script>
<template>
<view class="xCascaderTree" :style="{width:_width,height:_height}">
<!-- 导航 -->
<scroll-view direction="horizontal" class="xCascaderTreeBar">
<!--
@slot 顶部头菜单导航插槽,你可以完全写自己的导航样式
@prop {CASCADER_ITEM_INFO[]} menus - 菜单导航,注意,可能为空
-->
<slot name="header" :menus="_menusList">
<view v-if="_menus.length>0" class="xCascaderBarTreeItem" v-for="(item,index) in _menus" :key="index">
<text @click="menuBarClick(item,index)" v-if="item.selected" class="xCascaderTreeItemBarText"
:style="{
color:_itemActiveColor,
fontSize:_fontSize,
whiteSpace:'nowrap',
border:`1px solid ${_itemActiveColor}`
}
">
{{elitext(item.item.title)}}
</text>
<x-icon v-if="(item.item?.children??[]).length>0&&item.selected" class="xCascaderBarTreeItemRight"
:font-size="_fontSize" name="arrow-right-s-line" :color="_itemActiveColor"></x-icon>
</view>
<view v-if="isCurrentNext" class="xCascaderBarTreeItem">
<text :style="{color:_itemTextColor,fontSize:_fontSize,whiteSpace:'nowrap'}">
<!-- 请选择 -->
{{i18n!.t('tmui4x.cascader.placeholder')}}
</text>
<x-icon class="xCascaderBarTreeItemRight" font-size="14" name="arrow-right-s-line"
:color="_itemActiveColor"></x-icon>
</view>
</slot>
</scroll-view>
<view :style="{height:'1px',borderBottom:`1px solid ${_borderColor}`}"></view>
<!-- 内容区域 -->
<view style="flex: 1;">
<swiper :duration="200" :current="currentIndex" class="xCascaderTreeSwiper" :disable-touch="true">
<swiper-item v-for="(item,index) in _list" :key="index" class="xCascaderTreeSwiperItem">
<list-view class="xCascaderScoll" direction="vertical">
<list-item v-for="(item2,index2) in item" :key="item2.id">
<view @click="nextCellClick(item2,index,index2)"
:class="[(item2?.disabled??false)?'xCascaderItemDisabled':'']" class="xCascaderItem">
<text class="xCascaderItemLeft" :style="{
fontSize:_fontSize,
color:isSelected(item2)?_itemActiveColor : _itemTextColor
}">{{item2.title}}{{item2.id}}</text>
<view class="xCascaderItemRight">
<text class="xCascaderTreeItemRightTextBtns"
:style="{color:_itemActiveColor,border:`1px solid ${_itemActiveColor}`}"
v-if="(item2?.children??[]).length>0&&_showCurrentBtn"
@click.stop="selectedCurrentChildren(item2,index)">
<!-- 选择本级 -->
{{i18n!.t('tmui4x.cascader.currentPlaceholder')}}
</text>
<x-icon v-if="(item2?.children??[]).length>0" :color="_itemTextColor"
name="arrow-right-s-line"></x-icon>
</view>
</view>
</list-item>
</list-view>
</swiper-item>
</swiper>
</view>
</view>
</template>
<style scoped>
.xCascaderItemDisabled {
opacity: 0.6;
}
.xCascaderTreeBar {
display: flex;
flex-direction: row;
position: relative;
height: 50px;
}
.xCascaderBarTreeItem {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
height: 100%;
}
.xCascaderBarTreeItemRight {
margin: 0px 0px;
}
.xCascaderTreeItemBarText {
text-overflow: ellipsis;
lines: 1;
border-radius: 3px;
/* #ifdef APP */
padding: 0px 8px;
line-height: 1.5;
/* #endif */
/* #ifndef APP */
padding: 4px 8px;
line-height: 1;
/* #endif */
/* #ifndef APP */
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
/* 限定为3行 */
overflow: hidden;
text-overflow: ellipsis;
/* #endif */
}
.xCascaderItem {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
height: 44px;
}
.xCascaderItemLeft {
lines: 1;
margin-right: 24px;
}
.xCascaderItemRight {
display: flex;
flex-direction: row;
}
.xCascaderTreeItemRightTextBtns {
font-size: 11px;
padding: 1px 3px;
border-radius: 3px;
}
.xCascaderTree {
display: flex;
flex-direction: column;
}
.xCascaderTreeSwiper,
.xCascaderTreeSwiperItem {
height: 100%;
}
.xCascaderScoll {
height: 100%;
}
</style>