Cascader.vue 7.0 KB

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