fix: preview draft and queued image attachments directly

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;队列只读菜单不再显示解析来源和解析设置。
This commit is contained in:
mesalogo
2026-09-30 23:18:45 +08:00
parent c23dca65e8
commit b6d24ada87
8 changed files with 71 additions and 12 deletions
+6
View File
@@ -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: "分析这些附件" },
});
+9 -2
View File
@@ -9656,12 +9656,19 @@ function App(): React.JSX.Element {
title={attachment.preview}
>
{attachment.kind === "image" &&
attachment.thumbnailUrl ? (
(attachment.thumbnailUrl || attachment.contentUrl) ? (
<button
type="button"
className="message-image-button"
aria-label={t('chat.images.viewNamed', { title: attachment.name })}
onClick={(event) => openImageViewer({ src: attachment.contentUrl ?? attachment.thumbnailUrl!, title: attachment.name }, event.currentTarget)}
>
<img
alt=""
className="context-chip__thumbnail"
src={attachment.thumbnailUrl}
src={attachment.thumbnailUrl ?? attachment.contentUrl}
/>
</button>
) : (
<FileText size={14} />
)}
+4 -3
View File
@@ -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(<AttachmentActions attachment={attachment} readOnly />)
const { rerender } = render(<AttachmentActions attachment={{ ...attachment, sendMode: 'text' }} conversationId="conversation" readOnly />)
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(<AttachmentActions attachment={{ ...attachment, resourceId: undefined }} />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
+5 -5
View File
@@ -31,24 +31,24 @@ export function AttachmentActions({ attachment, conversationId, onBusyChange, re
return <>
<button type="button" className="icon-button attachment-action" title="更多附件操作" data-tooltip="更多附件操作" ref={anchor} aria-haspopup="menu" aria-expanded={menu} aria-controls={menu ? id : undefined}
aria-label={`更多附件操作:${attachment.name}`} disabled={Boolean(operation)} onClick={() => {
if (!menu) {
if (!menu && !readOnly) {
setProvider('正在读取已保存的解析来源')
void window.goodbuddy.documentParsing!.getSnapshot().then((snapshot) => setProvider(snapshot.settings.ocrProvider === 'paddleocr-vl' ? '解析来源:HTTP PaddleOCR-VL(远程服务)' : '解析来源:本地 OCR'), () => setProvider('来源读取失败;操作仍使用已保存的解析设置'))
}
setMenu((value) => !value)
}}><Ellipsis size={16} aria-hidden="true" /></button>
{menu && <AnchoredMenu anchorRef={anchor} id={id} label={`附件操作:${attachment.name}`} onClose={() => setMenu(false)}>
<small>{provider}</small>
{!readOnly && <small>{provider}</small>}
<button type="button" role="menuitem" onClick={() => run(() => window.goodbuddy.context.openOriginal(attachment.resourceId!))}>打开原文件:{attachment.originalName ?? attachment.name}</button>
{conversationId && <button type="button" role="menuitem" onClick={() => run((operationId) => window.goodbuddy.context.reparseDraft(conversationId, attachment.id, operationId))}>
{!readOnly && conversationId && <button type="button" role="menuitem" onClick={() => run((operationId) => window.goodbuddy.context.reparseDraft(conversationId, attachment.id, operationId))}>
{attachment.sendMode ? '提取图片文字' : '使用当前设置重新解析'}:{attachment.originalName ?? attachment.name}
</button>}
{conversationId && attachment.sendMode === 'text' && <button type="button" role="menuitem" onClick={() => run(() => window.goodbuddy.context.sendOriginal(conversationId, attachment.id))}>改为发送原图:{attachment.originalName}</button>}
{!readOnly && conversationId && attachment.sendMode === 'text' && <button type="button" role="menuitem" onClick={() => run(() => window.goodbuddy.context.sendOriginal(conversationId, attachment.id))}>改为发送原图:{attachment.originalName}</button>}
{!readOnly && !conversationId && target?.activeId && <button type="button" role="menuitem" onClick={() => run(async (operationId) => {
await window.goodbuddy.context.copyToDraft(target.activeId!, attachment.resourceId!, operationId)
target.notify('已按当前设置解析并添加到目标会话草稿')
})}>{attachment.sendMode ? '添加到当前草稿并提取文字' : '添加到当前草稿重新解析'}:{attachment.originalName ?? attachment.name}</button>}
<button type="button" role="menuitem" onClick={() => { setMenu(false); void window.goodbuddy.localInference.openSettings('document-parsing') }}>前往文档解析设置</button>
{!readOnly && <button type="button" role="menuitem" onClick={() => { setMenu(false); void window.goodbuddy.localInference.openSettings('document-parsing') }}>前往文档解析设置</button>}
</AnchoredMenu>}
{operation && <span role="status">正在处理附件<button type="button" className="secondary-button" onClick={() => void window.goodbuddy.context.cancelParsing(operation)}>取消解析</button></span>}
{error && <span role="alert">{error}</span>}
@@ -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(<DocumentConversationContext.Provider value={{ openImage, create: vi.fn(), navigate: vi.fn(), notify: vi.fn() }}>
<QueuedAttachmentsDialog itemId="queued" onClose={vi.fn()} />
</DocumentConversationContext.Provider>)
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([]) } })
+7 -1
View File
@@ -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<HTMLButtonElement>(null)
const conversationContext = useContext(DocumentConversationContext)
const [items, setItems] = useState<ContextAttachment[]>()
const [error, setError] = useState('')
useEffect(() => activateModalFocus(() => close.current), [])
@@ -29,6 +31,10 @@ export function QueuedAttachmentsDialog({ itemId, onClose }: { itemId: string; o
{items?.length === 0 && <p>此输入没有附件。</p>}
<div className="document-result-panel">{items?.map((item) => <article key={item.id}>
<h3>{item.name}</h3>
{item.kind === 'image' && (item.thumbnailUrl || item.contentUrl) && <div className="document-result-image"><button
type="button" className="message-image-button" aria-label={`查看图片 ${item.name}`}
onClick={(event) => conversationContext?.openImage(item.contentUrl ?? item.thumbnailUrl!, item.name, event.currentTarget)}
><img src={item.thumbnailUrl ?? item.contentUrl} alt={item.name} loading="lazy" /></button></div>}
<div className="attachment-metadata"><AttachmentStatus attachment={item} /></div>
<div className="attachment-actions">
{item.resultId && <AttachmentResultButton resultId={item.resultId} name={item.name} readOnly />}
+7 -1
View File
@@ -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;