Bladeren bron

feat:merge deg

AmandaG 9 maanden geleden
bovenliggende
commit
9aca16b74b

+ 2 - 2
.env.test

@@ -1,2 +1,2 @@
-VITE_API_HOST = 'https://ra.kerryapex.com/new/online_backend'
-VITE_BASE_URL = '/new/'
+VITE_API_HOST = '/kln/online_backend'
+VITE_BASE_URL = /kln/

+ 2 - 0
package.json

@@ -12,6 +12,7 @@
     "build-only": "vite build",
     "build:dev": "vite build --mode development",
     "build:pro": "vite build --mode product",
+    "build:test": "vite build --mode test",
     "type-check": "vue-tsc --build --force",
     "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
     "format": "prettier --write src/"
@@ -40,6 +41,7 @@
     "vue-draggable-plus": "^0.5.3",
     "vue-router": "^4.3.3",
     "vue3-puzzle-vcode": "^1.1.7",
+    "vue3-virtual-scroller": "^0.2.3",
     "vuedraggable": "^2.24.3",
     "vxe-pc-ui": "^4.1.7",
     "vxe-table": "^4.7.70",

+ 2 - 1
src/auto-imports.d.ts

@@ -3,6 +3,7 @@
 // @ts-nocheck
 // noinspection JSUnusedGlobalSymbols
 // Generated by unplugin-auto-import
