@yxhl/specter-pui-vtk 1.0.102 → 1.0.103

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.
@@ -26,7 +26,7 @@
26
26
  :key="file.uid"
27
27
  class="vtk-upload__picture-card-item"
28
28
  >
29
- <v-img :key="file.url || file.preview || file.uid" :src="file.url || file.preview" cover class="fill-height" />
29
+ <v-img :key="file.url || file.preview || file.uid" :src="file.url || file.preview" cover class="fill-height" />
30
30
  <div class="vtk-upload__picture-card-mask">
31
31
  <VBtn icon size="x-small" variant="text" color="white" title="查看" @click.stop="handlePreview(file)">
32
32
  <VIcon>mdi-eye</VIcon>
@@ -217,6 +217,11 @@ const props = defineProps({
217
217
  type: Number,
218
218
  default: 0,
219
219
  },
220
+ /** 单张图片最大像素(宽*高),超过则拒绝上传;传 0 表示不限制 */
221
+ maxPixels: {
222
+ type: Number,
223
+ default: 50_000_000,
224
+ },
220
225
  /** 列表类型:text | picture | picture-card */
221
226
  listType: {
222
227
  type: String,
@@ -297,10 +302,10 @@ const previewTranslateX = ref(0);
297
302
  const previewTranslateY = ref(0);
298
303
  const previewDragState = ref(null);
299
304
 
300
- // 内部维护文件列表
301
- const fileList = ref([]);
302
- let isSyncingModel = false;
303
- const localPreviewUrls = new Set();
305
+ // 内部维护文件列表
306
+ const fileList = ref([]);
307
+ let isSyncingModel = false;
308
+ const localPreviewUrls = new Set();
304
309
 
305
310
  watch(
306
311
  () => props.modelValue,
@@ -310,47 +315,139 @@ watch(
310
315
  return;
311
316
  }
312
317
 
313
- // Accept external URL lists or file object lists.
314
- revokeAllLocalPreviews();
315
- fileList.value = normalizeFileList(val || []);
316
- },
317
- );
318
+ // Accept external URL lists or file object lists.
319
+ revokeAllLocalPreviews();
320
+ fileList.value = normalizeFileList(val || []);
321
+ },
322
+ );
318
323
 
319
324
  /* -------------------- 工具函数 -------------------- */
320
325
  let uidCounter = 0;
321
326
  const genUid = () => `vtk-upload-${Date.now()}-${uidCounter++}`;
322
327
 
