index.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888
  1. <script lang="tsx" setup>
  2. import { renderMarkdownText, renderMermaidProcess } from './plugins/markdown'
  3. import type { CrossTransformFunction, TransformFunction } from './models'
  4. import { defaultMockModelName } from './models'
  5. const pdfPreview = ref(false)
  6. const iframeTitle = ref('')
  7. const iframeURL = ref('')
  8. interface Props {
  9. reader?: ReadableStreamDefaultReader<Uint8Array> | ReadableStreamDefaultReader<string> | null | undefined
  10. model: string | null| undefined
  11. transformStreamFn: TransformFunction | null | undefined
  12. }
  13. const fileLoading = ref(false)
  14. const props = withDefaults(
  15. defineProps<Props>(),
  16. {
  17. reader: null
  18. }
  19. )
  20. // 定义响应式变量
  21. const displayText = ref('')
  22. const textBuffer = ref('')
  23. const readerLoading = ref(false)
  24. const isAbort = ref(false)
  25. const isCompleted = ref(false)
  26. const emit = defineEmits([
  27. 'failed',
  28. 'completed',
  29. 'update:reader'
  30. ])
  31. const refWrapperContent = ref<HTMLElement>()
  32. let typingAnimationFrame: number | null = null
  33. const renderedMarkdown = computed(() => {
  34. return renderMarkdownText(displayText.value)
  35. })
  36. // 接口响应是否正在排队等待
  37. const waitingForQueue = ref(false)
  38. const WaitTextRender = defineComponent({
  39. render() {
  40. return (
  41. <n-empty
  42. size="large"
  43. class="font-bold [&_.n-empty\_\_icon]:flex [&_.n-empty\_\_icon]:justify-center"
  44. >
  45. {{
  46. default: () => (
  47. <div
  48. whitespace-break-spaces
  49. text-center
  50. >请求排队处理中,请耐心等待...</div>
  51. ),
  52. icon: () => (
  53. <n-icon class="text-30">
  54. <div class="i-svg-spinners:clock"></div>
  55. </n-icon>
  56. )
  57. }}
  58. </n-empty>
  59. )
  60. }
  61. })
  62. const abortReader = () => {
  63. if (props.reader) {
  64. props.reader.cancel()
  65. }
  66. isAbort.value = true
  67. readIsOver.value = false
  68. emit('update:reader', null)
  69. initializeEnd()
  70. isCompleted.value = true
  71. }
  72. const resetStatus = () => {
  73. isAbort.value = false
  74. isCompleted.value = false
  75. readIsOver.value = false
  76. emit('update:reader', null)
  77. initializeEnd()
  78. displayText.value = ''
  79. textBuffer.value = ''
  80. readerLoading.value = false
  81. if (typingAnimationFrame) {
  82. cancelAnimationFrame(typingAnimationFrame)
  83. typingAnimationFrame = null
  84. }
  85. }
  86. /**
  87. * 检查是否有实际内容
  88. */
  89. function hasActualContent(html) {
  90. const text = html.replace(/<[^>]*>/g, '')
  91. return /\S/.test(text)
  92. }
  93. const showCopy = computed(() => {
  94. if (!isCompleted.value) return false
  95. if (hasActualContent(displayText.value)) {
  96. return true
  97. }
  98. return false
  99. })
  100. const renderedContent = computed(() => {
  101. // 在 renderedMarkdown 末尾插入光标标记
  102. return `${ renderedMarkdown.value }`
  103. })
  104. const initialized = ref(false)
  105. const initializeStart = () => {
  106. initialized.value = true
  107. }
  108. const initializeEnd = () => {
  109. initialized.value = false
  110. }
  111. /**
  112. * reader 读取是否结束
  113. */
  114. const readIsOver = ref(false)
  115. const readTextStream = async () => {
  116. if (!props.reader) return
  117. const textDecoder = new TextDecoder('utf-8')
  118. readerLoading.value = true
  119. while (true) {
  120. if (isAbort.value) {
  121. break
  122. }
  123. try {
  124. if (!props.reader) {
  125. readIsOver.value = true
  126. break
  127. }
  128. const { value, done } = await props.reader.read()
  129. if (!props.reader) {
  130. readIsOver.value = true
  131. break
  132. }
  133. if (done) {
  134. readIsOver.value = true
  135. break
  136. }
  137. const transformer = props.transformStreamFn as CrossTransformFunction
  138. if (!transformer) {
  139. break
  140. }
  141. const stream = transformer(value, textDecoder)
  142. if (stream.done) {
  143. readIsOver.value = true
  144. break
  145. }
  146. if (stream.isWaitQueuing) {
  147. waitingForQueue.value = stream.isWaitQueuing
  148. }
  149. if (stream.content) {
  150. waitingForQueue.value = false
  151. textBuffer.value += stream.content
  152. }
  153. if (typingAnimationFrame === null) {
  154. showText()
  155. }
  156. } catch (error) {
  157. readIsOver.value = true
  158. emit('failed', error)
  159. resetStatus()
  160. break
  161. } finally {
  162. initializeEnd()
  163. }
  164. }
  165. }
  166. const scrollToBottom = async () => {
  167. await nextTick()
  168. if (!refWrapperContent.value) return
  169. refWrapperContent.value.scrollTop = refWrapperContent.value.scrollHeight
  170. const chatContainer = document.querySelector('.chat-scroll__black')
  171. if (chatContainer) {
  172. chatContainer.scrollTop = chatContainer.scrollHeight
  173. }
  174. }
  175. const scrollToBottomByThreshold = async () => {
  176. if (!refWrapperContent.value) return
  177. const threshold = 100
  178. const distanceToBottom = refWrapperContent.value.scrollHeight - refWrapperContent.value.scrollTop - refWrapperContent.value.clientHeight
  179. if (distanceToBottom <= threshold) {
  180. scrollToBottom()
  181. }
  182. }
  183. /**
  184. * 通用文件预览函数
  185. * @param fileUrl - 文件的公网 URL 地址
  186. */
  187. async function previewFile(fileUrl: string, file_name: string) {
  188. const ext = fileUrl.split('.').pop()?.toLowerCase().split('?')[0] || ''
  189. const open = (url: string) => {
  190. location.href = url
  191. }
  192. const isOffice = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext)
  193. const isText = ['txt', 'csv'].includes(ext)
  194. if (ext === 'pdf') {
  195. iframeTitle.value = file_name
  196. iframeURL.value = `${ location.origin }/pdfJS/web/viewer.html?file=${ encodeURIComponent(fileUrl) }`
  197. pdfPreview.value = true
  198. } else if (isOffice) {
  199. const encoded = encodeURIComponent(fileUrl)
  200. open(`https://view.officeapps.live.com/op/view.aspx?src=${ encoded }`)
  201. } else if (isText) {
  202. open(fileUrl)
  203. } else {
  204. // fallback:不支持的格式可以提示或触发下载
  205. const a = document.createElement('a')
  206. a.href = fileUrl
  207. a.download = ''
  208. a.click()
  209. }
  210. }
  211. const closePreview = () => {
  212. iframeTitle.value = ''
  213. iframeURL.value = ''
  214. pdfPreview.value = false
  215. }
  216. const scrollToBottomIfAtBottom = async () => {
  217. // TODO: 需要同时支持手动向上滚动
  218. scrollToBottomByThreshold()
  219. const MARGIN = 20
  220. interface Note {
  221. content: string
  222. file_url: string
  223. file_name: string
  224. }
  225. document.querySelectorAll<HTMLElement>('.markdown-wrapper .trigger').forEach(trigger => {
  226. trigger.addEventListener('click', (e: MouseEvent) => {
  227. e.stopPropagation()
  228. const target = e.target as HTMLElement | null
  229. const position = target?.getAttribute('position')
  230. const storageData = localStorage.getItem('chatNotes')
  231. if (!storageData || !position) return
  232. const notes: Record<string, Note> = JSON.parse(storageData)
  233. if (!notes[position]) return
  234. document.querySelector('.note-black')?.remove()
  235. const html = `
  236. <div class="note-black">
  237. <div class="note-black__popover-bg"></div>
  238. <div class="note-black__popover">
  239. <div class="popover-icon">
  240. <svg t="1751424254143" class="icon" viewBox="0 0 1024 1024" version="1.1"
  241. xmlns="http://www.w3.org/2000/svg" p-id="4596" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200">
  242. <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"
  243. fill="#f0f0f0" p-id="4597"></path>
  244. </svg>
  245. </div>
  246. <div class="popover-content">${ notes[position].content }</div>
  247. <div class="popover-footer">
  248. <a href="javascript:;" class="preview-file" data-url="${ notes[position].file_url }" data-name="${ notes[position].file_name }">
  249. ${ notes[position].file_name }
  250. </a>
  251. </div>
  252. </div>
  253. </div>`
  254. const wrapper = document.createElement('div')
  255. wrapper.innerHTML = html
  256. const node = wrapper.firstElementChild as HTMLElement | null
  257. if (!node) return
  258. document.body.appendChild(node)
  259. document.querySelector('.note-black__popover-bg')?.addEventListener('click', () => {
  260. document.querySelector('.note-black')?.remove()
  261. })
  262. document.querySelector('.note-black .preview-file')?.addEventListener('click', (event) => {
  263. const targetA = event.target as HTMLElement | null
  264. const link = targetA?.getAttribute('data-url') || ''
  265. const fileName = targetA?.getAttribute('data-name') || ''
  266. previewFile(link?.split('?')[0], fileName)
  267. document.querySelector('.note-black')?.remove()
  268. })
  269. const container = document.querySelector('.note-black') as HTMLElement | null
  270. const popover = container?.querySelector('.note-black__popover') as HTMLElement | null
  271. if (!container || !popover) return
  272. // 关闭其他弹窗
  273. document.querySelectorAll<HTMLElement>('.note-black__popover').forEach(p => {
  274. if (p !== popover) p.style.display = 'none'
  275. })
  276. const isVisible = popover.style.display === 'block'
  277. if (isVisible) {
  278. popover.style.display = 'none'
  279. return
  280. }
  281. popover.style.display = 'block'
  282. popover.style.top = ''
  283. popover.style.bottom = ''
  284. popover.style.left = ''
  285. popover.style.right = ''
  286. const rect = target?.getBoundingClientRect()
  287. if (!rect) return
  288. const triggerTop = rect.top
  289. const triggerLeft = rect.left - 20
  290. const triggerHeight = trigger.offsetHeight
  291. const triggerWidth = trigger.offsetWidth
  292. const popoverWidth = popover.offsetWidth
  293. const popoverHeight = popover.offsetHeight
  294. const containerHeight = container.offsetHeight
  295. // 垂直定位
  296. if (containerHeight - (triggerTop + triggerHeight) >= popoverHeight + MARGIN) {
  297. // 下方显示
  298. popover.style.top = `${ triggerTop + 30 }px`
  299. popover.style.bottom = 'auto'
  300. } else if (triggerTop >= popoverHeight + MARGIN) {
  301. // 上方显示
  302. popover.style.bottom = `${ containerHeight - triggerTop + 5 }px`
  303. popover.style.top = 'auto'
  304. } else {
  305. // 默认下方
  306. popover.style.top = `${ triggerTop + 30 }px`
  307. popover.style.bottom = 'auto'
  308. }
  309. // 水平居中定位
  310. let left = triggerLeft + triggerWidth / 2 - popoverWidth / 2
  311. if (left < MARGIN) left = MARGIN
  312. const maxLeft = container.offsetWidth - popoverWidth - MARGIN
  313. if (left > maxLeft) left = maxLeft
  314. popover.style.left = `${ left }px`
  315. })
  316. })
  317. }
  318. /**
  319. * 读取 buffer 内容,逐字追加到 displayText
  320. */
  321. const runReadBuffer = (readCallback = () => {}, endCallback = () => {}) => {
  322. if (textBuffer.value.length > 0) {
  323. const nextChunk = textBuffer.value.substring(0, 10)
  324. displayText.value += nextChunk
  325. textBuffer.value = textBuffer.value.substring(10)
  326. readCallback()
  327. } else {
  328. endCallback()
  329. }
  330. }
  331. const showText = () => {
  332. if (isAbort.value && typingAnimationFrame) {
  333. cancelAnimationFrame(typingAnimationFrame)
  334. typingAnimationFrame = null
  335. readerLoading.value = false
  336. renderMermaidProcess(scrollToBottom)
  337. return
  338. }
  339. // 若 reader 还没结束,则保持打字行为
  340. if (!readIsOver.value) {
  341. runReadBuffer()
  342. renderMermaidProcess(scrollToBottom)
  343. typingAnimationFrame = requestAnimationFrame(showText)
  344. } else {
  345. // 读取剩余的 buffer
  346. runReadBuffer(
  347. () => {
  348. renderMermaidProcess(scrollToBottom)
  349. typingAnimationFrame = requestAnimationFrame(showText)
  350. },
  351. () => {
  352. renderMermaidProcess(scrollToBottom)
  353. // window.$ModalNotification.success({
  354. // title: '生成完毕',
  355. // duration: 1500
  356. // })
  357. emit('update:reader', null)
  358. emit('completed')
  359. readerLoading.value = false
  360. isCompleted.value = true
  361. nextTick(() => {
  362. readIsOver.value = false
  363. })
  364. typingAnimationFrame = null
  365. }
  366. )
  367. }
  368. scrollToBottomIfAtBottom()
  369. }
  370. watch(
  371. () => props.reader,
  372. () => {
  373. if (props.reader) {
  374. readTextStream()
  375. }
  376. },
  377. {
  378. immediate: true,
  379. deep: true
  380. }
  381. )
  382. onUnmounted(() => {
  383. resetStatus()
  384. })
  385. defineExpose({
  386. abortReader,
  387. resetStatus,
  388. initializeStart,
  389. initializeEnd
  390. })
  391. const showLoading = computed(() => {
  392. if (initialized.value) {
  393. return true
  394. }
  395. if (!props.reader) {
  396. return false
  397. }
  398. if (!readerLoading) {
  399. return false
  400. }
  401. if (displayText.value) {
  402. return false
  403. }
  404. return false
  405. })
  406. const refClipBoard = ref()
  407. const handlePassClip = () => {
  408. if (refClipBoard.value) {
  409. refClipBoard.value.copyText()
  410. }
  411. }
  412. </script>
  413. <template>
  414. <n-spin
  415. relative
  416. flex="1 ~"
  417. min-h-0
  418. w-full
  419. h-full
  420. content-class="w-full h-full flex"
  421. :show="false"
  422. :rotate="false"
  423. class="bg-#fff:30"
  424. :style="{
  425. '--n-opacity-spinning': '0.3'
  426. }"
  427. >
  428. <transition name="fade">
  429. <n-float-button
  430. v-if="showCopy"
  431. position="absolute"
  432. :top="0"
  433. :right="0"
  434. color
  435. class="c-warning bg-#fff/80 hover:bg-#fff/90 transition-all-200 z-2"
  436. @click="handlePassClip()"
  437. >
  438. <clip-board
  439. ref="refClipBoard"
  440. :auto-color="false"
  441. no-copy
  442. :text="displayText ? displayText.replace(/<[^>]*>/g, '') : ''"
  443. />
  444. </n-float-button>
  445. </transition>
  446. <template #icon>
  447. <div class="i-svg-spinners:3-dots-rotate"></div>
  448. </template>
  449. <!-- b="~ solid #ddd" -->
  450. <div
  451. flex="1 ~"
  452. min-w-0
  453. min-h-0
  454. :class="[
  455. reader
  456. ? ''
  457. : 'justify-center items-center'
  458. ]"
  459. >
  460. <div
  461. text-16
  462. class="w-full h-full overflow-hidden"
  463. :class="[
  464. !displayText && 'flex items-center justify-center'
  465. ]"
  466. >
  467. <WaitTextRender
  468. v-if="waitingForQueue && !displayText"
  469. />
  470. <template v-else>
  471. <!-- <n-empty
  472. v-if="!displayText"
  473. size="medium"
  474. :show-icon="false"
  475. >
  476. <div
  477. whitespace-break-spaces
  478. text-center
  479. v-html="emptyPlaceholder"
  480. ></div>
  481. </n-empty> -->
  482. <div
  483. ref="refWrapperContent"
  484. text-16
  485. class="w-full h-full overflow-y-auto"
  486. >
  487. <div
  488. class="markdown-wrapper"
  489. v-html="renderedContent"
  490. ></div>
  491. <WaitTextRender
  492. v-if="waitingForQueue"
  493. />
  494. <div
  495. v-if="readerLoading"
  496. size-24
  497. class="i-svg-spinners:pulse-3"
  498. ></div>
  499. </div>
  500. </template>
  501. </div>
  502. </div>
  503. <div
  504. v-if="fileLoading"
  505. class="loading-block"
  506. >
  507. <div class="loading-items">
  508. <div class="loading-container">
  509. <div class="loading-overlay__spinner"></div>
  510. </div>
  511. <span class="loading-text">文件加载中...</span>
  512. </div>
  513. </div>
  514. <div
  515. v-if="pdfPreview"
  516. class="pdf-black"
  517. >
  518. <div
  519. class="header-block-div"
  520. @click="closePreview()"
  521. >
  522. <span class="back"><svg
  523. xmlns="http://www.w3.org/2000/svg"
  524. viewBox="0 0 24 24"
  525. aria-hidden="true"
  526. focusable="false"
  527. role="presentation"
  528. class="icon icon-caret"
  529. >
  530. <path d="M 7.75 1.34375 L 6.25 2.65625 L 14.65625 12 L 6.25 21.34375 L 7.75 22.65625 L 16.75 12.65625 L 17.34375 12 L 16.75 11.34375 Z" />
  531. </svg></span>
  532. <div class="title-black">
  533. <span class="title">{{ iframeTitle }}</span>
  534. <span class="subtitle">文件预览</span>
  535. </div>
  536. </div>
  537. <iframe
  538. :src="iframeURL"
  539. frameborder="0"
  540. ></iframe>
  541. </div>
  542. </n-spin>
  543. </template>
  544. <style lang="scss">
  545. .markdown-wrapper {
  546. * {
  547. padding: 0;
  548. margin: 0;
  549. }
  550. h1 {
  551. font-size: 2em;
  552. }
  553. h2 {
  554. font-size: 1.5em;
  555. }
  556. h3 {
  557. font-size: 1.25em;
  558. }
  559. h4 {
  560. font-size: 1em;
  561. }
  562. h5 {
  563. font-size: 0.875em;
  564. }
  565. h6 {
  566. font-size: 0.85em;
  567. }
  568. h1,h2,h3,h4,h5,h6 {
  569. margin: 0 auto;
  570. line-height: 1.25;
  571. }
  572. & ul,ol {
  573. padding-left: 1.5em;
  574. line-height: 0.8;
  575. }
  576. & ul,li,ol {
  577. list-style-position: outside;
  578. white-space: normal;
  579. }
  580. li {
  581. line-height: 1.7;
  582. & > code {
  583. --at-apply: 'bg-#e5e5e5';
  584. --at-apply: whitespace-pre m-2px px-6px py-2px rounded-5px;
  585. }
  586. }
  587. ol ol {
  588. padding-left: 20px;
  589. }
  590. ul ul {
  591. padding-left: 20px;
  592. }
  593. hr {
  594. margin: 16px 0;
  595. }
  596. a {
  597. color: $color-default;
  598. font-weight: bolder;
  599. text-decoration: underline;
  600. padding: 0 3px;
  601. }
  602. p {
  603. line-height: 1.4;
  604. & > code {
  605. --at-apply: 'bg-#e5e5e5';
  606. --at-apply: whitespace-pre mx-4px px-6px py-3px rounded-5px;
  607. }
  608. img {
  609. display: inline-block;
  610. }
  611. }
  612. li > p {
  613. line-height: 2
  614. }
  615. blockquote {
  616. padding: 10px;
  617. margin: 20px 0;
  618. border-left: 5px solid #ccc;
  619. background-color: #f9f9f9;
  620. color: #555;
  621. & > p {
  622. margin: 0;
  623. }
  624. }
  625. .katex {
  626. --at-apply: c-primary;
  627. }
  628. kbd {
  629. --at-apply: inline-block align-middle p-0.1em p-0.3em;
  630. --at-apply: bg-#fcfcfc text-#555;
  631. --at-apply: border border-solid border-#ccc border-b-#bbb;
  632. --at-apply: rounded-0.2em shadow-[inset_0_-1px_0_#bbb] text-0.9em;
  633. }
  634. table {
  635. --at-apply: w-fit border-collapse my-16;
  636. }
  637. th, td {
  638. --at-apply: p-7 text-left border border-solid border-#ccc;
  639. }
  640. th {
  641. --at-apply: bg-#f2f2f2 font-bold;
  642. }
  643. tr:nth-child(even) {
  644. --at-apply: bg-#f9f9f9;
  645. }
  646. tr:hover {
  647. --at-apply: bg-#f1f1f1;
  648. }
  649. // Deepseek 深度思考 Wrapper
  650. .think-wrapper {
  651. --at-apply: pl-13 text-14 c-#8b8b8b;
  652. --at-apply: b-l-2 b-l-solid b-#e5e5e5;
  653. p {
  654. --at-apply: line-height-26;
  655. }
  656. }
  657. .trigger {
  658. cursor: pointer;
  659. color: #3BB279;
  660. margin-left: 5px;
  661. }
  662. }
  663. .note-black {
  664. position: fixed;
  665. left: 0;
  666. top: 0;
  667. width: 100%;
  668. height: 100%;
  669. z-index: 999;
  670. display: flex;
  671. justify-content: center;
  672. align-items: center;
  673. .note-black__popover-bg {
  674. position: absolute;
  675. top: 0;
  676. left: 0;
  677. width: 100%;
  678. height: 100%;
  679. }
  680. .note-black__popover {
  681. position: absolute;
  682. background: #fff;
  683. border-radius: 6px;
  684. z-index: 99;
  685. width: 250px;
  686. padding: 10px;
  687. box-shadow: 0 0 10px #ccc;
  688. span {
  689. display: block;
  690. }
  691. .popover-icon {
  692. height: 20px;
  693. svg {
  694. width: 20px;
  695. height: 20px;
  696. }
  697. }
  698. .popover-content {
  699. font-size: 14px;
  700. margin-bottom: 12px;
  701. }
  702. .popover-footer {
  703. border-top: 1px solid #f0f0f0;
  704. padding-top: 12px;
  705. .preview-file {
  706. color: #3BB279;
  707. font-size: 14px;
  708. text-decoration: none;
  709. padding: 0;
  710. display: block;
  711. font-weight: normal;
  712. }
  713. }
  714. }
  715. }
  716. .pdf-black {
  717. position: fixed;
  718. top: 0;
  719. left: 0;
  720. width: 100%;
  721. height: 100%;
  722. z-index: 999;
  723. background: #fff;
  724. iframe {
  725. display: block;
  726. width: 100%;
  727. height: calc(100% - 44px);
  728. }
  729. .header-block-div {
  730. display: flex;
  731. align-items: center;
  732. gap: 5px;
  733. cursor: pointer;
  734. width: 100%;
  735. display: flex;
  736. padding-left: 16px;
  737. padding-right: 16px;
  738. min-height: 44px;
  739. align-items: center;
  740. .title-black {
  741. display: flex;
  742. flex-direction: column;
  743. width: calc(100% - 25px);
  744. }
  745. }
  746. .back {
  747. display: flex;
  748. align-items: center;
  749. svg {
  750. width: 16px;
  751. height: 16px;
  752. transform: rotate(180deg);
  753. }
  754. }
  755. .title {
  756. font-weight: bold;
  757. font-size: 14px;
  758. line-height: 1.5;
  759. white-space: nowrap;
  760. text-overflow: ellipsis;
  761. overflow: hidden;
  762. width: 100%;
  763. display: block;
  764. }
  765. .subtitle {
  766. font-size: 12px;
  767. color: #999;
  768. line-height: 1.5;
  769. }
  770. }
  771. </style>