cl-cascader.uvue 11 KB

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