@yxhl/specter-pui-vtk 1.0.77 → 1.0.78

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.
@@ -1,602 +1,602 @@
1
- <template>
2
- <div class="vtk-upload">
3
- <!-- 拖拽 / 点击上传区域 -->
4
- <div
5
- v-if="listType !== 'picture-card'"
6
- :class="['vtk-upload__dragger', { 'is-dragover': isDragover, 'is-disabled': disabled }]"
7
- @click="!disabled && triggerInput()"
8
- @dragover.prevent="onDragover"
9
- @dragleave.prevent="isDragover = false"
10
- @drop.prevent="onDrop"
11
- >
12
- <slot>
13
- <div class="vtk-upload__dragger-inner">
14
- <VIcon size="40" color="grey-lighten-1">mdi-cloud-upload-outline</VIcon>
15
- <div class="text-body-2 mt-2 text-grey">将文件拖到此处,或<span class="text-primary" style="cursor:pointer">点击上传</span></div>
16
- <div v-if="tip" class="text-caption text-grey-lighten-1 mt-1">{{ tip }}</div>
17
- </div>
18
- </slot>
19
- </div>
20
-
21
- <!-- picture-card 模式 -->
22
- <div v-if="listType === 'picture-card'" class="vtk-upload__picture-card-wrap">
23
- <div
24
- v-for="file in fileList"
25
- :key="file.uid"
26
- class="vtk-upload__picture-card-item"
27
- >
28
- <v-img :src="file.url || file.preview" cover class="fill-height" />
29
- <div class="vtk-upload__picture-card-mask">
30
- <VBtn v-if="!disabled" icon size="x-small" variant="text" color="white" @click.stop="handlePreview(file)">
31
- <VIcon>mdi-eye</VIcon>
32
- </VBtn>
33
- <VBtn v-if="!disabled" icon size="x-small" variant="text" color="white" @click.stop="handleRemove(file)">
34
- <VIcon>mdi-delete</VIcon>
35
- </VBtn>
36
- </div>
37
- <!-- 上传进度 -->
38
- <div v-if="file.status === 'uploading'" class="vtk-upload__picture-card-progress">
39
- <v-progress-circular :model-value="file.percentage" size="36" color="white" />
40
- </div>
41
- </div>
42
-
43
- <!-- 添加按钮 -->
44
- <div
45
- v-if="!disabled && (limit === 0 || fileList.length < limit)"
46
- :class="['vtk-upload__picture-card-add', { 'is-dragover': isDragover }]"
47
- @click="triggerInput()"
48
- @dragover.prevent="onDragover"
49
- @dragleave.prevent="isDragover = false"
50
- @drop.prevent="onDrop"
51
- >
52
- <VIcon size="28" color="grey-lighten-1">mdi-plus</VIcon>
53
- </div>
54
- </div>
55
-
56
- <!-- 隐藏的 input -->
57
- <input
58
- ref="inputRef"
59
- type="file"
60
- class="vtk-upload__input"
61
- :accept="accept"
62
- :multiple="multiple"
63
- @change="onInputChange"
64
- />
65
-
66
- <!-- 文件列表 (非 picture-card) -->
67
- <div v-if="showFileList && listType !== 'picture-card' && fileList.length > 0" class="vtk-upload__list mt-2">
68
- <div
69
- v-for="file in fileList"
70
- :key="file.uid"
71
- :class="['vtk-upload__list-item', `is-${file.status}`]"
72
- style="cursor: pointer;"
73
- >
74
- <VIcon size="18" class="mr-1" :color="file.status === 'error' ? 'error' : 'primary'">
75
- {{ fileIcon(file) }}
76
- </VIcon>
77
- <span class="vtk-upload__list-item-name flex-grow-1 text-truncate" @click="handlePreview(file)" :title="file.name">{{ file.name }}</span>
78
- <span v-if="file.status === 'uploading'" class="text-caption text-grey ml-2">{{ file.percentage }}%</span>
79
- <VBtn
80
- v-if="!disabled"
81
- icon
82
- size="x-small"
83
- variant="text"
84
- color="grey"
85
- class="ml-1"
86
- @click="handleRemove(file)"
87
- >
88
- <VIcon size="16">mdi-close</VIcon>
89
- </VBtn>
90
- <!-- 进度条 -->
91
- <v-progress-linear
92
- v-if="file.status === 'uploading'"
93
- :model-value="file.percentage"
94
- color="primary"
95
- class="vtk-upload__list-progress"
96
- height="2"
97
- />
98
- </div>
99
- </div>
100
-
101
- <!-- 预览 Dialog -->
102
- <VDialog v-model="previewVisible" max-width="800">
103
- <VCard>
104
- <v-card-title class="d-flex align-center bg-primary">
105
- {{ previewFile?.name }}
106
- <v-spacer></v-spacer>
107
- <v-btn class="mx-0" icon @click="previewVisible = false" variant="plain">
108
- <v-icon>mdi-close</v-icon>
109
- </v-btn>
110
- </v-card-title>
111
- <div class="pa-4 d-flex justify-center">
112
- <v-img v-if="isImage(previewFile)" :src="previewFile?._previewSrc" max-height="600" contain />
113
- <div v-else class="text-center pa-8 text-grey">
114
- <VIcon size="64">{{ isExcel(previewFile) ? 'mdi-microsoft-excel' : 'mdi-file-outline' }}</VIcon>
115
- <div class="mt-2">{{ previewFile?.name }}</div>
116
- <div class="text-caption mt-1">该文件暂时无法在线预览</div>
117
- </div>
118
- </div>
119
- </VCard>
120
- </VDialog>
121
- </div>
122
- </template>
123
-
124
- <script setup>
125
- import { ref, watch } from 'vue';
126
- import Request from '../../commons/request.js';
127
-
128
- defineOptions({
129
- name: 'VtkUpload',
130
- inheritAttrs: false,
131
- });
132
-
133
- const props = defineProps({
134
- /** v-model 文件列表 */
135
- modelValue: {
136
- type: Array,
137
- default: () => [],
138
- },
139
- /** 上传地址 */
140
- action: {
141
- type: String,
142
- default: '',
143
- },
144
- /** 接受的文件类型,同原生 accept */
145
- accept: {
146
- type: String,
147
- default: '',
148
- },
149
- /** 是否多选 */
150
- multiple: {
151
- type: Boolean,
152
- default: false,
153
- },
154
- /** 最大上传数量,0 表示不限制 */
155
- limit: {
156
- type: Number,
157
- default: 0,
158
- },
159
- /** 单文件最大体积,单位 MB,0 表示不限制 */
160
- maxSize: {
161
- type: Number,
162
- default: 0,
163
- },
164
- /** 列表类型:text | picture | picture-card */
165
- listType: {
166
- type: String,
167
- default: 'text',
168
- validator: (v) => ['text', 'picture', 'picture-card'].includes(v),
169
- },
170
- /** 是否显示文件列表 */
171
- showFileList: {
172
- type: Boolean,
173
- default: true,
174
- },
175
- /** 是否自动上传 */
176
- autoUpload: {
177
- type: Boolean,
178
- default: true,
179
- },
180
- /** 是否禁用 */
181
- disabled: {
182
- type: Boolean,
183
- default: false,
184
- },
185
- /** 附加请求头 */
186
- headers: {
187
- type: Object,
188
- default: () => ({}),
189
- },
190
- /** 附加请求数据 */
191
- data: {
192
- type: Object,
193
- default: () => ({}),
194
- },
195
- /** 文件字段名 */
196
- name: {
197
- type: String,
198
- default: 'file',
199
- },
200
- /** 提示文字 */
201
- tip: {
202
- type: String,
203
- default: '',
204
- },
205
- /** 上传前钩子,返回 false 或 rejected Promise 则停止上传 */
206
- beforeUpload: {
207
- type: Function,
208
- default: null,
209
- },
210
- /** 移除前钩子 */
211
- beforeRemove: {
212
- type: Function,
213
- default: null,
214
- },
215
- });
216
-
217
- const emit = defineEmits([
218
- 'update:modelValue',
219
- 'change',
220
- 'success',
221
- 'error',
222
- 'progress',
223
- 'remove',
224
- 'exceed',
225
- 'preview',
226
- ]);
227
-
228
- /* -------------------- 内部状态 -------------------- */
229
- const inputRef = ref(null);
230
- const isDragover = ref(false);
231
- const previewVisible = ref(false);
232
- const previewFile = ref(null);
233
-
234
- // 内部维护文件列表
235
- const fileList = ref([...(props.modelValue || [])]);
236
-
237
- watch(
238
- () => props.modelValue,
239
- (val) => {
240
- // 外部传入字符串数组(url 列表)时跳过,避免覆盖内部状态
241
- if (!val?.length || typeof val[0] === 'string') return;
242
- fileList.value = [...val];
243
- },
244
- );
245
-
246
- /* -------------------- 工具函数 -------------------- */
247
- let uidCounter = 0;
248
- const genUid = () => `vtk-upload-${Date.now()}-${uidCounter++}`;
249
-
250
- const isImage = (file) => {
251
- if (!file) return false;
252
- return /image\//.test(file.type) || /\.(png|jpg|jpeg|gif|bmp|webp|svg)$/i.test(file.name || '');
253
- };
254
-
255
- const fileIcon = (file) => {
256
- if (file.status === 'error') return 'mdi-file-alert-outline';
257
- if (isImage(file)) return 'mdi-file-image-outline';
258
- return 'mdi-file-outline';
259
- };
260
-
261
- /* -------------------- 触发 input -------------------- */
262
- const triggerInput = () => {
263
- inputRef.value && inputRef.value.click();
264
- };
265
-
266
- /* -------------------- 拖拽 -------------------- */
267
- const onDragover = () => {
268
- if (!props.disabled) isDragover.value = true;
269
- };
270
-
271
- const onDrop = (e) => {
272
- isDragover.value = false;
273
- if (props.disabled) return;
274
- processFiles(Array.from(e.dataTransfer.files));
275
- };
276
-
277
- /* -------------------- input change -------------------- */
278
- const onInputChange = (e) => {
279
- processFiles(Array.from(e.target.files));
280
- // 清空,允许重复选同一文件
281
- e.target.value = '';
282
- };
283
-
284
- /* -------------------- 文件处理 -------------------- */
285
- const processFiles = (rawFiles) => {
286
- if (!rawFiles.length) return;
287
-
288
- // 数量限制检查
289
- if (props.limit > 0 && fileList.value.length + rawFiles.length > props.limit) {
290
- emit('exceed', rawFiles, fileList.value);
291
- return;
292
- }
293
-
294
- rawFiles.forEach((raw) => {
295
- // 体积检查
296
- if (props.maxSize > 0 && raw.size / 1024 / 1024 > props.maxSize) {
297
- const errFile = buildFile(raw, 'error');
298
- emit('error', new Error(`文件 ${raw.name} 超过最大限制 ${props.maxSize}MB`), errFile, fileList.value);
299
- return;
300
- }
301
-
302
- const file = buildFile(raw, 'ready');
303
-
304
- // 生成预览
305
- if (isImage(raw)) {
306
- const reader = new FileReader();
307
- reader.onload = (e) => { file.preview = e.target.result; };
308
- reader.readAsDataURL(raw);
309
- }
310
-
311
- const beforeHook = props.beforeUpload ? props.beforeUpload(raw) : true;
312
- Promise.resolve(beforeHook).then((result) => {
313
- if (result === false) return;
314
- addFile(file);
315
- if (props.autoUpload && props.action) {
316
- uploadFile(file);
317
- }
318
- }).catch(() => {});
319
- });
320
- };
321
-
322
- const buildFile = (raw, status) => ({
323
- uid: genUid(),
324
- name: raw.name,
325
- size: raw.size,
326
- type: raw.type,
327
- status,
328
- percentage: 0,
329
- raw,
330
- url: '',
331
- preview: '',
332
- response: null,
333
- });
334
-
335
- const addFile = (file) => {
336
- fileList.value.push(file);
337
- syncModel();
338
- emit('change', file, fileList.value);
339
- };
340
-
341
- const syncModel = () => {
342
- const urls = fileList.value
343
- .filter((f) => f.status === 'success' && f.url)
344
- .map((f) => String(f.url));
345
- emit('update:modelValue', urls);
346
- };
347
-
348
- /* -------------------- 上传逻辑 -------------------- */
349
- const uploadFile = (file) => {
350
- file.status = 'uploading';
351
- const formData = new FormData();
352
- formData.append(props.name, file.raw);
353
- Object.entries(props.data).forEach(([k, v]) => formData.append(k, v));
354
-
355
- // 使用 axios 直接调,以获得 onUploadProgress
356
- const tokenKey = window.VTK_CONFIG?.storageKeys?.token || '_mis_acis_token';
357
- const token = window.$vtk?.storage?.get(tokenKey) || localStorage.getItem(tokenKey);
358
-
359
- const headers = {
360
- 'content-type': 'multipart/form-data',
361
- ...props.headers,
362
- };
363
- if (token) headers['Authorization'] = `Bearer ${token}`;
364
-
365
- Request.http(props.action, formData, 'POST', headers).then((res) => {
366
- file.status = 'success';
367
- file.response = res;
368
- // 兼容 { data: 'url' } 和 { data: { url: '...' } } 两种结构
369
- const url = typeof res?.data === 'string' ? res.data : (res?.data?.url || res?.url || '');
370
- if (url) file.url = url;
371
- console.log('[VtkUpload] res:', res, '| file.url:', file.url, '| fileList:', JSON.parse(JSON.stringify(fileList.value)));
372
- syncModel();
373
- emit('success', res, file, fileList.value);
374
- emit('change', file, fileList.value);
375
- }).catch((err) => {
376
- file.status = 'error';
377
- syncModel();
378
- emit('error', err, file, fileList.value);
379
- emit('change', file, fileList.value);
380
- });
381
- };
382
-
383
- /* -------------------- 移除文件 -------------------- */
384
- const handleRemove = (file) => {
385
- const doRemove = () => {
386
- fileList.value = fileList.value.filter((f) => f.uid !== file.uid);
387
- syncModel();
388
- emit('remove', file, fileList.value);
389
- };
390
-
391
- if (props.beforeRemove) {
392
- Promise.resolve(props.beforeRemove(file, fileList.value)).then((result) => {
393
- if (result !== false) doRemove();
394
- }).catch(() => {});
395
- } else {
396
- doRemove();
397
- }
398
- };
399
-
400
- /* -------------------- 预览 -------------------- */
401
- const isPdf = (file) => /\.pdf$/i.test(file?.name || '') || file?.type === 'application/pdf';
402
- const isExcel = (file) => /\.(xlsx|xls)$/i.test(file?.name || '');
403
-
404
- const handlePreview = (file) => {
405
- emit('preview', file);
406
-
407
- const fileUrl = file.url || file.preview;
408
-
409
- // PDF:新标签页直接打开(浏览器原生支持)
410
- if (isPdf(file)) {
411
- if (fileUrl) {
412
- window.open(fileUrl, '_blank');
413
- } else if (file.raw instanceof File || file.raw instanceof Blob) {
414
- // 本地未上传文件,用 Blob URL 打开
415
- const blobUrl = URL.createObjectURL(file.raw);
416
- const win = window.open(blobUrl, '_blank');
417
- win?.addEventListener('unload', () => URL.revokeObjectURL(blobUrl));
418
- } else {
419
- // 无可用地址也无本地文件,降级到 Dialog
420
- previewFile.value = { ...file, _previewSrc: fileUrl };
421
- previewVisible.value = true;
422
- }
423
- return;
424
- }
425
-
426
- // Excel:Office Online 查看器(需要文件有公网 URL)
427
- if (isExcel(file)) {
428
- if (fileUrl && /^https?:\/\//.test(fileUrl)) {
429
- window.open(`https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(fileUrl)}`, '_blank');
430
- } else {
431
- // 无公网地址,降级为 Dialog 显示提示
432
- previewFile.value = { ...file, _previewSrc: fileUrl };
433
- previewVisible.value = true;
434
- }
435
- return;
436
- }
437
-
438
- // 图片及其他:Dialog 内嵌预览
439
- // 统一挂载可用的预览地址,供 Dialog 使用
440
- previewFile.value = {
441
- ...file,
442
- _previewSrc: fileUrl,
443
- };
444
- previewVisible.value = true;
445
- };
446
-
447
- /* -------------------- 对外暴露方法 -------------------- */
448
- /** 手动触发未上传文件的上传 */
449
- const submit = () => {
450
- fileList.value
451
- .filter((f) => f.status === 'ready' && props.action)
452
- .forEach(uploadFile);
453
- };
454
-
455
- /** 清空文件列表 */
456
- const clearFiles = () => {
457
- fileList.value = [];
458
- syncModel();
459
- };
460
-
461
- /** 手动移除某个文件 */
462
- const remove = (file) => handleRemove(file);
463
-
464
- defineExpose({ submit, clearFiles, remove });
465
- </script>
466
-
467
- <style lang="scss" scoped>
468
- .vtk-upload {
469
- width: 100%;
470
- }
471
-
472
- /* ---- 拖拽区域 ---- */
473
- .vtk-upload__dragger {
474
- border: 1px dashed rgba(0, 0, 0, 0.2);
475
- border-radius: 6px;
476
- padding: 24px 16px;
477
- text-align: center;
478
- cursor: pointer;
479
- transition: border-color 0.2s, background 0.2s;
480
- background: transparent;
481
-
482
- &:hover:not(.is-disabled) {
483
- border-color: rgb(var(--v-theme-primary));
484
- }
485
-
486
- &.is-dragover {
487
- border-color: rgb(var(--v-theme-primary));
488
- background: rgba(var(--v-theme-primary), 0.05);
489
- }
490
-
491
- &.is-disabled {
492
- cursor: not-allowed;
493
- opacity: 0.6;
494
- }
495
- }
496
-
497
- /* ---- 文件列表 ---- */
498
- .vtk-upload__list {
499
- border-top: 1px solid rgba(0, 0, 0, 0.08);
500
- }
501
-
502
- .vtk-upload__list-item {
503
- position: relative;
504
- display: flex;
505
- align-items: center;
506
- padding: 4px 4px 4px 8px;
507
- border-radius: 4px;
508
- font-size: 13px;
509
- transition: background 0.15s;
510
-
511
- &:hover {
512
- background: rgba(0, 0, 0, 0.04);
513
- }
514
-
515
- &.is-error {
516
- color: rgb(var(--v-theme-error));
517
- }
518
-
519
- &.is-success .vtk-upload__list-item-name {
520
- cursor: pointer;
521
- &:hover { color: rgb(var(--v-theme-primary)); }
522
- }
523
- }
524
-
525
- .vtk-upload__list-item-name {
526
- overflow: hidden;
527
- text-overflow: ellipsis;
528
- white-space: nowrap;
529
- max-width: 500px;
530
- }
531
-
532
- .vtk-upload__list-progress {
533
- position: absolute;
534
- bottom: 0;
535
- left: 0;
536
- right: 0;
537
- }
538
-
539
- /* ---- picture-card ---- */
540
- .vtk-upload__picture-card-wrap {
541
- display: flex;
542
- flex-wrap: wrap;
543
- gap: 8px;
544
- }
545
-
546
- .vtk-upload__picture-card-item,
547
- .vtk-upload__picture-card-add {
548
- width: 100px;
549
- height: 100px;
550
- border-radius: 6px;
551
- overflow: hidden;
552
- }
553
-
554
- .vtk-upload__picture-card-item {
555
- position: relative;
556
- border: 1px solid rgba(0, 0, 0, 0.12);
557
-
558
- &:hover .vtk-upload__picture-card-mask {
559
- opacity: 1;
560
- }
561
- }
562
-
563
- .vtk-upload__picture-card-mask {
564
- position: absolute;
565
- inset: 0;
566
- background: rgba(0, 0, 0, 0.45);
567
- display: flex;
568
- align-items: center;
569
- justify-content: center;
570
- gap: 4px;
571
- opacity: 0;
572
- transition: opacity 0.2s;
573
- }
574
-
575
- .vtk-upload__picture-card-progress {
576
- position: absolute;
577
- inset: 0;
578
- background: rgba(0, 0, 0, 0.4);
579
- display: flex;
580
- align-items: center;
581
- justify-content: center;
582
- }
583
-
584
- .vtk-upload__picture-card-add {
585
- border: 1px dashed rgba(0, 0, 0, 0.2);
586
- display: flex;
587
- align-items: center;
588
- justify-content: center;
589
- cursor: pointer;
590
- transition: border-color 0.2s, background 0.2s;
591
-
592
- &:hover, &.is-dragover {
593
- border-color: rgb(var(--v-theme-primary));
594
- background: rgba(var(--v-theme-primary), 0.04);
595
- }
596
- }
597
-
598
- /* ---- 隐藏 input ---- */
599
- .vtk-upload__input {
600
- display: none;
601
- }
602
- </style>
1
+ <template>
2
+ <div class="vtk-upload">
3
+ <!-- 拖拽 / 点击上传区域 -->
4
+ <div
5
+ v-if="listType !== 'picture-card'"
6
+ :class="['vtk-upload__dragger', { 'is-dragover': isDragover, 'is-disabled': disabled }]"
7
+ @click="!disabled && triggerInput()"
8
+ @dragover.prevent="onDragover"
9
+ @dragleave.prevent="isDragover = false"
10
+ @drop.prevent="onDrop"
11
+ >
12
+ <slot>
13
+ <div class="vtk-upload__dragger-inner">
14
+ <VIcon size="40" color="grey-lighten-1">mdi-cloud-upload-outline</VIcon>
15
+ <div class="text-body-2 mt-2 text-grey">将文件拖到此处,或<span class="text-primary" style="cursor:pointer">点击上传</span></div>
16
+ <div v-if="tip" class="text-caption text-grey-lighten-1 mt-1">{{ tip }}</div>
17
+ </div>
18
+ </slot>
19
+ </div>
20
+
21
+ <!-- picture-card 模式 -->
22
+ <div v-if="listType === 'picture-card'" class="vtk-upload__picture-card-wrap">
23
+ <div
24
+ v-for="file in fileList"
25
+ :key="file.uid"
26
+ class="vtk-upload__picture-card-item"
27
+ >
28
+ <v-img :src="file.url || file.preview" cover class="fill-height" />
29
+ <div class="vtk-upload__picture-card-mask">
30
+ <VBtn v-if="!disabled" icon size="x-small" variant="text" color="white" @click.stop="handlePreview(file)">
31
+ <VIcon>mdi-eye</VIcon>
32
+ </VBtn>
33
+ <VBtn v-if="!disabled" icon size="x-small" variant="text" color="white" @click.stop="handleRemove(file)">
34
+ <VIcon>mdi-delete</VIcon>
35
+ </VBtn>
36
+ </div>
37
+ <!-- 上传进度 -->
38
+ <div v-if="file.status === 'uploading'" class="vtk-upload__picture-card-progress">
39
+ <v-progress-circular :model-value="file.percentage" size="36" color="white" />
40
+ </div>
41
+ </div>
42
+
43
+ <!-- 添加按钮 -->
44
+ <div
45
+ v-if="!disabled && (limit === 0 || fileList.length < limit)"
46
+ :class="['vtk-upload__picture-card-add', { 'is-dragover': isDragover }]"
47
+ @click="triggerInput()"
48
+ @dragover.prevent="onDragover"
49
+ @dragleave.prevent="isDragover = false"
50
+ @drop.prevent="onDrop"
51
+ >
52
+ <VIcon size="28" color="grey-lighten-1">mdi-plus</VIcon>
53
+ </div>
54
+ </div>
55
+
56
+ <!-- 隐藏的 input -->
57
+ <input
58
+ ref="inputRef"
59
+ type="file"
60
+ class="vtk-upload__input"
61
+ :accept="accept"
62
+ :multiple="multiple"
63
+ @change="onInputChange"
64
+ />
65
+
66
+ <!-- 文件列表 (非 picture-card) -->
67
+ <div v-if="showFileList && listType !== 'picture-card' && fileList.length > 0" class="vtk-upload__list mt-2">
68
+ <div
69
+ v-for="file in fileList"
70
+ :key="file.uid"
71
+ :class="['vtk-upload__list-item', `is-${file.status}`]"
72
+ style="cursor: pointer;"
73
+ >
74
+ <VIcon size="18" class="mr-1" :color="file.status === 'error' ? 'error' : 'primary'">
75
+ {{ fileIcon(file) }}
76
+ </VIcon>
77
+ <span class="vtk-upload__list-item-name flex-grow-1 text-truncate" @click="handlePreview(file)" :title="file.name">{{ file.name }}</span>
78
+ <span v-if="file.status === 'uploading'" class="text-caption text-grey ml-2">{{ file.percentage }}%</span>
79
+ <VBtn
80
+ v-if="!disabled"
81
+ icon
82
+ size="x-small"
83
+ variant="text"
84
+ color="grey"
85
+ class="ml-1"
86
+ @click="handleRemove(file)"
87
+ >
88
+ <VIcon size="16">mdi-close</VIcon>
89
+ </VBtn>
90
+ <!-- 进度条 -->
91
+ <v-progress-linear
92
+ v-if="file.status === 'uploading'"
93
+ :model-value="file.percentage"
94
+ color="primary"
95
+ class="vtk-upload__list-progress"
96
+ height="2"
97
+ />
98
+ </div>
99
+ </div>
100
+
101
+ <!-- 预览 Dialog -->
102
+ <VDialog v-model="previewVisible" max-width="800">
103
+ <VCard>
104
+ <v-card-title class="d-flex align-center bg-primary">
105
+ {{ previewFile?.name }}
106
+ <v-spacer></v-spacer>
107
+ <v-btn class="mx-0" icon @click="previewVisible = false" variant="plain">
108
+ <v-icon>mdi-close</v-icon>
109
+ </v-btn>
110
+ </v-card-title>
111
+ <div class="pa-4 d-flex justify-center">
112
+ <v-img v-if="isImage(previewFile)" :src="previewFile?._previewSrc" max-height="600" contain />
113
+ <div v-else class="text-center pa-8 text-grey">
114
+ <VIcon size="64">{{ isExcel(previewFile) ? 'mdi-microsoft-excel' : 'mdi-file-outline' }}</VIcon>
115
+ <div class="mt-2">{{ previewFile?.name }}</div>
116
+ <div class="text-caption mt-1">该文件暂时无法在线预览</div>
117
+ </div>
118
+ </div>
119
+ </VCard>
120
+ </VDialog>
121
+ </div>
122
+ </template>
123
+
124
+ <script setup>
125
+ import { ref, watch } from 'vue';
126
+ import Request from '../../commons/request.js';
127
+
128
+ defineOptions({
129
+ name: 'VtkUpload',
130
+ inheritAttrs: false,
131
+ });
132
+
133
+ const props = defineProps({
134
+ /** v-model 文件列表 */
135
+ modelValue: {
136
+ type: Array,
137
+ default: () => [],
138
+ },
139
+ /** 上传地址 */
140
+ action: {
141
+ type: String,
142
+ default: '',
143
+ },
144
+ /** 接受的文件类型,同原生 accept */
145
+ accept: {
146
+ type: String,
147
+ default: '',
148
+ },
149
+ /** 是否多选 */
150
+ multiple: {
151
+ type: Boolean,
152
+ default: false,
153
+ },
154
+ /** 最大上传数量,0 表示不限制 */
155
+ limit: {
156
+ type: Number,
157
+ default: 0,
158
+ },
159
+ /** 单文件最大体积,单位 MB,0 表示不限制 */
160
+ maxSize: {
161
+ type: Number,
162
+ default: 0,
163
+ },
164
+ /** 列表类型:text | picture | picture-card */
165
+ listType: {
166
+ type: String,
167
+ default: 'text',
168
+ validator: (v) => ['text', 'picture', 'picture-card'].includes(v),
169
+ },
170
+ /** 是否显示文件列表 */
171
+ showFileList: {
172
+ type: Boolean,
173
+ default: true,
174
+ },
175
+ /** 是否自动上传 */
176
+ autoUpload: {
177
+ type: Boolean,
178
+ default: true,
179
+ },
180
+ /** 是否禁用 */
181
+ disabled: {
182
+ type: Boolean,
183
+ default: false,
184
+ },
185
+ /** 附加请求头 */
186
+ headers: {
187
+ type: Object,
188
+ default: () => ({}),
189
+ },
190
+ /** 附加请求数据 */
191
+ data: {
192
+ type: Object,
193
+ default: () => ({}),
194
+ },
195
+ /** 文件字段名 */
196
+ name: {
197
+ type: String,
198
+ default: 'file',
199
+ },
200
+ /** 提示文字 */
201
+ tip: {
202
+ type: String,
203
+ default: '',
204
+ },
205
+ /** 上传前钩子,返回 false 或 rejected Promise 则停止上传 */
206
+ beforeUpload: {
207
+ type: Function,
208
+ default: null,
209
+ },
210
+ /** 移除前钩子 */
211
+ beforeRemove: {
212
+ type: Function,
213
+ default: null,
214
+ },
215
+ });
216
+
217
+ const emit = defineEmits([
218
+ 'update:modelValue',
219
+ 'change',
220
+ 'success',
221
+ 'error',
222
+ 'progress',
223
+ 'remove',
224
+ 'exceed',
225
+ 'preview',
226
+ ]);
227
+
228
+ /* -------------------- 内部状态 -------------------- */
229
+ const inputRef = ref(null);
230
+ const isDragover = ref(false);
231
+ const previewVisible = ref(false);
232
+ const previewFile = ref(null);
233
+
234
+ // 内部维护文件列表
235
+ const fileList = ref([...(props.modelValue || [])]);
236
+
237
+ watch(
238
+ () => props.modelValue,
239
+ (val) => {
240
+ // 外部传入字符串数组(url 列表)时跳过,避免覆盖内部状态
241
+ if (!val?.length || typeof val[0] === 'string') return;
242
+ fileList.value = [...val];
243
+ },
244
+ );
245
+
246
+ /* -------------------- 工具函数 -------------------- */
247
+ let uidCounter = 0;
248
+ const genUid = () => `vtk-upload-${Date.now()}-${uidCounter++}`;
249
+
250
+ const isImage = (file) => {
251
+ if (!file) return false;
252
+ return /image\//.test(file.type) || /\.(png|jpg|jpeg|gif|bmp|webp|svg)$/i.test(file.name || '');
253
+ };
254
+
255
+ const fileIcon = (file) => {
256
+ if (file.status === 'error') return 'mdi-file-alert-outline';
257
+ if (isImage(file)) return 'mdi-file-image-outline';
258
+ return 'mdi-file-outline';
259
+ };
260
+
261
+ /* -------------------- 触发 input -------------------- */
262
+ const triggerInput = () => {
263
+ inputRef.value && inputRef.value.click();
264
+ };
265
+
266
+ /* -------------------- 拖拽 -------------------- */
267
+ const onDragover = () => {
268
+ if (!props.disabled) isDragover.value = true;
269
+ };
270
+
271
+ const onDrop = (e) => {
272
+ isDragover.value = false;
273
+ if (props.disabled) return;
274
+ processFiles(Array.from(e.dataTransfer.files));
275
+ };
276
+
277
+ /* -------------------- input change -------------------- */
278
+ const onInputChange = (e) => {
279
+ processFiles(Array.from(e.target.files));
280
+ // 清空,允许重复选同一文件
281
+ e.target.value = '';
282
+ };
283
+
284
+ /* -------------------- 文件处理 -------------------- */
285
+ const processFiles = (rawFiles) => {
286
+ if (!rawFiles.length) return;
287
+
288
+ // 数量限制检查
289
+ if (props.limit > 0 && fileList.value.length + rawFiles.length > props.limit) {
290
+ emit('exceed', rawFiles, fileList.value);
291
+ return;
292
+ }
293
+
294
+ rawFiles.forEach((raw) => {
295
+ // 体积检查
296
+ if (props.maxSize > 0 && raw.size / 1024 / 1024 > props.maxSize) {
297
+ const errFile = buildFile(raw, 'error');
298
+ emit('error', new Error(`文件 ${raw.name} 超过最大限制 ${props.maxSize}MB`), errFile, fileList.value);
299
+ return;
300
+ }
301
+
302
+ const file = buildFile(raw, 'ready');
303
+
304
+ // 生成预览
305
+ if (isImage(raw)) {
306
+ const reader = new FileReader();
307
+ reader.onload = (e) => { file.preview = e.target.result; };
308
+ reader.readAsDataURL(raw);
309
+ }
310
+
311
+ const beforeHook = props.beforeUpload ? props.beforeUpload(raw) : true;
312
+ Promise.resolve(beforeHook).then((result) => {
313
+ if (result === false) return;
314
+ addFile(file);
315
+ if (props.autoUpload && props.action) {
316
+ uploadFile(file);
317
+ }
318
+ }).catch(() => {});
319
+ });
320
+ };
321
+
322
+ const buildFile = (raw, status) => ({
323
+ uid: genUid(),
324
+ name: raw.name,
325
+ size: raw.size,
326
+ type: raw.type,
327
+ status,
328
+ percentage: 0,
329
+ raw,
330
+ url: '',
331
+ preview: '',
332
+ response: null,
333
+ });
334
+
335
+ const addFile = (file) => {
336
+ fileList.value.push(file);
337
+ syncModel();
338
+ emit('change', file, fileList.value);
339
+ };
340
+
341
+ const syncModel = () => {
342
+ const urls = fileList.value
343
+ .filter((f) => f.status === 'success' && f.url)
344
+ .map((f) => String(f.url));
345
+ emit('update:modelValue', urls);
346
+ };
347
+
348
+ /* -------------------- 上传逻辑 -------------------- */
349
+ const uploadFile = (file) => {
350
+ file.status = 'uploading';
351
+ const formData = new FormData();
352
+ formData.append(props.name, file.raw);
353
+ Object.entries(props.data).forEach(([k, v]) => formData.append(k, v));
354
+
355
+ // 使用 axios 直接调,以获得 onUploadProgress
356
+ const tokenKey = window.VTK_CONFIG?.storageKeys?.token || '_mis_acis_token';
357
+ const token = window.$vtk?.storage?.get(tokenKey) || localStorage.getItem(tokenKey);
358
+
359
+ const headers = {
360
+ 'content-type': 'multipart/form-data',
361
+ ...props.headers,
362
+ };
363
+ if (token) headers['Authorization'] = `Bearer ${token}`;
364
+
365
+ Request.http(props.action, formData, 'POST', headers).then((res) => {
366
+ file.status = 'success';
367
+ file.response = res;
368
+ // 兼容 { data: 'url' } 和 { data: { url: '...' } } 两种结构
369
+ const url = typeof res?.data === 'string' ? res.data : (res?.data?.url || res?.url || '');
370
+ if (url) file.url = url;
371
+ console.log('[VtkUpload] res:', res, '| file.url:', file.url, '| fileList:', JSON.parse(JSON.stringify(fileList.value)));
372
+ syncModel();
373
+ emit('success', res, file, fileList.value);
374
+ emit('change', file, fileList.value);
375
+ }).catch((err) => {
376
+ file.status = 'error';
377
+ syncModel();
378
+ emit('error', err, file, fileList.value);
379
+ emit('change', file, fileList.value);
380
+ });
381
+ };
382
+
383
+ /* -------------------- 移除文件 -------------------- */
384
+ const handleRemove = (file) => {
385
+ const doRemove = () => {
386
+ fileList.value = fileList.value.filter((f) => f.uid !== file.uid);
387
+ syncModel();
388
+ emit('remove', file, fileList.value);
389
+ };
390
+
391
+ if (props.beforeRemove) {
392
+ Promise.resolve(props.beforeRemove(file, fileList.value)).then((result) => {
393
+ if (result !== false) doRemove();
394
+ }).catch(() => {});
395
+ } else {
396
+ doRemove();
397
+ }
398
+ };
399
+
400
+ /* -------------------- 预览 -------------------- */
401
+ const isPdf = (file) => /\.pdf$/i.test(file?.name || '') || file?.type === 'application/pdf';
402
+ const isExcel = (file) => /\.(xlsx|xls)$/i.test(file?.name || '');
403
+
404
+ const handlePreview = (file) => {
405
+ emit('preview', file);
406
+
407
+ const fileUrl = file.url || file.preview;
408
+
409
+ // PDF:新标签页直接打开(浏览器原生支持)
410
+ if (isPdf(file)) {
411
+ if (fileUrl) {
412
+ window.open(fileUrl, '_blank');
413
+ } else if (file.raw instanceof File || file.raw instanceof Blob) {
414
+ // 本地未上传文件,用 Blob URL 打开
415
+ const blobUrl = URL.createObjectURL(file.raw);
416
+ const win = window.open(blobUrl, '_blank');
417
+ win?.addEventListener('unload', () => URL.revokeObjectURL(blobUrl));
418
+ } else {
419
+ // 无可用地址也无本地文件,降级到 Dialog
420
+ previewFile.value = { ...file, _previewSrc: fileUrl };
421
+ previewVisible.value = true;
422
+ }
423
+ return;
424
+ }
425
+
426
+ // Excel:Office Online 查看器(需要文件有公网 URL)
427
+ if (isExcel(file)) {
428
+ if (fileUrl && /^https?:\/\//.test(fileUrl)) {
429
+ window.open(`https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(fileUrl)}`, '_blank');
430
+ } else {
431
+ // 无公网地址,降级为 Dialog 显示提示
432
+ previewFile.value = { ...file, _previewSrc: fileUrl };
433
+ previewVisible.value = true;
434
+ }
435
+ return;
436
+ }
437
+
438
+ // 图片及其他:Dialog 内嵌预览
439
+ // 统一挂载可用的预览地址,供 Dialog 使用
440
+ previewFile.value = {
441
+ ...file,
442
+ _previewSrc: fileUrl,
443
+ };
444
+ previewVisible.value = true;
445
+ };
446
+
447
+ /* -------------------- 对外暴露方法 -------------------- */
448
+ /** 手动触发未上传文件的上传 */
449
+ const submit = () => {
450
+ fileList.value
451
+ .filter((f) => f.status === 'ready' && props.action)
452
+ .forEach(uploadFile);
453
+ };
454
+
455
+ /** 清空文件列表 */
456
+ const clearFiles = () => {
457
+ fileList.value = [];
458
+ syncModel();
459
+ };
460
+
461
+ /** 手动移除某个文件 */
462
+ const remove = (file) => handleRemove(file);
463
+
464
+ defineExpose({ submit, clearFiles, remove });
465
+ </script>
466
+
467
+ <style lang="scss" scoped>
468
+ .vtk-upload {
469
+ width: 100%;
470
+ }
471
+
472
+ /* ---- 拖拽区域 ---- */
473
+ .vtk-upload__dragger {
474
+ border: 1px dashed rgba(0, 0, 0, 0.2);
475
+ border-radius: 6px;
476
+ padding: 24px 16px;
477
+ text-align: center;
478
+ cursor: pointer;
479
+ transition: border-color 0.2s, background 0.2s;
480
+ background: transparent;
481
+
482
+ &:hover:not(.is-disabled) {
483
+ border-color: rgb(var(--v-theme-primary));
484
+ }
485
+
486
+ &.is-dragover {
487
+ border-color: rgb(var(--v-theme-primary));
488
+ background: rgba(var(--v-theme-primary), 0.05);
489
+ }
490
+
491
+ &.is-disabled {
492
+ cursor: not-allowed;
493
+ opacity: 0.6;
494
+ }
495
+ }
496
+
497
+ /* ---- 文件列表 ---- */
498
+ .vtk-upload__list {
499
+ border-top: 1px solid rgba(0, 0, 0, 0.08);
500
+ }
501
+
502
+ .vtk-upload__list-item {
503
+ position: relative;
504
+ display: flex;
505
+ align-items: center;
506
+ padding: 4px 4px 4px 8px;
507
+ border-radius: 4px;
508
+ font-size: 13px;
509
+ transition: background 0.15s;
510
+
511
+ &:hover {
512
+ background: rgba(0, 0, 0, 0.04);
513
+ }
514
+
515
+ &.is-error {
516
+ color: rgb(var(--v-theme-error));
517
+ }
518
+
519
+ &.is-success .vtk-upload__list-item-name {
520
+ cursor: pointer;
521
+ &:hover { color: rgb(var(--v-theme-primary)); }
522
+ }
523
+ }
524
+
525
+ .vtk-upload__list-item-name {
526
+ overflow: hidden;
527
+ text-overflow: ellipsis;
528
+ white-space: nowrap;
529
+ max-width: 500px;
530
+ }
531
+
532
+ .vtk-upload__list-progress {
533
+ position: absolute;
534
+ bottom: 0;
535
+ left: 0;
536
+ right: 0;
537
+ }
538
+
539
+ /* ---- picture-card ---- */
540
+ .vtk-upload__picture-card-wrap {
541
+ display: flex;
542
+ flex-wrap: wrap;
543
+ gap: 8px;
544
+ }
545
+
546
+ .vtk-upload__picture-card-item,
547
+ .vtk-upload__picture-card-add {
548
+ width: 100px;
549
+ height: 100px;
550
+ border-radius: 6px;
551
+ overflow: hidden;
552
+ }
553
+
554
+ .vtk-upload__picture-card-item {
555
+ position: relative;
556
+ border: 1px solid rgba(0, 0, 0, 0.12);
557
+
558
+ &:hover .vtk-upload__picture-card-mask {
559
+ opacity: 1;
560
+ }
561
+ }
562
+
563
+ .vtk-upload__picture-card-mask {
564
+ position: absolute;
565
+ inset: 0;
566
+ background: rgba(0, 0, 0, 0.45);
567
+ display: flex;
568
+ align-items: center;
569
+ justify-content: center;
570
+ gap: 4px;
571
+ opacity: 0;
572
+ transition: opacity 0.2s;
573
+ }
574
+
575
+ .vtk-upload__picture-card-progress {
576
+ position: absolute;
577
+ inset: 0;
578
+ background: rgba(0, 0, 0, 0.4);
579
+ display: flex;
580
+ align-items: center;
581
+ justify-content: center;
582
+ }
583
+
584
+ .vtk-upload__picture-card-add {
585
+ border: 1px dashed rgba(0, 0, 0, 0.2);
586
+ display: flex;
587
+ align-items: center;
588
+ justify-content: center;
589
+ cursor: pointer;
590
+ transition: border-color 0.2s, background 0.2s;
591
+
592
+ &:hover, &.is-dragover {
593
+ border-color: rgb(var(--v-theme-primary));
594
+ background: rgba(var(--v-theme-primary), 0.04);
595
+ }
596
+ }
597
+
598
+ /* ---- 隐藏 input ---- */
599
+ .vtk-upload__input {
600
+ display: none;
601
+ }
602
+ </style>