mao-mobile 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,123 @@
1
+ # ProDataPicker 级联选择
2
+
3
+ 一个级联选择组件,支持本地数据和远程数据加载。
4
+
5
+ ## 功能特点
6
+
7
+ - 支持多级数据选择
8
+ - 支持本地数据和远程数据加载
9
+ - 自定义主题色
10
+ - 本地数据支持数据回显
11
+ - 支持自定义数据属性映射
12
+ - 支持禁用选项
13
+ - 支持空数据状态展示
14
+
15
+ ## 基础用法
16
+
17
+ ```vue
18
+ <template>
19
+ <pro-data-picker
20
+ v-model="selectedValues"
21
+ :localdata="localData"
22
+ @change="handleChange"
23
+ />
24
+ </template>
25
+
26
+ <script setup>
27
+ import { ref } from "vue";
28
+
29
+ const selectedValues = ref([]);
30
+ const localData = ref([
31
+ {
32
+ text: "选项1",
33
+ value: "1",
34
+ children: [
35
+ {
36
+ text: "选项1-1",
37
+ value: "1-1",
38
+ },
39
+ ],
40
+ },
41
+ ]);
42
+
43
+ const handleChange = (value) => {
44
+ console.log("选择的值:", value);
45
+ };
46
+ </script>
47
+ ```
48
+
49
+ ## 远程数据加载
50
+
51
+ ```vue
52
+ <template>
53
+ <pro-data-picker
54
+ v-model="selectedValues"
55
+ :level="3"
56
+ :lazyLoad="fetchData"
57
+ @change="handleChange"
58
+ />
59
+ </template>
60
+
61
+ <script setup>
62
+ import { ref } from "vue";
63
+
64
+ const selectedValues = ref([]);
65
+
66
+ const fetchData = async (level, parent) => {
67
+ // 根据层级和父级数据获取下一级数据
68
+ const response = await fetch(
69
+ `/api/data?level=${level}&parent=${parent?.value}`
70
+ );
71
+ return response.data;
72
+ };
73
+
74
+ const handleChange = (value) => {
75
+ console.log("选择的值:", value);
76
+ };
77
+ </script>
78
+ ```
79
+
80
+ ## API
81
+
82
+ ### Props
83
+
84
+ | 参数 | 说明 | 类型 | 默认值 |
85
+ | ---------- | ------------------------ | -------- | --------- |
86
+ | modelValue | 选中的值(支持 v-model) | Array | [] |
87
+ | title | 弹出层标题 | String | '' |
88
+ | themeColor | 主题色 | String | '#0BC8C8' |
89
+ | localdata | 本地数据源 | Array | [] |
90
+ | dataprops | 数据属性映射 | Object | {} |
91
+ | level | 远程数据层级数 | Number | 3 |
92
+ | lazyLoad | 远程数据加载方法 | Function | null |
93
+
94
+ ### dataprops 配置项
95
+
96
+ | 参数 | 说明 | 类型 | 默认值 |
97
+ | -------------- | --------------- | ------ | ---------- |
98
+ | text | 选项文本字段名 | String | 'text' |
99
+ | value | 选项值字段名 | String | 'value' |
100
+ | children | 子选项字段名 | String | 'children' |
101
+ | remark | 备注字段名 | String | 'remark' |
102
+ | emptyText | 空数据提示文本 | String | '暂无数据' |
103
+ | `level${n}Tab` | 第 n 级标签文本 | String | '请选择' |
104
+
105
+ ### Events
106
+
107
+ | 事件名 | 说明 | 回调参数 |
108
+ | ----------------- | ---------------- | -------------- |
109
+ | update:modelValue | 选中值变化时触发 | (value: Array) |
110
+ | change | 选择完成时触发 | (value: Array) |
111
+
112
+ ### 方法
113
+
114
+ | 方法名 | 说明 | 参数 |
115
+ | ------ | ---------- | ---- |
116
+ | open | 打开选择器 | - |
117
+
118
+ ## 注意事项
119
+
120
+ 1. 使用本地数据时,需要提供符合数据结构的 `localdata` 数组
121
+ 2. 使用远程数据时,需要提供 `lazyLoad` 方法来加载数据
122
+ 3. 可以通过 `dataprops` 配置项来自定义数据字段的映射关系
123
+ 4. 选择器的值格式为数组,每个元素包含 `name`、`value` 和 `flag` 属性
@@ -0,0 +1,505 @@
1
+ <template>
2
+ <view>
3
+ <pro-popup
4
+ ref="dataPickerPopupRef"
5
+ position="bottom"
6
+ :title="title"
7
+ :is-show-header="true"
8
+ @change="handlePopupChange"
9
+ >
10
+ <view class="pro-data-picker">
11
+ <view class="pro-data-picker__tabs">
12
+ <view
13
+ v-for="item in tabs"
14
+ :key="item.flag"
15
+ :class="[
16
+ 'pro-data-picker__tab',
17
+ flag === item.flag
18
+ ? 'pro-data-picker__tab--active'
19
+ : 'pro-data-picker__tab--default',
20
+ ]"
21
+ @tap="handleTabClick(item)"
22
+ >
23
+ {{ item.name }}
24
+ <view v-if="flag === item.flag" class="pro-data-picker__tab-line" />
25
+ </view>
26
+ </view>
27
+ <view class="pro-data-picker__content">
28
+ <view v-if="currentList.length">
29
+ <view
30
+ v-for="item in currentList"
31
+ :key="getItemValue(item)"
32
+ :class="[
33
+ 'pro-data-picker__item',
34
+ isSelected(item)
35
+ ? 'pro-data-picker__item--active'
36
+ : 'pro-data-picker__item--default',
37
+ item.disable ? 'pro-data-picker__item--disabled' : '',
38
+ ]"
39
+ @tap="!item.disable && handleItemClick(item)"
40
+ >
41
+ {{ getItemText(item) }}
42
+ <text v-if="isSelected(item)" class="pro-data-picker__item-check"
43
+ >✔</text
44
+ >
45
+ <text v-else class="pro-data-picker__item-remark">{{
46
+ getItemRemark(item)
47
+ }}</text>
48
+ </view>
49
+ </view>
50
+ <view
51
+ class="pro-data-picker__item-empty"
52
+ v-if="!currentList.length && !loading"
53
+ >
54
+ <image
55
+ class="pro-data-picker__item-empty-img"
56
+ mode="heightFix"
57
+ src="https://static.wxb.com.cn/frontEnd/images/ideacome-mobile/data-picker-empty.png"
58
+ ></image>
59
+ <text class="pro-data-picker__item-empty-text">{{
60
+ mergeddataprops.emptyText
61
+ }}</text>
62
+ </view>
63
+ </view>
64
+ </view>
65
+ </pro-popup>
66
+ </view>
67
+ </template>
68
+
69
+ <script setup>
70
+ import { ref, computed } from "vue";
71
+ import ProPopup from "../ProPopup/index.vue";
72
+
73
+ const props = defineProps({
74
+ // 弹出层标题
75
+ title: {
76
+ type: String,
77
+ default: "",
78
+ },
79
+ // 默认选中的值
80
+ modelValue: {
81
+ type: Array,
82
+ default: () => [],
83
+ },
84
+ // 主题色
85
+ themeColor: {
86
+ type: String,
87
+ default: "#0BC8C8",
88
+ },
89
+ // 本地数据源
90
+ localdata: {
91
+ type: Array,
92
+ default: () => [],
93
+ },
94
+ // 数据属性映射
95
+ dataprops: {
96
+ type: Object,
97
+ default: () => ({}),
98
+ },
99
+ // 远程数据层级数
100
+ level: {
101
+ type: Number,
102
+ default: 3,
103
+ },
104
+ // 远程数据加载方法
105
+ lazyLoad: {
106
+ type: Function,
107
+ default: null,
108
+ },
109
+ });
110
+
111
+ const emit = defineEmits(["update:modelValue", "change"]);
112
+
113
+ // 弹窗引用
114
+ const dataPickerPopupRef = ref(null);
115
+ // 当前选中的值
116
+ const selectedValues = ref([]);
117
+ // 当前激活的标签页
118
+ const flag = ref(0);
119
+ // 标签页配置
120
+ const tabs = ref([]);
121
+ // 数据列表
122
+ const dataList = ref([]);
123
+ const levelLists = ref([]);
124
+ const loading = ref(false);
125
+
126
+ // 合并后的数据属性映射
127
+ const mergeddataprops = computed(() => ({
128
+ text: "text",
129
+ value: "value",
130
+ children: "children",
131
+ remark: "remark",
132
+ emptyText: "暂无数据",
133
+ ...props.dataprops,
134
+ }));
135
+
136
+ // 获取选项文本
137
+ const getItemText = (item) => item[mergeddataprops.value.text];
138
+
139
+ // 获取选项备注
140
+ const getItemRemark = (item) => item[mergeddataprops.value.remark];
141
+
142
+ // 获取选项值
143
+ const getItemValue = (item) => item[mergeddataprops.value.value];
144
+
145
+ // 获取子选项
146
+ const getItemChildren = (item) => item[mergeddataprops.value.children] || [];
147
+
148
+ // 当前显示的数据列表
149
+ const currentList = computed(() =>
150
+ props.localdata.length ? dataList.value : levelLists.value[flag.value] || []
151
+ );
152
+
153
+ // 判断是否选中
154
+ const isSelected = (item) =>
155
+ selectedValues.value.some(
156
+ (val) => val.value === getItemValue(item) && val.flag === flag.value
157
+ );
158
+
159
+ // 初始化标签页
160
+ const initTabs = () => {
161
+ const tabCount = props.localdata.length
162
+ ? getMaxLevel(props.localdata)
163
+ : props.level;
164
+ tabs.value = Array.from({ length: tabCount }, (_, index) => ({
165
+ flag: index,
166
+ name: mergeddataprops.value[`level${index + 1}Tab`] || "请选择",
167
+ }));
168
+
169
+ if (!props.localdata.length) {
170
+ levelLists.value = Array.from({ length: tabCount }, () => []);
171
+ }
172
+ };
173
+
174
+ // 获取最大层级
175
+ const getMaxLevel = (data, level = 1) => {
176
+ return Math.max(
177
+ level,
178
+ ...data.map((item) => {
179
+ const children = getItemChildren(item);
180
+ return children.length ? getMaxLevel(children, level + 1) : level;
181
+ })
182
+ );
183
+ };
184
+
185
+ // 更新标签页名称
186
+ const updateTabNames = () => {
187
+ tabs.value = tabs.value.map((tab, index) => {
188
+ const selected = selectedValues.value.find((val) => val.flag === index);
189
+ return {
190
+ ...tab,
191
+ name: selected
192
+ ? selected.name
193
+ : mergeddataprops.value[`level${index + 1}Tab`] || "请选择",
194
+ };
195
+ });
196
+ };
197
+
198
+ // 处理选项点击
199
+ const handleItemClick = (item) => {
200
+ const currentFlag = flag.value;
201
+ const newValue = {
202
+ name: getItemText(item),
203
+ value: getItemValue(item),
204
+ flag: currentFlag,
205
+ };
206
+
207
+ const index = selectedValues.value.findIndex(
208
+ (val) => val.flag === currentFlag
209
+ );
210
+ if (index > -1) {
211
+ selectedValues.value[index] = newValue;
212
+ } else {
213
+ selectedValues.value.push(newValue);
214
+ }
215
+
216
+ // 清空下级选择
217
+ selectedValues.value = selectedValues.value.filter(
218
+ (val) => val.flag <= currentFlag
219
+ );
220
+ updateTabNames();
221
+
222
+ // 判断是否需要加载下一级数据
223
+ if (currentFlag < tabs.value.length - 1) {
224
+ if (props.localdata.length) {
225
+ const children = getItemChildren(item);
226
+ if (children.length) {
227
+ dataList.value = children;
228
+ flag.value++;
229
+ } else {
230
+ completeSelection();
231
+ }
232
+ } else {
233
+ fetchRegionList(item);
234
+ flag.value++;
235
+ }
236
+ } else {
237
+ completeSelection();
238
+ }
239
+ };
240
+
241
+ // 完成选择
242
+ const completeSelection = () => {
243
+ emit("update:modelValue", selectedValues.value);
244
+ emit("change", selectedValues.value);
245
+ handleClose();
246
+ };
247
+
248
+ const resetState = () => {
249
+ // 使用setTimeout等待动画结束后再重置数据
250
+ setTimeout(() => {
251
+ selectedValues.value = [];
252
+ dataList.value = [];
253
+ levelLists.value = [];
254
+ flag.value = 0;
255
+ tabs.value = [];
256
+ loading.value = false;
257
+ }, 300);
258
+ };
259
+
260
+ // 关闭弹窗
261
+ const handleClose = () => {
262
+ dataPickerPopupRef.value.close();
263
+ };
264
+
265
+ // 打开弹窗
266
+ const open = () => {
267
+ initTabs();
268
+
269
+ if (props.localdata.length) {
270
+ // 使用本地数据
271
+ dataList.value = props.localdata;
272
+
273
+ // 如果有 modelValue,进行回显处理
274
+ if (props.modelValue && props.modelValue.length) {
275
+ selectedValues.value = [...props.modelValue];
276
+
277
+ // 更新标签页名称
278
+ tabs.value = tabs.value.map((tab, index) => {
279
+ const selected = props.modelValue.find((val) => val.flag === index);
280
+ return {
281
+ ...tab,
282
+ name: selected
283
+ ? selected.name
284
+ : mergeddataprops.value[`level${index + 1}Tab`] || "请选择",
285
+ };
286
+ });
287
+
288
+ // 设置当前显示的数据列表和 flag
289
+ let currentData = props.localdata;
290
+ let lastFlag = 0;
291
+
292
+ // 遍历已选择的值,找到对应的数据层级
293
+ for (let i = 0; i < props.modelValue.length; i++) {
294
+ const selected = props.modelValue[i];
295
+ const found = currentData.find(
296
+ (item) => getItemValue(item) === selected.value
297
+ );
298
+ if (found) {
299
+ lastFlag = i;
300
+ if (i === props.modelValue.length - 1) {
301
+ // 如果是最后一级,显示当前数据
302
+ dataList.value = currentData;
303
+ } else {
304
+ // 否则继续查找下一级
305
+ currentData = getItemChildren(found);
306
+ }
307
+ }
308
+ }
309
+
310
+ // 设置 flag 到最后一级
311
+ flag.value = lastFlag;
312
+ }
313
+ } else {
314
+ // 使用接口数据
315
+ flag.value = 0;
316
+ selectedValues.value = [];
317
+ fetchRegionList();
318
+ }
319
+
320
+ dataPickerPopupRef.value.open();
321
+ };
322
+
323
+ // 获取地区数据
324
+ const fetchRegionList = async (item) => {
325
+ if (!props.lazyLoad) {
326
+ console.error("未提供数据加载方法");
327
+ return;
328
+ }
329
+
330
+ try {
331
+ loading.value = true;
332
+ const list = await props.lazyLoad(flag.value, item);
333
+ loading.value = false;
334
+ levelLists.value[flag.value] = list;
335
+ } catch (error) {
336
+ console.error("获取数据失败:", error);
337
+ }
338
+ };
339
+
340
+ // 处理标签页点击
341
+ const handleTabClick = (item) => {
342
+ if (item.flag === 0) {
343
+ flag.value = item.flag;
344
+ if (props.localdata.length) {
345
+ dataList.value = props.localdata;
346
+ }
347
+ return;
348
+ }
349
+
350
+ const prevSelected = selectedValues.value.find(
351
+ (val) => val.flag === item.flag - 1
352
+ );
353
+ if (prevSelected) {
354
+ if (props.localdata.length) {
355
+ // 获取上一级选中项的children作为当前列表
356
+ const prevItem = findItemByValue(prevSelected.value);
357
+ if (prevItem?.child.length) {
358
+ flag.value = item.flag;
359
+ dataList.value = getItemChildren(prevItem);
360
+ } else {
361
+ uni.showToast({
362
+ title: "当前项没有下级",
363
+ icon: "none",
364
+ });
365
+ }
366
+ } else {
367
+ flag.value = item.flag;
368
+ }
369
+ } else {
370
+ uni.showToast({
371
+ title: "请先选择上一级",
372
+ icon: "none",
373
+ });
374
+ }
375
+ };
376
+
377
+ // 根据值查找选项
378
+ const findItemByValue = (value, list = props.localdata) => {
379
+ for (const item of list) {
380
+ if (getItemValue(item) === value) {
381
+ return item;
382
+ }
383
+ const children = getItemChildren(item);
384
+ if (children.length) {
385
+ const found = findItemByValue(value, children);
386
+ if (found) return found;
387
+ }
388
+ }
389
+ return null;
390
+ };
391
+
392
+ // 处理弹窗状态变化
393
+ const handlePopupChange = (e) => {
394
+ if (!e.show) {
395
+ resetState();
396
+ }
397
+ };
398
+
399
+ defineExpose({
400
+ open,
401
+ });
402
+ </script>
403
+
404
+ <style lang="scss" scoped>
405
+ .pro-data-picker {
406
+ font-family: PingFang SC, sans-serif;
407
+ height: calc(100vh * 0.618 - 174rpx);
408
+ display: flex;
409
+ flex-direction: column;
410
+ box-sizing: border-box;
411
+ overflow: hidden;
412
+
413
+ &__tabs {
414
+ flex-shrink: 0;
415
+ padding: 0 8rpx;
416
+ height: 60rpx;
417
+ display: flex;
418
+ align-items: center;
419
+ justify-content: space-between;
420
+ }
421
+
422
+ &__tab {
423
+ flex: 1;
424
+ position: relative;
425
+ padding: 0 4rpx 24rpx 4rpx;
426
+ font-size: 26rpx;
427
+ line-height: 36rpx;
428
+ text-align: center;
429
+ text-overflow: ellipsis;
430
+ white-space: nowrap;
431
+ overflow: hidden;
432
+
433
+ &--active {
434
+ font-weight: 500;
435
+ color: v-bind("themeColor");
436
+ }
437
+
438
+ &--default {
439
+ color: #666;
440
+ }
441
+ }
442
+
443
+ &__tab-line {
444
+ position: absolute;
445
+ bottom: 0;
446
+ left: 50%;
447
+ transform: translateX(-50%);
448
+ width: 116rpx;
449
+ height: 6rpx;
450
+ background: v-bind("themeColor");
451
+ }
452
+
453
+ &__content {
454
+ flex: 1;
455
+ margin-top: 32rpx;
456
+ padding: 0 12rpx;
457
+ overflow-y: auto;
458
+ }
459
+
460
+ &__item {
461
+ display: flex;
462
+ justify-content: space-between;
463
+ align-items: center;
464
+ padding: 24rpx 0;
465
+ font-size: 28rpx;
466
+ line-height: 40rpx;
467
+
468
+ &--active {
469
+ color: v-bind("themeColor");
470
+ }
471
+
472
+ &--default {
473
+ color: #212121;
474
+ }
475
+
476
+ &--disabled {
477
+ color: #888;
478
+ cursor: not-allowed;
479
+ }
480
+
481
+ &-check {
482
+ color: v-bind("themeColor");
483
+ }
484
+ &-empty {
485
+ padding-top: 114rpx;
486
+ display: flex;
487
+ flex-direction: column;
488
+ align-items: center;
489
+ &-img {
490
+ height: 180rpx;
491
+ }
492
+ &-text {
493
+ margin-top: 18rpx;
494
+ font-size: 32rpx;
495
+ color: #666666;
496
+ line-height: 44rpx;
497
+ }
498
+ }
499
+ }
500
+
501
+ &__item-remark {
502
+ color: #888888;
503
+ }
504
+ }
505
+ </style>