1301 lines
55 KiB
TypeScript
1301 lines
55 KiB
TypeScript
import React, { useState } from 'react';
|
||
import { UploadZone } from './components/UploadZone';
|
||
import { AnimationPreview } from './components/AnimationPreview';
|
||
import { generateSpriteSheet, generateCharacterPortrait, generateDirectionalSpriteSheet, generateGameMap, MapType, generateGameItem, ItemType } from './services/geminiService';
|
||
import { sliceSpriteSheet } from './utils/imageUtils';
|
||
import { AppState } from './types';
|
||
import { Wand2, Zap, LayoutGrid, Loader2, RotateCcw, Image, Type, User, Film, Download, Move, Map, Sword } from 'lucide-react';
|
||
|
||
const App: React.FC = () => {
|
||
const [appState, setAppState] = useState<AppState>(AppState.IDLE);
|
||
const [originalImage, setOriginalImage] = useState<string | null>(null);
|
||
const [actionPrompt, setActionPrompt] = useState<string>("");
|
||
const [generatedFrames, setGeneratedFrames] = useState<string[]>([]);
|
||
const [fps, setFps] = useState<number>(6);
|
||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||
|
||
// Configuration state - Default to 4 for stability
|
||
const [frameCount, setFrameCount] = useState<number>(4);
|
||
|
||
// Mode: 'image' = with reference image, 'text' = text-only generation
|
||
const [mode, setMode] = useState<'image' | 'text'>('text');
|
||
const [characterDescription, setCharacterDescription] = useState<string>("");
|
||
|
||
// Feature: 'animation', 'portrait', 'directional', 'map', or 'item'
|
||
const [feature, setFeature] = useState<'animation' | 'portrait' | 'directional' | 'map' | 'item'>('animation');
|
||
const [portraitImage, setPortraitImage] = useState<string | null>(null);
|
||
const [portraitStyle, setPortraitStyle] = useState<string>('2D卡通风格');
|
||
|
||
// Directional sprite sheet state
|
||
const [directionalSpriteSheet, setDirectionalSpriteSheet] = useState<string | null>(null);
|
||
const [directionalFrames, setDirectionalFrames] = useState<string[][]>([[], [], [], []]);
|
||
const [selectedDirection, setSelectedDirection] = useState<number>(0);
|
||
const [directionalAction, setDirectionalAction] = useState<string>('行走');
|
||
const [directionalFps, setDirectionalFps] = useState<number>(6);
|
||
|
||
// Map generation state
|
||
const [mapImage, setMapImage] = useState<string | null>(null);
|
||
const [mapType, setMapType] = useState<MapType>('rpg');
|
||
const [mapDescription, setMapDescription] = useState<string>('');
|
||
const [mapStyle, setMapStyle] = useState<string>('像素风格');
|
||
|
||
// Item generation state
|
||
const [itemImage, setItemImage] = useState<string | null>(null);
|
||
const [itemType, setItemType] = useState<ItemType>('weapon');
|
||
const [itemDescription, setItemDescription] = useState<string>('');
|
||
const [itemStyle, setItemStyle] = useState<string>('像素风格');
|
||
const [itemQuantity, setItemQuantity] = useState<'single' | 'set'>('single');
|
||
|
||
// Step 1: Handle Image Upload
|
||
const handleImageSelected = (base64: string) => {
|
||
setOriginalImage(base64);
|
||
setAppState(AppState.READY_TO_GENERATE);
|
||
setErrorMsg(null);
|
||
// Pre-fill a default action if empty to encourage user
|
||
if (!actionPrompt) setActionPrompt("闲置动作");
|
||
};
|
||
|
||
// Step 2: Generate Animation
|
||
const handleGenerate = async () => {
|
||
if (mode === 'image' && !originalImage) {
|
||
setErrorMsg("请先上传图片");
|
||
return;
|
||
}
|
||
if (mode === 'text' && !characterDescription) {
|
||
setErrorMsg("请输入角色描述");
|
||
return;
|
||
}
|
||
if (!actionPrompt) {
|
||
setErrorMsg("请输入动作描述");
|
||
return;
|
||
}
|
||
|
||
setAppState(AppState.GENERATING);
|
||
setErrorMsg(null);
|
||
setGeneratedFrames([]);
|
||
|
||
// Calculate rows and cols based on frame count
|
||
// NOTE: This must match the logic in geminiService implicitly via params
|
||
let rows = 2;
|
||
let cols = 2; // Default 4 frames (2x2)
|
||
|
||
if (frameCount === 4) { rows = 2; cols = 2; }
|
||
if (frameCount === 6) { rows = 2; cols = 3; }
|
||
if (frameCount === 8) { rows = 2; cols = 4; }
|
||
if (frameCount === 9) { rows = 3; cols = 3; }
|
||
if (frameCount === 16) { rows = 4; cols = 4; }
|
||
|
||
try {
|
||
const spriteSheetBase64 = await generateSpriteSheet(
|
||
mode === 'image' ? originalImage : null,
|
||
actionPrompt,
|
||
rows,
|
||
cols,
|
||
mode === 'text' ? characterDescription : undefined
|
||
);
|
||
|
||
// Slice the grid using Fixed Grid slicing
|
||
const frames = await sliceSpriteSheet(spriteSheetBase64, rows, cols, true);
|
||
|
||
if (!frames || frames.length === 0) {
|
||
throw new Error("图像处理失败,请重试。");
|
||
}
|
||
|
||
setGeneratedFrames(frames);
|
||
setAppState(AppState.COMPLETE);
|
||
} catch (err: any) {
|
||
console.error("Generation Error:", err);
|
||
setErrorMsg(err.message || "生成失败,请尝试不同的提示词。");
|
||
setAppState(AppState.ERROR);
|
||
}
|
||
};
|
||
|
||
const handleReset = () => {
|
||
setAppState(AppState.IDLE);
|
||
setGeneratedFrames([]);
|
||
setPortraitImage(null);
|
||
setDirectionalSpriteSheet(null);
|
||
setDirectionalFrames([[], [], [], []]);
|
||
setMapImage(null);
|
||
setItemImage(null);
|
||
setErrorMsg(null);
|
||
};
|
||
|
||
// Generate Portrait
|
||
const handleGeneratePortrait = async () => {
|
||
if (!characterDescription) {
|
||
setErrorMsg("请输入角色描述");
|
||
return;
|
||
}
|
||
|
||
setAppState(AppState.GENERATING);
|
||
setErrorMsg(null);
|
||
setPortraitImage(null);
|
||
|
||
try {
|
||
const portrait = await generateCharacterPortrait(
|
||
characterDescription,
|
||
portraitStyle,
|
||
'3:4'
|
||
);
|
||
setPortraitImage(portrait);
|
||
setAppState(AppState.COMPLETE);
|
||
} catch (err: any) {
|
||
console.error("Portrait Generation Error:", err);
|
||
setErrorMsg(err.message || "立绘生成失败,请重试。");
|
||
setAppState(AppState.ERROR);
|
||
}
|
||
};
|
||
|
||
// Download portrait
|
||
const handleDownloadPortrait = () => {
|
||
if (!portraitImage) return;
|
||
const link = document.createElement('a');
|
||
link.href = portraitImage;
|
||
link.download = 'character-portrait.png';
|
||
link.click();
|
||
};
|
||
|
||
// Generate Directional Sprite Sheet
|
||
const handleGenerateDirectional = async () => {
|
||
if (mode === 'image' && !originalImage) {
|
||
setErrorMsg("请先上传图片");
|
||
return;
|
||
}
|
||
if (mode === 'text' && !characterDescription) {
|
||
setErrorMsg("请输入角色描述");
|
||
return;
|
||
}
|
||
|
||
setAppState(AppState.GENERATING);
|
||
setErrorMsg(null);
|
||
setDirectionalSpriteSheet(null);
|
||
setDirectionalFrames([[], [], [], []]);
|
||
|
||
try {
|
||
const spriteSheetBase64 = await generateDirectionalSpriteSheet(
|
||
mode === 'image' ? originalImage : null,
|
||
directionalAction,
|
||
mode === 'text' ? characterDescription : undefined
|
||
);
|
||
|
||
setDirectionalSpriteSheet(spriteSheetBase64);
|
||
|
||
// Slice into 4 rows (directions), each with 4 frames
|
||
const allFrames = await sliceSpriteSheet(spriteSheetBase64, 4, 4, true);
|
||
|
||
// Split into 4 directions
|
||
const dirFrames: string[][] = [
|
||
allFrames.slice(0, 4), // Down
|
||
allFrames.slice(4, 8), // Left
|
||
allFrames.slice(8, 12), // Right
|
||
allFrames.slice(12, 16) // Up
|
||
];
|
||
|
||
setDirectionalFrames(dirFrames);
|
||
setAppState(AppState.COMPLETE);
|
||
} catch (err: any) {
|
||
console.error("Directional Generation Error:", err);
|
||
setErrorMsg(err.message || "序列帧生成失败,请重试。");
|
||
setAppState(AppState.ERROR);
|
||
}
|
||
};
|
||
|
||
// Download directional sprite sheet
|
||
const handleDownloadDirectional = () => {
|
||
if (!directionalSpriteSheet) return;
|
||
const link = document.createElement('a');
|
||
link.href = directionalSpriteSheet;
|
||
link.download = 'directional-sprite-sheet.png';
|
||
link.click();
|
||
};
|
||
|
||
const directionLabels = ['向下 (正面)', '向左', '向右', '向上 (背面)'];
|
||
|
||
// Generate Map
|
||
const handleGenerateMap = async () => {
|
||
if (!mapDescription) {
|
||
setErrorMsg("请输入地图描述");
|
||
return;
|
||
}
|
||
|
||
setAppState(AppState.GENERATING);
|
||
setErrorMsg(null);
|
||
setMapImage(null);
|
||
|
||
try {
|
||
const map = await generateGameMap(mapType, mapDescription, mapStyle);
|
||
setMapImage(map);
|
||
setAppState(AppState.COMPLETE);
|
||
} catch (err: any) {
|
||
console.error("Map Generation Error:", err);
|
||
setErrorMsg(err.message || "地图生成失败,请重试。");
|
||
setAppState(AppState.ERROR);
|
||
}
|
||
};
|
||
|
||
// Download map
|
||
const handleDownloadMap = () => {
|
||
if (!mapImage) return;
|
||
const link = document.createElement('a');
|
||
link.href = mapImage;
|
||
link.download = `game-map-${mapType}.png`;
|
||
link.click();
|
||
};
|
||
|
||
const mapTypeLabels: Record<MapType, string> = {
|
||
rpg: 'RPG 场景',
|
||
tilemap: '瓦片素材',
|
||
platformer: '横版关卡',
|
||
strategy: '战棋地图',
|
||
world: '世界地图'
|
||
};
|
||
|
||
const mapStyleOptions = ['像素风格', '卡通风格', '写实风格', '手绘风格'];
|
||
|
||
// Generate Item
|
||
const handleGenerateItem = async () => {
|
||
if (!itemDescription) {
|
||
setErrorMsg("请输入道具描述");
|
||
return;
|
||
}
|
||
|
||
setAppState(AppState.GENERATING);
|
||
setErrorMsg(null);
|
||
setItemImage(null);
|
||
|
||
try {
|
||
const item = await generateGameItem(itemType, itemDescription, itemStyle, itemQuantity);
|
||
setItemImage(item);
|
||
setAppState(AppState.COMPLETE);
|
||
} catch (err: any) {
|
||
console.error("Item Generation Error:", err);
|
||
setErrorMsg(err.message || "道具生成失败,请重试。");
|
||
setAppState(AppState.ERROR);
|
||
}
|
||
};
|
||
|
||
// Download item
|
||
const handleDownloadItem = () => {
|
||
if (!itemImage) return;
|
||
const link = document.createElement('a');
|
||
link.href = itemImage;
|
||
link.download = `game-item-${itemType}.png`;
|
||
link.click();
|
||
};
|
||
|
||
const itemTypeLabels: Record<ItemType, string> = {
|
||
weapon: '武器',
|
||
armor: '防具',
|
||
potion: '药水/消耗品',
|
||
material: '材料',
|
||
treasure: '宝箱/财宝',
|
||
accessory: '饰品'
|
||
};
|
||
|
||
const itemStyleOptions = ['像素风格', '卡通风格', '写实风格', '手绘风格'];
|
||
|
||
return (
|
||
<div className="min-h-screen bg-[#F8FAFC] flex flex-col font-sans pb-10">
|
||
{/* Header */}
|
||
<header className="bg-white border-b border-gray-100 sticky top-0 z-10 shadow-sm">
|
||
<div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-8 h-8 bg-lime-400 rounded-lg flex items-center justify-center shadow-sm">
|
||
<Zap className="text-black fill-current" size={20} />
|
||
</div>
|
||
<span className="font-bold text-xl tracking-tight text-gray-800">ToonMotion</span>
|
||
</div>
|
||
<div className="text-sm text-gray-600 font-medium bg-gray-100 px-3 py-1 rounded-full border border-gray-200">
|
||
Powered by Gemini 2.5
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main className="flex-1 max-w-6xl mx-auto px-6 py-8 w-full">
|
||
|
||
{/* Feature Tabs */}
|
||
<div className="mb-6 flex gap-4 border-b border-gray-200">
|
||
<button
|
||
onClick={() => setFeature('animation')}
|
||
className={`pb-3 px-1 font-medium flex items-center gap-2 border-b-2 transition-all ${
|
||
feature === 'animation'
|
||
? 'border-lime-500 text-gray-900'
|
||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||
}`}
|
||
>
|
||
<Film size={18} /> 动画生成
|
||
</button>
|
||
<button
|
||
onClick={() => setFeature('portrait')}
|
||
className={`pb-3 px-1 font-medium flex items-center gap-2 border-b-2 transition-all ${
|
||
feature === 'portrait'
|
||
? 'border-lime-500 text-gray-900'
|
||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||
}`}
|
||
>
|
||
<User size={18} /> 角色立绘
|
||
</button>
|
||
<button
|
||
onClick={() => setFeature('directional')}
|
||
className={`pb-3 px-1 font-medium flex items-center gap-2 border-b-2 transition-all ${
|
||
feature === 'directional'
|
||
? 'border-lime-500 text-gray-900'
|
||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||
}`}
|
||
>
|
||
<Move size={18} /> 四方向序列帧
|
||
</button>
|
||
<button
|
||
onClick={() => setFeature('map')}
|
||
className={`pb-3 px-1 font-medium flex items-center gap-2 border-b-2 transition-all ${
|
||
feature === 'map'
|
||
? 'border-lime-500 text-gray-900'
|
||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||
}`}
|
||
>
|
||
<Map size={18} /> 游戏地图
|
||
</button>
|
||
<button
|
||
onClick={() => setFeature('item')}
|
||
className={`pb-3 px-1 font-medium flex items-center gap-2 border-b-2 transition-all ${
|
||
feature === 'item'
|
||
? 'border-lime-500 text-gray-900'
|
||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||
}`}
|
||
>
|
||
<Sword size={18} /> 武器道具
|
||
</button>
|
||
</div>
|
||
|
||
{/* Intro */}
|
||
<div className="mb-8">
|
||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||
{feature === 'animation' ? '让你的角色动起来' : feature === 'portrait' ? '生成角色立绘' : feature === 'directional' ? '四方向序列帧' : feature === 'map' ? '游戏地图生成' : '武器道具生成'}
|
||
</h1>
|
||
<p className="text-gray-600 max-w-2xl">
|
||
{feature === 'portrait'
|
||
? '描述你想要的角色,Gemini 将为你生成高质量的角色立绘。'
|
||
: feature === 'directional'
|
||
? '生成角色的四个方向(上下左右)动画序列帧,适用于 RPG 游戏开发。'
|
||
: feature === 'map'
|
||
? '生成各类游戏地图,包括 RPG 场景、瓦片素材、横版关卡、战棋地图和世界地图。'
|
||
: feature === 'item'
|
||
? '生成游戏中的武器、防具、药水、材料等道具图标,支持单个或批量生成。'
|
||
: mode === 'image'
|
||
? '上传静态角色图片,输入动作描述,Gemini 将为你生成逐帧动画。'
|
||
: '描述你想要的角色和动作,Gemini 将从零开始生成逐帧动画。'}
|
||
</p>
|
||
</div>
|
||
|
||
{feature === 'animation' ? (
|
||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||
|
||
{/* Left Column: Input */}
|
||
<div className="lg:col-span-5 space-y-6">
|
||
|
||
{/* Mode Switch */}
|
||
<div className="bg-white rounded-2xl p-4 shadow-sm border border-gray-100">
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => setMode('text')}
|
||
className={`flex-1 py-3 px-4 rounded-xl font-medium flex items-center justify-center gap-2 transition-all ${
|
||
mode === 'text'
|
||
? 'bg-lime-400 text-black shadow-md'
|
||
: 'bg-gray-50 text-gray-600 hover:bg-gray-100'
|
||
}`}
|
||
>
|
||
<Type size={18} /> 纯文字生成
|
||
</button>
|
||
<button
|
||
onClick={() => setMode('image')}
|
||
className={`flex-1 py-3 px-4 rounded-xl font-medium flex items-center justify-center gap-2 transition-all ${
|
||
mode === 'image'
|
||
? 'bg-lime-400 text-black shadow-md'
|
||
: 'bg-gray-50 text-gray-600 hover:bg-gray-100'
|
||
}`}
|
||
>
|
||
<Image size={18} /> 参考图生成
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Conditional: Upload or Character Description */}
|
||
{mode === 'image' ? (
|
||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
|
||
上传角色图片
|
||
</h2>
|
||
</div>
|
||
|
||
<UploadZone
|
||
selectedImage={originalImage}
|
||
onImageSelected={handleImageSelected}
|
||
onClear={() => {
|
||
setOriginalImage(null);
|
||
setAppState(AppState.IDLE);
|
||
setGeneratedFrames([]);
|
||
setErrorMsg(null);
|
||
}}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
|
||
角色描述
|
||
</h2>
|
||
</div>
|
||
|
||
<textarea
|
||
placeholder="例如:一只橙色的小猫,大眼睛,戴着红色围巾"
|
||
className={`w-full border rounded-xl px-4 py-3 outline-none transition-all shadow-sm resize-none h-28
|
||
${!characterDescription && errorMsg ? 'border-red-300 focus:ring-red-200' : 'border-gray-200 focus:ring-2 focus:ring-lime-400 focus:border-lime-400'}
|
||
`}
|
||
value={characterDescription}
|
||
onChange={(e) => setCharacterDescription(e.target.value)}
|
||
/>
|
||
|
||
{/* Quick Character Prompts */}
|
||
<div className="flex gap-2 mt-3 flex-wrap">
|
||
{['可爱的小猫咪', '像素风格勇士', '圆滚滚的机器人', 'Q版小女孩'].map((p) => (
|
||
<button
|
||
key={p}
|
||
onClick={() => setCharacterDescription(p)}
|
||
className="px-3 py-1.5 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg text-xs font-medium text-gray-600 transition-colors"
|
||
>
|
||
{p}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Step 2: Settings Card */}
|
||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
|
||
动画设置
|
||
</h2>
|
||
{appState === AppState.ERROR && (
|
||
<button onClick={handleReset} className="text-xs text-gray-500 flex items-center gap-1 hover:text-gray-800">
|
||
<RotateCcw size={12} /> 重置状态
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-6">
|
||
{/* Motion Prompt */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
动作提示词
|
||
</label>
|
||
<div className="relative">
|
||
<textarea
|
||
placeholder="例如:奔跑循环, 开心地跳跃, 施法动作"
|
||
className={`w-full border rounded-xl px-4 py-3 outline-none transition-all shadow-sm resize-none h-24
|
||
${!actionPrompt && errorMsg ? 'border-red-300 focus:ring-red-200' : 'border-gray-200 focus:ring-2 focus:ring-lime-400 focus:border-lime-400'}
|
||
`}
|
||
value={actionPrompt}
|
||
onChange={(e) => setActionPrompt(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
{/* Quick Prompts */}
|
||
<div className="flex gap-2 mt-2 flex-wrap">
|
||
{['奔跑循环', '发送爱心', '攻击动作', '受伤倒地'].map((p) => (
|
||
<button
|
||
key={p}
|
||
onClick={() => setActionPrompt(p)}
|
||
className="px-3 py-1.5 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg text-xs font-medium text-gray-600 transition-colors"
|
||
>
|
||
{p}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Frame Count & FPS Row */}
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-xs font-bold text-gray-500 uppercase mb-2">
|
||
生成帧数
|
||
</label>
|
||
<div className="relative">
|
||
<select
|
||
value={frameCount}
|
||
onChange={(e) => setFrameCount(Number(e.target.value))}
|
||
className="w-full bg-gray-50 border border-gray-200 text-gray-700 text-sm rounded-xl focus:ring-lime-500 focus:border-lime-500 block p-2.5 outline-none appearance-none"
|
||
>
|
||
<option value={4}>4 帧 (最稳定)</option>
|
||
<option value={6}>6 帧</option>
|
||
<option value={8}>8 帧</option>
|
||
<option value={9}>9 帧</option>
|
||
<option value={16}>16 帧</option>
|
||
</select>
|
||
<LayoutGrid size={16} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-bold text-gray-500 uppercase mb-2 flex justify-between">
|
||
<span>播放速度</span>
|
||
<span className="text-lime-600">{fps} FPS</span>
|
||
</label>
|
||
<input
|
||
type="range"
|
||
min="1"
|
||
max="12"
|
||
value={fps}
|
||
onChange={(e) => setFps(Number(e.target.value))}
|
||
className="w-full accent-lime-500 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer mt-2"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleGenerate}
|
||
disabled={appState === AppState.GENERATING}
|
||
className={`w-full py-4 rounded-xl font-bold text-lg flex items-center justify-center gap-2 mt-2 shadow-md transition-all transform active:scale-[0.99]
|
||
${(appState === AppState.GENERATING)
|
||
? 'bg-gray-100 text-gray-400 cursor-not-allowed border border-gray-200'
|
||
: 'bg-lime-400 hover:bg-lime-500 text-black hover:shadow-lg hover:shadow-lime-200 border border-lime-400'}`}
|
||
>
|
||
{appState === AppState.GENERATING ? (
|
||
<>
|
||
<Loader2 size={20} className="animate-spin" /> 正在生成...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Wand2 size={20} /> 开始生成动画
|
||
</>
|
||
)}
|
||
</button>
|
||
|
||
{errorMsg && (
|
||
<div className="p-3 bg-red-50 text-red-600 text-sm rounded-lg border border-red-100 mt-2 animate-pulse">
|
||
{errorMsg}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
{/* Right Column: Result */}
|
||
<div className="lg:col-span-7 space-y-6">
|
||
<div className="sticky top-24">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
|
||
生成结果
|
||
</h2>
|
||
</div>
|
||
|
||
<AnimationPreview
|
||
frames={generatedFrames}
|
||
fps={fps}
|
||
isLoading={appState === AppState.GENERATING}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
) : feature === 'portrait' ? (
|
||
/* Portrait Feature UI */
|
||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||
{/* Left Column: Input */}
|
||
<div className="lg:col-span-5 space-y-6">
|
||
{/* Character Description */}
|
||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
|
||
角色描述
|
||
</h2>
|
||
</div>
|
||
|
||
<textarea
|
||
placeholder="例如:一位身穿蓝色铠甲的女骑士,金色长发,手持银色长剑,英姿飒爽"
|
||
className={`w-full border rounded-xl px-4 py-3 outline-none transition-all shadow-sm resize-none h-32
|
||
${!characterDescription && errorMsg ? 'border-red-300 focus:ring-red-200' : 'border-gray-200 focus:ring-2 focus:ring-lime-400 focus:border-lime-400'}
|
||
`}
|
||
value={characterDescription}
|
||
onChange={(e) => setCharacterDescription(e.target.value)}
|
||
/>
|
||
|
||
{/* Quick Character Prompts */}
|
||
<div className="flex gap-2 mt-3 flex-wrap">
|
||
{['蓝发魔法少女', '可爱的精灵', '帅气的剑士', '萌系兽耳娘'].map((p) => (
|
||
<button
|
||
key={p}
|
||
onClick={() => setCharacterDescription(p)}
|
||
className="px-3 py-1.5 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg text-xs font-medium text-gray-600 transition-colors"
|
||
>
|
||
{p}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Style Selection */}
|
||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
|
||
画风选择
|
||
</h2>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{['2D卡通风格', '日系动漫风格', '像素风格', 'Q版可爱风格'].map((style) => (
|
||
<button
|
||
key={style}
|
||
onClick={() => setPortraitStyle(style)}
|
||
className={`py-3 px-4 rounded-xl font-medium text-sm transition-all ${
|
||
portraitStyle === style
|
||
? 'bg-lime-400 text-black shadow-md'
|
||
: 'bg-gray-50 text-gray-600 hover:bg-gray-100 border border-gray-200'
|
||
}`}
|
||
>
|
||
{style}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleGeneratePortrait}
|
||
disabled={appState === AppState.GENERATING || !characterDescription}
|
||
className={`w-full py-4 rounded-xl font-bold text-lg flex items-center justify-center gap-2 mt-6 shadow-md transition-all transform active:scale-[0.99]
|
||
${(appState === AppState.GENERATING || !characterDescription)
|
||
? 'bg-gray-100 text-gray-400 cursor-not-allowed border border-gray-200'
|
||
: 'bg-lime-400 hover:bg-lime-500 text-black hover:shadow-lg hover:shadow-lime-200 border border-lime-400'}`}
|
||
>
|
||
{appState === AppState.GENERATING ? (
|
||
<>
|
||
<Loader2 size={20} className="animate-spin" /> 正在生成...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Wand2 size={20} /> 生成立绘
|
||
</>
|
||
)}
|
||
</button>
|
||
|
||
{errorMsg && (
|
||
<div className="p-3 bg-red-50 text-red-600 text-sm rounded-lg border border-red-100 mt-4 animate-pulse">
|
||
{errorMsg}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right Column: Portrait Result */}
|
||
<div className="lg:col-span-7 space-y-6">
|
||
<div className="sticky top-24">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
|
||
生成结果
|
||
</h2>
|
||
{portraitImage && (
|
||
<button
|
||
onClick={handleDownloadPortrait}
|
||
className="flex items-center gap-1 text-sm text-lime-600 hover:text-lime-700 font-medium"
|
||
>
|
||
<Download size={16} /> 下载立绘
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 min-h-[400px] flex items-center justify-center">
|
||
{appState === AppState.GENERATING ? (
|
||
<div className="text-center text-gray-400">
|
||
<Loader2 size={48} className="animate-spin mx-auto mb-4" />
|
||
<p>正在生成立绘...</p>
|
||
</div>
|
||
) : portraitImage ? (
|
||
<img
|
||
src={portraitImage}
|
||
alt="Generated Portrait"
|
||
className="max-w-full max-h-[500px] rounded-xl shadow-lg"
|
||
/>
|
||
) : (
|
||
<div className="text-center text-gray-400">
|
||
<User size={48} className="mx-auto mb-4 opacity-50" />
|
||
<p>立绘将在这里显示</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : feature === 'directional' ? (
|
||
/* Directional Sprite Sheet Feature UI */
|
||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||
{/* Left Column: Input */}
|
||
<div className="lg:col-span-5 space-y-6">
|
||
{/* Mode Switch */}
|
||
<div className="bg-white rounded-2xl p-4 shadow-sm border border-gray-100">
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => setMode('text')}
|
||
className={`flex-1 py-3 px-4 rounded-xl font-medium flex items-center justify-center gap-2 transition-all ${
|
||
mode === 'text'
|
||
? 'bg-lime-400 text-black shadow-md'
|
||
: 'bg-gray-50 text-gray-600 hover:bg-gray-100'
|
||
}`}
|
||
>
|
||
<Type size={18} /> 纯文字生成
|
||
</button>
|
||
<button
|
||
onClick={() => setMode('image')}
|
||
className={`flex-1 py-3 px-4 rounded-xl font-medium flex items-center justify-center gap-2 transition-all ${
|
||
mode === 'image'
|
||
? 'bg-lime-400 text-black shadow-md'
|
||
: 'bg-gray-50 text-gray-600 hover:bg-gray-100'
|
||
}`}
|
||
>
|
||
<Image size={18} /> 参考图生成
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Conditional: Upload or Character Description */}
|
||
{mode === 'image' ? (
|
||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
|
||
上传角色图片
|
||
</h2>
|
||
</div>
|
||
<UploadZone
|
||
selectedImage={originalImage}
|
||
onImageSelected={handleImageSelected}
|
||
onClear={() => {
|
||
setOriginalImage(null);
|
||
setAppState(AppState.IDLE);
|
||
setDirectionalSpriteSheet(null);
|
||
setDirectionalFrames([[], [], [], []]);
|
||
setErrorMsg(null);
|
||
}}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
|
||
角色描述
|
||
</h2>
|
||
</div>
|
||
<textarea
|
||
placeholder="例如:一只橙色的小猫,大眼睛,戴着红色围巾"
|
||
className={`w-full border rounded-xl px-4 py-3 outline-none transition-all shadow-sm resize-none h-28
|
||
${!characterDescription && errorMsg ? 'border-red-300 focus:ring-red-200' : 'border-gray-200 focus:ring-2 focus:ring-lime-400 focus:border-lime-400'}
|
||
`}
|
||
value={characterDescription}
|
||
onChange={(e) => setCharacterDescription(e.target.value)}
|
||
/>
|
||
<div className="flex gap-2 mt-3 flex-wrap">
|
||
{['像素风格勇士', 'Q版小女孩', '可爱的小猫咪', '圆滚滚的机器人'].map((p) => (
|
||
<button
|
||
key={p}
|
||
onClick={() => setCharacterDescription(p)}
|
||
className="px-3 py-1.5 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg text-xs font-medium text-gray-600 transition-colors"
|
||
>
|
||
{p}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Action Settings */}
|
||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
|
||
动作设置
|
||
</h2>
|
||
{appState === AppState.ERROR && (
|
||
<button onClick={handleReset} className="text-xs text-gray-500 flex items-center gap-1 hover:text-gray-800">
|
||
<RotateCcw size={12} /> 重置状态
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">动作类型</label>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{['行走', '奔跑', '攻击', '闲置'].map((action) => (
|
||
<button
|
||
key={action}
|
||
onClick={() => setDirectionalAction(action)}
|
||
className={`py-2 px-4 rounded-xl font-medium text-sm transition-all ${
|
||
directionalAction === action
|
||
? 'bg-lime-400 text-black shadow-md'
|
||
: 'bg-gray-50 text-gray-600 hover:bg-gray-100 border border-gray-200'
|
||
}`}
|
||
>
|
||
{action}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-bold text-gray-500 uppercase mb-2 flex justify-between">
|
||
<span>预览速度</span>
|
||
<span className="text-lime-600">{directionalFps} FPS</span>
|
||
</label>
|
||
<input
|
||
type="range"
|
||
min="1"
|
||
max="12"
|
||
value={directionalFps}
|
||
onChange={(e) => setDirectionalFps(Number(e.target.value))}
|
||
className="w-full accent-lime-500 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleGenerateDirectional}
|
||
disabled={appState === AppState.GENERATING}
|
||
className={`w-full py-4 rounded-xl font-bold text-lg flex items-center justify-center gap-2 mt-2 shadow-md transition-all transform active:scale-[0.99]
|
||
${(appState === AppState.GENERATING)
|
||
? 'bg-gray-100 text-gray-400 cursor-not-allowed border border-gray-200'
|
||
: 'bg-lime-400 hover:bg-lime-500 text-black hover:shadow-lg hover:shadow-lime-200 border border-lime-400'}`}
|
||
>
|
||
{appState === AppState.GENERATING ? (
|
||
<>
|
||
<Loader2 size={20} className="animate-spin" /> 正在生成...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Wand2 size={20} /> 生成四方向序列帧
|
||
</>
|
||
)}
|
||
</button>
|
||
|
||
{errorMsg && (
|
||
<div className="p-3 bg-red-50 text-red-600 text-sm rounded-lg border border-red-100 mt-2 animate-pulse">
|
||
{errorMsg}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right Column: Result */}
|
||
<div className="lg:col-span-7 space-y-6">
|
||
<div className="sticky top-24">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
|
||
生成结果
|
||
</h2>
|
||
{directionalSpriteSheet && (
|
||
<button
|
||
onClick={handleDownloadDirectional}
|
||
className="flex items-center gap-1 text-sm text-lime-600 hover:text-lime-700 font-medium"
|
||
>
|
||
<Download size={16} /> 下载序列帧
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Direction Tabs */}
|
||
{directionalFrames[0].length > 0 && (
|
||
<div className="flex gap-2 mb-4">
|
||
{directionLabels.map((label, idx) => (
|
||
<button
|
||
key={idx}
|
||
onClick={() => setSelectedDirection(idx)}
|
||
className={`flex-1 py-2 px-3 rounded-lg font-medium text-sm transition-all ${
|
||
selectedDirection === idx
|
||
? 'bg-lime-400 text-black shadow-md'
|
||
: 'bg-white text-gray-600 hover:bg-gray-50 border border-gray-200'
|
||
}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Preview */}
|
||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
|
||
{appState === AppState.GENERATING ? (
|
||
<div className="text-center text-gray-400 py-20">
|
||
<Loader2 size={48} className="animate-spin mx-auto mb-4" />
|
||
<p>正在生成四方向序列帧...</p>
|
||
</div>
|
||
) : directionalFrames[0].length > 0 ? (
|
||
<div className="space-y-4">
|
||
<div className="text-center text-sm text-gray-500 mb-2">
|
||
{directionLabels[selectedDirection]} - 动画预览
|
||
</div>
|
||
<AnimationPreview
|
||
frames={directionalFrames[selectedDirection]}
|
||
fps={directionalFps}
|
||
isLoading={false}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="text-center text-gray-400 py-20">
|
||
<Move size={48} className="mx-auto mb-4 opacity-50" />
|
||
<p>四方向序列帧将在这里显示</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Full Sprite Sheet Preview */}
|
||
{directionalSpriteSheet && (
|
||
<div className="mt-4 bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
|
||
<h3 className="font-semibold text-gray-800 mb-3">完整序列帧</h3>
|
||
<img
|
||
src={directionalSpriteSheet}
|
||
alt="Full Sprite Sheet"
|
||
className="w-full rounded-lg border border-gray-200"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : feature === 'map' ? (
|
||
/* Map Generation Feature UI */
|
||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||
{/* Left Column: Input */}
|
||
<div className="lg:col-span-5 space-y-6">
|
||
{/* Map Type Selection */}
|
||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
|
||
地图类型
|
||
</h2>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{(Object.keys(mapTypeLabels) as MapType[]).map((type) => (
|
||
<button
|
||
key={type}
|
||
onClick={() => setMapType(type)}
|
||
className={`py-3 px-4 rounded-xl font-medium text-sm transition-all ${
|
||
mapType === type
|
||
? 'bg-lime-400 text-black shadow-md'
|
||
: 'bg-gray-50 text-gray-600 hover:bg-gray-100 border border-gray-200'
|
||
}`}
|
||
>
|
||
{mapTypeLabels[type]}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Map Description */}
|
||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
|
||
场景描述
|
||
</h2>
|
||
</div>
|
||
<textarea
|
||
placeholder="例如:森林中的小村庄,有河流穿过,远处有山脉"
|
||
className={`w-full border rounded-xl px-4 py-3 outline-none transition-all shadow-sm resize-none h-28
|
||
${!mapDescription && errorMsg ? 'border-red-300 focus:ring-red-200' : 'border-gray-200 focus:ring-2 focus:ring-lime-400 focus:border-lime-400'}
|
||
`}
|
||
value={mapDescription}
|
||
onChange={(e) => setMapDescription(e.target.value)}
|
||
/>
|
||
<div className="flex gap-2 mt-3 flex-wrap">
|
||
{['森林村庄', '沙漠遗迹', '雪山城堡', '海边港口', '地下洞穴'].map((p) => (
|
||
<button
|
||
key={p}
|
||
onClick={() => setMapDescription(p)}
|
||
className="px-3 py-1.5 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg text-xs font-medium text-gray-600 transition-colors"
|
||
>
|
||
{p}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Style Selection */}
|
||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
|
||
画风选择
|
||
</h2>
|
||
{appState === AppState.ERROR && (
|
||
<button onClick={handleReset} className="text-xs text-gray-500 flex items-center gap-1 hover:text-gray-800">
|
||
<RotateCcw size={12} /> 重置状态
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{mapStyleOptions.map((style) => (
|
||
<button
|
||
key={style}
|
||
onClick={() => setMapStyle(style)}
|
||
className={`py-2 px-4 rounded-xl font-medium text-sm transition-all ${
|
||
mapStyle === style
|
||
? 'bg-lime-400 text-black shadow-md'
|
||
: 'bg-gray-50 text-gray-600 hover:bg-gray-100 border border-gray-200'
|
||
}`}
|
||
>
|
||
{style}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleGenerateMap}
|
||
disabled={appState === AppState.GENERATING || !mapDescription}
|
||
className={`w-full py-4 rounded-xl font-bold text-lg flex items-center justify-center gap-2 mt-6 shadow-md transition-all transform active:scale-[0.99]
|
||
${(appState === AppState.GENERATING || !mapDescription)
|
||
? 'bg-gray-100 text-gray-400 cursor-not-allowed border border-gray-200'
|
||
: 'bg-lime-400 hover:bg-lime-500 text-black hover:shadow-lg hover:shadow-lime-200 border border-lime-400'}`}
|
||
>
|
||
{appState === AppState.GENERATING ? (
|
||
<>
|
||
<Loader2 size={20} className="animate-spin" /> 正在生成...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Wand2 size={20} /> 生成地图
|
||
</>
|
||
)}
|
||
</button>
|
||
|
||
{errorMsg && (
|
||
<div className="p-3 bg-red-50 text-red-600 text-sm rounded-lg border border-red-100 mt-4 animate-pulse">
|
||
{errorMsg}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right Column: Map Result */}
|
||
<div className="lg:col-span-7 space-y-6">
|
||
<div className="sticky top-24">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
|
||
生成结果
|
||
</h2>
|
||
{mapImage && (
|
||
<button
|
||
onClick={handleDownloadMap}
|
||
className="flex items-center gap-1 text-sm text-lime-600 hover:text-lime-700 font-medium"
|
||
>
|
||
<Download size={16} /> 下载地图
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 min-h-[400px] flex items-center justify-center">
|
||
{appState === AppState.GENERATING ? (
|
||
<div className="text-center text-gray-400">
|
||
<Loader2 size={48} className="animate-spin mx-auto mb-4" />
|
||
<p>正在生成{mapTypeLabels[mapType]}...</p>
|
||
</div>
|
||
) : mapImage ? (
|
||
<img
|
||
src={mapImage}
|
||
alt="Generated Map"
|
||
className="max-w-full max-h-[600px] rounded-xl shadow-lg"
|
||
/>
|
||
) : (
|
||
<div className="text-center text-gray-400">
|
||
<Map size={48} className="mx-auto mb-4 opacity-50" />
|
||
<p>地图将在这里显示</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
/* Item Generation Feature UI */
|
||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||
{/* Left Column: Input */}
|
||
<div className="lg:col-span-5 space-y-6">
|
||
{/* Item Type Selection */}
|
||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
|
||
道具类型
|
||
</h2>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{(Object.keys(itemTypeLabels) as ItemType[]).map((type) => (
|
||
<button
|
||
key={type}
|
||
onClick={() => setItemType(type)}
|
||
className={`py-3 px-4 rounded-xl font-medium text-sm transition-all ${
|
||
itemType === type
|
||
? 'bg-lime-400 text-black shadow-md'
|
||
: 'bg-gray-50 text-gray-600 hover:bg-gray-100 border border-gray-200'
|
||
}`}
|
||
>
|
||
{itemTypeLabels[type]}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Item Description */}
|
||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
|
||
道具描述
|
||
</h2>
|
||
</div>
|
||
<textarea
|
||
placeholder="例如:火焰之剑,发出红色光芒,剑身刻有古老符文"
|
||
className={`w-full border rounded-xl px-4 py-3 outline-none transition-all shadow-sm resize-none h-28
|
||
${!itemDescription && errorMsg ? 'border-red-300 focus:ring-red-200' : 'border-gray-200 focus:ring-2 focus:ring-lime-400 focus:border-lime-400'}
|
||
`}
|
||
value={itemDescription}
|
||
onChange={(e) => setItemDescription(e.target.value)}
|
||
/>
|
||
<div className="flex gap-2 mt-3 flex-wrap">
|
||
{['传说之剑', '治疗药水', '魔法宝石', '黄金宝箱', '皮革护甲'].map((p) => (
|
||
<button
|
||
key={p}
|
||
onClick={() => setItemDescription(p)}
|
||
className="px-3 py-1.5 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg text-xs font-medium text-gray-600 transition-colors"
|
||
>
|
||
{p}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Style & Quantity Selection */}
|
||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
|
||
生成设置
|
||
</h2>
|
||
{appState === AppState.ERROR && (
|
||
<button onClick={handleReset} className="text-xs text-gray-500 flex items-center gap-1 hover:text-gray-800">
|
||
<RotateCcw size={12} /> 重置状态
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">画风选择</label>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{itemStyleOptions.map((style) => (
|
||
<button
|
||
key={style}
|
||
onClick={() => setItemStyle(style)}
|
||
className={`py-2 px-4 rounded-xl font-medium text-sm transition-all ${
|
||
itemStyle === style
|
||
? 'bg-lime-400 text-black shadow-md'
|
||
: 'bg-gray-50 text-gray-600 hover:bg-gray-100 border border-gray-200'
|
||
}`}
|
||
>
|
||
{style}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">生成数量</label>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<button
|
||
onClick={() => setItemQuantity('single')}
|
||
className={`py-2 px-4 rounded-xl font-medium text-sm transition-all ${
|
||
itemQuantity === 'single'
|
||
? 'bg-lime-400 text-black shadow-md'
|
||
: 'bg-gray-50 text-gray-600 hover:bg-gray-100 border border-gray-200'
|
||
}`}
|
||
>
|
||
单个道具
|
||
</button>
|
||
<button
|
||
onClick={() => setItemQuantity('set')}
|
||
className={`py-2 px-4 rounded-xl font-medium text-sm transition-all ${
|
||
itemQuantity === 'set'
|
||
? 'bg-lime-400 text-black shadow-md'
|
||
: 'bg-gray-50 text-gray-600 hover:bg-gray-100 border border-gray-200'
|
||
}`}
|
||
>
|
||
批量生成 (6个)
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleGenerateItem}
|
||
disabled={appState === AppState.GENERATING || !itemDescription}
|
||
className={`w-full py-4 rounded-xl font-bold text-lg flex items-center justify-center gap-2 mt-6 shadow-md transition-all transform active:scale-[0.99]
|
||
${(appState === AppState.GENERATING || !itemDescription)
|
||
? 'bg-gray-100 text-gray-400 cursor-not-allowed border border-gray-200'
|
||
: 'bg-lime-400 hover:bg-lime-500 text-black hover:shadow-lg hover:shadow-lime-200 border border-lime-400'}`}
|
||
>
|
||
{appState === AppState.GENERATING ? (
|
||
<>
|
||
<Loader2 size={20} className="animate-spin" /> 正在生成...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Wand2 size={20} /> 生成道具
|
||
</>
|
||
)}
|
||
</button>
|
||
|
||
{errorMsg && (
|
||
<div className="p-3 bg-red-50 text-red-600 text-sm rounded-lg border border-red-100 mt-4 animate-pulse">
|
||
{errorMsg}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right Column: Item Result */}
|
||
<div className="lg:col-span-7 space-y-6">
|
||
<div className="sticky top-24">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="font-semibold flex items-center gap-2 text-gray-800 text-lg">
|
||
生成结果
|
||
</h2>
|
||
{itemImage && (
|
||
<button
|
||
onClick={handleDownloadItem}
|
||
className="flex items-center gap-1 text-sm text-lime-600 hover:text-lime-700 font-medium"
|
||
>
|
||
<Download size={16} /> 下载道具
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 min-h-[400px] flex items-center justify-center">
|
||
{appState === AppState.GENERATING ? (
|
||
<div className="text-center text-gray-400">
|
||
<Loader2 size={48} className="animate-spin mx-auto mb-4" />
|
||
<p>正在生成{itemTypeLabels[itemType]}...</p>
|
||
</div>
|
||
) : itemImage ? (
|
||
<img
|
||
src={itemImage}
|
||
alt="Generated Item"
|
||
className="max-w-full max-h-[600px] rounded-xl shadow-lg"
|
||
/>
|
||
) : (
|
||
<div className="text-center text-gray-400">
|
||
<Sword size={48} className="mx-auto mb-4 opacity-50" />
|
||
<p>道具将在这里显示</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</main>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default App; |