lushixing 3 veckor sedan
förälder
incheckning
b0ae0ea1b7
3 ändrade filer med 272 tillägg och 1 borttagningar
  1. 259 1
      src/components/MarkdownPreview/index.vue
  2. 12 0
      src/store/business/index.ts
  3. 1 0
      src/views/chat.vue

+ 259 - 1
src/components/MarkdownPreview/index.vue

@@ -9,6 +9,7 @@ interface Props {
   model: string | null| undefined
   transformStreamFn: TransformFunction | null | undefined
 }
+const fileLoading = ref(false)
 
 const props = withDefaults(
   defineProps<Props>(),
@@ -215,9 +216,195 @@ const scrollToBottomByThreshold = async () => {
   }
 }
 
+/**
+ * 判断服务器响应头中的 Content-Disposition 类型
+ */
+async function checkContentDisposition(fileUrl: string): Promise<'inline' | 'attachment' | 'none'> {
+  try {
+    const response = await fetch(fileUrl, {
+      method: 'HEAD'
+    })
+    const disposition = response.headers.get('Content-Disposition')
+    if (!disposition) return 'none'
+    const disp = disposition.toLowerCase()
+    if (disp.includes('inline')) return 'inline'
+    if (disp.includes('attachment')) return 'attachment'
+    return 'none'
+  } catch (err) {
+    fileLoading.value = false
+    console.warn('Failed to check Content-Disposition:', err)
+    return 'none'
+  }
+}
+
+/**
+ * 强制以 Blob URL 的形式打开 PDF 文件(绕过浏览器的下载限制)
+ */
+async function openPdfAsBlob(fileUrl: string) {
+  const res = await fetch(fileUrl)
+  const blob = await res.blob()
+  const blobUrl = URL.createObjectURL(blob)
+  window.open(blobUrl, '_blank')
+}
+
+/**
+ * 通用文件预览函数
+ * @param fileUrl - 文件的公网 URL 地址
+ */
+async function previewFile(fileUrl: string) {
+  const ext = fileUrl.split('.').pop()?.toLowerCase().split('?')[0] || ''
+  const open = (url: string) => window.open(url, '_blank')
+
+  const isOffice = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext)
+  const isText = ['txt', 'csv'].includes(ext)
+
+  if (ext === 'pdf') {
+    fileLoading.value = true
+    const disposition = await checkContentDisposition(fileUrl)
+    if (disposition === 'inline') {
+      fileLoading.value = false
+      open(fileUrl)
+    } else {
+      await openPdfAsBlob(fileUrl) // fallback:强制以 blob 打开
+      fileLoading.value = false
+    }
+  } else if (isOffice) {
+    const encoded = encodeURIComponent(fileUrl)
+    open(`https://view.officeapps.live.com/op/view.aspx?src=${ encoded }`)
+  } else if (isText) {
+    open(fileUrl)
+  } else {
+    // fallback:不支持的格式可以提示或触发下载
+    const a = document.createElement('a')
+    a.href = fileUrl
+    a.download = ''
+    a.click()
+  }
+}
+
 const scrollToBottomIfAtBottom = async () => {
   // TODO: 需要同时支持手动向上滚动
   scrollToBottomByThreshold()
+
+  const MARGIN = 20
+
+  interface Note {
+    content: string
+    file_url: string
+    file_name: string
+  }
+
+  document.querySelectorAll<HTMLElement>('.markdown-wrapper .trigger').forEach(trigger => {
+    trigger.addEventListener('click', (e: MouseEvent) => {
+      e.stopPropagation()
+
+      const target = e.target as HTMLElement | null
+      const position = target?.getAttribute('position')
+
+      const storageData = localStorage.getItem('chatNotes')
+      if (!storageData || !position) return
+
+      const notes: Record<string, Note> = JSON.parse(storageData)
+      if (!notes[position]) return
+
+      document.querySelector('.note-black')?.remove()
+
+      const html = `
+      <div class="note-black">
+        <div class="note-black__popover-bg"></div> 
+        <div class="note-black__popover">
+          <div class="popover-icon">
+            <svg t="1751424254143" class="icon" viewBox="0 0 1024 1024" version="1.1"
+              xmlns="http://www.w3.org/2000/svg" p-id="4596" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200">
+              <path d="M156.09136 606.57001a457.596822 457.596822 0 0 1 221.680239-392.516385 50.844091 50.844091 0 1 1 50.844091 86.943396 355.90864 355.90864 0 0 0-138.804369 152.532274h16.77855a152.532274 152.532274 0 1 1-152.532274 152.532274z m406.752731 0a457.596822 457.596822 0 0 1 221.680239-392.007944 50.844091 50.844091 0 1 1 50.844091 86.943396 355.90864 355.90864 0 0 0-138.804369 152.532274h16.77855a152.532274 152.532274 0 1 1-152.532274 152.532274z"
+                fill="#f0f0f0" p-id="4597"></path>
+            </svg>
+          </div>
+          <div class="popover-content">${ notes[position].content }</div>
+          <div class="popover-footer">
+            <a href="javascript:;" class="preview-file" data-url="${ notes[position].file_url }">
+              ${ notes[position].file_name }
+            </a>
+          </div>
+        </div>
+      </div>`
+
+      const wrapper = document.createElement('div')
+      wrapper.innerHTML = html
+
+      const node = wrapper.firstElementChild as HTMLElement | null
+      if (!node) return
+
+      document.body.appendChild(node)
+
+      document.querySelector('.note-black__popover-bg')?.addEventListener('click', () => {
+        document.querySelector('.note-black')?.remove()
+      })
+      document.querySelector('.note-black .preview-file')?.addEventListener('click', (event) => {
+        const targetA = event.target as HTMLElement | null
+        const link = targetA?.getAttribute('data-url') || ''
+        previewFile(link?.split('?')[0])
+        document.querySelector('.note-black')?.remove()
+      })
+
+      const container = document.querySelector('.note-black') as HTMLElement | null
+      const popover = container?.querySelector('.note-black__popover') as HTMLElement | null
+      if (!container || !popover) return
+
+      // 关闭其他弹窗
+      document.querySelectorAll<HTMLElement>('.note-black__popover').forEach(p => {
+        if (p !== popover) p.style.display = 'none'
+      })
+
+      const isVisible = popover.style.display === 'block'
+      if (isVisible) {
+        popover.style.display = 'none'
+        return
+      }
+
+      popover.style.display = 'block'
+      popover.style.top = ''
+      popover.style.bottom = ''
+      popover.style.left = ''
+      popover.style.right = ''
+
+      const rect = target?.getBoundingClientRect()
+      if (!rect) return
+
+      const triggerTop = rect.top
+      const triggerLeft = rect.left - 20
+      const triggerHeight = trigger.offsetHeight
+      const triggerWidth = trigger.offsetWidth
+      const popoverWidth = popover.offsetWidth
+      const popoverHeight = popover.offsetHeight
+      const containerHeight = container.offsetHeight
+
+      // 垂直定位
+      if (containerHeight - (triggerTop + triggerHeight) >= popoverHeight + MARGIN) {
+      // 下方显示
+        popover.style.top = `${ triggerTop + 30 }px`
+        popover.style.bottom = 'auto'
+      } else if (triggerTop >= popoverHeight + MARGIN) {
+      // 上方显示
+        popover.style.bottom = `${ containerHeight - triggerTop + 5 }px`
+        popover.style.top = 'auto'
+      } else {
+      // 默认下方
+        popover.style.top = `${ triggerTop + 30 }px`
+        popover.style.bottom = 'auto'
+      }
+
+      // 水平居中定位
+      let left = triggerLeft + triggerWidth / 2 - popoverWidth / 2
+      if (left < MARGIN) left = MARGIN
+
+      const maxLeft = container.offsetWidth - popoverWidth - MARGIN
+      if (left > maxLeft) left = maxLeft
+
+      popover.style.left = `${ left }px`
+    })
+  })
+
 }
 
 /**
@@ -356,7 +543,7 @@ const handlePassClip = () => {
           ref="refClipBoard"
           :auto-color="false"
           no-copy
-          :text="displayText"
+          :text="displayText ? displayText.replace(/<[^>]*>/g, '') : ''"
         />
       </n-float-button>
     </transition>
@@ -417,6 +604,17 @@ const handlePassClip = () => {
         </template>
       </div>
     </div>
+    <div
+      v-if="fileLoading"
+      class="loading-block"
+    >
+      <div class="loading-items">
+        <div class="loading-container">
+          <div class="loading-overlay__spinner"></div>
+        </div>
+        <span class="loading-text">文件加载中...</span>
+      </div>
+    </div>
   </n-spin>
 </template>
 
@@ -566,5 +764,65 @@ const handlePassClip = () => {
       --at-apply: line-height-26;
     }
   }
+  .trigger {
+    cursor: pointer;
+    color: #3BB279;
+    margin-left: 5px;
+  }
+}
+.note-black {
+  position: fixed;
+  left: 0;
+  top: 0;
+  width: 100%;
+  height: 100%;
+  z-index: 999;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+
+  .note-black__popover-bg {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+  }
+  .note-black__popover {
+    position: absolute;
+    background: #fff;
+    border-radius: 6px;
+    z-index: 99;
+    width: 250px;
+    padding: 10px;
+    box-shadow: 0 0 10px #ccc;
+    span {
+      display: block;
+    }
+    .popover-icon {
+      height: 20px;
+      svg {
+        width: 20px;
+        height: 20px;
+      }
+    }
+    .popover-content {
+      font-size: 14px;
+      margin-bottom: 12px;
+    }
+    .popover-footer {
+      border-top: 1px solid #f0f0f0;
+      padding-top: 12px;
+      .preview-file {
+        color: #3BB279;
+        font-size: 14px;
+        text-decoration: none;
+        padding: 0;
+        display: block;
+        font-weight: normal;
+      }
+    }
+  }
 }
+
 </style>

+ 12 - 0
src/store/business/index.ts

@@ -36,6 +36,18 @@ export const useBusinessStore = defineStore('business-store', {
           .then(async (res) => {
             const json = await res.json()
             if (json.code === 200) {
+              if (json.notes) {
+                const storageData = localStorage.getItem('chatNotes')
+                if (storageData) {
+                  const merged = {
+                    ...JSON.parse(storageData),
+                    ...json.notes
+                  }
+                  localStorage.setItem('chatNotes', JSON.stringify(merged))
+                } else {
+                  localStorage.setItem('chatNotes', JSON.stringify(json.notes))
+                }
+              }
               resolve({
                 error: 0,
                 content: json.content

+ 1 - 0
src/views/chat.vue

@@ -69,6 +69,7 @@ if (storageData) {
 
 const updateMessages = () => {
   localStorage.removeItem(storageKey)
+  localStorage.removeItem('chatNotes')
   messages.value = []
 }