Files
Genarrative/src/components/rpg-creation-asset-studio/RpgCreationRoleVisualSection.tsx
kdletters cf8da3f50f Merge branch 'codex/dev' into codex/backend-rewrite-spacetimedb
# Conflicts:
#	docs/technical/README.md
#	server-node/src/modules/assets/qwenSpriteRoutes.ts
#	src/components/CustomWorldResultView.test.tsx
#	src/components/CustomWorldResultView.tsx
#	src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx
#	src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx
#	src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx
#	src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx
#	src/components/rpg-entry/RpgEntryCharacterSelectView.tsx
#	src/components/rpg-entry/RpgEntryHomeView.tsx
#	src/services/apiClient.ts
#	src/tools/QwenSpriteSheetTool.tsx
2026-04-21 20:16:01 +08:00

187 lines
5.6 KiB
TypeScript

import type { ChangeEvent, ReactNode } from 'react';
import { ImagePlus, RefreshCcw } from 'lucide-react';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type ActionButtonProps = {
icon?: ReactNode;
label: string;
subLabel?: string;
onClick: () => void;
disabled?: boolean;
tone?: 'default' | 'sky' | 'green';
};
type FieldProps = {
label: string;
children: ReactNode;
};
type TextAreaProps = {
value: string;
onChange: (value: string) => void;
rows?: number;
placeholder?: string;
readOnly?: boolean;
};
type SectionProps = {
title: string;
children: ReactNode;
};
export function RpgCreationRoleVisualSection(props: {
ActionButton: (props: ActionButtonProps) => ReactNode;
Field: (props: FieldProps) => ReactNode;
Section: (props: SectionProps) => ReactNode;
TextArea: (props: TextAreaProps) => ReactNode;
handleReferenceImageUpload: (
event: ChangeEvent<HTMLInputElement>,
) => Promise<void>;
hasGeneratedVisualPreview: boolean;
isApplyingVisual: boolean;
isGeneratingVisuals: boolean;
previewImageSrc: string;
referenceImageDataUrls: string[];
selectedTemplatePortrait?: string | null;
selectedTemplateName?: string | null;
syncBusy: boolean;
visualPointCost: number;
visualPromptText: string;
visualStatus: string | null;
workingRoleName: string;
onClearReferenceImages: () => void;
onGenerateVisuals: () => void;
onVisualPromptChange: (value: string) => void;
}) {
const {
ActionButton,
Field,
Section,
TextArea,
handleReferenceImageUpload,
hasGeneratedVisualPreview,
isApplyingVisual,
isGeneratingVisuals,
previewImageSrc,
referenceImageDataUrls,
selectedTemplateName,
selectedTemplatePortrait,
syncBusy,
visualPointCost,
visualPromptText,
visualStatus,
workingRoleName,
onClearReferenceImages,
onGenerateVisuals,
onVisualPromptChange,
} = props;
return (
<Section title="角色形象">
<div className="space-y-4">
<div className="platform-role-studio__preview overflow-hidden rounded-3xl">
<div className="flex min-h-[18rem] items-center justify-center p-4 sm:min-h-[22rem]">
{previewImageSrc ? (
<ResolvedAssetImage
src={previewImageSrc}
alt={workingRoleName}
className="max-h-[28rem] w-full object-contain"
/>
) : selectedTemplatePortrait ? (
<ResolvedAssetImage
src={selectedTemplatePortrait}
alt={selectedTemplateName ?? workingRoleName}
className="max-h-[20rem] w-full object-contain"
/>
) : (
<div className="px-6 text-center text-sm text-zinc-500">
</div>
)}
</div>
</div>
<Field label="形象描述">
<TextArea
value={visualPromptText}
onChange={onVisualPromptChange}
rows={6}
placeholder="这里默认展示角色形象描述,也可以继续手动细化。"
/>
</Field>
<Field label="参考图">
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
<input
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
onChange={(event) => {
void handleReferenceImageUpload(event);
}}
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
/>
</label>
{referenceImageDataUrls.length > 0 ? (
<div className="mt-3 space-y-3">
<div className="flex flex-wrap gap-2">
{referenceImageDataUrls.map((imageSrc, index) => (
<div
key={`${imageSrc}-${index}`}
className="h-16 w-16 overflow-hidden rounded-xl border border-white/10 bg-black/25"
>
<img
src={imageSrc}
alt={`reference-${index + 1}`}
className="h-full w-full object-cover"
/>
</div>
))}
</div>
<div>
<ActionButton
label="清空参考图"
onClick={onClearReferenceImages}
disabled={isGeneratingVisuals || isApplyingVisual || syncBusy}
/>
</div>
</div>
) : null}
</Field>
<div className="flex flex-wrap gap-3">
<ActionButton
icon={
hasGeneratedVisualPreview ? (
<RefreshCcw className="h-4 w-4" />
) : (
<ImagePlus className="h-4 w-4" />
)
}
label={
isGeneratingVisuals
? '生成中...'
: hasGeneratedVisualPreview
? '重新生成角色形象'
: '生成角色形象'
}
subLabel={`消耗${visualPointCost}叙世币`}
onClick={onGenerateVisuals}
disabled={isGeneratingVisuals || isApplyingVisual || syncBusy}
tone="sky"
/>
</div>
{visualStatus ? (
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{visualStatus}
</div>
) : null}
</div>
</Section>
);
}
export default RpgCreationRoleVisualSection;