Procházet zdrojové kódy

WIP: 支持空 Markdown 文件预览

[gstack-context]
Decisions: 将文本是否已加载与文本内容是否非空分离,空 Markdown 显示空白预览而不是不支持提示。
Remaining: 对最终 HEAD 重跑 Vitest 和完整 Playwright。
Skill: /investigate
[/gstack-context]
samien před 2 měsíci
rodič
revize
6f0f205e55

+ 6 - 3
src/client/components/library/PreviewInspector.vue

@@ -17,6 +17,7 @@ const officeRoot = ref<HTMLElement | null>(null)
 const pdfRoot = ref<HTMLElement | null>(null)
 const pdfRoot = ref<HTMLElement | null>(null)
 const spreadsheetRows = ref<unknown[][]>([])
 const spreadsheetRows = ref<unknown[][]>([])
 const previewText = ref('')
 const previewText = ref('')
+const previewTextLoaded = ref(false)
 const previewLoading = ref(false)
 const previewLoading = ref(false)
 const previewError = ref('')
 const previewError = ref('')
 const extension = computed(() => props.item.name.split('.').at(-1)?.toLocaleLowerCase() || '')
 const extension = computed(() => props.item.name.split('.').at(-1)?.toLocaleLowerCase() || '')
@@ -31,6 +32,7 @@ async function loadRichPreview() {
   const generation = ++previewGeneration
   const generation = ++previewGeneration
   spreadsheetRows.value = []
   spreadsheetRows.value = []
   previewText.value = props.item.textContent || ''
   previewText.value = props.item.textContent || ''
+  previewTextLoaded.value = props.item.textContent !== undefined
   previewError.value = ''
   previewError.value = ''
   if (!sourceUrl.value) return
   if (!sourceUrl.value) return
   if (!isWord.value && !isSpreadsheet.value && !isTextual.value && !isPdf.value) return
   if (!isWord.value && !isSpreadsheet.value && !isTextual.value && !isPdf.value) return
@@ -75,8 +77,9 @@ async function loadRichPreview() {
     } else if (isSpreadsheet.value) {
     } else if (isSpreadsheet.value) {
       const { default: readXlsxFile } = await import('read-excel-file/browser')
       const { default: readXlsxFile } = await import('read-excel-file/browser')
       spreadsheetRows.value = await readXlsxFile(blob) as unknown[][]
       spreadsheetRows.value = await readXlsxFile(blob) as unknown[][]
-    } else if (isTextual.value && !previewText.value) {
-      previewText.value = await blob.text()
+    } else if (isTextual.value) {
+      if (!previewTextLoaded.value) previewText.value = await blob.text()
+      previewTextLoaded.value = true
     }
     }
   } catch (error) {
   } catch (error) {
     previewError.value = error instanceof Error ? error.message : '无法生成预览'
     previewError.value = error instanceof Error ? error.message : '无法生成预览'
@@ -108,7 +111,7 @@ watch(() => props.item.id, loadRichPreview, { immediate: true, flush: 'post' })
       <div v-else-if="isPdf && canInline" ref="pdfRoot" class="preview-pdf" />
       <div v-else-if="isPdf && canInline" ref="pdfRoot" class="preview-pdf" />
       <div v-else-if="isWord" ref="officeRoot" class="preview-office" />
       <div v-else-if="isWord" ref="officeRoot" class="preview-office" />
       <div v-else-if="isSpreadsheet && spreadsheetRows.length" class="preview-sheet"><table><tbody><tr v-for="(row, rowIndex) in spreadsheetRows.slice(0, 200)" :key="rowIndex"><td v-for="(cell, cellIndex) in row.slice(0, 50)" :key="cellIndex">{{ cell }}</td></tr></tbody></table></div>
       <div v-else-if="isSpreadsheet && spreadsheetRows.length" class="preview-sheet"><table><tbody><tr v-for="(row, rowIndex) in spreadsheetRows.slice(0, 200)" :key="rowIndex"><td v-for="(cell, cellIndex) in row.slice(0, 50)" :key="cellIndex">{{ cell }}</td></tr></tbody></table></div>
-      <div v-else-if="isTextual && previewText" class="preview-text"><pre v-if="item.kind === 'code'">{{ previewText }}</pre><MarkdownContent v-else :content="previewText" /></div>
+      <div v-else-if="isTextual && previewTextLoaded" class="preview-text"><pre v-if="item.kind === 'code'">{{ previewText }}</pre><MarkdownContent v-else :content="previewText" /></div>
       <a v-else-if="item.kind === 'link' && (item.previewUrl || item.downloadUrl)" class="preview-link" :href="item.previewUrl || item.downloadUrl" target="_blank" rel="noopener noreferrer"><span><AppIcon name="link" :size="27" /></span><strong>{{ item.title || item.name }}</strong><small>在新窗口中打开</small></a>
       <a v-else-if="item.kind === 'link' && (item.previewUrl || item.downloadUrl)" class="preview-link" :href="item.previewUrl || item.downloadUrl" target="_blank" rel="noopener noreferrer"><span><AppIcon name="link" :size="27" /></span><strong>{{ item.title || item.name }}</strong><small>在新窗口中打开</small></a>
       <div v-else class="preview-unavailable"><span><AppIcon :name="previewError ? 'alert' : 'file'" :size="28" /></span><strong>{{ previewLoading ? '正在生成预览…' : previewError || '此格式暂不支持内嵌预览' }}</strong><p>{{ previewError ? '仍可下载文件后使用系统应用查看。' : '可下载文件,使用系统应用查看。' }}</p></div>
       <div v-else class="preview-unavailable"><span><AppIcon :name="previewError ? 'alert' : 'file'" :size="28" /></span><strong>{{ previewLoading ? '正在生成预览…' : previewError || '此格式暂不支持内嵌预览' }}</strong><p>{{ previewError ? '仍可下载文件后使用系统应用查看。' : '可下载文件,使用系统应用查看。' }}</p></div>
     </div>
     </div>

+ 20 - 1
tests/e2e/workspace.spec.ts

@@ -112,9 +112,19 @@ test('previews an octet-stream Markdown file from the file library', async ({ pa
             modifiedAt: Date.now() / 1000,
             modifiedAt: Date.now() / 1000,
             exists: true,
             exists: true,
             origins: [{ profile: 'yaoyao', sessionId: 'session-demo', sessionTitle: '夭夭 Web 验收会话' }],
             origins: [{ profile: 'yaoyao', sessionId: 'session-demo', sessionTitle: '夭夭 Web 验收会话' }],
+          }, {
+            id: 'empty-markdown-file',
+            path: '/tmp/空白说明.md',
+            name: '空白说明.md',
+            extension: 'md',
+            mimeType: 'application/octet-stream',
+            size: 0,
+            modifiedAt: Date.now() / 1000,
+            exists: true,
+            origins: [{ profile: 'yaoyao', sessionId: 'session-demo', sessionTitle: '夭夭 Web 验收会话' }],
           }],
           }],
           nextCursor: null,
           nextCursor: null,
