vite-uni-dev-tool 1.1.0 → 1.2.0

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.
Files changed (135) hide show
  1. package/README.md +8 -0
  2. package/dist/const.cjs +1 -1
  3. package/dist/const.d.ts +1 -0
  4. package/dist/const.d.ts.map +1 -1
  5. package/dist/const.js +1 -1
  6. package/dist/core.d.ts.map +1 -1
  7. package/dist/core.js +2 -2
  8. package/dist/i18n/locales/en.cjs +1 -1
  9. package/dist/i18n/locales/en.d.ts +5 -0
  10. package/dist/i18n/locales/en.d.ts.map +1 -1
  11. package/dist/i18n/locales/en.js +1 -1
  12. package/dist/i18n/locales/zh-Hans.cjs +1 -1
  13. package/dist/i18n/locales/zh-Hans.d.ts +5 -0
  14. package/dist/i18n/locales/zh-Hans.d.ts.map +1 -1
  15. package/dist/i18n/locales/zh-Hans.js +1 -1
  16. package/dist/modules/devIntercept/index.cjs +9 -9
  17. package/dist/modules/devIntercept/index.d.ts +1 -1
  18. package/dist/modules/devIntercept/index.d.ts.map +1 -1
  19. package/dist/modules/devIntercept/index.js +9 -9
  20. package/dist/modules/devStore/index.cjs +1 -1
  21. package/dist/modules/devStore/index.d.ts.map +1 -1
  22. package/dist/modules/devStore/index.js +1 -1
  23. package/dist/plugins/uniDevTool/transform/transformMain.cjs +3 -3
  24. package/dist/plugins/uniDevTool/transform/transformMain.d.ts +2 -1
  25. package/dist/plugins/uniDevTool/transform/transformMain.d.ts.map +1 -1
  26. package/dist/plugins/uniDevTool/transform/transformMain.js +3 -3
  27. package/dist/plugins/uniDevTool/transform/transformVue.cjs +31 -25
  28. package/dist/plugins/uniDevTool/transform/transformVue.d.ts +2 -1
  29. package/dist/plugins/uniDevTool/transform/transformVue.d.ts.map +1 -1
  30. package/dist/plugins/uniDevTool/transform/transformVue.js +30 -24
  31. package/dist/plugins/uniDevTool/uniDevTool.cjs +3 -3
  32. package/dist/plugins/uniDevTool/uniDevTool.d.ts +3 -1
  33. package/dist/plugins/uniDevTool/uniDevTool.d.ts.map +1 -1
  34. package/dist/plugins/uniDevTool/uniDevTool.js +3 -3
  35. package/dist/type.d.ts +3 -0
  36. package/dist/type.d.ts.map +1 -1
  37. package/dist/v3/DevTool/components/BluetoothList/BluetoothItem.vue +199 -199
  38. package/dist/v3/DevTool/components/BluetoothList/BluetoothTool.vue +730 -730
  39. package/dist/v3/DevTool/components/BluetoothList/index.vue +167 -167
  40. package/dist/v3/DevTool/components/CaptureScreen/index.vue +109 -109
  41. package/dist/v3/DevTool/components/ConsoleList/ConsoleItem.vue +230 -225
  42. package/dist/v3/DevTool/components/ConsoleList/RunJSInput.vue +247 -247
  43. package/dist/v3/DevTool/components/ConsoleList/index.vue +171 -171
  44. package/dist/v3/DevTool/components/ConsoleList/staticTips.ts +1145 -1145
  45. package/dist/v3/DevTool/components/DevToolTitle/index.vue +24 -24
  46. package/dist/v3/DevTool/components/DevToolWindow/DevToolOverlay.vue +197 -197
  47. package/dist/v3/DevTool/components/DevToolWindow/hooks/dataUtils.ts +48 -48
  48. package/dist/v3/DevTool/components/DevToolWindow/hooks/useDevToolData.ts +387 -387
  49. package/dist/v3/DevTool/components/DevToolWindow/hooks/useDevToolHandlers.ts +629 -629
  50. package/dist/v3/DevTool/components/DevToolWindow/hooks/useDevToolOverlay.ts +201 -197
  51. package/dist/v3/DevTool/components/ElEvent/ElEventItem.vue +105 -105
  52. package/dist/v3/DevTool/components/ElEvent/index.vue +106 -106
  53. package/dist/v3/DevTool/components/Instance/components/InstanceTreeNode.vue +265 -265
  54. package/dist/v3/DevTool/components/Instance/flatten.ts +226 -226
  55. package/dist/v3/DevTool/components/Instance/index.vue +94 -94
  56. package/dist/v3/DevTool/components/Instance/registry.ts +49 -49
  57. package/dist/v3/DevTool/components/Instance/transformTree.ts +375 -375
  58. package/dist/v3/DevTool/components/Instance/transformTreeCtx.ts +268 -268
  59. package/dist/v3/DevTool/components/Instance/typing.d.ts +43 -43
  60. package/dist/v3/DevTool/components/InstanceDetail/index.vue +485 -485
  61. package/dist/v3/DevTool/components/JsonDetail/index.vue +70 -70
  62. package/dist/v3/DevTool/components/NFCList/NFCItem.vue +112 -112
  63. package/dist/v3/DevTool/components/NFCList/NFCTool.vue +454 -454
  64. package/dist/v3/DevTool/components/NFCList/const.ts +56 -56
  65. package/dist/v3/DevTool/components/NFCList/index.vue +94 -94
  66. package/dist/v3/DevTool/components/NetworkList/InterceptConfig.vue +624 -624
  67. package/dist/v3/DevTool/components/NetworkList/InterceptItem.vue +140 -140
  68. package/dist/v3/DevTool/components/NetworkList/NetworkDetail.vue +287 -287
  69. package/dist/v3/DevTool/components/NetworkList/NetworkIntercept.vue +88 -88
  70. package/dist/v3/DevTool/components/NetworkList/NetworkItem.vue +163 -163
  71. package/dist/v3/DevTool/components/NetworkList/NetworkSend.vue +589 -589
  72. package/dist/v3/DevTool/components/NetworkList/const.ts +4 -4
  73. package/dist/v3/DevTool/components/NetworkList/hooks/useNetworkForm.ts +86 -86
  74. package/dist/v3/DevTool/components/NetworkList/index.vue +160 -160
  75. package/dist/v3/DevTool/components/NetworkList/utils.ts +101 -101
  76. package/dist/v3/DevTool/components/Performance/index.vue +498 -498
  77. package/dist/v3/DevTool/components/Performance/modules/PerformanceMetrics.vue +153 -153
  78. package/dist/v3/DevTool/components/Performance/modules/usePerformanceChart.ts +460 -460
  79. package/dist/v3/DevTool/components/Performance/modules/usePerformanceData.ts +258 -258
  80. package/dist/v3/DevTool/components/PiniaList/index.vue +93 -93
  81. package/dist/v3/DevTool/components/RunJS/index.vue +148 -148
  82. package/dist/v3/DevTool/components/ScanCodeList/ScanCodeItem.vue +97 -97
  83. package/dist/v3/DevTool/components/ScanCodeList/index.vue +100 -100
  84. package/dist/v3/DevTool/components/SettingButton/index.vue +45 -45
  85. package/dist/v3/DevTool/components/SettingList/index.vue +218 -218
  86. package/dist/v3/DevTool/components/SettingList/modules/SettingBarrage.vue +304 -304
  87. package/dist/v3/DevTool/components/SettingList/modules/SettingDevTool.vue +212 -212
  88. package/dist/v3/DevTool/components/SettingList/modules/SettingInfo.vue +157 -157
  89. package/dist/v3/DevTool/components/SettingList/modules/SettingLanguage.vue +74 -74
  90. package/dist/v3/DevTool/components/SettingList/modules/SettingLog.vue +230 -230
  91. package/dist/v3/DevTool/components/SettingList/typing.d.ts +2 -2
  92. package/dist/v3/DevTool/components/SourceCode/Line.vue +127 -127
  93. package/dist/v3/DevTool/components/SourceCode/parseCode.ts +609 -609
  94. package/dist/v3/DevTool/components/StorageList/index.vue +174 -174
  95. package/dist/v3/DevTool/components/TransferList/TransferDetail.vue +268 -268
  96. package/dist/v3/DevTool/components/VuexList/index.vue +84 -84
  97. package/dist/v3/DevTool/index.vue +1 -0
  98. package/dist/v3/components/AppTransition/index.vue +176 -176
  99. package/dist/v3/components/AutoSizer/index.vue +192 -192
  100. package/dist/v3/components/AutoSizer/index1.vue +184 -184
  101. package/dist/v3/components/AutoSizer/utils.ts +49 -49
  102. package/dist/v3/components/Barrage/BarrageItem.vue +137 -137
  103. package/dist/v3/components/Barrage/index.vue +202 -202
  104. package/dist/v3/components/CircularButton/index.vue +84 -84
  105. package/dist/v3/components/CustomSwiper/CustomSwiperItem.vue +49 -49
  106. package/dist/v3/components/CustomSwiper/index.vue +104 -104
  107. package/dist/v3/components/DevErrorBoundary/index.vue +380 -0
  108. package/dist/v3/components/Empty/index.vue +29 -29
  109. package/dist/v3/components/FilterSelect/index.vue +179 -179
  110. package/dist/v3/components/JsonPretty/components/Brackets/index.vue +27 -27
  111. package/dist/v3/components/JsonPretty/components/Carets/index.vue +59 -59
  112. package/dist/v3/components/JsonPretty/components/CheckController/index.vue +136 -136
  113. package/dist/v3/components/JsonPretty/components/TreeNode/index.vue +387 -387
  114. package/dist/v3/components/JsonPretty/hooks/useClipboard.ts +21 -21
  115. package/dist/v3/components/JsonPretty/hooks/useError.ts +21 -21
  116. package/dist/v3/components/JsonPretty/type.ts +127 -127
  117. package/dist/v3/components/JsonPretty/utils/index.ts +169 -169
  118. package/dist/v3/components/MovableContainer/index.vue +8 -4
  119. package/dist/v3/components/Pick/index.vue +322 -322
  120. package/dist/v3/components/Tag/index.vue +113 -113
  121. package/dist/v3/components/VirtualList/AutoSize.vue +40 -40
  122. package/dist/v3/components/VirtualList/index.vue +416 -416
  123. package/dist/v3/hooks/useBluetooth/index.ts +561 -561
  124. package/dist/v3/hooks/useContainerStyle.ts +153 -153
  125. package/dist/v3/hooks/useNFC/index.ts +107 -107
  126. package/dist/v3/hooks/useNFC/typing.d.ts +396 -396
  127. package/dist/v3/hooks/useNFC/useNFCAndroid.ts +966 -966
  128. package/dist/v3/hooks/useNFC/useNFCMpWeiXin.ts +812 -812
  129. package/dist/v3/hooks/useNFC/utils.ts +754 -754
  130. package/dist/v3/hooks/useRequest/index.ts +586 -586
  131. package/dist/v3/hooks/useRequest/utils.ts +267 -267
  132. package/dist/v3/hooks/useScanCode/index.ts +206 -206
  133. package/dist/v3/hooks/useWebsocket/index.ts +253 -253
  134. package/dist/v3/styles/theme.ts +12 -12
  135. package/package.json +9 -1
