Cascader.vue 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. <template>
  2. <view class="nana-form-cascader">
  3. <Tabs
  4. :currentIndex="headerTabCurrent"
  5. :tabs="headerTabs"
  6. :autoItemWidth="false"
  7. :innerStyle="headerStyle"
  8. v-bind="tabProps"
  9. @update:currentIndex="handleTabChange"
  10. />
  11. <slot name="header" :currentIndex="headerTabCurrent" />
  12. <SimpleList
  13. mode="single-check"
  14. virtual
  15. :data="currentDataDynamicLoading ? [] : currentData"
  16. :dataDisplayProp="textKey"
  17. colorProp="color"
  18. disabledProp="disabled"
  19. :innerStyle="{
  20. width: '100%',
  21. height: themeContext.resolveThemeSize(props.listHeight),
  22. }"
  23. :checkedItems="[ currentCheckedItem ]"
  24. @itemClick="handleItemClick"
  25. >
  26. <template #empty>
  27. <LoadingPage v-if="currentDataDynamicLoading" />
  28. <Empty v-else :description="currentDataDynamicLoadErrorText || '暂无数据'" :image="currentDataDynamicLoadErrorText ? 'error' : 'default'" />
  29. </template>
  30. </SimpleList>
  31. </view>
  32. </template>
  33. <script setup lang="ts">
  34. import { computed, onMounted, ref, watch } from 'vue';
  35. import { useTheme } from '../theme/ThemeDefine';
  36. import Tabs, { type TabsItemData, type TabsProps } from '../nav/Tabs.vue';
  37. import SimpleList from '../list/SimpleList.vue';
  38. import { DynamicSize } from '../theme/ThemeTools';
  39. import LoadingPage from '../display/loading/LoadingPage.vue';
  40. import Empty from '../feedback/Empty.vue';
  41. import { getCascaderText } from './CascaderUtils';
  42. const themeContext = useTheme();
  43. const headerStyle = themeContext.useThemeStyle({
  44. marginLeft: DynamicSize('SimpleListItemPaddingHorizontal', 35),
  45. marginRight: DynamicSize('SimpleListItemPaddingHorizontal', 35),
  46. })
  47. export interface CascaderItem extends Record<string, any> {
  48. /**
  49. * 选项的文本
  50. */
  51. text: string;
  52. /**
  53. * 选项的值
  54. */
  55. value: string|number;
  56. /**
  57. * 选项是否禁用
  58. */
  59. disabled?: boolean;
  60. /**
  61. * 选项的颜色
  62. */
  63. color?: string;
  64. /**
  65. * 子选项
  66. */
  67. children?: CascaderItem[];
  68. }
  69. export interface CascaderProps {
  70. /**
  71. * 选中的值
  72. */
  73. modelValue: (number|string)[],
  74. /**
  75. * 数据
  76. */
  77. data: CascaderItem[];
  78. /**
  79. * 是否在选择完成后直接关闭。
  80. * @default true
  81. */
  82. autoConfirm?: boolean,
  83. /**
  84. * 列表的高度rpx
  85. * @default 700
  86. */
  87. listHeight?: number|string;
  88. /**
  89. * 选项的文本键名
  90. * @default 'text'
  91. */
  92. textKey?: string,
  93. /**
  94. * 选项的值键名
  95. * @default 'value'
  96. */
  97. valueKey?: string,
  98. /**
  99. * 选项的子选项键名
  100. * @default 'children'
  101. */
  102. childrenKey?: string,
  103. /**
  104. * Tab组件的自定义选项
  105. */
  106. tabProps?: Omit<TabsProps, 'tabs' | 'currentIndex'>,
  107. /**
  108. * 异步加载数据。
  109. * 如果你需要异步加载数据,当选项打开后并且 children 为空数组时,会调用该函数加载数据。
  110. * * 注:当一个条目的数据已加载后,它会缓存到 children 数组中再次点击不会再加载数据,若要刷新,可手动清空 children 数组。
  111. */
  112. asyncLoadData?: (group: CascaderItem, level: number) => Promise<CascaderItem[]>;
  113. /**
  114. * 最大选择层级。在异步加载数据时建议设置最大层级,否则将尝试每一级都继续加载数据。
  115. * @default undefined
  116. */
  117. maxSelectLevel?: number;
  118. }
  119. const emit = defineEmits([ 'update:modelValue', 'selectTextChange', 'pickEnd' ]);
  120. const props = withDefaults(defineProps<CascaderProps>(), {
  121. autoConfirm: true,
  122. listHeight: 700,
  123. tabProps: () => ({
  124. width: 750 - 70,
  125. }),
  126. textKey: 'text',
  127. valueKey: 'value',
  128. childrenKey: 'children',
  129. });
  130. const headerTabCurrent = ref(0);
  131. const headerTabs = computed<TabsItemData[]>(() => {
  132. const tabs: TabsItemData[] = [];
  133. let currentGroup : CascaderItem[]|undefined = props.data;
  134. let i = 0;
  135. for (; i < props.modelValue.length; i++) {
  136. const item : CascaderItem|undefined = currentGroup?.find(item => item[props.valueKey] === props.modelValue[i]);
  137. if (item) {
  138. tabs.push({
  139. text: item[props.textKey],
  140. data: item[props.valueKey],
  141. width: -1,
  142. })
  143. currentGroup = item[props.childrenKey];
  144. }
  145. }
  146. if ((currentGroup !== undefined && currentGroup.length > 0) || canLoadLevel(i)) {
  147. tabs.push({
  148. text: '请选择',
  149. data: null,
  150. width: -1,
  151. });
  152. }
  153. return tabs;
  154. });
  155. function canLoadLevel(level: number) {
  156. return (props.maxSelectLevel === undefined || level < props.maxSelectLevel) && props.asyncLoadData;
  157. }
  158. const currentDataDynamicLoading = ref(false);
  159. const currentDataDynamicLoadErrorText = ref('');
  160. const currentDataDynamicLoad = ref<CascaderItem[]>([]);
  161. const currentData = computed(() => {
  162. let currentGroup = props.data;
  163. for (let i = 0; i < headerTabCurrent.value; i++) {
  164. const item = currentGroup.find(item => item[props.valueKey] === props.modelValue[i]);
  165. if (item) {
  166. currentGroup = item[props.childrenKey] || [];
  167. if (currentGroup.length === 0 && props.asyncLoadData) {
  168. loadAsyncData(item);
  169. return currentDataDynamicLoad.value;
  170. }
  171. }
  172. }
  173. return currentGroup;
  174. });
  175. const currentCheckedItem = computed(() => {
  176. return currentData.value.find(item => item[props.valueKey] === props.modelValue[headerTabCurrent.value]);
  177. });
  178. function updateText(value: (number|string)[]) {
  179. emit('selectTextChange', getCascaderText(
  180. value,
  181. props.valueKey,
  182. props.textKey,
  183. props.childrenKey,
  184. props.data
  185. ));
  186. }
  187. async function loadAsyncData(group: CascaderItem) {
  188. if (props.asyncLoadData) {
  189. currentDataDynamicLoading.value = true;
  190. props.asyncLoadData(group, headerTabCurrent.value)
  191. .then(data => {
  192. group[props.childrenKey] = data;
  193. currentDataDynamicLoad.value = data;
  194. currentDataDynamicLoadErrorText.value = '';
  195. console.log(group);
  196. }).catch((e) => {
  197. console.error('asyncLoadData failed', e);
  198. currentDataDynamicLoadErrorText.value = '' + e;
  199. })
  200. .finally(() => {
  201. currentDataDynamicLoading.value = false;
  202. });
  203. }
  204. }
  205. function loadValue() {
  206. headerTabCurrent.value = Math.min(props.modelValue.length, headerTabs.value.length - 1);
  207. }
  208. function handleItemClick(item: CascaderItem) {
  209. let values = props.modelValue;
  210. if (values.length > headerTabCurrent.value)
  211. values = values.slice(0, headerTabCurrent.value);
  212. values = values.concat([item[props.valueKey]]);
  213. emit('update:modelValue', values);
  214. updateText(values);
  215. const nextGroup = item[props.childrenKey];
  216. if (
  217. (!nextGroup || nextGroup.length === 0)
  218. && !canLoadLevel(headerTabCurrent.value + 1)
  219. )
  220. emit('pickEnd');
  221. }
  222. function handleTabChange(v: number) {
  223. headerTabCurrent.value = v;
  224. }
  225. watch(() => props.modelValue, (v) => {
  226. loadValue();
  227. updateText(v);
  228. })
  229. onMounted(() => {
  230. loadValue();
  231. updateText(props.modelValue);
  232. })
  233. defineOptions({
  234. options: {
  235. styleIsolation: "shared",
  236. virtualHost: true,
  237. }
  238. })
  239. </script>
  240. <style lang="scss">
  241. .nana-form-cascader {
  242. position: relative;
  243. display: flex;
  244. flex-direction: column;
  245. }
  246. </style>