This commit is contained in:
2026-01-05 12:47:14 +08:00
commit 1fc846fae3
1614 changed files with 162035 additions and 0 deletions

View File

@@ -0,0 +1,106 @@
import type { ExtractPropTypes, PropType, StyleValue } from 'vue'
import {
CHANGE_EVENT,
CLOSE_EVENT,
CLOSED_EVENT,
OPEN_EVENT,
OPENED_EVENT,
UPDATE_MODEL_EVENT,
UPDATE_VISIBLE_EVENT,
} from '../_constants'
import { commonProps, makeArrayProp, makeNumericProp, makeStringProp, truthProp } from '../_utils'
import { popupProps } from '../popup/popup'
import type { CascaderOption, CascaderValue } from './types'
export const cascaderProps = {
...popupProps,
...commonProps,
/**
* @description 选中值,双向绑定
*/
modelValue: Array,
/**
* @description 显示选择层
*/
visible: Boolean,
/**
* @description 级联数据
*/
options: makeArrayProp<any>([]),
/**
* @description 是否开启动态加载
*/
lazy: Boolean,
/**
* @description 动态加载回调,开启动态加载时生效
*/
lazyLoad: Function,
/**
* @description 自定义 `options` 结构中 `value` 的字段
*/
valueKey: makeStringProp('value'),
/**
* @description 自定义 `options` 结构中 `text` 的字段
*/
textKey: makeStringProp('text'),
/**
* @description 自定义 `options` 结构中 `children` 的字段
*/
childrenKey: makeStringProp('children'),
/**
* @description 当 `options` 为可转换为树形结构的扁平结构时,配置转换规则
*/
convertConfig: Object,
/**
* @description 是否需要弹层展示(设置为 `false` 后,`title` 失效)
*/
poppable: truthProp,
/**
* @description 标题
*/
title: String,
/**
* @description 选中底部展示样式 可选值: 'line', 'smile'
*/
titleType: makeStringProp<'line' | 'card' | 'smile'>('line'),
/**
* @description 标签栏字体尺寸大小 可选值: 'large', 'normal', 'small'
*/
titleSize: makeStringProp<'large' | 'normal' | 'small'>('normal'),
/**
* @description 标签间隙
*/
titleGutter: makeNumericProp(0),
/**
* @description 是否省略过长的标题文字
*/
titleEllipsis: truthProp,
/**
* @description 自定义弹窗样式
*/
popStyle: {
type: [String, Object, Array] as PropType<StyleValue>,
default: '',
},
/**
* @description 遮罩显示时的背景是否锁定
*/
lockScroll: truthProp,
}
export type CascaderProps = ExtractPropTypes<typeof cascaderProps>
/* eslint-disable unused-imports/no-unused-vars */
export const cascaderEmits = {
[UPDATE_MODEL_EVENT]: (value: CascaderValue) => true,
[UPDATE_VISIBLE_EVENT]: (value: boolean) => true,
[CHANGE_EVENT]: (value: CascaderValue, nodes: CascaderOption[]) => true,
pathChange: (nodes: CascaderOption[]) => true,
[OPEN_EVENT]: () => true,
[OPENED_EVENT]: () => true,
[CLOSE_EVENT]: () => true,
[CLOSED_EVENT]: () => true,
}
/* eslint-enable unused-imports/no-unused-vars */
export type CascaderEmits = typeof cascaderEmits

View File