@@ -1,416 +1,416 @@
1
- <template>
2
- <view :class="['virtual-list-wrapper', className]" :style="wrapperStyle">
3
- <scroll-view
4
- scroll-y
5
- :scroll-x="scrollX && !isEdgeSwipe"
6
- class="virtual-list-scroll"
7
- :scroll-top="scrollTopVal"
8
- :scroll-with-animation="scrollWithAnimation"
9
- :scroll-into-view="scrollIntoView"
10
- @scroll="handleScroll"
11
- @touchstart="onTouchStart"
12
- @touchmove="onTouchMove"
13
- @touchend="onTouchEnd"
14
- @mousedown="onMouseDown">
15
- <!-- 滚动容器:统一负责撑开横向和纵向的滚动区域 -->
16
- <view class="virtual-list-container" :style="containerStyle">
17
- <!-- 实际渲染内容的列表块 -->
18
- <view class="virtual-list-content" :style="contentStyle">
19
- <view class="virtual-list-measure-inner">
20
- <slot
21
- :list="visibleData"
22
- :startIndex="startIndex"
23
- :start="startIndex"
24
- :current="Math.floor(startIndex / props.pageSize) + 1"
25
- :itemsHeight="itemsHeightMap"
26
- :onSizeChange="onSizeChange"></slot>
27
- </view>
28
- </view>
29
- </view>
30
- </scroll-view>
31
-
32
- <!-- 返回顶部按钮:移出 scroll-view,绝对定位在 wrapper 内部 -->
33
- <view
34
- :style="{ display: showBackTop && scrollTop > 200 ? 'flex' : 'none' }"
35
- class="virtual-back-top"
36
- @click="scrollToTop">
37
-
38
- </view>
39
- </view>
40
- </template>
41
-
42
- <script lang="ts" setup>
43
- import {
44
- ref,
45
- reactive,
46
- computed,
47
- watch,
48
- nextTick,
49
- getCurrentInstance,
50
- onMounted,
51
- } from 'vue';
52
-
53
- interface Props {
54
- /** 渲染数据源 */
55
- data: any[];
56
- /** 容器高度 */
57
- height?: number | string;
58
- /** 每页渲染数量 (虚拟化块大小) */
59
- pageSize?: number;
60
- /** 预估的项高度 (用于初次计算) */
61
- itemHeight?: number;
62
- /** 额外的缓冲区数量 (防止滚动过快白屏) */
63
- buffer?: number;
64
- /** 自定义类名 */
65
- className?: string;
66
- /** 是否显示返回顶部 */
67
- showBackTop?: boolean;
68
- /** 数据更新时是否自动滚动到顶部 */
69
- dataChangeScrollToTop?: boolean;
70
- /** 预加载高度 (兼容 Pro) */
71
- preLodeHeight?: number;
72
- /** 默认当前页 (从 1 开始) */
73
- defaultCurrent?: number;
74
- /** 横向滚动 */
75
- scrollX?: boolean;
76
- /** 滚动动画 */
77
- scrollWithAnimation?: boolean;
78
- /** 滚动到指定元素 ID */
79
- scrollIntoView?: string;
80
- }
81
-
82
- const props = withDefaults(defineProps<Props>(), {
83
- height: '100%',
84
- pageSize: 20,
85
- itemHeight: 50,
86
- buffer: 5,
87
- showBackTop: true,
88
- dataChangeScrollToTop: true,
89
- preLodeHeight: 100,
90
- defaultCurrent: 1,
91
- scrollX: false,
92
- scrollWithAnimation: false,
93
- scrollIntoView: '',
94
- });
95
-
96
- const emit = defineEmits(['scroll']);
97
-
98
- /** 定义插槽类型 */
99
- defineSlots<{
100
- default(props: {
101
- /** 当前可见数据列表 */
102
- list: any[];
103
- /** 起始索引 (绝对索引) */
104
- startIndex: number;
105
- /** 起始索引 (别名,同 startIndex) */
106
- start: number;
107
- /** 当前页码 (基于 startIndex 和 pageSize 计算) */
108
- current: number;
109
- /** 各项高度映射记录 */
110
- itemsHeight: Record<number, number>;
111
- /** 子项尺寸变化回调 */
112
- onSizeChange: (index: number, height: number) => void;
113
- }): any;
114
- }>();
115
-
116
- // --- 状态变量 ---
117
- const scrollTop = ref(0);
118
- const scrollTopVal = ref(0); // 用于控制转跳的响应式值
119
- const startIndex = ref(0);
120
- const itemsHeightMap = reactive<Record<number, number>>({}); // 存储每一项的实际高度
121
-
122
- const isEdgeSwipe = ref(false);
123
- const isCapturing = ref(false); // 是否由当前列表锁定捕获手势
124
- let containerRect = { left: 0, right: 0, width: 0 };
125
-
126
- const instance = getCurrentInstance();
127
- const maxWidth = ref(0);
128
-
129
- onMounted(() => {
130
- // 获取容器位置和宽度,用于边缘检测
131
- uni
132
- .createSelectorQuery()
133
- .in(instance)
134
- .select('.virtual-list-wrapper')
135
- .boundingClientRect((data: any) => {
136
- if (data) {
137
- containerRect = data;
138
- }
139
- })
140
- .exec();
141
- });
142
-
143
- function onTouchStart(e: TouchEvent | MouseEvent) {
144
- if (!props.scrollX) return;
145
- const touch = (e as TouchEvent).touches
146
- ? (e as TouchEvent).touches[0]
147
- : (e as MouseEvent);
148
- const x = touch.clientX;
149
-
150
- // 如果从边缘 40px 内开始滑动,则视为侧滑切换 Tab,允许事件冒泡到父级
151
- const threshold = 40;
152
- if (
153
- x < containerRect.left + threshold ||
154
- x > containerRect.right - threshold
155
- ) {
156
- isEdgeSwipe.value = true;
157
- isCapturing.value = false;
158
- } else {
159
- isEdgeSwipe.value = false;
160
- isCapturing.value = true;
161
- // 重要:锁定在此,阻止事件冒泡到 CustomSwiper
162
- e.stopPropagation();
163
- }
164
- }
165
-
166
- function onTouchMove(e: TouchEvent) {
167
- if (isCapturing.value) {
168
- // 已经锁定时,继续阻止冒泡,确保父级不会触发滑屏逻辑
169
- e.stopPropagation();
170
- }
171
- }
172
-
173
- function onTouchEnd(e: TouchEvent | MouseEvent) {
174
- if (isCapturing.value) {
175
- e.stopPropagation();
176
- }
177
- isCapturing.value = false;
178
- isEdgeSwipe.value = false;
179
- }
180
-
181
- function onMouseDown(e: MouseEvent) {
182
- onTouchStart(e);
183
- }
184
-
185
- // 根据 defaultCurrent 初始化位置
186
- if (props.defaultCurrent > 1) {
187
- startIndex.value = Math.max(0, (props.defaultCurrent - 1) * props.pageSize);
188
- }
189
- const offset = ref(0);
190
-
191
- // 初始化 offset
192
- if (startIndex.value > 0) {
193
- let initialOffset = 0;
194
- for (let i = 0; i < startIndex.value; i++) {
195
- initialOffset += itemsHeightMap[i] || props.itemHeight;
196
- }
197
- offset.value = initialOffset;
198
- }
199
-
200
- // --- 计算属性 ---
201
- // 外层容器样式
202
- const wrapperStyle = computed(
203
- () =>
204
- ({
205
- height:
206
- typeof props.height === 'number' ? `${props.height}px` : props.height,
207
- position: 'relative' as const,
208
- overflow: 'hidden' as const,
209
- }) as any,
210
- );
211
-
212
- // 计算总高度:已测量的高度 + 未测量的默认高度
213
- const totalHeight = computed(() => {
214
- let total = 0;
215
- for (let i = 0; i < props.data.length; i++) {
216
- total += itemsHeightMap[i] || props.itemHeight;
217
- }
218
- return total;
219
- });
220
-
221
- // 可见数据切片
222
- const visibleData = computed(() => {
223
- // 我们多渲染一点,避免滚动白屏
224
- const end = Math.min(
225
- startIndex.value + props.pageSize + props.buffer,
226
- props.data.length,
227
- );
228
- return props.data.slice(startIndex.value, end);
229
- });
230
-
231
- // 滚动容器样式 (撑开滚动区域的核心)
232
- const containerStyle = computed(() => {
233
- const style: any = {
234
- height: `${totalHeight.value}px`,
235
- position: 'relative',
236
- width: '100%',
237
- };
238
- if (props.scrollX) {
239
- if (maxWidth.value) {
240
- style.minWidth = `${maxWidth.value}px`;
241
- }
242
- }
243
- return style;
244
- });
245
-
246
- // 内容块样式
247
- const contentStyle = computed(() => {
248
- return {
249
- transform: `translateY(${offset.value}px)`,
250
- position: 'absolute' as const,
251
- left: 0,
252
- top: 0,
253
- width: '100%',
254
- // minWidth: 'max-content',
255
- };
256
- });
257
-
258
- // --- 方法 ---
259
-
260
- /**
261
- * 核心滚动处理函数
262
- */
263
- const handleScroll = (e: any) => {
264
- // Uni-app 环境下 e.detail.scrollTop 可能存在,标准 H5 下是 e.target.scrollTop
265
- const st = e.detail?.scrollTop ?? e.target?.scrollTop ?? 0;
266
- scrollTop.value = st;
267
-
268
- // 根据当前滚动位置计算 startIndex
269
- let accumulatedHeight = 0;
270
- let index = -1;
271
- const dataLen = props.data.length;
272
-
273
- for (let i = 0; i < dataLen; i++) {
274
- const h = itemsHeightMap[i] || props.itemHeight;
275
- if (accumulatedHeight + h > st) {
276
- index = i;
277
- break;
278
- }
279
- accumulatedHeight += h;
280
- }
281
-
282
- // 修复核心:如果 st 很大,超过了所有项的高度和,或者刚好在最后,确保 index 不会回滚到 0
283
- if (index === -1) {
284
- index = dataLen > 0 ? dataLen - 1 : 0;
285
- }
286
-
287
- // 加上缓冲区处理
288
- const targetIndex = Math.max(0, index - props.buffer);
289
-
290
- // 更新状态
291
- if (targetIndex !== startIndex.value) {
292
- startIndex.value = targetIndex;
293
-
294
- // 计算内容块的偏移量:即渲染起始项之前的全部高度
295
- let currentOffset = 0;
296
- for (let i = 0; i < targetIndex; i++) {
297
- currentOffset += itemsHeightMap[i] || props.itemHeight;
298
- }
299
- offset.value = currentOffset;
300
- }
301
-
302
- emit('scroll', e);
303
- };
304
-
305
- /**
306
- * 子项尺寸变化时更新高度映射
307
- */
308
- const onSizeChange = (index: number, height: number) => {
309
- if (height > 0 && itemsHeightMap[index] !== height) {
310
- itemsHeightMap[index] = height;
311
- }
312
- };
313
-
314
- /**
315
- * 滚动到指定位置
316
- */
317
- const scrollToTop = () => {
318
- scrollTopVal.value = scrollTop.value; // 先同步一次
319
- nextTick(() => {
320
- scrollTopVal.value = 0;
321
- scrollTop.value = 0;
322
- });
323
- };
324
-
325
- // 暴露 API 给外部使用
326
- defineExpose({
327
- scrollToTop,
328
- itemsHeightMap,
329
- });
330
-
331
- /**
332
- * 更新记录的最大宽度
333
- */
334
- const updateMaxWidth = () => {
335
- if (!props.scrollX) return;
336
- uni
337
- .createSelectorQuery()
338
- .in(instance)
339
- .select('.virtual-list-measure-inner')
340
- .boundingClientRect((res: any) => {
341
- // 只有当检测到更大的宽度时才更新,实现“只增不减”
342
- if (res && res.width > maxWidth.value) {
343
- maxWidth.value = res.width;
344
- }
345
- })
346
- .exec();
347
- };
348
-
349
- // 监听可见数据变化,更新最大宽度
350
- watch(visibleData, () => {
351
- nextTick(() => {
352
- updateMaxWidth();
353
- });
354
- });
355
-
356
- // 数据变化处理
357
- watch(
358
- () => props.data,
359
- () => {
360
- if (props.dataChangeScrollToTop) {
361
- maxWidth.value = 0; // 只有在需要回到顶部(彻底换数据)时才重置宽度
362
- scrollToTop();
363
- // 数据重置时,立即更新内部索引,防止旧位置引起的白屏
364
- startIndex.value = 0;
365
- offset.value = 0;
366
- scrollTop.value = 0;
367
- }
368
- },
369
- { deep: false },
370
- );
371
- </script>
372
-
373
- <style scoped>
374
- .virtual-list-wrapper {
375
- box-sizing: border-box;
376
- }
377
-
378
- .virtual-list-scroll {
379
- width: 100%;
380
- height: 100%;
381
- }
382
-
383
- :deep(.virtual-list-scroll .uni-scroll-view .uni-scroll-view) {
384
- overflow: auto !important;
385
- }
386
-
387
- .virtual-list-measure-inner {
388
- /* width: max-content; */
389
- min-width: 100%;
390
- }
391
-
392
- .virtual-back-top {
393
- position: absolute;
394
- bottom: 24px;
395
- right: 24px;
396
- width: 44px;
397
- height: 44px;
398
- background-color: var(--dev-tool-main-color, #9254de);
399
- color: #fff;
400
- border-radius: 50%;
401
- display: flex;
402
- align-items: center;
403
- justify-content: center;
404
- box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
405
- font-size: 20px;
406
- z-index: 1000;
407
- cursor: pointer;
408
- transition:
409
- opacity 0.3s,
410
- transform 0.2s;
411
- }
412
-
413
- .virtual-back-top:active {
414
- transform: scale(0.9);
415
- }
416
- </style>
1
+ <template>
2
+ <view :class="['virtual-list-wrapper', className]" :style="wrapperStyle">
3
+ <scroll-view
4
+ scroll-y
5
+ :scroll-x="scrollX && !isEdgeSwipe"
6
+ class="virtual-list-scroll"
7
+ :scroll-top="scrollTopVal"
8
+ :scroll-with-animation="scrollWithAnimation"
9
+ :scroll-into-view="scrollIntoView"
10
+ @scroll="handleScroll"
11
+ @touchstart="onTouchStart"
12
+ @touchmove="onTouchMove"
13
+ @touchend="onTouchEnd"
14
+ @mousedown="onMouseDown">
15
+ <!-- 滚动容器:统一负责撑开横向和纵向的滚动区域 -->
16
+ <view class="virtual-list-container" :style="containerStyle">
17
+ <!-- 实际渲染内容的列表块 -->
18
+ <view class="virtual-list-content" :style="contentStyle">
19
+ <view class="virtual-list-measure-inner">
20
+ <slot
21
+ :list="visibleData"
22
+ :startIndex="startIndex"
23
+ :start="startIndex"
24
+ :current="Math.floor(startIndex / props.pageSize) + 1"
25
+ :itemsHeight="itemsHeightMap"
26
+ :onSizeChange="onSizeChange"></slot>
27
+ </view>
28
+ </view>
29
+ </view>
30
+ </scroll-view>
31
+
32
+ <!-- 返回顶部按钮:移出 scroll-view,绝对定位在 wrapper 内部 -->
33
+ <view
34
+ :style="{ display: showBackTop && scrollTop > 200 ? 'flex' : 'none' }"
35
+ class="virtual-back-top"
36
+ @click="scrollToTop">
37
+
38
+ </view>
39
+ </view>
40
+ </template>
41
+
42
+ <script lang="ts" setup>
43
+ import {
44
+ ref,
45
+ reactive,
46
+ computed,
47
+ watch,
48
+ nextTick,
49
+ getCurrentInstance,
50
+ onMounted,
51
+ } from 'vue';
52
+
53
+ interface Props {
54
+ /** 渲染数据源 */
55
+ data: any[];
56
+ /** 容器高度 */
57
+ height?: number | string;
58
+ /** 每页渲染数量 (虚拟化块大小) */
59
+ pageSize?: number;
60
+ /** 预估的项高度 (用于初次计算) */
61
+ itemHeight?: number;
62
+ /** 额外的缓冲区数量 (防止滚动过快白屏) */
63
+ buffer?: number;
64
+ /** 自定义类名 */
65
+ className?: string;
66
+ /** 是否显示返回顶部 */
67
+ showBackTop?: boolean;
68
+ /** 数据更新时是否自动滚动到顶部 */
69
+ dataChangeScrollToTop?: boolean;
70
+ /** 预加载高度 (兼容 Pro) */
71
+ preLodeHeight?: number;
72
+ /** 默认当前页 (从 1 开始) */
73
+ defaultCurrent?: number;
74
+ /** 横向滚动 */
75
+ scrollX?: boolean;
76
+ /** 滚动动画 */
77
+ scrollWithAnimation?: boolean;
78
+ /** 滚动到指定元素 ID */
79
+ scrollIntoView?: string;
80
+ }
81
+
82
+ const props = withDefaults(defineProps<Props>(), {
83
+ height: '100%',
84
+ pageSize: 20,
85
+ itemHeight: 50,
86
+ buffer: 5,
87
+ showBackTop: true,
88
+ dataChangeScrollToTop: true,
89
+ preLodeHeight: 100,
90
+ defaultCurrent: 1,
91
+ scrollX: false,
92
+ scrollWithAnimation: false,
93
+ scrollIntoView: '',
94
+ });
95
+
96
+ const emit = defineEmits(['scroll']);
97
+
98
+ /** 定义插槽类型 */
99
+ defineSlots<{
100
+ default(props: {
101
+ /** 当前可见数据列表 */
102
+ list: any[];
103
+ /** 起始索引 (绝对索引) */
104
+ startIndex: number;
105
+ /** 起始索引 (别名,同 startIndex) */
106
+ start: number;
107
+ /** 当前页码 (基于 startIndex 和 pageSize 计算) */
108
+ current: number;
109
+ /** 各项高度映射记录 */
110
+ itemsHeight: Record<number, number>;
111
+ /** 子项尺寸变化回调 */
112
+ onSizeChange: (index: number, height: number) => void;
113
+ }): any;
114
+ }>();
115
+
116
+ // --- 状态变量 ---
117
+ const scrollTop = ref(0);
118
+ const scrollTopVal = ref(0); // 用于控制转跳的响应式值
119
+ const startIndex = ref(0);
120
+ const itemsHeightMap = reactive<Record<number, number>>({}); // 存储每一项的实际高度
121
+
122
+ const isEdgeSwipe = ref(false);
123
+ const isCapturing = ref(false); // 是否由当前列表锁定捕获手势
124
+ let containerRect = { left: 0, right: 0, width: 0 };
125
+
126
+ const instance = getCurrentInstance();
127
+ const maxWidth = ref(0);
128
+
129
+ onMounted(() => {
130
+ // 获取容器位置和宽度,用于边缘检测
131
+ uni
132
+ .createSelectorQuery()
133
+ .in(instance)
134
+ .select('.virtual-list-wrapper')
135
+ .boundingClientRect((data: any) => {
136
+ if (data) {
137
+ containerRect = data;
138
+ }
139
+ })
140
+ .exec();
141
+ });
142
+
143
+ function onTouchStart(e: TouchEvent | MouseEvent) {
144
+ if (!props.scrollX) return;
145
+ const touch = (e as TouchEvent).touches
146
+ ? (e as TouchEvent).touches[0]
147
+ : (e as MouseEvent);
148
+ const x = touch.clientX;
149
+
150
+ // 如果从边缘 40px 内开始滑动,则视为侧滑切换 Tab,允许事件冒泡到父级
151
+ const threshold = 40;
152
+ if (
153
+ x < containerRect.left + threshold ||
154
+ x > containerRect.right - threshold
155
+ ) {
156
+ isEdgeSwipe.value = true;
157
+ isCapturing.value = false;
158
+ } else {
159
+ isEdgeSwipe.value = false;
160
+ isCapturing.value = true;
161
+ // 重要:锁定在此,阻止事件冒泡到 CustomSwiper
162
+ e.stopPropagation();
163
+ }
164
+ }
165
+
166
+ function onTouchMove(e: TouchEvent) {
167
+ if (isCapturing.value) {
168
+ // 已经锁定时,继续阻止冒泡,确保父级不会触发滑屏逻辑
169
+ e.stopPropagation();
170
+ }
171
+ }
172
+
173
+ function onTouchEnd(e: TouchEvent | MouseEvent) {
174
+ if (isCapturing.value) {
175
+ e.stopPropagation();
176
+ }
177
+ isCapturing.value = false;
178
+ isEdgeSwipe.value = false;
179
+ }
180
+
181
+ function onMouseDown(e: MouseEvent) {
182
+ onTouchStart(e);
183
+ }
184
+
185
+ // 根据 defaultCurrent 初始化位置
186
+ if (props.defaultCurrent > 1) {
187
+ startIndex.value = Math.max(0, (props.defaultCurrent - 1) * props.pageSize);
188
+ }
189
+ const offset = ref(0);
190
+
191
+ // 初始化 offset
192
+ if (startIndex.value > 0) {
193
+ let initialOffset = 0;
194
+ for (let i = 0; i < startIndex.value; i++) {
195
+ initialOffset += itemsHeightMap[i] || props.itemHeight;
196
+ }
197
+ offset.value = initialOffset;
198
+ }
199
+
200
+ // --- 计算属性 ---
201
+ // 外层容器样式
202
+ const wrapperStyle = computed(
203
+ () =>
204
+ ({
205
+ height:
206
+ typeof props.height === 'number' ? `${props.height}px` : props.height,
207
+ position: 'relative' as const,
208
+ overflow: 'hidden' as const,
209
+ }) as any,
210
+ );
211
+
212
+ // 计算总高度:已测量的高度 + 未测量的默认高度
213
+ const totalHeight = computed(() => {
214
+ let total = 0;
215
+ for (let i = 0; i < props.data.length; i++) {
216
+ total += itemsHeightMap[i] || props.itemHeight;
217
+ }
218
+ return total;
219
+ });
220
+
221
+ // 可见数据切片
222
+ const visibleData = computed(() => {
223
+ // 我们多渲染一点,避免滚动白屏
224
+ const end = Math.min(
225
+ startIndex.value + props.pageSize + props.buffer,
226
+ props.data.length,
227
+ );
228
+ return props.data.slice(startIndex.value, end);
229
+ });
230
+
231
+ // 滚动容器样式 (撑开滚动区域的核心)
232
+ const containerStyle = computed(() => {
233
+ const style: any = {
234
+ height: `${totalHeight.value}px`,
235
+ position: 'relative',
236
+ width: '100%',
237
+ };
238
+ if (props.scrollX) {
239
+ if (maxWidth.value) {
240
+ style.minWidth = `${maxWidth.value}px`;
241
+ }
242
+ }
243
+ return style;
244
+ });
245
+
246
+ // 内容块样式
247
+ const contentStyle = computed(() => {
248
+ return {
249
+ transform: `translateY(${offset.value}px)`,
250
+ position: 'absolute' as const,
251
+ left: 0,
252
+ top: 0,
253
+ width: '100%',
254
+ // minWidth: 'max-content',
255
+ };
256
+ });
257
+
258
+ // --- 方法 ---
259
+
260
+ /**
261
+ * 核心滚动处理函数
262
+ */
263
+ const handleScroll = (e: any) => {
264
+ // Uni-app 环境下 e.detail.scrollTop 可能存在,标准 H5 下是 e.target.scrollTop
265
+ const st = e.detail?.scrollTop ?? e.target?.scrollTop ?? 0;
266
+ scrollTop.value = st;
267
+
268
+ // 根据当前滚动位置计算 startIndex
269
+ let accumulatedHeight = 0;
270
+ let index = -1;
271
+ const dataLen = props.data.length;
272
+
273
+ for (let i = 0; i < dataLen; i++) {
274
+ const h = itemsHeightMap[i] || props.itemHeight;
275
+ if (accumulatedHeight + h > st) {
276
+ index = i;
277
+ break;
278
+ }
279
+ accumulatedHeight += h;
280
+ }
281
+
282
+ // 修复核心:如果 st 很大,超过了所有项的高度和,或者刚好在最后,确保 index 不会回滚到 0
283
+ if (index === -1) {
284
+ index = dataLen > 0 ? dataLen - 1 : 0;
285
+ }
286
+
287
+ // 加上缓冲区处理
288
+ const targetIndex = Math.max(0, index - props.buffer);
289
+
290
+ // 更新状态
291
+ if (targetIndex !== startIndex.value) {
292
+ startIndex.value = targetIndex;
293
+
294
+ // 计算内容块的偏移量:即渲染起始项之前的全部高度
295
+ let currentOffset = 0;
296
+ for (let i = 0; i < targetIndex; i++) {
297
+ currentOffset += itemsHeightMap[i] || props.itemHeight;
298
+ }
299
+ offset.value = currentOffset;
300
+ }
301
+
302
+ emit('scroll', e);
303
+ };
304
+
305
+ /**
306
+ * 子项尺寸变化时更新高度映射
307
+ */
308
+ const onSizeChange = (index: number, height: number) => {
309
+ if (height > 0 && itemsHeightMap[index] !== height) {
310
+ itemsHeightMap[index] = height;
311
+ }
312
+ };
313
+
314
+ /**
315
+ * 滚动到指定位置
316
+ */
317
+ const scrollToTop = () => {
318
+ scrollTopVal.value = scrollTop.value; // 先同步一次
319
+ nextTick(() => {
320
+ scrollTopVal.value = 0;
321
+ scrollTop.value = 0;
322
+ });
323
+ };
324
+
325
+ // 暴露 API 给外部使用
326
+ defineExpose({
327
+ scrollToTop,
328
+ itemsHeightMap,
329
+ });
330
+
331
+ /**
332
+ * 更新记录的最大宽度
333
+ */
334
+ const updateMaxWidth = () => {
335
+ if (!props.scrollX) return;
336
+ uni
337
+ .createSelectorQuery()
338
+ .in(instance)
339
+ .select('.virtual-list-measure-inner')
340
+ .boundingClientRect((res: any) => {
341
+ // 只有当检测到更大的宽度时才更新,实现“只增不减”
342
+ if (res && res.width > maxWidth.value) {
343
+ maxWidth.value = res.width;
344
+ }
345
+ })
346
+ .exec();
347
+ };
348
+
349
+ // 监听可见数据变化,更新最大宽度
350
+ watch(visibleData, () => {
351
+ nextTick(() => {
352
+ updateMaxWidth();
353
+ });
354
+ });
355
+
356
+ // 数据变化处理
357
+ watch(
358
+ () => props.data,
359
+ () => {
360
+ if (props.dataChangeScrollToTop) {
361
+ maxWidth.value = 0; // 只有在需要回到顶部(彻底换数据)时才重置宽度
362
+ scrollToTop();
363
+ // 数据重置时,立即更新内部索引,防止旧位置引起的白屏
364
+ startIndex.value = 0;
365
+ offset.value = 0;
366
+ scrollTop.value = 0;
367
+ }
368
+ },
369
+ { deep: false },
370
+ );
371
+ </script>
372
+
373
+ <style scoped>
374
+ .virtual-list-wrapper {
375
+ box-sizing: border-box;
376
+ }
377
+
378
+ .virtual-list-scroll {
379
+ width: 100%;
380
+ height: 100%;
381
+ }
382
+
383
+ :deep(.virtual-list-scroll .uni-scroll-view .uni-scroll-view) {
384
+ overflow: auto !important;
385
+ }
386
+
387
+ .virtual-list-measure-inner {
388
+ /* width: max-content; */
389
+ min-width: 100%;
390
+ }
391
+
392
+ .virtual-back-top {
393
+ position: absolute;
394
+ bottom: 24px;
395
+ right: 24px;
396
+ width: 44px;
397
+ height: 44px;
398
+ background-color: var(--dev-tool-main-color, #9254de);
399
+ color: #fff;
400
+ border-radius: 50%;
401
+ display: flex;
402
+ align-items: center;
403
+ justify-content: center;
404
+ box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
405
+ font-size: 20px;
406
+ z-index: 1000;
407
+ cursor: pointer;
408
+ transition:
409
+ opacity 0.3s,
410
+ transform 0.2s;
411
+ }
412
+
413
+ .virtual-back-top:active {
414
+ transform: scale(0.9);
415
+ }
416
+ </style>