+// biome-ignore lint: disable
 export {}
 declare global {
   const $api: typeof import('@/api/index')['default']
@@ -68,6 +69,6 @@ declare global {
 // for type re-export
 declare global {
   // @ts-ignore
-  export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
+  export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
   import('vue')
 }

+ 73 - 28
src/components/NotificationMessageCard/src/NotificationMessageCard.vue

@@ -4,17 +4,23 @@ import PasswordCard from './components/PasswordCard.vue'
 import FeatureUpdateCard from './components/FeatureUpdateCard.vue'
 import { useNotificationMessage } from '@/stores/modules/notificationMessage'
 import { cloneDeep } from 'lodash'
+import { DynamicScroller, DynamicScrollerItem } from 'vue3-virtual-scroller'
+import 'vue3-virtual-scroller/dist/vue3-virtual-scroller.css'
 
 const notificationMsgStore = useNotificationMessage()
 const props = withDefaults(
   defineProps<{
     data: any
     isObserver?: boolean // 是否开启监听
+    isScrollPadding?: boolean // 是否有padding
     updateReadCardsOnChange?: boolean // 是否在数据变化时请求接口更新已读卡片
+    topOffset?: number // 应减去高度
   }>(),
   {
     isObserver: true,
-    updateReadCardsOnChange: true
+    isScrollPadding: false,
+    updateReadCardsOnChange: true,
+    topOffset: 220
   }
 )
 const pageData = ref<any[]>([])
@@ -101,14 +107,19 @@ const compareIdsInArrays = (arr1, arr2) => {
   return true
 }
 
+const handleData = (data) => {
+  data.map((item) => {
+    item.id = String(Math.random()).slice(-10)
+  })
+  return data
+}
 const initData = () => {
   const watchOptions = { deep: true, immediate: true }
-
   const handleDataChange = (newData, oldData) => {
     // 如果id相等,则不需要更新页面数据
     if (compareIdsInArrays(oldData || [], newData || [])) return
 
-    pageData.value = cloneDeep(newData)
+    pageData.value = handleData(cloneDeep(newData))
 
     // 清除旧数据中的卡片监听
     clearReadData(oldData)
@@ -120,22 +131,26 @@ const initData = () => {
 
     // 重新监听新数据中的卡片
     nextTick(() => {
-      watchCards()
+      setTimeout(() => {
+        watchCards()
+      }, 500)
     })
   }
+  // 需要监听卡片已读未读状态
   if (props.isObserver) {
     watch(
       () => props.data,
-      (newData, oldData) => {
-        handleDataChange(newData, oldData)
+      (newVal, oldVal) => {
+        handleDataChange(newVal, oldVal)
       },
       watchOptions
     )
   } else {
+    // 不需要监听
     watch(
       () => props.data,
       (newVal) => {
-        pageData.value = cloneDeep(newVal)
+        pageData.value = handleData(cloneDeep(newVal))
       },
       watchOptions
     )
@@ -172,30 +187,60 @@ onUnmounted(() => {
 const handleViewMore = () => {
   emit('viewMore')
 }
+const parentHeight = computed(() => {
+  return (window.innerHeight || document.documentElement.clientHeight) - props.topOffset
+})
+const scrollParentBoxStyle = computed(() => {
+  console.log(props.topOffset, 'value')
+  return props.topOffset ? { height: `${parentHeight.value}px` } : {}
+})
 </script>
 
 <template>
-  <div
-    class="notification-message-card"
-    :data-card-id="item.info.id"
-    :data-card-isread="item.info.isRead"
-    v-for="item in pageData"
-    :key="item.info.id || Math.random()"
+  <DynamicScroller
+    class="scroller"
+    :style="scrollParentBoxStyle"
+    :items="pageData"
+    :min-item-size="100"
+    key-field="id"
   >
-    <EventCard
-      @seeAll="handleSeeAll"
-      @jump-tracking="emit('jumpTracking')"
-      v-if="item.notificationType === 'event'"
-      :data="item.info"
-    />
-    <PasswordCard v-else-if="item.notificationType === 'password'" :data="item.info" />
-    <FeatureUpdateCard
-      @view-more="handleViewMore"
-      v-else-if="item.notificationType === 'feature'"
-      :data="item.info"
-    />
-    <slot></slot>
-  </div>
+    <template v-slot="{ item, index, active }">
+      <DynamicScrollerItem
+        :class="{ 'scroll-padding': props.isScrollPadding }"
+        :item="item"
+        :active="active"
+        :size-dependencies="[item.info.isRead]"
+      >
+        <div
+          class="notification-message-card"
+          :data-card-id="item.info.id"
+          :data-card-isread="item.info.isRead"
+        >
+          <EventCard
+            @seeAll="handleSeeAll"
+            @jump-tracking="emit('jumpTracking')"
+            v-if="item.notificationType === 'event'"
+            :data="item.info"
+          />
+          <PasswordCard v-else-if="item.notificationType === 'password'" :data="item.info" />
+          <FeatureUpdateCard
+            @view-more="handleViewMore"
+            v-else-if="item.notificationType === 'feature'"
+            :data="item.info"
+          />
+          <slot></slot>
+        </div>
+      </DynamicScrollerItem>
+    </template>
+  </DynamicScroller>
 </template>
 
-<style lang="scss" scoped></style>
+<style lang="scss" scoped>
+.scroller {
+  width: 100%;
+  overflow-y: auto;
+  .scroll-padding {
+    padding: 10px 140px 0px 16px;
+  }
+}
+</style>

+ 7 - 4
src/components/NotificationMessageCard/src/components/EventCard.vue

@@ -23,6 +23,7 @@ interface EventCardPropsData {
   insert_date_format?: string // 用来跳转到System Message详情页
   frequency_type?: string // 用来跳转到System Message详情页
   rules_type?: string // 用来跳转到System Message详情页
+  is_display_hbol: boolean // 是否显示HBOL
   previous?: {
     date: string
     tag: string
@@ -110,7 +111,9 @@ const jumpTracking = (data: EventCardPropsData) => {
         <!-- 除了container类型,其他类型都显示运输方式图标 -->
         <div style="display: inline-block" v-if="data.type !== 'container'">
           <span class="font_family" :class="[`icon-${transportationMode?.[data.mode]}`]"></span>
-          <span @click="jumpTracking(data)" class="no no-link">HBOL: {{ data.no }}</span>
+          <span @click="jumpTracking(data)" class="no no-link"
+            >{{ data.is_display_hbol ? 'HBOL:' : 'MAWB:' }} {{ data.no }}</span
+          >
         </div>
         <!-- container类型显示图标 -->
         <div v-else>
@@ -157,7 +160,7 @@ const jumpTracking = (data: EventCardPropsData) => {
         <span style="margin-right: 3px">{{
           dayjs(data.info.time).format('MMM DD, YYYY hh:mm')
         }}</span>
-        <span>{{ getTimezone(data.info.timezone) }}</span>
+        <span>{{ getTimezone(data.info.timezone, data.info.time) }}</span>
       </div>
       <!-- <div class="change-time" v-if="data.type === 'change' && data.info?.time">
         <span style="margin-left: 1px" class="font_family icon-icon_time_b"></span>
@@ -167,13 +170,13 @@ const jumpTracking = (data: EventCardPropsData) => {
         <span class="font_family icon-icon_time_b"></span>
         <span style="margin-right: 3px" v-if="data.timeLabel">{{ data.timeLabel }}:</span>
         <span style="margin-right: 3px">{{ dayjs(data.time).format('MMM DD, YYYY hh:mm') }}</span>
-        <span>{{ getTimezone(data.timezone) }}</span>
+        <span>{{ getTimezone(data.timezone, data.time) }}</span>
       </div>
       <div class="previous" v-if="data.previous">
         <span class="previous-icon"></span>
         <span
           >{{ data.previous.tag }}&nbsp;({{ data.previous.time }}
-          {{ getTimezone(data.previous.timezone) }})</span
+          {{ getTimezone(data.previous.timezone, data.previous.time) }})</span
         >
       </div>
     </div>

+ 12 - 2
src/styles/elementui.scss

@@ -171,7 +171,7 @@ button.el-button.el-button--icon {
   }
 }
 // 初始为黑色
-.el-button.el-button--dark {
+button.el-button.el-button--dark {
   background-color: var(--color-btn-default-dark-bg);
   fill: var(--color-white);
   border: none;
@@ -183,7 +183,17 @@ button.el-button.el-button--icon {
     background-color: var(--color-btn-default-dark-hover-bg);
     fill: var(--color-btn-default-dark-hover-bg);
     span {
-      color: var(--color-btn-default-dark-hover) !important;
+      color: var(--color-btn-default-dark-hover);
+    }
+  }
+}
+button.el-button.el-button--dark.is-disabled {
+  opacity: 0.3;
+  &:hover {
+    background-color: var(--color-btn-default-dark-bg);
+    fill: var(--color-white);
+    span {
+      color: var(--color-white);
     }
   }
 }

+ 20 - 4
src/styles/icons/iconfont.css

@@ -1,9 +1,9 @@
 @font-face {
   font-family: "font_family"; /* Project id 4672385 */
-  src: url('iconfont.woff2?t=1744960401055') format('woff2'),
-       url('iconfont.woff?t=1744960401055') format('woff'),
-       url('iconfont.ttf?t=1744960401055') format('truetype'),
-       url('iconfont.svg?t=1744960401055#font_family') format('svg');
+  src: url('iconfont.woff2?t=1745286986564') format('woff2'),
+       url('iconfont.woff?t=1745286986564') format('woff'),
+       url('iconfont.ttf?t=1745286986564') format('truetype'),
+       url('iconfont.svg?t=1745286986564#font_family') format('svg');
 }
 
 .font_family {
@@ -14,6 +14,22 @@
   -moz-osx-font-smoothing: grayscale;
 }
 
+.icon-icon_good_b:before {
+  content: "\e713";
+}
+
+.icon-icon_notgood__filled_b:before {
+  content: "\e714";
+}
+
+.icon-icon_good__filled_b:before {
+  content: "\e715";
+}
+
+.icon-icon_notgood_b:before {
+  content: "\e716";
+}
+
 .icon-icon_send_b:before {
   content: "\e712";
 }

File diff suppressed because it is too large
+ 0 - 0
src/styles/icons/iconfont.js


+ 8 - 0
src/styles/icons/iconfont.svg

@@ -14,6 +14,14 @@
     />
       <missing-glyph />
       
+      <glyph glyph-name="icon_good_b" unicode="&#59155;" d="M549.376 780.48c-38.272 0-73.6-20.608-92.352-53.888L297.408 444.032H135.04a32 32 0 0 1-32-32v-392.384a32 32 0 0 1 32-32h98.56v-0.128h64v0.128h430.72a96 96 0 0 1 92.352 69.952l90.24 320.256a96 96 0 0 1-92.352 122.048h-162.944V674.368c0 58.624-47.552 106.112-106.176 106.112zM297.6 51.648v328.32h18.56a32 32 0 0 1 27.776 16.32L512.704 695.04a42.112 42.112 0 0 0 78.848-20.736v-206.464a32 32 0 0 1 32-32h194.944a32 32 0 0 0 30.72-40.64l-90.24-320.32a32 32 0 0 0-30.72-23.296h-430.72z m-64 328.32v-328.32H167.04v328.32h66.56z"  horiz-adv-x="1088" />
+      
+      <glyph glyph-name="icon_notgood__filled_b" unicode="&#59156;" d="M488.576-69.568c38.272 0 73.6 20.608 92.416 53.952l159.36 282.176V723.328H309.76a96 96 0 0 1-92.352-70.016l-90.24-320.256a96 96 0 0 1 92.352-122.048h162.944v-174.464c0-58.624 47.552-106.112 106.112-106.112z m315.776 792.896V266.88h98.624a32 32 0 0 1 32 32V691.2a32 32 0 0 1-32 32h-98.56z"  horiz-adv-x="1088" />
+      
+      <glyph glyph-name="icon_good__filled_b" unicode="&#59157;" d="M574.4 780.48c-38.272 0-73.6-20.608-92.416-53.888L322.432 444.032H160a32 32 0 0 1-32-32v-392.384a32 32 0 0 1 32-32h98.56v456.32h64v-456.32h430.656a96 96 0 0 1 92.416 69.952l90.24 320.256a96 96 0 0 1-92.416 122.048h-162.944V674.368c0 58.624-47.488 106.112-106.112 106.112z"  horiz-adv-x="1088" />
+      
+      <glyph glyph-name="icon_notgood_b" unicode="&#59158;" d="M488.576-69.568c38.272 0 73.6 20.608 92.416 53.952l159.616 282.56h162.368a32 32 0 0 1 32 32V691.2a32 32 0 0 1-32 32H309.76a96 96 0 0 1-92.352-69.952l-90.24-320.256a96 96 0 0 1 92.352-122.048h162.944v-174.464c0-58.624 47.552-106.112 106.112-106.112z m36.672 85.44a42.112 42.112 0 0 0-78.72 20.672v206.464a32 32 0 0 1-32 32H219.52a32 32 0 0 0-30.784 40.704l90.24 320.256a32 32 0 0 0 30.784 23.36h430.656V330.88h-18.56a32 32 0 0 1-27.84-16.256l-168.768-298.88zM804.48 330.88V659.2h66.56v-328.32h-66.56z"  horiz-adv-x="1088" />
+      
       <glyph glyph-name="icon_send_b" unicode="&#59154;" d="M678.272 424.256l-460.928 225.92 56.32-225.92h404.608z m-403.712-76.8l-57.216-229.568 468.352 229.568H274.56z m601.664 65.28a32 32 0 0 0 0-57.472L164.672 6.592a32 32 0 0 0-45.184 36.48l83.072 333.184a32 32 0 0 1 0 15.488L119.488 724.992a32 32 0 0 0 45.184 36.48L876.16 412.736z"  horiz-adv-x="1088" />
       
       <glyph glyph-name="icon_sidebar__window_b" unicode="&#59151;" d="M740.352 809.28h132.16v-76.8h-132.16v76.8z m-72.448-102.592a25.6 25.6 0 0 0 25.6 25.6v76.8H119.04a102.4 102.4 0 0 1-102.4-102.4v-645.376a102.4 102.4 0 0 1 102.4-102.4H693.504v76.8a25.6 25.6 0 0 0-25.6 25.6v80.64h-76.8v-80.64c0-8.832 1.088-17.408 3.2-25.6H119.04a25.6 25.6 0 0 0-25.6 25.6V706.688c0 14.08 11.456 25.6 25.6 25.6h475.264c-2.112-8.192-3.2-16.768-3.2-25.6v-80.64h76.8v80.64z m251.072 25.6a25.6 25.6 0 0 0 25.6-25.6v-80.64h76.8v80.64a102.4 102.4 0 0 1-102.4 102.4v-76.8zM667.904 410.88V572.16h-76.8v-161.28h76.8z m276.672 161.28v-161.28h76.8V572.16h-76.8z m-276.672-376.384v161.28h-76.8v-161.28h76.8z m276.672 161.28v-161.28h76.8v161.28h-76.8z m0-215.04v-80.64a25.6 25.6 0 0 0-25.6-25.6v-76.8a102.4 102.4 0 0 1 102.4 102.4v80.64h-76.8zM541.632 406.592L433.28 514.944l-45.248-45.248 53.76-53.76H186.88v-64h254.784l-53.696-53.696 45.248-45.248 108.352 108.352a32 32 0 0 1 0 45.248z m330.88-370.88h-132.16v-76.8h132.16v76.8z"  horiz-adv-x="1088" />

+ 13 - 2
src/styles/theme.scss

@@ -76,8 +76,6 @@
   --color-border: #eaebed;
   --color-select-border: #eaebed;
   --border-color-2: #eaebed;
-  --color-border-1: #e8eaee;
-  --color-border-2: #eaebed;
 
   --color-mune-active-bg: #fdf5f1;
 
@@ -285,6 +283,12 @@
 
   // AI Robot
   --color-dialogue-bg: #fff;
+  --color-ai-chat-header-bg-gradient-begin: #eaecff;
+  --color-ai-chat-header-bg-gradient-end: #fefdff;
+  --color-ai-user-bubble-bg-gradient-begin: #ffede6;
+  --color-ai-user-bubble-bg-gradient-end: #f2f4f7;
+  --input-border: #eaebed;
+  --color-pause-btn-bg: #fff1e6;
 }
 
 :root.dark {
@@ -459,4 +463,11 @@
       fill: var(--color-white);
     }
   }
+
+  --color-ai-chat-header-bg-gradient-begin: #484f82;
+  --color-ai-chat-header-bg-gradient-end: #31363d;
+  --color-ai-user-bubble-bg-gradient-begin: #716763;
+  --color-ai-user-bubble-bg-gradient-end: #5c6a7d;
+  --input-border: #656f7d;
+  --color-pause-btn-bg: #453b36;
 }

+ 6 - 4
src/utils/tools.ts

@@ -15,7 +15,8 @@ export const formatTimezone = (time: string, timezone?: string) => {
       return formattedTime
     }
     let utcOffset = ''
-    const timeZoneOffset = moment.tz(`${moment().year()}-01-01`, timezone).format('Z')
+
+    const timeZoneOffset = moment.tz(time, timezone).format('Z')
     // 替换 "+07:00" 为 "UTC+07"
     utcOffset = `(UTC${timeZoneOffset.slice(0, 3)})`
     return `${formattedTime} ${utcOffset}`
@@ -30,9 +31,10 @@ export const formatTimezone = (time: string, timezone?: string) => {
  * @param timezone
  * @returns
  */
-export const getTimezone = (timezone: string): string => {
+export const getTimezone = (timezone: string, time?: string): string => {
   if (!timezone) return ''
-  const offset = moment.tz(`${moment().year()}-01-01`, timezone).format('Z')
+  const computedTime = time ? time : moment(time).format(formatString.value)
+  const offset = moment.tz(computedTime, timezone).format('Z')
   return `UTC${offset.slice(0, 3)}`
 }
 
@@ -42,7 +44,7 @@ export const formatTimezoneByUTCorGMT = (time: string, timezone: string) => {
   formattedTime = moment(time).format(`${formatString.value} hh:mm A`)
   let gmtOffset = ''
   if (timezone != null) {
-    const timeZoneOffset = moment().tz(timezone).format('Z')
+    const timeZoneOffset = moment.tz(time, timezone).format('Z')
     // 替换 "+07:00" 为 "GMT+07"
     if (timezone.includes('Seoul')) {
       gmtOffset = `(UTC${timeZoneOffset.slice(0, 3)})`

+ 1 - 0
src/views/AIRobotChat/index.ts

@@ -0,0 +1 @@
+export { default } from './src/AIRobotChat.vue'

+ 455 - 0
src/views/AIRobotChat/src/AIRobotChat.vue

@@ -0,0 +1,455 @@
+<script setup lang="ts">
+import AutoResizeTextarea from './components/AutoResizeTextarea.vue'
+import userBubbleLight from './image/userBubbleLight.png'
+import userBubbleDark from './image/userBubbleDark.png'
+import robotBubbleLight from './image/robotBubbleLight.png'
+import robotBubbleDark from './image/robotBubbleDark.png'
+import { useThemeStore } from '@/stores/modules/theme'
+
+const themeStore = useThemeStore()
+const modalSize = ref('large')
+const userQuestion = ref()
+
+const isFooterInputFocus = ref(false)
+
+const userBubbleImg = computed(() => {
+  return themeStore.theme === 'light' ? userBubbleLight : userBubbleDark
+})
+const robotBubbleImg = computed(() => {
+  return themeStore.theme === 'light' ? robotBubbleLight : robotBubbleDark
+})
+
+interface MessageItem {
+  type: 'robot' | 'user'
+  content: string
+  feedback?: 'good' | 'noGood' | '' // 反馈结果
+  isShowFeedback?: boolean // 是否展示反馈样式
+  isAnswer?: boolean // 是否为用户问题的答案,是则才能展示反馈组件
+}
+const messages = ref<MessageItem[]>([
+  {
+    type: 'robot',
+    content: 'You can click on Frequently Asked Questions above or type your own question'
+  },
+  {
+    type: 'user',
+    content: 'Hi! I am your Freight Assistant. How can I help you?'
+  },
+  {
+    type: 'robot',
+    feedback: '',
+    isShowFeedback: false,
+    isAnswer: true,
+    content: 'Can you help me with my shipment?'
+  },
+  {
+    type: 'user',
+    content: 'Of course! Please provide me with the details of your shipment.'
+  }
+])
+
+// 用户问题请求时间
+const queryTime = ref(-1)
+// 当前用户问题回复进度
+const progressStatus = {
+  init: 'You can click on Frequently Asked Questions above or type your own question',
+  '0': 'Thinking about your question...',
+  '15': 'Searching for relevant data, please wait...',
+  '30': 'This query is complex and may take more time',
+  '60': 'You may try simplifying your question or selecting a Frequently Asked Question',
+  '120': 'Sorry, the query failed. Please try again later or select a Frequently Asked Question',
+  cancel: 'You have stopped this answer'
+}
+
+const progressInterval = ref()
+const handleSend = () => {
+  if (!userQuestion.value) return
+
+  messages.value.push({
+    type: 'user',
+    content: userQuestion.value
+  })
+  userQuestion.value = ''
+  queryTime.value = 0
+  messages.value.push({
+    type: 'robot',
+    content: progressStatus[0]
+  })
+  progressInterval.value = setInterval(() => {
+    queryTime.value++
+  }, 1000)
+}
+
+// 根据时间更新消息内容
+const updateMessageContent = (time) => {
+  const lastMessageIndex = messages.value.length - 1
+
+  // 确保消息数组不为空
+  if (lastMessageIndex >= 0) {
+    messages.value[lastMessageIndex].content = progressStatus[time]
+    if (time === 120) {
+      clearInterval(progressInterval.value)
+      queryTime.value = -3
+    }
+  }
+}
+watch(
+  () => queryTime.value,
+  (newVal) => {
+    // 定义时间点与对应状态的映射
+    const timeToStatusMap = {
+      15: 15,
+      30: 30,
+      60: 60,
+      120: 120
+    }
+
+    // 如果当前时间点在映射中,更新消息内容
+    if (timeToStatusMap[newVal] !== undefined) {
+      updateMessageContent(timeToStatusMap[newVal])
+    }
+  }
+)
+// 暂停回答
+const handlePause = () => {
+  clearInterval(progressInterval.value)
+  queryTime.value = -2
+  messages.value[messages.value.length - 1].content = progressStatus.cancel
+}
+
+const emit = defineEmits(['close'])
+// 关闭聊天窗口
+const handleClose = () => {
+  progressInterval.value && clearInterval(progressInterval.value)
+  emit('close')
+}
+</script>
+
+<template>
+  <div class="ai-robot" :style="{ width: modalSize === 'large' ? '1000px' : '484px' }">
+    <div class="top-section">
+      <div class="header">
+        <span class="welcome">Hi! I'm your Freight Assistant</span>
+        <div class="option-icon">
+          <span
+            v-if="modalSize === 'large'"
+            class="font_family icon-icon_sidebar__window_b"
+            @click="modalSize = 'small'"
+          ></span>
+          <span
+            v-else-if="modalSize !== 'large'"
+            class="font_family icon-icon_maximized__window_b"
+            @click="modalSize = 'large'"
+          ></span>
+          <span @click="handleClose" class="font_family icon-icon_collapsed__to_widget_b"></span>
+        </div>
+      </div>
+    </div>
+    <div class="chat-messages" ref="messagesRef">
+      <div
+        class="message-item"
+        :class="[
+          msg.type === 'user' ? 'user-bubble' : 'robot-bubble',
+          ((queryTime > -1 && queryTime < 120) || queryTime === -2) && index === messages.length - 1
+            ? 'query-style'
+            : ''
+        ]"
+        v-for="(msg, index) in messages"
+        :key="index"
+        @mouseenter="msg.isShowFeedback = true"
+        @mouseleave="msg.isShowFeedback = false"
+      >
+        <!-- 请求失败后的提示icon -->
+        <span
+          v-if="queryTime === -3 && index === messages.length - 1"
+          class="font_family icon-icon_warning_fill_b"
+          style="margin-top: 1px; color: #c9353f"
+        ></span>
+        <!-- loading icon -->
+        <img
+          class="loading-img"
+          v-if="queryTime > -1 && queryTime < 120 && index === messages.length - 1"
+          src="./image/icon_loading.png"
+          alt=""
+        />
+        {{ msg.content }}
+        <div class="review" v-if="msg.isShowFeedback && msg.isAnswer">
+          <el-button
+            v-if="msg.feedback !== 'good'"
+            class="el-button--text"
+            @click="msg.feedback = 'good'"
+          >
+            <span class="font_family icon-icon_good_b"></span>
+          </el-button>
+          <div v-if="msg.feedback === 'good'" style="width: 16px; text-align: center">
+            <span
+              style="color: var(--color-theme); font-size: 14px"
+              class="font_family icon-icon_good__filled_b"
+            ></span>
+          </div>
+          <el-button
+            v-if="msg.feedback !== 'noGood'"
+            class="el-button--text"
+            @click="msg.feedback = 'noGood'"
+          >
+            <span class="font_family icon-icon_notgood_b"></span>
+          </el-button>
+          <div v-if="msg.feedback === 'noGood'" style="width: 16px; text-align: center">
+            <span
+              style="color: var(--color-theme); font-size: 14px"
+              class="font_family icon-icon_notgood__filled_b"
+            ></span>
+          </div>
+        </div>
+
+        <img class="robot-bubble-img" v-if="msg.type === 'robot'" :src="robotBubbleImg" alt="" />
+        <img class="user-bubble-img" v-else-if="msg.type === 'user'" :src="userBubbleImg" alt="" />
+        <!-- 暂停回答 icon -->
+        <div
+          class="pause-btn"
+          v-if="index === messages.length - 1 && queryTime > 30 && queryTime < 120"
+          @click="handlePause"
+        >
+          <div class="dot"></div>
+        </div>
+      </div>
+    </div>
+
+    <div class="footer-input" :class="[isFooterInputFocus ? 'focus-style' : '']">
+      <AutoResizeTextarea
+        v-model="userQuestion"
+        :placeholder="'Type your question here...'"
+        @focus="isFooterInputFocus = true"
+        @blur="isFooterInputFocus = false"
+      />
+      <div
+        class="input-icon"
+        :class="[userQuestion ? 'input-style' : 'disable']"
+        @click="handleSend"
+      >
+        <span class="font_family icon-icon_send_b"></span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.ai-robot {
+  position: absolute;
+  top: 74px;
+  right: 24px;
+  height: calc(100% - 98px);
+  z-index: 4000;
+  display: flex;
+  flex-direction: column;
+  border-radius: 12px;
+  border: 1px solid var(--color-border);
+  box-shadow: 4px 4px 32px 0px rgba(0, 0, 0, 0.2);
+  background-color: var(--color-dialog-body-bg);
+  overflow: hidden;
+  .top-section {
+    height: 150px;
+    background: linear-gradient(
+      to bottom,
+      var(--color-ai-chat-header-bg-gradient-begin) 10%,
+      var(--color-ai-chat-header-bg-gradient-begin) 10%,
+      var(--color-ai-chat-header-bg-gradient-end) 100%
+    );
+    .header {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      height: 64px;
+      padding: 0 16px;
+      .welcome {
+        font-size: 18px;
+        font-weight: 700;
+      }
+      .option-icon {
+        display: flex;
+        align-items: center;
+        gap: 6px;
+        .font_family {
+          font-size: 16px;
+          cursor: pointer;
+          &:hover {
+            color: var(--color-theme);
+          }
+        }
+      }
+    }
+  }
+  .chat-messages {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    gap: 16px;
+    margin-top: 81px;
+    padding: 0 16px;
+    overflow: auto;
+    .message-item {
+      position: relative;
+      display: inline-block;
+      padding: 11px 8px;
+      margin-bottom: 7px;
+      border-radius: 12px;
+      background-color: var(--scoring-bg-color);
+      .review {
+        position: absolute;
+        bottom: -24px;
+        left: 0;
+        display: flex;
+        align-items: center;
+        gap: 13px;
+        width: 100%;
+        height: 30px;
+        margin-top: 10px;
+        padding-left: 30px;
+        padding-top: 10px;
+
+        button.el-button + .el-button {
+          margin-left: 0px;
+        }
+      }
+      .review-input-card {
+        margin-top: 6px;
+        padding: 8px;
+        text-align: right;
+        box-shadow: 1px 1px 12px 0px rgba(0, 0, 0, 0.05);
+        border-radius: 6px;
+      }
+
+      .el-button--text {
+        height: 16px;
+        width: 16px;
+        span {
+          color: var(--color-neutral-2);
+          font-size: 14px;
+        }
+        &:hover {
+          span {
+            color: var(--color-theme);
+          }
+        }
+      }
+
+      .loading-img {
+        width: 16px;
+        height: 16px;
+        margin-top: -1px;
+        margin-right: 2px;
+        animation: loading-rotate 2s linear infinite;
+      }
+
+      .pause-btn {
+        position: absolute;
+        right: -22px;
+        top: 13px;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        height: 16px;
+        width: 16px;
+        border-radius: 50%;
+        background-color: var(--color-customize-column-right-section-bg);
+        .dot {
+          height: 5px;
+          width: 5px;
+          border-radius: 1px;
+          background-color: var(--color-theme);
+        }
+      }
+    }
+    .query-style {
+      color: #b5b9bf;
+    }
+    .robot-bubble {
+      background: var(--scoring-bg-color);
+      align-self: flex-start;
+      .robot-bubble-img {
+        position: absolute;
+        left: -1px;
+        bottom: -7px;
+      }
+    }
+    .user-bubble {
+      align-self: flex-end;
+      background: linear-gradient(
+        to right,
+        var(--color-ai-user-bubble-bg-gradient-begin),
+        var(--color-ai-user-bubble-bg-gradient-end)
+      );
+      .user-bubble-img {
+        position: absolute;
+        right: 0;
+        bottom: -7px;
+      }
+    }
+  }
+  .footer-input {
+    display: flex;
+    align-items: flex-end;
+    gap: 12px;
+    padding: 4px 12px;
+    padding-right: 4px;
+    margin: 12px 16px;
+    border: 1px solid var(--input-border);
+    border-radius: 20px;
+    box-sizing: border-box;
+    .input-icon {
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      height: 32px;
+      width: 32px;
+      padding: 1px 0 0 2px;
+      border-radius: 50%;
+      cursor: pointer;
+      &.disable {
+        cursor: not-allowed;
+      }
+    }
+    .input-style {
+      background-color: var(--color-theme);
+      span {
+        color: #fff;
+      }
+      &:hover {
+        background-color: #d56200;
+      }
+    }
+    &.focus-style {
+      border: 1px solid var(--color-theme);
+    }
+  }
+
+  // .input-area {
+  //   width: 100%;
+  //   font-size: 14px;
+  //   line-height: 21px;
+  //   padding: 4px;
+  //   resize: none;
+  //   overflow-y: hidden; // 默认不显示滚动条
+  //   height: 40px; // 初始高度(1 行)
+  //   max-height: 100px; // 最多 4 行
+  //   box-sizing: border-box;
+  //   border: none;
+  //   outline-color: #fff;
+  //   border-radius: 8px;
+  //   transition: height 0.1s ease;
+  //   &::placeholder {
+  //     color: #b5b9bf;
+  //     opacity: 1;
+  //   }
+  // }
+  @keyframes loading-rotate {
+    0% {
+      transform: rotate(0deg);
+    }
+
+    100% {
+      transform: rotate(360deg);
+    }
+  }
+}
+</style>

+ 70 - 0
src/views/AIRobotChat/src/components/AutoResizeTextarea.vue

@@ -0,0 +1,70 @@
+<script setup lang="ts">
+const inputVModel = defineModel({ type: String })
+const props = defineProps<{
+  placeholder?: string
+}>()
+
+watch(
+  () => inputVModel.value,
+  () => {
+    textareaRef.value.style.height = '30px' // 重置高度
+  }
+)
+const textareaRef = ref(null)
+// 实现自适应高度(最多 4 行)
+const resize = () => {
+  const el = textareaRef.value
+  if (!el) return
+
+  el.style.height = 'auto' // 先清空旧高度
+  const scrollHeight = el.scrollHeight
+
+  const maxHeight = 92 // 四行时高度
+
+  if (scrollHeight <= maxHeight) {
+    el.style.overflowY = 'hidden'
+    el.style.height = scrollHeight + 'px'
+  } else {
+    el.style.overflowY = 'auto'
+    el.style.height = maxHeight + 'px'
+  }
+}
+const emit = defineEmits(['focus', 'blur'])
+</script>
+
+<template>
+  <textarea
+    ref="textareaRef"
+    v-model="inputVModel"
+    class="input-area"
+    rows="1"
+    :placeholder="props.placeholder"
+    @input="resize"
+    @focus="emit('focus')"
+    @blur="emit('blur')"
+  />
+</template>
+
+<style lang="scss" scoped>
+.input-area {
+  width: 100%;
+  font-size: 14px;
+  line-height: 21px;
+  padding: 4px;
+  resize: none;
+  overflow-y: hidden; // 默认不显示滚动条
+  height: 30px; // 初始高度(1 行)
+  max-height: 92px; // 最多 4 行
+  box-sizing: border-box;
+  border: none;
+  outline-color: transparent;
+  outline: none;
+  background-color: transparent;
+  border-radius: 8px;
+  transition: height 0.1s ease;
+  &::placeholder {
+    color: #b5b9bf;
+    opacity: 1;
+  }
+}
+</style>

BIN
src/views/AIRobotChat/src/image/icon_loading.png


BIN
src/views/AIRobotChat/src/image/robotBubbleDark.png


BIN
src/views/AIRobotChat/src/image/robotBubbleLight.png


BIN
src/views/AIRobotChat/src/image/userBubbleDark.png


BIN
src/views/AIRobotChat/src/image/userBubbleLight.png


+ 12 - 0
src/views/Layout/src/LayoutView.vue

@@ -5,6 +5,7 @@ import Menu from './components/Menu/MenuView.vue'
 import Logo from './images/logo.png'
 import ScoringGrade from '@/components/ScoringGrade'
 import AIRobot from '@/components/AIRobot'
+import AIRobotChat from '@/views/AIRobotChat/src/AIRobotChat.vue'
 import LogoMenu from './images/logo_menu.png'
 
 const leftAsideWidth = ref('232px')
@@ -13,6 +14,14 @@ const handleMenuCollapse = (val: boolean) => {
   isCollapse.value = val
   val ? (leftAsideWidth.value = '64px') : (leftAsideWidth.value = '232px')
 }
+
+const isShowAIRobotChat = ref(false)
+const handleColseRobotChat = () => {
+  isShowAIRobotChat.value = false
+}
+const onClick = () => {
+  isShowAIRobotChat.value = true
+}
 </script>
 <template>
   <el-container class="layout-container">
@@ -32,6 +41,7 @@ const handleMenuCollapse = (val: boolean) => {
 
     <!-- 右侧整体布局 -->
     <el-container style="min-width: 900px">
+      <el-button @click="onClick">测试</el-button>
       <!-- 顶部Header -->
       <el-header class="layout-header">
         <Header></Header>
@@ -43,6 +53,8 @@ const handleMenuCollapse = (val: boolean) => {
       </el-main>
     </el-container>
     <AIRobot></AIRobot>
+    <!-- <AIRobot></AIRobot> -->
+    <AIRobotChat v-if="isShowAIRobotChat" @close="handleColseRobotChat"></AIRobotChat>
     <ScoringGrade></ScoringGrade>
   </el-container>
 </template>

+ 2 - 0
src/views/Layout/src/components/Header/components/NotificationDrawer.vue

@@ -133,6 +133,7 @@ const notificationMessageCardsRef = ref()
             @view-more="drawerRef.handleClose()"
             @jump-tracking="drawerRef.handleClose()"
             :data="notificationList"
+            :topOffset="185"
             ref="notificationMessageCardsRef"
           />
         </div>
@@ -143,6 +144,7 @@ const notificationMessageCardsRef = ref()
 <style lang="scss" scoped>
 div.layout-toolbar {
   .notification-content {
+    height: calc(100% - 40px);
     padding: 16px;
     background-color: var(--color-dialog-body-bg);
   }

+ 7 - 4
src/views/Layout/src/components/Header/components/TrainingCard.vue

@@ -27,7 +27,7 @@ const pollingNewMessage = () => {
     getNotificationList(dayjs().format('MM/DD/YYYY HH:mm:ss'))
   }, 300000)
 }
-pollingNewMessage()
+userStore.isLogin && pollingNewMessage()
 
 // 登录后自动轮播消息
 const trainingCardAfterLogin = () => {
@@ -60,6 +60,7 @@ const nextNotification = () => {
 
 // 轮询时的轮播定时器
 const initTrainingCard = () => {
+  // 类型为event时才设置定时器,不为event类型时需要手动关闭消息
   if (curCard.value?.notificationType === 'event') {
     trainingIntervalId = setInterval(nextNotification, 2000)
   }
@@ -118,8 +119,6 @@ const closeMessage = () => {
   if (result) {
     trainingIntervalId = setInterval(nextNotification, 2000)
   }
-  // 将当前消息标记为已读
-  // notificationMsgStore.setReadCardMap(curCard.value?.info?.id)
 }
 </script>
 
@@ -136,6 +135,7 @@ const closeMessage = () => {
       :isObserver="false"
       v-if="curCard"
       :data="[curCard]"
+      :topOffset="0"
     ></NotificationMessageCard>
   </div>
 </template>
@@ -148,7 +148,6 @@ const closeMessage = () => {
   z-index: 2300;
   width: 432px;
   padding: 16px;
-  padding-bottom: 0;
   background-color: var(--color-dialog-body-bg);
   border-radius: 12px;
   box-shadow: 4px 4px 32px 0px rgba(0, 0, 0, 0.2);
@@ -160,6 +159,10 @@ const closeMessage = () => {
     padding-bottom: 0px;
     font-size: 18px;
     cursor: pointer;
+    z-index: 3000;
+  }
+  :deep(.notification-card) {
+    margin-bottom: 0px;
   }
 }
 </style>

+ 6 - 2
src/views/SystemMessage/src/SystemMessage.vue

@@ -171,11 +171,12 @@ onMounted(() => {
             <template #label>
               <span style="margin-right: 4px">All Notifications</span>
             </template>
-            <div style="padding: 10px 140px 0px 16px" v-if="activeTabName === 'All Notifications'">
+            <div style="padding-bottom: 20px" v-if="activeTabName === 'All Notifications'">
               <NotificationMessageCard
                 v-if="activeTabName === 'All Notifications'"
                 :data="notificationList"
                 @hasCardRead="changeCardRead"
+                :isScrollPadding="true"
                 :updateReadCardsOnChange="false"
               ></NotificationMessageCard>
             </div>
@@ -195,6 +196,7 @@ onMounted(() => {
               <NotificationMessageCard
                 v-if="activeTabName === 'Unread'"
                 :data="unreadNotificationList"
+                :isScrollPadding="true"
                 :updateReadCardsOnChange="false"
               ></NotificationMessageCard>
             </div>
@@ -205,6 +207,7 @@ onMounted(() => {
               <NotificationMessageCard
                 v-if="activeTabName === 'Read'"
                 :updateReadCardsOnChange="false"
+                :isScrollPadding="true"
                 :data="readNotificationList"
               >
               </NotificationMessageCard>
@@ -313,9 +316,10 @@ onMounted(() => {
     }
   }
   :deep(.el-tabs) {
-    height: calc(100%);
+    height: 100%;
     .el-tabs__content {
       overflow-y: auto;
+      height: 100%;
     }
   }
 }

+ 2 - 1
src/views/SystemMessage/src/components/SystemMessageDetail.vue

@@ -144,12 +144,13 @@ const handleIframeLoaded = () => {
       width: 8px;
       height: 8px;
       border-radius: 50%;
-      background-color: var(--color-theme);
+      background-color: var(--color-border);
       margin-right: 10px;
     }
     .title {
       font-weight: 700;
       font-size: 24px;
+      color: #b5b9bf;
     }
   }
   .total-tips {

+ 2 - 2
src/views/Tracking/src/components/TrackingDetail/src/TrackingDetail.vue

@@ -498,7 +498,7 @@ const SubscribeShipments = () => {
   .share-link {
     position: fixed;
     bottom: 152px;
-    right: 0px;
+    right: 20px;
     display: flex;
     align-items: center;
     justify-content: center;
@@ -507,7 +507,7 @@ const SubscribeShipments = () => {
     z-index: 1500;
     background-color: var(--management-bg-color);
     box-shadow: -2px 2px 12px rgba(0, 0, 0, 0.15);
-    border-radius: 12px 0 0 12px;
+    border-radius: 12px;
     &:hover {
       background-color: var(--border-hover-color);
     }

+ 52 - 50
vite.config.ts

@@ -1,5 +1,5 @@
 import { fileURLToPath, URL } from 'node:url'
-import { defineConfig } from 'vite'
+import { defineConfig, loadEnv } from 'vite'
 import vue from '@vitejs/plugin-vue'
 import AutoImport from 'unplugin-auto-import/vite'
 import Components from 'unplugin-vue-components/vite'
@@ -8,56 +8,58 @@ import Icons from 'unplugin-icons/vite'
 import IconsResolver from 'unplugin-icons/resolver'
 
 // https://vitejs.dev/config/
-export default defineConfig({
-  base: `/`,
-  // base: `/k_new_online/`,
-  resolve: {
-    alias: {
-      '@': fileURLToPath(new URL('./src', import.meta.url))
-    }
-  },
-  plugins: [
-    vue(),
-    AutoImport({
-      resolvers: [
-        ElementPlusResolver(),
-        IconsResolver({
-          prefix: 'Icon'
-        })
-      ],
-      imports: [
-        'vue',
-        // 自定义全局引入
-        {
-          // 全局引入"src/api/index.ts"的`default`导出,注册为全局变量`api`
-          // 相当于在每个ts/vue文件中执行了一次 `import api from "@/api/index"`;
-          '@/api/index': [['default', '$api']]
+export default defineConfig((mode) => {
+  const env = loadEnv(mode, process.cwd())
+  return {
+    base: env.VITE_BASE_URL || '/',
+    resolve: {
+      alias: {
+        '@': fileURLToPath(new URL('./src', import.meta.url))
+      }
+    },
+    plugins: [
+      vue(),
+      AutoImport({
+        resolvers: [
+          ElementPlusResolver(),
+          IconsResolver({
+            prefix: 'Icon'
+          })
+        ],
+        imports: [
+          'vue',
+          // 自定义全局引入
+          {
+            // 全局引入"src/api/index.ts"的`default`导出,注册为全局变量`api`
+            // 相当于在每个ts/vue文件中执行了一次 `import api from "@/api/index"`;
+            '@/api/index': [['default', '$api']]
+          }
+        ],
+        dts: './src/auto-imports.d.ts'
+      }),
+      Components({
+        resolvers: [
+          ElementPlusResolver(), // 自动注册图标组件
+          IconsResolver({
+            enabledCollections: ['ep']
+          })
+        ]
+      }),
+      Icons({
+        autoInstall: true
+      })
+    ],
+    server: {
+      port: 80,
+      hmr: true,
+      open: true,
+      // 设置 https 代理
+      proxy: {
+        '/api': {
+          target: 'http://192.168.0.161',
+          changeOrigin: true,
+          rewrite: (path: string) => path.replace(/^\/api/, '')
         }
-      ],
-      dts: './src/auto-imports.d.ts'
-    }),
-    Components({
-      resolvers: [
-        ElementPlusResolver(), // 自动注册图标组件
-        IconsResolver({
-          enabledCollections: ['ep']
-        })
-      ]
-    }),
-    Icons({
-      autoInstall: true
-    })
-  ],
-  server: {
-    port: 80,
-    hmr: true,
-    open: true,
-    // 设置 https 代理
-    proxy: {
-      '/api': {
-        target: 'http://192.168.0.161',
-        changeOrigin: true,
-        rewrite: (path: string) => path.replace(/^\/api/, '')
       }
     }
   }

Some files were not shown because too many files changed in this diff