323
- const getFileNameFromUrl = (url) => {
324
- const cleanUrl = String(url || '').split('?')[0].split('#')[0];
325
- const name = cleanUrl.substring(cleanUrl.lastIndexOf('/') + 1);
326
- try {
327
- return decodeURIComponent(name || cleanUrl || 'file');
328
+ const getFileNameFromUrl = (url) => {
329
+ const cleanUrl = String(url || '').split('?')[0].split('#')[0];
330
+ const name = cleanUrl.substring(cleanUrl.lastIndexOf('/') + 1);
331
+ try {
332
+ return decodeURIComponent(name || cleanUrl || 'file');
328
333
  } catch (e) {
329
334
  return name || cleanUrl || 'file';
330
- }
331
- };
332
-
333
- const isImageUrl = (url) => {
334
- const value = String(url || '');
335
- const cleanUrl = value.split('?')[0].split('#')[0];
336
- return /^data:image\//i.test(value) || /\.(png|jpg|jpeg|gif|bmp|webp|svg)$/i.test(cleanUrl);
337
- };
338
-
339
- const normalizeFileList = (value) => {
340
- if (!Array.isArray(value)) return [];
341
-
342
- return value
335
+ }
336
+ };
337
+
338
+ const isImageUrl = (url) => {
339
+ const value = String(url || '');
340
+ const cleanUrl = value.split('?')[0].split('#')[0];
341
+ return /^data:image\//i.test(value) || /\.(png|jpg|jpeg|gif|bmp|webp|svg)$/i.test(cleanUrl);
342
+ };
343
+
344
+ const isImageLike = (raw) => {
345
+ if (!raw) return false;
346
+ return /^image\//.test(raw.type || '') || isImageUrl(raw.name || '');
347
+ };
348
+
349
+ const readUInt16BE = (bytes, offset) => (bytes[offset] << 8) | bytes[offset + 1];
350
+ const readUInt16LE = (bytes, offset) => bytes[offset] | (bytes[offset + 1] << 8);
351
+ const readUInt32BE = (bytes, offset) => (
352
+ ((bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]) >>> 0
353
+ );
354
+
355
+ // 按字节解析图片头部宽高(PNG / JPEG / GIF / WEBP),禁止整图解码
356
+ const parseDimensions = (bytes) => {
357
+ const length = bytes.length;
358
+
359
+ // PNG:魔数 89 50 4E 47,IHDR 后宽 16 / 高 20(大端 uint32)
360
+ if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
361
+ return { w: readUInt32BE(bytes, 16), h: readUInt32BE(bytes, 20) };
362
+ }
363
+
364
+ // GIF:魔数 GIF8,宽 6-7 / 高 8-9(小端 uint16)
365
+ if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) {
366
+ return { w: readUInt16LE(bytes, 6), h: readUInt16LE(bytes, 8) };
367
+ }
368
+
369
+ // JPEG:扫 SOF 段(0xC0–0xCF,排除 C4/C8/CC),高在段起点 +5、宽在 +7(大端 uint16)
370
+ if (bytes[0] === 0xff && bytes[1] === 0xd8) {
371
+ const sofMarkers = new Set([
372
+ 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf,
373
+ ]);
374
+ let offset = 2;
375
+ while (offset + 9 < length) {
376
+ if (bytes[offset] !== 0xff) { offset += 1; continue; }
377
+ const segmentStart = offset;
378
+ let p = offset;
379
+ while (p < length && bytes[p] === 0xff) p += 1;
380
+ if (p >= length - 1) break;
381
+ const marker = bytes[p];
382
+ if (marker === 0x00 || marker === 0xd8 || marker === 0xd9) { offset = p + 1; continue; }
383
+ if (sofMarkers.has(marker)) {
384
+ return { w: readUInt16BE(bytes, segmentStart + 7), h: readUInt16BE(bytes, segmentStart + 5) };
385
+ }
386
+ if (p + 2 >= length) break;
387
+ const segLength = readUInt16BE(bytes, p + 2);
388
+ if (segLength < 2) break;
389
+ offset = p + 2 + segLength;
390
+ }
391
+ }
392
+
393
+ // WEBP:RIFF....WEBP
394
+ if (
395
+ bytes.length >= 30
396
+ && bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46
397
+ && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
398
+ ) {
399
+ const fourcc = String.fromCharCode(bytes[12], bytes[13], bytes[14], bytes[15]);
400
+ if (fourcc === 'VP8X') {
401
+ return {
402
+ w: 1 + ((bytes[24]) | (bytes[25] << 8) | (bytes[26] << 16)),
403
+ h: 1 + ((bytes[27]) | (bytes[28] << 8) | (bytes[29] << 16)),
404
+ };
405
+ }
406
+ if (fourcc === 'VP8 ') {
407
+ return {
408
+ w: bytes[26] | ((bytes[27] & 0x3f) << 8),
409
+ h: bytes[28] | ((bytes[29] & 0x3f) << 8),
410
+ };
411
+ }
412
+ if (fourcc === 'VP8L' && length >= 25) {
413
+ return {
414
+ w: 1 + (((bytes[22] & 0x3f) << 8) | bytes[21]),
415
+ h: 1 + (((bytes[24] & 0x0f) << 8) | bytes[23]),
416
+ };
417
+ }
418
+ }
419
+
420
+ return { w: 0, h: 0 };
421
+ };
422
+
423
+ // 只读本地文件头部 8KB 拿尺寸;解析失败返回 null(不阻塞普通图片上传)
424
+ const parseImageSizeFromFile = async (raw) => {
425
+ if (!(raw instanceof Blob)) return null;
426
+ try {
427
+ const buffer = await raw.slice(0, 8192).arrayBuffer();
428
+ const { w, h } = parseDimensions(new Uint8Array(buffer));
429
+ if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return null;
430
+ return { w, h };
431
+ } catch (error) {
432
+ return null;
433
+ }
434
+ };
435
+
436
+ const normalizeFileList = (value) => {
437
+ if (!Array.isArray(value)) return [];
438
+
439
+ return value
343
440
  .filter((item) => item)
344
441
  .map((item) => {
345
442
  if (typeof item === 'string') {
346
443
  return {
347
- uid: genUid(),
348
- name: getFileNameFromUrl(item),
349
- size: 0,
350
- type: props.listType === 'picture-card' || isImageUrl(item) ? 'image/*' : '',
351
- status: 'success',
352
- percentage: 100,
353
- raw: null,
444
+ uid: genUid(),
445
+ name: getFileNameFromUrl(item),
446
+ size: 0,
447
+ type: props.listType === 'picture-card' || isImageUrl(item) ? 'image/*' : '',
448
+ status: 'success',
449
+ percentage: 100,
450
+ raw: null,
354
451
  url: item,
355
452
  preview: '',
356
453
  response: null,
@@ -358,13 +455,13 @@ const normalizeFileList = (value) => {
358
455
  }
359
456
 
360
457
  return {
361
- uid: item.uid || genUid(),
362
- name: item.name || getFileNameFromUrl(item.url || item.preview),
363
- size: item.size || 0,
364
- type: item.type || (props.listType === 'picture-card' && (item.url || item.preview) ? 'image/*' : ''),
365
- status: item.status || 'success',
366
- percentage: item.percentage ?? 100,
367
- raw: item.raw || null,
458
+ uid: item.uid || genUid(),
459
+ name: item.name || getFileNameFromUrl(item.url || item.preview),
460
+ size: item.size || 0,
461
+ type: item.type || (props.listType === 'picture-card' && (item.url || item.preview) ? 'image/*' : ''),
462
+ status: item.status || 'success',
463
+ percentage: item.percentage ?? 100,
464
+ raw: item.raw || null,
368
465
  url: item.url || '',
369
466
  preview: item.preview || '',
370
467
  response: item.response || null,
@@ -375,10 +472,10 @@ const normalizeFileList = (value) => {
375
472
 
376
473
  fileList.value = normalizeFileList(props.modelValue || []);
377
474
 
378
- const isImage = (file) => {
379
- if (!file) return false;
380
- return /image\//.test(file.type) || isImageUrl(file.name) || isImageUrl(file.url) || isImageUrl(file.preview);
381
- };
475
+ const isImage = (file) => {
476
+ if (!file) return false;
477
+ return /image\//.test(file.type) || isImageUrl(file.name) || isImageUrl(file.url) || isImageUrl(file.preview);
478
+ };
382
479
 
383
480
  const fileIcon = (file) => {
384
481
  if (file.status === 'error') return 'mdi-file-alert-outline';
@@ -386,21 +483,35 @@ const fileIcon = (file) => {
386
483
  return 'mdi-file-outline';
387
484
  };
388
485
 
389
- const getPreviewSrc = (file) => file?.url || file?.preview || '';
390
-
391
- const revokeLocalPreview = (file) => {
392
- const preview = file?.preview;
393
- if (preview && localPreviewUrls.has(preview)) {
394
- URL.revokeObjectURL(preview);
395
- localPreviewUrls.delete(preview);
396
- }
397
- };
398
-
399
- const revokeAllLocalPreviews = () => {
400
- fileList.value.forEach(revokeLocalPreview);
401
- localPreviewUrls.forEach((url) => URL.revokeObjectURL(url));
402
- localPreviewUrls.clear();
403
- };
486
+ const getPreviewSrc = (file) => file?.url || file?.preview || '';
487
+
488
+ // 拦截反馈:优先用全局 toast,找不到则降级 alert
489
+ const showBlockMessage = (message) => {
490
+ try {
491
+ const toast = window.$vtk?.message?.toast;
492
+ if (typeof toast === 'function') {
493
+ toast(message, { color: 'error' });
494
+ return;
495
+ }
496
+ } catch (error) {
497
+ console.warn('VtkUpload: failed to show message toast', error);
498
+ }
499
+ window.alert?.(message);
500
+ };
501
+
502
+ const revokeLocalPreview = (file) => {
503
+ const preview = file?.preview;
504
+ if (preview && localPreviewUrls.has(preview)) {
505
+ URL.revokeObjectURL(preview);
506
+ localPreviewUrls.delete(preview);
507
+ }
508
+ };
509
+
510
+ const revokeAllLocalPreviews = () => {
511
+ fileList.value.forEach(revokeLocalPreview);
512
+ localPreviewUrls.forEach((url) => URL.revokeObjectURL(url));
513
+ localPreviewUrls.clear();
514
+ };
404
515
 
405
516
  const previewImageList = computed(() => fileList.value.filter((file) => isImage(file) && getPreviewSrc(file)));
406
517
 
@@ -450,7 +561,7 @@ const processFiles = (rawFiles) => {
450
561
  return;
451
562
  }
452
563
 
453
- rawFiles.forEach((raw) => {
564
+ rawFiles.forEach(async (raw) => {
454
565
  // 体积检查
455
566
  if (props.maxSize > 0 && raw.size / 1024 / 1024 > props.maxSize) {
456
567
  const errFile = buildFile(raw, 'error');
@@ -458,22 +569,34 @@ const processFiles = (rawFiles) => {
458
569
  return;
459
570
  }
460
571
 
572
+ // 分辨率检查:只读图片头部字节,超阈值则拒绝
573
+ if (props.maxPixels > 0 && isImageLike(raw)) {
574
+ const dims = await parseImageSizeFromFile(raw);
575
+ if (dims && dims.w * dims.h > props.maxPixels) {
576
+ const errFile = buildFile(raw, 'error');
577
+ const message = `图片 ${raw.name} 分辨率(宽*高)超出允许限制`;
578
+ showBlockMessage(message);
579
+ emit('error', new Error(message), errFile, fileList.value);
580
+ return;
581
+ }
582
+ }
583
+
461
584
  const file = buildFile(raw, 'ready');
462
585
 
463
- // 生成本地预览地址,保证 picture-card 选图后立即回显缩略图。
464
- if (isImage(raw)) {
465
- if (typeof URL !== 'undefined' && URL.createObjectURL) {
466
- file.preview = URL.createObjectURL(raw);
467
- localPreviewUrls.add(file.preview);
468
- } else {
469
- const reader = new FileReader();
470
- reader.onload = (e) => {
471
- file.preview = e.target.result;
472
- emit('change', file, fileList.value);
473
- };
474
- reader.readAsDataURL(raw);
475
- }
476
- }
586
+ // 生成本地预览地址,保证 picture-card 选图后立即回显缩略图。
587
+ if (isImage(raw)) {
588
+ if (typeof URL !== 'undefined' && URL.createObjectURL) {
589
+ file.preview = URL.createObjectURL(raw);
590
+ localPreviewUrls.add(file.preview);
591
+ } else {
592
+ const reader = new FileReader();
593
+ reader.onload = (e) => {
594
+ file.preview = e.target.result;
595
+ emit('change', file, fileList.value);
596
+ };
597
+ reader.readAsDataURL(raw);
598
+ }
599
+ }
477
600
 
478
601
  const beforeHook = props.beforeUpload ? props.beforeUpload(raw) : true;
479
602
  Promise.resolve(beforeHook).then((result) => {
@@ -536,12 +659,12 @@ const uploadFile = (file) => {
536
659
  Request.http(props.action, formData, 'POST', headers).then((res) => {
537
660
  file.status = 'success';
538
661
  file.response = res;
539
- // 兼容 { data: 'url' } 和 { data: { url: '...' } } 两种结构
540
- const url = typeof res?.data === 'string' ? res.data : (res?.data?.url || res?.url || '');
541
- if (url) {
542
- file.url = url;
543
- revokeLocalPreview(file);
544
- }
662
+ // 兼容 { data: 'url' } 和 { data: { url: '...' } } 两种结构
663
+ const url = typeof res?.data === 'string' ? res.data : (res?.data?.url || res?.url || '');
664
+ if (url) {
665
+ file.url = url;
666
+ revokeLocalPreview(file);
667
+ }
545
668
  console.log('[VtkUpload] res:', res, '| file.url:', file.url, '| fileList:', JSON.parse(JSON.stringify(fileList.value)));
546
669
  syncModel();
547
670
  emit('success', res, file, fileList.value);
@@ -556,11 +679,11 @@ const uploadFile = (file) => {
556
679
 
557
680
  /* -------------------- 移除文件 -------------------- */
558
681
  const handleRemove = (file) => {
559
- const doRemove = () => {
560
- revokeLocalPreview(file);
561
- fileList.value = fileList.value.filter((f) => f.uid !== file.uid);
562
- syncModel();
563
- emit('remove', file, fileList.value);
682
+ const doRemove = () => {
683
+ revokeLocalPreview(file);
684
+ fileList.value = fileList.value.filter((f) => f.uid !== file.uid);
685
+ syncModel();
686
+ emit('remove', file, fileList.value);
564
687
  };
565
688
 
566
689
  if (props.beforeRemove) {
@@ -715,10 +838,10 @@ watch(previewVisible, (visible) => {
715
838
  resetPreviewTransform();
716
839
  });
717
840
 
718
- onBeforeUnmount(() => {
719
- window.removeEventListener('keydown', handlePreviewKeydown);
720
- revokeAllLocalPreviews();
721
- });
841
+ onBeforeUnmount(() => {
842
+ window.removeEventListener('keydown', handlePreviewKeydown);
843
+ revokeAllLocalPreviews();
844
+ });
722
845
 
723
846
  /* -------------------- 对外暴露方法 -------------------- */
724
847
  /** 手动触发未上传文件的上传 */
@@ -729,11 +852,11 @@ const submit = () => {
729
852
  };
730
853
 
731
854
  /** 清空文件列表 */
732
- const clearFiles = () => {
733
- revokeAllLocalPreviews();
734
- fileList.value = [];
735
- syncModel();
736
- };
855
+ const clearFiles = () => {
856
+ revokeAllLocalPreviews();
857
+ fileList.value = [];
858
+ syncModel();
859
+ };
737
860
 
738
861
  /** 手动移除某个文件 */
739
862
  const remove = (file) => handleRemove(file);
@@ -892,24 +1015,24 @@ defineExpose({ submit, clearFiles, remove });
892
1015
  position: relative;
893
1016
  }
894
1017
 
895
- .vtk-upload__preview-nav {
896
- position: fixed;
897
- top: 50%;
898
- z-index: 10;
899
- width: 50px;
900
- height: 50px;
901
- color: #fff;
902
- background: rgba(0, 0, 0, 0.5);
903
- transform: translateY(-50%);
904
- }
905
-
906
- .vtk-upload__preview-nav--prev {
907
- left: 20px;
908
- }
909
-
910
- .vtk-upload__preview-nav--next {
911
- right: 20px;
912
- }
1018
+ .vtk-upload__preview-nav {
1019
+ position: fixed;
1020
+ top: 50%;
1021
+ z-index: 10;
1022
+ width: 50px;
1023
+ height: 50px;
1024
+ color: #fff;
1025
+ background: rgba(0, 0, 0, 0.5);
1026
+ transform: translateY(-50%);
1027
+ }
1028
+
1029
+ .vtk-upload__preview-nav--prev {
1030
+ left: 20px;
1031
+ }
1032
+
1033
+ .vtk-upload__preview-nav--next {
1034
+ right: 20px;
1035
+ }
913
1036
 
914
1037
  .vtk-upload__preview-image-wrap {
915
1038
  min-width: 100%;