cl-cascader.uvue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. <template>
  2. <cl-select-trigger
  3. v-if="showTrigger"
  4. :pt="ptTrigger"
  5. :placeholder="placeholder"
  6. :disabled="disabled"
  7. :focus="popupRef?.isOpen"
  8. :text="text"
  9. @open="open"
  10. @clear="clear"
  11. ></cl-select-trigger>
  12. <cl-popup ref="popupRef" v-model="visible" :title="title" :pt="ptPopup" @closed="onClosed">
  13. <view class="cl-select-popup" @touchmove.stop>
  14. <view class="cl-select-popup__labels">
  15. <cl-tag
  16. v-for="(item, index) in labels"
  17. :key="index"
  18. :type="index != current ? 'info' : 'primary'"
  19. plain
  20. @tap="onLabelTap(index)"
  21. >
  22. {{ item }}
  23. </cl-tag>
  24. </view>
  25. <view
  26. class="cl-select-popup__list"
  27. :style="{
  28. height: getUnit(height)
  29. }"
  30. >
  31. <swiper
  32. v-if="isMp() ? popupRef?.isOpen : true"
  33. class="h-full bg-transparent"
  34. :current="current"
  35. :disable-touch="disableTouch"
  36. @change="onSwiperChange"
  37. >
  38. <swiper-item
  39. v-for="(data, index) in list"
  40. :key="index"
  41. class="h-full bg-transparent"
  42. >
  43. <cl-list-view
  44. :data="data"
  45. :item-height="45"
  46. :virtual="!isMp()"
  47. @item-tap="onItemTap"
  48. >
  49. <template #item="{ data, item }">
  50. <view
  51. class="flex flex-row items-center justify-between w-full px-[10px]"
  52. :class="{
  53. 'bg-primary-50': onItemActive(index, data),
  54. 'bg-surface-800': isDark && onItemActive(index, data)
  55. }"
  56. :style="{
  57. height: item.height + 'px'
  58. }"
  59. >
  60. <cl-text
  61. :pt="{
  62. className: parseClass({
  63. 'text-primary-500': onItemActive(index, data)
  64. })
  65. }"
  66. >{{ data[labelKey] }}</cl-text
  67. >
  68. </view>
  69. </template>
  70. </cl-list-view>
  71. </swiper-item>
  72. </swiper>
  73. </view>
  74. </view>
  75. </cl-popup>
  76. </template>
  77. <script setup lang="ts">
  78. import { ref, computed, type PropType, nextTick } from "vue";
  79. import {
  80. isDark,
  81. isEmpty,
  82. isMp,
  83. isNull,
  84. parseClass,
  85. parsePt,
  86. parseToObject,
  87. getUnit,
  88. t
  89. } from "@/.cool";
  90. import type { ClSelectTriggerPassThrough } from "../cl-select-trigger/props";
  91. import type { ClPopupPassThrough } from "../cl-popup/props";
  92. import type { ClListViewItem } from "../../types";
  93. defineOptions({
  94. name: "cl-cascader"
  95. });
  96. /**
  97. * 组件属性定义
  98. * 定义级联选择器组件的所有可配置属性
  99. */
  100. const props = defineProps({
  101. /**
  102. * 透传样式配置
  103. * 用于自定义组件各部分的样式,支持嵌套配置
  104. * 可配置:trigger(触发器)、popup(弹窗)等部分的样式
  105. */
  106. pt: {
  107. type: Object,
  108. default: () => ({})
  109. },
  110. /**
  111. * 选择器的值 - v-model绑定
  112. * 数组形式,按层级顺序存储选中的值
  113. * 例如:["province", "city", "district"] 表示选中了省市区三级
  114. */
  115. modelValue: {
  116. type: Array as PropType<string[]>,
  117. default: () => []
  118. },
  119. /**
  120. * 选择器弹窗标题
  121. * 显示在弹窗顶部的标题文字
  122. */
  123. title: {
  124. type: String,
  125. default: () => t("请选择")
  126. },
  127. /**
  128. * 选择器占位符文本
  129. * 当没有选中任何值时显示的提示文字
  130. */
  131. placeholder: {
  132. type: String,
  133. default: () => t("请选择")
  134. },
  135. /**
  136. * 选项数据源,支持树形结构
  137. * 每个选项需包含 labelKey 和 valueKey 指定的字段
  138. * 如果有子级,需包含 children 字段
  139. */
  140. options: {
  141. type: Array as PropType<ClListViewItem[]>,
  142. default: () => []
  143. },
  144. /**
  145. * 是否显示选择器触发器
  146. * 设为 false 时可以通过编程方式控制弹窗显示
  147. */
  148. showTrigger: {
  149. type: Boolean,
  150. default: true
  151. },
  152. /**
  153. * 是否禁用选择器
  154. * 禁用状态下无法点击触发器打开弹窗
  155. */
  156. disabled: {
  157. type: Boolean,
  158. default: false
  159. },
  160. /**
  161. * 标签显示字段的键名
  162. * 指定从数据项的哪个字段读取显示文字
  163. */
  164. labelKey: {
  165. type: String,
  166. default: "label"
  167. },
  168. /**
  169. * 值字段的键名
  170. * 指定从数据项的哪个字段读取实际值
  171. */
  172. valueKey: {
  173. type: String,
  174. default: "label"
  175. },
  176. /**
  177. * 文本分隔符
  178. * 用于连接多级标签的文本
  179. */
  180. textSeparator: {
  181. type: String,
  182. default: " - "
  183. },
  184. /**
  185. * 列表高度
  186. */
  187. height: {
  188. type: [String, Number],
  189. default: 400
  190. }
  191. });
  192. /**
  193. * 定义组件事件
  194. * 向父组件发射的事件列表
  195. */
  196. const emit = defineEmits(["update:modelValue", "change"]);
  197. /**
  198. * 弹出层组件的引用
  199. * 用于调用弹出层的方法,如打开、关闭等
  200. */
  201. const popupRef = ref<ClPopupComponentPublicInstance | null>(null);
  202. /**
  203. * 透传样式类型定义
  204. * 定义可以透传给子组件的样式配置结构
  205. */
  206. type PassThrough = {
  207. trigger?: ClSelectTriggerPassThrough; // 触发器样式配置
  208. popup?: ClPopupPassThrough; // 弹窗样式配置
  209. };
  210. /**
  211. * 解析透传样式配置
  212. * 将传入的样式配置按照指定类型进行解析和处理
  213. */
  214. const pt = computed(() => parsePt<PassThrough>(props.pt));
  215. // 解析触发器透传样式配置
  216. const ptTrigger = computed(() => parseToObject(pt.value.trigger));
  217. // 解析弹窗透传样式配置
  218. const ptPopup = computed(() => parseToObject(pt.value.popup));
  219. /**
  220. * 当前显示的级联层级索引
  221. * 用于控制 swiper 组件显示哪一级的选项列表
  222. */
  223. const current = ref(0);
  224. /**
  225. * 是否还有下一级可选
  226. * 当选中项没有子级时设为 false,表示选择完成
  227. */
  228. const isNext = ref(true);
  229. /**
  230. * 当前临时选中的值数组
  231. * 存储用户在弹窗中正在选择的值,确认后才会更新到 modelValue
  232. */
  233. const value = ref<any[]>([]);
  234. /**
  235. * 级联选择的数据列表
  236. * 根据当前选中的值生成多级选项数据数组
  237. * 返回二维数组,第一维是级别,第二维是该级别的选项
  238. *
  239. * 计算逻辑:
  240. * 1. 如果没有选中任何值,返回根级选项
  241. * 2. 根据已选中的值,逐级查找对应的子级选项
  242. * 3. 最终返回所有级别的选项数据
  243. */
  244. const list = computed<ClListViewItem[][]>(() => {
  245. let data = props.options;
  246. // 如果没有选中任何值,直接返回根级选项
  247. if (isEmpty(value.value)) {
  248. return [data];
  249. }
  250. // 根据选中的值逐级构建选项数据
  251. const arr = value.value.map((v) => {
  252. // 在当前级别中查找选中的项
  253. const item = data.find((e) => e[props.valueKey] == v);
  254. if (item == null) {
  255. return [];
  256. }
  257. // 如果找到的项有子级,更新data为子级数据
  258. if (!isNull(item.children)) {
  259. data = item.children ?? [];
  260. }
  261. return data as ClListViewItem[];
  262. });
  263. // 返回根级选项 + 各级子选项
  264. return [props.options, ...arr];
  265. });
  266. /**
  267. * 扁平化的选项数据
  268. * 将树形结构的选项数据转换为一维数组
  269. * 用于根据值快速查找对应的选项信息
  270. */
  271. const flatOptions = computed(() => {
  272. const data = props.options;
  273. const arr = [] as ClListViewItem[];
  274. /**
  275. * 深度遍历树形数据,将所有节点添加到扁平数组中
  276. * @param list 当前层级的选项列表
  277. */
  278. function deep(list: ClListViewItem[]) {
  279. list.forEach((e) => {
  280. // 将当前项添加到扁平数组
  281. arr.push(e);
  282. // 如果有子级,递归处理
  283. if (e.children != null) {
  284. deep(e.children!);
  285. }
  286. });
  287. }
  288. // 开始深度遍历
  289. deep(data);
  290. return arr;
  291. });
  292. /**
  293. * 当前选中项的标签数组
  294. * 根据选中的值获取对应的显示标签
  295. * 用于在弹窗顶部显示选择路径
  296. */
  297. const labels = computed(() => {
  298. const arr = value.value.map((v, i) => {
  299. // 在对应级别的选项中查找匹配的项,返回其标签
  300. return list.value[i].find((e) => e[props.valueKey] == v)?.[props.labelKey] ?? "";
  301. });
  302. if (isNext.value && !isEmpty(flatOptions.value)) {
  303. arr.push(t("请选择"));
  304. }
  305. return arr;
  306. });
  307. /**
  308. * 触发器显示的文本
  309. * 将选中的值转换为对应的标签,用 " - " 连接
  310. * 例如:北京 - 朝阳区 - 三里屯街道
  311. */
  312. const text = computed(() => {
  313. return props.modelValue
  314. .map((v) => {
  315. // 在扁平化数据中查找对应的选项,获取其标签
  316. return flatOptions.value.find((e) => e[props.valueKey] == v)?.[props.labelKey] ?? "";
  317. })
  318. .join(props.textSeparator);
  319. });
  320. /**
  321. * 选择器弹窗显示状态
  322. * 控制弹窗的打开和关闭
  323. */
  324. const visible = ref(false);
  325. /**
  326. * 打开选择器弹窗
  327. * 检查禁用状态,如果未禁用则显示弹窗
  328. */
  329. function open() {
  330. visible.value = true;
  331. }
  332. /**
  333. * 关闭选择器弹窗
  334. * 直接设置弹窗为隐藏状态
  335. */
  336. function close() {
  337. visible.value = false;
  338. }
  339. /**
  340. * 重置选择器
  341. */
  342. function reset() {
  343. // 重置当前级别索引
  344. current.value = 0;
  345. // 清空临时选中的值
  346. value.value = [];
  347. // 重置下一级状态
  348. isNext.value = true;
  349. }
  350. /**
  351. * 弹窗关闭完成后的回调
  352. * 重置所有临时状态,为下次打开做准备
  353. */
  354. function onClosed() {
  355. reset();
  356. }
  357. /**
  358. * 清空选择器的值
  359. * 重置所有状态并触发相关事件
  360. */
  361. function clear() {
  362. reset();
  363. // 触发值更新事件
  364. emit("update:modelValue", value.value);
  365. emit("change", value.value);
  366. }
  367. /**
  368. * 是否禁用触摸
  369. */
  370. const disableTouch = ref(false);
  371. /**
  372. * 处理选项点击事件
  373. * 根据点击的选项更新选中状态,如果是叶子节点则完成选择并关闭弹窗
  374. *
  375. * @param item 被点击的选项数据
  376. */
  377. function onItemTap(item: ClListViewItem) {
  378. if (disableTouch.value == true) {
  379. return;
  380. }
  381. // 处理选项点击事件的主逻辑,防止重复点击,确保级联选择流程正确
  382. disableTouch.value = true;
  383. // 设置新的定时器
  384. setTimeout(() => {
  385. disableTouch.value = false;
  386. }, 300);
  387. // 如果选项没有值,直接返回
  388. if (item[props.valueKey] == null) {
  389. return;
  390. }
  391. // 在当前级别的数据中查找对应的完整选项信息
  392. const data = list.value[current.value].find((e) => e[props.valueKey] == item[props.valueKey]);
  393. // 截取当前级别之前的值,清除后续级别的选择
  394. value.value = value.value.slice(0, current.value);
  395. // 添加当前选中的值
  396. value.value.push(item[props.valueKey]!);
  397. if (data != null) {
  398. // 判断是否为叶子节点(没有子级或子级为空)
  399. if (data.children == null || isEmpty(data.children!)) {
  400. // 关闭弹窗
  401. close();
  402. // 设置下一级状态为不可选
  403. isNext.value = false;
  404. // 选择完成
  405. emit("update:modelValue", value.value);
  406. emit("change", value.value);
  407. } else {
  408. // 还有下一级,继续选择
  409. isNext.value = true;
  410. nextTick(() => {
  411. current.value += 1; // 切换到下一级
  412. });
  413. }
  414. }
  415. }
  416. /**
  417. * 判断选项是否为当前激活状态
  418. * 用于高亮显示当前选中的选项
  419. *
  420. * @param index 当前级别索引
  421. * @param item 选项数据
  422. * @returns 是否为激活状态
  423. */
  424. function onItemActive(index: number, item: ClListViewItem) {
  425. // 如果没有选中任何值,则没有激活项
  426. if (isEmpty(value.value)) {
  427. return false;
  428. }
  429. // 如果索引超出选中值的长度,说明该级别没有选中项
  430. if (index >= value.value.length) {
  431. return false;
  432. }
  433. // 判断当前级别的选中值是否与该选项的值相匹配
  434. return value.value[index] == item[props.valueKey];
  435. }
  436. /**
  437. * 处理标签点击事件
  438. * 点击标签可以快速跳转到对应的级别
  439. *
  440. * @param index 要跳转到的级别索引
  441. */
  442. function onLabelTap(index: number) {
  443. current.value = index;
  444. }
  445. /**
  446. * 处理 swiper 组件的切换事件
  447. * 当用户滑动切换级别时同步更新当前级别索引
  448. *
  449. * @param e swiper 切换事件对象
  450. */
  451. function onSwiperChange(e: UniSwiperChangeEvent) {
  452. current.value = e.detail.current;
  453. }
  454. defineExpose({
  455. open,
  456. close,
  457. reset,
  458. clear
  459. });
  460. </script>
  461. <style lang="scss" scoped>
  462. .cl-select {
  463. &-popup {
  464. &__labels {
  465. @apply flex flex-row mb-3;
  466. padding: 0 10px;
  467. }
  468. &__list {
  469. @apply relative;
  470. }
  471. }
  472. }
  473. </style>