-          total: 1,
+          total: 2,
         }),
         }),
       })
       })
       return
       return
@@ -127,6 +137,10 @@ test('previews an octet-stream Markdown file from the file library', async ({ pa
       })
       })
       return
       return
     }
     }
+    if (url.pathname === '/api/app/files/empty-markdown-file/preview') {
+      await route.fulfill({ status: 200, contentType: 'application/octet-stream', body: '' })
+      return
+    }
     await route.continue()
     await route.continue()
   })
   })
 
 
@@ -135,6 +149,11 @@ test('previews an octet-stream Markdown file from the file library', async ({ pa
   const dialog = page.getByRole('dialog', { name: '预览 版本说明.md' })
   const dialog = page.getByRole('dialog', { name: '预览 版本说明.md' })
   await expect(dialog.locator('.preview-text h1')).toHaveText('版本说明')
   await expect(dialog.locator('.preview-text h1')).toHaveText('版本说明')
   await expect(dialog.getByText('Markdown 预览已恢复', { exact: true })).toBeVisible()
   await expect(dialog.getByText('Markdown 预览已恢复', { exact: true })).toBeVisible()
+  await dialog.locator('.preview-close').click()
+  await page.getByText('空白说明.md', { exact: true }).click()
+  const emptyDialog = page.getByRole('dialog', { name: '预览 空白说明.md' })
+  await expect(emptyDialog.locator('.preview-text')).toBeVisible()
+  await expect(emptyDialog.locator('.preview-unavailable')).toHaveCount(0)
 })
 })
 
 
 test('keeps pins first and loads the next session page at the list bottom', async ({ page }) => {
 test('keeps pins first and loads the next session page at the list bottom', async ({ page }) => {