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:
@@ -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: "分析这些附件" },
|
||||
});
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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([]) } })
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user