Files
Genarrative/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx
T
kdletters 6f181b36a1
Project CI / Frontend tests (push) Failing after 22s
Project CI / Repository checks (push) Successful in 58s
Project CI / Backend tests (push) Successful in 3m30s
Project CI / Native shell tests (push) Successful in 12m34s
修复编辑器生成幂等与参考图契约
生成队列按稳定请求标识去重并保持外部幂等哈希兼容
统一拒绝参考图超限并同步前端、后端、Provider 与 OpenAPI 契约
按真实归属重建生成引用并阻止直接持久化伪造来源
锁定参考图在途上传上下文并保留批量部分成功结果
关闭内部生成 POST 自动重试并补齐回归测试与项目文档
修正最新主线开发者密钥弹窗的导入排序门禁
2026-08-05 23:31:16 +08:00

323 lines
11 KiB
TypeScript

import { ImagePlus } from 'lucide-react';
import {
type CSSProperties,
type Dispatch,
type ReactNode,
type RefObject,
type SetStateAction,
} from 'react';
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import type {
CharacterReferenceImage,
GenerateDialogState,
PublicationMaterialsGameInfo,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import {
buildPublicationMaterialsPrompt,
calculateEditorImageModelPrice,
DEFAULT_PUBLICATION_GAME_INFO,
IMAGE_MODEL_GPT_IMAGE_2,
} from './ImageCanvasGenerationModel';
import type { PublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
type ImageCanvasPublicationMaterialsDemoPanelViewProps = {
dialog: GenerateDialogState;
workflow: PublicationMaterialsWorkflow;
style: CSSProperties;
publicationReferenceButtonRef: RefObject<HTMLButtonElement | null>;
isPublicationReferenceMenuOpen: boolean;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
setIsPublicationReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsPickingPublicationReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
renderEditorPortal: (node: ReactNode) => ReactNode;
buildPortalMenuStyle: (
anchor: HTMLElement | null,
placement: 'above' | 'below',
) => CSSProperties;
onRequestUpload: (target: UploadTarget) => void;
onRememberImageModel: (model: string) => void;
hasPendingImageReferenceUploads?: boolean;
onSubmit: (dialog: GenerateDialogState) => void;
};
function resetFailedDialogStatus(dialog: GenerateDialogState) {
return {
...dialog,
status: dialog.status === 'failed' ? 'idle' : dialog.status,
errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage,
};
}
function updatePublicationGameInfoField(
currentDialog: GenerateDialogState | null,
key: keyof PublicationMaterialsGameInfo,
value: string,
): GenerateDialogState | null {
if (currentDialog?.mode !== 'publication') {
return currentDialog;
}
const publicationGameInfo = {
...DEFAULT_PUBLICATION_GAME_INFO,
...currentDialog.publicationGameInfo,
[key]: value,
};
return {
...resetFailedDialogStatus(currentDialog),
publicationGameInfo,
prompt: buildPublicationMaterialsPrompt(publicationGameInfo),
};
}
function ReferenceThumb({
reference,
index,
onRemove,
}: {
reference: CharacterReferenceImage;
index: number;
onRemove: () => void;
}) {
const label = reference.label || `参考图${index + 1}`;
return (
<ImageCanvasReferenceSlot
tone="default"
icon={<ImagePlus className="h-4 w-4" aria-hidden="true" />}
imageSrc={reference.src}
objectKey={reference.objectKey}
label={label}
ariaLabel={label}
title={reference.label}
onRemove={onRemove}
removeLabel={`删除${label}`}
/>
);
}
export function ImageCanvasPublicationMaterialsDemoPanelView({
dialog,
workflow,
style,
publicationReferenceButtonRef,
isPublicationReferenceMenuOpen,
setGenerateDialog,
setIsPublicationReferenceMenuOpen,
setIsPickingPublicationReferenceFromCanvas,
renderEditorPortal,
buildPortalMenuStyle,
onRequestUpload,
onRememberImageModel,
hasPendingImageReferenceUploads = false,
onSubmit,
}: ImageCanvasPublicationMaterialsDemoPanelViewProps) {
const references = dialog.publicationReferences ?? [];
const publicationGameInfo = {
...DEFAULT_PUBLICATION_GAME_INFO,
...dialog.publicationGameInfo,
};
return (
<form
className="image-canvas-editor__character-composer image-canvas-editor__publication-composer"
style={style}
role="dialog"
aria-label={`${workflow.label}生成卡片`}
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (dialog.status !== 'generating') {
onSubmit(dialog);
}
}}
>
<div className="image-canvas-editor__reference-strip">
{references.map((reference, index) => (
<ReferenceThumb
key={reference.id}
reference={reference}
index={index}
onRemove={() =>
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'publication'
? {
...resetFailedDialogStatus(currentDialog),
publicationReferences: (
currentDialog.publicationReferences ?? []
).filter((item) => item.id !== reference.id),
}
: currentDialog,
)
}
/>
))}
<ImageCanvasReferenceSlot
buttonRef={publicationReferenceButtonRef}
tone="default"
icon={<ImagePlus className="h-4 w-4" aria-hidden="true" />}
label="添加参考图"
ariaLabel="添加参考图"
disabled={dialog.status === 'generating'}
isAdd
onClick={() => setIsPublicationReferenceMenuOpen((open) => !open)}
/>
{isPublicationReferenceMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__publication-reference-menu image-canvas-editor__portal-menu"
label="宣发素材参考图来源"
placement="top-start"
style={buildPortalMenuStyle(
publicationReferenceButtonRef.current,
'above',
)}
>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
setIsPickingPublicationReferenceFromCanvas(true);
setIsPublicationReferenceMenuOpen(false);
}}
>
从画布中选择
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
setIsPublicationReferenceMenuOpen(false);
onRequestUpload('publication-reference');
}}
>
上传图片
</PlatformFloatingMenuItem>
</PlatformFloatingMenu>,
)
: null}
</div>
<div className="image-canvas-editor__publication-composer-fields">
<div className="image-canvas-editor__publication-composer-summary">
<span>{workflow.outputLabel}</span>
<strong>{workflow.sizeLabel}</strong>
</div>
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
>
游戏名
</PlatformFieldLabel>
<PlatformTextField
aria-label={`${workflow.label}游戏名`}
value={publicationGameInfo.gameName}
disabled={dialog.status === 'generating'}
placeholder="马戏团午夜惊魂"
size="sm"
density="compact"
className="image-canvas-editor__publication-input"
onChange={(event) =>
setGenerateDialog((currentDialog) =>
updatePublicationGameInfoField(
currentDialog,
'gameName',
event.target.value,
),
)
}
/>
</label>
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
>
游戏分类
</PlatformFieldLabel>
<PlatformTextField
aria-label={`${workflow.label}游戏分类`}
value={publicationGameInfo.gameCategories}
disabled={dialog.status === 'generating'}
placeholder="非对称对抗"
size="sm"
density="compact"
className="image-canvas-editor__publication-input"
onChange={(event) =>
setGenerateDialog((currentDialog) =>
updatePublicationGameInfoField(
currentDialog,
'gameCategories',
event.target.value,
),
)
}
/>
</label>
<label className="image-canvas-editor__field-block image-canvas-editor__publication-description-field">
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
>
一句话描述游戏
</PlatformFieldLabel>
<AutoGrowTextArea
aria-label={`${workflow.label}一句话描述游戏`}
value={publicationGameInfo.gameDescription}
disabled={dialog.status === 'generating'}
placeholder="神奇数字马戏团里,帕姆尼失控了,找到钥匙,开门逃离马戏团,千万别被抓住了"
className="image-canvas-editor__generation-prompt image-canvas-editor__publication-description"
onChange={(event) =>
setGenerateDialog((currentDialog) =>
updatePublicationGameInfoField(
currentDialog,
'gameDescription',
event.target.value,
),
)
}
/>
</label>
</div>
{dialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{dialog.errorMessage}
</PlatformStatusMessage>
) : null}
<div className="image-canvas-editor__generation-composer-footer">
<ImageCanvasGenerationImageOptionsView
dialog={dialog}
setGenerateDialog={setGenerateDialog}
includeDimensions={false}
onRememberImageModel={onRememberImageModel}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
lockedModel={IMAGE_MODEL_GPT_IMAGE_2}
optionLabelPrefix="宣发素材"
cost={calculateEditorImageModelPrice(
IMAGE_MODEL_GPT_IMAGE_2,
workflow.imageSize,
)}
submitLabel="生成"
submitAriaLabel="生成"
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
/>
</div>
</form>
);
}