@@ -0,0 +1,171 @@
<script lang="ts" setup>
import { computed, defineComponent, ref, useSlots, watch } from 'vue'
import {
CHANGE_EVENT,
CLOSE_EVENT,
CLOSED_EVENT,
OPEN_EVENT,
OPENED_EVENT,
PREFIX,
UPDATE_MODEL_EVENT,
UPDATE_VISIBLE_EVENT,
} from '../_constants'
import { getMainClass } from '../_utils'
import NutCascaderItem from '../cascaderitem/cascaderitem.vue'
import NutPopup from '../popup/popup.vue'
import { cascaderEmits, cascaderProps } from './cascader'
import type { CascaderOption, CascaderValue } from './types'
const props = defineProps(cascaderProps)
const emit = defineEmits(cascaderEmits)
const slots = useSlots()
const innerValue = ref(props.modelValue as CascaderValue)
const innerVisible = computed({
get() {
return props.visible
},
set(value) {
emit(UPDATE_VISIBLE_EVENT, value)
},
})
const classes = computed(() => {
return getMainClass(props, componentName)
})
const popClasses = computed(() => {
return `${componentName}__popup ${props.popClass}`
})
const overlayClasses = computed(() => {
return `${componentName}__overlay ${props.overlayClass}`
})
function handleChange(value: CascaderValue, pathNodes: CascaderOption[]) {
innerValue.value = value
innerVisible.value = false
emit(UPDATE_MODEL_EVENT, value)
emit(CHANGE_EVENT, value, pathNodes)
}
function handlePathChange(pathNodes: CascaderOption[]) {
emit('pathChange', pathNodes)
}
function handleOpen() {
emit(OPEN_EVENT)
}
function handleOpened() {
emit(OPENED_EVENT)
}
function handleClose() {
emit(CLOSE_EVENT)
}
function handleClosed() {
emit(CLOSED_EVENT)
}
watch(() => props.modelValue, (value) => {
if (value !== innerValue.value) {
innerValue.value = value as CascaderValue
}
})
</script>
<script lang="ts">
const componentName = `${PREFIX}-cascader`
export default defineComponent({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
styleIsolation: 'shared',
},
})
</script>
<template>
<view :class="classes" :style="props.customStyle">
<template v-if="props.poppable">
<NutPopup
v-model:visible="innerVisible"
:custom-class="popClasses"
:custom-style="props.popStyle"
:overlay-class="overlayClasses"
:overlay-style="props.overlayStyle"
position="bottom"
round
:closeable="props.closeable"
:close-icon="props.closeIcon"
:close-icon-position="props.closeIconPosition"
:z-index="props.zIndex"
:lock-scroll="props.lockScroll"
:overlay="props.overlay"
:close-on-click-overlay="props.closeOnClickOverlay"
:destroy-on-close="false"
@open="handleOpen"
@opened="handleOpened"
@close="handleClose"
@closed="handleClosed"
>
<slot v-if="slots.title" name="title" />
<template v-else>
<rich-text v-if="props.title" class="nut-cascader__bar" :nodes="props.title" />
</template>
<NutCascaderItem
:model-value="innerValue"
:visible="innerVisible"
:options="props.options"
:lazy="props.lazy"
:lazy-load="props.lazyLoad"
:value-key="props.valueKey"
:text-key="props.textKey"
:children-key="props.childrenKey"
:convert-config="props.convertConfig"
:title-type="props.titleType"
:title-size="props.titleSize"
:title-gutter="props.titleGutter"
:title-ellipsis="props.titleEllipsis"
@change="handleChange"
@path-change="handlePathChange"
/>
</NutPopup>
</template>
<template v-else>
<NutCascaderItem
:model-value="innerValue"
:visible="innerVisible"
:options="props.options"
:lazy="props.lazy"
:lazy-load="props.lazyLoad"
:value-key="props.valueKey"
:text-key="props.textKey"
:children-key="props.childrenKey"
:convert-config="props.convertConfig"
:title-type="props.titleType"
:title-size="props.titleSize"
:title-gutter="props.titleGutter"
:title-ellipsis="props.titleEllipsis"
@change="handleChange"
@path-change="handlePathChange"
/>
</template>
</view>
</template>
<style lang="scss">
@import "./index";
@import "../popup/index";
</style>

View File

@@ -0,0 +1,80 @@
import type { CascaderConfig, CascaderOption, ConvertConfig } from './types'
export function formatTree(tree: CascaderOption[], parent: CascaderOption | null, config: CascaderConfig): CascaderOption[] {
return tree.map((node: CascaderOption) => {
const { value: valueKey = 'value', text: textKey = 'text', children: childrenKey = 'children' } = config
const { [valueKey]: value, [textKey]: text, [childrenKey]: children, ...others } = node
const newNode: CascaderOption = {
loading: false,
...others,
level: parent ? ((parent && parent.level) || 0) + 1 : 0,
value,
text,
children,
_parent: parent,
}
if (newNode.children && newNode.children.length)
newNode.children = formatTree(newNode.children, newNode, config)
return newNode
})
}
export function eachTree(tree: CascaderOption[], cb: (node: CascaderOption) => any): void {
let i = 0
let node: CascaderOption
/* eslint-disable no-cond-assign */
while ((node = tree[i++])) {
if (cb(node) === true)
break
if (node.children && node.children.length)
eachTree(node.children, cb)
}
}
const defaultConvertConfig = {
topId: null,
idKey: 'id',
pidKey: 'pid',
sortKey: '',
}
export function convertListToOptions(list: CascaderOption[], options: ConvertConfig): CascaderOption[] {
const mergedOptions = {
...defaultConvertConfig,
...(options || {}),
}
const { topId, idKey, pidKey, sortKey } = mergedOptions
let result: CascaderOption[] = []
let map: any = {}
list.forEach((node: CascaderOption) => {
node = { ...node }
const { [idKey]: id, [pidKey]: pid } = node
const children = (map[pid] = map[pid] || [])
if (!result.length && pid === topId)
result = children
children.push(node)
node.children = map[id] || (map[id] = [])
})
if (sortKey) {
Object.keys(map).forEach((i) => {
if (map[i].length > 1)
map[i].sort((a: CascaderOption, b: CascaderOption) => a[sortKey] - b[sortKey])
})
}
map = null
return result
}

