Переглянути джерело

WIP: 修复文件库侧边栏与文本预览

[gstack-context]
Decisions: 文件库复用聊天草稿会话入口;侧边栏顶部控件降至 36px;文本扩展名参与预览分类;文本预览横向内边距增至 28px。
Remaining: 运行全量 Vitest、完整 Playwright 与最终工作区核验。
Skill: /investigate
[/gstack-context]
samien 2 місяців тому
батько
коміт
bceba7a891

+ 5 - 5
src/client/components/app/WorkspaceShell.vue

@@ -76,7 +76,7 @@ const contextHeading = computed(() => ({
   files: '历史记录',
 })[activeNav.value.key])
 
-const hasPrimaryAction = computed(() => activeNav.value.key === 'chat' || activeNav.value.key === 'groups')
+const hasPrimaryAction = computed(() => ['chat', 'groups', 'files'].includes(activeNav.value.key))
 
 async function navigate(path: string) {
   mobileDrawerOpen.value = false
@@ -449,7 +449,7 @@ onBeforeUnmount(() => {
 .sidebar-feature-nav button {
   display: flex;
   width: calc(100% - 20px);
-  min-height: 40px;
+  min-height: 36px;
   align-items: center;
   gap: 11px;
   margin-inline: 10px;
@@ -479,16 +479,16 @@ onBeforeUnmount(() => {
   top: var(--sidebar-search-top);
   left: 10px;
   width: calc(100% - 20px);
-  min-height: 40px;
+  min-height: 36px;
   margin: 0;
   background: var(--surface);
 }
 
-.sidebar-primary-action { margin: 5px 10px; }
+.sidebar-primary-action { margin: 3px 10px; }
 .sidebar-primary-action :deep(button) {
   display: flex;
   width: 100%;
-  min-height: 40px;
+  min-height: 36px;
   align-items: center;
   justify-content: flex-start;
   gap: 11px;

+ 2 - 1
src/client/components/library/PreviewInspector.vue

@@ -119,11 +119,12 @@ watch(() => props.item.id, loadRichPreview, { immediate: true, flush: 'post' })
 <style scoped>
 .preview-inspector { display: flex; height: 100%; min-height: 0; flex-direction: column; overflow: hidden; }
 header { display: flex; min-height: 56px; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--line); } header > strong { min-width: 0; flex: 1; overflow: hidden; font-size: 12px; font-weight: 630; text-overflow: ellipsis; white-space: nowrap; }.preview-header-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 3px; }.preview-header-actions .icon-button { display: grid; width: 32px; height: 32px; place-items: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--text-muted); cursor: pointer; text-decoration: none; }.preview-header-actions .icon-button:hover { background: var(--surface-soft); color: var(--text-primary); }
-.preview-stage { display: grid; min-height: 0; flex: 1 1 auto; place-items: center; overflow: hidden; background: var(--surface-soft); touch-action: pan-y; }.preview-stage img, .preview-stage video { display: block; width: auto; height: auto; max-width: 100%; max-height: 100%; object-fit: contain; }.preview-stage audio { width: calc(100% - 34px); }.preview-stage object { width: 100%; height: 100%; border: 0; }.preview-text { width: 100%; height: 100%; box-sizing: border-box; padding: 16px; overflow: auto; background: var(--surface-raised); }.preview-text pre { margin: 0; color: var(--text-secondary); font: 10px/1.65 var(--font-code); white-space: pre-wrap; overflow-wrap: anywhere; }
+.preview-stage { display: grid; min-height: 0; flex: 1 1 auto; place-items: center; overflow: hidden; background: var(--surface-soft); touch-action: pan-y; }.preview-stage img, .preview-stage video { display: block; width: auto; height: auto; max-width: 100%; max-height: 100%; object-fit: contain; }.preview-stage audio { width: calc(100% - 34px); }.preview-stage object { width: 100%; height: 100%; border: 0; }.preview-text { width: 100%; height: 100%; box-sizing: border-box; padding: 20px 28px 28px; overflow: auto; background: var(--surface-raised); }.preview-text pre { margin: 0; color: var(--text-secondary); font: 10px/1.65 var(--font-code); white-space: pre-wrap; overflow-wrap: anywhere; }
 .preview-office { width: 100%; height: 100%; overflow: auto; background: #e6e6e4; color: #111; }.preview-office :deep(.docx-wrapper) { padding: 18px 0; background: #e6e6e4; }.preview-office :deep(.docx) { margin-bottom: 14px; box-shadow: 0 4px 18px rgba(0,0,0,.12); }
 .preview-pdf { display: flex; width: 100%; height: 100%; box-sizing: border-box; align-items: center; flex-direction: column; gap: 12px; padding: 12px; overflow: auto; background: #dadad7; }.preview-pdf :deep(.pdf-page) { flex: 0 0 auto; overflow: hidden; background: #fff; box-shadow: 0 3px 14px rgba(0,0,0,.15); }.preview-pdf :deep(canvas) { display: block; }
 .preview-sheet { width: 100%; height: 100%; overflow: auto; background: var(--surface-raised); }.preview-sheet table { min-width: 100%; border-collapse: collapse; font-size: 9px; }.preview-sheet td { min-width: 72px; max-width: 240px; padding: 6px 8px; overflow: hidden; border: 1px solid var(--line); color: var(--text-secondary); text-overflow: ellipsis; white-space: nowrap; }.preview-sheet tr:first-child td { position: sticky; top: 0; background: var(--surface-soft); color: var(--text-primary); font-weight: 600; }
 .preview-link { display: flex; width: calc(100% - 34px); flex-direction: column; align-items: center; color: var(--text-secondary); text-decoration: none; text-align: center; }.preview-link > span, .preview-unavailable > span { display: grid; place-items: center; width: 52px; height: 52px; margin-bottom: 12px; border: 1px solid var(--line); border-radius: 16px; background: var(--surface-raised); }.preview-link strong { max-width: 100%; overflow: hidden; color: var(--text-primary); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }.preview-link small { margin-top: 5px; color: var(--text-muted); font-size: 9px; }
 .preview-unavailable { display: flex; padding: 30px; flex-direction: column; align-items: center; color: var(--text-secondary); text-align: center; }.preview-unavailable strong { font-size: 11px; }.preview-unavailable p { margin: 5px 0 0; color: var(--text-muted); font-size: 9px; }
 @media (max-width: 900px) { header { padding-right: 12px; } }
+@media (max-width: 600px) { .preview-text { padding: 18px 20px 24px; } }
 </style>

+ 7 - 3
src/client/components/workspace/viewModels.ts

@@ -245,9 +245,13 @@ export function roomToUi(room: GroupRoomDetail): UiRoom {
 }
 
 export function fileToUi(item: FileLibraryItem): UiLibraryItem {
-  const kind: UiLibraryItem['kind'] = item.kind === 'document'
-    ? item.mimeType === 'application/pdf' ? 'pdf' : /text|json|xml|javascript|typescript|css|html/.test(item.mimeType) ? 'text' : 'document'
-    : item.kind === 'other' ? 'file' : item.kind
+  const extension = (item.extension || item.name.split('.').at(-1) || '').toLocaleLowerCase()
+  const isTextFile = ['md', 'markdown', 'txt', 'json', 'yaml', 'yml', 'csv', 'js', 'ts', 'py', 'sh', 'css', 'html', 'xml'].includes(extension)
+  const kind: UiLibraryItem['kind'] = item.mimeType === 'application/pdf' || extension === 'pdf'
+    ? 'pdf'
+    : isTextFile || /text|json|xml|javascript|typescript|css|html/.test(item.mimeType)
+      ? 'text'
+      : item.kind === 'document' ? 'document' : item.kind === 'other' ? 'file' : item.kind
   const origin = item.origins.at(-1)
   return {
     id: item.id,

+ 15 - 0
src/client/views/FilesView.vue

@@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { useRouter } from 'vue-router'
 import type { FileKind } from '@shared/types'
 import AppIcon from '@/components/common/AppIcon.vue'
+import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
 import LibraryGrid from '@/components/library/LibraryGrid.vue'
 import PreviewModal from '@/components/library/PreviewModal.vue'
 import ImagePreviewLightbox from '@/components/library/ImagePreviewLightbox.vue'
@@ -94,6 +95,14 @@ async function selectSession(id: string) {
   await router.push({ path: `/chat/${encodeURIComponent(id)}`, query: profile ? { profile } : {} })
 }
 
+async function createChat() {
+  selected.value = null
+  mediaIndex.value = null
+  const profile = auth.activeProfile?.name || 'default'
+  const id = chat.createSession(profile)
+  await router.push({ path: `/chat/${encodeURIComponent(id)}`, query: { profile } })
+}
+
 onMounted(() => {
   void files.load(true)
   void chat.loadSessions(auth.activeProfile?.name)
@@ -114,6 +123,12 @@ watch(() => auth.activeProfile?.name, profile => {
 
 <template>
   <WorkspaceView sidebar-title="历史记录" :sidebar-subtitle="`${chat.sessions.length} 个会话`">
+    <template #sidebar-action>
+      <button type="button" title="新建聊天" aria-label="新建聊天" @click="createChat">
+        <YaoYaoSidebarIcon name="add" />
+        <span>新建聊天</span>
+      </button>
+    </template>
     <template #sidebar>
       <ResourceSidebar
         :items="historyItems"

+ 65 - 0
tests/e2e/workspace.spec.ts

@@ -58,6 +58,13 @@ test('navigates every 9119 workspace without blank transitions', async ({ page }
 
   await page.goto('/files')
   await expect(page.getByRole('heading', { name: '文件库', exact: true }).last()).toBeVisible()
+  const fileSidebar = page.locator('.desktop-sidebar')
+  await expect(fileSidebar.getByRole('button', { name: '新建聊天' })).toBeVisible()
+  const compactControlHeights = await fileSidebar.locator('.sidebar-search-trigger, .sidebar-primary-action button, .sidebar-feature-nav button').evaluateAll(elements => (
+    elements.map(element => element.getBoundingClientRect().height)
+  ))
+  expect(compactControlHeights.length).toBeGreaterThanOrEqual(4)
+  expect(Math.max(...compactControlHeights)).toBeLessThanOrEqual(36)
   await expect(page.getByText('demo-report.pdf')).toBeVisible()
   const firstFileCard = page.locator('.library-grid article').first()
   const cardGeometry = await firstFileCard.evaluate(element => {
@@ -77,6 +84,8 @@ test('navigates every 9119 workspace without blank transitions', async ({ page }
   const modalHeight = await page.locator('.preview-modal').evaluate(element => element.getBoundingClientRect().height)
   expect(modalHeight).toBeGreaterThan(600)
   await previewDialog.locator('.preview-close').click()
+  await fileSidebar.getByRole('button', { name: '新建聊天' }).click()
+  await expect(page).toHaveURL(/\/chat\/draft-[^?]+\?profile=yaoyao/)
 
   await page.goto('/artifacts')
   await expect(page).toHaveURL(/\/chat$/)
@@ -84,6 +93,50 @@ test('navigates every 9119 workspace without blank transitions', async ({ page }
   expect(consoleErrors).toEqual([])
 })
 
+test('previews an octet-stream Markdown file from the file library', async ({ page }) => {
+  await page.route('**/api/app/files**', async route => {
+    const url = new URL(route.request().url())
+    if (url.pathname === '/api/app/files') {
+      await route.fulfill({
+        status: 200,
+        contentType: 'application/json',
+        body: JSON.stringify({
+          profile: 'yaoyao',
+          items: [{
+            id: 'markdown-file',
+            path: '/tmp/版本说明.md',
+            name: '版本说明.md',
+            extension: 'md',
+            mimeType: 'application/octet-stream',
+            size: 48,
+            modifiedAt: Date.now() / 1000,
+            exists: true,
+            origins: [{ profile: 'yaoyao', sessionId: 'session-demo', sessionTitle: '夭夭 Web 验收会话' }],
+          }],
+          nextCursor: null,
+          total: 1,
+        }),
+      })
+      return
+    }
+    if (url.pathname === '/api/app/files/markdown-file/preview') {
+      await route.fulfill({
+        status: 200,
+        contentType: 'application/octet-stream',
+        body: '# 版本说明\n\n- Markdown 预览已恢复',
+      })
+      return
+    }
+    await route.continue()
+  })
+
+  await page.goto('/files')
+  await page.getByText('版本说明.md', { exact: true }).click()
+  const dialog = page.getByRole('dialog', { name: '预览 版本说明.md' })
+  await expect(dialog.locator('.preview-text h1')).toHaveText('版本说明')
+  await expect(dialog.getByText('Markdown 预览已恢复', { exact: true })).toBeVisible()
+})
+
 test('keeps pins first and loads the next session page at the list bottom', async ({ page }) => {
   const sidebar = page.locator('.desktop-sidebar')
   const items = sidebar.locator('.sidebar-list [role="option"]')
@@ -273,12 +326,24 @@ test('renders model changes as a collapsed system event', async ({ page }) => {
 })
 
 test('opens a local message file link as a floating preview card', async ({ page }) => {
+  await page.route('**/Users/samien/Agents/%E6%96%B9%E6%A1%88%E8%8D%89%E7%A8%BF.md', route => route.fulfill({
+    status: 200,
+    contentType: 'text/markdown',
+    body: '# 会话文件\n\n这是会话中的文本文件。',
+  }))
   await page.goto('/chat/session-demo')
   const card = page.getByRole('link', { name: '预览文件 方案草稿.md' })
   await expect(card).toHaveClass(/file-link-card/)
   await card.click()
   const dialog = page.getByRole('dialog', { name: '预览 方案草稿.md' })
   await expect(dialog).toBeVisible()
+  await expect(dialog.locator('.preview-text')).toBeVisible()
+  const textPadding = await dialog.locator('.preview-text').evaluate(element => {
+    const style = getComputedStyle(element)
+    return { left: Number.parseFloat(style.paddingLeft), right: Number.parseFloat(style.paddingRight) }
+  })
+  expect(textPadding.left).toBeGreaterThanOrEqual(24)
+  expect(textPadding.right).toBeGreaterThanOrEqual(24)
   const stageHeight = await dialog.locator('.preview-stage').evaluate(stage => stage.getBoundingClientRect().height)
   expect(stageHeight).toBeGreaterThan(500)
 })