@tencentcloud/ai-desk-customer-vue 1.7.3 → 1.7.4

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/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## 1.7.4 @2026.5.26
2
+
3
+ ### Features
4
+ - 支持 H5 端图片的缩放预览
5
+ - 支持图片文件、视频文件在消息列表中直接预览
6
+
1
7
  ## 1.7.3 @2026.4.27
2
8
 
3
9
  ### Features
@@ -27,6 +27,7 @@
27
27
  @closeBottomQuickOrder="closeBottomQuickOrder"
28
28
  :enableFeedback="props.enableFeedback"
29
29
  :enableAINote="props.enableAINote"
30
+ :enableFileMediaPreview="props.enableFileMediaPreview"
30
31
  @like="onLike"
31
32
  @dislike="onDislike"
32
33
  />
@@ -153,6 +154,7 @@ interface IProps {
153
154
  enableFeedback?: number;
154
155
  enableAINote?: number;
155
156
  enableURLDetection?: number;
157
+ enableFileMediaPreview?: number;
156
158
  headerConfig?: IHeaderConfig;
157
159
  enableSendingAudio?: number;
158
160
  showQueuePage?: number;
@@ -205,6 +207,7 @@ const props = withDefaults(defineProps<IProps>(), {
205
207
  enableAINote: 1,
206
208
  langList: () => [],
207
209
  enableURLDetection: 0,
210
+ enableFileMediaPreview: 0,
208
211
  enableSendingAudio: 0,
209
212
  showQueuePage: 0,
210
213
  showAllRobotWelcomeItems: 0,
@@ -53,6 +53,7 @@
53
53
  :enableAINote="props.enableAINote"
54
54
  @like="onLike"
55
55
  @dislike="onDislike"
56
+ @previewImage="handleImagePreviewByUrl"
56
57
  />
57
58
  <div
58
59
  v-else
@@ -72,6 +73,7 @@
72
73
  :messageItem="JSON.parse(JSON.stringify(item))"
73
74
  :enableFeedback="props.enableFeedback"
74
75
  :enableAINote="props.enableAINote"
76
+ :fileMediaType="getFileMediaType(item)"
75
77
  @blinkMessage="blinkMessage"
76
78
  @resendMessage="resendMessage(item)"
77
79
  @like="onLike"
@@ -85,23 +87,27 @@
85
87
  :flow="item.flow"
86
88
  />
87
89
  <ProgressMessage
88
- v-else-if="item.type === TYPES.MSG_IMAGE"
89
- :content="item.getMessageContent()"
90
+ v-else-if="item.type === TYPES.MSG_IMAGE
91
+ || (item.type === TYPES.MSG_FILE && getFileMediaType(item) === 'image')"
92
+ :content="getDisplayImageContent(item)"
90
93
  :messageItem="item"
91
94
  >
92
95
  <MessageImage
93
- :content="item.getMessageContent()"
96
+ :content="getDisplayImageContent(item)"
94
97
  :messageItem="item"
95
- @previewImage="handleImagePreview"
98
+ @previewImage="item.type === TYPES.MSG_IMAGE
99
+ ? handleImagePreview(item)
100
+ : handleImagePreviewByUrl(item.getMessageContent()?.url)"
96
101
  />
97
102
  </ProgressMessage>
98
103
  <ProgressMessage
99
- v-else-if="item.type === TYPES.MSG_VIDEO"
100
- :content="item.getMessageContent()"
104
+ v-else-if="item.type === TYPES.MSG_VIDEO
105
+ || (item.type === TYPES.MSG_FILE && getFileMediaType(item) === 'video')"
106
+ :content="getDisplayVideoContent(item)"
101
107
  :messageItem="item"
102
108
  >
103
109
  <MessageVideo
104
- :content="item.getMessageContent()"
110
+ :content="getDisplayVideoContent(item)"
105
111
  :messageItem="item"
106
112
  />
107
113
  </ProgressMessage>
@@ -143,6 +149,7 @@
143
149
  v-else-if="item.type === TYPES.MSG_STREAM"
144
150
  :content="shallowCopyMessage(item.payload)"
145
151
  @heightChanged="onHeightChanged"
152
+ @previewImage="handleImagePreviewByUrl"
146
153
  />
147
154
  </template>
148
155
  </MessageBubble>
@@ -185,6 +192,7 @@
185
192
  v-if="showImagePreview"
186
193
  :currentImage="currentImagePreview"
187
194
  :imageList="imageMessageList"
195
+ :imageUrl="markdownImagePreviewUrl || undefined"
188
196
  @close="onImagePreviewerClose"
189
197
  />
190
198
  <BottomQuickOrder
@@ -243,12 +251,14 @@ import {
243
251
  isNonEmptyObject,
244
252
  updateCustomStore,
245
253
  throttle,
246
- shallowCopyMessage
254
+ shallowCopyMessage,
255
+ getMediaTypeByUrl
247
256
  } from '../../../utils/utils';
248
257
  import { isMessageInvisible, isThinkingMessage, isThinkingMessageOverTime, JSONToObject, isTransferMessageWithoutDesc } from '../../../utils/index';
249
258
  import { isCustomerConversation } from '../../../index';
250
259
  import { CUSTOM_MESSAGE_SRC } from '../../../constant';
251
260
  import { QuickOrderModel } from '../../../interface';
261
+ import type { IImageMessageContent, IVideoMessageContent } from '../../../interface';
252
262
  import Log from '../../../utils/logger';
253
263
 
254
264
  interface ScrollConfig {
@@ -265,11 +275,13 @@ interface IProps {
265
275
  showBottomQuickOrder: boolean;
266
276
  enableFeedback: number;
267
277
  enableAINote: number;
278
+ enableFileMediaPreview?: number;
268
279
  }
269
280
  const props = withDefaults(defineProps<IProps>(), {
270
281
  showBottomQuickOrder: false,
271
282
  enableFeedback: 0,
272
283
  enableAINote: 1,
284
+ enableFileMediaPreview: 0,
273
285
  });
274
286
 
275
287
  interface IEmits {
@@ -310,6 +322,7 @@ const enableAutoScroll = ref(true);
310
322
  // image preview
311
323
  const showImagePreview = ref(false);
312
324
  const currentImagePreview = ref<IMessageModel>();
325
+ const markdownImagePreviewUrl = ref<string>('');
313
326
  const imageMessageList = computed(() =>
314
327
  messageList?.value?.filter((item: IMessageModel) => {
315
328
  return (
@@ -598,9 +611,55 @@ const handleImagePreview = (message: IMessageModel) => {
598
611
  currentImagePreview.value = message;
599
612
  };
600
613
 
614
+ const getFileMediaType = (message: IMessageModel): '' | 'image' | 'video' => {
615
+ if (!props.enableFileMediaPreview) return '';
616
+ if (message.type !== TYPES.value.MSG_FILE) return '';
617
+ const content = message.getMessageContent?.() || {};
618
+ const mediaType = getMediaTypeByUrl(content.url, content.name);
619
+ return mediaType === 'image' || mediaType === 'video' ? mediaType : '';
620
+ };
621
+
622
+ const getDisplayImageContent = (message: IMessageModel): IImageMessageContent => {
623
+ if (message.type === TYPES.value.MSG_FILE) {
624
+ return mapFileToImageContent(message.getMessageContent());
625
+ }
626
+ return message.getMessageContent();
627
+ };
628
+
629
+ const getDisplayVideoContent = (message: IMessageModel): IVideoMessageContent => {
630
+ if (message.type === TYPES.value.MSG_FILE) {
631
+ return mapFileToVideoContent(message.getMessageContent());
632
+ }
633
+ return message.getMessageContent();
634
+ };
635
+
636
+ const mapFileToImageContent = (content: any): IImageMessageContent => ({
637
+ url: content.url,
638
+ showName: content.name,
639
+ width: 0,
640
+ height: 0,
641
+ });
642
+
643
+ const mapFileToVideoContent = (content: any): IVideoMessageContent => ({
644
+ url: content.url,
645
+ showName: content.name,
646
+ snapshotUrl: '',
647
+ snapshotWidth: 0,
648
+ snapshotHeight: 0,
649
+ });
650
+
651
+ const handleImagePreviewByUrl = (url: string) => {
652
+ if (showImagePreview.value || isLongpressing.value || !url) {
653
+ return;
654
+ }
655
+ markdownImagePreviewUrl.value = url;
656
+ showImagePreview.value = true;
657
+ };
658
+
601
659
  const onImagePreviewerClose = () => {
602
660
  showImagePreview.value = false;
603
661
  currentImagePreview.value = null;
662
+ markdownImagePreviewUrl.value = '';
604
663
  };
605
664
 
606
665
  // toggle message
@@ -55,11 +55,7 @@
55
55
  />
56
56
  <div class="content-main">
57
57
  <img
58
- v-if="
59
- (message.type === TYPES.MSG_IMAGE ||
60
- message.type === TYPES.MSG_VIDEO) &&
61
- message.hasRiskContent
62
- "
58
+ v-if="isDisplayAsImageOrVideo && message.hasRiskContent"
63
59
  :class="[
64
60
  'message-risk-replace',
65
61
  !isPC && 'message-risk-replace-h5',
@@ -168,6 +164,7 @@ interface IProps {
168
164
  isAudioPlayed?: boolean | undefined;
169
165
  enableFeedback: number;
170
166
  enableAINote: number;
167
+ fileMediaType?: '' | 'image' | 'video';
171
168
  }
172
169
 
173
170
  interface IEmits {
@@ -189,6 +186,7 @@ const props = withDefaults(defineProps<IProps>(), {
189
186
  classNameList: () => [],
190
187
  enableFeedback: 0,
191
188
  enableAINote: 1,
189
+ fileMediaType: '',
192
190
  });
193
191
 
194
192
  const TYPES = TUIChatEngine.TYPES;
@@ -317,11 +315,22 @@ const isProductCardOrOrderMessage = computed(() => {
317
315
  return false;
318
316
  });
319
317
 
318
+ const isDisplayAsImageOrVideo = computed(() => {
319
+ if (props.fileMediaType === 'image' || props.fileMediaType === 'video') {
320
+ return true;
321
+ }
322
+ return (
323
+ message.value.type === TYPES.MSG_IMAGE
324
+ || message.value.type === TYPES.MSG_VIDEO
325
+ );
326
+ });
327
+
320
328
  const isNoPadding = computed(() => {
321
329
  return (
322
330
  !hasEmojiReaction.value
323
- && [TYPES.MSG_IMAGE, TYPES.MSG_VIDEO, TYPES.MSG_MERGER].includes(
324
- message.value.type,
331
+ && (
332
+ isDisplayAsImageOrVideo.value
333
+ || message.value.type === TYPES.MSG_MERGER
325
334
  )
326
335
  );
327
336
  });
@@ -38,12 +38,14 @@
38
38
  <MessageRichText
39
39
  :payload="payload"
40
40
  @heightChanged="onHeightChanged"
41
+ @previewImage="onPreviewImage"
41
42
  />
42
43
  </div>
43
44
  <div v-if="payload.src === CUSTOM_MESSAGE_SRC.STREAM_TEXT">
44
45
  <MessageStream
45
46
  :payload="payload"
46
47
  @heightChanged="onHeightChanged"
48
+ @previewImage="onPreviewImage"
47
49
  />
48
50
  </div>
49
51
  <div v-if="payload.src === CUSTOM_MESSAGE_SRC.MULTI_BRANCH">
@@ -132,7 +134,7 @@ export default {
132
134
  default: () => ({}),
133
135
  },
134
136
  },
135
- emits: ['showFormPopup', 'heightChanged', 'messageSent'],
137
+ emits: ['showFormPopup', 'heightChanged', 'messageSent', 'previewImage'],
136
138
  setup(props: Props, { emit }) {
137
139
  const payload = computed<customerServicePayloadType>(() => {
138
140
  return props.message && JSONToObject(props.message?.payload?.data);
@@ -163,6 +165,9 @@ export default {
163
165
  const onHeightChanged = () => {
164
166
  emit('heightChanged');
165
167
  };
168
+ const onPreviewImage = (url: string) => {
169
+ emit('previewImage', url);
170
+ };
166
171
 
167
172
  return {
168
173
  payload,
@@ -172,6 +177,7 @@ export default {
172
177
  sendCustomMessage,
173
178
  handleShowFormPopup,
174
179
  onHeightChanged,
180
+ onPreviewImage,
175
181
  };
176
182
  },
177
183
  };
@@ -5,15 +5,6 @@
5
5
  class="message-marked"
6
6
  v-html="parsedContent"
7
7
  />
8
- <div v-if="image" class="rich-image-previewer" @click="closeImage">
9
- <img
10
- class="rich-image-preview"
11
- :style="{
12
- transform: `scale(1) rotate(0deg)`,
13
- }"
14
- :src="imageSrc"
15
- />
16
- </div>
17
8
  </div>
18
9
  </div>
19
10
  </template>
@@ -22,7 +13,7 @@
22
13
  import vue from '../../../../../../adapter-vue';
23
14
  import { parseMarkdown } from "./marked";
24
15
  import { customerServicePayloadType } from '../../../../../../interface';
25
- const { computed,ref } = vue;
16
+ const { computed } = vue;
26
17
  interface Props {
27
18
  payload: customerServicePayloadType;
28
19
  }
@@ -34,15 +25,12 @@ export default {
34
25
  default: () => ({}),
35
26
  },
36
27
  },
28
+ emits: ['heightChanged', 'previewImage'],
37
29
  setup(props: Props, { emit }) {
38
- const image = ref(false);
39
- const imageSrc = ref('');
40
- const imageList = [];
41
30
  const parsedContent = computed(() => {
42
31
  // @ts-ignore
43
32
  window.onMarkdownImageClicked = function(href: string) {
44
- image.value = !image.value;
45
- imageSrc.value = decodeURIComponent(href);
33
+ emit('previewImage', decodeURIComponent(href));
46
34
  }
47
35
  // @ts-ignore
48
36
  window.onMarkdownMediaLoad = function() {
@@ -51,47 +39,14 @@ export default {
51
39
  return parseMarkdown(props.payload.content);
52
40
  });
53
41
 
54
- const closeImage = () => {
55
- image.value = !image.value;
56
- imageSrc.value = '';
57
- }
58
-
59
42
  return {
60
43
  props,
61
44
  parsedContent,
62
- image,
63
- imageSrc,
64
- closeImage,
65
- imageList,
66
45
  };
67
46
  },
68
47
  };
69
48
  </script>
70
49
  <style lang="scss">
71
- .rich-image-previewer {
72
- position: fixed;
73
- z-index: 101;
74
- width: 100vw;
75
- height: 100vh;
76
- background: rgba(#000, 0.8);
77
- top: 0;
78
- left: 0;
79
- display: flex;
80
- flex-direction: column;
81
- align-items: center;
82
- justify-content: center;
83
- user-select: none;
84
- }
85
-
86
- .rich-image-preview {
87
- max-width: 90%;
88
- height: auto;
89
- display: flex;
90
- align-items: center;
91
- justify-content: center;
92
- overflow: hidden;
93
- pointer-events: auto;
94
- }
95
50
  .message-video {
96
51
  position: relative;
97
52
  display: flex;
@@ -145,4 +100,8 @@ export default {
145
100
  }
146
101
  }
147
102
  }
103
+
104
+ .message-marked .image-container {
105
+ -webkit-tap-highlight-color: transparent;
106
+ }
148
107
  </style>
@@ -4,12 +4,6 @@
4
4
  <span v-if="isCursorBlinking" class="blinking-cursor">|</span>
5
5
  <div ref="contentRef" :class="['message-marked']"></div>
6
6
  </div>
7
- <div v-if="image" class="rich-image-previewer" @click="closeImage">
8
- <img
9
- class="rich-image-preview"
10
- :src="imageSrc"
11
- />
12
- </div>
13
7
  </div>
14
8
  </template>
15
9
 
@@ -32,8 +26,6 @@ const props = withDefaults(defineProps<Props>(), {
32
26
 
33
27
  const isCursorBlinking = ref<boolean>(true);
34
28
  const isStreaming = ref<boolean>(false);
35
- const image = ref(false);
36
- const imageSrc = ref('');
37
29
  const chunks = ref<string>('');
38
30
  const isFinished = ref<boolean>(true);
39
31
  const prevChunksLength = ref<number>(0);
@@ -43,8 +35,7 @@ const contentRef = ref<HTMLElement>(); // DOM 容器引用
43
35
  // 设置全局回调函数(只需设置一次)
44
36
  // @ts-ignore
45
37
  window.onMarkdownImageClicked = function(href: string) {
46
- image.value = !image.value;
47
- imageSrc.value = decodeURIComponent(href);
38
+ emits('previewImage', decodeURIComponent(href));
48
39
  }
49
40
  // @ts-ignore
50
41
  window.onMarkdownMediaLoad = function() {
@@ -288,7 +279,7 @@ watch(streamContent, () => {
288
279
  doRender();
289
280
  });
290
281
 
291
- const emits = defineEmits(['heightChanged']);
282
+ const emits = defineEmits(['heightChanged', 'previewImage']);
292
283
  // 不支持 ResizeObserver 的浏览器太老旧,默认不处理了
293
284
  const canIUseResizeObserver = typeof ResizeObserver === 'undefined' ? false : true;
294
285
 
@@ -431,11 +422,6 @@ const observeHeightChanged = (newHeight) => {
431
422
  }
432
423
  };
433
424
 
434
- const closeImage = () => {
435
- image.value = !image.value;
436
- imageSrc.value = '';
437
- };
438
-
439
425
  </script>
440
426
  <style lang="scss" scoped>
441
427
  @import "../../../../style/common.scss";
@@ -452,6 +438,10 @@ const closeImage = () => {
452
438
  margin-left: 1.5em;
453
439
  }
454
440
 
441
+ ::v-deep .message-marked .image-container {
442
+ -webkit-tap-highlight-color: transparent;
443
+ }
444
+
455
445
  .blinking-cursor {
456
446
  animation: blink 0.8s step-end infinite;
457
447
  color: #000;
@@ -20,6 +20,7 @@
20
20
  :message="props.message"
21
21
  @heightChanged="onHeightChanged"
22
22
  @messageSent="onMessageSent"
23
+ @previewImage="onPreviewImage"
23
24
  />
24
25
  </template>
25
26
  <template #messageTip>
@@ -65,6 +66,7 @@ const emits = defineEmits([
65
66
  'messageSent',
66
67
  'like',
67
68
  'dislike',
69
+ 'previewImage',
68
70
  ]);
69
71
  const messageModel = computed(() => TUIStore.getMessageModel(props.message.ID));
70
72
 
@@ -111,6 +113,10 @@ const onMessageSent = () => {
111
113
  emits('messageSent');
112
114
  };
113
115
 
116
+ const onPreviewImage = (url: string) => {
117
+ emits('previewImage', url);
118
+ };
119
+
114
120
  function onLike(type: number, messageInfo: Object) {
115
121
  emits('like', type, messageInfo);
116
122
  };
@@ -81,6 +81,7 @@ function toggleShow() {
81
81
  .image-container {
82
82
  overflow: hidden;
83
83
  background-color: #f4f4f4;
84
+ -webkit-tap-highlight-color: transparent;
84
85
 
85
86
  .message-image {
86
87
  max-width: min(calc(100vw - 180px), 300px);
@@ -4,12 +4,6 @@
4
4
  <span v-if="isCursorBlinking" class="blinking-cursor">|</span>
5
5
  <div ref="preRef" :class="['message-marked']" v-html="displayedContent" />
6
6
  </div>
7
- <div v-if="image" class="rich-image-previewer" @click="closeImage">
8
- <img
9
- class="rich-image-preview"
10
- :src="imageSrc"
11
- />
12
- </div>
13
7
  </div>
14
8
  </template>
15
9
 
@@ -32,8 +26,6 @@ const props = withDefaults(defineProps<Props>(), {
32
26
 
33
27
  const isCursorBlinking = ref<boolean>(true);
34
28
  const isStreaming = ref<boolean>(false);
35
- const image = ref(false);
36
- const imageSrc = ref('');
37
29
  const markdown = ref<string>('');
38
30
  const isStreamEnded = ref<boolean>(true);
39
31
  const prevmarkdownLength = ref<number>(0);
@@ -41,8 +33,7 @@ const streamContent = ref<string>('');
41
33
  const displayedContent = computed(() => {
42
34
  // @ts-ignore
43
35
  window.onMarkdownImageClicked = function(href:string) {
44
- image.value = !image.value;
45
- imageSrc.value = decodeURIComponent(href);
36
+ emits('previewImage', decodeURIComponent(href));
46
37
  }
47
38
  // @ts-ignore
48
39
  window.onMarkdownMediaLoad = function() {
@@ -53,7 +44,7 @@ const displayedContent = computed(() => {
53
44
  return result;
54
45
  });
55
46
  const preRef = ref();
56
- const emits = defineEmits(['heightChanged']);
47
+ const emits = defineEmits(['heightChanged', 'previewImage']);
57
48
  // 不支持 ResizeObserver 的浏览器太老旧,默认不处理了
58
49
  const canIUseResizeObserver = typeof ResizeObserver === 'undefined' ? false : true;
59
50
 
@@ -141,11 +132,6 @@ const observeHeightChanged = (newHeight) => {
141
132
  }
142
133
  };
143
134
 
144
- const closeImage = () => {
145
- image.value = !image.value;
146
- imageSrc.value = '';
147
- };
148
-
149
135
  </script>
150
136
  <style lang="scss" scoped>
151
137
  @import "../../style/common.scss";
@@ -45,10 +45,14 @@
45
45
  class="dialog-video"
46
46
  >
47
47
  <div
48
- class="dialog-video-close"
48
+ class="icon icon-close"
49
49
  @click.stop="toggleVideoPreviewer"
50
50
  >
51
- <Icon :file="closeSVG" />
51
+ <Icon
52
+ :file="closeSVG"
53
+ width="14px"
54
+ height="14px"
55
+ />
52
56
  </div>
53
57
  <div
54
58
  class="dialog-video-box"
@@ -277,13 +281,21 @@ async function handlePosterUrl(
277
281
  flex-direction: column;
278
282
  align-items: center;
279
283
 
280
- &-close {
284
+ .icon-close {
285
+ position: absolute;
286
+ cursor: pointer;
287
+ border-radius: 50%;
288
+ top: 3%;
289
+ right: 3%;
290
+ padding: 8px;
291
+ background: #fff;
281
292
  display: flex;
282
- justify-content: flex-end;
283
- background: #000;
284
- width: 100%;
285
- box-sizing: border-box;
286
- padding: 10px;
293
+ z-index: 1;
294
+
295
+ &:before,
296
+ &:after {
297
+ background-color: #444;
298
+ }
287
299
  }
288
300
 
289
301
  &-box {
@@ -2,7 +2,7 @@
2
2
  <img
3
3
  class="image-preview"
4
4
  :style="{
5
- transform: `scale(${props.zoom}) rotate(${props.rotate}deg)`,
5
+ transform: `translate3d(${props.translateX}px, ${props.translateY}px, 0) scale(${props.zoom}) rotate(${props.rotate}deg)`,
6
6
  }"
7
7
  :src="props.src"
8
8
  >
@@ -21,6 +21,14 @@ const props = defineProps({
21
21
  type: String,
22
22
  default: '',
23
23
  },
24
+ translateX: {
25
+ type: Number,
26
+ default: 0,
27
+ },
28
+ translateY: {
29
+ type: Number,
30
+ default: 0,
31
+ },
24
32
  });
25
33
  </script>
26
34
  <style lang="scss">
@@ -13,6 +13,7 @@
13
13
  @wheel.stop="handleWheel"
14
14
  >
15
15
  <ul
16
+ v-if="!props.imageUrl"
16
17
  ref="ulRef"
17
18
  class="image-list"
18
19
  :style="{
@@ -31,40 +32,59 @@
31
32
  <ImageItem
32
33
  :zoom="zoom"
33
34
  :rotate="rotate"
35
+ :translateX="translateX"
36
+ :translateY="translateY"
34
37
  :src="getImageUrl(item)"
35
38
  :messageItem="item"
36
39
  />
37
40
  </li>
38
41
  </ul>
42
+ <ul
43
+ v-else
44
+ ref="ulRef"
45
+ class="image-list"
46
+ :style="{ width: '100%' }"
47
+ >
48
+ <li class="image-item">
49
+ <ImageItem
50
+ :zoom="zoom"
51
+ :rotate="rotate"
52
+ :translateX="translateX"
53
+ :translateY="translateY"
54
+ :src="props.imageUrl"
55
+ />
56
+ </li>
57
+ </ul>
39
58
  </div>
40
59
  <div
41
- v-show="isPC"
42
60
  class="icon icon-close"
43
61
  @click="close"
44
62
  >
45
63
  <Icon
46
64
  :file="iconClose"
47
- width="16px"
48
- height="16px"
65
+ width="14px"
66
+ height="14px"
49
67
  />
50
68
  </div>
51
69
  <div
52
- v-if="isPC && currentImageIndex > 0"
70
+ v-if="isPC && !props.imageUrl && currentImageIndex > 0"
53
71
  class="image-button image-button-left"
54
72
  @click="goPrev"
55
73
  >
56
74
  <Icon :file="iconArrowLeft" />
57
75
  </div>
58
76
  <div
59
- v-if="isPC && currentImageIndex < imageList.length - 1"
77
+ v-if="isPC && !props.imageUrl && currentImageIndex < imageList.length - 1"
60
78
  class="image-button image-button-right"
61
79
  @click="goNext"
62
80
  >
63
81
  <Icon :file="iconArrowLeft" />
64
82
  </div>
65
- <div :class="['actions-bar', isMobile && 'actions-bar-h5']">
83
+ <div
84
+ v-if="isPC"
85
+ :class="['actions-bar', isMobile && 'actions-bar-h5']"
86
+ >
66
87
  <div
67
- v-if="isPC"
68
88
  class="icon-zoom-in"
69
89
  @click="zoomIn"
70
90
  >
@@ -75,7 +95,6 @@
75
95
  />
76
96
  </div>
77
97
  <div
78
- v-if="isPC"
79
98
  class="icon-zoom-out"
80
99
  @click="zoomOut"
81
100
  >
@@ -86,7 +105,6 @@
86
105
  />
87
106
  </div>
88
107
  <div
89
- v-if="isPC"
90
108
  class="icon-refresh-left"
91
109
  @click="rotateLeft"
92
110
  >
@@ -97,7 +115,6 @@
97
115
  />
98
116
  </div>
99
117
  <div
100
- v-if="isPC"
101
118
  class="icon-refresh-right"
102
119
  @click="rotateRight"
103
120
  >
@@ -107,7 +124,7 @@
107
124
  height="27px"
108
125
  />
109
126
  </div>
110
- <span class="image-counter">
127
+ <span v-if="!props.imageUrl" class="image-counter">
111
128
  {{ currentImageIndex + 1 }} / {{ imageList.length }}
112
129
  </span>
113
130
  </div>
@@ -154,10 +171,12 @@ const props = withDefaults(
154
171
  defineProps<{
155
172
  imageList: IMessageModel[];
156
173
  currentImage: IMessageModel;
174
+ imageUrl?: string;
157
175
  }>(),
158
176
  {
159
177
  imageList: () => [] as IMessageModel[],
160
178
  messageItem: () => ({} as IMessageModel),
179
+ imageUrl: '',
161
180
  },
162
181
  );
163
182
 
@@ -175,14 +194,33 @@ const minZoom = ref(0.1);
175
194
  const currentImageIndex = ref(0);
176
195
  const image = ref();
177
196
  const ulRef = ref();
197
+ const translateX = ref(0);
198
+ const translateY = ref(0);
178
199
  // touch
179
200
  let startX = 0;
201
+ let startY = 0;
202
+ let baseTranslateX = 0;
203
+ let baseTranslateY = 0;
180
204
  const touchStore = {} as touchesPosition;
181
205
  let moveFlag = false;
182
206
  let twoTouchesFlag = false;
183
207
  let timer: number | null = null;
208
+ // double tap to zoom
209
+ let lastTapTime = 0;
210
+ let lastTapX = 0;
211
+ let lastTapY = 0;
212
+ let singleTapTimer: number | null = null;
213
+ let dblTapStep = 0;
214
+ const DOUBLE_TAP_DELAY = 300;
215
+ const DOUBLE_TAP_DIST = 30;
216
+ const TAP_MOVE_THRESHOLD = 10;
217
+ const DBL_TAP_ZOOM_LEVELS = [2, 3.5];
184
218
 
185
219
  watchEffect(() => {
220
+ if (props.imageUrl) {
221
+ currentImageIndex.value = 0;
222
+ return;
223
+ }
186
224
  currentImageIndex.value = props.imageList.findIndex((message: any) => {
187
225
  return message.ID === props?.currentImage?.ID;
188
226
  });
@@ -204,6 +242,8 @@ const handleTouchMove = (e: any) => {
204
242
  if (e.touches && e.touches.length === 2) {
205
243
  twoTouchesFlag = true;
206
244
  handleTwoTouches(e);
245
+ } else if (e.touches && e.touches.length === 1 && zoom.value > 1) {
246
+ handleOneTouchPan(e);
207
247
  }
208
248
  };
209
249
 
@@ -225,26 +265,46 @@ const handleTouchEnd = (e: any) => {
225
265
  }
226
266
  // H5 touch move to left to go to prev image
227
267
  // H5 touch move to right to go to next image
228
- if (timer === null) {
229
- switch (moveFlag) {
230
- // touch event
231
- case true:
232
- moveEndX = e?.changedTouches[0]?.pageX;
233
- X = moveEndX - startX;
268
+ const endX = e?.changedTouches[0]?.pageX ?? 0;
269
+ const endY = e?.changedTouches[0]?.pageY ?? 0;
270
+ const isTap = Math.hypot(endX - startX, endY - startY) < TAP_MOVE_THRESHOLD;
271
+ if (moveFlag && !isTap) {
272
+ if (timer === null) {
273
+ moveEndX = endX;
274
+ X = moveEndX - startX;
275
+ if (zoom.value <= 1 && !props.imageUrl) {
234
276
  if (X > 100) {
235
277
  goPrev();
236
278
  } else if (X < -100) {
237
279
  goNext();
238
280
  }
239
- break;
240
- // click event
241
- case false:
281
+ }
282
+ timer = setTimeout(() => {
283
+ timer = null;
284
+ }, 200);
285
+ }
286
+ } else {
287
+ const now = Date.now();
288
+ const dist = Math.hypot(endX - lastTapX, endY - lastTapY);
289
+ if (now - lastTapTime < DOUBLE_TAP_DELAY && dist < DOUBLE_TAP_DIST) {
290
+ if (singleTapTimer !== null) {
291
+ clearTimeout(singleTapTimer);
292
+ singleTapTimer = null;
293
+ }
294
+ lastTapTime = 0;
295
+ dblTapZoom(endX, endY);
296
+ } else {
297
+ lastTapTime = now;
298
+ lastTapX = endX;
299
+ lastTapY = endY;
300
+ if (singleTapTimer !== null) {
301
+ clearTimeout(singleTapTimer);
302
+ }
303
+ singleTapTimer = setTimeout(() => {
304
+ singleTapTimer = null;
242
305
  close();
243
- break;
306
+ }, DOUBLE_TAP_DELAY);
244
307
  }
245
- timer = setTimeout(() => {
246
- timer = null;
247
- }, 200);
248
308
  }
249
309
  };
250
310
 
@@ -265,9 +325,19 @@ const handleWheel = (e: any) => {
265
325
 
266
326
  const moveInit = (e: any) => {
267
327
  startX = e?.changedTouches[0]?.pageX;
328
+ startY = e?.changedTouches[0]?.pageY;
329
+ baseTranslateX = translateX.value;
330
+ baseTranslateY = translateY.value;
268
331
  moveFlag = false;
269
332
  };
270
333
 
334
+ const handleOneTouchPan = (e: any) => {
335
+ const touch = e?.touches[0];
336
+ if (!touch) return;
337
+ translateX.value = baseTranslateX + (touch.pageX - startX);
338
+ translateY.value = baseTranslateY + (touch.pageY - startY);
339
+ };
340
+
271
341
  const twoTouchesInit = (e: any) => {
272
342
  const touch1 = e?.touches[0];
273
343
  const touch2 = e?.touches[1];
@@ -314,7 +384,24 @@ const handleTwoTouches = (e: any) => {
314
384
  touchStore.pageX2 as number,
315
385
  touchStore.pageY2 as number,
316
386
  );
317
- zoom.value = Math.min(Math.max(0.5, zoom.value * touchZoom), 4);
387
+ const newZoom = Math.min(Math.max(0.5, zoom.value * touchZoom), 4);
388
+ // anchor zoom at the midpoint of two fingers
389
+ const wrapperRect = image.value?.getBoundingClientRect?.();
390
+ if (wrapperRect) {
391
+ const centerX = wrapperRect.left + wrapperRect.width / 2;
392
+ const centerY = wrapperRect.top + wrapperRect.height / 2;
393
+ const midX = (touch1.pageX + touch2.pageX) / 2;
394
+ const midY = (touch1.pageY + touch2.pageY) / 2;
395
+ const ratio = newZoom / zoom.value;
396
+ translateX.value = midX - centerX - (midX - centerX - translateX.value) * ratio;
397
+ translateY.value = midY - centerY - (midY - centerY - translateY.value) * ratio;
398
+ }
399
+ zoom.value = newZoom;
400
+ // refresh anchor for the next move frame
401
+ touchStore.pageX1 = touch1.pageX;
402
+ touchStore.pageY1 = touch1.pageY;
403
+ touchStore.pageX2 = touch2.pageX;
404
+ touchStore.pageY2 = touch2.pageY;
318
405
  };
319
406
 
320
407
  onMounted(() => {
@@ -357,9 +444,39 @@ const goPrev = () => {
357
444
  const initStyle = () => {
358
445
  zoom.value = 1;
359
446
  rotate.value = 0;
447
+ translateX.value = 0;
448
+ translateY.value = 0;
449
+ dblTapStep = 0;
450
+ lastTapTime = 0;
451
+ };
452
+
453
+ // 1st double tap: zoom to 1.5x; 2nd: 2.5x; 3rd: reset to 1x
454
+ const dblTapZoom = (x: number, y: number) => {
455
+ const wrapperRect = image.value?.getBoundingClientRect?.();
456
+ if (!wrapperRect) return;
457
+ if (dblTapStep >= DBL_TAP_ZOOM_LEVELS.length) {
458
+ // reset to original size
459
+ zoom.value = 1;
460
+ translateX.value = 0;
461
+ translateY.value = 0;
462
+ dblTapStep = 0;
463
+ return;
464
+ }
465
+ const targetZoom = DBL_TAP_ZOOM_LEVELS[dblTapStep];
466
+ // anchor zoom at the double tap point (same formula as handleTwoTouches)
467
+ const centerX = wrapperRect.left + wrapperRect.width / 2;
468
+ const centerY = wrapperRect.top + wrapperRect.height / 2;
469
+ const ratio = targetZoom / zoom.value;
470
+ translateX.value = x - centerX - (x - centerX - translateX.value) * ratio;
471
+ translateY.value = y - centerY - (y - centerY - translateY.value) * ratio;
472
+ zoom.value = targetZoom;
473
+ dblTapStep += 1;
360
474
  };
361
475
 
362
476
  const getImageUrl = (message: IMessageModel) => {
477
+ if (props.imageUrl) {
478
+ return props.imageUrl;
479
+ }
363
480
  if (isPC) {
364
481
  return message?.payload?.imageInfoArray[0]?.url;
365
482
  } else {
@@ -368,6 +485,11 @@ const getImageUrl = (message: IMessageModel) => {
368
485
  };
369
486
 
370
487
  const save = () => {
488
+ // bare url preview mode: download the url directly
489
+ if (props.imageUrl) {
490
+ downloadImgInWeb(props.imageUrl);
491
+ return;
492
+ }
371
493
  const imageMessage = props.imageList[
372
494
  currentImageIndex.value
373
495
  ] as IMessageModel;
@@ -383,6 +505,14 @@ const save = () => {
383
505
 
384
506
  };
385
507
 
508
+ const getExtFromUrl = (src: string) => {
509
+ const pathname = src.split('?')[0].split('#')[0];
510
+ const dotIndex = pathname.lastIndexOf('.');
511
+ if (dotIndex < 0) return 'jpg';
512
+ const ext = pathname.slice(dotIndex + 1).toLowerCase();
513
+ return ext && ext.length <= 5 ? ext : 'jpg';
514
+ };
515
+
386
516
  const downloadImgInWeb = (src: string) => {
387
517
  const option: any = {
388
518
  mode: 'cors',
@@ -390,16 +520,23 @@ const downloadImgInWeb = (src: string) => {
390
520
  'Content-Type': 'application/x-www-form-urlencoded',
391
521
  }),
392
522
  };
393
- const imageMessage = props.imageList[
394
- currentImageIndex.value
395
- ] as IMessageModel;
396
- const imageFormat: number = imageMessage?.payload?.imageFormat;
397
- if (!imageFormatMap.has(imageFormat)) {
398
- Toast({
399
- message: TUITranslateService.t('Component.暂不支持下载此类型图片'),
400
- type: TOAST_TYPE.ERROR,
401
- });
402
- return;
523
+ let extension: string;
524
+ if (props.imageUrl) {
525
+ // bare url preview mode: derive extension from url
526
+ extension = getExtFromUrl(src);
527
+ } else {
528
+ const imageMessage = props.imageList[
529
+ currentImageIndex.value
530
+ ] as IMessageModel;
531
+ const imageFormat: number = imageMessage?.payload?.imageFormat;
532
+ if (!imageFormatMap.has(imageFormat)) {
533
+ Toast({
534
+ message: TUITranslateService.t('Component.暂不支持下载此类型图片'),
535
+ type: TOAST_TYPE.ERROR,
536
+ });
537
+ return;
538
+ }
539
+ extension = imageFormatMap.get(imageFormat) as string;
403
540
  }
404
541
  // If the browser supports fetch, use blob to download, so as to avoid the browser clicking the a tag and jumping to the preview of the new page
405
542
  if ((window as any).fetch) {
@@ -409,14 +546,14 @@ const downloadImgInWeb = (src: string) => {
409
546
  const a = document.createElement('a');
410
547
  const url = window.URL.createObjectURL(blob);
411
548
  a.href = url;
412
- a.download = url + '.' + imageFormatMap.get(imageFormat);
549
+ a.download = url + '.' + extension;
413
550
  a.click();
414
551
  });
415
552
  } else {
416
553
  const a = document.createElement('a');
417
554
  a.href = src;
418
555
  a.target = '_blank';
419
- a.download = src + '.' + imageFormatMap.get(imageFormat);
556
+ a.download = src + '.' + extension;
420
557
  a.click();
421
558
  }
422
559
  };
@@ -438,7 +575,7 @@ onUnmounted(() => {
438
575
  bottom: 5%;
439
576
  padding: 12px;
440
577
  border-radius: 6px;
441
- background: rgba(255, 255, 255, 0.8);
578
+ background: #fff;
442
579
 
443
580
  &-h5 {
444
581
  padding: 6px;
@@ -465,7 +602,7 @@ onUnmounted(() => {
465
602
 
466
603
  .image-previewer {
467
604
  position: fixed;
468
- z-index: 101;
605
+ z-index: 9999;
469
606
  width: 100vw;
470
607
  height: 100vh;
471
608
  background: rgba(#000, 0.3);
@@ -558,8 +695,8 @@ onUnmounted(() => {
558
695
  border-radius: 50%;
559
696
  top: 3%;
560
697
  right: 3%;
561
- padding: 10px;
562
- background: rgba(255, 255, 255, 0.8);
698
+ padding: 8px;
699
+ background: #fff;
563
700
  display: flex;
564
701
 
565
702
  &::before,
@@ -585,7 +722,7 @@ onUnmounted(() => {
585
722
  position: absolute;
586
723
  bottom: 5%;
587
724
  right: 5%;
588
- padding: 12px;
725
+ padding: 8px;
589
726
  border-radius: 6px;
590
727
  background: rgba(255, 255, 255, 0.8);
591
728
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tencentcloud/ai-desk-customer-vue",
3
- "version": "1.7.3",
3
+ "version": "1.7.4",
4
4
  "description": "Vue2/Vue3 UIKit for AI Desk",
5
5
  "main": "index",
6
6
  "keywords": [
package/utils/utils.ts CHANGED
@@ -59,6 +59,30 @@ export const handleSkeletonSize = (
59
59
  return { width: maxWidth, height: height * (maxWidth / width) };
60
60
  };
61
61
 
62
+ const IMAGE_EXTENSIONS = ['gif', 'jpeg', 'jpg', 'png', 'bmp', 'webp'];
63
+ const VIDEO_EXTENSIONS = ['mov', 'mp4'];
64
+
65
+ export function getMediaTypeByUrl(url?: string, name?: string): 'image' | 'video' | 'file' {
66
+ const source = name || url || '';
67
+ if (!source) {
68
+ return 'file';
69
+ }
70
+
71
+ const pathname = source.split(/[?#]/)[0];
72
+ const dotIndex = pathname.lastIndexOf('.');
73
+ if (dotIndex < 0) {
74
+ return 'file';
75
+ }
76
+ const ext = pathname.slice(dotIndex + 1).toLowerCase();
77
+ if (IMAGE_EXTENSIONS.includes(ext)) {
78
+ return 'image';
79
+ }
80
+ if (VIDEO_EXTENSIONS.includes(ext)) {
81
+ return 'video';
82
+ }
83
+ return 'file';
84
+ }
85
+
62
86
  // Image loading complete
63
87
  export function getImgLoad(container: any, className: string, callback: any) {
64
88
  const images = container?.querySelectorAll(`.${className}`) || [];