xydata-tools-uniapp 1.0.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.
@@ -0,0 +1,1645 @@
1
+ <template>
2
+ <view class="media-picker-wrapper">
3
+ <!-- ==================== 编辑模式 ==================== -->
4
+ <view class="media-picker-box" v-if="!readonly">
5
+ <!-- 垂直布局:上传按钮在媒体区域下方 -->
6
+ <view v-if="actualButtonPosition === 'vertical'" :class="['vertical-layout']">
7
+ <!-- 图片区域 -->
8
+ <view v-if="enableImage && showFileList" class="media-section">
9
+ <shmily-drag-media
10
+ v-if="imageFileList.length > 0"
11
+ v-model="imageUrls"
12
+ :number="imageLimit"
13
+ :cols="cols"
14
+ :imageWidth="itemWidth"
15
+ :padding="10"
16
+ :borderRadius="itemBorderRadius"
17
+ :draggable="draggable"
18
+ moveType="image"
19
+ @input="handleImageDragChange"
20
+ >
21
+ </shmily-drag-media>
22
+ </view>
23
+
24
+ <!-- 视频区域 -->
25
+ <view v-if="enableVideo && showFileList" class="media-section" :style="{ marginTop: enableImage && videoFileList.length > 0 ? '20rpx' : '0' }">
26
+ <shmily-drag-media
27
+ v-if="videoFileList.length > 0"
28
+ v-model="videoUrls"
29
+ :number="videoLimit"
30
+ :cols="cols"
31
+ :imageWidth="itemWidth"
32
+ :padding="10"
33
+ :borderRadius="itemBorderRadius"
34
+ :draggable="draggable"
35
+ moveType="video"
36
+ @input="handleVideoDragChange"
37
+ @videoClick="handleVideoItemClick"
38
+ >
39
+ </shmily-drag-media>
40
+ </view>
41
+
42
+ <!-- 上传按钮区域(both 模式时并排显示) -->
43
+ <view class="upload-buttons-row">
44
+ <!-- 图片上传按钮 -->
45
+ <view v-if="enableImage" class="upload-button-wrapper">
46
+ <slot name="image-upload-button" :select="selectAndUploadImage" :limit="imageLimit" :count="imageFileList.length">
47
+ <view
48
+ class="upload-btn-vertical"
49
+ :style="{ borderRadius: itemBorderRadius + 'rpx' }"
50
+ v-if="imageFileList.length < imageLimit"
51
+ @click="selectAndUploadImage"
52
+ >
53
+ <image :src="imageUploadIcon" class="upload-icon"></image>
54
+ <view class="upload-text">{{ imageButtonTitle }}</view>
55
+ </view>
56
+ </slot>
57
+ </view>
58
+
59
+ <!-- 视频上传按钮 -->
60
+ <view v-if="enableVideo" class="upload-button-wrapper">
61
+ <slot name="video-upload-button" :select="selectAndUploadVideo" :limit="videoLimit" :count="videoFileList.length">
62
+ <view
63
+ class="upload-btn-vertical"
64
+ :style="{ borderRadius: itemBorderRadius + 'rpx' }"
65
+ v-if="videoFileList.length < videoLimit"
66
+ @click="selectAndUploadVideo"
67
+ >
68
+ <image :src="videoUploadIcon" class="upload-icon"></image>
69
+ <view class="upload-text">{{ videoButtonTitle }}</view>
70
+ </view>
71
+ </slot>
72
+ </view>
73
+ </view>
74
+ </view>
75
+
76
+ <!-- 水平布局:上传按钮跟随媒体(仅单一媒体类型时使用) -->
77
+ <view v-else class="horizontal-layout">
78
+ <!-- 图片水平布局 -->
79
+ <shmily-drag-media
80
+ v-if="enableImage && !enableVideo && showFileList"
81
+ v-model="imageUrls"
82
+ :number="imageLimit"
83
+ :cols="cols"
84
+ :imageWidth="itemWidth"
85
+ :padding="10"
86
+ :borderRadius="itemBorderRadius"
87
+ :align="align"
88
+ :draggable="draggable"
89
+ :enableAddButton="true"
90
+ moveType="image"
91
+ @input="handleImageDragChange"
92
+ @add-click="selectAndUploadImage"
93
+ >
94
+ <template #add-button>
95
+ <slot name="image-upload-button" :select="selectAndUploadImage" :limit="imageLimit" :count="imageFileList.length">
96
+ <view class="drag-upload-btn" @click.stop="selectAndUploadImage">
97
+ <image :src="imageUploadIcon" class="upload-icon" :style="dragIconStyle"></image>
98
+ <view class="upload-text" :style="dragTextStyle">{{ imageButtonTitle }} </view>
99
+ </view>
100
+ </slot>
101
+ </template>
102
+ </shmily-drag-media>
103
+
104
+ <!-- 视频水平布局 -->
105
+ <shmily-drag-media
106
+ v-else-if="enableVideo && !enableImage && showFileList"
107
+ v-model="videoUrls"
108
+ :number="videoLimit"
109
+ :cols="cols"
110
+ :imageWidth="itemWidth"
111
+ :padding="10"
112
+ :borderRadius="itemBorderRadius"
113
+ :align="align"
114
+ :draggable="draggable"
115
+ :enableAddButton="true"
116
+ moveType="video"
117
+ @input="handleVideoDragChange"
118
+ @videoClick="handleVideoItemClick"
119
+ >
120
+ <template #add-button>
121
+ <slot name="video-upload-button" :select="selectAndUploadVideo" :limit="videoLimit" :count="videoFileList.length">
122
+ <view class="drag-upload-btn" @click="selectAndUploadVideo">
123
+ <image :src="videoUploadIcon" class="upload-icon" :style="dragIconStyle"></image>
124
+ <view class="upload-text" :style="dragTextStyle">{{ videoButtonTitle }} </view>
125
+ </view>
126
+ </slot>
127
+ </template>
128
+ </shmily-drag-media>
129
+ </view>
130
+ </view>
131
+
132
+ <!-- ==================== 只读模式(混合展示图片和视频) ==================== -->
133
+ <block v-else-if="allFileList.length && showFileList">
134
+ <view class="readonly-grid" :class="{ 'align-right': align === 'right' && itemWidth > 0 }" :style="readonlyGridStyle">
135
+ <view
136
+ v-for="(file, i) in readonlyDisplayList"
137
+ :key="i"
138
+ :style="{ borderRadius: itemBorderRadius + 'rpx' }"
139
+ :class="['readonly-grid-item', { 'placeholder-item': file.type === 'placeholder' }, { 'filter-avatar': file.type !== 'placeholder' && isLastRealItem(i) }]"
140
+ :data-count="file.type !== 'placeholder' && isLastRealItem(i) ? '+' + (allFileList.length - maxShowCount) : ''"
141
+ @click="handleReadonlyItemClick(i, file)"
142
+ >
143
+ <!-- 占位元素(不显示任何内容) -->
144
+ <view v-if="file.type === 'placeholder'" class="placeholder-content"></view>
145
+
146
+ <!-- 图片 -->
147
+ <template v-else>
148
+ <image
149
+ v-if="file.type === 'image'"
150
+ :src="lazyLoadImageSrcs[i]"
151
+ mode="aspectFill"
152
+ :style="{ borderRadius: itemBorderRadius + 'rpx' }"
153
+ :id="componentId + '-imgview-' + i"
154
+ @load="onMediaLoad('readonly-' + i)"
155
+ @error="onMediaError('readonly-' + i)"
156
+ >
157
+ </image>
158
+
159
+ <!-- 视频 -->
160
+ <video
161
+ v-else-if="file.type === 'video'"
162
+ :id="'readonlyVideo' + i"
163
+ class="readonly-video"
164
+ :src="file.url"
165
+ :controls="false"
166
+ :show-center-play-btn="false"
167
+ :style="{ borderRadius: itemBorderRadius + 'rpx' }"
168
+ @loadedmetadata="onMediaLoad('readonly-' + i)"
169
+ @error="onMediaError('readonly-' + i)"
170
+ ></video>
171
+
172
+ <!-- 视频播放图标覆盖层 -->
173
+ <view v-if="file.type === 'video'" class="video-play-overlay">
174
+ <image class="play-icon" :src="playIcon"></image>
175
+ </view>
176
+
177
+ <!-- 加载动画遮罩 -->
178
+ <view class="media-loading-overlay" v-if="!mediaLoadingStatus['readonly-' + i]">
179
+ <view class="loading-spinner"></view>
180
+ </view>
181
+ </template>
182
+ </view>
183
+ </view>
184
+ </block>
185
+
186
+ <!-- ==================== 自定义弹窗展示所有媒体(从底部滑出) ==================== -->
187
+ <view v-if="popupVisible" class="popup-overlay" @click="closePopup" @touchmove.stop.prevent :class="{ 'popup-overlay-show': popupAnimated }">
188
+ <view class="popup-content" @click.stop @touchmove.stop :class="{ 'popup-content-show': popupAnimated }">
189
+ <view class="popup-header">
190
+ <view class="popup-title">{{ popupTitle }}</view>
191
+ <view class="popup-close" @click="closePopup">✕</view>
192
+ </view>
193
+ <scroll-view class="popup-scroll" scroll-y :show-scrollbar="false">
194
+ <view class="popup-grid" :style="popupGridStyle">
195
+ <view v-for="(file, i) in allFileList" :key="i" class="popup-grid-item" @click="handlePopupItemClick(file, i)">
196
+ <!-- 图片 -->
197
+ <image
198
+ v-if="file.type === 'image'"
199
+ :src="popupLazyLoadImageSrcs[i]"
200
+ mode="aspectFill"
201
+ :style="{ borderRadius: itemBorderRadius + 'rpx' }"
202
+ :id="componentId + '-popup-imgview-' + i"
203
+ @load="onMediaLoad('popup-' + i)"
204
+ @error="onMediaError('popup-' + i)"
205
+ >
206
+ </image>
207
+
208
+ <!-- 视频 -->
209
+ <video
210
+ v-else-if="file.type === 'video'"
211
+ class="popup-video"
212
+ :src="file.url"
213
+ :controls="false"
214
+ :show-center-play-btn="false"
215
+ :style="{ borderRadius: itemBorderRadius + 'rpx' }"
216
+ @loadedmetadata="onMediaLoad('popup-' + i)"
217
+ @error="onMediaError('popup-' + i)"
218
+ ></video>
219
+
220
+ <!-- 视频播放图标 -->
221
+ <view v-if="file.type === 'video'" class="video-play-overlay">
222
+ <image class="play-icon-small" :src="playIcon"></image>
223
+ </view>
224
+
225
+ <!-- 加载动画遮罩 -->
226
+ <view class="media-loading-overlay" v-if="!mediaLoadingStatus['popup-' + i]">
227
+ <view class="loading-spinner"></view>
228
+ </view>
229
+ </view>
230
+ </view>
231
+ </scroll-view>
232
+ </view>
233
+ </view>
234
+
235
+ <!-- 专门用于全屏预览的 video,隐藏在视图外 -->
236
+ <video
237
+ v-if="enableVideo"
238
+ id="fullscreenPreviewVideo"
239
+ class="fullscreen-preview-video"
240
+ :src="fullscreenVideoSrc"
241
+ :controls="true"
242
+ :show-center-play-btn="true"
243
+ object-fit="contain"
244
+ style="position: fixed; left: -9999px; top: -9999px; width: 1px; height: 1px"
245
+ ></video>
246
+
247
+ <!-- 隐藏的水印绘制 canvas(仅非 H5 平台需要,固定定位移出屏幕而非 display:none,避免小程序无法绘制) -->
248
+ <!-- #ifndef H5 -->
249
+ <canvas
250
+ v-if="watermarkCanvasId"
251
+ :canvas-id="watermarkCanvasId"
252
+ :id="watermarkCanvasId"
253
+ class="watermark-canvas"
254
+ :style="{ width: watermarkCanvasWidth + 'px', height: watermarkCanvasHeight + 'px' }"
255
+ ></canvas>
256
+ <!-- #endif -->
257
+ </view>
258
+ </template>
259
+
260
+ <script>
261
+ import shmilyDragMedia from './shmily-drag-media.vue';
262
+ import defaultAddPicBg from '../../../assets/defaultAddPicBg.png';
263
+ import uploadVideoBg from '../../../assets/uploadVideoBg.png';
264
+ import videoPlay from '../../../assets/videoPlay.png';
265
+ import { applyImageWatermarkToPath, resolveWatermarkError } from '../watermark/index.js';
266
+ import { normalizeWatermarkConfig } from '../../../utils/watermark-shared.js';
267
+
268
+ export default {
269
+ name: 'MediaPicker',
270
+ components: {
271
+ shmilyDragMedia
272
+ },
273
+ props: {
274
+ // ==================== 通用配置 ====================
275
+ /** 上传类型: 'image' - 仅图片, 'video' - 仅视频, 'both' - 图片+视频(必填) */
276
+ uploadType: {
277
+ type: String,
278
+ default: 'image',
279
+ validator: (value) => ['image', 'video', 'both'].includes(value)
280
+ },
281
+ /** 上传地址 */
282
+ uploadUrl: {
283
+ type: String,
284
+ default: ''
285
+ },
286
+ /** 只读模式 */
287
+ readonly: {
288
+ type: Boolean,
289
+ default: false
290
+ },
291
+ /** 上传按钮位置: 'vertical' - 垂直布局, 'horizontal' - 水平布局(仅单一媒体类型时有效) */
292
+ buttonPosition: {
293
+ type: String,
294
+ default: 'horizontal',
295
+ validator: (value) => ['vertical', 'horizontal'].includes(value)
296
+ },
297
+ /** 媒体列数,默认3列 */
298
+ cols: {
299
+ type: Number,
300
+ default: 3
301
+ },
302
+ /** 媒体项固定宽度(单位 rpx),如果设置则 cols 无效 */
303
+ itemWidth: {
304
+ type: Number,
305
+ default: 0
306
+ },
307
+ /** 媒体项圆角大小(单位 rpx) */
308
+ itemBorderRadius: {
309
+ type: Number,
310
+ default: 16
311
+ },
312
+ /** 只读模式下最多显示的媒体数量,超过则显示 +N */
313
+ maxShowCount: {
314
+ type: Number,
315
+ default: 9
316
+ },
317
+ /** 对齐方式:'left' 从左往右排列,'right' 从右往左排列 buttonPosition为'horizontal'下有效*/
318
+ align: {
319
+ type: String,
320
+ default: 'left',
321
+ validator: (value) => ['left', 'right'].includes(value)
322
+ },
323
+ /** 弹窗标题 */
324
+ popupTitle: {
325
+ type: String,
326
+ default: '全部媒体'
327
+ },
328
+ /** 是否可以拖拽排序(仅编辑模式有效) */
329
+ draggable: {
330
+ type: Boolean,
331
+ default: true
332
+ },
333
+ /** 是否展示文件列表 */
334
+ showFileList: {
335
+ type: Boolean,
336
+ default: true
337
+ },
338
+
339
+ // ==================== 图片配置 ====================
340
+ /** 图片文件列表 */
341
+ imageFiles: {
342
+ type: Array,
343
+ default: () => []
344
+ },
345
+ /** 图片最大上传数量限制 */
346
+ imageLimit: {
347
+ type: Number,
348
+ default: 9
349
+ },
350
+ /** 图片上传按钮图标 */
351
+ imageButtonIcon: {
352
+ type: String,
353
+ default: ''
354
+ },
355
+ /** 图片上传按钮文字 */
356
+ imageButtonTitle: {
357
+ type: String,
358
+ default: '上传图片'
359
+ },
360
+ /** 图片选择来源:['album', 'camera'] */
361
+ imageSourceType: {
362
+ type: Array,
363
+ default: () => ['album', 'camera']
364
+ },
365
+ /** 图片最大大小限制(单位:MB),0 表示不限制 */
366
+ imageMaxSize: {
367
+ type: Number,
368
+ default: 0
369
+ },
370
+ /** 图片上传前水印配置(与 PC 端 UploadFile 的 watermark 参数一致),默认不启用 */
371
+ watermark: {
372
+ type: Object,
373
+ default: null
374
+ },
375
+
376
+ // ==================== 视频配置 ====================
377
+ /** 视频文件列表 */
378
+ videoFiles: {
379
+ type: Array,
380
+ default: () => []
381
+ },
382
+ /** 视频最大上传数量限制 */
383
+ videoLimit: {
384
+ type: Number,
385
+ default: 9
386
+ },
387
+ /** 视频上传按钮图标 */
388
+ videoButtonIcon: {
389
+ type: String,
390
+ default: ''
391
+ },
392
+ /** 视频上传按钮文字 */
393
+ videoButtonTitle: {
394
+ type: String,
395
+ default: '上传视频'
396
+ },
397
+ /** 视频选择来源:['album', 'camera'] */
398
+ videoSourceType: {
399
+ type: Array,
400
+ default: () => ['album', 'camera']
401
+ },
402
+ /** 视频最大时长限制(单位:秒),0 表示不限制 */
403
+ videoMaxDuration: {
404
+ type: Number,
405
+ default: 60
406
+ }
407
+ },
408
+ data() {
409
+ return {
410
+ imageFileList: [],
411
+ videoFileList: [],
412
+ defaultImageIcon: defaultAddPicBg,
413
+ defaultVideoIcon: uploadVideoBg,
414
+ playIcon: videoPlay,
415
+ actualUploadUrl: '',
416
+ popupVisible: false,
417
+ popupAnimated: false,
418
+ mediaLoadingStatus: {},
419
+ fullscreenVideoSrc: '', // 专门用于全屏播放的视频源
420
+ windowWidth: uni.getSystemInfoSync ? uni.getSystemInfoSync().windowWidth : 375,
421
+ lazyLoadImageSrcs: [], // 用于图片懒加载,存储进入视图的图片src
422
+ observer: null,
423
+ componentId: null, // 组件实例的唯一ID
424
+ popupLazyLoadImageSrcs: [], // 用于弹窗中图片懒加载,存储进入视图的图片src
425
+ popupObserver: null, // 弹窗图片懒加载观察器
426
+ watermarkCanvasWidth: 300, // 水印 canvas 绘制宽度(px)
427
+ watermarkCanvasHeight: 300 // 水印 canvas 绘制高度(px)
428
+ };
429
+ },
430
+ created() {
431
+ this.componentId = 'mp-' + Date.now() + Math.random().toString(36).substr(2, 9);
432
+ },
433
+ computed: {
434
+ // ==================== 媒体类型判断 ====================
435
+ /** 是否启用图片上传 */
436
+ enableImage() {
437
+ return ['image', 'both'].includes(this.uploadType);
438
+ },
439
+ /** 是否启用视频上传 */
440
+ enableVideo() {
441
+ return ['video', 'both'].includes(this.uploadType);
442
+ },
443
+ /** 实际的布局方式(两种类型时强制为 vertical) */
444
+ actualButtonPosition() {
445
+ if (this.enableImage && this.enableVideo) {
446
+ return 'vertical';
447
+ }
448
+ return this.buttonPosition;
449
+ },
450
+
451
+ /** 只读模式:需要显示的文件列表(右对齐时包含占位元素) */
452
+ readonlyDisplayList() {
453
+ const displayList = this.allFileList.slice(0, this.maxShowCount);
454
+
455
+ // 如果是右对齐,需要在最后一行前面添加占位元素
456
+ if (this.readonly && this.align === 'right' && displayList.length > 0) {
457
+ const totalCount = displayList.length;
458
+ const lastRowCount = totalCount % this.cols;
459
+
460
+ // 如果最后一行不满,需要在最后一行开始位置插入占位元素
461
+ if (lastRowCount !== 0) {
462
+ const placeholderCount = this.cols - lastRowCount;
463
+ const rows = Math.ceil(totalCount / this.cols);
464
+ const lastRowStartIndex = (rows - 1) * this.cols;
465
+
466
+ // 创建占位元素
467
+ const placeholders = Array(placeholderCount)
468
+ .fill(null)
469
+ .map((_, i) => ({
470
+ type: 'placeholder',
471
+ id: `placeholder-${i}`
472
+ }));
473
+
474
+ // 将列表分为:前面几行(满的) + 最后一行
475
+ const beforeLastRow = displayList.slice(0, lastRowStartIndex);
476
+ const lastRow = displayList.slice(lastRowStartIndex);
477
+
478
+ // 在最后一行前面插入占位元素
479
+ return [...beforeLastRow, ...placeholders, ...lastRow];
480
+ }
481
+ }
482
+
483
+ return displayList;
484
+ },
485
+
486
+ // ==================== 图标配置 ====================
487
+ imageUploadIcon() {
488
+ return this.imageButtonIcon || this.defaultImageIcon;
489
+ },
490
+ videoUploadIcon() {
491
+ return this.videoButtonIcon || this.defaultVideoIcon;
492
+ },
493
+
494
+ // ==================== URL 列表 ====================
495
+ imageUrls: {
496
+ get() {
497
+ return this.imageFileList.map((file) => file.url);
498
+ },
499
+ set(newUrls) {
500
+ // setter 用于拖拽组件更新时,由 handleImageDragChange 处理
501
+ }
502
+ },
503
+ videoUrls: {
504
+ get() {
505
+ return this.videoFileList.map((file) => file.url);
506
+ },
507
+ set(newUrls) {
508
+ // setter 用于拖拽组件更新时,由 handleVideoDragChange 处理
509
+ }
510
+ },
511
+ /** 合并的所有媒体列表(用于只读模式和弹窗) */
512
+ allFileList() {
513
+ const combined = [];
514
+ // 标记每个文件的类型
515
+ this.imageFileList.forEach((file) => {
516
+ combined.push({ ...file, type: 'image' });
517
+ });
518
+ this.videoFileList.forEach((file) => {
519
+ combined.push({ ...file, type: 'video' });
520
+ });
521
+ return combined;
522
+ },
523
+
524
+ // ==================== 上传地址 ====================
525
+ finalUploadUrl() {
526
+ return this.actualUploadUrl || this.uploadUrl || (getApp().globalData && getApp().globalData.uploadUrl) || '';
527
+ },
528
+
529
+ // ==================== 水印 ====================
530
+ /** 是否启用水印 */
531
+ watermarkEnabled() {
532
+ const normalized = normalizeWatermarkConfig(this.watermark);
533
+ return !!normalized;
534
+ },
535
+ /** 水印绘制的 canvas-id(基于 componentId 保证多实例唯一) */
536
+ watermarkCanvasId() {
537
+ return this.watermarkEnabled ? `${this.componentId}-wm` : '';
538
+ },
539
+
540
+ // ==================== 动态尺寸计算 ====================
541
+ uploadIconSize() {
542
+ if (this.itemWidth > 0) {
543
+ return Math.floor((this.itemWidth - 20) * 0.3);
544
+ }
545
+ const screenWidth = 750;
546
+ const gap = 20;
547
+ const containerWidth = (screenWidth - gap * (this.cols + 1)) / this.cols;
548
+ return Math.floor(containerWidth * 0.3);
549
+ },
550
+ uploadTextSize() {
551
+ return Math.max(20, Math.floor(this.uploadIconSize * 0.38));
552
+ },
553
+ // 拖拽模式下的图标和文字样式(预计算好,避免模板中使用方法调用报错)
554
+ dragIconStyle() {
555
+ return `width: ${this.uploadIconSize}rpx; height: ${this.uploadIconSize}rpx;`;
556
+ },
557
+ dragTextStyle() {
558
+ return `fontSize:${this.uploadTextSize}rpx`;
559
+ },
560
+ // ==================== 只读模式网格样式 ====================
561
+ readonlyGridStyle() {
562
+ // 如果指定了 itemWidth,使用固定宽度;否则使用 1fr 自动分配
563
+ const columnWidth = this.itemWidth ? `${this.itemWidth}rpx` : '1fr';
564
+ return `display: grid; grid-template-columns: repeat(${this.cols}, ${columnWidth}); gap: 10rpx;`;
565
+ },
566
+ // ==================== 弹窗模式网格样式 ====================
567
+ popupGridStyle() {
568
+ return `display: grid; grid-template-columns: repeat(${this.cols}, 1fr); gap: 10rpx;`;
569
+ }
570
+ },
571
+ options: {
572
+ styleIsolation: 'shared'
573
+ },
574
+ watch: {
575
+ imageFiles: {
576
+ handler(val) {
577
+ this.imageFileList = val || [];
578
+ },
579
+ immediate: true
580
+ },
581
+ videoFiles: {
582
+ handler(val) {
583
+ this.videoFileList = val || [];
584
+ },
585
+ immediate: true
586
+ },
587
+ allFileList: {
588
+ handler() {
589
+ this.initLazyObserver(); // 当文件列表变化时重新初始化观察器
590
+ }
591
+ }
592
+ },
593
+ mounted() {
594
+ if (!this.uploadUrl) {
595
+ this.actualUploadUrl = (getApp().globalData && getApp().globalData.uploadUrl) || '';
596
+ }
597
+ this.initLazyObserver(); // 在mounted时初始化观察器
598
+ },
599
+ onShow() {
600
+ this.initLazyObserver();
601
+ },
602
+ // #ifdef VUE3
603
+ beforeUnmount() {
604
+ this.clearObservers();
605
+ },
606
+ // #endif
607
+ // #ifndef VUE3
608
+ beforeDestroy() {
609
+ this.clearObservers();
610
+ },
611
+ // #endif
612
+ methods: {
613
+ // ==================== 事件监听辅助方法 ====================
614
+ /**
615
+ * 检查事件是否被监听
616
+ * @param {String} eventName - 事件名称
617
+ * @returns {Boolean} - 是否被监听
618
+ */
619
+ hasEventListener(eventName) {
620
+ // #ifdef VUE3
621
+ // Vue 3 中事件监听器存储在 $attrs 中,且以 'on' 开头
622
+ const onEventName = 'on' + eventName.charAt(0).toUpperCase() + eventName.slice(1);
623
+ return !!(this.$attrs[eventName] || this.$attrs[onEventName]);
624
+ // #endif
625
+ // #ifndef VUE3
626
+ return !!(this.$listeners && this.$listeners[eventName]);
627
+ // #endif
628
+ },
629
+
630
+ // ==================== 图片相关方法 ====================
631
+ /**
632
+ * 检查图片大小是否符合限制
633
+ * @param {Number} size - 图片大小(字节)
634
+ * @returns {Boolean} - 是否符合限制
635
+ */
636
+ checkImageSize(size) {
637
+ if (this.imageMaxSize <= 0) {
638
+ return true;
639
+ }
640
+ const maxSizeInBytes = this.imageMaxSize * 1024 * 1024;
641
+ return size <= maxSizeInBytes;
642
+ },
643
+
644
+ /**
645
+ * 获取大小提示文本
646
+ * @param {Number} size - 图片大小(字节)
647
+ * @returns {String} - 格式化后的大小文本
648
+ */
649
+ formatFileSize(size) {
650
+ if (size < 1024) {
651
+ return size + 'B';
652
+ } else if (size < 1024 * 1024) {
653
+ return (size / 1024).toFixed(2) + 'KB';
654
+ } else {
655
+ return (size / (1024 * 1024)).toFixed(2) + 'MB';
656
+ }
657
+ },
658
+
659
+ /**
660
+ * 对图片路径应用水印,返回处理后的结果。
661
+ * 水印失败时按 onWatermarkError 语义降级:返回 false 阻止上传,其余返回原路径。
662
+ * @param {String} path - 原始图片路径
663
+ * @param {Object} file - 原始文件对象(用于错误上下文与文件名提取)
664
+ * @returns {Promise<{path: String, file: File|null, ext: String, canceled: Boolean}>}
665
+ */
666
+ async applyWatermark(path, file) {
667
+ if (!this.watermarkEnabled) {
668
+ return { path, file: null, ext: '', canceled: false };
669
+ }
670
+
671
+ const normalized = normalizeWatermarkConfig(this.watermark);
672
+ // H5 端从原始 File 对象中提取文件名,保证水印后文件名与扩展名正确
673
+ const fileName = file?.name || (typeof file?.path === 'string' ? file.path.split('/').pop() : '');
674
+
675
+ try {
676
+ const result = await applyImageWatermarkToPath(path, normalized, {
677
+ canvasId: this.watermarkCanvasId,
678
+ instance: this,
679
+ fileName,
680
+ // 非 H5 平台:绘制前动态设置 canvas 尺寸,等待 DOM 更新后再绘制
681
+ setCanvasSize: (width, height) => {
682
+ return new Promise((resolve) => {
683
+ this.watermarkCanvasWidth = width;
684
+ this.watermarkCanvasHeight = height;
685
+ this.$nextTick(() => {
686
+ setTimeout(resolve, 30);
687
+ });
688
+ });
689
+ }
690
+ });
691
+
692
+ // 兼容多种返回形态:
693
+ // - { file, ext, format } 对象:H5 水印渲染层返回 File 对象
694
+ // - { url, format } 对象:非 H5 水印渲染层返回临时路径
695
+ // - 原始字符串路径:processor 自定义处理器返回
696
+ if (result && typeof result === 'object' && result.file) {
697
+ return { path: '', file: result.file, ext: result.ext || '', canceled: false };
698
+ }
699
+ if (result && typeof result === 'object' && result.url) {
700
+ return { path: result.url, file: null, ext: result.format?.ext || '', canceled: false };
701
+ }
702
+ return { path: result, file: null, ext: '', canceled: false };
703
+ } catch (error) {
704
+ const fallbackResult = await resolveWatermarkError(error, {
705
+ path,
706
+ file,
707
+ watermark: normalized
708
+ });
709
+
710
+ if (fallbackResult === false) {
711
+ return { path, file: null, ext: '', canceled: true };
712
+ }
713
+ return { path, file: null, ext: '', canceled: false };
714
+ }
715
+ },
716
+
717
+ /**
718
+ * 非微信小程序:串行处理图片(应用水印)并上传
719
+ * @param {Array<String>} tempFilePaths - 图片临时路径列表
720
+ * @param {Array<Object>} tempFiles - 图片临时文件列表
721
+ */
722
+ async uploadImageWithWatermark(tempFilePaths, tempFiles) {
723
+ try {
724
+ for (let index = 0; index < tempFilePaths.length; index++) {
725
+ const path = tempFilePaths[index];
726
+ const tempFile = tempFiles[index];
727
+
728
+ // 应用水印
729
+ const watermarkResult = await this.applyWatermark(path, tempFile);
730
+ if (watermarkResult.canceled) {
731
+ continue;
732
+ }
733
+
734
+ await this.uploadImageFileAsync(this.finalUploadUrl, watermarkResult.path, watermarkResult.file, watermarkResult.ext);
735
+ }
736
+ } catch (error) {
737
+ console.error('[MediaPicker] 上传流程异常', error);
738
+ uni.hideLoading();
739
+ uni.showToast({ title: '上传失败', icon: 'none' });
740
+ this.$emit('imageUploadFail', { error, message: '图片上传失败' });
741
+ }
742
+ },
743
+
744
+ /**
745
+ * 上传图片文件(非微信小程序,Promise 化,串行处理用)
746
+ * @returns {Promise<void>}
747
+ */
748
+ uploadImageFileAsync(url, filePath, file, ext) {
749
+ return new Promise((resolve) => {
750
+ this.uploadImageFile(url, filePath, file, ext, () => {
751
+ resolve();
752
+ });
753
+ });
754
+ },
755
+
756
+ /**
757
+ * 微信小程序:确保用户已同意隐私协议后再调用媒体选择 API。
758
+ * @returns {Promise<void>}
759
+ */
760
+ ensureWeixinPrivacyAuthorization() {
761
+ return new Promise((resolve, reject) => {
762
+ // #ifdef MP-WEIXIN
763
+ if (typeof wx === 'undefined' || typeof wx.getPrivacySetting !== 'function') {
764
+ resolve();
765
+ return;
766
+ }
767
+
768
+ wx.getPrivacySetting({
769
+ success: (res) => {
770
+ if (!res.needAuthorization) {
771
+ resolve();
772
+ return;
773
+ }
774
+
775
+ if (typeof wx.requirePrivacyAuthorize !== 'function') {
776
+ reject(new Error('当前微信基础库不支持隐私授权'));
777
+ return;
778
+ }
779
+
780
+ wx.requirePrivacyAuthorize({
781
+ success: resolve,
782
+ fail: reject
783
+ });
784
+ },
785
+ fail: reject
786
+ });
787
+ // #endif
788
+
789
+ // #ifndef MP-WEIXIN
790
+ resolve();
791
+ // #endif
792
+ });
793
+ },
794
+
795
+ /**
796
+ * 选择并上传图片
797
+ */
798
+ selectAndUploadImage() {
799
+ if (this.imageFileList.length >= this.imageLimit) {
800
+ // 触发图片数量限制事件
801
+ this.$emit('imageLimitReached', {
802
+ current: this.imageFileList.length,
803
+ limit: this.imageLimit
804
+ });
805
+
806
+ // 如果用户未监听事件,显示默认提示
807
+ if (!this.hasEventListener('imageLimitReached')) {
808
+ uni.showToast({
809
+ icon: 'none',
810
+ title: `上传最大限制${this.imageLimit}张`
811
+ });
812
+ }
813
+ return;
814
+ }
815
+
816
+ if (!this.finalUploadUrl) {
817
+ uni.showToast({
818
+ title: '请先配置上传地址',
819
+ icon: 'none'
820
+ });
821
+ return;
822
+ }
823
+
824
+ const remainCount = this.imageLimit - this.imageFileList.length;
825
+ console.log('chooseImage')
826
+ const chooseImageOptions = {
827
+ count: remainCount,
828
+ sizeType: ['original', 'compressed'],
829
+ sourceType: this.imageSourceType,
830
+ // #ifndef MP-WEIXIN
831
+ success: (chooseImageRes) => {
832
+ if (chooseImageRes.tempFiles.length + this.imageFileList.length > this.imageLimit) {
833
+ // 触发图片数量限制事件
834
+ this.$emit('imageLimitReached', {
835
+ current: this.imageFileList.length,
836
+ limit: this.imageLimit,
837
+ attempted: chooseImageRes.tempFiles.length
838
+ });
839
+
840
+ // 如果用户未监听事件,显示默认提示
841
+ if (!this.hasEventListener('imageLimitReached')) {
842
+ uni.showToast({
843
+ icon: 'none',
844
+ title: `上传最大限制${this.imageLimit}张`
845
+ });
846
+ }
847
+ return;
848
+ }
849
+
850
+ // 检查图片大小
851
+ if (this.imageMaxSize > 0) {
852
+ const oversizedFiles = chooseImageRes.tempFiles.filter((file) => !this.checkImageSize(file.size));
853
+ if (oversizedFiles.length > 0) {
854
+ const firstOversized = oversizedFiles[0];
855
+
856
+ // 触发图片大小超限事件
857
+ this.$emit('imageSizeExceed', {
858
+ size: firstOversized.size,
859
+ maxSize: this.imageMaxSize * 1024 * 1024,
860
+ file: firstOversized,
861
+ message: `图片大小不能超过${this.imageMaxSize}MB(当前:${this.formatFileSize(firstOversized.size)})`
862
+ });
863
+
864
+ // 如果用户未监听事件,显示默认提示
865
+ if (!this.hasEventListener('imageSizeExceed')) {
866
+ uni.showToast({
867
+ icon: 'none',
868
+ title: `图片大小不能超过${this.imageMaxSize}MB(当前:${this.formatFileSize(firstOversized.size)})`,
869
+ duration: 3000
870
+ });
871
+ }
872
+ return;
873
+ }
874
+ }
875
+
876
+ uni.showLoading({
877
+ title: '上传中',
878
+ mask: true
879
+ });
880
+
881
+ this.uploadImageWithWatermark(chooseImageRes.tempFilePaths, chooseImageRes.tempFiles);
882
+ },
883
+ // #endif
884
+
885
+ // #ifdef MP-WEIXIN
886
+ success: (chooseImageRes) => {
887
+ const tempFiles = (chooseImageRes.tempFiles || []).map((file) => ({
888
+ ...file,
889
+ path: file.path || file.tempFilePath
890
+ }));
891
+ const tempFilePaths = tempFiles.map((file) => file.path);
892
+
893
+ if (tempFiles.length + this.imageFileList.length > this.imageLimit) {
894
+ // 触发图片数量限制事件
895
+ this.$emit('imageLimitReached', {
896
+ current: this.imageFileList.length,
897
+ limit: this.imageLimit,
898
+ attempted: tempFiles.length
899
+ });
900
+
901
+ // 如果用户未监听事件,显示默认提示
902
+ if (!this.hasEventListener('imageLimitReached')) {
903
+ uni.showToast({
904
+ icon: 'none',
905
+ title: `上传最大限制${this.imageLimit}张`
906
+ });
907
+ }
908
+ return;
909
+ }
910
+
911
+ // 检查图片大小
912
+ if (this.imageMaxSize > 0) {
913
+ const oversizedFiles = tempFiles.filter((file) => !this.checkImageSize(file.size));
914
+ if (oversizedFiles.length > 0) {
915
+ const firstOversized = oversizedFiles[0];
916
+
917
+ // 触发图片大小超限事件
918
+ this.$emit('imageSizeExceed', {
919
+ size: firstOversized.size,
920
+ maxSize: this.imageMaxSize * 1024 * 1024,
921
+ file: firstOversized,
922
+ message: `图片大小不能超过${this.imageMaxSize}MB(当前:${this.formatFileSize(firstOversized.size)})`
923
+ });
924
+
925
+ // 如果用户未监听事件,显示默认提示
926
+ if (!this.hasEventListener('imageSizeExceed')) {
927
+ uni.showToast({
928
+ icon: 'none',
929
+ title: `图片大小不能超过${this.imageMaxSize}MB(当前:${this.formatFileSize(firstOversized.size)})`,
930
+ duration: 3000
931
+ });
932
+ }
933
+ return;
934
+ }
935
+ }
936
+
937
+ uni.showLoading({
938
+ title: '上传中',
939
+ mask: true
940
+ });
941
+
942
+ // 微信小程序分支:先压缩(如需要),再串行应用水印并上传
943
+ this.uploadWeixinImages(tempFilePaths, tempFiles);
944
+ },
945
+ // #endif
946
+
947
+ fail: (e) => {
948
+ console.log(e)
949
+ }
950
+ };
951
+
952
+ const openImageChooser = () => {
953
+ // #ifdef MP-WEIXIN
954
+ uni.chooseMedia({
955
+ ...chooseImageOptions,
956
+ mediaType: ['image']
957
+ });
958
+ // #endif
959
+ // #ifndef MP-WEIXIN
960
+ uni.chooseImage(chooseImageOptions);
961
+ // #endif
962
+ };
963
+
964
+ // #ifdef MP-WEIXIN
965
+ this.ensureWeixinPrivacyAuthorization()
966
+ .then(openImageChooser)
967
+ .catch((error) => {
968
+ console.error('[MediaPicker] 隐私授权失败', error);
969
+ uni.showToast({
970
+ title: '请先同意隐私协议',
971
+ icon: 'none'
972
+ });
973
+ });
974
+ // #endif
975
+ // #ifndef MP-WEIXIN
976
+ openImageChooser();
977
+ // #endif
978
+ },
979
+
980
+ /**
981
+ * 微信小程序:串行处理图片(压缩 + 水印)并上传
982
+ */
983
+ async uploadWeixinImages(tempFilePaths, tempFiles) {
984
+ try {
985
+ for (let index = 0; index < tempFilePaths.length; index++) {
986
+ const tempPath = tempFilePaths[index];
987
+ const tempFile = tempFiles[index];
988
+ const lowerPath = tempPath.toLowerCase();
989
+
990
+ let targetPath = tempPath;
991
+
992
+ // 对 jpg/jpeg 格式的图片进行压缩
993
+ if (lowerPath.indexOf('.jpg') !== -1 || lowerPath.indexOf('.jpeg') !== -1) {
994
+ targetPath = await this.compressImageByPath(tempFile.path);
995
+ }
996
+
997
+ // 应用水印
998
+ const watermarkResult = await this.applyWatermark(targetPath, tempFile);
999
+ if (watermarkResult.canceled) {
1000
+ continue;
1001
+ }
1002
+
1003
+ await this.uploadImageFileByPathAsync(this.finalUploadUrl, watermarkResult.path, watermarkResult.ext);
1004
+ }
1005
+ } catch (error) {
1006
+ console.error('[MediaPicker] 上传流程异常', error);
1007
+ uni.hideLoading();
1008
+ uni.showToast({ title: '上传失败', icon: 'none' });
1009
+ this.$emit('imageUploadFail', { error, message: '图片上传失败' });
1010
+ }
1011
+ },
1012
+
1013
+ /**
1014
+ * 上传图片文件(微信小程序,Promise 化,串行处理用)
1015
+ * @returns {Promise<void>}
1016
+ */
1017
+ uploadImageFileByPathAsync(url, filePath, ext) {
1018
+ return new Promise((resolve) => {
1019
+ this.uploadImageFileByPath(url, filePath, ext, () => {
1020
+ resolve();
1021
+ });
1022
+ });
1023
+ },
1024
+
1025
+ /**
1026
+ * 微信小程序:压缩图片(Promise 化)
1027
+ * @returns {Promise<String>} 压缩后的临时路径
1028
+ */
1029
+ compressImageByPath(path) {
1030
+ return new Promise((resolve) => {
1031
+ uni.compressImage({
1032
+ src: path,
1033
+ quality: 100,
1034
+ success: (res) => {
1035
+ resolve(res.tempFilePath);
1036
+ },
1037
+ fail: () => {
1038
+ // 压缩失败则返回原路径
1039
+ resolve(path);
1040
+ }
1041
+ });
1042
+ });
1043
+ },
1044
+
1045
+ /**
1046
+ * 上传图片文件(非微信小程序,使用 File 对象)
1047
+ * @param {String} url - 上传地址
1048
+ * @param {String} filePath - 文件路径
1049
+ * @param {File} [file] - 可选,H5 平台水印处理后的 File 对象(携带正确文件名与 MIME)
1050
+ * @param {String} [ext] - 可选,文件扩展名
1051
+ * @param {Function} [onComplete] - 可选,上传完成回调(串行处理用)
1052
+ */
1053
+ uploadImageFile(url, filePath, file, ext, onComplete) {
1054
+ // H5 平台:水印后拿到的是 File 对象,用 files 参数直接上传,
1055
+ // 保证 multipart 里的 filename 与 MIME 正确,避免后端按扩展名校验失败返回 500。
1056
+ // 非 H5 / 无水印:沿用 filePath 上传。
1057
+ const uploadOptions = file ? { files: [{ name: 'file', file, uri: '' }] } : { filePath, name: 'file' };
1058
+
1059
+ uni.uploadFile({
1060
+ url,
1061
+ ...uploadOptions,
1062
+ success: (uploadFileRes) => {
1063
+ if (uploadFileRes.statusCode !== 200) {
1064
+ console.error('HTTP状态码错误:', uploadFileRes.statusCode);
1065
+ uni.showToast({ title: `服务器响应异常(${uploadFileRes.statusCode})`, icon: 'none' });
1066
+ return;
1067
+ }
1068
+
1069
+ try {
1070
+ const result = JSON.parse(uploadFileRes.data);
1071
+
1072
+ if (result.rspCode === '000000') {
1073
+ const fileInfo = {
1074
+ fileName: result.data.fileName,
1075
+ name: result.data.originFileName,
1076
+ url: result.data.pathUrl,
1077
+ type: 'image'
1078
+ };
1079
+ this.imageFileList.push(fileInfo);
1080
+
1081
+ this.$emit('imageUploadSuccess', {
1082
+ uploadFileRes: uploadFileRes,
1083
+ file: fileInfo,
1084
+ response: result
1085
+ });
1086
+ } else {
1087
+ // 后端业务层报错(如:格式不支持、权限不足)
1088
+ uni.showToast({ title: result.rspMsg || '业务处理失败', icon: 'none' });
1089
+ this.$emit('imageUploadFail', { error: result, message: result.rspMsg });
1090
+ }
1091
+ } catch (error) {
1092
+ // 解析 JSON 失败,通常是后端挂了返回了非 JSON 格式
1093
+ console.error('返回数据解析异常:', uploadFileRes.data);
1094
+ uni.showToast({ title: '服务器数据解析失败', icon: 'none' });
1095
+ this.$emit('imageUploadFail', { error: error, message: '解析失败' });
1096
+ }
1097
+ },
1098
+ fail: (error) => {
1099
+ uni.showToast({
1100
+ icon: 'none',
1101
+ title: '上传失败'
1102
+ });
1103
+
1104
+ // 触发图片上传失败事件
1105
+ this.$emit('imageUploadFail', {
1106
+ error: error,
1107
+ message: '图片上传失败'
1108
+ });
1109
+ },
1110
+ complete: () => {
1111
+ uni.hideLoading();
1112
+ this.emitImageChange();
1113
+ if (typeof onComplete === 'function') {
1114
+ onComplete();
1115
+ }
1116
+ }
1117
+ });
1118
+ },
1119
+
1120
+ /**
1121
+ * 上传图片文件(微信小程序,使用文件路径)
1122
+ * @param {String} url - 上传地址
1123
+ * @param {String} filePath - 文件路径
1124
+ * @param {String} [ext] - 可选,文件扩展名(预留,微信端文件名取自临时路径)
1125
+ * @param {Function} [onComplete] - 可选,上传完成回调(串行处理用)
1126
+ */
1127
+ uploadImageFileByPath(url, filePath, ext, onComplete) {
1128
+ uni.uploadFile({
1129
+ url,
1130
+ filePath,
1131
+ name: 'file',
1132
+ success: (uploadFileRes) => {
1133
+ if (uploadFileRes.statusCode !== 200) {
1134
+ console.error('HTTP状态码错误:', uploadFileRes.statusCode, uploadFileRes.data);
1135
+ uni.showToast({ title: `服务器响应异常(${uploadFileRes.statusCode})`, icon: 'none' });
1136
+ return;
1137
+ }
1138
+
1139
+ try {
1140
+ const result = JSON.parse(uploadFileRes.data);
1141
+
1142
+ if (result.rspCode === '000000') {
1143
+ const fileInfo = {
1144
+ fileName: result.data.fileName,
1145
+ name: result.data.originFileName,
1146
+ url: result.data.pathUrl,
1147
+ type: 'image'
1148
+ };
1149
+ this.imageFileList.push(fileInfo);
1150
+
1151
+ // 触发图片上传成功事件
1152
+ this.$emit('imageUploadSuccess', {
1153
+ file: fileInfo,
1154
+ response: result
1155
+ });
1156
+ } else {
1157
+ // 后端业务层报错(如:格式不支持、权限不足)
1158
+ uni.showToast({ title: result.rspMsg || '业务处理失败', icon: 'none' });
1159
+ this.$emit('imageUploadFail', { error: result, message: result.rspMsg });
1160
+ }
1161
+ } catch (error) {
1162
+ console.error('返回数据解析异常:', uploadFileRes.data);
1163
+ uni.showToast({ title: '服务器数据解析失败', icon: 'none' });
1164
+ this.$emit('imageUploadFail', { error, message: '解析失败' });
1165
+ }
1166
+ },
1167
+ fail: (error) => {
1168
+ uni.showToast({
1169
+ icon: 'none',
1170
+ title: '上传失败'
1171
+ });
1172
+
1173
+ // 触发图片上传失败事件
1174
+ this.$emit('imageUploadFail', {
1175
+ error: error,
1176
+ message: '图片上传失败'
1177
+ });
1178
+ },
1179
+ complete: () => {
1180
+ uni.hideLoading();
1181
+ this.emitImageChange();
1182
+ if (typeof onComplete === 'function') {
1183
+ onComplete();
1184
+ }
1185
+ }
1186
+ });
1187
+ },
1188
+
1189
+ /**
1190
+ * 处理图片拖拽排序变化
1191
+ */
1192
+ handleImageDragChange(newUrls) {
1193
+ const newFileList = newUrls
1194
+ .map((url) => {
1195
+ return this.imageFileList.find((file) => file.url === url);
1196
+ })
1197
+ .filter(Boolean);
1198
+
1199
+ this.imageFileList = newFileList;
1200
+ this.emitImageChange();
1201
+ },
1202
+
1203
+ /**
1204
+ * 预览图片
1205
+ */
1206
+ previewImage(urls, current) {
1207
+ uni.previewImage({
1208
+ current,
1209
+ urls,
1210
+ indicator: 'number'
1211
+ });
1212
+ },
1213
+
1214
+ /**
1215
+ * 触发图片变更事件
1216
+ */
1217
+ emitImageChange() {
1218
+ this.$emit('imageChange', this.imageFileList);
1219
+ },
1220
+
1221
+ // ==================== 视频相关方法 ====================
1222
+ /**
1223
+ * 选择并上传视频
1224
+ */
1225
+ selectAndUploadVideo() {
1226
+ if (!this.finalUploadUrl) {
1227
+ uni.showToast({
1228
+ title: '请先配置上传地址',
1229
+ icon: 'none'
1230
+ });
1231
+ return;
1232
+ }
1233
+
1234
+ if (this.videoFileList.length >= this.videoLimit) {
1235
+ // 触发视频数量限制事件
1236
+ this.$emit('videoLimitReached', {
1237
+ current: this.videoFileList.length,
1238
+ limit: this.videoLimit
1239
+ });
1240
+
1241
+ // 如果用户未监听事件,显示默认提示
1242
+ if (!this.hasEventListener('videoLimitReached')) {
1243
+ uni.showToast({
1244
+ title: `最多上传${this.videoLimit}个视频`,
1245
+ icon: 'none'
1246
+ });
1247
+ }
1248
+ return;
1249
+ }
1250
+
1251
+ uni.chooseVideo({
1252
+ sourceType: this.videoSourceType,
1253
+ maxDuration: this.videoMaxDuration > 0 ? this.videoMaxDuration : 60,
1254
+ success: (res) => {
1255
+ // 如果设置了时长限制(大于0),则验证时长
1256
+ if (this.videoMaxDuration > 0 && res.duration > this.videoMaxDuration) {
1257
+ // 触发视频时长超限事件
1258
+ this.$emit('videoDurationExceed', {
1259
+ duration: res.duration,
1260
+ maxDuration: this.videoMaxDuration,
1261
+ file: res,
1262
+ message: `视频时长不能超过${this.videoMaxDuration}秒`
1263
+ });
1264
+
1265
+ // 如果用户未监听事件,显示默认提示
1266
+ if (!this.hasEventListener('videoDurationExceed')) {
1267
+ uni.showToast({
1268
+ title: `视频时长不能超过${this.videoMaxDuration}秒`,
1269
+ icon: 'none'
1270
+ });
1271
+ }
1272
+ return;
1273
+ }
1274
+ this.uploadVideo(res.tempFilePath);
1275
+ }
1276
+ });
1277
+ },
1278
+
1279
+ /**
1280
+ * 上传视频
1281
+ */
1282
+ uploadVideo(filePath) {
1283
+ const uploadTask = uni.uploadFile({
1284
+ url: this.finalUploadUrl,
1285
+ filePath,
1286
+ name: 'file',
1287
+ success: (uploadFileRes) => {
1288
+ const result = JSON.parse(uploadFileRes.data);
1289
+
1290
+ const fileInfo = {
1291
+ fileName: result.data.fileName,
1292
+ name: result.data.originFileName || 'video',
1293
+ url: result.data.pathUrl,
1294
+ type: 'video'
1295
+ };
1296
+
1297
+ this.videoFileList.push(fileInfo);
1298
+ this.emitVideoChange();
1299
+
1300
+ // 触发视频上传成功事件
1301
+ this.$emit('videoUploadSuccess', {
1302
+ file: fileInfo,
1303
+ response: result
1304
+ });
1305
+ },
1306
+ fail: (err) => {
1307
+ uni.showToast({
1308
+ icon: 'none',
1309
+ title: '视频上传失败'
1310
+ });
1311
+
1312
+ // 触发视频上传失败事件
1313
+ this.$emit('videoUploadFail', {
1314
+ error: err,
1315
+ message: '视频上传失败'
1316
+ });
1317
+ },
1318
+ complete: () => {
1319
+ uni.hideLoading();
1320
+ }
1321
+ });
1322
+
1323
+ uploadTask.onProgressUpdate((res) => {
1324
+ uni.showLoading({
1325
+ title: '已上传' + res.progress + '%',
1326
+ mask: true
1327
+ });
1328
+ console.log('上传进度' + res.progress);
1329
+ console.log('已经上传的数据长度' + res.totalBytesSent);
1330
+ console.log('预期需要上传的数据总长度' + res.totalBytesExpectedToSend);
1331
+ // // 测试条件,取消上传任务。
1332
+ if (res.progress === 100) {
1333
+ uni.hideLoading();
1334
+ uni.showLoading({
1335
+ title: '确认中...',
1336
+ mask: true
1337
+ });
1338
+ }
1339
+ });
1340
+ },
1341
+
1342
+ /**
1343
+ * 处理视频拖拽排序变化
1344
+ */
1345
+ handleVideoDragChange(newUrls) {
1346
+ const newFileList = newUrls
1347
+ .map((url) => {
1348
+ return this.videoFileList.find((file) => file.url === url);
1349
+ })
1350
+ .filter(Boolean);
1351
+
1352
+ this.videoFileList = newFileList;
1353
+ this.emitVideoChange();
1354
+ },
1355
+
1356
+ /**
1357
+ * 触发视频变更事件
1358
+ */
1359
+ emitVideoChange() {
1360
+ this.$emit('videoChange', this.videoFileList);
1361
+ },
1362
+
1363
+ /**
1364
+ * 处理拖拽组件中的视频点击
1365
+ */
1366
+ handleVideoItemClick(item) {
1367
+ console.log('点击视频:', item);
1368
+ // shmily-drag-media 传递的是 item.src
1369
+ this.playVideo(item.src);
1370
+ },
1371
+
1372
+ /**
1373
+ * 播放视频(统一播放方法)
1374
+ */
1375
+ playVideo(url) {
1376
+ // 设置视频源
1377
+ this.fullscreenVideoSrc = url;
1378
+ // 等待视频组件更新
1379
+ this.$nextTick(() => {
1380
+ const videoContext = uni.createVideoContext('fullscreenPreviewVideo', this);
1381
+ videoContext.requestFullScreen();
1382
+ videoContext.play();
1383
+ });
1384
+ },
1385
+
1386
+ // ==================== 只读模式交互 ====================
1387
+ /**
1388
+ * 判断是否是最后一个真实元素(用于显示 +N 遮罩)
1389
+ */
1390
+ isLastRealItem(displayIndex) {
1391
+ const file = this.readonlyDisplayList[displayIndex];
1392
+ if (!file || file.type === 'placeholder') return false;
1393
+
1394
+ // 计算占位元素数量
1395
+ const placeholderCount = this.readonlyDisplayList.filter((f) => f.type === 'placeholder').length;
1396
+ const realIndex = displayIndex - placeholderCount;
1397
+
1398
+ // 判断是否是最后一个真实元素,且总数超过限制
1399
+ return this.allFileList.length > this.maxShowCount && realIndex === this.maxShowCount - 1;
1400
+ },
1401
+
1402
+ /**
1403
+ * 处理只读模式媒体项点击
1404
+ */
1405
+ handleReadonlyItemClick(index, file) {
1406
+ // 如果点击的是占位元素,不做任何处理
1407
+ if (!file || file.type === 'placeholder') {
1408
+ return;
1409
+ }
1410
+
1411
+ // 判断是否点击的是遮罩层(最后一项且超出限制)
1412
+ if (this.isLastRealItem(index)) {
1413
+ this.$emit('moreClick', {
1414
+ total: this.allFileList.length,
1415
+ showCount: this.maxShowCount,
1416
+ hiddenCount: this.allFileList.length - this.maxShowCount,
1417
+ allFiles: this.allFileList
1418
+ });
1419
+
1420
+ if (!this.hasEventListener('moreClick')) {
1421
+ this.openPopup();
1422
+ }
1423
+ } else {
1424
+ if (file.type === 'image') {
1425
+ // 预览图片(只提取图片列表)
1426
+ const imageUrls = this.allFileList.filter((f) => f.type === 'image').map((f) => f.url);
1427
+ const imageIndex = this.imageFileList.findIndex((f) => f.url === file.url);
1428
+ this.previewImage(imageUrls, imageIndex);
1429
+ } else if (file.type === 'video') {
1430
+ this.playVideo(file.url);
1431
+ }
1432
+ }
1433
+ },
1434
+
1435
+ /**
1436
+ * 处理弹窗内媒体项点击
1437
+ */
1438
+ handlePopupItemClick(file, index) {
1439
+ if (file.type === 'image') {
1440
+ const imageUrls = this.allFileList.filter((f) => f.type === 'image').map((f) => f.url);
1441
+ const imageIndex = this.imageFileList.findIndex((f) => f.url === file.url);
1442
+ this.previewImage(imageUrls, imageIndex);
1443
+ } else if (file.type === 'video') {
1444
+ this.playVideo(file.url);
1445
+ }
1446
+ },
1447
+
1448
+ // ==================== 弹窗控制 ====================
1449
+ openPopup() {
1450
+ this.popupVisible = true;
1451
+ this.$nextTick(() => {
1452
+ setTimeout(() => {
1453
+ this.popupAnimated = true;
1454
+ this.initPopupLazyObserver(); // 弹窗打开时初始化懒加载观察器
1455
+ }, 50);
1456
+ });
1457
+ },
1458
+
1459
+ closePopup() {
1460
+ this.popupAnimated = false;
1461
+ setTimeout(() => {
1462
+ this.popupVisible = false;
1463
+ if (this.popupObserver) {
1464
+ this.popupObserver.disconnect(); // 弹窗关闭时断开观察器
1465
+ this.popupObserver = null;
1466
+ }
1467
+ }, 300);
1468
+ },
1469
+
1470
+ // ==================== 加载状态管理 ====================
1471
+ onMediaLoad(key) {
1472
+ // #ifdef VUE3
1473
+ this.mediaLoadingStatus[key] = true;
1474
+ // #endif
1475
+ // #ifndef VUE3
1476
+ this.$set(this.mediaLoadingStatus, key, true);
1477
+ // #endif
1478
+ },
1479
+ onMediaError(key) {
1480
+ // #ifdef VUE3
1481
+ this.mediaLoadingStatus[key] = true;
1482
+ // #endif
1483
+ // #ifndef VUE3
1484
+ this.$set(this.mediaLoadingStatus, key, true);
1485
+ // #endif
1486
+ },
1487
+
1488
+ // ==================== 初始化图片懒加载观察器 ====================
1489
+ /**
1490
+ * 初始化图片懒加载观察器
1491
+ */
1492
+ initLazyObserver() {
1493
+ if (this.readonly && this.allFileList.length > 0) {
1494
+ // 确保 observer 每次都被重新创建,以防元素变化
1495
+ if (this.observer) {
1496
+ this.observer.disconnect();
1497
+ this.observer = null;
1498
+ }
1499
+
1500
+ this.lazyLoadImageSrcs = Array(this.allFileList.length).fill(null);
1501
+
1502
+ this.$nextTick(() => {
1503
+ const currentObserver = uni.createIntersectionObserver(this, { observeAll: true });
1504
+ this.observer = currentObserver;
1505
+
1506
+ if (!this.observer) {
1507
+ console.warn('IntersectionObserver failed to create.');
1508
+ return;
1509
+ }
1510
+
1511
+ this.observer.relativeToViewport({ top: 0, bottom: 50 });
1512
+
1513
+ this.allFileList.forEach((file, index) => {
1514
+ if (file.type === 'image') {
1515
+ const sel = '#' + this.componentId + '-imgview-' + index;
1516
+ currentObserver.observe(sel, (res) => {
1517
+ const isVisible = res.intersectionRatio > 0;
1518
+ if (isVisible && !this.lazyLoadImageSrcs[index]) {
1519
+ // #ifdef VUE3
1520
+ this.lazyLoadImageSrcs[index] = file.url;
1521
+ // #endif
1522
+ // #ifndef VUE3
1523
+ this.$set(this.lazyLoadImageSrcs, index, file.url);
1524
+ // #endif
1525
+ }
1526
+ });
1527
+ }
1528
+ });
1529
+ });
1530
+ } else if (this.observer) {
1531
+ this.observer.disconnect();
1532
+ this.observer = null;
1533
+ }
1534
+ },
1535
+
1536
+ /**
1537
+ * 初始化弹窗图片懒加载观察器
1538
+ */
1539
+ initPopupLazyObserver() {
1540
+ if (this.popupVisible && this.allFileList.length > 0) {
1541
+ if (this.popupObserver) {
1542
+ this.popupObserver.disconnect();
1543
+ this.popupObserver = null;
1544
+ }
1545
+
1546
+ this.popupLazyLoadImageSrcs = Array(this.allFileList.length).fill(null);
1547
+
1548
+ this.$nextTick(() => {
1549
+ const currentPopupObserver = uni.createIntersectionObserver(this, { observeAll: true });
1550
+ this.popupObserver = currentPopupObserver;
1551
+
1552
+ if (!this.popupObserver) {
1553
+ console.warn('Popup IntersectionObserver failed to create.');
1554
+ return;
1555
+ }
1556
+
1557
+ // 在弹窗的scroll-view中创建观察器
1558
+ // 注意:这里需要确保scroll-view的selector是唯一的,或者使用组件内实例
1559
+ this.popupObserver.relativeToViewport({ top: 0, bottom: 50 });
1560
+
1561
+ this.allFileList.forEach((file, index) => {
1562
+ if (file.type === 'image') {
1563
+ const sel = '#' + this.componentId + '-popup-imgview-' + index;
1564
+ currentPopupObserver.observe(sel, (res) => {
1565
+ const isVisible = res.intersectionRatio > 0;
1566
+ if (isVisible && !this.popupLazyLoadImageSrcs[index]) {
1567
+ // #ifdef VUE3
1568
+ this.popupLazyLoadImageSrcs[index] = file.url;
1569
+ // #endif
1570
+ // #ifndef VUE3
1571
+ this.$set(this.popupLazyLoadImageSrcs, index, file.url);
1572
+ // #endif
1573
+ }
1574
+ });
1575
+ }
1576
+ });
1577
+ });
1578
+ } else if (this.popupObserver) {
1579
+ this.popupObserver.disconnect();
1580
+ this.popupObserver = null;
1581
+ }
1582
+ },
1583
+
1584
+ // ==================== 通用方法 ====================
1585
+ /**
1586
+ * 获取所有文件列表
1587
+ */
1588
+ getFileList() {
1589
+ if (this.uploadType === 'both') {
1590
+ return {
1591
+ images: this.imageFileList,
1592
+ videos: this.videoFileList
1593
+ };
1594
+ } else if (this.uploadType === 'image') {
1595
+ return this.imageFileList;
1596
+ } else if (this.uploadType === 'video') {
1597
+ return this.videoFileList;
1598
+ }
1599
+ },
1600
+
1601
+ /**
1602
+ * 获取参数(不包含 url 字段)
1603
+ */
1604
+ getParams() {
1605
+ const imageParams = this.imageFileList.map((file) => {
1606
+ const { url, type, ...rest } = file;
1607
+ return rest;
1608
+ });
1609
+ const videoParams = this.videoFileList.map((file) => {
1610
+ const { url, type, ...rest } = file;
1611
+ return rest;
1612
+ });
1613
+ if (this.uploadType === 'both') {
1614
+ return {
1615
+ images: JSON.stringify(imageParams),
1616
+ videos: JSON.stringify(videoParams)
1617
+ };
1618
+ } else if (this.uploadType === 'image') {
1619
+ return {
1620
+ images: JSON.stringify(imageParams)
1621
+ };
1622
+ } else if (this.uploadType === 'video') {
1623
+ return {
1624
+ videos: JSON.stringify(videoParams)
1625
+ };
1626
+ }
1627
+ },
1628
+ // ==================== 私有方法 ====================
1629
+ clearObservers() {
1630
+ if (this.observer) {
1631
+ this.observer.disconnect();
1632
+ this.observer = null;
1633
+ }
1634
+ if (this.popupObserver) {
1635
+ this.popupObserver.disconnect();
1636
+ this.popupObserver = null;
1637
+ }
1638
+ }
1639
+ }
1640
+ };
1641
+ </script>
1642
+
1643
+ <style lang="scss">
1644
+ @import './styles.scss';
1645
+ </style>