| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514 |
- <template>
- <cl-select-trigger
- v-if="showTrigger"
- :pt="{
- className: pt.trigger?.className,
- icon: pt.trigger?.icon
- }"
- :placeholder="placeholder"
- :disabled="disabled"
- :focus="popupRef?.isOpen"
- :text="text"
- @open="open"
- @clear="clear"
- ></cl-select-trigger>
- <cl-popup
- ref="popupRef"
- v-model="visible"
- :title="title"
- :pt="{
- className: pt.popup?.className,
- header: pt.popup?.header,
- container: pt.popup?.container,
- mask: pt.popup?.mask,
- draw: pt.popup?.draw
- }"
- @closed="onClosed"
- >
- <view class="cl-select-popup" @touchmove.stop>
- <view class="cl-select-popup__labels">
- <cl-tag
- v-for="(item, index) in labels"
- :key="index"
- :type="index != current ? 'info' : 'primary'"
- plain
- @tap="onLabelTap(index)"
- >
- {{ item }}
- </cl-tag>
- </view>
- <view
- class="cl-select-popup__list"
- :style="{
- height: parseRpx(height)
- }"
- >
- <swiper
- v-if="isMp() ? popupRef?.isOpen : true"
- class="h-full bg-transparent"
- :current="current"
- @change="onSwiperChange"
- >
- <swiper-item
- v-for="(data, index) in list"
- :key="index"
- class="h-full bg-transparent"
- >
- <cl-list-view
- :data="data"
- :item-height="45"
- :virtual="!isMp()"
- @item-tap="onItemTap"
- >
- <template #item="{ data, item }">
- <view
- class="flex flex-row items-center justify-between w-full px-[20rpx]"
- :class="{
- 'bg-primary-50': onItemActive(index, data),
- 'bg-surface-800': isDark && onItemActive(index, data)
- }"
- :style="{
- height: item.height + 'px'
- }"
- >
- <cl-text
- :pt="{
- className: parseClass({
- '!text-primary-500': onItemActive(index, data)
- })
- }"
- >{{ data[labelKey] }}</cl-text
- >
- </view>
- </template>
- </cl-list-view>
- </swiper-item>
- </swiper>
- </view>
- </view>
- </cl-popup>
- </template>
- <script setup lang="ts">
- import { ref, computed, type PropType, nextTick } from "vue";
- import { isDark, isEmpty, isMp, isNull, parseClass, parsePt, parseRpx } from "@/cool";
- import type { ClSelectTriggerPassThrough } from "../cl-select-trigger/props";
- import type { ClPopupPassThrough } from "../cl-popup/props";
- import { t } from "@/locale";
- import type { ClListViewItem } from "../../types";
- defineOptions({
- name: "cl-cascader"
- });
- /**
- * 组件属性定义
- * 定义级联选择器组件的所有可配置属性
- */
- const props = defineProps({
- /**
- * 透传样式配置
- * 用于自定义组件各部分的样式,支持嵌套配置
- * 可配置:trigger(触发器)、popup(弹窗)等部分的样式
- */
- pt: {
- type: Object,
- default: () => ({})
- },
- /**
- * 选择器的值 - v-model绑定
- * 数组形式,按层级顺序存储选中的值
- * 例如:["province", "city", "district"] 表示选中了省市区三级
- */
- modelValue: {
- type: Array as PropType<string[]>,
- default: () => []
- },
- /**
- * 选择器弹窗标题
- * 显示在弹窗顶部的标题文字
- */
- title: {
- type: String,
- default: () => t("请选择")
- },
- /**
- * 选择器占位符文本
- * 当没有选中任何值时显示的提示文字
- */
- placeholder: {
- type: String,
- default: () => t("请选择")
- },
- /**
- * 选项数据源,支持树形结构
- * 每个选项需包含 labelKey 和 valueKey 指定的字段
- * 如果有子级,需包含 children 字段
- */
- options: {
- type: Array as PropType<ClListViewItem[]>,
- default: () => []
- },
- /**
- * 是否显示选择器触发器
- * 设为 false 时可以通过编程方式控制弹窗显示
- */
- showTrigger: {
- type: Boolean,
- default: true
- },
- /**
- * 是否禁用选择器
- * 禁用状态下无法点击触发器打开弹窗
- */
- disabled: {
- type: Boolean,
- default: false
- },
- /**
- * 标签显示字段的键名
- * 指定从数据项的哪个字段读取显示文字
- */
- labelKey: {
- type: String,
- default: "label"
- },
- /**
- * 值字段的键名
- * 指定从数据项的哪个字段读取实际值
- */
- valueKey: {
- type: String,
- default: "label"
- },
- /**
- * 文本分隔符
- * 用于连接多级标签的文本
- */
- textSeparator: {
- type: String,
- default: " - "
- },
- /**
- * 列表高度
- */
- height: {
- type: [String, Number],
- default: 800
- }
- });
- /**
- * 定义组件事件
- * 向父组件发射的事件列表
- */
- const emit = defineEmits(["update:modelValue", "change"]);
- /**
- * 弹出层组件的引用
- * 用于调用弹出层的方法,如打开、关闭等
- */
- const popupRef = ref<ClPopupComponentPublicInstance | null>(null);
- /**
- * 透传样式类型定义
- * 定义可以透传给子组件的样式配置结构
- */
- type PassThrough = {
- trigger?: ClSelectTriggerPassThrough; // 触发器样式配置
- popup?: ClPopupPassThrough; // 弹窗样式配置
- };
- /**
- * 解析透传样式配置
- * 将传入的样式配置按照指定类型进行解析和处理
- */
- const pt = computed(() => parsePt<PassThrough>(props.pt));
- /**
- * 当前显示的级联层级索引
- * 用于控制 swiper 组件显示哪一级的选项列表
- */
- const current = ref(0);
- /**
- * 是否还有下一级可选
- * 当选中项没有子级时设为 false,表示选择完成
- */
- const isNext = ref(true);
- /**
- * 当前临时选中的值数组
- * 存储用户在弹窗中正在选择的值,确认后才会更新到 modelValue
- */
- const value = ref<any[]>([]);
- /**
- * 级联选择的数据列表
- * 根据当前选中的值生成多级选项数据数组
- * 返回二维数组,第一维是级别,第二维是该级别的选项
- *
- * 计算逻辑:
- * 1. 如果没有选中任何值,返回根级选项
- * 2. 根据已选中的值,逐级查找对应的子级选项
- * 3. 最终返回所有级别的选项数据
- */
- const list = computed<ClListViewItem[][]>(() => {
- let data = props.options;
- // 如果没有选中任何值,直接返回根级选项
- if (isEmpty(value.value)) {
- return [data];
- }
- // 根据选中的值逐级构建选项数据
- const arr = value.value.map((v) => {
- // 在当前级别中查找选中的项
- const item = data.find((e) => e[props.valueKey] == v);
- if (item == null) {
- return [];
- }
- // 如果找到的项有子级,更新data为子级数据
- if (!isNull(item.children)) {
- data = item.children ?? [];
- }
- return data as ClListViewItem[];
- });
- // 返回根级选项 + 各级子选项
- return [props.options, ...arr];
- });
- /**
- * 扁平化的选项数据
- * 将树形结构的选项数据转换为一维数组
- * 用于根据值快速查找对应的选项信息
- */
- const flatOptions = computed(() => {
- const data = props.options;
- const arr = [] as ClListViewItem[];
- /**
- * 深度遍历树形数据,将所有节点添加到扁平数组中
- * @param list 当前层级的选项列表
- */
- function deep(list: ClListViewItem[]) {
- list.forEach((e) => {
- // 将当前项添加到扁平数组
- arr.push(e);
- // 如果有子级,递归处理
- if (e.children != null) {
- deep(e.children!);
- }
- });
- }
- // 开始深度遍历
- deep(data);
- return arr;
- });
- /**
- * 当前选中项的标签数组
- * 根据选中的值获取对应的显示标签
- * 用于在弹窗顶部显示选择路径
- */
- const labels = computed(() => {
- const arr = value.value.map((v, i) => {
- // 在对应级别的选项中查找匹配的项,返回其标签
- return list.value[i].find((e) => e[props.valueKey] == v)?.[props.labelKey] ?? "";
- });
- if (isNext.value && !isEmpty(flatOptions.value)) {
- arr.push(t("请选择"));
- }
- return arr;
- });
- /**
- * 触发器显示的文本
- * 将选中的值转换为对应的标签,用 " - " 连接
- * 例如:北京 - 朝阳区 - 三里屯街道
- */
- const text = computed(() => {
- return props.modelValue
- .map((v) => {
- // 在扁平化数据中查找对应的选项,获取其标签
- return flatOptions.value.find((e) => e[props.valueKey] == v)?.[props.labelKey] ?? "";
- })
- .join(props.textSeparator);
- });
- /**
- * 选择器弹窗显示状态
- * 控制弹窗的打开和关闭
- */
- const visible = ref(false);
- /**
- * 打开选择器弹窗
- * 检查禁用状态,如果未禁用则显示弹窗
- */
- function open() {
- visible.value = true;
- }
- /**
- * 关闭选择器弹窗
- * 直接设置弹窗为隐藏状态
- */
- function close() {
- visible.value = false;
- }
- /**
- * 重置选择器
- */
- function reset() {
- // 重置当前级别索引
- current.value = 0;
- // 清空临时选中的值
- value.value = [];
- // 重置下一级状态
- isNext.value = true;
- }
- /**
- * 弹窗关闭完成后的回调
- * 重置所有临时状态,为下次打开做准备
- */
- function onClosed() {
- reset();
- }
- /**
- * 清空选择器的值
- * 重置所有状态并触发相关事件
- */
- function clear() {
- reset();
- // 触发值更新事件
- emit("update:modelValue", value.value);
- emit("change", value.value);
- }
- /**
- * 处理选项点击事件
- * 根据点击的选项更新选中状态,如果是叶子节点则完成选择并关闭弹窗
- *
- * @param item 被点击的选项数据
- */
- function onItemTap(item: ClListViewItem) {
- // 如果选项没有值,直接返回
- if (item[props.valueKey] == null) {
- return;
- }
- // 在当前级别的数据中查找对应的完整选项信息
- const data = list.value[current.value].find((e) => e[props.valueKey] == item[props.valueKey]);
- // 截取当前级别之前的值,清除后续级别的选择
- value.value = value.value.slice(0, current.value);
- // 添加当前选中的值
- value.value.push(item[props.valueKey]!);
- if (data != null) {
- // 判断是否为叶子节点(没有子级或子级为空)
- if (data.children == null || isEmpty(data.children!)) {
- // 关闭弹窗
- close();
- // 设置下一级状态为不可选
- isNext.value = false;
- // 选择完成
- emit("update:modelValue", value.value);
- emit("change", value.value);
- } else {
- // 还有下一级,继续选择
- isNext.value = true;
- nextTick(() => {
- current.value += 1; // 切换到下一级
- });
- }
- }
- }
- /**
- * 判断选项是否为当前激活状态
- * 用于高亮显示当前选中的选项
- *
- * @param index 当前级别索引
- * @param item 选项数据
- * @returns 是否为激活状态
- */
- function onItemActive(index: number, item: ClListViewItem) {
- // 如果没有选中任何值,则没有激活项
- if (isEmpty(value.value)) {
- return false;
- }
- // 如果索引超出选中值的长度,说明该级别没有选中项
- if (index >= value.value.length) {
- return false;
- }
- // 判断当前级别的选中值是否与该选项的值相匹配
- return value.value[index] == item[props.valueKey];
- }
- /**
- * 处理标签点击事件
- * 点击标签可以快速跳转到对应的级别
- *
- * @param index 要跳转到的级别索引
- */
- function onLabelTap(index: number) {
- current.value = index;
- }
- /**
- * 处理 swiper 组件的切换事件
- * 当用户滑动切换级别时同步更新当前级别索引
- *
- * @param e swiper 切换事件对象
- */
- function onSwiperChange(e: UniSwiperChangeEvent) {
- current.value = e.detail.current;
- }
- defineExpose({
- open,
- close,
- reset,
- clear
- });
- </script>
- <style lang="scss" scoped>
- .cl-select {
- &-popup {
- &__labels {
- @apply flex flex-row mb-3;
- padding: 0 20rpx;
- }
- &__list {
- @apply relative;
- }
- }
- }
- </style>
|