View File

@@ -0,0 +1,110 @@
.nut-theme-dark {
.nut-cascader {
.nut-tabs__titles {
background: $dark-background3 !important;
}
&__bar {
color: $dark-color;
background: $dark-background2;
}
&-item {
&__inner {
color: $dark-color-gray;
}
}
}
}
.nut-cascader {
.nut-tab-pane {
padding: 0;
}
.nut-tabs__titles {
padding: $cascader-tabs-item-padding;
background: #fff;
}
&-item {
width: 100%;
font-size: $cascader-font-size;
line-height: $cascader-line-height;
$block: &;
&.nut-tabs {
&.horizontal {
.nut-tabs__titles {
.nut-tabs__titles-item {
flex: initial;
padding: $cascader-tabs-item-padding;
white-space: nowrap;
}
}
}
}
&__inner {
display: flex;
align-items: center;
padding: $cascader-item-padding;
margin: 0;
font-size: $cascader-item-font-size;
color: $cascader-item-color;
cursor: pointer;
}
&__title {
flex: 1;
}
&__icon-check {
margin-left: 10px;
visibility: hidden;
}
&__icon-loading {
margin-left: 10px;
}
&.active {
&:not(.disabled) {
color: $cascader-item-active-color;
}
#{$block}__icon-check {
color: $cascader-item-active-color;
visibility: visible;
}
}
&.disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
&__bar {
display: flex;
align-items: center;
justify-content: center;
padding: $cascader-bar-padding;
font-size: $cascader-bar-font-size;
font-weight: bold;
line-height: $cascader-bar-line-height;
color: $cascader-bar-color;
text-align: center;
}
&-pane {
display: block;
width: 100%;
height: 342px;
padding: 10px 0 0;
margin: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
}

View File

@@ -0,0 +1 @@
export * from './cascader'

View File

@@ -0,0 +1,73 @@
import { eachTree, formatTree } from './helper'
import type { CascaderConfig, CascaderOption, CascaderValue } from './types'
class Tree {
nodes: CascaderOption[]
readonly config: CascaderConfig
constructor(nodes: CascaderOption[], config?: CascaderConfig) {
this.config = {
value: 'value',
text: 'text',
children: 'children',
...(config || {}),
}
this.nodes = formatTree(nodes, null, this.config)
}
updateChildren(nodes: CascaderOption[], parent: CascaderOption | null): void {
if (!parent)
this.nodes = formatTree(nodes, null, this.config)
else
parent.children = formatTree(nodes, parent, this.config)
}
// for test
getNodeByValue(value: CascaderOption['value']): CascaderOption | void {
let foundNode
eachTree(this.nodes, (node: CascaderOption) => {
if (node.value === value) {
foundNode = node
return true
}
})
return foundNode
}
getPathNodesByValue(value: CascaderValue): CascaderOption[] {
if (!value.length)
return []
const pathNodes = []
let currentNodes: CascaderOption[] | void = this.nodes
while (currentNodes && currentNodes.length) {
const foundNode: CascaderOption | void = currentNodes.find(node => node.value === value[node.level as number])
if (!foundNode)
break
pathNodes.push(foundNode)
currentNodes = foundNode.children
}
return pathNodes
}
isLeaf(node: CascaderOption, lazy: boolean): boolean {
const { leaf, children } = node
const hasChildren = Array.isArray(children) && Boolean(children.length)
return leaf == null ? !hasChildren && !lazy : leaf
}
hasChildren(node: CascaderOption, lazy: boolean): boolean {
if (lazy)
return Array.isArray(node.children) && Boolean(node.children.length)
return !this.isLeaf(node, lazy)
}
}
export default Tree

View File

@@ -0,0 +1,37 @@
export interface CascaderPane {
nodes: CascaderOption[]
selectedNode: CascaderOption | null
}
export interface CascaderConfig {
value?: string
text?: string
children?: string
}
export interface CascaderTabs {
title: string
paneKey: string
disabled: boolean
}
export interface CascaderOption {
text?: string
value?: number | string
disabled?: boolean
children?: CascaderOption[]
leaf?: boolean
level?: number
loading?: boolean
[key: PropertyKey]: any
}
export type CascaderValue = CascaderOption['value'][]
export interface ConvertConfig {
topId?: string | number | null
idKey?: string
pidKey?: string
sortKey?: string
}