Nano Banana2 Forms
Prop contracts and interactions for TextGenerationForm & ImageEditForm
Overview
src/components/workbench/nano-banana/ 暴露了 TextGenerationForm 与 ImageEditForm 两个受控组件,主工作台通过 props 注入状态、文案与回调来驱动 UI。下文整理它们的主要输入与交互,以便 Story/Docs 复用与后续重构。
共享约定
copy源自LocalizedContent,必须保证en/zh均已填充;表单不直接读取messages/*.json。- 所有输入均为受控组件,父级负责字符长度、数量选项等业务校验。
localeKey仅用于生成Select的key,避免在切换语言时 React 重用旧节点。isSubmitting会禁用按钮并展示Loader2,任何提交逻辑都应幂等。
TextGenerationForm
Props
| Prop | Type | 说明 |
|---|---|---|
copy | LocalizedContent['textTab'] | label、占位符、信用点提示等全部来自此对象。 |
localeKey | LocaleKey | 切换语言时强制重建比例下拉。 |
prompt | string | 提示词文本域的受控值。 |
promptLength | number | 供提示词计数模板 {count} 使用。 |
maxPromptCharacters | number | 传给 Textarea.maxLength,用于 UI 级别限制。 |
onPromptChange | (event) => void | 更新提示词状态,父层可同时刷新 promptLength。 |
format | string | 当前成品格式值。 |
onFormatChange | (value: string) => void | 选择框回调,值来自 copy.formatOptions。 |
ratio | string | 当前画幅值。 |
onRatioChange | (value: string) => void | 更新画幅;Select key 结合 localeKey。 |
selectedRatio | RatioOption | 用于展示当前画幅名称、描述与形状。 |
quantity | number | 生成数量按钮的受控状态。 |
onQuantityChange | (value: number) => void | 点击数量按钮时触发。 |
quantityOptions | number[] | 渲染 4 列按钮的候选值。 |
model | string | 显示 / 发送的模型值,通常来自 copy.modelOptions。 |
onModelChange | (value: string) => void | 模型选择回调,驱动 UI 与请求。 |
isSubmitting | boolean | 控制按钮 loading/disabled。 |
onSubmit | () => void | 点击主按钮触发,需处理信用点扣减。 |
submitError | string | null | 若存在,将以红字展示在按钮下方。 |
交互流程
- 文案区域通过
copy.promptStatsTemplate实时显示字符数,父级在onPromptChange后更新promptLength。 format、ratio使用 ShadCNSelect,外观通过bg-background/80与自定义图形保持与 Preview 面板一致。- 数量选择渲染为四列按钮,选中项具备
aria-pressed与主色描边,可直接复用在快捷 Story 中。 - 模型下拉延续 AI 工作台样式,
aria-label& 描述文本跟随copy.modelOptions,回调给父层以便记录选择。 - 主按钮展示信用点徽章(
Zap图标),isSubmitting会切换至Loader2并防止重复点击。 submitError存在时渲染text-destructive的段落,Story 中可注入假数据展示。
可达性
- 数量按钮的
aria-pressed使得屏幕阅读器可读。 - 信用点徽章提供
aria-label,需确保文案在copy.creditCost中同步更新。
ImageEditForm
此组件在 Text tab 基础上扩展了参考图上传、轮播与批量操作。
Props
| Prop | Type | 说明 |
|---|---|---|
copy | LocalizedContent['imageTab'] | 包含参考图、上传提示等文案。 |
localeKey | LocaleKey | 同 Text 表单,用于比率 Select。 |
prompt / promptLength / maxPromptCharacters / onPromptChange | 同 Text 表单 | 复用同一受控逻辑。 |
format / onFormatChange | 同 Text 表单 | |
ratio / onRatioChange / selectedRatio | 同 Text 表单 | |
quantity / onQuantityChange / quantityOptions | 同 Text 表单 | |
model / onModelChange | 同 Text 表单 | |
isSubmitting / onSubmit / submitError | 同 Text 表单 | |
referenceImagesLength | number | 当前已上传参考图总数。 |
visibleReferenceImages | ReferenceImage[] | 轮播窗口中可见的图片列表。 |
visibleReferenceStart | number | 可见窗口在全集中的起始索引,用于页码显示。 |
maxReferenceImages | number | 用于“n / limit”提示与禁用上传。 |
canScrollReferencePrev / canScrollReferenceNext | boolean | 控制左右箭头的 disabled 状态。 |
onScrollReferencePrev / onScrollReferenceNext | () => void | 切换参考图窗口。 |
onSelectReferenceImage | (id: string) => void | 选中缩略图并高亮。 |
onOpenReferencePreview | (image: ReferenceImage) => void | 打开放大预览/对话框。 |
onRemoveReferenceImage | (id: string) => void | 删除单张参考图。 |
onClearReferenceImages | () => void | 清空所有参考图。 |
activeReferenceImageId | string | null | 当前激活缩略图,用于描边状态。 |
referenceImageCountLabel | string | 文案形式的计数(含 {count}/{max}),方便国际化。 |
onReferenceUploadClick | () => void | 触发隐藏 input 的点击。 |
hasReferenceUploadError | boolean | 在 badge 中展示错误态。 |
referenceFileInputRef | React.RefObject<HTMLInputElement> | 提供对隐藏 <input type=\"file\"> 的直接访问。 |
onReferenceInputChange | (event: ChangeEvent<HTMLInputElement>) => void | 处理文件选择,父层负责上传与状态更新。 |
getReferenceUploadStatusLabel | (status: ReferenceUploadStatus) => string | 根据上传状态返回本地化徽章文案。 |
交互流程
- 参考图头部展示数量(
referenceImageCountLabel)与上传按钮;当达到maxReferenceImages或hasReferenceUploadError时显示对应 Badge。 - 轮播区域允许左右箭头与缩略图点击切换,高亮逻辑由
activeReferenceImageId控制。 Upload按钮通过onReferenceUploadClick间接触发隐藏 input,onReferenceInputChange将文件推送给上传状态机。- 每张缩略图支持“预览”、“移除”;批量清空按钮调用
onClearReferenceImages。 - 底部 Prompt/Format/Ratio/Quantity/模型选择与 Text 表单保持一致,确保体验统一。
错误与 Loading
- 上传错误会在卡片中展示
AlertTriangle,并依据hasReferenceUploadError控制文案。 - 表单提交与信用点提示同 Text 表单逻辑,
submitError也渲染在按钮下方。
补充这些说明后,即可在 Storybook(或内部设计文档)中引用本页,减少重复梳理组件约束的工作量。
Minimal Examples
TextGenerationForm
'use client';
import { useState } from 'react';
import enMessages from '@/messages/en.json';
import { TextGenerationForm } from '@/components/workbench/nano-banana/text-generation-form';
import type { LocalizedContent } from '@/components/workbench/nano-banana/types';
const nanoCopy = enMessages.NanoBananaWorkbench as LocalizedContent;
export function TextFormDemo() {
const [prompt, setPrompt] = useState('');
const [format, setFormat] = useState(nanoCopy.textTab.formatOptions[0].value);
const [ratio, setRatio] = useState(nanoCopy.textTab.defaultRatio);
const [quantity, setQuantity] = useState(1);
const [model, setModel] = useState(nanoCopy.textTab.modelOptions[0].value);
const selectedRatio =
nanoCopy.textTab.ratioOptions.find((option) => option.value === ratio) ??
nanoCopy.textTab.ratioOptions[0];
return (
<TextGenerationForm
copy={nanoCopy.textTab}
localeKey="en"
prompt={prompt}
promptLength={prompt.length}
maxPromptCharacters={1000}
onPromptChange={(event) => setPrompt(event.target.value)}
format={format}
onFormatChange={setFormat}
ratio={ratio}
onRatioChange={setRatio}
selectedRatio={selectedRatio}
quantity={quantity}
onQuantityChange={setQuantity}
quantityOptions={[1, 2, 3, 4]}
model={model}
onModelChange={setModel}
isSubmitting={false}
onSubmit={() => {
/* wire up your action here */
}}
submitError={null}
/>
);
}ImageEditForm
'use client';
import { useMemo, useRef, useState } from 'react';
import enMessages from '@/messages/en.json';
import { ImageEditForm } from '@/components/workbench/nano-banana/image-edit-form';
import type {
LocalizedContent,
ReferenceImage,
ReferenceUploadStatus,
} from '@/components/workbench/nano-banana/types';
const nanoCopy = enMessages.NanoBananaWorkbench as LocalizedContent;
const MAX_REFERENCE_IMAGES = 10;
export function ImageFormDemo() {
const [prompt, setPrompt] = useState('');
const [format, setFormat] = useState(nanoCopy.imageTab.formatOptions[0].value);
const [ratio, setRatio] = useState(nanoCopy.imageTab.defaultRatio);
const [quantity, setQuantity] = useState(1);
const [model, setModel] = useState(nanoCopy.imageTab.modelOptions[0].value);
const [referenceImages, setReferenceImages] = useState<ReferenceImage[]>([]);
const referenceFileInputRef = useRef<HTMLInputElement>(null);
const visibleReferenceImages = referenceImages.slice(0, 3);
const selectedRatio =
nanoCopy.imageTab.ratioOptions.find((option) => option.value === ratio) ??
nanoCopy.imageTab.ratioOptions[0];
const referenceImageCountLabel = `${referenceImages.length}/${MAX_REFERENCE_IMAGES}`;
const getReferenceUploadStatusLabel = useMemo(
() => (status: ReferenceUploadStatus) => {
if (status === 'uploaded') {
return 'Uploaded';
}
if (status === 'error') {
return 'Error';
}
return 'Pending';
},
[]
);
return (
<ImageEditForm
copy={nanoCopy.imageTab}
localeKey="en"
prompt={prompt}
promptLength={prompt.length}
maxPromptCharacters={1000}
onPromptChange={(event) => setPrompt(event.target.value)}
format={format}
onFormatChange={setFormat}
ratio={ratio}
onRatioChange={setRatio}
selectedRatio={selectedRatio}
quantity={quantity}
onQuantityChange={setQuantity}
quantityOptions={[1, 2, 3, 4]}
model={model}
onModelChange={setModel}
isSubmitting={false}
onSubmit={() => {
/* trigger your mutation */
}}
submitError={null}
referenceImagesLength={referenceImages.length}
visibleReferenceImages={visibleReferenceImages}
visibleReferenceStart={0}
maxReferenceImages={MAX_REFERENCE_IMAGES}
canScrollReferencePrev={false}
canScrollReferenceNext={false}
onScrollReferencePrev={() => {}}
onScrollReferenceNext={() => {}}
onSelectReferenceImage={() => {}}
onOpenReferencePreview={() => {}}
onRemoveReferenceImage={(id) =>
setReferenceImages((images) => images.filter((image) => image.id !== id))
}
onClearReferenceImages={() => setReferenceImages([])}
activeReferenceImageId={null}
referenceImageCountLabel={referenceImageCountLabel}
onReferenceUploadClick={() => referenceFileInputRef.current?.click()}
hasReferenceUploadError={false}
referenceFileInputRef={referenceFileInputRef}
onReferenceInputChange={() => {}}
getReferenceUploadStatusLabel={getReferenceUploadStatusLabel}
/>
);
}以上示例直接复用 messages/en.json 中的文案,便于在 Storybook 或沙盒页面中快速挂载组件并演练受控属性。
MkSaaS Docs