This commit is contained in:
2026-09-24 16:25:22 +08:00
commit 7428184f01
1198 changed files with 314515 additions and 0 deletions
@@ -0,0 +1,18 @@
/**
* 侧边分类数据
*/
export type SLIDER_TREE_ITEM = {
id : string,
title : string,
children : SLIDER_TREE_ITEM[],
disabled : boolean,
/** 当前选中的id数组 */
selected : string[],
checked:boolean
}
export type NODES_PATH_MENU_TYPE = {
indexPath:number[],
ids:string[],
pathData:SLIDER_TREE_ITEM[]
}
@@ -0,0 +1,125 @@
import { CASCADER_TREE_ITEM, CASCADER_PATH_MENU_TYPE } from "../../interface.uts"
/**
* 当前树有多少个被选中了。
*/
export function getTreeSelectedNum(item : CASCADER_TREE_ITEM[], target : Set<string>) : number {
let inx = 0;
function jshz(tree : CASCADER_TREE_ITEM[]) {
for (let i = 0; i < tree.length; i++) {
let item = tree[i]
if (item.children.length > 0) {
jshz(item.children)
} else if (target.has(item.id)) {
inx += 1
}
}
}
jshz(item)
return inx;
}
/**
* 根据目标节点,过滤掉父节点。
* @param item
* @param target
*/
export function filterParentNode(item : CASCADER_TREE_ITEM[], target : Set<string>) : string[] {
function jshz(tree : CASCADER_TREE_ITEM[]) : string[] {
let arr = [] as string[]
for (let i = 0; i < tree.length; i++) {
let item = tree[i]
if (item.children.length > 0) {
arr = arr.concat(jshz(item.children))
} else if (target.has(item.id)) {
arr.push(item.id)
}
}
return arr;
}
return jshz(item)
}
/**
* 是否是父节点
* @param item
* @param target
*/
export function isParent(item : CASCADER_TREE_ITEM[], target : string) : boolean {
let parent = false;
function jshz(tree : CASCADER_TREE_ITEM[]) {
if (parent) {
return;
}
for (let i = 0; i < tree.length; i++) {
let item = tree[i]
if (item.id == target && item.children.length > 0) {
parent = true;
} else if (item.id != target && item.children.length > 0) {
jshz(item.children)
}
}
}
jshz(item)
return parent
}
/** 通过节点获取当前节点的索引路径和ids数组 */
export function getIndexPathAndIds(tree : CASCADER_TREE_ITEM[], targetId : string) : string[] {
const path : string[] = [];
function dfs(node : CASCADER_TREE_ITEM[]) : boolean {
for (let i = 0; i < node.length; i++) {
let item = node[i];
path.push(item.id);
if (item.id === targetId) {
return true;
}
if (dfs(item.children)) {
return true;
}
path.pop();
}
return false;
}
if (dfs(tree)) {
return path;
} else {
return [] as string[];
}
}
/** 通过节点获取当前节点的索引路径和ids数组 */
export function getTreeNodesPath(tree : CASCADER_TREE_ITEM[], targetId : string) : CASCADER_PATH_MENU_TYPE|null {
const path : string[] = [];
const indexPaths:number[] = [];
const pathData:CASCADER_TREE_ITEM[] = [];
function dfs(node : CASCADER_TREE_ITEM[]) : boolean {
for (let i = 0; i < node.length; i++) {
let item = node[i];
path.push(item.id);
indexPaths.push(i)
pathData.push(item)
if (item.id === targetId) {
return true;
}
if (dfs(item.children)) {
return true;
}
path.pop();
indexPaths.pop();
pathData.pop();
}
return false;
}
if (dfs(tree)) {
return {
indexPath:indexPaths,
ids:path,
pathData
} as CASCADER_PATH_MENU_TYPE;
}
return null
}
@@ -0,0 +1,754 @@
<script lang="ts">
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"
/**
* @name 极联器 xCascader
* @page /pages/index/cascader
* @category 表单组件
* @description 极联选择器,单选模式。
* @constant 平台兼容
* | H5 | andriod | IOS | 小程序 | UTS | UNIAPP-X SDK | version |
| --- | --- | --- | --- | --- | --- | --- |
| ☑ | ☑️ | ☑️ | ☑️ | ☑️ | 4.44+ | 1.1.9 |
*/
export default {
data() {
return {
//当前的层级
activeId: "",
selectedsIds: [] as string[],
//当前的层级索引
selectedsIdsItem: [] as CASCADER_TREE_ITEM[],
menuBarList: [] as CASCADER_TREE_ITEM[],
activeIndex: [] as number[],
changshowpenl: "0",
showCchangshowpenl: true,
swiperIndex: 0,
_nowListSwiper:[] as CASCADER_TREE_ITEM[][],
isAndriod:(uni.getSystemInfoSync().platform == 'android') as boolean,
// 添加切换状态管理,防止闪烁
isTransitioning: false,
nextSwiperIndex: 0,
tid:20
}
},
emits: [
/**
* 选中触发时变化,只要路径变化了就会触发
* @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'
],
props: {
/**
* 宽,不可为auto。
*/
width: {
type: String,
default: "100%"
},
/**
* 高是必填,不可为auto。
*/
height: {
type: String,
default: "150"
},
/**
* 选项项目未选中的文字颜色
*/
itemTextColor: {
type: String,
default: "#333333"
},
/**
* 选项项目未选中的暗黑文字颜色,空值是取白色
*/
darkItemTextColor: {
type: String,
default: ""
},
/**
* 选项项目选中的文字颜色,空值取全局主题
*/
itemActiveColor: {
type: String,
default: ""
},
/**
* 内容区域背景颜色
*/
sliderContentBgColor: {
type: String,
default: "transparent"
},
/**
* 提供的数据结构
*/
list: {
type: Array as PropType<CASCADER_ITEM_INFO[]>,
default: () : CASCADER_ITEM_INFO[] => [] as CASCADER_ITEM_INFO[]
},
/**
* 当前选中项的id
*/
modelValue: {
type: String,
default: ""
},
/**
* 每级是否允许多选
* 暂不开放,如需多选请参考组件slider-tree。
*/
multiple: {
type: Boolean,
default: false
},
/**
* 项目文字大小
*/
fontSize: {
type: String,
default: "18"
},
/**
* 是否在有下级的项目上显示选择本级按钮.
* 当用户选中了本级时就同选择最后一项一样会触发confirm及同步vmodel值
*/
showCurrentBtn: {
type: Boolean,
default: false
},
},
computed: {
_showCurrentBtn() : boolean {
return this.showCurrentBtn
},
_width() : string {
return checkIsCssUnit(this.width, xConfig.unit);
},
_height() : string {
return checkIsCssUnit(this.height, xConfig.unit);
},
_fontSize() : string {
let fontSize = checkIsCssUnit(this.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)
},
_itemTextColor() : string {
let color = this.itemTextColor;
if (xConfig.dark == 'dark') {
color = this.darkItemTextColor != "" ? this.darkItemTextColor : "#ffffff"
}
return getDefaultColor(color);
},
_itemActiveColor() : string {
return this.itemActiveColor != "" ? getDefaultColor(this.itemActiveColor) : getDefaultColor(xConfig.color);
},
_sliderContentBgColor() : string {
return getDefaultColor(this.sliderContentBgColor);
},
_multiple() : boolean {
return this.multiple
},
_list() : CASCADER_TREE_ITEM[] {
let list = this.list as CASCADER_ITEM_INFO[];
function addOptionalFieldsToTree(tree : CASCADER_ITEM_INFO[]) : void {
for (let i = 0; i < tree.length; i++) {
const node = tree[i];
node.disabled = node.disabled == null ? false : node.disabled! as boolean;
node.selected = node.selected == null ? [] : node.selected! as string[];
node.children = node.children == null ? ([] as CASCADER_ITEM_INFO[]) : node.children! as CASCADER_ITEM_INFO[];
if ((node.children!).length > 0) {
addOptionalFieldsToTree(node.children! as CASCADER_ITEM_INFO[]);
}
}
}
function addOptionalFieldsToTreeClolone(tree : CASCADER_ITEM_INFO[]) : CASCADER_TREE_ITEM[] {
let nowlist = [] as CASCADER_TREE_ITEM[]
for (let i = 0; i < tree.length; i++) {
const node = tree[i];
node.disabled = node.disabled == null ? false : node.disabled! as boolean;
node.selected = node.selected == null ? [] : node.selected! as string[];
node.children = node.children == null ? ([] as CASCADER_ITEM_INFO[]) : node.children! as CASCADER_ITEM_INFO[];
let item = {
id: node.id,
title: node.title,
disabled: node.disabled!,
selected: node.selected!,
children: [] as CASCADER_TREE_ITEM[],
checked: false
} as CASCADER_TREE_ITEM
if ((node.children!).length > 0) {
item.children = addOptionalFieldsToTreeClolone(node.children! as CASCADER_ITEM_INFO[]);
}
nowlist.push(item)
}
return nowlist
}
addOptionalFieldsToTree(list as CASCADER_ITEM_INFO[]);
return addOptionalFieldsToTreeClolone(list)
},
_borderColor() : string {
if (xConfig.dark == 'dark') return xConfig.borderDarkColor
return '#f5f5f5'
},
_nextChildren() : CASCADER_TREE_ITEM[] {
if (this.menuBarList.length == 0) return this._list
let lastChildren = this.menuBarList[this.menuBarList.length -1]
if(lastChildren.children.length==0) return [] as CASCADER_TREE_ITEM[];
return lastChildren.children
},
},
mounted() {
this.oninit();
},
beforeUnmount() {
clearTimeout(this.tid)
},
watch: {
modelValue(newval : string) {
let nowid = ""
if (this.selectedsIds.length > 0) {
nowid = this.selectedsIds[this.selectedsIds.length - 1]
}
if (nowid == newval) return;
this.oninit();
}
},
methods: {
getFontSize(k:string):string{
return checkIsCssUnit(k,xConfig.unit)
},
getNowListSwiper(){
if (this.menuBarList.length == 0){
// 当菜单为空时,显示根级数据
this._nowListSwiper = [this._list]
return
}
let lastId = this.menuBarList[this.menuBarList.length -1].id;
let temlist = [] as CASCADER_TREE_ITEM[][]
try {
temlist = this.findIdToMenuAry(lastId,this._list)
} catch (error) {
//TODO handle the exception
console.error(error)
// 出错时回退到根级数据
temlist = [this._list]
}
this._nowListSwiper = temlist
},
// 根据id返回第一级到当前id级的数据列表
findIdToMenuAry(id: string | number, trees: CASCADER_TREE_ITEM[]): CASCADER_TREE_ITEM[][] {
// 存储最终结果:每个层级的所有节点(带checked状态)
const result: CASCADER_TREE_ITEM[][] = [];
// 存储从根到目标节点的路径
const selectedPath: CASCADER_TREE_ITEM[] = [];
// 标记是否找到目标节点
let found = false;
// 递归查找目标节点并构建路径
function findPath(node: CASCADER_TREE_ITEM, path: CASCADER_TREE_ITEM[] = []): boolean {
// 当前节点加入临时路径
const currentPath = [...path, node] as CASCADER_TREE_ITEM[];
// 找到目标节点
if (node.id == id) {
// 复制路径到selectedPath
selectedPath.push(...currentPath);
found = true;
return true;
}
// 递归搜索子节点
if (node.children.length>0) {
for (let i = 0; i < node.children.length; i++) {
const child = node.children[i]
if (findPath(child, currentPath)) {
return true;
}
}
}
return false;
}
for (let i = 0; i < trees.length; i++) {
const tree = trees[i]
// 直接检查第一级节点
if (tree.id == id) {
selectedPath.push(tree);
found = true;
break;
}
// 如果第一级不匹配,继续递归查找
else if (findPath(tree)) {
break;
}
}
// 如果找到目标节点,构建结果数组
if (found) {
// 构建每一层级的节点列表
let currentLevel = trees;
let currentParent = null as null|CASCADER_TREE_ITEM;
// 遍历路径中的每个节点
for (let i = 0; i < selectedPath.length; i++) {
const pathNode = selectedPath[i];
// 为当前层级的所有节点添加checked状态
const levelWithChecked = currentLevel
// 添加到结果数组
result.push(levelWithChecked);
// 更新下一层级的父节点和子节点列表
if (i < selectedPath.length - 1) {
currentParent = currentLevel.find((node:CASCADER_TREE_ITEM):boolean => node.id == pathNode.id);
currentLevel = currentParent?.children??[]
}
}
return result;
}
return [trees];
},
//初始默认选中一个id值。如果为空的话。
oninit() {
const trees = toRaw(this._list) as CASCADER_TREE_ITEM[]
let rulst = getTreeNodesPath(trees, this.modelValue)
if (rulst != null) {
let indexpath = rulst.indexPath!
this.menuBarList = rulst.pathData
this.selectedsIds = rulst.ids
this.activeIndex = indexpath
this.changshowpenl = getUid() as string;
this.swiperIndex = this.menuBarList.length - 1
}
this.getNowListSwiper()
},
change(isCurrent:boolean) {
let idis = this.selectedsIds.slice(0);
let empty = ""
// 单项模式下,父节点不更新对外更新值,只取最后一个确定的值。
if (idis.length > 0) {
empty = idis[idis.length - 1]
}
if (empty != "" && (this._nextChildren.length==0||isCurrent)) {
/**
* 更新当前的值,等同v-model
*/
this.$emit('update:modelValue', empty)
/**
* 最后一项时触发.
*/
this.$emit('confirm',empty,idis)
}
this.$emit('change', idis)
},
swiperChange(detail:UniSwiperChangeEvent){
this.swiperIndex = detail.detail.current;
},
/**
* isCurrent:是否选中本级,不进行下级跳转
*/
nextOnClick(item : CASCADER_TREE_ITEM, parentIndex : number,childrenIndex:number, isNext : boolean,isCurrent:boolean) {
if (item.disabled || this.isTransitioning) return;
// 设置过渡状态,防止快速点击造成的闪烁
this.isTransitioning = true;
if(isNext){
this.menuBarList.push(item)
this.selectedsIds = this.menuBarList.map((el:CASCADER_TREE_ITEM):string => el.id)
// 先更新数据,再切换swiper
this.getNowListSwiper()
if(item.children.length>0 && !isCurrent){
// 使用nextTick确保数据更新完成后再切换
this.$nextTick(() => {
this.swiperIndex += 1
// 延迟重置过渡状态
const _this =this;
this.tid = setTimeout(() => {
_this.isTransitioning = false
}, 200)
})
}else{
if(!isCurrent){
// #ifdef APP-IOS
this.swiperIndex = parentIndex
// #endif
}
this.isTransitioning = false
}
this.$emit("cellClick",item,parentIndex-1,childrenIndex)
this.change(isCurrent)
return;
}
this.menuBarList = this.menuBarList.slice(0,this.swiperIndex+1)
if(item.children.length>0){
if(this.swiperIndex == this.menuBarList.length-1){
this.menuBarList.splice(this.menuBarList.length-1,1,item)
}else{
this.menuBarList.push(item)
}
// 先更新数据
this.getNowListSwiper()
if(!isCurrent){
// 使用nextTick确保数据更新完成
this.$nextTick(() => {
this.swiperIndex += 1
const _this =this;
this.tid = setTimeout(() => {
_this.isTransitioning = false
}, 200)
})
} else {
this.isTransitioning = false
}
}else{
if(parentIndex>this.menuBarList.length-1){
this.menuBarList.push(item)
}else{
this.menuBarList.splice(this.menuBarList.length-1,1,item)
}
this.isTransitioning = false
}
this.selectedsIds = this.menuBarList.map((el:CASCADER_TREE_ITEM):string => el.id)
this.$emit("cellClick",item,parentIndex,childrenIndex)
this.change(isCurrent)
},
menuBarClick(index : number) {
if(index==-1 || this.isTransitioning) return;
// 设置过渡状态
this.isTransitioning = true;
if(index==0){
this.menuBarList = [] as CASCADER_TREE_ITEM[]
}else{
this.menuBarList = this.menuBarList.slice(0,index)
}
// 先更新数据,再切换swiper
this.getNowListSwiper()
// 使用nextTick确保数据更新完成
this.$nextTick(() => {
this.swiperIndex = index
this.selectedsIds = this.menuBarList.map((el:CASCADER_TREE_ITEM):string => el.id)
this.change(false)
// 延迟重置过渡状态
const _this =this;
this.tid = setTimeout(() => {
_this.isTransitioning = false
}, 200)
})
},
elitext(text : string) : string {
let len = text.length;
return len <= 7 ? text : (text.substring(0, 7) + '..')
},
/**
* 当前是否选中
*/
isSelected(item : CASCADER_TREE_ITEM) : boolean {
return (this.selectedsIds.includes(item.id)) && item.children.length == 0
},
/**
* 本下级选了几个
*/
isSelectedNum(item : CASCADER_TREE_ITEM) : number {
let ps = new Set(this.selectedsIds)
return getTreeSelectedNum(item.children, ps);
},
},
}
</script>
<template>
<view class="xCascaderTree" :style="{width:_width,minHeight:`calc(${_height} + 50px)`}">
<scroll-view direction="horizontal" class="xCascaderTreeBar">
<!--
@slot 顶部头菜单导航插槽,你可以完全写自己的导航样式
@prop {CASCADER_TREE_ITEM[]} menus - 菜单导航,注意,可能为空
-->
<slot name="header" :menus="menuBarList" >
<view @click="menuBarClick(index)" v-if="menuBarList.length>0" class="xCascaderBarTreeItem"
v-for="(item,index) in menuBarList" :key="index">
<text class="xCascaderTreeItemBarText"
:style="{
color:_itemActiveColor,
fontSize:_fontSize,
whiteSpace:'nowrap',
border:`1px solid ${_itemActiveColor}`
}
">
{{elitext(item.title)}}
</text>
<x-icon
v-if="index<menuBarList.length-1||(index==0&&_nextChildren.length>0)||_nextChildren.length>0"
class="xCascaderBarTreeItemRight" :font-size="_fontSize"
name="arrow-right-s-line" :color="_itemActiveColor"></x-icon>
</view>
<view v-if="_nextChildren.length>0||(menuBarList.length==0&&_list.length>0)" @click="menuBarClick(-1)" 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>
<swiper :disable-touch="true" :duration="200" @change="swiperChange" :style="{height:_height}" :current="swiperIndex">
<swiper-item v-for="(children,childrenIndex) in _nowListSwiper" :key="childrenIndex" class="xCascaderTreeSwiperItem"
:style="{height:_height}">
<list-view style="width: 100%;height: 100%;" direction="vertical">
<list-item v-for="(item,index) in children" :key="index" :style="{height:getFontSize('50')}">
<view :hover-start-time="10" :hover-stay-time="100"
:hover-class="item.disabled?'':'xCascaderTreeItemHover'"
@click="nextOnClick(item,childrenIndex,index,false,false)" class="xCascaderTreeItemRight"
:style="{backgroundColor:_sliderContentBgColor,opacity:item.disabled?'0.5':1}">
<view style="flex: 1;">
<text class="xCascaderTreeItemRightText"
:style="{fontSize:_fontSize,color:isSelected(item)?_itemActiveColor : _itemTextColor}">
{{item.title}}
</text>
</view>
<view
style="display: flex;flex-direction: row;justify-content: flex-end;align-items: center;">
<text class="xCascaderTreeItemRightTextBtns" :style="{color:_itemActiveColor,border:`1px solid ${_itemActiveColor}`}"
v-if="item.children.length>0&&_showCurrentBtn" @click.stop="nextOnClick(item,swiperIndex+1,index,false,true)">
<!-- 选择本级 -->
{{i18n!.t('tmui4x.cascader.currentPlaceholder')}}
</text>
<text :style="{fontSize:_fontSize,color:_itemActiveColor,marginRight:'8px'}"
v-if="item.children.length>0&&isSelectedNum(item)>0">已选({{isSelectedNum(item)}})</text>
<x-icon v-if="isSelected(item)" :color="_itemActiveColor"
name="check-line"></x-icon>
<x-icon v-if="item.children.length>0" :color="_itemTextColor"
name="arrow-right-s-line"></x-icon>
</view>
</view>
</list-item>
</list-view>
</swiper-item>
<swiper-item v-if="_nextChildren.length>0||isAndriod" class="xCascaderTreeSwiperItem" :style="{height:_height}">
<list-view style="width: 100%;height: 100%;" direction="vertical">
<list-item v-for="(item,index) in _nextChildren" :key="index" :style="{height:getFontSize('50')}">
<view :hover-start-time="10" :hover-stay-time="100"
:hover-class="item.disabled?'':'xCascaderTreeItemHover'"
@click="nextOnClick(item,swiperIndex+1,index,true,false)" class="xCascaderTreeItemRight"
:style="{backgroundColor:_sliderContentBgColor,opacity:item.disabled?'0.5':1}">
<view style="flex: 1;">
<text class="xCascaderTreeItemRightText"
:style="{fontSize:_fontSize,color:isSelected(item)?_itemActiveColor : _itemTextColor}">
{{item.title}}
</text>
</view>
<view
style="display: flex;flex-direction: row;justify-content: flex-end;align-items: center;">
<text class="xCascaderTreeItemRightTextBtns" :style="{color:_itemActiveColor,border:`1px solid ${_itemActiveColor}`}"
v-if="item.children.length>0&&_showCurrentBtn" @click.stop="nextOnClick(item,swiperIndex+1,index,true,true)">
<!-- 选择本级 -->
{{i18n!.t('tmui4x.cascader.currentPlaceholder')}}
</text>
<text :style="{fontSize:_fontSize,color:_itemActiveColor,marginRight:'8px'}"
v-if="item.children.length>0&&isSelectedNum(item)>0">已选({{isSelectedNum(item)}})</text>
<x-icon v-if="isSelected(item)" :color="_itemActiveColor"
name="check-line"></x-icon>
<x-icon v-if="item.children.length>0" :color="_itemTextColor"
name="arrow-right-s-line"></x-icon>
</view>
</view>
</list-item>
</list-view>
</swiper-item>
</swiper>
</view>
</template>
<style scoped>
.xCascaderTreeItemRightTextBtns{
font-size: 12px;
border-radius: 3px;
/* #ifdef APP-ANDROID */
padding: 0px 8px;
line-height:1.5;
/* #endif */
/* #ifndef APP-ANDROID */
padding: 4px 8px;
line-height:1;
/* #endif */
}
.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 */
}
.xCascaderTreeBar {
display: flex;
flex-direction: row;
height: 50px;
}
.xCascaderBarTreeItem {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
height: 100%;
}
.xCascaderBarTreeItemRight {
margin: 0px 0px;
}
.xCascaderTreeSwiperItem {
/* #ifdef WEB */
cursor: default;
/* #endif */
}
.xCascaderTreeItemHover {
background-color: rgba(155, 155, 155, 0.1);
}
.xCascaderTreeItemMoreHeader {
padding: 12px;
flex-direction: row;
align-items: center;
}
.xCascaderTreeItemMore {
position: absolute;
left: 0px;
top: 0px;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
z-index: 10;
}
.xCascaderTreeItem {
width: 100%;
height: 100%;
}
.xCascaderTreeItemRight {
/* padding:0 24rpx; */
height: 50px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.xCascaderTreeItemRightText {
text-align: left;
lines: 1;
text-overflow: ellipsis;
/* #ifndef APP */
white-space: nowrap;
/* 禁止换行 */
overflow: hidden;
width: 100%;
/* #endif */
}
</style>
@@ -0,0 +1,525 @@
<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>