11 Commits

Author SHA1 Message Date
lan
fdd1d0c17b fix: 修复ci缓存导致的错误 2026-02-24 17:07:48 +08:00
lan
42c2fb4ce3 fix: 修复ci中出现的错误 2026-02-24 12:50:38 +08:00
lan
2e85be4657 chore: remove unnecessary QEMU setup for single platform build 2026-02-24 11:27:29 +08:00
lan
dad28881ed chore: add Docker configuration and Gitea CI/CD workflow 2026-02-24 11:26:38 +08:00
lan
0c6c0ae1ac fix: 修复了大写导致的不能正常上传镜像 2026-02-24 11:18:00 +08:00
lan
2124790c8d 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.
2026-02-24 11:09:37 +08:00
f5455afaf2 Merge pull request '解决了不跟手问题' (#8) from feature/slide-captcha into main
Reviewed-on: CarrotSkin/carrotskin#8
2026-02-15 00:26:04 +08:00
eed6920d4a 移除了滑动验证码的延迟渲染 2026-02-14 00:11:00 +08:00
00984b6d67 移除了滑动验证码的延迟渲染 2026-02-13 23:59:56 +08:00
344cae80af Merge pull request '添加了滑动验证码组件' (#7) from feature/slide-captcha into main
Reviewed-on: CarrotSkin/carrotskin#7
2026-02-12 23:08:56 +08:00
321b32e312 添加了滑动验证码组件 2026-02-12 20:54:06 +08:00
15 changed files with 363 additions and 136 deletions

41
.dockerignore Normal file
View File

@@ -0,0 +1,41 @@
# 依赖
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Next.js 构建产物
.next
out
# 测试
coverage
.nyc_output
# IDE
.vscode
.idea
*.swp
*.swo
*~
# 操作系统
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Docker
Dockerfile
.dockerignore
# 文档
docs
*.md
# 其他
.env
.env.local
.env.*.local

View File

@@ -0,0 +1,85 @@
name: Build and Push Docker Image
on:
push:
branches:
- main
- master
- develop
pull_request:
branches:
- main
- master
workflow_dispatch:
env:
REGISTRY: code.littlelan.cn
IMAGE_NAME: carrotskin/carrotskin
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
# main/master 分支标记为 latest
type=raw,value=latest,enable={{is_default_branch}}
# 所有分支的标签
type=ref,event=branch
# Git tag 时创建版本标签(如 1.0.0, 1.0
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
# 每次构建的 SHA 标签
type=sha
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
provenance: false
# 禁用 buildcache 以避免 413 错误
# cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
# cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
- name: Show image tags
run: |
echo "Built and pushed image with tags:"
echo "${{ steps.meta.outputs.tags }}"
echo ""
echo "Image digest: ${{ steps.meta.outputs.digest }}"
- name: Summary
if: always()
run: |
echo "## Docker Image Build Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Image:** ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" >> $GITHUB_STEP_SUMMARY
echo "**Tags:**" >> $GITHUB_STEP_SUMMARY
echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
echo "**Digest:** ${{ steps.meta.outputs.digest }}" >> $GITHUB_STEP_SUMMARY

49
Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
# 构建阶段
FROM node:alpine AS builder
# 设置工作目录
WORKDIR /app
# 复制 package 文件
COPY package*.json ./
# 安装所有依赖(包括 devDependencies
RUN npm ci
# 复制源代码
COPY . .
# 构建应用
RUN npm run build
# 生产阶段
FROM node:alpine AS runner
# 设置工作目录
WORKDIR /app
# 创建非 root 用户
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# 复制构建产物standalone 模式)
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# 设置正确的权限
RUN chown -R nextjs:nodejs /app
# 切换到非 root 用户
USER nextjs
# 暴露端口
EXPOSE 3000
# 设置环境变量
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# 启动应用
CMD ["node", "server.js"]

View File

@@ -1,7 +1,15 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: 'standalone',
rewrites: async () => {
return [
{
source: '/api/v1/:path*',
destination: 'http://localhost:8080/api/v1/:path*',
},
];
},
};
export default nextConfig;

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) => {
@@ -379,6 +391,8 @@ export default function ProfilePage() {
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

@@ -2,14 +2,15 @@
import { motion, AnimatePresence } from 'framer-motion';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, useState, useRef } from 'react';
import { useEffect, useState, useRef, Suspense } from 'react';
import { useRouter } from 'next/navigation';
interface PageTransitionProps {
children: React.ReactNode;
}
export default function PageTransition({ children }: PageTransitionProps) {
// 内部组件:使用 useSearchParams 的部分
function PageTransitionContent({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useRouter();
@@ -171,3 +172,20 @@ export default function PageTransition({ children }: PageTransitionProps) {
</>
);
}
// 加载状态组件
function PageTransitionFallback() {
return (
<div className="min-h-screen">
{null}
</div>
);
}
export default function PageTransition({ children }: PageTransitionProps) {
return (
<Suspense fallback={<PageTransitionFallback />}>
<PageTransitionContent>{children}</PageTransitionContent>
</Suspense>
);
}

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

@@ -76,18 +76,19 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
const response = await axios.get(`${API_BASE_URL}/captcha/generate`, {
withCredentials: true // 关键:允许跨域携带凭证
});
const { code, msg: resMsg, captcha_id, mBase64, tBase64, y } = response.data;
const { code, msg: resMsg, data } = response.data;
const { masterImage, tileImage, captchaId, y } = data;
// 后端返回成功状态code=200
if (code === 200) {
// 设置背景图
setBackgroundImage(mBase64);
setBackgroundImage(masterImage);
// 设置拼图图片
setPuzzleImage(tBase64);
setPuzzleImage(tileImage);
// 设置拼图y坐标从后端获取以背景图左上角为原点
setPuzzleY(y);
// 设置进程ID用于后续验证
setProcessId(captcha_id);
setProcessId(captchaId);
// 随机生成拼图x坐标确保拼图在背景图内
// setPuzzlePosition(Math.random() * (CANVAS_WIDTH - 50 - 50) + 50);
// 保存后端返回的提示信息
@@ -163,47 +164,59 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
try {
// 向后端发送验证请求参数为滑块位置x坐标和进程ID
// 使用sliderPosition作为dx值这是拼图块左上角的位置
const response = await axios.post(`${API_BASE_URL}/captcha/verify`, {
dx: sliderPosition, // 滑块位置拼图左上角x坐标以背景图左上角为原点
captcha_id: processId // 验证码进程ID
captchaId: processId // 验证码进程ID
},{ withCredentials: true });
const { code, msg: resMsg, data } = response.data;
// 保存后端返回的提示信息
setMsg(resMsg);
// 后端返回成功 code=200
// 根据后端返回的code判断验证结果
// 验证成功code=200
if (code === 200) {
// 验证成功data=true
if (data === true) {
setIsVerified(true);
setVerifyResult(true);
// 延迟1.2秒后调用验证成功回调
setTimeout(() => onVerify(true), 1200);
}
// 验证失败data=false
else {
setVerifyResult(false);
setShowError(true);
// 增加尝试次数
setAttempts(prev => prev + 1);
// 1.5秒后重置滑块位置并隐藏错误提示
setTimeout(() => {
setSliderPosition(0);
setShowError(false);
}, 1500);
}
}
// 后端返回参数错误400或系统错误500
else if (code === 400 || code === 500) {
setVerifyResult('error');
setShowError(true);
// 增加尝试次数
setAttempts(prev => prev + 1);
// 1.5秒后重置滑块位置并隐藏错误提示
// 重置所有状态,确保验证成功状态的纯净性
setShowError(false);
setVerifyResult(false);
// 直接设置验证成功状态,不使用异步更新
setIsVerified(true);
// 延迟1.2秒后调用验证成功回调
setTimeout(() => onVerify(true), 1200);
}
// 验证失败code=400
else if (code === 400) {
// 确保错误状态的正确性:验证失败显示红色
setVerifyResult(false);
setShowError(true);
setIsVerified(false);
// 增加尝试次数
setAttempts(prev => prev + 1);
// 1.5秒后重置滑块位置、隐藏错误提示并重置验证结果
setTimeout(() => {
setSliderPosition(0);
setShowError(false);
setVerifyResult(false);
setIsVerified(false);
}, 1500);
}
// 后端返回系统错误500
else if (code === 500) {
// 系统错误显示橙色
setVerifyResult('error');
setShowError(true);
setIsVerified(false);
// 增加尝试次数
setAttempts(prev => prev + 1);
// 1.5秒后重置滑块位置、隐藏错误提示并重置验证结果
setTimeout(() => {
setSliderPosition(0);
setShowError(false);
setVerifyResult(false);
setIsVerified(false);
}, 1500);
}
@@ -318,12 +331,12 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
// 加载中显示旋转动画
return <div className="w-5 h-5 border-2 border-blue-300 border-t-blue-600 rounded-full animate-spin" />;
}
// 验证成功时,无论其他状态如何,都显示对勾图标
if (isVerified) {
// 验证成功显示对勾图标
return <Check className="w-5 h-5 text-green-600" />;
}
if (showError) {
// 验证失败显示叉号图标
// 验证失败或错误时显示叉号图标
if (showError || verifyResult === 'error') {
return <X className="w-5 h-5 text-red-600" />;
}
// 默认显示蓝色圆点
@@ -332,8 +345,12 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
const getStatusText = () => {
if (verifyResult === 'error' || showError || isVerified) {
// 错误、验证失败或成功时显示后端返回的消息
if (isVerified) {
// 验证成功时优先显示成功消息
return msg;
}
if (verifyResult === 'error' || showError) {
// 错误或验证失败时显示后端返回的消息
return msg;
}
// 默认显示拖拽提示
@@ -342,17 +359,21 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
const getStatusColor = () => {
if (verifyResult === 'error') return 'text-orange-700';
if (isVerified) return 'text-green-700';
if (verifyResult === 'error') return 'text-orange-700';
if (showError) return 'text-red-700';
return 'text-gray-600';
};
const getProgressColor = () => {
if (verifyResult === 'error') return 'bg-gradient-to-r from-orange-400 to-orange-500';
// 验证成功时,无论其他状态如何,都显示绿色渐变
if (isVerified) return 'bg-gradient-to-r from-green-400 to-green-500';
if (showError) return 'bg-gradient-to-r from-red-400 to-red-500';
// 系统错误后端返回400/500显示橙色渐变
if (verifyResult === 'error') return 'bg-gradient-to-r from-orange-400 to-orange-500';
// 验证失败后端返回200但data=false显示红色渐变
if (showError && verifyResult !== 'error') return 'bg-gradient-to-r from-red-400 to-red-500';
// 默认显示蓝色渐变
return 'bg-gradient-to-r from-blue-400 to-blue-500';
};
@@ -386,9 +407,9 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
/>
)}
{/* 可移动拼图块 */}
{puzzleImage && !isVerified && (
{puzzleImage && (
<div
className="absolute transition-all duration-300"
className={`absolute ${isDragging ? '' : 'transition-all duration-300'}`}
style={{
left: `${sliderPosition}px`, // 滑块x位置拼图左上角x坐标
top: `${puzzleY}px`, // 拼图y位置从后端获取拼图左上角y坐标
@@ -400,7 +421,6 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
alt="拼图块"
className={`${isVerified ? 'opacity-100' : 'opacity-90'}`}
style={{
filter: isVerified ? 'drop-shadow(0 0 10px rgba(34, 197, 94, 0.5))' : 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))'
}}
/>
@@ -415,7 +435,7 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
<div className="relative bg-gray-100 rounded-full h-12 overflow-hidden select-none" ref={trackRef} style={{ width: `${TRACK_WIDTH}px`, margin: '0 auto' }}>
{/* 进度条 */}
<div
className={`absolute left-0 top-0 h-full transition-all duration-200 ease-out ${getProgressColor()}`}
className={`absolute left-0 top-0 h-full ${isDragging ? '' : 'transition-all duration-200 ease-out'} ${getProgressColor()}`}
style={{
width: `${sliderPosition + SLIDER_WIDTH}px`,
transform: isDragging ? 'scaleY(1.05)' : 'scaleY(1)',
@@ -424,14 +444,13 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
/>
{/* 滑块按钮 */}
<div
className={`absolute top-1 w-10 h-10 bg-white rounded-full shadow-lg cursor-pointer flex items-center justify-center transition-all duration-200 ease-out select-none ${
className={`absolute top-1 w-10 h-10 bg-white rounded-full shadow-lg cursor-pointer flex items-center justify-center ${isDragging ? '' : 'transition-all duration-200 ease-out'} select-none ${
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>
@@ -450,7 +469,7 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
{/* 底部信息区域 */}
<div className="px-6 pb-6">
<div className="flex items-center justify-between text-xs text-gray-500">
<span>: {attempts + 1}/5</span>
<span>: {attempts}</span>
<span className="flex items-center space-x-1">
<Shield className="w-3 h-3" />
<span></span>

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

@@ -1,4 +1,4 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080/api/v1';
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || '/api/v1';
export interface Texture {
id: number;
@@ -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();
}