From b6d24ada87e90e1065fcd0b2a2d646f710abae80 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Wed, 30 Sep 2026 23:18:45 +0800 Subject: [PATCH] fix: preview draft and queued image attachments directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make draft thumbnails and queued images open the shared image viewer without parsing. Keep queued attachment menus read-only and remove OCR source and settings entries while preserving explicit text extraction in drafts. Validation: 14 focused tests, typecheck and lint passed. Full suite: 5534 passed, 3 failures outside this change in conversation scrolling and frosted-glass descriptions. Release note: 草稿和队列附件中的图片现在支持点击放大查看,无需 OCR;队列只读菜单不再显示解析来源和解析设置。 --- .../features/document-processing/ui-design.md | 4 +++ src/renderer/src/App.test.tsx | 6 ++++ src/renderer/src/App.tsx | 11 +++++-- src/renderer/src/AttachmentActions.test.tsx | 7 +++-- src/renderer/src/AttachmentActions.tsx | 10 +++---- .../src/QueuedAttachmentsDialog.test.tsx | 29 +++++++++++++++++++ src/renderer/src/QueuedAttachmentsDialog.tsx | 8 ++++- src/renderer/src/styles.css | 8 ++++- 8 files changed, 71 insertions(+), 12 deletions(-) diff --git a/docs/features/document-processing/ui-design.md b/docs/features/document-processing/ui-design.md index 8ee82c83..2196e8cd 100644 --- a/docs/features/document-processing/ui-design.md +++ b/docs/features/document-processing/ui-design.md @@ -51,6 +51,10 @@ 菜单项包含附件名称以区分多个文件。普通图片的“提取图片文字”可作为就近次操作,不能和 菜单形成两套状态或重复执行。菜单复用 `AnchoredMenu` 的定位、键盘和焦点恢复。 +草稿与队列中的图片缩略图可点击进入共享图片查看器,优先显示已保存的完整图片,只有 +缩略图时使用缩略图预览;查看不触发 OCR。队列附件菜单只提供打开原文件,不读取 OCR +来源或显示解析设置;修改发送方式须先恢复到草稿。解析结果入口仅在已有结果时显示。 + 草稿、历史消息和队列预览中的“查看解析结果”“更多附件操作”使用共享 `icon-button` 视觉,图标分别为文档查阅和省略号,点击区域为 `32px × 32px`,在独立操作行内排列。 按钮提供含文件名的可访问名称、悬停提示及键盘焦点提示;草稿移除和导入取消使用同尺寸 diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index e11cbcbe..d43a2f22 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -6953,6 +6953,12 @@ describe("App", () => { await within(composer).findByText("需求说明.md"), ).toBeInTheDocument(); expect(within(composer).getByText("页面截图.png")).toBeInTheDocument(); + const draftImage = within(composer).getByRole("button", { name: "查看图片 页面截图.png" }); + fireEvent.click(draftImage); + const draftViewer = await screen.findByRole("dialog", { name: "页面截图.png" }); + expect(within(draftViewer).getByRole("img")).toHaveAttribute("src", imageAttachment.contentUrl); + fireEvent.click(within(draftViewer).getByRole("button", { name: "关闭图片查看器" })); + await waitFor(() => expect(draftImage).toHaveFocus()); fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "分析这些附件" }, }); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 8fa235ba..63997bc1 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -9656,12 +9656,19 @@ function App(): React.JSX.Element { title={attachment.preview} > {attachment.kind === "image" && - attachment.thumbnailUrl ? ( + (attachment.thumbnailUrl || attachment.contentUrl) ? ( + ) : ( )} diff --git a/src/renderer/src/AttachmentActions.test.tsx b/src/renderer/src/AttachmentActions.test.tsx index 16c7f8e6..815bc346 100644 --- a/src/renderer/src/AttachmentActions.test.tsx +++ b/src/renderer/src/AttachmentActions.test.tsx @@ -60,10 +60,11 @@ it('keeps attachment actions icon-only and named, and restores keyboard focus af }) it('keeps queued attachments read-only and exposes no action for legacy attachments without assets', async () => { - const { rerender } = render() + const { rerender } = render() fireEvent.click(screen.getByRole('button')) - await screen.findByText('解析来源:HTTP PaddleOCR-VL(远程服务)') - expect(screen.getAllByRole('menuitem')).toHaveLength(2) + expect(screen.queryByText(/解析来源/)).not.toBeInTheDocument() + expect(window.goodbuddy.documentParsing!.getSnapshot).not.toHaveBeenCalled() + expect(screen.getAllByRole('menuitem')).toHaveLength(1) fireEvent.keyDown(document.activeElement!, { key: 'Escape' }) rerender() expect(screen.queryByRole('button')).not.toBeInTheDocument() diff --git a/src/renderer/src/AttachmentActions.tsx b/src/renderer/src/AttachmentActions.tsx index fe2b133a..aca1eccb 100644 --- a/src/renderer/src/AttachmentActions.tsx +++ b/src/renderer/src/AttachmentActions.tsx @@ -31,24 +31,24 @@ export function AttachmentActions({ attachment, conversationId, onBusyChange, re return <> {menu && setMenu(false)}> - {provider} + {!readOnly && {provider}} - {conversationId && } - {conversationId && attachment.sendMode === 'text' && } + {!readOnly && conversationId && attachment.sendMode === 'text' && } {!readOnly && !conversationId && target?.activeId && } - + {!readOnly && } } {operation && 正在处理附件} {error && {error}} diff --git a/src/renderer/src/QueuedAttachmentsDialog.test.tsx b/src/renderer/src/QueuedAttachmentsDialog.test.tsx index cd145341..e806788d 100644 --- a/src/renderer/src/QueuedAttachmentsDialog.test.tsx +++ b/src/renderer/src/QueuedAttachmentsDialog.test.tsx @@ -1,9 +1,38 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' import { QueuedAttachmentsDialog } from './QueuedAttachmentsDialog' +import { DocumentConversationContext } from './DocumentConversationContext' afterEach(() => { cleanup(); vi.unstubAllGlobals() }) +it.each([true, false])('previews queued images without parsing (full image available: %s)', async (fullImage) => { + const thumbnailUrl = 'data:image/png;base64,thumbnail' + const contentUrl = fullImage ? 'data:image/png;base64,full' : undefined + const openImage = vi.fn() + const getSnapshot = vi.fn() + const reparseDraft = vi.fn() + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }) + vi.stubGlobal('goodbuddy', { + conversationQueue: { getAttachments: vi.fn().mockResolvedValue([ + { id: 'image', resourceId: 'image', name: 'screenshot.png', kind: 'image', sendMode: 'image', thumbnailUrl, contentUrl } + ]) }, + documentParsing: { getSnapshot }, context: { reparseDraft } + }) + render( + + ) + expect(await screen.findByRole('img', { name: 'screenshot.png' })).toHaveAttribute('src', thumbnailUrl) + const trigger = screen.getByRole('button', { name: '查看图片 screenshot.png' }) + fireEvent.click(trigger) + expect(openImage).toHaveBeenCalledWith(contentUrl ?? thumbnailUrl, 'screenshot.png', trigger) + fireEvent.click(screen.getByRole('button', { name: '更多附件操作:screenshot.png' })) + expect(screen.getAllByRole('menuitem')).toHaveLength(1) + expect(screen.getByRole('menuitem')).toHaveTextContent('打开原文件') + expect(screen.queryByRole('button', { name: /查看解析结果/ })).not.toBeInTheDocument() + expect(getSnapshot).not.toHaveBeenCalled() + expect(reparseDraft).not.toHaveBeenCalled() +}) + it('opens snapshot guidance without hiding empty state or closing the dialog on Escape', async () => { const onClose = vi.fn() vi.stubGlobal('goodbuddy', { conversationQueue: { getAttachments: vi.fn().mockResolvedValue([]) } }) diff --git a/src/renderer/src/QueuedAttachmentsDialog.tsx b/src/renderer/src/QueuedAttachmentsDialog.tsx index f1f0f9da..8e7faf98 100644 --- a/src/renderer/src/QueuedAttachmentsDialog.tsx +++ b/src/renderer/src/QueuedAttachmentsDialog.tsx @@ -1,13 +1,15 @@ -import { useEffect, useRef, useState } from 'react' +import { useContext, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import type { ContextAttachment } from '../../shared/contracts' import { activateModalFocus, trapTabFocus } from './dialog-focus' import { AttachmentResultButton } from './AttachmentResultButton' import { AttachmentActions, AttachmentStatus } from './AttachmentActions' import { InlineHelp } from './InlineHelp' +import { DocumentConversationContext } from './DocumentConversationContext' export function QueuedAttachmentsDialog({ itemId, onClose }: { itemId: string; onClose: () => void }): React.JSX.Element { const close = useRef(null) + const conversationContext = useContext(DocumentConversationContext) const [items, setItems] = useState() const [error, setError] = useState('') useEffect(() => activateModalFocus(() => close.current), []) @@ -29,6 +31,10 @@ export function QueuedAttachmentsDialog({ itemId, onClose }: { itemId: string; o {items?.length === 0 &&

此输入没有附件。

}
{items?.map((item) =>

{item.name}

+ {item.kind === 'image' && (item.thumbnailUrl || item.contentUrl) &&
}
{item.resultId && } diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 12157f73..31456932 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -7752,11 +7752,17 @@ button > svg { cursor: pointer; } -.context-chip > button:hover { +.context-chip > button:not(.message-image-button):hover { background: var(--danger-subtle); color: var(--danger); } +.context-chip > .message-image-button { + width: auto; + height: auto; + cursor: zoom-in; +} + .attachment-metadata, .attachment-actions { display: flex;