Nora / frontend /components /CharacterCustomizationDialog.tsx
GitHub Action
Deploy clean version of Nora
59bd45e
import React, { useState, useEffect } from 'react';
import { X, Sparkles, Loader2, Check, History, Plus } from 'lucide-react';
import { apiService } from '../services/api';
interface CharacterCustomizationDialogProps {
isOpen: boolean;
onClose: () => void;
onGenerate: (preferences: CharacterPreferences) => Promise<void>;
onSelectHistory: (imageUrl: string) => void;
currentPreferences?: CharacterPreferences;
currentImageUrl?: string;
}
export interface CharacterPreferences {
color: string;
personality: string;
appearance: string;
role: string;
}
interface HistoricalImage {
filename: string;
url: string;
color: string;
personality: string;
timestamp: string;
created_at: number;
size: number;
}
const COLOR_OPTIONS = [
{ value: '温暖粉', label: '温暖粉', color: 'bg-pink-200' },
{ value: '天空蓝', label: '天空蓝', color: 'bg-blue-200' },
{ value: '薄荷绿', label: '薄荷绿', color: 'bg-green-200' },
{ value: '奶油黄', label: '奶油黄', color: 'bg-yellow-200' },
{ value: '薰衣草紫', label: '薰衣草紫', color: 'bg-purple-200' },
{ value: '珊瑚橙', label: '珊瑚橙', color: 'bg-orange-200' },
{ value: '纯白', label: '纯白', color: 'bg-white border border-gray-300' },
{ value: '浅灰', label: '浅灰', color: 'bg-gray-200' },
];
const PERSONALITY_OPTIONS = [
{ value: '活泼', label: '活泼', emoji: '😄' },
{ value: '温柔', label: '温柔', emoji: '😊' },
{ value: '聪明', label: '聪明', emoji: '🤓' },
{ value: '慵懒', label: '慵懒', emoji: '😴' },
{ value: '勇敢', label: '勇敢', emoji: '💪' },
{ value: '害羞', label: '害羞', emoji: '😳' },
];
const APPEARANCE_OPTIONS = [
{ value: '戴眼镜', label: '戴眼镜', emoji: '👓' },
{ value: '戴帽子', label: '戴帽子', emoji: '🎩' },
{ value: '戴围巾', label: '戴围巾', emoji: '🧣' },
{ value: '戴蝴蝶结', label: '戴蝴蝶结', emoji: '🎀' },
{ value: '无配饰', label: '无配饰', emoji: '✨' },
];
const ROLE_OPTIONS = [
{ value: '陪伴式朋友', label: '陪伴式朋友', emoji: '🤝' },
{ value: '温柔照顾型长辈', label: '温柔照顾型长辈', emoji: '🤗' },
{ value: '引导型老师', label: '引导型老师', emoji: '👨‍🏫' },
];
export const CharacterCustomizationDialog: React.FC<CharacterCustomizationDialogProps> = ({
isOpen,
onClose,
onGenerate,
onSelectHistory,
currentPreferences,
currentImageUrl,
}) => {
const [mode, setMode] = useState<'history' | 'create'>('history');
const [preferences, setPreferences] = useState<CharacterPreferences>({
color: '温暖粉',
personality: '温柔',
appearance: '无配饰',
role: '陪伴式朋友',
});
const [isGenerating, setIsGenerating] = useState(false);
const [step, setStep] = useState(1);
const [error, setError] = useState<string | null>(null);
const [historyImages, setHistoryImages] = useState<HistoricalImage[]>([]);
const [loadingHistory, setLoadingHistory] = useState(false);
const [selectedHistoryImage, setSelectedHistoryImage] = useState<string | null>(null);
useEffect(() => {
if (currentPreferences) {
setPreferences(currentPreferences);
}
}, [currentPreferences]);
useEffect(() => {
if (isOpen && mode === 'history') {
loadHistory();
}
}, [isOpen, mode]);
const loadHistory = async () => {
setLoadingHistory(true);
try {
const response = await apiService.getCharacterHistory();
setHistoryImages(response.images);
} catch (error) {
console.error('Failed to load history:', error);
} finally {
setLoadingHistory(false);
}
};
const handleSelectHistoryImage = async (image: HistoricalImage) => {
setSelectedHistoryImage(image.filename);
try {
const response = await apiService.selectCharacter(image.filename);
onSelectHistory(response.image_url);
setTimeout(() => {
onClose();
setMode('history');
setStep(1);
setError(null);
setSelectedHistoryImage(null);
}, 500);
} catch (error: any) {
console.error('Failed to select character:', error);
setError(error.message || '选择失败,请重试');
setSelectedHistoryImage(null);
}
};
if (!isOpen) return null;
const handleGenerate = async () => {
setIsGenerating(true);
setError(null);
try {
await onGenerate(preferences);
setTimeout(() => {
onClose();
setMode('history');
setStep(1);
setError(null);
}, 1000);
} catch (error: any) {
console.error('Failed to generate character:', error);
let errorMessage = '生成失败,请重试';
if (error.message) {
if (error.message.includes('MiniMax API 未配置')) {
errorMessage = 'MiniMax API 未配置,请在 .env 文件中配置 MINIMAX_API_KEY';
} else if (error.message.includes('invalid api key')) {
errorMessage = 'API 密钥无效,请检查 MINIMAX_API_KEY 配置';
} else if (error.message.includes('配额不足')) {
errorMessage = 'API 配额不足,请充值或等待配额恢复';
} else if (error.message.includes('timeout') || error.message.includes('超时')) {
errorMessage = '请求超时,图像生成时间较长(约60-90秒),请耐心等待或稍后重试';
} else if (error.message.includes('Failed to fetch') || error.message.includes('fetch')) {
errorMessage = '网络连接失败,请检查:\n1. 后端服务是否运行\n2. 网络连接是否正常\n3. 防火墙是否允许访问';
} else {
errorMessage = error.message;
}
}
setError(errorMessage);
} finally {
setIsGenerating(false);
}
};
const renderHistoryView = () => (
<div className="space-y-4">
{loadingHistory ? (
<div className="flex items-center justify-center py-12">
<Loader2 size={32} className="animate-spin text-purple-400" />
</div>
) : historyImages.length === 0 ? (
<div className="text-center py-12">
<p className="text-slate-400 mb-4">还没有历史形象</p>
<button
onClick={() => setMode('create')}
className="
px-6 py-3 rounded-2xl
bg-gradient-to-br from-purple-400 to-pink-400
hover:from-purple-500 hover:to-pink-500
text-white font-medium
transition-all duration-200
hover:scale-105 active:scale-95
"
>
创建第一个形象
</button>
</div>
) : (
<div className="grid grid-cols-2 gap-4 max-h-[400px] overflow-y-auto pr-2">
{historyImages.map((image) => (
<button
key={image.filename}
onClick={() => handleSelectHistoryImage(image)}
disabled={selectedHistoryImage === image.filename}
className={`
relative p-3 rounded-2xl bg-white/80
hover:bg-white hover:scale-105
transition-all duration-200
${selectedHistoryImage === image.filename ? 'opacity-50' : ''}
`}
>
<img
src={image.url}
alt={`${image.color} ${image.personality}`}
className="w-full aspect-square rounded-xl object-cover mb-2"
/>
<div className="text-xs text-slate-600 space-y-0.5">
<p>🎨 {image.color}</p>
<p>😊 {image.personality}</p>
</div>
{selectedHistoryImage === image.filename && (
<div className="absolute inset-0 flex items-center justify-center bg-white/80 rounded-2xl">
<Loader2 size={24} className="animate-spin text-purple-400" />
</div>
)}
</button>
))}
</div>
)}
</div>
);
const renderStep1 = () => (
<div className="space-y-6">
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3">选择颜色</h4>
<div className="grid grid-cols-4 gap-3">
{COLOR_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => setPreferences({ ...preferences, color: option.value })}
className={`
relative p-3 rounded-xl transition-all duration-200
${preferences.color === option.value
? 'ring-2 ring-purple-400 scale-105'
: 'hover:scale-105'
}
`}
>
<div className={`w-full h-12 rounded-lg ${option.color}`} />
<p className="text-xs text-slate-600 mt-2">{option.label}</p>
{preferences.color === option.value && (
<div className="absolute top-1 right-1 w-5 h-5 bg-purple-400 rounded-full flex items-center justify-center">
<Check size={12} className="text-white" />
</div>
)}
</button>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3">选择性格</h4>
<div className="grid grid-cols-3 gap-3">
{PERSONALITY_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => setPreferences({ ...preferences, personality: option.value })}
className={`
relative p-3 rounded-xl bg-white/80 transition-all duration-200
${preferences.personality === option.value
? 'ring-2 ring-purple-400 scale-105'
: 'hover:scale-105'
}
`}
>
<div className="text-2xl mb-1">{option.emoji}</div>
<p className="text-xs text-slate-600">{option.label}</p>
{preferences.personality === option.value && (
<div className="absolute top-1 right-1 w-5 h-5 bg-purple-400 rounded-full flex items-center justify-center">
<Check size={12} className="text-white" />
</div>
)}
</button>
))}
</div>
</div>
</div>
);
const renderStep2 = () => (
<div className="space-y-6">
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3">选择外观</h4>
<div className="grid grid-cols-3 gap-3">
{APPEARANCE_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => setPreferences({ ...preferences, appearance: option.value })}
className={`
relative p-3 rounded-xl bg-white/80 transition-all duration-200
${preferences.appearance === option.value
? 'ring-2 ring-purple-400 scale-105'
: 'hover:scale-105'
}
`}
>
<div className="text-2xl mb-1">{option.emoji}</div>
<p className="text-xs text-slate-600">{option.label}</p>
{preferences.appearance === option.value && (
<div className="absolute top-1 right-1 w-5 h-5 bg-purple-400 rounded-full flex items-center justify-center">
<Check size={12} className="text-white" />
</div>
)}
</button>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3">选择角色</h4>
<div className="grid grid-cols-1 gap-3">
{ROLE_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => setPreferences({ ...preferences, role: option.value })}
className={`
relative p-4 rounded-xl bg-white/80 transition-all duration-200
flex items-center gap-3
${preferences.role === option.value
? 'ring-2 ring-purple-400 scale-105'
: 'hover:scale-105'
}
`}
>
<div className="text-2xl">{option.emoji}</div>
<p className="text-sm text-slate-700">{option.label}</p>
{preferences.role === option.value && (
<div className="absolute top-3 right-3 w-5 h-5 bg-purple-400 rounded-full flex items-center justify-center">
<Check size={12} className="text-white" />
</div>
)}
</button>
))}
</div>
</div>
</div>
);
return (
<div className="
fixed inset-0 z-[100]
flex items-center justify-center
bg-black/30 backdrop-blur-sm
animate-[fadeIn_0.3s_ease-out]
p-4
">
<div className="
relative w-full max-w-lg
bg-gradient-to-br from-white/95 to-purple-50/95
backdrop-blur-xl
rounded-3xl shadow-2xl
border border-white/50
p-6
animate-[slideUp_0.3s_ease-out]
max-h-[90vh]
overflow-y-auto
">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Sparkles className="text-purple-400" size={20} />
<h3 className="text-lg font-medium text-slate-700">
AI 形象定制
</h3>
</div>
<button
onClick={onClose}
disabled={isGenerating}
className="
p-2 rounded-full
text-slate-400 hover:text-slate-600
hover:bg-white/50
transition-all duration-200
disabled:opacity-50
"
>
<X size={20} />
</button>
</div>
<div className="flex gap-2 mb-6">
<button
onClick={() => setMode('history')}
className={`
flex-1 flex items-center justify-center gap-2
px-4 py-2 rounded-xl
transition-all duration-200
${mode === 'history'
? 'bg-purple-400 text-white'
: 'bg-white/80 text-slate-600 hover:bg-white'
}
`}
>
<History size={16} />
<span className="text-sm">历史形象</span>
</button>
<button
onClick={() => {
setMode('create');
setStep(1);
}}
className={`
flex-1 flex items-center justify-center gap-2
px-4 py-2 rounded-xl
transition-all duration-200
${mode === 'create'
? 'bg-purple-400 text-white'
: 'bg-white/80 text-slate-600 hover:bg-white'
}
`}
>
<Plus size={16} />
<span className="text-sm">生成新形象</span>
</button>
</div>
{mode === 'create' && currentImageUrl && (
<div className="mb-6 p-4 bg-gradient-to-br from-purple-50 to-pink-50 rounded-2xl border border-purple-100">
<div className="flex items-center gap-4">
<img
src={currentImageUrl}
alt="Current character"
className="w-24 h-24 rounded-full object-cover border-4 border-white shadow-lg"
/>
<div className="flex-1">
<p className="text-sm font-medium text-slate-700 mb-1">当前形象</p>
<div className="text-xs text-slate-500 space-y-0.5">
<p>🎨 {preferences.color}</p>
<p>😊 {preferences.personality}</p>
<p>✨ {preferences.appearance}</p>
<p>🎭 {preferences.role}</p>
</div>
</div>
</div>
</div>
)}
{mode === 'history' ? (
renderHistoryView()
) : (
<>
<div className="flex items-center justify-center gap-2 mb-6">
<div className={`
w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium
${step === 1 ? 'bg-purple-400 text-white' : 'bg-white/80 text-slate-400'}
`}>
1
</div>
<div className="w-12 h-0.5 bg-slate-200" />
<div className={`
w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium
${step === 2 ? 'bg-purple-400 text-white' : 'bg-white/80 text-slate-400'}
`}>
2
</div>
</div>
{step === 1 ? renderStep1() : renderStep2()}
<div className="flex gap-3 mt-6">
{step === 2 && (
<button
onClick={() => setStep(1)}
disabled={isGenerating}
className="
flex-1 px-4 py-3 rounded-2xl
bg-white/80 text-slate-600
hover:bg-white
transition-all duration-200
disabled:opacity-50
"
>
上一步
</button>
)}
{step === 1 ? (
<button
onClick={() => setStep(2)}
className="
flex-1 px-4 py-3 rounded-2xl
bg-gradient-to-br from-purple-400 to-pink-400
hover:from-purple-500 hover:to-pink-500
text-white font-medium
transition-all duration-200
hover:scale-105 active:scale-95
"
>
下一步
</button>
) : (
<button
onClick={handleGenerate}
disabled={isGenerating}
className="
flex-1 flex items-center justify-center gap-2
px-4 py-3 rounded-2xl
bg-gradient-to-br from-purple-400 to-pink-400
hover:from-purple-500 hover:to-pink-500
text-white font-medium
transition-all duration-200
disabled:opacity-50
hover:scale-105 active:scale-95
"
>
{isGenerating ? (
<>
<Loader2 size={18} className="animate-spin" />
<span>生成中...</span>
</>
) : (
<>
<Sparkles size={18} />
<span>{currentImageUrl ? '重新生成' : '生成形象'}</span>
</>
)}
</button>
)}
</div>
</>
)}
{error && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-xl">
<p className="text-sm text-red-600 text-center">{error}</p>
</div>
)}
<p className="text-xs text-slate-400 text-center mt-4">
{mode === 'history'
? '点击历史形象即可切换,或创建新形象'
: isGenerating
? '正在生成你的专属 AI 形象,请稍候(约 30-60 秒)...'
: currentImageUrl
? '修改选项后点击"重新生成"更新形象'
: '选择你喜欢的风格,生成专属的 AI 陪伴形象'
}
</p>
</div>
<style>{`
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
`}</style>
</div>
);
};