修复画布图片信息复制

为图片信息弹窗增加复制信息按钮

复制内容仅包含当前可见元数据字段

补充图片信息复制回归测试和文档约定
This commit is contained in:
2026-06-27 17:15:19 +08:00
parent 228d31db31
commit 20f6f20c55
5 changed files with 136 additions and 6 deletions
@@ -2835,7 +2835,7 @@
## 2026-06-16 图片画布图片信息页不展示生图 Prompt
- 背景:图片画布中每张生成图片的信息页原来展示 `Prompt` 和复制 Prompt,但该字段可能是后端组装后的生图提示词,不适合作为用户可见的图片输入信息。
- 决策:图片信息页删除生图 Prompt 展示和复制入口,改为展示生成时的用户面板输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、修改要求,以及角色规范、常规参考图、图标规范和修改参考图等参考图卡片。旧数据或上传图片没有输入快照时显示 `-`,不得回退展示内部 Prompt。
- 决策:图片信息页删除生图 Prompt 展示和复制入口,改为展示生成时的用户面板输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、修改要求,以及角色规范、常规参考图、图标规范和修改参考图等参考图卡片,并只允许“复制信息”复制当前可见字段。旧数据或上传图片没有输入快照时显示 `-`,不得回退展示内部 Prompt。
- 影响范围:`src/components/image-editor/ImageCanvasEditorView.tsx`、图片画布 layout snapshot、图片画布技术方案。
- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx` 应覆盖图片信息页无 `Prompt`、无 `复制Prompt`,并展示普通生成、角色生成、图标素材和修改结果的输入快照。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
File diff suppressed because one or more lines are too long
@@ -3,9 +3,14 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import * as clipboardService from '../../services/clipboard';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import { ImageCanvasMetadataModalView } from './ImageCanvasMetadataModalView';
vi.mock('../../services/clipboard', () => ({
copyTextToClipboard: vi.fn(),
}));
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
id: 'layer-1',
@@ -67,6 +72,51 @@ describe('ImageCanvasMetadataModalView', () => {
expect(within(dialog).getByText('generated/object.png')).toBeTruthy();
});
it('copies the visible metadata snapshot without restoring internal prompt fields', async () => {
vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValueOnce(true);
render(
<ImageCanvasMetadataModalView
layer={createLayer({
prompt: '内部组装 Prompt 不应复制',
model: 'gpt-image-2',
taskId: 'gpt-image-2-task-123',
objectKey: 'generated/object.png',
generationInputs: {
fields: [{ title: '生成提示词', value: '清爽游戏按钮' }],
references: [
{
title: '参考图',
label: '角色立绘',
refType: 'project-resource',
refId: 'resource-reference',
},
],
},
})}
onClose={vi.fn()}
/>,
);
const dialog = screen.getByRole('dialog', { name: '图片信息' });
fireEvent.click(within(dialog).getByRole('button', { name: '复制图片信息' }));
expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith(
[
'图片类型:生成图片',
'生成输入:\n生成提示词:清爽游戏按钮\n参考图:角色立绘 (项目资源 · resource-reference)',
'Modelgpt-image-2',
'Resolution1024 x 768 px',
'Task123',
'Objectgenerated/object.png',
].join('\n'),
);
expect(clipboardService.copyTextToClipboard).not.toHaveBeenCalledWith(
expect.stringContaining('内部组装 Prompt 不应复制'),
);
expect(await within(dialog).findByText('已复制')).toBeTruthy();
});
it('renders nanobanana2 instead of the raw Gemini image model id', () => {
render(
<ImageCanvasMetadataModalView
@@ -1,4 +1,8 @@
import { useEffect } from 'react';
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
import { UnifiedModal } from '../common/UnifiedModal';
import { useCopyFeedback } from '../common/useCopyFeedback';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import { formatTaskIdForDisplay } from './ImageCanvasExportModel';
import {
@@ -16,10 +20,50 @@ function formatReferenceType(refType: 'project-resource' | 'asset') {
return refType === 'project-resource' ? '项目资源' : '素材';
}
function formatLayerDurationForDisplay(layer: CanvasLayer) {
return formatCanvasDurationMetric(layer.durationSeconds).replace(
/^\s*/u,
'',
);
}
function formatGenerationInputsForCopy(layer: CanvasLayer) {
const fields = layer.generationInputs?.fields ?? [];
const references = layer.generationInputs?.references ?? [];
if (!fields.length && !references.length) {
return '-';
}
return [
...fields.map((field) => `${field.title}${field.value}`),
...references.map(
(reference) =>
`${reference.title}${reference.label} (${formatReferenceType(
reference.refType,
)} · ${reference.refId})`,
),
].join('\n');
}
function buildLayerMetadataCopyText(layer: CanvasLayer, mediaLabel: string) {
return [
`${mediaLabel}类型:${formatLayerImageType(layer)}`,
`生成输入:\n${formatGenerationInputsForCopy(layer)}`,
`Model${layer.model ? getEditorImageModelDisplayName(layer.model) : '-'}`,
layer.mediaType === 'audio'
? `时长:${formatLayerDurationForDisplay(layer)}`
: `Resolution${layer.originalWidth} x ${layer.originalHeight} px`,
`Task${formatTaskIdForDisplay(layer.taskId)}`,
`Object${layer.objectKey ?? layer.assetObjectId ?? '-'}`,
].join('\n');
}
export function ImageCanvasMetadataModalView({
layer,
onClose,
}: ImageCanvasMetadataModalViewProps) {
const { copyState, copyText, resetCopyState } = useCopyFeedback();
const mediaLabel =
layer?.mediaType === 'audio'
? '音频'
@@ -28,6 +72,20 @@ export function ImageCanvasMetadataModalView({
: layer?.mediaType === 'image-sequence'
? '序列帧'
: '图片';
const metadataText = layer ? buildLayerMetadataCopyText(layer, mediaLabel) : '';
useEffect(() => {
resetCopyState();
}, [metadataText, resetCopyState]);
const copyMetadata = () => {
if (!metadataText) {
return;
}
void copyText(metadataText);
};
return (
<UnifiedModal
open={Boolean(layer)}
@@ -37,6 +95,20 @@ export function ImageCanvasMetadataModalView({
onClose={onClose}
panelClassName="image-canvas-editor__metadata-dialog"
bodyClassName="image-canvas-editor__metadata-body"
footerClassName="image-canvas-editor__metadata-footer"
footer={
layer ? (
<CopyFeedbackButton
state={copyState}
idleLabel="复制信息"
accessibleLabel={`复制${mediaLabel}信息`}
actionSurface="platform"
actionFullWidth
className="image-canvas-editor__metadata-copy-button sm:w-auto"
onClick={copyMetadata}
/>
) : null
}
>
{layer ? (
<dl className="image-canvas-editor__metadata-grid">
@@ -90,10 +162,7 @@ export function ImageCanvasMetadataModalView({
<>
<dt></dt>
<dd>
{formatCanvasDurationMetric(layer.durationSeconds).replace(
/^\s*/u,
'',
)}
{formatLayerDurationForDisplay(layer)}
</dd>
</>
) : (
+11
View File
@@ -8418,6 +8418,9 @@ button.image-canvas-editor__reference-chip:disabled {
.image-canvas-editor__metadata-body {
padding: 0;
-webkit-touch-callout: default;
-webkit-user-select: text;
user-select: text;
}
.image-canvas-editor__generate-dialog-body {
@@ -8549,6 +8552,14 @@ button.image-canvas-editor__reference-chip:disabled {
overflow-wrap: anywhere;
}
.image-canvas-editor__metadata-footer {
padding: 0.72rem 0.85rem;
}
.image-canvas-editor__metadata-copy-button {
justify-content: center;
}
@media (max-width: 760px) {
.image-canvas-editor {
flex-direction: column;