# 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
187 lines
5.6 KiB
TypeScript
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;
|