feat: add Docker support and texture deletion functionality

Add Docker configuration with standalone output mode for containerized
deployment. Implement texture deletion API with proper error handling
and user feedback. Fix skin viewer sizing issues by using explicit
dimensions and removing conflicting layout properties. Add captcha
ID parameter to registration flow. Improve profile page UX including
Yggdrasil password reset display and character card editing controls.
This commit is contained in:
2026-02-24 11:09:37 +08:00
parent f5455afaf2
commit 2124790c8d
13 changed files with 264 additions and 81 deletions

View File

@@ -38,6 +38,7 @@ import {
generateAvatarUploadUrl,
updateAvatarUrl,
resetYggdrasilPassword,
deleteTexture,
type Texture,
type Profile
} from '@/lib/api';
@@ -194,7 +195,18 @@ export default function ProfilePage() {
const handleDeleteSkin = async (skinId: number) => {
if (!confirm('确定要删除这个皮肤吗?')) return;
setMySkins(prev => prev.filter(skin => skin.id !== skinId));
try {
const response = await deleteTexture(skinId);
if (response.code === 200) {
setMySkins(prev => prev.filter(skin => skin.id !== skinId));
messageManager.success('皮肤删除成功', { duration: 3000 });
} else {
messageManager.error(response.message || '删除皮肤失败', { duration: 3000 });
}
} catch (error) {
console.error('删除皮肤失败:', error);
messageManager.error('删除皮肤失败', { duration: 3000 });
}
};
const handleToggleFavorite = async (skinId: number) => {
@@ -373,12 +385,14 @@ export default function ProfilePage() {
const handleResetYggdrasilPassword = async () => {
if (!confirm('确定要重置Yggdrasil密码吗这将生成一个新的密码。')) return;
setIsResettingYggdrasilPassword(true);
try {
const response = await resetYggdrasilPassword();
if (response.code === 200) {
setYggdrasilPassword(response.data.password);
setShowYggdrasilPassword(true);
messageManager.success('Yggdrasil密码重置成功请妥善保管新密码。', { duration: 5000 });
} else {
throw new Error(response.message || '重置Yggdrasil密码失败');
@@ -955,14 +969,15 @@ export default function ProfilePage() {
}
}}
>
<SkinViewer
skinUrl={skin.url}
isSlim={skin.is_slim}
width={200}
height={200}
className="w-full h-full"
autoRotate={false}
/>
<div className="w-full h-full flex items-center justify-center">
<SkinViewer
skinUrl={skin.url}
isSlim={skin.is_slim}
width={180}
height={180}
autoRotate={false}
/>
</div>
<div className="absolute bottom-0 left-0 right-0 bg-black/50 text-white text-xs p-2 text-center">
{skin.name}
</div>

View File

@@ -171,15 +171,15 @@ export default function SkinCard({
</AnimatePresence>
{texture.type === 'SKIN' ? (
<div className="relative w-full h-full bg-white dark:bg-gray-800">
<div className="relative w-full h-full flex items-center justify-center bg-white dark:bg-gray-800">
<SkinViewer
skinUrl={texture.url}
isSlim={texture.is_slim}
width={300}
height={300}
className={`w-full h-full transition-all duration-500 ${
imageLoaded ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
} ${isHovered ? 'scale-110' : ''}`}
width={280}
height={280}
className={`transition-opacity duration-500 ${
imageLoaded ? 'opacity-100' : 'opacity-0'
}`}
autoRotate={isHovered}
walking={false}
onImageLoaded={() => setImageLoaded(true)}

View File

@@ -296,7 +296,7 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
isSlim={texture.is_slim}
width={600}
height={600}
className="w-full h-full rounded-2xl shadow-2xl border-2 border-white/60 dark:border-gray-600/60 relative z-10"
className="rounded-2xl shadow-2xl border-2 border-white/60 dark:border-gray-600/60 relative z-10"
autoRotate={autoRotate}
walking={currentAnimation === 'walking'}
running={currentAnimation === 'running'}

View File

@@ -83,28 +83,24 @@ export default function SkinViewer({
try {
console.log('初始化3D皮肤查看器:', { skinUrl, isSlim, width, height });
// 使用canvas的实际尺寸参考blessingskin
// 使用传入的宽高参数初始化
const canvas = canvasRef.current;
const viewer = new SkinViewer3D({
canvas: canvas,
width: canvas.clientWidth || width,
height: canvas.clientHeight || height,
width: width,
height: height,
skin: skinUrl,
cape: capeUrl,
model: isSlim ? 'slim' : 'default',
zoom: 1.0, // 使用blessingskin的zoom方式
zoom: 1.0,
});
viewerRef.current = viewer;
// 设置背景和控制选项 - 参考blessingskin
// 设置背景和控制选项
viewer.background = null; // 透明背景
viewer.autoRotate = false; // 完全禁用自动旋转
// 调整光照设置,避免皮肤发黑
viewer.globalLight.intensity = 0.8; // 增加环境光强度
viewer.cameraLight.intensity = 0.4; // 降低相机光源强度,避免过强的阴影
// 外部预览时禁用所有动画和旋转
if (isExternalPreview) {
viewer.autoRotate = false;
@@ -268,11 +264,8 @@ export default function SkinViewer({
ref={canvasRef}
className={className}
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%'
width: width,
height: height
}}
/>
);

View File

@@ -448,10 +448,9 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
isDragging ? 'scale-110 shadow-xl' : 'scale-100'
} ${isVerified || verifyResult === 'error' ? 'cursor-default' : 'cursor-grab active:cursor-grabbing'}`}
style={{ left: `${sliderPosition + 2}px`, zIndex: 10 }}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
onMouseDown={verifyResult === 'error' ? undefined : handleMouseDown}
onTouchStart={verifyResult === 'error' ? undefined : handleTouchStart}
ref={sliderRef}
disabled={verifyResult === 'error'}
>
{getSliderIcon()}
</div>

View File

@@ -42,19 +42,32 @@ export default function CharacterCard({
transition={{ duration: 0.2 }}
>
<div className="flex items-center justify-between mb-4">
{isEditing ? (
<input
type="text"
value={editName}
onChange={(e) => onEditNameChange(e.target.value)}
className="text-lg font-semibold bg-transparent border-b border-orange-500 focus:outline-none text-gray-900 dark:text-white flex-1 mr-2"
onBlur={() => onSave(profile.uuid)}
onKeyPress={(e) => e.key === 'Enter' && onSave(profile.uuid)}
autoFocus
/>
) : (
<h3 className="text-lg font-semibold text-gray-900 dark:text-white truncate flex-1">{profile.name}</h3>
)}
<div className="flex items-center gap-2 flex-1">
{isEditing ? (
<input
type="text"
value={editName}
onChange={(e) => onEditNameChange(e.target.value)}
className="text-lg font-semibold bg-transparent border-b border-orange-500 focus:outline-none text-gray-900 dark:text-white flex-1"
onBlur={() => onSave(profile.uuid)}
onKeyPress={(e) => e.key === 'Enter' && onSave(profile.uuid)}
autoFocus
/>
) : (
<>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white truncate">{profile.name}</h3>
<motion.button
onClick={() => onEdit(profile.uuid, profile.name)}
className="text-gray-500 hover:text-orange-500 transition-colors"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
title="改名"
>
<PencilIcon className="w-4 h-4" />
</motion.button>
</>
)}
</div>
{profile.is_active && (
<span className="px-2 py-1 bg-gradient-to-r from-green-500 to-emerald-500 text-white text-xs rounded-full flex items-center space-x-1">
<CheckIcon className="w-3 h-3" />
@@ -68,9 +81,8 @@ export default function CharacterCard({
<SkinViewer
skinUrl={skinUrl}
isSlim={isSlim}
width={200}
height={200}
className="w-full h-full"
width={180}
height={180}
autoRotate={false}
/>
) : (
@@ -82,32 +94,10 @@ export default function CharacterCard({
<UserIcon className="w-10 h-10 text-white" />
</motion.div>
)}
{/* 皮肤选择按钮 */}
<motion.button
onClick={() => onSelectSkin(profile.uuid)}
className="absolute bottom-2 right-2 bg-gradient-to-r from-orange-500 to-amber-500 text-white p-2 rounded-full shadow-lg"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
title="选择皮肤"
>
<PencilIcon className="w-4 h-4" />
</motion.button>
</div>
{/* 操作按钮 */}
<div className="flex gap-2">
{!profile.is_active && (
<motion.button
onClick={() => onSetActive(profile.uuid)}
className="flex-1 bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white text-sm py-2 px-3 rounded-lg transition-all duration-200"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
使
</motion.button>
)}
{isEditing ? (
<>
<motion.button
@@ -130,13 +120,12 @@ export default function CharacterCard({
) : (
<>
<motion.button
onClick={() => onEdit(profile.uuid, profile.name)}
className="flex-1 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 text-sm py-2 px-3 rounded-lg transition-all duration-200"
onClick={() => onSelectSkin(profile.uuid)}
className="flex-1 bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white text-sm py-2 px-3 rounded-lg transition-all duration-200"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<PencilIcon className="w-4 h-4 inline mr-1" />
</motion.button>
<motion.button
onClick={() => onDelete(profile.uuid)}

View File

@@ -68,7 +68,6 @@ export default function MySkinsTab({
key={skin.id}
texture={skin}
onViewDetails={handleViewDetails}
onToggleVisibility={onToggleVisibility}
customActions={
<div className="flex gap-2">
<button

View File

@@ -22,7 +22,7 @@ interface AuthContextType {
isLoading: boolean;
isAuthenticated: boolean;
login: (username: string, password: string) => Promise<void>;
register: (username: string, email: string, password: string, verificationCode: string) => Promise<void>;
register: (username: string, email: string, password: string, verificationCode: string, captchaId?: string) => Promise<void>;
logout: () => void;
updateUser: (userData: Partial<User>) => void;
refreshUser: () => Promise<void>;
@@ -106,7 +106,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
};
const register = async (username: string, email: string, password: string, verificationCode: string) => {
const register = async (username: string, email: string, password: string, verificationCode: string, captchaId?: string) => {
setIsLoading(true);
try {
const response = await fetch(`${API_BASE_URL}/auth/register`, {
@@ -119,6 +119,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
email,
password,
verification_code: verificationCode,
...(captchaId && { captcha_id: captchaId }),
}),
});

View File

@@ -313,3 +313,13 @@ export async function resetYggdrasilPassword(): Promise<ApiResponse<{
return response.json();
}
// 删除材质
export async function deleteTexture(id: number): Promise<ApiResponse<null>> {
const response = await fetch(`${API_BASE_URL}/texture/${id}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
return response.json();
}