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.
- package/README.md +60 -0
- package/dist/assets/defaultAddPicBg.png +0 -0
- package/dist/assets/file-icon/document.png +0 -0
- package/dist/assets/file-icon/image.png +0 -0
- package/dist/assets/file-icon/ms-excel.png +0 -0
- package/dist/assets/file-icon/ms-powerpoint.png +0 -0
- package/dist/assets/file-icon/ms-word.png +0 -0
- package/dist/assets/file-icon/pdf.png +0 -0
- package/dist/assets/file-icon/video.png +0 -0
- package/dist/assets/file-icon/zip.png +0 -0
- package/dist/assets/loading.png +0 -0
- package/dist/assets/uploadVideoBg.png +0 -0
- package/dist/assets/videoPlay.png +0 -0
- package/dist/components/uni-app/file-picker/file-picker.vue +633 -0
- package/dist/components/uni-app/file-picker/styles.scss +196 -0
- package/dist/components/uni-app/media-picker/media-picker.vue +1645 -0
- package/dist/components/uni-app/media-picker/shmily-drag-media.vue +924 -0
- package/dist/components/uni-app/media-picker/styles.scss +531 -0
- package/dist/components/uni-app/watermark/index.js +633 -0
- package/dist/index.d.ts +452 -0
- package/dist/index.js +9 -0
- package/dist/shims-vue.d.ts +8 -0
- package/dist/types/components/uni-app/watermark/index.d.ts +2 -0
- package/dist/types/utils/watermark-shared.d.ts +42 -0
- package/dist/utils/watermark-shared.js +346 -0
- package/package.json +42 -0
|
@@ -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>
|