|
@@ -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>
|