From cae453b6979fedf6be861c35a5c85091c176c7d3 Mon Sep 17 00:00:00 2001 From: lzp Date: Fri, 16 Oct 2026 22:27:18 +0800 Subject: [PATCH 01/24] =?UTF-8?q?test(context-room):=20=E5=85=B3=E7=B3=BB?= =?UTF-8?q?=E9=A1=B5=E5=88=9D=E5=A7=8B=E6=80=81=E6=96=AD=E8=A8=80=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E6=97=A0=E9=A1=B5=E8=84=9A=E2=80=94=E2=80=94=E5=BD=93?= =?UTF-8?q?=E5=89=8D=20Room=20=E4=B8=8D=E6=B8=B2=E6=9F=93=E3=80=8C?= =?UTF-8?q?=E6=89=93=E5=BC=80=20Room=E3=80=8D=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2e5d22e85 起当前 Room 检查器有意不渲染「打开 Room」按钮,页脚随之省略; 选中关系后的页脚断言保持不变。 --- apps/desktop/tests/context-room-relations-pane.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/tests/context-room-relations-pane.test.tsx b/apps/desktop/tests/context-room-relations-pane.test.tsx index cb180eed..7b43df41 100644 --- a/apps/desktop/tests/context-room-relations-pane.test.tsx +++ b/apps/desktop/tests/context-room-relations-pane.test.tsx @@ -183,7 +183,8 @@ describe('RelationsPane graph selection', () => { }) expect(renderer!.root.findByProps({ className: 'context-room-graph-inspector-scroll' })).toBeTruthy() - expect(renderer!.root.findByProps({ className: 'context-room-graph-inspector-footer' })).toBeTruthy() + // 当前 Room 自身不渲染「打开 Room」按钮,页脚随之省略。 + expect(renderer!.root.findAllByProps({ className: 'context-room-graph-inspector-footer' })).toHaveLength(0) await act(async () => { graphProps.at(-1)!.onSelectRelation('relation-a-b') From cd8208b03e9e78afe1b8227821dd70f282bd44c8 Mon Sep 17 00:00:00 2001 From: lzp Date: Sat, 17 Oct 2026 16:34:51 +0800 Subject: [PATCH 02/24] =?UTF-8?q?fix(files):=20=E6=96=87=E4=BB=B6=E9=A1=B5?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E5=8D=A1=E6=98=BE=E7=A4=BA=E5=B0=81=E9=9D=A2?= =?UTF-8?q?=E7=BC=A9=E7=95=A5=E5=B9=B6=E5=8F=AF=E5=BA=94=E7=94=A8=E5=86=85?= =?UTF-8?q?=E9=A2=84=E8=A7=88=EF=BC=9B=E6=A0=87=E9=A2=98=E5=8D=95=E8=A1=8C?= =?UTF-8?q?=E6=88=AA=E6=96=AD=20(#309)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 图片卡图标位换成 everroom-material:// 封面缩略(140px cover), 加载失败回退图标布局;heic 排除(Chromium 不可渲染) - 点击图片改为应用内放大预览 dialog(Esc/点背景关闭),不再跳外部程序 - 标题补 white-space:nowrap 单行省略,悬停显示全名;删除占位空 span --- apps/desktop/src/renderer/mock-files.html | 12 ++ .../src/components/pages/FilesPage.test.tsx | 125 ++++++++++++++++++ .../src/components/pages/FilesPage.tsx | 116 ++++++++++++++-- .../src/i18n/locales/en-US/surface.json | 3 +- .../src/i18n/locales/zh-CN/surface.json | 3 +- .../src/renderer/src/mock-files-entry.tsx | 14 ++ apps/desktop/src/renderer/src/styles.css | 30 +++++ apps/desktop/vite.browser.config.mts | 13 +- 8 files changed, 299 insertions(+), 17 deletions(-) create mode 100644 apps/desktop/src/renderer/mock-files.html create mode 100644 apps/desktop/src/renderer/src/components/pages/FilesPage.test.tsx create mode 100644 apps/desktop/src/renderer/src/mock-files-entry.tsx diff --git a/apps/desktop/src/renderer/mock-files.html b/apps/desktop/src/renderer/mock-files.html new file mode 100644 index 00000000..b8275344 --- /dev/null +++ b/apps/desktop/src/renderer/mock-files.html @@ -0,0 +1,12 @@ + + + + + + FilesPage 浏览器复现 + + +
+ + + diff --git a/apps/desktop/src/renderer/src/components/pages/FilesPage.test.tsx b/apps/desktop/src/renderer/src/components/pages/FilesPage.test.tsx new file mode 100644 index 00000000..83138fbb --- /dev/null +++ b/apps/desktop/src/renderer/src/components/pages/FilesPage.test.tsx @@ -0,0 +1,125 @@ +import React from 'react' +import TestRenderer, { act } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { FileCatalogDto } from '../../../../shared/ingest' + +vi.mock('@/i18n/LocaleContext', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useLocale: () => ({ + locale: 'zh-CN', + setLocale: vi.fn(), + t: (message: string, values?: Record) => actual.translate('zh-CN', message, values), + formatNumber: (value: number) => value.toLocaleString('zh-CN'), + formatDate: (value: Date | number | string, options?: Intl.DateTimeFormatOptions) => ( + new Intl.DateTimeFormat('zh-CN', options).format(new Date(value)) + ), + }), + } +}) + +const HASH_JPG = 'a1'.repeat(32) +const HASH_HEIC = 'c3'.repeat(32) + +function catalogFile(overrides: Partial & Pick): FileCatalogDto { + return { + displayName: null, + sharedTitle: overrides.originalName, + sourceKind: 'manual-upload', + sourceLabel: '手动上传', + relativePath: null, + provider: null, + bytes: 1024, + dataType: 'image', + agentCategory: null, + summary: null, + tags: [], + processingState: 'ready', + clusterId: null, + contentHash: 'b2'.repeat(32), + currentVersionId: null, + parsed: true, + updatedAt: '2026-10-17T09:24:00.000Z', + ...overrides, + } +} + +const flush = async () => { + for (let index = 0; index < 4; index += 1) await act(async () => { await Promise.resolve() }) +} + +describe('FilesPage', () => { + let renderer!: TestRenderer.ReactTestRenderer + let keydownHandlers: Array<(event: { key: string }) => void> + + beforeEach(() => { + vi.resetModules() + keydownHandlers = [] + const files = [ + catalogFile({ id: 'img-1', originalName: '007 一杯咖啡与一本书的相遇 — 副本.jpeg', contentHash: HASH_JPG }), + catalogFile({ id: 'heic-1', originalName: '现场照片.heic', contentHash: HASH_HEIC }), + catalogFile({ id: 'doc-1', originalName: '超长文件名文档示例用于验证单行截断.pdf', dataType: 'document', sourceKind: 'manual-upload' }), + ] + vi.stubGlobal('window', { + addEventListener: (type: string, handler: (event: { key: string }) => void) => { + if (type === 'keydown') keydownHandlers.push(handler) + }, + removeEventListener: vi.fn(), + setInterval, + clearInterval, + setTimeout, + clearTimeout, + innerWidth: 1400, + nxcore: { + platform: 'darwin', + files: { list: vi.fn(async () => ({ items: files, total: files.length })) }, + ingest: { listEvents: vi.fn(async () => ({ items: [], total: 0 })) }, + }, + }) + vi.stubGlobal('document', { + hidden: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + querySelector: vi.fn(() => null), + }) + }) + + afterEach(() => { + act(() => { renderer.unmount() }) + vi.unstubAllGlobals() + }) + + it('图片分类卡显示素材协议封面,heic 回退图标,点击进预览 dialog 且 Esc 关闭', async () => { + const { FilesPage } = await import('./FilesPage') + await act(async () => { + renderer = TestRenderer.create( undefined} onOpenOfficePreview={() => undefined} />) + }) + await flush() + + const imageCategoryCard = renderer.root.findAll((node) => + typeof node.props.className === 'string' && node.props.className.startsWith('file-category-card') + && node.props.className.includes('teal'))[0] + expect(imageCategoryCard).toBeTruthy() + await act(async () => { imageCategoryCard.props.onClick() }) + await flush() + + const detail = renderer.root.findAll((node) => node.props?.['data-file-id'] === 'img-1')[0] + const mainButton = detail.findByProps({ className: 'file-document-main has-cover' }) + const cover = mainButton.findByProps({ className: 'file-document-cover' }) + expect(cover.props.src).toBe(`everroom-material://${HASH_JPG}`) + expect(cover.props.alt).toBe('007 一杯咖啡与一本书的相遇 — 副本.jpeg') + + const heicDetail = renderer.root.findAll((node) => node.props?.['data-file-id'] === 'heic-1')[0] + expect(() => heicDetail.findByProps({ className: 'file-document-cover' })).toThrow() + expect(heicDetail.findByProps({ className: 'file-document-main' }).props.className).not.toContain('has-cover') + + await act(async () => { mainButton.props.onClick() }) + const dialog = renderer.root.findByProps({ role: 'dialog' }) + expect(dialog.findByProps({ className: 'image-preview-figure' }).props.src).toBe(`everroom-material://${HASH_JPG}`) + + await act(async () => { keydownHandlers.forEach((handler) => handler({ key: 'Escape' })) }) + expect(renderer.root.findAllByType('section').some((node) => node.props.className?.includes('image-preview-dialog'))).toBe(false) + }) +}) diff --git a/apps/desktop/src/renderer/src/components/pages/FilesPage.tsx b/apps/desktop/src/renderer/src/components/pages/FilesPage.tsx index a39e7948..2742a349 100644 --- a/apps/desktop/src/renderer/src/components/pages/FilesPage.tsx +++ b/apps/desktop/src/renderer/src/components/pages/FilesPage.tsx @@ -157,6 +157,8 @@ export function FilesPage({ const [searchQuery, setSearchQuery] = useState('') const [dragActive, setDragActive] = useState(false) const [clipperPreview, setClipperPreview] = useState<{ capture: BrowserExtensionClipperCapture; markdown: string } | null>(null) + const [imagePreview, setImagePreview] = useState(null) + const [imagePreviewFailed, setImagePreviewFailed] = useState(false) const [focusedFileId, setFocusedFileId] = useState(null) const handledFocusRequestIdRef = useRef(-1) const dragDepth = useRef(0) @@ -275,6 +277,18 @@ export function FilesPage({ }).catch(() => undefined) }, [focusRequest, loadFiles]) + const openImagePreview = useCallback((file: FileCatalogDto) => { + setImagePreviewFailed(false) + setImagePreview(file) + }, []) + + useEffect(() => { + if (!imagePreview) return + const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') setImagePreview(null) } + window.addEventListener('keydown', closeOnEscape) + return () => window.removeEventListener('keydown', closeOnEscape) + }, [imagePreview]) + const classifiedFilesByCategory = useMemo(() => groupCatalogFiles(files), [files]) const normalizedSearch = searchQuery.trim().toLocaleLowerCase() @@ -615,15 +629,19 @@ export function FilesPage({ items={selectedCategory.files.map(({ file }) => file.id)} renderItem={(fileId) => { const file = selectedCategory.files.find(({ file: item }) => item.id === fileId)!.file - return
- - -
{file.processingState === 'ready' ? t('surface:files.parsed') : file.processingState === 'failed' ? t('surface:files.failed') : file.processingState === 'missing' ? t('surface:files.originalMissing') : t('surface:files.processing')}{formatBytes(file.bytes)}{formatDate(file.updatedAt, locale, t)}
-
{file.sourceKind === 'web-clipper' ? t('surface:files.clipperSource') : file.sourceLabel}
-
+ return ( + + ) }} /> @@ -698,12 +716,50 @@ export function FilesPage({ ) : null} + {imagePreview ? ( +
{ + if (event.target === event.currentTarget) setImagePreview(null) + }}> +
+
+
+ {t('surface:files.categoryImage')} +

{imagePreview.originalName}

+ {formatBytes(imagePreview.bytes)} · {formatDate(imagePreview.updatedAt, locale, t)} +
+ +
+
+ {imagePreviewFailed ? ( +
+ ) : ( + {imagePreview.sharedTitle} setImagePreviewFailed(true)} + /> + )} +
+
+
+ ) : null} + ) } const IMAGE_ICON_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'heic']) +/** Chromium 可渲染且素材协议按字节嗅探能出图的扩展名(heic 排除:嗅探不出 image/* 必 404)。 */ +const PREVIEWABLE_IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']) +const CONTENT_HASH_PATTERN = /^[a-f0-9]{64}$/ + +function isPreviewableImage(file: FileCatalogDto): boolean { + const extension = file.originalName.split('.').pop()?.toLowerCase() + return PREVIEWABLE_IMAGE_EXTENSIONS.has(extension ?? '') && CONTENT_HASH_PATTERN.test(file.contentHash) +} + function FileTypeIcon({ file }: { file: FileCatalogDto }) { const extension = file.originalName.split('.').pop()?.toLowerCase() const Icon = IMAGE_ICON_EXTENSIONS.has(extension ?? '') @@ -716,6 +772,48 @@ function FileTypeIcon({ file }: { file: FileCatalogDto }) { return